cabloy 5.1.207 → 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 (97) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +1 -0
  4. package/package.json +1 -1
  5. package/repo-agent-governance/managed-assets.json +9 -9
  6. package/repo-agent-governance/policies/repository.md +1 -1
  7. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +4 -2
  8. package/repo-agent-governance/skills/cabloy-workflow/evals/evals.json +13 -1
  9. package/repo-docs/backend/unit-testing.md +1 -1
  10. package/repo-docs/frontend/form-guide.md +17 -1
  11. package/repo-docs/frontend/model-architecture.md +4 -0
  12. package/repo-docs/frontend/model-state-guide.md +23 -2
  13. package/repo-docs/frontend/resource-entry-page-deep-dive.md +119 -0
  14. package/repo-docs/frontend/scripts.md +3 -3
  15. package/repo-docs/frontend/zova-form-under-the-hood.md +5 -0
  16. package/repo-docs/fullstack/parallel-worktree-environment.md +3 -3
  17. package/repo-docs/fullstack/quickstart.md +11 -1
  18. package/repo-docs/reference/repo-scripts.md +19 -5
  19. package/repo-e2e/specs/cabloy-basic.spec.ts +1577 -0
  20. package/vona/packages-vona/vona/package.json +1 -1
  21. package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
  22. package/vona/src/suite/a-home/modules/home-user/src/dto/passport.ts +4 -0
  23. package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +172 -0
  24. package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +1 -0
  25. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  26. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  27. package/vona/src/suite-vendor/a-vona/modules/a-openapi/package.json +1 -1
  28. package/vona/src/suite-vendor/a-vona/modules/a-openapi/src/types/rest.ts +1 -0
  29. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  30. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  31. package/zova/packages-cli/cli/package.json +2 -2
  32. package/zova/packages-cli/cli-set-front/cli/templates/rest/render.ts +2 -0
  33. package/zova/packages-cli/cli-set-front/cli/templates/rest/rest.ts +9 -0
  34. package/zova/packages-cli/cli-set-front/package.json +1 -1
  35. package/zova/packages-zova/zova/package.json +2 -2
  36. package/zova/src/suite/a-home/modules/home-api/src/.metadata/index.ts +17 -17
  37. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +68 -52
  38. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +524 -343
  39. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +45 -11
  40. package/zova/src/suite/a-home/modules/home-passport/test/lib/persistenceIdentity.test.ts +229 -0
  41. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/package.json +9 -1
  42. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/.metadata/index.ts +81 -5
  43. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/bean/bean.draftCoordinator.ts +158 -0
  44. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockForm/controller.tsx +11 -3
  45. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx +438 -8
  46. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/config.ts +11 -0
  47. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/en-us.ts +9 -0
  48. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/zh-cn.ts +9 -0
  49. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/lib/draftSchema.ts +649 -0
  50. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/model/draft.ts +59 -0
  51. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/monkey.ts +11 -0
  52. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/types/draft.ts +49 -0
  53. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftDate.test.ts +262 -0
  54. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLifecycle.test.ts +1073 -0
  55. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLoader.mjs +13 -0
  56. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftModel.test.ts +572 -0
  57. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchema.test.ts +427 -0
  58. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchemaSafety.test.ts +200 -0
  59. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  60. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/page/entry/controller.tsx +6 -1
  61. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  62. package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
  63. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/form/controller.tsx +86 -1
  64. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/formField/controller.tsx +88 -13
  65. package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/schemaRefresh.ts +751 -0
  66. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefresh.test.ts +637 -0
  67. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefreshLifecycle.test.ts +994 -0
  68. package/zova/src/suite-vendor/a-zova/modules/a-model/package.json +1 -1
  69. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.persister.ts +331 -87
  70. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.query.ts +11 -6
  71. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useQuery.ts +3 -1
  72. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useState.ts +17 -1
  73. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/index.ts +1 -0
  74. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/modelPersistence.ts +204 -0
  75. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/persistenceQueue.ts +50 -0
  76. package/zova/src/suite-vendor/a-zova/modules/a-model/src/monkey.ts +5 -0
  77. package/zova/src/suite-vendor/a-zova/modules/a-model/src/service/storage.ts +47 -2
  78. package/zova/src/suite-vendor/a-zova/modules/a-model/src/types/query.ts +3 -0
  79. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelLoader.mjs +34 -0
  80. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelPersistence.test.ts +727 -0
  81. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceQueue.test.ts +137 -0
  82. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceTransition.test.ts +129 -0
  83. package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
  84. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/monkey.ts +7 -5
  85. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/rest.ts +2 -0
  86. package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
  87. package/zova/src/suite-vendor/a-zova/modules/a-router/src/bean/bean.router.ts +14 -0
  88. package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/routeDiscard.ts +26 -0
  89. package/zova/src/suite-vendor/a-zova/modules/a-router/src/service/routerGuards.ts +6 -3
  90. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/index.ts +1 -0
  91. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/routeDiscard.ts +24 -0
  92. package/zova/src/suite-vendor/a-zova/modules/a-router/test/lib/routeDiscard.test.ts +105 -0
  93. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/package.json +1 -1
  94. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/src/model/tabs.ts +202 -30
  95. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabs.test.ts +524 -0
  96. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabsLoader.mjs +35 -0
  97. package/zova/src/suite-vendor/a-zova/package.json +6 -6
