@githolon/dsl 0.1.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/LICENSE.md +36 -0
- package/compile_package.mjs +50 -0
- package/package.json +59 -0
- package/src/aggregate.ts +167 -0
- package/src/authoring.ts +119 -0
- package/src/build_package.ts +636 -0
- package/src/certified_read.ts +313 -0
- package/src/codegen_dart.ts +2732 -0
- package/src/codegen_dot.ts +466 -0
- package/src/codegen_provider_dart.ts +358 -0
- package/src/codegen_ts.ts +365 -0
- package/src/codegen_usda.ts +388 -0
- package/src/combined.ts +195 -0
- package/src/compile_engine.ts +567 -0
- package/src/compile_package_main.ts +496 -0
- package/src/compose.ts +317 -0
- package/src/count.ts +218 -0
- package/src/ctx.ts +57 -0
- package/src/derived.ts +138 -0
- package/src/directive.ts +306 -0
- package/src/drivers.ts +95 -0
- package/src/emits_guard.ts +123 -0
- package/src/engine_entry.ts +449 -0
- package/src/exists.ts +170 -0
- package/src/extremum.ts +227 -0
- package/src/fields.ts +291 -0
- package/src/framework/bootstrap.ts +22 -0
- package/src/framework/disclosure.ts +108 -0
- package/src/framework/domain_lifecycle.ts +108 -0
- package/src/framework/identity.ts +537 -0
- package/src/framework/impure_capability.ts +643 -0
- package/src/framework/rbac.ts +418 -0
- package/src/framework/repair.ts +150 -0
- package/src/framework/sync_lifecycle.ts +125 -0
- package/src/framework/workspace_invariant.ts +128 -0
- package/src/framework/workspaces.ts +817 -0
- package/src/index.ts +317 -0
- package/src/manifest.ts +947 -0
- package/src/ops.ts +145 -0
- package/src/ordered_read.ts +228 -0
- package/src/predicate.ts +203 -0
- package/src/query/compile.ts +0 -0
- package/src/query/relations.ts +144 -0
- package/src/query.ts +151 -0
- package/src/read.ts +54 -0
- package/src/relation.ts +189 -0
- package/src/report/csv.ts +54 -0
- package/src/report.ts +401 -0
- package/src/spatial.ts +115 -0
- package/src/sum.ts +194 -0
- package/src/usd.ts +563 -0
- package/src/wire.ts +149 -0
- package/src/wire_encode.ts +250 -0
|
@@ -0,0 +1,2732 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine},
|
|
4
|
+
// byte-identical everywhere. Host JS is only the bridge; domain JS runs inside
|
|
5
|
+
// GitHolon, never as a second execution path.
|
|
6
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Dart frontend codegen (DSL decision-6: the schema is the single source of
|
|
10
|
+
* truth -> TS (done), proto (later), and Dart frontend types (here)).
|
|
11
|
+
*
|
|
12
|
+
* Emits, per domain:
|
|
13
|
+
* - a Dart class per aggregate with its typed fields, parsed (`fromJson`) from
|
|
14
|
+
* the FLAT, already-decoded projection `data` map that `query()`/`query_by_id()`
|
|
15
|
+
* emit (e.g. `{name:"HQ", createdAt:1000}`);
|
|
16
|
+
* - typed READ accessors per aggregate + per declared query — reactive
|
|
17
|
+
* `watch…`/one-shot `read…` over the GitHolon `query()`/`query_by_id()`
|
|
18
|
+
* surface (the read half of the dispatch(intent) boundary);
|
|
19
|
+
* - a Dart enum per `t.enum([...])` field;
|
|
20
|
+
* - a Dart intent class per domain action — a pure typed DTO. Each class exposes its
|
|
21
|
+
* `domain` / `intentType` (so the call site never hand-types a string) and a
|
|
22
|
+
* `toPayloadJson()` that serialises only its typed fields to data. It builds no
|
|
23
|
+
* `Event` and no `Hlc`: the GitHolon executes the installed domain plan.
|
|
24
|
+
* - a single generic write verb [`dispatch`] that ships {domain, intentType,
|
|
25
|
+
* toPayloadJson()} to the GitHolon `author(domain, intentType, payloadJson, actor)`.
|
|
26
|
+
*
|
|
27
|
+
* It does NOT hand-write the fixed wire types (`Value`/`Op`/`Event`/...): those
|
|
28
|
+
* live hand-written in `dart/lib/src/wire.dart`. Only the per-domain shapes are
|
|
29
|
+
* generated, so they stay reproducible from the domain definitions.
|
|
30
|
+
*/
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import type { AggregateHandle } from "./aggregate.js";
|
|
33
|
+
import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
|
|
34
|
+
import type { Field, FieldKind } from "./fields.js";
|
|
35
|
+
import type { Directive } from "./directive.js";
|
|
36
|
+
import { executeDirectiveToIntent } from "./wire_encode.js";
|
|
37
|
+
import { deterministicPorts } from "./ctx.js";
|
|
38
|
+
import type { QueryDecl } from "./query.js";
|
|
39
|
+
import { finishCount, type AnyCount } from "./count.js";
|
|
40
|
+
import type { SpatialDecl } from "./spatial.js";
|
|
41
|
+
import { finishSum, type AnySum } from "./sum.js";
|
|
42
|
+
import { finishExtremum, type AnyExtremum } from "./extremum.js";
|
|
43
|
+
import { finishExists, type AnyExists } from "./exists.js";
|
|
44
|
+
import type { OrderedReadDecl } from "./ordered_read.js";
|
|
45
|
+
import type { DerivedDecl } from "./derived.js";
|
|
46
|
+
import type { CombinedDecl } from "./combined.js";
|
|
47
|
+
import { emitProviderSdk, type ImpureCapabilityDecl } from "./codegen_provider_dart.js";
|
|
48
|
+
|
|
49
|
+
/** A directive of any payload type. `Directive<P>` is invariant in `P`, so the
|
|
50
|
+
* generator (which only introspects shapes) takes the `any`-payload form. */
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
|
+
type AnyDirective = Directive<any>;
|
|
53
|
+
|
|
54
|
+
// ---- Zod payload introspection --------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** How a typed payload field serialises into the payload-as-DATA JSON the engine reads. */
|
|
57
|
+
export type ToJsonWrap =
|
|
58
|
+
| "scalar" // String/int/bool — emit the field directly.
|
|
59
|
+
| "enum" // generated enum — emit `.wire`.
|
|
60
|
+
| "strList" // List<String> — emit directly.
|
|
61
|
+
| "json"; // nested object — emit the field's `Map<String, Object?>` directly.
|
|
62
|
+
|
|
63
|
+
export interface PayloadFieldSpec {
|
|
64
|
+
name: string;
|
|
65
|
+
dartType: string;
|
|
66
|
+
/** for enums: the Dart enum type name + raw values. */
|
|
67
|
+
enumValues?: string[];
|
|
68
|
+
/** how this field serialises into `toPayloadJson()`. */
|
|
69
|
+
toJson: ToJsonWrap;
|
|
70
|
+
/**
|
|
71
|
+
* Whether the payload field is OPTIONAL (Zod `.optional()`/`.default()`). Optional
|
|
72
|
+
* fields are nullable in Dart and OMITTED from `toPayloadJson()` when null — so the
|
|
73
|
+
* engine's `.plan()` sees exactly the present-field set its TS Zod schema would (it
|
|
74
|
+
* only emits ops for present fields). Required fields are always emitted.
|
|
75
|
+
*/
|
|
76
|
+
optional: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type ZodInternalDef = {
|
|
80
|
+
type?: string | z.ZodTypeAny;
|
|
81
|
+
innerType?: z.ZodTypeAny;
|
|
82
|
+
element?: z.ZodTypeAny;
|
|
83
|
+
checks?: ReadonlyArray<unknown>;
|
|
84
|
+
shape?: Record<string, z.ZodTypeAny> | (() => Record<string, z.ZodTypeAny>);
|
|
85
|
+
entries?: Record<string, string | number>;
|
|
86
|
+
options?: z.ZodTypeAny[];
|
|
87
|
+
value?: unknown;
|
|
88
|
+
values?: readonly unknown[];
|
|
89
|
+
valueType?: z.ZodTypeAny;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function zodDef(zt: z.ZodTypeAny): ZodInternalDef {
|
|
93
|
+
const raw = zt as unknown as { _def?: ZodInternalDef; def?: ZodInternalDef };
|
|
94
|
+
return raw._def ?? raw.def ?? {};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function zodTypeName(zt: z.ZodTypeAny): string {
|
|
98
|
+
const def = zodDef(zt);
|
|
99
|
+
switch (def.type) {
|
|
100
|
+
case "string":
|
|
101
|
+
return "ZodString";
|
|
102
|
+
case "number":
|
|
103
|
+
return "ZodNumber";
|
|
104
|
+
case "boolean":
|
|
105
|
+
return "ZodBoolean";
|
|
106
|
+
case "enum":
|
|
107
|
+
return "ZodEnum";
|
|
108
|
+
case "array":
|
|
109
|
+
return "ZodArray";
|
|
110
|
+
case "object":
|
|
111
|
+
return "ZodObject";
|
|
112
|
+
case "record":
|
|
113
|
+
return "ZodRecord";
|
|
114
|
+
case "optional":
|
|
115
|
+
return "ZodOptional";
|
|
116
|
+
case "default":
|
|
117
|
+
return "ZodDefault";
|
|
118
|
+
case "unknown":
|
|
119
|
+
case "any":
|
|
120
|
+
return "ZodThing";
|
|
121
|
+
case "union":
|
|
122
|
+
return "ZodUnion";
|
|
123
|
+
case "literal":
|
|
124
|
+
return "ZodLiteral";
|
|
125
|
+
case "nullable":
|
|
126
|
+
return "ZodNullable";
|
|
127
|
+
default:
|
|
128
|
+
return typeof def.type === "string" ? `Zod${cap(def.type)}` : "ZodUnknown";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function zodInnerType(zt: z.ZodTypeAny): z.ZodTypeAny {
|
|
133
|
+
const inner = zodDef(zt).innerType;
|
|
134
|
+
if (inner === undefined) throw new Error(`Zod ${zodTypeName(zt)} has no innerType`);
|
|
135
|
+
return inner;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function zodArrayElement(zt: z.ZodTypeAny): z.ZodTypeAny {
|
|
139
|
+
const def = zodDef(zt);
|
|
140
|
+
if (def.element !== undefined) return def.element;
|
|
141
|
+
if (typeof def.type !== "string" && def.type !== undefined) return def.type;
|
|
142
|
+
throw new Error("ZodArray has no element type");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function zodObjectShape(zt: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
|
|
146
|
+
const shape = zodDef(zt).shape;
|
|
147
|
+
if (typeof shape === "function") return shape();
|
|
148
|
+
if (shape !== undefined) return shape;
|
|
149
|
+
const raw = zt as unknown as { shape?: Record<string, z.ZodTypeAny> };
|
|
150
|
+
if (raw.shape !== undefined) return raw.shape;
|
|
151
|
+
throw new Error("ZodObject has no shape");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function zodEnumValues(zt: z.ZodTypeAny): string[] {
|
|
155
|
+
const raw = zt as unknown as { options?: readonly unknown[] };
|
|
156
|
+
if (raw.options !== undefined) return raw.options.map(String);
|
|
157
|
+
const entries = zodDef(zt).entries;
|
|
158
|
+
if (entries !== undefined) return Object.values(entries).map(String);
|
|
159
|
+
throw new Error("ZodEnum has no values");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function zodUnionOptions(zt: z.ZodTypeAny): z.ZodTypeAny[] {
|
|
163
|
+
return zodDef(zt).options ?? [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function zodLiteralValue(zt: z.ZodTypeAny): unknown {
|
|
167
|
+
const def = zodDef(zt);
|
|
168
|
+
if (def.value !== undefined) return def.value;
|
|
169
|
+
if (def.values !== undefined) return def.values[0];
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function zodRecordValueType(zt: z.ZodTypeAny): z.ZodTypeAny | undefined {
|
|
174
|
+
return zodDef(zt).valueType;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isOptionalOrDefault(zt: z.ZodTypeAny): boolean {
|
|
178
|
+
const tn = zodTypeName(zt);
|
|
179
|
+
return tn === "ZodOptional" || tn === "ZodDefault";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* True when a `ZodNumber` schema carries the `.int()` constraint. Zod things `.int()`
|
|
184
|
+
* as a `{ kind: "int" }` entry in the number's `_def.checks` array; an unconstrained
|
|
185
|
+
* `z.number()` has no such check and is a FRACTIONAL double. This is the ONE bit that
|
|
186
|
+
* decides whether the generated Dart field is `int` (whole number) or `double`
|
|
187
|
+
* (fractional, e.g. a 0.5 metre scale) — mirroring exactly the runtime constraint the
|
|
188
|
+
* engine's same Zod schema validates, so the call-site type can never truncate a
|
|
189
|
+
* fractional value the engine would accept.
|
|
190
|
+
*/
|
|
191
|
+
function zodNumberIsInt(zt: z.ZodTypeAny): boolean {
|
|
192
|
+
const raw = zt as unknown as { isInt?: boolean };
|
|
193
|
+
if (raw.isInt === true) return true;
|
|
194
|
+
const checks = zodDef(zt).checks ?? [];
|
|
195
|
+
return checks.some((c) => {
|
|
196
|
+
const check = c as { kind?: string; def?: { check?: string; format?: string } };
|
|
197
|
+
return (
|
|
198
|
+
check.kind === "int" ||
|
|
199
|
+
(check.def?.check === "number_format" && check.def.format === "safeint")
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function introspectPayload(
|
|
205
|
+
schema: z.ZodTypeAny,
|
|
206
|
+
enumDartTypes: Map<string, string>,
|
|
207
|
+
): PayloadFieldSpec[] {
|
|
208
|
+
if (zodTypeName(schema) !== "ZodObject") {
|
|
209
|
+
throw new Error("directive payload must be a z.object");
|
|
210
|
+
}
|
|
211
|
+
const shape = zodObjectShape(schema);
|
|
212
|
+
const out: PayloadFieldSpec[] = [];
|
|
213
|
+
for (const [name, ztRaw] of Object.entries(shape)) {
|
|
214
|
+
// Unwrap ZodOptional / ZodDefault to the inner type, REMEMBERING that the field
|
|
215
|
+
// is optional (it becomes a nullable Dart field, omitted from the payload JSON
|
|
216
|
+
// when null — mirroring the engine's Zod, which only sees present fields).
|
|
217
|
+
let zt = ztRaw as z.ZodTypeAny;
|
|
218
|
+
let optional = false;
|
|
219
|
+
while (isOptionalOrDefault(zt)) {
|
|
220
|
+
optional = true;
|
|
221
|
+
zt = zodInnerType(zt);
|
|
222
|
+
}
|
|
223
|
+
out.push({ ...introspectField(name, zt, enumDartTypes), optional });
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Introspect ONE (already-unwrapped) Zod field into its Dart spec. Handles the
|
|
229
|
+
* four shapes a tenant domain uses: scalar (string/int/bool), enum, string[], and a
|
|
230
|
+
* NESTED object (e.g. attach/detach's `target:{targetType,targetId}`) — the engine
|
|
231
|
+
* receives nested objects as DATA, so we model them as `Map<String, Object?>`. */
|
|
232
|
+
function introspectField(
|
|
233
|
+
name: string,
|
|
234
|
+
zt: z.ZodTypeAny,
|
|
235
|
+
enumDartTypes: Map<string, string>,
|
|
236
|
+
): Omit<PayloadFieldSpec, "optional"> {
|
|
237
|
+
const tn = zodTypeName(zt);
|
|
238
|
+
switch (tn) {
|
|
239
|
+
case "ZodString":
|
|
240
|
+
return { name, dartType: "String", toJson: "scalar" };
|
|
241
|
+
case "ZodNumber":
|
|
242
|
+
// A `z.number()` is a FRACTIONAL double in JS (e.g. catalogues `createListing`
|
|
243
|
+
// scaleX/scaleY = 0.5) UNLESS the schema adds the `.int()` check. Only an
|
|
244
|
+
// `.int()`-constrained number is a Dart `int`; an unconstrained `z.number()`
|
|
245
|
+
// emits `double` so a fractional value (0.5/0.75) is NOT truncated to 0 at the
|
|
246
|
+
// call site. Detect the `.int()` check on the Zod number's `_def.checks`
|
|
247
|
+
// (`{kind:"int"}`), exactly the runtime constraint the engine's Zod validates.
|
|
248
|
+
return {
|
|
249
|
+
name,
|
|
250
|
+
dartType: zodNumberIsInt(zt) ? "int" : "double",
|
|
251
|
+
toJson: "scalar",
|
|
252
|
+
};
|
|
253
|
+
case "ZodBoolean":
|
|
254
|
+
return { name, dartType: "bool", toJson: "scalar" };
|
|
255
|
+
case "ZodEnum": {
|
|
256
|
+
const values = zodEnumValues(zt);
|
|
257
|
+
const key = values.join("|");
|
|
258
|
+
const dartType = enumDartTypes.get(key) ?? `${cap(name)}Enum`;
|
|
259
|
+
return { name, dartType, toJson: "enum", enumValues: values };
|
|
260
|
+
}
|
|
261
|
+
case "ZodArray": {
|
|
262
|
+
const elem = zodArrayElement(zt);
|
|
263
|
+
// A `string[]` is a typed `List<String>`. ANY other element type (e.g.
|
|
264
|
+
// `z.array(z.object(...))` — proposals' replacement `edges`) is arbitrary
|
|
265
|
+
// JSON DATA to the engine, modelled as `List<Object?>` and shipped verbatim.
|
|
266
|
+
if (zodTypeName(elem) === "ZodString") {
|
|
267
|
+
return { name, dartType: "List<String>", toJson: "strList" };
|
|
268
|
+
}
|
|
269
|
+
return { name, dartType: "List<Object?>", toJson: "json" };
|
|
270
|
+
}
|
|
271
|
+
case "ZodObject":
|
|
272
|
+
// Nested object payload (e.g. `target: z.object({targetType, targetId})`). The
|
|
273
|
+
// engine receives it as DATA verbatim, so we model it as an arbitrary JSON map
|
|
274
|
+
// — the call site builds the literal, and `toPayloadJson()` ships it through.
|
|
275
|
+
return { name, dartType: "Map<String, Object?>", toJson: "json" };
|
|
276
|
+
case "ZodRecord": {
|
|
277
|
+
const valueType = zodRecordValueType(zt);
|
|
278
|
+
const dartType =
|
|
279
|
+
valueType !== undefined && zodTypeName(valueType) === "ZodString"
|
|
280
|
+
? "Map<String, String>"
|
|
281
|
+
: "Map<String, Object?>";
|
|
282
|
+
return { name, dartType, toJson: "json" };
|
|
283
|
+
}
|
|
284
|
+
case "ZodThing":
|
|
285
|
+
// A `z.thing(...)` payload — also arbitrary JSON DATA to the engine.
|
|
286
|
+
return { name, dartType: "Map<String, Object?>", toJson: "json" };
|
|
287
|
+
default:
|
|
288
|
+
throw new Error(`unsupported payload field type '${tn}' for '${name}'`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A `///`-doc fragment for a payload class: the directive's `.doc(...)` prose, or a
|
|
293
|
+
* sensible default derived from the directive id (so EVERY class is documented). */
|
|
294
|
+
function directiveDoc(d: AnyDirective): string {
|
|
295
|
+
if (d.description !== undefined && d.description.trim() !== "") {
|
|
296
|
+
return d.description.trim();
|
|
297
|
+
}
|
|
298
|
+
// Default: split the camelCase id into words → "create site", "set site custom field".
|
|
299
|
+
const words = d.id
|
|
300
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
301
|
+
.toLowerCase()
|
|
302
|
+
.trim();
|
|
303
|
+
return `Dispatch the \`${d.id}\` directive (${words}).`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- aggregate field -> Dart type -----------------------------------------
|
|
307
|
+
|
|
308
|
+
type MaterializedFieldKind = Exclude<FieldKind, "hasMany">;
|
|
309
|
+
|
|
310
|
+
interface AggFieldSpec {
|
|
311
|
+
/** Dart-safe field/property name exposed on generated classes. */
|
|
312
|
+
name: string;
|
|
313
|
+
/** Canonical wire/readmodel key declared by the DSL, e.g. `spec.domainHash`. */
|
|
314
|
+
wireName: string;
|
|
315
|
+
kind: MaterializedFieldKind;
|
|
316
|
+
dartType: string;
|
|
317
|
+
enumValues?: string[];
|
|
318
|
+
/** For an own-id string field represented as a typed aggregate id value class. */
|
|
319
|
+
valueClass?: string;
|
|
320
|
+
/**
|
|
321
|
+
* For a `ref`-kind field: the TYPED id class of the target aggregate (e.g. a
|
|
322
|
+
* `Building.siteId` ref → `SiteId`). The field's Dart type is this typed id (NOT a
|
|
323
|
+
* bare `String`), so a cross-aggregate ref carries its target's TYPE — passing a
|
|
324
|
+
* `RoomId` where a `SiteId` is expected is a compile error. `undefined` for a
|
|
325
|
+
* non-ref field (or a ref whose target type is unknown — falls back to `String`).
|
|
326
|
+
*/
|
|
327
|
+
refIdClass?: string;
|
|
328
|
+
/**
|
|
329
|
+
* Whether the field is OPTIONAL on a folded aggregate — derived from the DSL field
|
|
330
|
+
* schema (GAP 1). A `set`/`map` is a COLLECTION: it is ALWAYS empty-defaultable
|
|
331
|
+
* (a freshly-created aggregate that never folded it decodes to an empty collection),
|
|
332
|
+
* so it is never `required` in the Dart ctor and its reader tolerates absence. A
|
|
333
|
+
* SCALAR (`string`/`int`/`enum`/`ref`/`json`) is optional ONLY when the DSL marked
|
|
334
|
+
* it `.optional()` (`field.isOptional`) — then the Dart field is NULLABLE and its
|
|
335
|
+
* reader returns null when absent; an unmarked scalar stays REQUIRED (a missing
|
|
336
|
+
* value still throws, preserving the strict-decode contract for must-be-folded
|
|
337
|
+
* fields like a create's id/name).
|
|
338
|
+
*/
|
|
339
|
+
optional: boolean;
|
|
340
|
+
/**
|
|
341
|
+
* For a `map`-kind field: the inner VALUE field's kind (`field.mapValueKind`). A
|
|
342
|
+
* `"json"`-valued map (`t.map(t.json())`) is surfaced as `Map<String, Object?>` (each
|
|
343
|
+
* entry JSON-DECODED back to its object — a value-object map round-trips losslessly),
|
|
344
|
+
* whereas any other (default `"string"`) stays `Map<String, String>` (flat strings).
|
|
345
|
+
* `undefined` for a non-map field.
|
|
346
|
+
*/
|
|
347
|
+
mapValueKind?: FieldKind;
|
|
348
|
+
/**
|
|
349
|
+
* For a `json`-kind field: the asserted decoded SHAPE (`field.jsonShape`). `"object"`
|
|
350
|
+
* (a `t.jsonObject()` leaf, e.g. `location`) means the value is ALWAYS a JSON object —
|
|
351
|
+
* the generated read model surfaces it as a strict `Map<String, Object?>`. `undefined`
|
|
352
|
+
* (a plain `t.json()`) means the shape is unknown/non-object (a double/bool/list
|
|
353
|
+
* authored as a JSON leaf — the kernel `Value` is Str|Int only), so the projection
|
|
354
|
+
* defaults it to the permissive `Object?` with a non-throwing reader. This affects
|
|
355
|
+
* only the generated read-model type. `undefined` for a non-json field.
|
|
356
|
+
*/
|
|
357
|
+
jsonShape?: "object";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface DartImport {
|
|
361
|
+
uri: string;
|
|
362
|
+
prefix?: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export interface DartRefImport {
|
|
366
|
+
aggregateType: string;
|
|
367
|
+
uri: string;
|
|
368
|
+
prefix: string;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export interface PermissionVocabulary {
|
|
372
|
+
resourceTypes: readonly string[];
|
|
373
|
+
roleCatalogue: Readonly<Record<string, readonly string[]>>;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** True for the COLLECTION kinds, which always carry a sensible empty default. */
|
|
377
|
+
function isCollectionKind(kind: FieldKind): boolean {
|
|
378
|
+
return kind === "set" || kind === "map";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** True when a `map`-kind field's VALUE leaves are JSON objects (`t.map(t.json())`),
|
|
382
|
+
* surfaced as `Map<String, Object?>` rather than the flat-string `Map<String, String>`. */
|
|
383
|
+
function isJsonValuedMap(s: AggFieldSpec): boolean {
|
|
384
|
+
return s.kind === "map" && s.mapValueKind === "json";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** True when a `json`-kind field is an asserted JSON-object value-object
|
|
388
|
+
* (`t.jsonObject()`, `jsonShape === "object"`) surfaced as a strict
|
|
389
|
+
* `Map<String, Object?>`. A plain `t.json()` (shape unknown/non-object) is false,
|
|
390
|
+
* surfaced as the permissive `Object?`. */
|
|
391
|
+
function isJsonObjectLeaf(s: AggFieldSpec): boolean {
|
|
392
|
+
return s.kind === "json" && s.jsonShape === "object";
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** True for a permissive `json` leaf on the generated read model: a plain `t.json()` whose
|
|
396
|
+
* decoded shape is unknown/non-object (a double/bool/list authored as a JSON leaf). Its
|
|
397
|
+
* projection type is the already-nullable `Object?` (not a strict `Map`) with a
|
|
398
|
+
* non-throwing reader, so it is treated like an always-nullable scalar (no `required`,
|
|
399
|
+
* no extra `?`, no ctor default). An OBJECT-asserted leaf (`t.jsonObject()`) is NOT
|
|
400
|
+
* permissive — it is the strict `Map<String, Object?>`. */
|
|
401
|
+
function isPermissiveJsonLeaf(s: AggFieldSpec): boolean {
|
|
402
|
+
return s.kind === "json" && s.jsonShape !== "object";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The `?` nullable suffix for a projection field DECL. A permissive `json` leaf's type
|
|
406
|
+
* is ALREADY the nullable `Object?`, so it takes NO extra `?` (a doubled `Object??` is
|
|
407
|
+
* invalid); every other field appends `?` only when `.optional()`. */
|
|
408
|
+
function readModelNullableSuffix(s: AggFieldSpec): string {
|
|
409
|
+
if (isPermissiveJsonLeaf(s)) return ""; // `Object?` is already nullable.
|
|
410
|
+
return s.optional ? "?" : "";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function dartMemberName(raw: string): string {
|
|
414
|
+
const parts = raw.split(/[^A-Za-z0-9]+/).filter((p) => p !== "");
|
|
415
|
+
const camel =
|
|
416
|
+
parts.length === 0
|
|
417
|
+
? "field"
|
|
418
|
+
: parts[0] + parts.slice(1).map((p) => cap(p)).join("");
|
|
419
|
+
const cleaned = camel.replace(/[^A-Za-z0-9_]/g, "_");
|
|
420
|
+
const safe = /^[0-9]/.test(cleaned) ? `v${cleaned}` : cleaned;
|
|
421
|
+
return DART_RESERVED.has(safe) ? `${safe}_` : safe;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function dartTypeNameFromField(raw: string): string {
|
|
425
|
+
return cap(dartMemberName(raw));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function aggregateIdDartType(aggregateType: string): string {
|
|
429
|
+
return typedIdClassForAggregateType(aggregateType);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function aggregateIdFromRowExpr(aggregateType: string, rowIdExpr: string): string {
|
|
433
|
+
return `${aggregateIdDartType(aggregateType)}.fromRow(${rowIdExpr})`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function aggregateFieldSpecForKey(
|
|
437
|
+
aggregatesById: Map<string, AggregateHandle>,
|
|
438
|
+
aggregateType: string,
|
|
439
|
+
keyField: string,
|
|
440
|
+
enumDartTypes: Map<string, string>,
|
|
441
|
+
refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
|
|
442
|
+
ownIdFieldsByAggregate: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
|
|
443
|
+
): AggFieldSpec | undefined {
|
|
444
|
+
const agg = aggregatesById.get(aggregateType);
|
|
445
|
+
const field = (agg?.fields as Record<string, Field> | undefined)?.[keyField];
|
|
446
|
+
if (field === undefined || field.kind === "hasMany") return undefined;
|
|
447
|
+
const valueClass =
|
|
448
|
+
field.kind === "string" && (ownIdFieldsByAggregate.get(aggregateType)?.has(keyField) ?? false)
|
|
449
|
+
? aggregateIdDartType(aggregateType)
|
|
450
|
+
: undefined;
|
|
451
|
+
return aggFieldSpec(keyField, field, enumDartTypes, refIdClassForAggregate, valueClass);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function readKeyParamSpec(
|
|
455
|
+
paramName: string,
|
|
456
|
+
fieldSpec: AggFieldSpec | undefined,
|
|
457
|
+
): { dartType: string; valueExpr: string } {
|
|
458
|
+
if (fieldSpec === undefined) return { dartType: "String", valueExpr: paramName };
|
|
459
|
+
if (fieldSpec.valueClass !== undefined) {
|
|
460
|
+
return { dartType: fieldSpec.valueClass, valueExpr: `${paramName}.value` };
|
|
461
|
+
}
|
|
462
|
+
if (fieldSpec.kind === "ref" && fieldSpec.refIdClass !== undefined) {
|
|
463
|
+
return { dartType: fieldSpec.refIdClass, valueExpr: `${paramName}.value` };
|
|
464
|
+
}
|
|
465
|
+
if (fieldSpec.kind === "enum") {
|
|
466
|
+
return { dartType: fieldSpec.dartType, valueExpr: `${paramName}.wire` };
|
|
467
|
+
}
|
|
468
|
+
if (fieldSpec.kind === "int") {
|
|
469
|
+
return { dartType: "int", valueExpr: paramName };
|
|
470
|
+
}
|
|
471
|
+
return { dartType: "String", valueExpr: paramName };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function dartImportLine(imp: DartImport): string {
|
|
475
|
+
const prefix = imp.prefix !== undefined && imp.prefix !== "" ? ` as ${imp.prefix}` : "";
|
|
476
|
+
return `import '${imp.uri}'${prefix};`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function refIdClassResolver(mod: DomainModule): (aggregateType: string) => string {
|
|
480
|
+
const imports = new Map<string, DartRefImport>();
|
|
481
|
+
for (const imp of mod.dartRefImports ?? []) {
|
|
482
|
+
imports.set(imp.aggregateType, imp);
|
|
483
|
+
}
|
|
484
|
+
return (aggregateType: string): string => {
|
|
485
|
+
const imp = imports.get(aggregateType);
|
|
486
|
+
const cls = typedIdClassForAggregateType(aggregateType);
|
|
487
|
+
return imp === undefined ? cls : `${imp.prefix}.${cls}`;
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function unwrapProjectionReturn(zt: z.ZodTypeAny): z.ZodTypeAny {
|
|
492
|
+
let cur = zt;
|
|
493
|
+
while (
|
|
494
|
+
zodTypeName(cur) === "ZodOptional" ||
|
|
495
|
+
zodTypeName(cur) === "ZodDefault" ||
|
|
496
|
+
zodTypeName(cur) === "ZodNullable"
|
|
497
|
+
) {
|
|
498
|
+
cur = zodInnerType(cur);
|
|
499
|
+
}
|
|
500
|
+
return cur;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function projectionReturnDartBaseType(schema: z.ZodTypeAny): string {
|
|
504
|
+
const inner = unwrapProjectionReturn(schema);
|
|
505
|
+
switch (zodTypeName(inner)) {
|
|
506
|
+
case "ZodString":
|
|
507
|
+
case "ZodEnum":
|
|
508
|
+
return "String";
|
|
509
|
+
case "ZodNumber":
|
|
510
|
+
return zodNumberIsInt(inner) ? "int" : "double";
|
|
511
|
+
case "ZodBoolean":
|
|
512
|
+
return "bool";
|
|
513
|
+
case "ZodObject":
|
|
514
|
+
case "ZodRecord":
|
|
515
|
+
return "Map<String, Object?>";
|
|
516
|
+
case "ZodArray":
|
|
517
|
+
return "List<Object?>";
|
|
518
|
+
default:
|
|
519
|
+
return "Object?";
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function projectionReturnDartType(schema: z.ZodTypeAny): string {
|
|
524
|
+
const base = projectionReturnDartBaseType(schema);
|
|
525
|
+
return base.endsWith("?") ? base : `${base}?`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function parseProjectionReturnField(field: string, schema: z.ZodTypeAny): string {
|
|
529
|
+
const inner = unwrapProjectionReturn(schema);
|
|
530
|
+
switch (zodTypeName(inner)) {
|
|
531
|
+
case "ZodString":
|
|
532
|
+
case "ZodEnum":
|
|
533
|
+
return `data['${field}'] as String?`;
|
|
534
|
+
case "ZodBoolean":
|
|
535
|
+
return `data['${field}'] as bool?`;
|
|
536
|
+
case "ZodNumber":
|
|
537
|
+
return zodNumberIsInt(inner)
|
|
538
|
+
? `(() { final v = data['${field}']; return v == null ? null : (v as num).toInt(); })()`
|
|
539
|
+
: `(() { final v = data['${field}']; return v == null ? null : (v as num).toDouble(); })()`;
|
|
540
|
+
case "ZodObject":
|
|
541
|
+
case "ZodRecord":
|
|
542
|
+
return `(data['${field}'] as Map?)?.cast<String, Object?>()`;
|
|
543
|
+
case "ZodArray":
|
|
544
|
+
return `(data['${field}'] as List?)?.cast<Object?>()`;
|
|
545
|
+
default:
|
|
546
|
+
return `data['${field}']`;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Unwrap a possibly-`.optional()`/`.default()` Zod schema to its inner type, so an
|
|
551
|
+
* optional enum field still yields its literal options. */
|
|
552
|
+
function unwrapZod(zt: z.ZodTypeAny): z.ZodTypeAny {
|
|
553
|
+
let inner = zt;
|
|
554
|
+
while (isOptionalOrDefault(inner)) {
|
|
555
|
+
inner = zodInnerType(inner);
|
|
556
|
+
}
|
|
557
|
+
return inner;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function aggFieldSpec(
|
|
561
|
+
wireName: string,
|
|
562
|
+
field: Field,
|
|
563
|
+
enumDartTypes: Map<string, string>,
|
|
564
|
+
refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
|
|
565
|
+
valueClass?: string,
|
|
566
|
+
): AggFieldSpec {
|
|
567
|
+
const name = dartMemberName(wireName);
|
|
568
|
+
// Collections are ALWAYS empty-defaultable; scalars are optional only when the DSL
|
|
569
|
+
// field schema marked them `.optional()`.
|
|
570
|
+
const optional = isCollectionKind(field.kind) ? false : field.isOptional;
|
|
571
|
+
switch (field.kind) {
|
|
572
|
+
case "string":
|
|
573
|
+
return {
|
|
574
|
+
name,
|
|
575
|
+
wireName,
|
|
576
|
+
kind: field.kind,
|
|
577
|
+
dartType: valueClass ?? "String",
|
|
578
|
+
optional,
|
|
579
|
+
...(valueClass !== undefined ? { valueClass } : {}),
|
|
580
|
+
};
|
|
581
|
+
case "json":
|
|
582
|
+
// `jsonShape` ("object" via `t.jsonObject()`) chooses strict-object vs
|
|
583
|
+
// permissive read-model type.
|
|
584
|
+
return {
|
|
585
|
+
name,
|
|
586
|
+
wireName,
|
|
587
|
+
kind: field.kind,
|
|
588
|
+
dartType: "String",
|
|
589
|
+
optional,
|
|
590
|
+
...(field.jsonShape !== undefined ? { jsonShape: field.jsonShape } : {}),
|
|
591
|
+
};
|
|
592
|
+
case "ref": {
|
|
593
|
+
// A `ref` field carries ANOTHER aggregate's id — type it as that target's
|
|
594
|
+
// TYPED id class (cross-aggregate ref typing), so e.g. a `Building.siteId`
|
|
595
|
+
// ref is a `SiteId`, never a bare String. The target wire id is recorded on
|
|
596
|
+
// the field (`refAggregateId`); fall back to `String` only if it is absent.
|
|
597
|
+
const targetType = field.refAggregateId;
|
|
598
|
+
const refIdClass =
|
|
599
|
+
targetType !== undefined && targetType !== ""
|
|
600
|
+
? refIdClassForAggregate(targetType)
|
|
601
|
+
: undefined;
|
|
602
|
+
return {
|
|
603
|
+
name,
|
|
604
|
+
wireName,
|
|
605
|
+
kind: field.kind,
|
|
606
|
+
dartType: refIdClass ?? "String",
|
|
607
|
+
optional,
|
|
608
|
+
...(refIdClass !== undefined ? { refIdClass } : {}),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
case "int":
|
|
612
|
+
return { name, wireName, kind: field.kind, dartType: "int", optional };
|
|
613
|
+
case "enum": {
|
|
614
|
+
// Pull the enum values out of the Zod schema (unwrapping `.optional()`).
|
|
615
|
+
const values = zodEnumValues(unwrapZod(field.zod));
|
|
616
|
+
const key = values.join("|");
|
|
617
|
+
const dartType = enumDartTypes.get(key) ?? `${dartTypeNameFromField(wireName)}Enum`;
|
|
618
|
+
return { name, wireName, kind: field.kind, dartType, enumValues: values, optional };
|
|
619
|
+
}
|
|
620
|
+
case "evidence":
|
|
621
|
+
// A content-addressed EvidenceRef → the shared typed `EvidenceRef` Dart class
|
|
622
|
+
// (emitted once per file). A scalar value, optional only when `.optional()`.
|
|
623
|
+
return { name, wireName, kind: field.kind, dartType: "EvidenceRef", optional };
|
|
624
|
+
case "set":
|
|
625
|
+
return { name, wireName, kind: field.kind, dartType: "Set<String>", optional: false };
|
|
626
|
+
case "map": {
|
|
627
|
+
// A `json`-valued map (`t.map(t.json())`) surfaces each entry as its DECODED
|
|
628
|
+
// object (`Map<String, Object?>`) so a value-object map round-trips losslessly;
|
|
629
|
+
// every other map keeps the flat-string `Map<String, String>`. The wire driver is
|
|
630
|
+
// identical for both, so this is a frontend-only type choice (no hash change).
|
|
631
|
+
const jsonValued = field.mapValueKind === "json";
|
|
632
|
+
return {
|
|
633
|
+
name,
|
|
634
|
+
wireName,
|
|
635
|
+
kind: field.kind,
|
|
636
|
+
dartType: jsonValued ? "Map<String, Object?>" : "Map<String, String>",
|
|
637
|
+
optional: false,
|
|
638
|
+
...(field.mapValueKind !== undefined ? { mapValueKind: field.mapValueKind } : {}),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
case "hasMany":
|
|
642
|
+
throw new Error(
|
|
643
|
+
`virtual hasMany field '${name}' is read-index only and cannot be emitted as folded Dart state`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function aggregateFieldSpecs(
|
|
649
|
+
agg: AggregateHandle,
|
|
650
|
+
enumDartTypes: Map<string, string>,
|
|
651
|
+
refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
|
|
652
|
+
ownIdFields: ReadonlySet<string> = new Set(),
|
|
653
|
+
): AggFieldSpec[] {
|
|
654
|
+
const used = new Set<string>(["aggregateId"]);
|
|
655
|
+
// `agg.fields` is STORED fields only — virtual `t.hasMany` inverses are in `agg.hasMany`
|
|
656
|
+
// and never reach Dart materialization (they are served by the inverse read index).
|
|
657
|
+
return Object.entries(agg.fields as Record<string, Field>)
|
|
658
|
+
.map(([name, f]) => {
|
|
659
|
+
const valueClass =
|
|
660
|
+
f.kind === "string" && ownIdFields.has(name)
|
|
661
|
+
? aggregateIdDartType(agg.id)
|
|
662
|
+
: undefined;
|
|
663
|
+
const spec = aggFieldSpec(
|
|
664
|
+
name,
|
|
665
|
+
f,
|
|
666
|
+
enumDartTypes,
|
|
667
|
+
refIdClassForAggregate,
|
|
668
|
+
valueClass,
|
|
669
|
+
);
|
|
670
|
+
if (used.has(spec.name)) {
|
|
671
|
+
throw new Error(
|
|
672
|
+
`aggregate '${agg.id}' field '${spec.wireName}' maps to duplicate Dart field '${spec.name}'`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
used.add(spec.name);
|
|
676
|
+
return spec;
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ---- emit helpers ----------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
export function cap(s: string): string {
|
|
683
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ---- typed-id naming (the type-safety keystone, generalized) ----------------
|
|
687
|
+
//
|
|
688
|
+
// A DISTINCT typed id class is emitted PER AGGREGATE so passing one aggregate's
|
|
689
|
+
// id where another's is expected is a COMPILE ERROR. The class NAME must be a pure
|
|
690
|
+
// function of the aggregate's wire TYPE (e.g. `SiteRootAggregate`) so three sites
|
|
691
|
+
// agree on it everywhere it is referenced — the typed-id class declaration, every
|
|
692
|
+
// `createX` factory's return type, AND every cross-aggregate REF field that targets
|
|
693
|
+
// that type. Convention: strip a trailing `RootAggregate`/`Aggregate` suffix and
|
|
694
|
+
// append `Id` — `SiteRootAggregate → SiteId`, `ThingAggregate → ThingId`,
|
|
695
|
+
// `TrackableThing → TrackableThingId`, `BuildingAggregate → BuildingId`. The
|
|
696
|
+
// `RootAggregate` strip BEFORE `Aggregate` keeps the proven `SiteId` byte-identical.
|
|
697
|
+
|
|
698
|
+
/** The typed-id class name for an aggregate WIRE TYPE (e.g. `SiteRootAggregate` →
|
|
699
|
+
* `SiteId`). Pure + deterministic: the SAME wire type ALWAYS yields the SAME name,
|
|
700
|
+
* so the id-class decl, the `createX` factory return, and every typed ref field that
|
|
701
|
+
* points at this type all agree. */
|
|
702
|
+
function typedIdClassForAggregateType(aggregateType: string): string {
|
|
703
|
+
let stem = aggregateType;
|
|
704
|
+
if (stem.endsWith("RootAggregate")) {
|
|
705
|
+
stem = stem.slice(0, -"RootAggregate".length);
|
|
706
|
+
} else if (stem.endsWith("Aggregate")) {
|
|
707
|
+
stem = stem.slice(0, -"Aggregate".length);
|
|
708
|
+
}
|
|
709
|
+
// Empty stem (a type literally named `Aggregate`) is impossible for registered
|
|
710
|
+
// aggregates, but guard so the name is always a legal identifier.
|
|
711
|
+
return `${cap(stem === "" ? aggregateType : stem)}Id`;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Collect all enum value-sets across aggregates + directives, name each ONCE. */
|
|
715
|
+
function collectEnums(
|
|
716
|
+
aggregates: AggregateHandle[],
|
|
717
|
+
directives: AnyDirective[],
|
|
718
|
+
): { dartTypes: Map<string, string>; decls: string[] } {
|
|
719
|
+
const dartTypes = new Map<string, string>(); // "a|b|c" -> DartEnumName
|
|
720
|
+
const usedNames = new Set<string>(); // every emitted enum name (collision guard)
|
|
721
|
+
const decls: string[] = [];
|
|
722
|
+
const register = (preferredName: string, values: readonly string[]) => {
|
|
723
|
+
const key = values.join("|");
|
|
724
|
+
if (dartTypes.has(key)) return;
|
|
725
|
+
// Two DIFFERENT value-sets can prefer the SAME name (e.g. several `status`
|
|
726
|
+
// fields with distinct literal lists all want `StatusEnum`). Suffix on collision
|
|
727
|
+
// so each distinct value-set gets a UNIQUE Dart enum (`StatusEnum`, `Status2Enum`).
|
|
728
|
+
const base = `${dartTypeNameFromField(preferredName)}Enum`;
|
|
729
|
+
let name = base;
|
|
730
|
+
let n = 1;
|
|
731
|
+
while (usedNames.has(name)) {
|
|
732
|
+
n += 1;
|
|
733
|
+
name = `${dartTypeNameFromField(preferredName)}${n}Enum`;
|
|
734
|
+
}
|
|
735
|
+
usedNames.add(name);
|
|
736
|
+
dartTypes.set(key, name);
|
|
737
|
+
const variants = values
|
|
738
|
+
.map((v) => ` ${dartIdent(v)}('${v}')`)
|
|
739
|
+
.join(",\n");
|
|
740
|
+
decls.push(
|
|
741
|
+
`/// Generated enum for field values: ${values.join(", ")}.\n` +
|
|
742
|
+
`enum ${name} {\n${variants};\n\n` +
|
|
743
|
+
` final String wire;\n` +
|
|
744
|
+
` const ${name}(this.wire);\n\n` +
|
|
745
|
+
` static ${name} fromWire(String wire) =>\n` +
|
|
746
|
+
` ${name}.values.firstWhere((e) => e.wire == wire,\n` +
|
|
747
|
+
` orElse: () => throw FormatException('unknown ${name}: \$wire'));\n` +
|
|
748
|
+
`}`,
|
|
749
|
+
);
|
|
750
|
+
};
|
|
751
|
+
for (const agg of aggregates) {
|
|
752
|
+
for (const [name, field] of Object.entries(
|
|
753
|
+
agg.fields as Record<string, Field>,
|
|
754
|
+
)) {
|
|
755
|
+
if (field.kind === "enum") {
|
|
756
|
+
// Unwrap `.optional()` so an optional enum field still registers its options.
|
|
757
|
+
register(name, zodEnumValues(unwrapZod(field.zod)));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
for (const d of directives) {
|
|
762
|
+
if (zodTypeName(d.payloadSchema as z.ZodTypeAny) !== "ZodObject") continue;
|
|
763
|
+
const shape = zodObjectShape(d.payloadSchema as z.ZodTypeAny);
|
|
764
|
+
for (const [name, zt] of Object.entries(shape)) {
|
|
765
|
+
const inner = unwrapZod(zt as z.ZodTypeAny);
|
|
766
|
+
if (zodTypeName(inner) === "ZodEnum") {
|
|
767
|
+
register(name, zodEnumValues(inner));
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return { dartTypes, decls };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Dart reserved words that cannot be bare identifiers (enum members / fields). A
|
|
775
|
+
* value like `"new"` (feedback status) collides, so it is suffixed (`new_`). */
|
|
776
|
+
const DART_RESERVED = new Set([
|
|
777
|
+
"abstract", "as", "assert", "async", "await", "break", "case", "catch", "class",
|
|
778
|
+
"const", "continue", "covariant", "default", "deferred", "do", "dynamic", "else",
|
|
779
|
+
"enum", "export", "extends", "extension", "external", "factory", "false", "final",
|
|
780
|
+
"finally", "for", "function", "get", "hide", "if", "implements", "import", "in",
|
|
781
|
+
"interface", "is", "late", "library", "mixin", "new", "null", "on", "operator",
|
|
782
|
+
"part", "required", "rethrow", "return", "set", "show", "static", "super",
|
|
783
|
+
"switch", "this", "throw", "true", "try", "typedef", "var", "void", "while",
|
|
784
|
+
"with", "yield",
|
|
785
|
+
]);
|
|
786
|
+
|
|
787
|
+
/** A Dart-safe enum identifier (handles values like "in_repair", and reserved words
|
|
788
|
+
* like "new"). */
|
|
789
|
+
function dartIdent(v: string): string {
|
|
790
|
+
const cleaned = v.replace(/[^A-Za-z0-9_]/g, "_");
|
|
791
|
+
const safe = /^[0-9]/.test(cleaned) ? `v$cleaned` : cleaned;
|
|
792
|
+
return DART_RESERVED.has(safe) ? `${safe}_` : safe;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function dartString(s: string): string {
|
|
796
|
+
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\$/g, "\\$")}'`;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function emitPermissionVocabulary(vocab: PermissionVocabulary | undefined): string {
|
|
800
|
+
if (vocab === undefined) return "";
|
|
801
|
+
const capabilities = [...new Set(Object.values(vocab.roleCatalogue).flat())].sort();
|
|
802
|
+
const roleNames = Object.keys(vocab.roleCatalogue).sort();
|
|
803
|
+
const roleDecls = roleNames.map((role) => {
|
|
804
|
+
const caps = [...new Set(vocab.roleCatalogue[role] ?? [])].sort();
|
|
805
|
+
const capSet =
|
|
806
|
+
caps.length === 0
|
|
807
|
+
? `<String>{}`
|
|
808
|
+
: `<String>{${caps.map(dartString).join(", ")}}`;
|
|
809
|
+
return ` static const ${dartIdent(role)} = PermissionRole(${dartString(role)}, ${capSet});`;
|
|
810
|
+
});
|
|
811
|
+
const capabilityDecls = capabilities.map(
|
|
812
|
+
(capability) => ` static const ${dartIdent(capability)} = ${dartString(capability)};`,
|
|
813
|
+
);
|
|
814
|
+
const values = roleNames.map((role) => dartIdent(role)).join(", ");
|
|
815
|
+
const allCapabilities = capabilities.map(dartString).join(", ");
|
|
816
|
+
|
|
817
|
+
return [
|
|
818
|
+
`/// Generated permission vocabulary from the domain role catalogue.`,
|
|
819
|
+
`///`,
|
|
820
|
+
`/// This is frontend read/dispatch vocabulary only. Domain execution and`,
|
|
821
|
+
`/// admission still happen inside the installed GitHolon domain law.`,
|
|
822
|
+
`class PermissionRole {`,
|
|
823
|
+
` const PermissionRole(this.value, this.capabilities);`,
|
|
824
|
+
``,
|
|
825
|
+
` final String value;`,
|
|
826
|
+
` final Set<String> capabilities;`,
|
|
827
|
+
``,
|
|
828
|
+
` bool grants(String capability) => capabilities.contains(capability);`,
|
|
829
|
+
``,
|
|
830
|
+
` @override`,
|
|
831
|
+
` bool operator ==(Object other) => other is PermissionRole && other.value == value;`,
|
|
832
|
+
``,
|
|
833
|
+
` @override`,
|
|
834
|
+
` int get hashCode => value.hashCode;`,
|
|
835
|
+
`}`,
|
|
836
|
+
``,
|
|
837
|
+
`class PermissionRoles {`,
|
|
838
|
+
` const PermissionRoles._();`,
|
|
839
|
+
...roleDecls,
|
|
840
|
+
``,
|
|
841
|
+
` static const values = <PermissionRole>[${values}];`,
|
|
842
|
+
``,
|
|
843
|
+
` static PermissionRole fromWire(String value) => values.firstWhere(`,
|
|
844
|
+
` (role) => role.value == value,`,
|
|
845
|
+
` orElse: () => PermissionRole(value, const <String>{}),`,
|
|
846
|
+
` );`,
|
|
847
|
+
`}`,
|
|
848
|
+
``,
|
|
849
|
+
`class PermissionCapabilities {`,
|
|
850
|
+
` const PermissionCapabilities._();`,
|
|
851
|
+
...capabilityDecls,
|
|
852
|
+
``,
|
|
853
|
+
` static const values = <String>[${allCapabilities}];`,
|
|
854
|
+
`}`,
|
|
855
|
+
``,
|
|
856
|
+
`class PermissionBindingProjection {`,
|
|
857
|
+
` const PermissionBindingProjection({`,
|
|
858
|
+
` required this.permissionId,`,
|
|
859
|
+
` required this.userId,`,
|
|
860
|
+
` required this.resourceType,`,
|
|
861
|
+
` required this.resourceId,`,
|
|
862
|
+
` required this.role,`,
|
|
863
|
+
` required this.status,`,
|
|
864
|
+
` required this.grantedBy,`,
|
|
865
|
+
` this.grantedAt,`,
|
|
866
|
+
` this.displayName,`,
|
|
867
|
+
` });`,
|
|
868
|
+
``,
|
|
869
|
+
` final String permissionId;`,
|
|
870
|
+
` final String userId;`,
|
|
871
|
+
` final ResourceTypeEnum resourceType;`,
|
|
872
|
+
` final String resourceId;`,
|
|
873
|
+
` final PermissionRole role;`,
|
|
874
|
+
` final String status;`,
|
|
875
|
+
` final String grantedBy;`,
|
|
876
|
+
` final DateTime? grantedAt;`,
|
|
877
|
+
` final String? displayName;`,
|
|
878
|
+
``,
|
|
879
|
+
` bool get isActive => status != 'revoked' && status != 'deleted';`,
|
|
880
|
+
` bool grants(String capability) => isActive && role.grants(capability);`,
|
|
881
|
+
``,
|
|
882
|
+
` factory PermissionBindingProjection.fromProjected(`,
|
|
883
|
+
` String permissionId,`,
|
|
884
|
+
` Map<String, Object?> json,`,
|
|
885
|
+
` ) {`,
|
|
886
|
+
` final resourceTypeWire = (json['resourceType'] ?? '').toString();`,
|
|
887
|
+
` return PermissionBindingProjection(`,
|
|
888
|
+
` permissionId: permissionId,`,
|
|
889
|
+
` userId: (json['userId'] ?? '').toString(),`,
|
|
890
|
+
` resourceType: ResourceTypeEnum.fromWire(resourceTypeWire),`,
|
|
891
|
+
` resourceId: (json['resourceId'] ?? json['estateId'] ?? '').toString(),`,
|
|
892
|
+
` role: PermissionRoles.fromWire((json['role'] ?? '').toString()),`,
|
|
893
|
+
` status: (json['status'] ?? 'active').toString(),`,
|
|
894
|
+
` grantedBy: (json['grantedBy'] ?? '').toString(),`,
|
|
895
|
+
` grantedAt: _permissionDate(json['grantedAt']),`,
|
|
896
|
+
` displayName: json['displayName']?.toString(),`,
|
|
897
|
+
` );`,
|
|
898
|
+
` }`,
|
|
899
|
+
`}`,
|
|
900
|
+
``,
|
|
901
|
+
`extension UserPermissionReadModelRbac on UserPermissionReadModel {`,
|
|
902
|
+
` List<PermissionBindingProjection> get activePermissionBindings =>`,
|
|
903
|
+
` permissionBindings.where((binding) => binding.isActive).toList(growable: false);`,
|
|
904
|
+
``,
|
|
905
|
+
` List<PermissionBindingProjection> get permissionBindings => permissions.entries`,
|
|
906
|
+
` .where((entry) => entry.value is Map)`,
|
|
907
|
+
` .map(`,
|
|
908
|
+
` (entry) => PermissionBindingProjection.fromProjected(`,
|
|
909
|
+
` entry.key,`,
|
|
910
|
+
` (entry.value as Map).cast<String, Object?>(),`,
|
|
911
|
+
` ),`,
|
|
912
|
+
` )`,
|
|
913
|
+
` .toList(growable: false);`,
|
|
914
|
+
``,
|
|
915
|
+
` bool hasCapability({`,
|
|
916
|
+
` required ResourceTypeEnum resourceType,`,
|
|
917
|
+
` required String resourceId,`,
|
|
918
|
+
` required String capability,`,
|
|
919
|
+
` }) => activePermissionBindings.any(`,
|
|
920
|
+
` (binding) =>`,
|
|
921
|
+
` binding.resourceType == resourceType &&`,
|
|
922
|
+
` binding.resourceId == resourceId &&`,
|
|
923
|
+
` binding.role.grants(capability),`,
|
|
924
|
+
` );`,
|
|
925
|
+
``,
|
|
926
|
+
` bool hasCapabilityInAnyScope(String capability) => activePermissionBindings.any(`,
|
|
927
|
+
` (binding) => binding.role.grants(capability),`,
|
|
928
|
+
` );`,
|
|
929
|
+
``,
|
|
930
|
+
` bool hasCapabilityForAnyResource({`,
|
|
931
|
+
` required ResourceTypeEnum resourceType,`,
|
|
932
|
+
` required String capability,`,
|
|
933
|
+
` }) => activePermissionBindings.any(`,
|
|
934
|
+
` (binding) =>`,
|
|
935
|
+
` binding.resourceType == resourceType && binding.role.grants(capability),`,
|
|
936
|
+
` );`,
|
|
937
|
+
`}`,
|
|
938
|
+
``,
|
|
939
|
+
`DateTime? _permissionDate(Object? raw) {`,
|
|
940
|
+
` if (raw is DateTime) return raw;`,
|
|
941
|
+
` if (raw is int && raw > 0) {`,
|
|
942
|
+
` return DateTime.fromMillisecondsSinceEpoch(raw, isUtc: true);`,
|
|
943
|
+
` }`,
|
|
944
|
+
` if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw);`,
|
|
945
|
+
` return null;`,
|
|
946
|
+
`}`,
|
|
947
|
+
].join("\n");
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// ---- aggregate class -------------------------------------------------------
|
|
951
|
+
|
|
952
|
+
function emitAggregate(
|
|
953
|
+
agg: AggregateHandle,
|
|
954
|
+
enumDartTypes: Map<string, string>,
|
|
955
|
+
refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
|
|
956
|
+
ownIdFields: ReadonlySet<string> = new Set(),
|
|
957
|
+
): string {
|
|
958
|
+
const className = agg.id; // wire id is already PascalCase by convention.
|
|
959
|
+
const idType = aggregateIdDartType(agg.id);
|
|
960
|
+
const specs = aggregateFieldSpecs(
|
|
961
|
+
agg,
|
|
962
|
+
enumDartTypes,
|
|
963
|
+
refIdClassForAggregate,
|
|
964
|
+
ownIdFields,
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
// GAP 2 — the kernel aggregate id. The projection strips the reserved `__id`/
|
|
968
|
+
// `__type` from each row's flat `data`; the row-level `id` (== the kernel aggregate
|
|
969
|
+
// id, e.g. the siteId) is the ONLY carrier of it. We codegen a non-domain,
|
|
970
|
+
// TYPED `aggregateId` field threaded from the row id through `fromJson`'s optional
|
|
971
|
+
// second positional arg, so BOTH the indexed accessors (`read…`/`watch…`) AND the
|
|
972
|
+
// by-id accessors (`read…ById`) — all of which funnel through `NomosReads.decodeRows`,
|
|
973
|
+
// which now passes `row['id']` — surface the id on the typed aggregate. (No domain
|
|
974
|
+
// aggregate declares an `aggregateId` field, so there is no collision.)
|
|
975
|
+
|
|
976
|
+
// Field decls: the GAP-2 id first, then each domain field. A `.optional()` scalar is
|
|
977
|
+
// nullable (`String?`/`int?`/`Enum?`); collections (`set`/`map`) keep their non-null
|
|
978
|
+
// container type but default to EMPTY when absent.
|
|
979
|
+
const fieldDecls = [
|
|
980
|
+
` /// The kernel aggregate id (the \`{type,id,data}\` row's \`id\`) — the id the`,
|
|
981
|
+
` /// indexed/by-id accessor matched on (e.g. the siteId). Empty string only when`,
|
|
982
|
+
` /// the aggregate is built from a bare \`data\` map with no row id (e.g. a unit test).`,
|
|
983
|
+
` final ${idType} aggregateId;`,
|
|
984
|
+
...specs.map(
|
|
985
|
+
(s) => ` final ${s.dartType}${s.optional ? "?" : ""} ${s.name};`,
|
|
986
|
+
),
|
|
987
|
+
].join("\n");
|
|
988
|
+
const ctorParams = [
|
|
989
|
+
` this.aggregateId = const ${idType}.fromRow(''),`,
|
|
990
|
+
...specs.map(
|
|
991
|
+
// A required scalar stays `required`; an optional scalar OR a collection is not
|
|
992
|
+
// `required` (an absent optional scalar is null; an absent collection is empty).
|
|
993
|
+
(s) =>
|
|
994
|
+
s.optional || isCollectionKind(s.kind)
|
|
995
|
+
? ` ${collectionDefaultParam(s)}`
|
|
996
|
+
: ` required this.${s.name},`,
|
|
997
|
+
),
|
|
998
|
+
].join("\n");
|
|
999
|
+
|
|
1000
|
+
// fromJson: the FLAT, already-decoded projection `data` map (what `query()` /
|
|
1001
|
+
// `query_by_id()` emit per aggregate — e.g. `{"name":"HQ",
|
|
1002
|
+
// "createdAt":1000,"location":{…}}`), NOT the projection-internal
|
|
1003
|
+
// `{field:{value,stamp}}` shape. Each field reads via a tolerant
|
|
1004
|
+
// flat-projection reader from `subscriptions.dart`. The kernel aggregate [id] is
|
|
1005
|
+
// threaded from the row id (GAP 2) — defaulting to '' for a bare-map call.
|
|
1006
|
+
const parseLines = [
|
|
1007
|
+
` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
|
|
1008
|
+
...specs.map((s) => ` ${s.name}: ${parseField(s, className)},`),
|
|
1009
|
+
].join("\n");
|
|
1010
|
+
|
|
1011
|
+
// `==`/`hashCode` include the id (two rows with the same data but different ids are
|
|
1012
|
+
// distinct aggregates). An optional scalar compares with `==` (null-safe); a
|
|
1013
|
+
// collection compares set/map-wise (always non-null after the empty default).
|
|
1014
|
+
const eqLines = [
|
|
1015
|
+
`other.aggregateId == aggregateId`,
|
|
1016
|
+
...specs.map((s) =>
|
|
1017
|
+
s.kind === "set"
|
|
1018
|
+
? `other.${s.name}.length == ${s.name}.length && other.${s.name}.containsAll(${s.name})`
|
|
1019
|
+
: s.kind === "map"
|
|
1020
|
+
? `_mapEq(other.${s.name}, ${s.name})`
|
|
1021
|
+
: `other.${s.name} == ${s.name}`,
|
|
1022
|
+
),
|
|
1023
|
+
].join(" &&\n ");
|
|
1024
|
+
|
|
1025
|
+
const hashLines = [
|
|
1026
|
+
` aggregateId,`,
|
|
1027
|
+
...specs.map((s) =>
|
|
1028
|
+
s.kind === "set" || s.kind === "map"
|
|
1029
|
+
? ` Object.hashAll(${s.name}${s.kind === "map" ? ".entries.map((e) => Object.hash(e.key, e.value))" : ""}),`
|
|
1030
|
+
: ` ${s.name},`,
|
|
1031
|
+
),
|
|
1032
|
+
].join("\n");
|
|
1033
|
+
|
|
1034
|
+
return [
|
|
1035
|
+
`/// Generated projection aggregate for '${agg.id}'.`,
|
|
1036
|
+
`///`,
|
|
1037
|
+
`/// Deserialized via [${className}.fromJson] from the FLAT, already-decoded`,
|
|
1038
|
+
`/// projection \`data\` map (the \`{type,id,data}\` row \`query()\`/\`query_by_id()\``,
|
|
1039
|
+
`/// emits) — e.g. \`{"name":"HQ","createdAt":1000}\`. The kernel aggregate`,
|
|
1040
|
+
`/// [aggregateId] is carried from the row \`id\`. Read it reactively through the`,
|
|
1041
|
+
`/// generated \`watch…\` accessors below. Collection fields (\`set\`/\`map\`) default to`,
|
|
1042
|
+
`/// EMPTY and \`.optional()\` scalars to \`null\` when a freshly-folded aggregate has`,
|
|
1043
|
+
`/// not yet folded them; must-be-folded scalars still throw when absent.`,
|
|
1044
|
+
`class ${className} {`,
|
|
1045
|
+
fieldDecls,
|
|
1046
|
+
``,
|
|
1047
|
+
` const ${className}({`,
|
|
1048
|
+
ctorParams,
|
|
1049
|
+
` });`,
|
|
1050
|
+
``,
|
|
1051
|
+
` /// Parse from the FLAT projection \`data\` map (the \`{type,id,data}\` row's`,
|
|
1052
|
+
` /// \`data\` that \`query()\`/\`query_by_id()\` emit — fields already`,
|
|
1053
|
+
` /// decoded to bare JSON), carrying the kernel aggregate [id] from the row \`id\``,
|
|
1054
|
+
` /// (defaults to '' for a bare-map call). Missing REQUIRED fields throw a`,
|
|
1055
|
+
` /// [FormatException]; absent collections decode empty and absent \`.optional()\``,
|
|
1056
|
+
` /// scalars decode \`null\`.`,
|
|
1057
|
+
` factory ${className}.fromJson(Map<String, dynamic> data, [String id = '']) {`,
|
|
1058
|
+
` return ${className}(`,
|
|
1059
|
+
parseLines,
|
|
1060
|
+
` );`,
|
|
1061
|
+
` }`,
|
|
1062
|
+
``,
|
|
1063
|
+
` @override`,
|
|
1064
|
+
` bool operator ==(Object other) =>`,
|
|
1065
|
+
` other is ${className} &&`,
|
|
1066
|
+
` ${eqLines};`,
|
|
1067
|
+
``,
|
|
1068
|
+
` @override`,
|
|
1069
|
+
` int get hashCode => Object.hashAll([`,
|
|
1070
|
+
hashLines,
|
|
1071
|
+
` ]);`,
|
|
1072
|
+
`}`,
|
|
1073
|
+
].join("\n");
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** The shared, domain-agnostic `EvidenceRef` Dart class (`evidence.md` §9.2) — emitted
|
|
1077
|
+
* ONCE per generated file when any aggregate/projection field is `t.evidence()`. A typed
|
|
1078
|
+
* read of the read engine's clean decoded object (`{hash, mediaType, byteLength,
|
|
1079
|
+
* storageClass}`); it carries the content hash + metadata, NOT the bytes (the read side
|
|
1080
|
+
* fetches the Evidence Store by hash; a shredded ref materialises a typed "redacted").
|
|
1081
|
+
* Pure data + value-equality so it slots into the aggregate's `==`/`hashCode`. */
|
|
1082
|
+
function emitEvidenceRefClass(): string {
|
|
1083
|
+
return [
|
|
1084
|
+
`/// A content-addressed EVIDENCE reference (evidence.md §1/§9.2) — the framework`,
|
|
1085
|
+
`/// commits this (hash + metadata), never the bytes. Read it off an aggregate field`,
|
|
1086
|
+
`/// declared \`t.evidence()\`; fetch the bytes from the Evidence Store BY \`hash\` (a`,
|
|
1087
|
+
`/// shredded ref materialises a typed "redacted", never the bytes). \`hash\` is git's`,
|
|
1088
|
+
`/// blob oid — the SAME content hash for git + external storage (backend-portable).`,
|
|
1089
|
+
`class EvidenceRef {`,
|
|
1090
|
+
` /// The content hash — git's blob oid (the identity).`,
|
|
1091
|
+
` final String hash;`,
|
|
1092
|
+
` /// The declared content type (e.g. \`application/pdf\`).`,
|
|
1093
|
+
` final String mediaType;`,
|
|
1094
|
+
` /// The byte length of the referenced content.`,
|
|
1095
|
+
` final int byteLength;`,
|
|
1096
|
+
` /// Where the bytes live: 'git' (inline-syncing) or 'external' (shreddable storage).`,
|
|
1097
|
+
` final String storageClass;`,
|
|
1098
|
+
``,
|
|
1099
|
+
` const EvidenceRef({`,
|
|
1100
|
+
` required this.hash,`,
|
|
1101
|
+
` required this.mediaType,`,
|
|
1102
|
+
` required this.byteLength,`,
|
|
1103
|
+
` required this.storageClass,`,
|
|
1104
|
+
` });`,
|
|
1105
|
+
``,
|
|
1106
|
+
` /// Read from the read engine's CLEAN decoded object`,
|
|
1107
|
+
` /// (\`{hash, mediaType, byteLength, storageClass}\`).`,
|
|
1108
|
+
` factory EvidenceRef.fromProjected(Map<String, Object?> m) {`,
|
|
1109
|
+
` return EvidenceRef(`,
|
|
1110
|
+
` hash: m['hash']! as String,`,
|
|
1111
|
+
` mediaType: m['mediaType']! as String,`,
|
|
1112
|
+
` byteLength: (m['byteLength']! as num).toInt(),`,
|
|
1113
|
+
` storageClass: m['storageClass']! as String,`,
|
|
1114
|
+
` );`,
|
|
1115
|
+
` }`,
|
|
1116
|
+
``,
|
|
1117
|
+
` /// The authored payload shape (for dispatch): the typed value-object the DSL`,
|
|
1118
|
+
` /// \`t.evidence()\` field lowers to a kernel \`Value::Evidence\`.`,
|
|
1119
|
+
` Map<String, Object?> toJson() => <String, Object?>{`,
|
|
1120
|
+
` 'hash': hash,`,
|
|
1121
|
+
` 'mediaType': mediaType,`,
|
|
1122
|
+
` 'byteLength': byteLength,`,
|
|
1123
|
+
` 'storageClass': storageClass,`,
|
|
1124
|
+
` };`,
|
|
1125
|
+
``,
|
|
1126
|
+
` @override`,
|
|
1127
|
+
` bool operator ==(Object other) =>`,
|
|
1128
|
+
` other is EvidenceRef &&`,
|
|
1129
|
+
` other.hash == hash &&`,
|
|
1130
|
+
` other.mediaType == mediaType &&`,
|
|
1131
|
+
` other.byteLength == byteLength &&`,
|
|
1132
|
+
` other.storageClass == storageClass;`,
|
|
1133
|
+
``,
|
|
1134
|
+
` @override`,
|
|
1135
|
+
` int get hashCode => Object.hash(hash, mediaType, byteLength, storageClass);`,
|
|
1136
|
+
`}`,
|
|
1137
|
+
].join("\n");
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/** True when ANY aggregate field in the module is `t.evidence()` — gates emitting the
|
|
1141
|
+
* shared [emitEvidenceRefClass] once. */
|
|
1142
|
+
function moduleUsesEvidence(mod: DomainModule): boolean {
|
|
1143
|
+
return mod.aggregates.some((a) =>
|
|
1144
|
+
Object.values(a.fields as Record<string, Field>).some((f) => f.kind === "evidence"),
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** The ctor param for a non-`required` field: an optional scalar takes no default
|
|
1149
|
+
* (null), a collection takes an empty-collection default so it is never null. */
|
|
1150
|
+
function collectionDefaultParam(s: AggFieldSpec): string {
|
|
1151
|
+
switch (s.kind) {
|
|
1152
|
+
case "set":
|
|
1153
|
+
return `this.${s.name} = const <String>{},`;
|
|
1154
|
+
case "map":
|
|
1155
|
+
return isJsonValuedMap(s)
|
|
1156
|
+
? `this.${s.name} = const <String, Object?>{},`
|
|
1157
|
+
: `this.${s.name} = const <String, String>{},`;
|
|
1158
|
+
default:
|
|
1159
|
+
// An optional scalar — nullable, no default needed (defaults to null).
|
|
1160
|
+
return `this.${s.name},`;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** Expression that extracts one field's typed value from the FLAT, already-decoded
|
|
1165
|
+
* projection `data` map, via a tolerant flat-projection reader (`subscriptions.dart`).
|
|
1166
|
+
*
|
|
1167
|
+
* The projection has ALREADY decoded each kernel `Value` to bare JSON: a
|
|
1168
|
+
* `string`/`ref` field is a bare JSON string; an `int` is a number; a `set` is a
|
|
1169
|
+
* string array; a `map` is a `{k:v}` object (or a structural `Value::Map` form a
|
|
1170
|
+
* spike path may emit); an `enum` is its bare wire string; a `json` field is the
|
|
1171
|
+
* stored JSON which may have re-parsed into a container (re-stringified to keep the
|
|
1172
|
+
* aggregate's `String` type). */
|
|
1173
|
+
function parseField(s: AggFieldSpec, aggregate: string): string {
|
|
1174
|
+
// A `.optional()` scalar reads through the `…OrNull` tolerant reader (returns null
|
|
1175
|
+
// when the flat `data` lacks it — a freshly-folded aggregate need not have folded
|
|
1176
|
+
// it); a required scalar keeps the strict reader (absence throws). Collections read
|
|
1177
|
+
// through their empty-defaulting reader (absence ⇒ an empty collection).
|
|
1178
|
+
const opt = s.optional;
|
|
1179
|
+
switch (s.kind) {
|
|
1180
|
+
case "ref": {
|
|
1181
|
+
// A ref reads its id STRING, then wraps it via `.fromRow` (NOT the public
|
|
1182
|
+
// `.fromMinted`, which asserts the type tag). A ref may
|
|
1183
|
+
// legitimately point at a not-yet-minted aggregate whose id carries no
|
|
1184
|
+
// type tag; enforcing the tag is the CREATE gate's job, not a read's. When the
|
|
1185
|
+
// target type is unknown the field stays a bare `String`.
|
|
1186
|
+
const reader = opt
|
|
1187
|
+
? `readStrOrNull(data, '${s.wireName}')`
|
|
1188
|
+
: `readStr(data, '${aggregate}', '${s.wireName}')`;
|
|
1189
|
+
if (s.refIdClass === undefined) return reader;
|
|
1190
|
+
return opt
|
|
1191
|
+
? `() { final v = ${reader}; return v == null ? null : ${s.refIdClass}.fromRow(v); }()`
|
|
1192
|
+
: `${s.refIdClass}.fromRow(${reader})`;
|
|
1193
|
+
}
|
|
1194
|
+
case "string":
|
|
1195
|
+
if (s.valueClass !== undefined) {
|
|
1196
|
+
const reader = opt
|
|
1197
|
+
? `readStrOrNull(data, '${s.wireName}')`
|
|
1198
|
+
: `readStr(data, '${aggregate}', '${s.wireName}')`;
|
|
1199
|
+
return opt
|
|
1200
|
+
? `() { final v = ${reader}; return v == null ? null : ${s.valueClass}.fromRow(v); }()`
|
|
1201
|
+
: `${s.valueClass}.fromRow(${reader})`;
|
|
1202
|
+
}
|
|
1203
|
+
return opt
|
|
1204
|
+
? `readStrOrNull(data, '${s.wireName}')`
|
|
1205
|
+
: `readStr(data, '${aggregate}', '${s.wireName}')`;
|
|
1206
|
+
case "json":
|
|
1207
|
+
// A `json`-kind field is typed `String` on the aggregate; the projection may
|
|
1208
|
+
// have parsed its stored JSON into a container — re-stringify to keep `String`.
|
|
1209
|
+
return opt
|
|
1210
|
+
? `readJsonStrOrNull(data, '${s.wireName}')`
|
|
1211
|
+
: `readJsonStr(data, '${aggregate}', '${s.wireName}')`;
|
|
1212
|
+
case "int":
|
|
1213
|
+
return opt
|
|
1214
|
+
? `readIntOrNull(data, '${aggregate}', '${s.wireName}')`
|
|
1215
|
+
: `readInt(data, '${aggregate}', '${s.wireName}')`;
|
|
1216
|
+
case "enum":
|
|
1217
|
+
return opt
|
|
1218
|
+
? `readEnumOrNull(data, '${aggregate}', '${s.wireName}', ${s.dartType}.fromWire)`
|
|
1219
|
+
: `readEnum(data, '${aggregate}', '${s.wireName}', ${s.dartType}.fromWire)`;
|
|
1220
|
+
case "evidence":
|
|
1221
|
+
// An EvidenceRef reads the read engine's CLEAN decoded object
|
|
1222
|
+
// (`{hash, mediaType, byteLength, storageClass}`) via the same `readJsonObject`
|
|
1223
|
+
// reader a `t.jsonObject()` leaf uses, then wraps it in the typed `EvidenceRef`.
|
|
1224
|
+
return opt
|
|
1225
|
+
? `() { final m = readJsonObjectOrNull(data, '${aggregate}', '${s.wireName}'); return m == null ? null : EvidenceRef.fromProjected(m); }()`
|
|
1226
|
+
: `EvidenceRef.fromProjected(readJsonObject(data, '${aggregate}', '${s.wireName}'))`;
|
|
1227
|
+
case "set":
|
|
1228
|
+
// Collections are always empty-defaulting (absent ⇒ empty), never required.
|
|
1229
|
+
return `readSetOrEmpty(data, '${aggregate}', '${s.wireName}')`;
|
|
1230
|
+
case "map":
|
|
1231
|
+
// A `json`-valued map decodes each entry value back to its OBJECT (so a
|
|
1232
|
+
// value-object map — e.g. `customFields`, each entry the canonical
|
|
1233
|
+
// `CustomField.toJson()` — round-trips losslessly); a string-valued map stays flat.
|
|
1234
|
+
return isJsonValuedMap(s)
|
|
1235
|
+
? `readJsonMapOrEmpty(data, '${aggregate}', '${s.wireName}')`
|
|
1236
|
+
: `readStrMapOrEmpty(data, '${aggregate}', '${s.wireName}')`;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// ---- generated read model class --------------------------------------------
|
|
1241
|
+
//
|
|
1242
|
+
// A typed read model class generated from aggregate DSL field declarations. It
|
|
1243
|
+
// decodes the flat `{type,id,data}` projection rows emitted by the read engine.
|
|
1244
|
+
|
|
1245
|
+
/** The projection class name for an aggregate wire type: strip the
|
|
1246
|
+
* trailing `RootAggregate`/`Aggregate` (the SAME stem the typed-id naming uses, so
|
|
1247
|
+
* `SiteRootAggregate` → `Site`) and append `ReadModel` (`SiteReadModel`,
|
|
1248
|
+
* `BuildingReadModel`, `ThingReadModel`, `TrackableThingReadModel`). Pure + deterministic
|
|
1249
|
+
* — the same wire type always yields the same name. */
|
|
1250
|
+
function readModelClassName(aggregateType: string): string {
|
|
1251
|
+
let stem = aggregateType;
|
|
1252
|
+
if (stem.endsWith("RootAggregate")) {
|
|
1253
|
+
stem = stem.slice(0, -"RootAggregate".length);
|
|
1254
|
+
} else if (stem.endsWith("Aggregate")) {
|
|
1255
|
+
stem = stem.slice(0, -"Aggregate".length);
|
|
1256
|
+
}
|
|
1257
|
+
return `${cap(stem === "" ? aggregateType : stem)}ReadModel`;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** The Dart type for one generated read-model field. */
|
|
1261
|
+
function readModelDartType(s: AggFieldSpec): string {
|
|
1262
|
+
switch (s.kind) {
|
|
1263
|
+
case "ref":
|
|
1264
|
+
return s.refIdClass ?? "String";
|
|
1265
|
+
case "json":
|
|
1266
|
+
// A `t.jsonObject()` leaf (`jsonShape:"object"`, e.g. `location`) is the DECODED
|
|
1267
|
+
// structured JSON OBJECT (`Map<String, Object?>`, strict). A plain `t.json()` leaf
|
|
1268
|
+
// has an UNKNOWN/non-object decoded shape (a double/bool/list authored as a JSON
|
|
1269
|
+
// leaf — the kernel `Value` is Str|Int only), so it is surfaced as the permissive
|
|
1270
|
+
// `Object?` (the read engine's decoded value as-is — never coerced/thrown).
|
|
1271
|
+
return isJsonObjectLeaf(s) ? "Map<String, Object?>" : "Object?";
|
|
1272
|
+
case "string":
|
|
1273
|
+
return s.valueClass ?? "String";
|
|
1274
|
+
case "int":
|
|
1275
|
+
return "int";
|
|
1276
|
+
case "enum":
|
|
1277
|
+
return s.dartType; // the generated enum type
|
|
1278
|
+
case "evidence":
|
|
1279
|
+
return "EvidenceRef"; // the shared typed content-addressed ref class
|
|
1280
|
+
case "set":
|
|
1281
|
+
return "Set<String>";
|
|
1282
|
+
case "map":
|
|
1283
|
+
return isJsonValuedMap(s) ? "Map<String, Object?>" : "Map<String, String>";
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/** The ctor param for a non-`required` projection field: an optional scalar takes no
|
|
1288
|
+
* default (null); a collection takes its empty-collection default. Mirrors
|
|
1289
|
+
* [collectionDefaultParam] but a `json` leaf's container is `Map<String, Object?>`. */
|
|
1290
|
+
function readModelDefaultParam(s: AggFieldSpec): string {
|
|
1291
|
+
switch (s.kind) {
|
|
1292
|
+
case "set":
|
|
1293
|
+
return `this.${s.name} = const <String>{},`;
|
|
1294
|
+
case "map":
|
|
1295
|
+
return isJsonValuedMap(s)
|
|
1296
|
+
? `this.${s.name} = const <String, Object?>{},`
|
|
1297
|
+
: `this.${s.name} = const <String, String>{},`;
|
|
1298
|
+
default:
|
|
1299
|
+
// An optional scalar, an optional OBJECT json leaf (nullable `Map`), OR a
|
|
1300
|
+
// permissive plain-json leaf (already-nullable `Object?`) — nullable, defaults to
|
|
1301
|
+
// null (no ctor default needed).
|
|
1302
|
+
return `this.${s.name},`;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/** Expression that extracts one field's typed value from the flat read-engine
|
|
1307
|
+
* `data` map via `subscriptions.dart` readers. */
|
|
1308
|
+
function parseReadModelField(s: AggFieldSpec, aggregate: string): string {
|
|
1309
|
+
const opt = s.optional;
|
|
1310
|
+
switch (s.kind) {
|
|
1311
|
+
case "ref": {
|
|
1312
|
+
const reader = opt
|
|
1313
|
+
? `readStrOrNull(data, '${s.wireName}')`
|
|
1314
|
+
: `readStr(data, '${aggregate}', '${s.wireName}')`;
|
|
1315
|
+
if (s.refIdClass === undefined) return reader;
|
|
1316
|
+
return opt
|
|
1317
|
+
? `() { final v = ${reader}; return v == null ? null : ${s.refIdClass}.fromRow(v); }()`
|
|
1318
|
+
: `${s.refIdClass}.fromRow(${reader})`;
|
|
1319
|
+
}
|
|
1320
|
+
case "string":
|
|
1321
|
+
if (s.valueClass !== undefined) {
|
|
1322
|
+
const reader = opt
|
|
1323
|
+
? `readStrOrNull(data, '${s.wireName}')`
|
|
1324
|
+
: `readStr(data, '${aggregate}', '${s.wireName}')`;
|
|
1325
|
+
return opt
|
|
1326
|
+
? `() { final v = ${reader}; return v == null ? null : ${s.valueClass}.fromRow(v); }()`
|
|
1327
|
+
: `${s.valueClass}.fromRow(${reader})`;
|
|
1328
|
+
}
|
|
1329
|
+
return opt
|
|
1330
|
+
? `readStrOrNull(data, '${s.wireName}')`
|
|
1331
|
+
: `readStr(data, '${aggregate}', '${s.wireName}')`;
|
|
1332
|
+
case "json":
|
|
1333
|
+
// An OBJECT-asserted leaf (`t.jsonObject()`, e.g. `location`): the read engine has
|
|
1334
|
+
// decoded the stored JSON into a structured object — surface it as a STRICT
|
|
1335
|
+
// `Map<String, Object?>` (a present-but-non-object value throws; the strict-decode
|
|
1336
|
+
// contract). A PERMISSIVE plain `t.json()` leaf has an unknown/non-object decoded
|
|
1337
|
+
// shape (a double/bool/list authored as a JSON leaf), so surface the read engine's
|
|
1338
|
+
// decoded value AS-IS via the NON-THROWING `readJsonValueOrNull` (`Object?`, null
|
|
1339
|
+
// when absent) — NEVER a strict-object reader that would throw on a legit non-object.
|
|
1340
|
+
if (isPermissiveJsonLeaf(s)) {
|
|
1341
|
+
return `readJsonValueOrNull(data, '${s.wireName}')`;
|
|
1342
|
+
}
|
|
1343
|
+
return opt
|
|
1344
|
+
? `readJsonObjectOrNull(data, '${aggregate}', '${s.wireName}')`
|
|
1345
|
+
: `readJsonObject(data, '${aggregate}', '${s.wireName}')`;
|
|
1346
|
+
case "int":
|
|
1347
|
+
return opt
|
|
1348
|
+
? `readIntOrNull(data, '${aggregate}', '${s.wireName}')`
|
|
1349
|
+
: `readInt(data, '${aggregate}', '${s.wireName}')`;
|
|
1350
|
+
case "enum":
|
|
1351
|
+
return opt
|
|
1352
|
+
? `readEnumOrNull(data, '${aggregate}', '${s.wireName}', ${s.dartType}.fromWire)`
|
|
1353
|
+
: `readEnum(data, '${aggregate}', '${s.wireName}', ${s.dartType}.fromWire)`;
|
|
1354
|
+
case "evidence":
|
|
1355
|
+
// Same clean-object read + typed wrap as the aggregate class.
|
|
1356
|
+
return opt
|
|
1357
|
+
? `() { final m = readJsonObjectOrNull(data, '${aggregate}', '${s.wireName}'); return m == null ? null : EvidenceRef.fromProjected(m); }()`
|
|
1358
|
+
: `EvidenceRef.fromProjected(readJsonObject(data, '${aggregate}', '${s.wireName}'))`;
|
|
1359
|
+
case "set":
|
|
1360
|
+
return `readSetOrEmpty(data, '${aggregate}', '${s.wireName}')`;
|
|
1361
|
+
case "map":
|
|
1362
|
+
return isJsonValuedMap(s)
|
|
1363
|
+
? `readJsonMapOrEmpty(data, '${aggregate}', '${s.wireName}')`
|
|
1364
|
+
: `readStrMapOrEmpty(data, '${aggregate}', '${s.wireName}')`;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/** Emit the generated typed read-model class for one aggregate. The class
|
|
1369
|
+
* (`SiteReadModel`, …) carries the kernel aggregate [aggregateId] (threaded from the
|
|
1370
|
+
* `{type,id,data}` row id like the aggregate class) plus a DSL-named typed field per DSL
|
|
1371
|
+
* field declaration, and a [fromProjected] factory that reads the read engine's
|
|
1372
|
+
* already-decoded projected `data` shape. */
|
|
1373
|
+
function emitReadModel(
|
|
1374
|
+
agg: AggregateHandle,
|
|
1375
|
+
enumDartTypes: Map<string, string>,
|
|
1376
|
+
deriveds: DerivedDecl[] = [],
|
|
1377
|
+
combineds: CombinedDecl[] = [],
|
|
1378
|
+
refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
|
|
1379
|
+
ownIdFields: ReadonlySet<string> = new Set(),
|
|
1380
|
+
): string {
|
|
1381
|
+
const className = readModelClassName(agg.id);
|
|
1382
|
+
const idType = aggregateIdDartType(agg.id);
|
|
1383
|
+
const wireType = agg.id;
|
|
1384
|
+
const specs = aggregateFieldSpecs(
|
|
1385
|
+
agg,
|
|
1386
|
+
enumDartTypes,
|
|
1387
|
+
refIdClassForAggregate,
|
|
1388
|
+
ownIdFields,
|
|
1389
|
+
);
|
|
1390
|
+
|
|
1391
|
+
// DERIVED/COMBINED read fields are engine-projected JSON leaves. Their value schema is
|
|
1392
|
+
// declared in the DSL via `.returns(z...)`, so the Dart surface is typed instead of
|
|
1393
|
+
// `Object?`. The fields stay nullable because a freshly mounted projection can be
|
|
1394
|
+
// observed before the engine pass has materialised them.
|
|
1395
|
+
|
|
1396
|
+
// Field decls: the kernel aggregate id first (threaded from the row id, like the
|
|
1397
|
+
// aggregate class), then each DSL-named domain field. An optional scalar (incl. an
|
|
1398
|
+
// optional json-object leaf) is nullable; a PERMISSIVE json leaf is already the
|
|
1399
|
+
// nullable `Object?` (no extra `?`); a collection keeps its non-null container.
|
|
1400
|
+
const fieldDecls = [
|
|
1401
|
+
` /// The kernel aggregate id (the \`{type,id,data}\` row's \`id\`, e.g. the siteId).`,
|
|
1402
|
+
` /// Empty string only when built from a bare \`data\` map with no row id (a unit test).`,
|
|
1403
|
+
` final ${idType} aggregateId;`,
|
|
1404
|
+
...specs.map(
|
|
1405
|
+
(s) => ` final ${readModelDartType(s)}${readModelNullableSuffix(s)} ${s.name};`,
|
|
1406
|
+
),
|
|
1407
|
+
...deriveds.map(
|
|
1408
|
+
(d) =>
|
|
1409
|
+
` /// Engine-projected DERIVED read field (pure fn of the folded fields; NOT kernel state).\n final ${projectionReturnDartType(d.returns)} ${d.id};`,
|
|
1410
|
+
),
|
|
1411
|
+
...combineds.map(
|
|
1412
|
+
(c) =>
|
|
1413
|
+
` /// Engine-projected COMBINED read field (JOIN across aggregates; NOT kernel state).\n final ${projectionReturnDartType(c.returns)} ${c.id};`,
|
|
1414
|
+
),
|
|
1415
|
+
].join("\n");
|
|
1416
|
+
const ctorParams = [
|
|
1417
|
+
` this.aggregateId = const ${idType}.fromRow(''),`,
|
|
1418
|
+
...specs.map((s) =>
|
|
1419
|
+
// A required field is `required`; an optional scalar, a collection, OR a
|
|
1420
|
+
// permissive json leaf (already nullable `Object?`) takes its default/null.
|
|
1421
|
+
s.optional || isCollectionKind(s.kind) || isPermissiveJsonLeaf(s)
|
|
1422
|
+
? ` ${readModelDefaultParam(s)}`
|
|
1423
|
+
: ` required this.${s.name},`,
|
|
1424
|
+
),
|
|
1425
|
+
// Derived/combined fields are always nullable (null until the engine pass runs).
|
|
1426
|
+
...deriveds.map((d) => ` this.${d.id},`),
|
|
1427
|
+
...combineds.map((c) => ` this.${c.id},`),
|
|
1428
|
+
].join("\n");
|
|
1429
|
+
|
|
1430
|
+
const parseLines = [
|
|
1431
|
+
` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
|
|
1432
|
+
...specs.map((s) => ` ${s.name}: ${parseReadModelField(s, wireType)},`),
|
|
1433
|
+
// Derived/combined fields decode from the engine-produced bare JSON leaf.
|
|
1434
|
+
...deriveds.map((d) => ` ${d.id}: ${parseProjectionReturnField(d.id, d.returns)},`),
|
|
1435
|
+
...combineds.map((c) => ` ${c.id}: ${parseProjectionReturnField(c.id, c.returns)},`),
|
|
1436
|
+
].join("\n");
|
|
1437
|
+
|
|
1438
|
+
return [
|
|
1439
|
+
`/// Generated read model for '${agg.id}'.`,
|
|
1440
|
+
`///`,
|
|
1441
|
+
`/// Deserialized via [${className}.fromProjected] from the Rust read engine's already-`,
|
|
1442
|
+
`/// decoded projected \`data\` map (the \`{type,id,data}\` row's \`data\`). Fields are`,
|
|
1443
|
+
`/// DSL-named; a \`json\` value-object leaf (e.g. \`location\`)`,
|
|
1444
|
+
`/// is a STRUCTURED \`Map<String, Object?>\` — the decoded shape the engine emits, NOT a`,
|
|
1445
|
+
`/// JSON string. This class is fully DSL-derived.`,
|
|
1446
|
+
`/// Collection fields (\`set\`/\`map\`) default to EMPTY and \`.optional()\` scalars to`,
|
|
1447
|
+
`/// \`null\` when a freshly-folded aggregate has not yet folded them.`,
|
|
1448
|
+
`class ${className} {`,
|
|
1449
|
+
fieldDecls,
|
|
1450
|
+
``,
|
|
1451
|
+
` const ${className}({`,
|
|
1452
|
+
ctorParams,
|
|
1453
|
+
` });`,
|
|
1454
|
+
``,
|
|
1455
|
+
` /// Parse from the Rust read engine's already-decoded projected \`data\` map (the`,
|
|
1456
|
+
` /// \`{type,id,data}\` row's \`data\` that \`query()\`/\`query_by_id()\` emit — every field`,
|
|
1457
|
+
` /// already decoded to bare/structured JSON), carrying the kernel aggregate [id] from`,
|
|
1458
|
+
` /// the row \`id\` (defaults to '' for a bare-map call). Missing REQUIRED fields throw a`,
|
|
1459
|
+
` /// [FormatException]; absent collections decode empty and absent \`.optional()\` scalars`,
|
|
1460
|
+
` /// decode \`null\`.`,
|
|
1461
|
+
` factory ${className}.fromProjected(Map<String, dynamic> data, [String id = '']) {`,
|
|
1462
|
+
` return ${className}(`,
|
|
1463
|
+
parseLines,
|
|
1464
|
+
` );`,
|
|
1465
|
+
` }`,
|
|
1466
|
+
`}`,
|
|
1467
|
+
].join("\n");
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// ---- public intent payload class -------------------------------------------
|
|
1471
|
+
|
|
1472
|
+
function emitDirective(
|
|
1473
|
+
d: AnyDirective,
|
|
1474
|
+
domainName: string,
|
|
1475
|
+
enumDartTypes: Map<string, string>,
|
|
1476
|
+
): string {
|
|
1477
|
+
const className = `${cap(d.id)}Payload`;
|
|
1478
|
+
const specs = introspectPayload(d.payloadSchema as z.ZodTypeAny, enumDartTypes);
|
|
1479
|
+
|
|
1480
|
+
// Field decls: optional payload fields are nullable Dart fields (omitted from the
|
|
1481
|
+
// payload JSON when null); required fields are non-null + `required` in the ctor.
|
|
1482
|
+
const fieldDecls = specs
|
|
1483
|
+
.map((s) => ` final ${s.dartType}${s.optional ? "?" : ""} ${s.name};`)
|
|
1484
|
+
.join("\n");
|
|
1485
|
+
const ctorParams = specs
|
|
1486
|
+
.map((s) => ` ${s.optional ? "" : "required "}this.${s.name},`)
|
|
1487
|
+
.join("\n");
|
|
1488
|
+
|
|
1489
|
+
// toPayloadJson: serialise only typed fields to data. No Event, no Hlc; the
|
|
1490
|
+
// GitHolon executes the installed domain plan. Optional
|
|
1491
|
+
// fields are written only when present (mirrors the engine's Zod: ops are emitted
|
|
1492
|
+
// only for present fields).
|
|
1493
|
+
const requiredEntries = specs
|
|
1494
|
+
.filter((s) => !s.optional)
|
|
1495
|
+
.map((s) => ` '${s.name}': ${toJsonExpr(s, s.name)},`);
|
|
1496
|
+
const optionalLines = specs
|
|
1497
|
+
.filter((s) => s.optional)
|
|
1498
|
+
.map(
|
|
1499
|
+
(s) =>
|
|
1500
|
+
` if (${s.name} != null) '${s.name}': ${toJsonExpr(s, `${s.name}!`)},`,
|
|
1501
|
+
);
|
|
1502
|
+
|
|
1503
|
+
const docFirst = directiveDoc(d);
|
|
1504
|
+
|
|
1505
|
+
return [
|
|
1506
|
+
`/// ${docFirst}`,
|
|
1507
|
+
`///`,
|
|
1508
|
+
`/// Generated typed intent DTO for '${d.id}' (marker: ${d.marker},`,
|
|
1509
|
+
`/// aggregate: ${d.aggregateId}). It carries its [domain] + [intentType] and a`,
|
|
1510
|
+
`/// [toPayloadJson] that ships only typed fields as data. It builds no`,
|
|
1511
|
+
`/// \`Event\` and no \`Hlc\`. Pass it to [dispatch]; the GitHolon executes`,
|
|
1512
|
+
`/// the installed domain plan.`,
|
|
1513
|
+
`class ${className} implements NomosIntent {`,
|
|
1514
|
+
` /// The policy domain this intent belongs to (the kernel \`author\` \`domain\`).`,
|
|
1515
|
+
` static const String domainName = '${domainName}';`,
|
|
1516
|
+
` /// The public intent type within [domainName] — never hand-typed at the call site.`,
|
|
1517
|
+
` static const String intentTypeName = '${d.id}';`,
|
|
1518
|
+
` /// The aggregate this intent targets (provenance only; the engine binds the instance).`,
|
|
1519
|
+
` static const String aggregateId = '${d.aggregateId}';`,
|
|
1520
|
+
` /// The referential marker (creates/mutates/ensures/archives).`,
|
|
1521
|
+
` static const String marker = '${d.marker}';`,
|
|
1522
|
+
``,
|
|
1523
|
+
fieldDecls,
|
|
1524
|
+
``,
|
|
1525
|
+
` /// ${docFirst}`,
|
|
1526
|
+
` const ${className}({`,
|
|
1527
|
+
ctorParams,
|
|
1528
|
+
` });`,
|
|
1529
|
+
``,
|
|
1530
|
+
` @override`,
|
|
1531
|
+
` String get domain => domainName;`,
|
|
1532
|
+
``,
|
|
1533
|
+
` @override`,
|
|
1534
|
+
` String get intentType => intentTypeName;`,
|
|
1535
|
+
``,
|
|
1536
|
+
` /// Serialise this payload to the intent DATA JSON (the shape the`,
|
|
1537
|
+
` /// domain Zod schema validates inside the engine). Optional fields are`,
|
|
1538
|
+
` /// omitted when null.`,
|
|
1539
|
+
` @override`,
|
|
1540
|
+
` Map<String, Object?> toPayloadJson() => <String, Object?>{`,
|
|
1541
|
+
...requiredEntries,
|
|
1542
|
+
...optionalLines,
|
|
1543
|
+
` };`,
|
|
1544
|
+
`}`,
|
|
1545
|
+
].join("\n");
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/** Expression that serialises ONE typed payload field into its JSON value. */
|
|
1549
|
+
export function toJsonExpr(spec: PayloadFieldSpec, accessor: string): string {
|
|
1550
|
+
switch (spec.toJson) {
|
|
1551
|
+
case "scalar":
|
|
1552
|
+
case "strList":
|
|
1553
|
+
case "json":
|
|
1554
|
+
return accessor;
|
|
1555
|
+
case "enum":
|
|
1556
|
+
return `${accessor}.wire`;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// ---- read accessors (typed subscriptions) ----------------------------------
|
|
1561
|
+
//
|
|
1562
|
+
// The READ half of the peer dispatch surface (Jack 2026-06-01): the app reads ONLY via
|
|
1563
|
+
// these generated typed accessors over the GitHolon read surface
|
|
1564
|
+
// (`query(query_id, params_json)` + `query_by_id(aggregate_id)`, #140's indexed/PK
|
|
1565
|
+
// read-closure). It invents NO new Rust read primitive. Two emitters:
|
|
1566
|
+
// * per declared `QueryDecl` → a typed indexed `watch…`/`read…` pair (the
|
|
1567
|
+
// INDEXED, O(matches) reads — `watchSites`, `watchThings`, …);
|
|
1568
|
+
// * per aggregate → a typed by-id `watch…ById`/`read…ById` pair off the PK
|
|
1569
|
+
// `query_by_id` (the single-aggregate reads — `watchSite`, `watchThing`, …).
|
|
1570
|
+
// Every accessor hangs off the one [NomosReads] the app threads in, and carries a
|
|
1571
|
+
// `///` hover-doc (the same self-describing mechanism the directive payloads use).
|
|
1572
|
+
|
|
1573
|
+
/** A Dart-safe method-name fragment from a query id (e.g. `sitesByOwner`). The id
|
|
1574
|
+
* is already a lowerCamel identifier by convention; reserved-word-guarded for safety. */
|
|
1575
|
+
function readMethodName(queryId: string): string {
|
|
1576
|
+
return DART_RESERVED.has(queryId) ? `${queryId}_` : queryId;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** A Dart-safe PARAM identifier from an index-key field, which may be a NESTED
|
|
1580
|
+
* dotted path. Folds `a.b.c` → `aBC`-style lowerCamel (drops the dots, capitalising
|
|
1581
|
+
* each following segment) so it is a legal Dart parameter name; reserved-word-guarded. */
|
|
1582
|
+
function dartParamName(keyField: string): string {
|
|
1583
|
+
const parts = keyField.split(".").filter((p) => p !== "");
|
|
1584
|
+
const camel =
|
|
1585
|
+
parts.length === 0
|
|
1586
|
+
? "key"
|
|
1587
|
+
: parts[0] + parts.slice(1).map((p) => cap(p)).join("");
|
|
1588
|
+
const cleaned = camel.replace(/[^A-Za-z0-9_]/g, "_");
|
|
1589
|
+
const safe = /^[0-9]/.test(cleaned) ? `v${cleaned}` : cleaned;
|
|
1590
|
+
return DART_RESERVED.has(safe) ? `${safe}_` : safe;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/** Emit the typed accessor PAIR for ONE declared query: a reactive `watch<Id>` →
|
|
1594
|
+
* `Stream<List<Agg>>` and a one-shot `read<Id>` → `List<Agg>`. The query's single
|
|
1595
|
+
* index-key field becomes a `String` param (every registered query keys on an
|
|
1596
|
+
* id/ref string). Both call through the [NomosReads] `query`/`watchQuery` seam with
|
|
1597
|
+
* the query id + `{keyField: value}` params + the aggregate's flat `fromJson`. */
|
|
1598
|
+
function emitQueryReads(
|
|
1599
|
+
q: QueryDecl,
|
|
1600
|
+
aggregatesById: Map<string, AggregateHandle>,
|
|
1601
|
+
enumDartTypes: Map<string, string>,
|
|
1602
|
+
refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
|
|
1603
|
+
ownIdFieldsByAggregate: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
|
|
1604
|
+
): string {
|
|
1605
|
+
const agg = q.returns; // the aggregate TYPE id (== the generated class name).
|
|
1606
|
+
const method = readMethodName(q.id);
|
|
1607
|
+
const Method = cap(method);
|
|
1608
|
+
// The query registry keys every query on EXACTLY ONE string field; take it as
|
|
1609
|
+
// the param. (A multi-key index is a later step — see the gaps in the report.)
|
|
1610
|
+
const keyField = q.key[0] ?? "key";
|
|
1611
|
+
// The index-key field may be a NESTED DOTTED PATH (e.g.
|
|
1612
|
+
// `structuralAssignment.targetKey`) — that is the JSON key the index probe
|
|
1613
|
+
// wants, but NOT a legal Dart identifier. Sanitize it to a camelCase PARAM name
|
|
1614
|
+
// (`structuralAssignmentTargetKey`) while keeping the dotted path as the
|
|
1615
|
+
// JSON key the params map ships.
|
|
1616
|
+
const paramName = dartParamName(keyField);
|
|
1617
|
+
const keySpec = readKeyParamSpec(
|
|
1618
|
+
paramName,
|
|
1619
|
+
aggregateFieldSpecForKey(
|
|
1620
|
+
aggregatesById,
|
|
1621
|
+
agg,
|
|
1622
|
+
keyField,
|
|
1623
|
+
enumDartTypes,
|
|
1624
|
+
refIdClassForAggregate,
|
|
1625
|
+
ownIdFieldsByAggregate,
|
|
1626
|
+
),
|
|
1627
|
+
);
|
|
1628
|
+
const params = `'${keyField}': ${keySpec.valueExpr}`;
|
|
1629
|
+
const keyParam = `${keySpec.dartType} ${paramName}`;
|
|
1630
|
+
const words = q.id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
1631
|
+
return [
|
|
1632
|
+
`/// Reactive typed read: every \`${agg}\` matching the indexed \`${q.id}\` query`,
|
|
1633
|
+
`/// (${words}), keyed on \`${keyField}\`. Emits the current matches, then re-emits`,
|
|
1634
|
+
`/// on each projection tick. O(matches) via the projection index (#140) — never a`,
|
|
1635
|
+
`/// workspace scan.`,
|
|
1636
|
+
`Stream<List<${agg}>> watch${Method}(NomosReads reads, ${keyParam}) =>`,
|
|
1637
|
+
` reads.watchQuery('${q.id}', {${params}}, ${agg}.fromJson);`,
|
|
1638
|
+
``,
|
|
1639
|
+
`/// One-shot typed read of the \`${q.id}\` query (${words}): the CURRENT matching`,
|
|
1640
|
+
`/// \`${agg}\` list, keyed on \`${keyField}\`. O(matches).`,
|
|
1641
|
+
`List<${agg}> read${Method}(NomosReads reads, ${keyParam}) =>`,
|
|
1642
|
+
` reads.readQuery('${q.id}', {${params}}, ${agg}.fromJson);`,
|
|
1643
|
+
].join("\n");
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/** Emit the typed by-id accessor PAIR for ONE aggregate: a reactive
|
|
1647
|
+
* `watch<Agg>ById` → `Stream<Agg?>` and a one-shot `read<Agg>ById` → `Agg?`, both
|
|
1648
|
+
* off the PK `query_by_id` seam (resolves the unbound singleton by its type-key AND
|
|
1649
|
+
* an instance-bound aggregate by its id). */
|
|
1650
|
+
function emitAggregateByIdReads(agg: AggregateHandle): string {
|
|
1651
|
+
const cls = agg.id;
|
|
1652
|
+
const idType = aggregateIdDartType(agg.id);
|
|
1653
|
+
return [
|
|
1654
|
+
`/// Reactive typed by-id read: the \`${cls}\` with kernel aggregate id [id]`,
|
|
1655
|
+
`/// (\`null\` until/unless it has been folded). Emits the current value, then`,
|
|
1656
|
+
`/// re-emits on each projection tick. Primary-key lookup (#140) — O(1).`,
|
|
1657
|
+
`Stream<${cls}?> watch${cls}ById(NomosReads reads, ${idType} id) =>`,
|
|
1658
|
+
` reads.watchById(id.value, ${cls}.fromJson);`,
|
|
1659
|
+
``,
|
|
1660
|
+
`/// One-shot typed by-id read: the CURRENT \`${cls}\` with kernel aggregate id`,
|
|
1661
|
+
`/// [id], or \`null\` when no such aggregate has been folded. O(1).`,
|
|
1662
|
+
`${cls}? read${cls}ById(NomosReads reads, ${idType} id) =>`,
|
|
1663
|
+
` reads.readById(id.value, ${cls}.fromJson);`,
|
|
1664
|
+
].join("\n");
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// ---- maintained count/sum accessors (Slice 1: .where predicated count + sum) ----
|
|
1668
|
+
//
|
|
1669
|
+
// The read engine maintains an O(1)-lookup `counts` table and (Slice 1) a `sums` table
|
|
1670
|
+
// from the declarations shipped in the runtime manifest. These emitters produce TYPED
|
|
1671
|
+
// `watchCount`/`readCount` and `watchSum`/`readSum` Dart accessors — a `Stream<int>`
|
|
1672
|
+
// and an `int`, never a query, never SQL. The predicate is INVISIBLE to the accessor:
|
|
1673
|
+
// it is baked into the maintained counter. The dev reads `watchPublishedCount(reads,
|
|
1674
|
+
// catalogueId)` and never sees a `where`-clause (the worldview law: domain devs must
|
|
1675
|
+
// NOT understand Nomos internals).
|
|
1676
|
+
//
|
|
1677
|
+
// Note that the accessor is GROUPED (takes a `groupKey` String) when the count/sum
|
|
1678
|
+
// declares a `.by(...)` field, and GROUP-KEY-FREE (no param) for a grand total. The
|
|
1679
|
+
// grand-total variant passes the empty string `''` as the group key (the read engine
|
|
1680
|
+
// uses `''` as the synthetic grand-total group key, matching `count_group_key`'s
|
|
1681
|
+
// `None → Some(String::new())` arm in Rust).
|
|
1682
|
+
|
|
1683
|
+
/** Emit the typed accessor PAIR for ONE declared count (read-engine maintained counter):
|
|
1684
|
+
* a reactive `watch<Id>` → `Stream<int>` and a one-shot `read<Id>` → `int`. Both
|
|
1685
|
+
* call through the [NomosReads] `watchCount`/`readCount` seam with the count id and
|
|
1686
|
+
* (if grouped) the group key. The predicate is INVISIBLE (baked into the maintained
|
|
1687
|
+
* counter). O(1) lookup — never a scan. */
|
|
1688
|
+
function emitCountReads(
|
|
1689
|
+
c: ReturnType<typeof finishCount>,
|
|
1690
|
+
aggregatesById: Map<string, AggregateHandle>,
|
|
1691
|
+
enumDartTypes: Map<string, string>,
|
|
1692
|
+
refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
|
|
1693
|
+
ownIdFieldsByAggregate: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
|
|
1694
|
+
): string {
|
|
1695
|
+
const method = readMethodName(c.id);
|
|
1696
|
+
const Method = cap(method);
|
|
1697
|
+
const words = c.id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
1698
|
+
const byDesc = c.by !== undefined ? `, grouped by \`${c.by}\`` : " (grand total)";
|
|
1699
|
+
if (c.by !== undefined) {
|
|
1700
|
+
const groupSpec = readKeyParamSpec(
|
|
1701
|
+
"groupKey",
|
|
1702
|
+
aggregateFieldSpecForKey(
|
|
1703
|
+
aggregatesById,
|
|
1704
|
+
c.of,
|
|
1705
|
+
c.by,
|
|
1706
|
+
enumDartTypes,
|
|
1707
|
+
refIdClassForAggregate,
|
|
1708
|
+
ownIdFieldsByAggregate,
|
|
1709
|
+
),
|
|
1710
|
+
);
|
|
1711
|
+
return [
|
|
1712
|
+
`/// Reactive maintained count \`${c.id}\` (${words}${byDesc}). O(1) lookup`,
|
|
1713
|
+
`/// (read-engine maintained counter — never a scan). Emits the current count,`,
|
|
1714
|
+
`/// then re-emits on each projection tick. The predicate is baked in.`,
|
|
1715
|
+
`Stream<int> watch${Method}(NomosReads reads, ${groupSpec.dartType} groupKey) =>`,
|
|
1716
|
+
` reads.watchCount('${c.id}', ${groupSpec.valueExpr});`,
|
|
1717
|
+
``,
|
|
1718
|
+
`/// One-shot maintained count \`${c.id}\` (${words}), keyed on [groupKey]. O(1).`,
|
|
1719
|
+
`int read${Method}(NomosReads reads, ${groupSpec.dartType} groupKey) =>`,
|
|
1720
|
+
` reads.readCount('${c.id}', ${groupSpec.valueExpr});`,
|
|
1721
|
+
].join("\n");
|
|
1722
|
+
}
|
|
1723
|
+
// Grand total — no groupKey param; pass '' (the synthetic grand-total group key).
|
|
1724
|
+
return [
|
|
1725
|
+
`/// Reactive maintained count \`${c.id}\` (${words}${byDesc}). O(1) lookup`,
|
|
1726
|
+
`/// (read-engine maintained counter — never a scan). Emits the current count,`,
|
|
1727
|
+
`/// then re-emits on each projection tick. The predicate is baked in.`,
|
|
1728
|
+
`Stream<int> watch${Method}(NomosReads reads) =>`,
|
|
1729
|
+
` reads.watchCount('${c.id}', '');`,
|
|
1730
|
+
``,
|
|
1731
|
+
`/// One-shot maintained count \`${c.id}\` (${words}) — grand total. O(1).`,
|
|
1732
|
+
`int read${Method}(NomosReads reads) =>`,
|
|
1733
|
+
` reads.readCount('${c.id}', '');`,
|
|
1734
|
+
].join("\n");
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/** Emit the typed accessor PAIR for ONE declared sum (read-engine maintained total):
|
|
1738
|
+
* a reactive `watch<Id>` → `Stream<int>` and a one-shot `read<Id>` → `int`. Mirrors
|
|
1739
|
+
* `emitCountReads` exactly — only the seam name (`watchSum`/`readSum`) differs. */
|
|
1740
|
+
function emitSumReads(
|
|
1741
|
+
s: ReturnType<typeof finishSum>,
|
|
1742
|
+
aggregatesById: Map<string, AggregateHandle>,
|
|
1743
|
+
enumDartTypes: Map<string, string>,
|
|
1744
|
+
refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
|
|
1745
|
+
ownIdFieldsByAggregate: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
|
|
1746
|
+
): string {
|
|
1747
|
+
const method = readMethodName(s.id);
|
|
1748
|
+
const Method = cap(method);
|
|
1749
|
+
const words = s.id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
1750
|
+
const byDesc = s.by !== undefined ? `, grouped by \`${s.by}\`` : " (grand total)";
|
|
1751
|
+
if (s.by !== undefined) {
|
|
1752
|
+
const groupSpec = readKeyParamSpec(
|
|
1753
|
+
"groupKey",
|
|
1754
|
+
aggregateFieldSpecForKey(
|
|
1755
|
+
aggregatesById,
|
|
1756
|
+
s.of,
|
|
1757
|
+
s.by,
|
|
1758
|
+
enumDartTypes,
|
|
1759
|
+
refIdClassForAggregate,
|
|
1760
|
+
ownIdFieldsByAggregate,
|
|
1761
|
+
),
|
|
1762
|
+
);
|
|
1763
|
+
return [
|
|
1764
|
+
`/// Reactive maintained sum \`${s.id}\` (${words}${byDesc}). O(1) lookup`,
|
|
1765
|
+
`/// (read-engine maintained total — never a scan). Emits the current sum,`,
|
|
1766
|
+
`/// then re-emits on each projection tick. The predicate is baked in.`,
|
|
1767
|
+
`Stream<int> watch${Method}(NomosReads reads, ${groupSpec.dartType} groupKey) =>`,
|
|
1768
|
+
` reads.watchSum('${s.id}', ${groupSpec.valueExpr});`,
|
|
1769
|
+
``,
|
|
1770
|
+
`/// One-shot maintained sum \`${s.id}\` (${words}), keyed on [groupKey]. O(1).`,
|
|
1771
|
+
`int read${Method}(NomosReads reads, ${groupSpec.dartType} groupKey) =>`,
|
|
1772
|
+
` reads.readSum('${s.id}', ${groupSpec.valueExpr});`,
|
|
1773
|
+
].join("\n");
|
|
1774
|
+
}
|
|
1775
|
+
return [
|
|
1776
|
+
`/// Reactive maintained sum \`${s.id}\` (${words}${byDesc}). O(1) lookup`,
|
|
1777
|
+
`/// (read-engine maintained total — never a scan). Emits the current sum,`,
|
|
1778
|
+
`/// then re-emits on each projection tick. The predicate is baked in.`,
|
|
1779
|
+
`Stream<int> watch${Method}(NomosReads reads) =>`,
|
|
1780
|
+
` reads.watchSum('${s.id}', '');`,
|
|
1781
|
+
``,
|
|
1782
|
+
`/// One-shot maintained sum \`${s.id}\` (${words}) — grand total. O(1).`,
|
|
1783
|
+
`int read${Method}(NomosReads reads) =>`,
|
|
1784
|
+
` reads.readSum('${s.id}', '');`,
|
|
1785
|
+
].join("\n");
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/** Emit the typed accessor PAIR for ONE declared min (read-engine maintained minimum):
|
|
1789
|
+
* a reactive `watch<Id>` → `Stream<int?>` and a one-shot `read<Id>` → `int?`.
|
|
1790
|
+
* Returns `int?` (nullable): an empty group has NO minimum, and `0` is a legitimate
|
|
1791
|
+
* minimum value — collapsing empty-group to `0` would be the sum-of-0 ambiguity. Mirrors
|
|
1792
|
+
* `emitSumReads` exactly, differing only in seam name (`watchMin`/`readMin`) and return
|
|
1793
|
+
* type (`int?` vs `int`). The predicate is INVISIBLE (baked into the maintained minimum). */
|
|
1794
|
+
function emitMinReads(
|
|
1795
|
+
e: ReturnType<typeof finishExtremum>,
|
|
1796
|
+
aggregatesById: Map<string, AggregateHandle>,
|
|
1797
|
+
enumDartTypes: Map<string, string>,
|
|
1798
|
+
refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
|
|
1799
|
+
ownIdFieldsByAggregate: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
|
|
1800
|
+
): string {
|
|
1801
|
+
const method = readMethodName(e.id);
|
|
1802
|
+
const Method = cap(method);
|
|
1803
|
+
const words = e.id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
1804
|
+
const byDesc = e.by !== undefined ? `, grouped by \`${e.by}\`` : " (grand total)";
|
|
1805
|
+
if (e.by !== undefined) {
|
|
1806
|
+
const groupSpec = readKeyParamSpec(
|
|
1807
|
+
"groupKey",
|
|
1808
|
+
aggregateFieldSpecForKey(
|
|
1809
|
+
aggregatesById,
|
|
1810
|
+
e.of,
|
|
1811
|
+
e.by,
|
|
1812
|
+
enumDartTypes,
|
|
1813
|
+
refIdClassForAggregate,
|
|
1814
|
+
ownIdFieldsByAggregate,
|
|
1815
|
+
),
|
|
1816
|
+
);
|
|
1817
|
+
return [
|
|
1818
|
+
`/// Reactive maintained minimum \`${e.id}\` (${words}${byDesc}). O(1) lookup`,
|
|
1819
|
+
`/// (read-engine maintained minimum — never a scan). Emits the current minimum`,
|
|
1820
|
+
`/// (null when no contributing member), then re-emits on each projection tick.`,
|
|
1821
|
+
`/// The predicate is baked in. null = empty group (0 is a legitimate minimum).`,
|
|
1822
|
+
`Stream<int?> watch${Method}(NomosReads reads, ${groupSpec.dartType} groupKey) =>`,
|
|
1823
|
+
` reads.watchMin('${e.id}', ${groupSpec.valueExpr});`,
|
|
1824
|
+
``,
|
|
1825
|
+
`/// One-shot maintained minimum \`${e.id}\` (${words}), keyed on [groupKey]. O(1).`,
|
|
1826
|
+
`/// Returns null when no contributing member exists in the group.`,
|
|
1827
|
+
`int? read${Method}(NomosReads reads, ${groupSpec.dartType} groupKey) =>`,
|
|
1828
|
+
` reads.readMin('${e.id}', ${groupSpec.valueExpr});`,
|
|
1829
|
+
].join("\n");
|
|
1830
|
+
}
|
|
1831
|
+
return [
|
|
1832
|
+
`/// Reactive maintained minimum \`${e.id}\` (${words}${byDesc}). O(1) lookup.`,
|
|
1833
|
+
`/// Returns null when no contributing member exists.`,
|
|
1834
|
+
`Stream<int?> watch${Method}(NomosReads reads) =>`,
|
|
1835
|
+
` reads.watchMin('${e.id}', '');`,
|
|
1836
|
+
``,
|
|
1837
|
+
`/// One-shot maintained minimum \`${e.id}\` (${words}) — grand total. O(1).`,
|
|
1838
|
+
`int? read${Method}(NomosReads reads) =>`,
|
|
1839
|
+
` reads.readMin('${e.id}', '');`,
|
|
1840
|
+
].join("\n");
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/** Emit the typed accessor PAIR for ONE declared max (read-engine maintained maximum):
|
|
1844
|
+
* a reactive `watch<Id>` → `Stream<int?>` and a one-shot `read<Id>` → `int?`.
|
|
1845
|
+
* Returns `int?` (nullable): mirrors `emitMinReads`, differing only in seam name
|
|
1846
|
+
* (`watchMax`/`readMax`). */
|
|
1847
|
+
function emitMaxReads(
|
|
1848
|
+
e: ReturnType<typeof finishExtremum>,
|
|
1849
|
+
aggregatesById: Map<string, AggregateHandle>,
|
|
1850
|
+
enumDartTypes: Map<string, string>,
|
|
1851
|
+
refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
|
|
1852
|
+
ownIdFieldsByAggregate: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
|
|
1853
|
+
): string {
|
|
1854
|
+
const method = readMethodName(e.id);
|
|
1855
|
+
const Method = cap(method);
|
|
1856
|
+
const words = e.id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
1857
|
+
const byDesc = e.by !== undefined ? `, grouped by \`${e.by}\`` : " (grand total)";
|
|
1858
|
+
if (e.by !== undefined) {
|
|
1859
|
+
const groupSpec = readKeyParamSpec(
|
|
1860
|
+
"groupKey",
|
|
1861
|
+
aggregateFieldSpecForKey(
|
|
1862
|
+
aggregatesById,
|
|
1863
|
+
e.of,
|
|
1864
|
+
e.by,
|
|
1865
|
+
enumDartTypes,
|
|
1866
|
+
refIdClassForAggregate,
|
|
1867
|
+
ownIdFieldsByAggregate,
|
|
1868
|
+
),
|
|
1869
|
+
);
|
|
1870
|
+
return [
|
|
1871
|
+
`/// Reactive maintained maximum \`${e.id}\` (${words}${byDesc}). O(1) lookup`,
|
|
1872
|
+
`/// (read-engine maintained maximum — never a scan). Emits the current maximum`,
|
|
1873
|
+
`/// (null when no contributing member), then re-emits on each projection tick.`,
|
|
1874
|
+
`/// The predicate is baked in. null = empty group (0 is a legitimate maximum).`,
|
|
1875
|
+
`Stream<int?> watch${Method}(NomosReads reads, ${groupSpec.dartType} groupKey) =>`,
|
|
1876
|
+
` reads.watchMax('${e.id}', ${groupSpec.valueExpr});`,
|
|
1877
|
+
``,
|
|
1878
|
+
`/// One-shot maintained maximum \`${e.id}\` (${words}), keyed on [groupKey]. O(1).`,
|
|
1879
|
+
`/// Returns null when no contributing member exists in the group.`,
|
|
1880
|
+
`int? read${Method}(NomosReads reads, ${groupSpec.dartType} groupKey) =>`,
|
|
1881
|
+
` reads.readMax('${e.id}', ${groupSpec.valueExpr});`,
|
|
1882
|
+
].join("\n");
|
|
1883
|
+
}
|
|
1884
|
+
return [
|
|
1885
|
+
`/// Reactive maintained maximum \`${e.id}\` (${words}${byDesc}). O(1) lookup.`,
|
|
1886
|
+
`/// Returns null when no contributing member exists.`,
|
|
1887
|
+
`Stream<int?> watch${Method}(NomosReads reads) =>`,
|
|
1888
|
+
` reads.watchMax('${e.id}', '');`,
|
|
1889
|
+
``,
|
|
1890
|
+
`/// One-shot maintained maximum \`${e.id}\` (${words}) — grand total. O(1).`,
|
|
1891
|
+
`int? read${Method}(NomosReads reads) =>`,
|
|
1892
|
+
` reads.readMax('${e.id}', '');`,
|
|
1893
|
+
].join("\n");
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/** Emit the typed accessor PAIR for ONE declared exists (boolean membership over the
|
|
1897
|
+
* Slice-1 `counts` table): a reactive `watch<Id>` → `Stream<bool>` and a one-shot
|
|
1898
|
+
* `read<Id>` → `bool`. Returns `bool`, NOT `int` — the only shape difference from
|
|
1899
|
+
* `emitCountReads`. The predicate is INVISIBLE (baked into the maintained counter).
|
|
1900
|
+
* `exists` is backed by `readExists`/`watchExists` which internally call `count > 0`
|
|
1901
|
+
* on the `counts` table — zero new Rust state (LAW 4, one engine). */
|
|
1902
|
+
function emitExistsReads(
|
|
1903
|
+
e: ReturnType<typeof finishExists>,
|
|
1904
|
+
aggregatesById: Map<string, AggregateHandle>,
|
|
1905
|
+
enumDartTypes: Map<string, string>,
|
|
1906
|
+
refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
|
|
1907
|
+
ownIdFieldsByAggregate: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
|
|
1908
|
+
): string {
|
|
1909
|
+
const method = readMethodName(e.id);
|
|
1910
|
+
const Method = cap(method);
|
|
1911
|
+
const words = e.id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
1912
|
+
const byDesc = e.by !== undefined ? `, grouped by \`${e.by}\`` : " (grand total)";
|
|
1913
|
+
if (e.by !== undefined) {
|
|
1914
|
+
const groupSpec = readKeyParamSpec(
|
|
1915
|
+
"groupKey",
|
|
1916
|
+
aggregateFieldSpecForKey(
|
|
1917
|
+
aggregatesById,
|
|
1918
|
+
e.of,
|
|
1919
|
+
e.by,
|
|
1920
|
+
enumDartTypes,
|
|
1921
|
+
refIdClassForAggregate,
|
|
1922
|
+
ownIdFieldsByAggregate,
|
|
1923
|
+
),
|
|
1924
|
+
);
|
|
1925
|
+
return [
|
|
1926
|
+
`/// Reactive maintained existence check \`${e.id}\` (${words}${byDesc}). O(1) lookup`,
|
|
1927
|
+
`/// (backed by the read-engine counts table — count > 0). Emits the current boolean,`,
|
|
1928
|
+
`/// then re-emits on each projection tick. The predicate is baked in.`,
|
|
1929
|
+
`Stream<bool> watch${Method}(NomosReads reads, ${groupSpec.dartType} groupKey) =>`,
|
|
1930
|
+
` reads.watchExists('${e.id}', ${groupSpec.valueExpr});`,
|
|
1931
|
+
``,
|
|
1932
|
+
`/// One-shot existence check \`${e.id}\` (${words}), keyed on [groupKey]. O(1).`,
|
|
1933
|
+
`bool read${Method}(NomosReads reads, ${groupSpec.dartType} groupKey) =>`,
|
|
1934
|
+
` reads.readExists('${e.id}', ${groupSpec.valueExpr});`,
|
|
1935
|
+
].join("\n");
|
|
1936
|
+
}
|
|
1937
|
+
return [
|
|
1938
|
+
`/// Reactive maintained existence check \`${e.id}\` (${words}${byDesc}). O(1) lookup.`,
|
|
1939
|
+
`Stream<bool> watch${Method}(NomosReads reads) =>`,
|
|
1940
|
+
` reads.watchExists('${e.id}', '');`,
|
|
1941
|
+
``,
|
|
1942
|
+
`/// One-shot existence check \`${e.id}\` (${words}) — grand total. O(1).`,
|
|
1943
|
+
`bool read${Method}(NomosReads reads) =>`,
|
|
1944
|
+
` reads.readExists('${e.id}', '');`,
|
|
1945
|
+
].join("\n");
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
/** Emit the typed accessor PAIR for ONE declared `first` or `take(n)` ordered-row read:
|
|
1949
|
+
* a reactive `watch<Id>` → `Stream<List<Agg>>` (returns 0-or-1 for first, 0-to-n for
|
|
1950
|
+
* take) and a one-shot `read<Id>` → `List<Agg>`. The `orderKey` is baked into the spec
|
|
1951
|
+
* and INVISIBLE to the accessor — the dev declared it once; they consume an ordered list.
|
|
1952
|
+
* Returns projected typed aggregates (via `project_by_ids`, `lib.rs:1686`) — not raw ids.
|
|
1953
|
+
* Archived aggregates are auto-excluded (the `type_index` row is dropped on archive). */
|
|
1954
|
+
function emitOrderedReads(d: OrderedReadDecl): string {
|
|
1955
|
+
const agg = d.of;
|
|
1956
|
+
const method = readMethodName(d.id);
|
|
1957
|
+
const Method = cap(method);
|
|
1958
|
+
const words = d.id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
1959
|
+
const orderDesc = `${d.orderDesc ? "descending " : "ascending "}by \`${d.orderKey}\``;
|
|
1960
|
+
if (d.limit === 1) {
|
|
1961
|
+
// first — returns a single projected aggregate or null.
|
|
1962
|
+
return [
|
|
1963
|
+
`/// Reactive ordered-row read \`${d.id}\` (${words}): the FIRST \`${agg}\` ${orderDesc},`,
|
|
1964
|
+
`/// or null when none exist. LIMIT 1 applied after the full ORDER BY (order-sensitive;`,
|
|
1965
|
+
`/// the total order bakes in \`${d.orderKey}\` + aggregate_id tiebreak for determinism).`,
|
|
1966
|
+
`Stream<${agg}?> watch${Method}(NomosReads reads) =>`,
|
|
1967
|
+
` reads.watchFirst('${d.id}', ${agg}.fromJson);`,
|
|
1968
|
+
``,
|
|
1969
|
+
`/// One-shot ordered-row read \`${d.id}\` (${words}): the CURRENT first \`${agg}\`, or null.`,
|
|
1970
|
+
`${agg}? read${Method}(NomosReads reads) =>`,
|
|
1971
|
+
` reads.readFirst('${d.id}', ${agg}.fromJson);`,
|
|
1972
|
+
].join("\n");
|
|
1973
|
+
}
|
|
1974
|
+
// take(n) — returns a list, n applied after the total ORDER BY.
|
|
1975
|
+
return [
|
|
1976
|
+
`/// Reactive ordered-row read \`${d.id}\` (${words}): the TOP ${d.limit} \`${agg}\` ${orderDesc}.`,
|
|
1977
|
+
`/// LIMIT ${d.limit} applied after the full ORDER BY (order-sensitive; the total order bakes in`,
|
|
1978
|
+
`/// \`${d.orderKey}\` + aggregate_id tiebreak for determinism). Returns an empty list when none exist.`,
|
|
1979
|
+
`Stream<List<${agg}>> watch${Method}(NomosReads reads) =>`,
|
|
1980
|
+
` reads.watchTake('${d.id}', ${d.limit}, ${agg}.fromJson);`,
|
|
1981
|
+
``,
|
|
1982
|
+
`/// One-shot ordered-row read \`${d.id}\` (${words}): CURRENT top ${d.limit} \`${agg}\`.`,
|
|
1983
|
+
`List<${agg}> read${Method}(NomosReads reads) =>`,
|
|
1984
|
+
` reads.readTake('${d.id}', ${d.limit}, ${agg}.fromJson);`,
|
|
1985
|
+
].join("\n");
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// ---- minted typed-id creates (the kernel id-mint keystone) -----------------
|
|
1989
|
+
//
|
|
1990
|
+
// A `.creates` directive whose payload carries a target-id field (the id bound to the
|
|
1991
|
+
// NEW aggregate) is a KERNEL-MINTED create: the frontend must NOT pass a raw id. For
|
|
1992
|
+
// such a directive we emit, ALONGSIDE the plain payload DTO:
|
|
1993
|
+
// * a typed id value class (e.g. `SiteId`) wrapping the kernel-minted string, so a
|
|
1994
|
+
// raw `String` is never accepted where a minted id is required, and
|
|
1995
|
+
// * a top-level `create…({Minter mint, …})` factory that calls the KERNEL mint (no
|
|
1996
|
+
// raw id param, NO seed) to RESERVE the typed id and builds the payload from it — so
|
|
1997
|
+
// the only way to construct a minted create at the call site is THROUGH the kernel
|
|
1998
|
+
// mint, and the dev never sees a uuid/seed/nonce.
|
|
1999
|
+
//
|
|
2000
|
+
// THE MODEL: the KERNEL mints `<Type>_<uuidv7>` from the HOST-INJECTED rng; the id is
|
|
2001
|
+
// CAPTURED in the create event and READ on replay (never re-minted). The UUID body is
|
|
2002
|
+
// opaque uniqueness only — NOT time/order; intent HLCs carry ordering. There is NO
|
|
2003
|
+
// `mintSeed` payload field any more — the id itself is the proof. The factory passes
|
|
2004
|
+
// NOTHING to the mint but the aggregate type.
|
|
2005
|
+
|
|
2006
|
+
/**
|
|
2007
|
+
* The target-id field of a minted create: the payload field bound to the aggregate the
|
|
2008
|
+
* directive `.creates`. MARKER-DRIVEN — the kernel mints the `.creates` target's id, so we
|
|
2009
|
+
* identify that field by running the directive's REAL `.plan()` and seeing which payload
|
|
2010
|
+
* value becomes the created aggregate's `__id` (the ground truth), NOT by guessing from the
|
|
2011
|
+
* field name. This is correct for a COMPOSED create that also mutates a sibling: e.g.
|
|
2012
|
+
* `correctThingLabel` CREATES `RepairRecord` (keyed by its own id) AND MUTATES an existing
|
|
2013
|
+
* `TrackableThing` (keyed by `thingId`) — the name heuristic would mis-pick `thingId`; the
|
|
2014
|
+
* plan-trace picks the field that is the RepairRecord create's `__id`.
|
|
2015
|
+
*
|
|
2016
|
+
* Mechanism: probe each top-level STRING field with a unique sentinel, run `.plan()`, find
|
|
2017
|
+
* the `Create` event whose `__type` == the directive's `.creates` aggregate, and map its
|
|
2018
|
+
* `__id` sentinel back to the payload field. Returns `undefined` when the created aggregate's
|
|
2019
|
+
* id is not a verbatim top-level payload string (then no minting factory is emitted).
|
|
2020
|
+
*
|
|
2021
|
+
* `agg` is the `.creates` target handle (`directive.aggregateId`), required by `executeDirectiveToIntent`.
|
|
2022
|
+
* If the plan cannot be traced, emit no factory. Guessing by field name makes the
|
|
2023
|
+
* public Dart surface lie about aggregate identity.
|
|
2024
|
+
*/
|
|
2025
|
+
function mintedIdField(
|
|
2026
|
+
d: AnyDirective,
|
|
2027
|
+
agg: AggregateHandle | undefined,
|
|
2028
|
+
specs: PayloadFieldSpec[],
|
|
2029
|
+
): PayloadFieldSpec | undefined {
|
|
2030
|
+
return agg !== undefined ? mintedIdFieldByPlan(d, agg, specs) : undefined;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/** Marker-driven identification: run the REAL `.plan()` with sentinel string values and
|
|
2034
|
+
* find which payload field becomes the `.creates`-target aggregate's `__id`. Returns
|
|
2035
|
+
* `undefined` if the plan throws or the created id is not a verbatim top-level string. */
|
|
2036
|
+
function mintedIdFieldByPlan(
|
|
2037
|
+
d: AnyDirective,
|
|
2038
|
+
agg: AggregateHandle,
|
|
2039
|
+
specs: PayloadFieldSpec[],
|
|
2040
|
+
): PayloadFieldSpec | undefined {
|
|
2041
|
+
const stringFields = specs.filter((s) => s.dartType === "String");
|
|
2042
|
+
if (stringFields.length === 0) return undefined;
|
|
2043
|
+
const sentinelToField = new Map<string, string>();
|
|
2044
|
+
for (const s of stringFields) sentinelToField.set(`__MINT_PROBE_${s.name}__`, s.name);
|
|
2045
|
+
let payload: Record<string, unknown>;
|
|
2046
|
+
try {
|
|
2047
|
+
payload = buildProbePayload(d.payloadSchema as z.ZodTypeAny, sentinelToField);
|
|
2048
|
+
} catch {
|
|
2049
|
+
return undefined; // could not build a schema-valid probe
|
|
2050
|
+
}
|
|
2051
|
+
let intent: ReturnType<typeof executeDirectiveToIntent>;
|
|
2052
|
+
try {
|
|
2053
|
+
intent = executeDirectiveToIntent(d, agg, payload as never, deterministicPorts({ physical: 1, replica: 1 }));
|
|
2054
|
+
} catch {
|
|
2055
|
+
return undefined; // plan/Zod threw on the probe
|
|
2056
|
+
}
|
|
2057
|
+
// The created aggregate's event: marker Create + the `__type` stamp == the .creates target.
|
|
2058
|
+
for (const ev of intent.events) {
|
|
2059
|
+
if (ev.marker !== "Create") continue;
|
|
2060
|
+
const typeOp = ev.ops.find((o) => o.field === "__type");
|
|
2061
|
+
const typeVal = typeOp && "Set" in typeOp.op ? (typeOp.op.Set as { Str?: string }).Str : undefined;
|
|
2062
|
+
if (typeVal !== agg.id) continue;
|
|
2063
|
+
const idOp = ev.ops.find((o) => o.field === "__id");
|
|
2064
|
+
const idVal = idOp && "Set" in idOp.op ? (idOp.op.Set as { Str?: string }).Str : undefined;
|
|
2065
|
+
const fieldName = idVal !== undefined ? sentinelToField.get(idVal) : undefined;
|
|
2066
|
+
if (fieldName !== undefined) return specs.find((s) => s.name === fieldName);
|
|
2067
|
+
}
|
|
2068
|
+
return undefined;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
/** Build a minimal, schema-VALID probe payload for a directive: every top-level STRING field
|
|
2072
|
+
* gets its unique sentinel (so the `.plan()` trace can map the create's `__id` back to a
|
|
2073
|
+
* field); every other field gets a minimal valid sample. Throws if a field type cannot be
|
|
2074
|
+
* sampled (the caller falls back to the name heuristic). */
|
|
2075
|
+
function buildProbePayload(
|
|
2076
|
+
schema: z.ZodTypeAny,
|
|
2077
|
+
sentinelToField: Map<string, string>,
|
|
2078
|
+
): Record<string, unknown> {
|
|
2079
|
+
if (zodTypeName(schema) !== "ZodObject") throw new Error("payload is not a z.object");
|
|
2080
|
+
const fieldBySentinel = new Map<string, string>();
|
|
2081
|
+
for (const [sent, name] of sentinelToField) fieldBySentinel.set(name, sent);
|
|
2082
|
+
const shape = zodObjectShape(schema);
|
|
2083
|
+
const out: Record<string, unknown> = {};
|
|
2084
|
+
for (const [name, ztRaw] of Object.entries(shape)) {
|
|
2085
|
+
let zt = ztRaw as z.ZodTypeAny;
|
|
2086
|
+
while (isOptionalOrDefault(zt)) {
|
|
2087
|
+
zt = zodInnerType(zt);
|
|
2088
|
+
}
|
|
2089
|
+
const sentinel = fieldBySentinel.get(name);
|
|
2090
|
+
out[name] = sentinel !== undefined && zodTypeName(zt) === "ZodString"
|
|
2091
|
+
? sentinel
|
|
2092
|
+
: sampleFromZod(zt);
|
|
2093
|
+
}
|
|
2094
|
+
return out;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
/** A minimal valid sample value for a Zod type — enough for a directive `.plan()` probe. */
|
|
2098
|
+
function sampleFromZod(zt: z.ZodTypeAny): unknown {
|
|
2099
|
+
let t = zt;
|
|
2100
|
+
while (isOptionalOrDefault(t)) {
|
|
2101
|
+
t = zodInnerType(t);
|
|
2102
|
+
}
|
|
2103
|
+
switch (zodTypeName(t)) {
|
|
2104
|
+
case "ZodString":
|
|
2105
|
+
return "x";
|
|
2106
|
+
case "ZodNumber":
|
|
2107
|
+
return 1;
|
|
2108
|
+
case "ZodBoolean":
|
|
2109
|
+
return false;
|
|
2110
|
+
case "ZodEnum":
|
|
2111
|
+
return zodEnumValues(t)[0];
|
|
2112
|
+
case "ZodArray":
|
|
2113
|
+
return [];
|
|
2114
|
+
case "ZodObject": {
|
|
2115
|
+
const shape = zodObjectShape(t);
|
|
2116
|
+
const o: Record<string, unknown> = {};
|
|
2117
|
+
for (const [k, v] of Object.entries(shape)) o[k] = sampleFromZod(v as z.ZodTypeAny);
|
|
2118
|
+
return o;
|
|
2119
|
+
}
|
|
2120
|
+
case "ZodRecord":
|
|
2121
|
+
return {};
|
|
2122
|
+
case "ZodThing":
|
|
2123
|
+
return {};
|
|
2124
|
+
case "ZodUnion": {
|
|
2125
|
+
const opt = zodUnionOptions(t)[0];
|
|
2126
|
+
if (opt === undefined) throw new Error("ZodUnion has no options to sample");
|
|
2127
|
+
return sampleFromZod(opt);
|
|
2128
|
+
}
|
|
2129
|
+
case "ZodLiteral":
|
|
2130
|
+
return zodLiteralValue(t);
|
|
2131
|
+
case "ZodNullable":
|
|
2132
|
+
return null;
|
|
2133
|
+
default:
|
|
2134
|
+
throw new Error(`cannot sample Zod type '${zodTypeName(t)}'`);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
/** The Dart typed-id class name for a minted create's id field. Unified on the
|
|
2139
|
+
* aggregate WIRE TYPE (so `createSite`'s `siteId` → `SiteId`, the same name a ref
|
|
2140
|
+
* targeting `SiteRootAggregate` uses), NOT on the payload field name.
|
|
2141
|
+
* The minting factory uses `FooId._(mintedId)` (the truly private ctor, same file). */
|
|
2142
|
+
function typedIdClassName(aggregateType: string): string {
|
|
2143
|
+
return typedIdClassForAggregateType(aggregateType);
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
/** Emit a typed id value class for an aggregate TYPE. Type-safe: a raw `String` — or
|
|
2147
|
+
* another aggregate's typed id — is a COMPILE ERROR where a [${cls}] is required (the
|
|
2148
|
+
* value classes are distinct nominal types). Public constructors:
|
|
2149
|
+
* * [${cls}] / [${cls}.fromString] — non-strict wrappers for app route params,
|
|
2150
|
+
* existing refs, and test fixtures.
|
|
2151
|
+
* * [${cls}.fromMinted] — STRICT: asserts the kernel type tag matches.
|
|
2152
|
+
* One row-id constructor:
|
|
2153
|
+
* * [${cls}.fromRow] — wraps a kernel-stored row/ref id when deserializing a
|
|
2154
|
+
* projection row. This intentionally does NOT enforce the minted type tag because
|
|
2155
|
+
* reads may include not-yet-minted ids; write admission remains the mint gate.
|
|
2156
|
+
*/
|
|
2157
|
+
function emitTypedIdClass(aggregateType: string): string {
|
|
2158
|
+
const cls = typedIdClassName(aggregateType);
|
|
2159
|
+
return [
|
|
2160
|
+
`/// Typed id for \`${aggregateType}\`. A DISTINCT nominal type so a raw String — or`,
|
|
2161
|
+
`/// ANOTHER aggregate's typed id — is a COMPILE ERROR where a [${cls}] is required.`,
|
|
2162
|
+
`/// [fromMinted] is the strict constructor for ids reserved by the kernel mint.`,
|
|
2163
|
+
`/// [${cls}] / [fromString] / [fromRow] are non-strict wrappers for route params,`,
|
|
2164
|
+
`/// existing refs, and generated projection decoding.`,
|
|
2165
|
+
`class ${cls} {`,
|
|
2166
|
+
` /// The kernel-minted id string (\`${aggregateType}_<uuidv7>\`).`,
|
|
2167
|
+
` /// The UUID body is opaque uniqueness only — NOT time/order; intent HLCs carry ordering.`,
|
|
2168
|
+
` final String value;`,
|
|
2169
|
+
` const ${cls}(this.value);`,
|
|
2170
|
+
` const ${cls}._(this.value);`,
|
|
2171
|
+
` factory ${cls}.fromString(String value) => ${cls}.fromRow(value);`,
|
|
2172
|
+
` /// Wrap a kernel-stored row/ref id while decoding a projection row. This is`,
|
|
2173
|
+
` /// intentionally non-strict: old ledgers and cross-domain refs may not carry the`,
|
|
2174
|
+
` /// minted type tag, so admission/minting is enforced on writes, not reads.`,
|
|
2175
|
+
` const ${cls}.fromRow(this.value);`,
|
|
2176
|
+
` /// Wrap an ALREADY-kernel-minted id string (e.g. one read back from a query).`,
|
|
2177
|
+
` /// STRICT: asserts the type tag matches \`${aggregateType}\` — a wrong-typed id throws.`,
|
|
2178
|
+
` /// A minted id is \`<TypeTag>_<uuidv7>\`, so the tag is the prefix before the FIRST \`_\`.`,
|
|
2179
|
+
` /// Do not read time or ordering from the UUID body; it is an opaque uniqueness token.`,
|
|
2180
|
+
` factory ${cls}.fromMinted(String minted) {`,
|
|
2181
|
+
` final i = minted.indexOf('_');`,
|
|
2182
|
+
` final tag = i < 0 ? minted : minted.substring(0, i);`,
|
|
2183
|
+
` if (tag != '${aggregateType}') {`,
|
|
2184
|
+
` throw ArgumentError('id "$minted" is not a ${aggregateType} id (tag "$tag")');`,
|
|
2185
|
+
` }`,
|
|
2186
|
+
` return ${cls}._(minted);`,
|
|
2187
|
+
` }`,
|
|
2188
|
+
` @override`,
|
|
2189
|
+
` bool operator ==(Object other) => other is ${cls} && other.value == value;`,
|
|
2190
|
+
` @override`,
|
|
2191
|
+
` int get hashCode => value.hashCode;`,
|
|
2192
|
+
` @override`,
|
|
2193
|
+
` String toString() => '${cls}($value)';`,
|
|
2194
|
+
`}`,
|
|
2195
|
+
].join("\n");
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
/** Emit the top-level minted `create…(...)` factory function: it calls the KERNEL mint
|
|
2199
|
+
* (NO raw id param, NO seed) to RESERVE a typed id and builds the payload from it,
|
|
2200
|
+
* returning `(payload, typedId)`. This is the ONLY way to construct a minted create at the
|
|
2201
|
+
* call site — the dev supplies NOTHING about the id. The factory itself contains NO
|
|
2202
|
+
* id/uuid/seed construction — only the runtime kernel-mint call via [mint]. */
|
|
2203
|
+
function emitMintedFactory(
|
|
2204
|
+
d: AnyDirective,
|
|
2205
|
+
_domainName: string,
|
|
2206
|
+
idField: PayloadFieldSpec,
|
|
2207
|
+
specs: PayloadFieldSpec[],
|
|
2208
|
+
aggregateType: string,
|
|
2209
|
+
): string {
|
|
2210
|
+
const className = `${cap(d.id)}Payload`;
|
|
2211
|
+
const idCls = typedIdClassName(aggregateType);
|
|
2212
|
+
const fnName = d.id; // e.g. `createSite` — the typed minting factory.
|
|
2213
|
+
// The caller supplies every field EXCEPT the minted id (the kernel mint produces it).
|
|
2214
|
+
// Optional fields become optional named params. There is NO seed/author param: the
|
|
2215
|
+
// kernel mints the UUIDv7 from the host-injected rng — the dev touches none of it.
|
|
2216
|
+
const callerFields = specs.filter((s) => s.name !== idField.name);
|
|
2217
|
+
const ctorParams = callerFields
|
|
2218
|
+
.map((s) => ` ${s.optional ? "" : "required "}${s.dartType}${s.optional ? "?" : ""} ${s.name},`)
|
|
2219
|
+
.join("\n");
|
|
2220
|
+
const forwarded = callerFields.map((s) => ` ${s.name}: ${s.name},`).join("\n");
|
|
2221
|
+
return [
|
|
2222
|
+
`/// KERNEL-MINTED \`${d.id}\` factory: calls [mint] (the kernel id-mint) to RESERVE a`,
|
|
2223
|
+
`/// fresh typed [${idCls}] — NO raw id param, NO seed — and ships the minted id in the`,
|
|
2224
|
+
`/// payload, so the kernel GATE verifies its shape + type tag on author. The kernel`,
|
|
2225
|
+
`/// mints the \`${aggregateType}_<uuidv7>\` from the HOST-INJECTED rng (native OsRng / web`,
|
|
2226
|
+
`/// crypto). The UUID body is opaque uniqueness only — NOT time/order; intent HLCs carry`,
|
|
2227
|
+
`/// ordering. This factory builds NO id/uuid/seed itself — only the runtime mint call.`,
|
|
2228
|
+
`/// Returns the [${className}] AND the minted [${idCls}] (so the caller can reference the`,
|
|
2229
|
+
`/// new aggregate). This is the type-safe create path: the frontend cannot supply a raw id.`,
|
|
2230
|
+
`(${className}, ${idCls}) ${fnName}({`,
|
|
2231
|
+
` required Minter mint,`,
|
|
2232
|
+
ctorParams,
|
|
2233
|
+
`}) {`,
|
|
2234
|
+
` final mintedId = mint(aggregateType: '${aggregateType}');`,
|
|
2235
|
+
` final payload = ${className}(`,
|
|
2236
|
+
` ${idField.name}: mintedId,`,
|
|
2237
|
+
forwarded,
|
|
2238
|
+
` );`,
|
|
2239
|
+
` return (payload, ${idCls}._(mintedId));`,
|
|
2240
|
+
`}`,
|
|
2241
|
+
].join("\n");
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/** Emit the typed-id keystone for a domain (GENERALIZED across ALL aggregates):
|
|
2245
|
+
* 1. a DISTINCT typed id value class PER AGGREGATE (so cross-aggregate type
|
|
2246
|
+
* confusion is a compile error + ref fields have a target type to name); and
|
|
2247
|
+
* 2. a kernel-minting `create…` factory for every `.creates` directive whose real
|
|
2248
|
+
* plan trace proves a payload field is the created aggregate id.
|
|
2249
|
+
* Every aggregate gets a typed id even if it has no minting factory (a singleton /
|
|
2250
|
+
* kernel-minted create), because OTHER aggregates' ref fields may target it.
|
|
2251
|
+
* Returns `""` when the domain has no aggregates (nothing to emit). */
|
|
2252
|
+
function emitMintedCreates(
|
|
2253
|
+
aggregates: AggregateHandle[],
|
|
2254
|
+
directives: AnyDirective[],
|
|
2255
|
+
domainName: string,
|
|
2256
|
+
enumDartTypes: Map<string, string>,
|
|
2257
|
+
): string {
|
|
2258
|
+
const blocks: string[] = [];
|
|
2259
|
+
|
|
2260
|
+
// (1) A typed id class per aggregate. Dedup by the typed-id class NAME: two wire
|
|
2261
|
+
// types never collide on a name (the name is a pure function of the type), and a
|
|
2262
|
+
// domain that re-exports a framework aggregate must not emit its id class twice.
|
|
2263
|
+
const emittedIdClasses = new Set<string>();
|
|
2264
|
+
for (const agg of aggregates) {
|
|
2265
|
+
const cls = typedIdClassForAggregateType(agg.id);
|
|
2266
|
+
if (emittedIdClasses.has(cls)) continue;
|
|
2267
|
+
emittedIdClasses.add(cls);
|
|
2268
|
+
blocks.push(emitTypedIdClass(agg.id));
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// (2) A minting factory per qualifying create — the kernel mints the `.creates` target's
|
|
2272
|
+
// id, identified MARKER-DRIVEN by running the real `.plan()` (see `mintedIdField`).
|
|
2273
|
+
const aggById = new Map<string, AggregateHandle>();
|
|
2274
|
+
for (const a of aggregates) aggById.set(a.id, a);
|
|
2275
|
+
for (const d of directives) {
|
|
2276
|
+
if (d.marker !== "creates") continue;
|
|
2277
|
+
const specs = introspectPayload(d.payloadSchema as z.ZodTypeAny, enumDartTypes);
|
|
2278
|
+
const idField = mintedIdField(d, aggById.get(d.aggregateId), specs);
|
|
2279
|
+
if (idField === undefined) continue;
|
|
2280
|
+
const aggregateType = d.aggregateId;
|
|
2281
|
+
blocks.push(emitMintedFactory(d, domainName, idField, specs, aggregateType));
|
|
2282
|
+
}
|
|
2283
|
+
return blocks.join("\n\n");
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
function collectOwnIdFieldsByAggregate(
|
|
2287
|
+
aggregates: AggregateHandle[],
|
|
2288
|
+
directives: AnyDirective[],
|
|
2289
|
+
enumDartTypes: Map<string, string>,
|
|
2290
|
+
): Map<string, Set<string>> {
|
|
2291
|
+
const out = new Map<string, Set<string>>();
|
|
2292
|
+
const aggById = new Map<string, AggregateHandle>();
|
|
2293
|
+
for (const a of aggregates) aggById.set(a.id, a);
|
|
2294
|
+
for (const d of directives) {
|
|
2295
|
+
if (d.marker !== "creates") continue;
|
|
2296
|
+
const agg = aggById.get(d.aggregateId);
|
|
2297
|
+
if (agg === undefined) continue;
|
|
2298
|
+
const specs = introspectPayload(d.payloadSchema as z.ZodTypeAny, enumDartTypes);
|
|
2299
|
+
const idField = mintedIdField(d, agg, specs);
|
|
2300
|
+
if (idField === undefined) continue;
|
|
2301
|
+
const field = (agg.fields as Record<string, Field>)[idField.name];
|
|
2302
|
+
if (field?.kind !== "string") continue;
|
|
2303
|
+
const fields = out.get(agg.id) ?? new Set<string>();
|
|
2304
|
+
fields.add(idField.name);
|
|
2305
|
+
out.set(agg.id, fields);
|
|
2306
|
+
}
|
|
2307
|
+
return out;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
// ---- top-level generator ---------------------------------------------------
|
|
2311
|
+
|
|
2312
|
+
export interface DomainModule {
|
|
2313
|
+
/** Dart file basename (no extension), e.g. "sample_thing". */
|
|
2314
|
+
name: string;
|
|
2315
|
+
/**
|
|
2316
|
+
* The policy domain key the GitHolon `author` resolves the intent against (the engine
|
|
2317
|
+
* registry key, e.g. `thing_structures` / `identity`). Defaults to [name] when
|
|
2318
|
+
* omitted — they coincide for most domains, but the `identity` registry key differs
|
|
2319
|
+
* from any single file basename, so it is INJECTABLE.
|
|
2320
|
+
*/
|
|
2321
|
+
domain?: string;
|
|
2322
|
+
aggregates: AggregateHandle[];
|
|
2323
|
+
directives: AnyDirective[];
|
|
2324
|
+
/**
|
|
2325
|
+
* The domain's NAMED, INDEXED read declarations (read-side closure step 1).
|
|
2326
|
+
* ADDITIVE + OPTIONAL: a domain that declares no query omits this entirely and is
|
|
2327
|
+
* byte-identical in the canonical manifest to before `queries` existed. Wired the
|
|
2328
|
+
* same way `aggregates`/`directives` are; carried into the manifest/USD IR by
|
|
2329
|
+
* `manifest.ts`/`usd.ts` with omit-when-empty identity semantics.
|
|
2330
|
+
*/
|
|
2331
|
+
queries?: QueryDecl[];
|
|
2332
|
+
/**
|
|
2333
|
+
* The domain's NAMED, MAINTAINED count declarations (read-side closure step 3 — the
|
|
2334
|
+
* aggregation analogue of {@link queries}). ADDITIVE + OPTIONAL: a domain that
|
|
2335
|
+
* declares no count omits this entirely and is byte-identical in the canonical
|
|
2336
|
+
* manifest to before `counts` existed. Wired the same way `queries` is; carried into
|
|
2337
|
+
* the read manifest by `emit_manifests.ts` and the identity manifest by `manifest.ts`
|
|
2338
|
+
* with omit-when-empty identity semantics. The read engine (`nomos_readmodel`)
|
|
2339
|
+
* maintains an O(1)-lookup counter table from these declarations. Accepts either a
|
|
2340
|
+
* grouped `count(id).of(T).by(field)` declaration or a bare grand-total
|
|
2341
|
+
* `count(id).of(T)` builder (both are `AnyCount`; `finishCount` normalizes them).
|
|
2342
|
+
*/
|
|
2343
|
+
counts?: AnyCount[];
|
|
2344
|
+
/**
|
|
2345
|
+
* The domain's NAMED, MAINTAINED SPATIAL index declarations (read-side closure — the
|
|
2346
|
+
* GEOSPATIAL analogue of {@link counts}; the keystone of `docs/placement_and_spatial_reads.md`).
|
|
2347
|
+
* Each is a `spatial(id).of(T).on(geometryField)` declaration backed by SQLite R*Tree: the
|
|
2348
|
+
* read engine maintains a bounding-box index over the aggregate's GeoJSON geometry field so a
|
|
2349
|
+
* "within bbox" / "nearest" lookup is an R*Tree probe, NOT an in-VM scan. ADDITIVE + OPTIONAL:
|
|
2350
|
+
* a domain that declares no spatial index omits this entirely and is byte-identical in the
|
|
2351
|
+
* canonical manifest to before `spatials` existed (omit-when-empty, like {@link counts}/
|
|
2352
|
+
* {@link deriveds}). Carried into the read manifest + identity manifest by `manifest.ts` with
|
|
2353
|
+
* omit-when-empty identity semantics.
|
|
2354
|
+
*/
|
|
2355
|
+
spatials?: SpatialDecl[];
|
|
2356
|
+
/**
|
|
2357
|
+
* The domain's NAMED, MAINTAINED sum declarations (read-side closure — the numeric-
|
|
2358
|
+
* accumulation analogue of {@link counts}). ADDITIVE + OPTIONAL: a domain that
|
|
2359
|
+
* declares no sum omits this entirely and is byte-identical in the canonical manifest
|
|
2360
|
+
* to before `sums` existed. Each entry is an `AnySum` (either a grand-total builder or
|
|
2361
|
+
* a grouped `SumDecl`); `finishSum` normalizes both. The read engine maintains an
|
|
2362
|
+
* O(1)-lookup `sums` table from these declarations.
|
|
2363
|
+
*/
|
|
2364
|
+
sums?: AnySum[];
|
|
2365
|
+
/**
|
|
2366
|
+
* The domain's NAMED, MAINTAINED min declarations (read-side closure — the
|
|
2367
|
+
* minimum analogue of {@link sums}). ADDITIVE + OPTIONAL. The read engine maintains
|
|
2368
|
+
* an `extrema` table per (spec_id, group_key) recomputed via a bounded O(log n)
|
|
2369
|
+
* `SELECT MIN` over the `extremum_members` B-tree index. Generates `int?`-typed
|
|
2370
|
+
* (nullable) accessors: an empty group has NO minimum (0 is a legitimate minimum
|
|
2371
|
+
* value — `null` = no contributing member, per LAW 3). No `.orderBy` exposed (min
|
|
2372
|
+
* is order-INDEPENDENT — LAW 3). `finishExtremum` normalizes both builder + decl.
|
|
2373
|
+
*/
|
|
2374
|
+
mins?: AnyExtremum[];
|
|
2375
|
+
/**
|
|
2376
|
+
* The domain's NAMED, MAINTAINED max declarations (read-side closure — the
|
|
2377
|
+
* maximum analogue of {@link mins}). ADDITIVE + OPTIONAL. Same maintenance strategy
|
|
2378
|
+
* as mins. Generates `int?`-typed (nullable) accessors. No `.orderBy` exposed.
|
|
2379
|
+
*/
|
|
2380
|
+
maxes?: AnyExtremum[];
|
|
2381
|
+
/**
|
|
2382
|
+
* The domain's BOOLEAN EXISTENCE checks (read-side closure — the membership-test
|
|
2383
|
+
* analogue of {@link counts}). ADDITIVE + OPTIONAL. ZERO new IR: each `exists` entry
|
|
2384
|
+
* is serialized into the `counts` array in the manifest (the Rust engine sees an
|
|
2385
|
+
* ordinary count). The ONLY difference is in the generated accessor: it returns
|
|
2386
|
+
* `bool` = `count > 0`, not `int` (the `_existsMarker` lives only in the DSL/codegen
|
|
2387
|
+
* layer). No `.orderBy` exposed (membership is order-INDEPENDENT — LAW 3).
|
|
2388
|
+
*/
|
|
2389
|
+
exists_?: AnyExists[];
|
|
2390
|
+
/**
|
|
2391
|
+
* The domain's ORDERED ROW READS (first/take.orderBy — order-SENSITIVE reads).
|
|
2392
|
+
* ADDITIVE + OPTIONAL. Each entry is a FINISHED `OrderedReadDecl` (the type-level
|
|
2393
|
+
* guardrail ensures it is UN-CONSTRUCTIBLE without `.orderBy(key)` — a
|
|
2394
|
+
* `first("x").of(T)` without `.orderBy` is not assignable here → COMPILE ERROR).
|
|
2395
|
+
* The generated accessors return projected typed aggregates (not raw ids), with
|
|
2396
|
+
* `LIMIT n` applied strictly after the full `ORDER BY <key>, aggregate_id` (the
|
|
2397
|
+
* mandatory tiebreak that makes the order TOTAL even on key-ties — LAW 1).
|
|
2398
|
+
*/
|
|
2399
|
+
orderedReads?: OrderedReadDecl[];
|
|
2400
|
+
/**
|
|
2401
|
+
* The domain's WORKSPACE INVARIANTS (#266 — consistency level 2): HARD, synchronous,
|
|
2402
|
+
* admission-time predicates over a BOUNDED, DECLARED multi-aggregate/multi-domain
|
|
2403
|
+
* post-apply read-set. ADDITIVE + OPTIONAL: a domain that declares none omits this
|
|
2404
|
+
* entirely and is byte-identical in the canonical manifest to before this key existed.
|
|
2405
|
+
* PRESENCE ONLY (id + the directive it is `on`) is hashed by `manifest.ts`; the executable
|
|
2406
|
+
* `reads`/`assert` bodies ship in the engine bundle, NEVER the ledger (mirrors the
|
|
2407
|
+
* aggregate `hasInvariant` / a directive `plan`). See `docs/workspace_invariant.md`.
|
|
2408
|
+
*/
|
|
2409
|
+
workspaceInvariants?: WorkspaceInvariantDecl[];
|
|
2410
|
+
/**
|
|
2411
|
+
* The domain's ENGINE-PROJECTED DERIVED read fields (read-engine: derived read fields).
|
|
2412
|
+
* Each is a PURE fn of ONE aggregate's folded fields, computed BY THE ENGINE during the
|
|
2413
|
+
* projection projection and stored ONLY in the read model (NEVER stamped into the
|
|
2414
|
+
* ledger). ADDITIVE + OPTIONAL: a domain that declares no derived field omits this
|
|
2415
|
+
* entirely. Carried into the read manifest by `emit_manifests.ts` (the `{id, of}` decl —
|
|
2416
|
+
* the fn SOURCE is NOT in the manifest; it ships in the engine bundle's `planReport`
|
|
2417
|
+
* derive-dispatch) and the identity manifest by `manifest.ts` with omit-when-empty
|
|
2418
|
+
* semantics (the fn body is NOT hashed, exactly like a directive's `.plan` body). The
|
|
2419
|
+
* read engine (`nomos_readmodel`) stores each derived value as an ordinary projected
|
|
2420
|
+
* field, so a reader decodes it like any other field.
|
|
2421
|
+
*/
|
|
2422
|
+
deriveds?: DerivedDecl[];
|
|
2423
|
+
/**
|
|
2424
|
+
* The domain's ENGINE-PROJECTED COMBINED read fields (read-engine: combined read
|
|
2425
|
+
* fields) — the JOIN analogue of {@link deriveds}. Each is a PURE fn of ONE owner
|
|
2426
|
+
* aggregate's folded fields AND a RELATED aggregate's folded fields (reached by a typed
|
|
2427
|
+
* ref edge), computed BY THE ENGINE during the projection projection and stored ONLY in
|
|
2428
|
+
* the read model (NEVER stamped into the ledger). ADDITIVE + OPTIONAL: a domain that
|
|
2429
|
+
* declares no combined field omits this entirely. Carried into the read manifest by
|
|
2430
|
+
* `emit_manifests.ts` (the `{id, of, refField, reads}` decl — the fn SOURCE ships in the
|
|
2431
|
+
* engine bundle's `planReport` derive/combine-dispatch, NOT the manifest) and the
|
|
2432
|
+
* identity manifest by `manifest.ts` with omit-when-empty semantics (the fn body is NOT
|
|
2433
|
+
* hashed). The `reads` type is the CROSS-AGGREGATE INVALIDATION key: the read engine
|
|
2434
|
+
* recomputes every owner's combined field when a projection delta touches that type.
|
|
2435
|
+
*/
|
|
2436
|
+
combineds?: CombinedDecl[];
|
|
2437
|
+
/**
|
|
2438
|
+
* Wire types (e.g. `["SiteRootAggregate"]`) for which to emit generated read
|
|
2439
|
+
* model classes (`SiteReadModel`). A domain that omits this emits no read-model
|
|
2440
|
+
* classes. Generalizing = widening this allowlist (or, once
|
|
2441
|
+
* proven across the field-kind matrix, defaulting it to every aggregate). A type not in
|
|
2442
|
+
* `aggregates` is ignored (nothing to emit). NOT carried into any manifest/hash — it is
|
|
2443
|
+
* a pure FRONTEND codegen choice, so no domain identity changes.
|
|
2444
|
+
*/
|
|
2445
|
+
readModelAggregates?: string[];
|
|
2446
|
+
/**
|
|
2447
|
+
* The domain's `impureCapability` capabilities (framework Order→Receipt; `impure_capability.ts`)
|
|
2448
|
+
* for which to ALSO emit the typed PROVIDER SDK — the symmetric twin of the peer
|
|
2449
|
+
* payload classes. For each declared task the codegen emits a typed `XOrder` (+
|
|
2450
|
+
* `fromJson`), `XResult`/`XFailure`, a `XHandler` typedef and a `XProvider` dispatcher
|
|
2451
|
+
* that decodes the incoming Order JSON and encodes the Receipt (reusing the
|
|
2452
|
+
* peer-codegen `CompleteXPayload`/`FailXPayload`, so the Receipt is an ordinary
|
|
2453
|
+
* intent through the ONE write path). ADDITIVE + OPTIONAL: a domain that omits this is
|
|
2454
|
+
* byte-identical to before. NOT carried into any manifest/hash — a pure provider-side
|
|
2455
|
+
* codegen choice, like {@link readModelAggregates}. Each entry is a {@link ImpureCapabilityDecl}
|
|
2456
|
+
* (the `impureCapability(...)` factory return value satisfies it structurally).
|
|
2457
|
+
*/
|
|
2458
|
+
impureCapabilities?: ImpureCapabilityDecl[];
|
|
2459
|
+
/**
|
|
2460
|
+
* Optional permission/RBAC vocabulary declared by a tenant domain. This is not
|
|
2461
|
+
* executable law; it is generated frontend vocabulary over the domain-declared
|
|
2462
|
+
* resource types and role catalogue so app code never invents role strings in
|
|
2463
|
+
* Dart.
|
|
2464
|
+
*/
|
|
2465
|
+
permissionVocabulary?: PermissionVocabulary;
|
|
2466
|
+
/**
|
|
2467
|
+
* Extra Dart imports needed by this generated file. Used when a domain has typed
|
|
2468
|
+
* refs to aggregate id classes emitted by another generated domain file.
|
|
2469
|
+
*/
|
|
2470
|
+
dartImports?: DartImport[];
|
|
2471
|
+
/**
|
|
2472
|
+
* Mapping from referenced aggregate wire type to the Dart import prefix that exposes
|
|
2473
|
+
* that aggregate's typed id class. This preserves cross-domain ref typing without
|
|
2474
|
+
* forcing every domain into one giant generated library.
|
|
2475
|
+
*/
|
|
2476
|
+
dartRefImports?: DartRefImport[];
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
/** Generate the full Dart source for one domain module. */
|
|
2480
|
+
export function generateDartDomain(mod: DomainModule): string {
|
|
2481
|
+
const domainKey = mod.domain ?? mod.name;
|
|
2482
|
+
const { dartTypes, decls } = collectEnums(mod.aggregates, mod.directives);
|
|
2483
|
+
const refIdClassForAggregate = refIdClassResolver(mod);
|
|
2484
|
+
const aggregatesById = new Map<string, AggregateHandle>();
|
|
2485
|
+
for (const a of mod.aggregates) aggregatesById.set(a.id, a);
|
|
2486
|
+
const ownIdFieldsByAggregate = collectOwnIdFieldsByAggregate(
|
|
2487
|
+
mod.aggregates,
|
|
2488
|
+
mod.directives,
|
|
2489
|
+
dartTypes,
|
|
2490
|
+
);
|
|
2491
|
+
const enumBlock = decls.join("\n\n");
|
|
2492
|
+
const permissionVocabularyBlock = emitPermissionVocabulary(mod.permissionVocabulary);
|
|
2493
|
+
const aggBlock = mod.aggregates
|
|
2494
|
+
.map((a) =>
|
|
2495
|
+
emitAggregate(
|
|
2496
|
+
a,
|
|
2497
|
+
dartTypes,
|
|
2498
|
+
refIdClassForAggregate,
|
|
2499
|
+
ownIdFieldsByAggregate.get(a.id) ?? new Set(),
|
|
2500
|
+
),
|
|
2501
|
+
)
|
|
2502
|
+
.join("\n\n");
|
|
2503
|
+
const dirBlock = mod.directives
|
|
2504
|
+
.map((d) => emitDirective(d, domainKey, dartTypes))
|
|
2505
|
+
.join("\n\n");
|
|
2506
|
+
|
|
2507
|
+
// TYPED IDS + MINTED CREATES (the id-mint keystone, GENERALIZED): a DISTINCT typed
|
|
2508
|
+
// id class for EVERY aggregate (cross-aggregate type-confusion is a compile error;
|
|
2509
|
+
// ref fields name their target's typed id), plus a top-level `create…` factory that
|
|
2510
|
+
// mints THROUGH the kernel (no raw id) for every `.creates` directive carrying a
|
|
2511
|
+
// `mintSeed` + an own-id field.
|
|
2512
|
+
const mintedBlock = emitMintedCreates(mod.aggregates, mod.directives, domainKey, dartTypes);
|
|
2513
|
+
|
|
2514
|
+
// PROVIDER SDK (the symmetric twin of the peer payload classes): for each declared
|
|
2515
|
+
// `impureCapability` capability, a typed Order/Result/Failure + handler typedef + dispatcher.
|
|
2516
|
+
// ADDITIVE + omit-when-empty: a domain with no `impureCapabilities` emits nothing (and the
|
|
2517
|
+
// `provider.dart` import is omitted), so it is byte-identical to before. The enum map is
|
|
2518
|
+
// SHARED with the directive/aggregate codegen so an enum-typed order param references the
|
|
2519
|
+
// already-emitted domain enum (no duplicate decl).
|
|
2520
|
+
const providerBlock = emitProviderSdk(mod.impureCapabilities ?? [], dartTypes);
|
|
2521
|
+
|
|
2522
|
+
// Read-model classes emitted for aggregate wire types selected by
|
|
2523
|
+
// `readModelAggregates`.
|
|
2524
|
+
// additive frontend codegen — no manifest/hash impact.
|
|
2525
|
+
const readModelTypes = new Set(mod.readModelAggregates ?? []);
|
|
2526
|
+
// DERIVED read fields by aggregate type (read-engine: derived read fields): each
|
|
2527
|
+
// `…ReadModel` ALSO carries the engine-projected derived fields its type declares (e.g.
|
|
2528
|
+
// `SupportSessionReadModel.isTerminal`), decoded like a normal projected field from the
|
|
2529
|
+
// row's `data` (the Rust read engine stores it there as a json-leaf).
|
|
2530
|
+
const derivedsByType = new Map<string, DerivedDecl[]>();
|
|
2531
|
+
for (const d of mod.deriveds ?? []) {
|
|
2532
|
+
const list = derivedsByType.get(d.of) ?? [];
|
|
2533
|
+
list.push(d);
|
|
2534
|
+
derivedsByType.set(d.of, list);
|
|
2535
|
+
}
|
|
2536
|
+
// COMBINED read fields by OWNER aggregate type (read-engine: combined read fields):
|
|
2537
|
+
// the JOIN analogue — each `…ReadModel` ALSO carries the engine-projected combined
|
|
2538
|
+
// fields its type owns (e.g. `BuildingReadModel.siteName`), decoded like derived.
|
|
2539
|
+
const combinedsByType = new Map<string, CombinedDecl[]>();
|
|
2540
|
+
for (const c of mod.combineds ?? []) {
|
|
2541
|
+
const list = combinedsByType.get(c.of) ?? [];
|
|
2542
|
+
list.push(c);
|
|
2543
|
+
combinedsByType.set(c.of, list);
|
|
2544
|
+
}
|
|
2545
|
+
const readModelBlock = mod.aggregates
|
|
2546
|
+
.filter((a) => readModelTypes.has(a.id))
|
|
2547
|
+
.map((a) =>
|
|
2548
|
+
emitReadModel(
|
|
2549
|
+
a,
|
|
2550
|
+
dartTypes,
|
|
2551
|
+
derivedsByType.get(a.id) ?? [],
|
|
2552
|
+
combinedsByType.get(a.id) ?? [],
|
|
2553
|
+
refIdClassForAggregate,
|
|
2554
|
+
ownIdFieldsByAggregate.get(a.id) ?? new Set(),
|
|
2555
|
+
),
|
|
2556
|
+
)
|
|
2557
|
+
.join("\n\n");
|
|
2558
|
+
|
|
2559
|
+
// READ accessors: per declared query (indexed reads) + per aggregate (by-id PK
|
|
2560
|
+
// reads). A domain that declares no `queries` still gets the by-id reads for its
|
|
2561
|
+
// aggregates (they map onto `query_by_id`, which needs no registration).
|
|
2562
|
+
// SLICE 1: also emit per declared count (maintained counter O(1) read) and per
|
|
2563
|
+
// declared sum (maintained total O(1) read). These are NEW in Slice 1 — the spec
|
|
2564
|
+
// §5 claim that "codegen already emits count reads" was wrong; this is the actual
|
|
2565
|
+
// first emission. The predicate is baked into the maintained counter and is
|
|
2566
|
+
// INVISIBLE to the generated accessor (worldview law).
|
|
2567
|
+
const queryReads = (mod.queries ?? [])
|
|
2568
|
+
.map((q) =>
|
|
2569
|
+
emitQueryReads(
|
|
2570
|
+
q,
|
|
2571
|
+
aggregatesById,
|
|
2572
|
+
dartTypes,
|
|
2573
|
+
refIdClassForAggregate,
|
|
2574
|
+
ownIdFieldsByAggregate,
|
|
2575
|
+
),
|
|
2576
|
+
)
|
|
2577
|
+
.join("\n\n");
|
|
2578
|
+
const countReads = (mod.counts ?? [])
|
|
2579
|
+
.map(finishCount)
|
|
2580
|
+
.map((c) =>
|
|
2581
|
+
emitCountReads(
|
|
2582
|
+
c,
|
|
2583
|
+
aggregatesById,
|
|
2584
|
+
dartTypes,
|
|
2585
|
+
refIdClassForAggregate,
|
|
2586
|
+
ownIdFieldsByAggregate,
|
|
2587
|
+
),
|
|
2588
|
+
)
|
|
2589
|
+
.join("\n\n");
|
|
2590
|
+
const sumReads = (mod.sums ?? [])
|
|
2591
|
+
.map(finishSum)
|
|
2592
|
+
.map((s) =>
|
|
2593
|
+
emitSumReads(
|
|
2594
|
+
s,
|
|
2595
|
+
aggregatesById,
|
|
2596
|
+
dartTypes,
|
|
2597
|
+
refIdClassForAggregate,
|
|
2598
|
+
ownIdFieldsByAggregate,
|
|
2599
|
+
),
|
|
2600
|
+
)
|
|
2601
|
+
.join("\n\n");
|
|
2602
|
+
// SLICE 2a: min/max (order-independent extrema, int?-typed), exists (bool, count>0),
|
|
2603
|
+
// first/take (order-sensitive row reads, ordered by declared key + aggregate_id tiebreak).
|
|
2604
|
+
const minReads = (mod.mins ?? [])
|
|
2605
|
+
.map(finishExtremum)
|
|
2606
|
+
.map((e) =>
|
|
2607
|
+
emitMinReads(
|
|
2608
|
+
e,
|
|
2609
|
+
aggregatesById,
|
|
2610
|
+
dartTypes,
|
|
2611
|
+
refIdClassForAggregate,
|
|
2612
|
+
ownIdFieldsByAggregate,
|
|
2613
|
+
),
|
|
2614
|
+
)
|
|
2615
|
+
.join("\n\n");
|
|
2616
|
+
const maxReads = (mod.maxes ?? [])
|
|
2617
|
+
.map(finishExtremum)
|
|
2618
|
+
.map((e) =>
|
|
2619
|
+
emitMaxReads(
|
|
2620
|
+
e,
|
|
2621
|
+
aggregatesById,
|
|
2622
|
+
dartTypes,
|
|
2623
|
+
refIdClassForAggregate,
|
|
2624
|
+
ownIdFieldsByAggregate,
|
|
2625
|
+
),
|
|
2626
|
+
)
|
|
2627
|
+
.join("\n\n");
|
|
2628
|
+
const existsReads = (mod.exists_ ?? [])
|
|
2629
|
+
.map(finishExists)
|
|
2630
|
+
.map((e) =>
|
|
2631
|
+
emitExistsReads(
|
|
2632
|
+
e,
|
|
2633
|
+
aggregatesById,
|
|
2634
|
+
dartTypes,
|
|
2635
|
+
refIdClassForAggregate,
|
|
2636
|
+
ownIdFieldsByAggregate,
|
|
2637
|
+
),
|
|
2638
|
+
)
|
|
2639
|
+
.join("\n\n");
|
|
2640
|
+
const orderedReadBlock = (mod.orderedReads ?? []).map(emitOrderedReads).join("\n\n");
|
|
2641
|
+
const byIdReads = mod.aggregates.map(emitAggregateByIdReads).join("\n\n");
|
|
2642
|
+
const readBlock = [queryReads, countReads, sumReads, minReads, maxReads, existsReads, orderedReadBlock, byIdReads]
|
|
2643
|
+
.filter((s) => s !== "")
|
|
2644
|
+
.join("\n\n");
|
|
2645
|
+
|
|
2646
|
+
// `_mapEq` is only referenced by aggregate `==`/`hashCode` for MAP fields. Emit it
|
|
2647
|
+
// ONLY when some aggregate has a map field, else a map-less domain trips an
|
|
2648
|
+
// unused-element lint.
|
|
2649
|
+
const hasMapField = mod.aggregates.some((a) =>
|
|
2650
|
+
Object.values(a.fields as Record<string, Field>).some((f) => f.kind === "map"),
|
|
2651
|
+
);
|
|
2652
|
+
const helperBlock = hasMapField
|
|
2653
|
+
? [
|
|
2654
|
+
`// ---- helpers ----`,
|
|
2655
|
+
`bool _mapEq<K, V>(Map<K, V> a, Map<K, V> b) {`,
|
|
2656
|
+
` if (a.length != b.length) return false;`,
|
|
2657
|
+
` for (final e in a.entries) {`,
|
|
2658
|
+
` if (!b.containsKey(e.key) || b[e.key] != e.value) return false;`,
|
|
2659
|
+
` }`,
|
|
2660
|
+
` return true;`,
|
|
2661
|
+
`}`,
|
|
2662
|
+
]
|
|
2663
|
+
: [];
|
|
2664
|
+
|
|
2665
|
+
return [
|
|
2666
|
+
`// GENERATED by nomos2/dsl/src/codegen_dart.ts — DO NOT EDIT BY HAND.`,
|
|
2667
|
+
`//`,
|
|
2668
|
+
`// Source of truth: the '${mod.name}' domain (ts_packages/co2-nomos-domains/src/domains/`,
|
|
2669
|
+
`// for tenant domains; nomos2/dsl framework domains for the re-exported framework ones).`,
|
|
2670
|
+
`// Regenerate: \`npx tsx src/emit_dart.ts\` from ts_packages/co2-nomos-domains.`,
|
|
2671
|
+
`//`,
|
|
2672
|
+
`// ignore_for_file: unnecessary_this, prefer_const_constructors, constant_identifier_names`,
|
|
2673
|
+
`// ignore_for_file: unused_import`,
|
|
2674
|
+
``,
|
|
2675
|
+
// The generated tenant domains live in the `co2_nomos_domains` package and depend
|
|
2676
|
+
// on the framework `nomos_dsl` package by its PUBLIC package: URI — NOT a relative
|
|
2677
|
+
// `../wire.dart` path into the framework (which broke when the generated dir moved
|
|
2678
|
+
// out of nomos_dsl into the co-located tenant package). The framework barrel
|
|
2679
|
+
// re-exports wire/dispatch/subscriptions/provider, so one import covers them all.
|
|
2680
|
+
`import 'package:nomos_dsl/nomos_dsl.dart';`,
|
|
2681
|
+
...(mod.dartImports ?? []).map(dartImportLine),
|
|
2682
|
+
``,
|
|
2683
|
+
`// ---- enums ----`,
|
|
2684
|
+
enumBlock,
|
|
2685
|
+
``,
|
|
2686
|
+
...(permissionVocabularyBlock !== ""
|
|
2687
|
+
? [
|
|
2688
|
+
`// ---- permission vocabulary (generated from domain RBAC declarations) ----`,
|
|
2689
|
+
permissionVocabularyBlock,
|
|
2690
|
+
``,
|
|
2691
|
+
]
|
|
2692
|
+
: []),
|
|
2693
|
+
...(moduleUsesEvidence(mod)
|
|
2694
|
+
? [
|
|
2695
|
+
`// ---- evidence (content-addressed EvidenceRef; evidence.md §9.2) ----`,
|
|
2696
|
+
emitEvidenceRefClass(),
|
|
2697
|
+
``,
|
|
2698
|
+
]
|
|
2699
|
+
: []),
|
|
2700
|
+
`// ---- aggregates (projection types) ----`,
|
|
2701
|
+
aggBlock,
|
|
2702
|
+
``,
|
|
2703
|
+
...(readModelBlock !== ""
|
|
2704
|
+
? [
|
|
2705
|
+
`// ---- generated read models (from aggregate DSL fields) ----`,
|
|
2706
|
+
readModelBlock,
|
|
2707
|
+
``,
|
|
2708
|
+
]
|
|
2709
|
+
: []),
|
|
2710
|
+
`// ---- public intents (typed DTOs; ship via the generic dispatch() verb) ----`,
|
|
2711
|
+
dirBlock,
|
|
2712
|
+
``,
|
|
2713
|
+
...(mintedBlock !== ""
|
|
2714
|
+
? [
|
|
2715
|
+
`// ---- typed ids + minted creates (kernel id-mint: per-aggregate typed id classes + create… factories) ----`,
|
|
2716
|
+
mintedBlock,
|
|
2717
|
+
``,
|
|
2718
|
+
]
|
|
2719
|
+
: []),
|
|
2720
|
+
`// ---- read accessors (typed subscriptions over the GitHolon query()/query_by_id() surface) ----`,
|
|
2721
|
+
readBlock,
|
|
2722
|
+
``,
|
|
2723
|
+
...(providerBlock !== ""
|
|
2724
|
+
? [
|
|
2725
|
+
`// ---- provider SDK (capability providers; the symmetric twin of the peer payload classes) ----`,
|
|
2726
|
+
providerBlock,
|
|
2727
|
+
``,
|
|
2728
|
+
]
|
|
2729
|
+
: []),
|
|
2730
|
+
...helperBlock,
|
|
2731
|
+
].join("\n");
|
|
2732
|
+
}
|