@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.
@@ -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,50 +319,86 @@ 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
  }
326
+ // The app's stylesheet resolves every colour through the Nano console's
327
+ // semantic `--nano-*` token contract (see the Magikcraft/nano-bpm repo's
328
+ // `console/src/theme/tokens.css` — MIRROR: keep the palettes below in sync). When the app is embedded in the
329
+ // console it inherits nothing across the iframe boundary, so the console
330
+ // postMessages its resolved tokens + appearance and the runtime lays them onto
331
+ // this document's :root (see RENDERER_JS). Standalone (CLI on :3000) there is no
332
+ // console, so the defaults below stand on their own: dark by default, light when
333
+ // the OS asks for it and nothing has themed us. `--pc-*` are kept as thin
334
+ // aliases so existing class rules re-resolve through the shared tokens.
326
335
  const RENDERER_CSS = `
327
- :root { color-scheme: light dark; --pc-edge:#d0d0d8; --pc-accent:#3b5bdb; }
336
+ :root {
337
+ color-scheme: dark;
338
+ --nano-app:#0b0b10; --nano-panel:#10101a; --nano-raised:#16161f; --nano-inset:#08080c; --nano-hover:#1e1e2a;
339
+ --nano-edge:#24242f; --nano-edge-strong:#383848;
340
+ --nano-text:#f2f2f7; --nano-text-muted:#a3a3b2; --nano-text-faint:#6e6e80;
341
+ --nano-accent:#8b5cf6; --nano-accent-strong:#a78bfa; --nano-accent-2:#22d3ee; --nano-on-accent:#ffffff;
342
+ --nano-ok:#34d399; --nano-warn:#fbbf24; --nano-danger:#fb7185; --nano-info:#38bdf8;
343
+ }
344
+ :root[data-appearance="light"] {
345
+ color-scheme: light;
346
+ --nano-app:#f5f5f9; --nano-panel:#fdfdfe; --nano-raised:#ffffff; --nano-inset:#ededf3; --nano-hover:#e8e8f0;
347
+ --nano-edge:#e2e2ea; --nano-edge-strong:#c5c5d4;
348
+ --nano-text:#1a1a22; --nano-text-muted:#565664; --nano-text-faint:#8c8c9c;
349
+ --nano-accent:#7c3aed; --nano-accent-strong:#6d28d9; --nano-accent-2:#0891b2; --nano-on-accent:#ffffff;
350
+ --nano-ok:#059669; --nano-warn:#b45309; --nano-danger:#e11d48; --nano-info:#0369a1;
351
+ }
352
+ @media (prefers-color-scheme: light) {
353
+ /* Standalone only: follow the OS until the console (which sets data-appearance) themes us. */
354
+ :root:not([data-appearance]) {
355
+ color-scheme: light;
356
+ --nano-app:#f5f5f9; --nano-panel:#fdfdfe; --nano-raised:#ffffff; --nano-inset:#ededf3; --nano-hover:#e8e8f0;
357
+ --nano-edge:#e2e2ea; --nano-edge-strong:#c5c5d4;
358
+ --nano-text:#1a1a22; --nano-text-muted:#565664; --nano-text-faint:#8c8c9c;
359
+ --nano-accent:#7c3aed; --nano-accent-strong:#6d28d9; --nano-accent-2:#0891b2; --nano-on-accent:#ffffff;
360
+ --nano-ok:#059669; --nano-warn:#b45309; --nano-danger:#e11d48; --nano-info:#0369a1;
361
+ }
362
+ }
363
+ :root { --pc-edge:var(--nano-edge); --pc-accent:var(--nano-accent); }
328
364
  * { box-sizing: border-box; }
329
- body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; }
330
- .pc-empty { opacity:.6; }
365
+ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; background:var(--nano-app); color:var(--nano-text); }
366
+ .pc-empty { color:var(--nano-text-faint); }
331
367
  .pc-heading { font-size:1.6rem; font-weight:650; margin:0 0 .25rem; }
332
- .pc-sub { opacity:.7; margin:.25rem 0 1rem; }
368
+ .pc-sub { color:var(--nano-text-muted); margin:.25rem 0 1rem; }
333
369
  .pc-body { margin:.5rem 0; }
334
- .pc-card { border:1px solid var(--pc-edge); border-radius:.6rem; padding:1rem 1.15rem; margin:1rem 0; }
370
+ .pc-card { border:1px solid var(--nano-edge); border-radius:.6rem; padding:1rem 1.15rem; margin:1rem 0; background:var(--nano-panel); }
335
371
  .pc-card h2 { font-size:1rem; margin:0 0 .75rem; }
336
372
  .pc-field { display:flex; flex-direction:column; gap:.25rem; margin-bottom:.6rem; }
337
- .pc-field label { font-size:.8rem; opacity:.75; }
338
- .pc-field input { padding:.5rem .6rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
339
- .pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--pc-accent); color:#fff; font:inherit; cursor:pointer; }
373
+ .pc-field label { font-size:.8rem; color:var(--nano-text-muted); }
374
+ .pc-field input { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
375
+ .pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
340
376
  .pc-btn:disabled { opacity:.5; cursor:default; }
341
377
  .pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
342
- .pc-msg.err { color:#c0392b; }
343
- .pc-msg.ok { color:#2b8a3e; }
378
+ .pc-msg.err { color:var(--nano-danger); }
379
+ .pc-msg.ok { color:var(--nano-ok); }
344
380
  table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
345
- table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--pc-edge); }
346
- table.pc-grid th { font-weight:600; opacity:.75; }
381
+ table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); }
382
+ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
347
383
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
348
- .pc-tab { padding:.35rem .8rem; border:1px solid var(--pc-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
349
- .pc-tab.active { background:var(--pc-accent); color:#fff; border-color:var(--pc-accent); }
384
+ .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
385
+ .pc-tab.active { background:var(--nano-accent); color:var(--nano-on-accent); border-color:var(--nano-accent); }
350
386
  .pc-btn-sm { padding:.25rem .55rem; font-size:.8rem; margin-right:.3rem; }
351
- .pc-chevron { background:transparent; color:inherit; border:1px solid var(--pc-edge); }
387
+ .pc-chevron { background:transparent; color:inherit; border:1px solid var(--nano-edge); }
352
388
  .pc-row-actions { white-space:nowrap; text-align:right; }
353
389
  .pc-detail { padding:.75rem .25rem; }
354
390
  .pc-detail-field { display:flex; gap:.5rem; font-size:.85rem; margin:.15rem 0; }
355
- .pc-detail-label { opacity:.7; min-width:8rem; }
356
- .pc-link { color:var(--pc-accent); }
391
+ .pc-detail-label { color:var(--nano-text-muted); min-width:8rem; }
392
+ .pc-link { color:var(--nano-accent); }
357
393
  .pc-child { margin:.6rem 0; }
358
- .pc-child-title { font-size:.8rem; font-weight:600; opacity:.7; margin-bottom:.25rem; }
359
- .pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:rgba(120,120,160,.08); padding:.5rem; border-radius:.4rem; font-size:.8rem; margin-top:.4rem; }
360
- .pc-subform { margin-top:.75rem; padding:.6rem; border:1px dashed var(--pc-edge); border-radius:.5rem; }
394
+ .pc-child-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); margin-bottom:.25rem; }
395
+ .pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:var(--nano-inset); padding:.5rem; border-radius:.4rem; font-size:.8rem; margin-top:.4rem; }
396
+ .pc-subform { margin-top:.75rem; padding:.6rem; border:1px dashed var(--nano-edge); border-radius:.5rem; }
361
397
  .pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
362
- .pc-prompt { font-size:.85rem; opacity:.8; margin-bottom:.4rem; white-space:pre-wrap; }
363
- .pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
398
+ .pc-prompt { font-size:.85rem; color:var(--nano-text-muted); margin-bottom:.4rem; white-space:pre-wrap; }
399
+ .pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
364
400
  .pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
365
- .pc-chevron-inline { opacity:.6; font-size:.75rem; width:1em; }
401
+ .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
366
402
  .pc-card-body[hidden] { display:none; }
367
403
  `;
