@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_dart.ts
CHANGED
|
@@ -30,10 +30,12 @@
|
|
|
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";
|
|
36
37
|
import type { Field, FieldKind, ValueObjectDescriptor, ValueObjectFieldDescriptor } from "./fields.js";
|
|
38
|
+
import { admitsNull, nullMetaOf, nullDocLine } from "./null_meta.js";
|
|
37
39
|
import type { Directive } from "./directive.js";
|
|
38
40
|
import { executeDirectiveToIntent } from "./wire_encode.js";
|
|
39
41
|
import { deterministicPorts } from "./ctx.js";
|
|
@@ -80,6 +82,13 @@ export interface PayloadFieldSpec {
|
|
|
80
82
|
* only emits ops for present fields). Required fields are always emitted.
|
|
81
83
|
*/
|
|
82
84
|
optional: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* NULL SEMANTICS (null_semantics.md): the payload field ADMITS a domain `null` (its zod is
|
|
87
|
+
* `.nullable()`, a `t.nullable(...)`/`t.clearable(...)` sink). It generates as the sealed
|
|
88
|
+
* tri-state [NomosSlot] so the call site says OMITTED / EXPLICIT-NULL / PRESENT explicitly —
|
|
89
|
+
* `toPayloadJson` then omits, writes a real `null`, or writes the value accordingly.
|
|
90
|
+
* `false`/absent for a plain field (byte-identical to a pre-feature payload). */
|
|
91
|
+
nullAdmits?: boolean;
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
type ZodInternalDef = {
|
|
@@ -93,8 +102,23 @@ type ZodInternalDef = {
|
|
|
93
102
|
value?: unknown;
|
|
94
103
|
values?: readonly unknown[];
|
|
95
104
|
valueType?: z.ZodTypeAny;
|
|
105
|
+
getter?: () => z.ZodTypeAny; // `z.lazy(() => …)` — the deferred inner schema (zod v4)
|
|
96
106
|
};
|
|
97
107
|
|
|
108
|
+
/** Resolve `z.lazy(() => …)` chains to the first non-lazy schema, with an identity-keyed
|
|
109
|
+
* visited guard so a directly self-referencing lazy (`z.lazy(() => TheLazy)`) can't loop.
|
|
110
|
+
* A non-lazy schema returns unchanged. */
|
|
111
|
+
function resolveLazy(zt: z.ZodTypeAny): z.ZodTypeAny {
|
|
112
|
+
const seen = new Set<z.ZodTypeAny>();
|
|
113
|
+
let cur = zt;
|
|
114
|
+
while (zodDef(cur).type === "lazy" && typeof zodDef(cur).getter === "function") {
|
|
115
|
+
if (seen.has(cur)) return cur; // pathological lazy→lazy cycle — stop (caller degrades)
|
|
116
|
+
seen.add(cur);
|
|
117
|
+
cur = zodDef(cur).getter!();
|
|
118
|
+
}
|
|
119
|
+
return cur;
|
|
120
|
+
}
|
|
121
|
+
|
|
98
122
|
function zodDef(zt: z.ZodTypeAny): ZodInternalDef {
|
|
99
123
|
const raw = zt as unknown as { _def?: ZodInternalDef; def?: ZodInternalDef };
|
|
100
124
|
return raw._def ?? raw.def ?? {};
|
|
@@ -210,6 +234,7 @@ function zodNumberIsInt(zt: z.ZodTypeAny): boolean {
|
|
|
210
234
|
export function introspectPayload(
|
|
211
235
|
schema: z.ZodTypeAny,
|
|
212
236
|
enumDartTypes: Map<string, string>,
|
|
237
|
+
lazyNames?: Map<z.ZodTypeAny, string>,
|
|
213
238
|
): PayloadFieldSpec[] {
|
|
214
239
|
if (zodTypeName(schema) !== "ZodObject") {
|
|
215
240
|
throw new Error("directive payload must be a z.object");
|
|
@@ -222,11 +247,19 @@ export function introspectPayload(
|
|
|
222
247
|
// when null — mirroring the engine's Zod, which only sees present fields).
|
|
223
248
|
let zt = ztRaw as z.ZodTypeAny;
|
|
224
249
|
let optional = false;
|
|
225
|
-
|
|
226
|
-
|
|
250
|
+
let nullAdmits = false;
|
|
251
|
+
// Unwrap optional/default (omission) AND nullable (a domain `null`) in ANY order — a payload
|
|
252
|
+
// field may be `.nullable()`, `.optional()`, or both. A nullable field carries the tri-state slot.
|
|
253
|
+
while (isOptionalOrDefault(zt) || zodTypeName(zt) === "ZodNullable") {
|
|
254
|
+
if (zodTypeName(zt) === "ZodNullable") nullAdmits = true;
|
|
255
|
+
else optional = true;
|
|
227
256
|
zt = zodInnerType(zt);
|
|
228
257
|
}
|
|
229
|
-
out.push({
|
|
258
|
+
out.push({
|
|
259
|
+
...introspectField(name, zt, enumDartTypes, lazyNames),
|
|
260
|
+
optional,
|
|
261
|
+
...(nullAdmits ? { nullAdmits: true } : {}),
|
|
262
|
+
});
|
|
230
263
|
}
|
|
231
264
|
return out;
|
|
232
265
|
}
|
|
@@ -239,7 +272,26 @@ function introspectField(
|
|
|
239
272
|
name: string,
|
|
240
273
|
zt: z.ZodTypeAny,
|
|
241
274
|
enumDartTypes: Map<string, string>,
|
|
275
|
+
lazyNames?: Map<z.ZodTypeAny, string>,
|
|
242
276
|
): Omit<PayloadFieldSpec, "optional"> {
|
|
277
|
+
// RECURSIVE `z.lazy` (a self-referencing tree/JSON payload): if the compiler synthesized a
|
|
278
|
+
// typed recursive class for this knot (`lazyNames`), the field IS that class (toJson via its
|
|
279
|
+
// `.toJson()`). Without a synthesized class (no map, or a degraded knot) it resolves one level
|
|
280
|
+
// and flows into the opaque-JSON lane below (`Map<String,Object?>`) — the last-resort fallback.
|
|
281
|
+
if (zodTypeName(zt) === "ZodLazy") {
|
|
282
|
+
const cls = lazyNames?.get(zt);
|
|
283
|
+
if (cls !== undefined) return { name, dartType: cls, toJson: "vo" };
|
|
284
|
+
// No synthesized class (no map, or a DEGRADED knot — e.g. a lazy of a union): last-resort
|
|
285
|
+
// opaque JSON. Resolve one level and reuse the normal lane, but any shape the lane can't
|
|
286
|
+
// express (union, undeclared nested object) falls back to `Object?` rather than throwing.
|
|
287
|
+
const inner = resolveLazy(zt);
|
|
288
|
+
if (zodTypeName(inner) === "ZodLazy") return { name, dartType: "Object?", toJson: "json" };
|
|
289
|
+
try {
|
|
290
|
+
return introspectField(name, inner, enumDartTypes, lazyNames);
|
|
291
|
+
} catch {
|
|
292
|
+
return { name, dartType: "Object?", toJson: "json" };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
243
295
|
// A DECLARED VO/union keeps its name regardless of its zod kind (a union(...) is a
|
|
244
296
|
// z.discriminatedUnion, not a ZodObject — without this it would fall to the throw lane).
|
|
245
297
|
{
|
|
@@ -298,6 +350,11 @@ function introspectField(
|
|
|
298
350
|
if (zodTypeName(elem) === "ZodString") {
|
|
299
351
|
return { name, dartType: "List<String>", toJson: "strList" };
|
|
300
352
|
}
|
|
353
|
+
// A list of a RECURSIVE `z.lazy` knot (a synthesized class) is a typed `List<Class>`.
|
|
354
|
+
const elemCls = zodTypeName(elem) === "ZodLazy" ? lazyNames?.get(elem) : undefined;
|
|
355
|
+
if (elemCls !== undefined) {
|
|
356
|
+
return { name, dartType: `List<${elemCls}>`, toJson: "voList" };
|
|
357
|
+
}
|
|
301
358
|
const elemVo = voMetaOf(elem);
|
|
302
359
|
if (elemVo !== undefined) {
|
|
303
360
|
return { name, dartType: `List<${elemVo.name}>`, toJson: "voList" };
|
|
@@ -412,6 +469,15 @@ interface AggFieldSpec {
|
|
|
412
469
|
* only the generated read-model type. `undefined` for a non-json field.
|
|
413
470
|
*/
|
|
414
471
|
jsonShape?: "object";
|
|
472
|
+
/**
|
|
473
|
+
* NULL SEMANTICS (null_semantics.md): the field ADMITS a domain `null` (declared
|
|
474
|
+
* `t.nullable(...)` / `t.clearable(...)`), so its read-model + payload surface is the
|
|
475
|
+
* sealed tri-state [NomosSlot] (Omitted / ExplicitNull / Present) instead of a plain
|
|
476
|
+
* `T?` — so the app edge can't collapse omission and explicit-null. `false`/absent for a
|
|
477
|
+
* `forbid` field (byte-identical to a pre-feature field). */
|
|
478
|
+
nullAdmits?: boolean;
|
|
479
|
+
/** The law-documentation `reason` for a null-admitting field (surfaces in the Dart doc comment). */
|
|
480
|
+
nullReason?: string;
|
|
415
481
|
}
|
|
416
482
|
|
|
417
483
|
export interface DartImport {
|
|
@@ -764,11 +830,16 @@ function aggregateFieldSpecs(
|
|
|
764
830
|
// BIRTH-PRESENCE LAW (`.fromBirth()`): the gate refuses any event that leaves the field
|
|
765
831
|
// absent, so the read model may type it NON-NULLABLE — the one lawful exception to the
|
|
766
832
|
// partial-fold nullability doctrine (absence is unrepresentable by law, not convention).
|
|
767
|
-
const
|
|
833
|
+
const baseSpec: AggFieldSpec = isCollectionKind(base.kind)
|
|
768
834
|
? base
|
|
769
835
|
: f.isFromBirth === true
|
|
770
836
|
? { ...base, optional: false }
|
|
771
837
|
: { ...base, optional: true };
|
|
838
|
+
// NULL SEMANTICS (null_semantics.md): a null-admitting scalar carries the tri-state slot.
|
|
839
|
+
const nmeta = nullMetaOf(f);
|
|
840
|
+
const spec: AggFieldSpec = admitsNull(f)
|
|
841
|
+
? { ...baseSpec, nullAdmits: true, ...(nmeta.nullReason !== undefined ? { nullReason: nmeta.nullReason } : {}) }
|
|
842
|
+
: baseSpec;
|
|
772
843
|
if (used.has(spec.name)) {
|
|
773
844
|
throw new Error(
|
|
774
845
|
`aggregate '${agg.id}' field '${spec.wireName}' maps to duplicate Dart field '${spec.name}'`,
|
|
@@ -1093,19 +1164,27 @@ function emitAggregate(
|
|
|
1093
1164
|
` /// indexed/by-id accessor matched on (e.g. the siteId). Empty string only when`,
|
|
1094
1165
|
` /// the aggregate is built from a bare \`data\` map with no row id (e.g. a unit test).`,
|
|
1095
1166
|
` final ${idType} aggregateId;`,
|
|
1096
|
-
...specs.
|
|
1097
|
-
|
|
1098
|
-
|
|
1167
|
+
...specs.flatMap((s) => {
|
|
1168
|
+
const doc = s.nullReason !== undefined ? [` /// null semantic: ${s.nullReason}`] : [];
|
|
1169
|
+
// NULL SEMANTICS: a null-admitting field is the sealed tri-state slot (Omitted / ExplicitNull /
|
|
1170
|
+
// Present) — omission and explicit-null never collapse. Others keep the plain `T?`.
|
|
1171
|
+
return s.nullAdmits === true
|
|
1172
|
+
? [...doc, ` final NomosSlot<${stripNullable(s.dartType)}> ${s.name};`]
|
|
1173
|
+
: [` final ${s.dartType}${s.optional ? "?" : ""} ${s.name};`];
|
|
1174
|
+
}),
|
|
1099
1175
|
].join("\n");
|
|
1100
1176
|
const ctorParams = [
|
|
1101
1177
|
` this.aggregateId = const ${idType}.fromRow(''),`,
|
|
1102
1178
|
...specs.map(
|
|
1103
1179
|
// A required scalar stays `required`; an optional scalar OR a collection is not
|
|
1104
1180
|
// `required` (an absent optional scalar is null; an absent collection is empty).
|
|
1181
|
+
// A null-admitting slot defaults to Omitted.
|
|
1105
1182
|
(s) =>
|
|
1106
|
-
s.
|
|
1107
|
-
? `
|
|
1108
|
-
:
|
|
1183
|
+
s.nullAdmits === true
|
|
1184
|
+
? ` this.${s.name} = const NomosSlot.omitted(),`
|
|
1185
|
+
: s.optional || isCollectionKind(s.kind)
|
|
1186
|
+
? ` ${collectionDefaultParam(s)}`
|
|
1187
|
+
: ` required this.${s.name},`,
|
|
1109
1188
|
),
|
|
1110
1189
|
].join("\n");
|
|
1111
1190
|
|
|
@@ -1117,7 +1196,11 @@ function emitAggregate(
|
|
|
1117
1196
|
// threaded from the row id (GAP 2) — defaulting to '' for a bare-map call.
|
|
1118
1197
|
const parseLines = [
|
|
1119
1198
|
` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
|
|
1120
|
-
...specs.map((s) =>
|
|
1199
|
+
...specs.map((s) =>
|
|
1200
|
+
s.nullAdmits === true
|
|
1201
|
+
? ` ${s.name}: NomosSlot.decode<${stripNullable(s.dartType)}>(data, '${s.wireName}', ${slotDecodeCallback(s, "aggregate")}),`
|
|
1202
|
+
: ` ${s.name}: ${parseField(s, className)},`,
|
|
1203
|
+
),
|
|
1121
1204
|
].join("\n");
|
|
1122
1205
|
|
|
1123
1206
|
// `==`/`hashCode` include the id (two rows with the same data but different ids are
|
|
@@ -1912,6 +1995,53 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
|
|
|
1912
1995
|
}
|
|
1913
1996
|
}
|
|
1914
1997
|
|
|
1998
|
+
/**
|
|
1999
|
+
* The `(v) => …` DECODE CALLBACK for a null-admitting field's [NomosSlot] (null_semantics.md).
|
|
2000
|
+
* [NomosSlot.decode] pre-handles the OMITTED (key absent) + EXPLICIT-NULL (value `null`) cases,
|
|
2001
|
+
* so this callback ONLY sees a real PRESENT value `v` (never null) and turns it into the typed
|
|
2002
|
+
* Dart value — mirroring [parseField]'s present-branch per kind, but over a raw leaf `v`. */
|
|
2003
|
+
function slotDecodeCallback(s: AggFieldSpec, mode: "aggregate" | "readModel"): string {
|
|
2004
|
+
switch (s.kind) {
|
|
2005
|
+
case "ref":
|
|
2006
|
+
return s.refIdClass !== undefined
|
|
2007
|
+
? `(v) => ${s.refIdClass}.fromRow(v as String)`
|
|
2008
|
+
: `(v) => v as String`;
|
|
2009
|
+
case "position":
|
|
2010
|
+
case "string":
|
|
2011
|
+
return s.valueClass !== undefined
|
|
2012
|
+
? `(v) => ${s.valueClass}.fromRow(v as String)`
|
|
2013
|
+
: `(v) => v as String`;
|
|
2014
|
+
case "json":
|
|
2015
|
+
if (s.voClass !== undefined) {
|
|
2016
|
+
return `(v) => ${s.voClass}.fromProjected((v as Map).cast<String, Object?>())`;
|
|
2017
|
+
}
|
|
2018
|
+
// The AGGREGATE class types every `json` leaf as `String` (re-stringify a parsed container via
|
|
2019
|
+
// `jsonEncode`); the READ MODEL types a `t.jsonObject()` leaf as `Map<String, Object?>` (strict
|
|
2020
|
+
// object) and a plain `t.json()` leaf as the permissive `Object?` (kept as-is).
|
|
2021
|
+
if (mode === "aggregate") return `(v) => v is String ? v : jsonEncode(v)`;
|
|
2022
|
+
return s.jsonShape === "object" ? `(v) => (v as Map).cast<String, Object?>()` : `(v) => v`;
|
|
2023
|
+
case "bool":
|
|
2024
|
+
return `(v) => v as bool`;
|
|
2025
|
+
case "counter":
|
|
2026
|
+
case "int":
|
|
2027
|
+
return `(v) => (v as num).toInt()`;
|
|
2028
|
+
case "enum":
|
|
2029
|
+
return `(v) => ${s.dartType}.fromWire(v as String)`;
|
|
2030
|
+
case "evidence":
|
|
2031
|
+
return `(v) => EvidenceRef.fromProjected((v as Map).cast<String, Object?>())`;
|
|
2032
|
+
// Collections never admit null (the law bans nullable on a set/map) — unreachable.
|
|
2033
|
+
case "set":
|
|
2034
|
+
case "map":
|
|
2035
|
+
return `(v) => v`;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
/** Strip a trailing `?` so a nullable-typed base (`Object?`) becomes the slot's inner
|
|
2040
|
+
* non-null type (`NomosSlot<Object>` — the slot owns nullability via ExplicitNull). */
|
|
2041
|
+
function stripNullable(t: string): string {
|
|
2042
|
+
return t.endsWith("?") ? t.slice(0, -1) : t;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
1915
2045
|
// ---- generated read model class --------------------------------------------
|
|
1916
2046
|
//
|
|
1917
2047
|
// A typed read model class generated from aggregate DSL field declarations. It
|
|
@@ -2104,9 +2234,12 @@ function emitReadModel(
|
|
|
2104
2234
|
` /// The kernel aggregate id (the \`{type,id,data}\` row's \`id\`, e.g. the siteId).`,
|
|
2105
2235
|
` /// Empty string only when built from a bare \`data\` map with no row id (a unit test).`,
|
|
2106
2236
|
` final ${idType} aggregateId;`,
|
|
2107
|
-
...specs.
|
|
2108
|
-
|
|
2109
|
-
|
|
2237
|
+
...specs.flatMap((s) => {
|
|
2238
|
+
const doc = s.nullReason !== undefined ? [` /// null semantic: ${s.nullReason}`] : [];
|
|
2239
|
+
return s.nullAdmits === true
|
|
2240
|
+
? [...doc, ` final NomosSlot<${stripNullable(readModelDartType(s))}> ${s.name};`]
|
|
2241
|
+
: [` final ${readModelDartType(s)}${readModelNullableSuffix(s)} ${s.name};`];
|
|
2242
|
+
}),
|
|
2110
2243
|
...deriveds.map(
|
|
2111
2244
|
(d) =>
|
|
2112
2245
|
` /// Engine-projected DERIVED read field (pure fn of the folded fields; NOT kernel state).\n final ${projectionReturnDartType(d.returns)} ${d.id};`,
|
|
@@ -2121,9 +2254,12 @@ function emitReadModel(
|
|
|
2121
2254
|
...specs.map((s) =>
|
|
2122
2255
|
// A required field is `required`; an optional scalar, a collection, OR a
|
|
2123
2256
|
// permissive json leaf (already nullable `Object?`) takes its default/null.
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2257
|
+
// A null-admitting slot defaults to Omitted.
|
|
2258
|
+
s.nullAdmits === true
|
|
2259
|
+
? ` this.${s.name} = const NomosSlot.omitted(),`
|
|
2260
|
+
: s.optional || isCollectionKind(s.kind) || isPermissiveJsonLeaf(s)
|
|
2261
|
+
? ` ${readModelDefaultParam(s)}`
|
|
2262
|
+
: ` required this.${s.name},`,
|
|
2127
2263
|
),
|
|
2128
2264
|
// Derived/combined fields are always nullable (null until the engine pass runs).
|
|
2129
2265
|
...deriveds.map((d) => ` this.${d.id},`),
|
|
@@ -2132,7 +2268,11 @@ function emitReadModel(
|
|
|
2132
2268
|
|
|
2133
2269
|
const parseLines = [
|
|
2134
2270
|
` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
|
|
2135
|
-
...specs.map((s) =>
|
|
2271
|
+
...specs.map((s) =>
|
|
2272
|
+
s.nullAdmits === true
|
|
2273
|
+
? ` ${s.name}: NomosSlot.decode<${stripNullable(readModelDartType(s))}>(data, '${s.wireName}', ${slotDecodeCallback(s, "readModel")}),`
|
|
2274
|
+
: ` ${s.name}: ${parseReadModelField(s, wireType)},`,
|
|
2275
|
+
),
|
|
2136
2276
|
// Derived/combined fields decode from the engine-produced bare JSON leaf.
|
|
2137
2277
|
...deriveds.map((d) => ` ${d.id}: ${parseProjectionReturnField(d.id, d.returns)},`),
|
|
2138
2278
|
...combineds.map((c) => ` ${c.id}: ${parseProjectionReturnField(c.id, c.returns)},`),
|
|
@@ -2176,17 +2316,28 @@ function emitDirective(
|
|
|
2176
2316
|
d: AnyDirective,
|
|
2177
2317
|
domainName: string,
|
|
2178
2318
|
enumDartTypes: Map<string, string>,
|
|
2319
|
+
lazyNames?: Map<z.ZodTypeAny, string>,
|
|
2179
2320
|
): string {
|
|
2180
2321
|
const className = `${cap(d.id)}Payload`;
|
|
2181
|
-
const specs = introspectPayload(d.payloadSchema as z.ZodTypeAny, enumDartTypes);
|
|
2322
|
+
const specs = introspectPayload(d.payloadSchema as z.ZodTypeAny, enumDartTypes, lazyNames);
|
|
2182
2323
|
|
|
2183
2324
|
// Field decls: optional payload fields are nullable Dart fields (omitted from the
|
|
2184
2325
|
// payload JSON when null); required fields are non-null + `required` in the ctor.
|
|
2185
2326
|
const fieldDecls = specs
|
|
2186
|
-
.map((s) =>
|
|
2327
|
+
.map((s) =>
|
|
2328
|
+
s.nullAdmits === true
|
|
2329
|
+
? ` final NomosSlot<${stripNullable(s.dartType)}> ${s.name};`
|
|
2330
|
+
: ` final ${s.dartType}${s.optional ? "?" : ""} ${s.name};`,
|
|
2331
|
+
)
|
|
2187
2332
|
.join("\n");
|
|
2188
2333
|
const ctorParams = specs
|
|
2189
|
-
.map((s) =>
|
|
2334
|
+
.map((s) =>
|
|
2335
|
+
// A null-admitting slot defaults to Omitted (the call site chooses Present/ExplicitNull);
|
|
2336
|
+
// an optional field defaults to null; a required field is `required`.
|
|
2337
|
+
s.nullAdmits === true
|
|
2338
|
+
? ` this.${s.name} = const NomosSlot.omitted(),`
|
|
2339
|
+
: ` ${s.optional ? "" : "required "}this.${s.name},`,
|
|
2340
|
+
)
|
|
2190
2341
|
.join("\n");
|
|
2191
2342
|
// Zero-field payload (e.g. a `.births()` sanctioning directive with an empty
|
|
2192
2343
|
// object schema): `const X({});` / a dangling blank field block is INVALID Dart —
|
|
@@ -2201,14 +2352,21 @@ function emitDirective(
|
|
|
2201
2352
|
// fields are written only when present (mirrors the engine's Zod: ops are emitted
|
|
2202
2353
|
// only for present fields).
|
|
2203
2354
|
const requiredEntries = specs
|
|
2204
|
-
.filter((s) => !s.optional)
|
|
2355
|
+
.filter((s) => !s.optional && s.nullAdmits !== true)
|
|
2205
2356
|
.map((s) => ` '${s.name}': ${toJsonExpr(s, s.name)},`);
|
|
2206
2357
|
const optionalLines = specs
|
|
2207
|
-
.filter((s) => s.optional)
|
|
2358
|
+
.filter((s) => s.optional && s.nullAdmits !== true)
|
|
2208
2359
|
.map(
|
|
2209
2360
|
(s) =>
|
|
2210
2361
|
` if (${s.name} != null) '${s.name}': ${toJsonExpr(s, `${s.name}!`)},`,
|
|
2211
2362
|
);
|
|
2363
|
+
// NULL SEMANTICS: a null-admitting field writes through its slot — Omitted writes nothing,
|
|
2364
|
+
// ExplicitNull writes a real `null`, Present writes the encoded value (null_semantics.md).
|
|
2365
|
+
const slotSpecs = specs.filter((s) => s.nullAdmits === true);
|
|
2366
|
+
const slotWriteLines = slotSpecs.map(
|
|
2367
|
+
(s) => ` ${s.name}.writeTo(data, '${s.name}', (v) => ${toJsonExpr(s, "v")});`,
|
|
2368
|
+
);
|
|
2369
|
+
const hasSlots = slotSpecs.length > 0;
|
|
2212
2370
|
|
|
2213
2371
|
const docFirst = directiveDoc(d);
|
|
2214
2372
|
|
|
@@ -2242,12 +2400,25 @@ function emitDirective(
|
|
|
2242
2400
|
``,
|
|
2243
2401
|
` /// Serialise this payload to the intent DATA JSON (the shape the`,
|
|
2244
2402
|
` /// domain Zod schema validates inside the engine). Optional fields are`,
|
|
2245
|
-
` /// omitted when null.`,
|
|
2403
|
+
` /// omitted when null; a null-admitting field writes through its tri-state slot.`,
|
|
2246
2404
|
` @override`,
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2405
|
+
...(hasSlots
|
|
2406
|
+
? [
|
|
2407
|
+
` Map<String, Object?> toPayloadJson() {`,
|
|
2408
|
+
` final data = <String, Object?>{`,
|
|
2409
|
+
...requiredEntries,
|
|
2410
|
+
...optionalLines,
|
|
2411
|
+
` };`,
|
|
2412
|
+
...slotWriteLines,
|
|
2413
|
+
` return data;`,
|
|
2414
|
+
` }`,
|
|
2415
|
+
]
|
|
2416
|
+
: [
|
|
2417
|
+
` Map<String, Object?> toPayloadJson() => <String, Object?>{`,
|
|
2418
|
+
...requiredEntries,
|
|
2419
|
+
...optionalLines,
|
|
2420
|
+
` };`,
|
|
2421
|
+
]),
|
|
2251
2422
|
`}`,
|
|
2252
2423
|
].join("\n");
|
|
2253
2424
|
}
|
|
@@ -2270,6 +2441,159 @@ export function toJsonExpr(spec: PayloadFieldSpec, accessor: string): string {
|
|
|
2270
2441
|
}
|
|
2271
2442
|
}
|
|
2272
2443
|
|
|
2444
|
+
// ── RECURSIVE `z.lazy` payloads → synthesized Dart classes (full support) ─────────
|
|
2445
|
+
//
|
|
2446
|
+
// A self-referencing payload schema (`TreeNode = z.lazy(() => z.object({ label, children:
|
|
2447
|
+
// z.array(TreeNode).optional() }))`) is FULLY typed in Dart, not degraded: each lazy KNOT
|
|
2448
|
+
// becomes a real recursive `class` reusing the value-object class emitter — its `children`
|
|
2449
|
+
// field types as `List<SaveTreeRoot>?`, i.e. itself. We synthesize a `ValueObjectDescriptor`
|
|
2450
|
+
// per knot (a `{type:"object", ref:<self>}` field is the recursion, which the VO emitter
|
|
2451
|
+
// renders by NAME, so emission never loops) and route the payload field to it.
|
|
2452
|
+
//
|
|
2453
|
+
// A knot whose shape is NOT expressible as a VO field set (e.g. a lazy of a union, or an
|
|
2454
|
+
// interior undeclared nested object) is the ONE last-resort degrade: it drops to `Object?`
|
|
2455
|
+
// (opaque JSON) and its path is reported. The common tree/JSON recursion never degrades.
|
|
2456
|
+
|
|
2457
|
+
export interface SynthesizedRecursiveTypes {
|
|
2458
|
+
/** lazy zod identity → generated Dart class name (SUCCESSFULLY synthesized knots only). */
|
|
2459
|
+
readonly names: Map<z.ZodTypeAny, string>;
|
|
2460
|
+
/** generated recursive-class descriptors, keyed by class name (fed to [emitValueObjectClass]). */
|
|
2461
|
+
readonly descriptors: Map<string, ValueObjectDescriptor>;
|
|
2462
|
+
/** knot paths that could NOT be represented as a class (degraded to `Object?`) — reported. */
|
|
2463
|
+
readonly degraded: string[];
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
/** Synthesize the recursive-payload Dart classes for a module's directives (see the block header). */
|
|
2467
|
+
export function synthesizeRecursivePayloadTypes(
|
|
2468
|
+
directives: readonly { payloadSchema?: unknown; id?: string }[],
|
|
2469
|
+
): SynthesizedRecursiveTypes {
|
|
2470
|
+
const names = new Map<z.ZodTypeAny, string>();
|
|
2471
|
+
const descriptors = new Map<string, ValueObjectDescriptor>();
|
|
2472
|
+
const used = new Set<string>();
|
|
2473
|
+
const degraded: string[] = [];
|
|
2474
|
+
|
|
2475
|
+
const mint = (path: string): string => {
|
|
2476
|
+
const base = recursiveTypeBaseName(path);
|
|
2477
|
+
let n = base;
|
|
2478
|
+
for (let i = 2; used.has(n); i++) n = `${base}${i}`;
|
|
2479
|
+
used.add(n);
|
|
2480
|
+
return n;
|
|
2481
|
+
};
|
|
2482
|
+
|
|
2483
|
+
// ONE zod field inside a knot → its VO field descriptor. THROWS for a shape the VO field set
|
|
2484
|
+
// can't express (→ the enclosing knot degrades). A nested lazy recurses into [buildKnot].
|
|
2485
|
+
const fieldOf = (zt: z.ZodTypeAny, path: string): ValueObjectFieldDescriptor => {
|
|
2486
|
+
if (zodTypeName(zt) === "ZodOptional" || zodTypeName(zt) === "ZodDefault") {
|
|
2487
|
+
return { ...fieldOf(zodInnerType(zt), path), optional: true };
|
|
2488
|
+
}
|
|
2489
|
+
if (zodTypeName(zt) === "ZodLazy") {
|
|
2490
|
+
const ref = buildKnot(zt, path);
|
|
2491
|
+
if (ref === undefined) throw new Error("recursive knot degraded");
|
|
2492
|
+
return { type: "object", ref };
|
|
2493
|
+
}
|
|
2494
|
+
const vo = voMetaOf(zt);
|
|
2495
|
+
if (vo !== undefined) {
|
|
2496
|
+
return vo.geometry !== undefined
|
|
2497
|
+
? { type: "geometry", ref: vo.name, geometry: vo.geometry }
|
|
2498
|
+
: { type: "object", ref: vo.name };
|
|
2499
|
+
}
|
|
2500
|
+
switch (zodTypeName(zt)) {
|
|
2501
|
+
case "ZodString": {
|
|
2502
|
+
const b = brandedIdMetaOf(zt);
|
|
2503
|
+
if (b !== undefined) return { type: "string", brand: b };
|
|
2504
|
+
const idAgg = idOfMetaOf(zt);
|
|
2505
|
+
if (idAgg !== undefined) return { type: "string", brand: `${idAgg}Id` };
|
|
2506
|
+
return { type: "string" };
|
|
2507
|
+
}
|
|
2508
|
+
case "ZodNumber": {
|
|
2509
|
+
const unit = (zt as unknown as Record<symbol, unknown>)[NOMOS_UNIT_BRAND];
|
|
2510
|
+
return {
|
|
2511
|
+
type: "number",
|
|
2512
|
+
...(zodNumberIsInt(zt) ? { int: true as const } : {}),
|
|
2513
|
+
...(typeof unit === "string" ? { unit } : {}),
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
case "ZodBoolean":
|
|
2517
|
+
return { type: "boolean" };
|
|
2518
|
+
case "ZodEnum":
|
|
2519
|
+
return { type: "enum", values: zodEnumValues(zt) };
|
|
2520
|
+
case "ZodLiteral": {
|
|
2521
|
+
const v = zodLiteralValue(zt);
|
|
2522
|
+
if (typeof v !== "string") throw new Error("non-string literal");
|
|
2523
|
+
return { type: "literal", value: v };
|
|
2524
|
+
}
|
|
2525
|
+
case "ZodArray": {
|
|
2526
|
+
const items = fieldOf(zodArrayElement(zt), `${path}[]`);
|
|
2527
|
+
if (items.optional === true) throw new Error("array of optionals");
|
|
2528
|
+
return { type: "array", items };
|
|
2529
|
+
}
|
|
2530
|
+
case "ZodRecord": {
|
|
2531
|
+
const vt = zodRecordValueType(zt);
|
|
2532
|
+
if (vt === undefined) throw new Error("record without value type");
|
|
2533
|
+
const values = fieldOf(vt, `${path}.*`);
|
|
2534
|
+
if (values.optional === true) throw new Error("record of optionals");
|
|
2535
|
+
return { type: "map", values };
|
|
2536
|
+
}
|
|
2537
|
+
default:
|
|
2538
|
+
throw new Error(`unsupported field shape '${zodTypeName(zt)}'`);
|
|
2539
|
+
}
|
|
2540
|
+
};
|
|
2541
|
+
|
|
2542
|
+
// Build (once) the class descriptor for a lazy knot. Returns its class name, or undefined if
|
|
2543
|
+
// the knot degrades. Registers the name BEFORE recursing so a self-reference resolves cleanly.
|
|
2544
|
+
const buildKnot = (lazyZt: z.ZodTypeAny, path: string): string | undefined => {
|
|
2545
|
+
const existing = names.get(lazyZt);
|
|
2546
|
+
if (existing !== undefined) return existing;
|
|
2547
|
+
const name = mint(path);
|
|
2548
|
+
names.set(lazyZt, name);
|
|
2549
|
+
const obj = zodUnwrapLazy(lazyZt);
|
|
2550
|
+
if (zodTypeName(obj) !== "ZodObject") {
|
|
2551
|
+
names.delete(lazyZt);
|
|
2552
|
+
degraded.push(path);
|
|
2553
|
+
return undefined;
|
|
2554
|
+
}
|
|
2555
|
+
const fields: Record<string, ValueObjectFieldDescriptor> = {};
|
|
2556
|
+
try {
|
|
2557
|
+
const shape = zodObjectShape(obj);
|
|
2558
|
+
for (const key of Object.keys(shape).sort()) fields[key] = fieldOf(shape[key]!, `${path}.${key}`);
|
|
2559
|
+
} catch {
|
|
2560
|
+
names.delete(lazyZt);
|
|
2561
|
+
degraded.push(path);
|
|
2562
|
+
return undefined;
|
|
2563
|
+
}
|
|
2564
|
+
descriptors.set(name, { name, fields });
|
|
2565
|
+
return name;
|
|
2566
|
+
};
|
|
2567
|
+
|
|
2568
|
+
// Find the outermost lazy knots reachable from each payload field (through optional/array).
|
|
2569
|
+
const findKnots = (zt: z.ZodTypeAny, path: string): void => {
|
|
2570
|
+
switch (zodTypeName(zt)) {
|
|
2571
|
+
case "ZodLazy":
|
|
2572
|
+
buildKnot(zt, path);
|
|
2573
|
+
return;
|
|
2574
|
+
case "ZodOptional":
|
|
2575
|
+
case "ZodDefault":
|
|
2576
|
+
findKnots(zodInnerType(zt), path);
|
|
2577
|
+
return;
|
|
2578
|
+
case "ZodArray":
|
|
2579
|
+
findKnots(zodArrayElement(zt), `${path}[]`);
|
|
2580
|
+
return;
|
|
2581
|
+
default:
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
};
|
|
2585
|
+
|
|
2586
|
+
for (const d of directives) {
|
|
2587
|
+
const schema = d?.payloadSchema as z.ZodTypeAny | undefined;
|
|
2588
|
+
if (!schema || typeof schema !== "object" || zodTypeName(schema) !== "ZodObject") continue;
|
|
2589
|
+
const dirId = d.id ?? "";
|
|
2590
|
+
for (const [key, raw] of Object.entries(zodObjectShape(schema))) {
|
|
2591
|
+
findKnots(raw as z.ZodTypeAny, `${dirId}.${key}`);
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
return { names, descriptors, degraded };
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2273
2597
|
// ---- read accessors (typed subscriptions) ----------------------------------
|
|
2274
2598
|
//
|
|
2275
2599
|
// The READ half of the peer query surface (Jack 2026-06-18): the app reads ONLY via
|
|
@@ -3314,7 +3638,14 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
|
|
|
3314
3638
|
// the aggregate-derived set. Codegen-only: the IR key stays aggregate-derived (era rule).
|
|
3315
3639
|
const aggVoNames = new Set((mod.valueObjects ?? []).map((d) => d.name));
|
|
3316
3640
|
const payloadVos = collectPayloadValueObjectDescriptors(mod.directives ?? []).filter((d) => !aggVoNames.has(d.name));
|
|
3317
|
-
|
|
3641
|
+
// RECURSIVE `z.lazy` payloads → synthesized recursive-class descriptors (full support). They
|
|
3642
|
+
// are codegen-only (like payloadVos): the law IR / domainHash never sees them (era rule). The
|
|
3643
|
+
// name map routes each recursive payload field to its class in [emitDirective].
|
|
3644
|
+
const recursiveTypes = synthesizeRecursivePayloadTypes(mod.directives ?? []);
|
|
3645
|
+
const recursiveDescriptors = [...recursiveTypes.descriptors.values()].filter(
|
|
3646
|
+
(d) => !aggVoNames.has(d.name) && !payloadVos.some((p) => p.name === d.name),
|
|
3647
|
+
);
|
|
3648
|
+
const localValueObjects = [...(mod.valueObjects ?? []), ...payloadVos, ...recursiveDescriptors]
|
|
3318
3649
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
3319
3650
|
.filter((d) => !skipValueObjectNames.has(d.name));
|
|
3320
3651
|
// Each VO's block = its FIELD ENUMS (real Dart enums, tolerant fromWire) then its class —
|
|
@@ -3389,6 +3720,13 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
|
|
|
3389
3720
|
].join("\n\n");
|
|
3390
3721
|
// The symbols whose Dart surface this file EMITS (framework-shared ones live in the shared file).
|
|
3391
3722
|
const localAggregates = mod.aggregates.filter((a) => !skipAggregateIds.has(a.id));
|
|
3723
|
+
// A nullable plain-`t.json()` read-model field's slot decode re-stringifies a parsed container via
|
|
3724
|
+
// `jsonEncode` — the ONE case the generated file needs `dart:convert` (null_semantics.md).
|
|
3725
|
+
const needsJsonConvert = localAggregates.some((a) =>
|
|
3726
|
+
Object.values(a.fields as Record<string, Field>).some(
|
|
3727
|
+
(f) => admitsNull(f) && f.kind === "json" && f.valueObject === undefined,
|
|
3728
|
+
),
|
|
3729
|
+
);
|
|
3392
3730
|
const localDirectives = mod.directives.filter((d) => !skipDirectiveIds.has(d.id));
|
|
3393
3731
|
const localQueries = (mod.queries ?? []).filter((q) => !skipQueryIds.has(q.id));
|
|
3394
3732
|
// Enums are collected from the LOCALLY-emitted symbols only — a framework directive/aggregate's
|
|
@@ -3421,7 +3759,7 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
|
|
|
3421
3759
|
// the runtime aggregate-meta registry (lookup by op.aggregateType — generic surfaces).
|
|
3422
3760
|
const aggregateMetaBlock = emitAggregateMetaRegistry(domainKey, localAggregates);
|
|
3423
3761
|
const dirBlock = localDirectives
|
|
3424
|
-
.map((d) => emitDirective(d, domainKey, dartTypes))
|
|
3762
|
+
.map((d) => emitDirective(d, domainKey, dartTypes, recursiveTypes.names))
|
|
3425
3763
|
.join("\n\n");
|
|
3426
3764
|
|
|
3427
3765
|
// TYPED IDS + MINTED CREATES (the id-mint keystone, GENERALIZED): a DISTINCT typed
|
|
@@ -3596,6 +3934,8 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
|
|
|
3596
3934
|
`// ignore_for_file: unnecessary_this, prefer_const_constructors, constant_identifier_names`,
|
|
3597
3935
|
`// ignore_for_file: unused_import`,
|
|
3598
3936
|
``,
|
|
3937
|
+
// A nullable plain-`t.json()` read-model slot decode re-stringifies via `jsonEncode`.
|
|
3938
|
+
...(needsJsonConvert ? [`import 'dart:convert';`] : []),
|
|
3599
3939
|
// The generated tenant domains live in the `co2_nomos_domains` package and depend
|
|
3600
3940
|
// on the framework `nomos_client` package by its PUBLIC package: URI — NOT a relative
|
|
3601
3941
|
// `../wire.dart` path into the framework (which broke when the generated dir moved
|
package/src/codegen_openapi.ts
CHANGED
|
@@ -38,6 +38,8 @@ import { hasManyIndexes } from "./query.js";
|
|
|
38
38
|
import { finishCount } from "./count.js";
|
|
39
39
|
import { finishSum } from "./sum.js";
|
|
40
40
|
import { finishExtremum } from "./extremum.js";
|
|
41
|
+
import { nullDocLine } from "./null_meta.js";
|
|
42
|
+
import type { Field } from "./fields.js";
|
|
41
43
|
|
|
42
44
|
type AnyDirective = Directive<unknown>;
|
|
43
45
|
|
|
@@ -56,13 +58,30 @@ export interface OpenApiFragmentOptions {
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/** Lower a directive's payload zod to JSON Schema; a non-representable schema falls back to a
|
|
59
|
-
* permissive object rather than failing the whole compile (the engine still validates the real zod).
|
|
60
|
-
|
|
61
|
+
* permissive object rather than failing the whole compile (the engine still validates the real zod).
|
|
62
|
+
* NULL SEMANTICS (null_semantics.md): a payload property that maps by name to a null-admitting field
|
|
63
|
+
* on the directive's target aggregate carries that field's documented `reason` as its `description`,
|
|
64
|
+
* so every consumer of the spec knows what a `null` MEANS here. */
|
|
65
|
+
function payloadJsonSchema(d: AnyDirective, module: DomainModule): unknown {
|
|
66
|
+
let schema: unknown;
|
|
61
67
|
try {
|
|
62
|
-
|
|
68
|
+
schema = z.toJSONSchema((d as { payloadSchema: z.ZodTypeAny }).payloadSchema, { io: "input" });
|
|
63
69
|
} catch {
|
|
64
70
|
return { type: "object", additionalProperties: true };
|
|
65
71
|
}
|
|
72
|
+
const aggId = (d as { aggregateId?: string }).aggregateId;
|
|
73
|
+
const agg = aggId !== undefined ? module.aggregates.find((a) => a.id === aggId) : undefined;
|
|
74
|
+
const props = (schema as { properties?: Record<string, Record<string, unknown>> })?.properties;
|
|
75
|
+
if (agg !== undefined && props !== undefined && typeof props === "object") {
|
|
76
|
+
for (const [name, prop] of Object.entries(props)) {
|
|
77
|
+
const field = (agg.fields as Record<string, Field> | undefined)?.[name];
|
|
78
|
+
const doc = field !== undefined ? nullDocLine(field) : undefined;
|
|
79
|
+
if (doc !== undefined && prop !== null && typeof prop === "object") {
|
|
80
|
+
prop.description = prop.description !== undefined ? `${String(prop.description)} — ${doc}` : doc;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return schema;
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
/** The domain key the engine resolves intents against (the `author` registry key). */
|
|
@@ -111,7 +130,7 @@ export function generateOpenApiFragment(
|
|
|
111
130
|
},
|
|
112
131
|
requestBody: {
|
|
113
132
|
required: true,
|
|
114
|
-
content: { "application/json": { schema: payloadJsonSchema(dir) } },
|
|
133
|
+
content: { "application/json": { schema: payloadJsonSchema(dir, module) } },
|
|
115
134
|
},
|
|
116
135
|
responses: {
|
|
117
136
|
"200": { description: "admitted — the sealed intent head" },
|