@nanobpm/urban 0.16.1 → 0.17.1

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.
Files changed (42) hide show
  1. package/dist/runtime/core/manifest.d.ts +4 -134
  2. package/dist/runtime/core/manifest.js +11 -6
  3. package/dist/runtime/core/modules/triggers.d.ts +14 -2
  4. package/dist/runtime/core/modules/triggers.js +0 -6
  5. package/dist/runtime/core/validate.js +11 -10
  6. package/dist/runtime/index.d.ts +1 -1
  7. package/dist/toolkit/derivers/domain.d.ts +69 -0
  8. package/dist/toolkit/derivers/domain.js +243 -0
  9. package/dist/toolkit/derivers/messages.d.ts +36 -0
  10. package/dist/toolkit/derivers/messages.js +134 -0
  11. package/dist/toolkit/derivers/meta.d.ts +34 -0
  12. package/dist/toolkit/derivers/meta.js +106 -0
  13. package/dist/toolkit/derivers/migrations.d.ts +34 -1
  14. package/dist/toolkit/derivers/migrations.js +37 -8
  15. package/dist/toolkit/derivers/worker-io.d.ts +14 -0
  16. package/dist/toolkit/derivers/worker-io.js +13 -2
  17. package/dist/toolkit/gen.js +8 -2
  18. package/dist/toolkit/index.d.ts +15 -3
  19. package/dist/toolkit/index.js +9 -2
  20. package/package.json +3 -2
  21. package/src/runtime/core/manifest.test.ts +2 -3
  22. package/src/runtime/core/manifest.ts +35 -134
  23. package/src/runtime/core/modules/triggers.test.ts +5 -3
  24. package/src/runtime/core/modules/triggers.ts +18 -4
  25. package/src/runtime/core/modules/workers.ts +2 -2
  26. package/src/runtime/core/validate.ts +11 -10
  27. package/src/runtime/index.ts +7 -4
  28. package/src/toolkit/derivers/domain.test.ts +140 -0
  29. package/src/toolkit/derivers/domain.ts +317 -0
  30. package/src/toolkit/derivers/messages.test.ts +174 -0
  31. package/src/toolkit/derivers/messages.ts +166 -0
  32. package/src/toolkit/derivers/meta.test.ts +140 -0
  33. package/src/toolkit/derivers/meta.ts +128 -0
  34. package/src/toolkit/derivers/migrations.test.ts +42 -4
  35. package/src/toolkit/derivers/migrations.ts +61 -9
  36. package/src/toolkit/derivers/worker-io.test.ts +10 -0
  37. package/src/toolkit/derivers/worker-io.ts +14 -2
  38. package/src/toolkit/gen.test.ts +8 -1
  39. package/src/toolkit/gen.ts +8 -2
  40. package/src/toolkit/index.ts +53 -2
  41. package/dist/runtime/schema/nano-app.schema.json +0 -503
  42. package/src/runtime/schema/nano-app.schema.json +0 -503
@@ -1,139 +1,40 @@
1
- // Manifest types (a hand-mirrored subset of nano-app.schema.json — the block shapes the
2
- // runtime actually consumes) plus env-placeholder expansion and a host-driven loader.
3
- // The schema itself (src/schema/nano-app.schema.json) remains the source of truth for
4
- // validation; see validate.ts.
1
+ // Manifest types + env-placeholder expansion + a host-driven loader.
2
+ //
3
+ // The App manifest contract (nano.app.json) is owned by the canonical
4
+ // @nanobpm/nano-app-schema package (ADR 0027): the JSON Schema and its generated
5
+ // TypeScript types are the single source of truth. This module re-exports those
6
+ // types (type-only, so nothing from the package is pulled into the runtime
7
+ // bundle) and adds the runtime-side helpers the App loader needs. No shape is
8
+ // hand-mirrored here — that eliminated the schema/type drift the vendored copies
9
+ // used to cause.
5
10
 
