@nanobpm/urban 0.17.0 → 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.
@@ -2,6 +2,8 @@ import type { DerivedArtifact, Deriver } from "../artifact.ts";
2
2
  export interface ToolkitField {
3
3
  type?: string;
4
4
  optional?: boolean;
5
+ /** Marks a repeated field: the domain row-type projects it as `T[]` in the `DomainTypes` registry. */
6
+ list?: boolean;
5
7
  }
6
8
  export interface ToolkitType {
7
9
  table?: string;
@@ -20,7 +22,38 @@ export interface ToolkitManifest {
20
22
  /** Map a domain field type to a SQLite column type. Unknown types fall back to TEXT. */
21
23
  export declare function sqlType(t: string | undefined): string;
22
24
  export declare function assertSqlIdent(kind: string, name: string): string;
23
- /** Build the CREATE TABLE statement for one type. */
25
+ /** Double-quote an identifier for DDL. `assertSqlIdent` already rejects non-identifier
26
+ * chars (the injection guard), but quoting is what makes valid-but-reserved words
27
+ * (`order`, `group`, `select`, …) legal SQL — no reserved-word denylist to maintain.
28
+ * Embedded double quotes are doubled per the SQL standard (defensive; can't occur here). */
29
+ export declare function quoteSqlIdent(name: string): string;
30
+ /** A column in a manifest type's canonical table schema. `sqlType` is the SQLite
31
+ * declared type (the affinity source for both the CREATE TABLE and the row types). */
32
+ export interface DerivedColumn {
33
+ name: string;
34
+ sqlType: string;
35
+ notNull: boolean;
36
+ primaryKey: boolean;
37
+ }
38
+ /** The canonical table schema for one manifest type. */
39
+ export interface DerivedTable {
40
+ table: string;
41
+ columns: DerivedColumn[];
42
+ }
43
+ /**
44
+ * Derive the canonical table schema for one manifest type: an implicit
45
+ * autoincrement `id` primary key plus one column per declared field. This is the
46
+ * single source of truth both the SQL migration (`createTableSql`) and the domain
47
+ * row-types (`domain.ts`) derive from, so the schema can never drift between them.
48
+ */
49
+ export declare function tableSchemaForType(typeName: string, def: ToolkitType): DerivedTable;
50
+ /** Group manifest types by their datasource, preserving declaration order within each
51
+ * source. Shared by the migrations and domain derivers so both partition and order
52
+ * sources identically (a single source of truth for the source spine). */
53
+ export declare function groupTypesBySource(manifest: ToolkitManifest): Map<string, Array<[string, ToolkitType]>>;
54
+ /** Build the CREATE TABLE statement for one type, from its canonical schema. Table and
55
+ * column identifiers are quoted so valid-but-reserved names (`order`, `group`, …) emit
56
+ * legal SQL. */
24
57
  export declare function createTableSql(typeName: string, def: ToolkitType): string;
25
58
  export declare function deriveMigrations(manifest: ToolkitManifest): DerivedArtifact[];
26
59
  export declare const migrationsDeriver: Deriver<ToolkitManifest>;
@@ -31,25 +31,40 @@ export function assertSqlIdent(kind, name) {
31
31
  }
32
32
  return name;
33
33
  }
34
- /** Build the CREATE TABLE statement for one type. */
35
- export function createTableSql(typeName, def) {
34
+ /** Double-quote an identifier for DDL. `assertSqlIdent` already rejects non-identifier
35
+ * chars (the injection guard), but quoting is what makes valid-but-reserved words
36
+ * (`order`, `group`, `select`, …) legal SQL — no reserved-word denylist to maintain.
37
+ * Embedded double quotes are doubled per the SQL standard (defensive; can't occur here). */
38
+ export function quoteSqlIdent(name) {
39
+ return `"${name.replace(/"/g, '""')}"`;
40
+ }
41
+ /**
42
+ * Derive the canonical table schema for one manifest type: an implicit
43
+ * autoincrement `id` primary key plus one column per declared field. This is the
44
+ * single source of truth both the SQL migration (`createTableSql`) and the domain
45
+ * row-types (`domain.ts`) derive from, so the schema can never drift between them.
46
+ */
47
+ export function tableSchemaForType(typeName, def) {
36
48
  const table = assertSqlIdent("table name", def.table ?? typeName);
37
- const cols = [" id INTEGER PRIMARY KEY AUTOINCREMENT"];
49
+ const columns = [
50
+ { name: "id", sqlType: "INTEGER", notNull: false, primaryKey: true },
51
+ ];
38
52
  const fields = def.fields ?? {};
39
53
  for (const [name, f] of Object.entries(fields)) {
40
54
  assertSqlIdent("field name", name);
41
55
  if (name === "id") {
42
56
  throw new Error(`type "${typeName}": field "id" is reserved (the table's implicit primary key)`);
43
57
  }
44
- const notNull = f.optional ? "" : " NOT NULL";
45
- cols.push(` ${name} ${sqlType(f.type)}${notNull}`);
58
+ columns.push({ name, sqlType: sqlType(f.type), notNull: !f.optional, primaryKey: false });
46
59
  }
47
- return `CREATE TABLE IF NOT EXISTS ${table} (\n${cols.join(",\n")}\n);`;
60
+ return { table, columns };
48
61
  }
49
- export function deriveMigrations(manifest) {
62
+ /** Group manifest types by their datasource, preserving declaration order within each
63
+ * source. Shared by the migrations and domain derivers so both partition and order
64
+ * sources identically (a single source of truth for the source spine). */
65
+ export function groupTypesBySource(manifest) {
50
66
  const types = manifest.types ?? {};
51
67
  const defaultSource = manifest.data?.default ?? "app";
52
- // Group types by their datasource.
53
68
  const bySource = new Map();
54
69
  for (const [name, def] of Object.entries(types)) {
55
70
  const src = def.source ?? defaultSource;
@@ -57,6 +72,20 @@ export function deriveMigrations(manifest) {
57
72
  list.push([name, def]);
58
73
  bySource.set(src, list);
59
74
  }
75
+ return bySource;
76
+ }
77
+ /** Build the CREATE TABLE statement for one type, from its canonical schema. Table and
78
+ * column identifiers are quoted so valid-but-reserved names (`order`, `group`, …) emit
79
+ * legal SQL. */
80
+ export function createTableSql(typeName, def) {
81
+ const { table, columns } = tableSchemaForType(typeName, def);
82
+ const cols = columns.map((c) => c.primaryKey
83
+ ? ` ${quoteSqlIdent(c.name)} ${c.sqlType} PRIMARY KEY AUTOINCREMENT`
84
+ : ` ${quoteSqlIdent(c.name)} ${c.sqlType}${c.notNull ? " NOT NULL" : ""}`);
85
+ return `CREATE TABLE IF NOT EXISTS ${quoteSqlIdent(table)} (\n${cols.join(",\n")}\n);`;
86
+ }
87
+ export function deriveMigrations(manifest) {
88
+ const bySource = groupTypesBySource(manifest);
60
89
  const artifacts = [];
61
90
  for (const [src, list] of bySource) {
62
91
  const header = `-- Derived from manifest \`types\` by @nanobpm/urban (urban gen). Do not edit.\n` +
@@ -4,6 +4,16 @@ export interface ModelSource {
4
4
  path: string;
5
5
  xml: string;
6
6
  }
7
+ /** Deterministic comparator for ordering `ModelSource` lists by path. Returns 0 on equal
8
+ * paths so it satisfies the JS sort contract (a comparator that reports both `a>b` and
9
+ * `b>a` can reorder equal elements unpredictably) — keeping the derivers' cross-model
10
+ * fold order stable even if a manifest pattern yields duplicate paths. Shared by every
11
+ * model-scanning deriver so there is one canonical ordering. */
12
+ export declare function byModelPath(a: {
13
+ path: string;
14
+ }, b: {
15
+ path: string;
16
+ }): number;
7
17
  export interface WorkerIo {
8
18
  taskType: string;
9
19
  elementId?: string;
@@ -19,6 +29,10 @@ export declare const WORKER_BINDINGS_DTS = "worker-io.d.ts";
19
29
  export declare const DOMAIN_DTS = "domain-rows.d.ts";
20
30
  /** Scan one BPMN document for service-task worker I/O. */
21
31
  export declare function scanModelWorkers(xml: string): WorkerIo[];
32
+ /** A `DomainTypes[...]` ref for a declared type id (else undefined). The one canonical
33
+ * ref helper shared by the worker- and message-IO emitters (matches the console's single
34
+ * `typeRefFor` in domain_types.ts), so both stay identical to the host codegen. */
35
+ export declare function typeRefFor(id: string | undefined, declared: Set<string>): string | undefined;
22
36
  export interface WorkerBindingDecl {
23
37
  taskType?: string;
24
38
  inputType?: string;
@@ -4,6 +4,14 @@
4
4
  // `worker-io.d.ts` the console generates (a faithful port of `emitWorkerBindings` in the
5
5
  // server's domain_types.ts), so the toolkit is a drop-in for the IDE's codegen (ADR 0053).
6
6
  import { GENERATED_DIR } from "../artifact.js";
7
+ /** Deterministic comparator for ordering `ModelSource` lists by path. Returns 0 on equal
8
+ * paths so it satisfies the JS sort contract (a comparator that reports both `a>b` and
9
+ * `b>a` can reorder equal elements unpredictably) — keeping the derivers' cross-model
10
+ * fold order stable even if a manifest pattern yields duplicate paths. Shared by every
11
+ * model-scanning deriver so there is one canonical ordering. */
12
+ export function byModelPath(a, b) {
13
+ return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
14
+ }
7
15
  /** Filename the console uses; kept identical so the artifact is a drop-in. */
8
16
  export const WORKER_BINDINGS_DTS = "worker-io.d.ts";
9
17
  /** The domain type declarations `worker-io.d.ts` imports from (console: domain-rows.d.ts). */
@@ -51,7 +59,10 @@ export function scanModelWorkers(xml) {
51
59
  }
52
60
  // --- Faithful port of the console's emitWorkerBindings (domain_types.ts) so the emitted
53
61
  // worker-io.d.ts is byte-compatible with the IDE's own codegen. ---
54
- function typeRefFor(id, declared) {
62
+ /** A `DomainTypes[...]` ref for a declared type id (else undefined). The one canonical
63
+ * ref helper shared by the worker- and message-IO emitters (matches the console's single
64
+ * `typeRefFor` in domain_types.ts), so both stay identical to the host codegen. */
65
+ export function typeRefFor(id, declared) {
55
66
  return id != null && declared.has(id) ? `DomainTypes[${JSON.stringify(id)}]` : undefined;
56
67
  }
57
68
  /** Emit `worker-io.d.ts` from worker bindings + the set of declared domain type ids. */
@@ -120,7 +131,7 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
120
131
  */
121
132
  export function deriveWorkerBindings(models, declaredTypeIds = []) {
122
133
  const workers = [];
123
- for (const model of [...models].sort((a, b) => (a.path < b.path ? -1 : 1))) {
134
+ for (const model of [...models].sort(byModelPath)) {
124
135
  for (const w of scanModelWorkers(model.xml)) {
125
136
  workers.push({ taskType: w.taskType, inputType: w.in, outputType: w.out });
126
137
  }
@@ -4,7 +4,10 @@
4
4
  // pure; all IO is confined here behind a tiny FS port so the same code runs on Node and Deno.
5
5
  import { sortArtifacts } from "./artifact.js";
6
6
  import { deriveMigrations } from "./derivers/migrations.js";
7
+ import { deriveDomain } from "./derivers/domain.js";
7
8
  import { deriveWorkerBindings } from "./derivers/worker-io.js";
9
+ import { deriveMeta } from "./derivers/meta.js";
10
+ import { deriveMessageBindings } from "./derivers/messages.js";
8
11
  function join(root, rel) {
9
12
  // Trim either separator so callers may pass Windows-style paths; GenIO
10
13
  // implementations accept forward slashes on all platforms.
@@ -47,15 +50,18 @@ export async function collectArtifacts(opts) {
47
50
  const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
48
51
  const manifest = JSON.parse(await io.readText(manifestPath));
49
52
  const artifacts = [];
50
- // 1. types → migrations
53
+ // 1. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
51
54
  if (manifest.types && Object.keys(manifest.types).length > 0) {
52
55
  artifacts.push(...deriveMigrations(manifest));
56
+ artifacts.push(...deriveDomain(manifest));
53
57
  }
54
- // 2. models → worker I/O index
58
+ // 2. models → worker I/O index + model-metadata accessor + message payload map
55
59
  const models = await readModels(root, io, manifest);
56
60
  if (models.length > 0) {
57
61
  const declaredTypeIds = Object.keys(manifest.types ?? {});
58
62
  artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
63
+ artifacts.push(...deriveMeta(models));
64
+ artifacts.push(...deriveMessageBindings(models, declaredTypeIds));
59
65
  }
60
66
  return sortArtifacts(artifacts);
61
67
  }
@@ -1,10 +1,22 @@
1
1
  export type { DerivedArtifact, Deriver } from "./artifact.ts";
2
2
  export { GENERATED_DIR, sortArtifacts } from "./artifact.ts";
3
- export { deriveMigrations, migrationsDeriver, sqlType, createTableSql, } from "./derivers/migrations.ts";
4
- export type { ToolkitManifest, ToolkitType, ToolkitField } from "./derivers/migrations.ts";
3
+ export { deriveMigrations, migrationsDeriver, sqlType, createTableSql, tableSchemaForType, groupTypesBySource, } from "./derivers/migrations.ts";
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";
5
7
  export { deriveWorkerBindings, emitWorkerBindings, workerIoDeriver, scanModelWorkers, WORKER_BINDINGS_DTS, DOMAIN_DTS, } from "./derivers/worker-io.ts";
6
8
  export type { ModelSource, WorkerIo, WorkerBindingDecl } from "./derivers/worker-io.ts";
7
- export declare const DERIVERS: readonly [import("./artifact.ts").Deriver<import("./derivers/migrations.ts").ToolkitManifest>, import("./artifact.ts").Deriver<{
9
+ export { byModelPath, typeRefFor } from "./derivers/worker-io.ts";
10
+ export { deriveMeta, emitMeta, foldMeta, metaDeriver, scanModelMeta, META_TS, } from "./derivers/meta.ts";
11
+ export type { MetaDecl } from "./derivers/meta.ts";
12
+ export { deriveMessageBindings, emitMessageBindings, messagesDeriver, scanModelMessages, MESSAGE_BINDINGS_DTS, } from "./derivers/messages.ts";
13
+ export type { MessageIo, MessageBindingDecl } from "./derivers/messages.ts";
14
+ 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
+ models: import("./derivers/worker-io.ts").ModelSource[];
16
+ declaredTypeIds: Iterable<string>;
17
+ }>, import("./artifact.ts").Deriver<{
18
+ models: import("./derivers/worker-io.ts").ModelSource[];
19
+ }>, import("./artifact.ts").Deriver<{
8
20
  models: import("./derivers/worker-io.ts").ModelSource[];
9
21
  declaredTypeIds: Iterable<string>;
10
22
  }>];
@@ -4,12 +4,19 @@
4
4
  // (ADR 0054) — the toolkit derives migrations and worker I/O from the model.
5
5
  export { GENERATED_DIR, sortArtifacts } from "./artifact.js";
6
6
  // Derivers
7
- export { deriveMigrations, migrationsDeriver, sqlType, createTableSql, } from "./derivers/migrations.js";
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";
8
9
  export { deriveWorkerBindings, emitWorkerBindings, workerIoDeriver, scanModelWorkers, WORKER_BINDINGS_DTS, DOMAIN_DTS, } from "./derivers/worker-io.js";
10
+ export { byModelPath, typeRefFor } from "./derivers/worker-io.js";
11
+ 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";
9
13
  // The registry of all derivers (for discovery / IDE migration).
10
14
  import { migrationsDeriver } from "./derivers/migrations.js";
15
+ import { domainDeriver } from "./derivers/domain.js";
11
16
  import { workerIoDeriver } from "./derivers/worker-io.js";
12
- export const DERIVERS = [migrationsDeriver, workerIoDeriver];
17
+ import { metaDeriver } from "./derivers/meta.js";
18
+ import { messagesDeriver } from "./derivers/messages.js";
19
+ export const DERIVERS = [migrationsDeriver, domainDeriver, workerIoDeriver, metaDeriver, messagesDeriver];
13
20
  // Gen orchestrator + IO
14
21
  export { collectArtifacts, runGen, joinPath, readModels } from "./gen.js";
15
22
  export { createNodeGenIO } from "./fsio.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
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",
@@ -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
+ });