@githolon/dsl 0.38.0 → 0.40.0
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/package.json +1 -1
- package/src/codegen_dart.ts +250 -24
- package/src/codegen_proof.ts +7 -0
- package/src/codegen_provider_dart.ts +16 -0
- package/src/codegen_ts.ts +36 -2
- package/src/fields.ts +20 -1
- package/src/index.ts +7 -0
- package/src/shape_compat.ts +8 -0
- package/src/value_object.ts +331 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
package/src/codegen_dart.ts
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* generated, so they stay reproducible from the domain definitions.
|
|
30
30
|
*/
|
|
31
31
|
import { z } from "zod";
|
|
32
|
+
import { voMetaOf, collectPayloadValueObjectDescriptors, idOfMetaOf, NOMOS_UNIT_BRAND } from "./value_object.js";
|
|
32
33
|
import type { AggregateHandle } from "./aggregate.js";
|
|
33
34
|
import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
|
|
34
35
|
import type { RelationTypeDecl } from "./relation_schema.js";
|
|
@@ -60,7 +61,10 @@ export type ToJsonWrap =
|
|
|
60
61
|
| "scalar" // String/int/bool — emit the field directly.
|
|
61
62
|
| "enum" // generated enum — emit `.wire`.
|
|
62
63
|
| "strList" // List<String> — emit directly.
|
|
63
|
-
| "json"
|
|
64
|
+
| "json" // nested object — emit the field's `Map<String, Object?>` directly.
|
|
65
|
+
| "vo" // a declared value object — emit `.toJson()` (the generated VO class).
|
|
66
|
+
| "voList" // a list of value objects — emit `.map((e) => e.toJson()).toList()`.
|
|
67
|
+
| "wrapped"; // a typed-id class or unit extension type — emit `.value` (the wire scalar).
|
|
64
68
|
|
|
65
69
|
export interface PayloadFieldSpec {
|
|
66
70
|
name: string;
|
|
@@ -236,10 +240,25 @@ function introspectField(
|
|
|
236
240
|
zt: z.ZodTypeAny,
|
|
237
241
|
enumDartTypes: Map<string, string>,
|
|
238
242
|
): Omit<PayloadFieldSpec, "optional"> {
|
|
243
|
+
// A DECLARED VO/union keeps its name regardless of its zod kind (a union(...) is a
|
|
244
|
+
// z.discriminatedUnion, not a ZodObject — without this it would fall to the throw lane).
|
|
245
|
+
{
|
|
246
|
+
const vo = voMetaOf(zt);
|
|
247
|
+
if (vo !== undefined) {
|
|
248
|
+
return { name, dartType: vo.name, toJson: "vo" };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
239
251
|
const tn = zodTypeName(zt);
|
|
240
252
|
switch (tn) {
|
|
241
|
-
case "ZodString":
|
|
253
|
+
case "ZodString": {
|
|
254
|
+
// idOf(Agg) (dart_codegen_parity.md P2): wire-identical to z.string(), but the Dart
|
|
255
|
+
// field is the aggregate's TYPED id class — cross-aggregate id mixups fail to compile.
|
|
256
|
+
const idAgg = idOfMetaOf(zt);
|
|
257
|
+
if (idAgg !== undefined) {
|
|
258
|
+
return { name, dartType: typedIdClassForAggregateType(idAgg), toJson: "wrapped" };
|
|
259
|
+
}
|
|
242
260
|
return { name, dartType: "String", toJson: "scalar" };
|
|
261
|
+
}
|
|
243
262
|
case "ZodNumber":
|
|
244
263
|
// A `z.number()` is a FRACTIONAL double in JS (e.g. catalogues `createListing`
|
|
245
264
|
// scaleX/scaleY = 0.5) UNLESS the schema adds the `.int()` check. Only an
|
|
@@ -247,6 +266,12 @@ function introspectField(
|
|
|
247
266
|
// emits `double` so a fractional value (0.5/0.75) is NOT truncated to 0 at the
|
|
248
267
|
// call site. Detect the `.int()` check on the Zod number's `_def.checks`
|
|
249
268
|
// (`{kind:"int"}`), exactly the runtime constraint the engine's Zod validates.
|
|
269
|
+
{
|
|
270
|
+
const unitName = (zt as unknown as Record<symbol, unknown>)[NOMOS_UNIT_BRAND];
|
|
271
|
+
if (typeof unitName === "string") {
|
|
272
|
+
return { name, dartType: unitName, toJson: "wrapped" };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
250
275
|
return {
|
|
251
276
|
name,
|
|
252
277
|
dartType: zodNumberIsInt(zt) ? "int" : "double",
|
|
@@ -262,19 +287,31 @@ function introspectField(
|
|
|
262
287
|
}
|
|
263
288
|
case "ZodArray": {
|
|
264
289
|
const elem = zodArrayElement(zt);
|
|
265
|
-
// A `string[]` is a typed `List<String>`.
|
|
266
|
-
//
|
|
267
|
-
// JSON DATA to the engine, modelled as `List<Object
|
|
290
|
+
// A `string[]` is a typed `List<String>`. A list of DECLARED VALUE OBJECTS is typed
|
|
291
|
+
// with the generated VO class (vo_authoring_end_to_end.md slice B). ANY other element
|
|
292
|
+
// type is arbitrary JSON DATA to the engine, modelled as `List<Object?>`.
|
|
268
293
|
if (zodTypeName(elem) === "ZodString") {
|
|
269
294
|
return { name, dartType: "List<String>", toJson: "strList" };
|
|
270
295
|
}
|
|
296
|
+
const elemVo = voMetaOf(elem);
|
|
297
|
+
if (elemVo !== undefined) {
|
|
298
|
+
return { name, dartType: `List<${elemVo.name}>`, toJson: "voList" };
|
|
299
|
+
}
|
|
271
300
|
return { name, dartType: "List<Object?>", toJson: "json" };
|
|
272
301
|
}
|
|
273
|
-
case "ZodObject":
|
|
302
|
+
case "ZodObject": {
|
|
303
|
+
// A DECLARED VALUE OBJECT keeps its name: the payload field is the generated VO class
|
|
304
|
+
// (typed author path — the same class the read model decodes to), serialised via its
|
|
305
|
+
// own `toJson()`. An anonymous `z.object(...)` stays the raw-map lane below.
|
|
306
|
+
const vo = voMetaOf(zt);
|
|
307
|
+
if (vo !== undefined) {
|
|
308
|
+
return { name, dartType: vo.name, toJson: "vo" };
|
|
309
|
+
}
|
|
274
310
|
// Nested object payload (e.g. `target: z.object({targetType, targetId})`). The
|
|
275
311
|
// engine receives it as DATA verbatim, so we model it as an arbitrary JSON map
|
|
276
312
|
// — the call site builds the literal, and `toPayloadJson()` ships it through.
|
|
277
313
|
return { name, dartType: "Map<String, Object?>", toJson: "json" };
|
|
314
|
+
}
|
|
278
315
|
case "ZodRecord": {
|
|
279
316
|
const valueType = zodRecordValueType(zt);
|
|
280
317
|
const dartType =
|
|
@@ -1246,16 +1283,28 @@ function emitVoEnum(voName: string, fieldName: string, values: readonly string[]
|
|
|
1246
1283
|
|
|
1247
1284
|
/** Every (fieldName, values) enum pair a VO descriptor carries (arrays recursed —
|
|
1248
1285
|
* an array-of-enum field's element enum is named by the FIELD). */
|
|
1249
|
-
function voEnumFields(d: ValueObjectDescriptor): { fieldName: string; values: readonly string[] }[] {
|
|
1250
|
-
const out: { fieldName: string; values: readonly string[] }[] = [];
|
|
1251
|
-
const walk = (fieldName: string, f: ValueObjectFieldDescriptor): void => {
|
|
1252
|
-
if (f.type === "enum") out.push({ fieldName, values: f.values });
|
|
1253
|
-
else if (f.type === "array") walk(fieldName, f.items);
|
|
1286
|
+
function voEnumFields(d: ValueObjectDescriptor): { fieldName: string; values: readonly string[]; owner?: string }[] {
|
|
1287
|
+
const out: { fieldName: string; values: readonly string[]; owner?: string }[] = [];
|
|
1288
|
+
const walk = (fieldName: string, f: ValueObjectFieldDescriptor, owner?: string): void => {
|
|
1289
|
+
if (f.type === "enum") out.push({ fieldName, values: f.values, ...(owner !== undefined ? { owner } : {}) });
|
|
1290
|
+
else if (f.type === "array") walk(fieldName, f.items, owner);
|
|
1254
1291
|
};
|
|
1255
1292
|
for (const [fieldName, f] of Object.entries(d.fields ?? {})) walk(fieldName, f);
|
|
1293
|
+
// union arms: enums are owned by the ARM CLASS (`<Union><ArmCap>`) so two arms' same-named
|
|
1294
|
+
// enum fields with different value sets never collide.
|
|
1295
|
+
for (const [key, arm] of Object.entries(d.union ?? {})) {
|
|
1296
|
+
if ("fields" in arm) {
|
|
1297
|
+
for (const [fieldName, f] of Object.entries(arm.fields)) walk(fieldName, f, unionArmClassName(d.name, key));
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1256
1300
|
return out;
|
|
1257
1301
|
}
|
|
1258
1302
|
|
|
1303
|
+
/** The generated arm class name of one union arm (`Shape` + `circle` → `ShapeCircle`). */
|
|
1304
|
+
function unionArmClassName(unionName: string, armKey: string): string {
|
|
1305
|
+
return `${unionName}${armKey[0]!.toUpperCase()}${armKey.slice(1)}`;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1259
1308
|
/** The Dart type of one VO descriptor field. `voName`/`fieldName` name the field's
|
|
1260
1309
|
* generated enum type (`<VoName><FieldName>Enum`) — threaded through arrays. */
|
|
1261
1310
|
function dartTypeOfVoField(f: ValueObjectFieldDescriptor, voName: string, fieldName: string): string {
|
|
@@ -1263,12 +1312,16 @@ function dartTypeOfVoField(f: ValueObjectFieldDescriptor, voName: string, fieldN
|
|
|
1263
1312
|
case "string":
|
|
1264
1313
|
return "String";
|
|
1265
1314
|
case "number":
|
|
1266
|
-
|
|
1315
|
+
// a UNIT-tagged number is the generated zero-cost extension type; `.int()` is Dart int.
|
|
1316
|
+
if (f.unit !== undefined) return f.unit;
|
|
1317
|
+
return f.int === true ? "int" : "double";
|
|
1267
1318
|
case "boolean":
|
|
1268
1319
|
return "bool";
|
|
1269
1320
|
case "enum":
|
|
1270
1321
|
// A REAL generated Dart enum (`emitVoEnum`), named by the owning VO field.
|
|
1271
1322
|
return voEnumName(voName, fieldName);
|
|
1323
|
+
case "literal":
|
|
1324
|
+
return "String"; // surfaced as a constant getter, not a ctor param
|
|
1272
1325
|
case "array":
|
|
1273
1326
|
return `List<${dartTypeOfVoField(f.items, voName, fieldName)}>`;
|
|
1274
1327
|
case "object":
|
|
@@ -1293,7 +1346,10 @@ function voFromExpr(
|
|
|
1293
1346
|
// TOLERANT typed decode — an unknown wire value becomes the sentinel case.
|
|
1294
1347
|
return `${voEnumName(voName, fieldName)}.fromWire(${expr} as String)`;
|
|
1295
1348
|
case "number":
|
|
1296
|
-
return
|
|
1349
|
+
if (f.unit !== undefined) return `${f.unit}((${expr} as num).toDouble())`;
|
|
1350
|
+
return f.int === true ? `(${expr} as num).toInt()` : `(${expr} as num).toDouble()`;
|
|
1351
|
+
case "literal":
|
|
1352
|
+
return `${expr} as String`;
|
|
1297
1353
|
case "boolean":
|
|
1298
1354
|
return `${expr} as bool`;
|
|
1299
1355
|
case "array": {
|
|
@@ -1309,9 +1365,11 @@ function voFromExpr(
|
|
|
1309
1365
|
/** A Dart expression serializing the typed member `expr` back to plain json. */
|
|
1310
1366
|
function voToJsonExpr(f: ValueObjectFieldDescriptor, expr: string, depth = 0): string {
|
|
1311
1367
|
switch (f.type) {
|
|
1312
|
-
case "string":
|
|
1313
1368
|
case "number":
|
|
1369
|
+
return f.unit !== undefined ? `${expr}.value` : expr;
|
|
1370
|
+
case "string":
|
|
1314
1371
|
case "boolean":
|
|
1372
|
+
case "literal":
|
|
1315
1373
|
return expr;
|
|
1316
1374
|
case "enum":
|
|
1317
1375
|
return `${expr}.wire`; // the exact wire string the law's zod enum validates
|
|
@@ -1365,7 +1423,121 @@ function emitVoHelpers(): string {
|
|
|
1365
1423
|
* `toJson` (the authored payload shape — optionals OMITTED when null, matching the zod
|
|
1366
1424
|
* `.optional()` contract), and toJson-based value equality. A `geo.*` VO emits the
|
|
1367
1425
|
* fixed RFC 7946 shape (typed `coordinates`; `type` is the constant geometry kind). */
|
|
1426
|
+
/** Emit a `union(...)` as a SEALED Dart hierarchy: the sealed base (decode switch on the
|
|
1427
|
+
* blessed `type` discriminator + abstract `toJson`), one final arm class per arm (anonymous
|
|
1428
|
+
* arms carry their fields; NAMED-VO arms hold a single `value` of the named class), and the
|
|
1429
|
+
* load-bearing `<Name>Unknown` arm: a client compiled under OLD law decodes a NEWER law's arm
|
|
1430
|
+
* into `Unknown` (raw json, round-trips on re-write) instead of crashing — which makes adding
|
|
1431
|
+
* an arm ADDITIVE evolution. Exhaustive `switch` over the sealed base forces call sites to
|
|
1432
|
+
* decide what Unknown means for them (the forward-compat prompt we WANT the type system to give). */
|
|
1433
|
+
function emitUnionClasses(d: ValueObjectDescriptor): string {
|
|
1434
|
+
const arms = Object.entries(d.union ?? {});
|
|
1435
|
+
const base = [
|
|
1436
|
+
`/// Union \`${d.name}\` — a sum type stored as ONE json leaf (wholesale-replace; the`,
|
|
1437
|
+
`/// \`type\` discriminator names the arm). Adding an arm is ADDITIVE (older clients decode`,
|
|
1438
|
+
`/// it as [${d.name}Unknown]); removing/renaming an arm is a retype (#58 dispositions).`,
|
|
1439
|
+
`sealed class ${d.name} {`,
|
|
1440
|
+
` const ${d.name}();`,
|
|
1441
|
+
` Map<String, Object?> toJson();`,
|
|
1442
|
+
` factory ${d.name}.fromProjected(Map<String, Object?> m) => switch (m['type']) {`,
|
|
1443
|
+
...arms.map(([key]) => ` '${key}' => ${unionArmClassName(d.name, key)}.fromProjected(m),`),
|
|
1444
|
+
` _ => ${d.name}Unknown(m),`,
|
|
1445
|
+
` };`,
|
|
1446
|
+
`}`,
|
|
1447
|
+
].join("\n");
|
|
1448
|
+
|
|
1449
|
+
const armClasses = arms.map(([key, arm]) => {
|
|
1450
|
+
const cls = unionArmClassName(d.name, key);
|
|
1451
|
+
if ("ref" in arm) {
|
|
1452
|
+
return [
|
|
1453
|
+
`/// The \`${key}\` arm of [${d.name}] — holds the named value object [${arm.ref}]`,
|
|
1454
|
+
`/// (nested under \`value\` on the wire; the named VO stays the one home of its shape).`,
|
|
1455
|
+
`final class ${cls} extends ${d.name} {`,
|
|
1456
|
+
` final ${arm.ref} value;`,
|
|
1457
|
+
` const ${cls}(this.value);`,
|
|
1458
|
+
` factory ${cls}.fromProjected(Map<String, Object?> m) =>`,
|
|
1459
|
+
` ${cls}(${arm.ref}.fromProjected((m['value']! as Map).cast<String, Object?>()));`,
|
|
1460
|
+
` @override`,
|
|
1461
|
+
` Map<String, Object?> toJson() => <String, Object?>{'type': '${key}', 'value': value.toJson()};`,
|
|
1462
|
+
` @override`,
|
|
1463
|
+
` bool operator ==(Object other) => other is ${cls} && _voJsonEq(other.toJson(), toJson());`,
|
|
1464
|
+
` @override`,
|
|
1465
|
+
` int get hashCode => _voJsonHash(toJson());`,
|
|
1466
|
+
`}`,
|
|
1467
|
+
].join("\n");
|
|
1468
|
+
}
|
|
1469
|
+
const armFields = Object.entries(arm.fields).map(([wireName, f]) => ({
|
|
1470
|
+
wireName,
|
|
1471
|
+
member: dartMemberName(wireName),
|
|
1472
|
+
f,
|
|
1473
|
+
optional: f.optional === true,
|
|
1474
|
+
}));
|
|
1475
|
+
const armLiterals = armFields.filter((x) => x.f.type === "literal");
|
|
1476
|
+
const fields = armFields.filter((x) => x.f.type !== "literal");
|
|
1477
|
+
const literalGetters = armLiterals.map((x) => ` String get ${x.member} => '${(x.f as { value: string }).value}';`);
|
|
1478
|
+
const literalToLines = armLiterals.map((x) => ` '${x.wireName}': '${(x.f as { value: string }).value}',`);
|
|
1479
|
+
const decls = [
|
|
1480
|
+
...armFields.filter((x) => x.f.type !== "literal").map((x) => ` final ${dartTypeOfVoField(x.f, cls, x.wireName)}${x.optional ? "?" : ""} ${x.member};`),
|
|
1481
|
+
...literalGetters,
|
|
1482
|
+
];
|
|
1483
|
+
const ctor = fields.length === 0
|
|
1484
|
+
? [` const ${cls}();`]
|
|
1485
|
+
: [` const ${cls}({`, ...fields.map((x) => (x.optional ? ` this.${x.member},` : ` required this.${x.member},`)), ` });`];
|
|
1486
|
+
const fromLines = fields.map((x) =>
|
|
1487
|
+
x.optional
|
|
1488
|
+
? ` ${x.member}: m['${x.wireName}'] == null ? null : ${voFromExpr(x.f, `m['${x.wireName}']!`, cls, x.wireName)},`
|
|
1489
|
+
: ` ${x.member}: ${voFromExpr(x.f, `m['${x.wireName}']!`, cls, x.wireName)},`,
|
|
1490
|
+
);
|
|
1491
|
+
const toLines = fields.map((x) =>
|
|
1492
|
+
x.optional
|
|
1493
|
+
? ` if (${x.member} != null) '${x.wireName}': ${voToJsonExpr(x.f, `${x.member}!`)},`
|
|
1494
|
+
: ` '${x.wireName}': ${voToJsonExpr(x.f, x.member)},`,
|
|
1495
|
+
);
|
|
1496
|
+
return [
|
|
1497
|
+
`/// The \`${key}\` arm of [${d.name}].`,
|
|
1498
|
+
`final class ${cls} extends ${d.name} {`,
|
|
1499
|
+
...decls,
|
|
1500
|
+
...ctor,
|
|
1501
|
+
` factory ${cls}.fromProjected(Map<String, Object?> m) => ${cls}(`,
|
|
1502
|
+
...fromLines,
|
|
1503
|
+
` );`,
|
|
1504
|
+
` @override`,
|
|
1505
|
+
` Map<String, Object?> toJson() => <String, Object?>{`,
|
|
1506
|
+
` 'type': '${key}',`,
|
|
1507
|
+
...literalToLines,
|
|
1508
|
+
...toLines,
|
|
1509
|
+
` };`,
|
|
1510
|
+
` @override`,
|
|
1511
|
+
` bool operator ==(Object other) => other is ${cls} && _voJsonEq(other.toJson(), toJson());`,
|
|
1512
|
+
` @override`,
|
|
1513
|
+
` int get hashCode => _voJsonHash(toJson());`,
|
|
1514
|
+
`}`,
|
|
1515
|
+
].join("\n");
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
const unknown = [
|
|
1519
|
+
`/// FORWARD-COMPAT arm of [${d.name}]: a wire value whose \`type\` this client's law`,
|
|
1520
|
+
`/// version does not know (a NEWER law added an arm). Carries the raw json VERBATIM —`,
|
|
1521
|
+
`/// re-writing it round-trips unchanged. Handle it explicitly in every switch.`,
|
|
1522
|
+
`final class ${d.name}Unknown extends ${d.name} {`,
|
|
1523
|
+
` final Map<String, Object?> raw;`,
|
|
1524
|
+
` const ${d.name}Unknown(this.raw);`,
|
|
1525
|
+
` /// The unrecognised discriminator value ('' when absent/malformed).`,
|
|
1526
|
+
` String get type => (raw['type'] ?? '').toString();`,
|
|
1527
|
+
` @override`,
|
|
1528
|
+
` Map<String, Object?> toJson() => raw;`,
|
|
1529
|
+
` @override`,
|
|
1530
|
+
` bool operator ==(Object other) => other is ${d.name}Unknown && _voJsonEq(other.raw, raw);`,
|
|
1531
|
+
` @override`,
|
|
1532
|
+
` int get hashCode => _voJsonHash(raw);`,
|
|
1533
|
+
`}`,
|
|
1534
|
+
].join("\n");
|
|
1535
|
+
|
|
1536
|
+
return [base, ...armClasses, unknown].join("\n\n");
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1368
1539
|
function emitValueObjectClass(d: ValueObjectDescriptor): string {
|
|
1540
|
+
if (d.union !== undefined) return emitUnionClasses(d);
|
|
1369
1541
|
if (d.geometry !== undefined) {
|
|
1370
1542
|
const coordsType = geometryCoordsDart[d.geometry]!;
|
|
1371
1543
|
const coordsFrom: Record<string, string> = {
|
|
@@ -1401,12 +1573,21 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
|
|
|
1401
1573
|
`}`,
|
|
1402
1574
|
].join("\n");
|
|
1403
1575
|
}
|
|
1404
|
-
const
|
|
1576
|
+
const allFields = Object.entries(d.fields ?? {}).map(([wireName, f]) => ({
|
|
1405
1577
|
wireName,
|
|
1406
1578
|
member: dartMemberName(wireName),
|
|
1407
1579
|
f,
|
|
1408
1580
|
optional: f.optional === true,
|
|
1409
1581
|
}));
|
|
1582
|
+
// `z.literal(...)` members are CONSTANTS: a getter (never a ctor param), always serialized.
|
|
1583
|
+
const literals = allFields.filter((x) => x.f.type === "literal");
|
|
1584
|
+
const fields = allFields.filter((x) => x.f.type !== "literal");
|
|
1585
|
+
const literalGetters = literals.map(
|
|
1586
|
+
(x) => ` String get ${x.member} => '${(x.f as { value: string }).value}';`,
|
|
1587
|
+
);
|
|
1588
|
+
const literalToLines = literals.map(
|
|
1589
|
+
(x) => ` '${x.wireName}': '${(x.f as { value: string }).value}',`,
|
|
1590
|
+
);
|
|
1410
1591
|
const decls = fields.map(
|
|
1411
1592
|
(x) => ` final ${dartTypeOfVoField(x.f, d.name, x.wireName)}${x.optional ? "?" : ""} ${x.member};`,
|
|
1412
1593
|
);
|
|
@@ -1428,10 +1609,9 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
|
|
|
1428
1609
|
`/// zod \`.optional()\` contract — absent, never JSON null).`,
|
|
1429
1610
|
`class ${d.name} {`,
|
|
1430
1611
|
...decls,
|
|
1612
|
+
...literalGetters,
|
|
1431
1613
|
``,
|
|
1432
|
-
` const ${d.name}({`,
|
|
1433
|
-
...ctor,
|
|
1434
|
-
` });`,
|
|
1614
|
+
...(fields.length === 0 ? [` const ${d.name}();`] : [` const ${d.name}({`, ...ctor, ` });`]),
|
|
1435
1615
|
``,
|
|
1436
1616
|
` /// Read from the projection's decoded json object.`,
|
|
1437
1617
|
` factory ${d.name}.fromProjected(Map<String, Object?> m) {`,
|
|
@@ -1442,6 +1622,7 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
|
|
|
1442
1622
|
``,
|
|
1443
1623
|
` /// The authored payload shape (embed it in a directive payload / \`set()\`).`,
|
|
1444
1624
|
` Map<String, Object?> toJson() => <String, Object?>{`,
|
|
1625
|
+
...literalToLines,
|
|
1445
1626
|
...toLines,
|
|
1446
1627
|
` };`,
|
|
1447
1628
|
``,
|
|
@@ -1908,6 +2089,12 @@ export function toJsonExpr(spec: PayloadFieldSpec, accessor: string): string {
|
|
|
1908
2089
|
return accessor;
|
|
1909
2090
|
case "enum":
|
|
1910
2091
|
return `${accessor}.wire`;
|
|
2092
|
+
case "vo":
|
|
2093
|
+
return `${accessor}.toJson()`;
|
|
2094
|
+
case "voList":
|
|
2095
|
+
return `${accessor}.map((e) => e.toJson()).toList()`;
|
|
2096
|
+
case "wrapped":
|
|
2097
|
+
return `${accessor}.value`;
|
|
1911
2098
|
}
|
|
1912
2099
|
}
|
|
1913
2100
|
|
|
@@ -2942,19 +3129,58 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
|
|
|
2942
3129
|
const skipValueObjectNames = opts.skipValueObjectNames ?? new Set<string>();
|
|
2943
3130
|
// VALUE OBJECTS: one generated class per named VO the module carries (composeDomainModule
|
|
2944
3131
|
// collected + sorted them), minus the package-shared ones (emitted once in the framework file).
|
|
2945
|
-
|
|
3132
|
+
// Payload-only VOs (a VO used in a directive payload but on no aggregate field) still need
|
|
3133
|
+
// their generated class — collected from the branded payload schemas, deduped by name against
|
|
3134
|
+
// the aggregate-derived set. Codegen-only: the IR key stays aggregate-derived (era rule).
|
|
3135
|
+
const aggVoNames = new Set((mod.valueObjects ?? []).map((d) => d.name));
|
|
3136
|
+
const payloadVos = collectPayloadValueObjectDescriptors(mod.directives ?? []).filter((d) => !aggVoNames.has(d.name));
|
|
3137
|
+
const localValueObjects = [...(mod.valueObjects ?? []), ...payloadVos]
|
|
3138
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
3139
|
+
.filter((d) => !skipValueObjectNames.has(d.name));
|
|
2946
3140
|
// Each VO's block = its FIELD ENUMS (real Dart enums, tolerant fromWire) then its class —
|
|
2947
3141
|
// so skipping a shared VO (the framework-file dedup) skips its enums with it.
|
|
2948
|
-
|
|
2949
|
-
|
|
3142
|
+
// UNIT extension types (dart_codegen_parity.md P2): every `unit("X", z.number())` tag seen
|
|
3143
|
+
// in local VO descriptors OR directive payloads gets ONE zero-cost wrapper. Opaque by design —
|
|
3144
|
+
// no generated arithmetic/conversions (a physics library is not a type system).
|
|
3145
|
+
const unitNames = new Set<string>();
|
|
3146
|
+
const walkUnitField = (f: ValueObjectFieldDescriptor): void => {
|
|
3147
|
+
if (f.type === "number" && f.unit !== undefined) unitNames.add(f.unit);
|
|
3148
|
+
else if (f.type === "array") walkUnitField(f.items);
|
|
3149
|
+
};
|
|
3150
|
+
for (const d of localValueObjects) {
|
|
3151
|
+
for (const f of Object.values(d.fields ?? {})) walkUnitField(f);
|
|
3152
|
+
for (const arm of Object.values(d.union ?? {})) {
|
|
3153
|
+
if ("fields" in arm) for (const f of Object.values(arm.fields)) walkUnitField(f);
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
for (const dir of mod.directives ?? []) {
|
|
3157
|
+
const schema = (dir as { payloadSchema?: unknown }).payloadSchema;
|
|
3158
|
+
if (schema && typeof schema === "object" && zodTypeName(schema as z.ZodTypeAny) === "ZodObject") {
|
|
3159
|
+
try {
|
|
3160
|
+
for (const spec of introspectPayload(schema as z.ZodTypeAny, new Map())) {
|
|
3161
|
+
if (spec.toJson === "wrapped" && !spec.dartType.endsWith("Id")) unitNames.add(spec.dartType);
|
|
3162
|
+
}
|
|
3163
|
+
} catch { /* an unsupported payload shape is reported by the payload emitter, not here */ }
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
const unitBlock = [...unitNames].sort().map((u) => [
|
|
3167
|
+
`/// UNIT \`${u}\` — a ZERO-COST wrapper over the wire's plain JSON number (Dart extension`,
|
|
3168
|
+
`/// type): passing a bare double (or another unit) where [${u}] is required is a COMPILE`,
|
|
3169
|
+
`/// error. Opaque tag only — no arithmetic/conversions are generated.`,
|
|
3170
|
+
`extension type const ${u}(double value) {}`,
|
|
3171
|
+
].join("\n")).join("\n\n");
|
|
3172
|
+
const voBlock = [
|
|
3173
|
+
...(unitBlock !== "" ? [unitBlock] : []),
|
|
3174
|
+
...(localValueObjects.length > 0
|
|
2950
3175
|
? [
|
|
2951
3176
|
emitVoHelpers(),
|
|
2952
3177
|
...localValueObjects.flatMap((d) => [
|
|
2953
|
-
...voEnumFields(d).map((e) => emitVoEnum(d.name, e.fieldName, e.values)),
|
|
3178
|
+
...voEnumFields(d).map((e) => emitVoEnum(e.owner ?? d.name, e.fieldName, e.values)),
|
|
2954
3179
|
emitValueObjectClass(d),
|
|
2955
3180
|
]),
|
|
2956
|
-
]
|
|
2957
|
-
:
|
|
3181
|
+
]
|
|
3182
|
+
: []),
|
|
3183
|
+
].join("\n\n");
|
|
2958
3184
|
// The symbols whose Dart surface this file EMITS (framework-shared ones live in the shared file).
|
|
2959
3185
|
const localAggregates = mod.aggregates.filter((a) => !skipAggregateIds.has(a.id));
|
|
2960
3186
|
const localDirectives = mod.directives.filter((d) => !skipDirectiveIds.has(d.id));
|
package/src/codegen_proof.ts
CHANGED
|
@@ -44,6 +44,7 @@ import type { FieldOp } from "./ops.js";
|
|
|
44
44
|
import type { RelationTypeDecl, Rewrite } from "./relation_schema.js";
|
|
45
45
|
import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
|
|
46
46
|
import { finishCount } from "./count.js";
|
|
47
|
+
import { voMetaOf, voSample } from "./value_object.js";
|
|
47
48
|
import { mintedCreateField } from "./workspace_routing.js";
|
|
48
49
|
import { deterministicPorts } from "./ctx.js";
|
|
49
50
|
import { __setDispatchDomain } from "./framework/relations.js";
|
|
@@ -98,6 +99,12 @@ function sampleString(zt: z.ZodTypeAny, field: string): string {
|
|
|
98
99
|
|
|
99
100
|
/** One sample value for one zod shape. NO FALLBACK: an unhandled shape throws, naming its remedy. */
|
|
100
101
|
function sampleOfZod(zt: z.ZodTypeAny, field: string, where: string): unknown {
|
|
102
|
+
// VALUE OBJECTS FIRST (vo_authoring_end_to_end.md slice C): a declared VO carries its own
|
|
103
|
+
// lawful sample (the author's validated `example`, the blessed geometry sample, or per-field
|
|
104
|
+
// descriptor synthesis) — the raw-zod walk below cannot see constraints like geo's min-2
|
|
105
|
+
// coordinates and would synthesize a value the law itself refuses.
|
|
106
|
+
const vo = voMetaOf(zt);
|
|
107
|
+
if (vo !== undefined) return voSample(vo);
|
|
101
108
|
switch (zodKind(zt)) {
|
|
102
109
|
case "string":
|
|
103
110
|
return sampleString(zt, field);
|
|
@@ -171,6 +171,22 @@ function obligationFromJsonExpr(s: PayloadFieldSpec): string {
|
|
|
171
171
|
// a list. The Dart type drives the cast.
|
|
172
172
|
return s.optional ? `${raw} as ${s.dartType}?` : `${raw} as ${s.dartType}`;
|
|
173
173
|
}
|
|
174
|
+
case "vo": {
|
|
175
|
+
// A declared value object — decode through the generated class (typed both ways).
|
|
176
|
+
return s.optional
|
|
177
|
+
? `${raw} == null ? null : ${s.dartType}.fromProjected((${raw} as Map).cast<String, Object?>())`
|
|
178
|
+
: `${s.dartType}.fromProjected((${raw} as Map).cast<String, Object?>())`;
|
|
179
|
+
}
|
|
180
|
+
case "voList": {
|
|
181
|
+
const elem = s.dartType.replace(/^List<(.*)>$/, "$1");
|
|
182
|
+
const decode = `(${raw} as List).map((e) => ${elem}.fromProjected((e as Map).cast<String, Object?>())).toList()`;
|
|
183
|
+
return s.optional ? `${raw} == null ? null : ${decode}` : decode;
|
|
184
|
+
}
|
|
185
|
+
case "wrapped": {
|
|
186
|
+
// typed-id class (String wire) or unit extension type (num wire): wrap the raw scalar.
|
|
187
|
+
const wrap = (v: string) => `${s.dartType}${s.dartType.endsWith("Id") ? `.fromRow(${v} as String)` : `((${v} as num).toDouble())`}`;
|
|
188
|
+
return s.optional ? `${raw} == null ? null : ${wrap(raw)}` : wrap(raw);
|
|
189
|
+
}
|
|
174
190
|
}
|
|
175
191
|
}
|
|
176
192
|
|
package/src/codegen_ts.ts
CHANGED
|
@@ -41,6 +41,7 @@ import type { Field, ValueObjectDescriptor, ValueObjectFieldDescriptor } from ".
|
|
|
41
41
|
import type { Directive } from "./directive.js";
|
|
42
42
|
import type { DomainModule } from "./codegen_dart.js";
|
|
43
43
|
import type { QueryDecl } from "./query.js";
|
|
44
|
+
import { voMetaOf, collectPayloadValueObjectDescriptors } from "./value_object.js";
|
|
44
45
|
import { finishCount } from "./count.js";
|
|
45
46
|
import { finishSum } from "./sum.js";
|
|
46
47
|
import type { DerivedDecl } from "./derived.js";
|
|
@@ -122,6 +123,14 @@ const paren = (t: string) => (t.includes("|") || t.includes("&") ? `(${t})` : t)
|
|
|
122
123
|
|
|
123
124
|
/** Recursive zod → TS type text. NO FALLBACK: an unhandled shape throws. */
|
|
124
125
|
function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
|
|
126
|
+
// A DECLARED VALUE OBJECT keeps its NAME (vo_authoring_end_to_end.md slice B): the payload
|
|
127
|
+
// references the same exported interface the read model uses — one name, both directions —
|
|
128
|
+
// instead of an anonymous inline literal type. Checked on the BARE object only: optional/
|
|
129
|
+
// default wrappers must keep flowing through the switch so `| undefined` survives.
|
|
130
|
+
if (zodKind(zt) === "object" || zodKind(zt) === "union") {
|
|
131
|
+
const vo = voMetaOf(zt);
|
|
132
|
+
if (vo !== undefined) return vo.name;
|
|
133
|
+
}
|
|
125
134
|
switch (zodKind(zt)) {
|
|
126
135
|
case "string":
|
|
127
136
|
return "string";
|
|
@@ -282,11 +291,13 @@ function tsTypeOfVoField(f: ValueObjectFieldDescriptor): string {
|
|
|
282
291
|
case "string":
|
|
283
292
|
return "string";
|
|
284
293
|
case "number":
|
|
285
|
-
return "number";
|
|
294
|
+
return "number"; // `.int()`/units are Dart-side nominal products; TS keeps one number
|
|
286
295
|
case "boolean":
|
|
287
296
|
return "boolean";
|
|
288
297
|
case "enum":
|
|
289
298
|
return union(f.values.map((v) => JSON.stringify(v)));
|
|
299
|
+
case "literal":
|
|
300
|
+
return JSON.stringify(f.value);
|
|
290
301
|
case "array":
|
|
291
302
|
return `${paren(tsTypeOfVoField(f.items))}[]`;
|
|
292
303
|
case "object":
|
|
@@ -305,6 +316,25 @@ function emitValueObjectTs(d: ValueObjectDescriptor): string[] {
|
|
|
305
316
|
``,
|
|
306
317
|
];
|
|
307
318
|
}
|
|
319
|
+
if (d.union !== undefined) {
|
|
320
|
+
// A union(...) sum type: the named TS union of its arms. An anonymous arm's fields ride
|
|
321
|
+
// inline beside the `type` discriminator; a NAMED-VO arm nests under `value` (the named
|
|
322
|
+
// VO stays the one nominal home of its shape — dart_codegen_parity.md P1).
|
|
323
|
+
const arms = Object.entries(d.union).map(([key, arm]) => {
|
|
324
|
+
if ("ref" in arm) return `{ type: ${JSON.stringify(key)}; value: ${arm.ref} }`;
|
|
325
|
+
const fields = Object.entries(arm.fields)
|
|
326
|
+
.map(([n, f]) => `${n}${f.optional === true ? "?" : ""}: ${tsTypeOfVoField(f)}`)
|
|
327
|
+
.join("; ");
|
|
328
|
+
return fields.length > 0 ? `{ type: ${JSON.stringify(key)}; ${fields} }` : `{ type: ${JSON.stringify(key)} }`;
|
|
329
|
+
});
|
|
330
|
+
return [
|
|
331
|
+
`/** Union \`${d.name}\` — a sum type stored as ONE json leaf (wholesale-replace; the \`type\``,
|
|
332
|
+
` * discriminator names the arm). Adding an arm is ADDITIVE evolution; removing one is a retype. */`,
|
|
333
|
+
`export type ${d.name} =`,
|
|
334
|
+
...arms.map((a, i) => ` | ${a}${i === arms.length - 1 ? ";" : ""}`),
|
|
335
|
+
``,
|
|
336
|
+
];
|
|
337
|
+
}
|
|
308
338
|
const lines = Object.entries(d.fields ?? {}).map(
|
|
309
339
|
([name, f]) => ` ${name}${f.optional === true ? "?" : ""}: ${tsTypeOfVoField(f)};`,
|
|
310
340
|
);
|
|
@@ -476,7 +506,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
476
506
|
// ── value objects: one interface per named VO, deduped across the package's domains ──
|
|
477
507
|
const voByName = new Map<string, ValueObjectDescriptor>();
|
|
478
508
|
for (const mod of modules) {
|
|
479
|
-
|
|
509
|
+
// aggregate-carried VOs ∪ payload-only VOs (branded schemas) — both need their interface.
|
|
510
|
+
const payloadVos = collectPayloadValueObjectDescriptors(
|
|
511
|
+
((mod as { directives?: readonly { payloadSchema?: unknown }[] }).directives ?? []),
|
|
512
|
+
);
|
|
513
|
+
for (const d of [...((mod as { valueObjects?: readonly ValueObjectDescriptor[] }).valueObjects ?? []), ...payloadVos]) {
|
|
480
514
|
const existing = voByName.get(d.name);
|
|
481
515
|
if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(d)) {
|
|
482
516
|
throw new Error(
|
package/src/fields.ts
CHANGED
|
@@ -89,15 +89,28 @@ export type GeometryKind = "Point" | "LineString" | "Polygon";
|
|
|
89
89
|
*/
|
|
90
90
|
export type ValueObjectFieldDescriptor =
|
|
91
91
|
| { readonly type: "string"; readonly optional?: true }
|
|
92
|
-
|
|
92
|
+
/** `int: true` when the zod carried `.int()` (Dart `int`, TS `number`); `unit` when the field
|
|
93
|
+
* was declared through `unit(...)` — the generated Dart extension-type wrapper's name. */
|
|
94
|
+
| { readonly type: "number"; readonly int?: true; readonly unit?: string; readonly optional?: true }
|
|
93
95
|
| { readonly type: "boolean"; readonly optional?: true }
|
|
94
96
|
| { readonly type: "enum"; readonly values: readonly string[]; readonly optional?: true }
|
|
97
|
+
/** A `z.literal(<string>)` — a CONSTANT discriminator-style member (generated as a getter). */
|
|
98
|
+
| { readonly type: "literal"; readonly value: string; readonly optional?: true }
|
|
95
99
|
| { readonly type: "array"; readonly items: ValueObjectFieldDescriptor; readonly optional?: true }
|
|
96
100
|
/** A nested value object, referenced BY NAME (its own descriptor rides alongside). */
|
|
97
101
|
| { readonly type: "object"; readonly ref: string; readonly optional?: true }
|
|
98
102
|
/** A nested GEO value object (RFC 7946 geometry), referenced by name. */
|
|
99
103
|
| { readonly type: "geometry"; readonly ref: string; readonly geometry: GeometryKind; readonly optional?: true };
|
|
100
104
|
|
|
105
|
+
/**
|
|
106
|
+
* One arm of a `union(...)` value object: either an ANONYMOUS shape (fields ride inline,
|
|
107
|
+
* flattened beside the `type` discriminator on the wire) or a NAMED value object (the arm
|
|
108
|
+
* nests it under `value` on the wire — the named VO stays the one nominal home of its shape).
|
|
109
|
+
*/
|
|
110
|
+
export type UnionArmDescriptor =
|
|
111
|
+
| { readonly fields: Readonly<Record<string, ValueObjectFieldDescriptor>> }
|
|
112
|
+
| { readonly ref: string };
|
|
113
|
+
|
|
101
114
|
/**
|
|
102
115
|
* The canonical descriptor of ONE named value object. A structural VO carries
|
|
103
116
|
* `fields` (keys SORTED — byte-determinism); a `geo.*` VO carries `geometry`
|
|
@@ -107,6 +120,9 @@ export interface ValueObjectDescriptor {
|
|
|
107
120
|
readonly name: string;
|
|
108
121
|
readonly fields?: Readonly<Record<string, ValueObjectFieldDescriptor>>;
|
|
109
122
|
readonly geometry?: GeometryKind;
|
|
123
|
+
/** A `union(...)` sum type: arm key (the wire `type` discriminator value) → arm descriptor.
|
|
124
|
+
* Mutually exclusive with `fields`/`geometry`. Keys SORTED (byte-determinism). */
|
|
125
|
+
readonly union?: Readonly<Record<string, UnionArmDescriptor>>;
|
|
110
126
|
}
|
|
111
127
|
|
|
112
128
|
/**
|
|
@@ -126,6 +142,9 @@ export interface ValueObjectFieldMeta {
|
|
|
126
142
|
readonly dependencies: readonly ValueObjectDescriptor[];
|
|
127
143
|
/** Present iff this VO is a `geo.*` geometry (the spatial-index pairing check). */
|
|
128
144
|
readonly geometry?: GeometryKind;
|
|
145
|
+
/** The author-supplied lawful example (validated against the VO's own zod at declaration).
|
|
146
|
+
* Proof synthesis + docs prefer it over per-field synthesis. */
|
|
147
|
+
readonly example?: unknown;
|
|
129
148
|
}
|
|
130
149
|
|
|
131
150
|
export interface Field<T = unknown, K extends FieldKind = FieldKind> {
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,13 @@ export {
|
|
|
47
47
|
// `geo.*` are the RFC 7946 geometry VOs (pair `geo.point()` with `spatial(...)`).
|
|
48
48
|
export {
|
|
49
49
|
valueObject,
|
|
50
|
+
variant,
|
|
51
|
+
unit,
|
|
52
|
+
idOf,
|
|
53
|
+
idOfMetaOf,
|
|
54
|
+
voMetaOf,
|
|
55
|
+
voSample,
|
|
56
|
+
NOMOS_VO_BRAND,
|
|
50
57
|
geo,
|
|
51
58
|
type ValueObject,
|
|
52
59
|
type ValueObjectValue,
|
package/src/shape_compat.ts
CHANGED
|
@@ -80,6 +80,14 @@ function payloadFieldKind(spec: PayloadFieldSpec): string {
|
|
|
80
80
|
return "set";
|
|
81
81
|
case "json":
|
|
82
82
|
return "json";
|
|
83
|
+
case "wrapped":
|
|
84
|
+
// typed-id (string wire) or unit (number wire) — the interface sees the WIRE kind.
|
|
85
|
+
return spec.dartType.endsWith("Id") ? "string" : "double";
|
|
86
|
+
case "vo":
|
|
87
|
+
case "voList":
|
|
88
|
+
// Interface kinds stay at the base lane for now (slice D of vo_authoring_end_to_end.md
|
|
89
|
+
// upgrades these to a first-class "vo" kind) — a VO IS a json leaf structurally.
|
|
90
|
+
return "json";
|
|
83
91
|
case "scalar":
|
|
84
92
|
default:
|
|
85
93
|
switch (spec.dartType) {
|
package/src/value_object.ts
CHANGED
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
valueObjectField,
|
|
57
57
|
type Field,
|
|
58
58
|
type GeometryKind,
|
|
59
|
+
type UnionArmDescriptor,
|
|
59
60
|
type ValueObjectDescriptor,
|
|
60
61
|
type ValueObjectFieldDescriptor,
|
|
61
62
|
type ValueObjectFieldMeta,
|
|
@@ -186,6 +187,74 @@ function collectDescriptors(vo: ValueObject<unknown>, into: Map<string, ValueObj
|
|
|
186
187
|
}
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
/** `.int()` detection across zod v4's check encodings (mirrors codegen_dart's payload rule). */
|
|
191
|
+
function zodNumberIsInt(zt: z.ZodTypeAny): boolean {
|
|
192
|
+
const def = zodDef(zt) as { checks?: readonly unknown[]; format?: string };
|
|
193
|
+
if (def.format === "safeint" || def.format === "int32" || def.format === "int64") return true;
|
|
194
|
+
for (const c of def.checks ?? []) {
|
|
195
|
+
const chk = c as { kind?: string; def?: { check?: string; format?: string } };
|
|
196
|
+
if (chk.kind === "int") return true;
|
|
197
|
+
if (chk.def?.check === "number_format" && (chk.def.format === "safeint" || chk.def.format === "int32")) return true;
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* THE UNIT BRAND (dart_codegen_parity.md P2) — `unit("Millimetres", z.number())` stamps the
|
|
204
|
+
* schema so the descriptor carries the unit name and the generated Dart wraps the field in a
|
|
205
|
+
* ZERO-COST extension type (`Millimetres`). Compile-time only: the wire value stays a plain
|
|
206
|
+
* JSON number; validation is untouched; domainHash-inert. v1 = opaque tags — NO generated
|
|
207
|
+
* arithmetic or conversions (a physics library is not a type system).
|
|
208
|
+
*/
|
|
209
|
+
export const NOMOS_UNIT_BRAND: unique symbol = Symbol.for("nomos.unit");
|
|
210
|
+
|
|
211
|
+
const UNIT_NAME_RE = /^[A-Z][A-Za-z0-9_]*$/;
|
|
212
|
+
|
|
213
|
+
/** Tag a numeric field with a UNIT: `radiusMm: unit("Millimetres", z.number())`. */
|
|
214
|
+
export function unit<Z extends z.ZodTypeAny>(name: string, inner: Z): Z {
|
|
215
|
+
if (!UNIT_NAME_RE.test(name)) {
|
|
216
|
+
throw new Error(`unit: '${name}' must be a capitalized identifier — it becomes the generated extension type's name.`);
|
|
217
|
+
}
|
|
218
|
+
if (zodKind(inner) !== "number") {
|
|
219
|
+
throw new Error(`unit '${name}': only z.number() fields carry a unit in v1.`);
|
|
220
|
+
}
|
|
221
|
+
Object.defineProperty(inner, NOMOS_UNIT_BRAND, { value: name, enumerable: false });
|
|
222
|
+
return inner;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* THE TYPED-ID BRAND (dart_codegen_parity.md P2) — `idOf(Site)` in a directive payload is
|
|
227
|
+
* wire-identical to `z.string()` but the generated Dart field is the aggregate's TYPED id
|
|
228
|
+
* class (`SiteId`), and the generated TS references the branded alias. Passing another
|
|
229
|
+
* aggregate's id where a SiteId belongs becomes a COMPILE error. Payload-side only — the
|
|
230
|
+
* kernel keeps seeing a string; validation/domainHash untouched.
|
|
231
|
+
*/
|
|
232
|
+
export const NOMOS_IDOF_BRAND: unique symbol = Symbol.for("nomos.idOf");
|
|
233
|
+
|
|
234
|
+
/** A payload field holding an EXISTING aggregate's id, typed: `siteId: idOf(Site)`. */
|
|
235
|
+
export function idOf(aggregate: { id: string } | string): z.ZodType<string> {
|
|
236
|
+
const aggregateType = typeof aggregate === "string" ? aggregate : aggregate.id;
|
|
237
|
+
if (typeof aggregateType !== "string" || aggregateType === "") {
|
|
238
|
+
throw new Error("idOf: pass the aggregate handle (idOf(Site)) or its type name.");
|
|
239
|
+
}
|
|
240
|
+
const schema = z.string().min(1);
|
|
241
|
+
Object.defineProperty(schema, NOMOS_IDOF_BRAND, { value: aggregateType, enumerable: false });
|
|
242
|
+
return schema;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Resolve the `idOf(...)` aggregate type of a payload schema (through optional/default). */
|
|
246
|
+
export function idOfMetaOf(zt: z.ZodTypeAny): string | undefined {
|
|
247
|
+
let cur: z.ZodTypeAny | undefined = zt;
|
|
248
|
+
for (let hops = 0; cur !== undefined && hops < 16; hops++) {
|
|
249
|
+
const branded = (cur as unknown as Record<symbol, unknown>)[NOMOS_IDOF_BRAND];
|
|
250
|
+
if (typeof branded === "string") return branded;
|
|
251
|
+
const kind = zodKind(cur);
|
|
252
|
+
if (kind !== "optional" && kind !== "default" && kind !== "nullable") return undefined;
|
|
253
|
+
cur = zodDef(cur).innerType;
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
189
258
|
/**
|
|
190
259
|
* Describe one CONSTRAINED zod schema as a descriptor field (NO FALLBACK — an
|
|
191
260
|
* out-of-set shape throws with the field's path so the author fixes the declaration,
|
|
@@ -195,12 +264,26 @@ function describeZod(voName: string, path: string, schema: z.ZodTypeAny): ValueO
|
|
|
195
264
|
switch (zodKind(schema)) {
|
|
196
265
|
case "string":
|
|
197
266
|
return { type: "string" };
|
|
198
|
-
case "number":
|
|
199
|
-
|
|
267
|
+
case "number": {
|
|
268
|
+
const unitName = (schema as unknown as Record<symbol, unknown>)[NOMOS_UNIT_BRAND];
|
|
269
|
+
return {
|
|
270
|
+
type: "number",
|
|
271
|
+
...(zodNumberIsInt(schema) ? { int: true as const } : {}),
|
|
272
|
+
...(typeof unitName === "string" ? { unit: unitName } : {}),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
200
275
|
case "boolean":
|
|
201
276
|
return { type: "boolean" };
|
|
202
277
|
case "enum":
|
|
203
278
|
return { type: "enum", values: zodEnumValues(schema) };
|
|
279
|
+
case "literal": {
|
|
280
|
+
const def = zodDef(schema) as { value?: unknown; values?: readonly unknown[] };
|
|
281
|
+
const v = def.value ?? def.values?.[0];
|
|
282
|
+
if (typeof v !== "string") {
|
|
283
|
+
throw new Error(`valueObject '${voName}': field '${path}' — only STRING literals are in the v1 set.`);
|
|
284
|
+
}
|
|
285
|
+
return { type: "literal", value: v };
|
|
286
|
+
}
|
|
204
287
|
case "array": {
|
|
205
288
|
const items = describeZod(voName, `${path}[]`, zodArrayElement(schema));
|
|
206
289
|
if (items.optional === true) {
|
|
@@ -226,12 +309,109 @@ function describeZod(voName: string, path: string, schema: z.ZodTypeAny): ValueO
|
|
|
226
309
|
|
|
227
310
|
const VO_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
228
311
|
|
|
312
|
+
/**
|
|
313
|
+
* THE VO BRAND (slice A of vo_authoring_end_to_end.md) — `valueObject()` stamps its zod schema
|
|
314
|
+
* with the VO's {@link ValueObjectFieldMeta} under this symbol, COMPILE-TIME ONLY: the brand is
|
|
315
|
+
* a non-enumerable property, never serialized, never read by the engine's validation path, so a
|
|
316
|
+
* branded schema's package bytes (and domainHash) are byte-identical to an unbranded one.
|
|
317
|
+
* Every generated-surface introspector (Dart/TS payload gen, interface kinds, proof synthesis)
|
|
318
|
+
* consults {@link voMetaOf} BEFORE its raw zod-kind switch — the VO keeps its NAME on every
|
|
319
|
+
* surface instead of degrading to an anonymous json object.
|
|
320
|
+
*/
|
|
321
|
+
export const NOMOS_VO_BRAND: unique symbol = Symbol.for("nomos.vo");
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Resolve the value-object identity of a zod schema, unwrapping `.optional()` / `.default()`
|
|
325
|
+
* wrappers (the brand lives on the VO's own object schema). `undefined` for anything that is
|
|
326
|
+
* not a declared VO — callers then fall through to their raw-zod lane, so non-VO behaviour is
|
|
327
|
+
* untouched.
|
|
328
|
+
*/
|
|
329
|
+
export function voMetaOf(zt: z.ZodTypeAny): ValueObjectFieldMeta | undefined {
|
|
330
|
+
let cur: z.ZodTypeAny | undefined = zt;
|
|
331
|
+
for (let hops = 0; cur !== undefined && hops < 16; hops++) {
|
|
332
|
+
const branded = (cur as unknown as Record<symbol, unknown>)[NOMOS_VO_BRAND];
|
|
333
|
+
if (branded !== undefined) return branded as ValueObjectFieldMeta;
|
|
334
|
+
const kind = zodKind(cur);
|
|
335
|
+
if (kind !== "optional" && kind !== "default" && kind !== "nullable") return undefined;
|
|
336
|
+
cur = zodDef(cur).innerType;
|
|
337
|
+
}
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The blessed lawful sample per RFC 7946 geometry kind (proof synthesis + docs). */
|
|
342
|
+
const GEOMETRY_SAMPLES: Record<GeometryKind, unknown> = {
|
|
343
|
+
Point: { type: "Point", coordinates: [0, 0] },
|
|
344
|
+
LineString: { type: "LineString", coordinates: [[0, 0], [1, 1]] },
|
|
345
|
+
Polygon: { type: "Polygon", coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]] },
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
function sampleOfDescriptorField(
|
|
349
|
+
f: ValueObjectFieldDescriptor,
|
|
350
|
+
fieldName: string,
|
|
351
|
+
byName: Map<string, ValueObjectDescriptor>,
|
|
352
|
+
): unknown {
|
|
353
|
+
switch (f.type) {
|
|
354
|
+
case "string": return `sample-${fieldName}`;
|
|
355
|
+
case "number": return 1;
|
|
356
|
+
case "literal": return f.value;
|
|
357
|
+
case "boolean": return true;
|
|
358
|
+
case "enum": return f.values[0];
|
|
359
|
+
case "array": return [sampleOfDescriptorField(f.items, fieldName, byName)];
|
|
360
|
+
case "geometry": return GEOMETRY_SAMPLES[f.geometry];
|
|
361
|
+
case "object": {
|
|
362
|
+
const dep = byName.get(f.ref);
|
|
363
|
+
if (dep === undefined) throw new Error(`voSample: nested value object '${f.ref}' has no descriptor`);
|
|
364
|
+
return sampleOfDescriptor(dep, byName);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function sampleOfDescriptor(d: ValueObjectDescriptor, byName: Map<string, ValueObjectDescriptor>): unknown {
|
|
370
|
+
if (d.geometry !== undefined) return GEOMETRY_SAMPLES[d.geometry];
|
|
371
|
+
if (d.union !== undefined) {
|
|
372
|
+
// FIRST declared arm (keys are sorted — deterministic); `example:` overrides upstream.
|
|
373
|
+
const [key, arm] = Object.entries(d.union)[0]!;
|
|
374
|
+
if ("ref" in arm) {
|
|
375
|
+
const dep = byName.get(arm.ref);
|
|
376
|
+
if (dep === undefined) throw new Error(`voSample: union arm VO '${arm.ref}' has no descriptor`);
|
|
377
|
+
return { type: key, value: sampleOfDescriptor(dep, byName) };
|
|
378
|
+
}
|
|
379
|
+
const out: Record<string, unknown> = { type: key };
|
|
380
|
+
for (const [k, f] of Object.entries(arm.fields)) {
|
|
381
|
+
if (f.optional === true) continue;
|
|
382
|
+
out[k] = sampleOfDescriptorField(f, k, byName);
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
const out: Record<string, unknown> = {};
|
|
387
|
+
for (const [k, f] of Object.entries(d.fields ?? {})) {
|
|
388
|
+
if (f.optional === true) continue; // optional → omitted (mirrors payload synthesis)
|
|
389
|
+
out[k] = sampleOfDescriptorField(f, k, byName);
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* A LAWFUL sample value for a VO (slice C): the author's validated `example` when given, the
|
|
396
|
+
* blessed geometry sample for `geo.*` kinds, else per-field synthesis (recursing into nested
|
|
397
|
+
* VOs). Proof synthesis calls this instead of guessing at the raw zod — which is exactly the
|
|
398
|
+
* lane that refused its own sample on constrained shapes (geo min-2 coordinates).
|
|
399
|
+
*/
|
|
400
|
+
export function voSample(meta: ValueObjectFieldMeta): unknown {
|
|
401
|
+
if (meta.example !== undefined) return meta.example;
|
|
402
|
+
const byName = new Map<string, ValueObjectDescriptor>();
|
|
403
|
+
for (const d of meta.dependencies) byName.set(d.name, d);
|
|
404
|
+
byName.set(meta.descriptor.name, meta.descriptor);
|
|
405
|
+
return sampleOfDescriptor(meta.descriptor, byName);
|
|
406
|
+
}
|
|
407
|
+
|
|
229
408
|
function makeValueObject<T>(
|
|
230
409
|
name: string,
|
|
231
410
|
zodSchema: z.ZodType<T>,
|
|
232
411
|
descriptor: ValueObjectDescriptor,
|
|
233
412
|
dependencies: readonly ValueObjectDescriptor[],
|
|
234
413
|
geometry?: GeometryKind,
|
|
414
|
+
example?: T,
|
|
235
415
|
): ValueObject<T> {
|
|
236
416
|
if (!VO_NAME_RE.test(name)) {
|
|
237
417
|
throw new Error(
|
|
@@ -239,12 +419,25 @@ function makeValueObject<T>(
|
|
|
239
419
|
`generated TS interface / Dart class name.`,
|
|
240
420
|
);
|
|
241
421
|
}
|
|
422
|
+
if (example !== undefined) {
|
|
423
|
+
const v = zodSchema.safeParse(example);
|
|
424
|
+
if (!v.success) {
|
|
425
|
+
throw new Error(
|
|
426
|
+
`valueObject '${name}': the declared example does not satisfy the VO's own schema — ` +
|
|
427
|
+
`a lying example would poison proof synthesis. ${v.error.message}`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
242
431
|
const meta: ValueObjectFieldMeta = {
|
|
243
432
|
name,
|
|
244
433
|
descriptor,
|
|
245
434
|
dependencies,
|
|
246
435
|
...(geometry !== undefined ? { geometry } : {}),
|
|
436
|
+
...(example !== undefined ? { example } : {}),
|
|
247
437
|
};
|
|
438
|
+
// THE BRAND (slice A): compile-time identity on the schema itself — non-enumerable, never
|
|
439
|
+
// serialized, domainHash-inert. voMetaOf() is the one resolver every introspector uses.
|
|
440
|
+
Object.defineProperty(zodSchema, NOMOS_VO_BRAND, { value: meta, enumerable: false });
|
|
248
441
|
const vo: ValueObject<T> = {
|
|
249
442
|
__isValueObject: true,
|
|
250
443
|
name,
|
|
@@ -272,6 +465,13 @@ function makeValueObject<T>(
|
|
|
272
465
|
export function valueObject<const Name extends string, S extends Record<string, ValueObjectShapeEntry>>(
|
|
273
466
|
name: Name,
|
|
274
467
|
shape: S,
|
|
468
|
+
opts?: {
|
|
469
|
+
/** A lawful example value, VALIDATED against the VO's own schema at declaration (a lying
|
|
470
|
+
* example throws here, loudly). Proof synthesis prefers it over per-field synthesis — the
|
|
471
|
+
* escape hatch for constraint-rich shapes (refinements, cross-field rules) a synthesizer
|
|
472
|
+
* cannot guess. */
|
|
473
|
+
example?: ShapeValue<S>;
|
|
474
|
+
},
|
|
275
475
|
): ValueObject<ShapeValue<S>> {
|
|
276
476
|
const zodShape: Record<string, z.ZodTypeAny> = {};
|
|
277
477
|
const fields: Record<string, ValueObjectFieldDescriptor> = {};
|
|
@@ -308,9 +508,98 @@ export function valueObject<const Name extends string, S extends Record<string,
|
|
|
308
508
|
z.object(zodShape) as unknown as z.ZodType<ShapeValue<S>>,
|
|
309
509
|
descriptor,
|
|
310
510
|
dependencies,
|
|
511
|
+
undefined,
|
|
512
|
+
opts?.example,
|
|
311
513
|
);
|
|
312
514
|
}
|
|
313
515
|
|
|
516
|
+
// ── variant(...) — sum types as ONE json leaf (dart_codegen_parity.md P1; named `variant`
|
|
517
|
+
// because the ReBAC rewrite set-op already owns `union`) ──────────────
|
|
518
|
+
|
|
519
|
+
/** One arm of a `variant(...)`: an anonymous shape (the v1 VO field set) or a NAMED value object. */
|
|
520
|
+
export type UnionArmShape = Record<string, ValueObjectShapeEntry> | ValueObject<unknown>;
|
|
521
|
+
|
|
522
|
+
type UnionArmValue<A> = A extends ValueObject<infer V>
|
|
523
|
+
? { value: V }
|
|
524
|
+
: A extends Record<string, ValueObjectShapeEntry>
|
|
525
|
+
? ShapeValue<A>
|
|
526
|
+
: never;
|
|
527
|
+
|
|
528
|
+
type UnionValue<Arms extends Record<string, UnionArmShape>> = {
|
|
529
|
+
[K in keyof Arms & string]: { type: K } & UnionArmValue<Arms[K]>;
|
|
530
|
+
}[keyof Arms & string];
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Declare a NAMED SUM TYPE (`variant("Shape", { circle: {...}, rect: {...} })`). The blessed,
|
|
534
|
+
* FIXED discriminator is `type` (the geo/RFC 7946 convention); its value is the arm key.
|
|
535
|
+
* On the wire an ANONYMOUS arm's fields ride flattened beside `type`; a NAMED-VO arm nests
|
|
536
|
+
* its value under `value` (the named VO stays the one nominal home of its shape).
|
|
537
|
+
*
|
|
538
|
+
* STORAGE/MERGE: identical to every VO — ONE Lww-class json leaf, replaced WHOLESALE
|
|
539
|
+
* (switching arms is just a write; no per-arm merge). EVOLUTION: adding an arm is ADDITIVE
|
|
540
|
+
* (the generated Dart's `Unknown` arm keeps OLD clients decoding a NEWER law's arm without
|
|
541
|
+
* crashing); removing/renaming an arm is a retype (the #58 dispositions lane).
|
|
542
|
+
*/
|
|
543
|
+
export function variant<const Name extends string, Arms extends Record<string, UnionArmShape>>(
|
|
544
|
+
name: Name,
|
|
545
|
+
arms: Arms,
|
|
546
|
+
opts?: { example?: UnionValue<Arms> },
|
|
547
|
+
): ValueObject<UnionValue<Arms>> {
|
|
548
|
+
const armKeys = Object.keys(arms).sort();
|
|
549
|
+
if (armKeys.length === 0) throw new Error(`variant '${name}': at least one arm is required.`);
|
|
550
|
+
const deps = new Map<string, ValueObjectDescriptor>();
|
|
551
|
+
const armDescriptors: Record<string, UnionArmDescriptor> = {};
|
|
552
|
+
const armZods: z.ZodTypeAny[] = [];
|
|
553
|
+
for (const key of armKeys) {
|
|
554
|
+
if (!VO_NAME_RE.test(key) || key !== key[0]!.toLowerCase() + key.slice(1)) {
|
|
555
|
+
throw new Error(`variant '${name}': arm key '${key}' must be a lowerCamel identifier — it is the wire discriminator AND names the generated arm class.`);
|
|
556
|
+
}
|
|
557
|
+
const arm = arms[key]!;
|
|
558
|
+
if (isValueObject(arm)) {
|
|
559
|
+
if (arm.descriptor.union !== undefined) {
|
|
560
|
+
throw new Error(`variant '${name}': arm '${key}' is itself a union — unions-of-unions are refused in v1.`);
|
|
561
|
+
}
|
|
562
|
+
armDescriptors[key] = { ref: arm.name };
|
|
563
|
+
armZods.push(z.object({ type: z.literal(key), value: arm.zod as z.ZodTypeAny }));
|
|
564
|
+
collectDescriptors(arm, deps);
|
|
565
|
+
} else {
|
|
566
|
+
const fields: Record<string, ValueObjectFieldDescriptor> = {};
|
|
567
|
+
const zodShape: Record<string, z.ZodTypeAny> = { type: z.literal(key) };
|
|
568
|
+
for (const fieldKey of Object.keys(arm).sort()) {
|
|
569
|
+
if (fieldKey === "type") throw new Error(`variant '${name}': arm '${key}' declares a 'type' field — reserved for the discriminator.`);
|
|
570
|
+
if (fieldKey === "value") throw new Error(`variant '${name}': arm '${key}' declares a 'value' field — reserved for named-VO arms.`);
|
|
571
|
+
const entry = arm[fieldKey]!;
|
|
572
|
+
if (isOptionalValueObject(entry)) {
|
|
573
|
+
const inner = entry.valueObject;
|
|
574
|
+
zodShape[fieldKey] = (inner.zod as z.ZodTypeAny).optional();
|
|
575
|
+
fields[fieldKey] = inner.geometry !== undefined
|
|
576
|
+
? { type: "geometry", ref: inner.name, geometry: inner.geometry, optional: true }
|
|
577
|
+
: { type: "object", ref: inner.name, optional: true };
|
|
578
|
+
collectDescriptors(inner, deps);
|
|
579
|
+
} else if (isValueObject(entry)) {
|
|
580
|
+
zodShape[fieldKey] = entry.zod as z.ZodTypeAny;
|
|
581
|
+
fields[fieldKey] = entry.geometry !== undefined
|
|
582
|
+
? { type: "geometry", ref: entry.name, geometry: entry.geometry }
|
|
583
|
+
: { type: "object", ref: entry.name };
|
|
584
|
+
collectDescriptors(entry, deps);
|
|
585
|
+
} else {
|
|
586
|
+
fields[fieldKey] = describeZod(name, `${key}.${fieldKey}`, entry);
|
|
587
|
+
zodShape[fieldKey] = entry;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
armDescriptors[key] = { fields };
|
|
591
|
+
armZods.push(z.object(zodShape));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (deps.has(name)) throw new Error(`variant '${name}': a union cannot nest itself (directly or via a dependency).`);
|
|
595
|
+
const descriptor: ValueObjectDescriptor = { name, union: armDescriptors };
|
|
596
|
+
const dependencies = [...deps.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
597
|
+
const zodSchema = (armZods.length === 1
|
|
598
|
+
? armZods[0]!
|
|
599
|
+
: z.discriminatedUnion("type", armZods as [z.ZodObject<z.ZodRawShape>, ...z.ZodObject<z.ZodRawShape>[]])) as unknown as z.ZodType<UnionValue<Arms>>;
|
|
600
|
+
return makeValueObject(name, zodSchema, descriptor, dependencies, undefined, opts?.example);
|
|
601
|
+
}
|
|
602
|
+
|
|
314
603
|
// ── geo.* — the RFC 7946 geometry value objects ──────────────────────────────────────
|
|
315
604
|
|
|
316
605
|
/** An RFC 7946 position: `[lng, lat]` or `[lng, lat, altitude]`. */
|
|
@@ -388,6 +677,46 @@ export const geo = {
|
|
|
388
677
|
* descriptors throw (one name, one shape). Returns `[]` when no field carries a VO —
|
|
389
678
|
* the omit-when-empty input to the `nomosValueObjects` era key.
|
|
390
679
|
*/
|
|
680
|
+
/**
|
|
681
|
+
* Collect the value objects referenced by DIRECTIVE PAYLOADS (top-level fields + array
|
|
682
|
+
* elements), via the {@link NOMOS_VO_BRAND} the schemas carry. CODEGEN-ONLY (slice B of
|
|
683
|
+
* vo_authoring_end_to_end.md): the result feeds the generated Dart/TS surfaces so a
|
|
684
|
+
* payload-only VO still gets its class/interface — it does NOT enter the `nomosValueObjects`
|
|
685
|
+
* IR key (that stays aggregate-derived, keeping package bytes / domainHash untouched).
|
|
686
|
+
*/
|
|
687
|
+
export function collectPayloadValueObjectDescriptors(
|
|
688
|
+
directives: readonly { payloadSchema?: unknown }[],
|
|
689
|
+
): ValueObjectDescriptor[] {
|
|
690
|
+
const byName = new Map<string, ValueObjectDescriptor>();
|
|
691
|
+
const take = (meta: ValueObjectFieldMeta | undefined) => {
|
|
692
|
+
if (meta === undefined) return;
|
|
693
|
+
for (const d of [meta.descriptor, ...meta.dependencies]) {
|
|
694
|
+
const existing = byName.get(d.name);
|
|
695
|
+
if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(d)) {
|
|
696
|
+
throw new Error(`value object '${d.name}' has DIVERGENT shapes across payloads — one name, one shape.`);
|
|
697
|
+
}
|
|
698
|
+
byName.set(d.name, d);
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
for (const dir of directives) {
|
|
702
|
+
const schema = dir?.payloadSchema as z.ZodTypeAny | undefined;
|
|
703
|
+
if (!schema || typeof schema !== "object" || zodKind(schema) !== "object") continue;
|
|
704
|
+
const rawShape = (zodDef(schema) as { shape?: Record<string, z.ZodTypeAny> | (() => Record<string, z.ZodTypeAny>) }).shape
|
|
705
|
+
?? (schema as unknown as { shape?: Record<string, z.ZodTypeAny> }).shape;
|
|
706
|
+
const shape = typeof rawShape === "function" ? rawShape() : rawShape ?? {};
|
|
707
|
+
for (const raw of Object.values(shape)) {
|
|
708
|
+
let zt = raw as z.ZodTypeAny;
|
|
709
|
+
take(voMetaOf(zt));
|
|
710
|
+
// unwrap optional/default to reach an array of VOs
|
|
711
|
+
for (let hops = 0; hops < 8 && (zodKind(zt) === "optional" || zodKind(zt) === "default"); hops++) {
|
|
712
|
+
zt = zodDef(zt).innerType!;
|
|
713
|
+
}
|
|
714
|
+
if (zodKind(zt) === "array") take(voMetaOf(zodArrayElement(zt)));
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
718
|
+
}
|
|
719
|
+
|
|
391
720
|
export function collectValueObjectDescriptors(
|
|
392
721
|
aggregates: readonly { id: string; fields: Record<string, Field> }[],
|
|
393
722
|
): ValueObjectDescriptor[] {
|