@nanobpm/urban 0.25.0 → 0.26.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.
@@ -202,6 +202,12 @@ const PRIMITIVE_TS: Record<string, string> = {
202
202
  json: "unknown",
203
203
  };
204
204
 
205
+ /** Whether a manifest field keyword resolves to a primitive (vs a nominal ref). Shared with the
206
+ * shape fuse (`shapes.ts`) so the primitive-keyword set has one definition (no drift). */
207
+ export function isPrimitiveKeyword(type: string): boolean {
208
+ return Object.prototype.hasOwnProperty.call(PRIMITIVE_TS, type);
209
+ }
210
+
205
211
  /**
206
212
  * The TS type for a registry field. A primitive keyword maps directly (and takes precedence over
207
213
  * an identically named type, per the schema); anything else is a nominal reference to another
@@ -252,6 +258,171 @@ export function emitDomainModel(
252
258
  return registry ? `${spine}\n${registry}` : spine;
253
259
  }
254
260
 
261
+ // --- domain bindings: the typed data-object accessor (ADR 0029 §6) ---------------------------
262
+
263
+ /** The generated bindings file's basename — the typed `openDomain()` accessor materialised next to
264
+ * `domain-rows.d.ts` under a project's `nano-generated/`. */
265
+ export const DOMAIN_BINDINGS = "domain.ts";
266
+
267
+ /** Members `openDomain()` reserves on the returned handle — the `DomainBase` escape hatch
268
+ * (`raw`/`close`). A table whose wire name is one of these would clobber the escape hatch (or the
269
+ * type intersection), so it is dropped from the typed table surface; it stays reachable via
270
+ * `db.raw.table("<name>", pk)`. */
271
+ const RESERVED_DOMAIN_MEMBERS = new Set(["raw", "close"]);
272
+
273
+ /** The primary-key column for a table's `Table` gateway: its first declared PK column, or `id`
274
+ * when the table declares none. */
275
+ function primaryKeyOf(t: TableMeta): string {
276
+ return t.columns.find((c) => c.primaryKey)?.name ?? "id";
277
+ }
278
+
279
+ /**
280
+ * Emit `domain.ts`: a typed data-object accessor over the App's default datasource, so a worker
281
+ * writes `db.orders.insert({...})` / `db.orders.get(id)` instead of hand-writing SQL (ADR 0029 §6).
282
+ * The generic `Table<T>` runtime lives in `data-sdk.ts`; this generated module only binds each
283
+ * table name to its row type (from `domain-rows.d.ts`) and primary key, so it imports nothing but
284
+ * the sibling SDK (relative) and a type-only `domain-rows.d.ts` — staying dual-runtime and erased at
285
+ * `deno compile`. When the App declares multiple datasources this emits a *keyed*
286
+ * `openDomain<K>(source?)` whose returned accessor is typed against that source's tables; `db.raw`
287
+ * is the per-source escape hatch. Single-source apps keep the zero-arg `openDomain()` concrete form
288
+ * (byte-stable).
289
+ */
290
+ export function emitDomainBindings(
291
+ sources: SourceSchema[],
292
+ defaultSource?: string,
293
+ ): string {
294
+ const header =
295
+ "// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1/§6).\n" +
296
+ "// The typed data-object layer: `openDomain()` → one `Table<T>` per table, so\n" +
297
+ "// workers manipulate typed rows instead of hand-writing SQL. Do not edit —\n" +
298
+ "// regenerated from the live tables. Erased to plain JS at `deno compile`.\n" +
299
+ "// eslint-disable\n";
300
+
301
+ const multi = sources.length > 1;
302
+ const def = defaultSource && sources.some((s) => s.source === defaultSource)
303
+ ? defaultSource
304
+ : sources[0]?.source;
305
+ const primary = sources.find((s) => s.source === def);
306
+ const tables = (primary?.tables ?? []).filter(
307
+ (t) => !RESERVED_DOMAIN_MEMBERS.has(t.name),
308
+ );
309
+
310
+ // Multi-source apps: emit a *keyed* accessor so `openDomain("analytics")` is typed against that
311
+ // source's tables, not the default's. The row-type spine (`DomainSources`, keyed by alias then
312
+ // wire table name) is already emitted in `domain-rows.d.ts`; here we bind a runtime
313
+ // table-descriptor map per source and a generic `openDomain<K>` that indexes it. Single-source
314
+ // apps keep the zero-arg concrete form below (byte-stable).
315
+ if (multi) {
316
+ return emitKeyedDomainBindings(header, sources, def!);
317
+ }
318
+
319
+ // Interface names must match `domain-rows.d.ts` exactly (prefixed when multi-source).
320
+ const typeName = (table: string) =>
321
+ multi ? prefixedInterfaceName(primary!.source, table) : interfaceName(table);
322
+ const propKey = (table: string) => (isIdent(table) ? table : JSON.stringify(table));
323
+
324
+ const typeImports = [...new Set(tables.map((t) => typeName(t.name)))].sort();
325
+ const importTypes = typeImports.length > 0
326
+ ? `import type { ${typeImports.join(", ")} } from "./${DOMAIN_DTS}";\n`
327
+ : "";
328
+
329
+ const fields = tables
330
+ .map((t) => ` readonly ${propKey(t.name)}: Table<${typeName(t.name)}>;`)
331
+ .join("\n");
332
+ const binds = tables
333
+ .map((t) =>
334
+ ` ${propKey(t.name)}: raw.table<${typeName(t.name)}>(` +
335
+ `${JSON.stringify(t.name)}, ${JSON.stringify(primaryKeyOf(t))}),`
336
+ )
337
+ .join("\n");
338
+
339
+ const domainIface = `export interface Domain {\n` +
340
+ ` /** The underlying datasource handle — the raw-SQL escape hatch. */\n` +
341
+ ` readonly raw: DataSource;\n` +
342
+ (fields ? `${fields}\n` : "") +
343
+ ` /** Close the underlying connection. */\n close(): void;\n}`;
344
+
345
+ const openFn = `/**\n` +
346
+ ` * Open the App's ${multi ? `default (\`${def}\`) ` : ""}datasource as a typed domain: each table is\n` +
347
+ ` * a \`Table<T>\` gateway (\`db.orders.insert(...)\`, \`db.orders.get(id)\`), with the\n` +
348
+ ` * raw \`DataSource\` at \`db.raw\` for anything the gateway doesn't cover.\n` +
349
+ ` */\n` +
350
+ `export async function openDomain(source?: string): Promise<Domain> {\n` +
351
+ ` const raw = await openDataSource(source);\n` +
352
+ ` return {\n raw,\n` +
353
+ (binds ? `${binds}\n` : "") +
354
+ ` close: () => raw.close(),\n };\n}`;
355
+
356
+ return `${header}\n` +
357
+ `import { openDataSource, type DataSource, type Table } from "./data-sdk.ts";\n` +
358
+ importTypes +
359
+ `\n${domainIface}\n\n${openFn}\n\nexport type { Table };\n`;
360
+ }
361
+
362
+ /**
363
+ * Emit the *keyed* `domain.ts` for a multi-datasource App. `openDomain(source)` is
364
+ * runtime-polymorphic (it opens whichever source you name); here
365
+ * `openDomain<K extends DomainSource>(source?: K)` selects the row-type map for the requested
366
+ * source from `DomainSources` (emitted in `domain-rows.d.ts`), and a runtime per-source
367
+ * table-descriptor map binds the matching `Table<T>` gateways. Zero-arg / default-source calls keep
368
+ * resolving to the default source.
369
+ */
370
+ function emitKeyedDomainBindings(
371
+ header: string,
372
+ sources: SourceSchema[],
373
+ def: string,
374
+ ): string {
375
+ const tableDescriptors = sources
376
+ .map((s) => {
377
+ const rows = s.tables
378
+ .filter((t) => !RESERVED_DOMAIN_MEMBERS.has(t.name))
379
+ .map((t) =>
380
+ `{ name: ${JSON.stringify(t.name)}, pk: ${JSON.stringify(primaryKeyOf(t))} }`
381
+ )
382
+ .join(", ");
383
+ return ` ${JSON.stringify(s.source)}: [${rows}],`;
384
+ })
385
+ .join("\n");
386
+
387
+ const runtimeMap =
388
+ `const DOMAIN_TABLES: Record<string, { name: string; pk: string }[]> = {\n` +
389
+ `${tableDescriptors}\n};\n` +
390
+ `const DEFAULT_SOURCE = ${JSON.stringify(def)};`;
391
+
392
+ const types =
393
+ `/** The App's datasource aliases (ADR 0024). */\n` +
394
+ `export type DomainSource = keyof DomainSources;\n\n` +
395
+ `interface DomainBase {\n` +
396
+ ` /** The underlying datasource handle — the raw-SQL escape hatch. */\n` +
397
+ ` readonly raw: DataSource;\n` +
398
+ ` /** Close the underlying connection. */\n close(): void;\n}\n\n` +
399
+ `type DomainTablesOf<M extends Record<string, object>> = { readonly [K in keyof M as K extends "raw" | "close" ? never : K]: Table<M[K]> };\n\n` +
400
+ `/** A datasource opened as a typed domain: one \`Table<T>\` per table, plus\n` +
401
+ ` * \`raw\`/\`close\`. Defaults to the default source (\`${def}\`) when unparameterised. */\n` +
402
+ `export type Domain<M extends Record<string, object> = DomainSources[${JSON.stringify(def)}]> =\n` +
403
+ ` DomainBase & DomainTablesOf<M>;`;
404
+
405
+ const openFn =
406
+ `/**\n` +
407
+ ` * Open one of the App's datasources as a typed domain. The \`source\` key\n` +
408
+ ` * selects that source's tables (\`openDomain("analytics").customers\`); omit it\n` +
409
+ ` * for the default source (\`${def}\`). \`db.raw\` is the raw-SQL escape hatch.\n` +
410
+ ` */\n` +
411
+ `export async function openDomain<K extends DomainSource = ${JSON.stringify(def)}>(\n` +
412
+ ` source?: K,\n` +
413
+ `): Promise<Domain<DomainSources[K]>> {\n` +
414
+ ` const key = (source ?? DEFAULT_SOURCE) as string;\n` +
415
+ ` const raw = await openDataSource(key);\n` +
416
+ ` const db: Record<string, unknown> = { raw, close: () => raw.close() };\n` +
417
+ ` for (const t of DOMAIN_TABLES[key] ?? []) db[t.name] = raw.table(t.name, t.pk);\n` +
418
+ ` return db as Domain<DomainSources[K]>;\n}`;
419
+
420
+ return `${header}\n` +
421
+ `import { openDataSource, type DataSource, type Table } from "./data-sdk.ts";\n` +
422
+ `import type { DomainSources } from "./${DOMAIN_DTS}";\n\n` +
423
+ `${runtimeMap}\n\n${types}\n\n${openFn}\n\nexport type { Table };\n`;
424
+ }
425
+
255
426
  // --- manifest bridge (code-first, ADR 0054) --------------------------------------------------