@@ -0,0 +1,751 @@
1
+ export interface ISchemaRefreshField {
2
+ raw: string;
3
+ compatible: string;
4
+ eligible: boolean;
5
+ }
6
+
7
+ export interface ISchemaRefreshContract {
8
+ ready: boolean;
9
+ validation?: string;
10
+ validationRaw?: string;
11
+ fields: Record<string, ISchemaRefreshField>;
12
+ behavior?: string;
13
+ behaviorRaw?: string;
14
+ }
15
+
16
+ export interface ISchemaRefreshContext {
17
+ locale: string;
18
+ identity: string | undefined;
19
+ epoch: number;
20
+ scene: string;
21
+ contract: ISchemaRefreshContract;
22
+ configuration: string;
23
+ enabled: boolean;
24
+ eligible: boolean;
25
+ }
26
+
27
+ export interface ISchemaRefreshResult {
28
+ changed: boolean;
29
+ preserve: boolean;
30
+ }
31
+
32
+ const schemaMaps = new Set([
33
+ 'properties',
34
+ 'patternProperties',
35
+ '$defs',
36
+ 'definitions',
37
+ 'dependentSchemas',
38
+ ]);
39
+ const schemaArrays = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']);
40
+ const schemaSingles = new Set([
41
+ 'items',
42
+ 'additionalItems',
43
+ 'additionalProperties',
44
+ 'unevaluatedItems',
45
+ 'unevaluatedProperties',
46
+ 'contains',
47
+ 'propertyNames',
48
+ 'not',
49
+ 'if',
50
+ 'then',
51
+ 'else',
52
+ 'contentSchema',
53
+ ]);
54
+ const restScenes = new Set(['table', 'form', 'form-view', 'form-create', 'filter']);
55
+ const restKeys = new Set([
56
+ 'schemaScene',
57
+ 'fieldSource',
58
+ 'visible',
59
+ 'disableNotifyChanged',
60
+ 'readonly',
61
+ 'required',
62
+ 'draft',
63
+ 'order',
64
+ 'align',
65
+ 'width',
66
+ 'fixed',
67
+ 'enableSorting',
68
+ 'sortDescFirst',
69
+ 'render',
70
+ 'options',
71
+ 'class',
72
+ 'style',
73
+ 'placeholder',
74
+ 'blocks',
75
+ 'columnProps',
76
+ ]);
77
+ const renderProps = new Set([
78
+ 'items',
79
+ 'placeholder',
80
+ 'readonly',
81
+ 'required',
82
+ 'name',
83
+ 'type',
84
+ 'options',
85
+ 'class',
86
+ 'style',
87
+ 'relationName',
88
+ 'imageScene',
89
+ 'enableCrop',
90
+ 'cropAspectRatio',
91
+ 'resize',
92
+ 'itemValue',
93
+ 'itemTitle',
94
+ ]);
95
+ const unsafeKeys = new Set(['__proto__', 'prototype', 'constructor']);
96
+ const opaqueSnapshots = new WeakSet<object>();
97
+ const opaqueIds = new WeakMap<object, number>();
98
+ const symbolIds = new Map<symbol, number>();
99
+ let nextOpaqueId = 0;
100
+
101
+ function opaqueId(value: object): number {
102
+ let id = opaqueIds.get(value);
103
+ if (id === undefined) opaqueIds.set(value, (id = ++nextOpaqueId));
104
+ return id;
105
+ }
106
+
107
+ /** Stable configuration signature without invoking getters or retaining caller values. */
108
+ export function schemaRefreshFingerprint(value: unknown): string {
109
+ function encode(item: unknown, ancestors = new Set<object>()): unknown {
110
+ if (typeof item === 'symbol') {
111
+ let id = symbolIds.get(item);
112
+ if (id === undefined) symbolIds.set(item, (id = ++nextOpaqueId));
113
+ return ['symbol', id];
114
+ }
115
+ if (typeof item === 'function') return ['opaque', opaqueId(item)];
116
+ if (typeof item === 'bigint') return ['bigint', String(item)];
117
+ if (typeof item === 'number' && !Number.isFinite(item)) return ['number', String(item)];
118
+ if (item === null || typeof item !== 'object') return data(item);
119
+ if (ancestors.has(item)) return ['cycle', opaqueId(item)];
120
+ if (!Array.isArray(item) && !isRecord(item)) return ['opaque', opaqueId(item)];
121
+ const next = new Set(ancestors).add(item);
122
+ const descriptors = Object.getOwnPropertyDescriptors(item);
123
+ return [
124
+ Array.isArray(item) ? 'array' : 'object',
125
+ Object.keys(descriptors)
126
+ .sort()
127
+ .map(key => {
128
+ const descriptor = descriptors[key];
129
+ return [
130
+ key,
131
+ descriptor.enumerable,
132
+ 'value' in descriptor
133
+ ? encode(descriptor.value, next)
134
+ : [
135
+ 'accessor',
136
+ descriptor.get && opaqueId(descriptor.get),
137
+ descriptor.set && opaqueId(descriptor.set),
138
+ ],
139
+ ];
140
+ }),
141
+ Object.getOwnPropertySymbols(item).map(key => [
142
+ encode(key),
143
+ encode(Object.getOwnPropertyDescriptor(item, key)?.value, next),
144
+ ]),
145
+ ];
146
+ }
147
+ return JSON.stringify(encode(value));
148
+ }
149
+
150
+ class UnresolvedSchemaError extends Error {}
151
+
152
+ function isRecord(value: unknown): value is Record<string, unknown> {
153
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
154
+ const prototype = Object.getPrototypeOf(value);
155
+ return prototype === null || prototype === Object.prototype;
156
+ }
157
+
158
+ // Reject opaque runtime values instead of letting JSON.stringify silently discard them.
159
+ function snapshot(value: unknown, ancestors = new Set<object>()): unknown {
160
+ if (
161
+ value === null ||
162
+ value === undefined ||
163
+ typeof value === 'string' ||
164
+ typeof value === 'boolean'
165
+ ) {
166
+ return value;
167
+ }
168
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
169
+ if (typeof value === 'function') {
170
+ const marker = { $opaqueRuntime: opaqueId(value) };
171
+ opaqueSnapshots.add(marker);
172
+ return marker;
173
+ }
174
+ if (typeof value !== 'object' || ancestors.has(value)) throw new Error('Opaque metadata');
175
+ if (!Array.isArray(value) && !isRecord(value)) throw new Error('Opaque metadata');
176
+ if (Object.getOwnPropertySymbols(value).length) throw new Error('Opaque metadata');
177
+ const next = new Set(ancestors).add(value);
178
+ const descriptors = Object.getOwnPropertyDescriptors(value);
179
+ if (Array.isArray(value)) {
180
+ if (Object.keys(descriptors).length !== value.length + 1) throw new Error('Opaque array');
181
+ return Array.from({ length: value.length }, (_, index) => {
182
+ const descriptor = descriptors[index];
183
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor))
184
+ throw new Error('Opaque array');
185
+ return snapshot(descriptor.value, next);
186
+ });
187
+ }
188
+ const result: Record<string, unknown> = Object.create(null);
189
+ for (const [key, descriptor] of Object.entries(descriptors)) {
190
+ if (!descriptor.enumerable || !('value' in descriptor)) throw new Error('Opaque metadata');
191
+ result[key] = snapshot(descriptor.value, next);
192
+ }
193
+ return result;
194
+ }
195
+
196
+ // Tagged JSON keeps undefined distinct from missing, null, and literal marker strings.
197
+ function data(value: unknown): unknown {
198
+ if (value === undefined) return ['undefined'];
199
+ if (value === null) return ['null'];
200
+ if (typeof value === 'number') return ['number', Object.is(value, -0) ? '-0' : value];
201
+ if (typeof value === 'string' || typeof value === 'boolean') return [typeof value, value];
202
+ if (Array.isArray(value)) return ['array', value.map(data)];
203
+ if (isRecord(value)) {
204
+ return [
205
+ 'object',
206
+ Object.keys(value)
207
+ .sort()
208
+ .map(key => [key, data(value[key])]),
209
+ ];
210
+ }
211
+ throw new Error('Opaque metadata');
212
+ }
213
+
214
+ function containsOpaque(value: unknown): boolean {
215
+ if (!value || typeof value !== 'object') return false;
216
+ if (opaqueSnapshots.has(value)) return true;
217
+ return Array.isArray(value)
218
+ ? value.some(containsOpaque)
219
+ : isRecord(value) && Object.values(value).some(containsOpaque);
220
+ }
221
+
222
+ function opaqueBehavior(value: unknown): boolean {
223
+ if (typeof value === 'string') return value.startsWith('cel://');
224
+ if (Array.isArray(value)) return value.some(opaqueBehavior);
225
+ if (!isRecord(value)) return false;
226
+ return Object.entries(value).some(
227
+ ([key, item]) =>
228
+ (key === '$$typeof'
229
+ ? ![
230
+ 'zova-jsx:component',
231
+ 'zova-jsx:block',
232
+ 'zova-jsx:actionRow',
233
+ 'zova-jsx:actionBulk',
234
+ ].includes(String(item))
235
+ : /^(?:on[A-Z]|effect|evaluate|expression|command|behavior|validator|slot|v-|\$)/i.test(
236
+ key,
237
+ )) || opaqueBehavior(item),
238
+ );
239
+ }
240
+
241
+ const blockOptions: Record<string, string[]> = {
242
+ 'basic-pageentry:blockPageEntry': [
243
+ 'blocks',
244
+ 'resource',
245
+ 'id',
246
+ 'formScene',
247
+ 'pageTitleKey',
248
+ 'draft',
249
+ ],
250
+ 'basic-pageentry:blockForm': ['blocks'],
251
+ 'basic-form:blockFormLayout': ['formLayout'],
252
+ 'basic-pageentry:blockToolbarRow': ['actions'],
253
+ };
254
+
255
+ function safeLayoutNode(value: unknown): boolean {
256
+ if (!isRecord(value) || typeof value.type !== 'string') return false;
257
+ const keys: Record<string, string[]> = {
258
+ tabs: ['type', 'id', 'children'],
259
+ tab: ['type', 'id', 'title', 'children'],
260
+ group: ['type', 'id', 'title', 'description', 'children'],
261
+ section: ['type', 'id', 'title', 'description', 'layout', 'columns', 'children'],
262
+ field: ['type', 'name', 'span'],
263
+ };
264
+ const allowed = keys[value.type];
265
+ if (!allowed || Object.keys(value).some(key => !allowed.includes(key))) return false;
266
+ if (value.title !== undefined && typeof value.title !== 'string') return false;
267
+ if (value.type === 'field') return safeName(value.name);
268
+ return Array.isArray(value.children) && value.children.every(safeLayoutNode);
269
+ }
270
+
271
+ function safeLayout(value: unknown): boolean {
272
+ return (
273
+ isRecord(value) &&
274
+ Object.keys(value).every(key => key === 'children') &&
275
+ Array.isArray(value.children) &&
276
+ value.children.every(safeLayoutNode)
277
+ );
278
+ }
279
+
280
+ function safeActions(value: unknown): boolean {
281
+ return (
282
+ Array.isArray(value) &&
283
+ value.every(action => {
284
+ if (!isRecord(action) || typeof action.render !== 'string') return false;
285
+ if (Object.keys(action).some(key => !['$$typeof', 'name', 'render', 'options'].includes(key)))
286
+ return false;
287
+ if (action.$$typeof !== undefined && action.$$typeof !== 'zova-jsx:actionRow') return false;
288
+ return (
289
+ action.options === undefined ||
290
+ (isRecord(action.options) &&
291
+ Object.keys(action.options).every(key => ['permission', 'class', 'style'].includes(key)))
292
+ );
293
+ })
294
+ );
295
+ }
296
+
297
+ function safeBlocks(value: unknown): boolean {
298
+ return (
299
+ Array.isArray(value) &&
300
+ value.every(block => {
301
+ if (!isRecord(block) || typeof block.render !== 'string') return false;
302
+ if (Object.keys(block).some(key => !['$$typeof', 'render', 'options'].includes(key)))
303
+ return false;
304
+ if (block.$$typeof !== undefined && block.$$typeof !== 'zova-jsx:block') return false;
305
+ const allowed = blockOptions[block.render];
306
+ if (!allowed) return false;
307
+ if (block.options === undefined) return true;
308
+ if (!isRecord(block.options)) return false;
309
+ return Object.entries(block.options).every(([key, item]) => {
310
+ if (!allowed.includes(key) && !['class', 'style'].includes(key)) return false;
311
+ if (key === 'blocks') return safeBlocks(item);
312
+ if (key === 'formLayout') return safeLayout(item);
313
+ if (key === 'actions') return safeActions(item);
314
+ return true;
315
+ });
316
+ })
317
+ );
318
+ }
319
+
320
+ // Only Basic's declared layout grammar owns these title strings. Rest data/options,
321
+ // action metadata, descriptions, field names, and unknown structures stay strict.
322
+ function normalizeLayoutNode(value: unknown): unknown {
323
+ if (!isRecord(value)) return value;
324
+ const result: Record<string, unknown> = { ...value };
325
+ if (['tab', 'group', 'section'].includes(String(value.type)) && typeof value.title === 'string')
326
+ result.title = '';
327
+ if (Array.isArray(value.children)) result.children = value.children.map(normalizeLayoutNode);
328
+ return result;
329
+ }
330
+
331
+ function normalizeBlocks(value: unknown): unknown {
332
+ if (!Array.isArray(value)) return value;
333
+ return value.map(block => {
334
+ if (!isRecord(block) || !isRecord(block.options)) return block;
335
+ const options = { ...block.options };
336
+ if (Object.hasOwn(options, 'blocks')) options.blocks = normalizeBlocks(options.blocks);
337
+ if (block.render === 'basic-form:blockFormLayout' && safeLayout(options.formLayout)) {
338
+ const layout = options.formLayout as Record<string, unknown>;
339
+ options.formLayout = { children: (layout.children as unknown[]).map(normalizeLayoutNode) };
340
+ }
341
+ return { ...block, options };
342
+ });
343
+ }
344
+
345
+ function normalizeAncestorRest(value: unknown): unknown {
346
+ if (!isRecord(value)) return value;
347
+ const result = { ...value };
348
+ for (const [key, item] of Object.entries(value)) {
349
+ if (key === 'blocks' && safeBlocks(item)) result[key] = normalizeBlocks(item);
350
+ else if (restScenes.has(key)) result[key] = normalizeAncestorRest(item);
351
+ }
352
+ return result;
353
+ }
354
+
355
+ function safeRest(value: unknown): boolean {
356
+ if (value === undefined || value === null) return true;
357
+ if (!isRecord(value) || opaqueBehavior(value)) return false;
358
+ return Object.entries(value).every(([key, item]) => {
359
+ if (restScenes.has(key)) return safeRest(item);
360
+ if (!restKeys.has(key)) return false;
361
+ if (item === undefined || item === null) return true;
362
+ if (
363
+ [
364
+ 'visible',
365
+ 'disableNotifyChanged',
366
+ 'readonly',
367
+ 'required',
368
+ 'draft',
369
+ 'enableSorting',
370
+ 'sortDescFirst',
371
+ ].includes(key)
372
+ ) {
373
+ return typeof item === 'boolean';
374
+ }
375
+ if (['order', 'width'].includes(key)) return typeof item === 'number';
376
+ if (['schemaScene', 'fieldSource', 'align', 'fixed', 'placeholder', 'class'].includes(key))
377
+ return typeof item === 'string';
378
+ if (key === 'style') return isRecord(item);
379
+ if (key === 'blocks') return safeBlocks(item);
380
+ if (key === 'options' || key === 'columnProps')
381
+ return isRecord(item) && Object.keys(item).every(part => renderProps.has(part));
382
+ if (key !== 'render' || typeof item === 'string') return true;
383
+ if (!isRecord(item) || typeof item.type !== 'string') return false;
384
+ if (
385
+ Object.keys(item).some(
386
+ part => !['$$typeof', 'type', 'props', 'key', 'children'].includes(part),
387
+ )
388
+ ) {
389
+ return false;
390
+ }
391
+ return (
392
+ item.props === undefined ||
393
+ (isRecord(item.props) && Object.keys(item.props).every(part => renderProps.has(part)))
394
+ );
395
+ });
396
+ }
397
+
398
+ function safeName(value: unknown): value is string {
399
+ return (
400
+ typeof value === 'string' && value.split('.').every(part => !!part && !unsafeKeys.has(part))
401
+ );
402
+ }
403
+
404
+ /** Compile detached comparison facts from schema truth and SDK-canonical effective fields. */
405
+ export function createSchemaRefreshContract(
406
+ schema: unknown,
407
+ properties: readonly unknown[] | undefined,
408
+ resolveSchema: (ref: string) => unknown,
409
+ ): ISchemaRefreshContract {
410
+ if (schema === undefined || schema === null || properties === undefined)
411
+ return { ready: false, fields: {} };
412
+ const resolvedInputs = new Map<string, unknown>();
413
+ try {
414
+ const root = snapshot(schema);
415
+ const effective = snapshot(properties);
416
+ if (!isRecord(root) || !Array.isArray(effective)) throw new Error('Schema unavailable');
417
+ const targets = new Map<string, unknown>();
418
+ let eligible = true;
419
+ const ancestorsRest: unknown[] = [];
420
+ const ancestorsRestRaw: unknown[] = [];
421
+ const detailNames = new Set<string>();
422
+ for (const property of effective) {
423
+ if (!isRecord(property) || !isRecord(property.rest)) continue;
424
+ const rest = property.rest;
425
+ const form = isRecord(rest.form) ? rest.form : undefined;
426
+ const render = rest.render ?? form?.render;
427
+ const renderType = isRecord(render) ? render.type : render;
428
+ if (renderType !== 'basic-details:formFieldDetails') continue;
429
+ for (const name of [
430
+ property.key,
431
+ property.schemaKey,
432
+ ...(Array.isArray(property.schemaKeys) ? property.schemaKeys : []),
433
+ ]) {
434
+ if (typeof name === 'string') detailNames.add(name);
435
+ }
436
+ }
437
+ // Basic Details resolves its row schema through the exact hidden `_name` auxiliary.
438
+ // It belongs to that nested form, not to an unrelated parent field's behavior scope.
439
+ const auxiliaryDetails = new Set<string>();
440
+ for (const property of effective) {
441
+ if (
442
+ !isRecord(property) ||
443
+ typeof property.key !== 'string' ||
444
+ !property.key.startsWith('_') ||
445
+ !detailNames.has(property.key.slice(1)) ||
446
+ !isRecord(property.rest) ||
447
+ property.rest.visible !== false ||
448
+ !(
449
+ property.type === 'array' ||
450
+ (Array.isArray(property.type) &&
451
+ property.type.length === 2 &&
452
+ property.type.includes('array') &&
453
+ property.type.includes('null'))
454
+ ) ||
455
+ !isRecord(property.items) ||
456
+ typeof property.items.$ref !== 'string'
457
+ ) {
458
+ continue;
459
+ }
460
+ auxiliaryDetails.add(property.key);
461
+ }
462
+ for (const name of auxiliaryDetails) detailNames.add(name);
463
+ let localEligible = true;
464
+
465
+ function insideDetails(path: string[]): boolean {
466
+ return path[0] === 'fields'
467
+ ? detailNames.has(path[1]) &&
468
+ path
469
+ .slice(2)
470
+ .some(
471
+ part => schemaMaps.has(part) || schemaSingles.has(part) || schemaArrays.has(part),
472
+ )
473
+ : path[0] === 'properties' &&
474
+ detailNames.has(path[1]) &&
475
+ path
476
+ .slice(2)
477
+ .some(
478
+ part => schemaMaps.has(part) || schemaSingles.has(part) || schemaArrays.has(part),
479
+ );
480
+ }
481
+
482
+ function target(ref: string): unknown {
483
+ if (targets.has(ref)) return targets.get(ref);
484
+ let resolved = resolveSchema(ref);
485
+ if (resolved === undefined && ref.startsWith('#/')) {
486
+ resolved = root;
487
+ for (const part of ref.slice(2).split('/')) {
488
+ const key = decodeURIComponent(part).replace(/~1/g, '/').replace(/~0/g, '~');
489
+ resolved = isRecord(resolved) && Object.hasOwn(resolved, key) ? resolved[key] : undefined;
490
+ }
491
+ }
492
+ if (resolved === undefined) throw new UnresolvedSchemaError('Unresolved reference');
493
+ resolvedInputs.set(ref, resolved);
494
+ const detached = snapshot(resolved);
495
+ targets.set(ref, detached);
496
+ return detached;
497
+ }
498
+
499
+ function project(
500
+ node: unknown,
501
+ normalized: boolean,
502
+ includeRest: boolean,
503
+ refs = new Set<string>(),
504
+ path: string[] = [],
505
+ collect = false,
506
+ ): unknown {
507
+ if (typeof node === 'boolean') return ['schema', data(node)];
508
+ if (!isRecord(node)) throw new Error('Invalid schema node');
509
+ if (collect && Object.hasOwn(node, 'rest')) {
510
+ const safe = safeRest(node.rest);
511
+ if (insideDetails(path)) {
512
+ if (path[0] === 'fields') localEligible &&= safe;
513
+ } else {
514
+ eligible &&= safe;
515
+ }
516
+ // Known details fields own a nested form; their descendants cannot mutate siblings.
517
+ if (
518
+ !insideDetails(path) &&
519
+ (path.length === 0 ||
520
+ (path[0] !== 'fields' &&
521
+ [
522
+ 'properties',
523
+ 'patternProperties',
524
+ 'items',
525
+ 'prefixItems',
526
+ 'additionalProperties',
527
+ ].some(key => Object.hasOwn(node, key))))
528
+ ) {
529
+ ancestorsRestRaw.push([path, data(node.rest)]);
530
+ ancestorsRest.push([path, data(normalizeAncestorRest(node.rest))]);
531
+ }
532
+ }
533
+ const entries: unknown[] = [];
534
+ for (const key of Object.keys(node).sort()) {
535
+ const value = node[key];
536
+ if (key === 'rest' && !includeRest) continue;
537
+ let projected: unknown;
538
+ if (key === '$ref') {
539
+ if (typeof value !== 'string' || refs.has(value)) throw new Error('Cyclic reference');
540
+ projected = [
541
+ 'reference',
542
+ value,
543
+ project(
544
+ target(value),
545
+ normalized,
546
+ includeRest,
547
+ new Set(refs).add(value),
548
+ [...path, key],
549
+ collect,
550
+ ),
551
+ ];
552
+ } else if (
553
+ normalized &&
554
+ (key === 'title' || key === 'description') &&
555
+ typeof value === 'string'
556
+ ) {
557
+ projected = ['display'];
558
+ } else if (normalized && key === 'errorMessage' && isRecord(value)) {
559
+ projected = [
560
+ 'messages',
561
+ Object.keys(value)
562
+ .sort()
563
+ .map(part => [
564
+ part,
565
+ typeof value[part] === 'string' ? ['display'] : data(value[part]),
566
+ ]),
567
+ ];
568
+ } else if (schemaMaps.has(key)) {
569
+ if (!isRecord(value)) throw new Error('Invalid schema map');
570
+ projected = [
571
+ 'map',
572
+ Object.keys(value)
573
+ .sort()
574
+ .map(part => [
575
+ part,
576
+ project(value[part], normalized, includeRest, refs, [...path, key, part], collect),
577
+ ]),
578
+ ];
579
+ } else if (schemaArrays.has(key) || (key === 'items' && Array.isArray(value))) {
580
+ if (!Array.isArray(value)) throw new Error('Invalid schema array');
581
+ projected = [
582
+ 'schemas',
583
+ value.map((item, index) =>
584
+ project(item, normalized, includeRest, refs, [...path, key, String(index)], collect),
585
+ ),
586
+ ];
587
+ } else if (schemaSingles.has(key)) {
588
+ projected = project(value, normalized, includeRest, refs, [...path, key], collect);
589
+ } else if (key === 'dependencies') {
590
+ if (!isRecord(value)) throw new Error('Invalid dependencies');
591
+ projected = [
592
+ 'dependencies',
593
+ Object.keys(value)
594
+ .sort()
595
+ .map(part => [
596
+ part,
597
+ Array.isArray(value[part])
598
+ ? data(value[part])
599
+ : project(
600
+ value[part],
601
+ normalized,
602
+ includeRest,
603
+ refs,
604
+ [...path, key, part],
605
+ collect,
606
+ ),
607
+ ]),
608
+ ];
609
+ } else {
610
+ if (collect && key !== 'rest' && containsOpaque(value)) {
611
+ if (insideDetails(path)) {
612
+ if (path[0] === 'fields') localEligible = false;
613
+ } else {
614
+ eligible = false;
615
+ }
616
+ }
617
+ projected = data(value);
618
+ }
619
+ entries.push([key, projected]);
620
+ }
621
+ return ['schema', entries];
622
+ }
623
+
624
+ const validationRaw = JSON.stringify(project(root, false, false, new Set(), [], true));
625
+ const validation = JSON.stringify(project(root, true, false));
626
+ const fields: Record<string, ISchemaRefreshField> = {};
627
+ for (const property of effective) {
628
+ if (!isRecord(property) || !safeName(property.key) || Object.hasOwn(fields, property.key))
629
+ throw new Error('Invalid canonical field');
630
+ if (
631
+ (property.schemaKey !== undefined && !safeName(property.schemaKey)) ||
632
+ (property.schemaKeys !== undefined &&
633
+ (!Array.isArray(property.schemaKeys) || !property.schemaKeys.every(safeName)))
634
+ ) {
635
+ throw new Error('Invalid field alias');
636
+ }
637
+ localEligible = true;
638
+ const raw = JSON.stringify(
639
+ project(property, false, true, new Set(), ['fields', property.key], true),
640
+ );
641
+ const compatible = JSON.stringify(project(property, true, true));
642
+ fields[property.key] = { raw, compatible, eligible: localEligible };
643
+ }
644
+ for (const field of Object.values(fields)) field.eligible &&= eligible;
645
+ return {
646
+ ready: true,
647
+ validation,
648
+ validationRaw,
649
+ fields,
650
+ behavior: JSON.stringify([eligible, ancestorsRest]),
651
+ behaviorRaw: JSON.stringify([eligible, ancestorsRestRaw]),
652
+ };
653
+ } catch (error) {
654
+ if (error instanceof UnresolvedSchemaError) return { ready: false, fields: {} };
655
+ // Unsupported metadata remains observable, but can never authorize state replay.
656
+ const raw = schemaRefreshFingerprint([schema, [...resolvedInputs.entries()]]);
657
+ const fields: Record<string, ISchemaRefreshField> = {};
658
+ if (Array.isArray(properties)) {
659
+ for (const property of properties) {
660
+ if (!isRecord(property)) continue;
661
+ const descriptor = Object.getOwnPropertyDescriptor(property, 'key');
662
+ if (!descriptor || !('value' in descriptor) || !safeName(descriptor.value)) continue;
663
+ const fieldRaw = schemaRefreshFingerprint(property);
664
+ fields[descriptor.value] = { raw: fieldRaw, compatible: fieldRaw, eligible: false };
665
+ }
666
+ }
667
+ return { ready: true, validation: raw, validationRaw: raw, fields, behavior: raw };
668
+ }
669
+ }
670
+
671
+ interface ISchemaRefreshFacts extends Omit<ISchemaRefreshContext, 'contract'> {
672
+ validation: string | undefined;
673
+ validationRaw: string | undefined;
674
+ behavior: string | undefined;
675
+ behaviorRaw: string | undefined;
676
+ fieldRaw: string | undefined;
677
+ fieldCompatible: string | undefined;
678
+ fieldEligible: boolean;
679
+ }
680
+
681
+ /** Owns comparison facts only, never form values, errors, or schema facade references. */
682
+ export class SchemaRefreshTracker {
683
+ private readonly facts = new Map<string, ISchemaRefreshFacts>();
684
+
685
+ has(name: string): boolean {
686
+ return this.facts.has(name);
687
+ }
688
+
689
+ clear(): void {
690
+ this.facts.clear();
691
+ }
692
+
693
+ prune(contract: ISchemaRefreshContract): void {
694
+ if (!contract.ready) return;
695
+ for (const [name, facts] of this.facts) {
696
+ if (facts.fieldRaw !== undefined && !Object.hasOwn(contract.fields, name))
697
+ this.facts.delete(name);
698
+ }
699
+ }
700
+
701
+ consume(name: string, context: ISchemaRefreshContext): ISchemaRefreshResult {
702
+ if (!context.contract.ready) return { changed: false, preserve: false };
703
+ const { contract, ...scope } = context;
704
+ const field = contract.fields[name];
705
+ const next: ISchemaRefreshFacts = {
706
+ ...scope,
707
+ validation: contract.validation,
708
+ validationRaw: contract.validationRaw ?? contract.validation,
709
+ behavior: contract.behavior,
710
+ behaviorRaw: contract.behaviorRaw ?? contract.behavior,
711
+ fieldRaw: field?.raw,
712
+ fieldCompatible: field?.compatible,
713
+ fieldEligible: field?.eligible === true,
714
+ };
715
+ const previous = this.facts.get(name);
716
+ this.facts.set(name, next);
717
+ if (!previous) return { changed: false, preserve: false };
718
+ const changed = (
719
+ [
720
+ 'locale',
721
+ 'identity',
722
+ 'scene',
723
+ 'configuration',
724
+ 'enabled',
725
+ 'eligible',
726
+ 'validationRaw',
727
+ 'behaviorRaw',
728
+ 'fieldRaw',
729
+ 'fieldEligible',
730
+ ] as const
731
+ ).some(key => previous[key] !== next[key]);
732
+ const preserve =
733
+ changed &&
734
+ previous.locale !== next.locale &&
735
+ next.identity !== undefined &&
736
+ previous.identity === next.identity &&
737
+ previous.epoch === next.epoch &&
738
+ previous.scene === next.scene &&
739
+ previous.configuration === next.configuration &&
740
+ previous.enabled &&
741
+ next.enabled &&
742
+ previous.eligible &&
743
+ next.eligible &&
744
+ previous.fieldEligible &&
745
+ next.fieldEligible &&
746
+ previous.validation === next.validation &&
747
+ previous.behavior === next.behavior &&
748
+ previous.fieldCompatible === next.fieldCompatible;
749
+ return { changed, preserve };
750
+ }
751
+ }