@nanobpm/urban 0.24.1 → 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.
@@ -1,9 +1,12 @@
1
1
  import type { HostContext } from "../host.ts";
2
2
  import type { AppManifest } from "../manifest.ts";
3
- /** The DB-manager op protocol's known op set. `domaintypes` is part of the protocol but is not
4
- * yet handled here (it errors clearly — see the file header); every other op is dispatched by
5
- * `runDataOp`. Typing `op` as this union (instead of `string`) gives SDK/console callers
6
- * type-safety and catches typos at compile time. */
3
+ import { type ShapeDecl } from "../../../toolkit/derivers/shapes.ts";
4
+ import { type WorkerBindingDecl } from "../../../toolkit/derivers/worker-io.ts";
5
+ import { type MessageBindingDecl } from "../../../toolkit/derivers/messages.ts";
6
+ import { type MetaDecl } from "../../../toolkit/derivers/meta.ts";
7
+ /** The DB-manager op protocol's known op set — every op (including `domaintypes`) is dispatched by
8
+ * `runDataOp`. Typing `op` as this union (instead of `string`) gives SDK/console callers type-safety
9
+ * and catches typos at compile time. */
7
10
  export type DataOp = "sources" | "schema" | "query" | "exec" | "script" | "migrations" | "migrate" | "domaintypes";
8
11
  export interface DataRequest {
9
12
  op: DataOp;
@@ -11,6 +14,27 @@ export interface DataRequest {
11
14
  sql?: string;
12
15
  params?: unknown[];
13
16
  statements?: string[];
17
+ /**
18
+ * `domaintypes`: persist the derived artifacts (default true). A `write:false` request is the
19
+ * latency-sensitive composer preview — it introspects WITHOUT migrating the DB first and returns
20
+ * only the artifact *content* (never a DB side-effect per keystroke). `urban data` never writes
21
+ * files itself (the host is read-only); the caller persists the returned content when `write` is
22
+ * not false. This flag only gates the pre-introspection migration side-effect.
23
+ */
24
+ write?: boolean;
25
+ /** `domaintypes`: the model-derived worker-IO map (`taskType → {in,out,headerKeys}`), scanned
26
+ * from the process models by the caller. Overlaid on the manifest `workers[]` (authoritative for
27
+ * the envelope). Absent → the manifest projection alone. */
28
+ derivedWorkers?: WorkerBindingDecl[];
29
+ /** `domaintypes`: the model-derived message-payload map (`messageName → {in,out}`), scanned from
30
+ * the models' `bpmn:message` envelopes. Authoritative (no manifest projection). */
31
+ derivedMessages?: MessageBindingDecl[];
32
+ /** `domaintypes`: the model-derived composed shapes (`nano:shape`). Each is resolved through the
33
+ * fuse into a flat domain type folded into the `types` registry. Absent → no shapes. */
34
+ derivedShapes?: ShapeDecl[];
35
+ /** `domaintypes`: the model-derived model-level metadata (`nano:meta`). Folded into `meta.ts` and
36
+ * the structured `domain.json`. Absent → no metadata. */
37
+ derivedMeta?: MetaDecl[];
14
38
  }
15
39
  /** One datasource the manifest declares, resolved against the environment (env templates in
16
40
  * `driver`/`url` are already expanded by `loadManifest`). Mirrors the console's `ResolvedSource`
@@ -14,14 +14,25 @@
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
  import { isRecord } from "../guards.js";
21
22
  import { loadManifest } from "../manifest.js";
22
23
  import { validateManifest } from "../validate.js";
23
24
  import { makeGateway } from "./gateway.js";
24
25
  import { applyMigrations, MIGRATIONS_TABLE, openSqliteSource, resolveAppPath } from "./datasource.js";
26
+ // The `domaintypes` op composes the domain reifier from the pure toolkit derivers (single source of
27
+ // truth for the IDE's codegen, host dry-out nano-bpm#576). Imported directly from the deriver files
28
+ // (not the `toolkit/` barrel, which pulls the Node fs IO) so core stays free of `node:*`/Deno; the
29
+ // derivers' only core dependency is the gateway's `TableMeta`/`ColumnMeta` *types*, which erase.
30
+ import { GENERATED_DIR } from "../../../toolkit/artifact.js";
31
+ import { DOMAIN_BINDINGS, emitDomainBindings, emitDomainModel, registryFromManifest, } from "../../../toolkit/derivers/domain.js";
32
+ import { DOMAIN_MODEL_JSON, emitDomainModelJson, resolveShapes, } from "../../../toolkit/derivers/shapes.js";
33
+ import { DOMAIN_DTS, emitWorkerBindings, emitWorkerBindingsRuntime, overlayDerivedWorkerIo, WORKER_BINDINGS_DTS, WORKER_BINDINGS_TS, } from "../../../toolkit/derivers/worker-io.js";
34
+ import { emitMessageBindings, emitMessageBindingsRuntime, MESSAGE_BINDINGS_DTS, MESSAGE_BINDINGS_TS, } from "../../../toolkit/derivers/messages.js";
35
+ import { emitMeta, META_TS } from "../../../toolkit/derivers/meta.js";
25
36
  const DEFAULT_MIGRATIONS_DIR = "db/migrations";
26
37
  /** JSON-safe a value: BigInt → number (when lossless) or string, recursing objects/arrays. SQLite
27
38
  * reports `lastInsertRowid` (and INTEGER columns) as BigInt, which `JSON.stringify` cannot encode. */
@@ -222,8 +233,115 @@ export async function runDataOp(host, root, manifestPath, req) {
222
233
  }
223
234
  }
