diff --git a/.size-limit.js b/.size-limit.js index ea84935..d5e16ee 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -11,7 +11,7 @@ module.exports = [ { name: 'root persist, cjs module', path: 'build/index.cjs', - limit: '3368 B', + limit: '3979 B', // import: '{ persist }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -21,7 +21,7 @@ module.exports = [ { name: 'core persist, es module', path: 'build/core/index.js', - limit: '1095 B', + limit: '1096 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -29,7 +29,7 @@ module.exports = [ { name: 'core persist, cjs module', path: 'build/core/index.cjs', - limit: '1281 B', + limit: '1738 B', // import: '{ persist }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -111,7 +111,7 @@ module.exports = [ { name: '`localStorage` persist, es module', path: 'build/local/index.js', - limit: '1572 B', + limit: '1569 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -119,7 +119,7 @@ module.exports = [ { name: '`localStorage` persist, cjs module', path: 'build/local/index.cjs', - limit: '1854 B', + limit: '2330 B', // import: '{ persist }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -130,7 +130,7 @@ module.exports = [ { name: 'core adapter, es module', path: 'build/index.js', - limit: '1553 B', + limit: '1550 B', import: '{ persist, local }', ignore: ['effector'], gzip: true, @@ -138,7 +138,7 @@ module.exports = [ { name: 'core adapter factory, es module', path: ['build/index.js', 'build/local/index.js'], - limit: '1555 B', + limit: '1553 B', import: { 'build/index.js': '{ persist }', 'build/local/index.js': '{ local }', @@ -151,7 +151,7 @@ module.exports = [ { name: '`sessionStorage` persist, es module', path: 'build/session/index.js', - limit: '1569 B', + limit: '1566 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -159,7 +159,7 @@ module.exports = [ { name: '`sessionStorage` persist, cjs module', path: 'build/session/index.cjs', - limit: '1849 B', + limit: '2324 B', // import: '{ persist }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -169,7 +169,7 @@ module.exports = [ { name: 'query string persist, es module', path: 'build/query/index.js', - limit: '1626 B', + limit: '1622 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -177,7 +177,7 @@ module.exports = [ { name: 'query string persist, cjs module', path: 'build/query/index.cjs', - limit: '1921 B', + limit: '2393 B', // import: '{ persist }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -187,7 +187,7 @@ module.exports = [ { name: 'memory adapter, es module', path: 'build/memory/index.js', - limit: '1191 B', + limit: '1207 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -195,7 +195,7 @@ module.exports = [ { name: 'memory adapter, cjs module', path: 'build/memory/index.cjs', - limit: '1423 B', + limit: '1932 B', // import: '{ persist }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -231,7 +231,7 @@ module.exports = [ { name: 'broadcast channel adapter, cjs module', path: 'build/broadcast/index.cjs', - limit: '1691 B', + limit: '2169 B', // import: '{ broadcast }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, diff --git a/README.md b/README.md index 4aecca1..7d6eb0d 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ In order to synchronize _something_, you need to specify effector units. Dependi - `keyPrefix` ([_string_]): Prefix, used in adapter, to be concatenated to `key`. By default = `''`. - `operation` (_`'set'`_ | _`'get'`_ | _`'validate'`_): Type of operation, read (get), write (set) or validation against contract (validate). - `error` ([_Error_]): Error instance - - `value`? (_any_): In case of _'set'_ operation — value from `store`. In case of _'get'_ operation could contain raw value from storage or could be empty. + - `value`? (_any_): In case of _'set'_ operation — value from `store`. In case of _'get'_ and _'validate'_ operations could contain raw value from storage or could be empty. - `finally`? ([_Event_] | [_Effect_] | [_Store_]): Unit, which will be triggered either in case of success or error.
Payload structure: - `key` ([_string_]): Same `key` as above. @@ -274,6 +274,57 @@ persist({ - Custom `persist` function, with predefined adapter options. +## `createStorage` factory + +In rare cases you might want to get a granular control over a storage and manually set or get values from it. You can use `createStorage` factory for that. + +```javascript +import { sample, createEvent } from 'effector' +import { createStorage } from 'effector-storage/local' + +const persist = createStorage('my-storage') + +const userWantToSave = createEvent() +const userWantToLoad = createEvent() + +// ---8<--- + +sample({ + clock: userWantToSave, + fn: () => 'some data' + target: persist.setFx, +}) + +sample({ + source: userWantToLoad, + target: persist.getFx, +}) +``` + +### Options + +- `key`? ([_string_]): Key for local/session storage, to store value in. If omitted — `store` name is used. **Note!** If `key` is not specified, `store` _must_ have a `name`! You can use `'effector/babel-plugin'` to have those names automatically. +- `keyPrefix`? ([_string_]): Prefix, used in adapter, to be concatenated to `key`. By default = `''`. +- `context`? ([_Event_] | [_Effect_] | [_Store_]): Unit, which can set a special context for adapter. +- `contract`? ([_Contract_]): Rule to statically validate data from storage. + +### Returns + +An object with fields: + +- `getFx` (_Effect_): to get value from storage. +- `setFx` (_Effect_): to set value to storage. +- `removeFx` (_Effect_): to remove value from storage. + +All fields of returned object are _Effects_ units, so you can use them in `sample` as any other _Effects_. For example, you can add logging on failed storage operations: + +```javascript +sample({ + clock: getFx.fail, + target: sendLogToSentry, +}) +``` + ## Advanced usage `effector-storage` consists of a _core_ module and _adapter_ modules. diff --git a/src/broadcast/index.ts b/src/broadcast/index.ts index 30a241f..b53cd2e 100644 --- a/src/broadcast/index.ts +++ b/src/broadcast/index.ts @@ -3,10 +3,12 @@ import type { ConfigPersist as BaseConfigPersist, ConfigStore as BaseConfigStore, ConfigSourceTarget as BaseConfigSourceTarget, + ConfigCreateStorage as BaseConfigCreateStorage, StorageAdapter, + StorageHandles, } from '../types' import type { BroadcastConfig } from './adapter' -import { persist as base } from '../core' +import { persist as base, createStorage as baseCreateStorage } from '../core' import { nil } from '../nil' import { adapter } from './adapter' @@ -37,6 +39,19 @@ export interface Persist { (config: ConfigStore): Subscription } +export interface ConfigCreateStorage + extends BaseConfigCreateStorage {} + +export interface CreateStorage { + ( + key: string, + config?: BroadcastConfig & BaseConfigCreateStorage + ): StorageHandles + ( + config: BroadcastConfig & BaseConfigCreateStorage & { key: string } + ): StorageHandles +} + /** * Function, checking if `BroadcastChannel` exists and accessible */ @@ -73,3 +88,19 @@ export function createPersist(defaults?: ConfigPersist): Persist { * Default partially applied `persist` */ export const persist = /*#__PURE__*/ createPersist() + +/** + * Creates custom partially applied `createStorage` + * with predefined BroadcastChannel adapter + */ +export function createStorageFactory( + defaults?: ConfigCreateStorage +): CreateStorage { + return (...configs: any[]) => + baseCreateStorage({ adapter: broadcast }, defaults, ...configs) +} + +/** + * Default partially applied `createStorage` + */ +export const createStorage = /*#__PURE__*/ createStorageFactory() diff --git a/src/core/create-storage.ts b/src/core/create-storage.ts new file mode 100644 index 0000000..d5448dc --- /dev/null +++ b/src/core/create-storage.ts @@ -0,0 +1,188 @@ +import type { Event, Effect } from 'effector' +import type { + ConfigAdapter, + ConfigAdapterFactory, + StorageHandles, + ConfigCreateStorage, + Fail, +} from '../types' +import { + attach, + // clearNode, + // createNode, + createStore, + is, + sample, + scopeBind, +} from 'effector' +import { getAreaStorage } from './area' +import { validate } from './validate' + +type Config = Partial< + ConfigCreateStorage & { key: string } & ( + | ConfigAdapter + | ConfigAdapterFactory + ) +> + +export function createStorage( + ...configs: (string | Config | undefined)[] +): StorageHandles { + const config: Config = {} + for (const cfg of configs) { + Object.assign(config, typeof cfg === 'string' ? { key: cfg } : cfg) + } + + const { + adapter: adapterOrFactory, + context, + key: keyName, + keyPrefix = '', + contract, + } = config + + if (!adapterOrFactory) { + throw Error('Adapter is not defined') + } + if (!keyName) { + throw Error('Key is not defined') + } + + const adapter = + 'factory' in adapterOrFactory ? adapterOrFactory(config) : adapterOrFactory + + const key = keyName + const storage = getAreaStorage( + adapter.keyArea || adapter, + keyPrefix + key + ) + + // const region = createNode() + // let disposable: (_: any) => void = () => {} + // const desist = () => disposable(clearNode(region)) + + const ctx = createStore<[any?]>( + [is.store(context) ? context.defaultState : undefined], + { serialize: 'ignore' } + ) + + const value = adapter(keyPrefix + key, (x) => { + update(x) + }) + + // if (typeof value === 'function') { + // disposable = value + // } + + const fail = ( + operation: 'get' | 'set' | 'remove' | 'validate', + error: unknown, + value?: any + ) => + ({ + key, + keyPrefix, + operation, + error, + value: typeof value === 'function' ? undefined : value, // hide internal "box" implementation + }) as Fail + + const op = ( + operation: 'get' | 'set' | 'remove' | 'validate', + fn: (value: any, arg: any) => T, + value: any, + arg: any + ): T => { + try { + return fn(value, arg) + } catch (error) { + throw fail(operation, error, value) + } + } + + const getFx = attach({ + source: ctx, + effect([ref], raw?: any) { + const result = op('get', value.get, raw, ref) as any + return typeof result?.then === 'function' + ? Promise.resolve(result) + .then((result) => op('validate', validate, result, contract)) + .catch((error) => { + throw fail('get', error, raw) + }) + : op('validate', validate, result, contract) + }, + }) as Effect // as Effect> + + const setFx = attach({ + source: ctx, + effect([ref], state: State) { + const result = op('set', value.set, state, ref) + if (typeof result?.then === 'function') { + return Promise.resolve(result) + .then(() => undefined) + .catch((error) => { + throw fail('set', error, state) + }) + } + }, + }) as Effect // as Effect> + + const removeFx = attach({ + source: ctx, + effect([ref]) { + const result = op('remove', value.remove ?? value.set, null, ref) + if (typeof result?.then === 'function') { + return Promise.resolve(result) + .then(() => undefined) + .catch((error) => { + throw fail('remove', error) + }) + } + }, + }) as Effect + + removeFx.watch((_) => console.log('▼ removeFx.watch', _)) + removeFx.finally.watch((_) => console.log('▼ removeFx.finally.watch', _)) + getFx.watch((_) => console.log('▼ getFx.watch', _)) + getFx.finally.watch((_) => console.log('▼ getFx.finally.watch', _)) + storage.watch((_) => console.log('🚩 storage.watch', _)) + + let update: (raw?: any) => any = getFx + ctx.updates.watch(() => { + update = scopeBind(getFx as any, { safe: true }) + }) + + const external = createStore(true, { serialize: 'ignore' }) // + .on([getFx.finally, setFx.finally], () => false) + + sample({ + clock: [getFx.doneData as Event, sample(setFx, setFx.done)], + // filter: (x) => x !== undefined, + fn: (x) => x ?? null, + target: storage, + }) + + sample({ + clock: [storage, removeFx.finally], + filter: external, + fn: () => undefined, + target: getFx, + }) + + sample({ + clock: [getFx.finally, setFx.finally], + fn: () => true, + target: external, + }) + + if (context) { + ctx.on(context, ([ref], payload) => [payload === undefined ? ref : payload]) + } + + return { + getFx, + setFx, + removeFx, + } +} diff --git a/src/core/index.ts b/src/core/index.ts index feeee12..0b68565 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1 +1,2 @@ export { persist } from './persist' +export { createStorage } from './create-storage' diff --git a/src/core/persist.ts b/src/core/persist.ts index 297aaf7..5db9915 100644 --- a/src/core/persist.ts +++ b/src/core/persist.ts @@ -133,6 +133,8 @@ export function persist( source: ctx, effect: ([ref], raw?: any) => value.get(raw, ref), }) as Effect + getFx.watch((_) => console.log('🔻getFx.watch', _)) + getFx.finally.watch((_) => console.log('🔻getFx.finally.watch', _)) const setFx = attach({ source: ctx, diff --git a/src/index.ts b/src/index.ts index 3fc16da..8ab883c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,13 @@ -import type { ConfigPersist, Persist } from './types' -import { persist as basePersist } from './core' +import type { + ConfigPersist, + Persist, + CreateStorage, + ConfigCreateStorage, +} from './types' +import { + persist as basePersist, + createStorage as baseCreateStorage, +} from './core' export type { ConfigPersist, @@ -12,8 +20,11 @@ export type { Persist, Adapter, DisposableAdapter, + StorageHandles, StorageAdapter, StorageAdapterFactory, + CreateStorage, + ConfigCreateStorage, } from './types' // @@ -61,3 +72,17 @@ export function createPersist(defaults?: ConfigPersist): Persist { * Default `persist` */ export const persist: Persist = basePersist + +/** + * Creates custom `createStorage` + */ +export function createStorageFactory( + defaults?: ConfigCreateStorage +): CreateStorage { + return (...configs: any[]) => baseCreateStorage(defaults, ...configs) +} + +/** + * Default `createStorage` + */ +export const createStorage: CreateStorage = baseCreateStorage diff --git a/src/local/index.ts b/src/local/index.ts index 0563b69..baea1ce 100644 --- a/src/local/index.ts +++ b/src/local/index.ts @@ -3,9 +3,11 @@ import type { ConfigPersist as BaseConfigPersist, ConfigStore as BaseConfigStore, ConfigSourceTarget as BaseConfigSourceTarget, + ConfigCreateStorage as BaseConfigCreateStorage, StorageAdapter, + StorageHandles, } from '../types' -import { persist as base } from '../core' +import { persist as base, createStorage as baseCreateStorage } from '../core' import { nil } from '../nil' import { storage } from '../storage' @@ -46,6 +48,20 @@ export interface Persist { (config: ConfigStore): Subscription } +export interface ConfigCreateStorage + extends BaseConfigCreateStorage {} + +export interface CreateStorage { + ( + key: string, + config?: LocalStorageConfig & BaseConfigCreateStorage + ): StorageHandles + ( + config: LocalStorageConfig & + BaseConfigCreateStorage & { key: string } + ): StorageHandles +} + /** * Function, checking if `localStorage` exists */ @@ -90,3 +106,19 @@ export function createPersist(defaults?: ConfigPersist): Persist { * Default partially applied `persist` */ export const persist = /*#__PURE__*/ createPersist() + +/** + * Creates custom partially applied `createStorage` + * with predefined `localStorage` adapter + */ +export function createStorageFactory( + defaults?: ConfigCreateStorage +): CreateStorage { + return (...configs: any[]) => + baseCreateStorage({ adapter: local }, defaults, ...configs) +} + +/** + * Default partially applied `createStorage` + */ +export const createStorage = /*#__PURE__*/ createStorageFactory() diff --git a/src/memory/adapter.ts b/src/memory/adapter.ts index aec870d..4ab71bc 100644 --- a/src/memory/adapter.ts +++ b/src/memory/adapter.ts @@ -4,16 +4,21 @@ const data = new Map() export interface MemoryConfig { area?: Map + def?: any } /** * Memory adapter */ adapter.factory = true as const -export function adapter({ area = data }: MemoryConfig = {}): StorageAdapter { +export function adapter({ + area = data, + def, +}: MemoryConfig = {}): StorageAdapter { const adapter: StorageAdapter = (key: string) => ({ - get: () => area.get(key), + get: () => area.get(key) ?? def, set: (value: State) => void area.set(key, value), + remove: () => void area.delete(key), }) adapter.keyArea = area diff --git a/src/memory/index.ts b/src/memory/index.ts index 0e44245..b97878e 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -3,8 +3,10 @@ import type { ConfigPersist as BaseConfigPersist, ConfigStore as BaseConfigStore, ConfigSourceTarget as BaseConfigSourceTarget, + ConfigCreateStorage as BaseConfigCreateStorage, + StorageHandles, } from '../types' -import { persist as base } from '../core' +import { persist as base, createStorage as baseCreateStorage } from '../core' import { adapter } from './adapter' export type { @@ -32,6 +34,19 @@ export interface Persist { (config: ConfigStore): Subscription } +export interface ConfigCreateStorage + extends BaseConfigCreateStorage {} + +export interface CreateStorage { + ( + key: string, + config?: BaseConfigCreateStorage + ): StorageHandles + ( + config: BaseConfigCreateStorage & { key: string } + ): StorageHandles +} + /** * Returns memory adapter */ @@ -54,3 +69,19 @@ export function createPersist(defaults?: ConfigPersist): Persist { * Default partially applied `persist` */ export const persist = /*#__PURE__*/ createPersist() + +/** + * Creates custom partially applied `createStorage` + * with predefined `memory` adapter + */ +export function createStorageFactory( + defaults?: ConfigCreateStorage +): CreateStorage { + return (...configs: any[]) => + baseCreateStorage({ adapter: adapter() }, defaults, ...configs) +} + +/** + * Default partially applied `createStorage` + */ +export const createStorage = /*#__PURE__*/ createStorageFactory() diff --git a/src/query/index.ts b/src/query/index.ts index 4d6bcea..5fe6983 100644 --- a/src/query/index.ts +++ b/src/query/index.ts @@ -3,10 +3,12 @@ import type { ConfigPersist as BaseConfigPersist, ConfigStore as BaseConfigStore, ConfigSourceTarget as BaseConfigSourceTarget, + ConfigCreateStorage as BaseConfigCreateStorage, StorageAdapter, + StorageHandles, } from '../types' import type { ChangeMethod, StateBehavior, QueryConfig } from './adapter' -import { persist as base } from '../core' +import { persist as base, createStorage as baseCreateStorage } from '../core' import { nil } from '../nil' import { adapter } from './adapter' @@ -47,6 +49,19 @@ export interface Persist { (config: ConfigStore): Subscription } +export interface ConfigCreateStorage + extends BaseConfigCreateStorage {} + +export interface CreateStorage { + ( + key: string, + config?: QueryConfig & BaseConfigCreateStorage + ): StorageHandles + ( + config: QueryConfig & BaseConfigCreateStorage & { key: string } + ): StorageHandles +} + /** * Function, checking if `history` and `location` exists and accessible */ @@ -83,3 +98,19 @@ export function createPersist(defaults?: ConfigPersist): Persist { * Default partially applied `persist` */ export const persist = /*#__PURE__*/ createPersist() + +/** + * Creates custom partially applied `createStorage` + * with predefined `query` adapter + */ +export function createStorageFactory( + defaults?: ConfigCreateStorage +): CreateStorage { + return (...configs: any[]) => + baseCreateStorage({ adapter: query }, defaults, ...configs) +} + +/** + * Default partially applied `createStorage` + */ +export const createStorage = /*#__PURE__*/ createStorageFactory() diff --git a/src/session/index.ts b/src/session/index.ts index 5402dbb..f4b2adb 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -3,9 +3,11 @@ import type { ConfigPersist as BaseConfigPersist, ConfigStore as BaseConfigStore, ConfigSourceTarget as BaseConfigSourceTarget, + ConfigCreateStorage as BaseConfigCreateStorage, StorageAdapter, + StorageHandles, } from '../types' -import { persist as base } from '../core' +import { persist as base, createStorage as baseCreateStorage } from '../core' import { nil } from '../nil' import { storage } from '../storage' @@ -46,6 +48,20 @@ export interface Persist { (config: ConfigStore): Subscription } +export interface ConfigCreateStorage + extends BaseConfigCreateStorage {} + +export interface CreateStorage { + ( + key: string, + config?: SessionStorageConfig & BaseConfigCreateStorage + ): StorageHandles + ( + config: SessionStorageConfig & + BaseConfigCreateStorage & { key: string } + ): StorageHandles +} + /** * Function, checking if `sessionStorage` exists */ @@ -89,3 +105,19 @@ export function createPersist(defaults?: ConfigPersist): Persist { * Default partially applied `persist` */ export const persist = /*#__PURE__*/ createPersist() + +/** + * Creates custom partially applied `createStorage` + * with predefined `sessionStorage` adapter + */ +export function createStorageFactory( + defaults?: ConfigCreateStorage +): CreateStorage { + return (...configs: any[]) => + baseCreateStorage({ adapter: session }, defaults, ...configs) +} + +/** + * Default partially applied `createStorage` + */ +export const createStorage = /*#__PURE__*/ createStorageFactory() diff --git a/src/storage/index.ts b/src/storage/index.ts index bdde271..6af70f3 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -101,6 +101,13 @@ export function storage({ schedule() } }, + + remove() { + console.log('🔴 remove', key) + postponed() // cancel postponed flush + storage().removeItem(key) + // update(null) // call update with null to clear the value + }, }) } diff --git a/src/types.ts b/src/types.ts index 90c95ec..06d7d96 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,10 @@ export interface Adapter { value: State, ctx?: any ): void | Promise + remove?( + this: void, // + ctx?: any + ): void | Promise } export interface DisposableAdapter extends Adapter { @@ -48,7 +52,7 @@ export type Done = { export type Fail = { key: string keyPrefix: string - operation: 'set' | 'get' + operation: 'set' | 'get' | 'validate' error: Err value?: any } @@ -119,3 +123,36 @@ export interface Persist { AdapterConfig ): Subscription } + +export interface StorageHandles { + getFx: Effect> + setFx: Effect> + removeFx: Effect> +} + +export interface ConfigCreateStorage { + context?: Unit + keyPrefix?: string + contract?: Contract +} + +export interface CreateStorage { + ( + key: string, + config: ConfigAdapterFactory & + ConfigCreateStorage & + AdapterConfig + ): StorageHandles + ( + config: ConfigAdapterFactory & + ConfigCreateStorage & + AdapterConfig & { key: string } + ): StorageHandles + ( + key: string, + config: ConfigAdapter & ConfigCreateStorage + ): StorageHandles + ( + config: ConfigAdapter & ConfigCreateStorage & { key: string } + ): StorageHandles +} diff --git a/tests/broadcast.test.ts b/tests/broadcast.test.ts index 9242007..ddf7440 100644 --- a/tests/broadcast.test.ts +++ b/tests/broadcast.test.ts @@ -3,7 +3,7 @@ import * as assert from 'node:assert/strict' import { BroadcastChannel, Worker } from 'node:worker_threads' import { createEffect, createStore, sample } from 'effector' import { createEventsMock } from './mocks/events.mock' -import { broadcast, persist } from '../src/broadcast' +import { broadcast, persist, createStorage } from '../src/broadcast' import { broadcast as broadcastIndex } from '../src' import { either } from '../src/tools' import { log } from '../src/log' @@ -78,6 +78,7 @@ after(() => { test('should export adapter and `persist` function', () => { assert.ok(typeof broadcast === 'function') assert.ok(typeof persist === 'function') + assert.ok(typeof createStorage === 'function') }) test('should be exported from package root', () => { @@ -87,6 +88,8 @@ test('should be exported from package root', () => { test('should be ok on good parameters', () => { const $store = createStore(0, { name: 'broadcast' }) assert.doesNotThrow(() => persist({ store: $store })) + assert.doesNotThrow(() => createStorage('broadcast')) + assert.doesNotThrow(() => createStorage({ key: 'broadcast' })) }) test('should post message to broadcast channel on updates', async () => { diff --git a/tests/context-create-storage.test.ts b/tests/context-create-storage.test.ts new file mode 100644 index 0000000..d87dc5b --- /dev/null +++ b/tests/context-create-storage.test.ts @@ -0,0 +1,188 @@ +import type { StorageAdapter } from '../src/types' +import { test, mock } from 'node:test' +import * as assert from 'node:assert/strict' +import { createStore, createEvent, fork, allSettled } from 'effector' +import { createStorage } from '../src' + +// +// Tests +// + +test('context store value should be passed to adapter', async () => { + const watch = mock.fn() + + const context = createEvent() + + const { getFx, setFx } = createStorage('test-context-1', { + adapter: () => ({ get: watch, set: watch }), + context: createStore(42).on(context, (_, ctx) => ctx), + }) + + getFx() + + assert.strictEqual(watch.mock.callCount(), 1) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined, 42]) + + setFx(54) + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[1].arguments, [54, 42]) + + // update context + context(72) + + getFx() + + assert.strictEqual(watch.mock.callCount(), 3) + assert.deepEqual(watch.mock.calls[2].arguments, [undefined, 72]) + + setFx(27) + + assert.strictEqual(watch.mock.callCount(), 4) + assert.deepEqual(watch.mock.calls[3].arguments, [27, 72]) +}) + +test('context event value should be passed to adapter', async () => { + const watch = mock.fn() + + const context = createEvent() + + const { getFx, setFx } = createStorage('test-context-2', { + adapter: () => ({ get: watch, set: watch }), + context, + }) + + getFx() + + assert.strictEqual(watch.mock.callCount(), 1) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined, undefined]) + + setFx(54) + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[1].arguments, [54, undefined]) + + // update context + context('new context') + + getFx() + + assert.strictEqual(watch.mock.callCount(), 3) + assert.deepEqual(watch.mock.calls[2].arguments, [undefined, 'new context']) + + setFx(27) + + assert.strictEqual(watch.mock.callCount(), 4) + assert.deepEqual(watch.mock.calls[3].arguments, [27, 'new context']) +}) + +test('contexts in different scopes should be different', async () => { + const watch = mock.fn() + + const context = createEvent<{ name: string }>() + + const { getFx, setFx } = createStorage('test-context-3', { + adapter: () => ({ get: watch, set: watch }), + context, + }) + + const scopeA = fork() + const scopeB = fork() + + await allSettled(context, { scope: scopeA, params: { name: 'scopeA' } }) + await allSettled(context, { scope: scopeB, params: { name: 'scopeB' } }) + + await allSettled(getFx, { scope: scopeA }) + await allSettled(getFx, { scope: scopeB }) + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [ + undefined, + { name: 'scopeA' }, + ]) + assert.deepEqual(watch.mock.calls[1].arguments, [ + undefined, + { name: 'scopeB' }, + ]) + + await allSettled(setFx, { scope: scopeA, params: 'A' }) + await allSettled(setFx, { scope: scopeB, params: 'B' }) + + assert.strictEqual(watch.mock.callCount(), 4) + assert.deepEqual(watch.mock.calls[2].arguments, ['A', { name: 'scopeA' }]) + assert.deepEqual(watch.mock.calls[3].arguments, ['B', { name: 'scopeB' }]) +}) + +test('context should change scope for async adapter', async () => { + const watch = mock.fn((value) => value) + + const updated = createEvent() + const context = createEvent() + + createStorage('test-context-4', { + context, + adapter: (_key, update) => { + updated.watch(update) + return { get: watch, set: watch } + }, + }) + + updated('out of scope') // <- imitate external storage update + + assert.strictEqual(watch.mock.callCount(), 1) + assert.deepEqual(watch.mock.calls[0].arguments, ['out of scope', undefined]) + + const scope = fork() + + // set context, which should bind given scope + await allSettled(context, { scope, params: 'in scope' }) + + updated('in scope') // <- pickup new value, within scope + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[1].arguments, ['in scope', 'in scope']) +}) + +test('contexts should update scope / also works with adapter factory', async () => { + const queue = new EventTarget() + + adapterFactory.factory = true as const + function adapterFactory() { + const adapter: StorageAdapter = ( + _key: string, + update: (raw?: any) => void + ) => { + let value = 1 as State + queue.addEventListener('update', () => update((value = 2 as State))) + return { + get: () => value, + set: (x: State) => void (value = x), + } + } + return adapter + } + + const context = createEvent() + + const { getFx } = createStorage('test-context-4', { + adapter: adapterFactory, + context, + contract: (raw: unknown): raw is number => typeof raw === 'number', + }) + + const $store = createStore(0).on(getFx.doneData, (_, data) => data) + + const scope = fork() + + queue.dispatchEvent(new Event('update')) + + assert.strictEqual($store.getState(), 2) // <- changed + assert.strictEqual(scope.getState($store), 0) // <- default value + + await allSettled(context, { scope }) + + queue.dispatchEvent(new Event('update')) + + assert.strictEqual($store.getState(), 2) + assert.strictEqual(scope.getState($store), 2) // <- changed in scope +}) diff --git a/tests/contract-create-storage.test.ts b/tests/contract-create-storage.test.ts new file mode 100644 index 0000000..7cd38bd --- /dev/null +++ b/tests/contract-create-storage.test.ts @@ -0,0 +1,469 @@ +import type { StorageAdapter } from '../src' +import { test, before, after, mock } from 'node:test' +import * as assert from 'node:assert/strict' +import { createStore, createEvent } from 'effector' +import * as s from 'superstruct' +import { superstructContract } from '@farfetched/superstruct' +import { createStorage, storage, persist } from '../src' +import { createStorageMock } from './mocks/storage.mock' +import { type Events, createEventsMock } from './mocks/events.mock' + +// +// Mock abstract Storage adapter +// + +declare let global: any + +const mockStorage = createStorageMock() +let storageAdapter: StorageAdapter +let events: Events + +before(() => { + events = createEventsMock() + global.addEventListener = events.addEventListener + storageAdapter = storage({ storage: () => mockStorage, sync: true }) +}) + +after(() => { + global.addEventListener = undefined +}) + +// +// Tests +// + +test('shoult validate storage value on get', () => { + const watch = mock.fn() + + mockStorage.setItem('number1', '42') + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'number1', + contract: (raw): raw is number => typeof raw === 'number', + }) + + getFx.watch(watch) + getFx.finally.watch(watch) + + getFx() + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: 42, + }, + ]) // getFx result + + assert.strictEqual(mockStorage.getItem('number1'), '42') +}) + +test('shoult fail on invalid initial storage value with simple contract', () => { + const watch = mock.fn() + + mockStorage.setItem('number2', '"invalid"') // valid JSON, but invalid number + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'number2', + contract: (raw): raw is number => typeof raw === 'number', + }) + + getFx.watch(watch) + getFx.finally.watch(watch) + + getFx() + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'fail', + params: undefined, + error: { + key: 'number2', + keyPrefix: '', + operation: 'validate', + error: ['Invalid data'], + value: 'invalid', + }, + }, + ]) // getFx result + + assert.strictEqual(mockStorage.getItem('number2'), '"invalid"') // didn't change +}) + +test('should handle sync effects with same key and different validators', () => { + const watchPlain = mock.fn() + const watchBase64 = mock.fn() + + const { getFx: getPlainFx, setFx: setPlainFx } = createStorage({ + adapter: storageAdapter, + key: 'contract-same-key-1', + contract: (raw): raw is string => typeof raw === 'string', + }) + const { getFx: getBase64Fx, setFx: setBase64Fx } = createStorage({ + adapter: storageAdapter, + key: 'contract-same-key-1', + contract: (raw): raw is string => + global.Buffer.from(raw, 'base64').toString('base64') === raw, + }) + + getPlainFx.watch(watchPlain) + setPlainFx.watch(watchPlain) + getPlainFx.finally.watch(watchPlain) + setPlainFx.finally.watch(watchPlain) + + getBase64Fx.watch(watchBase64) + setBase64Fx.watch(watchBase64) + getBase64Fx.finally.watch(watchBase64) + setBase64Fx.finally.watch(watchBase64) + + assert.strictEqual(watchPlain.mock.callCount(), 0) + assert.strictEqual(watchBase64.mock.callCount(), 0) + + setPlainFx('plain value') + assert.strictEqual( + mockStorage.getItem('contract-same-key-1'), + '"plain value"' + ) + + assert.strictEqual(watchPlain.mock.callCount(), 2) + assert.deepEqual(watchPlain.mock.calls[0].arguments, ['plain value']) // setPlainFx trigger + assert.deepEqual(watchPlain.mock.calls[1].arguments, [ + { + status: 'done', + params: 'plain value', + result: undefined, + }, + ]) // setPlainFx result + + assert.strictEqual(watchBase64.mock.callCount(), 2) + assert.deepEqual(watchBase64.mock.calls[0].arguments, [undefined]) // getBase64Fx trigger + assert.deepEqual(watchBase64.mock.calls[1].arguments, [ + { + status: 'fail', + params: undefined, + error: { + key: 'contract-same-key-1', + keyPrefix: '', + operation: 'validate', + error: ['Invalid data'], + value: 'plain value', + }, + }, + ]) // getBase64Fx result + + setBase64Fx('YmFzZTY0IHZhbHVl') + assert.strictEqual( + mockStorage.getItem('contract-same-key-1'), + '"YmFzZTY0IHZhbHVl"' + ) + + assert.strictEqual(watchBase64.mock.callCount(), 4) + assert.deepEqual(watchBase64.mock.calls[2].arguments, ['YmFzZTY0IHZhbHVl']) // setBase64Fx trigger + assert.deepEqual(watchBase64.mock.calls[3].arguments, [ + { + status: 'done', + params: 'YmFzZTY0IHZhbHVl', + result: undefined, + }, + ]) // setBase64Fx result + + assert.strictEqual(watchPlain.mock.callCount(), 4) + assert.deepEqual(watchPlain.mock.calls[2].arguments, [undefined]) // getPlainFx trigger + assert.deepEqual(watchPlain.mock.calls[3].arguments, [ + { + status: 'done', + params: undefined, + result: 'YmFzZTY0IHZhbHVl', // this is valid string + }, + ]) // getPlainFx result +}) + +test('should handle sync with `persist` with different validators, update from store', () => { + const watch = mock.fn() + const $string = createStore('') + + persist({ + store: $string, + adapter: storageAdapter, + key: 'contract-same-key-2', + }) + + const { getFx, setFx } = createStorage({ + adapter: storageAdapter, + key: 'contract-same-key-2', + contract: (raw): raw is string => + global.Buffer.from(raw, 'base64').toString('base64') === raw, + }) + + getFx.watch(watch) + setFx.watch(watch) + getFx.finally.watch(watch) + setFx.finally.watch(watch) + + // + ;($string as any).setState('plain value') + assert.strictEqual( + mockStorage.getItem('contract-same-key-2'), + '"plain value"' + ) + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'fail', + params: undefined, + error: { + key: 'contract-same-key-2', + keyPrefix: '', + operation: 'validate', + error: ['Invalid data'], + value: 'plain value', + }, + }, + ]) // getFx result +}) + +test('should handle sync with `persist` with different validators, update from storage', () => { + const watch = mock.fn() + const fail = createEvent() + fail.watch(watch) + + const $base64 = createStore('') + + persist({ + store: $base64, + adapter: storageAdapter, + key: 'contract-same-key-3', + contract: (raw): raw is string => + global.Buffer.from(raw, 'base64').toString('base64') === raw, + fail, + }) + + const { setFx } = createStorage({ + adapter: storageAdapter, + key: 'contract-same-key-3', + contract: (raw): raw is string => + global.Buffer.from(raw, 'base64').toString('base64') === raw, + }) + + setFx('plain value') + assert.strictEqual( + mockStorage.getItem('contract-same-key-3'), + '"plain value"' + ) + + assert.strictEqual($base64.getState(), '') // <- didn't change + + assert.strictEqual(watch.mock.callCount(), 1) + assert.deepEqual(watch.mock.calls[0].arguments, [ + { + key: 'contract-same-key-3', + keyPrefix: '', + operation: 'validate', + error: ['Invalid data'], + value: 'plain value', + }, + ]) +}) + +test('shoult validate storage value on get with complex contract (valid)', () => { + const watch = mock.fn() + + const Asteroid = s.type({ + type: s.literal('asteroid'), + mass: s.number(), + }) + + mockStorage.setItem('asteroid0', '{"type":"asteroid","mass":42}') + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'asteroid0', + contract: superstructContract(Asteroid), + }) + + getFx.watch(watch) + getFx.finally.watch(watch) + + getFx() + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: { type: 'asteroid', mass: 42 }, + }, + ]) // getFx result +}) + +test('shoult validate storage value on get with complex contract (valid undefined)', () => { + const watch = mock.fn() + + const Asteroid = s.optional( + s.type({ + type: s.literal('asteroid'), + mass: s.number(), + }) + ) + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'asteroid1', + contract: superstructContract(Asteroid), + }) + + getFx.watch(watch) + getFx.finally.watch(watch) + + getFx() + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: undefined, + }, + ]) // getFx result +}) + +test('shoult validate storage value on get with complex contract (invalid undefined)', () => { + const watch = mock.fn() + + const Asteroid = s.type({ + type: s.literal('asteroid'), + mass: s.number(), + }) + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'asteroid1', + contract: superstructContract(Asteroid), + }) + + getFx.watch(watch) + getFx.finally.watch(watch) + + getFx() + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'fail', + params: undefined, + error: { + key: 'asteroid1', + keyPrefix: '', + operation: 'validate', + error: ['Expected an object, but received: undefined'], + value: undefined, + }, + }, + ]) // getFx result +}) + +test('shoult validate storage value on get with complex contract (invalid)', () => { + const watch = mock.fn() + + const Asteroid = s.type({ + type: s.literal('asteroid'), + mass: s.number(), + }) + + mockStorage.setItem('asteroid2', '42') + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'asteroid2', + contract: superstructContract(Asteroid), + }) + + getFx.watch(watch) + getFx.finally.watch(watch) + + getFx() + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'fail', + params: undefined, + error: { + key: 'asteroid2', + keyPrefix: '', + operation: 'validate', + error: ['Expected an object, but received: 42'], + value: 42, + }, + }, + ]) // getFx result + + assert.strictEqual(mockStorage.getItem('asteroid2'), '42') +}) + +test('should validate value on storage external update', async () => { + const watch = mock.fn() + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'storage-contract-counter-1', + contract: superstructContract(s.number()), + }) + + getFx.watch(watch) + getFx.finally.watch(watch) + + mockStorage.setItem('storage-contract-counter-1', '1') + await events.dispatchEvent('storage', { + storageArea: mockStorage, + key: 'storage-contract-counter-1', + oldValue: null, + newValue: '1', + }) + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, ['1']) // getFx trigger with raw value + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'done', + params: '1', // raw value from adapter + result: 1, + }, + ]) // getFx result + + mockStorage.setItem('storage-contract-counter-1', '"invalid"') + await events.dispatchEvent('storage', { + storageArea: mockStorage, + key: 'storage-contract-counter-1', + oldValue: null, + newValue: '"invalid"', + }) + + assert.strictEqual(watch.mock.callCount(), 4) + assert.deepEqual(watch.mock.calls[2].arguments, ['"invalid"']) // getFx trigger with raw value + assert.deepEqual(watch.mock.calls[3].arguments, [ + { + status: 'fail', + params: '"invalid"', // raw value from adapter + error: { + key: 'storage-contract-counter-1', + keyPrefix: '', + operation: 'validate', + error: ['Expected a number, but received: "invalid"'], + value: 'invalid', + }, + }, + ]) // getFx result +}) diff --git a/tests/core-create-storage.test.ts b/tests/core-create-storage.test.ts new file mode 100644 index 0000000..fbcefe8 --- /dev/null +++ b/tests/core-create-storage.test.ts @@ -0,0 +1,569 @@ +import { test, mock } from 'node:test' +import * as assert from 'node:assert/strict' +import { createStore, createEvent } from 'effector' +import { + createStorage, + createStorageFactory, + memory, + persist, + async, +} from '../src' + +// memory adapter with separate storage area, to prevent concurrency issues between tests +const adapter = memory({ area: new Map() }) + +// +// Tests +// + +test('should exports effects', () => { + assert.ok(typeof createStorageFactory === 'function') + assert.ok(typeof createStorageFactory() === 'function') + assert.ok(typeof createStorage === 'function') + const ret = createStorage('test-key', { adapter }) + assert.ok(typeof ret === 'object') + assert.ok(typeof ret.getFx === 'function') + assert.ok(typeof ret.setFx === 'function') + assert.ok(typeof ret.removeFx === 'function') +}) + +test('should be ok on good parameters', () => { + assert.doesNotThrow(() => { + createStorage('test-1', { + adapter, + }) + }) + assert.doesNotThrow(() => { + createStorage({ + adapter, + key: 'test-2', + }) + }) + assert.doesNotThrow(() => { + createStorage('test-3', { + adapter, + keyPrefix: 'prefix-3', + }) + }) + assert.doesNotThrow(() => { + createStorage({ + adapter, + key: 'tets-4', + keyPrefix: 'prefix-3', + }) + }) + assert.doesNotThrow(() => { + createStorage('test-1', { + adapter, + context: createStore(0), + }) + }) + assert.doesNotThrow(() => { + createStorage('test-1', { + adapter, + contract: (x): x is number => typeof x === 'number', + }) + }) +}) + +test('should handle wrong parameters', () => { + assert.throws( + // @ts-expect-error test wrong parameters + () => createStorage(), + /Adapter is not defined/ + ) + assert.throws( + // @ts-expect-error test wrong parameters + () => createStorage({}), + /Adapter is not defined/ + ) + assert.throws( + // @ts-expect-error test wrong parameters + () => createStorage('key', {}), + /Adapter is not defined/ + ) + assert.throws( + // @ts-expect-error test wrong parameters + () => createStorage({ key: 'key' }), + /Adapter is not defined/ + ) + assert.throws( + // @ts-expect-error test wrong parameters + () => createStorage({ adapter }), + /Key is not defined/ + ) +}) + +test('should get and set value from storage', async () => { + const watch = mock.fn() + + const { getFx, setFx } = createStorage('test-get-set-1', { + adapter, + }) + + getFx.watch(watch) + setFx.watch(watch) + getFx.finally.watch(watch) + setFx.finally.watch(watch) + + assert.strictEqual(watch.mock.callCount(), 0) + + getFx() + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: undefined, + }, + ]) // getFx result + + setFx(1) + + assert.strictEqual(watch.mock.callCount(), 4) + assert.deepEqual(watch.mock.calls[2].arguments, [1]) // setFx trigger + assert.deepEqual(watch.mock.calls[3].arguments, [ + { + status: 'done', + params: 1, + result: undefined, + }, + ]) // setFx result + + assert.strictEqual(await getFx(), 1) +}) + +test('should remove value from storage', async () => { + const { setFx, getFx, removeFx } = createStorage('test-get-set-1', { + adapter, + }) + + await setFx(1) + + assert.strictEqual(await getFx(), 1) + + await removeFx() + + assert.strictEqual(await getFx(), undefined) +}) + +test('should get and set value from storage (with adapter factory)', async () => { + const watch = mock.fn() + + const area = new Map() + const { getFx, setFx } = createStorage('test-get-set-2', { + adapter: memory, + area, + }) + + getFx.watch(watch) + setFx.watch(watch) + getFx.finally.watch(watch) + setFx.finally.watch(watch) + + assert.strictEqual(watch.mock.callCount(), 0) + + getFx() + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: undefined, + }, + ]) // getFx result + + setFx(1) + + assert.strictEqual(watch.mock.callCount(), 4) + assert.deepEqual(watch.mock.calls[2].arguments, [1]) // setFx trigger + assert.deepEqual(watch.mock.calls[3].arguments, [ + { + status: 'done', + params: 1, + result: undefined, + }, + ]) // setFx result + + assert.strictEqual(await getFx(), 1) + assert.strictEqual(area.get('test-get-set-2'), 1) +}) + +test('should get and set value from async storage', async () => { + const watch = mock.fn() + + const { getFx, setFx } = createStorage('test-get-set-3', { + adapter: async(adapter), + }) + + getFx.watch(watch) + setFx.watch(watch) + getFx.finally.watch(watch) + setFx.finally.watch(watch) + + assert.strictEqual(watch.mock.callCount(), 0) + + await getFx() + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: undefined, + }, + ]) // getFx result + + await setFx(1) + + assert.strictEqual(watch.mock.callCount(), 4) + assert.deepEqual(watch.mock.calls[2].arguments, [1]) // setFx trigger + assert.deepEqual(watch.mock.calls[3].arguments, [ + { + status: 'done', + params: 1, + result: undefined, + }, + ]) // setFx result + + assert.strictEqual(await getFx(), 1) +}) + +test('should sync effects for the same adapter-key', () => { + const watchSet = mock.fn() + const watchGet = mock.fn() + + const { setFx } = createStorage({ + adapter, + key: 'test-sync-same-key-1', + }) + const { getFx } = createStorage({ + adapter, + key: 'test-sync-same-key-1', + }) + + getFx.watch(watchGet) + setFx.watch(watchSet) + getFx.finally.watch(watchGet) + setFx.finally.watch(watchSet) + + assert.strictEqual(watchSet.mock.callCount(), 0) + assert.strictEqual(watchGet.mock.callCount(), 0) + + setFx(1) + + assert.strictEqual(watchSet.mock.callCount(), 2) + assert.strictEqual(watchGet.mock.callCount(), 2) + assert.deepEqual(watchSet.mock.calls[0].arguments, [1]) // setFx trigger + assert.deepEqual(watchSet.mock.calls[1].arguments, [ + { + status: 'done', + params: 1, + result: undefined, + }, + ]) // setFx result + assert.deepEqual(watchGet.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watchGet.mock.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: 1, + }, + ]) // getFx result +}) + +test('should sync with `persist` for the same adapter-key', async () => { + const watch = mock.fn() + const watchFx = mock.fn() + + const $store = createStore(11) + $store.watch(watch) + + assert.strictEqual($store.getState(), 11) + assert.strictEqual(watch.mock.callCount(), 1) + assert.deepEqual(watch.mock.calls[0].arguments, [11]) + + persist({ + store: $store, + adapter, + key: 'test-sync-same-key-2', + }) + + assert.strictEqual($store.getState(), 11) // did not change + assert.strictEqual(watch.mock.callCount(), 1) // did not trigger + + const { getFx, setFx } = createStorage({ + adapter, + key: 'test-sync-same-key-2', + }) + + getFx.watch(watchFx) + setFx.watch(watchFx) + getFx.finally.watch(watchFx) + setFx.finally.watch(watchFx) + + assert.strictEqual(watchFx.mock.callCount(), 0) // did not trigger + + setFx(22) + + assert.strictEqual(watchFx.mock.callCount(), 2) + assert.deepEqual(watchFx.mock.calls[0].arguments, [22]) // setFx trigger + assert.deepEqual(watchFx.mock.calls[1].arguments, [ + { + status: 'done', + params: 22, + result: undefined, + }, + ]) // setFx result + + assert.strictEqual($store.getState(), 22) // <- changed + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[1].arguments, [22]) + + // + ;($store as any).setState(33) + + assert.strictEqual($store.getState(), 33) + assert.strictEqual(watch.mock.callCount(), 3) + assert.deepEqual(watch.mock.calls[2].arguments, [33]) + + assert.strictEqual(watchFx.mock.callCount(), 4) + assert.deepEqual(watchFx.mock.calls[2].arguments, [undefined]) // getFx trigger + assert.deepEqual(watchFx.mock.calls[3].arguments, [ + { + status: 'done', + params: undefined, + result: 33, + }, + ]) // getFx result + + assert.strictEqual(await getFx(), 33) +}) + +test('should sync with `persist` for the same adapter-key when removing value', async () => { + const watch = mock.fn() + const watchFx = mock.fn() + + const storageArea = new Map() + const adapter = memory({ area: storageArea, def: 0 }) + storageArea.set('test-sync-same-key-3', -123) + + const $store = createStore(0) + $store.watch(watch) + + assert.strictEqual($store.getState(), 0) + assert.strictEqual(watch.mock.callCount(), 1) + assert.deepEqual(watch.mock.calls[0].arguments, [0]) + + persist({ + store: $store, + adapter, + key: 'test-sync-same-key-3', + }) + + assert.strictEqual($store.getState(), -123) // got from storage + assert.strictEqual(watch.mock.callCount(), 2) // store got updated from storage + + const { removeFx } = createStorage({ + adapter, + key: 'test-sync-same-key-3', + }) + + removeFx.watch(watchFx) + removeFx.finally.watch(watchFx) + + assert.strictEqual(watchFx.mock.callCount(), 0) // did not trigger + + removeFx() + + assert.strictEqual(watchFx.mock.callCount(), 2) + assert.deepEqual(watchFx.mock.calls[0].arguments, [undefined]) // removeFx trigger + assert.deepEqual(watchFx.mock.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: undefined, + }, + ]) // removeFx result + + assert.strictEqual($store.getState(), 0) // <- changed to default state + assert.strictEqual(watch.mock.callCount(), 3) + assert.deepEqual(watch.mock.calls[2].arguments, [0]) +}) + +test('should handle synchronous error in `get` and `set` effects', () => { + const watch = mock.fn() + + const { getFx, setFx } = createStorage('test-sync-throw', { + adapter: () => ({ + get: () => { + throw 'get test error' + }, + set: () => { + throw 'set test error' + }, + }), + }) + + getFx.watch(watch) + setFx.watch(watch) + getFx.finally.watch(watch) + setFx.finally.watch(watch) + + assert.strictEqual(watch.mock.callCount(), 0) + + getFx() + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'fail', + params: undefined, + error: { + key: 'test-sync-throw', + keyPrefix: '', + operation: 'get', + error: 'get test error', + value: undefined, + }, + }, + ]) // getFx fail + + setFx(1) + + assert.strictEqual(watch.mock.callCount(), 4) + assert.deepEqual(watch.mock.calls[2].arguments, [1]) // setFx trigger + assert.deepEqual(watch.mock.calls[3].arguments, [ + { + status: 'fail', + params: 1, + error: { + key: 'test-sync-throw', + keyPrefix: '', + operation: 'set', + error: 'set test error', + value: 1, + }, + }, + ]) // setFx fail +}) + +test('should handle asynchronous error in `get` and `set` effects', async () => { + const watch = mock.fn() + + const { getFx, setFx } = createStorage('test-async-throw', { + adapter: () => ({ + get: async () => Promise.reject('get test error'), + set: async () => Promise.reject('set test error'), + }), + }) + + getFx.watch(watch) + setFx.watch(watch) + getFx.finally.watch(watch) + setFx.finally.watch(watch) + + assert.strictEqual(watch.mock.callCount(), 0) + + try { + await getFx() + assert.fail('getFx should have thrown') + } catch (e) { + // ok + } + + assert.strictEqual(watch.mock.callCount(), 2) + assert.deepEqual(watch.mock.calls[0].arguments, [undefined]) // getFx trigger + assert.deepEqual(watch.mock.calls[1].arguments, [ + { + status: 'fail', + params: undefined, + error: { + key: 'test-async-throw', + keyPrefix: '', + operation: 'get', + error: 'get test error', + value: undefined, + }, + }, + ]) // getFx fail + + try { + await setFx(1) + assert.fail('setFx should have thrown') + } catch (e) { + // ok + } + + assert.strictEqual(watch.mock.callCount(), 4) + assert.deepEqual(watch.mock.calls[2].arguments, [1]) // setFx trigger + assert.deepEqual(watch.mock.calls[3].arguments, [ + { + status: 'fail', + params: 1, + error: { + key: 'test-async-throw', + keyPrefix: '', + operation: 'set', + error: 'set test error', + value: 1, + }, + }, + ]) // setFx fail +}) + +test('should hide internal implementation with `get` effect', () => { + const watch = mock.fn() + const fail = createEvent() + + const { getFx, setFx } = createStorage('test-throw-box', { + adapter: (_, update) => { + fail.watch(() => { + update(() => { + throw 'get box test error' + }) + }) + + return { + get: (box?: () => any) => { + if (box) return box() + }, + set: () => {}, + } + }, + }) + + getFx.watch(watch) + setFx.watch(watch) + getFx.finally.watch(watch) + setFx.finally.watch(watch) + + assert.strictEqual(watch.mock.callCount(), 0) + + fail() + + assert.strictEqual(watch.mock.callCount(), 2) + + const arg1 = watch.mock.calls[0].arguments[0] + assert.ok(arg1 instanceof Function) // getFx trigger - "box"ed error, don't know how to hide it here + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { params, ...arg2 } = watch.mock.calls[1].arguments[0] + assert.deepEqual(arg2, { + status: 'fail', + // params: Function, // "box"ed error... + error: { + key: 'test-throw-box', + keyPrefix: '', + operation: 'get', + error: 'get box test error', + value: undefined, + }, + }) // getFx fail +}) diff --git a/tests/index.types.ts b/tests/index.types.ts index b36eeb6..b6adbed 100644 --- a/tests/index.types.ts +++ b/tests/index.types.ts @@ -34,6 +34,24 @@ test('General `persist` should handle wrong arguments', async () => { persist({ adapter: fakeAdapter, target: store }) }) +test('General `createStorage` should handle wrong arguments', async () => { + const { createStorage } = await import('../src') + + const fakeAdapter: StorageAdapter = 0 as any + + // @ts-expect-error missing arguments + createStorage() + + // @ts-expect-error missing adapter + createStorage('key') + + // @ts-expect-error missing adapter + createStrorage({ key: 'key' }) + + // @ts-expect-error missing key + createStorage({ adapter: fakeAdapter }) +}) + test('General `persist` should return Subscription', async () => { const { persist } = await import('../src') diff --git a/tests/local-create-storage.test.ts b/tests/local-create-storage.test.ts new file mode 100644 index 0000000..4fd58a0 --- /dev/null +++ b/tests/local-create-storage.test.ts @@ -0,0 +1,106 @@ +import { test, before, after, mock } from 'node:test' +import * as assert from 'node:assert/strict' +import { createEffect, createStore } from 'effector' +import { createStorageMock } from './mocks/storage.mock' +import { type Events, createEventsMock } from './mocks/events.mock' +import { persist, createStorage } from '../src/local' + +// +// Mock `localStorage` and events +// + +declare let global: any +let events: Events + +before(() => { + global.localStorage = createStorageMock() + events = createEventsMock() + global.addEventListener = events.addEventListener +}) + +after(() => { + global.localStorage = undefined + global.addEventListener = undefined +}) + +// +// Tests +// + +test('should get and set values in localStorage', async () => { + const storage = createStorage('test-key-1') + + assert.strictEqual(global.localStorage.getItem('test-key-1'), null) + assert.strictEqual(await storage.getFx(), undefined) + + await storage.setFx(1) + + assert.strictEqual(global.localStorage.getItem('test-key-1'), '1') + assert.strictEqual(await storage.getFx(), 1) +}) + +test('should be in sync with persisted store', async () => { + const storage = createStorage('test-key-2') + + const $value = createStore(0) + persist({ store: $value, key: 'test-key-2' }) + + // this is expected, because store initial value is not written to localStorage + assert.strictEqual(await storage.getFx(), undefined) + + // set value to localStorage using createStorage effect + await storage.setFx(1) + + // storage and persisted store should be updated + assert.strictEqual(global.localStorage.getItem('test-key-2'), '1') + assert.strictEqual($value.getState(), 1) + + const watch = mock.fn() + storage.getFx.doneData.watch(watch) + + // set value to persisted store + ;($value as any).setState(2) + assert.strictEqual(global.localStorage.getItem('test-key-2'), '2') + + // createStorage effect should be called once + assert.strictEqual(watch.mock.callCount(), 1) + assert.deepEqual(watch.mock.calls[0].arguments, [2]) +}) + +test('createStorage get effect should be called on storage event', async () => { + const storage = createStorage('test-key-3') + + const watch = mock.fn() + storage.getFx.doneData.watch(watch) + + global.localStorage.setItem('test-key-3', '3') + await events.dispatchEvent('storage', { + storageArea: global.localStorage, + key: 'test-key-3', + oldValue: null, + newValue: '3', + }) + + // createStorage effect should be called once + assert.strictEqual(watch.mock.callCount(), 1) + assert.deepEqual(watch.mock.calls[0].arguments, [3]) +}) + +test.only('persisted store should be restored to initial value on delete effect', async () => { + const storage = createStorage('test-key-4') + const $value = createStore(0) + persist({ + store: $value, + key: 'test-key-4', + // def: 0, + finally: createEffect((_: any) => console.log('🔵', _)), + }) + assert.strictEqual(global.localStorage.getItem('test-key-4'), null) + assert.strictEqual($value.getState(), 0) + await storage.setFx(1) + assert.strictEqual(global.localStorage.getItem('test-key-4'), '1') + assert.strictEqual($value.getState(), 1) + await storage.removeFx() + assert.strictEqual(global.localStorage.getItem('test-key-4'), null) + assert.strictEqual($value.getState(), 0) +}) diff --git a/tests/local.test.ts b/tests/local.test.ts index 0528923..d058cc6 100644 --- a/tests/local.test.ts +++ b/tests/local.test.ts @@ -3,7 +3,7 @@ import * as assert from 'node:assert/strict' import { createEvent, createStore } from 'effector' import { createStorageMock } from './mocks/storage.mock' import { type Events, createEventsMock } from './mocks/events.mock' -import { local, persist } from '../src/local' +import { local, persist, createStorage } from '../src/local' import { local as localIndex } from '../src' // @@ -31,6 +31,7 @@ after(() => { test('should export adapter and `persist` function', () => { assert.ok(typeof local === 'function') assert.ok(typeof persist === 'function') + assert.ok(typeof createStorage === 'function') }) test('should be exported from package root', () => { @@ -40,6 +41,8 @@ test('should be exported from package root', () => { test('should be ok on good parameters', () => { const $store = createStore(0, { name: 'local::store' }) assert.doesNotThrow(() => persist({ store: $store })) + assert.doesNotThrow(() => createStorage('local::store')) + assert.doesNotThrow(() => createStorage({ key: 'local::store' })) }) test('persisted store should reset value on init to default', async () => { diff --git a/tests/memory.test.ts b/tests/memory.test.ts index 31e496a..ea633f5 100644 --- a/tests/memory.test.ts +++ b/tests/memory.test.ts @@ -1,7 +1,7 @@ import { test, mock } from 'node:test' import * as assert from 'node:assert/strict' import { createStore } from 'effector' -import { memory, persist } from '../src/memory' +import { memory, persist, createStorage } from '../src/memory' import { memory as memoryIndex } from '../src' // @@ -11,6 +11,7 @@ import { memory as memoryIndex } from '../src' test('should export adapter and `persist` function', () => { assert.ok(typeof memory === 'function') assert.ok(typeof persist === 'function') + assert.ok(typeof createStorage === 'function') }) test('should be exported from package root', () => { @@ -20,6 +21,8 @@ test('should be exported from package root', () => { test('should be ok on good parameters', () => { const $store = createStore(0, { name: 'memory::store' }) assert.doesNotThrow(() => persist({ store: $store })) + assert.doesNotThrow(() => createStorage('memory::store')) + assert.doesNotThrow(() => createStorage({ key: 'memory::store' })) }) test('should sync stores, persisted with memory adapter', () => { diff --git a/tests/query.test.ts b/tests/query.test.ts index 91a17df..525b539 100644 --- a/tests/query.test.ts +++ b/tests/query.test.ts @@ -7,6 +7,7 @@ import { type Events, createEventsMock } from './mocks/events.mock' import { persist, query, + createStorage, pushState, replaceState, locationAssign, @@ -53,6 +54,7 @@ after(() => { test('should export adapter and `persist` function', () => { assert.ok(typeof query === 'function') assert.ok(typeof persist === 'function') + assert.ok(typeof createStorage === 'function') assert.ok(typeof pushState === 'function') assert.ok(typeof replaceState === 'function') assert.ok(typeof locationAssign === 'function') @@ -66,6 +68,8 @@ test('should be exported from package root', () => { test('should be ok on good parameters', () => { const $store = createStore('0', { name: 'query::store' }) assert.doesNotThrow(() => persist({ store: $store })) + assert.doesNotThrow(() => createStorage('query::store')) + assert.doesNotThrow(() => createStorage({ key: 'query::store' })) }) test('store initial value should NOT be put in query string', () => { diff --git a/tests/session.test.ts b/tests/session.test.ts index 21737fd..a382390 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -3,7 +3,7 @@ import * as assert from 'node:assert/strict' import { createEvent, createStore } from 'effector' import { createStorageMock } from './mocks/storage.mock' import { type Events, createEventsMock } from './mocks/events.mock' -import { session, persist } from '../src/session' +import { session, persist, createStorage } from '../src/session' import { session as sessionIndex } from '../src' // @@ -31,6 +31,7 @@ after(() => { test('should export adapter and `persist` function', () => { assert.ok(typeof session === 'function') assert.ok(typeof persist === 'function') + assert.ok(typeof createStorage === 'function') }) test('should be exported from package root', () => { @@ -40,6 +41,8 @@ test('should be exported from package root', () => { test('should be ok on good parameters', () => { const $store = createStore(0, { name: 'session::store' }) assert.doesNotThrow(() => persist({ store: $store })) + assert.doesNotThrow(() => createStorage('session::store')) + assert.doesNotThrow(() => createStorage({ key: 'session::store' })) }) test('persisted store should reset value on init to default', async () => {