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,427 @@
1
+ import type { SchemaObject } from 'openapi3-ts/oas31';
2
+ import type { ISchemaObjectExtensionField } from 'zova-module-a-openapi';
3
+
4
+ import assert from 'node:assert/strict';
5
+ import test from 'node:test';
6
+
7
+ import {
8
+ createDraftSchema,
9
+ draftBaselineSignature,
10
+ projectDraftValues,
11
+ restoreDraftValues,
12
+ } from '../../src/lib/draftSchema.js';
13
+
14
+ interface ITestSchema extends Omit<ISchemaObjectExtensionField, 'properties' | 'items'> {
15
+ properties?: Record<string, ITestSchema>;
16
+ items?: ITestSchema;
17
+ }
18
+
19
+ function compile(
20
+ schema: ITestSchema,
21
+ scene: 'form' | 'form-create' = 'form',
22
+ refs: Record<string, SchemaObject> = {},
23
+ ) {
24
+ return createDraftSchema(schema, {
25
+ scene,
26
+ resolveSchema: reference => refs[reference],
27
+ loadProperties(schema, scene) {
28
+ const result: ISchemaObjectExtensionField[] = [];
29
+ for (const [name, raw] of Object.entries(schema.properties ?? {})) {
30
+ const source = (raw.$ref ? refs[raw.$ref] : raw) as ISchemaObjectExtensionField;
31
+ if (!source) continue;
32
+ const rest = {
33
+ ...source.rest,
34
+ ...(scene !== 'form' ? source.rest?.form : undefined),
35
+ ...source.rest?.[scene],
36
+ };
37
+ const key = rest.fieldSource ?? name;
38
+ let property = source;
39
+ if (rest.fieldSource) {
40
+ const [parent, child] = key.split('.');
41
+ const rawParent = schema.properties?.[parent] as SchemaObject;
42
+ const parentSchema = rawParent?.$ref ? refs[rawParent.$ref] : rawParent;
43
+ property = parentSchema?.properties?.[child] as ISchemaObjectExtensionField;
44
+ if (!property) continue;
45
+ }
46
+ const existing = result.find(item => item.key === key);
47
+ if (existing) {
48
+ existing.schemaKeys = [...(existing.schemaKeys ?? []), name];
49
+ continue;
50
+ }
51
+ result.push({
52
+ ...property,
53
+ key,
54
+ schemaKey: key === name ? undefined : name,
55
+ rest: {
56
+ ...property.rest,
57
+ ...(scene !== 'form' ? property.rest?.form : undefined),
58
+ ...property.rest?.[scene],
59
+ },
60
+ });
61
+ }
62
+ return result;
63
+ },
64
+ });
65
+ }
66
+
67
+ const string = { type: 'string' } as const;
68
+ const level = {
69
+ anyOf: [1, 2, 3].map(value => ({ type: 'number' as const, enum: [value] })),
70
+ } satisfies SchemaObject;
71
+
72
+ test('draft Student level unions recover numeric selections over the edit baseline', () => {
73
+ for (const union of [level, { oneOf: level.anyOf }]) {
74
+ const compiled = compile({ type: 'object', properties: { level: union } });
75
+ const projected = projectDraftValues(compiled, { level: 2 });
76
+ assert.deepEqual(projected.values, { level: 2 });
77
+ assert.deepEqual(
78
+ restoreDraftValues(compiled, JSON.parse(JSON.stringify(projected)), { level: 1 }),
79
+ {
80
+ level: 2,
81
+ },
82
+ );
83
+ assert.notEqual(
84
+ draftBaselineSignature(compiled, { level: 1 }),
85
+ draftBaselineSignature(compiled, { level: 2 }),
86
+ );
87
+ assert.deepEqual(
88
+ restoreDraftValues(
89
+ compiled,
90
+ { ...projected, values: { level: { private: 'no' } } },
91
+ { level: 1 },
92
+ ),
93
+ { level: 1 },
94
+ );
95
+ assert.deepEqual(
96
+ restoreDraftValues(compiled, { ...projected, values: { level: [2] } }, { level: 1 }),
97
+ { level: 1 },
98
+ );
99
+ }
100
+ });
101
+
102
+ test('draft primitive unions retain incomplete values and missing/undefined/null distinctions', () => {
103
+ const compiled = compile({ type: 'object', properties: { level } });
104
+ for (const value of [2, '', 'unfinished', false, 0, null, undefined, 99]) {
105
+ const projected = projectDraftValues(compiled, { level: value });
106
+ assert.deepEqual(
107
+ restoreDraftValues(compiled, JSON.parse(JSON.stringify(projected)), { level: 1 }),
108
+ {
109
+ level: value,
110
+ },
111
+ );
112
+ }
113
+ assert.deepEqual(restoreDraftValues(compiled, projectDraftValues(compiled, {}), { level: 1 }), {
114
+ level: 1,
115
+ });
116
+ for (const value of [Number.NaN, Infinity, -Infinity, {}, [], new Date(), 2n]) {
117
+ assert.deepEqual(projectDraftValues(compiled, { level: value }).values, {});
118
+ }
119
+ });
120
+
121
+ test('draft primitive/null alternatives include refs, const/enum inference and nested unions', () => {
122
+ const compiled = compile(
123
+ {
124
+ type: 'object',
125
+ properties: {
126
+ mixed: {
127
+ oneOf: [
128
+ { $ref: '#Scalar' },
129
+ { const: false },
130
+ { enum: [null, 1, 'other'] },
131
+ { type: ['integer', 'null'] },
132
+ { anyOf: [{ type: 'number' }, { type: 'null' }] },
133
+ ],
134
+ },
135
+ rows: { type: 'array', items: { type: 'object', properties: { level } } },
136
+ },
137
+ },
138
+ 'form',
139
+ { '#Scalar': { type: 'string', maxLength: 20 } },
140
+ );
141
+ const projected = projectDraftValues(compiled, { mixed: false, rows: [{ level: 2 }] });
142
+ assert.deepEqual(projected.values, { mixed: false, rows: [{ level: 2 }] });
143
+ assert.deepEqual(restoreDraftValues(compiled, JSON.parse(JSON.stringify(projected)), {}), {
144
+ mixed: false,
145
+ rows: [{ level: 2 }],
146
+ });
147
+ });
148
+
149
+ test('draft union signatures retain operator, branch constraints and ref wrappers, not labels', () => {
150
+ const schema: ITestSchema = { type: 'object', properties: { level: structuredClone(level) } };
151
+ const initial = compile(schema).signature;
152
+ schema.properties!.level.title = '培训阶段';
153
+ (schema.properties!.level.anyOf![1] as SchemaObject).title = '提升班';
154
+ assert.equal(compile(schema).signature, initial);
155
+ const replacements: ITestSchema[] = [
156
+ { oneOf: level.anyOf },
157
+ { anyOf: [...level.anyOf, { const: 4 }] },
158
+ { anyOf: [{ type: 'number', enum: [1], minimum: 1 }, ...level.anyOf.slice(1)] },
159
+ { anyOf: [{ type: 'number', enum: [1], multipleOf: 2 }, ...level.anyOf.slice(1)] },
160
+ { anyOf: [{ type: 'integer', enum: [1] }, ...level.anyOf.slice(1)] },
161
+ { anyOf: [{ type: 'number', enum: [4] }, ...level.anyOf.slice(1)] },
162
+ ];
163
+ for (const replacement of replacements) {
164
+ assert.notEqual(
165
+ compile({ type: 'object', properties: { level: replacement } }).signature,
166
+ initial,
167
+ );
168
+ }
169
+ const withRef = (minimum: number) =>
170
+ compile(
171
+ {
172
+ type: 'object',
173
+ properties: { level: { anyOf: [{ $ref: '#Level', minimum }, { type: 'null' }] } },
174
+ },
175
+ 'form',
176
+ { '#Level': { type: 'number', enum: [1, 2, 3] } },
177
+ );
178
+ assert.notEqual(withRef(1).signature, withRef(2).signature);
179
+ assert.notEqual(initial, compile({ type: 'object', properties: {} }).signature);
180
+ });
181
+
182
+ test('draft fields default on with sensitive, readonly and file exclusions', () => {
183
+ const schema = compile({
184
+ type: 'object',
185
+ properties: {
186
+ name: string,
187
+ empty: string,
188
+ title: string,
189
+ readonly: { ...string, readOnly: true },
190
+ rendererReadonly: { ...string, rest: { readonly: true } },
191
+ privateNote: { ...string, rest: { draft: false } },
192
+ password: { ...string, rest: { draft: true } },
193
+ code: { ...string, writeOnly: true },
194
+ accessToken: string,
195
+ verificationCode: string,
196
+ apiKey: string,
197
+ masked: { ...string, rest: { options: { type: 'password' } } } as any,
198
+ picture: { ...string, rest: { render: 'basic-image:formFieldImage' } },
199
+ file: { ...string, format: 'binary' },
200
+ },
201
+ });
202
+ const input = Object.fromEntries(
203
+ [
204
+ 'name',
205
+ 'empty',
206
+ 'title',
207
+ 'readonly',
208
+ 'rendererReadonly',
209
+ 'privateNote',
210
+ 'password',
211
+ 'code',
212
+ 'accessToken',
213
+ 'verificationCode',
214
+ 'apiKey',
215
+ 'masked',
216
+ 'picture',
217
+ 'file',
218
+ 'unknown',
219
+ ].map(key => [key, key]),
220
+ );
221
+ input.empty = '';
222
+ input.title = undefined as any;
223
+ const projected = projectDraftValues(schema, input);
224
+ assert.deepEqual(projected.values, { name: 'name', empty: '' });
225
+ assert.deepEqual(projected.undefinedPaths, ['["title"]']);
226
+ const restored = restoreDraftValues(schema, JSON.parse(JSON.stringify(projected)), {
227
+ title: 'baseline',
228
+ readonly: 'fresh',
229
+ privateNote: 'private',
230
+ });
231
+ assert.deepEqual(restored, {
232
+ title: undefined,
233
+ readonly: 'fresh',
234
+ privateNote: 'private',
235
+ name: 'name',
236
+ empty: '',
237
+ });
238
+ assert.equal(
239
+ projectDraftValues(
240
+ compile({ type: 'object', rest: { draft: false }, properties: { name: string } }),
241
+ input,
242
+ ).values.name,
243
+ undefined,
244
+ );
245
+ });
246
+
247
+ test('draft recursively projects declared fields, refs and scene overrides', () => {
248
+ const schema: ITestSchema = {
249
+ type: 'object',
250
+ properties: {
251
+ title: { ...string, rest: { 'form-create': { draft: false } } },
252
+ content: { $ref: '#Content' },
253
+ tags: { type: 'array', items: string },
254
+ blocked: { type: 'object', rest: { draft: false }, properties: { safe: string } },
255
+ },
256
+ };
257
+ const refs = {
258
+ '#Content': {
259
+ type: 'object',
260
+ properties: {
261
+ description: string,
262
+ secret: string,
263
+ nested: {
264
+ type: 'array',
265
+ items: {
266
+ type: 'object',
267
+ properties: { label: string, readonly: { ...string, readOnly: true } },
268
+ },
269
+ },
270
+ },
271
+ },
272
+ } as Record<string, SchemaObject>;
273
+ const input = {
274
+ title: 'draft',
275
+ content: {
276
+ description: 'text',
277
+ secret: 'no',
278
+ unknown: 'no',
279
+ nested: [{ label: 'yes', readonly: 'no' }],
280
+ },
281
+ tags: ['one', 'two'],
282
+ blocked: { safe: 'no' },
283
+ };
284
+ assert.deepEqual(projectDraftValues(compile(schema, 'form-create', refs), input).values, {
285
+ content: { description: 'text', nested: [{ label: 'yes' }] },
286
+ tags: ['one', 'two'],
287
+ });
288
+ assert.equal(projectDraftValues(compile(schema, 'form', refs), input).values.title, 'draft');
289
+ });
290
+
291
+ test('draft uses canonical fieldSource and conservatively excludes colliding aliases', () => {
292
+ const schema: ITestSchema = {
293
+ type: 'object',
294
+ properties: {
295
+ content: { type: 'object', properties: { description: string } },
296
+ _description: { ...string, rest: { fieldSource: 'content.description' } },
297
+ },
298
+ };
299
+ assert.deepEqual(
300
+ projectDraftValues(compile(schema), {
301
+ content: { description: 'yes', unknown: 'no' },
302
+ _description: 'not canonical',
303
+ }).values,
304
+ { content: { description: 'yes' } },
305
+ );
306
+ schema.properties!._secret = {
307
+ ...string,
308
+ rest: { fieldSource: 'content.description', draft: false },
309
+ };
310
+ assert.deepEqual(projectDraftValues(compile(schema), { content: { description: 'no' } }).values, {
311
+ content: {},
312
+ });
313
+ });
314
+
315
+ test('draft relation recovery preserves fresh IDs and readonly values across row reorder', () => {
316
+ const compiled = compile({
317
+ type: 'object',
318
+ properties: {
319
+ details: {
320
+ type: 'array',
321
+ items: {
322
+ type: 'object',
323
+ properties: {
324
+ id: { type: 'number', readOnly: true },
325
+ label: string,
326
+ deleted: { type: 'boolean', readOnly: true },
327
+ server: { ...string, readOnly: true },
328
+ },
329
+ },
330
+ },
331
+ },
332
+ });
333
+ const baseline = {
334
+ details: [
335
+ { id: 1, label: 'one', server: 'first' },
336
+ { id: 2, label: 'two', server: 'second' },
337
+ ],
338
+ };
339
+ const input = {
340
+ details: [
341
+ { id: 2, label: 'edited', server: 'stale' },
342
+ { id: 1, label: 'one', deleted: true },
343
+ { label: 'new' },
344
+ ],
345
+ };
346
+ const projected = projectDraftValues(compiled, input, baseline);
347
+ assert.deepEqual(projected.values, {
348
+ details: [{ label: 'edited' }, { label: 'one' }, { label: 'new' }],
349
+ });
350
+ assert.deepEqual(projected.relations, {
351
+ '["details"]': [{ id: 2 }, { id: 1, deleted: true }, {}],
352
+ });
353
+ assert.deepEqual(
354
+ restoreDraftValues(compiled, projected, { details: baseline.details.toReversed() }),
355
+ {
356
+ details: [
357
+ { id: 2, label: 'edited', server: 'second' },
358
+ { id: 1, label: 'one', server: 'first', deleted: true },
359
+ { label: 'new' },
360
+ ],
361
+ },
362
+ );
363
+ assert.equal(
364
+ restoreDraftValues(compiled, projected, { details: [baseline.details[0]] }),
365
+ undefined,
366
+ );
367
+ assert.equal(
368
+ draftBaselineSignature(compiled, baseline),
369
+ draftBaselineSignature(compiled, { details: baseline.details.toReversed() }),
370
+ );
371
+ const duplicate = structuredClone(projected);
372
+ duplicate.relations['["details"]'][1].id = 2;
373
+ assert.equal(restoreDraftValues(compiled, duplicate, baseline), undefined);
374
+ });
375
+
376
+ test('draft rejects runtime objects, cycles and prototype paths without leaking unknown fields', () => {
377
+ const compiled = compile({
378
+ type: 'object',
379
+ properties: {
380
+ 'normal': string,
381
+ 'unsupported': string,
382
+ 'number': { type: 'number' },
383
+ 'object': { type: 'object', properties: { child: string } },
384
+ '__proto__.polluted': string,
385
+ 'constructor.prototype.polluted': string,
386
+ },
387
+ });
388
+ const cyclic: Record<string, unknown> = {};
389
+ cyclic.child = cyclic;
390
+ const projection = projectDraftValues(compiled, {
391
+ normal: 'safe',
392
+ unsupported: new Blob(['private']),
393
+ number: Number.NaN,
394
+ object: cyclic,
395
+ });
396
+ assert.deepEqual(projection.values, { normal: 'safe', object: {} });
397
+ (projection.values as Record<string, unknown>).unknown = 'not declared';
398
+ projection.values.object = JSON.parse('{"child":"valid","__proto__":{"polluted":true}}');
399
+ const restored = restoreDraftValues(compiled, projection, {}) as Record<string, unknown>;
400
+ assert.deepEqual(restored.object, { child: 'valid' });
401
+ assert.equal(restored.unknown, undefined);
402
+ assert.equal(({} as any).polluted, undefined);
403
+ });
404
+
405
+ test('draft compatibility ignores translations but detects structure and safety changes', () => {
406
+ const schema: ITestSchema = {
407
+ type: 'object',
408
+ title: 'English',
409
+ properties: { name: { ...string, title: 'Name', description: 'Help' }, password: string },
410
+ };
411
+ const compiled = compile(schema);
412
+ const baseline = { name: 'old', password: 'never stored' };
413
+ const signature = draftBaselineSignature(compiled, baseline);
414
+ assert.equal(
415
+ signature,
416
+ draftBaselineSignature(compiled, { name: 'old', password: 'different secret' }),
417
+ );
418
+ assert.notEqual(
419
+ signature,
420
+ draftBaselineSignature(compiled, { name: 'new', password: 'never stored' }),
421
+ );
422
+ schema.title = '中文';
423
+ schema.properties!.name.title = '姓名';
424
+ assert.equal(compiled.signature, compile(schema).signature);
425
+ schema.properties!.name.rest = { draft: false };
426
+ assert.notEqual(compiled.signature, compile(schema).signature);
427
+ });
@@ -0,0 +1,200 @@
1
+ import type { ISchemaObjectExtensionField } from 'zova-module-a-openapi';
2
+
3
+ import assert from 'node:assert/strict';
4
+ import test from 'node:test';
5
+
6
+ import {
7
+ createDraftSchema,
8
+ projectDraftValues,
9
+ restoreDraftValues,
10
+ } from '../../src/lib/draftSchema.js';
11
+
12
+ function compile(
13
+ properties: Record<string, any>,
14
+ refs: Record<string, any> = {},
15
+ scene: 'form' | 'form-create' = 'form',
16
+ ) {
17
+ return createDraftSchema(
18
+ { type: 'object', properties },
19
+ {
20
+ scene,
21
+ resolveSchema: reference => refs[reference],
22
+ loadProperties(schema) {
23
+ return Object.entries(schema.properties ?? {}).map(
24
+ ([key, value]) => ({ ...value, key }) as ISchemaObjectExtensionField,
25
+ );
26
+ },
27
+ },
28
+ );
29
+ }
30
+
31
+ test('draft unions reject every unsafe alternative including null branches and ref wrappers', () => {
32
+ const exclusions = [
33
+ { readOnly: true },
34
+ { writeOnly: true },
35
+ { format: 'password' },
36
+ { format: 'binary' },
37
+ { contentEncoding: 'base64' },
38
+ { contentMediaType: 'image/png' },
39
+ { rest: { draft: false } },
40
+ { rest: { readonly: true } },
41
+ { rest: { options: { type: 'password' } } },
42
+ { rest: { render: { type: 'basic-file:formFieldFile' } } },
43
+ { rest: { render: { type: 'basic-input:formFieldInput', props: { readonly: true } } } },
44
+ ];
45
+ for (const metadata of exclusions) {
46
+ for (const type of ['number', 'null']) {
47
+ for (const branch of [
48
+ { type, ...metadata },
49
+ { $ref: '#Safe', ...metadata },
50
+ { $ref: '#Unsafe' },
51
+ { $ref: '#Wrapper' },
52
+ ]) {
53
+ const compiled = compile(
54
+ { value: { anyOf: [{ type: 'number' }, branch, { type: 'null' }] } },
55
+ {
56
+ '#Safe': { type },
57
+ '#Unsafe': { type, ...metadata },
58
+ '#Wrapper': { $ref: '#Safe', ...metadata },
59
+ },
60
+ );
61
+ assert.deepEqual(
62
+ projectDraftValues(compiled, { value: 2 }).values,
63
+ {},
64
+ JSON.stringify(branch),
65
+ );
66
+ }
67
+ }
68
+ }
69
+ });
70
+
71
+ test('draft union safety honors scene overrides without dropping excluded alternatives', () => {
72
+ const properties = {
73
+ value: {
74
+ anyOf: [{ type: 'number' }, { type: 'null', rest: { 'form-create': { draft: false } } }],
75
+ },
76
+ };
77
+ assert.deepEqual(projectDraftValues(compile(properties), { value: 2 }).values, { value: 2 });
78
+ assert.deepEqual(
79
+ projectDraftValues(compile(properties, {}, 'form-create'), { value: 2 }).values,
80
+ {},
81
+ );
82
+ });
83
+
84
+ test('draft unions fail closed on unsupported, unresolved, cyclic and structural alternatives', () => {
85
+ const invalid = [
86
+ {},
87
+ { default: 2 },
88
+ { enum: [] },
89
+ { const: {} },
90
+ { enum: [1, {}] },
91
+ { enum: [1, []] },
92
+ { type: [] },
93
+ { type: 'unknown' },
94
+ { type: ['number', 'object'] },
95
+ { type: ['number', 'array'], items: { type: 'number' } },
96
+ { type: 'object', properties: { label: { type: 'string' } } },
97
+ { type: 'array', items: { type: 'string' } },
98
+ { $ref: '#Missing' },
99
+ { $ref: '#Safe', type: ['number', 'object'] },
100
+ { $ref: '#Cycle' },
101
+ { $ref: '#Recursive' },
102
+ { anyOf: [{ type: 'number' }], oneOf: [{ type: 'number' }] },
103
+ { anyOf: [{ type: 'number' }], allOf: [{ type: 'number' }] },
104
+ { allOf: [{ type: 'number' }] },
105
+ ];
106
+ for (const branch of invalid) {
107
+ const compiled = compile(
108
+ { value: { anyOf: [{ type: 'number' }, branch, { type: 'null' }] } },
109
+ {
110
+ '#Safe': { type: 'number' },
111
+ '#Cycle': { $ref: '#Cycle' },
112
+ '#Recursive': { anyOf: [{ type: 'number' }, { $ref: '#Recursive' }] },
113
+ },
114
+ );
115
+ assert.deepEqual(projectDraftValues(compiled, { value: 2 }).values, {}, JSON.stringify(branch));
116
+ }
117
+ for (const value of [
118
+ { anyOf: [] },
119
+ { anyOf: [{ type: 'number' }], oneOf: [{ type: 'number' }] },
120
+ { anyOf: [{ type: 'number' }], allOf: [] },
121
+ { anyOf: [{ type: 'object' }, { type: 'object' }, { type: 'null' }] },
122
+ {
123
+ type: ['number', 'object'],
124
+ anyOf: [{ type: 'object', properties: { label: { type: 'string' } } }, { type: 'null' }],
125
+ },
126
+ ]) {
127
+ assert.deepEqual(projectDraftValues(compile({ value }), { value: 2 }).values, {});
128
+ }
129
+ });
130
+
131
+ test('draft nullable structural unions still reject unsafe or unresolved null alternatives', () => {
132
+ const object = { type: 'object', properties: { label: { type: 'string' } } };
133
+ for (const branch of [{ type: 'null', rest: { draft: false } }, { $ref: '#Missing' }]) {
134
+ const compiled = compile({ value: { anyOf: [object, { type: 'null' }, branch] } });
135
+ assert.deepEqual(projectDraftValues(compiled, { value: { label: 'no' } }).values, {});
136
+ }
137
+ });
138
+
139
+ test('draft excludes JSX file/password/readonly renderers and obvious credential names', () => {
140
+ const string = { type: 'string' };
141
+ const compiled = compile({
142
+ token: string,
143
+ login_token: string,
144
+ pin: string,
145
+ tokenizer: string,
146
+ code: string,
147
+ monkey: string,
148
+ upload: { ...string, rest: { render: { type: 'basic-file:formFieldFile' } } },
149
+ masked: {
150
+ ...string,
151
+ rest: { render: { type: 'basic-input:formFieldInput', props: { type: 'password' } } },
152
+ },
153
+ readonly: {
154
+ ...string,
155
+ rest: { render: { type: 'basic-input:formFieldInput', props: { readonly: true } } },
156
+ },
157
+ });
158
+ const values = Object.fromEntries(
159
+ [
160
+ 'token',
161
+ 'login_token',
162
+ 'pin',
163
+ 'tokenizer',
164
+ 'code',
165
+ 'monkey',
166
+ 'upload',
167
+ 'masked',
168
+ 'readonly',
169
+ ].map(key => [key, 'value']),
170
+ );
171
+ assert.deepEqual(projectDraftValues(compiled, values).values, {
172
+ tokenizer: 'value',
173
+ code: 'value',
174
+ monkey: 'value',
175
+ });
176
+ });
177
+
178
+ test('draft nullable objects and arrays preserve ordinary incomplete values', () => {
179
+ const compiled = compile({
180
+ nested: {
181
+ anyOf: [
182
+ { type: 'object', properties: { label: { type: 'string' }, password: { type: 'string' } } },
183
+ { type: 'null' },
184
+ ],
185
+ },
186
+ names: { type: ['array', 'null'], items: { type: 'string' } },
187
+ });
188
+ assert.deepEqual(
189
+ projectDraftValues(compiled, {
190
+ nested: { label: '', password: 'never' },
191
+ names: ['one', undefined, ''],
192
+ }).values,
193
+ { nested: { label: '' }, names: ['one', null, ''] },
194
+ );
195
+ const projected = projectDraftValues(compiled, { nested: null, names: ['one', undefined, ''] });
196
+ assert.deepEqual(restoreDraftValues(compiled, JSON.parse(JSON.stringify(projected)), {}), {
197
+ nested: null,
198
+ names: ['one', undefined, ''],
199
+ });
200
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-module-rest-resource",
3
- "version": "5.1.55",
3
+ "version": "5.1.56",
4
4
  "gitHead": "4dece229b6d3f4735930e833afcb4b95ec51bf86",
5
5
  "description": "",
6
6
  "keywords": [
@@ -2,6 +2,7 @@ import type {
2
2
  IFormMeta,
3
3
  IFormProvider,
4
4
  ISchemaObjectExtensionField,
5
+ IResourceRenderBlockOptionsBlock,
5
6
  TypeFormScene,
6
7
  } from 'zova-module-a-openapi';
7
8
 
@@ -29,6 +30,7 @@ export class ControllerPageEntry extends BeanControllerPageBase {
29
30
  formProvider: IFormProvider;
30
31
  formSchema?: ISchemaObjectExtensionField;
31
32
  jsxZova: ZovaJsx;
33
+ private _blocks?: IResourceRenderBlockOptionsBlock[];
32
34
 
33
35
  @Use({ beanFullName: 'rest-resource.model.resource' })
34
36
  get $$modelResource(): ModelResource {
@@ -71,7 +73,10 @@ export class ControllerPageEntry extends BeanControllerPageBase {
71
73
  }
72
74
 
73
75
  protected render() {
74
- const blocks = this.formSchema?.rest?.blocks;
76
+ const schema = this.formSchema;
77
+ // Keep the mounted editor while the current locale's schema is loading.
78
+ if (schema) this._blocks = schema.rest?.blocks;
79
+ const blocks = this._blocks;
75
80
  if (!blocks || blocks.length === 0) return;
76
81
  const domBlocks: VNode[] = [];
77
82
  blocks.forEach((block, index) => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-suite-a-cabloy",
3
- "version": "5.1.57",
3
+ "version": "5.1.58",
4
4
  "gitHead": "4dece229b6d3f4735930e833afcb4b95ec51bf86",
5
5
  "description": "",
6
6
  "author": "",
@@ -9,7 +9,7 @@
9
9
  "dependencies": {
10
10
  "zova-module-a-layoutprofile": "^5.0.3",
11
11
  "zova-module-a-rbac": "^5.0.1",
12
- "zova-module-rest-resource": "^5.1.55"
12
+ "zova-module-rest-resource": "^5.1.56"
13
13
  },
14
14
  "title": "a-cabloy"
15
15
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-module-a-form",
3
- "version": "5.1.51",
3
+ "version": "5.1.52",
4
4
  "gitHead": "09d901d17140a80ee0764211b441cda72fd94663",
5
5
  "description": "",
6
6
  "keywords": [