224
235
  case "domaintypes": {
225
- throw new Error("`domaintypes` is not yet implemented in `urban data` — the Nano console still serves it " +
226
- "(tracked as PR 2 of nano-bpm#576)");
236
+ // ADR 0029 §4.1/§4.2/§6 + ADR 0040 §9/§10: reify the domain model from the *live* schema.
237
+ // Union every declared datasource (the table spine), fold in the manifest `types` registry and
238
+ // the model's composed `nano:shape`s (resolved through the fuse), and derive the worker/message
239
+ // typed accessors. Unlike the vendored data_cli, this op NEVER writes files (the host is
240
+ // read-only): it returns every artifact's content and the caller persists it. `write:false` is
241
+ // the latency-sensitive composer preview — it skips the pre-introspection migration so a
242
+ // debounced keystroke has no DB side-effect and pays only for `text` + `shapeDiagnostics`.
243
+ const { default: def, sources } = listSources(manifest);
244
+ const persist = req.write !== false;
245
+ // Introspect each sqlite source's live schema. When persisting, migrate first (the domain
246
+ // model is derived from `gw.schema()`, so a regen that raced ahead of `migrate` on a fresh DB
247
+ // would emit an empty `Domain`); the migration runs on the shared `_urban_migrations` ledger.
248
+ const migrated = {};
249
+ const schemas = [];
250
+ for (const s of sources) {
251
+ if (String(s.driver) !== "sqlite")
252
+ continue; // only sqlite is introspectable (see openGateway)
253
+ const { gw, db } = await openGateway(host, root, manifest, s.name);
254
+ try {
255
+ if (persist) {
256
+ const applied = await applyMigrations(host, db, root, s.migrations ?? DEFAULT_MIGRATIONS_DIR);
257
+ if (applied.length)
258
+ migrated[s.name] = applied;
259
+ }
260
+ schemas.push({ source: s.name, tables: await gw.schema() });
261
+ }
262
+ finally {
263
+ db.close();
264
+ }
265
+ }
266
+ const types = registryFromManifest(manifest);
267
+ // Snapshot the manifest `types` *before* folding the resolved shapes so `domain.json` can tag
268
+ // each entity with its true provenance (manifest vs model) — once folded they are
269
+ // indistinguishable in `types`.
270
+ const manifestTypesSnapshot = { ...types };
271
+ // Composed motion shapes (ADR 0040 §9/§10): resolve each `nano:shape` against the leaf fuse
272
+ // into a flat domain type and fold it into the registry *before* it feeds the domain model and
273
+ // the worker/message bindings, so a composed shape becomes a first-class `DomainTypes` entry.
274
+ // Broken shapes are omitted; their diagnostics are returned so the panel can surface them.
275
+ const shapeResolution = resolveShapes(req.derivedShapes ?? [], types, schemas);
276
+ const shapeDeclById = new Map((req.derivedShapes ?? []).map((s) => [s.id, s]));
277
+ const shapeEntities = [];
278
+ for (const [id, shapeDef] of Object.entries(shapeResolution.types)) {
279
+ types[id] = shapeDef;
280
+ shapeEntities.push({ decl: shapeDeclById.get(id) ?? { id, ops: [] }, def: shapeDef });
281
+ }
282
+ const declaredIds = Object.keys(types);
283
+ const text = emitDomainModel(schemas, def, types);
284
+ const bindings = emitDomainBindings(schemas, def);
285
+ const derivedMeta = req.derivedMeta ?? [];
286
+ const metaAccessor = emitMeta(derivedMeta);
287
+ // Worker-IO map (ADR 0033 §3): the model is authoritative for the envelope, so an injected
288
+ // `derivedWorkers` is overlaid on the manifest `workers[]` (which still carries non-IO
289
+ // bindings such as `llm`); absent, the manifest projection stands alone.
290
+ const manifestWorkers = (manifest.workers ?? []).map((w) => ({
291
+ taskType: w.taskType,
292
+ inputType: w.inputType,
293
+ outputType: w.outputType,
294
+ }));
295
+ const workers = req.derivedWorkers
296
+ ? overlayDerivedWorkerIo(manifestWorkers, req.derivedWorkers)
297
+ : manifestWorkers;
298
+ const workerBindings = emitWorkerBindings(workers, declaredIds);
299
+ const workerRuntime = emitWorkerBindingsRuntime();
300
+ // Message-payload registry (ADR 0040 slice 2): no manifest projection, so the model-derived
301
+ // map is authoritative and emitted directly.
302
+ const messages = req.derivedMessages ?? [];
303
+ const messageBindings = emitMessageBindings(messages, declaredIds);
304
+ const messageRuntime = emitMessageBindingsRuntime();
305
+ // The structured Fused Domain Model (`domain.json`, ADR 0040 §1): the largest artifact, so it
306
+ // is built only when persisting (the preview consumes only `text` + `shapeDiagnostics`).
307
+ const meta = derivedMeta;
308
+ const domainModel = persist
309
+ ? emitDomainModelJson({
310
+ sources: schemas,
311
+ default: def,
312
+ manifestTypes: manifestTypesSnapshot,
313
+ shapes: shapeEntities,
314
+ meta,
315
+ diagnostics: shapeResolution.diagnostics,
316
+ })
317
+ : null;
318
+ const rel = (name) => `${GENERATED_DIR}/${name}`;
319
+ const tables = schemas.reduce((n, s) => n + s.tables.length, 0);
320
+ // The returned `*Path` fields are the app-relative targets the caller writes to when
321
+ // persisting (null on a preview). `urban data` returns content only — persisting is the
322
+ // caller's job (host dry-out nano-bpm#576). The static runtime wrappers (`workers.ts`,
323
+ // `messages.ts`) are returned too so the caller can write them verbatim.
324
+ return {
325
+ path: persist ? rel(DOMAIN_DTS) : null,
326
+ text,
327
+ tables,
328
+ bindingsPath: persist ? rel(DOMAIN_BINDINGS) : null,
329
+ bindings,
330
+ workerBindingsPath: persist ? rel(WORKER_BINDINGS_DTS) : null,
331
+ workerBindings,
332
+ workerRuntimePath: persist ? rel(WORKER_BINDINGS_TS) : null,
333
+ workerRuntime,
334
+ messageBindingsPath: persist ? rel(MESSAGE_BINDINGS_DTS) : null,
335
+ messageBindings,
336
+ messageRuntimePath: persist ? rel(MESSAGE_BINDINGS_TS) : null,
337
+ messageRuntime,
338
+ metaPath: persist ? rel(META_TS) : null,
339
+ meta: metaAccessor,
340
+ domainModelPath: persist ? rel(DOMAIN_MODEL_JSON) : null,
341
+ domainModel,
342
+ migrated,
343
+ shapeDiagnostics: shapeResolution.diagnostics,
344
+ };
227
345
  }
