@nanobpm/urban 0.25.0 → 0.27.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.
@@ -14,6 +14,9 @@ export function byModelPath(a, b) {
14
14
  }
15
15
  /** Filename the console uses; kept identical so the artifact is a drop-in. */
16
16
  export const WORKER_BINDINGS_DTS = "worker-io.d.ts";
17
+ /** The generated typed-`defineWorker` wrapper's basename — re-exports the worker SDK and overrides
18
+ * `defineWorker` with a taskType-keyed typed signature. */
19
+ export const WORKER_BINDINGS_TS = "workers.ts";
17
20
  /** The domain type declarations `worker-io.d.ts` imports from (console: domain-rows.d.ts). */
18
21
  export const DOMAIN_DTS = "domain-rows.d.ts";
19
22
  const ENVELOPE_IN = "io.nanobpm.dataEnvelope.in";
@@ -65,6 +68,22 @@ export function scanModelWorkers(xml) {
65
68
  export function typeRefFor(id, declared) {
66
69
  return id != null && declared.has(id) ? `DomainTypes[${JSON.stringify(id)}]` : undefined;
67
70
  }
71
+ /** The TS type expression for a worker's declared custom-header keys: an object type mapping each
72
+ * declared key to `string` (Zeebe headers are strings on the wire) plus a `string` index signature
73
+ * so undeclared headers stay accessible with the same honest wire type. Returns `undefined` when no
74
+ * keys are declared (the caller omits the entry so the taskType falls back to `WorkerHdrs`). */
75
+ function headerRefFor(keys) {
76
+ const clean = [
77
+ ...new Set((keys ?? [])
78
+ .filter((k) => typeof k === "string")
79
+ .map((k) => k.trim())
80
+ .filter((k) => k.length > 0)),
81
+ ];
82
+ if (clean.length === 0)
83
+ return undefined;
84
+ const fields = clean.map((k) => `${JSON.stringify(k)}: string`).join("; ");
85
+ return `{ ${fields}; [key: string]: string }`;
86
+ }
68
87
  /** Emit `worker-io.d.ts` from worker bindings + the set of declared domain type ids. */
69
88
  export function emitWorkerBindings(workers, declaredTypeIds) {
70
89
  const declared = new Set(declaredTypeIds);
@@ -79,6 +98,7 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
79
98
  : "string";
80
99
  const inputs = [];
81
100
  const outputs = [];
101
+ const headers = [];
82
102
  for (const w of workers) {
83
103
  if (typeof w?.taskType !== "string" || w.taskType.length === 0)
84
104
  continue;
@@ -88,6 +108,9 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
88
108
  const outRef = typeRefFor(w.outputType, declared);
89
109
  if (outRef)
90
110
  outputs.push(` ${propKey(w.taskType)}: ${outRef};`);
111
+ const hdrRef = headerRefFor(w.headerKeys);
112
+ if (hdrRef)
113
+ headers.push(` ${propKey(w.taskType)}: ${hdrRef};`);
91
114
  }
92
115
  const header = "// AUTO-GENERATED by nanobpmn from the App manifest (ADR 0033 §3).\n" +
93
116
  "// The bridge from the process model to the worker type system: each declared\n" +
@@ -105,7 +128,9 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
105
128
  const outputsIface = outputs.length > 0
106
129
  ? `export interface WorkerOutputs {\n${outputs.join("\n")}\n}\n`
107
130
  : `export interface WorkerOutputs {}\n`;
108
- const headersIface = `export interface WorkerHeaders {}\n`;
131
+ const headersIface = headers.length > 0
132
+ ? `export interface WorkerHeaders {\n${headers.join("\n")}\n}\n`
133
+ : `export interface WorkerHeaders {}\n`;
109
134
  return `${header}\n` +
110
135
  importTypes +
111
136
  `\n/** Untyped fallback for a job whose worker declares no input/output type. */\n` +
@@ -144,3 +169,78 @@ export const workerIoDeriver = {
144
169
  describe: "Derive worker-io.d.ts (task type + data-envelope in/out) from BPMN models.",
145
170
  derive: ({ models, declaredTypeIds }) => deriveWorkerBindings(models, declaredTypeIds),
146
171
  };
172
+ /**
173
+ * Overlay the model-derived worker-IO map onto the manifest `workers[]`. The model is authoritative
174
+ * for the envelope (`inputType`/`outputType`), so every scanned `taskType` takes its I/O from
175
+ * `derived` (clearing a stale manifest value when the model carries none); manifest entries the scan
176
+ * did not cover (e.g. a worker declared only for an `llm` binding, with no service task) are
177
+ * preserved. New `taskType`s seen only in the model are appended. A byte-faithful port of the
178
+ * console's `overlayDerivedWorkerIo` (server `data_cli.ts`).
179
+ */
180
+ export function overlayDerivedWorkerIo(manifest, derived) {
181
+ const byType = new Map();
182
+ for (const w of manifest) {
183
+ if (typeof w.taskType === "string")
184
+ byType.set(w.taskType, { ...w });
185
+ }
186
+ for (const d of derived) {
187
+ if (typeof d.taskType !== "string")
188
+ continue;
189
+ const existing = byType.get(d.taskType);
190
+ const merged = existing
191
+ ? { ...existing, taskType: d.taskType }
192
+ : { taskType: d.taskType };
193
+ if (d.inputType)
194
+ merged.inputType = d.inputType;
195
+ else
196
+ delete merged.inputType;
197
+ if (d.outputType)
198
+ merged.outputType = d.outputType;
199
+ else
200
+ delete merged.outputType;
201
+ // Header keys are authored on the model, so the derived set is authoritative: replace (not
202
+ // union) the manifest's, and clear when the model declares none.
203
+ if (d.headerKeys && d.headerKeys.length > 0)
204
+ merged.headerKeys = [...d.headerKeys];
205
+ else
206
+ delete merged.headerKeys;
207
+ byType.set(d.taskType, merged);
208
+ }
209
+ return [...byType.values()];
210
+ }
211
+ /**
212
+ * The static typed-`defineWorker` wrapper (`workers.ts`). It re-exports the whole worker SDK and
213
+ * overrides `defineWorker` with a signature that keys off the `type:` string literal: when a job
214
+ * type is present in the generated `WorkerInputs`/`WorkerOutputs` (ADR 0033 §3), the handler's
215
+ * `job.variables` and result are typed from the declared domain type; otherwise they fall back to
216
+ * `WorkerVars`. The body is a pass-through — the wire stays untyped JSON (ADR 0029 §3). This file
217
+ * never changes with the schema, so it is written verbatim (unlike the regenerated
218
+ * `worker-io.d.ts`). A byte-faithful port of the console's `emitWorkerBindingsRuntime`.
219
+ */
220
+ export function emitWorkerBindingsRuntime() {
221
+ return "// AUTO-GENERATED by nanobpmn (ADR 0033 §3): the typed `defineWorker`.\n" +
222
+ "// Re-exports the worker SDK and overrides `defineWorker` with a taskType-keyed\n" +
223
+ "// typed signature (job.variables, job.customHeaders + result typed from the\n" +
224
+ "// worker's declared input/output/header contract). Erased to a pass-through at\n" +
225
+ "// runtime. Do not edit.\n" +
226
+ "// eslint-disable\n\n" +
227
+ `import { defineWorker as defineWorkerRaw } from "./worker-sdk.ts";\n` +
228
+ `import type { WorkerOptions } from "./worker-sdk.ts";\n` +
229
+ `import type { WorkerInputs, WorkerOutputs, WorkerHeaders, WorkerTaskType, WorkerVars, WorkerHdrs } from "./${WORKER_BINDINGS_DTS}";\n\n` +
230
+ `export * from "./worker-sdk.ts";\n\n` +
231
+ `type InFor<K extends WorkerTaskType> = K extends keyof WorkerInputs ? WorkerInputs[K] : WorkerVars;\n` +
232
+ `type OutFor<K extends WorkerTaskType> = K extends keyof WorkerOutputs ? WorkerOutputs[K] : WorkerVars;\n` +
233
+ `type HdrFor<K extends WorkerTaskType> = K extends keyof WorkerHeaders ? WorkerHeaders[K] : WorkerHdrs;\n\n` +
234
+ `/**\n` +
235
+ ` * Typed \`defineWorker\`: \`type\` is constrained to the model's declared job types\n` +
236
+ ` * (\`WorkerTaskType\`, ADR 0033 §3) so it autocompletes and rejects unknown jobs,\n` +
237
+ ` * and the handler's \`job.variables\`, \`job.customHeaders\` + result are typed from\n` +
238
+ ` * the worker's declared \`inputType\`/\`outputType\`/header keys. A declared job type\n` +
239
+ ` * with no declared type falls back to WorkerVars / WorkerHdrs.\n` +
240
+ ` */\n` +
241
+ `export function defineWorker<K extends WorkerTaskType>(\n` +
242
+ ` opts: { type: K } & WorkerOptions<InFor<K> & object, OutFor<K> & object, HdrFor<K> & object>,\n` +
243
+ `): void {\n` +
244
+ ` defineWorkerRaw(opts as unknown as WorkerOptions);\n` +
245
+ `}\n`;
246
+ }
@@ -2,14 +2,16 @@ export type { DerivedArtifact, Deriver } from "./artifact.ts";
2
2
  export { GENERATED_DIR, sortArtifacts } from "./artifact.ts";
3
3
  export { deriveMigrations, migrationsDeriver, sqlType, createTableSql, tableSchemaForType, groupTypesBySource, } from "./derivers/migrations.ts";
4
4
  export type { ToolkitManifest, ToolkitType, ToolkitField, DerivedColumn, DerivedTable, } from "./derivers/migrations.ts";
5
- export { deriveDomain, emitDomain, emitDomainModel, emitDomainDts, emitDomainDtsForSources, emitDomainTypeRegistry, domainDeriver, sourcesFromManifest, registryFromManifest, sqliteAffinityToTs, interfaceName, } from "./derivers/domain.ts";
6
- export type { SourceSchema, DomainFieldDef, DomainTypeDef, DomainTypeRegistry, } from "./derivers/domain.ts";
7
- export { deriveWorkerBindings, emitWorkerBindings, workerIoDeriver, scanModelWorkers, WORKER_BINDINGS_DTS, DOMAIN_DTS, } from "./derivers/worker-io.ts";
5
+ export { deriveDomain, emitDomain, emitDomainModel, emitDomainBindings, emitDomainDts, emitDomainDtsForSources, emitDomainTypeRegistry, domainDeriver, isPrimitiveKeyword, sourcesFromManifest, registryFromManifest, sqliteAffinityToTs, interfaceName, DOMAIN_BINDINGS, } from "./derivers/domain.ts";
6
+ export type { SourceSchema, DomainFieldDef, DomainTypeDef, DomainTypeRegistry, ColumnMeta, TableMeta, } from "./derivers/domain.ts";
7
+ export { resolveShapes, columnToField, emitDomainModelJson, DOMAIN_MODEL_JSON, } from "./derivers/shapes.ts";
8
+ export type { ShapeOp, ShapeDecl, ShapeDiagnostic, ShapeResolution, FusedMetaDecl, } from "./derivers/shapes.ts";
9
+ export { deriveWorkerBindings, emitWorkerBindings, emitWorkerBindingsRuntime, overlayDerivedWorkerIo, workerIoDeriver, scanModelWorkers, WORKER_BINDINGS_DTS, WORKER_BINDINGS_TS, DOMAIN_DTS, } from "./derivers/worker-io.ts";
8
10
  export type { ModelSource, WorkerIo, WorkerBindingDecl } from "./derivers/worker-io.ts";
9
11
  export { byModelPath, typeRefFor } from "./derivers/worker-io.ts";
10
12
  export { deriveMeta, emitMeta, foldMeta, metaDeriver, scanModelMeta, META_TS, } from "./derivers/meta.ts";
11
13
  export type { MetaDecl } from "./derivers/meta.ts";
12
- export { deriveMessageBindings, emitMessageBindings, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, } from "./derivers/messages.ts";
14
+ export { deriveMessageBindings, emitMessageBindings, emitMessageBindingsRuntime, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, MESSAGE_BINDINGS_TS, } from "./derivers/messages.ts";
13
15
  export type { MessageIo, MessageBindingDecl } from "./derivers/messages.ts";
14
16
  export declare const DERIVERS: readonly [import("./artifact.ts").Deriver<import("./derivers/migrations.ts").ToolkitManifest>, import("./artifact.ts").Deriver<import("./derivers/migrations.ts").ToolkitManifest>, import("./artifact.ts").Deriver<{
15
17
  models: import("./derivers/worker-io.ts").ModelSource[];
@@ -5,11 +5,12 @@
5
5
  export { GENERATED_DIR, sortArtifacts } from "./artifact.js";
6
6
  // Derivers
7
7
  export { deriveMigrations, migrationsDeriver, sqlType, createTableSql, tableSchemaForType, groupTypesBySource, } from "./derivers/migrations.js";
8
- export { deriveDomain, emitDomain, emitDomainModel, emitDomainDts, emitDomainDtsForSources, emitDomainTypeRegistry, domainDeriver, sourcesFromManifest, registryFromManifest, sqliteAffinityToTs, interfaceName, } from "./derivers/domain.js";
9
- export { deriveWorkerBindings, emitWorkerBindings, workerIoDeriver, scanModelWorkers, WORKER_BINDINGS_DTS, DOMAIN_DTS, } from "./derivers/worker-io.js";
8
+ export { deriveDomain, emitDomain, emitDomainModel, emitDomainBindings, emitDomainDts, emitDomainDtsForSources, emitDomainTypeRegistry, domainDeriver, isPrimitiveKeyword, sourcesFromManifest, registryFromManifest, sqliteAffinityToTs, interfaceName, DOMAIN_BINDINGS, } from "./derivers/domain.js";
9
+ export { resolveShapes, columnToField, emitDomainModelJson, DOMAIN_MODEL_JSON, } from "./derivers/shapes.js";
10
+ export { deriveWorkerBindings, emitWorkerBindings, emitWorkerBindingsRuntime, overlayDerivedWorkerIo, workerIoDeriver, scanModelWorkers, WORKER_BINDINGS_DTS, WORKER_BINDINGS_TS, DOMAIN_DTS, } from "./derivers/worker-io.js";
10
11
  export { byModelPath, typeRefFor } from "./derivers/worker-io.js";
11
12
  export { deriveMeta, emitMeta, foldMeta, metaDeriver, scanModelMeta, META_TS, } from "./derivers/meta.js";
12
- export { deriveMessageBindings, emitMessageBindings, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, } from "./derivers/messages.js";
13
+ export { deriveMessageBindings, emitMessageBindings, emitMessageBindingsRuntime, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, MESSAGE_BINDINGS_TS, } from "./derivers/messages.js";
13
14
  // The registry of all derivers (for discovery / IDE migration).
14
15
  import { migrationsDeriver } from "./derivers/migrations.js";
15
16
  import { domainDeriver } from "./derivers/domain.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "git+https://github.com/jwulf/nano-ide.git",
22
+ "url": "git+https://github.com/nanobpm/nano-ide.git",
23
23
  "directory": "packages/urban"
24
24
  },
25
25
  "engines": {
@@ -46,6 +46,11 @@ function expectRecords(value: unknown): Record<string, unknown>[] {
46
46
  return value;
47
47
  }
48
48
 
49
+ function expectString(value: unknown): string {
50
+ if (typeof value !== "string") throw new TypeError("expected a string");
51
+ return value;
52
+ }
53
+
49
54
  test("sources lists declared datasources and the default", async () => {
50
55
  const { dir, cleanup } = await fixture();
51
56
  try {
@@ -225,15 +230,6 @@ test("migrations treats a missing migrations dir as an empty list", async () =>
225
230
  }
226
231
  });
227
232
 
228
- test("domaintypes is not yet implemented and errors clearly", async () => {
229
- const { dir, cleanup } = await fixture();
230
- try {
231
- await assert.rejects(run(dir, { op: "domaintypes" }), /not yet implemented/);
232
- } finally {
233
- await cleanup();
234
- }
235
- });
236
-
237
233
  test("an unknown op and an unknown source error", async () => {
238
234
  const { dir, cleanup } = await fixture();
239
235
  try {
@@ -304,3 +300,130 @@ test("an absolute --manifest path is honoured as-is (not re-prefixed with root)"
304
300
  await cleanup();
305
301
  }
306
302
  });
303
+
304
+ // --- domaintypes: live-schema reification + shape fuse (ADR 0029 §4/§6, ADR 0040 §9/§10) ---
305
+ //
306
+ // The op ported off the console's vendored data_cli (host dry-out nano-bpm#576). It migrates each
307
+ // sqlite source (write mode only), introspects the live schema, folds the manifest `types` + the
308
+ // model's composed shapes through the fuse, and RETURNS every artifact's content (the read-only
309
+ // host never writes files — the caller persists). These lock that contract.
310
+
311
+ test("domaintypes migrates, introspects the live schema, and returns every artifact", async () => {
312
+ const { dir, cleanup } = await fixture();
313
+ try {
314
+ const r = await run(dir, { op: "domaintypes" });
315
+ // Both migrations ran on the shared `_urban_migrations` ledger before introspection.
316
+ assert.deepEqual(r.migrated, { app: ["001_orders.sql", "002_customers.sql"] });
317
+ // Two tables reified from the live schema.
318
+ assert.equal(r.tables, 2);
319
+ // The domain `.d.ts` carries an interface per live table.
320
+ assert.equal(typeof r.text, "string");
321
+ assert.match(expectString(r.text), /export interface Orders\b/);
322
+ assert.match(expectString(r.text), /export interface Customers\b/);
323
+ // Persisting: every `*Path` is the app-relative nano-generated target.
324
+ assert.equal(r.path, "nano-generated/domain-rows.d.ts");
325
+ assert.equal(r.bindingsPath, "nano-generated/domain.ts");
326
+ assert.equal(r.workerBindingsPath, "nano-generated/worker-io.d.ts");
327
+ assert.equal(r.workerRuntimePath, "nano-generated/workers.ts");
328
+ assert.equal(r.messageBindingsPath, "nano-generated/message-io.d.ts");
329
+ assert.equal(r.messageRuntimePath, "nano-generated/messages.ts");
330
+ assert.equal(r.metaPath, "nano-generated/meta.ts");
331
+ assert.equal(r.domainModelPath, "nano-generated/domain.json");
332
+ // The Fused Domain Model is emitted and lists both live tables.
333
+ assert.equal(typeof r.domainModel, "string");
334
+ const model = JSON.parse(expectString(r.domainModel));
335
+ assert.deepEqual(
336
+ model.entities.map((e: { id: string }) => e.id).sort(),
337
+ ["app.customers", "app.orders"],
338
+ );
339
+ // The static runtime wrappers are returned verbatim for the caller to write.
340
+ assert.equal(typeof r.workerRuntime, "string");
341
+ assert.equal(typeof r.messageRuntime, "string");
342
+ assert.deepEqual(r.shapeDiagnostics, []);
343
+ } finally {
344
+ await cleanup();
345
+ }
346
+ });
347
+
348
+ test("domaintypes write:false is a side-effect-free preview (no migrate, null paths)", async () => {
349
+ const { dir, cleanup } = await fixture();
350
+ try {
351
+ const r = await run(dir, { op: "domaintypes", write: false });
352
+ // The preview never migrates — nothing applied, DB stays fresh.
353
+ assert.deepEqual(r.migrated, {});
354
+ // …and because this fixture's DB is fresh (unmigrated), introspecting the *current* live schema
355
+ // finds no tables. (An existing DB could legitimately report tables here; this count is scoped
356
+ // to the fresh fixture, not a general preview guarantee.)
357
+ assert.equal(r.tables, 0);
358
+ // Content is still returned (the composer preview needs `text`)…
359
+ assert.equal(typeof r.text, "string");
360
+ // …but every persistence target is null and the largest artifact is skipped.
361
+ assert.equal(r.path, null);
362
+ assert.equal(r.bindingsPath, null);
363
+ assert.equal(r.workerBindingsPath, null);
364
+ assert.equal(r.workerRuntimePath, null);
365
+ assert.equal(r.messageBindingsPath, null);
366
+ assert.equal(r.messageRuntimePath, null);
367
+ assert.equal(r.metaPath, null);
368
+ assert.equal(r.domainModelPath, null);
369
+ assert.equal(r.domainModel, null);
370
+ // A second preview did not leave a migrated DB behind for the next write.
371
+ const applied = await run(dir, { op: "migrations" });
372
+ assert.ok(expectRecords(applied.entries).every((e) => e.applied === false));
373
+ } finally {
374
+ await cleanup();
375
+ }
376
+ });
377
+
378
+ test("domaintypes folds a composed shape into DomainTypes and reports its diagnostics", async () => {
379
+ const { dir, cleanup } = await fixture();
380
+ try {
381
+ const r = await run(dir, {
382
+ op: "domaintypes",
383
+ derivedShapes: [
384
+ {
385
+ id: "ReviewRound",
386
+ ops: [
387
+ { op: "extend", name: "prUrl", type: "string" },
388
+ { op: "extend", name: "approved", type: "boolean" },
389
+ ],
390
+ },
391
+ // A broken shape: unresolved extend type → error diagnostic, omitted from the emit.
392
+ { id: "Broken", ops: [{ op: "extend", name: "ref", type: "NoSuchType" }] },
393
+ ],
394
+ });
395
+ // The good shape reifies into the `DomainTypes` registry surfaced by the domain `.d.ts`.
396
+ assert.match(expectString(r.text), /ReviewRound/);
397
+ assert.doesNotMatch(expectString(r.text), /Broken/);
398
+ // The broken shape's diagnostic is returned so the panel can surface it.
399
+ const diags = expectRecords(r.shapeDiagnostics);
400
+ assert.equal(diags.length, 1);
401
+ assert.equal(diags[0].shape, "Broken");
402
+ assert.equal(diags[0].severity, "error");
403
+ } finally {
404
+ await cleanup();
405
+ }
406
+ });
407
+
408
+ test("domaintypes emits worker/message bindings from the injected model IO maps", async () => {
409
+ const { dir, cleanup } = await fixture();
410
+ try {
411
+ const r = await run(dir, {
412
+ op: "domaintypes",
413
+ derivedShapes: [
414
+ { id: "Order", ops: [{ op: "extend", name: "total", type: "integer" }] },
415
+ ],
416
+ derivedWorkers: [
417
+ { taskType: "review", inputType: "Order", outputType: "Order", headerKeys: ["priority"] },
418
+ ],
419
+ derivedMessages: [{ messageName: "approved", inputType: "Order" }],
420
+ });
421
+ // The worker binding names the task type and threads the declared header key.
422
+ assert.match(expectString(r.workerBindings), /review/);
423
+ assert.match(expectString(r.workerBindings), /priority/);
424
+ // The message binding names the message.
425
+ assert.match(expectString(r.messageBindings), /approved/);
426
+ } finally {
427
+ await cleanup();
428
+ }
429
+ });
@@ -14,9 +14,10 @@
14
14
  // op = "sources" | "schema" | "query" | "exec" | "script" | "migrations" | "migrate"
15
15
  // | "domaintypes"
16
16
  //
17
- // `domaintypes` (live-schema reification + shape resolution) is a known protocol op but is
18
- // intentionally NOT handled here yet — it is ported to `urban data` in a follow-up (PR 2 of
19
- // #576) and errors clearly for now.
17
+ // `domaintypes` (live-schema reification + shape resolution) composes the domain reifier from the
18
+ // pure toolkit derivers (ported from the console's `domain_types.ts`, host dry-out #576); it never
19
+ // writes files (the host is read-only) — it returns every artifact's content and the caller
20
+ // persists it.
20
21
 
21
22
  import type { HostContext, SqliteDb } from "../host.ts";
22
23
  import { isRecord } from "../guards.ts";
@@ -25,11 +26,48 @@ import { loadManifest } from "../manifest.ts";
25
26
  import { validateManifest } from "../validate.ts";
26
27
  import { makeGateway, type DataSource as GatewayDataSource } from "./gateway.ts";
27
28
  import { applyMigrations, MIGRATIONS_TABLE, openSqliteSource, resolveAppPath } from "./datasource.ts";
29
+ // The `domaintypes` op composes the domain reifier from the pure toolkit derivers (single source of
30
+ // truth for the IDE's codegen, host dry-out nano-bpm#576). Imported directly from the deriver files
31
+ // (not the `toolkit/` barrel, which pulls the Node fs IO) so core stays free of `node:*`/Deno; the
32
+ // derivers' only core dependency is the gateway's `TableMeta`/`ColumnMeta` *types*, which erase.
33
+ import { GENERATED_DIR } from "../../../toolkit/artifact.ts";
34
+ import {
35
+ DOMAIN_BINDINGS,
36
+ type DomainTypeDef,
37
+ type DomainTypeRegistry,
38
+ emitDomainBindings,
39
+ emitDomainModel,
40
+ registryFromManifest,
41
+ type SourceSchema,
42
+ } from "../../../toolkit/derivers/domain.ts";
43
+ import {
44
+ DOMAIN_MODEL_JSON,
45
+ emitDomainModelJson,
46
+ type FusedMetaDecl,
47
+ resolveShapes,
48
+ type ShapeDecl,
49
+ } from "../../../toolkit/derivers/shapes.ts";
50
+ import {
51
+ DOMAIN_DTS,
52
+ emitWorkerBindings,
53
+ emitWorkerBindingsRuntime,
54
+ overlayDerivedWorkerIo,
55
+ WORKER_BINDINGS_DTS,
56
+ WORKER_BINDINGS_TS,
57
+ type WorkerBindingDecl,
58
+ } from "../../../toolkit/derivers/worker-io.ts";
59
+ import {
60
+ emitMessageBindings,
61
+ emitMessageBindingsRuntime,
62
+ MESSAGE_BINDINGS_DTS,
63
+ MESSAGE_BINDINGS_TS,
64
+ type MessageBindingDecl,
65
+ } from "../../../toolkit/derivers/messages.ts";
66
+ import { emitMeta, META_TS, type MetaDecl } from "../../../toolkit/derivers/meta.ts";
28
67
 
29
- /** The DB-manager op protocol's known op set. `domaintypes` is part of the protocol but is not
30
- * yet handled here (it errors clearly — see the file header); every other op is dispatched by
31
- * `runDataOp`. Typing `op` as this union (instead of `string`) gives SDK/console callers
32
- * type-safety and catches typos at compile time. */
68
+ /** The DB-manager op protocol's known op set — every op (including `domaintypes`) is dispatched by
69
+ * `runDataOp`. Typing `op` as this union (instead of `string`) gives SDK/console callers type-safety
70
+ * and catches typos at compile time. */
33
71
  export type DataOp =
34
72
  | "sources"
35
73
  | "schema"
@@ -46,6 +84,27 @@ export interface DataRequest {
46
84
  sql?: string;
47
85
  params?: unknown[];
48
86
  statements?: string[];
87
+ /**
88
+ * `domaintypes`: persist the derived artifacts (default true). A `write:false` request is the
89
+ * latency-sensitive composer preview — it introspects WITHOUT migrating the DB first and returns
90
+ * only the artifact *content* (never a DB side-effect per keystroke). `urban data` never writes
91
+ * files itself (the host is read-only); the caller persists the returned content when `write` is
92
+ * not false. This flag only gates the pre-introspection migration side-effect.
93
+ */
94
+ write?: boolean;
95
+ /** `domaintypes`: the model-derived worker-IO map (`taskType → {in,out,headerKeys}`), scanned
96
+ * from the process models by the caller. Overlaid on the manifest `workers[]` (authoritative for
97
+ * the envelope). Absent → the manifest projection alone. */
98
+ derivedWorkers?: WorkerBindingDecl[];
99
+ /** `domaintypes`: the model-derived message-payload map (`messageName → {in,out}`), scanned from
100
+ * the models' `bpmn:message` envelopes. Authoritative (no manifest projection). */
101
+ derivedMessages?: MessageBindingDecl[];
102
+ /** `domaintypes`: the model-derived composed shapes (`nano:shape`). Each is resolved through the
103
+ * fuse into a flat domain type folded into the `types` registry. Absent → no shapes. */
104
+ derivedShapes?: ShapeDecl[];
105
+ /** `domaintypes`: the model-derived model-level metadata (`nano:meta`). Folded into `meta.ts` and
106
+ * the structured `domain.json`. Absent → no metadata. */
107
+ derivedMeta?: MetaDecl[];
49
108
  }
50
109
 
51
110
  /** One datasource the manifest declares, resolved against the environment (env templates in
@@ -281,10 +340,120 @@ export async function runDataOp(
281
340
  }
282
341
  }
283
342
  case "domaintypes": {
284
- throw new Error(
285
- "`domaintypes` is not yet implemented in `urban data` — the Nano console still serves it " +
286
- "(tracked as PR 2 of nano-bpm#576)",
287
- );
343
+ // ADR 0029 §4.1/§4.2/§6 + ADR 0040 §9/§10: reify the domain model from the *live* schema.
344
+ // Union every declared datasource (the table spine), fold in the manifest `types` registry and
345
+ // the model's composed `nano:shape`s (resolved through the fuse), and derive the worker/message
346
+ // typed accessors. Unlike the vendored data_cli, this op NEVER writes files (the host is
347
+ // read-only): it returns every artifact's content and the caller persists it. `write:false` is
348
+ // the latency-sensitive composer preview — it skips the pre-introspection migration so a
349
+ // debounced keystroke has no DB side-effect and pays only for `text` + `shapeDiagnostics`.
350
+ const { default: def, sources } = listSources(manifest);
351
+ const persist = req.write !== false;
352
+ // Introspect each sqlite source's live schema. When persisting, migrate first (the domain
353
+ // model is derived from `gw.schema()`, so a regen that raced ahead of `migrate` on a fresh DB
354
+ // would emit an empty `Domain`); the migration runs on the shared `_urban_migrations` ledger.
355
+ const migrated: Record<string, string[]> = {};
356
+ const schemas: SourceSchema[] = [];
357
+ for (const s of sources) {
358
+ if (String(s.driver) !== "sqlite") continue; // only sqlite is introspectable (see openGateway)
359
+ const { gw, db } = await openGateway(host, root, manifest, s.name);
360
+ try {
361
+ if (persist) {
362
+ const applied = await applyMigrations(
363
+ host,
364
+ db,
365
+ root,
366
+ s.migrations ?? DEFAULT_MIGRATIONS_DIR,
367
+ );
368
+ if (applied.length) migrated[s.name] = applied;
369
+ }
370
+ schemas.push({ source: s.name, tables: await gw.schema() });
371
+ } finally {
372
+ db.close();
373
+ }
374
+ }
375
+
376
+ const types = registryFromManifest(manifest);
377
+ // Snapshot the manifest `types` *before* folding the resolved shapes so `domain.json` can tag
378
+ // each entity with its true provenance (manifest vs model) — once folded they are
379
+ // indistinguishable in `types`.
380
+ const manifestTypesSnapshot: DomainTypeRegistry = { ...types };
381
+ // Composed motion shapes (ADR 0040 §9/§10): resolve each `nano:shape` against the leaf fuse
382
+ // into a flat domain type and fold it into the registry *before* it feeds the domain model and
383
+ // the worker/message bindings, so a composed shape becomes a first-class `DomainTypes` entry.
384
+ // Broken shapes are omitted; their diagnostics are returned so the panel can surface them.
385
+ const shapeResolution = resolveShapes(req.derivedShapes ?? [], types, schemas);
386
+ const shapeDeclById = new Map((req.derivedShapes ?? []).map((s) => [s.id, s]));
387
+ const shapeEntities: { decl: ShapeDecl; def: DomainTypeDef }[] = [];
388
+ for (const [id, shapeDef] of Object.entries(shapeResolution.types)) {
389
+ types[id] = shapeDef;
390
+ shapeEntities.push({ decl: shapeDeclById.get(id) ?? { id, ops: [] }, def: shapeDef });
391
+ }
392
+ const declaredIds = Object.keys(types);
393
+
394
+ const text = emitDomainModel(schemas, def, types);
395
+ const bindings = emitDomainBindings(schemas, def);
396
+ const derivedMeta: MetaDecl[] = req.derivedMeta ?? [];
397
+ const metaAccessor = emitMeta(derivedMeta);
398
+ // Worker-IO map (ADR 0033 §3): the model is authoritative for the envelope, so an injected
399
+ // `derivedWorkers` is overlaid on the manifest `workers[]` (which still carries non-IO
400
+ // bindings such as `llm`); absent, the manifest projection stands alone.
401
+ const manifestWorkers: WorkerBindingDecl[] = (manifest.workers ?? []).map((w) => ({
402
+ taskType: w.taskType,
403
+ inputType: w.inputType,
404
+ outputType: w.outputType,
405
+ }));
406
+ const workers = req.derivedWorkers
407
+ ? overlayDerivedWorkerIo(manifestWorkers, req.derivedWorkers)
408
+ : manifestWorkers;
409
+ const workerBindings = emitWorkerBindings(workers, declaredIds);
410
+ const workerRuntime = emitWorkerBindingsRuntime();
411
+ // Message-payload registry (ADR 0040 slice 2): no manifest projection, so the model-derived
412
+ // map is authoritative and emitted directly.
413
+ const messages = req.derivedMessages ?? [];
414
+ const messageBindings = emitMessageBindings(messages, declaredIds);
415
+ const messageRuntime = emitMessageBindingsRuntime();
416
+ // The structured Fused Domain Model (`domain.json`, ADR 0040 §1): the largest artifact, so it
417
+ // is built only when persisting (the preview consumes only `text` + `shapeDiagnostics`).
418
+ const meta: FusedMetaDecl[] = derivedMeta;
419
+ const domainModel = persist
420
+ ? emitDomainModelJson({
421
+ sources: schemas,
422
+ default: def,
423
+ manifestTypes: manifestTypesSnapshot,
424
+ shapes: shapeEntities,
425
+ meta,
426
+ diagnostics: shapeResolution.diagnostics,
427
+ })
428
+ : null;
429
+
430
+ const rel = (name: string): string => `${GENERATED_DIR}/${name}`;
431
+ const tables = schemas.reduce((n, s) => n + s.tables.length, 0);
432
+ // The returned `*Path` fields are the app-relative targets the caller writes to when
433
+ // persisting (null on a preview). `urban data` returns content only — persisting is the
434
+ // caller's job (host dry-out nano-bpm#576). The static runtime wrappers (`workers.ts`,
435
+ // `messages.ts`) are returned too so the caller can write them verbatim.
436
+ return {
437
+ path: persist ? rel(DOMAIN_DTS) : null,
438
+ text,
439
+ tables,
440
+ bindingsPath: persist ? rel(DOMAIN_BINDINGS) : null,
441
+ bindings,
442
+ workerBindingsPath: persist ? rel(WORKER_BINDINGS_DTS) : null,
443
+ workerBindings,
444
+ workerRuntimePath: persist ? rel(WORKER_BINDINGS_TS) : null,
445
+ workerRuntime,
446
+ messageBindingsPath: persist ? rel(MESSAGE_BINDINGS_DTS) : null,
447
+ messageBindings,
448
+ messageRuntimePath: persist ? rel(MESSAGE_BINDINGS_TS) : null,
449
+ messageRuntime,
450
+ metaPath: persist ? rel(META_TS) : null,
451
+ meta: metaAccessor,
452
+ domainModelPath: persist ? rel(DOMAIN_MODEL_JSON) : null,
453
+ domainModel,
454
+ migrated,
455
+ shapeDiagnostics: shapeResolution.diagnostics,
456
+ };
288
457
  }
289
458
  default:
290
459
  throw new Error(`unknown op "${req.op}"`);
@@ -86,14 +86,56 @@ test("GET / serves the renderer shell with the home marker", async () => {
86
86
  assert.equal(res.status, 200);
87
87
  assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
88
88
  assert.match(res.body ?? "", /data-home="home"/);
89
+ // The runtime module is loaded by a *relative* src so it resolves against the
90
+ // document's mount path — works both at the origin root and under the Nano
91
+ // console's /console/app-view/<name>/ reverse proxy. A root-absolute
92
+ // "/app/runtime.js" would 404 against the console origin and never hydrate.
93
+ assert.match(res.body ?? "", /src="\.\/app\/runtime\.js"/);
94
+ assert.doesNotMatch(res.body ?? "", /src="\/app\/runtime\.js"/);
95
+ });
96
+
97
+ test("the shell styles the app through the shared --nano-* token contract", async () => {
98
+ const res = await dispatch("GET", "/");
99
+ const html = res.body ?? "";
100
+ // Body colours resolve through the console's token vocabulary, not hardcoded hex.
101
+ assert.match(html, /background:var\(--nano-app\)/);
102
+ assert.match(html, /color:var\(--nano-text\)/);
103
+ assert.match(html, /--pc-accent:var\(--nano-accent\)/);
104
+ // Self-contained defaults for standalone (CLI) runs: dark base + a light
105
+ // palette gated on data-appearance, and prefers-color-scheme only until themed.
106
+ assert.match(html, /--nano-accent:#8b5cf6/);
107
+ assert.match(html, /\[data-appearance="light"\]/);
108
+ assert.match(html, /:root:not\(\[data-appearance\]\)/);
89
109
  });
90
110
 
91
111
  test("GET /app/runtime.js serves the renderer module", async () => {
92
112
  const res = await dispatch("GET", "/app/runtime.js");
93
113
  assert.match(res.headers?.["content-type"] ?? "", /javascript/);
94
114
  assert.match(res.body ?? "", /pc:refresh/);
115
+ // Endpoints are rebased against the served module URL so the app works under a
116
+ // path-prefixed reverse proxy (Nano console embed), not just at the origin root.
117
+ assert.match(res.body ?? "", /new URL\("\.\.\/", import\.meta\.url\)/);
118
+ assert.match(res.body ?? "", /function apiUrl\(u\)/);
119
+ });
120
+
121
+ test("the renderer bridges the console theme over postMessage", async () => {
122
+ const res = await dispatch("GET", "/app/runtime.js");
123
+ const js = res.body ?? "";
124
+ // Announces readiness to the framing console (same-origin, posture A).
125
+ assert.match(js, /postMessage\(\{ type: "nano-app-ready" \}, window\.location\.origin\)/);
126
+ // Only trusts the framing parent, and only accepts nano-theme messages.
127
+ assert.match(js, /ev\.source === window\.parent/);
128
+ // Same-origin posture A: a cross-origin framer is rejected before applyTheme.
129
+ assert.match(js, /ev\.origin === window\.location\.origin/);
130
+ assert.match(js, /msg\.type !== "nano-theme"/);
131
+ // Mirrors the console appearance + lays --nano-* tokens onto :root; ignores
132
+ // any property outside the shared token namespace.
133
+ assert.match(js, /el\.dataset\.appearance = msg\.appearance/);
134
+ assert.match(js, /k\.startsWith\("--nano-"\)/);
135
+ assert.match(js, /el\.style\.setProperty\(k, v\)/);
95
136
  });
96
137
 
138
+
97
139
  test("renderer preserves grid row-detail expansion across refreshes", async () => {
98
140
  // Regression: the 5s poll rebuilds the whole tbody, which used to collapse an open
99
141
  // detail row (e.g. the escalation-answer form). The client now tracks open rowKeys