@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.
@@ -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.24.1",
3
+ "version": "0.26.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",
@@ -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,12 +86,22 @@ 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"/);
89
95
  });
90
96
 
91
97
  test("GET /app/runtime.js serves the renderer module", async () => {
92
98
  const res = await dispatch("GET", "/app/runtime.js");
93
99
  assert.match(res.headers?.["content-type"] ?? "", /javascript/);
94
100
  assert.match(res.body ?? "", /pc:refresh/);
101
+ // Endpoints are rebased against the served module URL so the app works under a
102
+ // path-prefixed reverse proxy (Nano console embed), not just at the origin root.
103
+ assert.match(res.body ?? "", /new URL\("\.\.\/", import\.meta\.url\)/);
104
+ assert.match(res.body ?? "", /function apiUrl\(u\)/);
95
105
  });
96
106
 
97
107
  test("renderer preserves grid row-detail expansion across refreshes", async () => {
@@ -348,7 +348,7 @@ function rendererShell(homePage: string): string {
348
348
  </head>
349
349
  <body>
350
350
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
351
- <script type="module" src="/app/runtime.js"></script>
351
+ <script type="module" src="./app/runtime.js"></script>
352
352
  </body>
353
353
  </html>`;
354
354
  }
@@ -404,8 +404,23 @@ const RENDERER_JS = String.raw`
404
404
  const root = document.getElementById("page");
405
405
  const HOME = root.dataset.home || "home";
406
406
 
407
+ // Resolve every app endpoint against where THIS module was actually served from,
408
+ // so absolute-looking "/app/…" paths work both at the origin root (direct run,
409
+ // e.g. the CLI on :3000) and under a path-prefixed reverse proxy (the Nano
410
+ // console embeds the app at /console/app-view/<name>/). "../" drops the trailing
411
+ // "app/runtime.js" to land on the app's mount root (always ends in "/").
412
+ const APP_BASE = new URL("../", import.meta.url);
413
+ function apiUrl(u) {
414
+ // Rebase root-absolute app paths ("/app/…") onto the mount root. Leave a
415
+ // protocol-relative ("//host/…") or otherwise non-root-relative string alone —
416
+ // only a single leading slash denotes an app path we own.
417
+ return typeof u === "string" && u.startsWith("/") && !u.startsWith("//")
418
+ ? new URL(u.slice(1), APP_BASE).toString()
419
+ : u;
420
+ }
421
+
407
422
  async function getJSON(url, opts) {
408
- const r = await fetch(url, opts);
423
+ const r = await fetch(apiUrl(url), opts);
409
424
  const body = await r.json().catch(() => ({}));
410
425
  if (!r.ok) throw new Error(body.error || ("HTTP " + r.status));
411
426
  return body;
@@ -324,3 +324,81 @@ test("runFromEnv anchors the host at a non-'.' root without double-prefixing pat
324
324
  await rm(dir, { recursive: true, force: true });
325
325
  }
326
326
  });
327
+
328
+ // Captures console.log for the duration of `fn`, restoring it after.
329
+ async function captureStdout(fn: () => Promise<void>): Promise<string[]> {
330
+ const lines: string[] = [];
331
+ const original = console.log;
332
+ console.log = (...args: unknown[]) => {
333
+ lines.push(args.map((a) => (typeof a === "string" ? a : String(a))).join(" "));
334
+ };
335
+ try {
336
+ await fn();
337
+ } finally {
338
+ console.log = original;
339
+ }
340
+ return lines;
341
+ }
342
+
343
+ test("emits the ADR 0057 boot handshake when NANOBPMN_APP_HANDSHAKE is set", async () => {
344
+ const dir = await makeFixture();
345
+ const host = createNodeHost({ cwd: dir, log: () => {} });
346
+ const engine = new FakeEngine();
347
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
348
+ const prev = process.env.NANOBPMN_APP_HANDSHAKE;
349
+ process.env.NANOBPMN_APP_HANDSHAKE = "1";
350
+ try {
351
+ const lines = await captureStdout(() => app.start());
352
+ const handshake = lines.find((l) => l.startsWith("@@NBPM_LISTENING@@"));
353
+ assert.ok(handshake, "a @@NBPM_LISTENING@@ line was emitted");
354
+ const payload = JSON.parse(handshake!.slice("@@NBPM_LISTENING@@".length));
355
+ assert.equal(payload.port, app.httpPort, "handshake reports the actual bound port");
356
+ assert.ok(payload.port > 0);
357
+ } finally {
358
+ if (prev === undefined) delete process.env.NANOBPMN_APP_HANDSHAKE;
359
+ else process.env.NANOBPMN_APP_HANDSHAKE = prev;
360
+ await app.stop();
361
+ await rm(dir, { recursive: true, force: true });
362
+ }
363
+ });
364
+
365
+ test("does NOT emit the boot handshake without NANOBPMN_APP_HANDSHAKE", async () => {
366
+ const dir = await makeFixture();
367
+ const host = createNodeHost({ cwd: dir, log: () => {} });
368
+ const engine = new FakeEngine();
369
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
370
+ const prev = process.env.NANOBPMN_APP_HANDSHAKE;
371
+ delete process.env.NANOBPMN_APP_HANDSHAKE;
372
+ try {
373
+ const lines = await captureStdout(() => app.start());
374
+ assert.ok(
375
+ !lines.some((l) => l.startsWith("@@NBPM_LISTENING@@")),
376
+ "no handshake line when the supervisor env is unset (clean terminal runs)",
377
+ );
378
+ } finally {
379
+ if (prev !== undefined) process.env.NANOBPMN_APP_HANDSHAKE = prev;
380
+ await app.stop();
381
+ await rm(dir, { recursive: true, force: true });
382
+ }
383
+ });
384
+
385
+ test("treats NANOBPMN_APP_HANDSHAKE other than \"1\" as opt-out", async () => {
386
+ const dir = await makeFixture();
387
+ const host = createNodeHost({ cwd: dir, log: () => {} });
388
+ const engine = new FakeEngine();
389
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
390
+ const prev = process.env.NANOBPMN_APP_HANDSHAKE;
391
+ process.env.NANOBPMN_APP_HANDSHAKE = "0";
392
+ try {
393
+ const lines = await captureStdout(() => app.start());
394
+ assert.ok(
395
+ !lines.some((l) => l.startsWith("@@NBPM_LISTENING@@")),
396
+ '"0" is not the opt-in sentinel ("1"), so no handshake is emitted',
397
+ );
398
+ } finally {
399
+ if (prev === undefined) delete process.env.NANOBPMN_APP_HANDSHAKE;
400
+ else process.env.NANOBPMN_APP_HANDSHAKE = prev;
401
+ await app.stop();
402
+ await rm(dir, { recursive: true, force: true });
403
+ }
404
+ });
@@ -191,9 +191,11 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
191
191
  }
192
192
 
193
193
  const routes: Route[] = [];
194
+ let hasUiSurfaces = false;
194
195
  if (flags.surfaces) {
195
196
  const s = mountSurfaces(ctx, api);
196
197
  routes.push(...s.routes);
198
+ hasUiSurfaces = s.routes.length > 0;
197
199
  describe.surfaces = s.describe();
198
200
  }
199
201
  if (flags.triggers) {
@@ -219,6 +221,22 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
219
221
  server = await host.serveHttp(port, makeRouter(routes));
220
222
  httpPort = server.port;
221
223
  host.log("info", "urban app serving surfaces/triggers", { port: httpPort, routes: routes.length });
224
+ // ADR 0057 boot handshake: under a supervising host (the nano-bpm
225
+ // Studio sets NANOBPMN_APP_HANDSHAKE), announce the port we actually
226
+ // bound on a machine-readable stdout control line so the host can
227
+ // locate the app's UI even when the app picks its port at runtime.
228
+ // Only emitted when the app actually serves UI surfaces (a trigger- or
229
+ // worker-only app binds a port too, but it has no webview for the host
230
+ // to frame), and gated on the env var so direct terminal runs aren't
231
+ // cluttered. `=== "1"` so an app that sets the var to "0" opts out.
232
+ if (httpPort !== undefined && hasUiSurfaces && host.env("NANOBPMN_APP_HANDSHAKE") === "1") {
233
+ try {
234
+ console.log(`@@NBPM_LISTENING@@${JSON.stringify({ port: httpPort })}`);
235
+ } catch {
236
+ // stdout may be closed / non-writable — the handshake is
237
+ // best-effort telemetry, never fatal to the app.
238
+ }
239
+ }
222
240
  }
223
241
  host.log("info", `urban app "${manifest.id}" started`, {});
224
242
  } catch (err) {