@githolon/dsl 0.38.0 → 0.39.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.38.0",
3
+ "version": "0.39.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,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 } 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,9 @@ 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"; // nested object — emit the field's `Map<String, Object?>` directly.
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()`.
64
67
 
65
68
  export interface PayloadFieldSpec {
66
69
  name: string;
@@ -262,19 +265,31 @@ function introspectField(
262
265
  }
263
266
  case "ZodArray": {
264
267
  const elem = zodArrayElement(zt);
265
- // A `string[]` is a typed `List<String>`. ANY other element type (e.g.
266
- // `z.array(z.object(...))` — proposals' replacement `edges`) is arbitrary
267
- // JSON DATA to the engine, modelled as `List<Object?>` and shipped verbatim.
268
+ // A `string[]` is a typed `List<String>`. A list of DECLARED VALUE OBJECTS is typed
269
+ // with the generated VO class (vo_authoring_end_to_end.md slice B). ANY other element
270
+ // type is arbitrary JSON DATA to the engine, modelled as `List<Object?>`.
268
271
  if (zodTypeName(elem) === "ZodString") {
269
272
  return { name, dartType: "List<String>", toJson: "strList" };
270
273
  }
274
+ const elemVo = voMetaOf(elem);
275
+ if (elemVo !== undefined) {
276
+ return { name, dartType: `List<${elemVo.name}>`, toJson: "voList" };
277
+ }
271
278
  return { name, dartType: "List<Object?>", toJson: "json" };
272
279
  }
273
- case "ZodObject":
280
+ case "ZodObject": {
281
+ // A DECLARED VALUE OBJECT keeps its name: the payload field is the generated VO class
282
+ // (typed author path — the same class the read model decodes to), serialised via its
283
+ // own `toJson()`. An anonymous `z.object(...)` stays the raw-map lane below.
284
+ const vo = voMetaOf(zt);
285
+ if (vo !== undefined) {
286
+ return { name, dartType: vo.name, toJson: "vo" };
287
+ }
274
288
  // Nested object payload (e.g. `target: z.object({targetType, targetId})`). The
275
289
  // engine receives it as DATA verbatim, so we model it as an arbitrary JSON map
276
290
  // — the call site builds the literal, and `toPayloadJson()` ships it through.
277
291
  return { name, dartType: "Map<String, Object?>", toJson: "json" };
292
+ }
278
293
  case "ZodRecord": {
279
294
  const valueType = zodRecordValueType(zt);
280
295
  const dartType =
@@ -1908,6 +1923,10 @@ export function toJsonExpr(spec: PayloadFieldSpec, accessor: string): string {
1908
1923
  return accessor;
1909
1924
  case "enum":
1910
1925
  return `${accessor}.wire`;
1926
+ case "vo":
1927
+ return `${accessor}.toJson()`;
1928
+ case "voList":
1929
+ return `${accessor}.map((e) => e.toJson()).toList()`;
1911
1930
  }
1912
1931
  }
1913
1932
 
@@ -2942,7 +2961,14 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
2942
2961
  const skipValueObjectNames = opts.skipValueObjectNames ?? new Set<string>();
2943
2962
  // VALUE OBJECTS: one generated class per named VO the module carries (composeDomainModule
2944
2963
  // collected + sorted them), minus the package-shared ones (emitted once in the framework file).
2945
- const localValueObjects = (mod.valueObjects ?? []).filter((d) => !skipValueObjectNames.has(d.name));
2964
+ // Payload-only VOs (a VO used in a directive payload but on no aggregate field) still need
2965
+ // their generated class — collected from the branded payload schemas, deduped by name against
2966
+ // the aggregate-derived set. Codegen-only: the IR key stays aggregate-derived (era rule).
2967
+ const aggVoNames = new Set((mod.valueObjects ?? []).map((d) => d.name));
2968
+ const payloadVos = collectPayloadValueObjectDescriptors(mod.directives ?? []).filter((d) => !aggVoNames.has(d.name));
2969
+ const localValueObjects = [...(mod.valueObjects ?? []), ...payloadVos]
2970
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
2971
+ .filter((d) => !skipValueObjectNames.has(d.name));
2946
2972
  // Each VO's block = its FIELD ENUMS (real Dart enums, tolerant fromWire) then its class —
2947
2973
  // so skipping a shared VO (the framework-file dedup) skips its enums with it.
2948
2974
  const voBlock =
@@ -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,17 @@ 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
+ }
174
185
  }
175
186
  }
176
187
 
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") {
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";
@@ -476,7 +485,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
476
485
  // ── value objects: one interface per named VO, deduped across the package's domains ──
477
486
  const voByName = new Map<string, ValueObjectDescriptor>();
478
487
  for (const mod of modules) {
479
- for (const d of (mod as { valueObjects?: readonly ValueObjectDescriptor[] }).valueObjects ?? []) {
488
+ // aggregate-carried VOs ∪ payload-only VOs (branded schemas) — both need their interface.
489
+ const payloadVos = collectPayloadValueObjectDescriptors(
490
+ ((mod as { directives?: readonly { payloadSchema?: unknown }[] }).directives ?? []),
491
+ );
492
+ for (const d of [...((mod as { valueObjects?: readonly ValueObjectDescriptor[] }).valueObjects ?? []), ...payloadVos]) {
480
493
  const existing = voByName.get(d.name);
481
494
  if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(d)) {
482
495
  throw new Error(
package/src/fields.ts CHANGED
@@ -126,6 +126,9 @@ export interface ValueObjectFieldMeta {
126
126
  readonly dependencies: readonly ValueObjectDescriptor[];
127
127
  /** Present iff this VO is a `geo.*` geometry (the spatial-index pairing check). */
128
128
  readonly geometry?: GeometryKind;
129
+ /** The author-supplied lawful example (validated against the VO's own zod at declaration).
130
+ * Proof synthesis + docs prefer it over per-field synthesis. */
131
+ readonly example?: unknown;
129
132
  }
130
133
 
131
134
  export interface Field<T = unknown, K extends FieldKind = FieldKind> {
package/src/index.ts CHANGED
@@ -47,6 +47,9 @@ export {
47
47
  // `geo.*` are the RFC 7946 geometry VOs (pair `geo.point()` with `spatial(...)`).
48
48
  export {
49
49
  valueObject,
50
+ voMetaOf,
51
+ voSample,
52
+ NOMOS_VO_BRAND,
50
53
  geo,
51
54
  type ValueObject,
52
55
  type ValueObjectValue,
@@ -80,6 +80,11 @@ function payloadFieldKind(spec: PayloadFieldSpec): string {
80
80
  return "set";
81
81
  case "json":
82
82
  return "json";
83
+ case "vo":
84
+ case "voList":
85
+ // Interface kinds stay at the base lane for now (slice D of vo_authoring_end_to_end.md
86
+ // upgrades these to a first-class "vo" kind) — a VO IS a json leaf structurally.
87
+ return "json";
83
88
  case "scalar":
84
89
  default:
85
90
  switch (spec.dartType) {
@@ -226,12 +226,93 @@ function describeZod(voName: string, path: string, schema: z.ZodTypeAny): ValueO
226
226
 
227
227
  const VO_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
228
228
 
229
+ /**
230
+ * THE VO BRAND (slice A of vo_authoring_end_to_end.md) — `valueObject()` stamps its zod schema
231
+ * with the VO's {@link ValueObjectFieldMeta} under this symbol, COMPILE-TIME ONLY: the brand is
232
+ * a non-enumerable property, never serialized, never read by the engine's validation path, so a
233
+ * branded schema's package bytes (and domainHash) are byte-identical to an unbranded one.
234
+ * Every generated-surface introspector (Dart/TS payload gen, interface kinds, proof synthesis)
235
+ * consults {@link voMetaOf} BEFORE its raw zod-kind switch — the VO keeps its NAME on every
236
+ * surface instead of degrading to an anonymous json object.
237
+ */
238
+ export const NOMOS_VO_BRAND: unique symbol = Symbol.for("nomos.vo");
239
+
240
+ /**
241
+ * Resolve the value-object identity of a zod schema, unwrapping `.optional()` / `.default()`
242
+ * wrappers (the brand lives on the VO's own object schema). `undefined` for anything that is
243
+ * not a declared VO — callers then fall through to their raw-zod lane, so non-VO behaviour is
244
+ * untouched.
245
+ */
246
+ export function voMetaOf(zt: z.ZodTypeAny): ValueObjectFieldMeta | 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_VO_BRAND];
250
+ if (branded !== undefined) return branded as ValueObjectFieldMeta;
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
+
258
+ /** The blessed lawful sample per RFC 7946 geometry kind (proof synthesis + docs). */
259
+ const GEOMETRY_SAMPLES: Record<GeometryKind, unknown> = {
260
+ Point: { type: "Point", coordinates: [0, 0] },
261
+ LineString: { type: "LineString", coordinates: [[0, 0], [1, 1]] },
262
+ Polygon: { type: "Polygon", coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]] },
263
+ };
264
+
265
+ function sampleOfDescriptorField(
266
+ f: ValueObjectFieldDescriptor,
267
+ fieldName: string,
268
+ byName: Map<string, ValueObjectDescriptor>,
269
+ ): unknown {
270
+ switch (f.type) {
271
+ case "string": return `sample-${fieldName}`;
272
+ case "number": return 1;
273
+ case "boolean": return true;
274
+ case "enum": return f.values[0];
275
+ case "array": return [sampleOfDescriptorField(f.items, fieldName, byName)];
276
+ case "geometry": return GEOMETRY_SAMPLES[f.geometry];
277
+ case "object": {
278
+ const dep = byName.get(f.ref);
279
+ if (dep === undefined) throw new Error(`voSample: nested value object '${f.ref}' has no descriptor`);
280
+ return sampleOfDescriptor(dep, byName);
281
+ }
282
+ }
283
+ }
284
+
285
+ function sampleOfDescriptor(d: ValueObjectDescriptor, byName: Map<string, ValueObjectDescriptor>): unknown {
286
+ if (d.geometry !== undefined) return GEOMETRY_SAMPLES[d.geometry];
287
+ const out: Record<string, unknown> = {};
288
+ for (const [k, f] of Object.entries(d.fields ?? {})) {
289
+ if (f.optional === true) continue; // optional → omitted (mirrors payload synthesis)
290
+ out[k] = sampleOfDescriptorField(f, k, byName);
291
+ }
292
+ return out;
293
+ }
294
+
295
+ /**
296
+ * A LAWFUL sample value for a VO (slice C): the author's validated `example` when given, the
297
+ * blessed geometry sample for `geo.*` kinds, else per-field synthesis (recursing into nested
298
+ * VOs). Proof synthesis calls this instead of guessing at the raw zod — which is exactly the
299
+ * lane that refused its own sample on constrained shapes (geo min-2 coordinates).
300
+ */
301
+ export function voSample(meta: ValueObjectFieldMeta): unknown {
302
+ if (meta.example !== undefined) return meta.example;
303
+ const byName = new Map<string, ValueObjectDescriptor>();
304
+ for (const d of meta.dependencies) byName.set(d.name, d);
305
+ byName.set(meta.descriptor.name, meta.descriptor);
306
+ return sampleOfDescriptor(meta.descriptor, byName);
307
+ }
308
+
229
309
  function makeValueObject<T>(
230
310
  name: string,
231
311
  zodSchema: z.ZodType<T>,
232
312
  descriptor: ValueObjectDescriptor,
233
313
  dependencies: readonly ValueObjectDescriptor[],
234
314
  geometry?: GeometryKind,
315
+ example?: T,
235
316
  ): ValueObject<T> {
236
317
  if (!VO_NAME_RE.test(name)) {
237
318
  throw new Error(
@@ -239,12 +320,25 @@ function makeValueObject<T>(
239
320
  `generated TS interface / Dart class name.`,
240
321
  );
241
322
  }
323
+ if (example !== undefined) {
324
+ const v = zodSchema.safeParse(example);
325
+ if (!v.success) {
326
+ throw new Error(
327
+ `valueObject '${name}': the declared example does not satisfy the VO's own schema — ` +
328
+ `a lying example would poison proof synthesis. ${v.error.message}`,
329
+ );
330
+ }
331
+ }
242
332
  const meta: ValueObjectFieldMeta = {
243
333
  name,
244
334
  descriptor,
245
335
  dependencies,
246
336
  ...(geometry !== undefined ? { geometry } : {}),
337
+ ...(example !== undefined ? { example } : {}),
247
338
  };