6
11
  import type { HostContext } from "./host.ts";
7
-
8
- export interface DataSource {
9
- driver: string;
10
- url: string;
11
- migrations?: string;
12
- }
13
-
14
- export interface TypeField {
15
- type: string;
16
- optional?: boolean;
17
- }
18
-
19
- export interface DomainType {
20
- name?: string;
21
- table?: string;
22
- fields?: Record<string, TypeField>;
23
- }
24
-
25
- export interface WorkerDecl {
26
- /** Job/task type the worker subscribes to. Schema allows `taskType` or `type`. */
27
- taskType?: string;
28
- type?: string;
29
- /** Path to the handler module, relative to the app root. Mutually exclusive with `llm`. */
30
- handler?: string;
31
- /** Name of an `llm[]` binding used as the worker (LLM-as-worker). Mutually exclusive with `handler`. */
32
- llm?: string;
33
- }
34
-
35
- /**
36
- * One named LLM binding (`llm.<id>` in the manifest). A binding is usable as a worker
37
- * (LLM-as-worker, ADR 0022 §E role 1) or as a chat surface agent. `provider` selects how
38
- * the endpoint is resolved (`"env"` reads it from the environment); `model` is typically
39
- * an env template. When `output.decision` is set, the model's JSON reply is fed through
40
- * that DMN decision (the "rails") and the decision's output is returned instead.
41
- */
42
- export interface LlmBinding {
43
- /** Provider selector. `"env"` resolves an OpenAI-compatible endpoint from the environment. */
44
- provider: string;
45
- /** Model id, typically an env template (e.g. `${NANO_APP_LLM_MODEL}`). */
46
- model: string;
47
- /** Constrains the model's structured output — currently a DMN decision id (the rails). */
48
- output?: { decision?: string };
49
- /** Action-API tools the agent may call (chat-agent role; unused by the worker role). */
50
- tools?: string[];
51
- }
52
-
53
- export interface TriggerDecl {
54
- id: string;
55
- type: string;
56
- path?: string;
57
- auth?: string;
58
- /** cron: the 5-field crontab spec (UTC), e.g. "0 6 * * *". */
59
- spec?: string;
60
- /** cron catch-up policy for fires missed while the app was down. Default "skip". */
61
- onMissed?: "skip" | "once" | "all";
62
- /** Source-kind-specific settings (e.g. file `{ pollMs }`). */
63
- config?: Record<string, unknown>;
64
- /** Name of a connections[] entry supplying this source's credentials. */
65
- connection?: string;
66
- /** Maps the event to one engine call: start a process OR publish a message (ADR 0025 §1). */
67
- action?: {
68
- /** Process id/name to start. */
69
- start?: string;
70
- /** messageName to publish as a CorrelateMessage. */
71
- message?: string;
72
- /** correlationKey (literal, or `= body.path`) for a message action. */
73
- correlationKey?: string;
74
- /**
75
- * Variables for the started instance / published message. Per the manifest schema
76
- * (`#/$defs/triggerAction.variables`) this is a FEEL string over the event body
77
- * (e.g. `= body.payload`); an inline object literal is also accepted for convenience.
78
- */
79
- variables?: string | Record<string, unknown>;
80
- };
81
- }
82
-
83
- export interface SurfaceDecl {
84
- enabled?: boolean;
85
- path?: string;
86
- agent?: string;
87
- /** pages surface: directory of `*.page.json` (relative to app root). Default `pages`. */
88
- pagesDir?: string;
89
- /** pages surface: the page served at `/`. Default `home`. */
90
- homePage?: string;
91
- /** pages surface: max rows a `dataGrid` fetch returns. Default 200. */
92
- rowLimit?: number;
93
- /** pages surface: the injected default datasource name. Default `app`. */
94
- sourceName?: string;
95
- }
96
-
97
- /**
98
- * An app-authored action handler override (ADR 0055 phase 3). Each declaration binds a
99
- * route to a handler module (default-exports an `ActionHandler`), letting an app wrap the
100
- * generic pages start/cancel/message actions with business logic. Mounted before the
101
- * generic pages action routes, so an exact override shadows the generic one.
102
- */
103
- export interface ActionDecl {
104
- /** Route path to serve, e.g. "/app/actions/cancel" or "/app/actions/start/convergence-loop". */
105
- path: string;
106
- /** Handler module path relative to the app root; default-exports an `ActionHandler`. */
107
- module: string;
108
- /** HTTP method to match. Default "POST". */
109
- method?: string;
110
- /** Match `path` as a prefix rather than exactly. Default false. */
111
- prefix?: boolean;
112
- }
113
-
114
- export interface AppManifest {
115
- $schema?: string;
116
- schemaVersion: 1;
117
- id: string;
118
- name: string;
119
- codename?: string;
120
- runtime?: { engine?: string; node?: string };
121
- models?: { processes?: string[]; decisions?: string[]; forms?: string[] };
122
- data?: { default?: string; sources?: Record<string, DataSource> };
123
- types?: Record<string, DomainType>;
124
- triggers?: TriggerDecl[];
125
- connections?: Record<string, { type: string; [k: string]: unknown }>;
126
- surfaces?: Record<string, SurfaceDecl>;
127
- actions?: ActionDecl[];
128
- workers?: WorkerDecl[];
129
- llm?: Record<string, LlmBinding>;
130
- security?: Record<string, unknown>;
131
- [k: string]: unknown;
132
- }
133
-
134
- /** The normalised job type for a worker declaration (`taskType` preferred, `type` fallback). */
135
- export function workerJobType(w: WorkerDecl): string | undefined {
136
- return w.taskType ?? w.type;
12
+ import type { AppManifest, Worker } from "@nanobpm/nano-app-schema";
13
+
14
+ export type {
15
+ ActionDecl,
16
+ AppManifest,
17
+ ChatSurface,
18
+ Connection,
19
+ Data,
20
+ DataSource,
21
+ DomainField,
22
+ DomainType,
23
+ LlmBinding,
24
+ Models,
25
+ PagesSurface,
26
+ Runtime,
27
+ Security,
28
+ Surfaces,
29
+ TaskInboxSurface,
30
+ Trigger,
31
+ TriggerAction,
32
+ Worker,
33
+ } from "@nanobpm/nano-app-schema";
34
+
35
+ /** The job type a worker subscribes to (schema key: `taskType`). */
36
+ export function workerJobType(w: Worker): string | undefined {
37
+ return w.taskType;
137
38
  }
138
39
 
139
40
  const ENV_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
@@ -2,7 +2,7 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import type { AppApi, RuntimeContext } from "../context.ts";
4
4
  import type { HttpRequest } from "../host.ts";
5
- import type { TriggerDecl } from "../manifest.ts";
5
+ import type { Trigger } from "../manifest.ts";
6
6
  import { makeRouter } from "../router.ts";
7
7
  import { evalCorrelation, mountTriggers, runTriggerAction, type SchedulerDeps } from "./triggers.ts";
8
8
 
@@ -33,7 +33,7 @@ function fakeApp(): { app: AppApi; calls: EngineCall[]; logs: Array<{ level: str
33
33
  return { app, calls, logs };
34
34
  }
35
35
 
36
- function fakeCtx(triggers: TriggerDecl[]): { ctx: RuntimeContext; hostLogs: Array<{ level: string; msg: string }> } {
36
+ function fakeCtx(triggers: Trigger[]): { ctx: RuntimeContext; hostLogs: Array<{ level: string; msg: string }> } {
37
37
  const hostLogs: Array<{ level: string; msg: string }> = [];
38
38
  const ctx = {
39
39
  root: "/app",
@@ -113,6 +113,8 @@ test("evalCorrelation returns undefined for a non-scalar result", () => {
113
113
 
114
114
  test("runTriggerAction starts a process with action.variables", async () => {
115
115
  const { app, calls } = fakeApp();
116
+ // The manifest surface types `variables` as a FEEL string; the runtime helper
117
+ // (runTriggerAction/resolveActionVariables) also accepts an inline object literal.
116
118
  const res = await runTriggerAction(app, { start: "proc-1", variables: { a: 1 } }, {
117
119
  body: { ignored: true },
118
120
  headers: {},
@@ -207,7 +209,7 @@ test("cron trigger with an invalid spec logs an error and does not schedule", ()
207
209
 
208
210
  test("cron trigger with no actionable action is not scheduled", () => {
209
211
  const { app, logs } = fakeApp();
210
- const { ctx } = fakeCtx([{ id: "noop", type: "cron", spec: "0 6 * * *" }]);
212
+ const { ctx } = fakeCtx([{ id: "noop", type: "cron", spec: "0 6 * * *", action: {} }]);
211
213
  const sched = fakeScheduler(0);
212
214
  const handle = mountTriggers(ctx, app, sched);
213
215
  assert.equal(sched.pending(), 0);
@@ -6,7 +6,7 @@
6
6
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
7
7
  import type { HttpRequest } from "../host.ts";
8
8
  import { json, normalizeRoutePath, type Route } from "../router.ts";
9
- import type { TriggerDecl } from "../manifest.ts";
9
+ import type { Trigger } from "../manifest.ts";
10
10
  import { nextCronFire, parseCron } from "./cron.ts";
11
11
 
12
12
  export interface TriggersHandle extends Mounted {
@@ -112,9 +112,23 @@ function headerRecord(req: HttpRequest): Record<string, string> {
112
112
  * of `correlationKey`/`variables`; cron passes a minimal `{ firedAt }` body. Returns what
113
113
  * was done.
114
114
  */
115
+ /**
116
+ * The trigger action as the *runtime* helper reads it: the fields `runTriggerAction`
117
+ * consumes, with `variables` permitting an inline object literal as well as a FEEL string.
118
+ * The manifest surface keeps `variables` string-only (a FEEL expression);
119
+ * `resolveActionVariables` tolerates both, so this shape matches runtime behaviour without
120
+ * loosening the schema. A locked `Trigger["action"]` is assignable to it.
121
+ */
122
+ export interface RuntimeTriggerAction {
123
+ start?: string;
124
+ message?: string;
125
+ correlationKey?: string;
126
+ variables?: string | Record<string, unknown>;
127
+ }
128
+
115
129
  export async function runTriggerAction(
116
130
  app: AppApi,
117
- action: TriggerDecl["action"],
131
+ action: RuntimeTriggerAction | undefined,
118
132
  scope: Scope,
119
133
  ): Promise<{ kind: "start" | "message" | "none"; target?: string; correlationKey?: string }> {
120
134
  if (!action) return { kind: "none" };
@@ -161,7 +175,7 @@ export function mountTriggers(
161
175
  let stopped = false;
162
176
 
163
177
  // Arm a cron trigger: compute the next UTC fire, sleep until it, fire the action, repeat.
164
- const armCron = (trig: TriggerDecl): void => {
178
+ const armCron = (trig: Trigger): void => {
165
179
  const spec = trig.spec;
166
180
  if (!spec) {
167
181
  ctx.host.log("warn", `trigger "${trig.id}": cron trigger has no spec, skipped`);
@@ -241,7 +255,7 @@ export function mountTriggers(
241
255
  arm();
242
256
  };
243
257
 
244
- for (const trig of (ctx.manifest.triggers ?? []) as TriggerDecl[]) {
258
+ for (const trig of (ctx.manifest.triggers ?? []) as Trigger[]) {
245
259
  if (trig.type === "cron") {
246
260
  armCron(trig);
247
261
  continue;
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
6
6
  import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
7
- import { workerJobType, type WorkerDecl } from "../manifest.ts";
7
+ import { workerJobType, type Worker } from "../manifest.ts";
8
8
  import { type DecisionEvaluator, type LlmRuntime, runLlmJob } from "./llm.ts";
9
9
 
10
10
  /** The subset of the engine SDK the LLM decision-rails need: evaluate a DMN decision,
@@ -106,7 +106,7 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
106
106
  return mod;
107
107
  };
108
108
 
109
- for (const decl of decls as WorkerDecl[]) {
109
+ for (const decl of decls as Worker[]) {
110
110
  const jobType = workerJobType(decl);
111
111
  if (!jobType) continue;
112
112
  if (!decl.handler) {
@@ -1,13 +1,14 @@
1
- // Manifest validation. We deliberately avoid a heavyweight JSON-Schema engine here to
2
- // keep core dependency-free and runtime-agnostic. Instead we drive validation *from* the
3
- // vendored nano-app.schema.json (the ADR 0027 source of truth) for the top-level envelope
4
- // — required keys, allowed keys (additionalProperties:false), the schemaVersion const, and
5
- // the id slug pattern — and then layer the runtime's binding rules (a worker needs a
6
- // handler, a data source needs driver+url, a typed table needs fields, a trigger needs
7
- // id+type). Reading the schema means the envelope check tracks the schema and can't drift
8
- // from it silently.
1
+ // Manifest validation. We deliberately avoid a heavyweight JSON-Schema engine here to keep
2
+ // core lean and runtime-agnostic. Instead we drive validation *from* the canonical
3
+ // nano-app.schema.json (the ADR 0027 source of truth, imported from the
4
+ // @nanobpm/nano-app-schema package) for the top-level envelope — required keys, allowed keys
5
+ // (additionalProperties:false), the schemaVersion const, and the id slug pattern — and then
6
+ // layer the runtime's binding rules (a worker needs a handler, a data source needs
7
+ // driver+url, a typed table needs fields, a trigger needs id+type). Importing the shared
8
+ // schema JSON means the envelope check tracks the published schema and can't drift from it
9
+ // silently — the whole point of consuming the package rather than vendoring a copy.
9
10
 
10
- import schema from "../schema/nano-app.schema.json" with { type: "json" };
11
+ import schema from "@nanobpm/nano-app-schema/schema" with { type: "json" };
11
12
  import { workerJobType, type AppManifest } from "./manifest.ts";
12
13
 
13
14
  export interface ValidationIssue {
@@ -73,7 +74,7 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
73
74
  if (Array.isArray(man.workers)) {
74
75
  man.workers.forEach((w, i) => {
75
76
  if (!workerJobType(w)) {
76
- issues.push({ path: `workers[${i}]`, message: "missing taskType/type" });
77
+ issues.push({ path: `workers[${i}]`, message: "missing taskType" });
77
78
  }
78
79
  // The schema allows a worker backed by either a `handler` file or an `llm`
79
80
  // binding (oneOf). Require exactly one — don't force `handler`.
@@ -19,13 +19,16 @@ export {
19
19
  export type {
20
20
  ActionDecl,
21
21
  AppManifest,
22
+ ChatSurface,
22
23
  DataSource,
24
+ DomainField,
23
25
  DomainType,
24
26
  LlmBinding,
25
- SurfaceDecl,
26
- TriggerDecl,
27
- TypeField,
28
- WorkerDecl,
27
+ PagesSurface,
28
+ Surfaces,
29
+ TaskInboxSurface,
30
+ Trigger,
31
+ Worker,
29
32
  } from "./core/manifest.ts";
30
33
 
31
34
  // Validation
@@ -0,0 +1,140 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ emitDomainModel,
5
+ emitDomain,
6
+ deriveDomain,
7
+ sourcesFromManifest,
8
+ registryFromManifest,
9
+ sqliteAffinityToTs,
10
+ interfaceName,
11
+ type SourceSchema,
12
+ type TableMeta,
13
+ type DomainTypeRegistry,
14
+ } from "./domain.ts";
15
+ import type { ToolkitManifest } from "./migrations.ts";
16
+
17
+ // Goldens captured verbatim from the console's `emitDomainModel` (server domain_types.ts) via
18
+ // Deno — this IS the per-emitter drift gate (ADR 0053): the toolkit must be byte-identical.
19
+ const GOLDENS: Record<string, string> = JSON.parse("{\n \"empty\": \"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1).\\n// Do not edit — regenerated from the live tables. Erased at `deno compile`.\\n// eslint-disable\\n\\nexport interface DomainTables {}\\n\",\n \"tablesOnly\": \"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1).\\n// Do not edit — regenerated from the live tables. Erased at `deno compile`.\\n// eslint-disable\\n\\nexport interface Customers {\\n /** INTEGER (primary key) */\\n id: number;\\n /** TEXT */\\n email: string;\\n /** TEXT */\\n tier: string | null;\\n}\\n\\nexport interface OrderItems {\\n /** INTEGER (primary key) */\\n id: number;\\n /** INTEGER */\\n qty: number;\\n /** REAL */\\n \\\"unit price\\\": number | null;\\n}\\n\\n/** Every table keyed by its wire name — index it for a row type. */\\nexport interface DomainTables {\\n \\\"customers\\\": Customers;\\n \\\"order_items\\\": OrderItems;\\n}\\n\",\n \"typesOnly\": \"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1).\\n// Do not edit — regenerated from the live tables. Erased at `deno compile`.\\n// eslint-disable\\n\\nexport interface DomainTables {}\\n\\n/** Declared (transient) domain types from the manifest `types` registry (ADR 0029 §4.2), keyed by id. */\\nexport interface DomainTypes {\\n \\\"taxReturn\\\": {\\n year: number;\\n notes?: string;\\n lines: DomainTypes[\\\"lineItem\\\"][];\\n };\\n \\\"lineItem\\\": {\\n \\\"line no\\\": number;\\n amount: number;\\n };\\n}\\n\",\n \"both\": \"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1).\\n// Do not edit — regenerated from the live tables. Erased at `deno compile`.\\n// eslint-disable\\n\\nexport interface Greetings {\\n /** INTEGER (primary key) */\\n id: number;\\n /** TEXT */\\n who: string;\\n /** TEXT */\\n message: string;\\n /** TEXT */\\n createdAt: string | null;\\n}\\n\\n/** Every table keyed by its wire name — index it for a row type. */\\nexport interface DomainTables {\\n \\\"greetings\\\": Greetings;\\n}\\n\\n/** Declared (transient) domain types from the manifest `types` registry (ADR 0029 §4.2), keyed by id. */\\nexport interface DomainTypes {\\n \\\"greeting\\\": {\\n who: string;\\n message: string;\\n createdAt?: string;\\n };\\n}\\n\",\n \"multi\": \"// AUTO-GENERATED by nanobpmn from the datasource schemas (ADR 0029 §4.1/§6).\\n// Do not edit — regenerated from the live tables. Erased at `deno compile`.\\n// eslint-disable\\n\\nexport interface AppCustomers {\\n /** INTEGER (primary key) */\\n id: number;\\n /** TEXT */\\n email: string;\\n}\\n\\nexport interface AnalyticsEvents {\\n /** INTEGER (primary key) */\\n id: number;\\n /** TEXT */\\n kind: string;\\n}\\n\\n/** Every datasource keyed by alias, then table by wire name. */\\nexport interface DomainSources {\\n \\\"app\\\": {\\n \\\"customers\\\": AppCustomers;\\n };\\n \\\"analytics\\\": {\\n \\\"events\\\": AnalyticsEvents;\\n };\\n}\\n\\n/** The default datasource's tables — index by wire name. */\\nexport type DomainTables = DomainSources[\\\"analytics\\\"];\\n\"\n}");
20
+
21
+ const col = (name: string, type: string, notNull: boolean, primaryKey: boolean) =>
22
+ ({ name, type, notNull, primaryKey });
23
+ const table = (name: string, cols: TableMeta["columns"]): TableMeta => ({ name, columns: cols, indexes: [], foreignKeys: [] });
24
+
25
+ test("emitDomainModel: empty app → bare DomainTables (byte-parity)", () => {
26
+ assert.equal(emitDomainModel([], undefined, {}), GOLDENS.empty);
27
+ });
28
+
29
+ test("emitDomainModel: table spine only (byte-parity)", () => {
30
+ const sources: SourceSchema[] = [{ source: "app", tables: [
31
+ table("customers", [col("id","INTEGER",false,true), col("email","TEXT",true,false), col("tier","TEXT",false,false)]),
32
+ table("order_items", [col("id","INTEGER",false,true), col("qty","INTEGER",true,false), col("unit price","REAL",false,false)]),
33
+ ]}];
34
+ assert.equal(emitDomainModel(sources, "app", {}), GOLDENS.tablesOnly);
35
+ });
36
+
37
+ test("emitDomainModel: registry only — nominal ref, list, optional, non-ident key (byte-parity)", () => {
38
+ const types: DomainTypeRegistry = {
39
+ taxReturn: { fields: { year: { type: "integer" }, notes: { type: "string", optional: true }, lines: { type: "lineItem", list: true } } },
40
+ lineItem: { fields: { "line no": { type: "integer" }, amount: { type: "number" } } },
41
+ };
42
+ assert.equal(emitDomainModel([], undefined, types), GOLDENS.typesOnly);
43
+ });
44
+
45
+ test("emitDomainModel: table + registry coexist for same type (byte-parity)", () => {
46
+ const sources: SourceSchema[] = [{ source: "app", tables: [
47
+ table("greetings", [col("id","INTEGER",false,true), col("who","TEXT",true,false), col("message","TEXT",true,false), col("createdAt","TEXT",false,false)]),
48
+ ]}];
49
+ const types: DomainTypeRegistry = { greeting: { table: "greetings", fields: { who: { type: "string" }, message: { type: "string" }, createdAt: { type: "datetime", optional: true } } } };
50
+ assert.equal(emitDomainModel(sources, "app", types), GOLDENS.both);
51
+ });
52
+
53
+ test("emitDomainModel: multi-source → DomainSources + DomainTables alias to default (byte-parity)", () => {
54
+ const sources: SourceSchema[] = [
55
+ { source: "app", tables: [ table("customers", [col("id","INTEGER",false,true), col("email","TEXT",true,false)]) ] },
56
+ { source: "analytics", tables: [ table("events", [col("id","INTEGER",false,true), col("kind","TEXT",true,false)]) ] },
57
+ ];
58
+ assert.equal(emitDomainModel(sources, "analytics", {}), GOLDENS.multi);
59
+ });
60
+
61
+ // --- manifest bridge: code-first derivation reproduces the console output --------------------
62
+
63
+ test("emitDomain: manifest types drive BOTH the table spine and the registry (byte-parity)", () => {
64
+ const manifest: ToolkitManifest = {
65
+ data: { default: "app" },
66
+ types: { greeting: { table: "greetings", fields: {
67
+ who: { type: "string" }, message: { type: "string" }, createdAt: { type: "datetime", optional: true },
68
+ } } },
69
+ };
70
+ // The manifest 'greeting' type yields DomainTables["greetings"] (id + fields) and
71
+ // DomainTypes["greeting"] (declared fields only) — identical to the console's `both` case.
72
+ assert.equal(emitDomain(manifest), GOLDENS.both);
73
+ });
74
+
75
+ test("sourcesFromManifest: implicit id PK + field affinity + nullability", () => {
76
+ const manifest: ToolkitManifest = { data: { default: "app" }, types: {
77
+ greeting: { table: "greetings", fields: { who: { type: "string" }, createdAt: { type: "datetime", optional: true } } },
78
+ } };
79
+ const [src] = sourcesFromManifest(manifest);
80
+ assert.equal(src.source, "app");
81
+ assert.deepEqual(src.tables[0], table("greetings", [
82
+ col("id", "INTEGER", false, true),
83
+ col("who", "TEXT", true, false),
84
+ col("createdAt", "TEXT", false, false),
85
+ ]));
86
+ });
87
+
88
+ test("sourcesFromManifest: table name defaults to the type id; types group by source", () => {
89
+ const manifest: ToolkitManifest = { data: { default: "app" }, types: {
90
+ order: { fields: { total: { type: "number" } } },
91
+ event: { source: "analytics", fields: { kind: { type: "string" } } },
92
+ } };
93
+ const sources = sourcesFromManifest(manifest);
94
+ assert.deepEqual(sources.map((s) => s.source), ["app", "analytics"]);
95
+ assert.equal(sources[0].tables[0].name, "order");
96
+ assert.equal(sources[1].tables[0].name, "event");
97
+ });
98
+
99
+ test("registryFromManifest: declared fields only (no persisted id); type defaults to string", () => {
100
+ const manifest: ToolkitManifest = { types: {
101
+ greeting: { table: "greetings", fields: { who: {}, note: { type: "string", optional: true } } },
102
+ } };
103
+ assert.deepEqual(registryFromManifest(manifest), {
104
+ greeting: { table: "greetings", fields: { who: { type: "string", optional: undefined, list: undefined }, note: { type: "string", optional: true, list: undefined } } },
105
+ });
106
+ });
107
+
108
+ test("registryFromManifest + emitDomain: the `list` modifier is preserved as `T[]`", () => {
109
+ const manifest: ToolkitManifest = { types: {
110
+ order: { fields: { tags: { type: "string", list: true } } },
111
+ } };
112
+ // The bridge must forward `list` (not drop it) so the emitter can project the repeated field.
113
+ assert.equal(registryFromManifest(manifest).order.fields.tags.list, true);
114
+ assert.match(emitDomain(manifest), /tags: string\[\];/);
115
+ });
116
+
117
+ test("deriveDomain: emits domain-rows.d.ts only when the manifest declares types", () => {
118
+ assert.deepEqual(deriveDomain({}), []);
119
+ assert.deepEqual(deriveDomain({ types: {} }), []);
120
+ const arts = deriveDomain({ data: { default: "app" }, types: { greeting: { table: "greetings", fields: { who: { type: "string" } } } } });
121
+ assert.equal(arts.length, 1);
122
+ assert.equal(arts[0].path, "nano-generated/domain-rows.d.ts");
123
+ });
124
+
125
+ test("sqliteAffinityToTs: SQLite affinity rules with boolean/datetime overrides", () => {
126
+ assert.equal(sqliteAffinityToTs("INTEGER"), "number");
127
+ assert.equal(sqliteAffinityToTs("TEXT"), "string");
128
+ assert.equal(sqliteAffinityToTs("REAL"), "number");
129
+ assert.equal(sqliteAffinityToTs("BLOB"), "Uint8Array");
130
+ assert.equal(sqliteAffinityToTs("BOOLEAN"), "boolean");
131
+ assert.equal(sqliteAffinityToTs("DATETIME"), "string");
132
+ assert.equal(sqliteAffinityToTs("NUMERIC"), "number");
133
+ assert.equal(sqliteAffinityToTs(""), "unknown");
134
+ });
135
+
136
+ test("interfaceName: PascalCases + sanitises table names to identifiers", () => {
137
+ assert.equal(interfaceName("order_items"), "OrderItems");
138
+ assert.equal(interfaceName("customers"), "Customers");
139
+ assert.equal(interfaceName("2024_events"), "T_2024Events");
140
+ });