256
427
 
257
428
  /** Project one manifest type's canonical schema into a `TableMeta` for the row-type spine. Reuses
@@ -12,6 +12,9 @@ import { byModelPath, DOMAIN_DTS, typeRefFor, type ModelSource } from "./worker-
12
12
 
13
13
  /** Filename the console uses for the derived message-payload map; kept identical (drop-in). */
14
14
  export const MESSAGE_BINDINGS_DTS = "message-io.d.ts";
15
+ /** The generated typed-`publishMessage` wrapper's basename — re-exports the worker SDK's
16
+ * `publishMessage` with a messageName-keyed typed signature. */
17
+ export const MESSAGE_BINDINGS_TS = "messages.ts";
15
18
 
16
19
  const ENVELOPE_IN = "io.nanobpm.dataEnvelope.in";
17
20
  const ENVELOPE_OUT = "io.nanobpm.dataEnvelope.out";
@@ -164,3 +167,38 @@ export const messagesDeriver: Deriver<{ models: ModelSource[]; declaredTypeIds:
164
167
  describe: "Derive message-io.d.ts (bpmn:message name + envelope payload) from BPMN models.",
165
168
  derive: ({ models, declaredTypeIds }) => deriveMessageBindings(models, declaredTypeIds),
166
169
  };
170
+
171
+ /**
172
+ * The static typed-`publishMessage` wrapper (`messages.ts`). It re-exports the worker SDK's
173
+ * `publishMessage` (and its option/result types) and overrides `publishMessage` with a signature
174
+ * that keys off the `name` string literal: when a message is present in the generated
175
+ * `MessagePayloads` (ADR 0040 slice 2), the call's `variables` are typed from the declared domain
176
+ * type; otherwise they fall back to `MessageVars`. The body is a pass-through — the wire stays
177
+ * untyped JSON. This file never changes with the model, so it is written verbatim (unlike the
178
+ * regenerated `message-io.d.ts`). A byte-faithful port of the console's
179
+ * `emitMessageBindingsRuntime`.
180
+ */
181
+ export function emitMessageBindingsRuntime(): string {
182
+ return "// AUTO-GENERATED by nanobpmn (ADR 0040 slice 2): the typed `publishMessage`.\n" +
183
+ "// Re-exports the worker SDK's `publishMessage` and overrides it with a\n" +
184
+ "// messageName-keyed typed signature (variables typed from the message's declared\n" +
185
+ "// envelope). Erased to a pass-through at runtime. Do not edit.\n" +
186
+ "// eslint-disable\n\n" +
187
+ `import { publishMessage as publishMessageRaw } from "./worker-sdk.ts";\n` +
188
+ `import type { PublishMessageOptions, PublishMessageResult } from "./worker-sdk.ts";\n` +
189
+ `import type { MessageName, MessagePayloads, MessageVars } from "./${MESSAGE_BINDINGS_DTS}";\n\n` +
190
+ `export type { PublishMessageOptions, PublishMessageResult } from "./worker-sdk.ts";\n\n` +
191
+ `type PayloadFor<K extends MessageName> = K extends keyof MessagePayloads ? MessagePayloads[K] : MessageVars;\n\n` +
192
+ `/**\n` +
193
+ ` * Typed \`publishMessage\`: \`name\` is constrained to the model's declared message\n` +
194
+ ` * names (\`MessageName\`, ADR 0040 slice 2) so it autocompletes and rejects unknown\n` +
195
+ ` * messages, and \`variables\` are typed from the message's declared envelope. A\n` +
196
+ ` * declared message with no domain type falls back to MessageVars.\n` +
197
+ ` */\n` +
198
+ `export function publishMessage<K extends MessageName>(\n` +
199
+ ` name: K,\n` +
200
+ ` opts?: PublishMessageOptions<PayloadFor<K> & object>,\n` +
201
+ `): Promise<PublishMessageResult> {\n` +
202
+ ` return publishMessageRaw(name, opts as unknown as PublishMessageOptions);\n` +
203
+ `}\n`;
204
+ }
@@ -0,0 +1,171 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ emitDomainModelJson,
5
+ resolveShapes,
6
+ type ShapeDecl,
7
+ } from "./shapes.ts";
8
+ import type { DomainTypeRegistry, SourceSchema } from "./domain.ts";
9
+
10
+ // The fuse + its drift guards, ported byte-for-byte from the console's `resolveShapes` /
11
+ // `emitDomainModelJson` tests (server domain_types_test.ts) — the toolkit is now the single source
12
+ // of truth for the IDE's shape fuse (host dry-out nano-bpm#576), so these lock the same behaviour.
13
+
14
+ test("emitDomainModelJson fuses tables, manifest types, shapes, and metadata", () => {
15
+ const json = emitDomainModelJson({
16
+ sources: [
17
+ {
18
+ source: "app",
19
+ tables: [
20
+ {
21
+ name: "orders",
22
+ columns: [{ name: "id", type: "INTEGER", notNull: true, primaryKey: true }],
23
+ indexes: [],
24
+ foreignKeys: [],
25
+ },
26
+ ],
27
+ },
28
+ ],
29
+ default: "app",
30
+ manifestTypes: { Order: { fields: { item: { type: "string" } } } },
31
+ shapes: [
32
+ {
33
+ decl: { id: "ApprovedOrder", process: "orders", ops: [] },
34
+ def: { fields: { item: { type: "string" }, approved: { type: "boolean" } } },
35
+ },
36
+ ],
37
+ meta: [{ process: "orders", key: "classification", value: "internal" }],
38
+ diagnostics: [],
39
+ });
40
+ const model = JSON.parse(json);
41
+ assert.equal(model.version, 1);
42
+ assert.equal(model.default, "app");
43
+ assert.equal(typeof model.inputsHash, "string");
44
+ assert.equal(model.inputsHash.length > 0, true);
45
+ const byId = new Map<string, { kind: string; provenance: string }>(
46
+ model.entities.map((e: { id: string; kind: string; provenance: string }) => [e.id, e]),
47
+ );
48
+ assert.equal(byId.get("app.orders")?.kind, "table");
49
+ assert.equal(byId.get("app.orders")?.provenance, "db:app.orders");
50
+ assert.equal(byId.get("Order")?.kind, "type");
51
+ assert.equal(byId.get("Order")?.provenance, "manifest:Order");
52
+ assert.equal(byId.get("ApprovedOrder")?.kind, "shape");
53
+ assert.equal(byId.get("ApprovedOrder")?.provenance, "model:orders");
54
+ assert.deepEqual(model.meta, [{ process: "orders", key: "classification", value: "internal" }]);
55
+ });
56
+
57
+ test("emitDomainModelJson inputsHash is stable across calls and shifts on any change", () => {
58
+ const base = {
59
+ sources: [],
60
+ manifestTypes: { Order: { fields: { item: { type: "string" } } } },
61
+ shapes: [],
62
+ meta: [{ key: "owner", value: "sre" }],
63
+ diagnostics: [],
64
+ };
65
+ const a = JSON.parse(emitDomainModelJson({ ...base }));
66
+ const b = JSON.parse(emitDomainModelJson({ ...base }));
67
+ assert.equal(a.inputsHash, b.inputsHash); // deterministic
68
+ const changed = JSON.parse(
69
+ emitDomainModelJson({ ...base, meta: [{ key: "owner", value: "ops" }] }),
70
+ );
71
+ assert.equal(changed.inputsHash !== a.inputsHash, true); // staleness detectable
72
+ });
73
+
74
+ test("resolveShapes reifies an all-extend shape into a flat domain type", () => {
75
+ const shapes: ShapeDecl[] = [
76
+ {
77
+ id: "PrReviewRoundIn",
78
+ name: "PR review round — input",
79
+ ops: [
80
+ { op: "extend", name: "prUrl", type: "string" },
81
+ { op: "extend", name: "prNumber", type: "integer" },
82
+ { op: "extend", name: "answer", type: "string", optional: true },
83
+ { op: "extend", name: "labels", type: "string", list: true },
84
+ ],
85
+ },
86
+ ];
87
+ const { types, diagnostics } = resolveShapes(shapes, {}, []);
88
+ assert.deepEqual(diagnostics, []);
89
+ assert.deepEqual(types.PrReviewRoundIn, {
90
+ name: "PR review round — input",
91
+ fields: {
92
+ prUrl: { type: "string" },
93
+ prNumber: { type: "integer" },
94
+ answer: { type: "string", optional: true },
95
+ labels: { type: "string", list: true },
96
+ },
97
+ });
98
+ });
99
+
100
+ test("resolveShapes drift-guards a shape id colliding with a manifest type", () => {
101
+ const shapes: ShapeDecl[] = [
102
+ { id: "Order", ops: [{ op: "extend", name: "item", type: "string" }] },
103
+ ];
104
+ const manifest: DomainTypeRegistry = { Order: { fields: { item: { type: "string" } } } };
105
+ const { types, diagnostics } = resolveShapes(shapes, manifest, []);
106
+ assert.deepEqual(Object.keys(types), []); // shape omitted — no silent shadow
107
+ assert.equal(diagnostics.length, 1);
108
+ assert.equal(diagnostics[0].kind, "same-id-collision");
109
+ assert.equal(diagnostics[0].severity, "error");
110
+ assert.equal(diagnostics[0].shape, "Order");
111
+ });
112
+
113
+ test("resolveShapes treats a qualified-alias carry of the colliding leaf as composed", () => {
114
+ // The shape id `orders` collides with the `app.orders` leaf, but it composes that same leaf via
115
+ // its unambiguous `app.orders` alias. Since both ids point at the one entity, this is a legitimate
116
+ // compose — not a same-id shadow — so no collision is raised.
117
+ const sources: SourceSchema[] = [
118
+ {
119
+ source: "app",
120
+ tables: [
121
+ {
122
+ name: "orders",
123
+ columns: [{ name: "id", type: "INTEGER", notNull: true, primaryKey: true }],
124
+ indexes: [],
125
+ foreignKeys: [],
126
+ },
127
+ ],
128
+ },
129
+ ];
130
+ const shapes: ShapeDecl[] = [{ id: "orders", ops: [{ op: "carry", ref: "app.orders" }] }];
131
+ const { types, diagnostics } = resolveShapes(shapes, {}, sources);
132
+ assert.equal(
133
+ diagnostics.some((d) => d.kind === "same-id-collision"),
134
+ false,
135
+ );
136
+ assert.ok(types.orders); // the shape resolves rather than being omitted
137
+ assert.deepEqual(Object.keys(types.orders.fields), ["id"]);
138
+ });
139
+
140
+ test("resolveShapes rejects a duplicate shape id as a fuse-identity collision", () => {
141
+ const shapes: ShapeDecl[] = [
142
+ { id: "Dup", ops: [{ op: "extend", name: "a", type: "string" }] },
143
+ { id: "Dup", ops: [{ op: "extend", name: "b", type: "string" }] },
144
+ ];
145
+ const { types, diagnostics } = resolveShapes(shapes, {}, []);
146
+ assert.deepEqual(Object.keys(types), []); // both omitted
147
+ assert.equal(diagnostics.length, 1);
148
+ assert.equal(diagnostics[0].kind, "duplicate-id");
149
+ assert.equal(diagnostics[0].severity, "error");
150
+ });
151
+
152
+ test("resolveShapes flags an extend whose type is neither scalar nor a fused entity", () => {
153
+ const shapes: ShapeDecl[] = [
154
+ { id: "Bad", ops: [{ op: "extend", name: "ref", type: "NoSuchType" }] },
155
+ ];
156
+ const { types, diagnostics } = resolveShapes(shapes, {}, []);
157
+ assert.deepEqual(Object.keys(types), []); // broken shape omitted
158
+ assert.equal(diagnostics.length, 1);
159
+ assert.equal(diagnostics[0].kind, "unresolved-reference");
160
+ assert.equal(diagnostics[0].severity, "error");
161
+ });
162
+
163
+ test("resolveShapes returns an empty resolution for no shapes", () => {
164
+ const { types, diagnostics } = resolveShapes(
165
+ [],
166
+ { Order: { fields: { item: { type: "string" } } } },
167
+ [],
168
+ );
169
+ assert.deepEqual(types, {});
170
+ assert.deepEqual(diagnostics, []);
171
+ });