368
404
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
@@ -373,8 +409,55 @@ const RENDERER_JS = String.raw `
373
409
  const root = document.getElementById("page");
374
410
  const HOME = root.dataset.home || "home";
375
411
 
412
+ // Theme bridge for the Nano console embed. The app runs in a sandboxed,
413
+ // same-origin iframe, so the console's --nano-* custom properties and its
414
+ // data-appearance never cascade in. Instead the console postMessages its
415
+ // resolved tokens; we lay them onto this document's :root and mirror the
416
+ // appearance. Standalone (no console parent) nothing is posted, so the CSS
417
+ // defaults stand. We announce readiness so the console can push the theme even
418
+ // if its first message raced our listener install.
419
+ function applyTheme(msg) {
420
+ if (!msg || msg.type !== "nano-theme") return;
421
+ const el = document.documentElement;
422
+ if (msg.appearance === "light" || msg.appearance === "dark") el.dataset.appearance = msg.appearance;
423
+ const vars = msg.vars;
424
+ if (vars && typeof vars === "object") {
425
+ for (const [k, v] of Object.entries(vars)) {
426
+ // Only accept the shared token namespace; ignore anything else.
427
+ if (typeof k === "string" && k.startsWith("--nano-") && typeof v === "string") {
428
+ el.style.setProperty(k, v);
429
+ }
430
+ }
431
+ }
432
+ }
433
+ if (window.parent && window.parent !== window) {
434
+ window.addEventListener("message", (ev) => {
435
+ // Same-origin proxy (posture A): only trust a message from the framing
436
+ // parent AND from our own origin. ev.source alone is insufficient — a
437
+ // cross-origin page that frames a standalone run becomes window.parent, so
438
+ // an origin check is what actually pins the trust boundary.
439
+ if (ev.origin === window.location.origin && ev.source === window.parent) applyTheme(ev.data);
440
+ });
441
+ window.parent.postMessage({ type: "nano-app-ready" }, window.location.origin);
442
+ }
443
+
444
+ // Resolve every app endpoint against where THIS module was actually served from,
445
+ // so absolute-looking "/app/…" paths work both at the origin root (direct run,
446
+ // e.g. the CLI on :3000) and under a path-prefixed reverse proxy (the Nano
447
+ // console embeds the app at /console/app-view/<name>/). "../" drops the trailing
448
+ // "app/runtime.js" to land on the app's mount root (always ends in "/").
449
+ const APP_BASE = new URL("../", import.meta.url);
450
+ function apiUrl(u) {
451
+ // Rebase root-absolute app paths ("/app/…") onto the mount root. Leave a
452
+ // protocol-relative ("//host/…") or otherwise non-root-relative string alone —
453
+ // only a single leading slash denotes an app path we own.
454
+ return typeof u === "string" && u.startsWith("/") && !u.startsWith("//")
455
+ ? new URL(u.slice(1), APP_BASE).toString()
456
+ : u;
457
+ }
458
+
376
459
  async function getJSON(url, opts) {
377
- const r = await fetch(url, opts);
460
+ const r = await fetch(apiUrl(url), opts);
378
461
  const body = await r.json().catch(() => ({}));
379
462
  if (!r.ok) throw new Error(body.error || ("HTTP " + r.status));
380
463
  return body;
@@ -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
+ }