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.
- package/.cabloy-version +1 -1
- package/CHANGELOG.md +13 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/repo-agent-governance/managed-assets.json +9 -9
- package/repo-agent-governance/policies/repository.md +1 -1
- package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +4 -2
- package/repo-agent-governance/skills/cabloy-workflow/evals/evals.json +13 -1
- package/repo-docs/backend/unit-testing.md +1 -1
- package/repo-docs/frontend/form-guide.md +17 -1
- package/repo-docs/frontend/model-architecture.md +4 -0
- package/repo-docs/frontend/model-state-guide.md +23 -2
- package/repo-docs/frontend/resource-entry-page-deep-dive.md +119 -0
- package/repo-docs/frontend/scripts.md +3 -3
- package/repo-docs/frontend/zova-form-under-the-hood.md +5 -0
- package/repo-docs/fullstack/parallel-worktree-environment.md +3 -3
- package/repo-docs/fullstack/quickstart.md +11 -1
- package/repo-docs/reference/repo-scripts.md +19 -5
- package/repo-e2e/specs/cabloy-basic.spec.ts +1577 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/src/dto/passport.ts +4 -0
- package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +172 -0
- package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +1 -0
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-openapi/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-openapi/src/types/rest.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/packages-cli/cli/package.json +2 -2
- package/zova/packages-cli/cli-set-front/cli/templates/rest/render.ts +2 -0
- package/zova/packages-cli/cli-set-front/cli/templates/rest/rest.ts +9 -0
- package/zova/packages-cli/cli-set-front/package.json +1 -1
- package/zova/packages-zova/zova/package.json +2 -2
- package/zova/src/suite/a-home/modules/home-api/src/.metadata/index.ts +17 -17
- package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +68 -52
- package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +524 -343
- package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +45 -11
- package/zova/src/suite/a-home/modules/home-passport/test/lib/persistenceIdentity.test.ts +229 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/package.json +9 -1
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/.metadata/index.ts +81 -5
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/bean/bean.draftCoordinator.ts +158 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockForm/controller.tsx +11 -3
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx +438 -8
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/config.ts +11 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/en-us.ts +9 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/zh-cn.ts +9 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/lib/draftSchema.ts +649 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/model/draft.ts +59 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/monkey.ts +11 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/types/draft.ts +49 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftDate.test.ts +262 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLifecycle.test.ts +1073 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLoader.mjs +13 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftModel.test.ts +572 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchema.test.ts +427 -0
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchemaSafety.test.ts +200 -0
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/page/entry/controller.tsx +6 -1
- package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/form/controller.tsx +86 -1
- package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/formField/controller.tsx +88 -13
- package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/schemaRefresh.ts +751 -0
- package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefresh.test.ts +637 -0
- package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefreshLifecycle.test.ts +994 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.persister.ts +331 -87
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.query.ts +11 -6
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useQuery.ts +3 -1
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useState.ts +17 -1
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/index.ts +1 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/modelPersistence.ts +204 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/persistenceQueue.ts +50 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/monkey.ts +5 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/service/storage.ts +47 -2
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/types/query.ts +3 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelLoader.mjs +34 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelPersistence.test.ts +727 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceQueue.test.ts +137 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceTransition.test.ts +129 -0
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/monkey.ts +7 -5
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/rest.ts +2 -0
- package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-router/src/bean/bean.router.ts +14 -0
- package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/routeDiscard.ts +26 -0
- package/zova/src/suite-vendor/a-zova/modules/a-router/src/service/routerGuards.ts +6 -3
- package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/index.ts +1 -0
- package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/routeDiscard.ts +24 -0
- package/zova/src/suite-vendor/a-zova/modules/a-router/test/lib/routeDiscard.test.ts +105 -0
- package/zova/src/suite-vendor/a-zova/modules/a-routertabs/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-routertabs/src/model/tabs.ts +202 -30
- package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabs.test.ts +524 -0
- package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabsLoader.mjs +35 -0
- package/zova/src/suite-vendor/a-zova/package.json +6 -6
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
import type { SchemaObject } from 'openapi3-ts/oas31';
|
|
2
|
+
import type { ISchemaObjectExtensionField } from 'zova-module-a-openapi';
|
|
3
|
+
|
|
4
|
+
import type { IDraftProjection, IDraftSchemaOptions } from '../types/draft.js';
|
|
5
|
+
|
|
6
|
+
interface IDraftNode {
|
|
7
|
+
kind: 'scalar' | 'object' | 'array';
|
|
8
|
+
shape: unknown;
|
|
9
|
+
fields?: { path: string[]; node: IDraftNode }[];
|
|
10
|
+
item?: IDraftNode;
|
|
11
|
+
relation?: boolean;
|
|
12
|
+
codec?: 'date-time';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface IDraftSchema {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
signature: string;
|
|
18
|
+
node: IDraftNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const unsafeKeys = new Set(['__proto__', 'prototype', 'constructor']);
|
|
22
|
+
const omitted = Symbol('omitted');
|
|
23
|
+
const sensitiveName =
|
|
24
|
+
/password|passwd|secret|credential|captcha|verificationCode|verifyCode|securityCode|smsCode|accessToken|refreshToken|authToken|apiKey|privateKey|signingKey|encryptionKey|oneTimePassword|otp|(?:^|[._\s])(?:token|key|pin)(?:$|[._\s])/i;
|
|
25
|
+
const sensitiveFormat = /^(?:password|secret|token|credential|captcha|otp|binary|byte)$/i;
|
|
26
|
+
const fileRenderer = /formField(?:File|Image|Upload)|^(?:file|image|upload|password|captcha)$/i;
|
|
27
|
+
|
|
28
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
29
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
30
|
+
const prototype = Object.getPrototypeOf(value);
|
|
31
|
+
return prototype === null || prototype === Object.prototype;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function safePath(key: string): string[] | undefined {
|
|
35
|
+
const path = key.split('.');
|
|
36
|
+
return path.every(part => part && !unsafeKeys.has(part)) ? path : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function effectiveSchema(schema: SchemaObject, options: IDraftSchemaOptions) {
|
|
40
|
+
const rest = (schema as ISchemaObjectExtensionField).rest;
|
|
41
|
+
return {
|
|
42
|
+
...schema,
|
|
43
|
+
rest: {
|
|
44
|
+
...rest,
|
|
45
|
+
...(options.scene !== 'form' ? rest?.form : undefined),
|
|
46
|
+
...rest?.[options.scene],
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function excluded(schema: ISchemaObjectExtensionField, key = '', includeReadonly = true) {
|
|
52
|
+
const rest = schema.rest as Record<string, any> | undefined;
|
|
53
|
+
const render = rest?.render;
|
|
54
|
+
const renderType = isRecord(render) ? render.type : render;
|
|
55
|
+
const renderProps = isRecord(render) && isRecord(render.props) ? render.props : undefined;
|
|
56
|
+
return (
|
|
57
|
+
rest?.draft === false ||
|
|
58
|
+
(includeReadonly &&
|
|
59
|
+
(schema.readOnly === true ||
|
|
60
|
+
!!rest?.readonly ||
|
|
61
|
+
!!rest?.options?.readonly ||
|
|
62
|
+
!!renderProps?.readonly)) ||
|
|
63
|
+
schema.writeOnly === true ||
|
|
64
|
+
sensitiveFormat.test(schema.format ?? '') ||
|
|
65
|
+
sensitiveName.test(key) ||
|
|
66
|
+
fileRenderer.test(String(renderType ?? '')) ||
|
|
67
|
+
sensitiveFormat.test(String(rest?.options?.type ?? renderProps?.type ?? '')) ||
|
|
68
|
+
(schema as any).contentEncoding === 'base64' ||
|
|
69
|
+
/^image\//i.test((schema as any).contentMediaType ?? '')
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function shapeOf(schema: SchemaObject) {
|
|
74
|
+
const shape: Record<string, unknown> = {};
|
|
75
|
+
for (const key of [
|
|
76
|
+
'type',
|
|
77
|
+
'format',
|
|
78
|
+
'nullable',
|
|
79
|
+
'enum',
|
|
80
|
+
'const',
|
|
81
|
+
'required',
|
|
82
|
+
'minLength',
|
|
83
|
+
'maxLength',
|
|
84
|
+
'minimum',
|
|
85
|
+
'maximum',
|
|
86
|
+
'multipleOf',
|
|
87
|
+
'exclusiveMinimum',
|
|
88
|
+
'exclusiveMaximum',
|
|
89
|
+
'pattern',
|
|
90
|
+
'minItems',
|
|
91
|
+
'maxItems',
|
|
92
|
+
'uniqueItems',
|
|
93
|
+
]) {
|
|
94
|
+
const value = (schema as any)[key];
|
|
95
|
+
if (value !== undefined) shape[key] = value;
|
|
96
|
+
}
|
|
97
|
+
return shape;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pathsOverlap(a: string[], b: string[]) {
|
|
101
|
+
return a.slice(0, Math.min(a.length, b.length)).every((part, index) => part === b[index]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function restrictNode(node: IDraftNode, blocked: string[][]): IDraftNode {
|
|
105
|
+
if (node.kind !== 'object') return node;
|
|
106
|
+
return {
|
|
107
|
+
...node,
|
|
108
|
+
fields: (node.fields ?? []).flatMap(field => {
|
|
109
|
+
const overlapping = blocked.filter(path => pathsOverlap(path, field.path));
|
|
110
|
+
if (overlapping.some(path => path.length <= field.path.length)) return [];
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
...field,
|
|
114
|
+
node: restrictNode(
|
|
115
|
+
field.node,
|
|
116
|
+
overlapping.map(path => path.slice(field.path.length)),
|
|
117
|
+
),
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resolve(
|
|
125
|
+
schema: SchemaObject | undefined,
|
|
126
|
+
options: IDraftSchemaOptions,
|
|
127
|
+
includeReadonly = true,
|
|
128
|
+
) {
|
|
129
|
+
const seen = new Set<string>();
|
|
130
|
+
while (schema) {
|
|
131
|
+
if (excluded(effectiveSchema(schema, options), '', includeReadonly)) return;
|
|
132
|
+
if (!schema.$ref) return schema;
|
|
133
|
+
if (seen.has(schema.$ref)) return;
|
|
134
|
+
seen.add(schema.$ref);
|
|
135
|
+
schema = options.resolveSchema(schema.$ref);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function primitiveValue(value: unknown) {
|
|
140
|
+
return (
|
|
141
|
+
value === null ||
|
|
142
|
+
typeof value === 'string' ||
|
|
143
|
+
typeof value === 'boolean' ||
|
|
144
|
+
(typeof value === 'number' && Number.isFinite(value))
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function primitiveSchema(schema: SchemaObject) {
|
|
149
|
+
if (schema.properties || schema.items) return false;
|
|
150
|
+
if (schema.type !== undefined) {
|
|
151
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
152
|
+
return (
|
|
153
|
+
types.length > 0 &&
|
|
154
|
+
types.every(type => ['string', 'boolean', 'number', 'integer', 'null'].includes(type))
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (Object.hasOwn(schema, 'const')) return primitiveValue(schema.const);
|
|
158
|
+
return !!schema.enum?.length && schema.enum.every(primitiveValue);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function nullSchema(schema: SchemaObject) {
|
|
162
|
+
if (schema.type !== undefined) {
|
|
163
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
164
|
+
return types.length > 0 && types.every(type => type === 'null');
|
|
165
|
+
}
|
|
166
|
+
if (Object.hasOwn(schema, 'const')) return schema.const === null;
|
|
167
|
+
return !!schema.enum?.length && schema.enum.every(value => value === null);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function dateTimeSchema(schema: SchemaObject) {
|
|
171
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
172
|
+
return (
|
|
173
|
+
schema.format === 'date-time' &&
|
|
174
|
+
types.includes('string') &&
|
|
175
|
+
types.every(type => type === 'string' || type === 'null')
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function compileNode(
|
|
180
|
+
schemaRaw: SchemaObject,
|
|
181
|
+
options: IDraftSchemaOptions,
|
|
182
|
+
ancestors: Set<SchemaObject>,
|
|
183
|
+
): IDraftNode | undefined {
|
|
184
|
+
if (!isRecord(schemaRaw) || ancestors.has(schemaRaw)) return;
|
|
185
|
+
const schema = effectiveSchema(schemaRaw, options);
|
|
186
|
+
if (excluded(schema) || schema.allOf || (schema.oneOf && schema.anyOf)) return;
|
|
187
|
+
const nextAncestors = new Set(ancestors).add(schemaRaw);
|
|
188
|
+
const shape = shapeOf(schema);
|
|
189
|
+
if (schema.$ref) {
|
|
190
|
+
if (schema.oneOf || schema.anyOf || schema.properties || schema.items) return;
|
|
191
|
+
const target = options.resolveSchema(schema.$ref);
|
|
192
|
+
const node = target && compileNode(target, options, nextAncestors);
|
|
193
|
+
if (!node) return;
|
|
194
|
+
if (schema.type !== undefined) {
|
|
195
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
196
|
+
const compatible =
|
|
197
|
+
node.kind === 'scalar'
|
|
198
|
+
? primitiveSchema(schema)
|
|
199
|
+
: types.length > 0 && types.every(type => type === node.kind || type === 'null');
|
|
200
|
+
if (!compatible) return;
|
|
201
|
+
}
|
|
202
|
+
const codec =
|
|
203
|
+
node.codec &&
|
|
204
|
+
(schema.type === undefined || dateTimeSchema({ ...schema, format: 'date-time' })) &&
|
|
205
|
+
(schema.format === undefined || schema.format === 'date-time')
|
|
206
|
+
? node.codec
|
|
207
|
+
: undefined;
|
|
208
|
+
return { ...node, codec, shape: { ...shape, $ref: schema.$ref, target: node.shape } };
|
|
209
|
+
}
|
|
210
|
+
const operator = schema.oneOf ? 'oneOf' : 'anyOf';
|
|
211
|
+
const union = schema[operator];
|
|
212
|
+
if (union) {
|
|
213
|
+
if (!union.length || schema.properties || schema.items) return;
|
|
214
|
+
const members = union.map(member =>
|
|
215
|
+
compileNode(member as SchemaObject, options, nextAncestors),
|
|
216
|
+
);
|
|
217
|
+
if (members.some(member => !member)) return;
|
|
218
|
+
const unionShape = { ...shape, [operator]: members.map(member => member!.shape) };
|
|
219
|
+
const nonNull = union.flatMap((raw, index) => {
|
|
220
|
+
const member = resolve(raw as SchemaObject, options);
|
|
221
|
+
return member && nullSchema(member) ? [] : [members[index]!];
|
|
222
|
+
});
|
|
223
|
+
if (members.every(member => member!.kind === 'scalar')) {
|
|
224
|
+
if (schema.type !== undefined && !primitiveSchema(schema)) return;
|
|
225
|
+
const codec =
|
|
226
|
+
nonNull.length > 0 &&
|
|
227
|
+
nonNull.every(member => member.codec === 'date-time') &&
|
|
228
|
+
(schema.type === undefined || dateTimeSchema({ ...schema, format: 'date-time' })) &&
|
|
229
|
+
(schema.format === undefined || schema.format === 'date-time')
|
|
230
|
+
? 'date-time'
|
|
231
|
+
: undefined;
|
|
232
|
+
return { kind: 'scalar', shape: unionShape, codec };
|
|
233
|
+
}
|
|
234
|
+
// Structural unions remain limited to one non-null member plus safe null branches.
|
|
235
|
+
if (nonNull.length !== 1 || nonNull.length === members.length) return;
|
|
236
|
+
const node = nonNull[0];
|
|
237
|
+
if (schema.type !== undefined) {
|
|
238
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
239
|
+
if (!types.length || types.some(type => type !== node.kind && type !== 'null')) return;
|
|
240
|
+
}
|
|
241
|
+
return { ...node, shape: unionShape };
|
|
242
|
+
}
|
|
243
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
244
|
+
if (schema.type === 'array' || schema.items) {
|
|
245
|
+
if (
|
|
246
|
+
schema.properties ||
|
|
247
|
+
types.some(type => type !== undefined && type !== 'array' && type !== 'null')
|
|
248
|
+
) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const itemSchema = resolve(schema.items as SchemaObject | undefined, options);
|
|
252
|
+
const item = schema.items && compileNode(schema.items as SchemaObject, options, nextAncestors);
|
|
253
|
+
if (!item) return;
|
|
254
|
+
const identity = itemSchema?.properties?.id as SchemaObject | undefined;
|
|
255
|
+
const relation =
|
|
256
|
+
item.kind === 'object' &&
|
|
257
|
+
!!identity &&
|
|
258
|
+
!!resolve(identity, options, false) &&
|
|
259
|
+
!excluded(effectiveSchema(resolve(identity, options, false)!, options), 'id', false);
|
|
260
|
+
return { kind: 'array', shape, item, relation };
|
|
261
|
+
}
|
|
262
|
+
if (schema.type === 'object' || schema.properties) {
|
|
263
|
+
if (types.some(type => type !== undefined && type !== 'object' && type !== 'null')) return;
|
|
264
|
+
const fields: NonNullable<IDraftNode['fields']> = [];
|
|
265
|
+
const blocked: string[][] = [];
|
|
266
|
+
// The runtime loader deduplicates aliases. Inspect their safety metadata before that
|
|
267
|
+
// deduplication so an excluded alias cannot make its canonical source persistable.
|
|
268
|
+
for (const [key, raw] of Object.entries(schema.properties ?? {})) {
|
|
269
|
+
const resolvedProperty = resolve(raw as SchemaObject, options);
|
|
270
|
+
const property = effectiveSchema(resolvedProperty ?? (raw as SchemaObject), options);
|
|
271
|
+
const path = safePath(property.rest?.fieldSource ?? key);
|
|
272
|
+
if (path && (!resolvedProperty || excluded(property, key))) blocked.push(path);
|
|
273
|
+
}
|
|
274
|
+
for (const property of options.loadProperties(schema, options.scene) ?? []) {
|
|
275
|
+
const key = property.key ?? '';
|
|
276
|
+
const path = safePath(key);
|
|
277
|
+
if (!path) continue;
|
|
278
|
+
if (excluded(property, [key, property.schemaKey, ...(property.schemaKeys ?? [])].join(' '))) {
|
|
279
|
+
blocked.push(path);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const node = compileNode(property, options, nextAncestors);
|
|
283
|
+
if (!node) {
|
|
284
|
+
blocked.push(path);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
fields.push({ path, node });
|
|
288
|
+
}
|
|
289
|
+
// Prefer the declared object over a duplicate flattened alias, but carry all alias
|
|
290
|
+
// safety exclusions down into that object so unrelated sibling fields still persist.
|
|
291
|
+
const uniqueFields = fields.filter(
|
|
292
|
+
(field, index) =>
|
|
293
|
+
!fields.some(
|
|
294
|
+
(other, otherIndex) =>
|
|
295
|
+
otherIndex !== index &&
|
|
296
|
+
pathsOverlap(other.path, field.path) &&
|
|
297
|
+
(other.path.length < field.path.length ||
|
|
298
|
+
(other.path.length === field.path.length && otherIndex < index)),
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
uniqueFields.sort((a, b) => JSON.stringify(a.path).localeCompare(JSON.stringify(b.path)));
|
|
302
|
+
return restrictNode({ kind: 'object', shape, fields: uniqueFields }, blocked);
|
|
303
|
+
}
|
|
304
|
+
// Scalar eligibility is structural, not submit validation: unfinished primitive values
|
|
305
|
+
// still use the ordinary projection policy without selecting a union branch.
|
|
306
|
+
if (primitiveSchema(schema)) {
|
|
307
|
+
return { kind: 'scalar', shape, codec: dateTimeSchema(schema) ? 'date-time' : undefined };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function describeNode(node: IDraftNode): unknown {
|
|
312
|
+
return [
|
|
313
|
+
node.kind,
|
|
314
|
+
node.shape,
|
|
315
|
+
node.relation ?? false,
|
|
316
|
+
node.fields?.map(field => [field.path, describeNode(field.node)]),
|
|
317
|
+
node.item && describeNode(node.item),
|
|
318
|
+
...(node.codec ? [node.codec] : []),
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function stableSerialize(value: unknown): string {
|
|
323
|
+
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]`;
|
|
324
|
+
if (isRecord(value)) {
|
|
325
|
+
return `{${Object.keys(value)
|
|
326
|
+
.sort()
|
|
327
|
+
.map(key => `${JSON.stringify(key)}:${stableSerialize(value[key])}`)
|
|
328
|
+
.join(',')}}`;
|
|
329
|
+
}
|
|
330
|
+
return JSON.stringify(value) ?? 'undefined';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function fingerprint(value: unknown) {
|
|
334
|
+
const text = stableSerialize(value);
|
|
335
|
+
let a = 2166136261;
|
|
336
|
+
let b = 2246822507;
|
|
337
|
+
for (let index = 0; index < text.length; index++) {
|
|
338
|
+
const code = text.charCodeAt(index);
|
|
339
|
+
a = Math.imul(a ^ code, 16777619);
|
|
340
|
+
b = Math.imul(b ^ code, 3266489909);
|
|
341
|
+
}
|
|
342
|
+
return `${(a >>> 0).toString(16).padStart(8, '0')}${(b >>> 0).toString(16).padStart(8, '0')}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function createDraftSchema(
|
|
346
|
+
schema: SchemaObject,
|
|
347
|
+
options: IDraftSchemaOptions,
|
|
348
|
+
): IDraftSchema {
|
|
349
|
+
const node = compileNode(schema, options, new Set());
|
|
350
|
+
return {
|
|
351
|
+
enabled: !!node,
|
|
352
|
+
signature: fingerprint(node && describeNode(node)),
|
|
353
|
+
node: node ?? { kind: 'object', shape: {}, fields: [] },
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function getPath(value: unknown, path: string[]) {
|
|
358
|
+
let current = value;
|
|
359
|
+
for (const part of path) {
|
|
360
|
+
if (!isRecord(current) || !Object.hasOwn(current, part)) return omitted;
|
|
361
|
+
current = current[part];
|
|
362
|
+
}
|
|
363
|
+
return current;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function setPath(target: Record<string, unknown>, path: string[], value: unknown) {
|
|
367
|
+
for (const part of path.slice(0, -1)) {
|
|
368
|
+
if (!isRecord(target[part])) target[part] = {};
|
|
369
|
+
target = target[part] as Record<string, unknown>;
|
|
370
|
+
}
|
|
371
|
+
target[path[path.length - 1]] = value;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function rowId(value: unknown): string | number | undefined {
|
|
375
|
+
if (!isRecord(value)) return;
|
|
376
|
+
const id = value.id;
|
|
377
|
+
return typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id)) ? id : undefined;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function dateValue(value: unknown) {
|
|
381
|
+
if (!(value instanceof Date)) return;
|
|
382
|
+
try {
|
|
383
|
+
const time = Date.prototype.getTime.call(value);
|
|
384
|
+
return Number.isFinite(time) ? time : undefined;
|
|
385
|
+
} catch {
|
|
386
|
+
// Objects with a forged Date prototype have no native date value.
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function projectNode(
|
|
391
|
+
node: IDraftNode,
|
|
392
|
+
value: unknown,
|
|
393
|
+
baseline: unknown,
|
|
394
|
+
path: (string | number)[],
|
|
395
|
+
projection: IDraftProjection,
|
|
396
|
+
ancestors: Set<object>,
|
|
397
|
+
): unknown {
|
|
398
|
+
if (value === omitted) return omitted;
|
|
399
|
+
if (value === undefined) {
|
|
400
|
+
projection.undefinedPaths.push(JSON.stringify(path));
|
|
401
|
+
return omitted;
|
|
402
|
+
}
|
|
403
|
+
if (value === null) return null;
|
|
404
|
+
if (node.codec === 'date-time' && value instanceof Date) {
|
|
405
|
+
const time = dateValue(value);
|
|
406
|
+
return time === undefined ? omitted : Date.prototype.toISOString.call(new Date(time));
|
|
407
|
+
}
|
|
408
|
+
if (typeof value === 'object') {
|
|
409
|
+
if (ancestors.has(value) || (!Array.isArray(value) && !isRecord(value))) return omitted;
|
|
410
|
+
ancestors = new Set(ancestors).add(value);
|
|
411
|
+
}
|
|
412
|
+
if (node.kind === 'scalar') {
|
|
413
|
+
return typeof value === 'string' ||
|
|
414
|
+
typeof value === 'boolean' ||
|
|
415
|
+
(typeof value === 'number' && Number.isFinite(value))
|
|
416
|
+
? value
|
|
417
|
+
: omitted;
|
|
418
|
+
}
|
|
419
|
+
if (node.kind === 'object') {
|
|
420
|
+
if (!isRecord(value)) return omitted;
|
|
421
|
+
const result: Record<string, unknown> = {};
|
|
422
|
+
for (const field of node.fields ?? []) {
|
|
423
|
+
const projected = projectNode(
|
|
424
|
+
field.node,
|
|
425
|
+
getPath(value, field.path),
|
|
426
|
+
getPath(baseline, field.path),
|
|
427
|
+
[...path, ...field.path],
|
|
428
|
+
projection,
|
|
429
|
+
ancestors,
|
|
430
|
+
);
|
|
431
|
+
if (projected !== omitted) setPath(result, field.path, projected);
|
|
432
|
+
}
|
|
433
|
+
return result;
|
|
434
|
+
}
|
|
435
|
+
if (!Array.isArray(value)) return omitted;
|
|
436
|
+
const rows = Array.isArray(baseline) ? baseline : [];
|
|
437
|
+
const result: unknown[] = [];
|
|
438
|
+
const identities: NonNullable<IDraftProjection['relations'][string]> = [];
|
|
439
|
+
for (const row of value) {
|
|
440
|
+
const index = result.length;
|
|
441
|
+
const id = node.relation ? rowId(row) : undefined;
|
|
442
|
+
const baselineRow = id === undefined ? rows[index] : rows.find(item => rowId(item) === id);
|
|
443
|
+
const projected = projectNode(
|
|
444
|
+
node.item!,
|
|
445
|
+
row,
|
|
446
|
+
baselineRow,
|
|
447
|
+
[...path, index],
|
|
448
|
+
projection,
|
|
449
|
+
ancestors,
|
|
450
|
+
);
|
|
451
|
+
if (projected === omitted && row !== undefined) continue;
|
|
452
|
+
result.push(projected === omitted ? null : projected);
|
|
453
|
+
if (node.relation) {
|
|
454
|
+
identities.push({
|
|
455
|
+
...(id !== undefined ? { id } : {}),
|
|
456
|
+
...(id !== undefined && isRecord(row) && row.deleted === true ? { deleted: true } : {}),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (node.relation) projection.relations[JSON.stringify(path)] = identities;
|
|
461
|
+
return result;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function projectDraftValues(
|
|
465
|
+
schema: IDraftSchema,
|
|
466
|
+
values: unknown,
|
|
467
|
+
baseline?: unknown,
|
|
468
|
+
): IDraftProjection {
|
|
469
|
+
const projection: IDraftProjection = { values: {}, relations: {}, undefinedPaths: [] };
|
|
470
|
+
if (!schema.enabled) return projection;
|
|
471
|
+
const result = projectNode(schema.node, values, baseline, [], projection, new Set());
|
|
472
|
+
if (isRecord(result)) projection.values = result;
|
|
473
|
+
return projection;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function cloneBaseline(value: unknown, ancestors = new Set<object>()): unknown {
|
|
477
|
+
if (
|
|
478
|
+
value === null ||
|
|
479
|
+
value === undefined ||
|
|
480
|
+
typeof value === 'string' ||
|
|
481
|
+
typeof value === 'boolean'
|
|
482
|
+
) {
|
|
483
|
+
return value;
|
|
484
|
+
}
|
|
485
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
|
|
486
|
+
if (typeof value !== 'object' || ancestors.has(value)) return;
|
|
487
|
+
if (value instanceof Date) {
|
|
488
|
+
const time = dateValue(value);
|
|
489
|
+
return time === undefined ? undefined : new Date(time);
|
|
490
|
+
}
|
|
491
|
+
if (!Array.isArray(value) && !isRecord(value)) return;
|
|
492
|
+
const next = new Set(ancestors).add(value);
|
|
493
|
+
if (Array.isArray(value)) return value.map(item => cloneBaseline(item, next));
|
|
494
|
+
const result: Record<string, unknown> = {};
|
|
495
|
+
for (const key of Object.keys(value)) {
|
|
496
|
+
if (!unsafeKeys.has(key)) result[key] = cloneBaseline(value[key], next);
|
|
497
|
+
}
|
|
498
|
+
return result;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function restoreNode(
|
|
502
|
+
node: IDraftNode,
|
|
503
|
+
value: unknown,
|
|
504
|
+
baseline: unknown,
|
|
505
|
+
path: (string | number)[],
|
|
506
|
+
projection: IDraftProjection,
|
|
507
|
+
undefinedPaths: Set<string>,
|
|
508
|
+
): unknown {
|
|
509
|
+
if (undefinedPaths.has(JSON.stringify(path))) return undefined;
|
|
510
|
+
if (value === omitted) return cloneBaseline(baseline === omitted ? undefined : baseline);
|
|
511
|
+
if (value === null) return null;
|
|
512
|
+
if (node.kind === 'scalar') {
|
|
513
|
+
if (
|
|
514
|
+
typeof value === 'string' ||
|
|
515
|
+
typeof value === 'boolean' ||
|
|
516
|
+
(typeof value === 'number' && Number.isFinite(value))
|
|
517
|
+
) {
|
|
518
|
+
return value;
|
|
519
|
+
}
|
|
520
|
+
return cloneBaseline(baseline === omitted ? undefined : baseline);
|
|
521
|
+
}
|
|
522
|
+
if (node.kind === 'object') {
|
|
523
|
+
const cloned = cloneBaseline(baseline);
|
|
524
|
+
const result = isRecord(cloned) ? cloned : {};
|
|
525
|
+
for (const field of node.fields ?? []) {
|
|
526
|
+
setPath(
|
|
527
|
+
result,
|
|
528
|
+
field.path,
|
|
529
|
+
restoreNode(
|
|
530
|
+
field.node,
|
|
531
|
+
getPath(value, field.path),
|
|
532
|
+
getPath(baseline, field.path),
|
|
533
|
+
[...path, ...field.path],
|
|
534
|
+
projection,
|
|
535
|
+
undefinedPaths,
|
|
536
|
+
),
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return result;
|
|
540
|
+
}
|
|
541
|
+
if (!Array.isArray(value)) return cloneBaseline(baseline);
|
|
542
|
+
const rows = Array.isArray(baseline) ? baseline : [];
|
|
543
|
+
const identities = projection.relations[JSON.stringify(path)];
|
|
544
|
+
if (node.relation && (!Array.isArray(identities) || identities.length !== value.length))
|
|
545
|
+
throw new Error('Invalid draft relation');
|
|
546
|
+
const used = new Set<string>();
|
|
547
|
+
return value.map((row, index) => {
|
|
548
|
+
const identity = node.relation ? identities[index] : undefined;
|
|
549
|
+
const id = identity?.id;
|
|
550
|
+
let baselineRow = rows[index];
|
|
551
|
+
if (node.relation) {
|
|
552
|
+
baselineRow = undefined;
|
|
553
|
+
if (id !== undefined) {
|
|
554
|
+
if (typeof id !== 'string' && (typeof id !== 'number' || !Number.isFinite(id)))
|
|
555
|
+
throw new Error('Invalid draft identity');
|
|
556
|
+
const key = `${typeof id}:${id}`;
|
|
557
|
+
const matches = rows.filter(item => rowId(item) === id);
|
|
558
|
+
if (used.has(key) || matches.length !== 1) throw new Error('Changed draft relation');
|
|
559
|
+
used.add(key);
|
|
560
|
+
baselineRow = matches[0];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const restored = restoreNode(
|
|
564
|
+
node.item!,
|
|
565
|
+
row,
|
|
566
|
+
baselineRow,
|
|
567
|
+
[...path, index],
|
|
568
|
+
projection,
|
|
569
|
+
undefinedPaths,
|
|
570
|
+
);
|
|
571
|
+
if (node.relation && isRecord(restored)) {
|
|
572
|
+
if (id === undefined) {
|
|
573
|
+
delete restored.id;
|
|
574
|
+
delete restored.deleted;
|
|
575
|
+
} else {
|
|
576
|
+
restored.id = id;
|
|
577
|
+
if (identity?.deleted === true) restored.deleted = true;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return restored;
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function restoreDraftValues<T extends {}>(
|
|
585
|
+
schema: IDraftSchema,
|
|
586
|
+
projection: IDraftProjection,
|
|
587
|
+
baseline: T,
|
|
588
|
+
): T | undefined {
|
|
589
|
+
if (
|
|
590
|
+
!schema.enabled ||
|
|
591
|
+
!isRecord(projection) ||
|
|
592
|
+
!isRecord(projection.values) ||
|
|
593
|
+
!isRecord(projection.relations) ||
|
|
594
|
+
!Array.isArray(projection.undefinedPaths) ||
|
|
595
|
+
!projection.undefinedPaths.every(path => typeof path === 'string')
|
|
596
|
+
) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
// Re-project untrusted stored values under current schema; only validated identity
|
|
601
|
+
// metadata participates in reconstruction, and undeclared keys never enter the form.
|
|
602
|
+
const sanitized = projectDraftValues(schema, projection.values, baseline);
|
|
603
|
+
sanitized.relations = projection.relations;
|
|
604
|
+
return restoreNode(
|
|
605
|
+
schema.node,
|
|
606
|
+
sanitized.values,
|
|
607
|
+
baseline,
|
|
608
|
+
[],
|
|
609
|
+
sanitized,
|
|
610
|
+
new Set(projection.undefinedPaths),
|
|
611
|
+
) as T;
|
|
612
|
+
} catch {
|
|
613
|
+
// Invalid identity metadata or unsupported stored data is not recoverable.
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function normalizeBaseline(node: IDraftNode, baseline: unknown): unknown {
|
|
618
|
+
if (
|
|
619
|
+
node.codec === 'date-time' &&
|
|
620
|
+
typeof baseline === 'string' &&
|
|
621
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(baseline)
|
|
622
|
+
) {
|
|
623
|
+
const date = new Date(baseline);
|
|
624
|
+
if (dateValue(date) !== undefined) return Date.prototype.toISOString.call(date);
|
|
625
|
+
}
|
|
626
|
+
if (node.kind === 'array' && Array.isArray(baseline)) {
|
|
627
|
+
const rows = baseline.map(row => normalizeBaseline(node.item!, row));
|
|
628
|
+
if (node.relation && rows.every(row => rowId(row) !== undefined)) {
|
|
629
|
+
rows.sort((a, b) =>
|
|
630
|
+
`${typeof rowId(a)}:${rowId(a)}`.localeCompare(`${typeof rowId(b)}:${rowId(b)}`),
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
return rows;
|
|
634
|
+
}
|
|
635
|
+
if (node.kind === 'object' && isRecord(baseline)) {
|
|
636
|
+
const result = cloneBaseline(baseline) as Record<string, unknown>;
|
|
637
|
+
for (const field of node.fields ?? []) {
|
|
638
|
+
const value = getPath(baseline, field.path);
|
|
639
|
+
if (value !== omitted) setPath(result, field.path, normalizeBaseline(field.node, value));
|
|
640
|
+
}
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
return baseline;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export function draftBaselineSignature(schema: IDraftSchema, baseline: unknown): string {
|
|
647
|
+
const normalized = normalizeBaseline(schema.node, baseline);
|
|
648
|
+
return fingerprint(projectDraftValues(schema, normalized, normalized));
|
|
649
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { IDecoratorModelOptions } from 'zova-module-a-model';
|
|
2
|
+
|
|
3
|
+
import { BeanModelBase, Model } from 'zova-module-a-model';
|
|
4
|
+
|
|
5
|
+
import type { IDraftEnvelope } from '../types/draft.js';
|
|
6
|
+
|
|
7
|
+
export interface IModelOptionsDraft extends IDecoratorModelOptions {}
|
|
8
|
+
|
|
9
|
+
@Model<IModelOptionsDraft>({ enableSelector: true })
|
|
10
|
+
export class ModelDraft extends BeanModelBase {
|
|
11
|
+
snapshot: IDraftEnvelope | null;
|
|
12
|
+
failed = false;
|
|
13
|
+
|
|
14
|
+
protected async __init__(key?: string) {
|
|
15
|
+
await super.__init__(key);
|
|
16
|
+
if (!process.env.CLIENT || !key) return;
|
|
17
|
+
this.snapshot = this.$useStateLocalAsync<IDraftEnvelope | null>({
|
|
18
|
+
queryKey: ['snapshot'],
|
|
19
|
+
meta: { defaultData: null, maxAge: this.scope.config.draft.maxAge },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async load() {
|
|
24
|
+
try {
|
|
25
|
+
await this.$ensureStateLocalAsync(this.snapshot);
|
|
26
|
+
this.failed = this.$persisterError(['snapshot']);
|
|
27
|
+
} catch {
|
|
28
|
+
this.failed = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async save(envelope: IDraftEnvelope) {
|
|
33
|
+
this.$setQueryData(['snapshot'], envelope, false);
|
|
34
|
+
try {
|
|
35
|
+
await this.$persisterSave(['snapshot']);
|
|
36
|
+
this.failed = false;
|
|
37
|
+
} catch {
|
|
38
|
+
this.failed = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async remove() {
|
|
43
|
+
// Materialize the query without loading a possibly stale persisted snapshot.
|
|
44
|
+
this.$useStateData<IDraftEnvelope | null>({
|
|
45
|
+
queryKey: ['snapshot'],
|
|
46
|
+
enabled: false,
|
|
47
|
+
staleTime: Infinity,
|
|
48
|
+
meta: { persister: { storage: 'db', sync: false }, ssr: { dehydrate: false } },
|
|
49
|
+
});
|
|
50
|
+
this.$setQueryData(['snapshot'], null, false);
|
|
51
|
+
try {
|
|
52
|
+
await this.$persisterRemove(['snapshot']);
|
|
53
|
+
this.failed = false;
|
|
54
|
+
} catch {
|
|
55
|
+
this.failed = true;
|
|
56
|
+
throw new Error('Draft storage removal failed');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|