@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.
- package/dist/runtime/core/modules/dataops.d.ts +28 -4
- package/dist/runtime/core/modules/dataops.js +123 -5
- package/dist/runtime/core/modules/pages.js +17 -2
- package/dist/runtime/core/runtime.js +19 -0
- package/dist/toolkit/derivers/domain.d.ts +18 -0
- package/dist/toolkit/derivers/domain.js +134 -0
- package/dist/toolkit/derivers/messages.d.ts +14 -0
- package/dist/toolkit/derivers/messages.js +37 -0
- package/dist/toolkit/derivers/shapes.d.ts +96 -0
- package/dist/toolkit/derivers/shapes.js +498 -0
- package/dist/toolkit/derivers/worker-io.d.ts +26 -0
- package/dist/toolkit/derivers/worker-io.js +101 -1
- package/dist/toolkit/index.d.ts +6 -4
- package/dist/toolkit/index.js +4 -3
- package/package.json +1 -1
- package/src/runtime/core/modules/dataops.test.ts +132 -9
- package/src/runtime/core/modules/dataops.ts +180 -11
- package/src/runtime/core/modules/pages.test.ts +10 -0
- package/src/runtime/core/modules/pages.ts +17 -2
- package/src/runtime/core/runtime.test.ts +78 -0
- package/src/runtime/core/runtime.ts +18 -0
- package/src/toolkit/derivers/bindings-parity.test.ts +66 -0
- package/src/toolkit/derivers/domain.ts +171 -0
- package/src/toolkit/derivers/messages.ts +38 -0
- package/src/toolkit/derivers/shapes.test.ts +171 -0
- package/src/toolkit/derivers/shapes.ts +602 -0
- package/src/toolkit/derivers/worker-io.ts +103 -1
- package/src/toolkit/index.ts +24 -0
package/dist/toolkit/index.d.ts
CHANGED
|
@@ -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 {
|
|
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[];
|
package/dist/toolkit/index.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
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)
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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="
|
|
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) {
|