@githolon/dsl 0.39.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.39.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",
@@ -29,7 +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 } from "./value_object.js";
32
+ import { voMetaOf, collectPayloadValueObjectDescriptors, idOfMetaOf, NOMOS_UNIT_BRAND } from "./value_object.js";
33
33
  import type { AggregateHandle } from "./aggregate.js";
34
34
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
35
35
  import type { RelationTypeDecl } from "./relation_schema.js";
@@ -63,7 +63,8 @@ export type ToJsonWrap =
63
63
  | "strList" // List<String> — emit directly.
64
64
  | "json" // nested object — emit the field's `Map<String, Object?>` directly.
65
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()`.
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).
67
68
 
68
69
  export interface PayloadFieldSpec {
69
70
  name: string;
@@ -239,10 +240,25 @@ function introspectField(
239
240
  zt: z.ZodTypeAny,
240
241
  enumDartTypes: Map<string, string>,
241
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
+ }
242
251
  const tn = zodTypeName(zt);
243
252
  switch (tn) {
244
- 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
+ }
245
260
  return { name, dartType: "String", toJson: "scalar" };
261
+ }
246
262
  case "ZodNumber":
247
263
  // A `z.number()` is a FRACTIONAL double in JS (e.g. catalogues `createListing`
248
264
  // scaleX/scaleY = 0.5) UNLESS the schema adds the `.int()` check. Only an
@@ -250,6 +266,12 @@ function introspectField(
250
266
  // emits `double` so a fractional value (0.5/0.75) is NOT truncated to 0 at the
251
267
  // call site. Detect the `.int()` check on the Zod number's `_def.checks`
252
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
+ }
253
275
  return {
254
276
  name,
255
277
  dartType: zodNumberIsInt(zt) ? "int" : "double",
@@ -1261,16 +1283,28 @@ function emitVoEnum(voName: string, fieldName: string, values: readonly string[]
1261
1283
 
1262
1284
  /** Every (fieldName, values) enum pair a VO descriptor carries (arrays recursed —
1263
1285
  * an array-of-enum field's element enum is named by the FIELD). */
1264
- function voEnumFields(d: ValueObjectDescriptor): { fieldName: string; values: readonly string[] }[] {
1265
- const out: { fieldName: string; values: readonly string[] }[] = [];
1266
- const walk = (fieldName: string, f: ValueObjectFieldDescriptor): void => {
1267
- if (f.type === "enum") out.push({ fieldName, values: f.values });
1268
- 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);
1269
1291
  };
1270
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
+ }
1271
1300
  return out;
1272
1301
  }
1273
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
+
1274
1308
  /** The Dart type of one VO descriptor field. `voName`/`fieldName` name the field's
1275
1309
  * generated enum type (`<VoName><FieldName>Enum`) — threaded through arrays. */
1276
1310
  function dartTypeOfVoField(f: ValueObjectFieldDescriptor, voName: string, fieldName: string): string {
@@ -1278,12 +1312,16 @@ function dartTypeOfVoField(f: ValueObjectFieldDescriptor, voName: string, fieldN
1278
1312
  case "string":
1279
1313
  return "String";
1280
1314
  case "number":
1281
- return "double";
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";
1282
1318
  case "boolean":
1283
1319
  return "bool";
1284
1320
  case "enum":
1285
1321
  // A REAL generated Dart enum (`emitVoEnum`), named by the owning VO field.
1286
1322
  return voEnumName(voName, fieldName);
1323
+ case "literal":
1324
+ return "String"; // surfaced as a constant getter, not a ctor param
1287
1325
  case "array":
1288
1326
  return `List<${dartTypeOfVoField(f.items, voName, fieldName)}>`;
1289
1327
  case "object":
@@ -1308,7 +1346,10 @@ function voFromExpr(
1308
1346
  // TOLERANT typed decode — an unknown wire value becomes the sentinel case.
1309
1347
  return `${voEnumName(voName, fieldName)}.fromWire(${expr} as String)`;
1310
1348
  case "number":
1311
- return `(${expr} as num).toDouble()`;
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`;
1312
1353
  case "boolean":
1313
1354
  return `${expr} as bool`;
