@githolon/dsl 0.54.1 → 0.55.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/dart/lib/nomos_client.dart +3 -0
- package/dart/lib/src/null_slot.dart +128 -0
- package/dart/test/null_slot_test.dart +51 -0
- package/package.json +1 -1
- package/src/authoring.ts +19 -0
- package/src/build_package.ts +5 -0
- package/src/clear_directive.ts +103 -0
- package/src/codegen_dart.ts +370 -30
- package/src/codegen_openapi.ts +23 -4
- package/src/codegen_proof.ts +73 -5
- package/src/codegen_ts.ts +150 -12
- package/src/compile_package_main.ts +20 -1
- package/src/fields.ts +221 -5
- package/src/index.ts +10 -0
- package/src/law_check.ts +183 -1
- package/src/null_meta.ts +65 -0
- package/src/ops.ts +32 -0
- package/src/wire_encode.ts +42 -2
- package/src/zod_lazy.ts +45 -0
package/src/codegen_proof.ts
CHANGED
|
@@ -60,8 +60,33 @@ import {
|
|
|
60
60
|
zodEnumValues,
|
|
61
61
|
zodKind,
|
|
62
62
|
zodObjectShape,
|
|
63
|
+
zodUnwrapLazy,
|
|
63
64
|
} from "./codegen_ts.js";
|
|
64
65
|
|
|
66
|
+
/** Shared empty visited-set for the lazy-recursion guard (no allocation on the common path). */
|
|
67
|
+
const EMPTY_LAZY_VISITED: ReadonlySet<z.ZodTypeAny> = new Set();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The top-level payload fields that ADMIT a domain `null` (their zod is `.nullable()`, a
|
|
71
|
+
* `t.nullable(...)`/`t.clearable(...)` sink — null_semantics.md). The proof drives the NULL ARM for
|
|
72
|
+
* each (the value arm is the normal synthesized sample) so the law is provably total over `null`.
|
|
73
|
+
* A nullable wrapped in optional/default still counts (unwrapped in ANY order). Empty for a
|
|
74
|
+
* null-free payload — no extra leg, byte-identical proof. */
|
|
75
|
+
export function nullablePayloadFields(schema: z.ZodTypeAny): string[] {
|
|
76
|
+
if (zodKind(schema) !== "object") return [];
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
for (const [name, raw] of Object.entries(zodObjectShape(schema))) {
|
|
79
|
+
let f = raw as z.ZodTypeAny;
|
|
80
|
+
let admits = false;
|
|
81
|
+
while (zodKind(f) === "optional" || zodKind(f) === "default" || zodKind(f) === "nullable") {
|
|
82
|
+
if (zodKind(f) === "nullable") admits = true;
|
|
83
|
+
f = zodDef(f).innerType!;
|
|
84
|
+
}
|
|
85
|
+
if (admits) out.push(name);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
|
|
66
91
|
const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
|
|
67
92
|
|
|
@@ -124,7 +149,28 @@ function joinIssuePath(base: string, segs: readonly PropertyKey[]): string {
|
|
|
124
149
|
* names the exact field (`controlPoints[0].geoPoint.latitude`, never a bare leaf or "field ?"). NO
|
|
125
150
|
* FALLBACK: an unhandled shape throws, naming the path and the remedy.
|
|
126
151
|
*/
|
|
127
|
-
function sampleOfZod(
|
|
152
|
+
function sampleOfZod(
|
|
153
|
+
zt: z.ZodTypeAny,
|
|
154
|
+
path: string,
|
|
155
|
+
where: string,
|
|
156
|
+
voIncludeOptional = false,
|
|
157
|
+
visited: ReadonlySet<z.ZodTypeAny> = EMPTY_LAZY_VISITED,
|
|
158
|
+
): unknown {
|
|
159
|
+
// RECURSIVE `z.lazy` (a self-referencing tree/JSON payload): unwrap one level, tracking the
|
|
160
|
+
// lazy object's IDENTITY on this descent. Re-entering the SAME lazy is a knot: sampling can go
|
|
161
|
+
// no deeper. The common case terminates NATURALLY one level up (the recursive branch is an
|
|
162
|
+
// OPTIONAL field, which the object case omits, or an ARRAY, which emits `[]`); only a REQUIRED
|
|
163
|
+
// non-array self-reference is genuinely unsampleable, and THAT throws with the exact path.
|
|
164
|
+
if (zodKind(zt) === "lazy") {
|
|
165
|
+
if (visited.has(zt)) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`codegen-proof: ${where} — field '${path}': recursive z.lazy schema — a REQUIRED self-reference makes a finite sample impossible; make the recursive field \`.optional()\` (proof omits it) or wrap it in an array (proof emits \`[]\`), or prove this directive by hand in test/e2e.mts`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const next = new Set(visited);
|
|
171
|
+
next.add(zt);
|
|
172
|
+
return sampleOfZod(zodUnwrapLazy(zt), path, where, voIncludeOptional, next);
|
|
173
|
+
}
|
|
128
174
|
// VALUE OBJECTS FIRST (vo_authoring_end_to_end.md slice C): a declared VO carries its own lawful
|
|
129
175
|
// sample (the author's validated `example`, the blessed geometry sample, or per-field descriptor
|
|
130
176
|
// synthesis, recursing through nested VOs / arrays-of-VOs / unions). The raw-zod walk below cannot
|
|
@@ -172,14 +218,17 @@ function sampleOfZod(zt: z.ZodTypeAny, path: string, where: string, voIncludeOpt
|
|
|
172
218
|
// A one-element array satisfies `.min(1)` coverage; a string element keeps the readable
|
|
173
219
|
// `sample-a` (constraint-probed) so golden outputs stay stable.
|
|
174
220
|
if (zodKind(el) === "string") return [sampleString(el, "a")];
|
|
175
|
-
|
|
221
|
+
// A recursive array element (its lazy is already on this descent) bottoms out as `[]` —
|
|
222
|
+
// an empty array is a valid finite sample and breaks the recursion (depth-bound at 1).
|
|
223
|
+
if (zodKind(el) === "lazy" && visited.has(el)) return [];
|
|
224
|
+
return [sampleOfZod(el, `${path}[0]`, where, voIncludeOptional, visited)];
|
|
176
225
|
}
|
|
177
226
|
case "object": {
|
|
178
227
|
const out: Record<string, unknown> = {};
|
|
179
228
|
for (const [name, raw] of Object.entries(zodObjectShape(zt))) {
|
|
180
229
|
const k = zodKind(raw as z.ZodTypeAny);
|
|
181
230
|
if (k === "optional" || k === "default") continue; // optional → omit
|
|
182
|
-
out[name] = sampleOfZod(raw as z.ZodTypeAny, joinIssuePath(path, [name]), where, voIncludeOptional);
|
|
231
|
+
out[name] = sampleOfZod(raw as z.ZodTypeAny, joinIssuePath(path, [name]), where, voIncludeOptional, visited);
|
|
183
232
|
}
|
|
184
233
|
return out;
|
|
185
234
|
}
|
|
@@ -188,10 +237,10 @@ function sampleOfZod(zt: z.ZodTypeAny, path: string, where: string, voIncludeOpt
|
|
|
188
237
|
case "nullable":
|
|
189
238
|
case "optional":
|
|
190
239
|
case "default":
|
|
191
|
-
return sampleOfZod(zodDef(zt).innerType!, path, where, voIncludeOptional);
|
|
240
|
+
return sampleOfZod(zodDef(zt).innerType!, path, where, voIncludeOptional, visited);
|
|
192
241
|
case "union": {
|
|
193
242
|
const first = zodDef(zt).options?.[0];
|
|
194
|
-
if (first !== undefined) return sampleOfZod(first, path, where, voIncludeOptional);
|
|
243
|
+
if (first !== undefined) return sampleOfZod(first, path, where, voIncludeOptional, visited);
|
|
195
244
|
throw new Error(`codegen-proof: ${where} — field '${path}': an empty union has no arm to sample`);
|
|
196
245
|
}
|
|
197
246
|
default:
|
|
@@ -2592,6 +2641,25 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
|
|
|
2592
2641
|
);
|
|
2593
2642
|
}
|
|
2594
2643
|
|
|
2644
|
+
// NULL SEMANTICS BOTH-ARM PROOF (null_semantics.md): the primary dispatch above already proved the
|
|
2645
|
+
// VALUE arm (a nullable field synthesizes to a sampled value); here we prove the NULL arm — the law
|
|
2646
|
+
// ADMITS an explicit `null` for each nullable field, so it is provably total over null. Each create
|
|
2647
|
+
// arm mints a fresh id. Empty for a null-free payload (byte-identical proof).
|
|
2648
|
+
const nullArms = nullablePayloadFields(createDir.payloadSchema as z.ZodTypeAny);
|
|
2649
|
+
for (const field of nullArms) {
|
|
2650
|
+
const armMint = mintField !== undefined ? `, ${JSON.stringify(mintField)}: await holon.mint(${JSON.stringify(agg.id)})` : "";
|
|
2651
|
+
out.push(
|
|
2652
|
+
`// ${n()}. NULL-ARM PROOF — \`${field}\` is nullable: the law must ADMIT an explicit null (both arms proven).`,
|
|
2653
|
+
`try {`,
|
|
2654
|
+
` await app.${createDir.id}({ ...${JSON.stringify(baseValue)}${armMint}, ${JSON.stringify(field)}: null });`,
|
|
2655
|
+
` ok(${JSON.stringify(`null-arm — ${domain}/${createDir.id} admitted with \`${field}\`=null (the law handles the explicit-null arm)`)});`,
|
|
2656
|
+
`} catch (e) {`,
|
|
2657
|
+
` fail(\`null-arm ${field}=null was REFUSED — a nullable field's null arm must be admitted: \${(e as Error).message}\`);`,
|
|
2658
|
+
`}`,
|
|
2659
|
+
``,
|
|
2660
|
+
);
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2595
2663
|
out.push(
|
|
2596
2664
|
`globalThis.fetch = realFetch;`,
|
|
2597
2665
|
`ok("offline proof ENFORCED — fetch was trapped: local write/read touched no network");`,
|
package/src/codegen_ts.ts
CHANGED
|
@@ -43,12 +43,14 @@ import type { DomainModule } from "./codegen_dart.js";
|
|
|
43
43
|
import type { NomosCompat } from "./compat_manifest.js";
|
|
44
44
|
import type { QueryDecl } from "./query.js";
|
|
45
45
|
import { voMetaOf, collectPayloadValueObjectDescriptors } from "./value_object.js";
|
|
46
|
+
import { recursiveTypeBaseName, zodUnwrapLazy } from "./zod_lazy.js";
|
|
46
47
|
import { finishCount } from "./count.js";
|
|
47
48
|
import { finishSum } from "./sum.js";
|
|
48
49
|
import type { DerivedDecl } from "./derived.js";
|
|
49
50
|
import type { CombinedDecl } from "./combined.js";
|
|
50
51
|
import { resolveWorkspaceTypes, type ResolvedWorkspaceType, type WorkspaceTypeDecl } from "./workspace_type.js";
|
|
51
52
|
import { deriveClientMintPlan, type ClientMintPlanEntry } from "./workspace_routing.js";
|
|
53
|
+
import { admitsNull, nullDocLine } from "./null_meta.js";
|
|
52
54
|
|
|
53
55
|
const cap = (s: string) => (s.length ? s[0]!.toUpperCase() + s.slice(1) : s);
|
|
54
56
|
const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
|
|
@@ -206,8 +208,13 @@ type ZodInternalDef = {
|
|
|
206
208
|
value?: unknown;
|
|
207
209
|
values?: readonly unknown[];
|
|
208
210
|
valueType?: z.ZodTypeAny;
|
|
211
|
+
getter?: () => z.ZodTypeAny; // `z.lazy(() => …)` — the deferred inner schema (zod v4)
|
|
209
212
|
};
|
|
210
213
|
|
|
214
|
+
// The lazy-unwrap + recursive-name helpers live in the dependency-free `zod_lazy.ts` (shared with
|
|
215
|
+
// codegen_dart WITHOUT an import cycle). Re-exported so codegen_proof's existing import holds.
|
|
216
|
+
export { zodUnwrapLazy };
|
|
217
|
+
|
|
211
218
|
export function zodDef(zt: z.ZodTypeAny): ZodInternalDef {
|
|
212
219
|
const raw = zt as unknown as { _def?: ZodInternalDef; def?: ZodInternalDef };
|
|
213
220
|
return raw._def ?? raw.def ?? {};
|
|
@@ -245,8 +252,44 @@ export function zodArrayElement(zt: z.ZodTypeAny): z.ZodTypeAny {
|
|
|
245
252
|
const union = (parts: string[]) => [...new Set(parts)].join(" | ");
|
|
246
253
|
const paren = (t: string) => (t.includes("|") || t.includes("&") ? `(${t})` : t);
|
|
247
254
|
|
|
248
|
-
/**
|
|
249
|
-
|
|
255
|
+
/**
|
|
256
|
+
* THE RECURSIVE-TYPE REGISTRY (`z.lazy` full support). A recursive payload schema (a `TreeNode`
|
|
257
|
+
* that references itself) can't be a finite inline literal — so at each lazy KNOT we MINT a stable
|
|
258
|
+
* named type alias (from the payload path — deterministic across compiles), emit its body ONCE, and
|
|
259
|
+
* reference the name everywhere it recurs. TS resolves named recursion natively for every shape, so
|
|
260
|
+
* NOTHING degrades: `type SaveTreeRoot = { label: string; children?: SaveTreeRoot[] }`. The `names`
|
|
261
|
+
* map (keyed on the lazy's object IDENTITY) doubles as the cycle guard — a re-encountered lazy
|
|
262
|
+
* returns its already-minted name. Mutual recursion A↔B mints two names by first-encounter path. */
|
|
263
|
+
interface LazyTypeRegistry {
|
|
264
|
+
readonly names: Map<z.ZodTypeAny, string>; // lazy identity → minted type name (also the guard)
|
|
265
|
+
readonly decls: Map<string, string>; // name → `export type <Name> = <body>;` (dedup, deterministic)
|
|
266
|
+
readonly used: Set<string>; // all minted names — collision-avoidance across knots
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function newLazyRegistry(): LazyTypeRegistry {
|
|
270
|
+
return { names: new Map(), decls: new Map(), used: new Set() };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Mint a UNIQUE PascalCase type name from a payload path; collisions get a numeric suffix. */
|
|
274
|
+
function mintLazyTypeName(reg: LazyTypeRegistry, path: string): string {
|
|
275
|
+
const base = recursiveTypeBaseName(path);
|
|
276
|
+
let name = base;
|
|
277
|
+
for (let i = 2; reg.used.has(name); i++) name = `${base}${i}`;
|
|
278
|
+
reg.used.add(name);
|
|
279
|
+
return name;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Recursive zod → TS type text. NO FALLBACK for an unknown shape (it throws). A RECURSIVE
|
|
283
|
+
* `z.lazy` knot is FULLY SUPPORTED: it mints a named type alias into `reg` and references it
|
|
284
|
+
* (never `JsonValue`) — see {@link LazyTypeRegistry}. `reg` is absent only for standalone callers
|
|
285
|
+
* (e.g. a query return) that can't host a named decl; a recursive lazy there is the one place the
|
|
286
|
+
* `JsonValue` safety valve applies (query returns are read models, never recursive in practice). */
|
|
287
|
+
function tsTypeOfZod(
|
|
288
|
+
zt: z.ZodTypeAny,
|
|
289
|
+
indent: string,
|
|
290
|
+
reg?: LazyTypeRegistry,
|
|
291
|
+
path = "",
|
|
292
|
+
): string {
|
|
250
293
|
// A DECLARED VALUE OBJECT keeps its NAME (vo_authoring_end_to_end.md slice B): the payload
|
|
251
294
|
// references the same exported interface the read model uses — one name, both directions —
|
|
252
295
|
// instead of an anonymous inline literal type. Checked on the BARE object only: optional/
|
|
@@ -256,6 +299,16 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
|
|
|
256
299
|
if (vo !== undefined) return vo.name;
|
|
257
300
|
}
|
|
258
301
|
switch (zodKind(zt)) {
|
|
302
|
+
case "lazy": {
|
|
303
|
+
if (reg === undefined) return "JsonValue"; // safety valve (no host for a named decl)
|
|
304
|
+
const existing = reg.names.get(zt);
|
|
305
|
+
if (existing !== undefined) return existing; // already minted (the cycle guard)
|
|
306
|
+
const name = mintLazyTypeName(reg, path);
|
|
307
|
+
reg.names.set(zt, name); // register BEFORE recursing so self-references resolve to the name
|
|
308
|
+
const body = tsTypeOfZod(zodUnwrapLazy(zt), "", reg, path);
|
|
309
|
+
reg.decls.set(name, `export type ${name} = ${body};`);
|
|
310
|
+
return name;
|
|
311
|
+
}
|
|
259
312
|
case "string":
|
|
260
313
|
return "string";
|
|
261
314
|
case "number":
|
|
@@ -270,7 +323,7 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
|
|
|
270
323
|
return JSON.stringify(v);
|
|
271
324
|
}
|
|
272
325
|
case "array":
|
|
273
|
-
return `${paren(tsTypeOfZod(zodArrayElement(zt), indent))}[]`;
|
|
326
|
+
return `${paren(tsTypeOfZod(zodArrayElement(zt), indent, reg, `${path}[]`))}[]`;
|
|
274
327
|
case "object": {
|
|
275
328
|
const shape = zodObjectShape(zt);
|
|
276
329
|
const inner = indent + " ";
|
|
@@ -281,21 +334,21 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
|
|
|
281
334
|
optional = true;
|
|
282
335
|
f = zodDef(f).innerType!;
|
|
283
336
|
}
|
|
284
|
-
return `${inner}${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, inner)};`;
|
|
337
|
+
return `${inner}${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, inner, reg, path === "" ? name : `${path}.${name}`)};`;
|
|
285
338
|
});
|
|
286
339
|
return fields.length ? `{\n${fields.join("\n")}\n${indent}}` : "Record<string, never>";
|
|
287
340
|
}
|
|
288
341
|
case "record": {
|
|
289
342
|
const vt = zodDef(zt).valueType;
|
|
290
|
-
return `Record<string, ${vt ? tsTypeOfZod(vt, indent) : "unknown"}>`;
|
|
343
|
+
return `Record<string, ${vt ? tsTypeOfZod(vt, indent, reg, `${path}.*`) : "unknown"}>`;
|
|
291
344
|
}
|
|
292
345
|
case "optional":
|
|
293
346
|
case "default":
|
|
294
|
-
return `${tsTypeOfZod(zodDef(zt).innerType!, indent)} | undefined`;
|
|
347
|
+
return `${tsTypeOfZod(zodDef(zt).innerType!, indent, reg, path)} | undefined`;
|
|
295
348
|
case "nullable":
|
|
296
|
-
return `${tsTypeOfZod(zodDef(zt).innerType!, indent)} | null`;
|
|
349
|
+
return `${tsTypeOfZod(zodDef(zt).innerType!, indent, reg, path)} | null`;
|
|
297
350
|
case "union":
|
|
298
|
-
return union((zodDef(zt).options ?? []).map((o) => tsTypeOfZod(o, indent)));
|
|
351
|
+
return union((zodDef(zt).options ?? []).map((o) => tsTypeOfZod(o, indent, reg, path)));
|
|
299
352
|
case "unknown":
|
|
300
353
|
case "any":
|
|
301
354
|
return "unknown";
|
|
@@ -304,6 +357,57 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
|
|
|
304
357
|
}
|
|
305
358
|
}
|
|
306
359
|
|
|
360
|
+
/**
|
|
361
|
+
* The RECURSIVE `z.lazy` knots in one directive payload, each reported as `{ path, name }` where
|
|
362
|
+
* `name` is the stable generated type minted for it (the SAME name {@link tsTypeOfZod} emits — the
|
|
363
|
+
* mint is deterministic from the path). Surfaced as a compile-summary NOTE so the tenant sees which
|
|
364
|
+
* fields became named recursive types (NOT a degrade — recursion is fully supported). `pathPrefix`
|
|
365
|
+
* (the directive id) scopes the minted names, matching the client's package-level registry. Empty
|
|
366
|
+
* for a lazy-free payload. */
|
|
367
|
+
export function recursiveLazyTypes(schema: z.ZodTypeAny, pathPrefix = ""): { path: string; name: string }[] {
|
|
368
|
+
const reg = newLazyRegistry();
|
|
369
|
+
const out: { path: string; name: string }[] = [];
|
|
370
|
+
const walk = (zt: z.ZodTypeAny, path: string): void => {
|
|
371
|
+
switch (zodKind(zt)) {
|
|
372
|
+
case "lazy": {
|
|
373
|
+
if (reg.names.has(zt)) return; // already recorded (the cycle guard)
|
|
374
|
+
const name = mintLazyTypeName(reg, path);
|
|
375
|
+
reg.names.set(zt, name);
|
|
376
|
+
out.push({ path, name });
|
|
377
|
+
walk(zodUnwrapLazy(zt), path);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
case "array":
|
|
381
|
+
walk(zodArrayElement(zt), `${path}[]`);
|
|
382
|
+
return;
|
|
383
|
+
case "object": {
|
|
384
|
+
if (voMetaOf(zt) !== undefined) return; // a VO is an opaque leaf — don't descend
|
|
385
|
+
for (const [name, raw] of Object.entries(zodObjectShape(zt))) {
|
|
386
|
+
let f = raw as z.ZodTypeAny;
|
|
387
|
+
while (zodKind(f) === "optional" || zodKind(f) === "default") f = zodDef(f).innerType!;
|
|
388
|
+
walk(f, path === "" ? name : `${path}.${name}`);
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
case "optional":
|
|
393
|
+
case "default":
|
|
394
|
+
case "nullable":
|
|
395
|
+
walk(zodDef(zt).innerType!, path);
|
|
396
|
+
return;
|
|
397
|
+
case "record":
|
|
398
|
+
if (zodDef(zt).valueType) walk(zodDef(zt).valueType!, `${path}.*`);
|
|
399
|
+
return;
|
|
400
|
+
case "union":
|
|
401
|
+
for (const o of zodDef(zt).options ?? []) walk(o, path);
|
|
402
|
+
return;
|
|
403
|
+
default:
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
walk(schema, pathPrefix);
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
307
411
|
// ── caller-side clock sugar (the `…At` payload convention) ────────────────────────
|
|
308
412
|
|
|
309
413
|
/**
|
|
@@ -325,15 +429,19 @@ export function autoStampFields(schema: z.ZodTypeAny): string[] {
|
|
|
325
429
|
return out;
|
|
326
430
|
}
|
|
327
431
|
|
|
328
|
-
/** The payload type text, with auto-stamped / front-door-minted / auto-placed fields optional (`?`).
|
|
432
|
+
/** The payload type text, with auto-stamped / front-door-minted / auto-placed fields optional (`?`).
|
|
433
|
+
* `reg` collects any RECURSIVE `z.lazy` named types minted while walking (emitted once by the
|
|
434
|
+
* caller); `pathPrefix` (the directive id) scopes their names so two directives can't collide. */
|
|
329
435
|
function tsTypeOfPayload(
|
|
330
436
|
schema: z.ZodTypeAny,
|
|
331
437
|
autoStamped: readonly string[],
|
|
332
438
|
minted: readonly string[] = [],
|
|
333
439
|
autoPlaced: readonly string[] = [],
|
|
440
|
+
reg?: LazyTypeRegistry,
|
|
441
|
+
pathPrefix = "",
|
|
334
442
|
): string {
|
|
335
443
|
if (zodKind(schema) !== "object" || (autoStamped.length === 0 && minted.length === 0 && autoPlaced.length === 0)) {
|
|
336
|
-
return tsTypeOfZod(schema, "");
|
|
444
|
+
return tsTypeOfZod(schema, "", reg, pathPrefix);
|
|
337
445
|
}
|
|
338
446
|
const stamped = new Set(autoStamped);
|
|
339
447
|
const mintedSet = new Set(minted);
|
|
@@ -352,7 +460,8 @@ function tsTypeOfPayload(
|
|
|
352
460
|
: placedSet.has(name)
|
|
353
461
|
? " // picked LEAST-LOADED over the coordinator's law-state at offer when omitted (sharding §3)"
|
|
354
462
|
: "";
|
|
355
|
-
|
|
463
|
+
const fieldPath = pathPrefix === "" ? name : `${pathPrefix}.${name}`;
|
|
464
|
+
return ` ${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, " ", reg, fieldPath)};${note}`;
|
|
356
465
|
});
|
|
357
466
|
return fields.length ? `{\n${fields.join("\n")}\n}` : "Record<string, never>";
|
|
358
467
|
}
|
|
@@ -360,6 +469,14 @@ function tsTypeOfPayload(
|
|
|
360
469
|
// ── DSL Field → read-model TS type (the projection's decode surface) ──────────────
|
|
361
470
|
|
|
362
471
|
function tsTypeOfField(field: Field): string {
|
|
472
|
+
const base = tsBaseTypeOfField(field);
|
|
473
|
+
// NULL SEMANTICS (null_semantics.md): a `nullable`/`explicit-empty` field ADMITS a domain `null`,
|
|
474
|
+
// so its read-model type carries the `| null` arm (distinct from the `?` partial-fold absence a
|
|
475
|
+
// non-null-admitting field gets). A `forbid` json/VO field NEVER gets the null arm.
|
|
476
|
+
return admitsNull(field) ? `${base} | null` : base;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function tsBaseTypeOfField(field: Field): string {
|
|
363
480
|
switch (field.kind) {
|
|
364
481
|
case "string":
|
|
365
482
|
return "string";
|
|
@@ -564,6 +681,10 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
564
681
|
const encPresent = encMap !== null;
|
|
565
682
|
const encConst = encryptionConstName(opts.packageName);
|
|
566
683
|
const out: string[] = [];
|
|
684
|
+
// The PACKAGE-LEVEL recursive-type registry: every directive payload's `z.lazy` knots mint
|
|
685
|
+
// named recursive type aliases into it (deduped by lazy identity across directives); the decls
|
|
686
|
+
// are emitted once, after the payload types (TS hoists type aliases, so order is free).
|
|
687
|
+
const lazyReg = newLazyRegistry();
|
|
567
688
|
out.push(
|
|
568
689
|
`// AUTO-GENERATED by nomos-compile — typed TS client for package "${opts.packageName}".`,
|
|
569
690
|
`// Regenerated on every compile; do not edit. Zero imports: bind it to any holon`,
|
|
@@ -600,6 +721,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
600
721
|
`/** One projection row: the aggregate's wire type, its id, and the folded fields. */`,
|
|
601
722
|
`export interface Row<T> { type: string; id: string; data: T }`,
|
|
602
723
|
``,
|
|
724
|
+
`/** Arbitrary JSON — the type a RECURSIVE \`z.lazy\` payload subtree degrades to (a self-`,
|
|
725
|
+
` * referencing tree/JSON schema can't be a finite named literal, so the engine validates it`,
|
|
726
|
+
` * at runtime and the recursive edge is typed here). Named recursion TS resolves fine. */`,
|
|
727
|
+
`export type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };`,
|
|
728
|
+
``,
|
|
603
729
|
`/** The holon surface this client binds to (structurally satisfied by @githolon/client). */`,
|
|
604
730
|
`export interface NomosHolon {`,
|
|
605
731
|
` /** THE ONE ENCODER offer (the single v2-envelope codec lives in @githolon/client \`encodeIntentOfferBytes\`):`,
|
|
@@ -1023,6 +1149,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1023
1149
|
out.push(`export interface ${typeId}Data {`);
|
|
1024
1150
|
for (const [name, field] of Object.entries(agg.fields)) {
|
|
1025
1151
|
const required = (field as Field).isFromBirth === true;
|
|
1152
|
+
const doc = nullDocLine(field);
|
|
1153
|
+
if (doc !== undefined) out.push(` /** ${doc} */`);
|
|
1026
1154
|
out.push(` ${name}${required ? "" : "?"}: ${tsTypeOfField(field as Field)};`);
|
|
1027
1155
|
}
|
|
1028
1156
|
for (const extra of extrasByType.get(typeId) ?? []) out.push(extra);
|
|
@@ -1280,7 +1408,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1280
1408
|
const pv = wantSeal ? "_sealed" : "payload";
|
|
1281
1409
|
out.push(
|
|
1282
1410
|
`/** \`${domain}/${d.id}\` payload (mirrors the engine's zod schema).${stamps.length ? ` Omitted ${stamps.map((s) => `\`${s}\``).join("/")} auto-stamp(s) ISO now() at offer.` : ""}${mint ? ` Omitted \`${mint.field}\` is KERNEL-MINTED at offer (returned on the result).` : ""}${placed.length ? " Omitted `shard` is picked LEAST-LOADED at offer (the routed holon)." : ""} */`,
|
|
1283
|
-
`export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed)};`,
|
|
1411
|
+
`export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed, lazyReg, d.id)};`,
|
|
1284
1412
|
``,
|
|
1285
1413
|
);
|
|
1286
1414
|
if (mint !== undefined) {
|
|
@@ -1467,5 +1595,15 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1467
1595
|
);
|
|
1468
1596
|
}
|
|
1469
1597
|
|
|
1598
|
+
// RECURSIVE PAYLOAD TYPES (`z.lazy`): emit each minted named alias once. Sorted by name for
|
|
1599
|
+
// byte-determinism; TS hoists type aliases so referencing them above is fine.
|
|
1600
|
+
if (lazyReg.decls.size > 0) {
|
|
1601
|
+
out.push(
|
|
1602
|
+
`// ── recursive payload types (z.lazy) — named so a self-referencing schema is a real TS type ──`,
|
|
1603
|
+
);
|
|
1604
|
+
for (const name of [...lazyReg.decls.keys()].sort()) out.push(lazyReg.decls.get(name)!);
|
|
1605
|
+
out.push(``);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1470
1608
|
return out.join("\n");
|
|
1471
1609
|
}
|
|
@@ -117,7 +117,7 @@ import {
|
|
|
117
117
|
} from "./stable_ids.js";
|
|
118
118
|
import { emitUsd, type UsdLayer } from "./usd.js";
|
|
119
119
|
import { emitLayeredRelationUsda, type RelationLayerInput } from "./relation_composition.js";
|
|
120
|
-
import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
|
|
120
|
+
import { generateTsClient, tsClientFactoryName, recursiveLazyTypes } from "./codegen_ts.js";
|
|
121
121
|
import { generateOpenApiFragment, mergeOpenApiFragments } from "./codegen_openapi.js";
|
|
122
122
|
import { domainInterface, type DomainInterface } from "./shape_compat.js";
|
|
123
123
|
import { generateTsProof, proofLegsIndex } from "./codegen_proof.js";
|
|
@@ -379,6 +379,10 @@ const DART_SUPPORT_SRC = [
|
|
|
379
379
|
"offer.dart",
|
|
380
380
|
"subscriptions.dart",
|
|
381
381
|
"provider.dart",
|
|
382
|
+
// The tri-state slot (NomosSlot<T>) for `nullable`/`clearable` fields (null_semantics.md) — the
|
|
383
|
+
// barrel re-exports it and generated read-model/payload classes reference it, so it must vendor.
|
|
384
|
+
// Zero external pub deps (pure Dart).
|
|
385
|
+
"null_slot.dart",
|
|
382
386
|
// Runtime aggregate-meta support types (NomosAggregateMeta/NomosFieldMeta) — the generated
|
|
383
387
|
// per-domain registry consts reference them through the barrel.
|
|
384
388
|
"aggregate_meta.dart",
|
|
@@ -1235,6 +1239,20 @@ async function main(): Promise<void> {
|
|
|
1235
1239
|
"utf8",
|
|
1236
1240
|
);
|
|
1237
1241
|
|
|
1242
|
+
// ── RECURSIVE-PAYLOAD NOTES (z.lazy) — a self-referencing tree/JSON payload field is FULLY
|
|
1243
|
+
// supported: each codegen layer mints a named recursive type for it (TS `type <Name>`, Dart
|
|
1244
|
+
// recursive class). Surfaced here so the tenant sees which fields became which named types.
|
|
1245
|
+
const recursiveNotes: string[] = [];
|
|
1246
|
+
for (const mod of domainModules) {
|
|
1247
|
+
for (const d of (mod.directives ?? []) as Array<{ id?: unknown; payloadSchema?: unknown }>) {
|
|
1248
|
+
if (!d || typeof d.id !== "string" || !d.payloadSchema || typeof d.payloadSchema !== "object") continue;
|
|
1249
|
+
for (const { path, name } of recursiveLazyTypes(d.payloadSchema as Parameters<typeof recursiveLazyTypes>[0], d.id)) {
|
|
1250
|
+
const field = path.startsWith(`${d.id}.`) ? path.slice(d.id.length + 1) : path;
|
|
1251
|
+
recursiveNotes.push(`${mod.domain ?? mod.name}.${d.id} payload '${field}': recursive z.lazy schema → generated named type '${name}' (TS type + Dart class)`);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1238
1256
|
// ── reports ──
|
|
1239
1257
|
const entryReports: string[] = [];
|
|
1240
1258
|
for (const [reportId, ref] of Object.entries(cfg.reports ?? {})) {
|
|
@@ -1815,6 +1833,7 @@ async function main(): Promise<void> {
|
|
|
1815
1833
|
}
|
|
1816
1834
|
}
|
|
1817
1835
|
console.log(` client ${rel(clientPath)} (typed TS client — payloads, read models, query accessors)`);
|
|
1836
|
+
for (const note of recursiveNotes) console.log(` note ${note}`);
|
|
1818
1837
|
console.log(` openapi ${rel(openapiPath)} (${Object.keys(openapi.paths).length} operation(s) — the law's self-describing API; the host's runtime routing table)`);
|
|
1819
1838
|
if (proofPath !== undefined) {
|
|
1820
1839
|
console.log(` proof ${rel(proofPath)} (a runnable e2e GENERATED from your law — run: githolon proof)`);
|