@githolon/dsl 0.32.0 → 0.33.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.
@@ -32,7 +32,7 @@ import { z } from "zod";
32
32
  import type { AggregateHandle } from "./aggregate.js";
33
33
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
34
34
  import type { RelationTypeDecl } from "./relation_schema.js";
35
- import type { Field, FieldKind } from "./fields.js";
35
+ import type { Field, FieldKind, ValueObjectDescriptor, ValueObjectFieldDescriptor } from "./fields.js";
36
36
  import type { Directive } from "./directive.js";
37
37
  import { executeDirectiveToIntent } from "./wire_encode.js";
38
38
  import { deterministicPorts } from "./ctx.js";
@@ -347,6 +347,13 @@ interface AggFieldSpec {
347
347
  * `undefined` for a non-map field.
348
348
  */
349
349
  mapValueKind?: FieldKind;
350
+ /**
351
+ * For a `json`-kind field lowered from a NAMED VALUE OBJECT (`ValueObject.field()`):
352
+ * the generated Dart class name. The field decodes via `readJsonObject…` +
353
+ * `<VoClass>.fromProjected(m)` (the EvidenceRef pattern) and types as the class.
354
+ * `undefined` for a plain `t.json()`/`t.jsonObject()` field.
355
+ */
356
+ voClass?: string;
350
357
  /**
351
358
  * For a `json`-kind field: the asserted decoded SHAPE (`field.jsonShape`). `"object"`
352
359
  * (a `t.jsonObject()` leaf, e.g. `location`) means the value is ALWAYS a JSON object —
@@ -581,8 +588,21 @@ function aggFieldSpec(
581
588
  ...(valueClass !== undefined ? { valueClass } : {}),
582
589
  };
583
590
  case "json":
591
+ // A NAMED VALUE OBJECT (`ValueObject.field()`) types as its generated Dart class
592
+ // (typed decode via `.fromProjected`, the EvidenceRef pattern). Otherwise
584
593
  // `jsonShape` ("object" via `t.jsonObject()`) chooses strict-object vs
585
594
  // permissive read-model type.
595
+ if (field.valueObject !== undefined) {
596
+ return {
597
+ name,
598
+ wireName,
599
+ kind: field.kind,
600
+ dartType: field.valueObject.name,
601
+ optional,
602
+ voClass: field.valueObject.name,
603
+ ...(field.jsonShape !== undefined ? { jsonShape: field.jsonShape } : {}),
604
+ };
605
+ }
586
606
  return {
587
607
  name,
588
608
  wireName,
@@ -1166,6 +1186,275 @@ function emitEvidenceRefClass(): string {
1166
1186
  ].join("\n");
1167
1187
  }
1168
1188
 
1189
+ // ── value objects → Dart classes (`value_object.ts` descriptors) ─────────────────────
1190
+
1191
+ /** The Dart coordinates type per RFC 7946 geometry kind. */
1192
+ const geometryCoordsDart: Record<string, string> = {
1193
+ Point: "List<double>",
1194
+ LineString: "List<List<double>>",
1195
+ Polygon: "List<List<List<double>>>",
1196
+ };
1197
+
1198
+ /**
1199
+ * The generated Dart enum NAME for one VO enum field: `<VoName><FieldName>Enum`
1200
+ * (e.g. `PostalAddressCountryCodeEnum`). The VO is the field's semantic home — the
1201
+ * exact analogue of the aggregate-enum convention's domain prefix (`collectEnums`'s
1202
+ * `<Domain><Field>Enum`), so two barrel-exported domains never collide on a bare name.
1203
+ * IDENTITY = THE VO FIELD, not the value set: two identical value-sets in DIFFERENT
1204
+ * VOs stay separate types (VO names are package-unique, so so are these).
1205
+ */
1206
+ function voEnumName(voName: string, fieldName: string): string {
1207
+ return `${voName}${dartTypeNameFromField(fieldName)}Enum`;
1208
+ }
1209
+
1210
+ /** The tolerant-decode SENTINEL case name — dodge a literal enum value that would
1211
+ * collide with it (`dartIdent("unknownWire") === "unknownWire"`). */
1212
+ function voEnumSentinel(values: readonly string[]): string {
1213
+ let s = "unknownWire";
1214
+ while (values.some((v) => dartIdent(v) === s)) s = `${s}_`;
1215
+ return s;
1216
+ }
1217
+
1218
+ /** Emit ONE generated Dart enum for a VO enum field. TOLERANT DECODE (law evolves —
1219
+ * a new value added by a later law must NOT crash an old client): `fromWire` maps an
1220
+ * unrecognized wire string to the [sentinel] case instead of throwing (the deliberate
1221
+ * divergence from the strict aggregate-enum `fromWire`; VO data crosses law versions). */
1222
+ function emitVoEnum(voName: string, fieldName: string, values: readonly string[]): string {
1223
+ const name = voEnumName(voName, fieldName);
1224
+ const sentinel = voEnumSentinel(values);
1225
+ const variants = values.map((v) => ` ${dartIdent(v)}('${v}')`).join(",\n");
1226
+ return [
1227
+ `/// Generated enum for value object field '${voName}.${fieldName}': ${values.join(", ")}.`,
1228
+ `/// TOLERANT DECODE: an unrecognized wire value (law can add enum values; old`,
1229
+ `/// clients must not crash on new data) decodes to [${name}.${sentinel}] — never a throw.`,
1230
+ `/// [wire] is the exact wire string ([${sentinel}].wire is the '__unknown__' marker;`,
1231
+ `/// authoring code constructs KNOWN cases, so toJson never writes it in practice).`,
1232
+ `enum ${name} {`,
1233
+ `${variants},`,
1234
+ ` /// The tolerant-decode sentinel — an enum value this client's law snapshot does not know.`,
1235
+ ` ${sentinel}('__unknown__');`,
1236
+ ``,
1237
+ ` final String wire;`,
1238
+ ` const ${name}(this.wire);`,
1239
+ ``,
1240
+ ` static ${name} fromWire(String wire) =>`,
1241
+ ` ${name}.values.firstWhere((e) => e.wire == wire,`,
1242
+ ` orElse: () => ${name}.${sentinel});`,
1243
+ `}`,
1244
+ ].join("\n");
1245
+ }
1246
+
1247
+ /** Every (fieldName, values) enum pair a VO descriptor carries (arrays recursed —
1248
+ * 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);
1254
+ };
1255
+ for (const [fieldName, f] of Object.entries(d.fields ?? {})) walk(fieldName, f);
1256
+ return out;
1257
+ }
1258
+
1259
+ /** The Dart type of one VO descriptor field. `voName`/`fieldName` name the field's
1260
+ * generated enum type (`<VoName><FieldName>Enum`) — threaded through arrays. */
1261
+ function dartTypeOfVoField(f: ValueObjectFieldDescriptor, voName: string, fieldName: string): string {
1262
+ switch (f.type) {
1263
+ case "string":
1264
+ return "String";
1265
+ case "number":
1266
+ return "double";
1267
+ case "boolean":
1268
+ return "bool";
1269
+ case "enum":
1270
+ // A REAL generated Dart enum (`emitVoEnum`), named by the owning VO field.
1271
+ return voEnumName(voName, fieldName);
1272
+ case "array":
1273
+ return `List<${dartTypeOfVoField(f.items, voName, fieldName)}>`;
1274
+ case "object":
1275
+ case "geometry":
1276
+ return f.ref; // the named VO class (emitted alongside / imported)
1277
+ }
1278
+ }
1279
+
1280
+ /** A Dart expression converting the RAW decoded json value `expr` to the field's typed
1281
+ * value. `depth` names nested lambda vars (`e0`, `e1`, …) for arrays-of-arrays. */
1282
+ function voFromExpr(
1283
+ f: ValueObjectFieldDescriptor,
1284
+ expr: string,
1285
+ voName: string,
1286
+ fieldName: string,
1287
+ depth = 0,
1288
+ ): string {
1289
+ switch (f.type) {
1290
+ case "string":
1291
+ return `${expr} as String`;
1292
+ case "enum":
1293
+ // TOLERANT typed decode — an unknown wire value becomes the sentinel case.
1294
+ return `${voEnumName(voName, fieldName)}.fromWire(${expr} as String)`;
1295
+ case "number":
1296
+ return `(${expr} as num).toDouble()`;
1297
+ case "boolean":
1298
+ return `${expr} as bool`;
1299
+ case "array": {
1300
+ const v = `e${depth}`;
1301
+ return `(${expr} as List).map((${v}) => ${voFromExpr(f.items, v, voName, fieldName, depth + 1)}).toList()`;
1302
+ }
1303
+ case "object":
1304
+ case "geometry":
1305
+ return `${f.ref}.fromProjected((${expr} as Map).cast<String, Object?>())`;
1306
+ }
1307
+ }
1308
+
1309
+ /** A Dart expression serializing the typed member `expr` back to plain json. */
1310
+ function voToJsonExpr(f: ValueObjectFieldDescriptor, expr: string, depth = 0): string {
1311
+ switch (f.type) {
1312
+ case "string":
1313
+ case "number":
1314
+ case "boolean":
1315
+ return expr;
1316
+ case "enum":
1317
+ return `${expr}.wire`; // the exact wire string the law's zod enum validates
1318
+ case "array": {
1319
+ const inner = voToJsonExpr(f.items, `e${depth}`, depth + 1);
1320
+ return inner === `e${depth}` ? expr : `${expr}.map((e${depth}) => ${inner}).toList()`;
1321
+ }
1322
+ case "object":
1323
+ case "geometry":
1324
+ return `${expr}.toJson()`;
1325
+ }
1326
+ }
1327
+
1328
+ /** Deep json equality/hash helpers shared by every generated VO class — emitted ONCE
1329
+ * per file when the file emits ≥1 VO class (`toJson()`-based value equality). */
1330
+ function emitVoHelpers(): string {
1331
+ return [
1332
+ `/// Deep json equality for generated value-object classes (toJson-based).`,
1333
+ `bool _voJsonEq(Object? a, Object? b) {`,
1334
+ ` if (a is Map && b is Map) {`,
1335
+ ` if (a.length != b.length) return false;`,
1336
+ ` for (final k in a.keys) {`,
1337
+ ` if (!b.containsKey(k) || !_voJsonEq(a[k], b[k])) return false;`,
1338
+ ` }`,
1339
+ ` return true;`,
1340
+ ` }`,
1341
+ ` if (a is List && b is List) {`,
1342
+ ` if (a.length != b.length) return false;`,
1343
+ ` for (var i = 0; i < a.length; i++) {`,
1344
+ ` if (!_voJsonEq(a[i], b[i])) return false;`,
1345
+ ` }`,
1346
+ ` return true;`,
1347
+ ` }`,
1348
+ ` return a == b;`,
1349
+ `}`,
1350
+ ``,
1351
+ `/// Deep json hash for generated value-object classes (toJson-based).`,
1352
+ `int _voJsonHash(Object? v) {`,
1353
+ ` if (v is Map) {`,
1354
+ ` return Object.hashAllUnordered(`,
1355
+ ` v.entries.map((e) => Object.hash(e.key, _voJsonHash(e.value))));`,
1356
+ ` }`,
1357
+ ` if (v is List) return Object.hashAll(v.map(_voJsonHash));`,
1358
+ ` return v.hashCode;`,
1359
+ `}`,
1360
+ ].join("\n");
1361
+ }
1362
+
1363
+ /** Emit ONE generated Dart value-object class from its canonical descriptor: typed
1364
+ * final fields, a const ctor, `fromProjected` (the projection's decoded json object),
1365
+ * `toJson` (the authored payload shape — optionals OMITTED when null, matching the zod
1366
+ * `.optional()` contract), and toJson-based value equality. A `geo.*` VO emits the
1367
+ * fixed RFC 7946 shape (typed `coordinates`; `type` is the constant geometry kind). */
1368
+ function emitValueObjectClass(d: ValueObjectDescriptor): string {
1369
+ if (d.geometry !== undefined) {
1370
+ const coordsType = geometryCoordsDart[d.geometry]!;
1371
+ const coordsFrom: Record<string, string> = {
1372
+ Point: `(m['coordinates']! as List).map((e0) => (e0 as num).toDouble()).toList()`,
1373
+ LineString: `(m['coordinates']! as List).map((e0) => (e0 as List).map((e1) => (e1 as num).toDouble()).toList()).toList()`,
1374
+ Polygon: `(m['coordinates']! as List).map((e0) => (e0 as List).map((e1) => (e1 as List).map((e2) => (e2 as num).toDouble()).toList()).toList()).toList()`,
1375
+ };
1376
+ return [
1377
+ `/// RFC 7946 ${d.geometry} — a \`geo.*\` value object ('${d.name}'). Positions are`,
1378
+ `/// \`[lng, lat]\` or \`[lng, lat, altitude]\` (RFC 7946 order).`,
1379
+ `class ${d.name} {`,
1380
+ ` final ${coordsType} coordinates;`,
1381
+ ``,
1382
+ ` const ${d.name}({required this.coordinates});`,
1383
+ ``,
1384
+ ` /// The RFC 7946 geometry kind (constant).`,
1385
+ ` String get type => '${d.geometry}';`,
1386
+ ``,
1387
+ ` factory ${d.name}.fromProjected(Map<String, Object?> m) =>`,
1388
+ ` ${d.name}(coordinates: ${coordsFrom[d.geometry]!});`,
1389
+ ``,
1390
+ ` Map<String, Object?> toJson() => <String, Object?>{`,
1391
+ ` 'type': '${d.geometry}',`,
1392
+ ` 'coordinates': coordinates,`,
1393
+ ` };`,
1394
+ ``,
1395
+ ` @override`,
1396
+ ` bool operator ==(Object other) =>`,
1397
+ ` other is ${d.name} && _voJsonEq(other.toJson(), toJson());`,
1398
+ ``,
1399
+ ` @override`,
1400
+ ` int get hashCode => _voJsonHash(toJson());`,
1401
+ `}`,
1402
+ ].join("\n");
1403
+ }
1404
+ const fields = Object.entries(d.fields ?? {}).map(([wireName, f]) => ({
1405
+ wireName,
1406
+ member: dartMemberName(wireName),
1407
+ f,
1408
+ optional: f.optional === true,
1409
+ }));
1410
+ const decls = fields.map(
1411
+ (x) => ` final ${dartTypeOfVoField(x.f, d.name, x.wireName)}${x.optional ? "?" : ""} ${x.member};`,
1412
+ );
1413
+ const ctor = fields.map((x) => (x.optional ? ` this.${x.member},` : ` required this.${x.member},`));
1414
+ const fromLines = fields.map((x) =>
1415
+ x.optional
1416
+ ? ` ${x.member}: m['${x.wireName}'] == null ? null : ${voFromExpr(x.f, `m['${x.wireName}']!`, d.name, x.wireName)},`
1417
+ : ` ${x.member}: ${voFromExpr(x.f, `m['${x.wireName}']!`, d.name, x.wireName)},`,
1418
+ );
1419
+ const toLines = fields.map((x) =>
1420
+ x.optional
1421
+ ? ` if (${x.member} != null) '${x.wireName}': ${voToJsonExpr(x.f, `${x.member}!`)},`
1422
+ : ` '${x.wireName}': ${voToJsonExpr(x.f, x.member)},`,
1423
+ );
1424
+ return [
1425
+ `/// Generated value object '${d.name}' (value_object.ts). Stored as ONE json-object`,
1426
+ `/// value leaf on its aggregate field — replaced WHOLESALE on write (Lww-class; no`,
1427
+ `/// per-subfield merge). Optional members are OMITTED from [toJson] when null (the`,
1428
+ `/// zod \`.optional()\` contract — absent, never JSON null).`,
1429
+ `class ${d.name} {`,
1430
+ ...decls,
1431
+ ``,
1432
+ ` const ${d.name}({`,
1433
+ ...ctor,
1434
+ ` });`,
1435
+ ``,
1436
+ ` /// Read from the projection's decoded json object.`,
1437
+ ` factory ${d.name}.fromProjected(Map<String, Object?> m) {`,
1438
+ ` return ${d.name}(`,
1439
+ ...fromLines,
1440
+ ` );`,
1441
+ ` }`,
1442
+ ``,
1443
+ ` /// The authored payload shape (embed it in a directive payload / \`set()\`).`,
1444
+ ` Map<String, Object?> toJson() => <String, Object?>{`,
1445
+ ...toLines,
1446
+ ` };`,
1447
+ ``,
1448
+ ` @override`,
1449
+ ` bool operator ==(Object other) =>`,
1450
+ ` other is ${d.name} && _voJsonEq(other.toJson(), toJson());`,
1451
+ ``,
1452
+ ` @override`,
1453
+ ` int get hashCode => _voJsonHash(toJson());`,
1454
+ `}`,
1455
+ ].join("\n");
1456
+ }
1457
+
1169
1458
  /** True when ANY aggregate field in the module is `t.evidence()` — gates emitting the
1170
1459
  * shared [emitEvidenceRefClass] once. */
1171
1460
  function moduleUsesEvidence(aggregates: readonly AggregateHandle[]): boolean {
@@ -1235,8 +1524,15 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
1235
1524
  ? `readStrOrNull(data, '${s.wireName}')`
1236
1525
  : `readStr(data, '${aggregate}', '${s.wireName}')`;
1237
1526
  case "json":
1238
- // A `json`-kind field is typed `String` on the aggregate; the projection may
1239
- // have parsed its stored JSON into a container — re-stringify to keep `String`.
1527
+ // A NAMED VALUE OBJECT reads the projection's decoded object and wraps it in its
1528
+ // generated class (the EvidenceRef pattern). A plain `json`-kind field is typed
1529
+ // `String` on the aggregate; the projection may have parsed its stored JSON into a
1530
+ // container — re-stringify to keep `String`.
1531
+ if (s.voClass !== undefined) {
1532
+ return opt
1533
+ ? `() { final m = readJsonObjectOrNull(data, '${aggregate}', '${s.wireName}'); return m == null ? null : ${s.voClass}.fromProjected(m); }()`
1534
+ : `${s.voClass}.fromProjected(readJsonObject(data, '${aggregate}', '${s.wireName}'))`;
1535
+ }
1240
1536
  return opt
1241
1537
  ? `readJsonStrOrNull(data, '${s.wireName}')`
1242
1538
  : `readJsonStr(data, '${aggregate}', '${s.wireName}')`;
@@ -1304,6 +1600,8 @@ function readModelDartType(s: AggFieldSpec): string {
1304
1600
  // has an UNKNOWN/non-object decoded shape (a double/bool/list authored as a JSON
1305
1601
  // leaf — the kernel `Value` is Str|Int only), so it is surfaced as the permissive
1306
1602
  // `Object?` (the read engine's decoded value as-is — never coerced/thrown).
1603
+ // A NAMED VALUE OBJECT reads as its generated class (typed decode).
1604
+ if (s.voClass !== undefined) return s.voClass;
1307
1605
  return isJsonObjectLeaf(s) ? "Map<String, Object?>" : "Object?";
1308
1606
  // `position`/`counter` are normalized to `string`/`int` in `aggFieldSpec` (read-collapsed scalars),
1309
1607
  // so these labels are unreachable at runtime — present for FieldKind exhaustiveness.
@@ -1380,6 +1678,12 @@ function parseReadModelField(s: AggFieldSpec, aggregate: string): string {
1380
1678
  // shape (a double/bool/list authored as a JSON leaf), so surface the read engine's
1381
1679
  // decoded value AS-IS via the NON-THROWING `readJsonValueOrNull` (`Object?`, null
1382
1680
  // when absent) — NEVER a strict-object reader that would throw on a legit non-object.
1681
+ if (s.voClass !== undefined) {
1682
+ // A NAMED VALUE OBJECT: the decoded object wrapped in its generated class.
1683
+ return opt
1684
+ ? `() { final m = readJsonObjectOrNull(data, '${aggregate}', '${s.wireName}'); return m == null ? null : ${s.voClass}.fromProjected(m); }()`
1685
+ : `${s.voClass}.fromProjected(readJsonObject(data, '${aggregate}', '${s.wireName}'))`;
1686
+ }
1383
1687
  if (isPermissiveJsonLeaf(s)) {
1384
1688
  return `readJsonValueOrNull(data, '${s.wireName}')`;
1385
1689
  }
@@ -2489,6 +2793,16 @@ export interface DomainModule {
2489
2793
  * AST is hashed; the recursive Check/Expand EVALUATOR is the Rust engine substrate, never authored.
2490
2794
  */
2491
2795
  relationTypes?: RelationTypeDecl[];
2796
+ /**
2797
+ * The domain's FIRST-CLASS VALUE OBJECTS (`value_object.ts`) — the deduped, sorted
2798
+ * canonical descriptors of every named VO the aggregates carry (transitive nested VOs
2799
+ * included; collected by `composeDomainModule`). ADDITIVE + OPTIONAL: a VO-free domain
2800
+ * omits this entirely (the `nomosValueObjects` era rule — package bytes unchanged).
2801
+ * NEVER in the canonical manifest/domainHash: a VO field's manifest bytes equal a plain
2802
+ * `t.jsonObject()` field's. Drives the generated TS interfaces + Dart classes and the
2803
+ * additive `nomosValueObjects` USD key (`usd.ts`).
2804
+ */
2805
+ valueObjects?: readonly ValueObjectDescriptor[];
2492
2806
  /**
2493
2807
  * The domain's FIRST-CLASS WORKSPACE TYPES (`workspace_type.ts`; sharding §10
2494
2808
  * RATIFIED, slice 1) — the law-declared workspace taxonomy
@@ -2598,6 +2912,13 @@ export interface GenerateDartDomainOptions {
2598
2912
  * `import` so skipped framework symbols resolve. Omit when nothing is skipped.
2599
2913
  */
2600
2914
  readonly frameworkImportUri?: string;
2915
+ /**
2916
+ * VALUE OBJECT names whose generated Dart class is emitted ONCE per package in the shared
2917
+ * `nomos_framework.dart` (a VO used by ≥2 domains) and MUST NOT be re-emitted here — this
2918
+ * domain references them via {@link frameworkImportUri}. Empty/absent ⇒ emit every VO the
2919
+ * module carries locally.
2920
+ */
2921
+ readonly skipValueObjectNames?: ReadonlySet<string>;
2601
2922
  }
2602
2923
 
2603
2924
  /** Generate the full Dart source for one domain module. */
@@ -2606,6 +2927,22 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
2606
2927
  const skipAggregateIds = opts.skipAggregateIds ?? new Set<string>();
2607
2928
  const skipDirectiveIds = opts.skipDirectiveIds ?? new Set<string>();
2608
2929
  const skipQueryIds = opts.skipQueryIds ?? new Set<string>();
2930
+ const skipValueObjectNames = opts.skipValueObjectNames ?? new Set<string>();
2931
+ // VALUE OBJECTS: one generated class per named VO the module carries (composeDomainModule
2932
+ // collected + sorted them), minus the package-shared ones (emitted once in the framework file).
2933
+ const localValueObjects = (mod.valueObjects ?? []).filter((d) => !skipValueObjectNames.has(d.name));
2934
+ // Each VO's block = its FIELD ENUMS (real Dart enums, tolerant fromWire) then its class —
2935
+ // so skipping a shared VO (the framework-file dedup) skips its enums with it.
2936
+ const voBlock =
2937
+ localValueObjects.length > 0
2938
+ ? [
2939
+ emitVoHelpers(),
2940
+ ...localValueObjects.flatMap((d) => [
2941
+ ...voEnumFields(d).map((e) => emitVoEnum(d.name, e.fieldName, e.values)),
2942
+ emitValueObjectClass(d),
2943
+ ]),
2944
+ ].join("\n\n")
2945
+ : "";
2609
2946
  // The symbols whose Dart surface this file EMITS (framework-shared ones live in the shared file).
2610
2947
  const localAggregates = mod.aggregates.filter((a) => !skipAggregateIds.has(a.id));
2611
2948
  const localDirectives = mod.directives.filter((d) => !skipDirectiveIds.has(d.id));
@@ -2841,6 +3178,13 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
2841
3178
  ``,
2842
3179
  ]
2843
3180
  : []),
3181
+ ...(voBlock !== ""
3182
+ ? [
3183
+ `// ---- value objects (named composable json-object leaves; value_object.ts) ----`,
3184
+ voBlock,
3185
+ ``,
3186
+ ]
3187
+ : []),
2844
3188
  `// ---- aggregates (projection types) ----`,
2845
3189
  aggBlock,
2846
3190
  ``,
package/src/codegen_ts.ts CHANGED
@@ -37,7 +37,7 @@
37
37
  import type { z } from "zod";
38
38
 
39
39
  import type { AggregateHandle } from "./aggregate.js";
40
- import type { Field } from "./fields.js";
40
+ import type { Field, ValueObjectDescriptor, ValueObjectFieldDescriptor } from "./fields.js";
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";
@@ -249,6 +249,9 @@ function tsTypeOfField(field: Field): string {
249
249
  case "ref":
250
250
  return "string"; // the related aggregate's id
251
251
  case "json":
252
+ // A NAMED VALUE OBJECT field reads as its generated interface (`value_object.ts`);
253
+ // a bare `t.jsonObject()` stays the anonymous object; a plain `t.json()` is unknown.
254
+ if (field.valueObject !== undefined) return field.valueObject.name;
252
255
  return field.jsonShape === "object" ? "Record<string, unknown>" : "unknown";
253
256
  case "set":
254
257
  return "string[]"; // `t.set(t.string())` is the one set shape (fields.ts)
@@ -264,6 +267,56 @@ function tsTypeOfField(field: Field): string {
264
267
  }
265
268
  }
266
269
 
270
+ // ── value objects → TS interfaces (`value_object.ts` descriptors) ─────────────────
271
+
272
+ /** The coordinates TS type per RFC 7946 geometry kind. */
273
+ const geometryCoordsTs: Record<string, string> = {
274
+ Point: "number[]",
275
+ LineString: "number[][]",
276
+ Polygon: "number[][][]",
277
+ };
278
+
279
+ /** One descriptor field → its TS type text. NO FALLBACK (the descriptor set is closed). */
280
+ function tsTypeOfVoField(f: ValueObjectFieldDescriptor): string {
281
+ switch (f.type) {
282
+ case "string":
283
+ return "string";
284
+ case "number":
285
+ return "number";
286
+ case "boolean":
287
+ return "boolean";
288
+ case "enum":
289
+ return union(f.values.map((v) => JSON.stringify(v)));
290
+ case "array":
291
+ return `${paren(tsTypeOfVoField(f.items))}[]`;
292
+ case "object":
293
+ case "geometry":
294
+ return f.ref; // the named VO interface (emitted alongside)
295
+ }
296
+ }
297
+
298
+ /** Emit one `export interface <VO> { ... }` from its canonical descriptor. A geo VO
299
+ * emits the fixed RFC 7946 shape (`{ type: "<kind>"; coordinates: ... }`). */
300
+ function emitValueObjectTs(d: ValueObjectDescriptor): string[] {
301
+ if (d.geometry !== undefined) {
302
+ return [
303
+ `/** RFC 7946 ${d.geometry} — a \`geo.${d.geometry === "Point" ? "point" : d.geometry === "LineString" ? "lineString" : "polygon"}(...)\` value object. */`,
304
+ `export interface ${d.name} { type: ${JSON.stringify(d.geometry)}; coordinates: ${geometryCoordsTs[d.geometry]}; }`,
305
+ ``,
306
+ ];
307
+ }
308
+ const lines = Object.entries(d.fields ?? {}).map(
309
+ ([name, f]) => ` ${name}${f.optional === true ? "?" : ""}: ${tsTypeOfVoField(f)};`,
310
+ );
311
+ return [
312
+ `/** Value object \`${d.name}\` — ONE json-object value leaf, replaced WHOLESALE on write (no per-subfield merge). */`,
313
+ `export interface ${d.name} {`,
314
+ ...lines,
315
+ `}`,
316
+ ``,
317
+ ];
318
+ }
319
+
267
320
  /** A loose TS type for a derived/combined field's declared zod `returns`. */
268
321
  function tsTypeOfReturns(returns: unknown): string {
269
322
  try {
@@ -420,6 +473,26 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
420
473
  );
421
474
  }
422
475
  }
476
+ // ── value objects: one interface per named VO, deduped across the package's domains ──
477
+ const voByName = new Map<string, ValueObjectDescriptor>();
478
+ for (const mod of modules) {
479
+ for (const d of (mod as { valueObjects?: readonly ValueObjectDescriptor[] }).valueObjects ?? []) {
480
+ const existing = voByName.get(d.name);
481
+ if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(d)) {
482
+ throw new Error(
483
+ `codegen-ts: value object '${d.name}' has DIVERGENT shapes across domains — one name, one shape.`,
484
+ );
485
+ }
486
+ voByName.set(d.name, d);
487
+ }
488
+ }
489
+ if (voByName.size > 0) {
490
+ out.push(`// ── value objects (named, composable json-object leaves — value_object.ts) ──`, ``);
491
+ for (const [, d] of [...voByName].sort(([a], [b]) => a.localeCompare(b))) {
492
+ out.push(...emitValueObjectTs(d));
493
+ }
494
+ }
495
+
423
496
  out.push(`// ── read models (projection \`data\` shapes; every field is partial-fold optional) ──`, ``);
