@githolon/dsl 0.1.3 → 0.2.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 CHANGED
@@ -24,6 +24,15 @@ with it; we keep the rest for now.
24
24
  - offer the Nomos runtime, or anything materially similar, as a hosted service;
25
25
  - reverse-engineer the holon wasm runtime.
26
26
 
27
+ ## Data retention
28
+
29
+ This is a pre-release we are actively evaluating. Workspaces you retire stop
30
+ counting toward your quota but are NOT deleted: **we retain all workspace data
31
+ (ledgers, law, intents) and may examine it to evaluate how the product is
32
+ used.** Don't put anything in a pre-release workspace you wouldn't want the
33
+ builders to read. If you need something truly expunged, ask:
34
+ jack@captainapp.co.uk.
35
+
27
36
  ## The rest
28
37
 
29
38
  Provided **as is**, with no warranty of any kind; to the maximum extent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/src/codegen_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,10 +47,21 @@ 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) ─────────
53
66
 
54
67
  type ZodInternalDef = {
@@ -151,6 +164,44 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
151
164
  }
152
165
  }
153
166
 
167
+ // ── caller-side clock sugar (the `…At` payload convention) ────────────────────────
168
+
169
+ /**
170
+ * Top-level REQUIRED string payload fields named `…At` (`signedAt`, `updatedAt`,
171
+ * `stampedAt`, …) are CALLER-STAMPED TIMESTAMPS by convention: the generated client
172
+ * fills an omitted one with `new Date().toISOString()` at dispatch. The stamp rides
173
+ * IN the payload — the plan stays a pure function of `(payload, ports)` and replays
174
+ * byte-identical on every peer — so the domain dev stops thinking about clocks.
175
+ * Passing the field explicitly always wins (scheduling-shaped fields like
176
+ * `expiresAt` should be passed explicitly — "now" is only the DEFAULT).
177
+ */
178
+ function autoStampFields(schema: z.ZodTypeAny): string[] {
179
+ if (zodKind(schema) !== "object") return [];
180
+ const out: string[] = [];
181
+ for (const [name, f] of Object.entries(zodObjectShape(schema))) {
182
+ // required-only: `.optional()`/`.default()` wrappers change the zod kind, so they skip.
183
+ if (name.length > 2 && name.endsWith("At") && zodKind(f as z.ZodTypeAny) === "string") out.push(name);
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /** The payload type text, with auto-stamped fields surfaced as optional (`?`). */
189
+ function tsTypeOfPayload(schema: z.ZodTypeAny, autoStamped: readonly string[]): string {
190
+ if (zodKind(schema) !== "object" || autoStamped.length === 0) return tsTypeOfZod(schema, "");
191
+ const stamped = new Set(autoStamped);
192
+ const fields = Object.entries(zodObjectShape(schema)).map(([name, raw]) => {
193
+ let f = raw as z.ZodTypeAny;
194
+ let optional = stamped.has(name);
195
+ while (zodKind(f) === "optional" || zodKind(f) === "default") {
196
+ optional = true;
197
+ f = zodDef(f).innerType!;
198
+ }
199
+ const note = stamped.has(name) ? " // auto-stamped with ISO now() at dispatch when omitted" : "";
200
+ return ` ${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, " ")};${note}`;
201
+ });
202
+ return fields.length ? `{\n${fields.join("\n")}\n}` : "Record<string, never>";
203
+ }
204
+
154
205
  // ── DSL Field → read-model TS type (the projection's decode surface) ──────────────
155
206
 
156
207
  function tsTypeOfField(field: Field): string {
@@ -214,7 +265,7 @@ export interface TsClientOptions {
214
265
  }
215
266
 
216
267
  export function generateTsClient(modules: readonly DomainModule[], opts: TsClientOptions): string {
217
- const hashConst = `${upperSnake(opts.packageName)}_DOMAIN_HASH`;
268
+ const hashConst = tsHashConstName(opts.packageName);
218
269
  const out: string[] = [];
219
270
  out.push(
220
271
  `// AUTO-GENERATED by nomos-compile — typed TS client for package "${opts.packageName}".`,
@@ -222,9 +273,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
222
273
  `// exposing the @githolon/client surface (the structural NomosHolon below), e.g.`,
223
274
  `//`,
224
275
  `// import { connect } from "@githolon/client";`,
225
- `// import { ${lcFirst(camel(modules[0]?.domain ?? "domain"))}Client } from "./build/${opts.packageName}.client.js";`,
276
+ `// import { ${tsClientFactoryName(modules[0]?.domain ?? "domain")} } from "./build/${opts.packageName}.client.js";`,
226
277
  `// const holon = await connect({ cloud, workspace, clientId });`,
227
- `// const app = ${lcFirst(camel(modules[0]?.domain ?? "domain"))}Client(holon);`,
278
+ `// const app = ${tsClientFactoryName(modules[0]?.domain ?? "domain")}(holon);`,
228
279
  ``,
229
280
  `/** sha256 of the deployed \`.package.usda\` — the installed law this client dispatches under. */`,
230
281
  `export const ${hashConst} = ${JSON.stringify(opts.domainHash)};`,
@@ -250,6 +301,28 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
250
301
  ``,
251
302
  );
252
303
 
304
+ const anyAutoStamp = modules.some((mod) =>
305
+ (mod.directives as Directive<unknown>[]).some(
306
+ (d) =>
307
+ d &&
308
+ typeof (d as { payloadSchema?: unknown }).payloadSchema === "object" &&
309
+ autoStampFields((d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema).length > 0,
310
+ ),
311
+ );
312
+ if (anyAutoStamp) {
313
+ out.push(
314
+ `/** Caller-side clock sugar: fill omitted \`…At\` timestamp payload fields with ISO now().`,
315
+ ` * The stamp rides IN the payload, so plans stay pure and replay byte-identical. */`,
316
+ `const stampNow = <T extends object>(payload: T, fields: string[]): T => {`,
317
+ ` const now = new Date().toISOString();`,
318
+ ` const out = { ...payload } as Record<string, unknown>;`,
319
+ ` for (const f of fields) if (out[f] === undefined) out[f] = now;`,
320
+ ` return out as T;`,
321
+ `};`,
322
+ ``,
323
+ );
324
+ }
325
+
253
326
  // ── read models: one interface per aggregate wire type (deduped), deriveds/combineds merged ──
254
327
  const aggByType = new Map<string, AggregateHandle>();
255
328
  const extrasByType = new Map<string, string[]>();
@@ -291,14 +364,17 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
291
364
  if (usedPayloadNames.has(payloadName)) payloadName = `${cap(camel(domain))}${cap(d.id)}Payload`;
292
365
  usedPayloadNames.add(payloadName);
293
366
  const schema = (d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema;
367
+ const stamps = autoStampFields(schema);
294
368
  out.push(
295
- `/** \`${domain}/${d.id}\` payload (mirrors the engine's zod schema). */`,
296
- `export type ${payloadName} = ${tsTypeOfZod(schema, "")};`,
369
+ `/** \`${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.` : ""} */`,
370
+ `export type ${payloadName} = ${tsTypeOfPayload(schema, stamps)};`,
297
371
  ``,
298
372
  );
299
373
  methods.push(
300
374
  ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head. */`,
301
- ` ${d.id}: (payload: ${payloadName}) => holon.dispatch(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, payload, { domainHash }),`,
375
+ stamps.length
376
+ ? ` ${d.id}: (payload: ${payloadName}) => holon.dispatch(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, stampNow(payload, ${JSON.stringify(stamps)}), { domainHash }),`
377
+ : ` ${d.id}: (payload: ${payloadName}) => holon.dispatch(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, payload, { domainHash }),`,
302
378
  );
303
379
  }
304
380
 
@@ -350,7 +426,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
350
426
 
351
427
  out.push(
352
428
  `/** Typed client for the \`${domain}\` domain, bound to a connected holon. */`,
353
- `export function ${lcFirst(camel(domain))}Client(holon: NomosHolon, domainHash: string = ${hashConst}) {`,
429
+ `export function ${tsClientFactoryName(domain)}(holon: NomosHolon, domainHash: string = ${hashConst}) {`,
354
430
  ` return {`,
355
431
  ` domain: ${JSON.stringify(domain)} as const,`,
356
432
  ` domainHash,`,
@@ -69,7 +69,7 @@ import {
69
69
  writeIdentity,
70
70
  type Mod,
71
71
  } from "./build_package.js";
72
- import { generateTsClient } from "./codegen_ts.js";
72
+ import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
73
73
 
74
74
  const DSL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
75
75
 
@@ -288,7 +288,12 @@ async function main(): Promise<void> {
288
288
  if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
289
289
  fail(`config must default-export { name, domains: [{ key, modules }, ...] }`);
290
290
  }
291
- if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(cfg.name)) fail(`package name '${cfg.name}' is not a safe basename`);
291
+ // ANY readable name works ("tool-library", "tool_library", "Tool Library"):
292
+ // artifact FILENAMES keep it verbatim; generated SYMBOLS normalize it
293
+ // (toolLibraryClient, TOOL_LIBRARY_DOMAIN_HASH — see codegen_ts.ts).
294
+ if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(cfg.name)) {
295
+ fail(`package name '${cfg.name}' is not a safe basename (letters, digits, spaces, '.', '-', '_')`);
296
+ }
292
297
 
293
298
  const outDir = path.resolve(cfgDir, cfg.outDir ?? "build");
294
299
  mkdirSync(outDir, { recursive: true });
@@ -477,7 +482,7 @@ async function main(): Promise<void> {
477
482
  ` queries: ${d.queries.join(", ") || "—"} counts: ${d.counts.join(", ") || "—"}`,
478
483
  ]),
479
484
  `deploy: POST ${cfg.name}.deploy.json to /v1/workspaces/<ws>/domains (or: githolon deploy <ws>)`,
480
- `typed client: ${cfg.name}.client.ts — ${cfg.name}Client(holon), law hash baked in`,
485
+ `typed client: ${cfg.name}.client.ts — ${tsClientFactoryName(domainModules[0]?.domain ?? domainModules[0]?.name ?? cfg.name)}(holon), law hash baked in`,
481
486
  ``,
482
487
  ].join("\n");
483
488
  writeFileSync(path.join(outDir, `${cfg.name}.summary.txt`), summaryTxt, "utf8");
@@ -519,9 +524,10 @@ async function main(): Promise<void> {
519
524
  console.log(` deploy ${rel(deployPath)}`);
520
525
  console.log(``);
521
526
  console.log(`deploy (Nomos Cloud):`);
522
- console.log(` curl -X POST -H 'content-type: application/json' \\`);
523
- console.log(` --data-binary @${rel(deployPath)} \\`);
524
- console.log(` https://nomos.captainapp.co.uk/v1/workspaces/<ws>/domains`);
527
+ console.log(` githolon ws create <ws> && githolon deploy <ws> # the secret is saved + sent for you`);
528
+ console.log(` # raw lane: curl -X POST -H 'content-type: application/json' -H 'Authorization: Bearer <workspaceSecret>' \\`);
529
+ console.log(` # --data-binary @${rel(deployPath)} https://nomos.captainapp.co.uk/v1/workspaces/<ws>/domains`);
530
+ console.log(`prove it: npm run e2e # offline write -> sync -> admission -> cloud query, live`);
525
531
  }
526
532
 
527
533
  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,