@hyperscale0/udl 1.0.0-alpha.1
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/AUTHORS +8 -0
- package/CHANGELOG.md +36 -0
- package/LICENSE +661 -0
- package/LICENSING.md +51 -0
- package/README.md +163 -0
- package/SECURITY.md +42 -0
- package/TRADEMARKS.md +35 -0
- package/conformance/README.md +79 -0
- package/conformance/invalid/blank-title.expected.json +10 -0
- package/conformance/invalid/blank-title.udl +50 -0
- package/conformance/invalid/depth-budget.expected.json +10 -0
- package/conformance/invalid/depth-budget.udl +49 -0
- package/conformance/invalid/format-version.expected.json +10 -0
- package/conformance/invalid/format-version.udl +50 -0
- package/conformance/invalid/invalid-utf8.expected.json +10 -0
- package/conformance/invalid/invalid-utf8.udl +1 -0
- package/conformance/invalid/malformed-json.expected.json +10 -0
- package/conformance/invalid/malformed-json.udl +1 -0
- package/conformance/invalid/missing-create-verb.expected.json +10 -0
- package/conformance/invalid/missing-create-verb.udl +45 -0
- package/conformance/invalid/not-an-object.expected.json +10 -0
- package/conformance/invalid/not-an-object.udl +1 -0
- package/conformance/invalid/noun-id-not-snake-case.expected.json +10 -0
- package/conformance/invalid/noun-id-not-snake-case.udl +50 -0
- package/conformance/invalid/unknown-key.expected.json +10 -0
- package/conformance/invalid/unknown-key.udl +51 -0
- package/conformance/invalid/unreachable-state.expected.json +10 -0
- package/conformance/invalid/unreachable-state.udl +51 -0
- package/conformance/invalid/verb-without-transition.expected.json +10 -0
- package/conformance/invalid/verb-without-transition.udl +55 -0
- package/conformance/valid/cards.expected.json +5 -0
- package/conformance/valid/cards.udl +1092 -0
- package/conformance/valid/commerce-escrow.expected.json +5 -0
- package/conformance/valid/commerce-escrow.udl +1011 -0
- package/conformance/valid/hand-edited.expected.json +5 -0
- package/conformance/valid/hand-edited.udl +1 -0
- package/conformance/valid/insured-car-marketplace.expected.json +5 -0
- package/conformance/valid/insured-car-marketplace.udl +831 -0
- package/conformance/valid/insured-travel.expected.json +5 -0
- package/conformance/valid/insured-travel.udl +2621 -0
- package/conformance/valid/minimal.expected.json +5 -0
- package/conformance/valid/minimal.udl +50 -0
- package/conformance/valid/protection.expected.json +5 -0
- package/conformance/valid/protection.udl +1136 -0
- package/dist/canonical.d.ts +2 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +35 -0
- package/dist/canonical.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +138 -0
- package/dist/cli.js.map +1 -0
- package/dist/duration.d.ts +3 -0
- package/dist/duration.d.ts.map +1 -0
- package/dist/duration.js +23 -0
- package/dist/duration.js.map +1 -0
- package/dist/evolution.d.ts +63 -0
- package/dist/evolution.d.ts.map +1 -0
- package/dist/evolution.js +345 -0
- package/dist/evolution.js.map +1 -0
- package/dist/finance.d.ts +53 -0
- package/dist/finance.d.ts.map +1 -0
- package/dist/finance.js +511 -0
- package/dist/finance.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/limits.d.ts +33 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +33 -0
- package/dist/limits.js.map +1 -0
- package/dist/parser.d.ts +4 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +54 -0
- package/dist/parser.js.map +1 -0
- package/dist/schema.d.ts +757 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +281 -0
- package/dist/schema.js.map +1 -0
- package/dist/validation.d.ts +64 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +1323 -0
- package/dist/validation.js.map +1 -0
- package/package.json +84 -0
- package/spec/README.md +137 -0
- package/spec/udl.schema.json +1019 -0
- package/src/canonical.ts +41 -0
- package/src/cli.ts +148 -0
- package/src/duration.ts +26 -0
- package/src/evolution.ts +587 -0
- package/src/finance.ts +775 -0
- package/src/index.ts +59 -0
- package/src/limits.ts +32 -0
- package/src/parser.ts +61 -0
- package/src/schema.ts +339 -0
- package/src/validation.ts +2053 -0
|
@@ -0,0 +1,2053 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Validator,
|
|
3
|
+
type OutputUnit,
|
|
4
|
+
type Schema,
|
|
5
|
+
type ValidationResult,
|
|
6
|
+
} from "@cfworker/json-schema";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
udlDocumentSchema,
|
|
11
|
+
type UdlAggregate,
|
|
12
|
+
type UdlDocument,
|
|
13
|
+
type UdlGate,
|
|
14
|
+
type UdlMove,
|
|
15
|
+
type UdlNoun,
|
|
16
|
+
type UdlStep,
|
|
17
|
+
type UdlVerb,
|
|
18
|
+
} from "./schema.js";
|
|
19
|
+
import { fixedIsoDurationMs } from "./duration.js";
|
|
20
|
+
import { analyzeNounFinance, financeAdmissionProblem } from "./finance.js";
|
|
21
|
+
import { UDL_LIMITS } from "./limits.js";
|
|
22
|
+
|
|
23
|
+
export type UdlIssueCode =
|
|
24
|
+
| "invalid_json"
|
|
25
|
+
| "invalid_semantics"
|
|
26
|
+
| "invalid_shape"
|
|
27
|
+
| "invalid_utf8"
|
|
28
|
+
| "resource_limit";
|
|
29
|
+
|
|
30
|
+
export interface UdlIssue {
|
|
31
|
+
readonly code: UdlIssueCode;
|
|
32
|
+
readonly message: string;
|
|
33
|
+
readonly path: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type UdlValidationResult =
|
|
37
|
+
| { readonly ok: true; readonly value: UdlDocument }
|
|
38
|
+
| { readonly issues: readonly UdlIssue[]; readonly ok: false };
|
|
39
|
+
|
|
40
|
+
export class UdlError extends Error {
|
|
41
|
+
readonly issues: readonly UdlIssue[];
|
|
42
|
+
|
|
43
|
+
constructor(issues: readonly UdlIssue[]) {
|
|
44
|
+
const first = issues[0];
|
|
45
|
+
super(
|
|
46
|
+
first
|
|
47
|
+
? `Invalid UDL at ${first.path}: ${first.message}`
|
|
48
|
+
: "Invalid UDL document",
|
|
49
|
+
);
|
|
50
|
+
this.name = "UdlError";
|
|
51
|
+
this.issues = Object.freeze([...issues]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateUdl(value: unknown): UdlValidationResult {
|
|
56
|
+
const resourceIssue = structuralBudgetIssue(value);
|
|
57
|
+
if (resourceIssue) return { issues: [resourceIssue], ok: false };
|
|
58
|
+
|
|
59
|
+
const parsed = udlDocumentSchema.safeParse(value);
|
|
60
|
+
if (!parsed.success) {
|
|
61
|
+
return {
|
|
62
|
+
issues: parsed.error.issues.map((issue) => ({
|
|
63
|
+
code: "invalid_shape" as const,
|
|
64
|
+
message: issue.message,
|
|
65
|
+
path: jsonPath(issue.path),
|
|
66
|
+
})),
|
|
67
|
+
ok: false,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const references = openReferenceShapeBudget();
|
|
72
|
+
const issues = semanticIssues(parsed.data, references);
|
|
73
|
+
if (references.exhausted) {
|
|
74
|
+
return {
|
|
75
|
+
issues: [
|
|
76
|
+
{
|
|
77
|
+
code: "resource_limit",
|
|
78
|
+
message: `document exceeds ${UDL_LIMITS.maxSchemaProbes} reference-shape checks; declare fewer nouns or fewer reference gates`,
|
|
79
|
+
path: "$.nouns",
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
ok: false,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return issues.length === 0
|
|
86
|
+
? { ok: true, value: parsed.data }
|
|
87
|
+
: { issues, ok: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function assertValidUdl(value: unknown): UdlDocument {
|
|
91
|
+
const result = validateUdl(value);
|
|
92
|
+
if (!result.ok) throw new UdlError(result.issues);
|
|
93
|
+
return result.value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Applies the sealed UDL JSON Schema subset to one value. Both inputs pass the
|
|
98
|
+
* same deterministic admission budgets as a complete UDL document before the
|
|
99
|
+
* standard validator or sealed format validators run.
|
|
100
|
+
*/
|
|
101
|
+
export function validateUdlSchemaValue(
|
|
102
|
+
schema: Readonly<Record<string, unknown>>,
|
|
103
|
+
value: unknown,
|
|
104
|
+
): ValidationResult {
|
|
105
|
+
const schemaAdmission = structuralBudgetIssue(schema);
|
|
106
|
+
if (schemaAdmission)
|
|
107
|
+
return admissionValidationResult(schemaAdmission, "invalid_schema");
|
|
108
|
+
const valueAdmission = structuralBudgetIssue(value);
|
|
109
|
+
if (valueAdmission)
|
|
110
|
+
return admissionValidationResult(valueAdmission, "invalid_value");
|
|
111
|
+
|
|
112
|
+
const schemaErrors: OutputUnit[] = [];
|
|
113
|
+
validateJsonSchema(schema, [], (path, message) => {
|
|
114
|
+
schemaErrors.push({
|
|
115
|
+
error: message,
|
|
116
|
+
instanceLocation: "",
|
|
117
|
+
keyword: "invalid_schema",
|
|
118
|
+
keywordLocation: jsonPath(path),
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
if (schemaErrors.length > 0) {
|
|
122
|
+
return { errors: schemaErrors, valid: false };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const boundedSchema = structuredClone(schema) as Schema;
|
|
126
|
+
const validator = new Validator(boundedSchema, "2020-12", false);
|
|
127
|
+
const standard = validator.validate(value);
|
|
128
|
+
const sealedErrors = sealedFormatErrors(value, boundedSchema);
|
|
129
|
+
const errors = [...standard.errors, ...sealedErrors];
|
|
130
|
+
return { errors, valid: errors.length === 0 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function admissionValidationResult(
|
|
134
|
+
issue: UdlIssue,
|
|
135
|
+
invalidKeyword: string,
|
|
136
|
+
): ValidationResult {
|
|
137
|
+
return {
|
|
138
|
+
errors: [
|
|
139
|
+
{
|
|
140
|
+
error: issue.message,
|
|
141
|
+
instanceLocation: issue.path,
|
|
142
|
+
keyword:
|
|
143
|
+
issue.code === "resource_limit" ? "resource_limit" : invalidKeyword,
|
|
144
|
+
keywordLocation: "$",
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
valid: false,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type AddIssue = (path: readonly PropertyKey[], message: string) => void;
|
|
152
|
+
|
|
153
|
+
const positiveMoneyPattern = "^[1-9][0-9]{0,17}$";
|
|
154
|
+
const nonNegativeMoneyPattern = "^(0|[1-9][0-9]{0,17})$";
|
|
155
|
+
const sealedDateTimeFormat = "hyperscale-date-time";
|
|
156
|
+
const jsonSchemaFormats = new Set([
|
|
157
|
+
"hyperscale-date",
|
|
158
|
+
sealedDateTimeFormat,
|
|
159
|
+
"hyperscale-email",
|
|
160
|
+
"hyperscale-uri",
|
|
161
|
+
]);
|
|
162
|
+
const jsonSchemaTypes = new Set([
|
|
163
|
+
"array",
|
|
164
|
+
"boolean",
|
|
165
|
+
"integer",
|
|
166
|
+
"object",
|
|
167
|
+
"string",
|
|
168
|
+
]);
|
|
169
|
+
const jsonSchemaKeywords = new Set([
|
|
170
|
+
"additionalProperties",
|
|
171
|
+
"const",
|
|
172
|
+
"description",
|
|
173
|
+
"enum",
|
|
174
|
+
"format",
|
|
175
|
+
"items",
|
|
176
|
+
"maxItems",
|
|
177
|
+
"maxLength",
|
|
178
|
+
"maximum",
|
|
179
|
+
"minItems",
|
|
180
|
+
"minLength",
|
|
181
|
+
"minimum",
|
|
182
|
+
"pattern",
|
|
183
|
+
"properties",
|
|
184
|
+
"required",
|
|
185
|
+
"title",
|
|
186
|
+
"type",
|
|
187
|
+
"x-hyperscale-fee-collection-port",
|
|
188
|
+
"x-hyperscale-reference-filter",
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
function semanticIssues(
|
|
192
|
+
document: UdlDocument,
|
|
193
|
+
references: ReferenceShapeBudget,
|
|
194
|
+
): UdlIssue[] {
|
|
195
|
+
const issues: UdlIssue[] = [];
|
|
196
|
+
const add: AddIssue = (path, message) => {
|
|
197
|
+
issues.push({ code: "invalid_semantics", message, path: jsonPath(path) });
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
document.nouns.forEach((noun, nounIndex) => {
|
|
201
|
+
const problem = financeAdmissionProblem(noun);
|
|
202
|
+
if (problem) add(["nouns", nounIndex, "lifecycle"], problem);
|
|
203
|
+
});
|
|
204
|
+
if (issues.length > 0) return issues;
|
|
205
|
+
|
|
206
|
+
validateDocumentSchemas(document, add);
|
|
207
|
+
if (issues.length > 0) return issues;
|
|
208
|
+
|
|
209
|
+
addDuplicateIssues(
|
|
210
|
+
document.subjects.map((subject) => subject.kind),
|
|
211
|
+
["subjects"],
|
|
212
|
+
"subject kind",
|
|
213
|
+
add,
|
|
214
|
+
);
|
|
215
|
+
addDuplicateIssues(
|
|
216
|
+
document.nouns.map((noun) => noun.id),
|
|
217
|
+
["nouns"],
|
|
218
|
+
"noun id",
|
|
219
|
+
add,
|
|
220
|
+
);
|
|
221
|
+
addDuplicateIssues(
|
|
222
|
+
document.nouns.map((noun) => noun.idPrefix),
|
|
223
|
+
["nouns"],
|
|
224
|
+
"noun idPrefix",
|
|
225
|
+
add,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const subjects = new Map(
|
|
229
|
+
document.subjects.map((subject) => [subject.kind, subject] as const),
|
|
230
|
+
);
|
|
231
|
+
const nouns = new Map(document.nouns.map((noun) => [noun.id, noun] as const));
|
|
232
|
+
|
|
233
|
+
for (const [subjectIndex, subject] of document.subjects.entries()) {
|
|
234
|
+
if (subject.schema.type !== "object") {
|
|
235
|
+
add(
|
|
236
|
+
["subjects", subjectIndex, "schema", "type"],
|
|
237
|
+
"a subject attribute schema must declare type object",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const [nounIndex, noun] of document.nouns.entries()) {
|
|
243
|
+
validateNoun(noun, nounIndex, nouns, subjects, references, add);
|
|
244
|
+
}
|
|
245
|
+
return issues;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function structuralBudgetIssue(value: unknown): UdlIssue | undefined {
|
|
249
|
+
let discovered = 1;
|
|
250
|
+
let nodes = 0;
|
|
251
|
+
let totalStringLength = 0;
|
|
252
|
+
const pending: {
|
|
253
|
+
readonly ancestors: ReadonlySet<object>;
|
|
254
|
+
readonly depth: number;
|
|
255
|
+
readonly path: readonly PropertyKey[];
|
|
256
|
+
readonly value: unknown;
|
|
257
|
+
}[] = [{ ancestors: new Set(), depth: 1, path: [], value }];
|
|
258
|
+
|
|
259
|
+
while (pending.length > 0) {
|
|
260
|
+
const entry = pending.pop() as (typeof pending)[number];
|
|
261
|
+
nodes += 1;
|
|
262
|
+
if (nodes > UDL_LIMITS.maxNodes) {
|
|
263
|
+
return resourceIssue(
|
|
264
|
+
entry.path,
|
|
265
|
+
`UDL contains more than ${UDL_LIMITS.maxNodes} values`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (entry.depth > UDL_LIMITS.maxDepth) {
|
|
269
|
+
return resourceIssue(
|
|
270
|
+
entry.path,
|
|
271
|
+
`UDL nesting exceeds ${UDL_LIMITS.maxDepth} levels`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (typeof entry.value === "string") {
|
|
275
|
+
if (entry.value.length > UDL_LIMITS.maxStringLength) {
|
|
276
|
+
return resourceIssue(
|
|
277
|
+
entry.path,
|
|
278
|
+
`UDL string exceeds ${UDL_LIMITS.maxStringLength} characters`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
totalStringLength += entry.value.length;
|
|
282
|
+
if (totalStringLength > UDL_LIMITS.maxTotalStringLength) {
|
|
283
|
+
return resourceIssue(
|
|
284
|
+
entry.path,
|
|
285
|
+
`UDL strings exceed ${UDL_LIMITS.maxTotalStringLength} total characters`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (entry.value === null || typeof entry.value === "boolean") continue;
|
|
291
|
+
if (typeof entry.value === "number") {
|
|
292
|
+
if (Number.isFinite(entry.value)) continue;
|
|
293
|
+
return jsonValueIssue(entry.path);
|
|
294
|
+
}
|
|
295
|
+
if (typeof entry.value !== "object") {
|
|
296
|
+
return jsonValueIssue(entry.path);
|
|
297
|
+
}
|
|
298
|
+
if (entry.ancestors.has(entry.value)) {
|
|
299
|
+
return resourceIssue(entry.path, "UDL must not contain object cycles");
|
|
300
|
+
}
|
|
301
|
+
const childAncestors = new Set(entry.ancestors);
|
|
302
|
+
childAncestors.add(entry.value);
|
|
303
|
+
if (Array.isArray(entry.value)) {
|
|
304
|
+
if (entry.value.length > UDL_LIMITS.maxNodes - discovered) {
|
|
305
|
+
return resourceIssue(
|
|
306
|
+
entry.path,
|
|
307
|
+
`UDL contains more than ${UDL_LIMITS.maxNodes} values`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
discovered += entry.value.length;
|
|
311
|
+
for (let index = entry.value.length - 1; index >= 0; index -= 1) {
|
|
312
|
+
pending.push({
|
|
313
|
+
ancestors: childAncestors,
|
|
314
|
+
depth: entry.depth + 1,
|
|
315
|
+
path: [...entry.path, index],
|
|
316
|
+
value: entry.value[index],
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const prototype = Object.getPrototypeOf(entry.value);
|
|
322
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
323
|
+
return jsonValueIssue(entry.path);
|
|
324
|
+
}
|
|
325
|
+
let childCount = 0;
|
|
326
|
+
for (const key in entry.value) {
|
|
327
|
+
if (!Object.hasOwn(entry.value, key)) continue;
|
|
328
|
+
childCount += 1;
|
|
329
|
+
if (childCount > UDL_LIMITS.maxNodes - discovered) {
|
|
330
|
+
return resourceIssue(
|
|
331
|
+
entry.path,
|
|
332
|
+
`UDL contains more than ${UDL_LIMITS.maxNodes} values`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
if (key.length > UDL_LIMITS.maxKeyLength) {
|
|
336
|
+
return resourceIssue(
|
|
337
|
+
[...entry.path, key],
|
|
338
|
+
`UDL key exceeds ${UDL_LIMITS.maxKeyLength} characters`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
totalStringLength += key.length;
|
|
342
|
+
if (totalStringLength > UDL_LIMITS.maxTotalStringLength) {
|
|
343
|
+
return resourceIssue(
|
|
344
|
+
[...entry.path, key],
|
|
345
|
+
`UDL strings exceed ${UDL_LIMITS.maxTotalStringLength} total characters`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
discovered += childCount;
|
|
350
|
+
for (const key in entry.value) {
|
|
351
|
+
if (!Object.hasOwn(entry.value, key)) continue;
|
|
352
|
+
pending.push({
|
|
353
|
+
ancestors: childAncestors,
|
|
354
|
+
depth: entry.depth + 1,
|
|
355
|
+
path: [...entry.path, key],
|
|
356
|
+
value: (entry.value as Record<string, unknown>)[key],
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function resourceIssue(
|
|
364
|
+
path: readonly PropertyKey[],
|
|
365
|
+
message: string,
|
|
366
|
+
): UdlIssue {
|
|
367
|
+
return { code: "resource_limit", message, path: jsonPath(path) };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function jsonValueIssue(path: readonly PropertyKey[]): UdlIssue {
|
|
371
|
+
return {
|
|
372
|
+
code: "invalid_shape",
|
|
373
|
+
message: "UDL must contain only JSON values",
|
|
374
|
+
path: jsonPath(path),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
379
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function validateDocumentSchemas(document: UdlDocument, add: AddIssue): void {
|
|
383
|
+
document.subjects.forEach((subject, subjectIndex) =>
|
|
384
|
+
validateJsonSchema(
|
|
385
|
+
subject.schema,
|
|
386
|
+
["subjects", subjectIndex, "schema"],
|
|
387
|
+
add,
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
document.nouns.forEach((noun, nounIndex) => {
|
|
391
|
+
for (const [field, schema] of Object.entries(noun.fields)) {
|
|
392
|
+
validateJsonSchema(schema, ["nouns", nounIndex, "fields", field], add);
|
|
393
|
+
}
|
|
394
|
+
for (const [verb, definition] of Object.entries(noun.verbs)) {
|
|
395
|
+
if (definition.input) {
|
|
396
|
+
validateJsonSchema(
|
|
397
|
+
definition.input,
|
|
398
|
+
["nouns", nounIndex, "verbs", verb, "input"],
|
|
399
|
+
add,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function validateJsonSchema(
|
|
407
|
+
schema: Record<string, unknown>,
|
|
408
|
+
path: readonly PropertyKey[],
|
|
409
|
+
add: AddIssue,
|
|
410
|
+
): void {
|
|
411
|
+
for (const keyword of Object.keys(schema)) {
|
|
412
|
+
if (!jsonSchemaKeywords.has(keyword)) {
|
|
413
|
+
add(
|
|
414
|
+
[...path, keyword],
|
|
415
|
+
`JSON Schema keyword ${keyword} is not in the UDL schema subset`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (typeof schema.type !== "string" || !jsonSchemaTypes.has(schema.type)) {
|
|
421
|
+
add(
|
|
422
|
+
[...path, "type"],
|
|
423
|
+
`JSON Schema type must be one of ${[...jsonSchemaTypes].join(", ")}`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
for (const keyword of ["description", "title"] as const) {
|
|
427
|
+
if (Object.hasOwn(schema, keyword) && typeof schema[keyword] !== "string") {
|
|
428
|
+
add([...path, keyword], `JSON Schema ${keyword} must be a string`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (
|
|
432
|
+
Object.hasOwn(schema, "enum") &&
|
|
433
|
+
(!Array.isArray(schema.enum) || schema.enum.length === 0)
|
|
434
|
+
) {
|
|
435
|
+
add([...path, "enum"], "JSON Schema enum must be a non-empty array");
|
|
436
|
+
}
|
|
437
|
+
if (
|
|
438
|
+
Object.hasOwn(schema, "properties") &&
|
|
439
|
+
(!isRecord(schema.properties) || schema.type !== "object")
|
|
440
|
+
) {
|
|
441
|
+
add(
|
|
442
|
+
[...path, "properties"],
|
|
443
|
+
"JSON Schema properties require an object schema and object value",
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
if (Object.hasOwn(schema, "required")) {
|
|
447
|
+
if (schema.type !== "object") {
|
|
448
|
+
add(
|
|
449
|
+
[...path, "required"],
|
|
450
|
+
"JSON Schema required is only valid on an object schema",
|
|
451
|
+
);
|
|
452
|
+
} else if (
|
|
453
|
+
!Array.isArray(schema.required) ||
|
|
454
|
+
!schema.required.every((field) => typeof field === "string")
|
|
455
|
+
) {
|
|
456
|
+
add(
|
|
457
|
+
[...path, "required"],
|
|
458
|
+
"JSON Schema required must be an array of property names",
|
|
459
|
+
);
|
|
460
|
+
} else {
|
|
461
|
+
const properties = recordValue(schema.properties);
|
|
462
|
+
for (const field of schema.required) {
|
|
463
|
+
if (!Object.hasOwn(properties, field)) {
|
|
464
|
+
add(
|
|
465
|
+
[...path, "required"],
|
|
466
|
+
`JSON Schema required references undeclared property ${field}`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (
|
|
473
|
+
Object.hasOwn(schema, "items") &&
|
|
474
|
+
(!isRecord(schema.items) || schema.type !== "array")
|
|
475
|
+
) {
|
|
476
|
+
add(
|
|
477
|
+
[...path, "items"],
|
|
478
|
+
"JSON Schema items require an array schema and object value",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
if (
|
|
482
|
+
Object.hasOwn(schema, "additionalProperties") &&
|
|
483
|
+
(schema.type !== "object" ||
|
|
484
|
+
(typeof schema.additionalProperties !== "boolean" &&
|
|
485
|
+
!isRecord(schema.additionalProperties)))
|
|
486
|
+
) {
|
|
487
|
+
add(
|
|
488
|
+
[...path, "additionalProperties"],
|
|
489
|
+
"JSON Schema additionalProperties requires an object schema and a boolean or schema value",
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
for (const keyword of [
|
|
493
|
+
"maxItems",
|
|
494
|
+
"maxLength",
|
|
495
|
+
"minItems",
|
|
496
|
+
"minLength",
|
|
497
|
+
] as const) {
|
|
498
|
+
const value = schema[keyword];
|
|
499
|
+
if (
|
|
500
|
+
Object.hasOwn(schema, keyword) &&
|
|
501
|
+
((keyword.endsWith("Items")
|
|
502
|
+
? schema.type !== "array"
|
|
503
|
+
: schema.type !== "string") ||
|
|
504
|
+
typeof value !== "number" ||
|
|
505
|
+
!Number.isInteger(value) ||
|
|
506
|
+
value < 0)
|
|
507
|
+
) {
|
|
508
|
+
add(
|
|
509
|
+
[...path, keyword],
|
|
510
|
+
`JSON Schema ${keyword} must be a non-negative integer on the matching schema type`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
for (const keyword of ["maximum", "minimum"] as const) {
|
|
515
|
+
const value = schema[keyword];
|
|
516
|
+
if (
|
|
517
|
+
Object.hasOwn(schema, keyword) &&
|
|
518
|
+
(schema.type !== "integer" ||
|
|
519
|
+
typeof value !== "number" ||
|
|
520
|
+
!Number.isFinite(value))
|
|
521
|
+
) {
|
|
522
|
+
add(
|
|
523
|
+
[...path, keyword],
|
|
524
|
+
`JSON Schema ${keyword} must be finite on an integer schema`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (
|
|
529
|
+
typeof schema.minLength === "number" &&
|
|
530
|
+
typeof schema.maxLength === "number" &&
|
|
531
|
+
schema.minLength > schema.maxLength
|
|
532
|
+
) {
|
|
533
|
+
add([...path, "maxLength"], "JSON Schema maxLength is below minLength");
|
|
534
|
+
}
|
|
535
|
+
if (
|
|
536
|
+
typeof schema.minItems === "number" &&
|
|
537
|
+
typeof schema.maxItems === "number" &&
|
|
538
|
+
schema.minItems > schema.maxItems
|
|
539
|
+
) {
|
|
540
|
+
add([...path, "maxItems"], "JSON Schema maxItems is below minItems");
|
|
541
|
+
}
|
|
542
|
+
if (
|
|
543
|
+
typeof schema.minimum === "number" &&
|
|
544
|
+
typeof schema.maximum === "number" &&
|
|
545
|
+
schema.minimum > schema.maximum
|
|
546
|
+
) {
|
|
547
|
+
add([...path, "maximum"], "JSON Schema maximum is below minimum");
|
|
548
|
+
}
|
|
549
|
+
if (Object.hasOwn(schema, "x-hyperscale-reference-filter")) {
|
|
550
|
+
const filter = schema["x-hyperscale-reference-filter"];
|
|
551
|
+
if (
|
|
552
|
+
!isRecord(filter) ||
|
|
553
|
+
schema.type !== "string" ||
|
|
554
|
+
Object.keys(filter).some((key) => key !== "column" && key !== "values") ||
|
|
555
|
+
typeof filter.column !== "string" ||
|
|
556
|
+
!Array.isArray(filter.values) ||
|
|
557
|
+
!filter.values.every((value) => typeof value === "string")
|
|
558
|
+
) {
|
|
559
|
+
add(
|
|
560
|
+
[...path, "x-hyperscale-reference-filter"],
|
|
561
|
+
"x-hyperscale-reference-filter requires only string column and string-array values",
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (
|
|
566
|
+
Object.hasOwn(schema, "x-hyperscale-fee-collection-port") &&
|
|
567
|
+
(schema.type !== "string" ||
|
|
568
|
+
schema["x-hyperscale-fee-collection-port"] !== true)
|
|
569
|
+
) {
|
|
570
|
+
add(
|
|
571
|
+
[...path, "x-hyperscale-fee-collection-port"],
|
|
572
|
+
"x-hyperscale-fee-collection-port must be true on a string schema",
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
if (Object.hasOwn(schema, "pattern")) {
|
|
576
|
+
if (schema.type !== "string" || typeof schema.pattern !== "string") {
|
|
577
|
+
add([...path, "pattern"], "JSON Schema pattern must be a string");
|
|
578
|
+
} else {
|
|
579
|
+
const problem = regexProblem(schema.pattern);
|
|
580
|
+
if (problem) add([...path, "pattern"], problem);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (
|
|
584
|
+
Object.hasOwn(schema, "format") &&
|
|
585
|
+
(schema.type !== "string" ||
|
|
586
|
+
typeof schema.format !== "string" ||
|
|
587
|
+
!jsonSchemaFormats.has(schema.format))
|
|
588
|
+
) {
|
|
589
|
+
add(
|
|
590
|
+
[...path, "format"],
|
|
591
|
+
`JSON Schema format must be one of ${[...jsonSchemaFormats].join(", ")}`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const properties = recordValue(schema.properties);
|
|
596
|
+
for (const [property, child] of Object.entries(properties)) {
|
|
597
|
+
if (isRecord(child)) {
|
|
598
|
+
validateJsonSchema(child, [...path, "properties", property], add);
|
|
599
|
+
} else {
|
|
600
|
+
add(
|
|
601
|
+
[...path, "properties", property],
|
|
602
|
+
"JSON Schema property must be a schema object",
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (isRecord(schema.items)) {
|
|
607
|
+
validateJsonSchema(schema.items, [...path, "items"], add);
|
|
608
|
+
}
|
|
609
|
+
if (isRecord(schema.additionalProperties)) {
|
|
610
|
+
validateJsonSchema(
|
|
611
|
+
schema.additionalProperties,
|
|
612
|
+
[...path, "additionalProperties"],
|
|
613
|
+
add,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Admits a document-authored `pattern` only when its worst-case match cost is
|
|
620
|
+
* bounded, by multiplying out the search space instead of guessing at which
|
|
621
|
+
* shapes are dangerous.
|
|
622
|
+
*
|
|
623
|
+
* The sealed subset leaves exactly three ways to make one anchored match
|
|
624
|
+
* attempt branch: an alternation group (as many ways as it has branches), an
|
|
625
|
+
* optional atom (two), and a variable-width `{n,m}` (span many). Unbounded
|
|
626
|
+
* quantifiers, group repetition, backreferences, lookaround and `.` are
|
|
627
|
+
* refused, so every remaining branch point is a finite factor and their
|
|
628
|
+
* product is an upper bound on the attempts the backtracking engine can be
|
|
629
|
+
* made to explore. Cap that product and matching cost is at most the budget
|
|
630
|
+
* times the pattern length — no ambiguity heuristic to out-think, because
|
|
631
|
+
* catastrophic backtracking IS an unbounded product.
|
|
632
|
+
*/
|
|
633
|
+
function regexProblem(pattern: string): string | undefined {
|
|
634
|
+
if (pattern.length > UDL_LIMITS.maxPatternLength) {
|
|
635
|
+
return `JSON Schema pattern exceeds ${UDL_LIMITS.maxPatternLength} characters`;
|
|
636
|
+
}
|
|
637
|
+
if (
|
|
638
|
+
!pattern.startsWith("^") ||
|
|
639
|
+
!pattern.endsWith("$") ||
|
|
640
|
+
isEscapedRegexToken(pattern, pattern.length - 1)
|
|
641
|
+
) {
|
|
642
|
+
return "JSON Schema pattern must be explicitly anchored with ^ and $";
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
let paths = 1;
|
|
646
|
+
const overBudget = (factor: number): boolean => {
|
|
647
|
+
paths *= factor;
|
|
648
|
+
return paths > UDL_LIMITS.maxPatternPaths;
|
|
649
|
+
};
|
|
650
|
+
const budgetProblem = `JSON Schema pattern may branch more than ${UDL_LIMITS.maxPatternPaths} ways`;
|
|
651
|
+
|
|
652
|
+
const branches: number[] = [];
|
|
653
|
+
let inClass = false;
|
|
654
|
+
let escaped = false;
|
|
655
|
+
let previous: "atom" | "group" | "quantifier" | undefined;
|
|
656
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
657
|
+
const token = pattern[index] as string;
|
|
658
|
+
if (escaped) {
|
|
659
|
+
if (/[1-9k]/.test(token)) {
|
|
660
|
+
return "JSON Schema pattern may not contain backreferences";
|
|
661
|
+
}
|
|
662
|
+
escaped = false;
|
|
663
|
+
previous = "atom";
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (token === "\\") {
|
|
667
|
+
escaped = true;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (inClass) {
|
|
671
|
+
if (token === "]") {
|
|
672
|
+
inClass = false;
|
|
673
|
+
previous = "atom";
|
|
674
|
+
}
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
if (token === "[") {
|
|
678
|
+
inClass = true;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (token === ".") {
|
|
682
|
+
return "JSON Schema pattern may not contain the unbounded wildcard .";
|
|
683
|
+
}
|
|
684
|
+
if (token === "(") {
|
|
685
|
+
if (pattern[index + 1] === "?" && pattern[index + 2] !== ":") {
|
|
686
|
+
return "JSON Schema pattern may not contain lookaround or inline flags";
|
|
687
|
+
}
|
|
688
|
+
branches.push(1);
|
|
689
|
+
previous = undefined;
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
if (token === ")") {
|
|
693
|
+
const groupBranches = branches.pop();
|
|
694
|
+
if (groupBranches === undefined) {
|
|
695
|
+
return "JSON Schema pattern contains an unmatched closing group";
|
|
696
|
+
}
|
|
697
|
+
if (overBudget(groupBranches)) return budgetProblem;
|
|
698
|
+
previous = "group";
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (token === "|") {
|
|
702
|
+
const enclosing = branches.length - 1;
|
|
703
|
+
if (enclosing < 0) {
|
|
704
|
+
return "JSON Schema pattern alternation must be enclosed in a group";
|
|
705
|
+
}
|
|
706
|
+
branches[enclosing] = (branches[enclosing] as number) + 1;
|
|
707
|
+
previous = undefined;
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (token === "*" || token === "+") {
|
|
711
|
+
return "JSON Schema pattern may not contain unbounded quantifiers";
|
|
712
|
+
}
|
|
713
|
+
if (token === "?") {
|
|
714
|
+
if (pattern[index - 1] === "(") continue;
|
|
715
|
+
if (!previous || previous === "quantifier") {
|
|
716
|
+
return "JSON Schema pattern contains an ambiguous quantifier";
|
|
717
|
+
}
|
|
718
|
+
if (overBudget(2)) return budgetProblem;
|
|
719
|
+
previous = "quantifier";
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (token === "{") {
|
|
723
|
+
const end = pattern.indexOf("}", index + 1);
|
|
724
|
+
const bounds = end < 0 ? "" : pattern.slice(index + 1, end);
|
|
725
|
+
const match = /^(\d+)(?:,(\d+))?$/.exec(bounds);
|
|
726
|
+
if (!match) {
|
|
727
|
+
return "JSON Schema pattern quantifiers must have a finite upper bound";
|
|
728
|
+
}
|
|
729
|
+
if (previous === "group") {
|
|
730
|
+
return "JSON Schema pattern may not repeat a group";
|
|
731
|
+
}
|
|
732
|
+
if (!previous || previous === "quantifier") {
|
|
733
|
+
return "JSON Schema pattern contains an ambiguous quantifier";
|
|
734
|
+
}
|
|
735
|
+
const lower = Number(match[1]);
|
|
736
|
+
const upper = Number(match[2] ?? match[1]);
|
|
737
|
+
if (upper < lower || upper > UDL_LIMITS.maxStringLength) {
|
|
738
|
+
return `JSON Schema pattern quantifier upper bound must not exceed ${UDL_LIMITS.maxStringLength}`;
|
|
739
|
+
}
|
|
740
|
+
if (overBudget(upper - lower + 1)) return budgetProblem;
|
|
741
|
+
previous = "quantifier";
|
|
742
|
+
index = end;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
if (token !== "^" && token !== "$" && token !== ":") previous = "atom";
|
|
746
|
+
}
|
|
747
|
+
if (escaped || inClass || branches.length !== 0) {
|
|
748
|
+
return "JSON Schema pattern is not syntactically closed";
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
new RegExp(pattern, "u");
|
|
752
|
+
} catch {
|
|
753
|
+
return "JSON Schema pattern is not valid ECMAScript syntax";
|
|
754
|
+
}
|
|
755
|
+
return undefined;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function isEscapedRegexToken(pattern: string, index: number): boolean {
|
|
759
|
+
let slashes = 0;
|
|
760
|
+
for (
|
|
761
|
+
let cursor = index - 1;
|
|
762
|
+
cursor >= 0 && pattern[cursor] === "\\";
|
|
763
|
+
cursor -= 1
|
|
764
|
+
) {
|
|
765
|
+
slashes += 1;
|
|
766
|
+
}
|
|
767
|
+
return slashes % 2 === 1;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function validateNoun(
|
|
771
|
+
noun: UdlNoun,
|
|
772
|
+
nounIndex: number,
|
|
773
|
+
nouns: ReadonlyMap<string, UdlNoun>,
|
|
774
|
+
subjects: ReadonlyMap<string, unknown>,
|
|
775
|
+
references: ReferenceShapeBudget,
|
|
776
|
+
add: AddIssue,
|
|
777
|
+
): void {
|
|
778
|
+
const base = ["nouns", nounIndex] as const;
|
|
779
|
+
const states = new Set(noun.lifecycle.states);
|
|
780
|
+
|
|
781
|
+
addDuplicateIssues(
|
|
782
|
+
noun.lifecycle.states,
|
|
783
|
+
[...base, "lifecycle", "states"],
|
|
784
|
+
"lifecycle state",
|
|
785
|
+
add,
|
|
786
|
+
);
|
|
787
|
+
if (!states.has(noun.lifecycle.initial)) {
|
|
788
|
+
add(
|
|
789
|
+
[...base, "lifecycle", "initial"],
|
|
790
|
+
`initial state ${noun.lifecycle.initial} is not declared in lifecycle.states`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
if (!Object.hasOwn(noun.verbs, "create")) {
|
|
794
|
+
add([...base, "verbs"], "every noun must declare the create verb");
|
|
795
|
+
}
|
|
796
|
+
if (Object.hasOwn(noun.lifecycle.transitions, "create")) {
|
|
797
|
+
add(
|
|
798
|
+
[...base, "lifecycle", "transitions", "create"],
|
|
799
|
+
"create lands on lifecycle.initial and must not declare a transition",
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
for (const [verb, transition] of Object.entries(noun.lifecycle.transitions)) {
|
|
804
|
+
if (!Object.hasOwn(noun.verbs, verb)) {
|
|
805
|
+
add(
|
|
806
|
+
[...base, "lifecycle", "transitions", verb],
|
|
807
|
+
`transition ${verb} has no matching verb`,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
addDuplicateIssues(
|
|
811
|
+
transition.from,
|
|
812
|
+
[...base, "lifecycle", "transitions", verb, "from"],
|
|
813
|
+
"source state",
|
|
814
|
+
add,
|
|
815
|
+
);
|
|
816
|
+
transition.from.forEach((from, fromIndex) => {
|
|
817
|
+
if (!states.has(from)) {
|
|
818
|
+
add(
|
|
819
|
+
[...base, "lifecycle", "transitions", verb, "from", fromIndex],
|
|
820
|
+
`source state ${from} is not declared in lifecycle.states`,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
if (!states.has(transition.to)) {
|
|
825
|
+
add(
|
|
826
|
+
[...base, "lifecycle", "transitions", verb, "to"],
|
|
827
|
+
`target state ${transition.to} is not declared in lifecycle.states`,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
for (const verb of Object.keys(noun.verbs)) {
|
|
833
|
+
if (verb !== "create" && !Object.hasOwn(noun.lifecycle.transitions, verb)) {
|
|
834
|
+
add(
|
|
835
|
+
[...base, "verbs", verb],
|
|
836
|
+
`verb ${verb} must declare a lifecycle transition`,
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
validateReachability(noun, base, add);
|
|
841
|
+
|
|
842
|
+
const reservedFields = new Set([
|
|
843
|
+
"createdAt",
|
|
844
|
+
"id",
|
|
845
|
+
"metadata",
|
|
846
|
+
"refs",
|
|
847
|
+
"status",
|
|
848
|
+
]);
|
|
849
|
+
for (const field of Object.keys(noun.fields)) {
|
|
850
|
+
if (reservedFields.has(field)) {
|
|
851
|
+
add(
|
|
852
|
+
[...base, "fields", field],
|
|
853
|
+
`${field} is an envelope field and cannot be authored`,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
addDuplicateIssues(
|
|
858
|
+
noun.required,
|
|
859
|
+
[...base, "required"],
|
|
860
|
+
"required field",
|
|
861
|
+
add,
|
|
862
|
+
);
|
|
863
|
+
noun.required.forEach((field, fieldIndex) => {
|
|
864
|
+
if (!Object.hasOwn(noun.fields, field)) {
|
|
865
|
+
add(
|
|
866
|
+
[...base, "required", fieldIndex],
|
|
867
|
+
`required references unknown field ${field}`,
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
if (noun.subject) {
|
|
873
|
+
addDuplicateIssues(
|
|
874
|
+
noun.subject.kinds,
|
|
875
|
+
[...base, "subject", "kinds"],
|
|
876
|
+
"subject kind",
|
|
877
|
+
add,
|
|
878
|
+
);
|
|
879
|
+
noun.subject.kinds.forEach((kind, kindIndex) => {
|
|
880
|
+
if (!subjects.has(kind)) {
|
|
881
|
+
add(
|
|
882
|
+
[...base, "subject", "kinds", kindIndex],
|
|
883
|
+
`noun subject references unknown kind ${kind}`,
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
validateParties(noun, base, references, add);
|
|
890
|
+
validateUpdate(noun, base, states, add);
|
|
891
|
+
validateSetsAt(noun, base, add);
|
|
892
|
+
validateVerbs(noun, base, nouns, references, add);
|
|
893
|
+
validateUnwind(noun, base, references, add);
|
|
894
|
+
for (const issue of analyzeNounFinance(noun)) {
|
|
895
|
+
add([...base, ...issue.path], issue.message);
|
|
896
|
+
}
|
|
897
|
+
validateAggregates(noun, base, nouns, references, add);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function validateSetsAt(
|
|
901
|
+
noun: UdlNoun,
|
|
902
|
+
base: readonly PropertyKey[],
|
|
903
|
+
add: AddIssue,
|
|
904
|
+
): void {
|
|
905
|
+
const writers = new Map<string, string>();
|
|
906
|
+
for (const [verbName, verb] of Object.entries(noun.verbs)) {
|
|
907
|
+
if (!verb.setsAt) continue;
|
|
908
|
+
const path = [...base, "verbs", verbName, "setsAt"] as const;
|
|
909
|
+
const { field, offset } = verb.setsAt;
|
|
910
|
+
if (verbName === "create") {
|
|
911
|
+
add(
|
|
912
|
+
path,
|
|
913
|
+
"setsAt requires a lifecycle transition and cannot run on create",
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
const earlierWriter = writers.get(field);
|
|
917
|
+
if (earlierWriter) {
|
|
918
|
+
add(
|
|
919
|
+
[...path, "field"],
|
|
920
|
+
`${field} is already written by verb ${earlierWriter}`,
|
|
921
|
+
);
|
|
922
|
+
} else {
|
|
923
|
+
writers.set(field, verbName);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const schema = noun.fields[field];
|
|
927
|
+
if (!schema) {
|
|
928
|
+
add([...path, "field"], `setsAt references unknown field ${field}`);
|
|
929
|
+
} else if (schema.type !== "string" || !isDateTimeFormat(schema.format)) {
|
|
930
|
+
add([...path, "field"], "setsAt must target a date-time field");
|
|
931
|
+
}
|
|
932
|
+
if (noun.required.includes(field)) {
|
|
933
|
+
add([...path, "field"], "setsAt target must be optional at create");
|
|
934
|
+
}
|
|
935
|
+
if (noun.update?.fields.includes(field)) {
|
|
936
|
+
add([...path, "field"], "setsAt target cannot also be mutable");
|
|
937
|
+
}
|
|
938
|
+
const offsetMs = fixedIsoDurationMs(offset);
|
|
939
|
+
if (offsetMs === null || offsetMs <= 0) {
|
|
940
|
+
add(
|
|
941
|
+
[...path, "offset"],
|
|
942
|
+
"setsAt.offset must be a positive fixed ISO-8601 duration",
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const readers = Object.entries(noun.verbs).filter(
|
|
947
|
+
([, candidate]) =>
|
|
948
|
+
candidate.due?.field === field || candidate.deadline?.field === field,
|
|
949
|
+
);
|
|
950
|
+
if (readers.length === 0) {
|
|
951
|
+
add(
|
|
952
|
+
path,
|
|
953
|
+
`setsAt field ${field} must anchor at least one due condition or deadline`,
|
|
954
|
+
);
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
for (const [readerName] of readers) {
|
|
958
|
+
if (
|
|
959
|
+
readerName === verbName ||
|
|
960
|
+
!verbDominatesReader(noun, verbName, readerName)
|
|
961
|
+
) {
|
|
962
|
+
add(
|
|
963
|
+
[...base, "verbs", readerName],
|
|
964
|
+
`verb ${readerName} can read ${field} before writer ${verbName}`,
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function verbDominatesReader(
|
|
972
|
+
noun: UdlNoun,
|
|
973
|
+
writer: string,
|
|
974
|
+
reader: string,
|
|
975
|
+
): boolean {
|
|
976
|
+
if (!noun.lifecycle.transitions[writer]) return false;
|
|
977
|
+
const readerTransition = noun.lifecycle.transitions[reader];
|
|
978
|
+
if (!readerTransition) return false;
|
|
979
|
+
const reachable = new Set([noun.lifecycle.initial]);
|
|
980
|
+
const pending = [noun.lifecycle.initial];
|
|
981
|
+
while (pending.length > 0) {
|
|
982
|
+
const state = pending.shift() as string;
|
|
983
|
+
for (const [verb, transition] of Object.entries(
|
|
984
|
+
noun.lifecycle.transitions,
|
|
985
|
+
)) {
|
|
986
|
+
if (
|
|
987
|
+
verb === writer ||
|
|
988
|
+
!transition.from.includes(state) ||
|
|
989
|
+
reachable.has(transition.to)
|
|
990
|
+
) {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
reachable.add(transition.to);
|
|
994
|
+
pending.push(transition.to);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return readerTransition.from.every((state) => !reachable.has(state));
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function validateReachability(
|
|
1001
|
+
noun: UdlNoun,
|
|
1002
|
+
base: readonly PropertyKey[],
|
|
1003
|
+
add: AddIssue,
|
|
1004
|
+
): void {
|
|
1005
|
+
const reachable = new Set([noun.lifecycle.initial]);
|
|
1006
|
+
let changed = true;
|
|
1007
|
+
while (changed) {
|
|
1008
|
+
changed = false;
|
|
1009
|
+
for (const transition of Object.values(noun.lifecycle.transitions)) {
|
|
1010
|
+
if (
|
|
1011
|
+
transition.from.some((state) => reachable.has(state)) &&
|
|
1012
|
+
!reachable.has(transition.to)
|
|
1013
|
+
) {
|
|
1014
|
+
reachable.add(transition.to);
|
|
1015
|
+
changed = true;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
noun.lifecycle.states.forEach((state, stateIndex) => {
|
|
1020
|
+
if (!reachable.has(state)) {
|
|
1021
|
+
add(
|
|
1022
|
+
[...base, "lifecycle", "states", stateIndex],
|
|
1023
|
+
`lifecycle state ${state} is unreachable from ${noun.lifecycle.initial}`,
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function validateParties(
|
|
1030
|
+
noun: UdlNoun,
|
|
1031
|
+
base: readonly PropertyKey[],
|
|
1032
|
+
references: ReferenceShapeBudget,
|
|
1033
|
+
add: AddIssue,
|
|
1034
|
+
): void {
|
|
1035
|
+
if (!noun.parties) return;
|
|
1036
|
+
const entries = Object.entries(noun.parties).filter(
|
|
1037
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
1038
|
+
);
|
|
1039
|
+
if (entries.length === 0) {
|
|
1040
|
+
add([...base, "parties"], "parties must contain at least one declaration");
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
if (noun.distinctParties && entries.length < 2) {
|
|
1044
|
+
add(
|
|
1045
|
+
[...base, "distinctParties"],
|
|
1046
|
+
"distinctParties requires at least two declared party roles",
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
addDuplicateIssues(
|
|
1050
|
+
entries.map(([, field]) => field),
|
|
1051
|
+
[...base, "parties"],
|
|
1052
|
+
"party field",
|
|
1053
|
+
add,
|
|
1054
|
+
);
|
|
1055
|
+
for (const [role, field] of entries) {
|
|
1056
|
+
const schema = noun.fields[field];
|
|
1057
|
+
if (!schema) {
|
|
1058
|
+
add(
|
|
1059
|
+
[...base, "parties", role],
|
|
1060
|
+
`party role ${role} references unknown field ${field}`,
|
|
1061
|
+
);
|
|
1062
|
+
} else if (!references.accepts(schema, "acct")) {
|
|
1063
|
+
add(
|
|
1064
|
+
[...base, "parties", role],
|
|
1065
|
+
`party role ${role} must reference an account-id field`,
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function validateUpdate(
|
|
1072
|
+
noun: UdlNoun,
|
|
1073
|
+
base: readonly PropertyKey[],
|
|
1074
|
+
states: ReadonlySet<string>,
|
|
1075
|
+
add: AddIssue,
|
|
1076
|
+
): void {
|
|
1077
|
+
if (!noun.update) return;
|
|
1078
|
+
addDuplicateIssues(
|
|
1079
|
+
noun.update.fields,
|
|
1080
|
+
[...base, "update", "fields"],
|
|
1081
|
+
"field",
|
|
1082
|
+
add,
|
|
1083
|
+
);
|
|
1084
|
+
addDuplicateIssues(
|
|
1085
|
+
noun.update.states,
|
|
1086
|
+
[...base, "update", "states"],
|
|
1087
|
+
"state",
|
|
1088
|
+
add,
|
|
1089
|
+
);
|
|
1090
|
+
noun.update.fields.forEach((field, index) => {
|
|
1091
|
+
if (!Object.hasOwn(noun.fields, field)) {
|
|
1092
|
+
add(
|
|
1093
|
+
[...base, "update", "fields", index],
|
|
1094
|
+
`update references unknown field ${field}`,
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
noun.update.states.forEach((state, index) => {
|
|
1099
|
+
if (!states.has(state)) {
|
|
1100
|
+
add(
|
|
1101
|
+
[...base, "update", "states", index],
|
|
1102
|
+
`update references unknown state ${state}`,
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function validateVerbs(
|
|
1109
|
+
noun: UdlNoun,
|
|
1110
|
+
base: readonly PropertyKey[],
|
|
1111
|
+
nouns: ReadonlyMap<string, UdlNoun>,
|
|
1112
|
+
references: ReferenceShapeBudget,
|
|
1113
|
+
add: AddIssue,
|
|
1114
|
+
): void {
|
|
1115
|
+
for (const [verb, definition] of Object.entries(noun.verbs)) {
|
|
1116
|
+
const verbBase = [...base, "verbs", verb] as const;
|
|
1117
|
+
if (verb === "create" && definition.input) {
|
|
1118
|
+
add(
|
|
1119
|
+
[...verbBase, "input"],
|
|
1120
|
+
"create already consumes noun fields and must not declare input",
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
if (definition.input && definition.input.type !== "object") {
|
|
1124
|
+
add(
|
|
1125
|
+
[...verbBase, "input", "type"],
|
|
1126
|
+
"verb input must declare an object-shaped JSON Schema",
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
addDuplicateIssues(
|
|
1131
|
+
definition.moves.map((move) => move.key),
|
|
1132
|
+
[...verbBase, "moves"],
|
|
1133
|
+
"move key",
|
|
1134
|
+
add,
|
|
1135
|
+
);
|
|
1136
|
+
if (definition.earnable && definition.moves.length === 0) {
|
|
1137
|
+
add(verbBase, "earnable requires a money move");
|
|
1138
|
+
}
|
|
1139
|
+
definition.steps.forEach((step, stepIndex) =>
|
|
1140
|
+
validateStep(
|
|
1141
|
+
noun,
|
|
1142
|
+
definition,
|
|
1143
|
+
step,
|
|
1144
|
+
[...verbBase, "steps", stepIndex],
|
|
1145
|
+
add,
|
|
1146
|
+
),
|
|
1147
|
+
);
|
|
1148
|
+
definition.moves.forEach((move, moveIndex) =>
|
|
1149
|
+
validateStep(
|
|
1150
|
+
noun,
|
|
1151
|
+
definition,
|
|
1152
|
+
move,
|
|
1153
|
+
[...verbBase, "moves", moveIndex],
|
|
1154
|
+
add,
|
|
1155
|
+
),
|
|
1156
|
+
);
|
|
1157
|
+
|
|
1158
|
+
if (definition.requiresRefs) {
|
|
1159
|
+
addDuplicateIssues(
|
|
1160
|
+
definition.requiresRefs.map((gate) => gate.field),
|
|
1161
|
+
[...verbBase, "requiresRefs"],
|
|
1162
|
+
"gate field",
|
|
1163
|
+
add,
|
|
1164
|
+
);
|
|
1165
|
+
definition.requiresRefs.forEach((gate, gateIndex) => {
|
|
1166
|
+
addDuplicateIssues(
|
|
1167
|
+
gate.statuses,
|
|
1168
|
+
[...verbBase, "requiresRefs", gateIndex, "statuses"],
|
|
1169
|
+
"gate status",
|
|
1170
|
+
add,
|
|
1171
|
+
);
|
|
1172
|
+
const schema = noun.fields[gate.field];
|
|
1173
|
+
if (!schema) {
|
|
1174
|
+
add(
|
|
1175
|
+
[...verbBase, "requiresRefs", gateIndex, "field"],
|
|
1176
|
+
`gate references unknown field ${gate.field}`,
|
|
1177
|
+
);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const targets = [...nouns.values()].filter((target) =>
|
|
1181
|
+
references.accepts(schema, target.idPrefix),
|
|
1182
|
+
);
|
|
1183
|
+
if (targets.length !== 1) {
|
|
1184
|
+
add(
|
|
1185
|
+
[...verbBase, "requiresRefs", gateIndex, "field"],
|
|
1186
|
+
`gate field ${gate.field} must identify exactly one noun (found ${targets.length})`,
|
|
1187
|
+
);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
const target = targets[0] as UdlNoun;
|
|
1191
|
+
gate.statuses.forEach((status, statusIndex) => {
|
|
1192
|
+
if (!target.lifecycle.states.includes(status)) {
|
|
1193
|
+
add(
|
|
1194
|
+
[...verbBase, "requiresRefs", gateIndex, "statuses", statusIndex],
|
|
1195
|
+
`gate status ${status} is not declared by ${target.id}`,
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
validateGateShape(
|
|
1200
|
+
noun,
|
|
1201
|
+
verb,
|
|
1202
|
+
gate,
|
|
1203
|
+
target,
|
|
1204
|
+
[...verbBase, "requiresRefs", gateIndex],
|
|
1205
|
+
add,
|
|
1206
|
+
);
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
if (definition.requiresDrainedAccount) {
|
|
1211
|
+
const drainBase = [...verbBase, "requiresDrainedAccount"] as const;
|
|
1212
|
+
if (verb === "create") {
|
|
1213
|
+
add(
|
|
1214
|
+
[...drainBase],
|
|
1215
|
+
"requiresDrainedAccount cannot gate create: no account exists yet",
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
const path = definition.requiresDrainedAccount.path;
|
|
1219
|
+
const declared =
|
|
1220
|
+
(path.startsWith("fields.") &&
|
|
1221
|
+
Object.hasOwn(noun.fields, path.slice("fields.".length))) ||
|
|
1222
|
+
(path.startsWith("refs.") &&
|
|
1223
|
+
declaredRefKeys(noun).has(path.slice("refs.".length)));
|
|
1224
|
+
if (!declared) {
|
|
1225
|
+
add(
|
|
1226
|
+
[...drainBase, "path"],
|
|
1227
|
+
`requiresDrainedAccount reads ${path}, which is not declared`,
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (definition.due) {
|
|
1233
|
+
const transition = noun.lifecycle.transitions[verb];
|
|
1234
|
+
if (definition.due.every) {
|
|
1235
|
+
// A recurring due verb is stationary: each occurrence fires in place
|
|
1236
|
+
// and the runtime keeps the instance's current status, so `to` is
|
|
1237
|
+
// only a witness naming one of the source states.
|
|
1238
|
+
if (!transition || !transition.from.includes(transition.to)) {
|
|
1239
|
+
add(
|
|
1240
|
+
[...verbBase, "due"],
|
|
1241
|
+
"a recurring due verb must be stationary: its transition's to must name one of its from states",
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
} else if (!transition || transition.from.includes(transition.to)) {
|
|
1245
|
+
add(
|
|
1246
|
+
[...verbBase, "due"],
|
|
1247
|
+
"a due verb must leave every source state so the maintenance loop fires its anchor exactly once",
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
const schema = noun.fields[definition.due.field];
|
|
1251
|
+
if (!schema) {
|
|
1252
|
+
add(
|
|
1253
|
+
[...verbBase, "due", "field"],
|
|
1254
|
+
`due condition references unknown field ${definition.due.field}`,
|
|
1255
|
+
);
|
|
1256
|
+
} else if (!isDateTimeFormat(schema.format)) {
|
|
1257
|
+
add(
|
|
1258
|
+
[...verbBase, "due", "field"],
|
|
1259
|
+
"due field must be a date-time field",
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
if (
|
|
1263
|
+
definition.due.offset &&
|
|
1264
|
+
fixedIsoDurationMs(definition.due.offset) === null
|
|
1265
|
+
) {
|
|
1266
|
+
add(
|
|
1267
|
+
[...verbBase, "due", "offset"],
|
|
1268
|
+
"due.offset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds",
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
if (definition.deadline) {
|
|
1274
|
+
if (definition.due) {
|
|
1275
|
+
add(
|
|
1276
|
+
[...verbBase, "deadline"],
|
|
1277
|
+
"a verb cannot declare both a due condition and a deadline",
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
const schema = noun.fields[definition.deadline.field];
|
|
1281
|
+
if (!schema) {
|
|
1282
|
+
add(
|
|
1283
|
+
[...verbBase, "deadline", "field"],
|
|
1284
|
+
`deadline references unknown field ${definition.deadline.field}`,
|
|
1285
|
+
);
|
|
1286
|
+
} else if (!isDateTimeFormat(schema.format)) {
|
|
1287
|
+
add(
|
|
1288
|
+
[...verbBase, "deadline", "field"],
|
|
1289
|
+
"deadline field must be a date-time field",
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
if (
|
|
1293
|
+
definition.deadline.offset &&
|
|
1294
|
+
fixedIsoDurationMs(definition.deadline.offset) === null
|
|
1295
|
+
) {
|
|
1296
|
+
add(
|
|
1297
|
+
[...verbBase, "deadline", "offset"],
|
|
1298
|
+
"deadline.offset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds",
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (definition.examples) {
|
|
1304
|
+
addDuplicateIssues(
|
|
1305
|
+
definition.examples.map((example) => example.name),
|
|
1306
|
+
[...verbBase, "examples"],
|
|
1307
|
+
"example name",
|
|
1308
|
+
add,
|
|
1309
|
+
);
|
|
1310
|
+
const inputSchema = exampleInputSchema(noun, verb, definition);
|
|
1311
|
+
definition.examples.forEach((example, exampleIndex) => {
|
|
1312
|
+
for (const error of validateUdlSchemaValue(inputSchema, example.input)
|
|
1313
|
+
.errors) {
|
|
1314
|
+
add(
|
|
1315
|
+
[...verbBase, "examples", exampleIndex, "input"],
|
|
1316
|
+
`example ${example.name} input ${error.error} at ${error.instanceLocation || "/"}`,
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Shape rules for one resolved reference gate: derived bindings and the
|
|
1326
|
+
* uniqueness claim are create-only, bind keys must be declared immutable
|
|
1327
|
+
* fields, and every referenced path must name declared target structure. The
|
|
1328
|
+
* exact mirror of the Hyperscale contract registry's rules, so a document that loads
|
|
1329
|
+
* open-grammar clean also loads registry clean.
|
|
1330
|
+
*/
|
|
1331
|
+
function validateGateShape(
|
|
1332
|
+
noun: UdlNoun,
|
|
1333
|
+
verb: string,
|
|
1334
|
+
gate: UdlGate,
|
|
1335
|
+
target: UdlNoun,
|
|
1336
|
+
base: readonly PropertyKey[],
|
|
1337
|
+
add: AddIssue,
|
|
1338
|
+
): void {
|
|
1339
|
+
if (gate.unique && verb !== "create") {
|
|
1340
|
+
add([...base, "unique"], "unique is a create admission gate");
|
|
1341
|
+
}
|
|
1342
|
+
if (gate.bind && verb !== "create") {
|
|
1343
|
+
add([...base, "bind"], "bind derives fields at create only");
|
|
1344
|
+
}
|
|
1345
|
+
if (gate.bind && Object.keys(gate.bind).length === 0) {
|
|
1346
|
+
add([...base, "bind"], "bind must not be empty");
|
|
1347
|
+
}
|
|
1348
|
+
if (gate.match && Object.keys(gate.match).length === 0) {
|
|
1349
|
+
add([...base, "match"], "match must not be empty");
|
|
1350
|
+
}
|
|
1351
|
+
if (gate.optional && noun.required.includes(gate.field)) {
|
|
1352
|
+
add(
|
|
1353
|
+
[...base, "optional"],
|
|
1354
|
+
`optional declares an opt-out on ${gate.field}, which required lists`,
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
for (const [key, path] of Object.entries(gate.bind ?? {})) {
|
|
1358
|
+
if (gate.optional && noun.required.includes(key)) {
|
|
1359
|
+
add(
|
|
1360
|
+
[...base, "bind", key],
|
|
1361
|
+
`an optional reference can only bind optional fields; required lists ${key}`,
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
if (!Object.hasOwn(noun.fields, key)) {
|
|
1365
|
+
add([...base, "bind", key], `bind targets unknown field ${key}`);
|
|
1366
|
+
}
|
|
1367
|
+
if (key === gate.field) {
|
|
1368
|
+
add([...base, "bind", key], "bind cannot target the gate field itself");
|
|
1369
|
+
}
|
|
1370
|
+
if (noun.update?.fields.includes(key)) {
|
|
1371
|
+
add(
|
|
1372
|
+
[...base, "bind", key],
|
|
1373
|
+
`bind targets ${key}, which update declares mutable`,
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
if (path === "nounInstanceId") {
|
|
1377
|
+
add(
|
|
1378
|
+
[...base, "bind", key],
|
|
1379
|
+
"the gate field already carries the referenced id",
|
|
1380
|
+
);
|
|
1381
|
+
} else if (!referencedPathDeclared(target, path)) {
|
|
1382
|
+
add(
|
|
1383
|
+
[...base, "bind", key],
|
|
1384
|
+
`bind reads ${path}, which ${target.id} does not declare`,
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
for (const [localPath, path] of Object.entries(gate.match ?? {})) {
|
|
1389
|
+
const localOk =
|
|
1390
|
+
verb === "create"
|
|
1391
|
+
? localPath.startsWith("fields.") &&
|
|
1392
|
+
Object.hasOwn(noun.fields, localPath.slice("fields.".length))
|
|
1393
|
+
: localPath === "nounInstanceId" ||
|
|
1394
|
+
(localPath.startsWith("fields.") &&
|
|
1395
|
+
Object.hasOwn(noun.fields, localPath.slice("fields.".length))) ||
|
|
1396
|
+
(localPath.startsWith("refs.") &&
|
|
1397
|
+
declaredRefKeys(noun).has(localPath.slice("refs.".length)));
|
|
1398
|
+
if (!localOk) {
|
|
1399
|
+
add(
|
|
1400
|
+
[...base, "match", localPath],
|
|
1401
|
+
verb === "create"
|
|
1402
|
+
? "a create match may only read the noun's own declared fields"
|
|
1403
|
+
: `match reads unknown local path ${localPath}`,
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
if (!referencedPathDeclared(target, path)) {
|
|
1407
|
+
add(
|
|
1408
|
+
[...base, "match", localPath],
|
|
1409
|
+
`match reads ${path}, which ${target.id} does not declare`,
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** Every `refs.<key>` a noun's instances can legitimately carry. */
|
|
1416
|
+
function declaredRefKeys(noun: UdlNoun): ReadonlySet<string> {
|
|
1417
|
+
return new Set([
|
|
1418
|
+
...Object.values(noun.verbs).flatMap((verb) =>
|
|
1419
|
+
[...verb.steps, ...verb.moves].flatMap((step) =>
|
|
1420
|
+
Object.keys(step.capture ?? {}),
|
|
1421
|
+
),
|
|
1422
|
+
),
|
|
1423
|
+
...(noun.unwind ? ["unwindRefund", "unwindPenalty"] : []),
|
|
1424
|
+
...(noun.subject ? ["subject"] : []),
|
|
1425
|
+
]);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/** Whether a bind/match referenced path names declared target structure. */
|
|
1429
|
+
function referencedPathDeclared(target: UdlNoun, path: string): boolean {
|
|
1430
|
+
if (path === "nounInstanceId") return true;
|
|
1431
|
+
if (path.startsWith("fields.")) {
|
|
1432
|
+
return Object.hasOwn(target.fields, path.slice("fields.".length));
|
|
1433
|
+
}
|
|
1434
|
+
if (path.startsWith("refs.")) {
|
|
1435
|
+
return declaredRefKeys(target).has(path.slice("refs.".length));
|
|
1436
|
+
}
|
|
1437
|
+
return false;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function validateStep(
|
|
1441
|
+
noun: UdlNoun,
|
|
1442
|
+
verb: UdlVerb,
|
|
1443
|
+
step: UdlStep | UdlMove,
|
|
1444
|
+
base: readonly PropertyKey[],
|
|
1445
|
+
add: AddIssue,
|
|
1446
|
+
): void {
|
|
1447
|
+
if (Object.keys(step.bind).length === 0) {
|
|
1448
|
+
add([...base, "bind"], "a kernel step must bind at least one value");
|
|
1449
|
+
}
|
|
1450
|
+
if (step.capture && Object.keys(step.capture).length === 0) {
|
|
1451
|
+
add([...base, "capture"], "capture must not be empty");
|
|
1452
|
+
}
|
|
1453
|
+
const inputFields = recordValue(verb.input?.properties);
|
|
1454
|
+
for (const [field, binding] of Object.entries(step.bind)) {
|
|
1455
|
+
if (binding.from === "const") continue;
|
|
1456
|
+
if (binding.from === "input") {
|
|
1457
|
+
const root = binding.path.split(".")[0] as string;
|
|
1458
|
+
if (!Object.hasOwn(inputFields, root)) {
|
|
1459
|
+
add(
|
|
1460
|
+
[...base, "bind", field, "path"],
|
|
1461
|
+
`input binding references undeclared verb input field ${root}`,
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
if (binding.path.startsWith("fields.")) {
|
|
1467
|
+
const root = binding.path.split(".")[1] as string;
|
|
1468
|
+
if (!Object.hasOwn(noun.fields, root)) {
|
|
1469
|
+
add(
|
|
1470
|
+
[...base, "bind", field, "path"],
|
|
1471
|
+
`instance binding references unknown noun field ${root}`,
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
if (
|
|
1477
|
+
binding.path !== "nounInstanceId" &&
|
|
1478
|
+
binding.path !== "workspaceId" &&
|
|
1479
|
+
!binding.path.startsWith("refs.")
|
|
1480
|
+
) {
|
|
1481
|
+
add(
|
|
1482
|
+
[...base, "bind", field, "path"],
|
|
1483
|
+
`instance binding path ${binding.path} must read nounInstanceId, workspaceId, fields.*, or refs.*`,
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
if (
|
|
1488
|
+
step.operation === "account.freeze" ||
|
|
1489
|
+
step.operation === "account.unfreeze"
|
|
1490
|
+
) {
|
|
1491
|
+
// Freeze steps act on an account the instance already carries and move no
|
|
1492
|
+
// money: the account must come from instance state, never caller input,
|
|
1493
|
+
// and a monetary leg on a freeze step is a contradiction in terms.
|
|
1494
|
+
const accountId = step.bind.accountId;
|
|
1495
|
+
if (!accountId || accountId.from !== "instance") {
|
|
1496
|
+
add(
|
|
1497
|
+
[...base, "bind", "accountId"],
|
|
1498
|
+
`${step.operation} must bind accountId from an instance path`,
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
for (const monetary of ["amount", "currency"] as const) {
|
|
1502
|
+
if (Object.hasOwn(step.bind, monetary)) {
|
|
1503
|
+
add(
|
|
1504
|
+
[...base, "bind", monetary],
|
|
1505
|
+
`${step.operation} must not bind ${monetary}`,
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
if (
|
|
1511
|
+
step.operation === "internal_transfer.create" ||
|
|
1512
|
+
step.operation === "internal_transfer.reserve"
|
|
1513
|
+
) {
|
|
1514
|
+
const source = step.bind.sourceAccountId;
|
|
1515
|
+
const destination = step.bind.destinationAccountId;
|
|
1516
|
+
if (
|
|
1517
|
+
source?.from === "instance" &&
|
|
1518
|
+
destination?.from === "instance" &&
|
|
1519
|
+
source.path === destination.path
|
|
1520
|
+
) {
|
|
1521
|
+
add(
|
|
1522
|
+
[...base, "bind", "destinationAccountId"],
|
|
1523
|
+
"transfer source and destination must be different accounts",
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function validateUnwind(
|
|
1530
|
+
noun: UdlNoun,
|
|
1531
|
+
base: readonly PropertyKey[],
|
|
1532
|
+
references: ReferenceShapeBudget,
|
|
1533
|
+
add: AddIssue,
|
|
1534
|
+
): void {
|
|
1535
|
+
const unwind = noun.unwind;
|
|
1536
|
+
if (!unwind) return;
|
|
1537
|
+
if (unwind.quote === unwind.confirm) {
|
|
1538
|
+
add(
|
|
1539
|
+
[...base, "unwind", "confirm"],
|
|
1540
|
+
"unwind quote and confirm verbs must differ",
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
for (const [fieldName, field] of [
|
|
1544
|
+
["refundableField", unwind.refundableField],
|
|
1545
|
+
["refundDestinationField", unwind.refundDestinationField],
|
|
1546
|
+
] as const) {
|
|
1547
|
+
if (!Object.hasOwn(noun.fields, field)) {
|
|
1548
|
+
add(
|
|
1549
|
+
[...base, "unwind", fieldName],
|
|
1550
|
+
`unwind references unknown field ${field}`,
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const refundable = noun.fields[unwind.refundableField];
|
|
1555
|
+
if (refundable && !isMoneySchema(refundable)) {
|
|
1556
|
+
add(
|
|
1557
|
+
[...base, "unwind", "refundableField"],
|
|
1558
|
+
"refundableField must be a money field",
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1561
|
+
const destination = noun.fields[unwind.refundDestinationField];
|
|
1562
|
+
if (destination && !references.accepts(destination, "acct")) {
|
|
1563
|
+
add(
|
|
1564
|
+
[...base, "unwind", "refundDestinationField"],
|
|
1565
|
+
"refundDestinationField must be an account-id field",
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
if (unwind.beforeField) {
|
|
1569
|
+
const before = noun.fields[unwind.beforeField];
|
|
1570
|
+
if (!before) {
|
|
1571
|
+
add(
|
|
1572
|
+
[...base, "unwind", "beforeField"],
|
|
1573
|
+
`unwind references unknown field ${unwind.beforeField}`,
|
|
1574
|
+
);
|
|
1575
|
+
} else if (!isDateTimeFormat(before.format)) {
|
|
1576
|
+
add(
|
|
1577
|
+
[...base, "unwind", "beforeField"],
|
|
1578
|
+
"beforeField must be a date-time field",
|
|
1579
|
+
);
|
|
1580
|
+
} else if (!noun.required.includes(unwind.beforeField)) {
|
|
1581
|
+
add([...base, "unwind", "beforeField"], "beforeField must be required");
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
for (const [verbName, verb] of [
|
|
1585
|
+
["quote", unwind.quote],
|
|
1586
|
+
["confirm", unwind.confirm],
|
|
1587
|
+
] as const) {
|
|
1588
|
+
const definition = noun.verbs[verb];
|
|
1589
|
+
if (!definition) {
|
|
1590
|
+
add(
|
|
1591
|
+
[...base, "unwind", verbName],
|
|
1592
|
+
`unwind references unknown verb ${verb}`,
|
|
1593
|
+
);
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
if (definition.earnable) {
|
|
1597
|
+
add(
|
|
1598
|
+
[...base, "verbs", verb, "earnable"],
|
|
1599
|
+
`unwind ${verbName} verb ${verb} is refund-shaped and cannot be earnable`,
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
const confirm = noun.verbs[unwind.confirm];
|
|
1604
|
+
if (confirm) {
|
|
1605
|
+
const transfers = confirm.moves.filter(
|
|
1606
|
+
(move) => move.operation === "internal_transfer.create",
|
|
1607
|
+
);
|
|
1608
|
+
const refund = transfers[0];
|
|
1609
|
+
const amount = refund?.bind.amount;
|
|
1610
|
+
const sourceBinding = refund?.bind.sourceAccountId;
|
|
1611
|
+
const destinationBinding = refund?.bind.destinationAccountId;
|
|
1612
|
+
if (
|
|
1613
|
+
transfers.length !== 1 ||
|
|
1614
|
+
amount?.from !== "instance" ||
|
|
1615
|
+
amount.path !== "refs.unwindRefund" ||
|
|
1616
|
+
sourceBinding?.from !== "instance" ||
|
|
1617
|
+
destinationBinding?.from !== "instance" ||
|
|
1618
|
+
destinationBinding.path !== `fields.${unwind.refundDestinationField}`
|
|
1619
|
+
) {
|
|
1620
|
+
add(
|
|
1621
|
+
[...base, "verbs", unwind.confirm, "moves"],
|
|
1622
|
+
`unwind confirm verb ${unwind.confirm} must contain exactly one internal transfer whose source comes from the noun instance, amount is refs.unwindRefund, and destination is fields.${unwind.refundDestinationField}`,
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
const offsets = unwind.penalty.flatMap((tier) =>
|
|
1627
|
+
tier.withinOffset ? [tier.withinOffset] : [],
|
|
1628
|
+
);
|
|
1629
|
+
addDuplicateIssues(
|
|
1630
|
+
offsets,
|
|
1631
|
+
[...base, "unwind", "penalty"],
|
|
1632
|
+
"penalty offset",
|
|
1633
|
+
add,
|
|
1634
|
+
);
|
|
1635
|
+
if (
|
|
1636
|
+
unwind.penalty.filter((tier) => tier.withinOffset === undefined).length > 1
|
|
1637
|
+
) {
|
|
1638
|
+
add(
|
|
1639
|
+
[...base, "unwind", "penalty"],
|
|
1640
|
+
"unwind may declare at most one floor tier",
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
unwind.penalty.forEach((tier, index) => {
|
|
1644
|
+
if (tier.withinOffset && fixedIsoDurationMs(tier.withinOffset) === null) {
|
|
1645
|
+
add(
|
|
1646
|
+
[...base, "unwind", "penalty", index, "withinOffset"],
|
|
1647
|
+
"withinOffset must be a fixed ISO-8601 duration using weeks, days, hours, minutes, or seconds",
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
function validateAggregates(
|
|
1654
|
+
noun: UdlNoun,
|
|
1655
|
+
base: readonly PropertyKey[],
|
|
1656
|
+
nouns: ReadonlyMap<string, UdlNoun>,
|
|
1657
|
+
references: ReferenceShapeBudget,
|
|
1658
|
+
add: AddIssue,
|
|
1659
|
+
): void {
|
|
1660
|
+
const aggregates = noun.aggregateInvariants;
|
|
1661
|
+
if (!aggregates) return;
|
|
1662
|
+
addDuplicateIssues(
|
|
1663
|
+
aggregates.map(
|
|
1664
|
+
(aggregate) =>
|
|
1665
|
+
`${aggregate.childNounId}:${aggregate.childRefField}:${aggregateMeasureKey(aggregate)}:${aggregate.parentField}`,
|
|
1666
|
+
),
|
|
1667
|
+
[...base, "aggregateInvariants"],
|
|
1668
|
+
"aggregate invariant",
|
|
1669
|
+
add,
|
|
1670
|
+
);
|
|
1671
|
+
aggregates.forEach((aggregate, aggregateIndex) => {
|
|
1672
|
+
const aggregateBase = [
|
|
1673
|
+
...base,
|
|
1674
|
+
"aggregateInvariants",
|
|
1675
|
+
aggregateIndex,
|
|
1676
|
+
] as const;
|
|
1677
|
+
const sum = "childField" in aggregate ? aggregate : undefined;
|
|
1678
|
+
const parentField = noun.fields[aggregate.parentField];
|
|
1679
|
+
if (!parentField) {
|
|
1680
|
+
add(
|
|
1681
|
+
[...aggregateBase, "parentField"],
|
|
1682
|
+
`aggregate references unknown parent field ${aggregate.parentField}`,
|
|
1683
|
+
);
|
|
1684
|
+
} else if (sum && !isMoneySchema(parentField)) {
|
|
1685
|
+
add(
|
|
1686
|
+
[...aggregateBase, "parentField"],
|
|
1687
|
+
`${noun.id}.${aggregate.parentField} must be a money field`,
|
|
1688
|
+
);
|
|
1689
|
+
} else if (
|
|
1690
|
+
!sum &&
|
|
1691
|
+
(parentField.type !== "integer" ||
|
|
1692
|
+
!noun.required.includes(aggregate.parentField))
|
|
1693
|
+
) {
|
|
1694
|
+
add(
|
|
1695
|
+
[...aggregateBase, "parentField"],
|
|
1696
|
+
`${noun.id}.${aggregate.parentField} must be a required integer field to cap a count`,
|
|
1697
|
+
);
|
|
1698
|
+
} else if (noun.update?.fields.includes(aggregate.parentField)) {
|
|
1699
|
+
add(
|
|
1700
|
+
[...aggregateBase, "parentField"],
|
|
1701
|
+
`${noun.id}.${aggregate.parentField} cannot be updateable while it caps an aggregate`,
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
const child = nouns.get(aggregate.childNounId);
|
|
1705
|
+
if (!child) {
|
|
1706
|
+
add(
|
|
1707
|
+
[...aggregateBase, "childNounId"],
|
|
1708
|
+
`aggregate references unknown child noun ${aggregate.childNounId}`,
|
|
1709
|
+
);
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
if (sum) {
|
|
1713
|
+
const childField = child.fields[sum.childField];
|
|
1714
|
+
if (!childField) {
|
|
1715
|
+
add(
|
|
1716
|
+
[...aggregateBase, "childField"],
|
|
1717
|
+
`aggregate references unknown child field ${sum.childField}`,
|
|
1718
|
+
);
|
|
1719
|
+
} else if (!isMoneySchema(childField)) {
|
|
1720
|
+
add(
|
|
1721
|
+
[...aggregateBase, "childField"],
|
|
1722
|
+
`${child.id}.${sum.childField} must be a money field`,
|
|
1723
|
+
);
|
|
1724
|
+
} else if (child.update?.fields.includes(sum.childField)) {
|
|
1725
|
+
add(
|
|
1726
|
+
[...aggregateBase, "childField"],
|
|
1727
|
+
`${child.id}.${sum.childField} cannot be updateable while it contributes to an aggregate`,
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
const window = "count" in aggregate ? aggregate.window : undefined;
|
|
1732
|
+
if (window) {
|
|
1733
|
+
const windowField = child.fields[window.field];
|
|
1734
|
+
if (!windowField) {
|
|
1735
|
+
add(
|
|
1736
|
+
[...aggregateBase, "window", "field"],
|
|
1737
|
+
`aggregate window references unknown child field ${window.field}`,
|
|
1738
|
+
);
|
|
1739
|
+
} else if (
|
|
1740
|
+
!isDateTimeFormat(windowField.format) ||
|
|
1741
|
+
!child.required.includes(window.field)
|
|
1742
|
+
) {
|
|
1743
|
+
add(
|
|
1744
|
+
[...aggregateBase, "window", "field"],
|
|
1745
|
+
`${child.id}.${window.field} must be a required date-time field to window a count`,
|
|
1746
|
+
);
|
|
1747
|
+
} else if (child.update?.fields.includes(window.field)) {
|
|
1748
|
+
add(
|
|
1749
|
+
[...aggregateBase, "window", "field"],
|
|
1750
|
+
`${child.id}.${window.field} cannot be updateable while it windows an aggregate`,
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
const refField = child.fields[aggregate.childRefField];
|
|
1755
|
+
if (!refField) {
|
|
1756
|
+
add(
|
|
1757
|
+
[...aggregateBase, "childRefField"],
|
|
1758
|
+
`aggregate references unknown child ref field ${aggregate.childRefField}`,
|
|
1759
|
+
);
|
|
1760
|
+
} else if (!references.accepts(refField, noun.idPrefix)) {
|
|
1761
|
+
add(
|
|
1762
|
+
[...aggregateBase, "childRefField"],
|
|
1763
|
+
`${child.id}.${aggregate.childRefField} must reference ${noun.id}`,
|
|
1764
|
+
);
|
|
1765
|
+
} else if (child.update?.fields.includes(aggregate.childRefField)) {
|
|
1766
|
+
add(
|
|
1767
|
+
[...aggregateBase, "childRefField"],
|
|
1768
|
+
`${child.id}.${aggregate.childRefField} cannot be updateable while it links an aggregate`,
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1771
|
+
addDuplicateIssues(
|
|
1772
|
+
aggregate.childStatuses,
|
|
1773
|
+
[...aggregateBase, "childStatuses"],
|
|
1774
|
+
"child status",
|
|
1775
|
+
add,
|
|
1776
|
+
);
|
|
1777
|
+
aggregate.childStatuses.forEach((status, statusIndex) => {
|
|
1778
|
+
if (!child.lifecycle.states.includes(status)) {
|
|
1779
|
+
add(
|
|
1780
|
+
[...aggregateBase, "childStatuses", statusIndex],
|
|
1781
|
+
`aggregate consumes unknown ${child.id} status ${status}`,
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
});
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function exampleInputSchema(
|
|
1789
|
+
noun: UdlNoun,
|
|
1790
|
+
verb: string,
|
|
1791
|
+
definition: UdlVerb,
|
|
1792
|
+
): Schema {
|
|
1793
|
+
if (verb !== "create") {
|
|
1794
|
+
return (definition.input ?? {
|
|
1795
|
+
additionalProperties: false,
|
|
1796
|
+
properties: {},
|
|
1797
|
+
type: "object",
|
|
1798
|
+
}) as Schema;
|
|
1799
|
+
}
|
|
1800
|
+
// Derived and machine-computed fields leave the authorable create surface:
|
|
1801
|
+
// the runtime writes them from referenced instances or lifecycle events.
|
|
1802
|
+
const boundKeys = new Set([
|
|
1803
|
+
...(definition.requiresRefs ?? []).flatMap((gate) =>
|
|
1804
|
+
Object.keys(gate.bind ?? {}),
|
|
1805
|
+
),
|
|
1806
|
+
...Object.values(noun.verbs).flatMap((candidate) =>
|
|
1807
|
+
candidate.setsAt ? [candidate.setsAt.field] : [],
|
|
1808
|
+
),
|
|
1809
|
+
]);
|
|
1810
|
+
const properties: Record<string, unknown> = Object.fromEntries(
|
|
1811
|
+
Object.entries(noun.fields).filter(([key]) => !boundKeys.has(key)),
|
|
1812
|
+
);
|
|
1813
|
+
const required = noun.required.filter((key) => !boundKeys.has(key));
|
|
1814
|
+
if (noun.subject) {
|
|
1815
|
+
properties.subject = { minLength: 1, type: "string" };
|
|
1816
|
+
required.push("subject");
|
|
1817
|
+
}
|
|
1818
|
+
return {
|
|
1819
|
+
additionalProperties: false,
|
|
1820
|
+
properties,
|
|
1821
|
+
required,
|
|
1822
|
+
type: "object",
|
|
1823
|
+
} as Schema;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
/** The measure half of an aggregate's identity: summed field, or count(+window). */
|
|
1827
|
+
function aggregateMeasureKey(aggregate: UdlAggregate): string {
|
|
1828
|
+
return "childField" in aggregate
|
|
1829
|
+
? aggregate.childField
|
|
1830
|
+
: `count${aggregate.window ? `[${aggregate.window.field} per ${aggregate.window.days}d]` : ""}`;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
function isMoneySchema(schema: Readonly<Record<string, unknown>>): boolean {
|
|
1834
|
+
return (
|
|
1835
|
+
schema.pattern === positiveMoneyPattern ||
|
|
1836
|
+
schema.pattern === nonNegativeMoneyPattern
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/**
|
|
1841
|
+
* One budget's worth of reference-shape classification: the memo of the answers
|
|
1842
|
+
* already bought and what is left of the probe budget. What is guaranteed is
|
|
1843
|
+
* that separate opens are independent — each {@link openReferenceShapeBudget}
|
|
1844
|
+
* call returns its own memo and its own counter, and nothing resets either
|
|
1845
|
+
* afterwards. Sharing is the caller's call, and both choices are made today:
|
|
1846
|
+
* `validateUdl` opens one budget for its semantic pass, while the engine's
|
|
1847
|
+
* `checkComposerDocument` deliberately runs its noun-composition and
|
|
1848
|
+
* gate-deadlock passes on a single shared budget.
|
|
1849
|
+
*/
|
|
1850
|
+
export interface ReferenceShapeBudget {
|
|
1851
|
+
/**
|
|
1852
|
+
* Does this field schema identify exactly one noun family, by accepting that
|
|
1853
|
+
* family's scoped id and refusing a foreign one? Answering compiles two JSON
|
|
1854
|
+
* Schema validators, and the question is asked once per declared noun per
|
|
1855
|
+
* reference gate — a product a document controls both factors of. The answer
|
|
1856
|
+
* is pure in (schema, prefix), so each pair is paid for once per budget.
|
|
1857
|
+
*
|
|
1858
|
+
* Once {@link UDL_LIMITS.maxSchemaProbes} answers have been bought this
|
|
1859
|
+
* budget stops buying: {@link exhausted} is true from that moment on, and a
|
|
1860
|
+
* pair not already in the memo answers "no" without being asked (memoized
|
|
1861
|
+
* pairs go on answering truthfully). Nothing in this type forces
|
|
1862
|
+
* a caller to notice that: reading {@link exhausted} and refusing instead of
|
|
1863
|
+
* trusting a dropped "no" is a convention each consumer keeps by hand. The
|
|
1864
|
+
* two that exist keep it — `validateUdl` below, and the engine's
|
|
1865
|
+
* `checkComposerDocument` — and a third would have to be written to.
|
|
1866
|
+
*/
|
|
1867
|
+
accepts(schema: Readonly<Record<string, unknown>>, prefix: string): boolean;
|
|
1868
|
+
/**
|
|
1869
|
+
* True once the probe budget is spent — which is the moment the last answer
|
|
1870
|
+
* is bought, before any answer has been dropped. A consumer that refuses
|
|
1871
|
+
* here refuses a document that fit the budget exactly.
|
|
1872
|
+
*/
|
|
1873
|
+
readonly exhausted: boolean;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* One sample id per prefix, built here and nowhere else. The probe below asks
|
|
1878
|
+
* a schema two questions and the answers are only comparable while both
|
|
1879
|
+
* samples differ in the prefix alone, so the environment segment and the body
|
|
1880
|
+
* are written once. The value is disposable: no document ever carries it, and
|
|
1881
|
+
* a host's real id grammar stays the host's business.
|
|
1882
|
+
*/
|
|
1883
|
+
const probeIdFor = (prefix: string): string =>
|
|
1884
|
+
`${prefix}_sandbox_0123456789abcdef`;
|
|
1885
|
+
|
|
1886
|
+
/** A prefix no host mints, so a schema that accepts it accepts anything. */
|
|
1887
|
+
const UNCLAIMED_PREFIX = "zzzz";
|
|
1888
|
+
|
|
1889
|
+
export function openReferenceShapeBudget(): ReferenceShapeBudget {
|
|
1890
|
+
const answers = new WeakMap<object, Map<string, boolean>>();
|
|
1891
|
+
let probes = 0;
|
|
1892
|
+
return {
|
|
1893
|
+
accepts(schema, prefix) {
|
|
1894
|
+
const seen = answers.get(schema);
|
|
1895
|
+
const cached = seen?.get(prefix);
|
|
1896
|
+
if (cached !== undefined) return cached;
|
|
1897
|
+
if (probes >= UDL_LIMITS.maxSchemaProbes) return false;
|
|
1898
|
+
probes += 1;
|
|
1899
|
+
|
|
1900
|
+
const answer =
|
|
1901
|
+
validateUdlSchemaValue(schema, probeIdFor(prefix)).errors.length ===
|
|
1902
|
+
0 &&
|
|
1903
|
+
validateUdlSchemaValue(schema, probeIdFor(UNCLAIMED_PREFIX)).errors
|
|
1904
|
+
.length > 0;
|
|
1905
|
+
if (seen) seen.set(prefix, answer);
|
|
1906
|
+
else answers.set(schema, new Map([[prefix, answer]]));
|
|
1907
|
+
return answer;
|
|
1908
|
+
},
|
|
1909
|
+
get exhausted() {
|
|
1910
|
+
return probes >= UDL_LIMITS.maxSchemaProbes;
|
|
1911
|
+
},
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const sealedFormatValidators: Readonly<
|
|
1916
|
+
Record<string, (value: string) => boolean>
|
|
1917
|
+
> = {
|
|
1918
|
+
"hyperscale-date": (value) => z.iso.date().safeParse(value).success,
|
|
1919
|
+
"hyperscale-date-time": (value) => z.iso.datetime().safeParse(value).success,
|
|
1920
|
+
"hyperscale-email": (value) => z.email().safeParse(value).success,
|
|
1921
|
+
"hyperscale-uri": (value) => z.url().safeParse(value).success,
|
|
1922
|
+
};
|
|
1923
|
+
|
|
1924
|
+
function sealedFormatErrors(
|
|
1925
|
+
instance: unknown,
|
|
1926
|
+
schema: Schema,
|
|
1927
|
+
instanceLocation = "",
|
|
1928
|
+
schemaLocation = "",
|
|
1929
|
+
): readonly OutputUnit[] {
|
|
1930
|
+
const errors: OutputUnit[] = [];
|
|
1931
|
+
const format =
|
|
1932
|
+
typeof schema.format === "string"
|
|
1933
|
+
? sealedFormatValidators[schema.format]
|
|
1934
|
+
: undefined;
|
|
1935
|
+
if (format && typeof instance === "string" && !format(instance)) {
|
|
1936
|
+
errors.push({
|
|
1937
|
+
error: `String does not match format "${schema.format}".`,
|
|
1938
|
+
instanceLocation,
|
|
1939
|
+
keyword: "format",
|
|
1940
|
+
keywordLocation: `${schemaLocation}/format`,
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
if (
|
|
1944
|
+
schema.properties &&
|
|
1945
|
+
instance !== null &&
|
|
1946
|
+
typeof instance === "object" &&
|
|
1947
|
+
!Array.isArray(instance)
|
|
1948
|
+
) {
|
|
1949
|
+
for (const [key, childSchema] of Object.entries(schema.properties)) {
|
|
1950
|
+
if (
|
|
1951
|
+
childSchema === false ||
|
|
1952
|
+
childSchema === true ||
|
|
1953
|
+
!Object.hasOwn(instance, key)
|
|
1954
|
+
) {
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
errors.push(
|
|
1958
|
+
...sealedFormatErrors(
|
|
1959
|
+
(instance as Readonly<Record<string, unknown>>)[key],
|
|
1960
|
+
childSchema,
|
|
1961
|
+
`${instanceLocation}/${jsonPointerSegment(key)}`,
|
|
1962
|
+
`${schemaLocation}/properties/${jsonPointerSegment(key)}`,
|
|
1963
|
+
),
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
if (
|
|
1968
|
+
schema.additionalProperties &&
|
|
1969
|
+
typeof schema.additionalProperties === "object" &&
|
|
1970
|
+
!Array.isArray(schema.additionalProperties) &&
|
|
1971
|
+
instance !== null &&
|
|
1972
|
+
typeof instance === "object" &&
|
|
1973
|
+
!Array.isArray(instance)
|
|
1974
|
+
) {
|
|
1975
|
+
const declared = new Set(Object.keys(schema.properties ?? {}));
|
|
1976
|
+
for (const [key, value] of Object.entries(instance)) {
|
|
1977
|
+
if (declared.has(key)) continue;
|
|
1978
|
+
errors.push(
|
|
1979
|
+
...sealedFormatErrors(
|
|
1980
|
+
value,
|
|
1981
|
+
schema.additionalProperties,
|
|
1982
|
+
`${instanceLocation}/${jsonPointerSegment(key)}`,
|
|
1983
|
+
`${schemaLocation}/additionalProperties`,
|
|
1984
|
+
),
|
|
1985
|
+
);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
const itemSchema = schema.items;
|
|
1989
|
+
if (
|
|
1990
|
+
itemSchema !== undefined &&
|
|
1991
|
+
typeof itemSchema === "object" &&
|
|
1992
|
+
!Array.isArray(itemSchema) &&
|
|
1993
|
+
Array.isArray(instance)
|
|
1994
|
+
) {
|
|
1995
|
+
instance.forEach((value, index) => {
|
|
1996
|
+
errors.push(
|
|
1997
|
+
...sealedFormatErrors(
|
|
1998
|
+
value,
|
|
1999
|
+
itemSchema,
|
|
2000
|
+
`${instanceLocation}/${index}`,
|
|
2001
|
+
`${schemaLocation}/items`,
|
|
2002
|
+
),
|
|
2003
|
+
);
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
return errors;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function isDateTimeFormat(format: unknown): boolean {
|
|
2010
|
+
return format === sealedDateTimeFormat;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function jsonPointerSegment(value: string): string {
|
|
2014
|
+
return value.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
function recordValue(value: unknown): Readonly<Record<string, unknown>> {
|
|
2018
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
2019
|
+
? (value as Readonly<Record<string, unknown>>)
|
|
2020
|
+
: {};
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
function addDuplicateIssues(
|
|
2024
|
+
values: readonly string[],
|
|
2025
|
+
path: readonly PropertyKey[],
|
|
2026
|
+
label: string,
|
|
2027
|
+
add: AddIssue,
|
|
2028
|
+
): void {
|
|
2029
|
+
const firstIndex = new Map<string, number>();
|
|
2030
|
+
values.forEach((value, index) => {
|
|
2031
|
+
const previous = firstIndex.get(value);
|
|
2032
|
+
if (previous === undefined) firstIndex.set(value, index);
|
|
2033
|
+
else
|
|
2034
|
+
add(
|
|
2035
|
+
[...path, index],
|
|
2036
|
+
`duplicate ${label} ${value}; first declared at index ${previous}`,
|
|
2037
|
+
);
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function jsonPath(path: readonly PropertyKey[]): string {
|
|
2042
|
+
let result = "$";
|
|
2043
|
+
for (const segment of path) {
|
|
2044
|
+
if (typeof segment === "number") result += `[${segment}]`;
|
|
2045
|
+
else if (
|
|
2046
|
+
typeof segment === "string" &&
|
|
2047
|
+
/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment)
|
|
2048
|
+
) {
|
|
2049
|
+
result += `.${segment}`;
|
|
2050
|
+
} else result += `[${JSON.stringify(String(segment))}]`;
|
|
2051
|
+
}
|
|
2052
|
+
return result;
|
|
2053
|
+
}
|