@githolon/dsl 0.54.2 → 0.55.1
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 +165 -27
- package/src/codegen_openapi.ts +23 -4
- package/src/codegen_proof.ts +74 -0
- package/src/codegen_ts.ts +11 -0
- package/src/compile_package_main.ts +4 -0
- 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/codegen_dart.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type { AggregateHandle } from "./aggregate.js";
|
|
|
35
35
|
import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
|
|
36
36
|
import type { RelationTypeDecl } from "./relation_schema.js";
|
|
37
37
|
import type { Field, FieldKind, ValueObjectDescriptor, ValueObjectFieldDescriptor } from "./fields.js";
|
|
38
|
+
import { admitsNull, nullMetaOf, nullDocLine } from "./null_meta.js";
|
|
38
39
|
import type { Directive } from "./directive.js";
|
|
39
40
|
import { executeDirectiveToIntent } from "./wire_encode.js";
|
|
40
41
|
import { deterministicPorts } from "./ctx.js";
|
|
@@ -81,6 +82,13 @@ export interface PayloadFieldSpec {
|
|
|
81
82
|
* only emits ops for present fields). Required fields are always emitted.
|
|
82
83
|
*/
|
|
83
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;
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
type ZodInternalDef = {
|
|
@@ -239,11 +247,19 @@ export function introspectPayload(
|
|
|
239
247
|
// when null — mirroring the engine's Zod, which only sees present fields).
|
|
240
248
|
let zt = ztRaw as z.ZodTypeAny;
|
|
241
249
|
let optional = false;
|
|
242
|
-
|
|
243
|
-
|
|
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;
|
|
244
256
|
zt = zodInnerType(zt);
|
|
245
257
|
}
|
|
246
|
-
out.push({
|
|
258
|
+
out.push({
|
|
259
|
+
...introspectField(name, zt, enumDartTypes, lazyNames),
|
|
260
|
+
optional,
|
|
261
|
+
...(nullAdmits ? { nullAdmits: true } : {}),
|
|
262
|
+
});
|
|
247
263
|
}
|
|
248
264
|
return out;
|
|
249
265
|
}
|
|
@@ -453,6 +469,15 @@ interface AggFieldSpec {
|
|
|
453
469
|
* only the generated read-model type. `undefined` for a non-json field.
|
|
454
470
|
*/
|
|
455
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;
|
|
456
481
|
}
|
|
457
482
|
|
|
458
483
|
export interface DartImport {
|
|
@@ -805,11 +830,16 @@ function aggregateFieldSpecs(
|
|
|
805
830
|
// BIRTH-PRESENCE LAW (`.fromBirth()`): the gate refuses any event that leaves the field
|
|
806
831
|
// absent, so the read model may type it NON-NULLABLE — the one lawful exception to the
|
|
807
832
|
// partial-fold nullability doctrine (absence is unrepresentable by law, not convention).
|
|
808
|
-
const
|
|
833
|
+
const baseSpec: AggFieldSpec = isCollectionKind(base.kind)
|
|
809
834
|
? base
|
|
810
835
|
: f.isFromBirth === true
|
|
811
836
|
? { ...base, optional: false }
|
|
812
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;
|
|
813
843
|
if (used.has(spec.name)) {
|
|
814
844
|
throw new Error(
|
|
815
845
|
`aggregate '${agg.id}' field '${spec.wireName}' maps to duplicate Dart field '${spec.name}'`,
|
|
@@ -1134,19 +1164,27 @@ function emitAggregate(
|
|
|
1134
1164
|
` /// indexed/by-id accessor matched on (e.g. the siteId). Empty string only when`,
|
|
1135
1165
|
` /// the aggregate is built from a bare \`data\` map with no row id (e.g. a unit test).`,
|
|
1136
1166
|
` final ${idType} aggregateId;`,
|
|
1137
|
-
...specs.
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
+
}),
|
|
1140
1175
|
].join("\n");
|
|
1141
1176
|
const ctorParams = [
|
|
1142
1177
|
` this.aggregateId = const ${idType}.fromRow(''),`,
|
|
1143
1178
|
...specs.map(
|
|
1144
1179
|
// A required scalar stays `required`; an optional scalar OR a collection is not
|
|
1145
1180
|
// `required` (an absent optional scalar is null; an absent collection is empty).
|
|
1181
|
+
// A null-admitting slot defaults to Omitted.
|
|
1146
1182
|
(s) =>
|
|
1147
|
-
s.
|
|
1148
|
-
? `
|
|
1149
|
-
:
|
|
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},`,
|
|
1150
1188
|
),
|
|
1151
1189
|
].join("\n");
|
|
1152
1190
|
|
|
@@ -1158,7 +1196,11 @@ function emitAggregate(
|
|
|
1158
1196
|
// threaded from the row id (GAP 2) — defaulting to '' for a bare-map call.
|
|
1159
1197
|
const parseLines = [
|
|
1160
1198
|
` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
|
|
1161
|
-
...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
|
+
),
|
|
1162
1204
|
].join("\n");
|
|
1163
1205
|
|
|
1164
1206
|
// `==`/`hashCode` include the id (two rows with the same data but different ids are
|
|
@@ -1953,6 +1995,53 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
|
|
|
1953
1995
|
}
|
|
1954
1996
|
}
|
|
1955
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
|
+
|
|
1956
2045
|
// ---- generated read model class --------------------------------------------
|
|
1957
2046
|
//
|
|
1958
2047
|
// A typed read model class generated from aggregate DSL field declarations. It
|
|
@@ -2145,9 +2234,12 @@ function emitReadModel(
|
|
|
2145
2234
|
` /// The kernel aggregate id (the \`{type,id,data}\` row's \`id\`, e.g. the siteId).`,
|
|
2146
2235
|
` /// Empty string only when built from a bare \`data\` map with no row id (a unit test).`,
|
|
2147
2236
|
` final ${idType} aggregateId;`,
|
|
2148
|
-
...specs.
|
|
2149
|
-
|
|
2150
|
-
|
|
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
|
+
}),
|
|
2151
2243
|
...deriveds.map(
|
|
2152
2244
|
(d) =>
|
|
2153
2245
|
` /// Engine-projected DERIVED read field (pure fn of the folded fields; NOT kernel state).\n final ${projectionReturnDartType(d.returns)} ${d.id};`,
|
|
@@ -2162,9 +2254,12 @@ function emitReadModel(
|
|
|
2162
2254
|
...specs.map((s) =>
|
|
2163
2255
|
// A required field is `required`; an optional scalar, a collection, OR a
|
|
2164
2256
|
// permissive json leaf (already nullable `Object?`) takes its default/null.
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
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},`,
|
|
2168
2263
|
),
|
|
2169
2264
|
// Derived/combined fields are always nullable (null until the engine pass runs).
|
|
2170
2265
|
...deriveds.map((d) => ` this.${d.id},`),
|
|
@@ -2173,7 +2268,11 @@ function emitReadModel(
|
|
|
2173
2268
|
|
|
2174
2269
|
const parseLines = [
|
|
2175
2270
|
` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
|
|
2176
|
-
...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
|
+
),
|
|
2177
2276
|
// Derived/combined fields decode from the engine-produced bare JSON leaf.
|
|
2178
2277
|
...deriveds.map((d) => ` ${d.id}: ${parseProjectionReturnField(d.id, d.returns)},`),
|
|
2179
2278
|
...combineds.map((c) => ` ${c.id}: ${parseProjectionReturnField(c.id, c.returns)},`),
|
|
@@ -2225,10 +2324,20 @@ function emitDirective(
|
|
|
2225
2324
|
// Field decls: optional payload fields are nullable Dart fields (omitted from the
|
|
2226
2325
|
// payload JSON when null); required fields are non-null + `required` in the ctor.
|
|
2227
2326
|
const fieldDecls = specs
|
|
2228
|
-
.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
|
+
)
|
|
2229
2332
|
.join("\n");
|
|
2230
2333
|
const ctorParams = specs
|
|
2231
|
-
.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
|
+
)
|
|
2232
2341
|
.join("\n");
|
|
2233
2342
|
// Zero-field payload (e.g. a `.births()` sanctioning directive with an empty
|
|
2234
2343
|
// object schema): `const X({});` / a dangling blank field block is INVALID Dart —
|
|
@@ -2243,14 +2352,21 @@ function emitDirective(
|
|
|
2243
2352
|
// fields are written only when present (mirrors the engine's Zod: ops are emitted
|
|
2244
2353
|
// only for present fields).
|
|
2245
2354
|
const requiredEntries = specs
|
|
2246
|
-
.filter((s) => !s.optional)
|
|
2355
|
+
.filter((s) => !s.optional && s.nullAdmits !== true)
|
|
2247
2356
|
.map((s) => ` '${s.name}': ${toJsonExpr(s, s.name)},`);
|
|
2248
2357
|
const optionalLines = specs
|
|
2249
|
-
.filter((s) => s.optional)
|
|
2358
|
+
.filter((s) => s.optional && s.nullAdmits !== true)
|
|
2250
2359
|
.map(
|
|
2251
2360
|
(s) =>
|
|
2252
2361
|
` if (${s.name} != null) '${s.name}': ${toJsonExpr(s, `${s.name}!`)},`,
|
|
2253
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;
|
|
2254
2370
|
|
|
2255
2371
|
const docFirst = directiveDoc(d);
|
|
2256
2372
|
|
|
@@ -2284,12 +2400,25 @@ function emitDirective(
|
|
|
2284
2400
|
``,
|
|
2285
2401
|
` /// Serialise this payload to the intent DATA JSON (the shape the`,
|
|
2286
2402
|
` /// domain Zod schema validates inside the engine). Optional fields are`,
|
|
2287
|
-
` /// omitted when null.`,
|
|
2403
|
+
` /// omitted when null; a null-admitting field writes through its tri-state slot.`,
|
|
2288
2404
|
` @override`,
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
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
|
+
]),
|
|
2293
2422
|
`}`,
|
|
2294
2423
|
].join("\n");
|
|
2295
2424
|
}
|
|
@@ -3591,6 +3720,13 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
|
|
|
3591
3720
|
].join("\n\n");
|
|
3592
3721
|
// The symbols whose Dart surface this file EMITS (framework-shared ones live in the shared file).
|
|
3593
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
|
+
);
|
|
3594
3730
|
const localDirectives = mod.directives.filter((d) => !skipDirectiveIds.has(d.id));
|
|
3595
3731
|
const localQueries = (mod.queries ?? []).filter((q) => !skipQueryIds.has(q.id));
|
|
3596
3732
|
// Enums are collected from the LOCALLY-emitted symbols only — a framework directive/aggregate's
|
|
@@ -3798,6 +3934,8 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
|
|
|
3798
3934
|
`// ignore_for_file: unnecessary_this, prefer_const_constructors, constant_identifier_names`,
|
|
3799
3935
|
`// ignore_for_file: unused_import`,
|
|
3800
3936
|
``,
|
|
3937
|
+
// A nullable plain-`t.json()` read-model slot decode re-stringifies via `jsonEncode`.
|
|
3938
|
+
...(needsJsonConvert ? [`import 'dart:convert';`] : []),
|
|
3801
3939
|
// The generated tenant domains live in the `co2_nomos_domains` package and depend
|
|
3802
3940
|
// on the framework `nomos_client` package by its PUBLIC package: URI — NOT a relative
|
|
3803
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" },
|
package/src/codegen_proof.ts
CHANGED
|
@@ -44,6 +44,7 @@ import type { FieldOp } from "./ops.js";
|
|
|
44
44
|
import type { RelationTypeDecl, Rewrite } from "./relation_schema.js";
|
|
45
45
|
import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
|
|
46
46
|
import { finishCount } from "./count.js";
|
|
47
|
+
import { clearDirectiveId } from "./clear_directive.js";
|
|
47
48
|
import { voMetaOf, voSample } from "./value_object.js";
|
|
48
49
|
import { mintedCreateField } from "./workspace_routing.js";
|
|
49
50
|
import { deterministicPorts } from "./ctx.js";
|
|
@@ -66,6 +67,27 @@ import {
|
|
|
66
67
|
/** Shared empty visited-set for the lazy-recursion guard (no allocation on the common path). */
|
|
67
68
|
const EMPTY_LAZY_VISITED: ReadonlySet<z.ZodTypeAny> = new Set();
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The top-level payload fields that ADMIT a domain `null` (their zod is `.nullable()`, a
|
|
72
|
+
* `t.nullable(...)`/`t.clearable(...)` sink — null_semantics.md). The proof drives the NULL ARM for
|
|
73
|
+
* each (the value arm is the normal synthesized sample) so the law is provably total over `null`.
|
|
74
|
+
* A nullable wrapped in optional/default still counts (unwrapped in ANY order). Empty for a
|
|
75
|
+
* null-free payload — no extra leg, byte-identical proof. */
|
|
76
|
+
export function nullablePayloadFields(schema: z.ZodTypeAny): string[] {
|
|
77
|
+
if (zodKind(schema) !== "object") return [];
|
|
78
|
+
const out: string[] = [];
|
|
79
|
+
for (const [name, raw] of Object.entries(zodObjectShape(schema))) {
|
|
80
|
+
let f = raw as z.ZodTypeAny;
|
|
81
|
+
let admits = false;
|
|
82
|
+
while (zodKind(f) === "optional" || zodKind(f) === "default" || zodKind(f) === "nullable") {
|
|
83
|
+
if (zodKind(f) === "nullable") admits = true;
|
|
84
|
+
f = zodDef(f).innerType!;
|
|
85
|
+
}
|
|
86
|
+
if (admits) out.push(name);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
|
|
70
92
|
const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
|
|
71
93
|
|
|
@@ -2620,6 +2642,58 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
|
|
|
2620
2642
|
);
|
|
2621
2643
|
}
|
|
2622
2644
|
|
|
2645
|
+
// NULL SEMANTICS BOTH-ARM PROOF (null_semantics.md): the primary dispatch above already proved the
|
|
2646
|
+
// VALUE arm (a nullable field synthesizes to a sampled value); here we prove the NULL arm — the law
|
|
2647
|
+
// ADMITS an explicit `null` for each nullable field, so it is provably total over null. Each create
|
|
2648
|
+
// arm mints a fresh id. Empty for a null-free payload (byte-identical proof).
|
|
2649
|
+
const nullArms = nullablePayloadFields(createDir.payloadSchema as z.ZodTypeAny);
|
|
2650
|
+
for (const field of nullArms) {
|
|
2651
|
+
const armMint = mintField !== undefined ? `, ${JSON.stringify(mintField)}: await holon.mint(${JSON.stringify(agg.id)})` : "";
|
|
2652
|
+
out.push(
|
|
2653
|
+
`// ${n()}. NULL-ARM PROOF — \`${field}\` is nullable: the law must ADMIT an explicit null (both arms proven).`,
|
|
2654
|
+
`try {`,
|
|
2655
|
+
` await app.${createDir.id}({ ...${JSON.stringify(baseValue)}${armMint}, ${JSON.stringify(field)}: null });`,
|
|
2656
|
+
` ok(${JSON.stringify(`null-arm — ${domain}/${createDir.id} admitted with \`${field}\`=null (the law handles the explicit-null arm)`)});`,
|
|
2657
|
+
`} catch (e) {`,
|
|
2658
|
+
` fail(\`null-arm ${field}=null was REFUSED — a nullable field's null arm must be admitted: \${(e as Error).message}\`);`,
|
|
2659
|
+
`}`,
|
|
2660
|
+
``,
|
|
2661
|
+
);
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
// CLEAR-LANE PROOF (null_semantics.md): a `t.clearable(...)` field's compose-injected clear directive
|
|
2665
|
+
// (`clearDirectiveId(agg, field)`) LOWERS to a Set of json null — dispatch it on the created instance
|
|
2666
|
+
// and read the field back as null (ExplicitNull). Only UN-GATED clear directives (the proof binds no
|
|
2667
|
+
// actor); a gated clear needs authority the negative/positive gate legs already cover. Empty for a
|
|
2668
|
+
// clearable-free aggregate (byte-identical proof).
|
|
2669
|
+
const clearLegs = Object.entries(agg.fields as Record<string, Field>)
|
|
2670
|
+
.filter(([, f]) => f.isClearable === true)
|
|
2671
|
+
.map(([field]) => ({ field, dirId: clearDirectiveId(agg.id, field) }))
|
|
2672
|
+
.filter(({ dirId }) => {
|
|
2673
|
+
const cd = (mod.directives as Directive<unknown>[]).find((d) => d.id === dirId);
|
|
2674
|
+
return cd !== undefined && (cd as { requiresRelationGate?: unknown }).requiresRelationGate === undefined;
|
|
2675
|
+
});
|
|
2676
|
+
for (const { field, dirId } of clearLegs) {
|
|
2677
|
+
out.push(
|
|
2678
|
+
`// ${n()}. CLEAR-LANE PROOF — \`${field}\` is clearable: \`${dirId}\` lowers to Set(json null); after it,`,
|
|
2679
|
+
`// the field reads back null (ExplicitNull), never omitted.`,
|
|
2680
|
+
`try {`,
|
|
2681
|
+
` await app.${dirId}({ id: createdId });`,
|
|
2682
|
+
`} catch (e) {`,
|
|
2683
|
+
` fail(\`clear-lane ${dirId} was REFUSED — an un-gated clear of a clearable field must be admitted: \${(e as Error).message}\`);`,
|
|
2684
|
+
`}`,
|
|
2685
|
+
`{`,
|
|
2686
|
+
` const clearedRows = await holon.queryById(createdId) as { data?: Record<string, unknown> }[];`,
|
|
2687
|
+
` const clearedRow = clearedRows[0];`,
|
|
2688
|
+
` if (clearedRow !== undefined && clearedRow.data !== undefined && clearedRow.data[${JSON.stringify(field)}] != null) {`,
|
|
2689
|
+
` fail(\`clear-lane ${field}: expected null after clear, got \${JSON.stringify(clearedRow.data[${JSON.stringify(field)}])}\`);`,
|
|
2690
|
+
` }`,
|
|
2691
|
+
` ok(${JSON.stringify(`clear-lane — ${domain}/${dirId} admitted; \`${field}\` reads back null (ExplicitNull)`)});`,
|
|
2692
|
+
`}`,
|
|
2693
|
+
``,
|
|
2694
|
+
);
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2623
2697
|
out.push(
|
|
2624
2698
|
`globalThis.fetch = realFetch;`,
|
|
2625
2699
|
`ok("offline proof ENFORCED — fetch was trapped: local write/read touched no network");`,
|
package/src/codegen_ts.ts
CHANGED
|
@@ -50,6 +50,7 @@ import type { DerivedDecl } from "./derived.js";
|
|
|
50
50
|
import type { CombinedDecl } from "./combined.js";
|
|
51
51
|
import { resolveWorkspaceTypes, type ResolvedWorkspaceType, type WorkspaceTypeDecl } from "./workspace_type.js";
|
|
52
52
|
import { deriveClientMintPlan, type ClientMintPlanEntry } from "./workspace_routing.js";
|
|
53
|
+
import { admitsNull, nullDocLine } from "./null_meta.js";
|
|
53
54
|
|
|
54
55
|
const cap = (s: string) => (s.length ? s[0]!.toUpperCase() + s.slice(1) : s);
|
|
55
56
|
const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
|
|
@@ -468,6 +469,14 @@ function tsTypeOfPayload(
|
|
|
468
469
|
// ── DSL Field → read-model TS type (the projection's decode surface) ──────────────
|
|
469
470
|
|
|
470
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 {
|
|
471
480
|
switch (field.kind) {
|
|
472
481
|
case "string":
|
|
473
482
|
return "string";
|
|
@@ -1140,6 +1149,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1140
1149
|
out.push(`export interface ${typeId}Data {`);
|
|
1141
1150
|
for (const [name, field] of Object.entries(agg.fields)) {
|
|
1142
1151
|
const required = (field as Field).isFromBirth === true;
|
|
1152
|
+
const doc = nullDocLine(field);
|
|
1153
|
+
if (doc !== undefined) out.push(` /** ${doc} */`);
|
|
1143
1154
|
out.push(` ${name}${required ? "" : "?"}: ${tsTypeOfField(field as Field)};`);
|
|
1144
1155
|
}
|
|
1145
1156
|
for (const extra of extrasByType.get(typeId) ?? []) out.push(extra);
|
|
@@ -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",
|