228
346
  default:
229
347
  throw new Error(`unknown op "${req.op}"`);
@@ -319,7 +319,7 @@ function rendererShell(homePage) {
319
319
  </head>
320
320
  <body>
321
321
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
322
- <script type="module" src="/app/runtime.js"></script>
322
+ <script type="module" src="./app/runtime.js"></script>
323
323
  </body>
324
324
  </html>`;
325
325
  }
@@ -373,8 +373,23 @@ const RENDERER_JS = String.raw `
373
373
  const root = document.getElementById("page");
374
374
  const HOME = root.dataset.home || "home";
375
375
 
376
+ // Resolve every app endpoint against where THIS module was actually served from,
377
+ // so absolute-looking "/app/…" paths work both at the origin root (direct run,
378
+ // e.g. the CLI on :3000) and under a path-prefixed reverse proxy (the Nano
379
+ // console embeds the app at /console/app-view/<name>/). "../" drops the trailing
380
+ // "app/runtime.js" to land on the app's mount root (always ends in "/").
381
+ const APP_BASE = new URL("../", import.meta.url);
382
+ function apiUrl(u) {
383
+ // Rebase root-absolute app paths ("/app/…") onto the mount root. Leave a
384
+ // protocol-relative ("//host/…") or otherwise non-root-relative string alone —
385
+ // only a single leading slash denotes an app path we own.
386
+ return typeof u === "string" && u.startsWith("/") && !u.startsWith("//")
387
+ ? new URL(u.slice(1), APP_BASE).toString()
388
+ : u;
389
+ }
390
+
376
391
  async function getJSON(url, opts) {
377
- const r = await fetch(url, opts);
392
+ const r = await fetch(apiUrl(url), opts);
378
393
  const body = await r.json().catch(() => ({}));
379
394
  if (!r.ok) throw new Error(body.error || ("HTTP " + r.status));
380
395
  return body;
@@ -131,9 +131,11 @@ export async function createUrbanApp(opts) {
131
131
  describe.connectors = c.describe?.();
132
132
  }
133
133
  const routes = [];
134
+ let hasUiSurfaces = false;
134
135
  if (flags.surfaces) {
135
136
  const s = mountSurfaces(ctx, api);
136
137
  routes.push(...s.routes);
138
+ hasUiSurfaces = s.routes.length > 0;
137
139
  describe.surfaces = s.describe();
138
140
  }
139
141
  if (flags.triggers) {
@@ -160,6 +162,23 @@ export async function createUrbanApp(opts) {
160
162
  server = await host.serveHttp(port, makeRouter(routes));
161
163
  httpPort = server.port;
162
164
  host.log("info", "urban app serving surfaces/triggers", { port: httpPort, routes: routes.length });
165
+ // ADR 0057 boot handshake: under a supervising host (the nano-bpm
166
+ // Studio sets NANOBPMN_APP_HANDSHAKE), announce the port we actually
167
+ // bound on a machine-readable stdout control line so the host can
168
+ // locate the app's UI even when the app picks its port at runtime.
169
+ // Only emitted when the app actually serves UI surfaces (a trigger- or
170
+ // worker-only app binds a port too, but it has no webview for the host
171
+ // to frame), and gated on the env var so direct terminal runs aren't
172
+ // cluttered. `=== "1"` so an app that sets the var to "0" opts out.
173
+ if (httpPort !== undefined && hasUiSurfaces && host.env("NANOBPMN_APP_HANDSHAKE") === "1") {
174
+ try {
175
+ console.log(`@@NBPM_LISTENING@@${JSON.stringify({ port: httpPort })}`);
176
+ }
177
+ catch {
178
+ // stdout may be closed / non-writable — the handshake is
179
+ // best-effort telemetry, never fatal to the app.
180
+ }
181
+ }
163
182
  }
164
183
  host.log("info", `urban app "${manifest.id}" started`, {});
165
184
  }
@@ -44,6 +44,9 @@ export interface DomainTypeDef {
44
44
  }
45
45
  /** The manifest `types` registry: type id → declared record type. */
46
46
  export type DomainTypeRegistry = Record<string, DomainTypeDef>;
47
+ /** Whether a manifest field keyword resolves to a primitive (vs a nominal ref). Shared with the
48
+ * shape fuse (`shapes.ts`) so the primitive-keyword set has one definition (no drift). */
49
+ export declare function isPrimitiveKeyword(type: string): boolean;
47
50
  /**
48
51
  * Emit the `DomainTypes` block for the manifest `types` registry: an inline map keyed by type id.
49
52
  * Returns `""` when the registry is empty, so the table spine stays byte-identical when no types
@@ -55,6 +58,21 @@ export declare function emitDomainTypeRegistry(types: DomainTypeRegistry): strin
55
58
  * followed by the manifest `types` registry (§4.2).
56
59
  */
57
60
  export declare function emitDomainModel(sources: SourceSchema[], defaultSource: string | undefined, types: DomainTypeRegistry): string;
61
+ /** The generated bindings file's basename — the typed `openDomain()` accessor materialised next to
62
+ * `domain-rows.d.ts` under a project's `nano-generated/`. */
63
+ export declare const DOMAIN_BINDINGS = "domain.ts";
64
+ /**
65
+ * Emit `domain.ts`: a typed data-object accessor over the App's default datasource, so a worker
66
+ * writes `db.orders.insert({...})` / `db.orders.get(id)` instead of hand-writing SQL (ADR 0029 §6).
67
+ * The generic `Table<T>` runtime lives in `data-sdk.ts`; this generated module only binds each
68
+ * table name to its row type (from `domain-rows.d.ts`) and primary key, so it imports nothing but
69
+ * the sibling SDK (relative) and a type-only `domain-rows.d.ts` — staying dual-runtime and erased at
70
+ * `deno compile`. When the App declares multiple datasources this emits a *keyed*
71
+ * `openDomain<K>(source?)` whose returned accessor is typed against that source's tables; `db.raw`
72
+ * is the per-source escape hatch. Single-source apps keep the zero-arg `openDomain()` concrete form
73
+ * (byte-stable).
74
+ */
75
+ export declare function emitDomainBindings(sources: SourceSchema[], defaultSource?: string): string;
58
76
  /** The datasource table spine derived from the manifest types, grouped by source (same grouping
59
77
  * and order as the SQL migration). */
60
78
  export declare function sourcesFromManifest(manifest: ToolkitManifest): SourceSchema[];
@@ -143,6 +143,11 @@ const PRIMITIVE_TS = {
143
143
  datetime: "string",
144
144
  json: "unknown",
145
145
  };
146
+ /** Whether a manifest field keyword resolves to a primitive (vs a nominal ref). Shared with the
147
+ * shape fuse (`shapes.ts`) so the primitive-keyword set has one definition (no drift). */
148
+ export function isPrimitiveKeyword(type) {
149
+ return Object.prototype.hasOwnProperty.call(PRIMITIVE_TS, type);
150
+ }
146
151
  /**
147
152
  * The TS type for a registry field. A primitive keyword maps directly (and takes precedence over
148
153
  * an identically named type, per the schema); anything else is a nominal reference to another
@@ -187,6 +192,135 @@ export function emitDomainModel(sources, defaultSource, types) {
187
192
  const registry = emitDomainTypeRegistry(types);
188
193
  return registry ? `${spine}\n${registry}` : spine;
189
194
  }
195
+ // --- domain bindings: the typed data-object accessor (ADR 0029 §6) ---------------------------
196
+ /** The generated bindings file's basename — the typed `openDomain()` accessor materialised next to
197
+ * `domain-rows.d.ts` under a project's `nano-generated/`. */
198
+ export const DOMAIN_BINDINGS = "domain.ts";
199
+ /** Members `openDomain()` reserves on the returned handle — the `DomainBase` escape hatch
200
+ * (`raw`/`close`). A table whose wire name is one of these would clobber the escape hatch (or the
201
+ * type intersection), so it is dropped from the typed table surface; it stays reachable via
202
+ * `db.raw.table("<name>", pk)`. */
203
+ const RESERVED_DOMAIN_MEMBERS = new Set(["raw", "close"]);
204
+ /** The primary-key column for a table's `Table` gateway: its first declared PK column, or `id`
205
+ * when the table declares none. */
206
+ function primaryKeyOf(t) {
207
+ return t.columns.find((c) => c.primaryKey)?.name ?? "id";
208
+ }
209
+ /**
210
+ * Emit `domain.ts`: a typed data-object accessor over the App's default datasource, so a worker
211
+ * writes `db.orders.insert({...})` / `db.orders.get(id)` instead of hand-writing SQL (ADR 0029 §6).
212
+ * The generic `Table<T>` runtime lives in `data-sdk.ts`; this generated module only binds each
213
+ * table name to its row type (from `domain-rows.d.ts`) and primary key, so it imports nothing but
214
+ * the sibling SDK (relative) and a type-only `domain-rows.d.ts` — staying dual-runtime and erased at
215
+ * `deno compile`. When the App declares multiple datasources this emits a *keyed*
216
+ * `openDomain<K>(source?)` whose returned accessor is typed against that source's tables; `db.raw`
217
+ * is the per-source escape hatch. Single-source apps keep the zero-arg `openDomain()` concrete form
218
+ * (byte-stable).
219
+ */
220
+ export function emitDomainBindings(sources, defaultSource) {
221
+ const header = "// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1/§6).\n" +
222
+ "// The typed data-object layer: `openDomain()` → one `Table<T>` per table, so\n" +
223
+ "// workers manipulate typed rows instead of hand-writing SQL. Do not edit —\n" +
224
+ "// regenerated from the live tables. Erased to plain JS at `deno compile`.\n" +
225
+ "// eslint-disable\n";
226
+ const multi = sources.length > 1;
227
+ const def = defaultSource && sources.some((s) => s.source === defaultSource)
228
+ ? defaultSource
229
+ : sources[0]?.source;
230
+ const primary = sources.find((s) => s.source === def);
231
+ const tables = (primary?.tables ?? []).filter((t) => !RESERVED_DOMAIN_MEMBERS.has(t.name));
232
+ // Multi-source apps: emit a *keyed* accessor so `openDomain("analytics")` is typed against that
233
+ // source's tables, not the default's. The row-type spine (`DomainSources`, keyed by alias then
234
+ // wire table name) is already emitted in `domain-rows.d.ts`; here we bind a runtime
235
+ // table-descriptor map per source and a generic `openDomain<K>` that indexes it. Single-source
236
+ // apps keep the zero-arg concrete form below (byte-stable).
237
+ if (multi) {
238
+ return emitKeyedDomainBindings(header, sources, def);
239
+ }
240
+ // Interface names must match `domain-rows.d.ts` exactly (prefixed when multi-source).
241
+ const typeName = (table) => multi ? prefixedInterfaceName(primary.source, table) : interfaceName(table);
242
+ const propKey = (table) => (isIdent(table) ? table : JSON.stringify(table));
243
+ const typeImports = [...new Set(tables.map((t) => typeName(t.name)))].sort();
244
+ const importTypes = typeImports.length > 0
245
+ ? `import type { ${typeImports.join(", ")} } from "./${DOMAIN_DTS}";\n`
246
+ : "";
247
+ const fields = tables
248
+ .map((t) => ` readonly ${propKey(t.name)}: Table<${typeName(t.name)}>;`)
249
+ .join("\n");
250
+ const binds = tables
251
+ .map((t) => ` ${propKey(t.name)}: raw.table<${typeName(t.name)}>(` +
252
+ `${JSON.stringify(t.name)}, ${JSON.stringify(primaryKeyOf(t))}),`)
253
+ .join("\n");
254
+ const domainIface = `export interface Domain {\n` +
255
+ ` /** The underlying datasource handle — the raw-SQL escape hatch. */\n` +
256
+ ` readonly raw: DataSource;\n` +
257
+ (fields ? `${fields}\n` : "") +
258
+ ` /** Close the underlying connection. */\n close(): void;\n}`;
259
+ const openFn = `/**\n` +
260
+ ` * Open the App's ${multi ? `default (\`${def}\`) ` : ""}datasource as a typed domain: each table is\n` +
261
+ ` * a \`Table<T>\` gateway (\`db.orders.insert(...)\`, \`db.orders.get(id)\`), with the\n` +
262
+ ` * raw \`DataSource\` at \`db.raw\` for anything the gateway doesn't cover.\n` +
263
+ ` */\n` +
264
+ `export async function openDomain(source?: string): Promise<Domain> {\n` +
265
+ ` const raw = await openDataSource(source);\n` +
266
+ ` return {\n raw,\n` +
267
+ (binds ? `${binds}\n` : "") +
268
+ ` close: () => raw.close(),\n };\n}`;
269
+ return `${header}\n` +
270
+ `import { openDataSource, type DataSource, type Table } from "./data-sdk.ts";\n` +
271
+ importTypes +
272
+ `\n${domainIface}\n\n${openFn}\n\nexport type { Table };\n`;
273
+ }
274
+ /**
275
+ * Emit the *keyed* `domain.ts` for a multi-datasource App. `openDomain(source)` is
276
+ * runtime-polymorphic (it opens whichever source you name); here
277
+ * `openDomain<K extends DomainSource>(source?: K)` selects the row-type map for the requested
278
+ * source from `DomainSources` (emitted in `domain-rows.d.ts`), and a runtime per-source
279
+ * table-descriptor map binds the matching `Table<T>` gateways. Zero-arg / default-source calls keep
280
+ * resolving to the default source.
281
+ */
282
+ function emitKeyedDomainBindings(header, sources, def) {
283
+ const tableDescriptors = sources
284
+ .map((s) => {
285
+ const rows = s.tables
286
+ .filter((t) => !RESERVED_DOMAIN_MEMBERS.has(t.name))
287
+ .map((t) => `{ name: ${JSON.stringify(t.name)}, pk: ${JSON.stringify(primaryKeyOf(t))} }`)
288
+ .join(", ");
289
+ return ` ${JSON.stringify(s.source)}: [${rows}],`;
290
+ })
291
+ .join("\n");
292
+ const runtimeMap = `const DOMAIN_TABLES: Record<string, { name: string; pk: string }[]> = {\n` +
293
+ `${tableDescriptors}\n};\n` +
294
+ `const DEFAULT_SOURCE = ${JSON.stringify(def)};`;
295
+ const types = `/** The App's datasource aliases (ADR 0024). */\n` +
296
+ `export type DomainSource = keyof DomainSources;\n\n` +
297
+ `interface DomainBase {\n` +
298
+ ` /** The underlying datasource handle — the raw-SQL escape hatch. */\n` +
299
+ ` readonly raw: DataSource;\n` +
300
+ ` /** Close the underlying connection. */\n close(): void;\n}\n\n` +
301
+ `type DomainTablesOf<M extends Record<string, object>> = { readonly [K in keyof M as K extends "raw" | "close" ? never : K]: Table<M[K]> };\n\n` +
302
+ `/** A datasource opened as a typed domain: one \`Table<T>\` per table, plus\n` +
303
+ ` * \`raw\`/\`close\`. Defaults to the default source (\`${def}\`) when unparameterised. */\n` +
304
+ `export type Domain<M extends Record<string, object> = DomainSources[${JSON.stringify(def)}]> =\n` +
305
+ ` DomainBase & DomainTablesOf<M>;`;
306
+ const openFn = `/**\n` +
307
+ ` * Open one of the App's datasources as a typed domain. The \`source\` key\n` +
308
+ ` * selects that source's tables (\`openDomain("analytics").customers\`); omit it\n` +
309
+ ` * for the default source (\`${def}\`). \`db.raw\` is the raw-SQL escape hatch.\n` +
310
+ ` */\n` +
311
+ `export async function openDomain<K extends DomainSource = ${JSON.stringify(def)}>(\n` +
312
+ ` source?: K,\n` +
313
+ `): Promise<Domain<DomainSources[K]>> {\n` +
314
+ ` const key = (source ?? DEFAULT_SOURCE) as string;\n` +
315
+ ` const raw = await openDataSource(key);\n` +
316
+ ` const db: Record<string, unknown> = { raw, close: () => raw.close() };\n` +
317
+ ` for (const t of DOMAIN_TABLES[key] ?? []) db[t.name] = raw.table(t.name, t.pk);\n` +
318
+ ` return db as Domain<DomainSources[K]>;\n}`;
319
+ return `${header}\n` +
320
+ `import { openDataSource, type DataSource, type Table } from "./data-sdk.ts";\n` +
321
+ `import type { DomainSources } from "./${DOMAIN_DTS}";\n\n` +
322
+ `${runtimeMap}\n\n${types}\n\n${openFn}\n\nexport type { Table };\n`;
323
+ }
190
324
  // --- manifest bridge (code-first, ADR 0054) --------------------------------------------------
191
325
  /** Project one manifest type's canonical schema into a `TableMeta` for the row-type spine. Reuses
192
326
  * the *same* `tableSchemaForType` the SQL migration uses, so the row types can't drift from the DB. */
@@ -2,6 +2,9 @@ import type { DerivedArtifact, Deriver } from "../artifact.ts";
2
2
  import { type ModelSource } from "./worker-io.ts";
3
3
  /** Filename the console uses for the derived message-payload map; kept identical (drop-in). */
4
4
  export declare const MESSAGE_BINDINGS_DTS = "message-io.d.ts";
5
+ /** The generated typed-`publishMessage` wrapper's basename — re-exports the worker SDK's
6
+ * `publishMessage` with a messageName-keyed typed signature. */
7
+ export declare const MESSAGE_BINDINGS_TS = "messages.ts";
5
8
  /** One message-IO entry scanned from a model: a `bpmn:message` name plus the envelope
6
9
  * type refs it carries. `inputType` is the received payload (what a `publishMessage`
7
10
  * caller sends); `outputType` is reserved. Mirrors the Rust `MessageIo`. */
@@ -34,3 +37,14 @@ export declare const messagesDeriver: Deriver<{
34
37
  models: ModelSource[];
35
38
  declaredTypeIds: Iterable<string>;
36
39
  }>;
40
+ /**
41
+ * The static typed-`publishMessage` wrapper (`messages.ts`). It re-exports the worker SDK's
42
+ * `publishMessage` (and its option/result types) and overrides `publishMessage` with a signature
43
+ * that keys off the `name` string literal: when a message is present in the generated
44
+ * `MessagePayloads` (ADR 0040 slice 2), the call's `variables` are typed from the declared domain
45
+ * type; otherwise they fall back to `MessageVars`. The body is a pass-through — the wire stays
46
+ * untyped JSON. This file never changes with the model, so it is written verbatim (unlike the
47
+ * regenerated `message-io.d.ts`). A byte-faithful port of the console's
48
+ * `emitMessageBindingsRuntime`.
49
+ */
50
+ export declare function emitMessageBindingsRuntime(): string;
@@ -9,6 +9,9 @@ import { GENERATED_DIR } from "../artifact.js";
9
9
  import { byModelPath, DOMAIN_DTS, typeRefFor } from "./worker-io.js";
10
10
  /** Filename the console uses for the derived message-payload map; kept identical (drop-in). */
11
11
  export const MESSAGE_BINDINGS_DTS = "message-io.d.ts";
12
+ /** The generated typed-`publishMessage` wrapper's basename — re-exports the worker SDK's
13
+ * `publishMessage` with a messageName-keyed typed signature. */
14
+ export const MESSAGE_BINDINGS_TS = "messages.ts";
12
15
  const ENVELOPE_IN = "io.nanobpm.dataEnvelope.in";
13
16
  const ENVELOPE_OUT = "io.nanobpm.dataEnvelope.out";
14
17
  function attr(tag, name) {
@@ -132,3 +135,37 @@ export const messagesDeriver = {
132
135
  describe: "Derive message-io.d.ts (bpmn:message name + envelope payload) from BPMN models.",
133
136
  derive: ({ models, declaredTypeIds }) => deriveMessageBindings(models, declaredTypeIds),
134
137
  };
138
+ /**
139
+ * The static typed-`publishMessage` wrapper (`messages.ts`). It re-exports the worker SDK's
140
+ * `publishMessage` (and its option/result types) and overrides `publishMessage` with a signature
141
+ * that keys off the `name` string literal: when a message is present in the generated
142
+ * `MessagePayloads` (ADR 0040 slice 2), the call's `variables` are typed from the declared domain
143
+ * type; otherwise they fall back to `MessageVars`. The body is a pass-through — the wire stays
144
+ * untyped JSON. This file never changes with the model, so it is written verbatim (unlike the
145
+ * regenerated `message-io.d.ts`). A byte-faithful port of the console's
146
+ * `emitMessageBindingsRuntime`.
147
+ */
148
+ export function emitMessageBindingsRuntime() {
149
+ return "// AUTO-GENERATED by nanobpmn (ADR 0040 slice 2): the typed `publishMessage`.\n" +
150
+ "// Re-exports the worker SDK's `publishMessage` and overrides it with a\n" +
151
+ "// messageName-keyed typed signature (variables typed from the message's declared\n" +
152
+ "// envelope). Erased to a pass-through at runtime. Do not edit.\n" +
153
+ "// eslint-disable\n\n" +
154
+ `import { publishMessage as publishMessageRaw } from "./worker-sdk.ts";\n` +
155
+ `import type { PublishMessageOptions, PublishMessageResult } from "./worker-sdk.ts";\n` +
156
+ `import type { MessageName, MessagePayloads, MessageVars } from "./${MESSAGE_BINDINGS_DTS}";\n\n` +
157
+ `export type { PublishMessageOptions, PublishMessageResult } from "./worker-sdk.ts";\n\n` +
158
+ `type PayloadFor<K extends MessageName> = K extends keyof MessagePayloads ? MessagePayloads[K] : MessageVars;\n\n` +
159
+ `/**\n` +
160
+ ` * Typed \`publishMessage\`: \`name\` is constrained to the model's declared message\n` +
161
+ ` * names (\`MessageName\`, ADR 0040 slice 2) so it autocompletes and rejects unknown\n` +
162
+ ` * messages, and \`variables\` are typed from the message's declared envelope. A\n` +
163
+ ` * declared message with no domain type falls back to MessageVars.\n` +
164
+ ` */\n` +
165
+ `export function publishMessage<K extends MessageName>(\n` +
166
+ ` name: K,\n` +
167
+ ` opts?: PublishMessageOptions<PayloadFor<K> & object>,\n` +
168
+ `): Promise<PublishMessageResult> {\n` +
169
+ ` return publishMessageRaw(name, opts as unknown as PublishMessageOptions);\n` +
170
+ `}\n`;
171
+ }
@@ -0,0 +1,96 @@
1
+ import type { ColumnMeta } from "../../runtime/core/modules/gateway.ts";
2
+ import { type DomainFieldDef, type DomainTypeDef, type DomainTypeRegistry, type SourceSchema } from "./domain.ts";
3
+ /** One composition operation of a `nano:shape`, in author (XML) order. */
4
+ export type ShapeOp = {
5
+ op: "carry";
6
+ ref: string;
7
+ } | {
8
+ op: "project";
9
+ ref: string;
10
+ fields: string[];
11
+ via?: string;
12
+ } | {
13
+ op: "extend";
14
+ name: string;
15
+ type: string;
16
+ optional?: boolean;
17
+ list?: boolean;
18
+ } | {
19
+ op: "reference";
20
+ name: string;
21
+ ref: string;
22
+ spread?: boolean;
23
+ list?: boolean;
24
+ };
25
+ /** A composed motion-shape declaration lifted from the model (ADR 0040 §9). */
26
+ export interface ShapeDecl {
27
+ /** The fuse identity; also the `DomainTypes` key the resolved shape lands under. */
28
+ id: string;
29
+ /** Optional human label (`nano:shape name`). */
30
+ name?: string;
31
+ /** The defining process id, for the `model:<processId>` provenance tag. */
32
+ process?: string;
33
+ /** The ordered composition operations. */
34
+ ops: ShapeOp[];
35
+ /** Model-level metadata (`nano:meta`), free-form key/value (ADR 0040 §5). */
36
+ meta?: Record<string, string>;
37
+ }
38
+ /** A scan/resolve-time problem with a shape, surfaced like the `workers[]` drift warning (never a
39
+ * silent merge). A shape with any `error` diagnostic is omitted from the fuse; a `warning` (e.g. a
40
+ * deliberate field shadow) still resolves. */
41
+ export interface ShapeDiagnostic {
42
+ /** The offending shape id. */
43
+ shape: string;
44
+ kind: "unresolved-reference" | "reference-cycle" | "field-conflict" | "unknown-field" | "duplicate-id" | "ambiguous-reference" | "nominal-table-ref" | "same-id-collision";
45
+ severity: "error" | "warning";
46
+ message: string;
47
+ }
48
+ /** The result of resolving the project's shapes against the leaf fuse. */
49
+ export interface ShapeResolution {
50
+ /** The resolved shapes as registry entries, keyed by shape id, ready to fold into the manifest
51
+ * `types` registry before `emitDomainModel`. */
52
+ types: DomainTypeRegistry;
53
+ diagnostics: ShapeDiagnostic[];
54
+ }
55
+ /** Map one datasource column to a domain field. A nullable column widens to `optional` (the
56
+ * composed shape is a motion snapshot: an absent value reads as undefined, not SQL NULL). The
57
+ * manifest keyword is inferred from the column's SQLite affinity so it flows through `fieldTsType`
58
+ * like any declared field; affinities with no keyword (BLOB/opaque) fall back to `json`. */
59
+ export declare function columnToField(col: ColumnMeta): DomainFieldDef;
60
+ /**
61
+ * Resolve the project's composed shapes into `DomainTypeDef`s and diagnostics (ADR 0040 §10).
62
+ * Leaves (DB tables, manifest types) fuse first; shapes resolve in dependency order so a shape can
63
+ * carry another shape regardless of declaration order. A shape in a reference cycle, or one that
64
+ * names an unresolvable id, is omitted (degrades to untyped) with an `error` diagnostic; a
65
+ * deliberate field shadow with a differing type resolves with a `field-conflict` warning.
66
+ */
67
+ export declare function resolveShapes(shapes: ShapeDecl[], types: DomainTypeRegistry, sources?: SourceSchema[]): ShapeResolution;
68
+ /** The basename of the generated structured fuse cache (ADR 0040 §1). */
69
+ export declare const DOMAIN_MODEL_JSON = "domain.json";
70
+ /** One model-level metadata entry (`nano:meta`) as `emitDomainModelJson` consumes it. Mirrors
71
+ * `meta.ts`'s `MetaDecl` (kept local so the fuse cache does not depend on the meta deriver). */
72
+ export interface FusedMetaDecl {
73
+ process?: string;
74
+ key: string;
75
+ value: string;
76
+ }
77
+ /**
78
+ * Emit `domain.json`: the structured Fused Domain Model (ADR 0040 §1). Assembles every fused
79
+ * entity — DB tables (`db:` provenance), manifest `types` (`manifest:`), and composed motion shapes
80
+ * (`model:<processId>`, or bare `model` for an unsaved editor shape with no process) — with its
81
+ * resolved fields, plus the model-level metadata and the shape diagnostics. An `inputsHash` —
82
+ * FNV-1a (hex) over the compact `JSON.stringify` of the model object with `inputsHash` omitted
83
+ * (property-insertion order, not the pretty-printed bytes) — tags the cache for staleness.
84
+ */
85
+ export declare function emitDomainModelJson(input: {
86
+ sources: SourceSchema[];
87
+ default?: string;
88
+ manifestTypes: DomainTypeRegistry;
89
+ /** Resolved shapes paired with their declaration (for provenance/label). */
90
+ shapes: {
91
+ decl: ShapeDecl;
92
+ def: DomainTypeDef;
93
+ }[];
94
+ meta: FusedMetaDecl[];
95
+ diagnostics: ShapeDiagnostic[];
96
+ }): string;