diff --git a/CHANGELOG.md b/CHANGELOG.md index a9fdf6c..8e74f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ ### 破坏性更新 - 删除hooks函数 `useDefined` +- 模型删除`onDestroy`事件钩子 +- 持久化删除`maxAge`配置 + +```diff +store.init({ + persist: [ + { + key: 'key', +- maxAge: 3600, + engines: engines.localStorage, + models: [], + } + ] +}) +``` + - 非hooks状态下计算属性使用执行函数的方式获取 ```diff @@ -50,9 +66,42 @@ const App: FC = () => { } ``` +- 持久化增加 **dump** 和 **load** 两个过滤函数 + +```diff +const model = defineModel('model', { + initialState: { firstName: 'tick', lastName: 'tock' }, + persist: { ++ dump(state) { ++ return state.firstName; ++ }, ++ load(dumpData) { ++ return { ...this.initialState, firstName: dumpData }; ++ }, + } +}); +``` + +- 持久化新增合并模式 `replace`, `merge`(默认), `deep-merge` + +```diff +store.init({ + persist: [ + { + key: 'item1', + version: '1.0', ++ merge: 'deep-merge', + engine: engines.localStorage, + models: [], + }, + ] +}) +``` + ### 其它 - npm包转译为ES5语法以兼容更早的浏览器 (#41) +- immer版本从 10.0.2 降级为 9.0.21 ## [2.0.1](https://github.com/foca-js/foca/compare/v2.0.0...v2.0.1)  (2023-08-10) diff --git a/docs/persist.md b/docs/persist.md index fbac401..9f90a94 100644 --- a/docs/persist.md +++ b/docs/persist.md @@ -20,6 +20,7 @@ store.init({ key: '$PROJECT_$ENV', version: 1, engine: engines.localStorage, + // 模型白名单列表 models: [userModel, agentModel], }, ], @@ -28,25 +29,22 @@ store.init({ 把需要持久化的模型扔进去,foca 就能自动帮你存取数据。 -# 参数说明 +`key`即为存储路径,最好采用**项目名-环境名**的形式组织。纯前端项目如果和其他前端项目共用一个域名,或者在同一域名下,则有可能使用共同的存储空间,因此需要保证`key`是唯一的值。 -#### key +# 存储引擎 -key 即为存储路径,最好采用`项目名-环境名`的形式组织。纯前端项目如果和其他前端项目共用一个域名,或者在同一域名下,你用 **index.html** 和 **index-test.html** 来访问不同的环境,这很容易导致 key 被覆盖。 - -#### engine - -存储引擎。不同的引擎会把数据存储到不同的空间,使用哪个引擎取决于项目跑在什么环境。为了统一操作,引擎操作都是异步的,目前内置的引擎有: +不同的引擎会把数据存储到不同的空间,使用哪个引擎取决于项目跑在什么环境。为了统一操作,引擎操作都是异步的,目前内置的引擎有: - localStorage - sessionStorage - memoryStorage -如果内置引擎无法满足,那么下面列举的第三方库也可以**直接当作**存储引擎: +如果内置引擎无法满足,那么安装下面列举的第三方库也可以**直接当作**存储引擎: -- Taro:[foca-taro-storage](https://github.com/foca-js/foca-taro-storage) -- React-Native:[@react-native-async-storage/async-storage](https://www.npmjs.com/package/@react-native-async-storage/async-storage) -- 浏览器:[localforage](https://www.npmjs.com/package/localforage)(localStorage, IndexedDB, WebSQL) | [foca-cookie-storage](https://github.com/foca-js/foca-cookie-storage) +- [localforage](https://www.npmjs.com/package/localforage) (localStorage, IndexedDB, WebSQL) - 浏览器专用 +- [@react-native-async-storage/async-storage](https://www.npmjs.com/package/@react-native-async-storage/async-storage) - React-Native专用 +- [foca-taro-storage](https://github.com/foca-js/foca-taro-storage) - Taro专用 +- [foca-cookie-storage](https://github.com/foca-js/foca-cookie-storage) - 浏览器专用,存储到cookie 如果有必要,你也可以自己实现一个引擎: @@ -61,92 +59,142 @@ export const customEngine: StorageEngine = { }; ``` -#### version +# 设置版本号 -版本号。如果数据结构有变化,那么可以直接升级版本号。 +当数据结构变化,我们不得不升级版本号来`删除`持久化数据,版本号又分为`全局版本`和`模型版本`两种。当修改模型内版本号时,仅删除该模型的持久化数据,而修改全局版本号时,白名单内所有模型的持久化数据都被删除。 -注意,**升级版本号会把相关模型的持久化数据都清除掉**,所以请谨慎操作!比如某个持久化模型存有用户 token 信息,那么升级 version 之后,用户再访问你的应用就得重新登录了。所以建议 tokenModel 放在独立的一个配置下,这样不容易被影响到: +建议优先修改模型内版本号!! + +```diff +const stockModel = defineModel('stock', { + initialState: {}, + persist: { + // 模型版本号,影响当前模型 ++ version: '2.0', + }, +}); -```typescript store.init({ persist: [ { key: '$PROJECT_normal_$ENV', - version: 3.6, + // 全局版本号,影响白名单全部模型 ++ version: '3.6', engine: engines.localStorage, - // 普通的数据放一起 models: [musicModel, stockModel], }, + ], +}); +``` + +# 数据合并 + +> v3.0.0 + +在项目的推进过程中,难免需要根据产品需求更新模型数据结构,结构变化后,我们可以简单粗暴地通过`版本号+1`的方式来删除持久化的数据。但如果只是新增了某一个字段,我们希望持久化恢复时能自动识别。试试推荐的`合并模式`吧: + +```diff +store.init({ + persist: [ { - key: '$PROJECT_token_$ENV', + key: 'myproject-a-prod', version: 1, ++ merge: 'merge', engine: engines.localStorage, - // 十分重要的数据建议单独存放 - models: [tokenModel], - }, - { - key: '$PROJECT_important_$ENV', - version: 2.1, - engine: engines.localStorage, - // 十分重要的数据建议单独存放 - models: [profileModel], + models: [userModel], }, ], }); ``` -修改全局配置的 version 总是危险的一意孤行的方式,因为没有人愿意改动某个模型后就大动干戈地清理掉所有模型的缓存数据,这是严重的内耗问题。所以现在,让我们试试模型里的持久化配置: +很轻松就设置上了,合并模式目前有3种可选的类型: -```typescript +- `replace` - 覆盖模式。数据从存储引擎取出后直接覆盖初始数据 +- `merge` - 合并模式(默认)。数据从存储引擎取出后,与初始数据多余部分进行合并,可以理解为 **Object.assign()** 操作 +- `deep-merge` - 二级合并模式。在合并模式的基础上,如果某个key的值为对象,则该对象也会执行合并操作 + +如果某个模型比较特殊,我们也可以在里面单独设置合并模式。 + +```diff const userModel = defineModel('user', { - initialState: [], - // 覆盖全局配置,只对当前模型有效 + initialState: {}, persist: { - version: 1, ++ merge: 'deep-merge', }, }); ``` -是的,如果有需要,每个模型都可以再次覆盖持久化配置以达到定制的需求。不要太方便呢! - -#### maxAge +接下来看看它的具体表现: -缓存存活时间。很显然这是针对每个模型的,因为不同模型的数据存入时间不一样,中途还可能更新缓存,所以模型的存活时间从最后一次更新开始计算。 +```typescript +const persistState = { obj: { test1: 'persist' } }; +const initialState = { obj: { test2: 'initial' }, foo: 'bar' }; + +// replace 效果 +const state = { obj: { test1: 'persist' } }; +// merge 效果 +const state = { obj: { test1: 'persist' }, foo: 'bar' }; +// deep-merge 效果 +const state = { obj: { test1: 'persist', test2: 'initial' }, foo: 'bar' }; +``` -你可以在模型中单独指定 maxAge 以达到定制效果: +需要注意的是合并模式对`数组无效`,当持久化数据和初始数据都为数组类型时,会强制使用持久化数据。当持久化数据和初始数据任何一边为数组类型时,会强制使用初始化数据。 ```typescript -// File: store.ts -store.init({ - persist: [ - { - ... - maxAge: 30 * 60 * 1000, - models: [userModel, agentModel, ...], - } - ] -}); +const persistState = [1, 2, 3]; ✅ +const initialState = [4, 5, 6, 7]; ❌ +// 合并效果 +const state = [1, 2, 3]; -// File: user.model.ts -const userModel = defineModel({ - initialState: [], - persist: { - maxAge: 10 * 60 * 1000, - }, -}); +// ------------------------- + +const persistState = [1, 2, 3]; ❌ +const initialState = { foo: 'bar' }; ✅ +// 合并效果 +const state = { foo: 'bar' }; + +// ------------------------- + +const persistState = { foo: 'bar' }; ❌ +const initialState = [1, 2, 3]; ✅ +// 合并效果 +const state = [1, 2, 3]; ``` -所有模型都会在 30 分钟后失效,除非它们在此期间更新了数据。其中有一个`userModel`的模型会在 10 分钟后就失效,这是你特别指定的。 +# 系列化钩子 + +> v3.0.0 + +数据在模型与持久化引擎互相转换期间,我们希望对它进行一些额外操作以满足业务需求。比如: -!> 不填 maxAge 代表永久缓存。 +- 只缓存部分字段,避免存储尺寸超过存储空间限制 +- 改变数据结构或者内容 +- 更新时间等动态信息 -#### models +foca提供了一对实用的过滤函数`dump`和`load`。**dump** 即 model->persist,**load** 即 persist->model。 -缓存白名单。把你认为应该缓存的模型填进去。 +```typescript +const model = defineModel('model', { + initialState: { + mode: 'foo', // 本地的设置,需要持久化缓存 + hugeDate: [], // API请求数据,数据量太大 + }, + persist: { + // 系列化 + dump(state) { + return state.mode; + }, + // 反系列化 + load(mode) { + return { ...this.initialState, mode }; + }, + }, +}); +``` # 分组 -我们注意到 persist 其实是个数组,这意味着你可以多填几组配置上去,把不同的模型数据存储到不同的地方。这看起来很酷,但我猜你不一定需要。 +我们注意到 persist 其实是个数组,这意味着你可以多填几组配置上去,把不同的模型数据存储到不同的地方。这看起来很酷,但我猜你不一定需要! ```typescript import { store, engines } from 'foca'; @@ -154,13 +202,13 @@ import { store, engines } from 'foca'; store.init({ persist: [ { - key: 'myproject-prod', + key: 'myproject-a-prod', version: 1, engine: engines.localStorage, models: [userModel], }, { - key: 'myproject-prod', + key: 'myproject-b-prod', version: 5, engine: engines.sessionStorage, models: [agentModel, teacherModel], diff --git a/src/model/cloneModel.ts b/src/model/cloneModel.ts index 92a17a6..b4b9c89 100644 --- a/src/model/cloneModel.ts +++ b/src/model/cloneModel.ts @@ -16,7 +16,11 @@ type OverrideOptions< Action extends object, Effect extends object, Computed extends object, -> = Pick, EditableKeys>; + PersistDump, +> = Pick< + DefineModelOptions, + EditableKeys +>; export const cloneModel = < Name extends string, @@ -24,20 +28,24 @@ export const cloneModel = < Action extends object, Effect extends object, Computed extends object, + PersistDump, >( uniqueName: Name, - model: Model, + model: Model, options?: - | Partial> + | Partial> | (( - prev: OverrideOptions, - ) => Partial>), -): Model => { + prev: OverrideOptions, + ) => Partial< + OverrideOptions + >), +): Model => { const realModel = model as unknown as InternalModel< string, State, Action, - Effect + Effect, + Computed >; const prevOpts = realModel._$opts; diff --git a/src/model/defineModel.ts b/src/model/defineModel.ts index c30def5..cac57b4 100644 --- a/src/model/defineModel.ts +++ b/src/model/defineModel.ts @@ -34,9 +34,10 @@ export const defineModel = < Action extends object, Effect extends object, Computed extends object, + PersistDump, >( uniqueName: Name, - options: DefineModelOptions, + options: DefineModelOptions, ): Model => { guard(uniqueName); @@ -269,6 +270,7 @@ export const defineModel = < { name: uniqueName, _$opts: options, + _$persistCtx: getInitialState({}), }, getState, ), diff --git a/src/model/types.ts b/src/model/types.ts index 24189d3..247780f 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -1,5 +1,6 @@ import type { AnyAction } from 'redux'; import type { EnhancedEffect } from './enhanceEffect'; +import type { PersistMergeMode } from '../persist/PersistItem'; export interface ComputedFlag { readonly _computedFlag: never; @@ -26,21 +27,61 @@ export interface GetInitialState { readonly initialState: State; } -export interface ModelPersist { +export type ModelPersist = { /** - * 持久化版本号,数据结构变化后建议立即升级该版本。默认值:0 + * 持久化版本号,数据结构变化后建议立即升级该版本。默认值:`0` */ version?: number | string; + /** - * 持久化数据活跃时间(ms),默认:Infinity - */ - maxAge?: number; - /** - * 持久化数据恢复到模型时的过滤函数,此时可修改数据以满足业务需求。 - * 如果数据结构变化,则建议直接更新版本号。 + * 持久化数据与初始数据的合并方式。默认值以全局配置为准 + * + * - replace - 覆盖模式。直接用持久化数据替换初始数据 + * - merge - 合并模式。持久化数据与初始数据新增的key进行合并,可理解为`Object.assign` + * - deep-merge - 二级合并模式。在合并模式的基础上,如果某个key的值为对象,则该对象也会执行合并操作 + * + * 注意:当数据为数组格式时该配置无效。 + * @since 3.0.0 */ - decode?: (state: State) => State | void; -} + merge?: PersistMergeMode; +} & ( + | { + /** + * 模型数据从内存存储到持久化引擎时的过滤函数,允许你只持久化部分数据。 + * ```typescript + * + * // state = { firstName: 'tick', lastName: 'tock' } + * dump: (state) => state + * dump: (state) => state.firstName + * dump: (state) => ({ name: state.lastName }) + * ``` + * + * @since 3.0.0 + */ + dump: (state: State) => PersistDump; + /** + * 持久化数据恢复到模型内存时的过滤函数,参数为`dump`返回的值。 + * ```typescript + * // state = { firstName: 'tick', lastName: 'tock' } + * { + * dump(state) { + * return state.firstName + * }, + * load(firstName) { + * return { ...this.initialState, firstName: firstName }; + * } + * } + * ``` + * + * @since 3.0.0 + */ + load: (this: GetInitialState, dumpData: PersistDump) => State; + } + | { + dump?: never; + load?: never; + } +); export interface ActionCtx extends GetName, @@ -148,7 +189,8 @@ export type InternalModel< Effect extends object = object, Computed extends object = object, > = BaseModel & { - readonly _$opts: DefineModelOptions; + readonly _$opts: DefineModelOptions; + readonly _$persistCtx: GetInitialState; }; export type InternalAction = { @@ -181,6 +223,7 @@ export interface DefineModelOptions< Action extends object, Effect extends object, Computed extends object, + PersistDump, > { /** * 初始状态 @@ -282,7 +325,7 @@ export interface DefineModelOptions< * * @see store.init() */ - persist?: ModelPersist & ThisType; + persist?: ModelPersist & ThisType; /** * 生命周期 * @since 0.11.1 diff --git a/src/persist/PersistItem.ts b/src/persist/PersistItem.ts index 718df61..ca45a83 100644 --- a/src/persist/PersistItem.ts +++ b/src/persist/PersistItem.ts @@ -1,6 +1,11 @@ import type { StorageEngine } from '../engines'; -import type { InternalModel, Model, ModelPersist } from '../model/types'; -import { isObject, isString } from '../utils/isType'; +import type { + GetInitialState, + InternalModel, + Model, + ModelPersist, +} from '../model/types'; +import { isObject, isPlainObject, isString } from '../utils/isType'; import { parseState, stringifyState } from '../utils/serialize'; export interface PersistSchema { @@ -21,29 +26,34 @@ export interface PersistItemSchema { * 版本 */ v: number | string; - /** - * 存储时间 - */ - t: number; /** * 数据 */ d: string; } +export type PersistMergeMode = 'replace' | 'merge' | 'deep-merge'; + export interface PersistOptions { - /** - * 每个模型的最大持久化时间,默认:Infinity。 - */ - maxAge?: number; /** * 存储唯一标识名称 */ key: string; /** - * 存储名称前缀,默认值:@@foca.persist: + * 存储名称前缀,默认值:`@@foca.persist:` */ keyPrefix?: string; + /** + * 持久化数据与初始数据的合并方式。默认值:`merge` + * + * - replace - 覆盖模式。数据从存储引擎取出后直接覆盖初始数据 + * - merge - 合并模式。数据从存储引擎取出后,与初始数据多余部分进行合并,可以理解为`Object.assign()`操作 + * - deep-merge - 二级合并模式。在合并模式的基础上,如果某个key的值为对象,则该对象也会执行合并操作 + * + * 注意:当数据为数组格式时该配置无效。 + * @since 3.0.0 + */ + merge?: PersistMergeMode; /** * 版本号 */ @@ -58,9 +68,11 @@ export interface PersistOptions { models: Model[]; } -type CustomModelPersistOptions = Required>; +type CustomModelPersistOptions = Required> & { + ctx: GetInitialState; +}; -const defaultDecodeFn = (value: any) => value; +const defaultDumpOrLoadFn = (value: any) => value; interface PersistRecord { model: Model; @@ -90,7 +102,7 @@ export class PersistItem { models, keyPrefix = '@@foca.persist:', key, - maxAge = Infinity, + merge = 'merge' satisfies PersistMergeMode, } = options; this.key = keyPrefix + key; @@ -98,17 +110,20 @@ export class PersistItem { for (let i = models.length; i-- > 0; ) { const model = models[i]!; const { - decode = defaultDecodeFn, - maxAge: customMaxAge = maxAge, + load = defaultDumpOrLoadFn, + dump = defaultDumpOrLoadFn, version: customVersion = 0, + merge: customMerge = merge, } = (model as unknown as InternalModel)._$opts.persist || {}; this.records[model.name] = { model, opts: { version: customVersion, - maxAge: customMaxAge, - decode, + merge: customMerge, + load, + dump, + ctx: (model as unknown as InternalModel)._$persistCtx, }, }; } @@ -117,6 +132,7 @@ export class PersistItem { init(): Promise { return this.options.engine.getItem(this.key).then((data) => { if (!data) { + this.loadMissingState(); return this.dump(); } @@ -124,40 +140,84 @@ export class PersistItem { const schema = JSON.parse(data); if (!this.validateSchema(schema)) { + this.loadMissingState(); return this.dump(); } - let changed: boolean = false; const schemaKeys = Object.keys(schema.d); for (let i = schemaKeys.length; i-- > 0; ) { const key = schemaKeys[i]!; const record = this.records[key]; if (record) { + const { opts } = record; const itemSchema = schema.d[key]!; - - if (this.validateItemSchema(itemSchema, record.opts)) { - const state: object = parseState(itemSchema.d); - const decodedState = record.opts.decode.call(null, state); + if (this.validateItemSchema(itemSchema, opts)) { + const dumpData = parseState(itemSchema.d); + record.prev = this.merge( + opts.load.call(opts.ctx, dumpData), + opts.ctx.initialState, + opts.merge, + ); record.schema = itemSchema; - record.prev = decodedState === void 0 ? state : decodedState; - } else { - changed ||= true; } - } else { - changed ||= true; } } - changed && this.dump(); - return; - } catch { + this.loadMissingState(); + return this.dump(); + } catch (e) { this.dump(); - throw new Error('[persist] 无法解析持久化数据,已重置'); + throw e; + } + }); + } + + loadMissingState() { + this.loop((record) => { + const { prev, opts, schema } = record; + if (!schema || !prev) { + const dumpData = opts.dump.call(null, opts.ctx.initialState); + record.prev = this.merge( + opts.load.call(opts.ctx, dumpData), + opts.ctx.initialState, + opts.merge, + ); + record.schema = { + v: opts.version, + d: stringifyState(dumpData), + }; } }); } + merge(persistState: any, initialState: any, mode: PersistMergeMode) { + const isStateArray = Array.isArray(persistState); + const isInitialStateArray = Array.isArray(initialState); + if (isStateArray && isInitialStateArray) return persistState; + if (isStateArray || isInitialStateArray) return initialState; + + if (mode === 'replace') return persistState; + + const state = Object.assign({}, initialState, persistState); + + if (mode === 'deep-merge') { + const keys = Object.keys(persistState); + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]!; + if ( + Object.prototype.hasOwnProperty.call(initialState, key) && + isPlainObject(state[key]) && + isPlainObject(initialState[key]) + ) { + state[key] = Object.assign({}, initialState[key], state[key]); + } + } + } + + return state; + } + collect(): Record { const stateMaps: Record = {}; @@ -169,21 +229,19 @@ export class PersistItem { } update(nextState: Record) { - const now = Date.now(); let changed = false; this.loop((record) => { const { model, prev, opts, schema } = record; - const nextStateForKey = nextState[model.name]; + const nextStateForKey = nextState[model.name]!; // 状态不变的情况下,即使过期了也无所谓,下次初始化时会自动剔除。 // 版本号改动的话一定会触发页面刷新。 if (nextStateForKey !== prev) { record.prev = nextStateForKey; - const nextSchema = { - t: now, + const nextSchema: PersistItemSchema = { v: opts.version, - d: stringifyState(nextStateForKey), + d: stringifyState(opts.dump.call(null, nextStateForKey)), }; if (!schema || nextSchema.d !== schema.d) { @@ -221,12 +279,7 @@ export class PersistItem { schema: PersistItemSchema | undefined, options: CustomModelPersistOptions, ) { - return ( - schema && - schema.v === options.version && - isString(schema.d) && - schema.t + options.maxAge >= Date.now() - ); + return schema && schema.v === options.version && isString(schema.d); } protected toJSON(): PersistSchema { diff --git a/src/persist/PersistManager.ts b/src/persist/PersistManager.ts index c9b7996..207b19c 100644 --- a/src/persist/PersistManager.ts +++ b/src/persist/PersistManager.ts @@ -6,14 +6,14 @@ export class PersistManager { protected initialized: boolean = false; protected readonly list: PersistItem[]; protected timer?: ReturnType; - protected unsubscrbeStore!: Unsubscribe; + protected unsubscribeStore!: Unsubscribe; constructor(options: PersistOptions[]) { this.list = options.map((option) => new PersistItem(option)); } init(store: Store, hydrate: boolean) { - this.unsubscrbeStore = store.subscribe(() => { + this.unsubscribeStore = store.subscribe(() => { this.initialized && this.update(store); }); @@ -24,7 +24,7 @@ export class PersistManager { } destroy() { - this.unsubscrbeStore(); + this.unsubscribeStore(); this.initialized = false; } @@ -36,9 +36,7 @@ export class PersistManager { combineReducer(original: Reducer): Reducer> { return (state, action) => { - if (state === void 0) { - state = {}; - } + if (state === void 0) state = {}; if (isHydrateAction(action)) { return Object.assign({}, state, action.payload); diff --git a/src/utils/isType.ts b/src/utils/isType.ts index 3d7d1f7..5d0f19f 100644 --- a/src/utils/isType.ts +++ b/src/utils/isType.ts @@ -7,5 +7,8 @@ export const isFunction = (value: any): value is T => export const isObject = (value: any): value is T => !!value && typeof value === OBJECT; +export const isPlainObject = (value: any): value is T => + !!value && Object.prototype.toString.call(value) === '[object Object]'; + export const isString = (value: any): value is T => typeof value === 'string'; diff --git a/test/clone.test.ts b/test/clone.test.ts index 498a2fb..1f0c5e9 100644 --- a/test/clone.test.ts +++ b/test/clone.test.ts @@ -54,8 +54,8 @@ test('Override persist', () => { const model1 = defineModel('model' + ++modelIndex, { initialState: {}, persist: { - maxAge: 20, - decode: (state) => state, + dump: (state) => state, + load: (state) => state, }, methods: { cc() { @@ -80,7 +80,8 @@ test('Override persist', () => { }) as unknown as InternalModel; expect(model3._$opts.persist).toHaveProperty('maxAge'); - expect(model3._$opts.persist).toHaveProperty('decode'); + expect(model3._$opts.persist).toHaveProperty('dump'); + expect(model3._$opts.persist).toHaveProperty('load'); }); test('override methods or unknown option can cause error', () => { diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 9eee4a1..6b0739c 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -48,7 +48,6 @@ describe('onInit', () => { v: 1, d: { [hookModel.name]: { - t: Date.now(), v: 0, d: JSON.stringify({ count: 20 }), }, diff --git a/test/models/persistModel.ts b/test/models/persistModel.ts index 6e8003c..5d6eca7 100644 --- a/test/models/persistModel.ts +++ b/test/models/persistModel.ts @@ -16,27 +16,25 @@ export const persistModel = defineModel('persist', { state.counter -= step; }, }, - persist: { - decode(state) { - state.counter = state.counter; - }, - }, + persist: {}, }); -export const hasVersionPersistModel = cloneModel('persit1', persistModel, { +export const hasVersionPersistModel = cloneModel('persist1', persistModel, { initialState: { counter: 56, }, persist: { - maxAge: 300, version: 10, }, }); -export const hasDecodePersistModel = cloneModel('persist2', persistModel, { +export const hasFilterPersistModel = cloneModel('persist2', persistModel, { persist: { - decode(state) { - state.counter = 57; + dump(state) { + return state.counter; + }, + load(counter) { + return { ...this.initialState, counter: counter + 1 }; }, }, }); diff --git a/test/persist.test.ts b/test/persist.test.ts index a54045e..2d0e14b 100644 --- a/test/persist.test.ts +++ b/test/persist.test.ts @@ -1,11 +1,15 @@ import sleep from 'sleep-promise'; -import { engines, Model, StorageEngine, store } from '../src'; -import { PersistItem, PersistSchema } from '../src/persist/PersistItem'; +import { defineModel, engines, Model, StorageEngine, store } from '../src'; +import { + PersistItem, + PersistMergeMode, + PersistSchema, +} from '../src/persist/PersistItem'; import { resolve } from '../src/utils/resolve'; import { stringifyState } from '../src/utils/serialize'; import { basicModel } from './models/basicModel'; import { - hasDecodePersistModel, + hasFilterPersistModel, hasVersionPersistModel, persistModel, } from './models/persistModel'; @@ -17,12 +21,42 @@ const stringifyTwice = (model: Model) => { const createDefaultInstance = () => { return new PersistItem({ version: 1, - key: 'test1', + key: 'test-' + Math.random(), engine: engines.memoryStorage, models: [persistModel], }); }; +const storageDump = (opts: { + key: string; + model: Model; + state?: object | string; + persistVersion?: number; + modelVersion?: number; + engine?: StorageEngine; +}) => { + const { + key, + model, + state = model.state, + persistVersion = 1, + modelVersion = 0, + engine = engines.memoryStorage, + } = opts; + return engine.setItem( + key, + JSON.stringify({ + v: persistVersion, + d: { + [model.name]: { + v: modelVersion, + d: typeof state === 'string' ? state : stringifyState(state), + }, + }, + }), + ); +}; + beforeEach(() => { store.init(); }); @@ -32,7 +66,7 @@ afterEach(async () => { await engines.memoryStorage.clear(); }); -test('rehydrate state to storage', async () => { +test('dump state', async () => { const persist = createDefaultInstance(); await expect(engines.memoryStorage.getItem(persist.key)).resolves.toBeNull(); @@ -42,9 +76,9 @@ test('rehydrate state to storage', async () => { await expect(engines.memoryStorage.getItem(persist.key)).resolves.toBe( JSON.stringify(persist), ); - await expect( - engines.memoryStorage.getItem(persist.key), - ).resolves.not.toContain(stringifyTwice(persistModel)); + await expect(engines.memoryStorage.getItem(persist.key)).resolves.toContain( + stringifyTwice(persistModel), + ); persistModel.plus(15); expect(persistModel.state.counter).toBe(15); @@ -60,22 +94,14 @@ test('rehydrate state to storage', async () => { expect(value).toContain(stringifyTwice(persistModel)); }); -test('hydrate state from storage', async () => { +test('load state', async () => { const persist = createDefaultInstance(); - await engines.memoryStorage.setItem( - persist.key, - JSON.stringify({ - v: 1, - d: { - [persistModel.name]: { - t: Date.now(), - v: 0, - d: stringifyState({ counter: 15, extra: undefined }), - }, - }, - }), - ); + await storageDump({ + key: persist.key, + model: persistModel, + state: { counter: 15, extra: undefined }, + }); await persist.init(); @@ -91,146 +117,56 @@ test('hydrate state from storage', async () => { ); }); -test('hydrate failed due to different persist version', async () => { +test('load failed due to persist version changed', async () => { const persist = createDefaultInstance(); - await engines.memoryStorage.setItem( - persist.key, - JSON.stringify({ - v: 20, - d: { - [persistModel.name]: { - t: Date.now(), - v: 0, - d: stringifyState(persistModel.state), - }, - }, - }), - ); - + await storageDump({ + key: persist.key, + model: persistModel, + persistVersion: 20, + state: { counter: 100 }, + }); await persist.init(); - expect(JSON.stringify(persist.collect())).toBe('{}'); + expect(persist.collect()).toStrictEqual({ + [persistModel.name]: { counter: 0 }, + }); }); -test('hydrate failed due to different model version', async () => { +test('load failed due to model version changed', async () => { const persist = createDefaultInstance(); - await engines.memoryStorage.setItem( - persist.key, - JSON.stringify({ - v: 1, - d: { - [persistModel.name]: { - t: Date.now(), - v: 17, - d: stringifyState(persistModel.state), - }, - }, - }), - ); - - await persist.init(); - - expect(JSON.stringify(persist.collect())).toBe('{}'); -}); - -test('hydrate failed due to expired', async () => { - const persist = new PersistItem({ - version: 1, - key: 'test1', - engine: engines.memoryStorage, - models: [persistModel], - maxAge: 100, + await storageDump({ + key: persist.key, + model: persistModel, + modelVersion: 17, + state: { counter: 100 }, }); - await engines.memoryStorage.setItem( - persist.key, - JSON.stringify({ - v: 1, - d: { - [persistModel.name]: { - t: Date.now() - 101, - v: 0, - d: stringifyState(persistModel.state), - }, - }, - }), - ); - await persist.init(); - expect(JSON.stringify(persist.collect())).toBe('{}'); -}); - -test('never rehydrate even time expired', async () => { - const persist = new PersistItem({ - version: 1, - key: 'test1', - engine: engines.memoryStorage, - models: [persistModel], - maxAge: 100, + expect(persist.collect()).toStrictEqual({ + [persistModel.name]: { counter: 0 }, }); +}); - await engines.memoryStorage.setItem( - persist.key, - JSON.stringify({ - v: 1, - d: { - [persistModel.name]: { - t: Date.now(), - v: 0, - d: stringifyState(persistModel.state), - }, - }, - }), - ); - - await persist.init(); - const currentValue = await engines.memoryStorage.getItem(persist.key); - expect(currentValue).toContain(stringifyTwice(persistModel)); - - await sleep(1); - persist.update({ - [persistModel.name]: persistModel.state, - }); - await sleep(1); - await expect(engines.memoryStorage.getItem(persist.key)).resolves.toBe( - currentValue, - ); +test('load failed due to invalid JSON literal', async () => { + let persist = createDefaultInstance(); - await sleep(100); - persist.update({ - [persistModel.name]: persistModel.state, + await storageDump({ + key: persist.key, + model: persistModel, + state: stringifyState(persistModel.state) + '$$$$', }); - await sleep(1); - await expect(engines.memoryStorage.getItem(persist.key)).resolves.toBe( - currentValue, - ); -}); -test('hydrate failed due to invalid format', async () => { - let persist = createDefaultInstance(); - await engines.memoryStorage.setItem( - persist.key, - JSON.stringify({ - v: 1, - d: { - [persistModel.name]: { - t: Date.now(), - v: 0, - d: stringifyState(persistModel.state) + '$$$$', - }, - }, - }), - ); await expect(persist.init()).rejects.toThrowError(); expect(persist.collect()).toStrictEqual({}); await Promise.resolve(); - persist = createDefaultInstance(); await expect(persist.init()).resolves.toBeUndefined(); - expect(persist.collect()).toStrictEqual({}); + expect(persist.collect()).toStrictEqual({ + [persistModel.name]: { counter: 0 }, + }); persist = createDefaultInstance(); await engines.memoryStorage.setItem( @@ -250,12 +186,13 @@ test('hydrate failed due to invalid format', async () => { expect(persist.collect()).toStrictEqual({}); await Promise.resolve(); - persist = createDefaultInstance(); await expect(persist.init()).resolves.toBeUndefined(); - expect(persist.collect()).toStrictEqual({}); + expect(persist.collect()).toStrictEqual({ + [persistModel.name]: { counter: 0 }, + }); }); -test('abandon unregisted model', async () => { +test('get rid of unregistered model', async () => { const persist = new PersistItem({ version: 1, key: 'test1', @@ -304,19 +241,11 @@ test('model can specific persist version', async () => { models: [persistModel, hasVersionPersistModel], }); - await engines.memoryStorage.setItem( - persist.key, - JSON.stringify({ - v: 1, - d: { - [hasVersionPersistModel.name]: { - t: Date.now(), - v: 10, - d: stringifyState(hasVersionPersistModel.state), - }, - }, - }), - ); + await storageDump({ + key: persist.key, + model: hasVersionPersistModel, + modelVersion: 10, + }); await persist.init(); @@ -325,12 +254,12 @@ test('model can specific persist version', async () => { }); }); -test('model can specific persist decoder', async () => { +test('model can specific persist filter function', async () => { const persist = new PersistItem({ version: 1, key: 'test1', engine: engines.memoryStorage, - models: [persistModel, hasDecodePersistModel], + models: [persistModel, hasFilterPersistModel], }); await engines.memoryStorage.setItem( @@ -338,10 +267,10 @@ test('model can specific persist decoder', async () => { JSON.stringify({ v: 1, d: { - [hasDecodePersistModel.name]: { + [hasFilterPersistModel.name]: { t: Date.now(), v: 0, - d: stringifyState(hasDecodePersistModel.state), + d: stringifyState(hasFilterPersistModel.state.counter), }, }, }), @@ -350,15 +279,25 @@ test('model can specific persist decoder', async () => { await persist.init(); expect(persist.collect()).toMatchObject({ - [hasDecodePersistModel.name]: { - counter: 57, - }, + [hasFilterPersistModel.name]: { counter: 1 }, + }); + + hasFilterPersistModel.plus(100); + hasFilterPersistModel.plus(5); + + persist.update({ + [hasFilterPersistModel.name]: hasFilterPersistModel.state, }); + + await sleep(1); + await expect(engines.memoryStorage.getItem(persist.key)).resolves.toContain( + '"d":"105"', + ); }); -test('stop restoring before hydrate (slow engine)', async () => { +test('no dump happen before load finished', async () => { let cache: Partial> = {}; - const engine: StorageEngine = { + const customEngine: StorageEngine = { async getItem(key) { const result = cache[key] === void 0 ? null : cache[key]!; await sleep(200); @@ -381,27 +320,20 @@ test('stop restoring before hydrate (slow engine)', async () => { }, }; - await engine.setItem( - '@test1', - JSON.stringify({ - v: 1, - d: { - [persistModel.name]: { - t: Date.now(), - v: 0, - d: stringifyState(persistModel.state), - }, - }, - }), - ); + await storageDump({ + key: '@test1', + model: persistModel, + engine: customEngine, + }); + store.unmount(); store.init({ persist: [ { version: 1, keyPrefix: '@', key: 'test1', - engine: engine, + engine: customEngine, models: [persistModel], }, ], @@ -411,17 +343,18 @@ test('stop restoring before hydrate (slow engine)', async () => { const promise = persistManager.init(store, true); persistModel.plus(6); + expect(persistModel.state.counter).toBe(6); await promise; expect(persistModel.state.counter).toBe(0); - await expect(engine.getItem('@test1')).resolves.toContain( + await expect(customEngine.getItem('@test1')).resolves.toContain( stringifyTwice(persistModel), ); await sleep(100); expect(persistModel.state.counter).toBe(0); - await expect(engine.getItem('@test1')).resolves.toContain( + await expect(customEngine.getItem('@test1')).resolves.toContain( stringifyTwice(persistModel), ); expect(persistManager.collect()).toMatchObject({ @@ -430,3 +363,229 @@ test('stop restoring before hydrate (slow engine)', async () => { store.unmount(); }); + +test('default merge mode is `merge`', async () => { + const persist = createDefaultInstance(); + + await storageDump({ + key: persist.key, + model: persistModel, + state: { hello: 'world' }, + }); + + await persist.init(); + + expect(persist.collect()).toStrictEqual({ + [persistModel.name]: { + hello: 'world', + counter: 0, + }, + }); +}); + +test('set merge mode to `replace`', async () => { + const persist = new PersistItem({ + version: 1, + key: 'test-' + Math.random(), + engine: engines.memoryStorage, + models: [persistModel], + merge: 'replace', + }); + + await storageDump({ + key: persist.key, + model: persistModel, + state: { hello: 'world' }, + }); + + await persist.init(); + + expect(persist.collect()).toStrictEqual({ + [persistModel.name]: { + hello: 'world', + }, + }); +}); + +test('set merge mode to `deep-merge`', async () => { + const model = defineModel('deep-merge-persist-model', { + initialState: { hi: 'here', data: { name: 'test', age: 20 } }, + }); + + const persist = new PersistItem({ + version: 1, + key: 'test-' + Math.random(), + engine: engines.memoryStorage, + models: [model], + merge: 'deep-merge', + }); + + await storageDump({ + key: persist.key, + model: model, + state: { hello: 'world', data: { name: 'g' } }, + }); + await persist.init(); + expect(persist.collect()).toStrictEqual({ + [model.name]: { + hello: 'world', + hi: 'here', + data: { name: 'g', age: 20 }, + }, + }); + + await storageDump({ + key: persist.key, + model: model, + state: { hello: 'world', data: 'x' }, + }); + await persist.init(); + expect(persist.collect()).toStrictEqual({ + [model.name]: { + hello: 'world', + hi: 'here', + data: 'x', + }, + }); +}); + +test('through dump and load function without storage data', async () => { + const spy1 = vitest.fn(); + const spy2 = vitest.fn(); + const model = defineModel('persist-model-' + Math.random(), { + initialState: {}, + persist: { + dump: spy1, + load: spy2, + }, + }); + + store.unmount(); + store.init({ + persist: [ + { + engine: engines.memoryStorage, + key: 'test-initial-state-dump-and-load', + version: 1, + models: [model], + }, + ], + }); + expect(spy1).toBeCalledTimes(0); + expect(spy2).toBeCalledTimes(0); + await store.onInitialized(); + expect(spy1).toBeCalledTimes(1); + expect(spy2).toBeCalledTimes(1); + spy1.mockRestore(); + spy2.mockRestore(); +}); + +test('context of load function contains initialState', async () => { + const model = defineModel('persist-model-' + Math.random(), { + initialState: { hello: 'world', count: 10 }, + persist: { + dump(state) { + return state.hello; + }, + // @ts-expect-error + load(hello) { + return { hello: hello + 'x', test: this.initialState }; + }, + }, + }); + + store.unmount(); + store.init({ + persist: [ + { + engine: engines.memoryStorage, + key: 'test-initialState-context', + version: 1, + models: [model], + }, + ], + }); + await store.onInitialized(); + expect(model.state).toStrictEqual({ + hello: 'worldx', + count: 10, + test: { + hello: 'world', + count: 10, + }, + }); +}); + +describe('merge method', () => { + const persistItem = new PersistItem({ + key: '', + version: '', + engine: engines.memoryStorage, + models: [], + }); + + test.each(['replace', 'merge', 'deep-merge'] satisfies PersistMergeMode[])( + 'array type', + (mode) => { + test('object + array', () => { + expect( + persistItem.merge({ hello: 'world' }, [{ foo: 'bar' }, {}], mode), + ).toStrictEqual([{ foo: 'bar' }, {}]); + }); + test('array + array', () => { + expect( + persistItem.merge([{ tick: 'tock' }], [{ foo: 'bar' }, {}], mode), + ).toStrictEqual([{ tick: 'tock' }]); + }); + test('array + object', () => { + expect( + persistItem.merge([{ tick: 'tock' }], { hello: 'world' }, mode), + ).toStrictEqual({ + hello: 'world', + }); + }); + }, + ); + + test('replace', () => { + expect( + persistItem.merge({ hi: 'there' }, { hello: 'world' }, 'replace'), + ).toStrictEqual({ hi: 'there' }); + }); + + test('merge', () => { + expect( + persistItem.merge( + { hello: 'world', hi: 'there', a: { c: '2' } }, + { hi: 'here', a: { b: '1' } }, + 'merge', + ), + ).toStrictEqual({ hi: 'there', hello: 'world', a: { c: '2' } }); + }); + + test('deep-merge', () => { + expect( + persistItem.merge( + { hello: 'world', hi: 'there', a: { c: '2' } }, + { hi: 'here', a: { b: '1' } }, + 'deep-merge', + ), + ).toStrictEqual({ hello: 'world', hi: 'there', a: { b: '1', c: '2' } }); + + expect( + persistItem.merge( + { hello: 'world', hi: 'there', a: { c: '2' } }, + { hi: 'here', a: 'x' }, + 'deep-merge', + ), + ).toStrictEqual({ hi: 'there', hello: 'world', a: { c: '2' } }); + + expect( + persistItem.merge( + { hello: 'world', hi: 'there', a: { c: '2' } }, + { hi: 'here', a: ['x'] }, + 'deep-merge', + ), + ).toStrictEqual({ hi: 'there', hello: 'world', a: { c: '2' } }); + }); +}); diff --git a/test/store.test.ts b/test/store.test.ts index a33d1e5..078cbf6 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -8,7 +8,7 @@ import { PersistManager } from '../src/persist/PersistManager'; import { basicModel, basicSkipRefreshModel } from './models/basicModel'; import { complexModel } from './models/complexModel'; import { - hasDecodePersistModel, + hasFilterPersistModel, hasVersionPersistModel, persistModel, } from './models/persistModel'; @@ -20,7 +20,7 @@ afterEach(() => { engines.sessionStorage.clear(); }); -const initializeStoreWithPersist = () => { +const initializeStoreWithMultiplePersist = () => { return store.init({ persist: [ { @@ -42,7 +42,7 @@ const initializeStoreWithPersist = () => { keyPrefix: 'Test:', version: 1, engine: engines.memoryStorage, - models: [hasVersionPersistModel, hasDecodePersistModel], + models: [hasVersionPersistModel, hasFilterPersistModel], }, ], }); @@ -73,85 +73,109 @@ test('Store can initialize many times except production env', async () => { await store.onInitialized(); }); -test('Delay to restore data when state changed', async () => { - initializeStoreWithPersist(); - - await store.onInitialized(); - - // @ts-expect-error - const getTimer = () => store['persister']!.timer; - let prevTimer: ReturnType; - - expect(getTimer()).toBeUndefined(); - basicModel.plus(1); - expect(getTimer()).not.toBeUndefined(); - prevTimer = getTimer(); - basicModel.plus(20); - expect(getTimer()).toBe(prevTimer); - basicModel.plus(1); - expect(getTimer()).toBe(prevTimer); - - expect(store['persister']?.collect()[basicModel.name]).not.toBe( - basicModel.state, - ); - await sleep(50); - expect(store['persister']?.collect()[basicModel.name]).toBe(basicModel.state); +describe('persist', () => { + test('Delay to dump data when state changed', async () => { + initializeStoreWithMultiplePersist(); + + await store.onInitialized(); + + // @ts-expect-error + const getTimer = () => store['persister']!.timer; + let prevTimer: ReturnType; + + expect(getTimer()).toBeUndefined(); + basicModel.plus(1); + expect(getTimer()).not.toBeUndefined(); + prevTimer = getTimer(); + basicModel.plus(20); + expect(getTimer()).toBe(prevTimer); + basicModel.plus(1); + expect(getTimer()).toBe(prevTimer); + + expect(store['persister']?.collect()[basicModel.name]).not.toBe( + basicModel.state, + ); + await sleep(50); + expect(store['persister']?.collect()[basicModel.name]).toBe( + basicModel.state, + ); + + expect(getTimer()).toBeUndefined(); + basicModel.plus(1); + expect(getTimer()).not.toBeUndefined(); + expect(getTimer()).not.toBe(prevTimer); + + expect(store['persister']?.collect()[basicModel.name]).not.toBe( + basicModel.state, + ); + await sleep(50); + expect(store['persister']?.collect()[basicModel.name]).toBe( + basicModel.state, + ); + }); - expect(getTimer()).toBeUndefined(); - basicModel.plus(1); - expect(getTimer()).not.toBeUndefined(); - expect(getTimer()).not.toBe(prevTimer); + test('Store can define persist with multiple engines', async () => { + initializeStoreWithMultiplePersist(); - expect(store['persister']?.collect()[basicModel.name]).not.toBe( - basicModel.state, - ); - await sleep(50); - expect(store['persister']?.collect()[basicModel.name]).toBe(basicModel.state); -}); - -test('Store can define persist with different engine', async () => { - initializeStoreWithPersist(); + await store.onInitialized(); - await store.onInitialized(); + expect(JSON.stringify(store['persister']?.collect())).toMatchInlineSnapshot( + '"{"basic":{"count":0,"hello":"world"},"complex":{"users":{},"ids":[]},"persist":{"counter":0},"persist1":{"counter":56},"persist2":{"counter":1}}"', + ); - expect(JSON.stringify(store['persister']?.collect())).toBe('{}'); + basicModel.plus(1); + persistModel.plus(103); - basicModel.plus(1); - - await sleep(50); - expect(store['persister']?.collect()).toMatchObject({ - [basicModel.name]: basicModel.state, - [persistModel.name]: persistModel.state, - [hasVersionPersistModel.name]: hasVersionPersistModel.state, - [hasDecodePersistModel.name]: hasDecodePersistModel.state, + await sleep(50); + expect(store['persister']?.collect()).toMatchInlineSnapshot(` + { + "basic": { + "count": 1, + "hello": "world", + }, + "complex": { + "ids": [], + "users": {}, + }, + "persist": { + "counter": 103, + }, + "persist1": { + "counter": 56, + }, + "persist2": { + "counter": 1, + }, + } + `); }); -}); -test('Store can hydrate persist state', async () => { - await engines.memoryStorage.setItem( - 'Test:test1', - JSON.stringify({ - v: 1, - d: { - [basicModel.name]: { - v: 0, - t: Date.now(), - d: JSON.stringify({ - count: 123, - hello: 'earth', - }), + test('Store can load persist state', async () => { + await engines.memoryStorage.setItem( + 'Test:test1', + JSON.stringify({ + v: 1, + d: { + [basicModel.name]: { + v: 0, + t: Date.now(), + d: JSON.stringify({ + count: 123, + hello: 'earth', + }), + }, }, - }, - }), - ); + }), + ); - initializeStoreWithPersist(); + initializeStoreWithMultiplePersist(); - await store.onInitialized(); + await store.onInitialized(); - expect(basicModel.state).toMatchObject({ - count: 123, - hello: 'earth', + expect(basicModel.state).toMatchObject({ + count: 123, + hello: 'earth', + }); }); }); diff --git a/test/typescript/computed.check.ts b/test/typescript/computed.check.ts index 5f1e4f6..b7a6251 100644 --- a/test/typescript/computed.check.ts +++ b/test/typescript/computed.check.ts @@ -1,5 +1,5 @@ import { expectType } from 'ts-expect'; -import { defineModel, useComputed } from '../../src'; +import { cloneModel, defineModel, useComputed } from '../../src'; import { ComputedFlag } from '../../src/model/types'; const model = defineModel('test', { @@ -62,3 +62,9 @@ useComputed(model.withMultipleParameter, ''); useComputed(model.withMultipleParameter, 0); useComputed(model.withMultipleParameter, '', 0); useComputed(model.withMultipleParameter, '', 0, false); + +{ + const model1 = cloneModel('clone-model', model); + model1.fullName(); + model1.withMultipleParameter('', 20); +} diff --git a/test/typescript/persist.check.ts b/test/typescript/persist.check.ts new file mode 100644 index 0000000..33d6786 --- /dev/null +++ b/test/typescript/persist.check.ts @@ -0,0 +1,123 @@ +import { TypeEqual, expectType } from 'ts-expect'; +import { cloneModel, defineModel } from '../../src'; +import { GetInitialState } from '../../src/model/types'; + +const state: { hello: string } = { hello: 'world' }; + +defineModel('model', { + initialState: state, + // @ts-expect-error + persist: { + dump() { + return ''; + }, + }, +}); + +defineModel('model', { + initialState: state, + // @ts-expect-error + persist: { + load() { + return {} as typeof state; + }, + }, +}); + +defineModel('model', { + initialState: state, + persist: { + dump() { + return ''; + }, + load() { + return {} as typeof state; + }, + }, +}); + +defineModel('model', { + initialState: state, + persist: {}, +}); + +defineModel('model', { + initialState: state, + persist: { + version: 1, + }, +}); + +defineModel('model', { + initialState: state, + persist: { + version: '1.0.0', + }, +}); + +const model = defineModel('model', { + initialState: state, + persist: { + dump(state) { + return state.hello; + }, + load(s) { + expectType>(true); + expectType, typeof this>>(true); + return { hello: s }; + }, + }, +}); + +cloneModel('model-1', model, { + persist: {}, +}); + +cloneModel('model-1', model, { + persist: { + version: '', + }, +}); + +cloneModel('model-1', model, { + persist: { + dump(state) { + return state.hello; + }, + load(s) { + expectType>(true); + expectType, typeof this>>(true); + return { hello: s }; + }, + }, +}); + +cloneModel('model-1', model, { + persist: { + dump() { + return 0; + }, + load(s) { + expectType>(true); + return { hello: String(s) }; + }, + }, +}); + +cloneModel('model-1', model, { + // @ts-expect-error + persist: { + dump() { + return 0; + }, + }, +}); + +cloneModel('model-1', model, { + // @ts-expect-error + persist: { + load(dumpData) { + return dumpData as typeof state; + }, + }, +});