diff --git a/.size-limit.js b/.size-limit.js index 7d6b255..a3b5a7f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -3,7 +3,7 @@ module.exports = [ { name: 'root persist, es module', path: 'build/index.js', - limit: '985 B', + limit: '989 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -11,7 +11,7 @@ module.exports = [ { name: 'root persist, cjs module', path: 'build/index.cjs', - limit: '3250 B', + limit: '3869 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: '980 B', + limit: '984 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -29,7 +29,7 @@ module.exports = [ { name: 'core persist, cjs module', path: 'build/core/index.cjs', - limit: '1166 B', + limit: '1643 B', // import: '{ persist }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -39,7 +39,7 @@ module.exports = [ { name: 'tools, es module', path: 'build/tools/index.js', - limit: '289 B', + limit: '292 B', import: '{ async, either, farcached }', ignore: ['effector'], gzip: true, @@ -47,7 +47,7 @@ module.exports = [ { name: 'tools, cjs module', path: 'build/tools/index.cjs', - limit: '482 B', + limit: '485 B', // import: '{ async, either, farcached }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -65,7 +65,7 @@ module.exports = [ { name: 'nil adapter, cjs module', path: 'build/nil/index.cjs', - limit: '139 B', + limit: '140 B', // import: '{ nil }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -75,7 +75,7 @@ module.exports = [ { name: 'log adapter, es module', path: 'build/log/index.js', - limit: '139 B', + limit: '140 B', import: '{ log }', ignore: ['effector'], gzip: true, @@ -93,7 +93,7 @@ module.exports = [ { name: 'storage adapter, es module', path: 'build/storage/index.js', - limit: '399 B', + limit: '401 B', import: '{ storage }', ignore: ['effector'], gzip: true, @@ -111,7 +111,7 @@ module.exports = [ { name: '`localStorage` persist, es module', path: 'build/local/index.js', - limit: '1453 B', + limit: '1464 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -119,7 +119,7 @@ module.exports = [ { name: '`localStorage` persist, cjs module', path: 'build/local/index.cjs', - limit: '1732 B', + limit: '2238 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: '1459 B', + limit: '1444 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: '1462 B', + limit: '1446 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: '1451 B', + limit: '1463 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -159,7 +159,7 @@ module.exports = [ { name: '`sessionStorage` persist, cjs module', path: 'build/session/index.cjs', - limit: '1729 B', + limit: '2234 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: '1509 B', + limit: '1514 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: '1802 B', + limit: '2295 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: '1073 B', + limit: '1078 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -195,7 +195,7 @@ module.exports = [ { name: 'memory adapter, cjs module', path: 'build/memory/index.cjs', - limit: '1304 B', + limit: '1816 B', // import: '{ persist }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -205,7 +205,7 @@ module.exports = [ { name: 'generic async storage adapter, es module', path: 'build/async-storage/index.js', - limit: '162 B', + limit: '165 B', import: '{ asyncStorage }', ignore: ['effector'], gzip: true, @@ -223,7 +223,7 @@ module.exports = [ { name: 'broadcast channel adapter, es module', path: 'build/broadcast/index.js', - limit: '1317 B', + limit: '371 B', import: '{ broadcast }', ignore: ['effector'], gzip: true, @@ -231,7 +231,7 @@ module.exports = [ { name: 'broadcast channel adapter, cjs module', path: 'build/broadcast/index.cjs', - limit: '1568 B', + limit: '2074 B', // import: '{ broadcast }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, diff --git a/README.md b/README.md index 23df96f..5211d87 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. @@ -300,8 +351,8 @@ interface StorageAdapter { key: string, update: (raw?: any) => void ): { - get(raw?: any, ctx?: any): State | Promise | undefined - set(value: State, ctx?: any): void + get(raw?: any, ctx?: any): State | undefined | Promise + set(value: State, ctx?: any): void | Promise } keyArea?: any noop?: boolean diff --git a/eslint.config.mjs b/eslint.config.mjs index 3a29f8d..1a4f280 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,10 +26,7 @@ export default tseslint.config( '@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/unified-signatures': 'off', '@typescript-eslint/consistent-type-definitions': 'off', - '@typescript-eslint/no-invalid-void-type': [ - 'error', - { allowAsThisParameter: true }, - ], + '@typescript-eslint/no-invalid-void-type': 'off', '@typescript-eslint/explicit-module-boundary-types': [ 'error', { allowArgumentsExplicitlyTypedAsAny: true }, diff --git a/rollup.config.mjs b/rollup.config.mjs index da55d6a..a845451 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -71,6 +71,7 @@ const src = (name) => ({ }, format: { comments: false, + preserve_annotations: true, }, }), diff --git a/src/broadcast/index.ts b/src/broadcast/index.ts index 47dc9d7..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 */ @@ -45,7 +60,7 @@ function supports() { } /** - * Creates BroadcastChannel string adapter + * Creates BroadcastChannel adapter */ broadcast.factory = true as const export function broadcast(config?: BroadcastConfig): StorageAdapter { @@ -72,4 +87,20 @@ export function createPersist(defaults?: ConfigPersist): Persist { /** * Default partially applied `persist` */ -export const persist = createPersist() +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/area.ts b/src/core/area.ts index a3f0e43..8fb650a 100644 --- a/src/core/area.ts +++ b/src/core/area.ts @@ -1,15 +1,18 @@ -import type { Store } from 'effector' +import type { StoreWritable } from 'effector' import { createStore } from 'effector' /** * Keys areas / namespaces cache */ -const areas = new Map>>() +const areas = new Map>>() /** * Get store, responsible for the key in key area / namespace */ -export function getAreaStorage(keyArea: any, key: string): Store { +export function getAreaStorage( + keyArea: any, + key: string +): StoreWritable { let area = areas.get(keyArea) if (area === undefined) { area = new Map() diff --git a/src/core/create-storage.ts b/src/core/create-storage.ts new file mode 100644 index 0000000..cc0f5db --- /dev/null +++ b/src/core/create-storage.ts @@ -0,0 +1,181 @@ +import type { Event, Effect } from 'effector' +import type { + ConfigAdapter, + ConfigAdapterFactory, + StorageHandles, + ConfigCreateStorage, + Contract, + Fail, +} from '../types' +import { + attach, + // clearNode, + // createNode, + createStore, + is, + sample, + scopeBind, +} from 'effector' +import { getAreaStorage } from './area' + +// helper function to validate data with contract +function validate(raw: unknown, contract?: Contract) { + if ( + !contract || // no contract -> data is valid + ('isData' in contract ? contract.isData(raw) : contract(raw)) + ) return raw as T // prettier-ignore + throw (contract as any).getErrorMessages?.(raw) ?? ['Invalid data'] +} + +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' | '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' | '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?: void) { + 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({ + mapParams: () => undefined as any, + effect: setFx, + }) as Effect> + + 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, + target: storage, + }) + + sample({ + clock: storage, + 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 8522c6d..0b68565 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,265 +1,2 @@ -import type { Effect, Subscription } from 'effector' -import type { - ConfigAdapter, - ConfigAdapterFactory, - ConfigPersist, - ConfigSourceTarget, - ConfigStore, - Contract, - Done, - Fail, - Finally, -} from '../types' -import { - attach, - clearNode, - createEvent, - createEffect, - createNode, - createStore, - is, - sample, - scopeBind, - withRegion, -} from 'effector' -import { getAreaStorage } from './area' - -// helper function to swap two function arguments -// end extract current context from ref-box -const contextual = - (fn: (value: T, ctx?: C) => R) => - ([ref]: [C?], value: T) => - fn(value, ref) - -// helper function to validate data with contract -const contracted = - (contract?: Contract) => - (raw: unknown) => - !contract || // no contract -> data is valid - raw === undefined || // `undefined` is always valid - ('isData' in contract ? contract.isData(raw) : contract(raw)) - ? (raw as T) - : (() => { - throw 'getErrorMessages' in contract - ? contract.getErrorMessages(raw) - : undefined - })() - -/** - * Default sink for unhandled errors - */ -const sink = createEvent>() -sink.watch((payload) => console.error(payload.error)) - -/** - * Main `persist` function - */ -export function persist( - config: Partial< - (ConfigAdapter | ConfigAdapterFactory) & - ConfigPersist & - ConfigStore & - ConfigSourceTarget - > -): Subscription { - const { - adapter: adapterOrFactory, - store, - source = store, - target = store, - clock = source, - done, - fail = sink, - finally: anyway, - pickup, - context, - key: keyName, - keyPrefix = '', - contract, - } = config - - if (!adapterOrFactory) { - throw Error('Adapter is not defined') - } - if (!source) { - throw Error('Store or source is not defined') - } - if (!target) { - throw Error('Target is not defined') - } - if (!keyName && source.shortName === (source as any).id) { - throw Error('Key or name is not defined') - } - if (source === target && !is.store(source)) { - throw Error('Source must be different from target') - } - - // get default value from store, if given - // this is used in adapter factory - if ((config as any).def === undefined && is.store(source)) { - ;(config as any).def = source.defaultState - } - - const adapter = - 'factory' in adapterOrFactory ? adapterOrFactory(config) : adapterOrFactory - - const key = keyName || source.shortName - const storage = getAreaStorage( - adapter.keyArea || adapter, - keyPrefix + key - ) - const region = createNode() - let disposable: (_: any) => void = () => {} - const desist = () => disposable(clearNode(region)) - - const op = - (operation: 'get' | 'set' | 'validate') => - ({ status = 'fail', params, result, error }: any): any => - status === 'done' - ? { - status, - key, - keyPrefix, - operation, - value: operation === 'get' ? result : params, - } - : { - status, - key, - keyPrefix, - operation, - value: typeof params === 'function' ? undefined : params, // hide internal "box" implementation - error, - } - - // create all auxiliary units and nodes within the region, - // to be able to remove them all at once on unsubscription - withRegion(region, () => { - const ctx = createStore<[any?]>([], { serialize: 'ignore' }) - - const value = adapter(keyPrefix + key, (x) => { - update(x) - }) - - if (typeof value === 'function') { - disposable = value - } - - const getFx = attach({ - source: ctx, - effect: contextual(value.get), - }) as any as Effect - - const setFx = attach({ - source: ctx, - effect: contextual(value.set), - }) as any as Effect - - const validateFx = createEffect(contracted(contract)) - - const complete = createEvent>() - - const trigger = createEvent() - - let update: (raw?: any) => any = getFx - ctx.updates.watch(() => { - update = scopeBind(getFx as any, { safe: true }) - }) - - sample({ - clock, // `clock` is always defined, as long as `source` is defined - source, - target: trigger, - } as any) - - sample({ - clock: trigger, - source: storage, - filter: (current, proposed) => proposed !== current, - fn: (_, proposed) => proposed, - target: setFx, - }) - - sample({ - clock: [getFx.doneData, sample(setFx, setFx.done)], - filter: (x?: T | undefined): x is T => x !== undefined, - target: storage as any, - }) - - sample({ - clock: [getFx.doneData, storage], - target: validateFx as any, - }) - - sample({ - clock: validateFx.doneData, - filter: (x?: T | undefined): x is T => x !== undefined, - target: target as any, - }) - - sample({ - clock: [ - getFx.finally.map(op('get')), - setFx.finally.map(op('set')), - validateFx.fail.map(op('validate')), - ], - target: complete, - }) - - // effector 23 introduced "targetable" types - UnitTargetable, StoreWritable, EventCallable - // so, targeting non-targetable unit is not allowed anymore. - // soothe typescript by casting to any for a while, until we drop support for effector 22 branch - if (anyway) { - sample({ - clock: complete, - target: anyway as any, - }) - } - - if (done) { - sample({ - clock: complete, - filter: ({ status }) => status === 'done', - fn: ({ key, keyPrefix, operation, value }): Done => ({ - key, - keyPrefix, - operation, - value, - }), - target: done as any, - }) - } - - sample({ - clock: complete, - filter: ({ status }) => status === 'fail', - fn: ({ key, keyPrefix, operation, error, value }: any): Fail => ({ - key, - keyPrefix, - operation, - error, - value, - }), - target: fail as any, - }) - - if (context) { - ctx.on(context, ([ref], payload) => [ - payload === undefined ? ref : payload, - ]) - } - - if (pickup) { - // pick up value from storage ONLY on `pickup` update - sample({ clock: pickup, fn: () => undefined, target: getFx }) - ctx.on(pickup, ([ref], payload) => [ - payload === undefined ? ref : payload, - ]) - } else { - // kick getter to pick up initial value from storage - getFx() - } - }) - - return (desist.unsubscribe = desist) -} +export { persist } from './persist' +export { createStorage } from './create-storage' diff --git a/src/core/persist.ts b/src/core/persist.ts new file mode 100644 index 0000000..c67e6a5 --- /dev/null +++ b/src/core/persist.ts @@ -0,0 +1,257 @@ +import type { Event, Effect, Subscription } from 'effector' +import type { + ConfigAdapter, + ConfigAdapterFactory, + ConfigPersist, + ConfigSourceTarget, + ConfigStore, + Done, + Fail, + Finally, + FinallyDone, + FinallyFail, +} from '../types' +import { + attach, + clearNode, + createEvent, + createEffect, + createNode, + createStore, + is, + sample, + scopeBind, + withRegion, +} from 'effector' +import { getAreaStorage } from './area' + +/** + * Default sink for unhandled errors + */ +const sink = createEvent>() +sink.watch((payload) => console.error(payload.error)) + +/** + * Main `persist` function + */ +export function persist( + config: Partial< + (ConfigAdapter | ConfigAdapterFactory) & + ConfigPersist & + ConfigStore & + ConfigSourceTarget + > +): Subscription { + const { + adapter: adapterOrFactory, + store, + source = store, + target = store, + clock = source, + done, + fail = sink, + finally: anyway, + pickup, + context, + key: keyName, + keyPrefix = '', + contract, + } = config + + if (!adapterOrFactory) { + throw Error('Adapter is not defined') + } + if (!source) { + throw Error('Store or source is not defined') + } + if (!target) { + throw Error('Target is not defined') + } + if (!keyName && source.shortName === (source as any).id) { + throw Error('Key or name is not defined') + } + if (source === target && !is.store(source)) { + throw Error('Source must be different from target') + } + + // get default value from store, if given + // this is used in adapter factory + if ((config as any).def === undefined && is.store(source)) { + ;(config as any).def = source.defaultState + } + + const adapter = + 'factory' in adapterOrFactory ? adapterOrFactory(config) : adapterOrFactory + + const key = keyName || source.shortName + const storage = getAreaStorage( + adapter.keyArea || adapter, + keyPrefix + key + ) + const region = createNode() + let disposable: (_: any) => void = () => {} + const desist = () => disposable(clearNode(region)) + + const op = + (operation: 'get' | 'set' | 'validate') => + ({ status = 'fail', params, result, error }: any): any => + status === 'done' + ? { + status, + key, + keyPrefix, + operation, + value: operation === 'get' ? result : params, + } + : { + status, + key, + keyPrefix, + operation, + value: typeof params === 'function' ? undefined : params, // hide internal "box" implementation + error, + } + + // create all auxiliary units and nodes within the region, + // to be able to remove them all at once on unsubscription + withRegion(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 getFx = attach({ + source: ctx, + effect: ([ref], raw?: any) => value.get(raw, ref), + }) as Effect + + const setFx = attach({ + source: ctx, + effect: ([ref], state: State) => value.set(state, ref), + }) as Effect + + const validateFx = createEffect((raw) => { + if ( + !contract || // no contract -> data is valid + raw === undefined || // `undefined` is always valid + ('isData' in contract ? contract.isData(raw) : contract(raw)) + ) return raw as State // prettier-ignore + throw (contract as any).getErrorMessages?.(raw) ?? ['Invalid data'] + }) + + const complete = createEvent>() + + const trigger = createEvent() + + let update: (raw?: any) => any = getFx + ctx.updates.watch(() => { + update = scopeBind(getFx, { safe: true }) + }) + + sample({ + clock, // `clock` is always defined, as long as `source` is defined + source, + target: trigger, + } as any) + + sample({ + clock: trigger, + source: storage, + filter: (current, proposed) => proposed !== current, + fn: (_, proposed) => proposed, + target: setFx, + }) + + sample({ + clock: [getFx.doneData as Event, sample(setFx, setFx.done)], + filter: (x) => x !== undefined, + target: storage, + }) + + sample({ + clock: [getFx.doneData as Event, storage], + target: validateFx, + }) + + sample({ + clock: validateFx.doneData, + filter: (x) => x !== undefined, + target: target as any, + }) + + sample({ + clock: [ + getFx.finally.map(op('get')), + setFx.finally.map(op('set')), + validateFx.fail.map(op('validate')), + ], + target: complete, + }) + + // effector 23 introduced "targetable" types - UnitTargetable, StoreWritable, EventCallable + // so, targeting non-targetable unit is not allowed anymore. + // soothe typescript by casting to any for a while, until we drop support for effector 22 branch + if (anyway) { + sample({ + clock: complete, + target: anyway as any, + }) + } + + if (done) { + sample({ + clock: complete, + filter: (payload: Finally): payload is FinallyDone => + payload.status === 'done', + fn: ({ key, keyPrefix, operation, value }): Done => ({ + key, + keyPrefix, + operation, + value, + }), + target: done as any, + }) + } + + sample({ + clock: complete, + filter: (payload: Finally): payload is FinallyFail => + payload.status === 'fail', + fn: ({ key, keyPrefix, operation, error, value }): Fail => ({ + key, + keyPrefix, + operation, + error, + value, + }), + target: fail as any, + }) + + if (context) { + ctx.on(context, ([ref], payload) => [ + payload === undefined ? ref : payload, + ]) + } + + if (pickup) { + // pick up value from storage ONLY on `pickup` update + sample({ clock: pickup, fn: () => undefined, target: getFx }) + ctx.on(pickup, ([ref], payload) => [ + payload === undefined ? ref : payload, + ]) + } else { + // kick getter to pick up initial value from storage + getFx() + } + }) + + return (desist.unsubscribe = desist) +} diff --git a/src/index.ts b/src/index.ts index af36a56..8ab883c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,13 @@ -import type { ConfigPersist, Persist } from './types' -import { persist as base } 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' // @@ -51,7 +62,7 @@ export { async, either, farcached } from './tools' */ export function createPersist(defaults?: ConfigPersist): Persist { return (config: any) => - base({ + basePersist({ ...defaults, ...config, }) @@ -60,4 +71,18 @@ export function createPersist(defaults?: ConfigPersist): Persist { /** * Default `persist` */ -export const persist: Persist = base +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 5e249e8..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 */ @@ -89,4 +105,20 @@ export function createPersist(defaults?: ConfigPersist): Persist { /** * Default partially applied `persist` */ -export const persist = createPersist() +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 0b54d14..aec870d 100644 --- a/src/memory/adapter.ts +++ b/src/memory/adapter.ts @@ -13,7 +13,7 @@ adapter.factory = true as const export function adapter({ area = data }: MemoryConfig = {}): StorageAdapter { const adapter: StorageAdapter = (key: string) => ({ get: () => area.get(key), - set: (value: State) => area.set(key, value), + set: (value: State) => void area.set(key, value), }) adapter.keyArea = area diff --git a/src/memory/index.ts b/src/memory/index.ts index aa6f523..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 */ @@ -53,4 +68,20 @@ export function createPersist(defaults?: ConfigPersist): Persist { /** * Default partially applied `persist` */ -export const persist = createPersist() +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 019e383..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 */ @@ -82,4 +97,20 @@ export function createPersist(defaults?: ConfigPersist): Persist { /** * Default partially applied `persist` */ -export const persist = createPersist() +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 39c036a..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 */ @@ -88,4 +104,20 @@ export function createPersist(defaults?: ConfigPersist): Persist { /** * Default partially applied `persist` */ -export const persist = createPersist() +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/types.ts b/src/types.ts index 3675b5d..e3c6ae3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,12 +5,13 @@ export interface Adapter { this: void, raw?: any, ctx?: any - ): State | Promise | undefined + ): State | undefined | Promise set( // this: void, value: State, ctx?: any - ): void + ): void | Promise + // remove?(ctx?: any): void | Promise } export interface DisposableAdapter extends Adapter { @@ -48,14 +49,14 @@ export type Done = { export type Fail = { key: string keyPrefix: string - operation: 'set' | 'get' + operation: 'set' | 'get' | 'validate' error: Err value?: any } -export type Finally = - | (Done & { status: 'done' }) - | (Fail & { status: 'fail' }) +export type FinallyDone = Done & { status: 'done' } +export type FinallyFail = Fail & { status: 'fail' } +export type Finally = FinallyDone | FinallyFail export interface ConfigPersist { pickup?: Unit @@ -119,3 +120,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 79d7137..56f142c 100644 --- a/tests/broadcast.test.ts +++ b/tests/broadcast.test.ts @@ -6,7 +6,7 @@ import * as assert from 'uvu/assert' 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' @@ -79,6 +79,7 @@ test.after(() => { test('should export adapter and `persist` function', () => { assert.type(broadcast, 'function') assert.type(persist, 'function') + assert.type(createStorage, 'function') }) test('should be exported from package root', () => { @@ -88,6 +89,8 @@ test('should be exported from package root', () => { test('should be ok on good parameters', () => { const $store = createStore(0, { name: 'broadcast' }) assert.not.throws(() => persist({ store: $store })) + assert.not.throws(() => createStorage('broadcast')) + assert.not.throws(() => 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..99e94ea --- /dev/null +++ b/tests/context-create-storage.test.ts @@ -0,0 +1,189 @@ +import type { StorageAdapter } from '../src/types' +import { test } from 'uvu' +import * as assert from 'uvu/assert' +import { snoop } from 'snoop' +import { createStore, createEvent, fork, allSettled } from 'effector' +import { createStorage } from '../src' + +// +// Tests +// + +test('context store value should be passed to adapter', async () => { + const watch = snoop(() => undefined as any) + + const context = createEvent() + + const { getFx, setFx } = createStorage('test-context-1', { + adapter: () => ({ get: watch.fn, set: watch.fn }), + context: createStore(42).on(context, (_, ctx) => ctx), + }) + + getFx() + + assert.is(watch.callCount, 1) + assert.equal(watch.calls[0].arguments, [undefined, 42]) + + setFx(54) + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[1].arguments, [54, 42]) + + // update context + context(72) + + getFx() + + assert.is(watch.callCount, 3) + assert.equal(watch.calls[2].arguments, [undefined, 72]) + + setFx(27) + + assert.is(watch.callCount, 4) + assert.equal(watch.calls[3].arguments, [27, 72]) +}) + +test('context event value should be passed to adapter', async () => { + const watch = snoop(() => undefined as any) + + const context = createEvent() + + const { getFx, setFx } = createStorage('test-context-2', { + adapter: () => ({ get: watch.fn, set: watch.fn }), + context, + }) + + getFx() + + assert.is(watch.callCount, 1) + assert.equal(watch.calls[0].arguments, [undefined, undefined]) + + setFx(54) + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[1].arguments, [54, undefined]) + + // update context + context('new context') + + getFx() + + assert.is(watch.callCount, 3) + assert.equal(watch.calls[2].arguments, [undefined, 'new context']) + + setFx(27) + + assert.is(watch.callCount, 4) + assert.equal(watch.calls[3].arguments, [27, 'new context']) +}) + +test('contexts in different scopes should be different', async () => { + const watch = snoop(() => undefined as any) + + const context = createEvent<{ name: string }>() + + const { getFx, setFx } = createStorage('test-context-3', { + adapter: () => ({ get: watch.fn, set: watch.fn }), + 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.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined, { name: 'scopeA' }]) + assert.equal(watch.calls[1].arguments, [undefined, { name: 'scopeB' }]) + + await allSettled(setFx, { scope: scopeA, params: 'A' }) + await allSettled(setFx, { scope: scopeB, params: 'B' }) + + assert.is(watch.callCount, 4) + assert.equal(watch.calls[2].arguments, ['A', { name: 'scopeA' }]) + assert.equal(watch.calls[3].arguments, ['B', { name: 'scopeB' }]) +}) + +test('context should change scope for async adapter', async () => { + const watch = snoop((value) => value) + + const updated = createEvent() + const context = createEvent() + + createStorage('test-context-4', { + context, + adapter: (_key, update) => { + updated.watch(update) + return { get: watch.fn, set: watch.fn } + }, + }) + + updated('out of scope') // <- imitate external storage update + + assert.is(watch.callCount, 1) + assert.equal(watch.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.is(watch.callCount, 2) + assert.equal(watch.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.is($store.getState(), 2) // <- changed + assert.is(scope.getState($store), 0) // <- default value + + await allSettled(context, { scope }) + + queue.dispatchEvent(new Event('update')) + + assert.is($store.getState(), 2) + assert.is(scope.getState($store), 2) // <- changed in scope +}) + +// +// Launch tests +// + +test.run() diff --git a/tests/context.test.ts b/tests/context-persist.test.ts similarity index 84% rename from tests/context.test.ts rename to tests/context-persist.test.ts index 1420148..b9e1a4f 100644 --- a/tests/context.test.ts +++ b/tests/context-persist.test.ts @@ -9,7 +9,7 @@ import { persist } from '../src/core' // test('context from pickup should be passed to adapter', async () => { - const watch = snoop((_value, _ctx) => undefined as any) // eslint-disable-line @typescript-eslint/no-unused-vars + const watch = snoop(() => undefined as any) const pickup = createEvent() const $store = createStore(0) @@ -33,8 +33,30 @@ test('context from pickup should be passed to adapter', async () => { assert.equal(watch.calls[1].arguments, [54, 42]) }) +test('context from store should be passed to adapter', async () => { + const watch = snoop(() => undefined as any) + + const $store = createStore(0) + + persist({ + store: $store, + adapter: () => ({ get: watch.fn, set: watch.fn }), + key: 'store', + context: createStore(42), + }) + + assert.is(watch.callCount, 1) + assert.equal(watch.calls[0].arguments, [undefined, 42]) + + // + ;($store as any).setState(54) // <- update store to trigger `set` + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[1].arguments, [54, 42]) +}) + test('context from context should be passed to adapter', async () => { - const watch = snoop((_value, _ctx) => undefined as any) // eslint-disable-line @typescript-eslint/no-unused-vars + const watch = snoop(() => undefined as any) const context = createEvent() const $store = createStore(0) @@ -67,7 +89,7 @@ test('context from context should be passed to adapter', async () => { }) test('pickup should set different contexts in different scopes', async () => { - const watch = snoop((_value, _ctx) => undefined as any) // eslint-disable-line @typescript-eslint/no-unused-vars + const watch = snoop(() => undefined as any) const pickup = createEvent<{ name: string }>() const $store = createStore('') @@ -109,7 +131,7 @@ test('pickup should set different contexts in different scopes', async () => { }) test('context should change scope for async adapter', async () => { - const watch = snoop((value, _ctx) => value) // eslint-disable-line @typescript-eslint/no-unused-vars + const watch = snoop((value) => value) const pickup = createEvent() const context = createEvent() diff --git a/tests/contract-create-storage.test.ts b/tests/contract-create-storage.test.ts new file mode 100644 index 0000000..4806bd4 --- /dev/null +++ b/tests/contract-create-storage.test.ts @@ -0,0 +1,466 @@ +import type { StorageAdapter } from '../src' +import { test } from 'uvu' +import * as assert from 'uvu/assert' +import { snoop } from 'snoop' +import { createStore, createEvent } from 'effector' +import { Record, Literal, Number, Optional } from 'runtypes' +import { runtypeContract } from '@farfetched/runtypes' +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 + +test.before(() => { + events = createEventsMock() + global.addEventListener = events.addEventListener + storageAdapter = storage({ storage: () => mockStorage, sync: true }) +}) + +test.after(() => { + delete global.addEventListener +}) + +// +// Tests +// + +test('shoult validate storage value on get', () => { + const watch = snoop(() => undefined) + + mockStorage.setItem('number1', '42') + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'number1', + contract: (raw): raw is number => typeof raw === 'number', + }) + + getFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + + getFx() + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watch.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: 42, + }, + ]) // getFx result + + assert.is(mockStorage.getItem('number1'), '42') +}) + +test('shoult fail on invalid initial storage value with simple contract', () => { + const watch = snoop(() => undefined) + + 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.fn) + getFx.finally.watch(watch.fn) + + getFx() + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watch.calls[1].arguments, [ + { + status: 'fail', + params: undefined, + error: { + key: 'number2', + keyPrefix: '', + operation: 'validate', + error: ['Invalid data'], + value: 'invalid', + }, + }, + ]) // getFx result + + assert.is(mockStorage.getItem('number2'), '"invalid"') // didn't change +}) + +test('should handle sync effects with same key and different validators', () => { + const watchPlain = snoop(() => undefined) + const watchBase64 = snoop(() => undefined) + + 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.fn) + setPlainFx.watch(watchPlain.fn) + getPlainFx.finally.watch(watchPlain.fn) + setPlainFx.finally.watch(watchPlain.fn) + + getBase64Fx.watch(watchBase64.fn) + setBase64Fx.watch(watchBase64.fn) + getBase64Fx.finally.watch(watchBase64.fn) + setBase64Fx.finally.watch(watchBase64.fn) + + assert.is(watchPlain.callCount, 0) + assert.is(watchBase64.callCount, 0) + + setPlainFx('plain value') + assert.is(mockStorage.getItem('contract-same-key-1'), '"plain value"') + + assert.is(watchPlain.callCount, 2) + assert.equal(watchPlain.calls[0].arguments, ['plain value']) // setPlainFx trigger + assert.equal(watchPlain.calls[1].arguments, [ + { + status: 'done', + params: 'plain value', + result: undefined, + }, + ]) // setPlainFx result + + assert.is(watchBase64.callCount, 2) + assert.equal(watchBase64.calls[0].arguments, [undefined]) // getBase64Fx trigger + assert.equal(watchBase64.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.is(mockStorage.getItem('contract-same-key-1'), '"YmFzZTY0IHZhbHVl"') + + assert.is(watchBase64.callCount, 4) + assert.equal(watchBase64.calls[2].arguments, ['YmFzZTY0IHZhbHVl']) // setBase64Fx trigger + assert.equal(watchBase64.calls[3].arguments, [ + { + status: 'done', + params: 'YmFzZTY0IHZhbHVl', + result: undefined, + }, + ]) // setBase64Fx result + + assert.is(watchPlain.callCount, 4) + assert.equal(watchPlain.calls[2].arguments, [undefined]) // getPlainFx trigger + assert.equal(watchPlain.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 = snoop(() => undefined) + 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.fn) + setFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + setFx.finally.watch(watch.fn) + + // + ;($string as any).setState('plain value') + assert.is(mockStorage.getItem('contract-same-key-2'), '"plain value"') + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watch.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 = snoop(() => undefined) + const fail = createEvent() + fail.watch(watch.fn) + + 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.is(mockStorage.getItem('contract-same-key-3'), '"plain value"') + + assert.is($base64.getState(), '') // <- didn't change + + assert.is(watch.callCount, 1) + assert.equal(watch.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 = snoop(() => undefined) + + const Asteroid = Record({ + type: Literal('asteroid'), + mass: Number, + }) + + mockStorage.setItem('asteroid0', '{"type":"asteroid","mass":42}') + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'asteroid0', + contract: runtypeContract(Asteroid), + }) + + getFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + + getFx() + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watch.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 = snoop(() => undefined) + + const Asteroid = Optional( + Record({ + type: Literal('asteroid'), + mass: Number, + }) + ) + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'asteroid1', + contract: runtypeContract(Asteroid), + }) + + getFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + + getFx() + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watch.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 = snoop(() => undefined) + + const Asteroid = Record({ + type: Literal('asteroid'), + mass: Number, + }) + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'asteroid1', + contract: runtypeContract(Asteroid), + }) + + getFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + + getFx() + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watch.calls[1].arguments, [ + { + status: 'fail', + params: undefined, + error: { + key: 'asteroid1', + keyPrefix: '', + operation: 'validate', + error: [ + 'Expected { type: "asteroid"; mass: number; }, but was undefined', + ], + value: undefined, + }, + }, + ]) // getFx result +}) + +test('shoult validate storage value on get with complex contract (invalid)', () => { + const watch = snoop(() => undefined) + + const Asteroid = Record({ + type: Literal('asteroid'), + mass: Number, + }) + + mockStorage.setItem('asteroid2', '42') + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'asteroid2', + contract: runtypeContract(Asteroid), + }) + + getFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + + getFx() + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watch.calls[1].arguments, [ + { + status: 'fail', + params: undefined, + error: { + key: 'asteroid2', + keyPrefix: '', + operation: 'validate', + error: ['Expected { type: "asteroid"; mass: number; }, but was number'], + value: 42, + }, + }, + ]) // getFx result + + assert.is(mockStorage.getItem('asteroid2'), '42') +}) + +test('should validate value on storage external update', async () => { + const watch = snoop(() => undefined) + + const { getFx } = createStorage({ + adapter: storageAdapter, + key: 'storage-contract-counter-1', + contract: runtypeContract(Number), + }) + + getFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + + mockStorage.setItem('storage-contract-counter-1', '1') + await events.dispatchEvent('storage', { + storageArea: mockStorage, + key: 'storage-contract-counter-1', + oldValue: null, + newValue: '1', + }) + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, ['1']) // getFx trigger with raw value + assert.equal(watch.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.is(watch.callCount, 4) + assert.equal(watch.calls[2].arguments, ['"invalid"']) // getFx trigger with raw value + assert.equal(watch.calls[3].arguments, [ + { + status: 'fail', + params: '"invalid"', // raw value from adapter + error: { + key: 'storage-contract-counter-1', + keyPrefix: '', + operation: 'validate', + error: ['Expected number, but was string'], + value: 'invalid', + }, + }, + ]) // getFx result +}) + +// +// Launch tests +// + +test.run() diff --git a/tests/contract.test.ts b/tests/contract-persist.test.ts similarity index 98% rename from tests/contract.test.ts rename to tests/contract-persist.test.ts index 564ec05..2626133 100644 --- a/tests/contract.test.ts +++ b/tests/contract-persist.test.ts @@ -74,7 +74,7 @@ test('shoult fail on invalid initial storage value with simple contract', () => key: 'number2', keyPrefix: '', operation: 'validate', - error: undefined, + error: ['Invalid data'], value: 'invalid', }, ]) @@ -118,7 +118,7 @@ test('should not break sync stores with same key and different validators', () = key: 'same-key-3', keyPrefix: '', operation: 'validate', - error: undefined, + error: ['Invalid data'], value: 'plain value', }, ]) @@ -167,7 +167,7 @@ test('validation should not prevent persisting state', () => { key: 'string1', keyPrefix: '', operation: 'validate', - error: undefined, + error: ['Invalid data'], value: 42, }, ]) diff --git a/tests/core-create-storage.test.ts b/tests/core-create-storage.test.ts new file mode 100644 index 0000000..17a9bd2 --- /dev/null +++ b/tests/core-create-storage.test.ts @@ -0,0 +1,526 @@ +import { test } from 'uvu' +import * as assert from 'uvu/assert' +import { snoop } from 'snoop' +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.type(createStorageFactory, 'function') + assert.type(createStorageFactory(), 'function') + assert.type(createStorage, 'function') + const ret = createStorage('test-key', { adapter }) + assert.type(ret, 'object') + assert.type(ret.getFx, 'function') + assert.type(ret.setFx, 'function') + assert.type(ret.removeFx, 'function') +}) + +test('should be ok on good parameters', () => { + assert.not.throws(() => { + createStorage('test-1', { + adapter, + }) + }) + assert.not.throws(() => { + createStorage({ + adapter, + key: 'test-2', + }) + }) + assert.not.throws(() => { + createStorage('test-3', { + adapter, + keyPrefix: 'prefix-3', + }) + }) + assert.not.throws(() => { + createStorage({ + adapter, + key: 'tets-4', + keyPrefix: 'prefix-3', + }) + }) + assert.not.throws(() => { + createStorage('test-1', { + adapter, + context: createStore(0), + }) + }) + assert.not.throws(() => { + 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 = snoop(() => undefined) + + const { getFx, setFx } = createStorage('test-get-set-1', { + adapter, + }) + + getFx.watch(watch.fn) + setFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + setFx.finally.watch(watch.fn) + + assert.is(watch.callCount, 0) + + getFx() + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watch.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: undefined, + }, + ]) // getFx result + + setFx(1) + + assert.is(watch.callCount, 4) + assert.equal(watch.calls[2].arguments, [1]) // setFx trigger + assert.equal(watch.calls[3].arguments, [ + { + status: 'done', + params: 1, + result: undefined, + }, + ]) // setFx result + + assert.is(await getFx(), 1) +}) + +test('should remove value from storage', async () => { + const { setFx, getFx, removeFx } = createStorage('test-get-set-1', { + adapter, + }) + + await setFx(1) + + assert.is(await getFx(), 1) + + await removeFx() + + assert.is(await getFx(), undefined) +}) + +test('should get and set value from storage (with adapter factory)', async () => { + const watch = snoop(() => undefined) + + const area = new Map() + const { getFx, setFx } = createStorage('test-get-set-2', { + adapter: memory, + area, + }) + + getFx.watch(watch.fn) + setFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + setFx.finally.watch(watch.fn) + + assert.is(watch.callCount, 0) + + getFx() + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watch.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: undefined, + }, + ]) // getFx result + + setFx(1) + + assert.is(watch.callCount, 4) + assert.equal(watch.calls[2].arguments, [1]) // setFx trigger + assert.equal(watch.calls[3].arguments, [ + { + status: 'done', + params: 1, + result: undefined, + }, + ]) // setFx result + + assert.is(await getFx(), 1) + assert.is(area.get('test-get-set-2'), 1) +}) + +test('should get and set value from async storage', async () => { + const watch = snoop(() => undefined) + + const { getFx, setFx } = createStorage('test-get-set-3', { + adapter: async(adapter), + }) + + getFx.watch(watch.fn) + setFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + setFx.finally.watch(watch.fn) + + assert.is(watch.callCount, 0) + + await getFx() + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watch.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: undefined, + }, + ]) // getFx result + + await setFx(1) + + assert.is(watch.callCount, 4) + assert.equal(watch.calls[2].arguments, [1]) // setFx trigger + assert.equal(watch.calls[3].arguments, [ + { + status: 'done', + params: 1, + result: undefined, + }, + ]) // setFx result + + assert.is(await getFx(), 1) +}) + +test('should sync effects for the same adapter-key', () => { + const watchSet = snoop(() => undefined) + const watchGet = snoop(() => undefined) + + const { setFx } = createStorage({ + adapter, + key: 'test-sync-same-key-1', + }) + const { getFx } = createStorage({ + adapter, + key: 'test-sync-same-key-1', + }) + + getFx.watch(watchGet.fn) + setFx.watch(watchSet.fn) + getFx.finally.watch(watchGet.fn) + setFx.finally.watch(watchSet.fn) + + assert.is(watchSet.callCount, 0) + assert.is(watchGet.callCount, 0) + + setFx(1) + + assert.is(watchSet.callCount, 2) + assert.is(watchGet.callCount, 2) + assert.equal(watchSet.calls[0].arguments, [1]) // setFx trigger + assert.equal(watchSet.calls[1].arguments, [ + { + status: 'done', + params: 1, + result: undefined, + }, + ]) // setFx result + assert.equal(watchGet.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watchGet.calls[1].arguments, [ + { + status: 'done', + params: undefined, + result: 1, + }, + ]) // getFx result +}) + +test('should sync with `persist` for the same adapter-key', async () => { + const watch = snoop(() => undefined) + const watchFx = snoop(() => undefined) + + const $store = createStore(11) + $store.watch(watch.fn) + + assert.is($store.getState(), 11) + assert.is(watch.callCount, 1) + assert.equal(watch.calls[0].arguments, [11]) + + persist({ + store: $store, + adapter, + key: 'test-sync-same-key-2', + }) + + assert.is($store.getState(), 11) // did not change + assert.is(watch.callCount, 1) // did not trigger + + const { getFx, setFx } = createStorage({ + adapter, + key: 'test-sync-same-key-2', + }) + + getFx.watch(watchFx.fn) + setFx.watch(watchFx.fn) + getFx.finally.watch(watchFx.fn) + setFx.finally.watch(watchFx.fn) + + assert.is(watchFx.callCount, 0) // did not trigger + + setFx(22) + + assert.is(watchFx.callCount, 2) + assert.equal(watchFx.calls[0].arguments, [22]) // setFx trigger + assert.equal(watchFx.calls[1].arguments, [ + { + status: 'done', + params: 22, + result: undefined, + }, + ]) // setFx result + + assert.is($store.getState(), 22) // <- changed + assert.is(watch.callCount, 2) + assert.equal(watch.calls[1].arguments, [22]) + + // + ;($store as any).setState(33) + + assert.is($store.getState(), 33) + assert.is(watch.callCount, 3) + assert.equal(watch.calls[2].arguments, [33]) + + assert.is(watchFx.callCount, 4) + assert.equal(watchFx.calls[2].arguments, [undefined]) // getFx trigger + assert.equal(watchFx.calls[3].arguments, [ + { + status: 'done', + params: undefined, + result: 33, + }, + ]) // getFx result + + assert.is(await getFx(), 33) +}) + +test('should handle synchronous error in `get` and `set` effects', () => { + const watch = snoop(() => undefined) + + const { getFx, setFx } = createStorage('test-sync-throw', { + adapter: () => ({ + get: () => { + throw 'get test error' + }, + set: () => { + throw 'set test error' + }, + }), + }) + + getFx.watch(watch.fn) + setFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + setFx.finally.watch(watch.fn) + + assert.is(watch.callCount, 0) + + getFx() + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watch.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.is(watch.callCount, 4) + assert.equal(watch.calls[2].arguments, [1]) // setFx trigger + assert.equal(watch.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 = snoop(() => undefined) + + 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.fn) + setFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + setFx.finally.watch(watch.fn) + + assert.is(watch.callCount, 0) + + try { + await getFx() + assert.unreachable('getFx should have thrown') + } catch (e) { + // ok + } + + assert.is(watch.callCount, 2) + assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger + assert.equal(watch.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.unreachable('setFx should have thrown') + } catch (e) { + // ok + } + + assert.is(watch.callCount, 4) + assert.equal(watch.calls[2].arguments, [1]) // setFx trigger + assert.equal(watch.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', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const watch = snoop((_) => undefined) + 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.fn) + setFx.watch(watch.fn) + getFx.finally.watch(watch.fn) + setFx.finally.watch(watch.fn) + + assert.is(watch.callCount, 0) + + fail() + + assert.is(watch.callCount, 2) + + const arg1 = watch.calls[0].arguments[0] + assert.instance(arg1, 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.calls[1].arguments[0] + assert.equal(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 +}) + +// +// Launch tests +// + +test.run() diff --git a/tests/core.test.ts b/tests/core-persist.test.ts similarity index 99% rename from tests/core.test.ts rename to tests/core-persist.test.ts index 9120e16..bff456d 100644 --- a/tests/core.test.ts +++ b/tests/core-persist.test.ts @@ -13,7 +13,7 @@ const dumbAdapter: StorageAdapter = () => { let __: T = 0 as any return { get: (): T => __, - set: (value: T) => (__ = value), + set: (value: T) => void (__ = value), } } diff --git a/tests/domain.test.ts b/tests/domain.test.ts index 46dc117..7dba8d9 100644 --- a/tests/domain.test.ts +++ b/tests/domain.test.ts @@ -13,7 +13,7 @@ const dumbAdapter: StorageAdapter = () => { let __: T = 0 as any return { get: (): T => __, - set: (value: T) => (__ = value), + set: (value: T) => void (__ = value), } } diff --git a/tests/index.types.ts b/tests/index.types.ts index a40263a..5e5a846 100644 --- a/tests/index.types.ts +++ b/tests/index.types.ts @@ -30,6 +30,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.test.ts b/tests/local.test.ts index 8852556..7eaf9b0 100644 --- a/tests/local.test.ts +++ b/tests/local.test.ts @@ -4,7 +4,7 @@ import { snoop } from 'snoop' 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' // @@ -32,6 +32,7 @@ test.after(() => { test('should export adapter and `persist` function', () => { assert.type(local, 'function') assert.type(persist, 'function') + assert.type(createStorage, 'function') }) test('should be exported from package root', () => { @@ -41,6 +42,8 @@ test('should be exported from package root', () => { test('should be ok on good parameters', () => { const $store = createStore(0, { name: 'local::store' }) assert.not.throws(() => persist({ store: $store })) + assert.not.throws(() => createStorage('local::store')) + assert.not.throws(() => createStorage({ key: 'local::store' })) }) test('persisted store shoult reset value on init to default', async () => { diff --git a/tests/memory.test.ts b/tests/memory.test.ts index 10673ee..e5107c2 100644 --- a/tests/memory.test.ts +++ b/tests/memory.test.ts @@ -2,7 +2,7 @@ import { test } from 'uvu' import * as assert from 'uvu/assert' import { snoop } from 'snoop' import { createStore } from 'effector' -import { memory, persist } from '../src/memory' +import { memory, persist, createStorage } from '../src/memory' import { memory as memoryIndex } from '../src' // @@ -12,6 +12,7 @@ import { memory as memoryIndex } from '../src' test('should export adapter and `persist` function', () => { assert.type(memory, 'function') assert.type(persist, 'function') + assert.type(createStorage, 'function') }) test('should be exported from package root', () => { @@ -21,6 +22,8 @@ test('should be exported from package root', () => { test('should be ok on good parameters', () => { const $store = createStore(0, { name: 'memory::store' }) assert.not.throws(() => persist({ store: $store })) + assert.not.throws(() => createStorage('memory::store')) + assert.not.throws(() => 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 3f3acfd..afd20fa 100644 --- a/tests/query.test.ts +++ b/tests/query.test.ts @@ -9,6 +9,7 @@ import { type Events, createEventsMock } from './mocks/events.mock' import { persist, query, + createStorage, pushState, replaceState, locationAssign, @@ -56,6 +57,7 @@ test.after(() => { test('should export adapter and `persist` function', () => { assert.type(query, 'function') assert.type(persist, 'function') + assert.type(createStorage, 'function') assert.type(pushState, 'function') assert.type(replaceState, 'function') assert.type(locationAssign, 'function') @@ -69,6 +71,8 @@ test('should be exported from package root', () => { test('should be ok on good parameters', () => { const $store = createStore('0', { name: 'query::store' }) assert.not.throws(() => persist({ store: $store })) + assert.not.throws(() => createStorage('query::store')) + assert.not.throws(() => createStorage({ key: 'query::store' })) }) test('store initial value should NOT be put in query string', () => { diff --git a/tests/serialize-scope.test.ts b/tests/serialize-scope.test.ts index b40e930..2eb256b 100644 --- a/tests/serialize-scope.test.ts +++ b/tests/serialize-scope.test.ts @@ -13,7 +13,7 @@ const dumbAdapter: StorageAdapter = () => { let __: T = 42 as any return { get: (): T => __, - set: (value: T) => (__ = value), + set: (value: T) => void (__ = value), } } @@ -116,6 +116,34 @@ test('persist usage should not warn', async () => { assert.is(fn.callCount, 0) }) +test('setting value to persisted store should not warn', async () => { + const fn: MockErrorFn = (console.error as any).mock + + const set = createEvent() + const pickup = createEvent() + const $store = createStore(0, { sid: 'y' }).on(set, (_, x) => x) + + persist({ + store: $store, + pickup, + adapter: dumbAdapter, + key: 'store_y', + }) + + const scope = fork() + await allSettled(pickup, { scope }) + await allSettled(set, { scope, params: 24 }) + + // should fill scoped store value + assert.is(scope.getState($store), 24) + assert.is($store.getState(), 0) + + serialize(scope) + + assert.equal(fn.calls[0]?.arguments, undefined) + assert.is(fn.callCount, 0) +}) + // // Launch tests // diff --git a/tests/session.test.ts b/tests/session.test.ts index a1395de..024c5a2 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -4,7 +4,7 @@ import { snoop } from 'snoop' 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' // @@ -32,6 +32,7 @@ test.after(() => { test('should export adapter and `persist` function', () => { assert.type(session, 'function') assert.type(persist, 'function') + assert.type(createStorage, 'function') }) test('should be exported from package root', () => { @@ -41,6 +42,8 @@ test('should be exported from package root', () => { test('should be ok on good parameters', () => { const $store = createStore(0, { name: 'session::store' }) assert.not.throws(() => persist({ store: $store })) + assert.not.throws(() => createStorage('session::store')) + assert.not.throws(() => createStorage({ key: 'session::store' })) }) test('persisted store shoult reset value on init to default', async () => { diff --git a/tests/tools-either.test.ts b/tests/tools-either.test.ts index 28f465b..9d67fd5 100644 --- a/tests/tools-either.test.ts +++ b/tests/tools-either.test.ts @@ -16,7 +16,7 @@ const dumbAdapter: StorageAdapter = () => { let __: T = 0 as any return { get: (): T => __, - set: (value: T) => (__ = value), + set: (value: T) => void (__ = value), } }