424
497
  for (const [typeId, agg] of [...aggByType].sort(([a], [b]) => a.localeCompare(b))) {
425
498
  out.push(`export interface ${typeId}Data {`);
@@ -422,8 +422,25 @@ function emitDart(
422
422
  const frameworkAggIds: ReadonlySet<string> = new Set(frameworkAggById.keys());
423
423
  const frameworkDirIds: ReadonlySet<string> = new Set(frameworkDirById.keys());
424
424
  const frameworkQueryIds: ReadonlySet<string> = new Set(frameworkQueryById.keys());
425
+ // VALUE OBJECTS shared by ≥2 domains: their generated Dart class emits ONCE in the shared
426
+ // `nomos_framework.dart` (the same collision rule as framework symbols — a barrel re-exporting
427
+ // two domains must not define one VO class twice). A single-domain VO stays in its domain file.
428
+ const voDomainsByName = new Map<string, number>();
429
+ const voDescriptorByName = new Map<string, NonNullable<DomainModule["valueObjects"]>[number]>();
430
+ for (const mod of modules) {
431
+ for (const d of mod.valueObjects ?? []) {
432
+ voDomainsByName.set(d.name, (voDomainsByName.get(d.name) ?? 0) + 1);
433
+ voDescriptorByName.set(d.name, d);
434
+ }
435
+ }
436
+ const sharedVoNames: ReadonlySet<string> = new Set(
437
+ [...voDomainsByName.entries()].filter(([, n]) => n > 1).map(([name]) => name),
438
+ );
439
+ const sharedVos = [...sharedVoNames]
440
+ .sort()
441
+ .map((name) => voDescriptorByName.get(name)!);
425
442
  const hasFrameworkSymbols =
426
- frameworkAggs.length > 0 || frameworkDirs.length > 0 || frameworkQueries.length > 0;
443
+ frameworkAggs.length > 0 || frameworkDirs.length > 0 || frameworkQueries.length > 0 || sharedVos.length > 0;
427
444
  const FRAMEWORK_FILE = "nomos_framework";
428
445
  const frameworkImportUri = hasFrameworkSymbols ? `${FRAMEWORK_FILE}.dart` : undefined;
429
446
 
@@ -455,6 +472,8 @@ function emitDart(
455
472
  directives: frameworkDirs,
456
473
  readModelAggregates: frameworkAggs.map((a) => a.id),
457
474
  ...(frameworkQueries.length > 0 ? { queries: frameworkQueries } : {}),
475
+ // Package-SHARED value objects (used by ≥2 domains) — their class emits here, once.
476
+ ...(sharedVos.length > 0 ? { valueObjects: sharedVos } : {}),
458
477
  };
459
478
  writeGenerated(FRAMEWORK_FILE, generateDartDomain(frameworkMod));
460
479
  }
@@ -480,12 +499,17 @@ function emitDart(
480
499
  const domainFwQueryIds = new Set(
481
500
  (modForDart.queries ?? []).filter((q) => frameworkQueryIds.has(q.id)).map((q) => q.id),
482
501
  );
483
- const foldsFramework = domainFwAggIds.size > 0 || domainFwDirIds.size > 0 || domainFwQueryIds.size > 0;
502
+ const domainSharedVoNames = new Set(
503
+ (modForDart.valueObjects ?? []).filter((d) => sharedVoNames.has(d.name)).map((d) => d.name),
504
+ );
505
+ const foldsFramework =
506
+ domainFwAggIds.size > 0 || domainFwDirIds.size > 0 || domainFwQueryIds.size > 0 || domainSharedVoNames.size > 0;
484
507
  const domainFrameworkImport = foldsFramework ? frameworkImportUri : undefined;
485
508
  const src = generateDartDomain(modForDart, {
486
509
  skipAggregateIds: domainFwAggIds,
487
510
  skipDirectiveIds: domainFwDirIds,
488
511
  skipQueryIds: domainFwQueryIds,
512
+ skipValueObjectNames: domainSharedVoNames,
489
513
  ...(domainFrameworkImport !== undefined ? { frameworkImportUri: domainFrameworkImport } : {}),
490
514
  });
491
515
  writeGenerated(mod.name, src);