diff --git a/.size-limit.js b/.size-limit.js
index 7d6b255..a3b5a7f 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -3,7 +3,7 @@ module.exports = [
{
name: 'root persist, es module',
path: 'build/index.js',
- limit: '985 B',
+ limit: '989 B',
import: '{ persist }',
ignore: ['effector'],
gzip: true,
@@ -11,7 +11,7 @@ module.exports = [
{
name: 'root persist, cjs module',
path: 'build/index.cjs',
- limit: '3250 B',
+ limit: '3869 B',
// import: '{ persist }', // tree-shaking is not working with cjs
ignore: ['effector'],
gzip: true,
@@ -21,7 +21,7 @@ module.exports = [
{
name: 'core persist, es module',
path: 'build/core/index.js',
- limit: '980 B',
+ limit: '984 B',
import: '{ persist }',
ignore: ['effector'],
gzip: true,
@@ -29,7 +29,7 @@ module.exports = [
{
name: 'core persist, cjs module',
path: 'build/core/index.cjs',
- limit: '1166 B',
+ limit: '1643 B',
// import: '{ persist }', // tree-shaking is not working with cjs
ignore: ['effector'],
gzip: true,
@@ -39,7 +39,7 @@ module.exports = [
{
name: 'tools, es module',
path: 'build/tools/index.js',
- limit: '289 B',
+ limit: '292 B',
import: '{ async, either, farcached }',
ignore: ['effector'],
gzip: true,
@@ -47,7 +47,7 @@ module.exports = [
{
name: 'tools, cjs module',
path: 'build/tools/index.cjs',
- limit: '482 B',
+ limit: '485 B',
// import: '{ async, either, farcached }', // tree-shaking is not working with cjs
ignore: ['effector'],
gzip: true,
@@ -65,7 +65,7 @@ module.exports = [
{
name: 'nil adapter, cjs module',
path: 'build/nil/index.cjs',
- limit: '139 B',
+ limit: '140 B',
// import: '{ nil }', // tree-shaking is not working with cjs
ignore: ['effector'],
gzip: true,
@@ -75,7 +75,7 @@ module.exports = [
{
name: 'log adapter, es module',
path: 'build/log/index.js',
- limit: '139 B',
+ limit: '140 B',
import: '{ log }',
ignore: ['effector'],
gzip: true,
@@ -93,7 +93,7 @@ module.exports = [
{
name: 'storage adapter, es module',
path: 'build/storage/index.js',
- limit: '399 B',
+ limit: '401 B',
import: '{ storage }',
ignore: ['effector'],
gzip: true,
@@ -111,7 +111,7 @@ module.exports = [
{
name: '`localStorage` persist, es module',
path: 'build/local/index.js',
- limit: '1453 B',
+ limit: '1464 B',
import: '{ persist }',
ignore: ['effector'],
gzip: true,
@@ -119,7 +119,7 @@ module.exports = [
{
name: '`localStorage` persist, cjs module',
path: 'build/local/index.cjs',
- limit: '1732 B',
+ limit: '2238 B',
// import: '{ persist }', // tree-shaking is not working with cjs
ignore: ['effector'],
gzip: true,
@@ -130,7 +130,7 @@ module.exports = [
{
name: 'core adapter, es module',
path: 'build/index.js',
- limit: '1459 B',
+ limit: '1444 B',
import: '{ persist, local }',
ignore: ['effector'],
gzip: true,
@@ -138,7 +138,7 @@ module.exports = [
{
name: 'core adapter factory, es module',
path: ['build/index.js', 'build/local/index.js'],
- limit: '1462 B',
+ limit: '1446 B',
import: {
'build/index.js': '{ persist }',
'build/local/index.js': '{ local }',
@@ -151,7 +151,7 @@ module.exports = [
{
name: '`sessionStorage` persist, es module',
path: 'build/session/index.js',
- limit: '1451 B',
+ limit: '1463 B',
import: '{ persist }',
ignore: ['effector'],
gzip: true,
@@ -159,7 +159,7 @@ module.exports = [
{
name: '`sessionStorage` persist, cjs module',
path: 'build/session/index.cjs',
- limit: '1729 B',
+ limit: '2234 B',
// import: '{ persist }', // tree-shaking is not working with cjs
ignore: ['effector'],
gzip: true,
@@ -169,7 +169,7 @@ module.exports = [
{
name: 'query string persist, es module',
path: 'build/query/index.js',
- limit: '1509 B',
+ limit: '1514 B',
import: '{ persist }',
ignore: ['effector'],
gzip: true,
@@ -177,7 +177,7 @@ module.exports = [
{
name: 'query string persist, cjs module',
path: 'build/query/index.cjs',
- limit: '1802 B',
+ limit: '2295 B',
// import: '{ persist }', // tree-shaking is not working with cjs
ignore: ['effector'],
gzip: true,
@@ -187,7 +187,7 @@ module.exports = [
{
name: 'memory adapter, es module',
path: 'build/memory/index.js',
- limit: '1073 B',
+ limit: '1078 B',
import: '{ persist }',
ignore: ['effector'],
gzip: true,
@@ -195,7 +195,7 @@ module.exports = [
{
name: 'memory adapter, cjs module',
path: 'build/memory/index.cjs',
- limit: '1304 B',
+ limit: '1816 B',
// import: '{ persist }', // tree-shaking is not working with cjs
ignore: ['effector'],
gzip: true,
@@ -205,7 +205,7 @@ module.exports = [
{
name: 'generic async storage adapter, es module',
path: 'build/async-storage/index.js',
- limit: '162 B',
+ limit: '165 B',
import: '{ asyncStorage }',
ignore: ['effector'],
gzip: true,
@@ -223,7 +223,7 @@ module.exports = [
{
name: 'broadcast channel adapter, es module',
path: 'build/broadcast/index.js',
- limit: '1317 B',
+ limit: '371 B',
import: '{ broadcast }',
ignore: ['effector'],
gzip: true,
@@ -231,7 +231,7 @@ module.exports = [
{
name: 'broadcast channel adapter, cjs module',
path: 'build/broadcast/index.cjs',
- limit: '1568 B',
+ limit: '2074 B',
// import: '{ broadcast }', // tree-shaking is not working with cjs
ignore: ['effector'],
gzip: true,
diff --git a/README.md b/README.md
index 23df96f..5211d87 100644
--- a/README.md
+++ b/README.md
@@ -174,7 +174,7 @@ In order to synchronize _something_, you need to specify effector units. Dependi
- `keyPrefix` ([_string_]): Prefix, used in adapter, to be concatenated to `key`. By default = `''`.
- `operation` (_`'set'`_ | _`'get'`_ | _`'validate'`_): Type of operation, read (get), write (set) or validation against contract (validate).
- `error` ([_Error_]): Error instance
- - `value`? (_any_): In case of _'set'_ operation — value from `store`. In case of _'get'_ operation could contain raw value from storage or could be empty.
+ - `value`? (_any_): In case of _'set'_ operation — value from `store`. In case of _'get'_ and _'validate'_ operations could contain raw value from storage or could be empty.
- `finally`? ([_Event_] | [_Effect_] | [_Store_]): Unit, which will be triggered either in case of success or error.
Payload structure:
- `key` ([_string_]): Same `key` as above.
@@ -274,6 +274,57 @@ persist({
- Custom `persist` function, with predefined adapter options.
+## `createStorage` factory
+
+In rare cases you might want to get a granular control over a storage and manually set or get values from it. You can use `createStorage` factory for that.
+
+```javascript
+import { sample, createEvent } from 'effector'
+import { createStorage } from 'effector-storage/local'
+
+const persist = createStorage('my-storage')
+
+const userWantToSave = createEvent()
+const userWantToLoad = createEvent()
+
+// ---8<---
+
+sample({
+ clock: userWantToSave,
+ fn: () => 'some data'
+ target: persist.setFx,
+})
+
+sample({
+ source: userWantToLoad,
+ target: persist.getFx,
+})
+```
+
+### Options
+
+- `key`? ([_string_]): Key for local/session storage, to store value in. If omitted — `store` name is used. **Note!** If `key` is not specified, `store` _must_ have a `name`! You can use `'effector/babel-plugin'` to have those names automatically.
+- `keyPrefix`? ([_string_]): Prefix, used in adapter, to be concatenated to `key`. By default = `''`.
+- `context`? ([_Event_] | [_Effect_] | [_Store_]): Unit, which can set a special context for adapter.
+- `contract`? ([_Contract_]): Rule to statically validate data from storage.
+
+### Returns
+
+An object with fields:
+
+- `getFx` (_Effect_): to get value from storage.
+- `setFx` (_Effect_): to set value to storage.
+- `removeFx` (_Effect_): to remove value from storage.
+
+All fields of returned object are _Effects_ units, so you can use them in `sample` as any other _Effects_. For example, you can add logging on failed storage operations:
+
+```javascript
+sample({
+ clock: getFx.fail,
+ target: sendLogToSentry,
+})
+```
+
## Advanced usage
`effector-storage` consists of a _core_ module and _adapter_ modules.
@@ -300,8 +351,8 @@ interface StorageAdapter {
key: string,
update: (raw?: any) => void
): {
- get(raw?: any, ctx?: any): State | Promise | undefined
- set(value: State, ctx?: any): void
+ get(raw?: any, ctx?: any): State | undefined | Promise
+ set(value: State, ctx?: any): void | Promise
}
keyArea?: any
noop?: boolean
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 3a29f8d..1a4f280 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -26,10 +26,7 @@ export default tseslint.config(
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/unified-signatures': 'off',
'@typescript-eslint/consistent-type-definitions': 'off',
- '@typescript-eslint/no-invalid-void-type': [
- 'error',
- { allowAsThisParameter: true },
- ],
+ '@typescript-eslint/no-invalid-void-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': [
'error',
{ allowArgumentsExplicitlyTypedAsAny: true },
diff --git a/rollup.config.mjs b/rollup.config.mjs
index da55d6a..a845451 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -71,6 +71,7 @@ const src = (name) => ({
},
format: {
comments: false,
+ preserve_annotations: true,
},
}),
diff --git a/src/broadcast/index.ts b/src/broadcast/index.ts
index 47dc9d7..b53cd2e 100644
--- a/src/broadcast/index.ts
+++ b/src/broadcast/index.ts
@@ -3,10 +3,12 @@ import type {
ConfigPersist as BaseConfigPersist,
ConfigStore as BaseConfigStore,
ConfigSourceTarget as BaseConfigSourceTarget,
+ ConfigCreateStorage as BaseConfigCreateStorage,
StorageAdapter,
+ StorageHandles,
} from '../types'
import type { BroadcastConfig } from './adapter'
-import { persist as base } from '../core'
+import { persist as base, createStorage as baseCreateStorage } from '../core'
import { nil } from '../nil'
import { adapter } from './adapter'
@@ -37,6 +39,19 @@ export interface Persist {
(config: ConfigStore): Subscription
}
+export interface ConfigCreateStorage
+ extends BaseConfigCreateStorage {}
+
+export interface CreateStorage {
+ (
+ key: string,
+ config?: BroadcastConfig & BaseConfigCreateStorage
+ ): StorageHandles
+ (
+ config: BroadcastConfig & BaseConfigCreateStorage & { key: string }
+ ): StorageHandles
+}
+
/**
* Function, checking if `BroadcastChannel` exists and accessible
*/
@@ -45,7 +60,7 @@ function supports() {
}
/**
- * Creates BroadcastChannel string adapter
+ * Creates BroadcastChannel adapter
*/
broadcast.factory = true as const
export function broadcast(config?: BroadcastConfig): StorageAdapter {
@@ -72,4 +87,20 @@ export function createPersist(defaults?: ConfigPersist): Persist {
/**
* Default partially applied `persist`
*/
-export const persist = createPersist()
+export const persist = /*#__PURE__*/ createPersist()
+
+/**
+ * Creates custom partially applied `createStorage`
+ * with predefined BroadcastChannel adapter
+ */
+export function createStorageFactory(
+ defaults?: ConfigCreateStorage
+): CreateStorage {
+ return (...configs: any[]) =>
+ baseCreateStorage({ adapter: broadcast }, defaults, ...configs)
+}
+
+/**
+ * Default partially applied `createStorage`
+ */
+export const createStorage = /*#__PURE__*/ createStorageFactory()
diff --git a/src/core/area.ts b/src/core/area.ts
index a3f0e43..8fb650a 100644
--- a/src/core/area.ts
+++ b/src/core/area.ts
@@ -1,15 +1,18 @@
-import type { Store } from 'effector'
+import type { StoreWritable } from 'effector'
import { createStore } from 'effector'
/**
* Keys areas / namespaces cache
*/
-const areas = new Map>>()
+const areas = new Map>>()
/**
* Get store, responsible for the key in key area / namespace
*/
-export function getAreaStorage(keyArea: any, key: string): Store {
+export function getAreaStorage(
+ keyArea: any,
+ key: string
+): StoreWritable {
let area = areas.get(keyArea)
if (area === undefined) {
area = new Map()
diff --git a/src/core/create-storage.ts b/src/core/create-storage.ts
new file mode 100644
index 0000000..cc0f5db
--- /dev/null
+++ b/src/core/create-storage.ts
@@ -0,0 +1,181 @@
+import type { Event, Effect } from 'effector'
+import type {
+ ConfigAdapter,
+ ConfigAdapterFactory,
+ StorageHandles,
+ ConfigCreateStorage,
+ Contract,
+ Fail,
+} from '../types'
+import {
+ attach,
+ // clearNode,
+ // createNode,
+ createStore,
+ is,
+ sample,
+ scopeBind,
+} from 'effector'
+import { getAreaStorage } from './area'
+
+// helper function to validate data with contract
+function validate(raw: unknown, contract?: Contract) {
+ if (
+ !contract || // no contract -> data is valid
+ ('isData' in contract ? contract.isData(raw) : contract(raw))
+ ) return raw as T // prettier-ignore
+ throw (contract as any).getErrorMessages?.(raw) ?? ['Invalid data']
+}
+
+type Config = Partial<
+ ConfigCreateStorage & { key: string } & (
+ | ConfigAdapter
+ | ConfigAdapterFactory
+ )
+>
+
+export function createStorage(
+ ...configs: (string | Config | undefined)[]
+): StorageHandles {
+ const config: Config = {}
+ for (const cfg of configs) {
+ Object.assign(config, typeof cfg === 'string' ? { key: cfg } : cfg)
+ }
+
+ const {
+ adapter: adapterOrFactory,
+ context,
+ key: keyName,
+ keyPrefix = '',
+ contract,
+ } = config
+
+ if (!adapterOrFactory) {
+ throw Error('Adapter is not defined')
+ }
+ if (!keyName) {
+ throw Error('Key is not defined')
+ }
+
+ const adapter =
+ 'factory' in adapterOrFactory ? adapterOrFactory(config) : adapterOrFactory
+
+ const key = keyName
+ const storage = getAreaStorage(
+ adapter.keyArea || adapter,
+ keyPrefix + key
+ )
+
+ // const region = createNode()
+ // let disposable: (_: any) => void = () => {}
+ // const desist = () => disposable(clearNode(region))
+
+ const ctx = createStore<[any?]>(
+ [is.store(context) ? context.defaultState : undefined],
+ { serialize: 'ignore' }
+ )
+
+ const value = adapter(keyPrefix + key, (x) => {
+ update(x)
+ })
+
+ // if (typeof value === 'function') {
+ // disposable = value
+ // }
+
+ const fail = (
+ operation: 'get' | 'set' | 'validate',
+ error: unknown,
+ value?: any
+ ) =>
+ ({
+ key,
+ keyPrefix,
+ operation,
+ error,
+ value: typeof value === 'function' ? undefined : value, // hide internal "box" implementation
+ }) as Fail
+
+ const op = (
+ operation: 'get' | 'set' | 'validate',
+ fn: (value: any, arg: any) => T,
+ value: any,
+ arg: any
+ ): T => {
+ try {
+ return fn(value, arg)
+ } catch (error) {
+ throw fail(operation, error, value)
+ }
+ }
+
+ const getFx = attach({
+ source: ctx,
+ effect([ref], raw?: void) {
+ const result = op('get', value.get, raw, ref) as any
+ return typeof result?.then === 'function'
+ ? Promise.resolve(result)
+ .then((result) => op('validate', validate, result, contract))
+ .catch((error) => {
+ throw fail('get', error, raw)
+ })
+ : op('validate', validate, result, contract)
+ },
+ }) as Effect // as Effect>
+
+ const setFx = attach({
+ source: ctx,
+ effect([ref], state: State) {
+ const result = op('set', value.set, state, ref)
+ if (typeof result?.then === 'function') {
+ return Promise.resolve(result)
+ .then(() => undefined)
+ .catch((error) => {
+ throw fail('set', error, state)
+ })
+ }
+ },
+ }) as Effect // as Effect>
+
+ const removeFx = attach({
+ mapParams: () => undefined as any,
+ effect: setFx,
+ }) as Effect>
+
+ let update: (raw?: any) => any = getFx
+ ctx.updates.watch(() => {
+ update = scopeBind(getFx as any, { safe: true })
+ })
+
+ const external = createStore(true, { serialize: 'ignore' }) //
+ .on([getFx.finally, setFx.finally], () => false)
+
+ sample({
+ clock: [getFx.doneData as Event, sample(setFx, setFx.done)],
+ filter: (x) => x !== undefined,
+ target: storage,
+ })
+
+ sample({
+ clock: storage,
+ filter: external,
+ fn: () => undefined,
+ target: getFx,
+ })
+
+ sample({
+ clock: [getFx.finally, setFx.finally],
+ fn: () => true,
+ target: external,
+ })
+
+ if (context) {
+ ctx.on(context, ([ref], payload) => [payload === undefined ? ref : payload])
+ }
+
+ return {
+ getFx,
+ setFx,
+ removeFx,
+ }
+}
diff --git a/src/core/index.ts b/src/core/index.ts
index 8522c6d..0b68565 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -1,265 +1,2 @@
-import type { Effect, Subscription } from 'effector'
-import type {
- ConfigAdapter,
- ConfigAdapterFactory,
- ConfigPersist,
- ConfigSourceTarget,
- ConfigStore,
- Contract,
- Done,
- Fail,
- Finally,
-} from '../types'
-import {
- attach,
- clearNode,
- createEvent,
- createEffect,
- createNode,
- createStore,
- is,
- sample,
- scopeBind,
- withRegion,
-} from 'effector'
-import { getAreaStorage } from './area'
-
-// helper function to swap two function arguments
-// end extract current context from ref-box
-const contextual =
- (fn: (value: T, ctx?: C) => R) =>
- ([ref]: [C?], value: T) =>
- fn(value, ref)
-
-// helper function to validate data with contract
-const contracted =
- (contract?: Contract) =>
- (raw: unknown) =>
- !contract || // no contract -> data is valid
- raw === undefined || // `undefined` is always valid
- ('isData' in contract ? contract.isData(raw) : contract(raw))
- ? (raw as T)
- : (() => {
- throw 'getErrorMessages' in contract
- ? contract.getErrorMessages(raw)
- : undefined
- })()
-
-/**
- * Default sink for unhandled errors
- */
-const sink = createEvent>()
-sink.watch((payload) => console.error(payload.error))
-
-/**
- * Main `persist` function
- */
-export function persist(
- config: Partial<
- (ConfigAdapter | ConfigAdapterFactory) &
- ConfigPersist &
- ConfigStore &
- ConfigSourceTarget
- >
-): Subscription {
- const {
- adapter: adapterOrFactory,
- store,
- source = store,
- target = store,
- clock = source,
- done,
- fail = sink,
- finally: anyway,
- pickup,
- context,
- key: keyName,
- keyPrefix = '',
- contract,
- } = config
-
- if (!adapterOrFactory) {
- throw Error('Adapter is not defined')
- }
- if (!source) {
- throw Error('Store or source is not defined')
- }
- if (!target) {
- throw Error('Target is not defined')
- }
- if (!keyName && source.shortName === (source as any).id) {
- throw Error('Key or name is not defined')
- }
- if (source === target && !is.store(source)) {
- throw Error('Source must be different from target')
- }
-
- // get default value from store, if given
- // this is used in adapter factory
- if ((config as any).def === undefined && is.store(source)) {
- ;(config as any).def = source.defaultState
- }
-
- const adapter =
- 'factory' in adapterOrFactory ? adapterOrFactory(config) : adapterOrFactory
-
- const key = keyName || source.shortName
- const storage = getAreaStorage(
- adapter.keyArea || adapter,
- keyPrefix + key
- )
- const region = createNode()
- let disposable: (_: any) => void = () => {}
- const desist = () => disposable(clearNode(region))
-
- const op =
- (operation: 'get' | 'set' | 'validate') =>
- ({ status = 'fail', params, result, error }: any): any =>
- status === 'done'
- ? {
- status,
- key,
- keyPrefix,
- operation,
- value: operation === 'get' ? result : params,
- }
- : {
- status,
- key,
- keyPrefix,
- operation,
- value: typeof params === 'function' ? undefined : params, // hide internal "box" implementation
- error,
- }
-
- // create all auxiliary units and nodes within the region,
- // to be able to remove them all at once on unsubscription
- withRegion(region, () => {
- const ctx = createStore<[any?]>([], { serialize: 'ignore' })
-
- const value = adapter(keyPrefix + key, (x) => {
- update(x)
- })
-
- if (typeof value === 'function') {
- disposable = value
- }
-
- const getFx = attach({
- source: ctx,
- effect: contextual(value.get),
- }) as any as Effect
-
- const setFx = attach({
- source: ctx,
- effect: contextual(value.set),
- }) as any as Effect
-
- const validateFx = createEffect(contracted(contract))
-
- const complete = createEvent>()
-
- const trigger = createEvent()
-
- let update: (raw?: any) => any = getFx
- ctx.updates.watch(() => {
- update = scopeBind(getFx as any, { safe: true })
- })
-
- sample({
- clock, // `clock` is always defined, as long as `source` is defined
- source,
- target: trigger,
- } as any)
-
- sample({
- clock: trigger,
- source: storage,
- filter: (current, proposed) => proposed !== current,
- fn: (_, proposed) => proposed,
- target: setFx,
- })
-
- sample({
- clock: [getFx.doneData, sample(setFx, setFx.done)],
- filter: (x?: T | undefined): x is T => x !== undefined,
- target: storage as any,
- })
-
- sample({
- clock: [getFx.doneData, storage],
- target: validateFx as any,
- })
-
- sample({
- clock: validateFx.doneData,
- filter: (x?: T | undefined): x is T => x !== undefined,
- target: target as any,
- })
-
- sample({
- clock: [
- getFx.finally.map(op('get')),
- setFx.finally.map(op('set')),
- validateFx.fail.map(op('validate')),
- ],
- target: complete,
- })
-
- // effector 23 introduced "targetable" types - UnitTargetable, StoreWritable, EventCallable
- // so, targeting non-targetable unit is not allowed anymore.
- // soothe typescript by casting to any for a while, until we drop support for effector 22 branch
- if (anyway) {
- sample({
- clock: complete,
- target: anyway as any,
- })
- }
-
- if (done) {
- sample({
- clock: complete,
- filter: ({ status }) => status === 'done',
- fn: ({ key, keyPrefix, operation, value }): Done => ({
- key,
- keyPrefix,
- operation,
- value,
- }),
- target: done as any,
- })
- }
-
- sample({
- clock: complete,
- filter: ({ status }) => status === 'fail',
- fn: ({ key, keyPrefix, operation, error, value }: any): Fail => ({
- key,
- keyPrefix,
- operation,
- error,
- value,
- }),
- target: fail as any,
- })
-
- if (context) {
- ctx.on(context, ([ref], payload) => [
- payload === undefined ? ref : payload,
- ])
- }
-
- if (pickup) {
- // pick up value from storage ONLY on `pickup` update
- sample({ clock: pickup, fn: () => undefined, target: getFx })
- ctx.on(pickup, ([ref], payload) => [
- payload === undefined ? ref : payload,
- ])
- } else {
- // kick getter to pick up initial value from storage
- getFx()
- }
- })
-
- return (desist.unsubscribe = desist)
-}
+export { persist } from './persist'
+export { createStorage } from './create-storage'
diff --git a/src/core/persist.ts b/src/core/persist.ts
new file mode 100644
index 0000000..c67e6a5
--- /dev/null
+++ b/src/core/persist.ts
@@ -0,0 +1,257 @@
+import type { Event, Effect, Subscription } from 'effector'
+import type {
+ ConfigAdapter,
+ ConfigAdapterFactory,
+ ConfigPersist,
+ ConfigSourceTarget,
+ ConfigStore,
+ Done,
+ Fail,
+ Finally,
+ FinallyDone,
+ FinallyFail,
+} from '../types'
+import {
+ attach,
+ clearNode,
+ createEvent,
+ createEffect,
+ createNode,
+ createStore,
+ is,
+ sample,
+ scopeBind,
+ withRegion,
+} from 'effector'
+import { getAreaStorage } from './area'
+
+/**
+ * Default sink for unhandled errors
+ */
+const sink = createEvent>()
+sink.watch((payload) => console.error(payload.error))
+
+/**
+ * Main `persist` function
+ */
+export function persist(
+ config: Partial<
+ (ConfigAdapter | ConfigAdapterFactory) &
+ ConfigPersist &
+ ConfigStore &
+ ConfigSourceTarget
+ >
+): Subscription {
+ const {
+ adapter: adapterOrFactory,
+ store,
+ source = store,
+ target = store,
+ clock = source,
+ done,
+ fail = sink,
+ finally: anyway,
+ pickup,
+ context,
+ key: keyName,
+ keyPrefix = '',
+ contract,
+ } = config
+
+ if (!adapterOrFactory) {
+ throw Error('Adapter is not defined')
+ }
+ if (!source) {
+ throw Error('Store or source is not defined')
+ }
+ if (!target) {
+ throw Error('Target is not defined')
+ }
+ if (!keyName && source.shortName === (source as any).id) {
+ throw Error('Key or name is not defined')
+ }
+ if (source === target && !is.store(source)) {
+ throw Error('Source must be different from target')
+ }
+
+ // get default value from store, if given
+ // this is used in adapter factory
+ if ((config as any).def === undefined && is.store(source)) {
+ ;(config as any).def = source.defaultState
+ }
+
+ const adapter =
+ 'factory' in adapterOrFactory ? adapterOrFactory(config) : adapterOrFactory
+
+ const key = keyName || source.shortName
+ const storage = getAreaStorage(
+ adapter.keyArea || adapter,
+ keyPrefix + key
+ )
+ const region = createNode()
+ let disposable: (_: any) => void = () => {}
+ const desist = () => disposable(clearNode(region))
+
+ const op =
+ (operation: 'get' | 'set' | 'validate') =>
+ ({ status = 'fail', params, result, error }: any): any =>
+ status === 'done'
+ ? {
+ status,
+ key,
+ keyPrefix,
+ operation,
+ value: operation === 'get' ? result : params,
+ }
+ : {
+ status,
+ key,
+ keyPrefix,
+ operation,
+ value: typeof params === 'function' ? undefined : params, // hide internal "box" implementation
+ error,
+ }
+
+ // create all auxiliary units and nodes within the region,
+ // to be able to remove them all at once on unsubscription
+ withRegion(region, () => {
+ const ctx = createStore<[any?]>(
+ [is.store(context) ? context.defaultState : undefined],
+ { serialize: 'ignore' }
+ )
+
+ const value = adapter(keyPrefix + key, (x) => {
+ update(x)
+ })
+
+ if (typeof value === 'function') {
+ disposable = value
+ }
+
+ const getFx = attach({
+ source: ctx,
+ effect: ([ref], raw?: any) => value.get(raw, ref),
+ }) as Effect
+
+ const setFx = attach({
+ source: ctx,
+ effect: ([ref], state: State) => value.set(state, ref),
+ }) as Effect
+
+ const validateFx = createEffect((raw) => {
+ if (
+ !contract || // no contract -> data is valid
+ raw === undefined || // `undefined` is always valid
+ ('isData' in contract ? contract.isData(raw) : contract(raw))
+ ) return raw as State // prettier-ignore
+ throw (contract as any).getErrorMessages?.(raw) ?? ['Invalid data']
+ })
+
+ const complete = createEvent>()
+
+ const trigger = createEvent()
+
+ let update: (raw?: any) => any = getFx
+ ctx.updates.watch(() => {
+ update = scopeBind(getFx, { safe: true })
+ })
+
+ sample({
+ clock, // `clock` is always defined, as long as `source` is defined
+ source,
+ target: trigger,
+ } as any)
+
+ sample({
+ clock: trigger,
+ source: storage,
+ filter: (current, proposed) => proposed !== current,
+ fn: (_, proposed) => proposed,
+ target: setFx,
+ })
+
+ sample({
+ clock: [getFx.doneData as Event, sample(setFx, setFx.done)],
+ filter: (x) => x !== undefined,
+ target: storage,
+ })
+
+ sample({
+ clock: [getFx.doneData as Event, storage],
+ target: validateFx,
+ })
+
+ sample({
+ clock: validateFx.doneData,
+ filter: (x) => x !== undefined,
+ target: target as any,
+ })
+
+ sample({
+ clock: [
+ getFx.finally.map(op('get')),
+ setFx.finally.map(op('set')),
+ validateFx.fail.map(op('validate')),
+ ],
+ target: complete,
+ })
+
+ // effector 23 introduced "targetable" types - UnitTargetable, StoreWritable, EventCallable
+ // so, targeting non-targetable unit is not allowed anymore.
+ // soothe typescript by casting to any for a while, until we drop support for effector 22 branch
+ if (anyway) {
+ sample({
+ clock: complete,
+ target: anyway as any,
+ })
+ }
+
+ if (done) {
+ sample({
+ clock: complete,
+ filter: (payload: Finally): payload is FinallyDone =>
+ payload.status === 'done',
+ fn: ({ key, keyPrefix, operation, value }): Done => ({
+ key,
+ keyPrefix,
+ operation,
+ value,
+ }),
+ target: done as any,
+ })
+ }
+
+ sample({
+ clock: complete,
+ filter: (payload: Finally): payload is FinallyFail =>
+ payload.status === 'fail',
+ fn: ({ key, keyPrefix, operation, error, value }): Fail => ({
+ key,
+ keyPrefix,
+ operation,
+ error,
+ value,
+ }),
+ target: fail as any,
+ })
+
+ if (context) {
+ ctx.on(context, ([ref], payload) => [
+ payload === undefined ? ref : payload,
+ ])
+ }
+
+ if (pickup) {
+ // pick up value from storage ONLY on `pickup` update
+ sample({ clock: pickup, fn: () => undefined, target: getFx })
+ ctx.on(pickup, ([ref], payload) => [
+ payload === undefined ? ref : payload,
+ ])
+ } else {
+ // kick getter to pick up initial value from storage
+ getFx()
+ }
+ })
+
+ return (desist.unsubscribe = desist)
+}
diff --git a/src/index.ts b/src/index.ts
index af36a56..8ab883c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,13 @@
-import type { ConfigPersist, Persist } from './types'
-import { persist as base } from './core'
+import type {
+ ConfigPersist,
+ Persist,
+ CreateStorage,
+ ConfigCreateStorage,
+} from './types'
+import {
+ persist as basePersist,
+ createStorage as baseCreateStorage,
+} from './core'
export type {
ConfigPersist,
@@ -12,8 +20,11 @@ export type {
Persist,
Adapter,
DisposableAdapter,
+ StorageHandles,
StorageAdapter,
StorageAdapterFactory,
+ CreateStorage,
+ ConfigCreateStorage,
} from './types'
//
@@ -51,7 +62,7 @@ export { async, either, farcached } from './tools'
*/
export function createPersist(defaults?: ConfigPersist): Persist {
return (config: any) =>
- base({
+ basePersist({
...defaults,
...config,
})
@@ -60,4 +71,18 @@ export function createPersist(defaults?: ConfigPersist): Persist {
/**
* Default `persist`
*/
-export const persist: Persist = base
+export const persist: Persist = basePersist
+
+/**
+ * Creates custom `createStorage`
+ */
+export function createStorageFactory(
+ defaults?: ConfigCreateStorage
+): CreateStorage {
+ return (...configs: any[]) => baseCreateStorage(defaults, ...configs)
+}
+
+/**
+ * Default `createStorage`
+ */
+export const createStorage: CreateStorage = baseCreateStorage
diff --git a/src/local/index.ts b/src/local/index.ts
index 5e249e8..baea1ce 100644
--- a/src/local/index.ts
+++ b/src/local/index.ts
@@ -3,9 +3,11 @@ import type {
ConfigPersist as BaseConfigPersist,
ConfigStore as BaseConfigStore,
ConfigSourceTarget as BaseConfigSourceTarget,
+ ConfigCreateStorage as BaseConfigCreateStorage,
StorageAdapter,
+ StorageHandles,
} from '../types'
-import { persist as base } from '../core'
+import { persist as base, createStorage as baseCreateStorage } from '../core'
import { nil } from '../nil'
import { storage } from '../storage'
@@ -46,6 +48,20 @@ export interface Persist {
(config: ConfigStore): Subscription
}
+export interface ConfigCreateStorage
+ extends BaseConfigCreateStorage {}
+
+export interface CreateStorage {
+ (
+ key: string,
+ config?: LocalStorageConfig & BaseConfigCreateStorage
+ ): StorageHandles
+ (
+ config: LocalStorageConfig &
+ BaseConfigCreateStorage & { key: string }
+ ): StorageHandles
+}
+
/**
* Function, checking if `localStorage` exists
*/
@@ -89,4 +105,20 @@ export function createPersist(defaults?: ConfigPersist): Persist {
/**
* Default partially applied `persist`
*/
-export const persist = createPersist()
+export const persist = /*#__PURE__*/ createPersist()
+
+/**
+ * Creates custom partially applied `createStorage`
+ * with predefined `localStorage` adapter
+ */
+export function createStorageFactory(
+ defaults?: ConfigCreateStorage
+): CreateStorage {
+ return (...configs: any[]) =>
+ baseCreateStorage({ adapter: local }, defaults, ...configs)
+}
+
+/**
+ * Default partially applied `createStorage`
+ */
+export const createStorage = /*#__PURE__*/ createStorageFactory()
diff --git a/src/memory/adapter.ts b/src/memory/adapter.ts
index 0b54d14..aec870d 100644
--- a/src/memory/adapter.ts
+++ b/src/memory/adapter.ts
@@ -13,7 +13,7 @@ adapter.factory = true as const
export function adapter({ area = data }: MemoryConfig = {}): StorageAdapter {
const adapter: StorageAdapter = (key: string) => ({
get: () => area.get(key),
- set: (value: State) => area.set(key, value),
+ set: (value: State) => void area.set(key, value),
})
adapter.keyArea = area
diff --git a/src/memory/index.ts b/src/memory/index.ts
index aa6f523..b97878e 100644
--- a/src/memory/index.ts
+++ b/src/memory/index.ts
@@ -3,8 +3,10 @@ import type {
ConfigPersist as BaseConfigPersist,
ConfigStore as BaseConfigStore,
ConfigSourceTarget as BaseConfigSourceTarget,
+ ConfigCreateStorage as BaseConfigCreateStorage,
+ StorageHandles,
} from '../types'
-import { persist as base } from '../core'
+import { persist as base, createStorage as baseCreateStorage } from '../core'
import { adapter } from './adapter'
export type {
@@ -32,6 +34,19 @@ export interface Persist {
(config: ConfigStore): Subscription
}
+export interface ConfigCreateStorage
+ extends BaseConfigCreateStorage {}
+
+export interface CreateStorage {
+ (
+ key: string,
+ config?: BaseConfigCreateStorage
+ ): StorageHandles
+ (
+ config: BaseConfigCreateStorage & { key: string }
+ ): StorageHandles
+}
+
/**
* Returns memory adapter
*/
@@ -53,4 +68,20 @@ export function createPersist(defaults?: ConfigPersist): Persist {
/**
* Default partially applied `persist`
*/
-export const persist = createPersist()
+export const persist = /*#__PURE__*/ createPersist()
+
+/**
+ * Creates custom partially applied `createStorage`
+ * with predefined `memory` adapter
+ */
+export function createStorageFactory(
+ defaults?: ConfigCreateStorage
+): CreateStorage {
+ return (...configs: any[]) =>
+ baseCreateStorage({ adapter: adapter() }, defaults, ...configs)
+}
+
+/**
+ * Default partially applied `createStorage`
+ */
+export const createStorage = /*#__PURE__*/ createStorageFactory()
diff --git a/src/query/index.ts b/src/query/index.ts
index 019e383..5fe6983 100644
--- a/src/query/index.ts
+++ b/src/query/index.ts
@@ -3,10 +3,12 @@ import type {
ConfigPersist as BaseConfigPersist,
ConfigStore as BaseConfigStore,
ConfigSourceTarget as BaseConfigSourceTarget,
+ ConfigCreateStorage as BaseConfigCreateStorage,
StorageAdapter,
+ StorageHandles,
} from '../types'
import type { ChangeMethod, StateBehavior, QueryConfig } from './adapter'
-import { persist as base } from '../core'
+import { persist as base, createStorage as baseCreateStorage } from '../core'
import { nil } from '../nil'
import { adapter } from './adapter'
@@ -47,6 +49,19 @@ export interface Persist {
(config: ConfigStore): Subscription
}
+export interface ConfigCreateStorage
+ extends BaseConfigCreateStorage {}
+
+export interface CreateStorage {
+ (
+ key: string,
+ config?: QueryConfig & BaseConfigCreateStorage
+ ): StorageHandles
+ (
+ config: QueryConfig & BaseConfigCreateStorage & { key: string }
+ ): StorageHandles
+}
+
/**
* Function, checking if `history` and `location` exists and accessible
*/
@@ -82,4 +97,20 @@ export function createPersist(defaults?: ConfigPersist): Persist {
/**
* Default partially applied `persist`
*/
-export const persist = createPersist()
+export const persist = /*#__PURE__*/ createPersist()
+
+/**
+ * Creates custom partially applied `createStorage`
+ * with predefined `query` adapter
+ */
+export function createStorageFactory(
+ defaults?: ConfigCreateStorage
+): CreateStorage {
+ return (...configs: any[]) =>
+ baseCreateStorage({ adapter: query }, defaults, ...configs)
+}
+
+/**
+ * Default partially applied `createStorage`
+ */
+export const createStorage = /*#__PURE__*/ createStorageFactory()
diff --git a/src/session/index.ts b/src/session/index.ts
index 39c036a..f4b2adb 100644
--- a/src/session/index.ts
+++ b/src/session/index.ts
@@ -3,9 +3,11 @@ import type {
ConfigPersist as BaseConfigPersist,
ConfigStore as BaseConfigStore,
ConfigSourceTarget as BaseConfigSourceTarget,
+ ConfigCreateStorage as BaseConfigCreateStorage,
StorageAdapter,
+ StorageHandles,
} from '../types'
-import { persist as base } from '../core'
+import { persist as base, createStorage as baseCreateStorage } from '../core'
import { nil } from '../nil'
import { storage } from '../storage'
@@ -46,6 +48,20 @@ export interface Persist {
(config: ConfigStore): Subscription
}
+export interface ConfigCreateStorage
+ extends BaseConfigCreateStorage {}
+
+export interface CreateStorage {
+ (
+ key: string,
+ config?: SessionStorageConfig & BaseConfigCreateStorage
+ ): StorageHandles
+ (
+ config: SessionStorageConfig &
+ BaseConfigCreateStorage & { key: string }
+ ): StorageHandles
+}
+
/**
* Function, checking if `sessionStorage` exists
*/
@@ -88,4 +104,20 @@ export function createPersist(defaults?: ConfigPersist): Persist {
/**
* Default partially applied `persist`
*/
-export const persist = createPersist()
+export const persist = /*#__PURE__*/ createPersist()
+
+/**
+ * Creates custom partially applied `createStorage`
+ * with predefined `sessionStorage` adapter
+ */
+export function createStorageFactory(
+ defaults?: ConfigCreateStorage
+): CreateStorage {
+ return (...configs: any[]) =>
+ baseCreateStorage({ adapter: session }, defaults, ...configs)
+}
+
+/**
+ * Default partially applied `createStorage`
+ */
+export const createStorage = /*#__PURE__*/ createStorageFactory()
diff --git a/src/types.ts b/src/types.ts
index 3675b5d..e3c6ae3 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -5,12 +5,13 @@ export interface Adapter {
this: void,
raw?: any,
ctx?: any
- ): State | Promise | undefined
+ ): State | undefined | Promise
set( //
this: void,
value: State,
ctx?: any
- ): void
+ ): void | Promise
+ // remove?(ctx?: any): void | Promise
}
export interface DisposableAdapter extends Adapter {
@@ -48,14 +49,14 @@ export type Done = {
export type Fail = {
key: string
keyPrefix: string
- operation: 'set' | 'get'
+ operation: 'set' | 'get' | 'validate'
error: Err
value?: any
}
-export type Finally =
- | (Done & { status: 'done' })
- | (Fail & { status: 'fail' })
+export type FinallyDone = Done & { status: 'done' }
+export type FinallyFail = Fail & { status: 'fail' }
+export type Finally = FinallyDone | FinallyFail
export interface ConfigPersist {
pickup?: Unit
@@ -119,3 +120,36 @@ export interface Persist {
AdapterConfig
): Subscription
}
+
+export interface StorageHandles {
+ getFx: Effect>
+ setFx: Effect>
+ removeFx: Effect>
+}
+
+export interface ConfigCreateStorage {
+ context?: Unit
+ keyPrefix?: string
+ contract?: Contract
+}
+
+export interface CreateStorage {
+ (
+ key: string,
+ config: ConfigAdapterFactory &
+ ConfigCreateStorage &
+ AdapterConfig
+ ): StorageHandles
+ (
+ config: ConfigAdapterFactory &
+ ConfigCreateStorage &
+ AdapterConfig & { key: string }
+ ): StorageHandles
+ (
+ key: string,
+ config: ConfigAdapter & ConfigCreateStorage
+ ): StorageHandles
+ (
+ config: ConfigAdapter & ConfigCreateStorage & { key: string }
+ ): StorageHandles
+}
diff --git a/tests/broadcast.test.ts b/tests/broadcast.test.ts
index 79d7137..56f142c 100644
--- a/tests/broadcast.test.ts
+++ b/tests/broadcast.test.ts
@@ -6,7 +6,7 @@ import * as assert from 'uvu/assert'
import { BroadcastChannel, Worker } from 'node:worker_threads'
import { createEffect, createStore, sample } from 'effector'
import { createEventsMock } from './mocks/events.mock'
-import { broadcast, persist } from '../src/broadcast'
+import { broadcast, persist, createStorage } from '../src/broadcast'
import { broadcast as broadcastIndex } from '../src'
import { either } from '../src/tools'
import { log } from '../src/log'
@@ -79,6 +79,7 @@ test.after(() => {
test('should export adapter and `persist` function', () => {
assert.type(broadcast, 'function')
assert.type(persist, 'function')
+ assert.type(createStorage, 'function')
})
test('should be exported from package root', () => {
@@ -88,6 +89,8 @@ test('should be exported from package root', () => {
test('should be ok on good parameters', () => {
const $store = createStore(0, { name: 'broadcast' })
assert.not.throws(() => persist({ store: $store }))
+ assert.not.throws(() => createStorage('broadcast'))
+ assert.not.throws(() => createStorage({ key: 'broadcast' }))
})
test('should post message to broadcast channel on updates', async () => {
diff --git a/tests/context-create-storage.test.ts b/tests/context-create-storage.test.ts
new file mode 100644
index 0000000..99e94ea
--- /dev/null
+++ b/tests/context-create-storage.test.ts
@@ -0,0 +1,189 @@
+import type { StorageAdapter } from '../src/types'
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+import { snoop } from 'snoop'
+import { createStore, createEvent, fork, allSettled } from 'effector'
+import { createStorage } from '../src'
+
+//
+// Tests
+//
+
+test('context store value should be passed to adapter', async () => {
+ const watch = snoop(() => undefined as any)
+
+ const context = createEvent()
+
+ const { getFx, setFx } = createStorage('test-context-1', {
+ adapter: () => ({ get: watch.fn, set: watch.fn }),
+ context: createStore(42).on(context, (_, ctx) => ctx),
+ })
+
+ getFx()
+
+ assert.is(watch.callCount, 1)
+ assert.equal(watch.calls[0].arguments, [undefined, 42])
+
+ setFx(54)
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[1].arguments, [54, 42])
+
+ // update context
+ context(72)
+
+ getFx()
+
+ assert.is(watch.callCount, 3)
+ assert.equal(watch.calls[2].arguments, [undefined, 72])
+
+ setFx(27)
+
+ assert.is(watch.callCount, 4)
+ assert.equal(watch.calls[3].arguments, [27, 72])
+})
+
+test('context event value should be passed to adapter', async () => {
+ const watch = snoop(() => undefined as any)
+
+ const context = createEvent()
+
+ const { getFx, setFx } = createStorage('test-context-2', {
+ adapter: () => ({ get: watch.fn, set: watch.fn }),
+ context,
+ })
+
+ getFx()
+
+ assert.is(watch.callCount, 1)
+ assert.equal(watch.calls[0].arguments, [undefined, undefined])
+
+ setFx(54)
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[1].arguments, [54, undefined])
+
+ // update context
+ context('new context')
+
+ getFx()
+
+ assert.is(watch.callCount, 3)
+ assert.equal(watch.calls[2].arguments, [undefined, 'new context'])
+
+ setFx(27)
+
+ assert.is(watch.callCount, 4)
+ assert.equal(watch.calls[3].arguments, [27, 'new context'])
+})
+
+test('contexts in different scopes should be different', async () => {
+ const watch = snoop(() => undefined as any)
+
+ const context = createEvent<{ name: string }>()
+
+ const { getFx, setFx } = createStorage('test-context-3', {
+ adapter: () => ({ get: watch.fn, set: watch.fn }),
+ context,
+ })
+
+ const scopeA = fork()
+ const scopeB = fork()
+
+ await allSettled(context, { scope: scopeA, params: { name: 'scopeA' } })
+ await allSettled(context, { scope: scopeB, params: { name: 'scopeB' } })
+
+ await allSettled(getFx, { scope: scopeA })
+ await allSettled(getFx, { scope: scopeB })
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined, { name: 'scopeA' }])
+ assert.equal(watch.calls[1].arguments, [undefined, { name: 'scopeB' }])
+
+ await allSettled(setFx, { scope: scopeA, params: 'A' })
+ await allSettled(setFx, { scope: scopeB, params: 'B' })
+
+ assert.is(watch.callCount, 4)
+ assert.equal(watch.calls[2].arguments, ['A', { name: 'scopeA' }])
+ assert.equal(watch.calls[3].arguments, ['B', { name: 'scopeB' }])
+})
+
+test('context should change scope for async adapter', async () => {
+ const watch = snoop((value) => value)
+
+ const updated = createEvent()
+ const context = createEvent()
+
+ createStorage('test-context-4', {
+ context,
+ adapter: (_key, update) => {
+ updated.watch(update)
+ return { get: watch.fn, set: watch.fn }
+ },
+ })
+
+ updated('out of scope') // <- imitate external storage update
+
+ assert.is(watch.callCount, 1)
+ assert.equal(watch.calls[0].arguments, ['out of scope', undefined])
+
+ const scope = fork()
+
+ // set context, which should bind given scope
+ await allSettled(context, { scope, params: 'in scope' })
+
+ updated('in scope') // <- pickup new value, within scope
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[1].arguments, ['in scope', 'in scope'])
+})
+
+test('contexts should update scope / also works with adapter factory', async () => {
+ const queue = new EventTarget()
+
+ adapterFactory.factory = true as const
+ function adapterFactory() {
+ const adapter: StorageAdapter = (
+ _key: string,
+ update: (raw?: any) => void
+ ) => {
+ let value = 1 as State
+ queue.addEventListener('update', () => update((value = 2 as State)))
+ return {
+ get: () => value,
+ set: (x: State) => void (value = x),
+ }
+ }
+ return adapter
+ }
+
+ const context = createEvent()
+
+ const { getFx } = createStorage('test-context-4', {
+ adapter: adapterFactory,
+ context,
+ contract: (raw: unknown): raw is number => typeof raw === 'number',
+ })
+
+ const $store = createStore(0).on(getFx.doneData, (_, data) => data)
+
+ const scope = fork()
+
+ queue.dispatchEvent(new Event('update'))
+
+ assert.is($store.getState(), 2) // <- changed
+ assert.is(scope.getState($store), 0) // <- default value
+
+ await allSettled(context, { scope })
+
+ queue.dispatchEvent(new Event('update'))
+
+ assert.is($store.getState(), 2)
+ assert.is(scope.getState($store), 2) // <- changed in scope
+})
+
+//
+// Launch tests
+//
+
+test.run()
diff --git a/tests/context.test.ts b/tests/context-persist.test.ts
similarity index 84%
rename from tests/context.test.ts
rename to tests/context-persist.test.ts
index 1420148..b9e1a4f 100644
--- a/tests/context.test.ts
+++ b/tests/context-persist.test.ts
@@ -9,7 +9,7 @@ import { persist } from '../src/core'
//
test('context from pickup should be passed to adapter', async () => {
- const watch = snoop((_value, _ctx) => undefined as any) // eslint-disable-line @typescript-eslint/no-unused-vars
+ const watch = snoop(() => undefined as any)
const pickup = createEvent()
const $store = createStore(0)
@@ -33,8 +33,30 @@ test('context from pickup should be passed to adapter', async () => {
assert.equal(watch.calls[1].arguments, [54, 42])
})
+test('context from store should be passed to adapter', async () => {
+ const watch = snoop(() => undefined as any)
+
+ const $store = createStore(0)
+
+ persist({
+ store: $store,
+ adapter: () => ({ get: watch.fn, set: watch.fn }),
+ key: 'store',
+ context: createStore(42),
+ })
+
+ assert.is(watch.callCount, 1)
+ assert.equal(watch.calls[0].arguments, [undefined, 42])
+
+ //
+ ;($store as any).setState(54) // <- update store to trigger `set`
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[1].arguments, [54, 42])
+})
+
test('context from context should be passed to adapter', async () => {
- const watch = snoop((_value, _ctx) => undefined as any) // eslint-disable-line @typescript-eslint/no-unused-vars
+ const watch = snoop(() => undefined as any)
const context = createEvent()
const $store = createStore(0)
@@ -67,7 +89,7 @@ test('context from context should be passed to adapter', async () => {
})
test('pickup should set different contexts in different scopes', async () => {
- const watch = snoop((_value, _ctx) => undefined as any) // eslint-disable-line @typescript-eslint/no-unused-vars
+ const watch = snoop(() => undefined as any)
const pickup = createEvent<{ name: string }>()
const $store = createStore('')
@@ -109,7 +131,7 @@ test('pickup should set different contexts in different scopes', async () => {
})
test('context should change scope for async adapter', async () => {
- const watch = snoop((value, _ctx) => value) // eslint-disable-line @typescript-eslint/no-unused-vars
+ const watch = snoop((value) => value)
const pickup = createEvent()
const context = createEvent()
diff --git a/tests/contract-create-storage.test.ts b/tests/contract-create-storage.test.ts
new file mode 100644
index 0000000..4806bd4
--- /dev/null
+++ b/tests/contract-create-storage.test.ts
@@ -0,0 +1,466 @@
+import type { StorageAdapter } from '../src'
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+import { snoop } from 'snoop'
+import { createStore, createEvent } from 'effector'
+import { Record, Literal, Number, Optional } from 'runtypes'
+import { runtypeContract } from '@farfetched/runtypes'
+import { createStorage, storage, persist } from '../src'
+import { createStorageMock } from './mocks/storage.mock'
+import { type Events, createEventsMock } from './mocks/events.mock'
+
+//
+// Mock abstract Storage adapter
+//
+
+declare let global: any
+
+const mockStorage = createStorageMock()
+let storageAdapter: StorageAdapter
+let events: Events
+
+test.before(() => {
+ events = createEventsMock()
+ global.addEventListener = events.addEventListener
+ storageAdapter = storage({ storage: () => mockStorage, sync: true })
+})
+
+test.after(() => {
+ delete global.addEventListener
+})
+
+//
+// Tests
+//
+
+test('shoult validate storage value on get', () => {
+ const watch = snoop(() => undefined)
+
+ mockStorage.setItem('number1', '42')
+
+ const { getFx } = createStorage({
+ adapter: storageAdapter,
+ key: 'number1',
+ contract: (raw): raw is number => typeof raw === 'number',
+ })
+
+ getFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+
+ getFx()
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'done',
+ params: undefined,
+ result: 42,
+ },
+ ]) // getFx result
+
+ assert.is(mockStorage.getItem('number1'), '42')
+})
+
+test('shoult fail on invalid initial storage value with simple contract', () => {
+ const watch = snoop(() => undefined)
+
+ mockStorage.setItem('number2', '"invalid"') // valid JSON, but invalid number
+
+ const { getFx } = createStorage({
+ adapter: storageAdapter,
+ key: 'number2',
+ contract: (raw): raw is number => typeof raw === 'number',
+ })
+
+ getFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+
+ getFx()
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'fail',
+ params: undefined,
+ error: {
+ key: 'number2',
+ keyPrefix: '',
+ operation: 'validate',
+ error: ['Invalid data'],
+ value: 'invalid',
+ },
+ },
+ ]) // getFx result
+
+ assert.is(mockStorage.getItem('number2'), '"invalid"') // didn't change
+})
+
+test('should handle sync effects with same key and different validators', () => {
+ const watchPlain = snoop(() => undefined)
+ const watchBase64 = snoop(() => undefined)
+
+ const { getFx: getPlainFx, setFx: setPlainFx } = createStorage({
+ adapter: storageAdapter,
+ key: 'contract-same-key-1',
+ contract: (raw): raw is string => typeof raw === 'string',
+ })
+ const { getFx: getBase64Fx, setFx: setBase64Fx } = createStorage({
+ adapter: storageAdapter,
+ key: 'contract-same-key-1',
+ contract: (raw): raw is string =>
+ global.Buffer.from(raw, 'base64').toString('base64') === raw,
+ })
+
+ getPlainFx.watch(watchPlain.fn)
+ setPlainFx.watch(watchPlain.fn)
+ getPlainFx.finally.watch(watchPlain.fn)
+ setPlainFx.finally.watch(watchPlain.fn)
+
+ getBase64Fx.watch(watchBase64.fn)
+ setBase64Fx.watch(watchBase64.fn)
+ getBase64Fx.finally.watch(watchBase64.fn)
+ setBase64Fx.finally.watch(watchBase64.fn)
+
+ assert.is(watchPlain.callCount, 0)
+ assert.is(watchBase64.callCount, 0)
+
+ setPlainFx('plain value')
+ assert.is(mockStorage.getItem('contract-same-key-1'), '"plain value"')
+
+ assert.is(watchPlain.callCount, 2)
+ assert.equal(watchPlain.calls[0].arguments, ['plain value']) // setPlainFx trigger
+ assert.equal(watchPlain.calls[1].arguments, [
+ {
+ status: 'done',
+ params: 'plain value',
+ result: undefined,
+ },
+ ]) // setPlainFx result
+
+ assert.is(watchBase64.callCount, 2)
+ assert.equal(watchBase64.calls[0].arguments, [undefined]) // getBase64Fx trigger
+ assert.equal(watchBase64.calls[1].arguments, [
+ {
+ status: 'fail',
+ params: undefined,
+ error: {
+ key: 'contract-same-key-1',
+ keyPrefix: '',
+ operation: 'validate',
+ error: ['Invalid data'],
+ value: 'plain value',
+ },
+ },
+ ]) // getBase64Fx result
+
+ setBase64Fx('YmFzZTY0IHZhbHVl')
+ assert.is(mockStorage.getItem('contract-same-key-1'), '"YmFzZTY0IHZhbHVl"')
+
+ assert.is(watchBase64.callCount, 4)
+ assert.equal(watchBase64.calls[2].arguments, ['YmFzZTY0IHZhbHVl']) // setBase64Fx trigger
+ assert.equal(watchBase64.calls[3].arguments, [
+ {
+ status: 'done',
+ params: 'YmFzZTY0IHZhbHVl',
+ result: undefined,
+ },
+ ]) // setBase64Fx result
+
+ assert.is(watchPlain.callCount, 4)
+ assert.equal(watchPlain.calls[2].arguments, [undefined]) // getPlainFx trigger
+ assert.equal(watchPlain.calls[3].arguments, [
+ {
+ status: 'done',
+ params: undefined,
+ result: 'YmFzZTY0IHZhbHVl', // this is valid string
+ },
+ ]) // getPlainFx result
+})
+
+test('should handle sync with `persist` with different validators, update from store', () => {
+ const watch = snoop(() => undefined)
+ const $string = createStore('')
+
+ persist({
+ store: $string,
+ adapter: storageAdapter,
+ key: 'contract-same-key-2',
+ })
+
+ const { getFx, setFx } = createStorage({
+ adapter: storageAdapter,
+ key: 'contract-same-key-2',
+ contract: (raw): raw is string =>
+ global.Buffer.from(raw, 'base64').toString('base64') === raw,
+ })
+
+ getFx.watch(watch.fn)
+ setFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+ setFx.finally.watch(watch.fn)
+
+ //
+ ;($string as any).setState('plain value')
+ assert.is(mockStorage.getItem('contract-same-key-2'), '"plain value"')
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'fail',
+ params: undefined,
+ error: {
+ key: 'contract-same-key-2',
+ keyPrefix: '',
+ operation: 'validate',
+ error: ['Invalid data'],
+ value: 'plain value',
+ },
+ },
+ ]) // getFx result
+})
+
+test('should handle sync with `persist` with different validators, update from storage', () => {
+ const watch = snoop(() => undefined)
+ const fail = createEvent()
+ fail.watch(watch.fn)
+
+ const $base64 = createStore('')
+
+ persist({
+ store: $base64,
+ adapter: storageAdapter,
+ key: 'contract-same-key-3',
+ contract: (raw): raw is string =>
+ global.Buffer.from(raw, 'base64').toString('base64') === raw,
+ fail,
+ })
+
+ const { setFx } = createStorage({
+ adapter: storageAdapter,
+ key: 'contract-same-key-3',
+ contract: (raw): raw is string =>
+ global.Buffer.from(raw, 'base64').toString('base64') === raw,
+ })
+
+ setFx('plain value')
+ assert.is(mockStorage.getItem('contract-same-key-3'), '"plain value"')
+
+ assert.is($base64.getState(), '') // <- didn't change
+
+ assert.is(watch.callCount, 1)
+ assert.equal(watch.calls[0].arguments, [
+ {
+ key: 'contract-same-key-3',
+ keyPrefix: '',
+ operation: 'validate',
+ error: ['Invalid data'],
+ value: 'plain value',
+ },
+ ])
+})
+
+test('shoult validate storage value on get with complex contract (valid)', () => {
+ const watch = snoop(() => undefined)
+
+ const Asteroid = Record({
+ type: Literal('asteroid'),
+ mass: Number,
+ })
+
+ mockStorage.setItem('asteroid0', '{"type":"asteroid","mass":42}')
+
+ const { getFx } = createStorage({
+ adapter: storageAdapter,
+ key: 'asteroid0',
+ contract: runtypeContract(Asteroid),
+ })
+
+ getFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+
+ getFx()
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'done',
+ params: undefined,
+ result: { type: 'asteroid', mass: 42 },
+ },
+ ]) // getFx result
+})
+
+test('shoult validate storage value on get with complex contract (valid undefined)', () => {
+ const watch = snoop(() => undefined)
+
+ const Asteroid = Optional(
+ Record({
+ type: Literal('asteroid'),
+ mass: Number,
+ })
+ )
+
+ const { getFx } = createStorage({
+ adapter: storageAdapter,
+ key: 'asteroid1',
+ contract: runtypeContract(Asteroid),
+ })
+
+ getFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+
+ getFx()
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'done',
+ params: undefined,
+ result: undefined,
+ },
+ ]) // getFx result
+})
+
+test('shoult validate storage value on get with complex contract (invalid undefined)', () => {
+ const watch = snoop(() => undefined)
+
+ const Asteroid = Record({
+ type: Literal('asteroid'),
+ mass: Number,
+ })
+
+ const { getFx } = createStorage({
+ adapter: storageAdapter,
+ key: 'asteroid1',
+ contract: runtypeContract(Asteroid),
+ })
+
+ getFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+
+ getFx()
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'fail',
+ params: undefined,
+ error: {
+ key: 'asteroid1',
+ keyPrefix: '',
+ operation: 'validate',
+ error: [
+ 'Expected { type: "asteroid"; mass: number; }, but was undefined',
+ ],
+ value: undefined,
+ },
+ },
+ ]) // getFx result
+})
+
+test('shoult validate storage value on get with complex contract (invalid)', () => {
+ const watch = snoop(() => undefined)
+
+ const Asteroid = Record({
+ type: Literal('asteroid'),
+ mass: Number,
+ })
+
+ mockStorage.setItem('asteroid2', '42')
+
+ const { getFx } = createStorage({
+ adapter: storageAdapter,
+ key: 'asteroid2',
+ contract: runtypeContract(Asteroid),
+ })
+
+ getFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+
+ getFx()
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'fail',
+ params: undefined,
+ error: {
+ key: 'asteroid2',
+ keyPrefix: '',
+ operation: 'validate',
+ error: ['Expected { type: "asteroid"; mass: number; }, but was number'],
+ value: 42,
+ },
+ },
+ ]) // getFx result
+
+ assert.is(mockStorage.getItem('asteroid2'), '42')
+})
+
+test('should validate value on storage external update', async () => {
+ const watch = snoop(() => undefined)
+
+ const { getFx } = createStorage({
+ adapter: storageAdapter,
+ key: 'storage-contract-counter-1',
+ contract: runtypeContract(Number),
+ })
+
+ getFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+
+ mockStorage.setItem('storage-contract-counter-1', '1')
+ await events.dispatchEvent('storage', {
+ storageArea: mockStorage,
+ key: 'storage-contract-counter-1',
+ oldValue: null,
+ newValue: '1',
+ })
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, ['1']) // getFx trigger with raw value
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'done',
+ params: '1', // raw value from adapter
+ result: 1,
+ },
+ ]) // getFx result
+
+ mockStorage.setItem('storage-contract-counter-1', '"invalid"')
+ await events.dispatchEvent('storage', {
+ storageArea: mockStorage,
+ key: 'storage-contract-counter-1',
+ oldValue: null,
+ newValue: '"invalid"',
+ })
+
+ assert.is(watch.callCount, 4)
+ assert.equal(watch.calls[2].arguments, ['"invalid"']) // getFx trigger with raw value
+ assert.equal(watch.calls[3].arguments, [
+ {
+ status: 'fail',
+ params: '"invalid"', // raw value from adapter
+ error: {
+ key: 'storage-contract-counter-1',
+ keyPrefix: '',
+ operation: 'validate',
+ error: ['Expected number, but was string'],
+ value: 'invalid',
+ },
+ },
+ ]) // getFx result
+})
+
+//
+// Launch tests
+//
+
+test.run()
diff --git a/tests/contract.test.ts b/tests/contract-persist.test.ts
similarity index 98%
rename from tests/contract.test.ts
rename to tests/contract-persist.test.ts
index 564ec05..2626133 100644
--- a/tests/contract.test.ts
+++ b/tests/contract-persist.test.ts
@@ -74,7 +74,7 @@ test('shoult fail on invalid initial storage value with simple contract', () =>
key: 'number2',
keyPrefix: '',
operation: 'validate',
- error: undefined,
+ error: ['Invalid data'],
value: 'invalid',
},
])
@@ -118,7 +118,7 @@ test('should not break sync stores with same key and different validators', () =
key: 'same-key-3',
keyPrefix: '',
operation: 'validate',
- error: undefined,
+ error: ['Invalid data'],
value: 'plain value',
},
])
@@ -167,7 +167,7 @@ test('validation should not prevent persisting state', () => {
key: 'string1',
keyPrefix: '',
operation: 'validate',
- error: undefined,
+ error: ['Invalid data'],
value: 42,
},
])
diff --git a/tests/core-create-storage.test.ts b/tests/core-create-storage.test.ts
new file mode 100644
index 0000000..17a9bd2
--- /dev/null
+++ b/tests/core-create-storage.test.ts
@@ -0,0 +1,526 @@
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+import { snoop } from 'snoop'
+import { createStore, createEvent } from 'effector'
+import {
+ createStorage,
+ createStorageFactory,
+ memory,
+ persist,
+ async,
+} from '../src'
+
+// memory adapter with separate storage area, to prevent concurrency issues between tests
+const adapter = memory({ area: new Map() })
+
+//
+// Tests
+//
+
+test('should exports effects', () => {
+ assert.type(createStorageFactory, 'function')
+ assert.type(createStorageFactory(), 'function')
+ assert.type(createStorage, 'function')
+ const ret = createStorage('test-key', { adapter })
+ assert.type(ret, 'object')
+ assert.type(ret.getFx, 'function')
+ assert.type(ret.setFx, 'function')
+ assert.type(ret.removeFx, 'function')
+})
+
+test('should be ok on good parameters', () => {
+ assert.not.throws(() => {
+ createStorage('test-1', {
+ adapter,
+ })
+ })
+ assert.not.throws(() => {
+ createStorage({
+ adapter,
+ key: 'test-2',
+ })
+ })
+ assert.not.throws(() => {
+ createStorage('test-3', {
+ adapter,
+ keyPrefix: 'prefix-3',
+ })
+ })
+ assert.not.throws(() => {
+ createStorage({
+ adapter,
+ key: 'tets-4',
+ keyPrefix: 'prefix-3',
+ })
+ })
+ assert.not.throws(() => {
+ createStorage('test-1', {
+ adapter,
+ context: createStore(0),
+ })
+ })
+ assert.not.throws(() => {
+ createStorage('test-1', {
+ adapter,
+ contract: (x): x is number => typeof x === 'number',
+ })
+ })
+})
+
+test('should handle wrong parameters', () => {
+ assert.throws(
+ // @ts-expect-error test wrong parameters
+ () => createStorage(),
+ /Adapter is not defined/
+ )
+ assert.throws(
+ // @ts-expect-error test wrong parameters
+ () => createStorage({}),
+ /Adapter is not defined/
+ )
+ assert.throws(
+ // @ts-expect-error test wrong parameters
+ () => createStorage('key', {}),
+ /Adapter is not defined/
+ )
+ assert.throws(
+ // @ts-expect-error test wrong parameters
+ () => createStorage({ key: 'key' }),
+ /Adapter is not defined/
+ )
+ assert.throws(
+ // @ts-expect-error test wrong parameters
+ () => createStorage({ adapter }),
+ /Key is not defined/
+ )
+})
+
+test('should get and set value from storage', async () => {
+ const watch = snoop(() => undefined)
+
+ const { getFx, setFx } = createStorage('test-get-set-1', {
+ adapter,
+ })
+
+ getFx.watch(watch.fn)
+ setFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+ setFx.finally.watch(watch.fn)
+
+ assert.is(watch.callCount, 0)
+
+ getFx()
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'done',
+ params: undefined,
+ result: undefined,
+ },
+ ]) // getFx result
+
+ setFx(1)
+
+ assert.is(watch.callCount, 4)
+ assert.equal(watch.calls[2].arguments, [1]) // setFx trigger
+ assert.equal(watch.calls[3].arguments, [
+ {
+ status: 'done',
+ params: 1,
+ result: undefined,
+ },
+ ]) // setFx result
+
+ assert.is(await getFx(), 1)
+})
+
+test('should remove value from storage', async () => {
+ const { setFx, getFx, removeFx } = createStorage('test-get-set-1', {
+ adapter,
+ })
+
+ await setFx(1)
+
+ assert.is(await getFx(), 1)
+
+ await removeFx()
+
+ assert.is(await getFx(), undefined)
+})
+
+test('should get and set value from storage (with adapter factory)', async () => {
+ const watch = snoop(() => undefined)
+
+ const area = new Map()
+ const { getFx, setFx } = createStorage('test-get-set-2', {
+ adapter: memory,
+ area,
+ })
+
+ getFx.watch(watch.fn)
+ setFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+ setFx.finally.watch(watch.fn)
+
+ assert.is(watch.callCount, 0)
+
+ getFx()
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'done',
+ params: undefined,
+ result: undefined,
+ },
+ ]) // getFx result
+
+ setFx(1)
+
+ assert.is(watch.callCount, 4)
+ assert.equal(watch.calls[2].arguments, [1]) // setFx trigger
+ assert.equal(watch.calls[3].arguments, [
+ {
+ status: 'done',
+ params: 1,
+ result: undefined,
+ },
+ ]) // setFx result
+
+ assert.is(await getFx(), 1)
+ assert.is(area.get('test-get-set-2'), 1)
+})
+
+test('should get and set value from async storage', async () => {
+ const watch = snoop(() => undefined)
+
+ const { getFx, setFx } = createStorage('test-get-set-3', {
+ adapter: async(adapter),
+ })
+
+ getFx.watch(watch.fn)
+ setFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+ setFx.finally.watch(watch.fn)
+
+ assert.is(watch.callCount, 0)
+
+ await getFx()
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'done',
+ params: undefined,
+ result: undefined,
+ },
+ ]) // getFx result
+
+ await setFx(1)
+
+ assert.is(watch.callCount, 4)
+ assert.equal(watch.calls[2].arguments, [1]) // setFx trigger
+ assert.equal(watch.calls[3].arguments, [
+ {
+ status: 'done',
+ params: 1,
+ result: undefined,
+ },
+ ]) // setFx result
+
+ assert.is(await getFx(), 1)
+})
+
+test('should sync effects for the same adapter-key', () => {
+ const watchSet = snoop(() => undefined)
+ const watchGet = snoop(() => undefined)
+
+ const { setFx } = createStorage({
+ adapter,
+ key: 'test-sync-same-key-1',
+ })
+ const { getFx } = createStorage({
+ adapter,
+ key: 'test-sync-same-key-1',
+ })
+
+ getFx.watch(watchGet.fn)
+ setFx.watch(watchSet.fn)
+ getFx.finally.watch(watchGet.fn)
+ setFx.finally.watch(watchSet.fn)
+
+ assert.is(watchSet.callCount, 0)
+ assert.is(watchGet.callCount, 0)
+
+ setFx(1)
+
+ assert.is(watchSet.callCount, 2)
+ assert.is(watchGet.callCount, 2)
+ assert.equal(watchSet.calls[0].arguments, [1]) // setFx trigger
+ assert.equal(watchSet.calls[1].arguments, [
+ {
+ status: 'done',
+ params: 1,
+ result: undefined,
+ },
+ ]) // setFx result
+ assert.equal(watchGet.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watchGet.calls[1].arguments, [
+ {
+ status: 'done',
+ params: undefined,
+ result: 1,
+ },
+ ]) // getFx result
+})
+
+test('should sync with `persist` for the same adapter-key', async () => {
+ const watch = snoop(() => undefined)
+ const watchFx = snoop(() => undefined)
+
+ const $store = createStore(11)
+ $store.watch(watch.fn)
+
+ assert.is($store.getState(), 11)
+ assert.is(watch.callCount, 1)
+ assert.equal(watch.calls[0].arguments, [11])
+
+ persist({
+ store: $store,
+ adapter,
+ key: 'test-sync-same-key-2',
+ })
+
+ assert.is($store.getState(), 11) // did not change
+ assert.is(watch.callCount, 1) // did not trigger
+
+ const { getFx, setFx } = createStorage({
+ adapter,
+ key: 'test-sync-same-key-2',
+ })
+
+ getFx.watch(watchFx.fn)
+ setFx.watch(watchFx.fn)
+ getFx.finally.watch(watchFx.fn)
+ setFx.finally.watch(watchFx.fn)
+
+ assert.is(watchFx.callCount, 0) // did not trigger
+
+ setFx(22)
+
+ assert.is(watchFx.callCount, 2)
+ assert.equal(watchFx.calls[0].arguments, [22]) // setFx trigger
+ assert.equal(watchFx.calls[1].arguments, [
+ {
+ status: 'done',
+ params: 22,
+ result: undefined,
+ },
+ ]) // setFx result
+
+ assert.is($store.getState(), 22) // <- changed
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[1].arguments, [22])
+
+ //
+ ;($store as any).setState(33)
+
+ assert.is($store.getState(), 33)
+ assert.is(watch.callCount, 3)
+ assert.equal(watch.calls[2].arguments, [33])
+
+ assert.is(watchFx.callCount, 4)
+ assert.equal(watchFx.calls[2].arguments, [undefined]) // getFx trigger
+ assert.equal(watchFx.calls[3].arguments, [
+ {
+ status: 'done',
+ params: undefined,
+ result: 33,
+ },
+ ]) // getFx result
+
+ assert.is(await getFx(), 33)
+})
+
+test('should handle synchronous error in `get` and `set` effects', () => {
+ const watch = snoop(() => undefined)
+
+ const { getFx, setFx } = createStorage('test-sync-throw', {
+ adapter: () => ({
+ get: () => {
+ throw 'get test error'
+ },
+ set: () => {
+ throw 'set test error'
+ },
+ }),
+ })
+
+ getFx.watch(watch.fn)
+ setFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+ setFx.finally.watch(watch.fn)
+
+ assert.is(watch.callCount, 0)
+
+ getFx()
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'fail',
+ params: undefined,
+ error: {
+ key: 'test-sync-throw',
+ keyPrefix: '',
+ operation: 'get',
+ error: 'get test error',
+ value: undefined,
+ },
+ },
+ ]) // getFx fail
+
+ setFx(1)
+
+ assert.is(watch.callCount, 4)
+ assert.equal(watch.calls[2].arguments, [1]) // setFx trigger
+ assert.equal(watch.calls[3].arguments, [
+ {
+ status: 'fail',
+ params: 1,
+ error: {
+ key: 'test-sync-throw',
+ keyPrefix: '',
+ operation: 'set',
+ error: 'set test error',
+ value: 1,
+ },
+ },
+ ]) // setFx fail
+})
+
+test('should handle asynchronous error in `get` and `set` effects', async () => {
+ const watch = snoop(() => undefined)
+
+ const { getFx, setFx } = createStorage('test-async-throw', {
+ adapter: () => ({
+ get: async () => Promise.reject('get test error'),
+ set: async () => Promise.reject('set test error'),
+ }),
+ })
+
+ getFx.watch(watch.fn)
+ setFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+ setFx.finally.watch(watch.fn)
+
+ assert.is(watch.callCount, 0)
+
+ try {
+ await getFx()
+ assert.unreachable('getFx should have thrown')
+ } catch (e) {
+ // ok
+ }
+
+ assert.is(watch.callCount, 2)
+ assert.equal(watch.calls[0].arguments, [undefined]) // getFx trigger
+ assert.equal(watch.calls[1].arguments, [
+ {
+ status: 'fail',
+ params: undefined,
+ error: {
+ key: 'test-async-throw',
+ keyPrefix: '',
+ operation: 'get',
+ error: 'get test error',
+ value: undefined,
+ },
+ },
+ ]) // getFx fail
+
+ try {
+ await setFx(1)
+ assert.unreachable('setFx should have thrown')
+ } catch (e) {
+ // ok
+ }
+
+ assert.is(watch.callCount, 4)
+ assert.equal(watch.calls[2].arguments, [1]) // setFx trigger
+ assert.equal(watch.calls[3].arguments, [
+ {
+ status: 'fail',
+ params: 1,
+ error: {
+ key: 'test-async-throw',
+ keyPrefix: '',
+ operation: 'set',
+ error: 'set test error',
+ value: 1,
+ },
+ },
+ ]) // setFx fail
+})
+
+test('should hide internal implementation with `get` effect', () => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const watch = snoop((_) => undefined)
+ const fail = createEvent()
+
+ const { getFx, setFx } = createStorage('test-throw-box', {
+ adapter: (_, update) => {
+ fail.watch(() => {
+ update(() => {
+ throw 'get box test error'
+ })
+ })
+
+ return {
+ get: (box?: () => any) => {
+ if (box) return box()
+ },
+ set: () => {},
+ }
+ },
+ })
+
+ getFx.watch(watch.fn)
+ setFx.watch(watch.fn)
+ getFx.finally.watch(watch.fn)
+ setFx.finally.watch(watch.fn)
+
+ assert.is(watch.callCount, 0)
+
+ fail()
+
+ assert.is(watch.callCount, 2)
+
+ const arg1 = watch.calls[0].arguments[0]
+ assert.instance(arg1, Function) // getFx trigger - "box"ed error, don't know how to hide it here
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { params, ...arg2 } = watch.calls[1].arguments[0]
+ assert.equal(arg2, {
+ status: 'fail',
+ // params: Function, // "box"ed error...
+ error: {
+ key: 'test-throw-box',
+ keyPrefix: '',
+ operation: 'get',
+ error: 'get box test error',
+ value: undefined,
+ },
+ }) // getFx fail
+})
+
+//
+// Launch tests
+//
+
+test.run()
diff --git a/tests/core.test.ts b/tests/core-persist.test.ts
similarity index 99%
rename from tests/core.test.ts
rename to tests/core-persist.test.ts
index 9120e16..bff456d 100644
--- a/tests/core.test.ts
+++ b/tests/core-persist.test.ts
@@ -13,7 +13,7 @@ const dumbAdapter: StorageAdapter = () => {
let __: T = 0 as any
return {
get: (): T => __,
- set: (value: T) => (__ = value),
+ set: (value: T) => void (__ = value),
}
}
diff --git a/tests/domain.test.ts b/tests/domain.test.ts
index 46dc117..7dba8d9 100644
--- a/tests/domain.test.ts
+++ b/tests/domain.test.ts
@@ -13,7 +13,7 @@ const dumbAdapter: StorageAdapter = () => {
let __: T = 0 as any
return {
get: (): T => __,
- set: (value: T) => (__ = value),
+ set: (value: T) => void (__ = value),
}
}
diff --git a/tests/index.types.ts b/tests/index.types.ts
index a40263a..5e5a846 100644
--- a/tests/index.types.ts
+++ b/tests/index.types.ts
@@ -30,6 +30,24 @@ test('General `persist` should handle wrong arguments', async () => {
persist({ adapter: fakeAdapter, target: store })
})
+test('General `createStorage` should handle wrong arguments', async () => {
+ const { createStorage } = await import('../src')
+
+ const fakeAdapter: StorageAdapter = 0 as any
+
+ // @ts-expect-error missing arguments
+ createStorage()
+
+ // @ts-expect-error missing adapter
+ createStorage('key')
+
+ // @ts-expect-error missing adapter
+ createStrorage({ key: 'key' })
+
+ // @ts-expect-error missing key
+ createStorage({ adapter: fakeAdapter })
+})
+
test('General `persist` should return Subscription', async () => {
const { persist } = await import('../src')
diff --git a/tests/local.test.ts b/tests/local.test.ts
index 8852556..7eaf9b0 100644
--- a/tests/local.test.ts
+++ b/tests/local.test.ts
@@ -4,7 +4,7 @@ import { snoop } from 'snoop'
import { createEvent, createStore } from 'effector'
import { createStorageMock } from './mocks/storage.mock'
import { type Events, createEventsMock } from './mocks/events.mock'
-import { local, persist } from '../src/local'
+import { local, persist, createStorage } from '../src/local'
import { local as localIndex } from '../src'
//
@@ -32,6 +32,7 @@ test.after(() => {
test('should export adapter and `persist` function', () => {
assert.type(local, 'function')
assert.type(persist, 'function')
+ assert.type(createStorage, 'function')
})
test('should be exported from package root', () => {
@@ -41,6 +42,8 @@ test('should be exported from package root', () => {
test('should be ok on good parameters', () => {
const $store = createStore(0, { name: 'local::store' })
assert.not.throws(() => persist({ store: $store }))
+ assert.not.throws(() => createStorage('local::store'))
+ assert.not.throws(() => createStorage({ key: 'local::store' }))
})
test('persisted store shoult reset value on init to default', async () => {
diff --git a/tests/memory.test.ts b/tests/memory.test.ts
index 10673ee..e5107c2 100644
--- a/tests/memory.test.ts
+++ b/tests/memory.test.ts
@@ -2,7 +2,7 @@ import { test } from 'uvu'
import * as assert from 'uvu/assert'
import { snoop } from 'snoop'
import { createStore } from 'effector'
-import { memory, persist } from '../src/memory'
+import { memory, persist, createStorage } from '../src/memory'
import { memory as memoryIndex } from '../src'
//
@@ -12,6 +12,7 @@ import { memory as memoryIndex } from '../src'
test('should export adapter and `persist` function', () => {
assert.type(memory, 'function')
assert.type(persist, 'function')
+ assert.type(createStorage, 'function')
})
test('should be exported from package root', () => {
@@ -21,6 +22,8 @@ test('should be exported from package root', () => {
test('should be ok on good parameters', () => {
const $store = createStore(0, { name: 'memory::store' })
assert.not.throws(() => persist({ store: $store }))
+ assert.not.throws(() => createStorage('memory::store'))
+ assert.not.throws(() => createStorage({ key: 'memory::store' }))
})
test('should sync stores, persisted with memory adapter', () => {
diff --git a/tests/query.test.ts b/tests/query.test.ts
index 3f3acfd..afd20fa 100644
--- a/tests/query.test.ts
+++ b/tests/query.test.ts
@@ -9,6 +9,7 @@ import { type Events, createEventsMock } from './mocks/events.mock'
import {
persist,
query,
+ createStorage,
pushState,
replaceState,
locationAssign,
@@ -56,6 +57,7 @@ test.after(() => {
test('should export adapter and `persist` function', () => {
assert.type(query, 'function')
assert.type(persist, 'function')
+ assert.type(createStorage, 'function')
assert.type(pushState, 'function')
assert.type(replaceState, 'function')
assert.type(locationAssign, 'function')
@@ -69,6 +71,8 @@ test('should be exported from package root', () => {
test('should be ok on good parameters', () => {
const $store = createStore('0', { name: 'query::store' })
assert.not.throws(() => persist({ store: $store }))
+ assert.not.throws(() => createStorage('query::store'))
+ assert.not.throws(() => createStorage({ key: 'query::store' }))
})
test('store initial value should NOT be put in query string', () => {
diff --git a/tests/serialize-scope.test.ts b/tests/serialize-scope.test.ts
index b40e930..2eb256b 100644
--- a/tests/serialize-scope.test.ts
+++ b/tests/serialize-scope.test.ts
@@ -13,7 +13,7 @@ const dumbAdapter: StorageAdapter = () => {
let __: T = 42 as any
return {
get: (): T => __,
- set: (value: T) => (__ = value),
+ set: (value: T) => void (__ = value),
}
}
@@ -116,6 +116,34 @@ test('persist usage should not warn', async () => {
assert.is(fn.callCount, 0)
})
+test('setting value to persisted store should not warn', async () => {
+ const fn: MockErrorFn = (console.error as any).mock
+
+ const set = createEvent()
+ const pickup = createEvent()
+ const $store = createStore(0, { sid: 'y' }).on(set, (_, x) => x)
+
+ persist({
+ store: $store,
+ pickup,
+ adapter: dumbAdapter,
+ key: 'store_y',
+ })
+
+ const scope = fork()
+ await allSettled(pickup, { scope })
+ await allSettled(set, { scope, params: 24 })
+
+ // should fill scoped store value
+ assert.is(scope.getState($store), 24)
+ assert.is($store.getState(), 0)
+
+ serialize(scope)
+
+ assert.equal(fn.calls[0]?.arguments, undefined)
+ assert.is(fn.callCount, 0)
+})
+
//
// Launch tests
//
diff --git a/tests/session.test.ts b/tests/session.test.ts
index a1395de..024c5a2 100644
--- a/tests/session.test.ts
+++ b/tests/session.test.ts
@@ -4,7 +4,7 @@ import { snoop } from 'snoop'
import { createEvent, createStore } from 'effector'
import { createStorageMock } from './mocks/storage.mock'
import { type Events, createEventsMock } from './mocks/events.mock'
-import { session, persist } from '../src/session'
+import { session, persist, createStorage } from '../src/session'
import { session as sessionIndex } from '../src'
//
@@ -32,6 +32,7 @@ test.after(() => {
test('should export adapter and `persist` function', () => {
assert.type(session, 'function')
assert.type(persist, 'function')
+ assert.type(createStorage, 'function')
})
test('should be exported from package root', () => {
@@ -41,6 +42,8 @@ test('should be exported from package root', () => {
test('should be ok on good parameters', () => {
const $store = createStore(0, { name: 'session::store' })
assert.not.throws(() => persist({ store: $store }))
+ assert.not.throws(() => createStorage('session::store'))
+ assert.not.throws(() => createStorage({ key: 'session::store' }))
})
test('persisted store shoult reset value on init to default', async () => {
diff --git a/tests/tools-either.test.ts b/tests/tools-either.test.ts
index 28f465b..9d67fd5 100644
--- a/tests/tools-either.test.ts
+++ b/tests/tools-either.test.ts
@@ -16,7 +16,7 @@ const dumbAdapter: StorageAdapter = () => {
let __: T = 0 as any
return {
get: (): T => __,
- set: (value: T) => (__ = value),
+ set: (value: T) => void (__ = value),
}
}