@githolon/dsl 0.1.6 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/codegen_proof.ts +430 -0
- package/src/codegen_ts.ts +92 -14
- package/src/compile_package_main.ts +41 -4
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -0,0 +1,430 @@
|
|
|
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
|
|
4
|
+
// QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
|
|
5
|
+
// wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
|
|
6
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GENERATED PROOF CODEGEN — `build/<name>.proof.mts`, the runnable e2e SYNTHESIZED
|
|
10
|
+
* FROM THE LAW ITSELF (the field-test-6 ask: reshaping the domain meant rewriting
|
|
11
|
+
* test/e2e.mts by hand; now the proof reshapes itself on every compile).
|
|
12
|
+
*
|
|
13
|
+
* `generateTsProof(modules, opts)` reads the domain's ACTUAL directives / queries /
|
|
14
|
+
* counts (the same composed modules every other emitter sees) and emits a
|
|
15
|
+
* domain-shaped live proof:
|
|
16
|
+
*
|
|
17
|
+
* throwaway workspace → deploy `build/<name>.deploy.json` → connect via the
|
|
18
|
+
* GENERATED typed client → fetch-TRAPPED offline dispatch of the first `.creates`
|
|
19
|
+
* directive (payload synthesized from its zod schema) → first declared query +
|
|
20
|
+
* first count answered LOCALLY → sync (edge admission) → the CLOUD answers →
|
|
21
|
+
* two-client AddWins concurrency (when the law has the shape for it) →
|
|
22
|
+
* convergence → ALL GREEN.
|
|
23
|
+
*
|
|
24
|
+
* Payload SYNTHESIS rules ({@link synthesizePayload}): string → `"sample-<field>"`,
|
|
25
|
+
* enum → its first option, number → 1, boolean → true, array of string →
|
|
26
|
+
* `["sample-a"]`, literal → its value, `.optional()`/`.default()` → omitted,
|
|
27
|
+
* required `…At` strings → omitted (the generated client auto-stamps ISO now() at
|
|
28
|
+
* dispatch — the same {@link autoStampFields} convention codegen_ts types by).
|
|
29
|
+
* NO FALLBACK: a shape the synthesizer can't sample throws, naming the field and
|
|
30
|
+
* the remedy — the compile then SKIPS the proof (the law still ships) and says why.
|
|
31
|
+
*
|
|
32
|
+
* Deterministic codegen: the emitted bytes are a pure function of the modules +
|
|
33
|
+
* package name + domain hash — no timestamps, no randomness (the proof picks its
|
|
34
|
+
* throwaway workspace name at RUN time, not at generation time).
|
|
35
|
+
*/
|
|
36
|
+
import type { z } from "zod";
|
|
37
|
+
|
|
38
|
+
import type { AggregateHandle } from "./aggregate.js";
|
|
39
|
+
import type { Field } from "./fields.js";
|
|
40
|
+
import type { Directive } from "./directive.js";
|
|
41
|
+
import type { DomainModule } from "./codegen_dart.js";
|
|
42
|
+
import type { QueryDecl } from "./query.js";
|
|
43
|
+
import { finishCount } from "./count.js";
|
|
44
|
+
import {
|
|
45
|
+
autoStampFields,
|
|
46
|
+
tsClientFactoryName,
|
|
47
|
+
tsHashConstName,
|
|
48
|
+
zodArrayElement,
|
|
49
|
+
zodDef,
|
|
50
|
+
zodEnumValues,
|
|
51
|
+
zodKind,
|
|
52
|
+
zodObjectShape,
|
|
53
|
+
} from "./codegen_ts.js";
|
|
54
|
+
|
|
55
|
+
const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
|
|
56
|
+
const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
|
|
57
|
+
|
|
58
|
+
// ── payload synthesis (the sample-value sibling of codegen_ts's type walker) ──────
|
|
59
|
+
|
|
60
|
+
export interface SynthesizedPayload {
|
|
61
|
+
/** The literal the proof dispatches. Auto-stamped `…At` fields are OMITTED here. */
|
|
62
|
+
readonly value: Record<string, unknown>;
|
|
63
|
+
/** Required `…At` string fields deliberately omitted — the generated client stamps ISO now() at dispatch. */
|
|
64
|
+
readonly autoStamped: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One sample value for one zod shape. NO FALLBACK: an unhandled shape throws, naming its remedy. */
|
|
68
|
+
function sampleOfZod(zt: z.ZodTypeAny, field: string, where: string): unknown {
|
|
69
|
+
switch (zodKind(zt)) {
|
|
70
|
+
case "string":
|
|
71
|
+
return `sample-${field}`;
|
|
72
|
+
case "number":
|
|
73
|
+
return 1;
|
|
74
|
+
case "boolean":
|
|
75
|
+
return true;
|
|
76
|
+
case "enum":
|
|
77
|
+
return zodEnumValues(zt)[0];
|
|
78
|
+
case "literal": {
|
|
79
|
+
const def = zodDef(zt);
|
|
80
|
+
return def.value !== undefined ? def.value : def.values?.[0];
|
|
81
|
+
}
|
|
82
|
+
case "array": {
|
|
83
|
+
const el = zodArrayElement(zt);
|
|
84
|
+
return zodKind(el) === "string" ? ["sample-a"] : [sampleOfZod(el, field, where)];
|
|
85
|
+
}
|
|
86
|
+
case "object": {
|
|
87
|
+
const out: Record<string, unknown> = {};
|
|
88
|
+
for (const [name, raw] of Object.entries(zodObjectShape(zt))) {
|
|
89
|
+
const k = zodKind(raw as z.ZodTypeAny);
|
|
90
|
+
if (k === "optional" || k === "default") continue; // optional → omit
|
|
91
|
+
out[name] = sampleOfZod(raw as z.ZodTypeAny, name, where);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
case "record":
|
|
96
|
+
return {};
|
|
97
|
+
case "nullable":
|
|
98
|
+
case "optional":
|
|
99
|
+
case "default":
|
|
100
|
+
return sampleOfZod(zodDef(zt).innerType!, field, where);
|
|
101
|
+
case "union": {
|
|
102
|
+
const first = zodDef(zt).options?.[0];
|
|
103
|
+
if (first !== undefined) return sampleOfZod(first, field, where);
|
|
104
|
+
throw new Error(`codegen-proof: empty union at field '${field}' of ${where}`);
|
|
105
|
+
}
|
|
106
|
+
default:
|
|
107
|
+
throw new Error(
|
|
108
|
+
`codegen-proof: cannot synthesize a sample for zod shape '${zodKind(zt)}' (field '${field}' of ${where}) — reshape the payload to a sampleable type, or prove that directive by hand in test/e2e.mts`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Synthesize a dispatchable sample payload from a directive's zod object schema:
|
|
115
|
+
* optional/defaulted fields omitted, required `…At` strings omitted (auto-stamped
|
|
116
|
+
* by the generated client), everything else sampled by {@link sampleOfZod}.
|
|
117
|
+
*/
|
|
118
|
+
export function synthesizePayload(schema: z.ZodTypeAny, where: string): SynthesizedPayload {
|
|
119
|
+
if (zodKind(schema) !== "object") {
|
|
120
|
+
throw new Error(`codegen-proof: ${where} payload is not a zod object — the proof can only synthesize object payloads`);
|
|
121
|
+
}
|
|
122
|
+
const autoStamped = autoStampFields(schema);
|
|
123
|
+
const stamped = new Set(autoStamped);
|
|
124
|
+
const value: Record<string, unknown> = {};
|
|
125
|
+
for (const [name, raw] of Object.entries(zodObjectShape(schema))) {
|
|
126
|
+
const f = raw as z.ZodTypeAny;
|
|
127
|
+
const k = zodKind(f);
|
|
128
|
+
if (k === "optional" || k === "default") continue; // optional → omit
|
|
129
|
+
if (stamped.has(name)) continue; // `…At` → omit; the client stamps it
|
|
130
|
+
value[name] = sampleOfZod(f, name, where);
|
|
131
|
+
}
|
|
132
|
+
return { value, autoStamped };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── shape discovery (which legs THIS law can prove) ───────────────────────────────
|
|
136
|
+
|
|
137
|
+
interface ConcurrencyLeg {
|
|
138
|
+
readonly directive: Directive<unknown>;
|
|
139
|
+
/** The payload field that carries the target instance id (first required string `…Id`). */
|
|
140
|
+
readonly idField: string;
|
|
141
|
+
/** The AddWins set field the directive adds to (payload array field name === aggregate set field name). */
|
|
142
|
+
readonly setField: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The AddWins two-writer leg needs the law to have the shape for it: a `.mutates`
|
|
147
|
+
* directive on the created aggregate whose payload carries (a) a required string
|
|
148
|
+
* `…Id` field (the instance) and (b) an array-of-string field named exactly like
|
|
149
|
+
* one of the aggregate's AddWins set fields (the `addToSet` signature). Absent
|
|
150
|
+
* that shape, the proof prints a skip note instead of inventing law.
|
|
151
|
+
*/
|
|
152
|
+
function findConcurrencyLeg(
|
|
153
|
+
directives: readonly Directive<unknown>[],
|
|
154
|
+
agg: AggregateHandle,
|
|
155
|
+
): ConcurrencyLeg | undefined {
|
|
156
|
+
const addWinsSets = Object.entries(agg.fields)
|
|
157
|
+
.filter(([, f]) => (f as Field).kind === "set" && ((f as Field).driver as { kind?: string }).kind === "AddWins")
|
|
158
|
+
.map(([name]) => name);
|
|
159
|
+
if (addWinsSets.length === 0) return undefined;
|
|
160
|
+
for (const d of directives) {
|
|
161
|
+
if (d.marker !== "mutates" || d.aggregateId !== agg.id) continue;
|
|
162
|
+
let shape: Record<string, z.ZodTypeAny>;
|
|
163
|
+
try {
|
|
164
|
+
shape = zodObjectShape(d.payloadSchema as z.ZodTypeAny);
|
|
165
|
+
} catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const idField = Object.entries(shape).find(
|
|
169
|
+
([name, f]) => name.length > 2 && name.endsWith("Id") && zodKind(f) === "string",
|
|
170
|
+
)?.[0];
|
|
171
|
+
if (idField === undefined) continue;
|
|
172
|
+
const setField = addWinsSets.find((s) => {
|
|
173
|
+
const f = shape[s];
|
|
174
|
+
return f !== undefined && zodKind(f) === "array" && zodKind(zodArrayElement(f)) === "string";
|
|
175
|
+
});
|
|
176
|
+
if (setField !== undefined) return { directive: d, idField, setField };
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── the generator ─────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export interface TsProofOptions {
|
|
184
|
+
/** The package name (`nomos.package.mjs` `name`) — names the artifacts the proof loads. */
|
|
185
|
+
readonly packageName: string;
|
|
186
|
+
/** sha256 of the built `.package.usda` — checked against the deploy response. */
|
|
187
|
+
readonly domainHash: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function generateTsProof(modules: readonly DomainModule[], opts: TsProofOptions): string {
|
|
191
|
+
// The proof drives the FIRST domain that has a creating write — without one
|
|
192
|
+
// there is nothing to author, so there is nothing to prove.
|
|
193
|
+
const mod = modules.find((m) =>
|
|
194
|
+
(m.directives as Directive<unknown>[]).some((d) => d?.marker === "creates" && typeof d.payloadSchema === "object"),
|
|
195
|
+
);
|
|
196
|
+
if (mod === undefined) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`no .creates directive in any domain — a generated proof needs one creating write; add a directive(...).creates(...) (test/e2e.mts remains the hand-authored lane)`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const domain = mod.domain ?? mod.name;
|
|
202
|
+
const factory = tsClientFactoryName(domain);
|
|
203
|
+
const hashConst = tsHashConstName(opts.packageName);
|
|
204
|
+
|
|
205
|
+
const createDir = (mod.directives as Directive<unknown>[]).find((d) => d?.marker === "creates")!;
|
|
206
|
+
const agg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === createDir.aggregateId);
|
|
207
|
+
if (agg === undefined) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`directive '${createDir.id}' creates '${createDir.aggregateId}' but no such aggregate is composed — export the aggregate from a listed module`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const synth = synthesizePayload(createDir.payloadSchema as z.ZodTypeAny, `${domain}/${createDir.id}`);
|
|
213
|
+
|
|
214
|
+
// The first declared query that can READ BACK the create: returns the created
|
|
215
|
+
// aggregate and every key field has a synthesized payload value to probe with.
|
|
216
|
+
const query = ((mod.queries ?? []) as QueryDecl[]).find(
|
|
217
|
+
(q) => q.returns === agg.id && q.key.length > 0 && q.key.every((k) => !k.includes(".") && synth.value[k] !== undefined),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// The first declared count the create moves: counts the created aggregate,
|
|
221
|
+
// unpredicated (a .where can't be evaluated at codegen time), and — when
|
|
222
|
+
// grouped — grouped by a field the synthesized payload carries.
|
|
223
|
+
const count = (mod.counts ?? [])
|
|
224
|
+
.map((raw) => finishCount(raw))
|
|
225
|
+
.find(
|
|
226
|
+
(c) =>
|
|
227
|
+
c.of === agg.id &&
|
|
228
|
+
(c as { where?: unknown }).where == null &&
|
|
229
|
+
(c.by == null || synth.value[c.by] !== undefined),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const concurrency = findConcurrencyLeg(mod.directives as Directive<unknown>[], agg);
|
|
233
|
+
|
|
234
|
+
// Workspace-name slug: any package name → a safe, recognizable ws prefix.
|
|
235
|
+
const slug =
|
|
236
|
+
opts.packageName
|
|
237
|
+
.toLowerCase()
|
|
238
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
239
|
+
.replace(/^-+|-+$/g, "")
|
|
240
|
+
.slice(0, 24) || "domain";
|
|
241
|
+
|
|
242
|
+
const payloadLit = JSON.stringify(synth.value);
|
|
243
|
+
const queryParams = query ? JSON.stringify(Object.fromEntries(query.key.map((k) => [k, synth.value[k]]))) : "";
|
|
244
|
+
const listAccessor = `list${agg.id}s`;
|
|
245
|
+
|
|
246
|
+
let step = 0;
|
|
247
|
+
const n = () => ++step;
|
|
248
|
+
const out: string[] = [];
|
|
249
|
+
|
|
250
|
+
out.push(
|
|
251
|
+
`// AUTO-GENERATED by nomos-compile — the RUNNABLE PROOF for package "${opts.packageName}",`,
|
|
252
|
+
`// synthesized from the domain's OWN directives/queries/counts. Reshape the law,`,
|
|
253
|
+
`// recompile, and this proof reshapes itself — no test rewriting. Run it:`,
|
|
254
|
+
`//`,
|
|
255
|
+
`// npx githolon proof (or: npx tsx build/${opts.packageName}.proof.mts)`,
|
|
256
|
+
`//`,
|
|
257
|
+
`// The flow mirrors the scaffold's test/e2e.mts (docs/ in a scaffolded app explains`,
|
|
258
|
+
`// every call — start at docs/01-mental-model.md). Regenerated on every compile; do not edit.`,
|
|
259
|
+
`import { readFileSync } from "node:fs";`,
|
|
260
|
+
`import { connect } from "@githolon/client";`,
|
|
261
|
+
`import { ${factory}, ${hashConst} } from "./${opts.packageName}.client.ts";`,
|
|
262
|
+
``,
|
|
263
|
+
`const CLOUD = (process.env.NOMOS_CLOUD || "https://nomos.captainapp.co.uk").replace(/\\/+$/, "");`,
|
|
264
|
+
`const WS = process.env.NOMOS_WS || ${JSON.stringify(slug + "-proof-")} + Math.random().toString(36).slice(2, 8);`,
|
|
265
|
+
``,
|
|
266
|
+
`// IDENTITY NOTE: this proof uses the bare x-nomos-principal lane so it is`,
|
|
267
|
+
`// SELF-CONTAINED (a throwaway workspace, no stored credentials). Real apps use`,
|
|
268
|
+
`// \`githolon login --agent\` (a VERIFIED identity) + \`githolon ws create/deploy\`.`,
|
|
269
|
+
`const fail: (m: string) => never = (m) => { console.error("✗ " + m); process.exit(1); };`,
|
|
270
|
+
`const ok = (m: string) => console.log("✓ " + m);`,
|
|
271
|
+
``,
|
|
272
|
+
`const deploy = JSON.parse(readFileSync(new URL(${JSON.stringify(`./${opts.packageName}.deploy.json`)}, import.meta.url), "utf8"));`,
|
|
273
|
+
``,
|
|
274
|
+
`// ${n()}. a throwaway workspace (the ONE-TIME secret comes back on create)`,
|
|
275
|
+
`let r = await fetch(\`\${CLOUD}/v1/workspaces/\${WS}\`, { method: "POST", headers: { "x-nomos-principal": "githolon-proof" } });`,
|
|
276
|
+
`let d = await r.json();`,
|
|
277
|
+
`if (!d.ok) fail(\`create workspace: \${JSON.stringify(d)}\`);`,
|
|
278
|
+
`const SECRET: string = d.workspaceSecret;`,
|
|
279
|
+
`if (!SECRET?.startsWith("nws_v1_")) fail(\`no workspaceSecret returned: \${JSON.stringify(d)}\`);`,
|
|
280
|
+
`ok(\`workspace \${WS} created — secret \${SECRET.slice(0, 10)}…\`);`,
|
|
281
|
+
``,
|
|
282
|
+
`// ${n()}. deploy YOUR compiled law (build/${opts.packageName}.deploy.json) WITH the secret`,
|
|
283
|
+
`r = await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/domains\`, { method: "POST", headers: { "content-type": "application/json", authorization: \`Bearer \${SECRET}\` }, body: JSON.stringify(deploy) });`,
|
|
284
|
+
`d = await r.json();`,
|
|
285
|
+
`if (!d.ok || d.installation?.[0]?.data?.["status.phase"] !== "Active") fail(\`deploy: \${JSON.stringify(d)}\`);`,
|
|
286
|
+
`if (d.domainHash !== ${hashConst}) fail(\`deploy hash \${d.domainHash} != the generated client's \${${hashConst}}\`);`,
|
|
287
|
+
`ok(\`law deployed Active — \${${hashConst}.slice(0, 12)}… (the content hash baked into your typed client)\`);`,
|
|
288
|
+
``,
|
|
289
|
+
`// ${n()}. connect + bind the GENERATED typed client`,
|
|
290
|
+
`const holon = await connect({ cloud: CLOUD, workspace: WS, clientId: "proof-1" });`,
|
|
291
|
+
`const app = ${factory}(holon);`,
|
|
292
|
+
`ok(\`web client connected — ${factory} bound (replica \${holon.replica.slice(0, 6)}…)\`);`,
|
|
293
|
+
``,
|
|
294
|
+
`// ${n()}. OFFLINE write — the proof is ENFORCED, not narrated: while we author and`,
|
|
295
|
+
`// read LOCALLY, fetch THROWS. The payload below was SYNTHESIZED from the zod`,
|
|
296
|
+
`// schema of \`${domain}/${createDir.id}\`${synth.autoStamped.length ? ` (${synth.autoStamped.map((s) => `\`${s}\``).join("/")} omitted — the client auto-stamps ISO now())` : ``}.`,
|
|
297
|
+
`const realFetch = globalThis.fetch;`,
|
|
298
|
+
`globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: local authoring touched the network"); }) as typeof fetch;`,
|
|
299
|
+
``,
|
|
300
|
+
`await app.${createDir.id}(${payloadLit});`,
|
|
301
|
+
`ok("dispatch ${domain}/${createDir.id} — offline write under the pulled law (payload synthesized from YOUR schema)");`,
|
|
302
|
+
``,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
if (query) {
|
|
306
|
+
out.push(
|
|
307
|
+
`// ${n()}. the first declared query routes LOCALLY (params from the synthesized payload)`,
|
|
308
|
+
`const rows = await app.${lcFirst(camel(query.id))}(${queryParams});`,
|
|
309
|
+
`if (rows.length !== 1) fail(\`local declared query ${query.id}: \${JSON.stringify(rows)}\`);`,
|
|
310
|
+
`const createdId = rows[0]!.id;`,
|
|
311
|
+
`ok(\`declared query ${query.id} answers locally — ${agg.id} \${createdId}\`);`,
|
|
312
|
+
``,
|
|
313
|
+
);
|
|
314
|
+
} else {
|
|
315
|
+
out.push(
|
|
316
|
+
`// ${n()}. no declared query reads this create back (declare a query(...) keyed on a`,
|
|
317
|
+
`// payload field to see one proved here) — the type-index probe captures the id.`,
|
|
318
|
+
`const rows = await app.${listAccessor}();`,
|
|
319
|
+
`if (rows.length !== 1) fail(\`local ${listAccessor}: \${JSON.stringify(rows)}\`);`,
|
|
320
|
+
`const createdId = rows[0]!.id;`,
|
|
321
|
+
`console.log("– declared-query leg SKIPPED — no query(...) returns ${agg.id} keyed on a synthesized payload field");`,
|
|
322
|
+
`ok(\`local read answers — ${agg.id} \${createdId}\`);`,
|
|
323
|
+
``,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (count) {
|
|
328
|
+
const accessor = lcFirst(camel(count.id));
|
|
329
|
+
const callArg = count.by != null ? JSON.stringify(String(synth.value[count.by])) : "";
|
|
330
|
+
out.push(
|
|
331
|
+
`// ${n()}. the first declared count — the maintained O(1) tally, locally`,
|
|
332
|
+
`const localCount = await app.${accessor}(${callArg});`,
|
|
333
|
+
`if (localCount !== 1) fail(\`local declared count ${count.id}: \${localCount}\`);`,
|
|
334
|
+
`ok("declared count ${count.id}${count.by != null ? `(${callArg.replace(/"/g, "'")})` : `()`} = 1 locally");`,
|
|
335
|
+
``,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
out.push(
|
|
340
|
+
`globalThis.fetch = realFetch;`,
|
|
341
|
+
`ok("offline proof ENFORCED — fetch was trapped: local write/read touched no network");`,
|
|
342
|
+
``,
|
|
343
|
+
`// ${n()}. sync: push the session branch; edge admission re-verifies and merges to main`,
|
|
344
|
+
`const s = await holon.sync();`,
|
|
345
|
+
`if (!s.admission?.ok) fail(\`sync/admit: \${JSON.stringify(s)}\`);`,
|
|
346
|
+
`const admitted = (s.admission.sessions || []).flatMap((x: { admitted?: unknown[] }) => x.admitted || []);`,
|
|
347
|
+
`if (admitted.length < 1) fail(\`edge admitted \${admitted.length} intent(s): \${JSON.stringify(s.admission)}\`);`,
|
|
348
|
+
`ok(\`synced — edge admission merged \${admitted.length} intent(s) to main\`);`,
|
|
349
|
+
``,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
if (query) {
|
|
353
|
+
out.push(
|
|
354
|
+
`// ${n()}. the CLOUD answers the SAME declared query (read-manifest overlay, edge projection)`,
|
|
355
|
+
`d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/query?queryId=${query.id}¶ms=\${encodeURIComponent(${JSON.stringify(queryParams)})}\`)).json();`,
|
|
356
|
+
`if (!(d.ok && d.rows.length === 1 && d.rows[0].id === createdId)) fail(\`cloud declared query: \${JSON.stringify(d)}\`);`,
|
|
357
|
+
`ok("cloud declared query ${query.id} returns the admitted row — full loop closed");`,
|
|
358
|
+
``,
|
|
359
|
+
);
|
|
360
|
+
} else {
|
|
361
|
+
out.push(
|
|
362
|
+
`// ${n()}. the CLOUD answers the by-id read (edge projection of the admitted write)`,
|
|
363
|
+
`d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/aggregates/\${encodeURIComponent(createdId)}\`)).json();`,
|
|
364
|
+
`if (!(d.rows?.length === 1 && d.rows[0].id === createdId)) fail(\`cloud by-id read: \${JSON.stringify(d)}\`);`,
|
|
365
|
+
`ok("cloud read returns the admitted row — full loop closed");`,
|
|
366
|
+
``,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (count) {
|
|
371
|
+
out.push(
|
|
372
|
+
`// ${n()}. the EDGE maintains the same count (same wasm, same law)`,
|
|
373
|
+
`d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/counts/${count.id}${count.by != null ? `?group=\${encodeURIComponent(${JSON.stringify(String(synth.value[count.by]))})}` : ``}\`)).json();`,
|
|
374
|
+
`if (!(d.ok && d.count === 1)) fail(\`cloud count ${count.id}: \${JSON.stringify(d)}\`);`,
|
|
375
|
+
`ok("cloud count ${count.id} = 1 — the O(1) maintained read, at the edge too");`,
|
|
376
|
+
``,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (concurrency) {
|
|
381
|
+
const { directive: cd, idField, setField } = concurrency;
|
|
382
|
+
const cSynth = synthesizePayload(cd.payloadSchema as z.ZodTypeAny, `${domain}/${cd.id}`);
|
|
383
|
+
const concPayload = (writer: string): string => {
|
|
384
|
+
const entries = Object.entries({ ...cSynth.value, [idField]: 0, [setField]: 0 }).map(([k]) => {
|
|
385
|
+
if (k === idField) return `${JSON.stringify(k)}: createdId`;
|
|
386
|
+
if (k === setField) return `${JSON.stringify(k)}: ${JSON.stringify([`proof-${writer}`])}`;
|
|
387
|
+
return `${JSON.stringify(k)}: ${JSON.stringify(cSynth.value[k])}`;
|
|
388
|
+
});
|
|
389
|
+
return `{ ${entries.join(", ")} }`;
|
|
390
|
+
};
|
|
391
|
+
out.push(
|
|
392
|
+
`// ${n()}. CONCURRENCY — two writers, two clientIds, BLIND to each other: each adds to`,
|
|
393
|
+
`// the AddWins set \`${agg.id}.${setField}\` via \`${domain}/${cd.id}\` offline; admission`,
|
|
394
|
+
`// merges both and the union keeps every add.`,
|
|
395
|
+
`const writerA = await connect({ cloud: CLOUD, workspace: WS, clientId: "proof-a" });`,
|
|
396
|
+
`const writerB = await connect({ cloud: CLOUD, workspace: WS, clientId: "proof-b" });`,
|
|
397
|
+
`await ${factory}(writerA).${cd.id}(${concPayload("a")});`,
|
|
398
|
+
`await ${factory}(writerB).${cd.id}(${concPayload("b")});`,
|
|
399
|
+
`await writerA.sync();`,
|
|
400
|
+
`await writerB.sync();`,
|
|
401
|
+
`d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/aggregates/\${encodeURIComponent(createdId)}\`)).json();`,
|
|
402
|
+
`const merged: string[] = d.rows?.[0]?.data?.[${JSON.stringify(setField)}] ?? [];`,
|
|
403
|
+
`if (!(merged.includes("proof-a") && merged.includes("proof-b"))) fail(\`AddWins union lost an add: \${JSON.stringify(merged)}\`);`,
|
|
404
|
+
`ok(\`two blind writers' concurrent adds BOTH survive the merge — ${setField} [\${[...merged].sort().join(", ")}]\`);`,
|
|
405
|
+
``,
|
|
406
|
+
);
|
|
407
|
+
} else {
|
|
408
|
+
out.push(
|
|
409
|
+
`// ${n()}. concurrency leg: this law has no addToSet-capable .mutates directive on an`,
|
|
410
|
+
`// AddWins set field, so there is nothing honest to race.`,
|
|
411
|
+
`console.log("– concurrency leg SKIPPED — add a .mutates directive carrying an array field named like an AddWins set (the addToSet shape) to see two blind writers merge");`,
|
|
412
|
+
``,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
out.push(
|
|
417
|
+
`// ${n()}. CONVERGENCE: the original instance adopts canonical main in place — no reconnect`,
|
|
418
|
+
`await holon.pull();`,
|
|
419
|
+
`const head = await holon.head();`,
|
|
420
|
+
`const refsAdv = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/git/info/refs?service=git-upload-pack\`)).text();`,
|
|
421
|
+
`const remoteMain = (refsAdv.match(/([0-9a-f]{40}) refs\\/heads\\/main/) || [])[1];`,
|
|
422
|
+
`if (head !== remoteMain) fail(\`not converged in place: local \${head?.slice(0, 10)} vs main \${remoteMain?.slice(0, 10)}\`);`,
|
|
423
|
+
`ok("converged in place — local head == canonical main");`,
|
|
424
|
+
``,
|
|
425
|
+
`console.log(\`\\nALL GREEN — generated proof for "${opts.packageName}": deploy → offline write → local reads → admission → cloud reads${concurrency ? ` → AddWins merge` : ``} → convergence (\${WS})\`);`,
|
|
426
|
+
``,
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
return out.join("\n");
|
|
430
|
+
}
|
package/src/codegen_ts.ts
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
* * a PAYLOAD TYPE per directive — derived from the SAME zod schema the sealed
|
|
19
19
|
* engine validates (recursive zod→TS: enums become literal unions, `.optional()`
|
|
20
20
|
* becomes `?`, `.int()` stays `number`), so a payload the type system accepts is
|
|
21
|
-
* a payload the engine accepts;
|
|
21
|
+
* a payload the engine accepts; required string fields named `…At` are
|
|
22
|
+
* CALLER-STAMPED TIMESTAMPS — optional in the type, auto-filled with ISO now()
|
|
23
|
+
* at dispatch (see {@link autoStampFields});
|
|
22
24
|
* * a READ-MODEL interface per aggregate — derived from the DSL `Field` kinds the
|
|
23
25
|
* projection decodes by (set→`T[]`, map→`Record<string, V>` via `mapValueKind`,
|
|
24
26
|
* `t.jsonObject()`→`Record<string, unknown>`, plain `t.json()`→`unknown`), with
|
|
@@ -45,11 +47,24 @@ import type { DerivedDecl } from "./derived.js";
|
|
|
45
47
|
import type { CombinedDecl } from "./combined.js";
|
|
46
48
|
|
|
47
49
|
const cap = (s: string) => (s.length ? s[0]!.toUpperCase() + s.slice(1) : s);
|
|
48
|
-
const camel = (s: string) => s.replace(/[_-]+(\w)/g, (_m, c: string) => c.toUpperCase());
|
|
50
|
+
const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
|
|
49
51
|
const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
|
|
50
52
|
const upperSnake = (s: string) => s.replace(/[-\s]+/g, "_").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
|
|
51
53
|
|
|
54
|
+
// ── NAME NORMALIZATION (no naming convention to learn) ────────────────────────────
|
|
55
|
+
// `nomos.package.mjs` names and domain keys may be ANY string — kebab-case,
|
|
56
|
+
// snake_case, space-separated, PascalCase. Artifact FILENAMES keep the raw name
|
|
57
|
+
// verbatim; generated SYMBOLS normalize it:
|
|
58
|
+
|
|
59
|
+
/** `tool-library` / `tool_library` / `Tool Library` → `toolLibraryClient` — the factory symbol. */
|
|
60
|
+
export const tsClientFactoryName = (domain: string): string => `${lcFirst(camel(domain))}Client`;
|
|
61
|
+
|
|
62
|
+
/** `tool-library` / `tool_library` / `Tool Library` → `TOOL_LIBRARY_DOMAIN_HASH` — the hash const. */
|
|
63
|
+
export const tsHashConstName = (packageName: string): string => `${upperSnake(packageName)}_DOMAIN_HASH`;
|
|
64
|
+
|
|
52
65
|
// ── zod → TS (the same `_def ?? def` convention as codegen_dart's walker) ─────────
|
|
66
|
+
// The walkers are exported: codegen_proof.ts synthesizes SAMPLE payloads from the
|
|
67
|
+
// same zod shapes this file derives TYPES from — one introspection, two emitters.
|
|
53
68
|
|
|
54
69
|
type ZodInternalDef = {
|
|
55
70
|
type?: string | z.ZodTypeAny;
|
|
@@ -63,17 +78,17 @@ type ZodInternalDef = {
|
|
|
63
78
|
valueType?: z.ZodTypeAny;
|
|
64
79
|
};
|
|
65
80
|
|
|
66
|
-
function zodDef(zt: z.ZodTypeAny): ZodInternalDef {
|
|
81
|
+
export function zodDef(zt: z.ZodTypeAny): ZodInternalDef {
|
|
67
82
|
const raw = zt as unknown as { _def?: ZodInternalDef; def?: ZodInternalDef };
|
|
68
83
|
return raw._def ?? raw.def ?? {};
|
|
69
84
|
}
|
|
70
85
|
|
|
71
|
-
function zodKind(zt: z.ZodTypeAny): string {
|
|
86
|
+
export function zodKind(zt: z.ZodTypeAny): string {
|
|
72
87
|
const t = zodDef(zt).type;
|
|
73
88
|
return typeof t === "string" ? t : "unknown";
|
|
74
89
|
}
|
|
75
90
|
|
|
76
|
-
function zodEnumValues(zt: z.ZodTypeAny): string[] {
|
|
91
|
+
export function zodEnumValues(zt: z.ZodTypeAny): string[] {
|
|
77
92
|
const raw = zt as unknown as { options?: readonly unknown[] };
|
|
78
93
|
if (raw.options !== undefined) return raw.options.map(String);
|
|
79
94
|
const entries = zodDef(zt).entries;
|
|
@@ -81,7 +96,7 @@ function zodEnumValues(zt: z.ZodTypeAny): string[] {
|
|
|
81
96
|
throw new Error("codegen-ts: ZodEnum has no values");
|
|
82
97
|
}
|
|
83
98
|
|
|
84
|
-
function zodObjectShape(zt: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
|
|
99
|
+
export function zodObjectShape(zt: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
|
|
85
100
|
const shape = zodDef(zt).shape;
|
|
86
101
|
if (typeof shape === "function") return shape();
|
|
87
102
|
if (shape !== undefined) return shape;
|
|
@@ -90,7 +105,7 @@ function zodObjectShape(zt: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
|
|
|
90
105
|
throw new Error("codegen-ts: ZodObject has no shape");
|
|
91
106
|
}
|
|
92
107
|
|
|
93
|
-
function zodArrayElement(zt: z.ZodTypeAny): z.ZodTypeAny {
|
|
108
|
+
export function zodArrayElement(zt: z.ZodTypeAny): z.ZodTypeAny {
|
|
94
109
|
const def = zodDef(zt);
|
|
95
110
|
if (def.element !== undefined) return def.element;
|
|
96
111
|
if (typeof def.type !== "string" && def.type !== undefined) return def.type;
|
|
@@ -151,6 +166,44 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
|
|
|
151
166
|
}
|
|
152
167
|
}
|
|
153
168
|
|
|
169
|
+
// ── caller-side clock sugar (the `…At` payload convention) ────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Top-level REQUIRED string payload fields named `…At` (`signedAt`, `updatedAt`,
|
|
173
|
+
* `stampedAt`, …) are CALLER-STAMPED TIMESTAMPS by convention: the generated client
|
|
174
|
+
* fills an omitted one with `new Date().toISOString()` at dispatch. The stamp rides
|
|
175
|
+
* IN the payload — the plan stays a pure function of `(payload, ports)` and replays
|
|
176
|
+
* byte-identical on every peer — so the domain dev stops thinking about clocks.
|
|
177
|
+
* Passing the field explicitly always wins (scheduling-shaped fields like
|
|
178
|
+
* `expiresAt` should be passed explicitly — "now" is only the DEFAULT).
|
|
179
|
+
*/
|
|
180
|
+
export function autoStampFields(schema: z.ZodTypeAny): string[] {
|
|
181
|
+
if (zodKind(schema) !== "object") return [];
|
|
182
|
+
const out: string[] = [];
|
|
183
|
+
for (const [name, f] of Object.entries(zodObjectShape(schema))) {
|
|
184
|
+
// required-only: `.optional()`/`.default()` wrappers change the zod kind, so they skip.
|
|
185
|
+
if (name.length > 2 && name.endsWith("At") && zodKind(f as z.ZodTypeAny) === "string") out.push(name);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The payload type text, with auto-stamped fields surfaced as optional (`?`). */
|
|
191
|
+
function tsTypeOfPayload(schema: z.ZodTypeAny, autoStamped: readonly string[]): string {
|
|
192
|
+
if (zodKind(schema) !== "object" || autoStamped.length === 0) return tsTypeOfZod(schema, "");
|
|
193
|
+
const stamped = new Set(autoStamped);
|
|
194
|
+
const fields = Object.entries(zodObjectShape(schema)).map(([name, raw]) => {
|
|
195
|
+
let f = raw as z.ZodTypeAny;
|
|
196
|
+
let optional = stamped.has(name);
|
|
197
|
+
while (zodKind(f) === "optional" || zodKind(f) === "default") {
|
|
198
|
+
optional = true;
|
|
199
|
+
f = zodDef(f).innerType!;
|
|
200
|
+
}
|
|
201
|
+
const note = stamped.has(name) ? " // auto-stamped with ISO now() at dispatch when omitted" : "";
|
|
202
|
+
return ` ${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, " ")};${note}`;
|
|
203
|
+
});
|
|
204
|
+
return fields.length ? `{\n${fields.join("\n")}\n}` : "Record<string, never>";
|
|
205
|
+
}
|
|
206
|
+
|
|
154
207
|
// ── DSL Field → read-model TS type (the projection's decode surface) ──────────────
|
|
155
208
|
|
|
156
209
|
function tsTypeOfField(field: Field): string {
|
|
@@ -214,7 +267,7 @@ export interface TsClientOptions {
|
|
|
214
267
|
}
|
|
215
268
|
|
|
216
269
|
export function generateTsClient(modules: readonly DomainModule[], opts: TsClientOptions): string {
|
|
217
|
-
const hashConst =
|
|
270
|
+
const hashConst = tsHashConstName(opts.packageName);
|
|
218
271
|
const out: string[] = [];
|
|
219
272
|
out.push(
|
|
220
273
|
`// AUTO-GENERATED by nomos-compile — typed TS client for package "${opts.packageName}".`,
|
|
@@ -222,9 +275,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
222
275
|
`// exposing the @githolon/client surface (the structural NomosHolon below), e.g.`,
|
|
223
276
|
`//`,
|
|
224
277
|
`// import { connect } from "@githolon/client";`,
|
|
225
|
-
`// import { ${
|
|
278
|
+
`// import { ${tsClientFactoryName(modules[0]?.domain ?? "domain")} } from "./build/${opts.packageName}.client.js";`,
|
|
226
279
|
`// const holon = await connect({ cloud, workspace, clientId });`,
|
|
227
|
-
`// const app = ${
|
|
280
|
+
`// const app = ${tsClientFactoryName(modules[0]?.domain ?? "domain")}(holon);`,
|
|
228
281
|
``,
|
|
229
282
|
`/** sha256 of the deployed \`.package.usda\` — the installed law this client dispatches under. */`,
|
|
230
283
|
`export const ${hashConst} = ${JSON.stringify(opts.domainHash)};`,
|
|
@@ -250,6 +303,28 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
250
303
|
``,
|
|
251
304
|
);
|
|
252
305
|
|
|
306
|
+
const anyAutoStamp = modules.some((mod) =>
|
|
307
|
+
(mod.directives as Directive<unknown>[]).some(
|
|
308
|
+
(d) =>
|
|
309
|
+
d &&
|
|
310
|
+
typeof (d as { payloadSchema?: unknown }).payloadSchema === "object" &&
|
|
311
|
+
autoStampFields((d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema).length > 0,
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
if (anyAutoStamp) {
|
|
315
|
+
out.push(
|
|
316
|
+
`/** Caller-side clock sugar: fill omitted \`…At\` timestamp payload fields with ISO now().`,
|
|
317
|
+
` * The stamp rides IN the payload, so plans stay pure and replay byte-identical. */`,
|
|
318
|
+
`const stampNow = <T extends object>(payload: T, fields: string[]): T => {`,
|
|
319
|
+
` const now = new Date().toISOString();`,
|
|
320
|
+
` const out = { ...payload } as Record<string, unknown>;`,
|
|
321
|
+
` for (const f of fields) if (out[f] === undefined) out[f] = now;`,
|
|
322
|
+
` return out as T;`,
|
|
323
|
+
`};`,
|
|
324
|
+
``,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
253
328
|
// ── read models: one interface per aggregate wire type (deduped), deriveds/combineds merged ──
|
|
254
329
|
const aggByType = new Map<string, AggregateHandle>();
|
|
255
330
|
const extrasByType = new Map<string, string[]>();
|
|
@@ -291,14 +366,17 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
291
366
|
if (usedPayloadNames.has(payloadName)) payloadName = `${cap(camel(domain))}${cap(d.id)}Payload`;
|
|
292
367
|
usedPayloadNames.add(payloadName);
|
|
293
368
|
const schema = (d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema;
|
|
369
|
+
const stamps = autoStampFields(schema);
|
|
294
370
|
out.push(
|
|
295
|
-
`/** \`${domain}/${d.id}\` payload (mirrors the engine's zod schema). */`,
|
|
296
|
-
`export type ${payloadName} = ${
|
|
371
|
+
`/** \`${domain}/${d.id}\` payload (mirrors the engine's zod schema).${stamps.length ? ` Omitted ${stamps.map((s) => `\`${s}\``).join("/")} auto-stamp(s) ISO now() at dispatch.` : ""} */`,
|
|
372
|
+
`export type ${payloadName} = ${tsTypeOfPayload(schema, stamps)};`,
|
|
297
373
|
``,
|
|
298
374
|
);
|
|
299
375
|
methods.push(
|
|
300
376
|
` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head. */`,
|
|
301
|
-
|
|
377
|
+
stamps.length
|
|
378
|
+
? ` ${d.id}: (payload: ${payloadName}) => holon.dispatch(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, stampNow(payload, ${JSON.stringify(stamps)}), { domainHash }),`
|
|
379
|
+
: ` ${d.id}: (payload: ${payloadName}) => holon.dispatch(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, payload, { domainHash }),`,
|
|
302
380
|
);
|
|
303
381
|
}
|
|
304
382
|
|
|
@@ -350,7 +428,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
350
428
|
|
|
351
429
|
out.push(
|
|
352
430
|
`/** Typed client for the \`${domain}\` domain, bound to a connected holon. */`,
|
|
353
|
-
`export function ${
|
|
431
|
+
`export function ${tsClientFactoryName(domain)}(holon: NomosHolon, domainHash: string = ${hashConst}) {`,
|
|
354
432
|
` return {`,
|
|
355
433
|
` domain: ${JSON.stringify(domain)} as const,`,
|
|
356
434
|
` domainHash,`,
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
* identityHashes}`: the EXACT body `POST /v1/workspaces/:ws/domains` accepts as
|
|
38
38
|
* `application/json` on Nomos Cloud (package + per-workspace manifest overlay in
|
|
39
39
|
* one deploy).
|
|
40
|
+
* * `<name>.proof.mts` — the GENERATED PROOF (codegen_proof.ts): a
|
|
41
|
+
* runnable, domain-shaped live e2e synthesized from the law's own directives /
|
|
42
|
+
* queries / counts. Run it with `githolon proof`. Never fatal: an unsampleable
|
|
43
|
+
* domain still compiles, the skip names its remedy.
|
|
40
44
|
*
|
|
41
45
|
* The engine entry is GENERATED (imports + one `registerEngine` call — the machinery
|
|
42
46
|
* lives in `@githolon/dsl/engine-entry`), bundled with the SAME deterministic esbuild
|
|
@@ -69,7 +73,8 @@ import {
|
|
|
69
73
|
writeIdentity,
|
|
70
74
|
type Mod,
|
|
71
75
|
} from "./build_package.js";
|
|
72
|
-
import { generateTsClient } from "./codegen_ts.js";
|
|
76
|
+
import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
|
|
77
|
+
import { generateTsProof } from "./codegen_proof.js";
|
|
73
78
|
|
|
74
79
|
const DSL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
75
80
|
|
|
@@ -288,7 +293,12 @@ async function main(): Promise<void> {
|
|
|
288
293
|
if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
|
|
289
294
|
fail(`config must default-export { name, domains: [{ key, modules }, ...] }`);
|
|
290
295
|
}
|
|
291
|
-
|
|
296
|
+
// ANY readable name works ("tool-library", "tool_library", "Tool Library"):
|
|
297
|
+
// artifact FILENAMES keep it verbatim; generated SYMBOLS normalize it
|
|
298
|
+
// (toolLibraryClient, TOOL_LIBRARY_DOMAIN_HASH — see codegen_ts.ts).
|
|
299
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(cfg.name)) {
|
|
300
|
+
fail(`package name '${cfg.name}' is not a safe basename (letters, digits, spaces, '.', '-', '_')`);
|
|
301
|
+
}
|
|
292
302
|
|
|
293
303
|
const outDir = path.resolve(cfgDir, cfg.outDir ?? "build");
|
|
294
304
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -467,6 +477,21 @@ async function main(): Promise<void> {
|
|
|
467
477
|
};
|
|
468
478
|
const deployPath = path.join(outDir, `${cfg.name}.deploy.json`);
|
|
469
479
|
writeFileSync(deployPath, JSON.stringify(deployBody) + "\n", "utf8");
|
|
480
|
+
|
|
481
|
+
// ── 4b. the GENERATED PROOF — a runnable, domain-shaped e2e synthesized from the
|
|
482
|
+
// law itself (codegen_proof.ts): reshape the law, recompile, the proof
|
|
483
|
+
// reshapes itself — no test rewriting. NEVER FATAL: a domain the
|
|
484
|
+
// synthesizer can't sample yet still compiles — the skip names its remedy.
|
|
485
|
+
let proofPath: string | undefined;
|
|
486
|
+
let proofSkip: string | undefined;
|
|
487
|
+
try {
|
|
488
|
+
const proofSrc = generateTsProof(domainModules, { packageName: cfg.name, domainHash });
|
|
489
|
+
proofPath = path.join(outDir, `${cfg.name}.proof.mts`);
|
|
490
|
+
writeFileSync(proofPath, proofSrc, "utf8");
|
|
491
|
+
} catch (e) {
|
|
492
|
+
proofSkip = (e as Error).message;
|
|
493
|
+
}
|
|
494
|
+
|
|
470
495
|
const summaryTxt = [
|
|
471
496
|
`${cfg.name} — compiled Nomos domain package`,
|
|
472
497
|
`law (domainHash): ${domainHash} # sha256(${cfg.name}.package.usda) — content-addressed`,
|
|
@@ -477,7 +502,10 @@ async function main(): Promise<void> {
|
|
|
477
502
|
` queries: ${d.queries.join(", ") || "—"} counts: ${d.counts.join(", ") || "—"}`,
|
|
478
503
|
]),
|
|
479
504
|
`deploy: POST ${cfg.name}.deploy.json to /v1/workspaces/<ws>/domains (or: githolon deploy <ws>)`,
|
|
480
|
-
`typed client: ${cfg.name}.client.ts — ${cfg.name}
|
|
505
|
+
`typed client: ${cfg.name}.client.ts — ${tsClientFactoryName(domainModules[0]?.domain ?? domainModules[0]?.name ?? cfg.name)}(holon), law hash baked in`,
|
|
506
|
+
proofPath !== undefined
|
|
507
|
+
? `generated proof: ${cfg.name}.proof.mts — run: githolon proof (live; ends ALL GREEN or names the jam)`
|
|
508
|
+
: `generated proof: SKIPPED — ${proofSkip}`,
|
|
481
509
|
``,
|
|
482
510
|
].join("\n");
|
|
483
511
|
writeFileSync(path.join(outDir, `${cfg.name}.summary.txt`), summaryTxt, "utf8");
|
|
@@ -513,6 +541,11 @@ async function main(): Promise<void> {
|
|
|
513
541
|
for (const [dom, h] of Object.entries(identity.hashes)) console.log(` ${dom.padEnd(20)} ${h}`);
|
|
514
542
|
for (const ex of identity.excluded) console.log(` EXCLUDED ${ex.domain}: ${ex.reason}`);
|
|
515
543
|
console.log(` client ${rel(clientPath)} (typed TS client — payloads, read models, query accessors)`);
|
|
544
|
+
if (proofPath !== undefined) {
|
|
545
|
+
console.log(` proof ${rel(proofPath)} (a runnable e2e GENERATED from your law — run: githolon proof)`);
|
|
546
|
+
} else {
|
|
547
|
+
console.log(` proof SKIPPED — ${proofSkip}`);
|
|
548
|
+
}
|
|
516
549
|
if (dartOut !== undefined) {
|
|
517
550
|
console.log(` dart ${rel(dartOut)}${path.sep} (${dartFiles.length} domain file(s) + vendored nomos_dsl support — typed Dart for Flutter)`);
|
|
518
551
|
}
|
|
@@ -522,7 +555,11 @@ async function main(): Promise<void> {
|
|
|
522
555
|
console.log(` githolon ws create <ws> && githolon deploy <ws> # the secret is saved + sent for you`);
|
|
523
556
|
console.log(` # raw lane: curl -X POST -H 'content-type: application/json' -H 'Authorization: Bearer <workspaceSecret>' \\`);
|
|
524
557
|
console.log(` # --data-binary @${rel(deployPath)} https://nomos.captainapp.co.uk/v1/workspaces/<ws>/domains`);
|
|
525
|
-
|
|
558
|
+
if (proofPath !== undefined) {
|
|
559
|
+
console.log(`prove it: npx githolon proof # GENERATED from your law — offline write -> sync -> admission -> cloud reads, live`);
|
|
560
|
+
} else {
|
|
561
|
+
console.log(`prove it: npm run e2e # offline write -> sync -> admission -> cloud query, live`);
|
|
562
|
+
}
|
|
526
563
|
}
|
|
527
564
|
|
|
528
565
|
await main();
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
7
7
|
|
|
8
8
|
/** `@githolon/dsl` — the Nomos 2 domain-authoring DSL. */
|
|
9
|
+
// ONE import for a domain file: `z` (payload schemas) rides along with the DSL, so
|
|
10
|
+
// authors never juggle a separate zod dependency (or risk a second zod instance).
|
|
11
|
+
export { z } from "zod";
|
|
9
12
|
export {
|
|
10
13
|
aggregate,
|
|
11
14
|
instance,
|