From e4ccfc33411ad6cd4294d229426f9978b9dae435 Mon Sep 17 00:00:00 2001 From: yumauri Date: Wed, 17 Apr 2024 11:23:47 +0300 Subject: [PATCH 01/12] Add `createStorage` function, to create stand-alone effects for storage --- .eslintrc.js | 1 + .size-limit.js | 8 +- README.md | 4 +- src/broadcast/index.ts | 35 +- src/core/area.ts | 9 +- src/core/create-storage.ts | 146 ++++++ src/core/index.ts | 267 +---------- src/core/persist.ts | 253 +++++++++++ src/index.ts | 33 +- src/local/index.ts | 34 +- src/memory/adapter.ts | 2 +- src/memory/index.ts | 33 +- src/query/index.ts | 33 +- src/session/index.ts | 34 +- src/types.ts | 39 +- tests/broadcast.test.ts | 5 +- tests/context-create-storage.test.ts | 189 ++++++++ ...ontext.test.ts => context-persist.test.ts} | 30 +- tests/contract-create-storage.test.ts | 430 ++++++++++++++++++ ...tract.test.ts => contract-persist.test.ts} | 6 +- tests/core-create-storage.test.ts | 340 ++++++++++++++ tests/{core.test.ts => core-persist.test.ts} | 2 +- tests/domain.test.ts | 2 +- tests/index.types.ts | 18 + tests/local.test.ts | 5 +- tests/memory.test.ts | 5 +- tests/query.test.ts | 4 + tests/serialize-scope.test.ts | 30 +- tests/session.test.ts | 5 +- tests/tools-either.test.ts | 2 +- 30 files changed, 1702 insertions(+), 302 deletions(-) create mode 100644 src/core/create-storage.ts create mode 100644 src/core/persist.ts create mode 100644 tests/context-create-storage.test.ts rename tests/{context.test.ts => context-persist.test.ts} (84%) create mode 100644 tests/contract-create-storage.test.ts rename tests/{contract.test.ts => contract-persist.test.ts} (98%) create mode 100644 tests/core-create-storage.test.ts rename tests/{core.test.ts => core-persist.test.ts} (99%) diff --git a/.eslintrc.js b/.eslintrc.js index aa817af..b089d93 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,6 +4,7 @@ module.exports = { extends: ['standard', 'plugin:@typescript-eslint/recommended', 'prettier'], plugins: ['@typescript-eslint'], rules: { + 'no-void': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-empty-interface': 'off', '@typescript-eslint/explicit-module-boundary-types': [ diff --git a/.size-limit.js b/.size-limit.js index 7d6b255..040f06e 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: '990 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: '3776 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: '985 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: '1549 B', // import: '{ persist }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, diff --git a/README.md b/README.md index 23df96f..31890f9 100644 --- a/README.md +++ b/README.md @@ -300,8 +300,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/src/broadcast/index.ts b/src/broadcast/index.ts index 47dc9d7..050e0b4 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 { @@ -73,3 +88,19 @@ export function createPersist(defaults?: ConfigPersist): Persist { * Default partially applied `persist` */ export const persist = 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 = 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..0fa7cc1 --- /dev/null +++ b/src/core/create-storage.ts @@ -0,0 +1,146 @@ +import type { Event, Effect } from 'effector' +import type { + ConfigAdapter, + ConfigAdapterFactory, + StorageHandles, + ConfigCreateStorage, + Contract, +} from '../types' +import { + attach, + // clearNode, + createEffect, + // 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'] // TODO: add validation error, with raw value from storage +} + +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, + // done, + // fail = sink, + // finally: anyway, + 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 getFx = attach({ + source: ctx, + effect([ref], raw?: void) { + const result = value.get(raw, ref) as any + return typeof result?.then === 'function' + ? Promise.resolve(result).then((x) => validate(x, contract)) + : validate(result, contract) + }, + }) as Effect + + const setFx = attach({ + source: ctx, + effect([ref], state: State) { + const result = value.set(state, ref) + if (typeof result?.then === 'function') { + return Promise.resolve(result).then(() => undefined) + } + }, + }) 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 { + get: getFx, + set: setFx, + remove: createEffect(() => {}), // TODO + clear: createEffect(() => {}), // TODO + } +} 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..46a64b1 --- /dev/null +++ b/src/core/persist.ts @@ -0,0 +1,253 @@ +import type { Event, Effect, Subscription } from 'effector' +import type { + ConfigAdapter, + ConfigAdapterFactory, + ConfigPersist, + ConfigSourceTarget, + ConfigStore, + Done, + Fail, + Finally, +} 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: ({ 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) +} 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 0615663..40b2af4 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 = 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 = 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..48edf13 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 = 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 = createStorageFactory() diff --git a/src/query/index.ts b/src/query/index.ts index 019e383..cce78d3 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 = 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 = createStorageFactory() diff --git a/src/session/index.ts b/src/session/index.ts index 0aa1227..4be0473 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 = 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 = createStorageFactory() diff --git a/src/types.ts b/src/types.ts index 972a352..987cfd0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,9 @@ import type { Event, Effect, Store, Unit, Subscription } from 'effector' export interface Adapter { - 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 + // remove?(ctx?: any): void | Promise } export interface DisposableAdapter extends Adapter { @@ -111,3 +112,37 @@ export interface Persist { AdapterConfig ): Subscription } + +export interface StorageHandles { + get: Effect + set: Effect + remove: Effect + clear: 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 6ea5290..d4b5f4f 100644 --- a/tests/broadcast.test.ts +++ b/tests/broadcast.test.ts @@ -7,7 +7,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' @@ -80,6 +80,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', () => { @@ -89,6 +90,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..e5cb9cb --- /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 { get: getFx, set: 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 { get: getFx, set: 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 { get: getFx, set: 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 { get: 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..0e8c0fe --- /dev/null +++ b/tests/contract-create-storage.test.ts @@ -0,0 +1,430 @@ +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 { get: 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 { get: 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: ['Invalid data'], + }, + ]) // 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 { get: getPlainFx, set: setPlainFx } = createStorage({ + adapter: storageAdapter, + key: 'contract-same-key-1', + contract: (raw): raw is string => typeof raw === 'string', + }) + const { get: getBase64Fx, set: 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: ['Invalid data'], + }, + ]) // 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 { get: getFx, set: 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: ['Invalid data'], + }, + ]) // 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 { set: 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 { get: 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 { get: 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 { get: 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: [ + 'Expected { type: "asteroid"; mass: number; }, but was 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 { get: 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: ['Expected { type: "asteroid"; mass: number; }, but was number'], + }, + ]) // getFx result + + assert.is(mockStorage.getItem('asteroid2'), '42') +}) + +test('should validate value on storage external update', async () => { + const watch = snoop(() => undefined) + + const { get: 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: ['Expected number, but was string'], + }, + ]) // 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..b4069f2 --- /dev/null +++ b/tests/core-create-storage.test.ts @@ -0,0 +1,340 @@ +import { test } from 'uvu' +import * as assert from 'uvu/assert' +import { snoop } from 'snoop' +import { createStore } 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.get, 'function') + assert.type(ret.set, 'function') + assert.type(ret.remove, 'function') + assert.type(ret.clear, '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 { get: getFx, set: 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 get and set value from storage (with adapter factory)', async () => { + const watch = snoop(() => undefined) + + const area = new Map() + const { get: getFx, set: 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 { get: getFx, set: 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 { set: setFx } = createStorage({ + adapter, + key: 'test-sync-same-key-1', + }) + const { get: 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 { get: getFx, set: 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) +}) + +// +// 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), } } From 06bdfb9f8ce04ec960029cb12608fd0a31decb0f Mon Sep 17 00:00:00 2001 From: yumauri Date: Wed, 17 Apr 2024 12:24:58 +0300 Subject: [PATCH 02/12] Add /*#__PURE__*/ annotations for dead code elimination --- .eslintrc.js | 5 +++++ .size-limit.js | 24 ++++++++++++------------ rollup.config.mjs | 1 + src/broadcast/index.ts | 4 ++-- src/local/index.ts | 4 ++-- src/memory/index.ts | 4 ++-- src/query/index.ts | 4 ++-- src/session/index.ts | 4 ++-- 8 files changed, 28 insertions(+), 22 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index b089d93..f1a4170 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,5 +13,10 @@ module.exports = { allowArgumentsExplicitlyTypedAsAny: true, }, ], + 'spaced-comment': [ + 'warn', + 'always', + { exceptions: ['-', '+', '#__PURE__'] }, + ], }, } diff --git a/.size-limit.js b/.size-limit.js index 040f06e..8938935 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -111,7 +111,7 @@ module.exports = [ { name: '`localStorage` persist, es module', path: 'build/local/index.js', - limit: '1453 B', + limit: '1461 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: '2141 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: '1439 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: '1442 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: '1457 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: '2136 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: '1515 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: '2201 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: '1077 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: '1720 B', // import: '{ persist }', // tree-shaking is not working with cjs 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: '370 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: '1979 B', // import: '{ broadcast }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, diff --git a/rollup.config.mjs b/rollup.config.mjs index e3b40f9..0e6f5e8 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 050e0b4..b53cd2e 100644 --- a/src/broadcast/index.ts +++ b/src/broadcast/index.ts @@ -87,7 +87,7 @@ export function createPersist(defaults?: ConfigPersist): Persist { /** * Default partially applied `persist` */ -export const persist = createPersist() +export const persist = /*#__PURE__*/ createPersist() /** * Creates custom partially applied `createStorage` @@ -103,4 +103,4 @@ export function createStorageFactory( /** * Default partially applied `createStorage` */ -export const createStorage = createStorageFactory() +export const createStorage = /*#__PURE__*/ createStorageFactory() diff --git a/src/local/index.ts b/src/local/index.ts index 40b2af4..7d0d037 100644 --- a/src/local/index.ts +++ b/src/local/index.ts @@ -105,7 +105,7 @@ export function createPersist(defaults?: ConfigPersist): Persist { /** * Default partially applied `persist` */ -export const persist = createPersist() +export const persist = /*#__PURE__*/ createPersist() /** * Creates custom partially applied `createStorage` @@ -121,4 +121,4 @@ export function createStorageFactory( /** * Default partially applied `createStorage` */ -export const createStorage = createStorageFactory() +export const createStorage = /*#__PURE__*/ createStorageFactory() diff --git a/src/memory/index.ts b/src/memory/index.ts index 48edf13..b97878e 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -68,7 +68,7 @@ export function createPersist(defaults?: ConfigPersist): Persist { /** * Default partially applied `persist` */ -export const persist = createPersist() +export const persist = /*#__PURE__*/ createPersist() /** * Creates custom partially applied `createStorage` @@ -84,4 +84,4 @@ export function createStorageFactory( /** * Default partially applied `createStorage` */ -export const createStorage = createStorageFactory() +export const createStorage = /*#__PURE__*/ createStorageFactory() diff --git a/src/query/index.ts b/src/query/index.ts index cce78d3..5fe6983 100644 --- a/src/query/index.ts +++ b/src/query/index.ts @@ -97,7 +97,7 @@ export function createPersist(defaults?: ConfigPersist): Persist { /** * Default partially applied `persist` */ -export const persist = createPersist() +export const persist = /*#__PURE__*/ createPersist() /** * Creates custom partially applied `createStorage` @@ -113,4 +113,4 @@ export function createStorageFactory( /** * Default partially applied `createStorage` */ -export const createStorage = createStorageFactory() +export const createStorage = /*#__PURE__*/ createStorageFactory() diff --git a/src/session/index.ts b/src/session/index.ts index 4be0473..12399ae 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -104,7 +104,7 @@ export function createPersist(defaults?: ConfigPersist): Persist { /** * Default partially applied `persist` */ -export const persist = createPersist() +export const persist = /*#__PURE__*/ createPersist() /** * Creates custom partially applied `createStorage` @@ -120,4 +120,4 @@ export function createStorageFactory( /** * Default partially applied `createStorage` */ -export const createStorage = createStorageFactory() +export const createStorage = /*#__PURE__*/ createStorageFactory() From 63d24354aa2dcf9dacfd6a3e0be12b73dd638cb8 Mon Sep 17 00:00:00 2001 From: yumauri Date: Fri, 19 Apr 2024 00:51:36 +0300 Subject: [PATCH 03/12] Storage handles effects always fails as Fail type --- .eslintrc.js | 2 + .size-limit.js | 30 ++--- README.md | 2 +- src/core/create-storage.ts | 55 +++++++-- src/core/persist.ts | 10 +- src/types.ts | 16 +-- tests/contract-create-storage.test.ts | 52 ++++++-- tests/core-create-storage.test.ts | 171 +++++++++++++++++++++++++- 8 files changed, 292 insertions(+), 46 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index f1a4170..c04615f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,5 +18,7 @@ module.exports = { 'always', { exceptions: ['-', '+', '#__PURE__'] }, ], + 'no-throw-literal': 'off', + 'prefer-promise-reject-errors': 'off', }, } diff --git a/.size-limit.js b/.size-limit.js index 8938935..0167c2c 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: '990 B', + limit: '988 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -11,7 +11,7 @@ module.exports = [ { name: 'root persist, cjs module', path: 'build/index.cjs', - limit: '3776 B', + limit: '3863 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: '985 B', + limit: '983 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -29,7 +29,7 @@ module.exports = [ { name: 'core persist, cjs module', path: 'build/core/index.cjs', - limit: '1549 B', + limit: '1640 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: '1461 B', + limit: '1458 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -119,7 +119,7 @@ module.exports = [ { name: '`localStorage` persist, cjs module', path: 'build/local/index.cjs', - limit: '2141 B', + limit: '2232 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: '1439 B', + limit: '1437 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: '1442 B', + limit: '1440 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: '1457 B', + limit: '1455 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -159,7 +159,7 @@ module.exports = [ { name: '`sessionStorage` persist, cjs module', path: 'build/session/index.cjs', - limit: '2136 B', + limit: '2226 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: '1515 B', + limit: '1513 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: '2201 B', + limit: '2292 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: '1077 B', + limit: '1076 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -195,7 +195,7 @@ module.exports = [ { name: 'memory adapter, cjs module', path: 'build/memory/index.cjs', - limit: '1720 B', + limit: '1813 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: '1979 B', + limit: '2069 B', // import: '{ broadcast }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, diff --git a/README.md b/README.md index 31890f9..b5648bc 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. diff --git a/src/core/create-storage.ts b/src/core/create-storage.ts index 0fa7cc1..3c464c0 100644 --- a/src/core/create-storage.ts +++ b/src/core/create-storage.ts @@ -5,6 +5,7 @@ import type { StorageHandles, ConfigCreateStorage, Contract, + Fail, } from '../types' import { attach, @@ -24,7 +25,7 @@ function validate(raw: unknown, contract?: Contract) { !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'] // TODO: add validation error, with raw value from storage + throw (contract as any).getErrorMessages?.(raw) ?? ['Invalid data'] } type Config = Partial< @@ -86,25 +87,59 @@ export function createStorage( // 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 = value.get(raw, ref) as any + const result = op('get', value.get, raw, ref) as any return typeof result?.then === 'function' - ? Promise.resolve(result).then((x) => validate(x, contract)) - : validate(result, contract) + ? 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 // as Effect> const setFx = attach({ source: ctx, effect([ref], state: State) { - const result = value.set(state, ref) + const result = op('set', value.set, state, ref) if (typeof result?.then === 'function') { - return Promise.resolve(result).then(() => undefined) + return Promise.resolve(result) + .then(() => undefined) + .catch((error) => { + throw fail('set', error, state) + }) } }, - }) as Effect + }) as Effect // as Effect> let update: (raw?: any) => any = getFx ctx.updates.watch(() => { @@ -140,7 +175,7 @@ export function createStorage( return { get: getFx, set: setFx, - remove: createEffect(() => {}), // TODO - clear: createEffect(() => {}), // TODO + remove: createEffect>(() => {}), // TODO + clear: createEffect>(() => {}), // TODO } } diff --git a/src/core/persist.ts b/src/core/persist.ts index 46a64b1..c67e6a5 100644 --- a/src/core/persist.ts +++ b/src/core/persist.ts @@ -8,6 +8,8 @@ import type { Done, Fail, Finally, + FinallyDone, + FinallyFail, } from '../types' import { attach, @@ -207,7 +209,8 @@ export function persist( if (done) { sample({ clock: complete, - filter: ({ status }) => status === 'done', + filter: (payload: Finally): payload is FinallyDone => + payload.status === 'done', fn: ({ key, keyPrefix, operation, value }): Done => ({ key, keyPrefix, @@ -220,8 +223,9 @@ export function persist( sample({ clock: complete, - filter: ({ status }) => status === 'fail', - fn: ({ key, keyPrefix, operation, error, value }: any): Fail => ({ + filter: (payload: Finally): payload is FinallyFail => + payload.status === 'fail', + fn: ({ key, keyPrefix, operation, error, value }): Fail => ({ key, keyPrefix, operation, diff --git a/src/types.ts b/src/types.ts index 987cfd0..8e1d774 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,14 +41,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 @@ -114,10 +114,10 @@ export interface Persist { } export interface StorageHandles { - get: Effect - set: Effect - remove: Effect - clear: Effect + get: Effect> + set: Effect> + remove: Effect> + clear: Effect> } export interface ConfigCreateStorage { diff --git a/tests/contract-create-storage.test.ts b/tests/contract-create-storage.test.ts index 0e8c0fe..71482c7 100644 --- a/tests/contract-create-storage.test.ts +++ b/tests/contract-create-storage.test.ts @@ -84,7 +84,13 @@ test('shoult fail on invalid initial storage value with simple contract', () => { status: 'fail', params: undefined, - error: ['Invalid data'], + error: { + key: 'number2', + keyPrefix: '', + operation: 'validate', + error: ['Invalid data'], + value: 'invalid', + }, }, ]) // getFx result @@ -139,7 +145,13 @@ test('should handle sync effects with same key and different validators', () => { status: 'fail', params: undefined, - error: ['Invalid data'], + error: { + key: 'contract-same-key-1', + keyPrefix: '', + operation: 'validate', + error: ['Invalid data'], + value: 'plain value', + }, }, ]) // getBase64Fx result @@ -199,7 +211,13 @@ test('should handle sync with `persist` with different validators, update from s { status: 'fail', params: undefined, - error: ['Invalid data'], + error: { + key: 'contract-same-key-2', + keyPrefix: '', + operation: 'validate', + error: ['Invalid data'], + value: 'plain value', + }, }, ]) // getFx result }) @@ -333,9 +351,15 @@ test('shoult validate storage value on get with complex contract (invalid undefi { status: 'fail', params: undefined, - error: [ - 'Expected { type: "asteroid"; mass: number; }, but was undefined', - ], + error: { + key: 'asteroid1', + keyPrefix: '', + operation: 'validate', + error: [ + 'Expected { type: "asteroid"; mass: number; }, but was undefined', + ], + value: undefined, + }, }, ]) // getFx result }) @@ -367,7 +391,13 @@ test('shoult validate storage value on get with complex contract (invalid)', () { status: 'fail', params: undefined, - error: ['Expected { type: "asteroid"; mass: number; }, but was number'], + error: { + key: 'asteroid2', + keyPrefix: '', + operation: 'validate', + error: ['Expected { type: "asteroid"; mass: number; }, but was number'], + value: 42, + }, }, ]) // getFx result @@ -418,7 +448,13 @@ test('should validate value on storage external update', async () => { { status: 'fail', params: '"invalid"', // raw value from adapter - error: ['Expected number, but was string'], + error: { + key: 'storage-contract-counter-1', + keyPrefix: '', + operation: 'validate', + error: ['Expected number, but was string'], + value: 'invalid', + }, }, ]) // getFx result }) diff --git a/tests/core-create-storage.test.ts b/tests/core-create-storage.test.ts index b4069f2..855fd04 100644 --- a/tests/core-create-storage.test.ts +++ b/tests/core-create-storage.test.ts @@ -1,7 +1,7 @@ import { test } from 'uvu' import * as assert from 'uvu/assert' import { snoop } from 'snoop' -import { createStore } from 'effector' +import { createStore, createEvent } from 'effector' import { createStorage, createStorageFactory, @@ -333,6 +333,175 @@ test('should sync with `persist` for the same adapter-key', async () => { assert.is(await getFx(), 33) }) +test('should handle synchronous error in `get` and `set` effects', () => { + const watch = snoop(() => undefined) + + const { get: getFx, set: 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 { get: getFx, set: 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) {} + + 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) {} + + 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 { get: getFx, set: 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 // From 714ecdafa58f683f84b52000579e064f8acbb151 Mon Sep 17 00:00:00 2001 From: yumauri Date: Sat, 6 Jul 2024 17:40:30 +0300 Subject: [PATCH 04/12] Fix merge with new eslint rules --- eslint.config.mjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index ee1a4e2..4bb8099 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,10 +25,7 @@ export default tseslint.config( '@typescript-eslint/no-empty-function': '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 }, From 0d8216372bfd2fa0d0e86f99dce9cf735a74492b Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Tue, 6 Aug 2024 19:13:37 +0700 Subject: [PATCH 05/12] Finalize code --- src/core/create-storage.ts | 12 ++++++------ src/types.ts | 1 - tests/core-create-storage.test.ts | 15 ++++++++++++++- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/core/create-storage.ts b/src/core/create-storage.ts index 3c464c0..6d2972f 100644 --- a/src/core/create-storage.ts +++ b/src/core/create-storage.ts @@ -10,7 +10,6 @@ import type { import { attach, // clearNode, - createEffect, // createNode, createStore, is, @@ -45,9 +44,6 @@ export function createStorage( const { adapter: adapterOrFactory, - // done, - // fail = sink, - // finally: anyway, context, key: keyName, keyPrefix = '', @@ -141,6 +137,11 @@ export function createStorage( }, }) 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 }) @@ -175,7 +176,6 @@ export function createStorage( return { get: getFx, set: setFx, - remove: createEffect>(() => {}), // TODO - clear: createEffect>(() => {}), // TODO + remove: removeFx, } } diff --git a/src/types.ts b/src/types.ts index de8c5b8..5bb3576 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,7 +125,6 @@ export interface StorageHandles { get: Effect> set: Effect> remove: Effect> - clear: Effect> } export interface ConfigCreateStorage { diff --git a/tests/core-create-storage.test.ts b/tests/core-create-storage.test.ts index 855fd04..47b2767 100644 --- a/tests/core-create-storage.test.ts +++ b/tests/core-create-storage.test.ts @@ -26,7 +26,6 @@ test('should exports effects', () => { assert.type(ret.get, 'function') assert.type(ret.set, 'function') assert.type(ret.remove, 'function') - assert.type(ret.clear, 'function') }) test('should be ok on good parameters', () => { @@ -137,6 +136,20 @@ test('should get and set value from storage', async () => { assert.is(await getFx(), 1) }) +test('should remove value from storage', async () => { + const { set, get, remove } = createStorage('test-get-set-1', { + adapter, + }) + + await set(1) + + assert.is(await get(), 1) + + await remove() + + assert.is(await get(), undefined) +}) + test('should get and set value from storage (with adapter factory)', async () => { const watch = snoop(() => undefined) From a6bb1f6c9b149497e9415f971ddb1fe7d1be7892 Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Tue, 6 Aug 2024 19:16:56 +0700 Subject: [PATCH 06/12] Edit size-limits --- .size-limit.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 0167c2c..3c16c20 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -119,7 +119,7 @@ module.exports = [ { name: '`localStorage` persist, cjs module', path: 'build/local/index.cjs', - limit: '2232 B', + limit: '2234 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: '1437 B', + limit: '1438 B', import: '{ persist, local }', ignore: ['effector'], gzip: true, @@ -159,7 +159,7 @@ module.exports = [ { name: '`sessionStorage` persist, cjs module', path: 'build/session/index.cjs', - limit: '2226 B', + limit: '2229 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: '1513 B', + limit: '1514 B', import: '{ persist }', ignore: ['effector'], gzip: true, From e45b2bd94a4960df6ff980506472b5ff007368ca Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Tue, 6 Aug 2024 19:30:50 +0700 Subject: [PATCH 07/12] Add basic docs --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/README.md b/README.md index b5648bc..0ac5c4f 100644 --- a/README.md +++ b/README.md @@ -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.set, +}) + +sample({ + source: userWantToLoad, + target: persist.get, +}) +``` + +### 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: + +- `get` (_Effect_): to get value from storage. +- `set` (_Effect_): to set value to storage. +- `remove` (_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: get.fail, + target: sendLogToSentry, +}) +``` + ## Advanced usage `effector-storage` consists of a _core_ module and _adapter_ modules. From 9bbcbd82f1973be5d75e0720aef826248dc8d345 Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Tue, 6 Aug 2024 19:33:28 +0700 Subject: [PATCH 08/12] Add fx suffix --- README.md | 12 ++++----- src/core/create-storage.ts | 6 ++--- src/types.ts | 6 ++--- tests/core-create-storage.test.ts | 42 +++++++++++++++++-------------- 4 files changed, 35 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 0ac5c4f..5211d87 100644 --- a/README.md +++ b/README.md @@ -292,12 +292,12 @@ const userWantToLoad = createEvent() sample({ clock: userWantToSave, fn: () => 'some data' - target: persist.set, + target: persist.setFx, }) sample({ source: userWantToLoad, - target: persist.get, + target: persist.getFx, }) ``` @@ -312,15 +312,15 @@ sample({ An object with fields: -- `get` (_Effect_): to get value from storage. -- `set` (_Effect_): to set value to storage. -- `remove` (_Effect_): to remove value from storage. +- `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: get.fail, + clock: getFx.fail, target: sendLogToSentry, }) ``` diff --git a/src/core/create-storage.ts b/src/core/create-storage.ts index 6d2972f..cc0f5db 100644 --- a/src/core/create-storage.ts +++ b/src/core/create-storage.ts @@ -174,8 +174,8 @@ export function createStorage( } return { - get: getFx, - set: setFx, - remove: removeFx, + getFx, + setFx, + removeFx, } } diff --git a/src/types.ts b/src/types.ts index 5bb3576..e3c6ae3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -122,9 +122,9 @@ export interface Persist { } export interface StorageHandles { - get: Effect> - set: Effect> - remove: Effect> + getFx: Effect> + setFx: Effect> + removeFx: Effect> } export interface ConfigCreateStorage { diff --git a/tests/core-create-storage.test.ts b/tests/core-create-storage.test.ts index 47b2767..17a9bd2 100644 --- a/tests/core-create-storage.test.ts +++ b/tests/core-create-storage.test.ts @@ -23,9 +23,9 @@ test('should exports effects', () => { assert.type(createStorage, 'function') const ret = createStorage('test-key', { adapter }) assert.type(ret, 'object') - assert.type(ret.get, 'function') - assert.type(ret.set, 'function') - assert.type(ret.remove, 'function') + assert.type(ret.getFx, 'function') + assert.type(ret.setFx, 'function') + assert.type(ret.removeFx, 'function') }) test('should be ok on good parameters', () => { @@ -98,7 +98,7 @@ test('should handle wrong parameters', () => { test('should get and set value from storage', async () => { const watch = snoop(() => undefined) - const { get: getFx, set: setFx } = createStorage('test-get-set-1', { + const { getFx, setFx } = createStorage('test-get-set-1', { adapter, }) @@ -137,24 +137,24 @@ test('should get and set value from storage', async () => { }) test('should remove value from storage', async () => { - const { set, get, remove } = createStorage('test-get-set-1', { + const { setFx, getFx, removeFx } = createStorage('test-get-set-1', { adapter, }) - await set(1) + await setFx(1) - assert.is(await get(), 1) + assert.is(await getFx(), 1) - await remove() + await removeFx() - assert.is(await get(), undefined) + 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 { get: getFx, set: setFx } = createStorage('test-get-set-2', { + const { getFx, setFx } = createStorage('test-get-set-2', { adapter: memory, area, }) @@ -197,7 +197,7 @@ test('should get and set value from storage (with adapter factory)', async () => test('should get and set value from async storage', async () => { const watch = snoop(() => undefined) - const { get: getFx, set: setFx } = createStorage('test-get-set-3', { + const { getFx, setFx } = createStorage('test-get-set-3', { adapter: async(adapter), }) @@ -239,11 +239,11 @@ test('should sync effects for the same adapter-key', () => { const watchSet = snoop(() => undefined) const watchGet = snoop(() => undefined) - const { set: setFx } = createStorage({ + const { setFx } = createStorage({ adapter, key: 'test-sync-same-key-1', }) - const { get: getFx } = createStorage({ + const { getFx } = createStorage({ adapter, key: 'test-sync-same-key-1', }) @@ -298,7 +298,7 @@ test('should sync with `persist` for the same adapter-key', async () => { assert.is($store.getState(), 11) // did not change assert.is(watch.callCount, 1) // did not trigger - const { get: getFx, set: setFx } = createStorage({ + const { getFx, setFx } = createStorage({ adapter, key: 'test-sync-same-key-2', }) @@ -349,7 +349,7 @@ test('should sync with `persist` for the same adapter-key', async () => { test('should handle synchronous error in `get` and `set` effects', () => { const watch = snoop(() => undefined) - const { get: getFx, set: setFx } = createStorage('test-sync-throw', { + const { getFx, setFx } = createStorage('test-sync-throw', { adapter: () => ({ get: () => { throw 'get test error' @@ -407,7 +407,7 @@ test('should handle synchronous error in `get` and `set` effects', () => { test('should handle asynchronous error in `get` and `set` effects', async () => { const watch = snoop(() => undefined) - const { get: getFx, set: setFx } = createStorage('test-async-throw', { + const { getFx, setFx } = createStorage('test-async-throw', { adapter: () => ({ get: async () => Promise.reject('get test error'), set: async () => Promise.reject('set test error'), @@ -424,7 +424,9 @@ test('should handle asynchronous error in `get` and `set` effects', async () => try { await getFx() assert.unreachable('getFx should have thrown') - } catch (e) {} + } catch (e) { + // ok + } assert.is(watch.callCount, 2) assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger @@ -445,7 +447,9 @@ test('should handle asynchronous error in `get` and `set` effects', async () => try { await setFx(1) assert.unreachable('setFx should have thrown') - } catch (e) {} + } catch (e) { + // ok + } assert.is(watch.callCount, 4) assert.equal(watch.calls[2].arguments, [1]) // setFx trigger @@ -469,7 +473,7 @@ test('should hide internal implementation with `get` effect', ( const watch = snoop((_) => undefined) const fail = createEvent() - const { get: getFx, set: setFx } = createStorage('test-throw-box', { + const { getFx, setFx } = createStorage('test-throw-box', { adapter: (_, update) => { fail.watch(() => { update(() => { From 9e4b685da1b8f0e0407863e9100405d3df45cabd Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Tue, 6 Aug 2024 19:36:27 +0700 Subject: [PATCH 09/12] Renaming finish --- tests/contract-create-storage.test.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/contract-create-storage.test.ts b/tests/contract-create-storage.test.ts index 71482c7..4806bd4 100644 --- a/tests/contract-create-storage.test.ts +++ b/tests/contract-create-storage.test.ts @@ -38,7 +38,7 @@ test('shoult validate storage value on get', () => { mockStorage.setItem('number1', '42') - const { get: getFx } = createStorage({ + const { getFx } = createStorage({ adapter: storageAdapter, key: 'number1', contract: (raw): raw is number => typeof raw === 'number', @@ -67,7 +67,7 @@ test('shoult fail on invalid initial storage value with simple contract', () => mockStorage.setItem('number2', '"invalid"') // valid JSON, but invalid number - const { get: getFx } = createStorage({ + const { getFx } = createStorage({ adapter: storageAdapter, key: 'number2', contract: (raw): raw is number => typeof raw === 'number', @@ -101,12 +101,12 @@ test('should handle sync effects with same key and different validators', () => const watchPlain = snoop(() => undefined) const watchBase64 = snoop(() => undefined) - const { get: getPlainFx, set: setPlainFx } = createStorage({ + const { getFx: getPlainFx, setFx: setPlainFx } = createStorage({ adapter: storageAdapter, key: 'contract-same-key-1', contract: (raw): raw is string => typeof raw === 'string', }) - const { get: getBase64Fx, set: setBase64Fx } = createStorage({ + const { getFx: getBase64Fx, setFx: setBase64Fx } = createStorage({ adapter: storageAdapter, key: 'contract-same-key-1', contract: (raw): raw is string => @@ -189,7 +189,7 @@ test('should handle sync with `persist` with different validators, update from s key: 'contract-same-key-2', }) - const { get: getFx, set: setFx } = createStorage({ + const { getFx, setFx } = createStorage({ adapter: storageAdapter, key: 'contract-same-key-2', contract: (raw): raw is string => @@ -238,7 +238,7 @@ test('should handle sync with `persist` with different validators, update from s fail, }) - const { set: setFx } = createStorage({ + const { setFx } = createStorage({ adapter: storageAdapter, key: 'contract-same-key-3', contract: (raw): raw is string => @@ -272,7 +272,7 @@ test('shoult validate storage value on get with complex contract (valid)', () => mockStorage.setItem('asteroid0', '{"type":"asteroid","mass":42}') - const { get: getFx } = createStorage({ + const { getFx } = createStorage({ adapter: storageAdapter, key: 'asteroid0', contract: runtypeContract(Asteroid), @@ -304,7 +304,7 @@ test('shoult validate storage value on get with complex contract (valid undefine }) ) - const { get: getFx } = createStorage({ + const { getFx } = createStorage({ adapter: storageAdapter, key: 'asteroid1', contract: runtypeContract(Asteroid), @@ -334,7 +334,7 @@ test('shoult validate storage value on get with complex contract (invalid undefi mass: Number, }) - const { get: getFx } = createStorage({ + const { getFx } = createStorage({ adapter: storageAdapter, key: 'asteroid1', contract: runtypeContract(Asteroid), @@ -374,7 +374,7 @@ test('shoult validate storage value on get with complex contract (invalid)', () mockStorage.setItem('asteroid2', '42') - const { get: getFx } = createStorage({ + const { getFx } = createStorage({ adapter: storageAdapter, key: 'asteroid2', contract: runtypeContract(Asteroid), @@ -407,7 +407,7 @@ test('shoult validate storage value on get with complex contract (invalid)', () test('should validate value on storage external update', async () => { const watch = snoop(() => undefined) - const { get: getFx } = createStorage({ + const { getFx } = createStorage({ adapter: storageAdapter, key: 'storage-contract-counter-1', contract: runtypeContract(Number), From 7ddff942111cbaf7217307e5cbfc082214370f95 Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Tue, 6 Aug 2024 19:37:59 +0700 Subject: [PATCH 10/12] Renaming finish --- tests/context-create-storage.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/context-create-storage.test.ts b/tests/context-create-storage.test.ts index e5cb9cb..99e94ea 100644 --- a/tests/context-create-storage.test.ts +++ b/tests/context-create-storage.test.ts @@ -14,7 +14,7 @@ test('context store value should be passed to adapter', async () => { const context = createEvent() - const { get: getFx, set: setFx } = createStorage('test-context-1', { + const { getFx, setFx } = createStorage('test-context-1', { adapter: () => ({ get: watch.fn, set: watch.fn }), context: createStore(42).on(context, (_, ctx) => ctx), }) @@ -48,7 +48,7 @@ test('context event value should be passed to adapter', async () => { const context = createEvent() - const { get: getFx, set: setFx } = createStorage('test-context-2', { + const { getFx, setFx } = createStorage('test-context-2', { adapter: () => ({ get: watch.fn, set: watch.fn }), context, }) @@ -82,7 +82,7 @@ test('contexts in different scopes should be different', async () => { const context = createEvent<{ name: string }>() - const { get: getFx, set: setFx } = createStorage('test-context-3', { + const { getFx, setFx } = createStorage('test-context-3', { adapter: () => ({ get: watch.fn, set: watch.fn }), context, }) @@ -159,7 +159,7 @@ test('contexts should update scope / also works with adapter factory', async () const context = createEvent() - const { get: getFx } = createStorage('test-context-4', { + const { getFx } = createStorage('test-context-4', { adapter: adapterFactory, context, contract: (raw: unknown): raw is number => typeof raw === 'number', From 8e18ec176e1e84ce92cac4ea9a4e7fb8a0e6cd0b Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Tue, 6 Aug 2024 19:44:11 +0700 Subject: [PATCH 11/12] Edit size-limits --- .size-limit.js | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 3c16c20..16fa455 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: '988 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: '3863 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: '983 B', + limit: '984 B', import: '{ persist }', 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: '1458 B', + limit: '1464 B', import: '{ persist }', ignore: ['effector'], gzip: true, @@ -130,7 +130,7 @@ module.exports = [ { name: 'core adapter, es module', path: 'build/index.js', - limit: '1438 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: '1440 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: '1455 B', + limit: '1463 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: '2292 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: '1076 B', + limit: '1078 B', import: '{ persist }', 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: '370 B', + limit: '371 B', import: '{ broadcast }', ignore: ['effector'], gzip: true, From 6346d2d1bdb572b37558da7c8b5ef06ba886be8f Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Tue, 6 Aug 2024 19:46:58 +0700 Subject: [PATCH 12/12] Edit size-limits --- .size-limit.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 16fa455..a3b5a7f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -29,7 +29,7 @@ module.exports = [ { name: 'core persist, cjs module', path: 'build/core/index.cjs', - limit: '1640 B', + limit: '1643 B', // import: '{ persist }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -119,7 +119,7 @@ module.exports = [ { name: '`localStorage` persist, cjs module', path: 'build/local/index.cjs', - limit: '2234 B', + limit: '2238 B', // import: '{ persist }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -159,7 +159,7 @@ module.exports = [ { name: '`sessionStorage` persist, cjs module', path: 'build/session/index.cjs', - limit: '2229 B', + limit: '2234 B', // import: '{ persist }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true, @@ -195,7 +195,7 @@ module.exports = [ { name: 'memory adapter, cjs module', path: 'build/memory/index.cjs', - limit: '1813 B', + limit: '1816 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: '2069 B', + limit: '2074 B', // import: '{ broadcast }', // tree-shaking is not working with cjs ignore: ['effector'], gzip: true,