1314
1355
  case "array": {
@@ -1324,9 +1365,11 @@ function voFromExpr(
1324
1365
  /** A Dart expression serializing the typed member `expr` back to plain json. */
1325
1366
  function voToJsonExpr(f: ValueObjectFieldDescriptor, expr: string, depth = 0): string {
1326
1367
  switch (f.type) {
1327
- case "string":
1328
1368
  case "number":
1369
+ return f.unit !== undefined ? `${expr}.value` : expr;
1370
+ case "string":
1329
1371
  case "boolean":
1372
+ case "literal":
1330
1373
  return expr;
1331
1374
  case "enum":
1332
1375
  return `${expr}.wire`; // the exact wire string the law's zod enum validates
@@ -1380,7 +1423,121 @@ function emitVoHelpers(): string {
1380
1423
  * `toJson` (the authored payload shape — optionals OMITTED when null, matching the zod
1381
1424
  * `.optional()` contract), and toJson-based value equality. A `geo.*` VO emits the
1382
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
+
1383
1539
  function emitValueObjectClass(d: ValueObjectDescriptor): string {
1540
+ if (d.union !== undefined) return emitUnionClasses(d);
1384
1541
  if (d.geometry !== undefined) {
1385
1542
  const coordsType = geometryCoordsDart[d.geometry]!;
1386
1543
  const coordsFrom: Record<string, string> = {
@@ -1416,12 +1573,21 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
1416
1573
  `}`,
1417
1574
  ].join("\n");
1418
1575
  }
1419
- const fields = Object.entries(d.fields ?? {}).map(([wireName, f]) => ({
1576
+ const allFields = Object.entries(d.fields ?? {}).map(([wireName, f]) => ({
1420
1577
  wireName,
1421
1578
  member: dartMemberName(wireName),
1422
1579
  f,
1423
1580
  optional: f.optional === true,
1424
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
+ );
1425
1591
  const decls = fields.map(
1426
1592
  (x) => ` final ${dartTypeOfVoField(x.f, d.name, x.wireName)}${x.optional ? "?" : ""} ${x.member};`,
1427
1593
  );
@@ -1443,10 +1609,9 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
1443
1609
  `/// zod \`.optional()\` contract — absent, never JSON null).`,
1444
1610
  `class ${d.name} {`,
1445
1611
  ...decls,
1612
+ ...literalGetters,
1446
1613
  ``,
1447
- ` const ${d.name}({`,
1448
- ...ctor,
1449
- ` });`,
1614
+ ...(fields.length === 0 ? [` const ${d.name}();`] : [` const ${d.name}({`, ...ctor, ` });`]),
1450
1615
  ``,
1451
1616
  ` /// Read from the projection's decoded json object.`,
1452
1617
  ` factory ${d.name}.fromProjected(Map<String, Object?> m) {`,
@@ -1457,6 +1622,7 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
1457
1622
  ``,
1458
1623
  ` /// The authored payload shape (embed it in a directive payload / \`set()\`).`,
1459
1624
  ` Map<String, Object?> toJson() => <String, Object?>{`,
1625
+ ...literalToLines,
1460
1626
  ...toLines,
1461
1627
  ` };`,
1462
1628
  ``,
@@ -1927,6 +2093,8 @@ export function toJsonExpr(spec: PayloadFieldSpec, accessor: string): string {
1927
2093
  return `${accessor}.toJson()`;
1928
2094
  case "voList":
1929
2095
  return `${accessor}.map((e) => e.toJson()).toList()`;
2096
+ case "wrapped":
2097
+ return `${accessor}.value`;
1930
2098
  }
1931
2099
  }
1932
2100
 
@@ -2971,16 +3139,48 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
2971
3139
  .filter((d) => !skipValueObjectNames.has(d.name));
2972
3140
  // Each VO's block = its FIELD ENUMS (real Dart enums, tolerant fromWire) then its class —
2973
3141
  // so skipping a shared VO (the framework-file dedup) skips its enums with it.
2974
- const voBlock =
2975
- localValueObjects.length > 0
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
2976
3175
  ? [
2977
3176
  emitVoHelpers(),
2978
3177
  ...localValueObjects.flatMap((d) => [
2979
- ...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)),
2980
3179
  emitValueObjectClass(d),
2981
3180
  ]),
2982
- ].join("\n\n")
2983
- : "";
3181
+ ]
3182
+ : []),
3183
+ ].join("\n\n");
2984
3184
  // The symbols whose Dart surface this file EMITS (framework-shared ones live in the shared file).
2985
3185
  const localAggregates = mod.aggregates.filter((a) => !skipAggregateIds.has(a.id));
2986
3186
  const localDirectives = mod.directives.filter((d) => !skipDirectiveIds.has(d.id));
@@ -182,6 +182,11 @@ function obligationFromJsonExpr(s: PayloadFieldSpec): string {
182
182
  const decode = `(${raw} as List).map((e) => ${elem}.fromProjected((e as Map).cast<String, Object?>())).toList()`;
183
183
  return s.optional ? `${raw} == null ? null : ${decode}` : decode;
184
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
+ }
185
190
  }
186
191
  }
187
192
 
package/src/codegen_ts.ts CHANGED
@@ -127,7 +127,7 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
127
127
  // references the same exported interface the read model uses — one name, both directions —
128
128
  // instead of an anonymous inline literal type. Checked on the BARE object only: optional/
129
129
  // default wrappers must keep flowing through the switch so `| undefined` survives.
130
- if (zodKind(zt) === "object") {
130
+ if (zodKind(zt) === "object" || zodKind(zt) === "union") {
131
131
  const vo = voMetaOf(zt);
132
132
  if (vo !== undefined) return vo.name;
133
133
  }
@@ -291,11 +291,13 @@ function tsTypeOfVoField(f: ValueObjectFieldDescriptor): string {
291
291
  case "string":
292
292
  return "string";
293
293
  case "number":
294
- return "number";
294
+ return "number"; // `.int()`/units are Dart-side nominal products; TS keeps one number
295
295
  case "boolean":
296
296
  return "boolean";
297
297
  case "enum":
298
298
  return union(f.values.map((v) => JSON.stringify(v)));
299
+ case "literal":
300
+ return JSON.stringify(f.value);
299
301
  case "array":
300
302
  return `${paren(tsTypeOfVoField(f.items))}[]`;
301
303
  case "object":
@@ -314,6 +316,25 @@ function emitValueObjectTs(d: ValueObjectDescriptor): string[] {
314
316
  ``,
315
317
  ];
316
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
+ }
317
338
  const lines = Object.entries(d.fields ?? {}).map(
318
339
  ([name, f]) => ` ${name}${f.optional === true ? "?" : ""}: ${tsTypeOfVoField(f)};`,
319
340
  );
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
- | { readonly type: "number"; readonly optional?: true }
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
  /**
package/src/index.ts CHANGED
@@ -47,6 +47,10 @@ 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,
50
54
  voMetaOf,
51
55
  voSample,
52
56
  NOMOS_VO_BRAND,
@@ -80,6 +80,9 @@ 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";
83
86
  case "vo":
84
87
  case "voList":
85
88
  // Interface kinds stay at the base lane for now (slice D of vo_authoring_end_to_end.md
@@ -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
- return { type: "number" };
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) {
@@ -270,6 +353,7 @@ function sampleOfDescriptorField(
270
353
  switch (f.type) {
271
354
  case "string": return `sample-${fieldName}`;
272
355
  case "number": return 1;
356
+ case "literal": return f.value;
273
357
  case "boolean": return true;
274
358
  case "enum": return f.values[0];
275
359
  case "array": return [sampleOfDescriptorField(f.items, fieldName, byName)];
@@ -284,6 +368,21 @@ function sampleOfDescriptorField(
284
368
 
285
369
  function sampleOfDescriptor(d: ValueObjectDescriptor, byName: Map<string, ValueObjectDescriptor>): unknown {
286
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
+ }
287
386
  const out: Record<string, unknown> = {};
288
387
  for (const [k, f] of Object.entries(d.fields ?? {})) {
289
388
  if (f.optional === true) continue; // optional → omitted (mirrors payload synthesis)
@@ -414,6 +513,93 @@ export function valueObject<const Name extends string, S extends Record<string,
414
513
  );
415
514
  }
416
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
+
417
603
  // ── geo.* — the RFC 7946 geometry value objects ──────────────────────────────────────
418
604
 
419
605
  /** An RFC 7946 position: `[lng, lat]` or `[lng, lat, altitude]`. */