cabloy 5.1.208 → 5.1.209

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/package.json +1 -1
  4. package/repo-agent-governance/managed-assets.json +9 -9
  5. package/repo-agent-governance/policies/repository.md +1 -1
  6. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +4 -2
  7. package/repo-agent-governance/skills/cabloy-workflow/evals/evals.json +13 -1
  8. package/repo-docs/backend/unit-testing.md +1 -1
  9. package/repo-docs/frontend/form-guide.md +17 -1
  10. package/repo-docs/frontend/model-architecture.md +4 -0
  11. package/repo-docs/frontend/model-state-guide.md +23 -2
  12. package/repo-docs/frontend/resource-entry-page-deep-dive.md +119 -0
  13. package/repo-docs/frontend/scripts.md +3 -3
  14. package/repo-docs/frontend/zova-form-under-the-hood.md +5 -0
  15. package/repo-docs/fullstack/parallel-worktree-environment.md +3 -3
  16. package/repo-docs/reference/repo-scripts.md +19 -5
  17. package/repo-e2e/specs/cabloy-basic.spec.ts +1577 -0
  18. package/vona/packages-vona/vona/package.json +1 -1
  19. package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
  20. package/vona/src/suite/a-home/modules/home-user/src/dto/passport.ts +4 -0
  21. package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +172 -0
  22. package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +1 -0
  23. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  24. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  25. package/vona/src/suite-vendor/a-vona/modules/a-openapi/package.json +1 -1
  26. package/vona/src/suite-vendor/a-vona/modules/a-openapi/src/types/rest.ts +1 -0
  27. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  28. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  29. package/zova/packages-cli/cli/package.json +2 -2
  30. package/zova/packages-cli/cli-set-front/cli/templates/rest/render.ts +2 -0
  31. package/zova/packages-cli/cli-set-front/cli/templates/rest/rest.ts +9 -0
  32. package/zova/packages-cli/cli-set-front/package.json +1 -1
  33. package/zova/packages-zova/zova/package.json +2 -2
  34. package/zova/src/suite/a-home/modules/home-api/src/.metadata/index.ts +17 -17
  35. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +68 -52
  36. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +524 -343
  37. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +45 -11
  38. package/zova/src/suite/a-home/modules/home-passport/test/lib/persistenceIdentity.test.ts +229 -0
  39. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/package.json +9 -1
  40. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/.metadata/index.ts +81 -5
  41. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/bean/bean.draftCoordinator.ts +158 -0
  42. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockForm/controller.tsx +11 -3
  43. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx +438 -8
  44. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/config.ts +11 -0
  45. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/en-us.ts +9 -0
  46. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/zh-cn.ts +9 -0
  47. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/lib/draftSchema.ts +649 -0
  48. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/model/draft.ts +59 -0
  49. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/monkey.ts +11 -0
  50. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/types/draft.ts +49 -0
  51. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftDate.test.ts +262 -0
  52. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLifecycle.test.ts +1073 -0
  53. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLoader.mjs +13 -0
  54. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftModel.test.ts +572 -0
  55. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchema.test.ts +427 -0
  56. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchemaSafety.test.ts +200 -0
  57. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  58. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/page/entry/controller.tsx +6 -1
  59. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  60. package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
  61. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/form/controller.tsx +86 -1
  62. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/formField/controller.tsx +88 -13
  63. package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/schemaRefresh.ts +751 -0
  64. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefresh.test.ts +637 -0
  65. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefreshLifecycle.test.ts +994 -0
  66. package/zova/src/suite-vendor/a-zova/modules/a-model/package.json +1 -1
  67. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.persister.ts +331 -87
  68. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.query.ts +11 -6
  69. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useQuery.ts +3 -1
  70. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useState.ts +17 -1
  71. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/index.ts +1 -0
  72. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/modelPersistence.ts +204 -0
  73. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/persistenceQueue.ts +50 -0
  74. package/zova/src/suite-vendor/a-zova/modules/a-model/src/monkey.ts +5 -0
  75. package/zova/src/suite-vendor/a-zova/modules/a-model/src/service/storage.ts +47 -2
  76. package/zova/src/suite-vendor/a-zova/modules/a-model/src/types/query.ts +3 -0
  77. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelLoader.mjs +34 -0
  78. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelPersistence.test.ts +727 -0
  79. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceQueue.test.ts +137 -0
  80. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceTransition.test.ts +129 -0
  81. package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
  82. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/monkey.ts +7 -5
  83. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/rest.ts +2 -0
  84. package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
  85. package/zova/src/suite-vendor/a-zova/modules/a-router/src/bean/bean.router.ts +14 -0
  86. package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/routeDiscard.ts +26 -0
  87. package/zova/src/suite-vendor/a-zova/modules/a-router/src/service/routerGuards.ts +6 -3
  88. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/index.ts +1 -0
  89. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/routeDiscard.ts +24 -0
  90. package/zova/src/suite-vendor/a-zova/modules/a-router/test/lib/routeDiscard.test.ts +105 -0
  91. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/package.json +1 -1
  92. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/src/model/tabs.ts +202 -30
  93. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabs.test.ts +524 -0
  94. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabsLoader.mjs +35 -0
  95. package/zova/src/suite-vendor/a-zova/package.json +6 -6
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-module-a-model",
3
- "version": "5.1.37",
3
+ "version": "5.1.38",
4
4
  "gitHead": "09d901d17140a80ee0764211b441cda72fd94663",
