@githolon/dsl 0.54.1 → 0.54.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/codegen_dart.ts +206 -4
- package/src/codegen_proof.ts +33 -5
- package/src/codegen_ts.ts +139 -12
- package/src/compile_package_main.ts +16 -1
- package/src/zod_lazy.ts +45 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.54.
|
|
3
|
+
"version": "0.54.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
package/src/codegen_dart.ts
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { z } from "zod";
|
|
32
32
|
import { voMetaOf, collectPayloadValueObjectDescriptors, idOfMetaOf, brandedIdMetaOf, NOMOS_UNIT_BRAND } from "./value_object.js";
|
|
33
|
+
import { recursiveTypeBaseName, zodUnwrapLazy } from "./zod_lazy.js";
|
|
33
34
|
import type { AggregateHandle } from "./aggregate.js";
|
|
34
35
|
import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
|
|
35
36
|
import type { RelationTypeDecl } from "./relation_schema.js";
|
|
@@ -93,8 +94,23 @@ type ZodInternalDef = {
|
|
|
93
94
|
value?: unknown;
|
|
94
95
|
values?: readonly unknown[];
|
|
95
96
|
valueType?: z.ZodTypeAny;
|
|
97
|
+
getter?: () => z.ZodTypeAny; // `z.lazy(() => …)` — the deferred inner schema (zod v4)
|
|
96
98
|
};
|
|
97
99
|
|
|
100
|
+
/** Resolve `z.lazy(() => …)` chains to the first non-lazy schema, with an identity-keyed
|
|
101
|
+
* visited guard so a directly self-referencing lazy (`z.lazy(() => TheLazy)`) can't loop.
|
|
102
|
+
* A non-lazy schema returns unchanged. */
|
|
103
|
+
function resolveLazy(zt: z.ZodTypeAny): z.ZodTypeAny {
|
|
104
|
+
const seen = new Set<z.ZodTypeAny>();
|
|
105
|
+
let cur = zt;
|
|
106
|
+
while (zodDef(cur).type === "lazy" && typeof zodDef(cur).getter === "function") {
|
|
107
|
+
if (seen.has(cur)) return cur; // pathological lazy→lazy cycle — stop (caller degrades)
|
|
108
|
+
seen.add(cur);
|
|
109
|
+
cur = zodDef(cur).getter!();
|
|
110
|
+
}
|
|
111
|
+
return cur;
|
|
112
|
+
}
|
|
113
|
+
|
|
98
114
|
function zodDef(zt: z.ZodTypeAny): ZodInternalDef {
|
|
99
115
|
const raw = zt as unknown as { _def?: ZodInternalDef; def?: ZodInternalDef };
|
|
100
116
|
return raw._def ?? raw.def ?? {};
|
|
@@ -210,6 +226,7 @@ function zodNumberIsInt(zt: z.ZodTypeAny): boolean {
|
|
|
210
226
|
export function introspectPayload(
|
|
211
227
|
schema: z.ZodTypeAny,
|
|
212
228
|
enumDartTypes: Map<string, string>,
|
|
229
|
+
lazyNames?: Map<z.ZodTypeAny, string>,
|
|
213
230
|
): PayloadFieldSpec[] {
|
|
214
231
|
if (zodTypeName(schema) !== "ZodObject") {
|
|
215
232
|
throw new Error("directive payload must be a z.object");
|
|
@@ -226,7 +243,7 @@ export function introspectPayload(
|
|
|
226
243
|
optional = true;
|
|
227
244
|
zt = zodInnerType(zt);
|
|
228
245
|
}
|
|
229
|
-
out.push({ ...introspectField(name, zt, enumDartTypes), optional });
|
|
246
|
+
out.push({ ...introspectField(name, zt, enumDartTypes, lazyNames), optional });
|
|
230
247
|
}
|
|
231
248
|
return out;
|
|
232
249
|
}
|
|
@@ -239,7 +256,26 @@ function introspectField(
|
|
|
239
256
|
name: string,
|
|
240
257
|
zt: z.ZodTypeAny,
|
|
241
258
|
enumDartTypes: Map<string, string>,
|
|
259
|
+
lazyNames?: Map<z.ZodTypeAny, string>,
|
|
242
260
|
): Omit<PayloadFieldSpec, "optional"> {
|
|
261
|
+
// RECURSIVE `z.lazy` (a self-referencing tree/JSON payload): if the compiler synthesized a
|
|
262
|
+
// typed recursive class for this knot (`lazyNames`), the field IS that class (toJson via its
|
|
263
|
+
// `.toJson()`). Without a synthesized class (no map, or a degraded knot) it resolves one level
|
|
264
|
+
// and flows into the opaque-JSON lane below (`Map<String,Object?>`) — the last-resort fallback.
|
|
265
|
+
if (zodTypeName(zt) === "ZodLazy") {
|
|
266
|
+
const cls = lazyNames?.get(zt);
|
|
267
|
+
if (cls !== undefined) return { name, dartType: cls, toJson: "vo" };
|
|
268
|
+
// No synthesized class (no map, or a DEGRADED knot — e.g. a lazy of a union): last-resort
|
|
269
|
+
// opaque JSON. Resolve one level and reuse the normal lane, but any shape the lane can't
|
|
270
|
+
// express (union, undeclared nested object) falls back to `Object?` rather than throwing.
|
|
271
|
+
const inner = resolveLazy(zt);
|
|
272
|
+
if (zodTypeName(inner) === "ZodLazy") return { name, dartType: "Object?", toJson: "json" };
|
|
273
|
+
try {
|
|
274
|
+
return introspectField(name, inner, enumDartTypes, lazyNames);
|
|
275
|
+
} catch {
|
|
276
|
+
return { name, dartType: "Object?", toJson: "json" };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
243
279
|
// A DECLARED VO/union keeps its name regardless of its zod kind (a union(...) is a
|
|
244
280
|
// z.discriminatedUnion, not a ZodObject — without this it would fall to the throw lane).
|
|
245
281
|
{
|
|
@@ -298,6 +334,11 @@ function introspectField(
|
|
|
298
334
|
if (zodTypeName(elem) === "ZodString") {
|
|
299
335
|
return { name, dartType: "List<String>", toJson: "strList" };
|
|
300
336
|
}
|
|
337
|
+
// A list of a RECURSIVE `z.lazy` knot (a synthesized class) is a typed `List<Class>`.
|
|
338
|
+
const elemCls = zodTypeName(elem) === "ZodLazy" ? lazyNames?.get(elem) : undefined;
|
|
339
|
+
if (elemCls !== undefined) {
|
|
340
|
+
return { name, dartType: `List<${elemCls}>`, toJson: "voList" };
|
|
341
|
+
}
|
|
301
342
|
const elemVo = voMetaOf(elem);
|
|
302
343
|
if (elemVo !== undefined) {
|
|
303
344
|
return { name, dartType: `List<${elemVo.name}>`, toJson: "voList" };
|
|
@@ -2176,9 +2217,10 @@ function emitDirective(
|
|
|
2176
2217
|
d: AnyDirective,
|
|
2177
2218
|
domainName: string,
|
|
2178
2219
|
enumDartTypes: Map<string, string>,
|
|
2220
|
+
lazyNames?: Map<z.ZodTypeAny, string>,
|
|
2179
2221
|
): string {
|
|
2180
2222
|
const className = `${cap(d.id)}Payload`;
|
|
2181
|
-
const specs = introspectPayload(d.payloadSchema as z.ZodTypeAny, enumDartTypes);
|
|
2223
|
+
const specs = introspectPayload(d.payloadSchema as z.ZodTypeAny, enumDartTypes, lazyNames);
|
|
2182
2224
|
|
|
2183
2225
|
// Field decls: optional payload fields are nullable Dart fields (omitted from the
|
|
2184
2226
|
// payload JSON when null); required fields are non-null + `required` in the ctor.
|
|
@@ -2270,6 +2312,159 @@ export function toJsonExpr(spec: PayloadFieldSpec, accessor: string): string {
|
|
|
2270
2312
|
}
|
|
2271
2313
|
}
|
|
2272
2314
|
|
|
2315
|
+
// ── RECURSIVE `z.lazy` payloads → synthesized Dart classes (full support) ─────────
|
|
2316
|
+
//
|
|
2317
|
+
// A self-referencing payload schema (`TreeNode = z.lazy(() => z.object({ label, children:
|
|
2318
|
+
// z.array(TreeNode).optional() }))`) is FULLY typed in Dart, not degraded: each lazy KNOT
|
|
2319
|
+
// becomes a real recursive `class` reusing the value-object class emitter — its `children`
|
|
2320
|
+
// field types as `List<SaveTreeRoot>?`, i.e. itself. We synthesize a `ValueObjectDescriptor`
|
|
2321
|
+
// per knot (a `{type:"object", ref:<self>}` field is the recursion, which the VO emitter
|
|
2322
|
+
// renders by NAME, so emission never loops) and route the payload field to it.
|
|
2323
|
+
//
|
|
2324
|
+
// A knot whose shape is NOT expressible as a VO field set (e.g. a lazy of a union, or an
|
|
2325
|
+
// interior undeclared nested object) is the ONE last-resort degrade: it drops to `Object?`
|
|
2326
|
+
// (opaque JSON) and its path is reported. The common tree/JSON recursion never degrades.
|
|
2327
|
+
|
|
2328
|
+
export interface SynthesizedRecursiveTypes {
|
|
2329
|
+
/** lazy zod identity → generated Dart class name (SUCCESSFULLY synthesized knots only). */
|
|
2330
|
+
readonly names: Map<z.ZodTypeAny, string>;
|
|
2331
|
+
/** generated recursive-class descriptors, keyed by class name (fed to [emitValueObjectClass]). */
|
|
2332
|
+
readonly descriptors: Map<string, ValueObjectDescriptor>;
|
|
2333
|
+
/** knot paths that could NOT be represented as a class (degraded to `Object?`) — reported. */
|
|
2334
|
+
readonly degraded: string[];
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/** Synthesize the recursive-payload Dart classes for a module's directives (see the block header). */
|
|
2338
|
+
export function synthesizeRecursivePayloadTypes(
|
|
2339
|
+
directives: readonly { payloadSchema?: unknown; id?: string }[],
|
|
2340
|
+
): SynthesizedRecursiveTypes {
|
|
2341
|
+
const names = new Map<z.ZodTypeAny, string>();
|
|
2342
|
+
const descriptors = new Map<string, ValueObjectDescriptor>();
|
|
2343
|
+
const used = new Set<string>();
|
|
2344
|
+
const degraded: string[] = [];
|
|
2345
|
+
|
|
2346
|
+
const mint = (path: string): string => {
|
|
2347
|
+
const base = recursiveTypeBaseName(path);
|
|
2348
|
+
let n = base;
|
|
2349
|
+
for (let i = 2; used.has(n); i++) n = `${base}${i}`;
|
|
2350
|
+
used.add(n);
|
|
2351
|
+
return n;
|
|
2352
|
+
};
|
|
2353
|
+
|
|
2354
|
+
// ONE zod field inside a knot → its VO field descriptor. THROWS for a shape the VO field set
|
|
2355
|
+
// can't express (→ the enclosing knot degrades). A nested lazy recurses into [buildKnot].
|
|
2356
|
+
const fieldOf = (zt: z.ZodTypeAny, path: string): ValueObjectFieldDescriptor => {
|
|
2357
|
+
if (zodTypeName(zt) === "ZodOptional" || zodTypeName(zt) === "ZodDefault") {
|
|
2358
|
+
return { ...fieldOf(zodInnerType(zt), path), optional: true };
|
|
2359
|
+
}
|
|
2360
|
+
if (zodTypeName(zt) === "ZodLazy") {
|
|
2361
|
+
const ref = buildKnot(zt, path);
|
|
2362
|
+
if (ref === undefined) throw new Error("recursive knot degraded");
|
|
2363
|
+
return { type: "object", ref };
|
|
2364
|
+
}
|
|
2365
|
+
const vo = voMetaOf(zt);
|
|
2366
|
+
if (vo !== undefined) {
|
|
2367
|
+
return vo.geometry !== undefined
|
|
2368
|
+
? { type: "geometry", ref: vo.name, geometry: vo.geometry }
|
|
2369
|
+
: { type: "object", ref: vo.name };
|
|
2370
|
+
}
|
|
2371
|
+
switch (zodTypeName(zt)) {
|
|
2372
|
+
case "ZodString": {
|
|
2373
|
+
const b = brandedIdMetaOf(zt);
|
|
2374
|
+
if (b !== undefined) return { type: "string", brand: b };
|
|
2375
|
+
const idAgg = idOfMetaOf(zt);
|
|
2376
|
+
if (idAgg !== undefined) return { type: "string", brand: `${idAgg}Id` };
|
|
2377
|
+
return { type: "string" };
|
|
2378
|
+
}
|
|
2379
|
+
case "ZodNumber": {
|
|
2380
|
+
const unit = (zt as unknown as Record<symbol, unknown>)[NOMOS_UNIT_BRAND];
|
|
2381
|
+
return {
|
|
2382
|
+
type: "number",
|
|
2383
|
+
...(zodNumberIsInt(zt) ? { int: true as const } : {}),
|
|
2384
|
+
...(typeof unit === "string" ? { unit } : {}),
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2387
|
+
case "ZodBoolean":
|
|
2388
|
+
return { type: "boolean" };
|
|
2389
|
+
case "ZodEnum":
|
|
2390
|
+
return { type: "enum", values: zodEnumValues(zt) };
|
|
2391
|
+
case "ZodLiteral": {
|
|
2392
|
+
const v = zodLiteralValue(zt);
|
|
2393
|
+
if (typeof v !== "string") throw new Error("non-string literal");
|
|
2394
|
+
return { type: "literal", value: v };
|
|
2395
|
+
}
|
|
2396
|
+
case "ZodArray": {
|
|
2397
|
+
const items = fieldOf(zodArrayElement(zt), `${path}[]`);
|
|
2398
|
+
if (items.optional === true) throw new Error("array of optionals");
|
|
2399
|
+
return { type: "array", items };
|
|
2400
|
+
}
|
|
2401
|
+
case "ZodRecord": {
|
|
2402
|
+
const vt = zodRecordValueType(zt);
|
|
2403
|
+
if (vt === undefined) throw new Error("record without value type");
|
|
2404
|
+
const values = fieldOf(vt, `${path}.*`);
|
|
2405
|
+
if (values.optional === true) throw new Error("record of optionals");
|
|
2406
|
+
return { type: "map", values };
|
|
2407
|
+
}
|
|
2408
|
+
default:
|
|
2409
|
+
throw new Error(`unsupported field shape '${zodTypeName(zt)}'`);
|
|
2410
|
+
}
|
|
2411
|
+
};
|
|
2412
|
+
|
|
2413
|
+
// Build (once) the class descriptor for a lazy knot. Returns its class name, or undefined if
|
|
2414
|
+
// the knot degrades. Registers the name BEFORE recursing so a self-reference resolves cleanly.
|
|
2415
|
+
const buildKnot = (lazyZt: z.ZodTypeAny, path: string): string | undefined => {
|
|
2416
|
+
const existing = names.get(lazyZt);
|
|
2417
|
+
if (existing !== undefined) return existing;
|
|
2418
|
+
const name = mint(path);
|
|
2419
|
+
names.set(lazyZt, name);
|
|
2420
|
+
const obj = zodUnwrapLazy(lazyZt);
|
|
2421
|
+
if (zodTypeName(obj) !== "ZodObject") {
|
|
2422
|
+
names.delete(lazyZt);
|
|
2423
|
+
degraded.push(path);
|
|
2424
|
+
return undefined;
|
|
2425
|
+
}
|
|
2426
|
+
const fields: Record<string, ValueObjectFieldDescriptor> = {};
|
|
2427
|
+
try {
|
|
2428
|
+
const shape = zodObjectShape(obj);
|
|
2429
|
+
for (const key of Object.keys(shape).sort()) fields[key] = fieldOf(shape[key]!, `${path}.${key}`);
|
|
2430
|
+
} catch {
|
|
2431
|
+
names.delete(lazyZt);
|
|
2432
|
+
degraded.push(path);
|
|
2433
|
+
return undefined;
|
|
2434
|
+
}
|
|
2435
|
+
descriptors.set(name, { name, fields });
|
|
2436
|
+
return name;
|
|
2437
|
+
};
|
|
2438
|
+
|
|
2439
|
+
// Find the outermost lazy knots reachable from each payload field (through optional/array).
|
|
2440
|
+
const findKnots = (zt: z.ZodTypeAny, path: string): void => {
|
|
2441
|
+
switch (zodTypeName(zt)) {
|
|
2442
|
+
case "ZodLazy":
|
|
2443
|
+
buildKnot(zt, path);
|
|
2444
|
+
return;
|
|
2445
|
+
case "ZodOptional":
|
|
2446
|
+
case "ZodDefault":
|
|
2447
|
+
findKnots(zodInnerType(zt), path);
|
|
2448
|
+
return;
|
|
2449
|
+
case "ZodArray":
|
|
2450
|
+
findKnots(zodArrayElement(zt), `${path}[]`);
|
|
2451
|
+
return;
|
|
2452
|
+
default:
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
};
|
|
2456
|
+
|
|
2457
|
+
for (const d of directives) {
|
|
2458
|
+
const schema = d?.payloadSchema as z.ZodTypeAny | undefined;
|
|
2459
|
+
if (!schema || typeof schema !== "object" || zodTypeName(schema) !== "ZodObject") continue;
|
|
2460
|
+
const dirId = d.id ?? "";
|
|
2461
|
+
for (const [key, raw] of Object.entries(zodObjectShape(schema))) {
|
|
2462
|
+
findKnots(raw as z.ZodTypeAny, `${dirId}.${key}`);
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
return { names, descriptors, degraded };
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2273
2468
|
// ---- read accessors (typed subscriptions) ----------------------------------
|
|
2274
2469
|
//
|
|
2275
2470
|
// The READ half of the peer query surface (Jack 2026-06-18): the app reads ONLY via
|
|
@@ -3314,7 +3509,14 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
|
|
|
3314
3509
|
// the aggregate-derived set. Codegen-only: the IR key stays aggregate-derived (era rule).
|
|
3315
3510
|
const aggVoNames = new Set((mod.valueObjects ?? []).map((d) => d.name));
|
|
3316
3511
|
const payloadVos = collectPayloadValueObjectDescriptors(mod.directives ?? []).filter((d) => !aggVoNames.has(d.name));
|
|
3317
|
-
|
|
3512
|
+
// RECURSIVE `z.lazy` payloads → synthesized recursive-class descriptors (full support). They
|
|
3513
|
+
// are codegen-only (like payloadVos): the law IR / domainHash never sees them (era rule). The
|
|
3514
|
+
// name map routes each recursive payload field to its class in [emitDirective].
|
|
3515
|
+
const recursiveTypes = synthesizeRecursivePayloadTypes(mod.directives ?? []);
|
|
3516
|
+
const recursiveDescriptors = [...recursiveTypes.descriptors.values()].filter(
|
|
3517
|
+
(d) => !aggVoNames.has(d.name) && !payloadVos.some((p) => p.name === d.name),
|
|
3518
|
+
);
|
|
3519
|
+
const localValueObjects = [...(mod.valueObjects ?? []), ...payloadVos, ...recursiveDescriptors]
|
|
3318
3520
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
3319
3521
|
.filter((d) => !skipValueObjectNames.has(d.name));
|
|
3320
3522
|
// Each VO's block = its FIELD ENUMS (real Dart enums, tolerant fromWire) then its class —
|
|
@@ -3421,7 +3623,7 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
|
|
|
3421
3623
|
// the runtime aggregate-meta registry (lookup by op.aggregateType — generic surfaces).
|
|
3422
3624
|
const aggregateMetaBlock = emitAggregateMetaRegistry(domainKey, localAggregates);
|
|
3423
3625
|
const dirBlock = localDirectives
|
|
3424
|
-
.map((d) => emitDirective(d, domainKey, dartTypes))
|
|
3626
|
+
.map((d) => emitDirective(d, domainKey, dartTypes, recursiveTypes.names))
|
|
3425
3627
|
.join("\n\n");
|
|
3426
3628
|
|
|
3427
3629
|
// TYPED IDS + MINTED CREATES (the id-mint keystone, GENERALIZED): a DISTINCT typed
|
package/src/codegen_proof.ts
CHANGED
|
@@ -60,8 +60,12 @@ 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
|
+
|
|
65
69
|
const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
|
|
66
70
|
const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
|
|
67
71
|
|
|
@@ -124,7 +128,28 @@ function joinIssuePath(base: string, segs: readonly PropertyKey[]): string {
|
|
|
124
128
|
* names the exact field (`controlPoints[0].geoPoint.latitude`, never a bare leaf or "field ?"). NO
|
|
125
129
|
* FALLBACK: an unhandled shape throws, naming the path and the remedy.
|
|
126
130
|
*/
|
|
127
|
-
function sampleOfZod(
|
|
131
|
+
function sampleOfZod(
|
|
132
|
+
zt: z.ZodTypeAny,
|
|
133
|
+
path: string,
|
|
134
|
+
where: string,
|
|
135
|
+
voIncludeOptional = false,
|
|
136
|
+
visited: ReadonlySet<z.ZodTypeAny> = EMPTY_LAZY_VISITED,
|
|
137
|
+
): unknown {
|
|
138
|
+
// RECURSIVE `z.lazy` (a self-referencing tree/JSON payload): unwrap one level, tracking the
|
|
139
|
+
// lazy object's IDENTITY on this descent. Re-entering the SAME lazy is a knot: sampling can go
|
|
140
|
+
// no deeper. The common case terminates NATURALLY one level up (the recursive branch is an
|
|
141
|
+
// OPTIONAL field, which the object case omits, or an ARRAY, which emits `[]`); only a REQUIRED
|
|
142
|
+
// non-array self-reference is genuinely unsampleable, and THAT throws with the exact path.
|
|
143
|
+
if (zodKind(zt) === "lazy") {
|
|
144
|
+
if (visited.has(zt)) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`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`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const next = new Set(visited);
|
|
150
|
+
next.add(zt);
|
|
151
|
+
return sampleOfZod(zodUnwrapLazy(zt), path, where, voIncludeOptional, next);
|
|
152
|
+
}
|
|
128
153
|
// VALUE OBJECTS FIRST (vo_authoring_end_to_end.md slice C): a declared VO carries its own lawful
|
|
129
154
|
// sample (the author's validated `example`, the blessed geometry sample, or per-field descriptor
|
|
130
155
|
// synthesis, recursing through nested VOs / arrays-of-VOs / unions). The raw-zod walk below cannot
|
|
@@ -172,14 +197,17 @@ function sampleOfZod(zt: z.ZodTypeAny, path: string, where: string, voIncludeOpt
|
|
|
172
197
|
// A one-element array satisfies `.min(1)` coverage; a string element keeps the readable
|
|
173
198
|
// `sample-a` (constraint-probed) so golden outputs stay stable.
|
|
174
199
|
if (zodKind(el) === "string") return [sampleString(el, "a")];
|
|
175
|
-
|
|
200
|
+
// A recursive array element (its lazy is already on this descent) bottoms out as `[]` —
|
|
201
|
+
// an empty array is a valid finite sample and breaks the recursion (depth-bound at 1).
|
|
202
|
+
if (zodKind(el) === "lazy" && visited.has(el)) return [];
|
|
203
|
+
return [sampleOfZod(el, `${path}[0]`, where, voIncludeOptional, visited)];
|
|
176
204
|
}
|
|
177
205
|
case "object": {
|
|
178
206
|
const out: Record<string, unknown> = {};
|
|
179
207
|
for (const [name, raw] of Object.entries(zodObjectShape(zt))) {
|
|
180
208
|
const k = zodKind(raw as z.ZodTypeAny);
|
|
181
209
|
if (k === "optional" || k === "default") continue; // optional → omit
|
|
182
|
-
out[name] = sampleOfZod(raw as z.ZodTypeAny, joinIssuePath(path, [name]), where, voIncludeOptional);
|
|
210
|
+
out[name] = sampleOfZod(raw as z.ZodTypeAny, joinIssuePath(path, [name]), where, voIncludeOptional, visited);
|
|
183
211
|
}
|
|
184
212
|
return out;
|
|
185
213
|
}
|
|
@@ -188,10 +216,10 @@ function sampleOfZod(zt: z.ZodTypeAny, path: string, where: string, voIncludeOpt
|
|
|
188
216
|
case "nullable":
|
|
189
217
|
case "optional":
|
|
190
218
|
case "default":
|
|
191
|
-
return sampleOfZod(zodDef(zt).innerType!, path, where, voIncludeOptional);
|
|
219
|
+
return sampleOfZod(zodDef(zt).innerType!, path, where, voIncludeOptional, visited);
|
|
192
220
|
case "union": {
|
|
193
221
|
const first = zodDef(zt).options?.[0];
|
|
194
|
-
if (first !== undefined) return sampleOfZod(first, path, where, voIncludeOptional);
|
|
222
|
+
if (first !== undefined) return sampleOfZod(first, path, where, voIncludeOptional, visited);
|
|
195
223
|
throw new Error(`codegen-proof: ${where} — field '${path}': an empty union has no arm to sample`);
|
|
196
224
|
}
|
|
197
225
|
default:
|
package/src/codegen_ts.ts
CHANGED
|
@@ -43,6 +43,7 @@ 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";
|
|
@@ -206,8 +207,13 @@ type ZodInternalDef = {
|
|
|
206
207
|
value?: unknown;
|
|
207
208
|
values?: readonly unknown[];
|
|
208
209
|
valueType?: z.ZodTypeAny;
|
|
210
|
+
getter?: () => z.ZodTypeAny; // `z.lazy(() => …)` — the deferred inner schema (zod v4)
|
|
209
211
|
};
|
|
210
212
|
|
|
213
|
+
// The lazy-unwrap + recursive-name helpers live in the dependency-free `zod_lazy.ts` (shared with
|
|
214
|
+
// codegen_dart WITHOUT an import cycle). Re-exported so codegen_proof's existing import holds.
|
|
215
|
+
export { zodUnwrapLazy };
|
|
216
|
+
|
|
211
217
|
export function zodDef(zt: z.ZodTypeAny): ZodInternalDef {
|
|
212
218
|
const raw = zt as unknown as { _def?: ZodInternalDef; def?: ZodInternalDef };
|
|
213
219
|
return raw._def ?? raw.def ?? {};
|
|
@@ -245,8 +251,44 @@ export function zodArrayElement(zt: z.ZodTypeAny): z.ZodTypeAny {
|
|
|
245
251
|
const union = (parts: string[]) => [...new Set(parts)].join(" | ");
|
|
246
252
|
const paren = (t: string) => (t.includes("|") || t.includes("&") ? `(${t})` : t);
|
|
247
253
|
|
|
248
|
-
/**
|
|
249
|
-
|
|
254
|
+
/**
|
|
255
|
+
* THE RECURSIVE-TYPE REGISTRY (`z.lazy` full support). A recursive payload schema (a `TreeNode`
|
|
256
|
+
* that references itself) can't be a finite inline literal — so at each lazy KNOT we MINT a stable
|
|
257
|
+
* named type alias (from the payload path — deterministic across compiles), emit its body ONCE, and
|
|
258
|
+
* reference the name everywhere it recurs. TS resolves named recursion natively for every shape, so
|
|
259
|
+
* NOTHING degrades: `type SaveTreeRoot = { label: string; children?: SaveTreeRoot[] }`. The `names`
|
|
260
|
+
* map (keyed on the lazy's object IDENTITY) doubles as the cycle guard — a re-encountered lazy
|
|
261
|
+
* returns its already-minted name. Mutual recursion A↔B mints two names by first-encounter path. */
|
|
262
|
+
interface LazyTypeRegistry {
|
|
263
|
+
readonly names: Map<z.ZodTypeAny, string>; // lazy identity → minted type name (also the guard)
|
|
264
|
+
readonly decls: Map<string, string>; // name → `export type <Name> = <body>;` (dedup, deterministic)
|
|
265
|
+
readonly used: Set<string>; // all minted names — collision-avoidance across knots
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function newLazyRegistry(): LazyTypeRegistry {
|
|
269
|
+
return { names: new Map(), decls: new Map(), used: new Set() };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Mint a UNIQUE PascalCase type name from a payload path; collisions get a numeric suffix. */
|
|
273
|
+
function mintLazyTypeName(reg: LazyTypeRegistry, path: string): string {
|
|
274
|
+
const base = recursiveTypeBaseName(path);
|
|
275
|
+
let name = base;
|
|
276
|
+
for (let i = 2; reg.used.has(name); i++) name = `${base}${i}`;
|
|
277
|
+
reg.used.add(name);
|
|
278
|
+
return name;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Recursive zod → TS type text. NO FALLBACK for an unknown shape (it throws). A RECURSIVE
|
|
282
|
+
* `z.lazy` knot is FULLY SUPPORTED: it mints a named type alias into `reg` and references it
|
|
283
|
+
* (never `JsonValue`) — see {@link LazyTypeRegistry}. `reg` is absent only for standalone callers
|
|
284
|
+
* (e.g. a query return) that can't host a named decl; a recursive lazy there is the one place the
|
|
285
|
+
* `JsonValue` safety valve applies (query returns are read models, never recursive in practice). */
|
|
286
|
+
function tsTypeOfZod(
|
|
287
|
+
zt: z.ZodTypeAny,
|
|
288
|
+
indent: string,
|
|
289
|
+
reg?: LazyTypeRegistry,
|
|
290
|
+
path = "",
|
|
291
|
+
): string {
|
|
250
292
|
// A DECLARED VALUE OBJECT keeps its NAME (vo_authoring_end_to_end.md slice B): the payload
|
|
251
293
|
// references the same exported interface the read model uses — one name, both directions —
|
|
252
294
|
// instead of an anonymous inline literal type. Checked on the BARE object only: optional/
|
|
@@ -256,6 +298,16 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
|
|
|
256
298
|
if (vo !== undefined) return vo.name;
|
|
257
299
|
}
|
|
258
300
|
switch (zodKind(zt)) {
|
|
301
|
+
case "lazy": {
|
|
302
|
+
if (reg === undefined) return "JsonValue"; // safety valve (no host for a named decl)
|
|
303
|
+
const existing = reg.names.get(zt);
|
|
304
|
+
if (existing !== undefined) return existing; // already minted (the cycle guard)
|
|
305
|
+
const name = mintLazyTypeName(reg, path);
|
|
306
|
+
reg.names.set(zt, name); // register BEFORE recursing so self-references resolve to the name
|
|
307
|
+
const body = tsTypeOfZod(zodUnwrapLazy(zt), "", reg, path);
|
|
308
|
+
reg.decls.set(name, `export type ${name} = ${body};`);
|
|
309
|
+
return name;
|
|
310
|
+
}
|
|
259
311
|
case "string":
|
|
260
312
|
return "string";
|
|
261
313
|
case "number":
|
|
@@ -270,7 +322,7 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
|
|
|
270
322
|
return JSON.stringify(v);
|
|
271
323
|
}
|
|
272
324
|
case "array":
|
|
273
|
-
return `${paren(tsTypeOfZod(zodArrayElement(zt), indent))}[]`;
|
|
325
|
+
return `${paren(tsTypeOfZod(zodArrayElement(zt), indent, reg, `${path}[]`))}[]`;
|
|
274
326
|
case "object": {
|
|
275
327
|
const shape = zodObjectShape(zt);
|
|
276
328
|
const inner = indent + " ";
|
|
@@ -281,21 +333,21 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
|
|
|
281
333
|
optional = true;
|
|
282
334
|
f = zodDef(f).innerType!;
|
|
283
335
|
}
|
|
284
|
-
return `${inner}${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, inner)};`;
|
|
336
|
+
return `${inner}${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, inner, reg, path === "" ? name : `${path}.${name}`)};`;
|
|
285
337
|
});
|
|
286
338
|
return fields.length ? `{\n${fields.join("\n")}\n${indent}}` : "Record<string, never>";
|
|
287
339
|
}
|
|
288
340
|
case "record": {
|
|
289
341
|
const vt = zodDef(zt).valueType;
|
|
290
|
-
return `Record<string, ${vt ? tsTypeOfZod(vt, indent) : "unknown"}>`;
|
|
342
|
+
return `Record<string, ${vt ? tsTypeOfZod(vt, indent, reg, `${path}.*`) : "unknown"}>`;
|
|
291
343
|
}
|
|
292
344
|
case "optional":
|
|
293
345
|
case "default":
|
|
294
|
-
return `${tsTypeOfZod(zodDef(zt).innerType!, indent)} | undefined`;
|
|
346
|
+
return `${tsTypeOfZod(zodDef(zt).innerType!, indent, reg, path)} | undefined`;
|
|
295
347
|
case "nullable":
|
|
296
|
-
return `${tsTypeOfZod(zodDef(zt).innerType!, indent)} | null`;
|
|
348
|
+
return `${tsTypeOfZod(zodDef(zt).innerType!, indent, reg, path)} | null`;
|
|
297
349
|
case "union":
|
|
298
|
-
return union((zodDef(zt).options ?? []).map((o) => tsTypeOfZod(o, indent)));
|
|
350
|
+
return union((zodDef(zt).options ?? []).map((o) => tsTypeOfZod(o, indent, reg, path)));
|
|
299
351
|
case "unknown":
|
|
300
352
|
case "any":
|
|
301
353
|
return "unknown";
|
|
@@ -304,6 +356,57 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
|
|
|
304
356
|
}
|
|
305
357
|
}
|
|
306
358
|
|
|
359
|
+
/**
|
|
360
|
+
* The RECURSIVE `z.lazy` knots in one directive payload, each reported as `{ path, name }` where
|
|
361
|
+
* `name` is the stable generated type minted for it (the SAME name {@link tsTypeOfZod} emits — the
|
|
362
|
+
* mint is deterministic from the path). Surfaced as a compile-summary NOTE so the tenant sees which
|
|
363
|
+
* fields became named recursive types (NOT a degrade — recursion is fully supported). `pathPrefix`
|
|
364
|
+
* (the directive id) scopes the minted names, matching the client's package-level registry. Empty
|
|
365
|
+
* for a lazy-free payload. */
|
|
366
|
+
export function recursiveLazyTypes(schema: z.ZodTypeAny, pathPrefix = ""): { path: string; name: string }[] {
|
|
367
|
+
const reg = newLazyRegistry();
|
|
368
|
+
const out: { path: string; name: string }[] = [];
|
|
369
|
+
const walk = (zt: z.ZodTypeAny, path: string): void => {
|
|
370
|
+
switch (zodKind(zt)) {
|
|
371
|
+
case "lazy": {
|
|
372
|
+
if (reg.names.has(zt)) return; // already recorded (the cycle guard)
|
|
373
|
+
const name = mintLazyTypeName(reg, path);
|
|
374
|
+
reg.names.set(zt, name);
|
|
375
|
+
out.push({ path, name });
|
|
376
|
+
walk(zodUnwrapLazy(zt), path);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
case "array":
|
|
380
|
+
walk(zodArrayElement(zt), `${path}[]`);
|
|
381
|
+
return;
|
|
382
|
+
case "object": {
|
|
383
|
+
if (voMetaOf(zt) !== undefined) return; // a VO is an opaque leaf — don't descend
|
|
384
|
+
for (const [name, raw] of Object.entries(zodObjectShape(zt))) {
|
|
385
|
+
let f = raw as z.ZodTypeAny;
|
|
386
|
+
while (zodKind(f) === "optional" || zodKind(f) === "default") f = zodDef(f).innerType!;
|
|
387
|
+
walk(f, path === "" ? name : `${path}.${name}`);
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
case "optional":
|
|
392
|
+
case "default":
|
|
393
|
+
case "nullable":
|
|
394
|
+
walk(zodDef(zt).innerType!, path);
|
|
395
|
+
return;
|
|
396
|
+
case "record":
|
|
397
|
+
if (zodDef(zt).valueType) walk(zodDef(zt).valueType!, `${path}.*`);
|
|
398
|
+
return;
|
|
399
|
+
case "union":
|
|
400
|
+
for (const o of zodDef(zt).options ?? []) walk(o, path);
|
|
401
|
+
return;
|
|
402
|
+
default:
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
walk(schema, pathPrefix);
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
|
|
307
410
|
// ── caller-side clock sugar (the `…At` payload convention) ────────────────────────
|
|
308
411
|
|
|
309
412
|
/**
|
|
@@ -325,15 +428,19 @@ export function autoStampFields(schema: z.ZodTypeAny): string[] {
|
|
|
325
428
|
return out;
|
|
326
429
|
}
|
|
327
430
|
|
|
328
|
-
/** The payload type text, with auto-stamped / front-door-minted / auto-placed fields optional (`?`).
|
|
431
|
+
/** The payload type text, with auto-stamped / front-door-minted / auto-placed fields optional (`?`).
|
|
432
|
+
* `reg` collects any RECURSIVE `z.lazy` named types minted while walking (emitted once by the
|
|
433
|
+
* caller); `pathPrefix` (the directive id) scopes their names so two directives can't collide. */
|
|
329
434
|
function tsTypeOfPayload(
|
|
330
435
|
schema: z.ZodTypeAny,
|
|
331
436
|
autoStamped: readonly string[],
|
|
332
437
|
minted: readonly string[] = [],
|
|
333
438
|
autoPlaced: readonly string[] = [],
|
|
439
|
+
reg?: LazyTypeRegistry,
|
|
440
|
+
pathPrefix = "",
|
|
334
441
|
): string {
|
|
335
442
|
if (zodKind(schema) !== "object" || (autoStamped.length === 0 && minted.length === 0 && autoPlaced.length === 0)) {
|
|
336
|
-
return tsTypeOfZod(schema, "");
|
|
443
|
+
return tsTypeOfZod(schema, "", reg, pathPrefix);
|
|
337
444
|
}
|
|
338
445
|
const stamped = new Set(autoStamped);
|
|
339
446
|
const mintedSet = new Set(minted);
|
|
@@ -352,7 +459,8 @@ function tsTypeOfPayload(
|
|
|
352
459
|
: placedSet.has(name)
|
|
353
460
|
? " // picked LEAST-LOADED over the coordinator's law-state at offer when omitted (sharding §3)"
|
|
354
461
|
: "";
|
|
355
|
-
|
|
462
|
+
const fieldPath = pathPrefix === "" ? name : `${pathPrefix}.${name}`;
|
|
463
|
+
return ` ${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, " ", reg, fieldPath)};${note}`;
|
|
356
464
|
});
|
|
357
465
|
return fields.length ? `{\n${fields.join("\n")}\n}` : "Record<string, never>";
|
|
358
466
|
}
|
|
@@ -564,6 +672,10 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
564
672
|
const encPresent = encMap !== null;
|
|
565
673
|
const encConst = encryptionConstName(opts.packageName);
|
|
566
674
|
const out: string[] = [];
|
|
675
|
+
// The PACKAGE-LEVEL recursive-type registry: every directive payload's `z.lazy` knots mint
|
|
676
|
+
// named recursive type aliases into it (deduped by lazy identity across directives); the decls
|
|
677
|
+
// are emitted once, after the payload types (TS hoists type aliases, so order is free).
|
|
678
|
+
const lazyReg = newLazyRegistry();
|
|
567
679
|
out.push(
|
|
568
680
|
`// AUTO-GENERATED by nomos-compile — typed TS client for package "${opts.packageName}".`,
|
|
569
681
|
`// Regenerated on every compile; do not edit. Zero imports: bind it to any holon`,
|
|
@@ -600,6 +712,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
600
712
|
`/** One projection row: the aggregate's wire type, its id, and the folded fields. */`,
|
|
601
713
|
`export interface Row<T> { type: string; id: string; data: T }`,
|
|
602
714
|
``,
|
|
715
|
+
`/** Arbitrary JSON — the type a RECURSIVE \`z.lazy\` payload subtree degrades to (a self-`,
|
|
716
|
+
` * referencing tree/JSON schema can't be a finite named literal, so the engine validates it`,
|
|
717
|
+
` * at runtime and the recursive edge is typed here). Named recursion TS resolves fine. */`,
|
|
718
|
+
`export type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };`,
|
|
719
|
+
``,
|
|
603
720
|
`/** The holon surface this client binds to (structurally satisfied by @githolon/client). */`,
|
|
604
721
|
`export interface NomosHolon {`,
|
|
605
722
|
` /** THE ONE ENCODER offer (the single v2-envelope codec lives in @githolon/client \`encodeIntentOfferBytes\`):`,
|
|
@@ -1280,7 +1397,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1280
1397
|
const pv = wantSeal ? "_sealed" : "payload";
|
|
1281
1398
|
out.push(
|
|
1282
1399
|
`/** \`${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)};`,
|
|
1400
|
+
`export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed, lazyReg, d.id)};`,
|
|
1284
1401
|
``,
|
|
1285
1402
|
);
|
|
1286
1403
|
if (mint !== undefined) {
|
|
@@ -1467,5 +1584,15 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1467
1584
|
);
|
|
1468
1585
|
}
|
|
1469
1586
|
|
|
1587
|
+
// RECURSIVE PAYLOAD TYPES (`z.lazy`): emit each minted named alias once. Sorted by name for
|
|
1588
|
+
// byte-determinism; TS hoists type aliases so referencing them above is fine.
|
|
1589
|
+
if (lazyReg.decls.size > 0) {
|
|
1590
|
+
out.push(
|
|
1591
|
+
`// ── recursive payload types (z.lazy) — named so a self-referencing schema is a real TS type ──`,
|
|
1592
|
+
);
|
|
1593
|
+
for (const name of [...lazyReg.decls.keys()].sort()) out.push(lazyReg.decls.get(name)!);
|
|
1594
|
+
out.push(``);
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1470
1597
|
return out.join("\n");
|
|
1471
1598
|
}
|
|
@@ -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";
|
|
@@ -1235,6 +1235,20 @@ async function main(): Promise<void> {
|
|
|
1235
1235
|
"utf8",
|
|
1236
1236
|
);
|
|
1237
1237
|
|
|
1238
|
+
// ── RECURSIVE-PAYLOAD NOTES (z.lazy) — a self-referencing tree/JSON payload field is FULLY
|
|
1239
|
+
// supported: each codegen layer mints a named recursive type for it (TS `type <Name>`, Dart
|
|
1240
|
+
// recursive class). Surfaced here so the tenant sees which fields became which named types.
|
|
1241
|
+
const recursiveNotes: string[] = [];
|
|
1242
|
+
for (const mod of domainModules) {
|
|
1243
|
+
for (const d of (mod.directives ?? []) as Array<{ id?: unknown; payloadSchema?: unknown }>) {
|
|
1244
|
+
if (!d || typeof d.id !== "string" || !d.payloadSchema || typeof d.payloadSchema !== "object") continue;
|
|
1245
|
+
for (const { path, name } of recursiveLazyTypes(d.payloadSchema as Parameters<typeof recursiveLazyTypes>[0], d.id)) {
|
|
1246
|
+
const field = path.startsWith(`${d.id}.`) ? path.slice(d.id.length + 1) : path;
|
|
1247
|
+
recursiveNotes.push(`${mod.domain ?? mod.name}.${d.id} payload '${field}': recursive z.lazy schema → generated named type '${name}' (TS type + Dart class)`);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1238
1252
|
// ── reports ──
|
|
1239
1253
|
const entryReports: string[] = [];
|
|
1240
1254
|
for (const [reportId, ref] of Object.entries(cfg.reports ?? {})) {
|
|
@@ -1815,6 +1829,7 @@ async function main(): Promise<void> {
|
|
|
1815
1829
|
}
|
|
1816
1830
|
}
|
|
1817
1831
|
console.log(` client ${rel(clientPath)} (typed TS client — payloads, read models, query accessors)`);
|
|
1832
|
+
for (const note of recursiveNotes) console.log(` note ${note}`);
|
|
1818
1833
|
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
1834
|
if (proofPath !== undefined) {
|
|
1820
1835
|
console.log(` proof ${rel(proofPath)} (a runnable e2e GENERATED from your law — run: githolon proof)`);
|
package/src/zod_lazy.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free helpers for RECURSIVE `z.lazy` payload schemas, shared by BOTH codegen walkers
|
|
3
|
+
* (codegen_ts + codegen_dart) WITHOUT coupling them. Keeping these here (rather than in codegen_ts)
|
|
4
|
+
* avoids a codegen_ts↔codegen_dart import cycle that would otherwise drag codegen_ts's heavy
|
|
5
|
+
* transitive deps (workspace_routing → node:crypto) into the tenant ENGINE bundle (esbuild
|
|
6
|
+
* neutral-platform, no node builtins). This module imports NOTHING but zod's type.
|
|
7
|
+
*/
|
|
8
|
+
import type { z } from "zod";
|
|
9
|
+
|
|
10
|
+
function lazyDef(zt: z.ZodTypeAny): { type?: unknown; getter?: () => z.ZodTypeAny } {
|
|
11
|
+
const raw = zt as unknown as { _def?: { type?: unknown; getter?: () => z.ZodTypeAny }; def?: { type?: unknown; getter?: () => z.ZodTypeAny } };
|
|
12
|
+
return raw._def ?? raw.def ?? {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a `z.lazy(() => …)` ONE level to its inner schema (zod v4 exposes the deferred getter on
|
|
17
|
+
* `_def.getter`). A non-lazy schema is returned unchanged. Recursive `z.lazy` schemas (a `TreeNode`
|
|
18
|
+
* that references itself) are common for tree/JSON payloads; every schema WALKER unwraps here and
|
|
19
|
+
* carries a VISITED / registry keyed on the lazy object's IDENTITY so a self-reference is a
|
|
20
|
+
* detectable knot, never an infinite descent.
|
|
21
|
+
*/
|
|
22
|
+
export function zodUnwrapLazy(zt: z.ZodTypeAny): z.ZodTypeAny {
|
|
23
|
+
const def = lazyDef(zt);
|
|
24
|
+
if (def.type === "lazy" && typeof def.getter === "function") return def.getter();
|
|
25
|
+
return zt;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const capFirst = (s: string): string => (s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1));
|
|
29
|
+
const camelize = (s: string): string => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A PascalCase type name from a payload path (`saveTree.root` → `SaveTreeRoot`), array markers
|
|
33
|
+
* dropped. The SAME base the TS and Dart recursive-type synthesizers use so a recursive type's
|
|
34
|
+
* TS alias and Dart class agree in the common case.
|
|
35
|
+
*/
|
|
36
|
+
export function recursiveTypeBaseName(path: string): string {
|
|
37
|
+
return (
|
|
38
|
+
path
|
|
39
|
+
.replace(/\[\]/g, "")
|
|
40
|
+
.split(/[.]/)
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.map((s) => capFirst(camelize(s)))
|
|
43
|
+
.join("") || "Recursive"
|
|
44
|
+
);
|
|
45
|
+
}
|