339
+ // THE BRAND (slice A): compile-time identity on the schema itself — non-enumerable, never
340
+ // serialized, domainHash-inert. voMetaOf() is the one resolver every introspector uses.
341
+ Object.defineProperty(zodSchema, NOMOS_VO_BRAND, { value: meta, enumerable: false });
248
342
  const vo: ValueObject<T> = {
249
343
  __isValueObject: true,
250
344
  name,
@@ -272,6 +366,13 @@ function makeValueObject<T>(
272
366
  export function valueObject<const Name extends string, S extends Record<string, ValueObjectShapeEntry>>(
273
367
  name: Name,
274
368
  shape: S,
369
+ opts?: {
370
+ /** A lawful example value, VALIDATED against the VO's own schema at declaration (a lying
371
+ * example throws here, loudly). Proof synthesis prefers it over per-field synthesis — the
372
+ * escape hatch for constraint-rich shapes (refinements, cross-field rules) a synthesizer
373
+ * cannot guess. */
374
+ example?: ShapeValue<S>;
375
+ },
275
376
  ): ValueObject<ShapeValue<S>> {
276
377
  const zodShape: Record<string, z.ZodTypeAny> = {};
277
378
  const fields: Record<string, ValueObjectFieldDescriptor> = {};
@@ -308,6 +409,8 @@ export function valueObject<const Name extends string, S extends Record<string,
308
409
  z.object(zodShape) as unknown as z.ZodType<ShapeValue<S>>,
309
410
  descriptor,
310
411
  dependencies,
412
+ undefined,
413
+ opts?.example,
311
414
  );
312
415
  }
313
416
 
@@ -388,6 +491,46 @@ export const geo = {
388
491
  * descriptors throw (one name, one shape). Returns `[]` when no field carries a VO —
389
492
  * the omit-when-empty input to the `nomosValueObjects` era key.
390
493
  */
494
+ /**
495
+ * Collect the value objects referenced by DIRECTIVE PAYLOADS (top-level fields + array
496
+ * elements), via the {@link NOMOS_VO_BRAND} the schemas carry. CODEGEN-ONLY (slice B of
497
+ * vo_authoring_end_to_end.md): the result feeds the generated Dart/TS surfaces so a
498
+ * payload-only VO still gets its class/interface — it does NOT enter the `nomosValueObjects`
499
+ * IR key (that stays aggregate-derived, keeping package bytes / domainHash untouched).
500
+ */
501
+ export function collectPayloadValueObjectDescriptors(
502
+ directives: readonly { payloadSchema?: unknown }[],
503
+ ): ValueObjectDescriptor[] {
504
+ const byName = new Map<string, ValueObjectDescriptor>();
505
+ const take = (meta: ValueObjectFieldMeta | undefined) => {
506
+ if (meta === undefined) return;
507
+ for (const d of [meta.descriptor, ...meta.dependencies]) {
508
+ const existing = byName.get(d.name);
509
+ if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(d)) {
510
+ throw new Error(`value object '${d.name}' has DIVERGENT shapes across payloads — one name, one shape.`);
511
+ }
512
+ byName.set(d.name, d);
513
+ }
514
+ };
515
+ for (const dir of directives) {
516
+ const schema = dir?.payloadSchema as z.ZodTypeAny | undefined;
517
+ if (!schema || typeof schema !== "object" || zodKind(schema) !== "object") continue;
518
+ const rawShape = (zodDef(schema) as { shape?: Record<string, z.ZodTypeAny> | (() => Record<string, z.ZodTypeAny>) }).shape
519
+ ?? (schema as unknown as { shape?: Record<string, z.ZodTypeAny> }).shape;
520
+ const shape = typeof rawShape === "function" ? rawShape() : rawShape ?? {};
521
+ for (const raw of Object.values(shape)) {
522
+ let zt = raw as z.ZodTypeAny;
523
+ take(voMetaOf(zt));
524
+ // unwrap optional/default to reach an array of VOs
525
+ for (let hops = 0; hops < 8 && (zodKind(zt) === "optional" || zodKind(zt) === "default"); hops++) {
526
+ zt = zodDef(zt).innerType!;
527
+ }
528
+ if (zodKind(zt) === "array") take(voMetaOf(zodArrayElement(zt)));
529
+ }
530
+ }
531
+ return [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
532
+ }
533
+
391
534
  export function collectValueObjectDescriptors(
392
535
  aggregates: readonly { id: string; fields: Record<string, Field> }[],
393
536
  ): ValueObjectDescriptor[] {