5
5
  "description": "core",
6
6
  "keywords": [
@@ -1,90 +1,147 @@
1
1
  import type { Query, QueryKey } from '@tanstack/vue-query';
2
2
 
3
+ import { CancelledError, notifyManager } from '@tanstack/query-core';
3
4
  import { experimental_createQueryPersister } from '@tanstack/query-persist-client-core';
5
+ import { hashKey } from '@tanstack/vue-query';
4
6
  import localforage from 'localforage';
5
7
  import { SymbolBeanFullName } from 'zova';
6
8
 
9
+ import type { ModelPersistenceToken } from '../../lib/modelPersistence.js';
7
10
  import type { QueryMetaPersister } from '../../types/index.js';
8
11
 
9
12
  import { CookieWrapper } from '../../common/cookieWrapper.js';
13
+ import { getModelPersistenceStorageKey } from '../../lib/modelPersistence.js';
14
+ import { getPersistenceQueue } from '../../lib/persistenceQueue.js';
10
15
  import { createQueryPersister } from '../../lib/queryPersister.js';
11
16
  import { resolveMaxAgeTime } from '../../types/index.js';
12
17
  import { BeanModelLast } from './bean.model.last.js';
13
18
 
14
19
  export class BeanModelPersister extends BeanModelLast {
20
+ private _persisterRestoreErrors = new Set<string>();
21
+
22
+ $persisterError(queryKey: QueryKey): boolean {
23
+ const query = this.self.$queryFind({ queryKey });
24
+ const options = this._adjustPersisterOptions(query?.meta?.persister);
25
+ return (
26
+ !!options &&
27
+ this._persisterRestoreErrors.has(this._getPersisterStorageKey(options, query, queryKey))
28
+ );
29
+ }
30
+
15
31
  private _persisterLoad_inner<T>(
16
- storage: Storage,
17
- storageKey: string,
18
32
  storedData: any,
19
33
  query: Query,
20
34
  options: QueryMetaPersister,
21
- ): T | undefined {
22
- if (!storedData) return undefined;
35
+ ): { data?: T; remove: boolean; timestamps?: { dataUpdatedAt: number; errorUpdatedAt: number } } {
36
+ if (!storedData) return { remove: false };
23
37
  const persistedQuery = options.deserialize
24
38
  ? options.deserialize(storedData as string, options.deserializeDefault!)
25
39
  : options.deserializeDefault!(storedData as string);
26
-
27
40
  if (persistedQuery.state.dataUpdatedAt) {
28
41
  const queryAge = Date.now() - persistedQuery.state.dataUpdatedAt;
29
42
  const expired = queryAge > (resolveMaxAgeTime(options.maxAge, query) ?? Infinity);
30
43
  const busted = persistedQuery.buster !== options.buster;
31
- if (expired || busted) {
32
- storage.removeItem(storageKey);
33
- } else {
34
- // Set proper updatedAt, since resolving in the first pass overrides those values
35
- query.setState({
36
- dataUpdatedAt: persistedQuery.state.dataUpdatedAt,
37
- errorUpdatedAt: persistedQuery.state.errorUpdatedAt,
38
- });
39
- return persistedQuery.state.data as T;
44
+ if (!expired && !busted) {
45
+ return {
46
+ data: persistedQuery.state.data as T,
47
+ remove: false,
48
+ timestamps: {
49
+ dataUpdatedAt: persistedQuery.state.dataUpdatedAt,
50
+ errorUpdatedAt: persistedQuery.state.errorUpdatedAt,
51
+ },
52
+ };
40
53
  }
41
- } else {
42
- storage.removeItem(storageKey);
43
54
  }
55
+ return { remove: true };
44
56
  }
45
57
 
46
58
  $persisterLoad<T>(queryKey: QueryKey): Promise<T | undefined> | T | undefined {
59
+ const ctx = this.ctx;
60
+ if (ctx?.disposed) return undefined;
61
+ const persistence = this.$modelPersistence;
47
62
  const query = this.self.$queryFind({ queryKey });
48
63
  if (!query) return undefined;
49
64
  const options = this._adjustPersisterOptions(query.meta?.persister);
50
65
  if (!options) return undefined;
51
66
  const storage = this._getPersisterStorage(options, query);
52
67
  if (!storage) return undefined;
53
- const storageKey = this._getPersisterStorageKey(options, query);
54
- try {
55
- const storedData = storage.getItem(storageKey);
56
- if (options.sync) {
57
- return this._persisterLoad_inner(storage as any, storageKey, storedData, query, options);
58
- } else {
59
- return (storedData as Promise<T>).then(storedData => {
60
- return this._persisterLoad_inner(storage as any, storageKey, storedData, query, options);
68
+ const token = persistence.capture();
69
+ if (options.storage === 'db') persistence.touch(query);
70
+ const storageKey = this._getPersisterStorageKey(options, query, undefined, token);
71
+ this._persisterRestoreErrors.delete(storageKey);
72
+ const queue = getPersistenceQueue(storage);
73
+ const generation = queue.generation(storageKey);
74
+ const current = () =>
75
+ !ctx?.disposed &&
76
+ generation === queue.generation(storageKey) &&
77
+ (options.storage !== 'db' || persistence.current(token));
78
+ if (!options.sync) {
79
+ return queue
80
+ .enqueue(async () => {
81
+ if (!current()) return undefined;
82
+ const storedData = await storage.getItem(storageKey);
83
+ if (!current()) return undefined;
84
+ try {
85
+ const result = this._persisterLoad_inner<T>(storedData, query, options);
86
+ if (!current()) return undefined;
87
+ if (result.timestamps) query.setState(result.timestamps);
88
+ if (result.remove) await storage.removeItem(storageKey);
89
+ return current() ? result.data : undefined;
90
+ } catch {
91
+ if (!current()) return undefined;
92
+ this._persisterRestoreErrors.add(storageKey);
93
+ await storage.removeItem(storageKey);
94
+ this._reportPersisterError('restore');
95
+ return undefined;
96
+ }
97
+ })
98
+ .catch(() => {
99
+ if (current()) {
100
+ this._persisterRestoreErrors.add(storageKey);
101
+ this._reportPersisterError('restore');
102
+ }
103
+ return undefined;
61
104
  });
105
+ }
106
+ try {
107
+ const result = this._persisterLoad_inner<T>(storage.getItem(storageKey), query, options);
108
+ if (result.timestamps) query.setState(result.timestamps);
109
+ if (result.remove) storage.removeItem(storageKey);
110
+ return result.data;
111
+ } catch {
112
+ this._persisterRestoreErrors.add(storageKey);
113
+ this._reportPersisterError('restore');
114
+ try {
115
+ storage.removeItem(storageKey);
116
+ } catch {
117
+ this._reportPersisterError('remove');
62
118
  }
63
- } catch (err) {
64
- if (process.env.DEV) {
65
- console.error(err);
66
- console.warn(
67
- 'Encountered an error attempting to restore query cache from persisted location.',
68
- );
69
- }
70
- storage.removeItem(storageKey);
71
119
  }
72
120
  }
73
121
 
74
- $persisterSave(queryKey: QueryKey) {
122
+ $persisterGeneration(queryKey: QueryKey): string {
75
123
  const query = this.self.$queryFind({ queryKey });
76
- if (!query) return;
124
+ const options = this._adjustPersisterOptions(query?.meta?.persister);
125
+ if (!options) return '';
126
+ const storage = this._getPersisterStorage(options, query);
127
+ if (!storage) return '';
128
+ const storageKey = this._getPersisterStorageKey(options, query, queryKey);
129
+ return `${options.storage === 'db' ? this.$modelPersistence.epoch : ''}:${storageKey}:${getPersistenceQueue(storage).generation(storageKey)}`;
130
+ }
131
+
132
+ $persisterSave(queryKey: QueryKey): Promise<void> {
133
+ const query = this.self.$queryFind({ queryKey });
134
+ if (!query) return Promise.resolve();
77
135
  const options = this._adjustPersisterOptions(query.meta?.persister);
78
- if (!options) return;
79
- this._persisterSave(query, options);
136
+ if (!options) return Promise.resolve();
137
+ return this._persisterSave(query, options);
80
138
  }
81
139
 
82
140
  $persisterSaveTo(queryKey: QueryKey, storageTarget: 'cookie' | 'local', value?: unknown) {
83
141
  const query = this.self.$queryFind({ queryKey });
84
142
  if (!query) {
85
- if (process.env.DEV) {
143
+ if (process.env.DEV)
86
144
  console.warn('Cannot save persisted query because it was not found', queryKey);
87
- }
88
145
  return;
89
146
  }
90
147
  const persister =
@@ -98,72 +155,252 @@ export class BeanModelPersister extends BeanModelLast {
98
155
  ? String(obj?.state?.data ?? '')
99
156
  : JSON.stringify(obj?.state?.data),
100
157
  });
101
- if (!options) return;
102
- this._persisterSave(query, options, value);
158
+ if (options) this._persisterSave(query, options, value);
103
159
  }
104
160
 
105
- private _persisterSave(query: Query, options: QueryMetaPersister, value?: unknown) {
106
- const storage = this._getPersisterStorage(options, query);
107
- if (!storage) return;
108
- const storageKey = this._getPersisterStorageKey(options, query);
161
+ private _serializePersister(query: Query, options: QueryMetaPersister, value?: unknown): string {
109
162
  const params = {
110
163
  state: value === undefined ? query.state : { ...query.state, data: value },
111
164
  queryKey: query.queryKey,
112
165
  queryHash: query.queryHash,
113
166
  buster: options.buster,
114
167
  };
115
- const data = options.serialize
168
+ return options.serialize
116
169
  ? options.serialize(params, options.serializeDefault!)
117
170
  : options.serializeDefault!(params);
118
- if (options.sync === true) {
119
- storage.setItem(storageKey, data);
120
- } else {
121
- // Persist if we have storage defined, we use timeout to get proper state to be persisted
122
- setTimeout(() => {
171
+ }
172
+
173
+ private _persisterSave(
174
+ query: Query,
175
+ options: QueryMetaPersister,
176
+ value?: unknown,
177
+ ): Promise<void> {
178
+ const storage = this._getPersisterStorage(options, query);
179
+ if (!storage) return Promise.resolve();
180
+ if (options.storage === 'db') this.$modelPersistence.touch(query);
181
+ const storageKey = this._getPersisterStorageKey(options, query);
182
+ const queue = getPersistenceQueue(storage);
183
+ queue.invalidate(storageKey);
184
+ let data: string;
185
+ try {
186
+ data = this._serializePersister(query, options, value);
187
+ } catch (error) {
188
+ return this._handlePersisterOperation(
189
+ queue.enqueue(() => {
190
+ throw error;
191
+ }),
192
+ 'save',
193
+ );
194
+ }
195
+ if (options.sync === true && options.storage !== 'db') {
196
+ try {
123
197
  storage.setItem(storageKey, data);
124
- }, 0);
198
+ return Promise.resolve();
199
+ } catch (error) {
200
+ return this._handlePersisterOperation(Promise.reject(error), 'save');
201
+ }
125
202
  }
203
+ // Accepted explicit writes keep their captured physical identity. The shared
204
+ // queue orders them before a later remove or full-store clear.
205
+ return this._handlePersisterOperation(
206
+ queue.enqueue(async () => {
207
+ await storage.setItem(storageKey, data);
208
+ }),
209
+ 'save',
210
+ );
126
211
  }
127
212
 
128
- $persisterRemove(queryKey: QueryKey) {
213
+ $persisterRemove(queryKey: QueryKey, persister?: QueryMetaPersister): Promise<void> {
129
214
  const query = this.self.$queryFind({ queryKey });
130
- if (!query) return;
131
- const options = this._adjustPersisterOptions(query.meta?.persister);
132
- if (!options) return;
215
+ if (!query && !persister) return Promise.resolve();
216
+ const options = this._adjustPersisterOptions(persister ?? query?.meta?.persister);
217
+ if (!options) return Promise.resolve();
133
218
  const storage = this._getPersisterStorage(options, query);
134
- if (!storage) return;
135
- const storageKey = this._getPersisterStorageKey(options, query);
136
- if (options.sync === true) {
137
- storage.removeItem(storageKey);
138
- } else {
139
- // Persist if we have storage defined, we use timeout to get proper state to be persisted
140
- setTimeout(() => {
219
+ if (!storage) return Promise.resolve();
220
+ if (options.storage === 'db') this.$modelPersistence.touch(query);
221
+ const storageKey = this._getPersisterStorageKey(options, query, queryKey);
222
+ const queue = getPersistenceQueue(storage);
223
+ queue.invalidate(storageKey);
224
+ if (options.sync === true && options.storage !== 'db') {
225
+ try {
141
226
  storage.removeItem(storageKey);
142
- }, 0);
227
+ return Promise.resolve();
228
+ } catch (error) {
229
+ return this._handlePersisterOperation(Promise.reject(error), 'remove');
230
+ }
143
231
  }
232
+ return this._handlePersisterOperation(
233
+ queue.enqueue(async () => {
234
+ await storage.removeItem(storageKey);
235
+ }),
236
+ 'remove',
237
+ );
238
+ }
239
+
240
+ async $persisterFlush(): Promise<void> {
241
+ if (process.env.SERVER) return;
242
+ await getPersistenceQueue(localforage).flush();
243
+ }
244
+
245
+ private _handlePersisterOperation<T>(operation: Promise<T>, action: string): Promise<T> {
246
+ operation.catch(() => this._reportPersisterError(action));
247
+ return operation;
248
+ }
249
+
250
+ private _reportPersisterError(action: string) {
251
+ if (process.env.DEV)
252
+ console.warn(`Encountered an error attempting to ${action} persisted query cache.`);
144
253
  }
145
254
 
146
255
  protected _createPersister(options?: QueryMetaPersister | boolean): any {
147
256
  options = this._adjustPersisterOptions(options);
148
257
  if (!options) return undefined;
149
- const persisterFn = experimental_createQueryPersister({
150
- storage: this._getPersisterStorage(options) as any,
151
- maxAge: options.maxAge as number,
152
- refetchOnRestore: options.refetchOnRestore,
153
- prefix: options.prefix,
154
- buster: options.buster,
155
- }).persisterFn;
156
- return createQueryPersister(persisterFn);
258
+ if (options.storage === 'db') return this._createDbPersister(options);
259
+ const storage = this._getPersisterStorage(options);
260
+ const persisterOptions = options;
261
+ return createQueryPersister((queryFn, context, query) => {
262
+ const queue = storage ? getPersistenceQueue(storage) : undefined;
263
+ const storageKey = `${persisterOptions.prefix}-${query.queryHash}`;
264
+ const generation = queue?.generation(storageKey);
265
+ const current = () => generation === queue?.generation(storageKey);
266
+ const orderedStorage =
267
+ storage && queue && !persisterOptions.sync
268
+ ? {
269
+ getItem: (key: string) =>
270
+ queue.enqueue(() => (current() ? storage.getItem(key) : undefined)),
271
+ setItem: (key: string, data: string) =>
272
+ this._handlePersisterOperation(
273
+ queue.enqueue(async () => {
274
+ if (current()) await storage.setItem(key, data);
275
+ }),
276
+ 'save',
277
+ ),
278
+ removeItem: (key: string) =>
279
+ this._handlePersisterOperation(
280
+ queue.enqueue(async () => {
281
+ if (current()) await storage.removeItem(key);
282
+ }),
283
+ 'remove',
284
+ ),
285
+ }
286
+ : storage;
287
+ return experimental_createQueryPersister({
288
+ storage: orderedStorage as any,
289
+ maxAge: persisterOptions.maxAge as number,
290
+ refetchOnRestore: persisterOptions.refetchOnRestore,
291
+ prefix: persisterOptions.prefix,
292
+ buster: persisterOptions.buster,
293
+ }).persisterFn(queryFn, context, query);
294
+ });
295
+ }
296
+
297
+ private _createDbPersister(options: QueryMetaPersister) {
298
+ const ctx = this.ctx;
299
+ const persistence = this.$modelPersistence;
300
+ // Do not use the upstream persister here: its deferred timestamp/refetch/save
301
+ // callbacks can run after a scope transition or owner disposal.
302
+ return async (queryFn, context, query: Query) => {
303
+ if (ctx?.disposed) throw new CancelledError({ silent: true, revert: false });
304
+ persistence.touch(query);
305
+ const token = persistence.capture();
306
+ const storage = this._getPersisterStorage(options, query);
307
+ const queue = storage && getPersistenceQueue(storage);
308
+ const storageKey = this._getPersisterStorageKey(options, query, undefined, token);
309
+ const generation = queue?.generation(storageKey);
310
+ const current = () =>
311
+ !ctx?.disposed &&
312
+ persistence.current(token) &&
313
+ generation === queue?.generation(storageKey);
314
+ const assertCurrent = () => {
315
+ if (!current()) throw new CancelledError({ silent: true, revert: false });
316
+ };
317
+ const bypass =
318
+ (query.state.fetchMeta as { bypassPersister?: boolean } | null)?.bypassPersister === true;
319
+ if (!bypass && storage && queue && query.state.data === undefined) {
320
+ this._persisterRestoreErrors.delete(storageKey);
321
+ const restored = await queue
322
+ .enqueue(async () => {
323
+ if (!current()) return undefined;
324
+ try {
325
+ const storedData = await storage.getItem(storageKey);
326
+ if (!current()) return undefined;
327
+ const result = this._persisterLoad_inner(storedData, query, options);
328
+ if (!current()) return undefined;
329
+ if (result.remove) await storage.removeItem(storageKey);
330
+ return current() ? result : undefined;
331
+ } catch {
332
+ if (current()) {
333
+ this._persisterRestoreErrors.add(storageKey);
334
+ await storage.removeItem(storageKey);
335
+ this._reportPersisterError('restore');
336
+ }
337
+ return undefined;
338
+ }
339
+ })
340
+ .catch(() => {
341
+ if (current()) {
342
+ this._persisterRestoreErrors.add(storageKey);
343
+ this._reportPersisterError('restore');
344
+ }
345
+ return undefined;
346
+ });
347
+ assertCurrent();
348
+ if (restored?.data !== undefined) {
349
+ notifyManager.schedule(() => {
350
+ if (!current()) return;
351
+ if (restored.timestamps) query.setState(restored.timestamps);
352
+ if (
353
+ options.refetchOnRestore === 'always' ||
354
+ (options.refetchOnRestore === true && query.isStale())
355
+ ) {
356
+ void query.fetch().catch(() => undefined);
357
+ }
358
+ });
359
+ return restored.data;
360
+ }
361
+ }
362
+ assertCurrent();
363
+ let result;
364
+ try {
365
+ result = await queryFn(context);
366
+ } catch (error) {
367
+ assertCurrent();
368
+ throw error;
369
+ }
370
+ assertCurrent();
371
+ if (!bypass && storage && queue) {
372
+ notifyManager.schedule(() => {
373
+ if (!current()) return;
374
+ let data: string;
375
+ try {
376
+ data = this._serializePersister(query, options, result);
377
+ } catch (error) {
378
+ this._handlePersisterOperation(
379
+ queue.enqueue(() => {
380
+ throw error;
381
+ }),
382
+ 'save',
383
+ );
384
+ return;
385
+ }
386
+ this._handlePersisterOperation(
387
+ queue.enqueue(async () => {
388
+ if (current()) await storage.setItem(storageKey, data);
389
+ }),
390
+ 'save',
391
+ );
392
+ });
393
+ }
394
+ return result;
395
+ };
157
396
  }
158
397
 
159
398
  protected _adjustPersisterOptions(options?: QueryMetaPersister | boolean) {
160
399
  if (options === false) return undefined;
161
- if (options === undefined || options === true) {
162
- options = {};
163
- } else {
164
- options = { ...options };
165
- }
400
+ options = options === undefined || options === true ? {} : { ...options };
166
401
  options.storage = options.storage ?? (options.sync ? 'local' : 'db');
402
+ // DB storage is always asynchronous, including custom explicitly synced options.
403
+ if (options.storage === 'db') options.sync = false;
167
404
  options.maxAge = options.maxAge ?? this.scopeSelf.config.persister.maxAge[options.storage];
168
405
  options.refetchOnRestore =
169
406
  options.refetchOnRestore ?? this.scopeSelf.config.persister.refetchOnRestore;
@@ -174,22 +411,32 @@ export class BeanModelPersister extends BeanModelLast {
174
411
  return options;
175
412
  }
176
413
 
177
- protected _getPersisterStorageKey(options: QueryMetaPersister, query: Query) {
178
- if (options.storageKeySimplify) return String(query.queryKey[query.queryKey.length - 1]);
179
- return `${options.prefix}-${query.queryHash}`;
414
+ protected _getPersisterStorageKey(
415
+ options: QueryMetaPersister,
416
+ query?: Query,
417
+ queryKey?: QueryKey,
418
+ token?: ModelPersistenceToken,
419
+ ) {
420
+ const key = query?.queryKey ?? this._forceQueryKeyPrefix(queryKey);
421
+ const logicalKey = options.storageKeySimplify
422
+ ? String(key[key.length - 1])
423
+ : `${options.prefix}-${query?.queryHash ?? hashKey(key)}`;
424
+ return options.storage === 'db'
425
+ ? getModelPersistenceStorageKey(
426
+ this.sys.env.APP_NAME ?? '',
427
+ (token ?? this.$modelPersistence.capture()).scope,
428
+ logicalKey,
429
+ )
430
+ : logicalKey;
180
431
  }
181
432
 
182
433
  protected _getPersisterStorage(options?: QueryMetaPersister | boolean, query?: Query) {
183
434
  options = this._adjustPersisterOptions(options);
184
435
  if (!options) return undefined;
185
- // cookie
186
436
  if (options.storage === 'cookie')
187
437
  return this.bean._newBeanSimple(CookieWrapper, false, options, query);
188
- // check server
189
438
  if (process.env.SERVER) return undefined;
190
- // local
191
439
  if (options.storage === 'local') return localStorage;
192
- // db
193
440
  if (options.storage === 'db') return localforage;
194
441
  }
195
442
 
@@ -205,9 +452,7 @@ export class BeanModelPersister extends BeanModelLast {
205
452
  if (!queryKey) queryKey = [];
206
453
  if (!this._prefixIsBeanFullName(queryKey[0])) {
207
454
  const prefixes = [this[SymbolBeanFullName]];
208
- if (this.$onionOptions?.enableSelector) {
209
- prefixes.push(this.selector);
210
- }
455
+ if (this.$onionOptions?.enableSelector) prefixes.push(this.selector);
211
456
  queryKey = prefixes.concat(queryKey as any);
212
457
  }
213
458
  return queryKey;
@@ -215,6 +460,5 @@ export class BeanModelPersister extends BeanModelLast {
215
460
 
216
461
  private _prefixIsBeanFullName(prefix?: any) {
217
462
  return prefix === this[SymbolBeanFullName];
218
- // return (prefix && typeof prefix === 'string' && prefix.split('.').length === 3);
219
463
  }
220
464
  }
@@ -17,6 +17,7 @@ import { cast } from 'zova';
17
17
 
18
18
  import type { MaybeRefDeep, NoUnknown } from '../../common/types.js';
19
19
 
20
+ import { getPersistenceQueue } from '../../lib/persistenceQueue.js';
20
21
  import { BeanModelCookie } from './bean.model.cookie.js';
21
22
 
22
23
  export class BeanModelQuery extends BeanModelCookie {
@@ -83,12 +84,16 @@ export class BeanModelQuery extends BeanModelCookie {
83
84
  }
84
85
 
85
86
  async $clear() {
86
- const queries = this.$queryClient.getQueryCache().getAll();
87
- for (const query of queries) {
88
- query?.setData(undefined);
89
- }
90
- // remove all db cache
91
- await localforage.clear();
87
+ const queue = getPersistenceQueue(localforage);
88
+ // Invalidate in-flight restores and scheduled persisters before clearing memory.
89
+ queue.invalidateAll();
90
+ const clear = process.env.SERVER
91
+ ? Promise.resolve()
92
+ : queue.enqueue(async () => {
93
+ await localforage.clear();
94
+ });
95
+ this.$modelPersistence.clear();
96
+ await clear;
92
97
  }
93
98
 
94
99
  $normalizeFilters<T extends {}>(filters?: T): T {
@@ -43,7 +43,9 @@ export class BeanModelUseQuery extends BeanModelQuery {
43
43
  $useQuery(options, queryClient) {
44
44
  const queryKey = this.self._forceQueryKeyPrefix(options.queryKey);
45
45
  const persister = this._createPersister(options.meta?.persister);
46
- options = Object.assign({}, options, { queryKey, persister });
46
+ options = this.$modelPersistence.guardOptions(
47
+ Object.assign({}, options, { queryKey, persister }),
48
+ );
47
49
  // staleTime
48
50
  const sync = typeof options.meta?.persister === 'object' && options.meta?.persister?.sync;
49
51
  if (sync !== true) {
@@ -346,17 +346,33 @@ export class BeanModelUseState extends BeanModelUseQuery {
346
346
  }
347
347
 
348
348
  private _handleAsyncDataGet(options, queryClient, persister) {
349
+ if (this.ctx?.disposed) return undefined;
350
+ // Track the context even when the disabled Query already contains data.
351
+ // A scope switch must invalidate the custom-ref consumer synchronously.
352
+ void this.$modelPersistence.epoch;
349
353
  const query = this.$useStateData(options, queryClient);
350
354
  if (query.data !== undefined) return query.data;
351
355
  return this._handleAsyncDataGet_inner(options, queryClient, persister);
352
356
  }
353
357
 
354
358
  private async _handleAsyncDataGet_inner(options, queryClient, persister) {
359
+ const ctx = this.ctx;
360
+ if (ctx?.disposed) return undefined;
361
+ const persistence = this.$modelPersistence;
355
362
  const queryKey = options.queryKey;
363
+ const token = persistence.capture();
356
364
  const query = this.$useStateData(options, queryClient);
357
365
  if (persister) {
366
+ const generation = this.$persisterGeneration(queryKey);
358
367
  const data = await this.$persisterLoad(queryKey);
359
- if (data !== undefined) {
368
+ if (
369
+ ctx?.disposed ||
370
+ !persistence.current(token) ||
371
+ generation !== this.$persisterGeneration(queryKey)
372
+ ) {
373
+ return undefined;
374
+ }
375
+ if (data !== undefined && query.data === undefined) {
360
376
  this.$setQueryData(queryKey, data, false);
361
377
  }
362
378
  }
@@ -1,2 +1,3 @@
1
1
  export * from './model.js';
2
+ export * from './modelPersistence.js';
2
3
  export * from './utils.js';