diff --git a/projects/ngx-pwa/local-storage/src/lib/storages/safe-storage-map.service.spec.ts b/projects/ngx-pwa/local-storage/src/lib/storages/safe-storage-map.service.spec.ts new file mode 100644 index 00000000..ad1ca18c --- /dev/null +++ b/projects/ngx-pwa/local-storage/src/lib/storages/safe-storage-map.service.spec.ts @@ -0,0 +1,1827 @@ +import { mergeMap, tap, filter } from 'rxjs/operators'; + +import { IndexedDBDatabase } from '../databases/indexeddb-database'; +import { LocalStorageDatabase } from '../databases/localstorage-database'; +import { MemoryDatabase } from '../databases/memory-database'; +import { DEFAULT_IDB_DB_NAME, DEFAULT_IDB_STORE_NAME, DEFAULT_IDB_DB_VERSION } from '../tokens'; +import { clearStorage, closeAndDeleteDatabase } from '../testing/cleaning'; +import { SafeStorageMap } from './safe-storage-map.service'; +import { VALIDATION_ERROR } from './exceptions'; +import { JSONSchema } from '../validation/json-schema'; + +function tests(description: string, localStorageServiceFactory: () => SafeStorageMap): void { + + interface Monster { + name: string; + address?: string; + } + + const key = 'test'; + let storage: SafeStorageMap; + + describe(description, () => { + + beforeAll(() => { + /* Via a factory as the class should be instancied only now, not before, otherwise tests could overlap */ + storage = localStorageServiceFactory(); + }); + + beforeEach((done) => { + /* Clear data to avoid tests overlap */ + clearStorage(done, storage); + }); + + afterAll((done) => { + /* Now that `indexedDB` store name can be customized, it's important: + * - to delete the database after each tests group, + * so the next tests group to will trigger the `indexedDB` `upgradeneeded` event, + * as it's where the store is created + * - to be able to delete the database, all connections to it must be closed */ + closeAndDeleteDatabase(done, storage); + }); + + describe(`get()`, () => { + + describe(`string`, () => { + + it('with value', (done) => { + + const value = 'blue'; + const schema = { type: 'string' } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: string | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('empty', (done) => { + + const value = ''; + const schema = { type: 'string' } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: string | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('const', (done) => { + + // TODO: documentation, `as const` must not be used with explicit type + // TODO: documentation needed, not working at all without `as const` AND without cast + const value = 'hello'; + const schema = { + type: 'string', + const: 'hello', + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: 'hello' | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('const without assertion', (done) => { + + const value = 'hello'; + const schema: JSONSchema = { + type: 'string', + const: 'hello', + }; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: string | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('enum', (done) => { + + const value = 'world'; + const schema = { + type: 'string', + enum: ['hello', 'world'], + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: 'hello' | 'world' | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('enum without assertion', (done) => { + + const value = 'world'; + const schema: JSONSchema = { + type: 'string', + enum: ['hello', 'world'], + }; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: string | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + }); + + describe(`number`, () => { + + it('with value', (done) => { + + const value = 1.5; + const schema = { type: 'number' } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: number | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('zero', (done) => { + + const value = 0; + const schema = { type: 'number' } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: number | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('const', (done) => { + + const value = 1.5; + const schema = { + type: 'number', + const: 1.5, + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: 1.5 | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('const without assertion', (done) => { + + const value = 1.5; + const schema: JSONSchema = { + type: 'number', + const: 1.5, + }; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: number | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('enum', (done) => { + + const value = 2.4; + const schema = { + type: 'number', + enum: [1.5, 2.4], + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: 1.5 | 2.4 | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('enum without assertion', (done) => { + + const value = 2.4; + const schema: JSONSchema = { + type: 'number', + enum: [1.5, 2.4], + }; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: number | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + }); + + describe(`integer`, () => { + + it('with value', (done) => { + + const value = 1; + const schema = { type: 'integer' } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: number | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('zero', (done) => { + + const value = 0; + const schema = { type: 'integer' } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: number | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('const', (done) => { + + const value = 1; + const schema = { + type: 'integer', + const: 1, + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: 1 | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('const without assertion', (done) => { + + const value = 1; + const schema: JSONSchema = { + type: 'integer', + const: 1, + }; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: number | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('enum', (done) => { + + const value = 2; + const schema = { + type: 'integer', + enum: [1, 2], + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: 1 | 2 | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('enum without assertion', (done) => { + + const value = 2; + const schema: JSONSchema = { + type: 'integer', + enum: [1, 2], + }; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: number | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + }); + + describe(`boolean`, () => { + + it('true', (done) => { + + const value = true; + const schema = { type: 'boolean' } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: boolean | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('false', (done) => { + + const value = false; + const schema = { type: 'boolean' } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: boolean | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('const', (done) => { + + const value = true; + const schema = { + type: 'boolean', + const: true, + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: true | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('const without assertion', (done) => { + + const value = true; + const schema: JSONSchema = { + type: 'boolean', + const: true, + }; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: boolean | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + }); + + describe('array', () => { + + it('of strings', (done) => { + + const value = ['hello', 'world', '!']; + const schema = { + type: 'array', + items: { type: 'string' }, + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: string[] | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + it('of integers', (done) => { + + const value = [1, 2, 3]; + const schema = { + type: 'array', + items: { type: 'integer' }, + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: number[] | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + it('of numbers', (done) => { + + const value = [1.5, 2.4, 3.67]; + const schema = { + type: 'array', + items: { type: 'number' }, + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: number[] | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + it('of booleans', (done) => { + + const value = [true, false, true]; + const schema = { + type: 'array', + items: { type: 'boolean' }, + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: boolean[] | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + it('of arrays', (done) => { + + const value = [['hello', 'world'], ['my', 'name'], ['is', 'Elmo']]; + const schema = { + type: 'array', + items: { + type: 'array', + items: { type: 'string' }, + }, + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: string[][] | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + it('of objects', (done) => { + + const value = [{ + name: 'Elmo', + address: 'Sesame street', + }, { + name: 'Cookie', + }, { + name: 'Chester', + }]; + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + address: { type: 'string' }, + }, + required: ['name'], + }, + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: Monster[] | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + it('Set', (done) => { + + const array = ['hello', 'world']; + const value = new Set(['hello', 'world']); + const schema = { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + } as const; + + storage.set(key, Array.from(value), schema).pipe( + mergeMap(() => storage.get(key, schema)), + ).subscribe((result: string[] | undefined) => { + + expect(result).toEqual(array); + + done(); + + }); + + }); + + }); + + describe('tuple', () => { + + it('with 1 value', (done) => { + + // TODO: documente, type required + const value: [Monster] = [{ + name: 'Elmo', + address: 'Sesame street', + }]; + const schema = { + type: 'array', + items: [{ + type: 'object', + properties: { + name: { type: 'string' }, + address: { type: 'string' }, + }, + required: ['name'], + }], + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: [Monster] | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + it('with 2 values', (done) => { + + const value: [string, Monster] = ['hello', { + name: 'Elmo', + address: 'Sesame street', + }]; + const schema = { + type: 'array', + items: [{ + type: 'string' + }, { + type: 'object', + properties: { + name: { type: 'string' }, + address: { type: 'string' }, + }, + required: ['name'], + }], + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: [string, Monster] | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + it('Map', (done) => { + + const array: [string, Monster][] = [ + ['Elmo', { + name: 'Elmo', + address: 'Sesame street', + }], + ['Cookie', { + name: 'Cookie', + }], + ]; + const value = new Map(array); + const schema = { + type: 'array', + items: { + type: 'array', + items: [{ + type: 'string' + }, { + type: 'object', + properties: { + name: { type: 'string' }, + address: { type: 'string' }, + }, + required: ['name'], + }], + }, + } as const; + + storage.set(key, Array.from(value), schema).pipe( + mergeMap(() => storage.get(key, schema)), + ).subscribe((result: [string, Monster][] | undefined) => { + + expect(result).toEqual(array); + + done(); + + }); + + }); + + it('with 3 primitive values', (done) => { + + const value: [string, number, boolean] = ['hello', 2, true]; + const schema = { + type: 'array', + items: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + ], + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: [string, number, boolean] | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + it('with 3 complex values', (done) => { + + const value: [string, number, Monster] = ['hello', 2, { + name: 'Elmo', + address: 'Sesame street', + }]; + const schema = { + type: 'array', + items: [ + { type: 'string' }, + { type: 'number' }, + { + type: 'object', + properties: { + name: { type: 'string' }, + address: { type: 'string' }, + }, + required: ['name'], + } + ], + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: unknown[] | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + }); + + describe('object', () => { + + it('with all subtypes', (done) => { + + interface User { + name: string; + age: number; + philosopher: boolean; + books: string[]; + family: { + brothers: number; + sisters: number; + }; + creditCard?: number; + } + + const value: User = { + name: 'Henri Bergson', + age: 81, + philosopher: true, + books: [`Essai sur les données immédiates de la conscience`, `Matière et mémoire`], + family: { + brothers: 5, + sisters: 3, + }, + }; + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + philosopher: { type: 'boolean' }, + books: { + type: 'array', + items: { type: 'string' }, + }, + family: { + type: 'object', + properties: { + brothers: { type: 'integer' }, + sisters: { type: 'integer' }, + }, + required: ['brothers', 'sisters'] + }, + creditCard: { type: 'number' }, + }, + required: ['name', 'age', 'philosopher', 'books', 'family'], + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: User | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + it('without required properties', (done) => { + + interface User { + name?: string; + age?: number; + } + + const value: User = { + name: 'Henri Bergson', + }; + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: User | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + it('without const assertion', (done) => { + + interface User { + name: string; + age: number; + philosopher: boolean; + books: string[]; + family: { + brothers: number; + sisters: number; + }; + creditCard?: number; + } + + // TODO: documentation, `as const` needed, and no type on value + const value = { + name: 'Henri Bergson', + age: 81, + philosopher: true, + books: [`Essai sur les données immédiates de la conscience`, `Matière et mémoire`], + family: { + brothers: 5, + sisters: 3, + }, + }; + const schema: JSONSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + philosopher: { type: 'boolean' }, + books: { + type: 'array', + items: { type: 'string' }, + }, + family: { + type: 'object', + properties: { + brothers: { type: 'integer' }, + sisters: { type: 'integer' }, + }, + required: ['brothers', 'sisters'] + }, + creditCard: { type: 'number' }, + }, + required: ['name', 'age', 'philosopher', 'books', 'family'], + }; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result: Partial | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + }); + + describe('specials', () => { + + it('unexisting key', (done) => { + + const schema = { type: 'string' } as const; + + storage.get(`unknown${Date.now()}`, schema).subscribe((data: string | undefined) => { + + expect(data).toBeUndefined(); + + done(); + + }); + + }); + + it('null', (done) => { + + const schema = { type: 'string' } as const; + + storage.set(key, 'test', schema).pipe( + mergeMap(() => storage.set(key, null, schema)), + mergeMap(() => storage.get(key, schema)), + ).subscribe((result: string | undefined) => { + + expect(result).toBeUndefined(); + + done(); + + }); + + }); + + it('undefined', (done) => { + + const schema = { type: 'string' } as const; + + storage.set(key, 'test', schema).pipe( + mergeMap(() => storage.set(key, undefined, schema)), + mergeMap(() => storage.get(key, schema)), + ).subscribe((result: string | undefined) => { + + expect(result).toBeUndefined(); + + done(); + + }); + + }); + + it('blob (will be pending in Safari private)', (done) => { + + const value = new Blob(); + const schema = { type: 'unknown' } as const; + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((storage.backingEngine === 'localStorage') ? { + next: () => {}, + error: () => { + expect().nothing(); + done(); + } + } : { + next: (result: unknown | undefined) => { + expect(result).toEqual(value); + done(); + }, + error: () => { + /* Safari in private mode doesn't allow to store `Blob` in `indexedDB` */ + pending(); + done(); + } + }); + + }); + + it('unknown schema', (done) => { + + const schema = { type: 'unknown' } as const; + + // @ts-expect-error + storage.get(key, schema).subscribe((result: string | undefined) => { + + expect().nothing(); + + done(); + + }); + + }); + + /* Inference from JSON schema is quite heavy, so this is a stress test for the compiler */ + it('heavy schema', (done) => { + + interface City { + country: string; + population: number; + coordinates: [number, number]; + monuments?: { + name: string; + constructionYear?: number; + }[]; + } + + const value: [string, City][] = [ + ['Paris', { + country: 'France', + population: 2187526, + coordinates: [48.866667, 2.333333], + monuments: [{ + name: `Tour Eiffel`, + constructionYear: 1889, + }, { + name: `Notre-Dame de Paris`, + constructionYear: 1345, + }], + }], + ['Kyōto', { + country: 'Japan', + population: 1467702, + coordinates: [35.011665, 135.768326], + monuments: [{ + name: `Sanjūsangen-dō`, + constructionYear: 1164, + }], + }], + ]; + + const schema = { + type: 'array', + items: { + type: 'array', + items: [{ + type: 'string' + }, { + type: 'object', + properties: { + country: { type: 'string' }, + population: { type: 'integer' }, + coordinates: { + type: 'array', + items: [ + { type: 'number'}, + { type: 'number'}, + ], + }, + monuments: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + constructionYear: { type: 'integer' }, + }, + required: ['name'], + }, + }, + }, + required: ['country', 'population', 'coordinates'], + }] + }, + } as const; + + + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)), + ).subscribe((result: [string, City][] | undefined) => { + + expect(result).toEqual(value); + + done(); + + }); + + }); + + }); + + }); + + describe(('set()'), () => { + + it('update', (done) => { + + const schema = { type: 'string' } as const; + + storage.set(key, 'value', schema).pipe( + mergeMap(() => storage.set(key, 'updated', schema)) + ).subscribe(() => { + + expect().nothing(); + + done(); + + }); + + }); + + it('concurrency', (done) => { + + const value1 = 'test1'; + const value2 = 'test2'; + const schema = { type: 'string' } as const; + + expect(() => { + + storage.set(key, value1, schema).subscribe(); + + storage.set(key, value2, schema).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe((result) => { + + expect(result).toBe(value2); + + done(); + + }); + + }).not.toThrow(); + + }); + + }); + + describe('deletion', () => { + + it('delete() with existing key', (done) => { + + const schema = { type: 'string' } as const; + + storage.set(key, 'test', schema).pipe( + mergeMap(() => storage.delete(key)), + mergeMap(() => storage.get(key, schema)) + ).subscribe((result) => { + + expect(result).toBeUndefined(); + + done(); + + }); + + }); + + it('delete() with unexisting key', (done) => { + + storage.delete(`unexisting${Date.now()}`).subscribe(() => { + + expect().nothing(); + + done(); + + }); + + }); + + it('clear()', (done) => { + + const schema = { type: 'string' } as const; + + storage.set(key, 'test', schema).pipe( + mergeMap(() => storage.clear()), + mergeMap(() => storage.get(key, schema)) + ).subscribe((result) => { + + expect(result).toBeUndefined(); + + done(); + + }); + + }); + + }); + + describe('Map-like API', () => { + + it('size', (done) => { + + const schema = { type: 'string' } as const; + + storage.size.pipe( + tap((length) => { expect(length).toBe(0); }), + mergeMap(() => storage.set(key, 'test', schema)), + mergeMap(() => storage.size), + tap((length) => { expect(length).toBe(1); }), + mergeMap(() => storage.set('', 'test', schema)), + mergeMap(() => storage.size), + tap((length) => { expect(length).toBe(2); }), + mergeMap(() => storage.delete(key)), + mergeMap(() => storage.size), + tap((length) => { expect(length).toBe(1); }), + mergeMap(() => storage.clear()), + mergeMap(() => storage.size), + tap((length) => { expect(length).toBe(0); }), + ).subscribe(() => { + done(); + }); + + }); + + it('keys()', (done) => { + + const key1 = 'index1'; + const key2 = 'index2'; + const keys = [key1, key2]; + const schema = { type: 'string' } as const; + + storage.set(key1, 'test', schema).pipe( + mergeMap(() => storage.set(key2, 'test', schema)), + mergeMap(() => storage.keys()), + ).subscribe({ + next: (value) => { + expect(keys).toContain(value); + keys.splice(keys.indexOf(value), 1); + }, + complete: () => { + done(); + }, + }); + + }); + + it('keys() when no items', (done) => { + + storage.keys().subscribe({ + next: () => { + fail(); + }, + complete: () => { + expect().nothing(); + done(); + }, + }); + + }); + + it('has() on existing', (done) => { + + const schema = { type: 'string' } as const; + + storage.set(key, 'test', schema).pipe( + mergeMap(() => storage.has(key)) + ).subscribe((result) => { + + expect(result).toBe(true); + + done(); + + }); + + }); + + it('has() on unexisting', (done) => { + + storage.has(`nokey${Date.now()}`).subscribe((result) => { + + expect(result).toBe(false); + + done(); + + }); + + }); + + it('advanced case: remove only some items', (done) => { + + const schema = { type: 'string' } as const; + + storage.set('user_firstname', 'test', schema).pipe( + mergeMap(() => storage.set('user_lastname', 'test', schema)), + mergeMap(() => storage.set('app_data1', 'test', schema)), + mergeMap(() => storage.set('app_data2', 'test', schema)), + mergeMap(() => storage.keys()), + filter((currentKey) => currentKey.startsWith('app_')), + mergeMap((currentKey) => storage.delete(currentKey)), + ).subscribe({ + /* So we need to wait for completion of all actions to check */ + complete: () => { + + storage.size.subscribe((size) => { + + expect(size).toBe(2); + + done(); + + }); + + } + }); + + }); + + }); + + describe('watch()', () => { + + it('valid', (done) => { + + const watchedKey = 'watched1'; + const values = [undefined, 'test1', undefined, 'test2', undefined]; + const schema = { type: 'string' } as const; + let i = 0; + + storage.watch(watchedKey, schema).subscribe((result: string | undefined) => { + + expect(result).toBe(values[i]); + + i += 1; + + if (i === 1) { + + storage.set(watchedKey, values[1], schema).pipe( + mergeMap(() => storage.delete(watchedKey)), + mergeMap(() => storage.set(watchedKey, values[3], schema)), + mergeMap(() => storage.clear()), + ).subscribe(); + + } + + if (i === values.length) { + done(); + } + + }); + + }); + + }); + + describe('validation', () => { + + const schema = { + type: 'object', + properties: { + expected: { + type: 'string' + } + }, + required: ['expected'] + } as const; + + it('valid schema with options', (done) => { + + const value = 5; + const schemaWithOptions = { type: 'number', maximum: 10 } as const; + + storage.set(key, value, schemaWithOptions).pipe( + mergeMap(() => storage.get(key, schemaWithOptions)), + ).subscribe((result: number | undefined) => { + + expect(result).toBe(value); + + done(); + + }); + + }); + + it('invalid schema with options', (done) => { + + const value = 15; + const schemaWithOptions = { type: 'number', maximum: 10 } as const; + + storage.set(key, value, { type: 'number' } as const).pipe( + mergeMap(() => storage.get(key, schemaWithOptions)), + ).subscribe({ + error: (error) => { + + expect(error.message).toBe(VALIDATION_ERROR); + + done(); + + } + }); + + }); + + it('invalid in get()', (done) => { + + storage.set(key, 'test', { type: 'string' } as const).pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe({ error: (error) => { + + expect(error.message).toBe(VALIDATION_ERROR); + + done(); + + } }); + + }); + + it('invalid in set()', (done) => { + + // @ts-expect-error + storage.set(key, 'test', schema).subscribe(() => { + + expect().nothing(); + + done(); + + }); + + }); + + it('invalid in watch()', (done) => { + + const watchedKey = 'watched2'; + + storage.set(watchedKey, 'test', { type: 'string' } as const).subscribe(() => { + + storage.watch(watchedKey, { type: 'number' } as const).subscribe({ + error: () => { + expect().nothing(); + done(); + } + }); + + }); + + }); + + it('null: no validation', (done) => { + + storage.get(`noassociateddata${Date.now()}`, schema).subscribe(() => { + + expect().nothing(); + + done(); + + }); + + }); + + }); + + /* Avoid https://github.com/cyrilletuzi/angular-async-local-storage/issues/25 + * Avoid https://github.com/cyrilletuzi/angular-async-local-storage/issues/5 */ + describe('complete', () => { + + const schema = { type: 'string' } as const; + + it('get()', (done) => { + + storage.get(key, schema).subscribe({ + complete: () => { + expect().nothing(); + done(); + } + }); + + }); + + it('set()', (done) => { + + storage.set('index', 'value', schema).subscribe({ + complete: () => { + expect().nothing(); + done(); + } + }); + + }); + + it('delete()', (done) => { + + storage.delete(key).subscribe({ + complete: () => { + expect().nothing(); + done(); + } + }); + + }); + + it('clear()', (done) => { + + storage.clear().subscribe({ + complete: () => { + expect().nothing(); + done(); + } + }); + + }); + + it('size', (done) => { + + storage.size.subscribe({ + complete: () => { + expect().nothing(); + done(); + } + }); + + }); + + it('keys()', (done) => { + + storage.keys().subscribe({ + complete: () => { + expect().nothing(); + done(); + } + }); + + }); + + it('has()', (done) => { + + storage.has(key).subscribe({ + complete: () => { + expect().nothing(); + done(); + } + }); + + }); + + }); + + describe('compatibility with Promise', () => { + + const schema = { type: 'string' } as const; + + it('Promise', (done) => { + + const value = 'test'; + + storage.set(key, value, schema).toPromise() + .then(() => storage.get(key, schema).toPromise()) + .then((result: string | undefined) => { + expect(result).toBe(value); + done(); + }); + + }); + + it('async / await', async () => { + + const value = 'test'; + + await storage.set(key, value, schema).toPromise(); + + const result: string | undefined = await storage.get(key, schema).toPromise(); + + expect(result).toBe(value); + + }); + + }); + + }); + +} + +describe('SafeStorageMap', () => { + + tests('memory', () => new SafeStorageMap(new MemoryDatabase())); + + tests('localStorage', () => new SafeStorageMap(new LocalStorageDatabase())); + + tests('localStorage with prefix', () => new SafeStorageMap(new LocalStorageDatabase(`ls`))); + + tests('indexedDB', () => new SafeStorageMap(new IndexedDBDatabase())); + + tests('indexedDB with custom options', () => new SafeStorageMap(new IndexedDBDatabase('customDbTest', 'storeTest', 2, false))); + + describe('browser APIs', () => { + + /* Avoid https://github.com/cyrilletuzi/angular-async-local-storage/issues/57 */ + it('IndexedDb is used (will be pending in Firefox/IE private mode)', (done) => { + + const index = `test${Date.now()}`; + const value = 'test'; + const schema = { type: 'string' } as const; + + const localStorageService = new SafeStorageMap(new IndexedDBDatabase()); + + localStorageService.set(index, value, schema).subscribe(() => { + + try { + + const dbOpen = indexedDB.open(DEFAULT_IDB_DB_NAME, DEFAULT_IDB_DB_VERSION); + + dbOpen.addEventListener('success', () => { + + const store = dbOpen.result.transaction([DEFAULT_IDB_STORE_NAME], 'readonly').objectStore(DEFAULT_IDB_STORE_NAME); + + const request = store.get(index); + + request.addEventListener('success', () => { + + expect(request.result).toBe(value); + + dbOpen.result.close(); + + closeAndDeleteDatabase(done, localStorageService); + + }); + + request.addEventListener('error', () => { + + dbOpen.result.close(); + + /* This case is not supposed to happen */ + fail(); + + }); + + }); + + dbOpen.addEventListener('error', () => { + + /* Cases : Firefox private mode where `indexedDb` exists but fails */ + pending(); + + }); + + } catch { + + /* Cases : IE private mode where `indexedDb` will exist but not its `open()` method */ + pending(); + + } + + }); + + }); + + it('indexedDb with default options (will be pending in Firefox private mode)', (done) => { + + const localStorageService = new SafeStorageMap(new IndexedDBDatabase()); + const schema = { type: 'string' } as const; + + /* Do a request first as a first transaction is needed to set the store name */ + localStorageService.get('test', schema).subscribe(() => { + + if (localStorageService.backingEngine === 'indexedDB') { + + const { database, store, version } = localStorageService.backingStore; + + expect(database).toBe(DEFAULT_IDB_DB_NAME); + expect(store).toBe(DEFAULT_IDB_STORE_NAME); + expect(version).toBe(DEFAULT_IDB_DB_VERSION); + + closeAndDeleteDatabase(done, localStorageService); + + } else { + + /* Cases: Firefox private mode */ + pending(); + + } + + }); + + }); + + /* Avoid https://github.com/cyrilletuzi/angular-async-local-storage/issues/57 */ + it('indexedDb with noWrap to false (will be pending in Firefox/IE private mode)', (done) => { + + const index = `wrap${Date.now()}`; + const value = 'test'; + const schema = { type: 'string' } as const; + + const localStorageService = new SafeStorageMap(new IndexedDBDatabase(undefined, undefined, undefined, false)); + + localStorageService.set(index, value, schema).subscribe(() => { + + try { + + const dbOpen = indexedDB.open(DEFAULT_IDB_DB_NAME, DEFAULT_IDB_DB_VERSION); + + dbOpen.addEventListener('success', () => { + + const store = dbOpen.result.transaction([DEFAULT_IDB_STORE_NAME], 'readonly').objectStore(DEFAULT_IDB_STORE_NAME); + + const request = store.get(index); + + request.addEventListener('success', () => { + + expect(request.result).toEqual({ value }); + + dbOpen.result.close(); + + closeAndDeleteDatabase(done, localStorageService); + + }); + + request.addEventListener('error', () => { + + dbOpen.result.close(); + + /* This case is not supposed to happen */ + fail(); + + }); + + }); + + dbOpen.addEventListener('error', () => { + + /* Cases : Firefox private mode where `indexedDb` exists but fails */ + pending(); + + }); + + } catch { + + /* Cases : IE private mode where `indexedDb` will exist but not its `open()` method */ + pending(); + + } + + }); + + }); + + it('indexedDb with custom options (will be pending in Firefox private mode)', (done) => { + + /* Unique names to be sure `indexedDB` `upgradeneeded` event is triggered */ + const dbName = `dbCustom${Date.now()}`; + const storeName = `storeCustom${Date.now()}`; + const schema = { type: 'string' } as const; + const dbVersion = 2; + const noWrap = false; + + const localStorageService = new SafeStorageMap(new IndexedDBDatabase(dbName, storeName, dbVersion, noWrap)); + + /* Do a request first as a first transaction is needed to set the store name */ + localStorageService.get('test', schema).subscribe(() => { + + if (localStorageService.backingEngine === 'indexedDB') { + + const { database, store, version } = localStorageService.backingStore; + + expect(database).toBe(dbName); + expect(store).toBe(storeName); + expect(version).toBe(dbVersion); + + closeAndDeleteDatabase(done, localStorageService); + + } else { + + /* Cases: Firefox private mode */ + pending(); + + } + + }); + + }); + + it('localStorage with prefix', () => { + + const prefix = `ls_`; + + const localStorageService = new SafeStorageMap(new LocalStorageDatabase(prefix)); + + expect(localStorageService.fallbackBackingStore.prefix).toBe(prefix); + + }); + + }); + +}); diff --git a/projects/ngx-pwa/local-storage/src/lib/storages/safe-storage-map.service.ts b/projects/ngx-pwa/local-storage/src/lib/storages/safe-storage-map.service.ts new file mode 100644 index 00000000..01155b61 --- /dev/null +++ b/projects/ngx-pwa/local-storage/src/lib/storages/safe-storage-map.service.ts @@ -0,0 +1,412 @@ +import { Injectable, Inject } from '@angular/core'; +import { Observable, throwError, of, OperatorFunction, ReplaySubject } from 'rxjs'; +import { mergeMap, catchError, tap } from 'rxjs/operators'; + +import { JSONSchema } from '../validation/json-schema'; +import { JSONValidator } from '../validation/json-validator'; +import { InferFromJSONSchema } from '../validation/infer-from-json-schema'; +import { IndexedDBDatabase } from '../databases/indexeddb-database'; +import { LocalStorageDatabase } from '../databases/localstorage-database'; +import { MemoryDatabase } from '../databases/memory-database'; +import { LocalDatabase } from '../databases/local-database'; +import { IDB_BROKEN_ERROR } from '../databases/exceptions'; +import { LS_PREFIX } from '../tokens'; +import { ValidationError } from './exceptions'; + +@Injectable({ + providedIn: 'root' +}) +export class SafeStorageMap { + + protected notifiers = new Map>(); + + /** + * Constructor params are provided by Angular (but can also be passed manually in tests) + * @param database Storage to use + * @param jsonValidator Validator service + * @param LSPrefix Prefix for `localStorage` keys to avoid collision for multiple apps on the same subdomain or for interoperability + */ + constructor( + protected database: LocalDatabase, + protected jsonValidator: JSONValidator = new JSONValidator(), + @Inject(LS_PREFIX) protected LSPrefix = '', + ) {} + + /** + * **Number of items** in storage, wrapped in an `Observable`. + * + * @example + * this.storageMap.size.subscribe((size) => { + * console.log(size); + * }); + */ + get size(): Observable { + + return this.database.size + /* Catch if `indexedDb` is broken */ + .pipe(this.catchIDBBroken(() => this.database.size)); + + } + + /** + * Tells you which storage engine is used. *Only useful for interoperability.* + * Note that due to some browsers issues in some special contexts + * (Firefox private mode and Safari cross-origin iframes), + * **this information may be wrong at initialization,** + * as the storage could fallback from `indexedDB` to `localStorage` + * only after a first read or write operation. + * @returns Storage engine used + * + * @see {@link https://github.com/cyrilletuzi/angular-async-local-storage/blob/master/docs/INTEROPERABILITY.md} + * + * @example + * if (this.storageMap.backingEngine === 'indexedDB') {} + */ + get backingEngine(): 'indexedDB' | 'localStorage' | 'memory' | 'unknown' { + + if (this.database instanceof IndexedDBDatabase) { + + return 'indexedDB'; + + } else if (this.database instanceof LocalStorageDatabase) { + + return 'localStorage'; + + } else if (this.database instanceof MemoryDatabase) { + + return 'memory'; + + } else { + + return 'unknown'; + + } + + } + + /** + * Info about `indexedDB` database. *Only useful for interoperability.* + * @returns `indexedDB` database name, store name and database version. + * **Values will be empty if the storage is not `indexedDB`,** + * **so it should be used after an engine check**. + * + * @see {@link https://github.com/cyrilletuzi/angular-async-local-storage/blob/master/docs/INTEROPERABILITY.md} + * + * @example + * if (this.storageMap.backingEngine === 'indexedDB') { + * const { database, store, version } = this.storageMap.backingStore; + * } + */ + get backingStore(): { database: string, store: string, version: number } { + + return (this.database instanceof IndexedDBDatabase) ? + this.database.backingStore : + { database: '', store: '', version: 0 }; + + } + + /** + * Info about `localStorage` fallback storage. *Only useful for interoperability.* + * @returns `localStorage` prefix. + * **Values will be empty if the storage is not `localStorage`,** + * **so it should be used after an engine check**. + * + * @see {@link https://github.com/cyrilletuzi/angular-async-local-storage/blob/master/docs/INTEROPERABILITY.md} + * + * @example + * if (this.storageMap.backingEngine === 'localStorage') { + * const { prefix } = this.storageMap.fallbackBackingStore; + * } + */ + get fallbackBackingStore(): { prefix: string } { + + return (this.database instanceof LocalStorageDatabase) ? + { prefix: this.database.prefix } : + { prefix: '' }; + + } + + /** + * Get an item value in storage, validated by a JSON schema. + * @see {@link https://github.com/cyrilletuzi/angular-async-local-storage/blob/master/docs/VALIDATION.md} + * @param key The item's key + * @param schema JSON schema to validate the data + * @returns The item's value if the key exists, `undefined` otherwise, wrapped in a RxJS `Observable` + * + * @example + * this.storageMap.get('key', { type: 'string' }).subscribe((result) => { + * result; // string or undefined + * }); + * + * @example + * const schema = { + * type: 'object', + * properties: { + * firstName: { type: 'string' }, + * lastName: { type: 'string' }, + * }, + * required: ['firstName'] + * } as const; + * + * this.storageMap.get('user', schema).subscribe((user) => { + * if (user) { + * user.firstName; + * } + * }); + */ + get(key: string, schema: Schema) { // tslint:disable-line:typedef + + return this.getAndValidate(key, schema) as Observable | undefined>; + + } + + protected getAndValidate(key: string, schema?: JSONSchema): Observable { + + /* Get the data in storage */ + return this.database.get(key).pipe( + /* Check if `indexedDb` is broken */ + this.catchIDBBroken(() => this.database.get(key)), + mergeMap((data) => { + + /* No need to validate if the data is empty */ + if ((data === undefined) || (data === null)) { + + return of(undefined); + + } + + /* Validate */ + if (schema) { + + /* Validate data against a JSON schema if provided */ + if (!this.jsonValidator.validate(data, schema)) { + return throwError(new ValidationError()); + } + + } + + return of(data); + + }), + ); + + } + + /** + * Set an item in storage. + * Note that setting `null` or `undefined` will remove the item to avoid some browsers issues. + * @param key The item's key + * @param data The item's value + * @param _ JSON schema to validate the data + * @returns A RxJS `Observable` to wait the end of the operation + * + * @example + * this.storageMap.set('key', 'value', { type: 'string' }).subscribe(() => {}); + */ + set(key: string, data: Readonly> | undefined | null, _: Schema): Observable { + + /* Schema is not required here as the compliance of the data is already checked at compilation */ + return this.setAndValidate(key, data); + + } + + protected setAndValidate(key: string, data: unknown, schema?: JSONSchema): Observable { + + /* Storing `undefined` or `null` is useless and can cause issues in `indexedDb` in some browsers, + * so removing item instead for all storages to have a consistent API */ + if ((data === undefined) || (data === null)) { + return this.delete(key); + } + + /* Validate data against a JSON schema if provided */ + if (schema && !this.jsonValidator.validate(data, schema)) { + return throwError(new ValidationError()); + } + + return this.database.set(key, data).pipe( + /* Catch if `indexedDb` is broken */ + this.catchIDBBroken(() => this.database.set(key, data)), + /* Notify watchers (must be last because it should only happen if the operation succeeds) */ + tap(() => { this.notify(key, data); }), + ); + + } + + /** + * Delete an item in storage + * @param key The item's key + * @returns A RxJS `Observable` to wait the end of the operation + * + * @example + * this.storageMap.delete('key').subscribe(() => {}); + */ + delete(key: string): Observable { + + return this.database.delete(key).pipe( + /* Catch if `indexedDb` is broken */ + this.catchIDBBroken(() => this.database.delete(key)), + /* Notify watchers (must be last because it should only happen if the operation succeeds) */ + tap(() => { this.notify(key, undefined); }), + ); + + } + + /** + * Delete all items in storage + * @returns A RxJS `Observable` to wait the end of the operation + * + * @example + * this.storageMap.clear().subscribe(() => {}); + */ + clear(): Observable { + + return this.database.clear().pipe( + /* Catch if `indexedDb` is broken */ + this.catchIDBBroken(() => this.database.clear()), + /* Notify watchers (must be last because it should only happen if the operation succeeds) */ + tap(() => { + for (const key of this.notifiers.keys()) { + this.notify(key, undefined); + } + }), + ); + + } + + /** + * Get all keys stored in storage. Note **this is an *iterating* `Observable`**: + * * if there is no key, the `next` callback will not be invoked, + * * if you need to wait the whole operation to end, be sure to act in the `complete` callback, + * as this `Observable` can emit several values and so will invoke the `next` callback several times. + * @returns A list of the keys wrapped in a RxJS `Observable` + * + * @example + * this.storageMap.keys().subscribe({ + * next: (key) => { console.log(key); }, + * complete: () => { console.log('Done'); }, + * }); + */ + keys(): Observable { + + return this.database.keys() + /* Catch if `indexedDb` is broken */ + .pipe(this.catchIDBBroken(() => this.database.keys())); + + } + + /** + * Tells if a key exists in storage + * @returns A RxJS `Observable` telling if the key exists + * + * @example + * this.storageMap.has('key').subscribe((hasKey) => { + * if (hasKey) {} + * }); + */ + has(key: string): Observable { + + return this.database.has(key) + /* Catch if `indexedDb` is broken */ + .pipe(this.catchIDBBroken(() => this.database.has(key))); + + } + + /** + * Watch an item value in storage. + * **Note only changes done via this lib will be watched**, external changes in storage can't be detected. + * The signature has many overloads due to validation, **please refer to the documentation.** + * @see https://github.com/cyrilletuzi/angular-async-local-storage/blob/master/docs/VALIDATION.md + * @param key The item's key to watch + * @param schema JSON schema to validate the initial value + * @returns An infinite `Observable` giving the current value + */ + watch(key: string, schema: Schema) { // tslint:disable-line:typedef + + return this.watchAndInit(key, schema) as Observable | undefined>; + + } + + protected watchAndInit(key: string, schema?: JSONSchema): Observable { + + /* Check if there is already a notifier */ + if (!this.notifiers.has(key)) { + this.notifiers.set(key, new ReplaySubject(1)); + } + + /* Non-null assertion is required because TypeScript doesn't narrow `.has()` yet */ + // tslint:disable-next-line: no-non-null-assertion + const notifier = this.notifiers.get(key)!; + + /* Get the current item value */ + this.getAndValidate(key, schema).subscribe({ + next: (result) => notifier.next(result), + error: (error) => notifier.error(error), + }); + + /* Only the public API of the `Observable` should be returned */ + return notifier.asObservable(); + + } + + /** + * Notify when a value changes + * @param key The item's key + * @param data The new value + */ + protected notify(key: string, value: unknown): void { + + this.notifiers.get(key)?.next(value); + + } + + /** + * RxJS operator to catch if `indexedDB` is broken + * @param operationCallback Callback with the operation to redo + */ + protected catchIDBBroken(operationCallback: () => Observable): OperatorFunction { + + return catchError((error) => { + + /* Check if `indexedDB` is broken based on error message (the specific error class seems to be lost in the process) */ + if ((error !== undefined) && (error !== null) && (error.message === IDB_BROKEN_ERROR)) { + + /* When storage is fully disabled in browser (via the "Block all cookies" option), + * just trying to check `localStorage` variable causes a security exception. + * Prevents https://github.com/cyrilletuzi/angular-async-local-storage/issues/118 + */ + try { + + if ('getItem' in localStorage) { + + /* Fallback to `localStorage` if available */ + this.database = new LocalStorageDatabase(this.LSPrefix); + + } else { + + /* Fallback to memory storage otherwise */ + this.database = new MemoryDatabase(); + + } + + } catch { + + /* Fallback to memory storage otherwise */ + this.database = new MemoryDatabase(); + + } + + /* Redo the operation */ + return operationCallback(); + + } else { + + /* Otherwise, rethrow the error */ + return throwError(error); + + } + + }); + + } + +} diff --git a/projects/ngx-pwa/local-storage/src/lib/storages/storage-map.service.spec.ts b/projects/ngx-pwa/local-storage/src/lib/storages/storage-map.service.spec.ts index 78223db7..c7252dfa 100644 --- a/projects/ngx-pwa/local-storage/src/lib/storages/storage-map.service.spec.ts +++ b/projects/ngx-pwa/local-storage/src/lib/storages/storage-map.service.spec.ts @@ -1,21 +1,15 @@ -import { mergeMap, tap, filter } from 'rxjs/operators'; +import { mergeMap } from 'rxjs/operators'; -import { IndexedDBDatabase } from '../databases/indexeddb-database'; -import { LocalStorageDatabase } from '../databases/localstorage-database'; import { MemoryDatabase } from '../databases/memory-database'; -import { DEFAULT_IDB_DB_NAME, DEFAULT_IDB_STORE_NAME, DEFAULT_IDB_DB_VERSION } from '../tokens'; +import { JSONSchema, JSONSchemaNumber } from '../validation/json-schema'; import { clearStorage, closeAndDeleteDatabase } from '../testing/cleaning'; import { StorageMap } from './storage-map.service'; import { VALIDATION_ERROR } from './exceptions'; -import { JSONSchema, JSONSchemaNumber } from '../validation/json-schema'; +import { LocalStorageDatabase } from '../databases/localstorage-database'; +import { IndexedDBDatabase } from '../databases/indexeddb-database'; function tests(description: string, localStorageServiceFactory: () => StorageMap): void { - interface Monster { - name: string; - address?: string; - } - const key = 'test'; let storage: StorageMap; @@ -33,10 +27,10 @@ function tests(description: string, localStorageServiceFactory: () => StorageMap afterAll((done) => { /* Now that `indexedDB` store name can be customized, it's important: - * - to delete the database after each tests group, - * so the next tests group to will trigger the `indexedDB` `upgradeneeded` event, - * as it's where the store is created - * - to be able to delete the database, all connections to it must be closed */ + * - to delete the database after each tests group, + * so the next tests group to will trigger the `indexedDB` `upgradeneeded` event, + * as it's where the store is created + * - to be able to delete the database, all connections to it must be closed */ closeAndDeleteDatabase(done, storage); }); @@ -127,1501 +121,443 @@ function tests(description: string, localStorageServiceFactory: () => StorageMap }); - }); - - describe(`get()`, () => { - - describe(`string`, () => { - - it('with value', (done) => { - - const value = 'blue'; - const schema: JSONSchema = { type: 'string' }; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: string | undefined) => { - - expect(result).toBe(value); - - done(); - - }); - - }); - - it('empty', (done) => { + it('string', (done) => { - const value = ''; - const schema: JSONSchema = { type: 'string' }; + storage.get('test', { type: 'string' }).subscribe((_: string | undefined) => { - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: string | undefined) => { - - expect(result).toBe(value); - - done(); - - }); + expect().nothing(); + done(); }); - it('const', (done) => { - - const value = 'hello'; - const schema: JSONSchema = { - type: 'string', - const: 'hello', - }; + }); - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get<'hello'>(key, schema)) - ).subscribe((result: 'hello' | undefined) => { + it('special string', (done) => { - expect(result).toBe(value); + type Theme = 'dark' | 'light'; - done(); + storage.get('test', { type: 'string', enum: ['dark', 'light'] }).subscribe((_: Theme | undefined) => { - }); + expect().nothing(); + done(); }); - it('enum', (done) => { - - const value = 'world'; - const schema: JSONSchema = { - type: 'string', - enum: ['hello', 'world'], - }; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get<'hello' | 'world'>(key, schema)) - ).subscribe((result: 'hello' | 'world' | undefined) => { + }); - expect(result).toBe(value); + it('number', (done) => { - done(); + storage.get('test', { type: 'number' }).subscribe((_: number | undefined) => { - }); + expect().nothing(); + done(); }); }); - describe(`number`, () => { - - it('with value', (done) => { + it('special number', (done) => { - const value = 1.5; - const schema: JSONSchema = { type: 'number' }; + type SomeNumbers = 1.5 | 2.5; - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: number | undefined) => { + storage.get('test', { type: 'number', enum: [1.5, 2.5] }).subscribe((_: SomeNumbers | undefined) => { - expect(result).toBe(value); - - done(); - - }); + expect().nothing(); + done(); }); - it('zero', (done) => { - - const value = 0; - const schema: JSONSchema = { type: 'number' }; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: number | undefined) => { + }); - expect(result).toBe(value); + it('integer', (done) => { - done(); + storage.get('test', { type: 'integer' }).subscribe((_: number | undefined) => { - }); + expect().nothing(); + done(); }); - it('const', (done) => { - - const value = 1.5; - const schema: JSONSchema = { - type: 'number', - const: 1.5, - }; + }); - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get<1.5>(key, schema)) - ).subscribe((result: 1.5 | undefined) => { + it('special integer', (done) => { - expect(result).toBe(value); + type SpecialIntegers = 1 | 2; - done(); + storage.get('test', { type: 'integer', enum: [1, 2] }).subscribe((_: SpecialIntegers | undefined) => { - }); + expect().nothing(); + done(); }); - it('enum', (done) => { - - const value = 2.4; - const schema: JSONSchema = { - type: 'number', - enum: [1.5, 2.4], - }; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get<1.5 | 2.4>(key, schema)) - ).subscribe((result: 1.5 | 2.4 | undefined) => { + }); - expect(result).toBe(value); + it('boolean', (done) => { - done(); + storage.get('test', { type: 'boolean' }).subscribe((_: boolean | undefined) => { - }); + expect().nothing(); + done(); }); }); - describe(`integer`, () => { + it('array of strings', (done) => { - it('with value', (done) => { + storage.get('test', { + type: 'array', + items: { type: 'string' }, + }).subscribe((_: string[] | undefined) => { - const value = 1; - const schema: JSONSchema = { type: 'integer' }; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: number | undefined) => { - - expect(result).toBe(value); - - done(); - - }); + expect().nothing(); + done(); }); - it('zero', (done) => { - - const value = 0; - const schema: JSONSchema = { type: 'integer' }; + }); - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: number | undefined) => { + it('special array of strings', (done) => { - expect(result).toBe(value); + type Themes = ('dark' | 'light')[]; - done(); + storage.get('test', { + type: 'array', + items: { + type: 'string', + enum: ['dark', 'light'], + }, + }).subscribe((_: Themes | undefined) => { - }); + expect().nothing(); + done(); }); - it('const', (done) => { - - const value = 1; - const schema: JSONSchema = { - type: 'integer', - const: 1, - }; + }); - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get<1>(key, schema)) - ).subscribe((result: 1 | undefined) => { + it('special readonly array of strings', (done) => { - expect(result).toBe(value); + type Themes = readonly ('dark' | 'light')[]; - done(); + storage.get('test', { + type: 'array', + items: { + type: 'string', + enum: ['dark', 'light'], + }, + }).subscribe((_: Themes | undefined) => { - }); + expect().nothing(); + done(); }); - it('enum', (done) => { - - const value = 2; - const schema: JSONSchema = { - type: 'integer', - enum: [1, 2], - }; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get<1 | 2>(key, schema)) - ).subscribe((result: 1 | 2 | undefined) => { + }); - expect(result).toBe(value); + it('array of numbers', (done) => { - done(); + storage.get('test', { + type: 'array', + items: { type: 'number' }, + }).subscribe((_: number[] | undefined) => { - }); + expect().nothing(); + done(); }); }); - describe(`boolean`, () => { + it('special array of numbers', (done) => { - it('true', (done) => { + type NumbersArray = (1 | 2)[]; - const value = true; - const schema: JSONSchema = { type: 'boolean' }; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: boolean | undefined) => { - - expect(result).toBe(value); - - done(); + storage.get('test', { + type: 'array', + items: { + type: 'number', + enum: [1, 2], + }, + }).subscribe((_: NumbersArray | undefined) => { - }); + expect().nothing(); + done(); }); - it('false', (done) => { - - const value = false; - const schema: JSONSchema = { type: 'boolean' }; + }); - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: boolean | undefined) => { + it('special readonly array of numbers', (done) => { - expect(result).toBe(value); + type NumbersArray = readonly (1 | 2)[]; - done(); + storage.get('test', { + type: 'array', + items: { + type: 'number', + enum: [1, 2], + }, + }).subscribe((_: NumbersArray | undefined) => { - }); + expect().nothing(); + done(); }); - it('const', (done) => { - - const value = true; - const schema: JSONSchema = { - type: 'boolean', - const: true, - }; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: true | undefined) => { + }); - expect(result).toBe(value); + it('array of integers', (done) => { - done(); + storage.get('test', { + type: 'array', + items: { type: 'integer' }, + }).subscribe((_: number[] | undefined) => { - }); + expect().nothing(); + done(); }); }); - describe('array', () => { - - it('of strings', (done) => { - - const value = ['hello', 'world', '!']; - const schema = { - type: 'array', - items: { type: 'string' }, - } as const; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: string[] | undefined) => { - - expect(result).toEqual(value); + it('array of booleans', (done) => { - done(); + storage.get('test', { + type: 'array', + items: { type: 'boolean' }, + }).subscribe((_: boolean[] | undefined) => { - }); + expect().nothing(); + done(); }); - it('of integers', (done) => { - - const value = [1, 2, 3]; - const schema = { - type: 'array', - items: { type: 'integer' }, - } as const; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: number[] | undefined) => { - - expect(result).toEqual(value); - - done(); - - }); - - }); + }); - it('of numbers', (done) => { + it('tuple', (done) => { - const value = [1.5, 2.4, 3.67]; - const schema = { + storage.get<[string, number][]>('test', { + type: 'array', + items: { type: 'array', - items: { type: 'number' }, - } as const; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: number[] | undefined) => { - - expect(result).toEqual(value); - - done(); + items: [ + { type: 'string' }, + { type: 'number' }, + ], + }, + }).subscribe((_: [string, number][] | undefined) => { - }); + expect().nothing(); + done(); }); - it('of booleans', (done) => { - - const value = [true, false, true]; - const schema = { - type: 'array', - items: { type: 'boolean' }, - } as const; + }); - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: boolean[] | undefined) => { + it('array of objects', (done) => { - expect(result).toEqual(value); + interface Test { + test: string; + } - done(); + storage.get('test', { + type: 'array', + items: { + type: 'object', + properties: { + test: { type: 'string' }, + }, + required: ['test'], + } + }).subscribe((_: Test[] | undefined) => { - }); + expect().nothing(); + done(); }); - it('of arrays', (done) => { - - const value = [['hello', 'world'], ['my', 'name'], ['is', 'Elmo']]; - const schema: JSONSchema = { - type: 'array', - items: { - type: 'array', - items: { type: 'string' }, - }, - }; + }); - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: string[][] | undefined) => { + it('objects / cast / no schema', (done) => { - expect(result).toEqual(value); + interface Test { + test: string; + } - done(); + // @ts-expect-error + // tslint:disable-next-line: deprecation + storage.get('test').subscribe((_: Test | undefined) => { - }); + expect().nothing(); + done(); }); - it('of objects', (done) => { - - const value = [{ - name: 'Elmo', - address: 'Sesame street', - }, { - name: 'Cookie', - }, { - name: 'Chester', - }]; - const schema: JSONSchema = { - type: 'array', - items: { - type: 'object', - properties: { - name: { type: 'string' }, - address: { type: 'string' }, - }, - required: ['name'], - }, - }; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: Monster[] | undefined) => { + }); - expect(result).toEqual(value); + it('objects / no cast / schema', (done) => { - done(); + storage.get('test', { + type: 'object', + properties: { + test: { type: 'string' } + } + // @ts-expect-error + }).subscribe((_: Test | undefined) => { - }); + expect().nothing(); + done(); }); - it('Set', (done) => { - - const array = ['hello', 'world']; - const value = new Set(['hello', 'world']); - const schema = { - type: 'array', - items: { type: 'string' }, - uniqueItems: true, - } as const; + }); - storage.set(key, Array.from(value), schema).pipe( - mergeMap(() => storage.get(key, schema)), - ).subscribe((result: string[] | undefined) => { + it('objects / cast / schema', (done) => { - expect(result).toEqual(array); + interface Test { + test: string; + } - done(); + storage.get('test', { + type: 'object', + properties: { + test: { type: 'string' } + } + }).subscribe((_: Test | undefined) => { - }); + expect().nothing(); + done(); }); - it('tuple', (done) => { - - const value: [string, Monster] = ['hello', { - name: 'Elmo', - address: 'Sesame street', - }]; - const schema: JSONSchema = { - type: 'array', - items: [{ - type: 'string' - }, { - type: 'object', - properties: { - name: { type: 'string' }, - address: { type: 'string' }, - }, - required: ['name'], - }], - }; - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get<[string, Monster]>(key, schema)) - ).subscribe((result: [string, Monster] | undefined) => { - - expect(result).toEqual(value); - - done(); + }); - }); + it('with const assertion', (done) => { - }); + interface Test { + test: string; + } - it('Map', (done) => { - - const array: [string, Monster][] = [ - ['Elmo', { - name: 'Elmo', - address: 'Sesame street', - }], - ['Cookie', { - name: 'Cookie', - }], - ]; - const value = new Map(array); - const schema: JSONSchema = { - type: 'array', - items: { + storage.get('test', { + type: 'object', + properties: { + test: { + type: 'string', + enum: ['hello', 'world'], + }, + list: { type: 'array', - items: [{ - type: 'string' - }, { - type: 'object', - properties: { - name: { type: 'string' }, - address: { type: 'string' }, - }, - required: ['name'], - }], + items: [{ type: 'string' }, { type: 'number' }], }, - }; - - storage.set(key, Array.from(value), schema).pipe( - mergeMap(() => storage.get<[string, Monster][]>(key, schema)), - ).subscribe((result: [string, Monster][] | undefined) => { - - expect(result).toEqual(array); - - done(); + }, + required: ['test'], + } as const).subscribe((_: Test | undefined) => { - }); + expect().nothing(); + done(); }); }); - describe('object', () => { + }); - it('with all subtypes', (done) => { + describe('validation', () => { - interface User { - name: string; - age: number; - philosopher: boolean; - books: string[]; - family: { - brothers: number; - sisters: number; - }; - creditCard?: number; - } + const schema: JSONSchema = { + type: 'object', + properties: { + expected: { type: 'string' }, + }, + required: ['expected'], + }; - const value: User = { - name: 'Henri Bergson', - age: 81, - philosopher: true, - books: [`Essai sur les données immédiates de la conscience`, `Matière et mémoire`], - family: { - brothers: 5, - sisters: 3, - }, - }; - const schema: JSONSchema = { - type: 'object', - properties: { - name: { type: 'string' }, - age: { type: 'number' }, - philosopher: { type: 'boolean' }, - books: { - type: 'array', - items: { type: 'string' }, - }, - family: { - type: 'object', - properties: { - brothers: { type: 'integer' }, - sisters: { type: 'integer' }, - }, - required: ['brothers', 'sisters'] - }, - creditCard: { type: 'number' }, - }, - required: ['name', 'age', 'philosopher', 'books', 'family'], - }; + it('valid', (done) => { - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: User | undefined) => { + const value = { expected: 'value' }; - expect(result).toEqual(value); + storage.set(key, value, schema).pipe( + mergeMap(() => storage.get(key, schema)), + ).subscribe((data) => { - done(); + expect(data).toEqual(value); - }); + done(); }); - it('without required properties', (done) => { - - interface User { - name?: string; - age?: number; - } + }); - const value: User = { - name: 'Henri Bergson', - }; - const schema: JSONSchema = { - type: 'object', - properties: { - name: { type: 'string' }, - age: { type: 'number' }, - }, - }; + it('invalid in get()', (done) => { - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result: User | undefined) => { + storage.set(key, 'test').pipe( + mergeMap(() => storage.get(key, schema)) + ).subscribe({ error: (error) => { - expect(result).toEqual(value); + expect(error.message).toBe(VALIDATION_ERROR); - done(); + done(); - }); + } }); - }); + }); - it('objects / cast / no schema', (done) => { + it('invalid in set()', (done) => { - interface Test { - test: string; - } + storage.set(key, 'test', schema).pipe( + mergeMap(() => storage.get(key, { type: 'string' })) + ).subscribe({ error: (error) => { - // @ts-expect-error - // tslint:disable-next-line: deprecation - storage.get('test').subscribe((_: Test | undefined) => { + expect(error.message).toBe(VALIDATION_ERROR); - expect().nothing(); - done(); + done(); - }); + } }); - }); + }); - it('objects / no cast / schema', (done) => { + it('invalid in watch()', (done) => { - storage.get('test', { - type: 'object', - properties: { - test: { type: 'string' } - } - // @ts-expect-error - }).subscribe((_: Test | undefined) => { + const watchedKey = 'watched2'; - expect().nothing(); - done(); + storage.set(watchedKey, 'test').subscribe(() => { + storage.watch(watchedKey, schema).subscribe({ + error: () => { + expect().nothing(); + done(); + } }); }); }); - describe('specials', () => { + it('null: no validation', (done) => { - it('unexisting key', (done) => { + storage.get<{ expected: string }>(`noassociateddata${Date.now()}`, schema).subscribe(() => { - const schema: JSONSchema = { type: 'string' }; + expect().nothing(); + done(); - storage.get(`unknown${Date.now()}`, schema).subscribe((data: string | undefined) => { + }); - expect(data).toBeUndefined(); + }); - done(); + }); - }); + }); - }); +} - it('null', (done) => { +describe('StorageMap', () => { - const schema: JSONSchema = { type: 'string' }; + tests('memory', () => new StorageMap(new MemoryDatabase())); - storage.set(key, 'test', schema).pipe( - mergeMap(() => storage.set(key, null, schema)), - mergeMap(() => storage.get(key, schema)), - ).subscribe((result: string | undefined) => { - - expect(result).toBeUndefined(); - - done(); - - }); - - }); - - it('undefined', (done) => { - - const schema: JSONSchema = { type: 'string' }; - - storage.set(key, 'test', schema).pipe( - mergeMap(() => storage.set(key, undefined, schema)), - mergeMap(() => storage.get(key, schema)), - ).subscribe((result: string | undefined) => { - - expect(result).toBeUndefined(); - - done(); - - }); - - }); - - it('blob (will be pending in Safari private)', (done) => { - - const value = new Blob(); - - storage.set(key, value).pipe( - mergeMap(() => storage.get(key)) - ).subscribe((storage.backingEngine === 'localStorage') ? { - next: () => {}, - error: () => { - expect().nothing(); - done(); - } - } : { - next: (result: unknown | undefined) => { - expect(result).toEqual(value); - done(); - }, - error: () => { - /* Safari in private mode doesn't allow to store `Blob` in `indexedDB` */ - pending(); - done(); - } - }); - - }); - - it('heavy schema', (done) => { - - interface City { - country: string; - population: number; - coordinates: [number, number]; - monuments?: { - name: string; - constructionYear?: number; - }[]; - } - - const value: [string, City][] = [ - ['Paris', { - country: 'France', - population: 2187526, - coordinates: [48.866667, 2.333333], - monuments: [{ - name: `Tour Eiffel`, - constructionYear: 1889, - }, { - name: `Notre-Dame de Paris`, - constructionYear: 1345, - }], - }], - ['Kyōto', { - country: 'Japan', - population: 1467702, - coordinates: [35.011665, 135.768326], - monuments: [{ - name: `Sanjūsangen-dō`, - constructionYear: 1164, - }], - }], - ]; - - const schema: JSONSchema = { - type: 'array', - items: { - type: 'array', - items: [{ - type: 'string' - }, { - type: 'object', - properties: { - country: { type: 'string' }, - population: { type: 'integer' }, - coordinates: { - type: 'array', - items: [ - { type: 'number'}, - { type: 'number'}, - ], - }, - monuments: { - type: 'array', - items: { - type: 'object', - properties: { - name: { type: 'string' }, - constructionYear: { type: 'integer' }, - }, - required: ['name'], - }, - }, - }, - required: ['country', 'population', 'coordinates'], - }] - }, - }; - - - storage.set(key, value, schema).pipe( - mergeMap(() => storage.get<[string, City][]>(key, schema)), - ).subscribe((result: [string, City][] | undefined) => { - - expect(result).toEqual(value); - - done(); - - }); - - }); - - }); - - }); - - describe('set()', () => { - - it('update', (done) => { - - const schema: JSONSchema = { type: 'string' }; - - storage.set(key, 'value', schema).pipe( - mergeMap(() => storage.set(key, 'updated', schema)) - ).subscribe(() => { - - expect().nothing(); - - done(); - - }); - - }); - - it('concurrency', (done) => { - - const value1 = 'test1'; - const value2 = 'test2'; - const schema: JSONSchema = { type: 'string' }; - - expect(() => { - - storage.set(key, value1, schema).subscribe(); - - storage.set(key, value2, schema).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe((result) => { - - expect(result).toBe(value2); - - done(); - - }); - - }).not.toThrow(); - - }); - - }); - - describe('deletion', () => { - - it('delete() with existing key', (done) => { - - storage.set(key, 'test').pipe( - mergeMap(() => storage.delete(key)), - mergeMap(() => storage.get(key)) - ).subscribe((result) => { - - expect(result).toBeUndefined(); - - done(); - - }); - - }); - - it('delete() with unexisting key', (done) => { - - storage.delete(`unexisting${Date.now()}`).subscribe(() => { - - expect().nothing(); - - done(); - - }); - - }); - - it('clear()', (done) => { - - storage.set(key, 'test').pipe( - mergeMap(() => storage.clear()), - mergeMap(() => storage.get(key)) - ).subscribe((result) => { - - expect(result).toBeUndefined(); - - done(); - - }); - - }); - - }); - - describe('Map-like API', () => { - - it('size', (done) => { - - storage.size.pipe( - tap((length) => { expect(length).toBe(0); }), - mergeMap(() => storage.set(key, 'test')), - mergeMap(() => storage.size), - tap((length) => { expect(length).toBe(1); }), - mergeMap(() => storage.set('', 'test')), - mergeMap(() => storage.size), - tap((length) => { expect(length).toBe(2); }), - mergeMap(() => storage.delete(key)), - mergeMap(() => storage.size), - tap((length) => { expect(length).toBe(1); }), - mergeMap(() => storage.clear()), - mergeMap(() => storage.size), - tap((length) => { expect(length).toBe(0); }), - ).subscribe(() => { - done(); - }); - - }); - - it('keys()', (done) => { - - const key1 = 'index1'; - const key2 = 'index2'; - const keys = [key1, key2]; - - storage.set(key1, 'test').pipe( - mergeMap(() => storage.set(key2, 'test')), - mergeMap(() => storage.keys()), - ).subscribe({ - next: (value) => { - expect(keys).toContain(value); - keys.splice(keys.indexOf(value), 1); - }, - complete: () => { - done(); - }, - }); - - }); - - it('keys() when no items', (done) => { - - storage.keys().subscribe({ - next: () => { - fail(); - }, - complete: () => { - expect().nothing(); - done(); - }, - }); - - }); - - it('has() on existing', (done) => { - - storage.set(key, 'test').pipe( - mergeMap(() => storage.has(key)) - ).subscribe((result) => { - - expect(result).toBe(true); - - done(); - - }); - - }); - - it('has() on unexisting', (done) => { - - storage.has(`nokey${Date.now()}`).subscribe((result) => { - - expect(result).toBe(false); - - done(); - - }); - - }); - - it('advanced case: remove only some items', (done) => { - - storage.set('user_firstname', 'test').pipe( - mergeMap(() => storage.set('user_lastname', 'test')), - mergeMap(() => storage.set('app_data1', 'test')), - mergeMap(() => storage.set('app_data2', 'test')), - mergeMap(() => storage.keys()), - filter((currentKey) => currentKey.startsWith('app_')), - mergeMap((currentKey) => storage.delete(currentKey)), - ).subscribe({ - /* So we need to wait for completion of all actions to check */ - complete: () => { - - storage.size.subscribe((size) => { - - expect(size).toBe(2); - - done(); - - }); - - } - }); - - }); - - }); - - describe('watch()', () => { - - it('valid', (done) => { - - const watchedKey = 'watched1'; - const values = [undefined, 'test1', undefined, 'test2', undefined]; - const schema: JSONSchema = { type: 'string' }; - let i = 0; - - storage.watch(watchedKey, schema).subscribe((result: string | undefined) => { - - expect(result).toBe(values[i]); - - i += 1; - - if (i === 1) { - - storage.set(watchedKey, values[1], schema).pipe( - mergeMap(() => storage.delete(watchedKey)), - mergeMap(() => storage.set(watchedKey, values[3], schema)), - mergeMap(() => storage.clear()), - ).subscribe(); - - } - - if (i === values.length) { - done(); - } - - }); - - }); - - }); - - describe('validation', () => { - - const schema: JSONSchema = { - type: 'object', - properties: { - expected: { - type: 'string' - } - }, - required: ['expected'] - }; - - it('valid schema with options', (done) => { - - const value = 5; - const schemaWithOptions: JSONSchema = { type: 'number', maximum: 10 }; - - storage.set(key, value, schemaWithOptions).pipe( - mergeMap(() => storage.get(key, schemaWithOptions)), - ).subscribe((result: number | undefined) => { - - expect(result).toBe(value); - - done(); - - }); - - }); - - it('invalid schema with options', (done) => { - - const value = 15; - const schemaWithOptions: JSONSchema = { type: 'number', maximum: 10 }; - - storage.set(key, value, { type: 'number' }).pipe( - mergeMap(() => storage.get(key, schemaWithOptions)), - ).subscribe({ - error: (error) => { - - expect(error.message).toBe(VALIDATION_ERROR); - - done(); - - } - }); - - }); - - it('invalid in get()', (done) => { - - storage.set(key, 'test', { type: 'string' }).pipe( - mergeMap(() => storage.get(key, schema)) - ).subscribe({ error: (error) => { - - expect(error.message).toBe(VALIDATION_ERROR); - - done(); - - } }); - - }); - - it('invalid in set()', (done) => { - - storage.set(key, 'test', schema).subscribe({ - error: (error) => { - - expect(error.message).toBe(VALIDATION_ERROR); - done(); - - }, - }); - - }); - - it('invalid in watch()', (done) => { - - const watchedKey = 'watched2'; - - storage.set(watchedKey, 'test', { type: 'string' }).subscribe(() => { - - storage.watch(watchedKey, { type: 'number' }).subscribe({ - error: () => { - expect().nothing(); - done(); - } - }); - - }); - - }); - - it('null: no validation', (done) => { - - storage.get(`noassociateddata${Date.now()}`, schema).subscribe(() => { - - expect().nothing(); - - done(); - - }); - - }); - - }); - - /* Avoid https://github.com/cyrilletuzi/angular-async-local-storage/issues/25 - * Avoid https://github.com/cyrilletuzi/angular-async-local-storage/issues/5 */ - describe('complete', () => { - - const schema: JSONSchema = { type: 'string' }; - - it('get()', (done) => { - - storage.get(key, schema).subscribe({ - complete: () => { - expect().nothing(); - done(); - } - }); - - }); - - it('set()', (done) => { - - storage.set('index', 'value', schema).subscribe({ - complete: () => { - expect().nothing(); - done(); - } - }); - - }); - - it('delete()', (done) => { - - storage.delete(key).subscribe({ - complete: () => { - expect().nothing(); - done(); - } - }); - - }); - - it('clear()', (done) => { - - storage.clear().subscribe({ - complete: () => { - expect().nothing(); - done(); - } - }); - - }); - - it('size', (done) => { - - storage.size.subscribe({ - complete: () => { - expect().nothing(); - done(); - } - }); - - }); - - it('keys()', (done) => { - - storage.keys().subscribe({ - complete: () => { - expect().nothing(); - done(); - } - }); - - }); - - it('has()', (done) => { - - storage.has(key).subscribe({ - complete: () => { - expect().nothing(); - done(); - } - }); - - }); - - }); - - describe('compatibility with Promise', () => { - - const schema: JSONSchema = { type: 'string' }; - - it('Promise', (done) => { - - const value = 'test'; - - storage.set(key, value, schema).toPromise() - .then(() => storage.get(key, schema).toPromise()) - .then((result: string | undefined) => { - expect(result).toBe(value); - done(); - }); - - }); - - it('async / await', async () => { - - const value = 'test'; - - await storage.set(key, value, schema).toPromise(); - - const result: string | undefined = await storage.get(key, schema).toPromise(); - - expect(result).toBe(value); - - }); - - }); - - }); - -} - -describe('StorageMap', () => { - - tests('memory', () => new StorageMap(new MemoryDatabase())); - - tests('localStorage', () => new StorageMap(new LocalStorageDatabase())); - - tests('localStorage with prefix', () => new StorageMap(new LocalStorageDatabase(`ls`))); + tests('localStorage', () => new StorageMap(new LocalStorageDatabase())); tests('indexedDB', () => new StorageMap(new IndexedDBDatabase())); - tests('indexedDB with custom options', () => new StorageMap(new IndexedDBDatabase('customDbTest', 'storeTest', 2, false))); - - describe('browser APIs', () => { - - /* Avoid https://github.com/cyrilletuzi/angular-async-local-storage/issues/57 */ - it('IndexedDb is used (will be pending in Firefox/IE private mode)', (done) => { - - const index = `test${Date.now()}`; - const value = 'test'; - const schema: JSONSchema = { type: 'string' }; - - const localStorageService = new StorageMap(new IndexedDBDatabase()); - - localStorageService.set(index, value, schema).subscribe(() => { - - try { - - const dbOpen = indexedDB.open(DEFAULT_IDB_DB_NAME, DEFAULT_IDB_DB_VERSION); - - dbOpen.addEventListener('success', () => { - - const store = dbOpen.result.transaction([DEFAULT_IDB_STORE_NAME], 'readonly').objectStore(DEFAULT_IDB_STORE_NAME); - - const request = store.get(index); - - request.addEventListener('success', () => { - - expect(request.result).toBe(value); - - dbOpen.result.close(); - - closeAndDeleteDatabase(done, localStorageService); - - }); - - request.addEventListener('error', () => { - - dbOpen.result.close(); - - /* This case is not supposed to happen */ - fail(); - - }); - - }); - - dbOpen.addEventListener('error', () => { - - /* Cases : Firefox private mode where `indexedDb` exists but fails */ - pending(); - - }); - - } catch { - - /* Cases : IE private mode where `indexedDb` will exist but not its `open()` method */ - pending(); - - } - - }); - - }); - - it('indexedDb with default options (will be pending in Firefox private mode)', (done) => { - - const localStorageService = new StorageMap(new IndexedDBDatabase()); - const schema: JSONSchema = { type: 'string' }; - - /* Do a request first as a first transaction is needed to set the store name */ - localStorageService.get('test', schema).subscribe(() => { - - if (localStorageService.backingEngine === 'indexedDB') { - - const { database, store, version } = localStorageService.backingStore; - - expect(database).toBe(DEFAULT_IDB_DB_NAME); - expect(store).toBe(DEFAULT_IDB_STORE_NAME); - expect(version).toBe(DEFAULT_IDB_DB_VERSION); - - closeAndDeleteDatabase(done, localStorageService); - - } else { - - /* Cases: Firefox private mode */ - pending(); - - } - - }); - - }); - - /* Avoid https://github.com/cyrilletuzi/angular-async-local-storage/issues/57 */ - it('indexedDb with noWrap to false (will be pending in Firefox/IE private mode)', (done) => { - - const index = `wrap${Date.now()}`; - const value = 'test'; - const schema: JSONSchema = { type: 'string' }; - - const localStorageService = new StorageMap(new IndexedDBDatabase(undefined, undefined, undefined, false)); - - localStorageService.set(index, value, schema).subscribe(() => { - - try { - - const dbOpen = indexedDB.open(DEFAULT_IDB_DB_NAME, DEFAULT_IDB_DB_VERSION); - - dbOpen.addEventListener('success', () => { - - const store = dbOpen.result.transaction([DEFAULT_IDB_STORE_NAME], 'readonly').objectStore(DEFAULT_IDB_STORE_NAME); - - const request = store.get(index); - - request.addEventListener('success', () => { - - expect(request.result).toEqual({ value }); - - dbOpen.result.close(); - - closeAndDeleteDatabase(done, localStorageService); - - }); - - request.addEventListener('error', () => { - - dbOpen.result.close(); - - /* This case is not supposed to happen */ - fail(); - - }); - - }); - - dbOpen.addEventListener('error', () => { - - /* Cases : Firefox private mode where `indexedDb` exists but fails */ - pending(); - - }); - - } catch { - - /* Cases : IE private mode where `indexedDb` will exist but not its `open()` method */ - pending(); - - } - - }); - - }); - - it('indexedDb with custom options (will be pending in Firefox private mode)', (done) => { - - /* Unique names to be sure `indexedDB` `upgradeneeded` event is triggered */ - const dbName = `dbCustom${Date.now()}`; - const storeName = `storeCustom${Date.now()}`; - const schema: JSONSchema = { type: 'string' }; - const dbVersion = 2; - const noWrap = false; - - const localStorageService = new StorageMap(new IndexedDBDatabase(dbName, storeName, dbVersion, noWrap)); - - /* Do a request first as a first transaction is needed to set the store name */ - localStorageService.get('test', schema).subscribe(() => { - - if (localStorageService.backingEngine === 'indexedDB') { - - const { database, store, version } = localStorageService.backingStore; - - expect(database).toBe(dbName); - expect(store).toBe(storeName); - expect(version).toBe(dbVersion); - - closeAndDeleteDatabase(done, localStorageService); - - } else { - - /* Cases: Firefox private mode */ - pending(); - - } - - }); - - }); - - it('localStorage with prefix', () => { - - const prefix = `ls_`; - - const localStorageService = new StorageMap(new LocalStorageDatabase(prefix)); - - expect(localStorageService.fallbackBackingStore.prefix).toBe(prefix); - - }); - - }); - }); diff --git a/projects/ngx-pwa/local-storage/src/lib/storages/storage-map.service.ts b/projects/ngx-pwa/local-storage/src/lib/storages/storage-map.service.ts index 6c3ea6d6..a9369945 100644 --- a/projects/ngx-pwa/local-storage/src/lib/storages/storage-map.service.ts +++ b/projects/ngx-pwa/local-storage/src/lib/storages/storage-map.service.ts @@ -1,139 +1,23 @@ -import { Injectable, Inject } from '@angular/core'; -import { Observable, throwError, of, OperatorFunction, ReplaySubject } from 'rxjs'; -import { mergeMap, catchError, tap } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; import { JSONSchema, JSONSchemaBoolean, JSONSchemaInteger, JSONSchemaNumber, JSONSchemaString, JSONSchemaArrayOf } from '../validation/json-schema'; -import { JSONValidator } from '../validation/json-validator'; -import { IndexedDBDatabase } from '../databases/indexeddb-database'; -import { LocalStorageDatabase } from '../databases/localstorage-database'; -import { MemoryDatabase } from '../databases/memory-database'; -import { LocalDatabase } from '../databases/local-database'; -import { IDB_BROKEN_ERROR } from '../databases/exceptions'; -import { LS_PREFIX } from '../tokens'; -import { ValidationError } from './exceptions'; +import { SafeStorageMap } from './safe-storage-map.service'; @Injectable({ providedIn: 'root' }) -export class StorageMap { - - protected notifiers = new Map>(); - - /** - * Constructor params are provided by Angular (but can also be passed manually in tests) - * @param database Storage to use - * @param jsonValidator Validator service - * @param LSPrefix Prefix for `localStorage` keys to avoid collision for multiple apps on the same subdomain or for interoperability - */ - constructor( - protected database: LocalDatabase, - protected jsonValidator: JSONValidator = new JSONValidator(), - @Inject(LS_PREFIX) protected LSPrefix = '', - ) {} - - /** - * **Number of items** in storage, wrapped in an `Observable`. - * - * @example - * this.storageMap.size.subscribe((size) => { - * console.log(size); - * }); - */ - get size(): Observable { - - return this.database.size - /* Catch if `indexedDb` is broken */ - .pipe(this.catchIDBBroken(() => this.database.size)); - - } - - /** - * Tells you which storage engine is used. *Only useful for interoperability.* - * Note that due to some browsers issues in some special contexts - * (Firefox private mode and Safari cross-origin iframes), - * **this information may be wrong at initialization,** - * as the storage could fallback from `indexedDB` to `localStorage` - * only after a first read or write operation. - * @returns Storage engine used - * - * @see {@link https://github.com/cyrilletuzi/angular-async-local-storage/blob/master/docs/INTEROPERABILITY.md} - * - * @example - * if (this.storageMap.backingEngine === 'indexedDB') {} - */ - get backingEngine(): 'indexedDB' | 'localStorage' | 'memory' | 'unknown' { - - if (this.database instanceof IndexedDBDatabase) { - - return 'indexedDB'; - - } else if (this.database instanceof LocalStorageDatabase) { - - return 'localStorage'; - - } else if (this.database instanceof MemoryDatabase) { - - return 'memory'; - - } else { - - return 'unknown'; - - } - - } - - /** - * Info about `indexedDB` database. *Only useful for interoperability.* - * @returns `indexedDB` database name, store name and database version. - * **Values will be empty if the storage is not `indexedDB`,** - * **so it should be used after an engine check**. - * - * @see {@link https://github.com/cyrilletuzi/angular-async-local-storage/blob/master/docs/INTEROPERABILITY.md} - * - * @example - * if (this.storageMap.backingEngine === 'indexedDB') { - * const { database, store, version } = this.storageMap.backingStore; - * } - */ - get backingStore(): { database: string, store: string, version: number } { - - return (this.database instanceof IndexedDBDatabase) ? - this.database.backingStore : - { database: '', store: '', version: 0 }; - - } - - /** - * Info about `localStorage` fallback storage. *Only useful for interoperability.* - * @returns `localStorage` prefix. - * **Values will be empty if the storage is not `localStorage`,** - * **so it should be used after an engine check**. - * - * @see {@link https://github.com/cyrilletuzi/angular-async-local-storage/blob/master/docs/INTEROPERABILITY.md} - * - * @example - * if (this.storageMap.backingEngine === 'localStorage') { - * const { prefix } = this.storageMap.fallbackBackingStore; - * } - */ - get fallbackBackingStore(): { prefix: string } { - - return (this.database instanceof LocalStorageDatabase) ? - { prefix: this.database.prefix } : - { prefix: '' }; - - } +export class StorageMap extends SafeStorageMap { /** * Get an item value in storage. * The signature has many overloads due to validation, **please refer to the documentation.** * @see {@link https://github.com/cyrilletuzi/angular-async-local-storage/blob/master/docs/VALIDATION.md} * @param key The item's key - * @param schema Optional JSON schema to validate the data + * @param schema Optional JSON schema to validate the data. If you use a schema, check the new `SafeStorageMap` service. * @returns The item's value if the key exists, `undefined` otherwise, wrapped in a RxJS `Observable` * * @example @@ -142,12 +26,7 @@ export class StorageMap { * }); * * @example - * interface User { - * firstName: string; - * lastName?: string; - * } - * - * const schema = { + * const schema: JSONSchema = { * type: 'object', * properties: { * firstName: { type: 'string' }, @@ -207,33 +86,11 @@ export class StorageMap { get(key: string, schema?: JSONSchema): Observable; get(key: string, schema?: JSONSchema): Observable { - /* Get the data in storage */ - return this.database.get(key).pipe( - /* Check if `indexedDb` is broken */ - this.catchIDBBroken(() => this.database.get(key)), - mergeMap((data) => { - - /* No need to validate if the data is empty */ - if ((data === undefined) || (data === null)) { - - return of(undefined); - - } else if (schema) { - - /* Validate data against a JSON schema if provided */ - if (!this.jsonValidator.validate(data, schema)) { - return throwError(new ValidationError()); - } - - /* Data have been checked, so it's OK to cast */ - return of(data as T | undefined); - - } - - /* Cast to unknown as the data wasn't checked */ - return of(data as unknown); - - }), + return (schema ? + /* If schema was provided, data has been validated, so it is OK to cast */ + this.getAndValidate(key, schema) as Observable : + /* Otherwise we don't known what we got */ + this.getAndValidate(key) as Observable ); } @@ -251,101 +108,7 @@ export class StorageMap { */ set(key: string, data: unknown, schema?: JSONSchema): Observable { - /* Storing `undefined` or `null` is useless and can cause issues in `indexedDb` in some browsers, - * so removing item instead for all storages to have a consistent API */ - if ((data === undefined) || (data === null)) { - return this.delete(key); - } - - /* Validate data against a JSON schema if provided */ - if (schema && !this.jsonValidator.validate(data, schema)) { - return throwError(new ValidationError()); - } - - return this.database.set(key, data).pipe( - /* Catch if `indexedDb` is broken */ - this.catchIDBBroken(() => this.database.set(key, data)), - /* Notify watchers (must be last because it should only happen if the operation succeeds) */ - tap(() => { this.notify(key, data); }), - ); - } - - /** - * Delete an item in storage - * @param key The item's key - * @returns A RxJS `Observable` to wait the end of the operation - * - * @example - * this.storageMap.delete('key').subscribe(() => {}); - */ - delete(key: string): Observable { - - return this.database.delete(key).pipe( - /* Catch if `indexedDb` is broken */ - this.catchIDBBroken(() => this.database.delete(key)), - /* Notify watchers (must be last because it should only happen if the operation succeeds) */ - tap(() => { this.notify(key, undefined); }), - ); - - } - - /** - * Delete all items in storage - * @returns A RxJS `Observable` to wait the end of the operation - * - * @example - * this.storageMap.clear().subscribe(() => {}); - */ - clear(): Observable { - - return this.database.clear().pipe( - /* Catch if `indexedDb` is broken */ - this.catchIDBBroken(() => this.database.clear()), - /* Notify watchers (must be last because it should only happen if the operation succeeds) */ - tap(() => { - for (const key of this.notifiers.keys()) { - this.notify(key, undefined); - } - }), - ); - - } - - /** - * Get all keys stored in storage. Note **this is an *iterating* `Observable`**: - * * if there is no key, the `next` callback will not be invoked, - * * if you need to wait the whole operation to end, be sure to act in the `complete` callback, - * as this `Observable` can emit several values and so will invoke the `next` callback several times. - * @returns A list of the keys wrapped in a RxJS `Observable` - * - * @example - * this.storageMap.keys().subscribe({ - * next: (key) => { console.log(key); }, - * complete: () => { console.log('Done'); }, - * }); - */ - keys(): Observable { - - return this.database.keys() - /* Catch if `indexedDb` is broken */ - .pipe(this.catchIDBBroken(() => this.database.keys())); - - } - - /** - * Tells if a key exists in storage - * @returns A RxJS `Observable` telling if the key exists - * - * @example - * this.storageMap.has('key').subscribe((hasKey) => { - * if (hasKey) {} - * }); - */ - has(key: string): Observable { - - return this.database.has(key) - /* Catch if `indexedDb` is broken */ - .pipe(this.catchIDBBroken(() => this.database.has(key))); + return this.setAndValidate(key, data, schema); } @@ -355,7 +118,7 @@ export class StorageMap { * The signature has many overloads due to validation, **please refer to the documentation.** * @see https://github.com/cyrilletuzi/angular-async-local-storage/blob/master/docs/VALIDATION.md * @param key The item's key to watch - * @param schema Optional JSON schema to validate the initial value + * @param schema Optional but recommended JSON schema to validate the initial value * @returns An infinite `Observable` giving the current value */ watch(key: string): Observable; @@ -396,6 +159,7 @@ export class StorageMap { */ watch(key: string, schema: JSONSchemaArrayOf): Observable; watch(key: string, schema: JSONSchema): Observable; + watch(key: string): Observable; /** * @deprecated The cast is useless here: as no JSON schema was provided for validation, the result will still be `unknown`. * @see {@link https://github.com/cyrilletuzi/angular-async-local-storage/blob/master/docs/VALIDATION.md} @@ -403,92 +167,13 @@ export class StorageMap { watch(key: string, schema?: JSONSchema): Observable; watch(key: string, schema?: JSONSchema): Observable { - /* Check if there is already a notifier */ - if (!this.notifiers.has(key)) { - this.notifiers.set(key, new ReplaySubject(1)); - } - - /* Non-null assertion is required because TypeScript doesn't narrow `.has()` yet */ - // tslint:disable-next-line: no-non-null-assertion - const notifier = this.notifiers.get(key)!; - - /* Get the current item value */ - (schema ? this.get(key, schema) : this.get(key)).subscribe({ - next: (result) => notifier.next(result), - error: (error) => notifier.error(error), - }); - - /* Only the public API of the `Observable` should be returned */ return (schema ? - notifier.asObservable() as Observable : - notifier.asObservable() as Observable + /* If schema was provided, data has been validated, so it is OK to cast */ + this.watchAndInit(key, schema) as Observable : + /* Otherwise we don't known what we got */ + this.watchAndInit(key) as Observable ); } - /** - * Notify when a value changes - * @param key The item's key - * @param data The new value - */ - protected notify(key: string, value: unknown): void { - - const notifier = this.notifiers.get(key); - - if (notifier) { - notifier.next(value); - } - - } - - /** - * RxJS operator to catch if `indexedDB` is broken - * @param operationCallback Callback with the operation to redo - */ - protected catchIDBBroken(operationCallback: () => Observable): OperatorFunction { - - return catchError((error) => { - - /* Check if `indexedDB` is broken based on error message (the specific error class seems to be lost in the process) */ - if ((error !== undefined) && (error !== null) && (error.message === IDB_BROKEN_ERROR)) { - - /* When storage is fully disabled in browser (via the "Block all cookies" option), - * just trying to check `localStorage` variable causes a security exception. - * Prevents https://github.com/cyrilletuzi/angular-async-local-storage/issues/118 - */ - try { - - if ('getItem' in localStorage) { - - /* Fallback to `localStorage` if available */ - this.database = new LocalStorageDatabase(this.LSPrefix); - - } else { - - /* Fallback to memory storage otherwise */ - this.database = new MemoryDatabase(); - - } - - } catch { - - /* Fallback to memory storage otherwise */ - this.database = new MemoryDatabase(); - - } - - /* Redo the operation */ - return operationCallback(); - - } else { - - /* Otherwise, rethrow the error */ - return throwError(error); - - } - - }); - - } - } diff --git a/projects/ngx-pwa/local-storage/src/lib/testing/cleaning.ts b/projects/ngx-pwa/local-storage/src/lib/testing/cleaning.ts index 6c58b6ec..b610b545 100644 --- a/projects/ngx-pwa/local-storage/src/lib/testing/cleaning.ts +++ b/projects/ngx-pwa/local-storage/src/lib/testing/cleaning.ts @@ -1,18 +1,19 @@ import { StorageMap } from '../storages/storage-map.service'; import { IndexedDBDatabase } from '../databases/indexeddb-database'; import { MemoryDatabase } from '../databases/memory-database'; +import { SafeStorageMap } from '../storages/safe-storage-map.service'; /** * Helper to clear all data in storage to avoid tests overlap * @param done Jasmine helper to explicit when the operation has ended to avoid tests overlap * @param storageService Service */ -export function clearStorage(done: DoneFn, storageService: StorageMap): void { +export function clearStorage(done: DoneFn, storageService: StorageMap | SafeStorageMap): void { if (storageService.backingEngine === 'indexedDB') { // tslint:disable-next-line: no-string-literal - const indexedDBService = storageService['database'] as IndexedDBDatabase; + const indexedDBService = (storageService as SafeStorageMap)['database'] as IndexedDBDatabase; try { @@ -82,7 +83,7 @@ export function clearStorage(done: DoneFn, storageService: StorageMap): void { } else if (storageService.backingEngine === 'memory') { // tslint:disable-next-line: no-string-literal - (storageService['database'] as MemoryDatabase)['memoryStorage'].clear(); + ((storageService as SafeStorageMap)['database'] as MemoryDatabase)['memoryStorage'].clear(); done(); @@ -103,13 +104,13 @@ export function clearStorage(done: DoneFn, storageService: StorageMap): void { * @param doneJasmine helper to explicit when the operation has ended to avoid tests overlap * @param storageService Service */ -export function closeAndDeleteDatabase(done: DoneFn, storageService: StorageMap): void { +export function closeAndDeleteDatabase(done: DoneFn, storageService: StorageMap | SafeStorageMap): void { /* Only `indexedDB` is concerned */ if (storageService.backingEngine === 'indexedDB') { // tslint:disable-next-line: no-string-literal - const indexedDBService = storageService['database'] as IndexedDBDatabase; + const indexedDBService = (storageService as SafeStorageMap)['database'] as IndexedDBDatabase; // tslint:disable-next-line: no-string-literal indexedDBService['database'].subscribe({ diff --git a/projects/ngx-pwa/local-storage/src/lib/validation/infer-from-json-schema.ts b/projects/ngx-pwa/local-storage/src/lib/validation/infer-from-json-schema.ts new file mode 100644 index 00000000..25bc58ef --- /dev/null +++ b/projects/ngx-pwa/local-storage/src/lib/validation/infer-from-json-schema.ts @@ -0,0 +1,68 @@ +import { JSONSchema } from './json-schema'; + +/** + * JSON schemas of primitive types + */ +type JSONSchemaPrimitive = { type: 'string' | 'integer' | 'number' | 'boolean' }; + +/** + * Infer data from a JSON schemas describing a primitive type. + */ +type InferFromJSONSchemaPrimitive = + /* Infer `const` and `enum` first, as they are more specific */ + Schema extends { const: infer ConstType } ? ConstType : + Schema extends { enum: readonly (infer EnumType)[] } ? EnumType : + /* Infer primitive types */ + Schema extends { type: 'string' } ? string : + Schema extends { type: 'integer' } ? number : + Schema extends { type: 'number' } ? number : + Schema extends { type: 'boolean' } ? boolean : + /* Default value, but not supposed to happen given the `JSONSchema` interface */ + unknown; + +/** + * Infer the data type from a tuple JSON schema describing a tuple of primitive types. + */ +type InferFromJSONSchemaTupleOfPrimitive = { + -readonly [Key in keyof Schemas]: Schemas[Key] extends JSONSchemaPrimitive ? InferFromJSONSchemaPrimitive : never +}; + +/** + * Infer the data type from a JSON schema describing a tuple: + * - with 1 or 2 values of any type (especially usefull for handling `Map`s), + * - with unlimited values but of primitive types only. + */ +type InferFromJSONSchemaTuple = + /* Allows inference from any JSON schema up to 2 values */ + Schemas extends readonly [JSONSchema] ? [InferFromJSONSchema] : + Schemas extends readonly [JSONSchema, JSONSchema] ? [InferFromJSONSchema, InferFromJSONSchema] : + /* Beyond 2 values, infer from primitive types only to avoid too deep type inference */ + Schemas extends readonly JSONSchemaPrimitive[] ? InferFromJSONSchemaTupleOfPrimitive : + unknown[] +; + +/** + * Infer the data type from a JSON schema. + */ +export type InferFromJSONSchema = + /* Infer primitive types */ + Schema extends JSONSchemaPrimitive ? InferFromJSONSchemaPrimitive : + /* Infer arrays */ + Schema extends { type: 'array', items: infer ItemsType } ? + /* Classic array */ + ItemsType extends JSONSchema ? InferFromJSONSchema[] : + /* Tuples (ie. array with different value types) */ + ItemsType extends readonly JSONSchema[] ? InferFromJSONSchemaTuple : + /* Default value, but not supposed to happen given the `JSONSchema` interface */ + unknown[] : + /* Infer objects */ + Schema extends { type: 'object', properties: infer Properties, required?: readonly (infer RequiredProperties)[] } ? + { + -readonly [Key in keyof Pick]: + Properties[Key] extends JSONSchema ? InferFromJSONSchema : never; + } & { + -readonly [Key in keyof Omit]?: + Properties[Key] extends JSONSchema ? InferFromJSONSchema : never; + } : + /* Default type if inference failed */ + unknown; diff --git a/projects/ngx-pwa/local-storage/src/lib/validation/json-schema.ts b/projects/ngx-pwa/local-storage/src/lib/validation/json-schema.ts index 81bed5bf..c2aa3352 100644 --- a/projects/ngx-pwa/local-storage/src/lib/validation/json-schema.ts +++ b/projects/ngx-pwa/local-storage/src/lib/validation/json-schema.ts @@ -252,6 +252,19 @@ export interface JSONSchemaObject { } +// TODO: documentation +/** + * JSON schema to describe a custom value (useful for `Blob`). + */ +export interface JSONSchemaUnknown { + + /** + * Type for an unknown value. + */ + type: 'unknown'; + +} + /** * Subset of the JSON Schema standard. * Types are enforced to validate everything: each value **must** have a `type`. @@ -284,5 +297,6 @@ export interface JSONSchemaObject { * }, * required: ['firstName'], * }; + * */ -export type JSONSchema = JSONSchemaString | JSONSchemaNumber | JSONSchemaInteger | JSONSchemaBoolean | JSONSchemaArray | JSONSchemaObject; +export type JSONSchema = JSONSchemaString | JSONSchemaNumber | JSONSchemaInteger | JSONSchemaBoolean | JSONSchemaArray | JSONSchemaObject | JSONSchemaUnknown; diff --git a/projects/ngx-pwa/local-storage/src/lib/validation/json-validator.ts b/projects/ngx-pwa/local-storage/src/lib/validation/json-validator.ts index 59aba517..a0208944 100644 --- a/projects/ngx-pwa/local-storage/src/lib/validation/json-validator.ts +++ b/projects/ngx-pwa/local-storage/src/lib/validation/json-validator.ts @@ -33,6 +33,9 @@ export class JSONValidator { return this.validateArray(data, schema); case 'object': return this.validateObject(data, schema); + // TODO: check how we handle this case (do not allow to bypass validation, or add a test) + case 'unknown': + return true; } diff --git a/projects/ngx-pwa/local-storage/src/public_api.ts b/projects/ngx-pwa/local-storage/src/public_api.ts index 6ec52309..2d562470 100644 --- a/projects/ngx-pwa/local-storage/src/public_api.ts +++ b/projects/ngx-pwa/local-storage/src/public_api.ts @@ -4,13 +4,14 @@ export { JSONSchema, JSONSchemaBoolean, JSONSchemaInteger, JSONSchemaNumber, JSONSchemaString, - JSONSchemaArray, JSONSchemaArrayOf, JSONSchemaObject + JSONSchemaArray, JSONSchemaArrayOf, JSONSchemaObject, JSONSchemaUnknown } from './lib/validation/json-schema'; export { JSONValidator } from './lib/validation/json-validator'; export { LocalDatabase } from './lib/databases/local-database'; export { SERIALIZATION_ERROR, SerializationError } from './lib/databases/exceptions'; +export { SafeStorageMap } from './lib/storages/safe-storage-map.service'; export { StorageMap } from './lib/storages/storage-map.service'; export { LocalStorage } from './lib/storages/local-storage.service'; export { ValidationError, VALIDATION_ERROR } from './lib/storages/exceptions';