@nanobpm/urban 0.74.0 → 0.75.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.
@@ -27,10 +27,17 @@ export interface ForeignKeyMeta {
27
27
  refColumn: string;
28
28
  onDelete: string;
29
29
  }
30
- /** One table: its columns, index names, and foreign keys. Powers domain-type projection and
31
- * the page runtime's list/detail binding. */
30
+ /** One introspected datasource object: its columns, index names, and foreign keys. Powers
31
+ * domain-type projection and the page runtime's list/detail binding. `kind` distinguishes a
32
+ * base `table` (read/write) from a SQL `view` (read-only): a view is readable through the same
33
+ * `SELECT * FROM <name>` path as a table, but Urban treats every view as read-only (a plain
34
+ * SQLite view rejects writes unless someone attaches INSTEAD OF triggers, which Urban never
35
+ * does), so write surfaces (DB Manager, forms, the domain writer bindings) must not offer
36
+ * insert/update/delete against a view. */
32
37
  export interface TableMeta {
33
38
  name: string;
39
+ /** `table` for a base table (read/write); `view` for a read-only SQL VIEW. */
40
+ kind: "table" | "view";
34
41
  columns: ColumnMeta[];
35
42
  indexes: string[];
36
43
  foreignKeys: ForeignKeyMeta[];
@@ -149,19 +149,30 @@ class SqliteGateway {
149
149
  }
150
150
  }
151
151
  async schema() {
152
+ // Introspect base tables AND views (`type IN ('table','view')`): the datasource read path
153
+ // (`SELECT * FROM <name>`) works verbatim on a view, so a page datasource / domain read must
154
+ // be able to see one. A view is tagged `kind:'view'` below so write surfaces know not to
155
+ // offer insert/update/delete against it (Urban treats every view as read-only; a plain
156
+ // SQLite view rejects writes absent INSTEAD OF triggers, which Urban never attaches).
157
+ //
152
158
  // Exclude SQLite internals (`sqlite_%`) and Nano's own bookkeeping tables (`_urban_%` /
153
- // `_nano_%`, e.g. the migrations ledger): neither is a user/domain table, so they must
154
- // never surface in the domain model, DB Manager, or forms.
155
- const tables = this.#db.all("SELECT name FROM sqlite_master WHERE type = 'table' " +
159
+ // `_nano_%`, e.g. the migrations ledger): neither is a user/domain object, so they must
160
+ // never surface in the domain model, DB Manager, or forms. The same exclusions apply to
161
+ // views.
162
+ const tables = this.#db.all("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') " +
156
163
  "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_urban\\_%' ESCAPE '\\' " +
157
164
  "AND name NOT LIKE '\\_nano\\_%' ESCAPE '\\' ORDER BY name");
158
165
  const out = [];
159
166
  for (const t of tables) {
167
+ // `PRAGMA table_info(<view>)` returns the view's columns (pk/notnull report 0, which is
168
+ // correct — a view has no primary key); `PRAGMA index_list` / `foreign_key_list` return
169
+ // empty for a view (harmless).
160
170
  const cols = this.#db.all(`PRAGMA table_info(${quoteIdent(t.name)})`);
161
171
  const idx = this.#db.all(`PRAGMA index_list(${quoteIdent(t.name)})`);
162
172
  const fks = this.#db.all(`PRAGMA foreign_key_list(${quoteIdent(t.name)})`);
163
173
  out.push({
164
174
  name: t.name,
175
+ kind: t.type === "view" ? "view" : "table",
165
176
  columns: cols.map((c) => ({
166
177
  name: c.name,
167
178
  type: c.type,
@@ -58,13 +58,17 @@ function fieldType(col) {
58
58
  // A NOT NULL column (or the INTEGER PRIMARY KEY rowid alias) is never null.
59
59
  return col.notNull || col.primaryKey ? base : `${base} | null`;
60
60
  }
61
- /** One `export interface` block for a table under an explicit interface name. */
61
+ /** One `export interface` block for a table under an explicit interface name. Urban treats a
62
+ * VIEW as read-only (a plain SQLite view rejects writes absent INSTEAD OF triggers, which Urban
63
+ * never attaches), so its fields are emitted `readonly` — consumers get typed reads and the
64
+ * `readonly` modifier signals/guards against mutating the row through the generated type. */
62
65
  function tableInterfaceNamed(name, t) {
66
+ const ro = t.kind === "view" ? "readonly " : "";
63
67
  const fields = t.columns
64
68
  .map((c) => {
65
69
  const key = isIdent(c.name) ? c.name : JSON.stringify(c.name);
66
70
  const pk = c.primaryKey ? " (primary key)" : "";
67
- return ` /** ${c.type || "?"}${pk} */\n ${key}: ${fieldType(c)};`;
71
+ return ` /** ${c.type || "?"}${pk} */\n ${ro}${key}: ${fieldType(c)};`;
68
72
  })
69
73
  .join("\n");
70
74
  return `export interface ${name} {\n${fields}\n}`;
@@ -206,6 +210,14 @@ const RESERVED_DOMAIN_MEMBERS = new Set(["raw", "close"]);
206
210
  function primaryKeyOf(t) {
207
211
  return t.columns.find((c) => c.primaryKey)?.name ?? "id";
208
212
  }
213
+ /** Urban treats a view as read-only (a plain SQLite view rejects writes absent INSTEAD OF
214
+ * triggers, which Urban never attaches), so it must not get a `Table<T>` writer binding
215
+ * in the generated `domain.ts`; its typed row still lives in `domain-rows.d.ts` (read-only) and
216
+ * stays reachable for reads via `db.raw`. A reserved-named object is dropped too, so it can't
217
+ * clobber the `raw`/`close` escape hatch. */
218
+ function isWriterBound(t) {
219
+ return t.kind !== "view" && !RESERVED_DOMAIN_MEMBERS.has(t.name);
220
+ }
209
221
  /**
210
222
  * Emit `domain.ts`: a typed data-object accessor over the App's default datasource, so a worker
211
223
  * writes `db.orders.insert({...})` / `db.orders.get(id)` instead of hand-writing SQL (ADR 0029 §6).
@@ -228,7 +240,7 @@ export function emitDomainBindings(sources, defaultSource) {
228
240
  ? defaultSource
229
241
  : sources[0]?.source;
230
242
  const primary = sources.find((s) => s.source === def);
231
- const tables = (primary?.tables ?? []).filter((t) => !RESERVED_DOMAIN_MEMBERS.has(t.name));
243
+ const tables = (primary?.tables ?? []).filter(isWriterBound);
232
244
  // Multi-source apps: emit a *keyed* accessor so `openDomain("analytics")` is typed against that
233
245
  // source's tables, not the default's. The row-type spine (`DomainSources`, keyed by alias then
234
246
  // wire table name) is already emitted in `domain-rows.d.ts`; here we bind a runtime
@@ -283,7 +295,7 @@ function emitKeyedDomainBindings(header, sources, def) {
283
295
  const tableDescriptors = sources
284
296
  .map((s) => {
285
297
  const rows = s.tables
286
- .filter((t) => !RESERVED_DOMAIN_MEMBERS.has(t.name))
298
+ .filter(isWriterBound)
287
299
  .map((t) => `{ name: ${JSON.stringify(t.name)}, pk: ${JSON.stringify(primaryKeyOf(t))} }`)
288
300
  .join(", ");
289
301
  return ` ${JSON.stringify(s.source)}: [${rows}],`;
@@ -328,6 +340,7 @@ function tableMetaFromType(typeName, def) {
328
340
  const { table, columns } = tableSchemaForType(typeName, def);
329
341
  return {
330
342
  name: table,
343
+ kind: "table",
331
344
  columns: columns.map((c) => ({
332
345
  name: c.name,
333
346
  type: c.sqlType,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.74.0",
3
+ "version": "0.75.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",
@@ -101,7 +101,7 @@
101
101
  "dependencies": {
102
102
  "@nanobpm/nano-app-schema": "^0.9.0",
103
103
  "@nanobpm/nano-sdk": "^1.2.5",
104
- "@nanobpm/workflow": "^0.13.1",
104
+ "@nanobpm/workflow": "^0.14.0",
105
105
  "bpmn-auto-layout": "^2.0.0-alpha.2",
106
106
  "create-urban-app": "^0.15.0",
107
107
  "yaml": "^2.9.0"
@@ -125,6 +125,99 @@ test("schema introspects columns/pk and excludes internal tables", async () => {
125
125
  });
126
126
  });
127
127
 
128
+ test("schema introspects a VIEW tagged read-only; base tables stay writable", async () => {
129
+ await withGateway(async (src) => {
130
+ await src.exec(
131
+ "INSERT INTO orders (id, status, total) VALUES (1, 'new', 10), (2, 'paid', 20)",
132
+ );
133
+ // A derived rollup as a SQL VIEW — the exact "no drift surface" the issue enables.
134
+ await src.exec(
135
+ "CREATE VIEW paid_orders AS SELECT id, total FROM orders WHERE status = 'paid'",
136
+ );
137
+
138
+ const meta = await src.schema();
139
+ const byName = new Map(meta.map((t) => [t.name, t]));
140
+
141
+ // Both the base table and the view are introspected…
142
+ assert.deepEqual(
143
+ meta.map((t) => t.name),
144
+ ["orders", "paid_orders"],
145
+ );
146
+ // …and tagged so a write surface can tell them apart.
147
+ assert.equal(byName.get("orders")?.kind, "table");
148
+ assert.equal(byName.get("paid_orders")?.kind, "view");
149
+
150
+ // A view has columns (pk/notnull report 0 — a view has no primary key).
151
+ const view = byName.get("paid_orders");
152
+ assert.deepEqual(
153
+ view?.columns.map((c) => c.name),
154
+ ["id", "total"],
155
+ );
156
+ assert.equal(view?.columns.every((c) => c.primaryKey === false), true);
157
+ });
158
+ });
159
+
160
+ test("datasource reads rows from a VIEW with filter and order applied", async () => {
161
+ interface Paid {
162
+ id: number;
163
+ total: number;
164
+ }
165
+ await withGateway(async (src) => {
166
+ await src.exec(
167
+ "INSERT INTO orders (id, status, total) VALUES " +
168
+ "(1, 'new', 10), (2, 'paid', 20), (3, 'paid', 5)",
169
+ );
170
+ await src.exec(
171
+ "CREATE VIEW paid_orders AS SELECT id, total FROM orders WHERE status = 'paid'",
172
+ );
173
+
174
+ const view = src.table<Paid>("paid_orders");
175
+ // The Table read path (`SELECT * FROM <name> …`) works verbatim on a view.
176
+ const all = await view.all();
177
+ assert.deepEqual(
178
+ all.map((r) => r.id).sort(),
179
+ [2, 3],
180
+ );
181
+ // filter (WHERE) applies as normal.
182
+ const byTotal = await view.find({ total: 20 });
183
+ assert.deepEqual(byTotal.map((r) => r.id), [2]);
184
+ // ordered raw read (ORDER BY) applies as normal.
185
+ const ordered = await src.query<Paid>(
186
+ "SELECT * FROM paid_orders ORDER BY total ASC",
187
+ );
188
+ assert.deepEqual(ordered.map((r) => r.id), [3, 2]);
189
+ });
190
+ });
191
+
192
+ test("a base table is writable but a VIEW is not", async () => {
193
+ await withGateway(async (src) => {
194
+ await src.exec("INSERT INTO orders (id, status, total) VALUES (1, 'paid', 20)");
195
+ await src.exec(
196
+ "CREATE VIEW paid_orders AS SELECT id, total FROM orders WHERE status = 'paid'",
197
+ );
198
+
199
+ // The base table writes fine.
200
+ const orders = src.table<Order>("orders");
201
+ const id = await orders.insert({ status: "new", total: 3 });
202
+ assert.equal(typeof id === "number" || typeof id === "bigint", true);
203
+
204
+ // Urban treats every view as read-only (no INSTEAD OF trigger), so a plain view rejects
205
+ // the write — the write surface must never offer it.
206
+ const view = src.table<{ id: number; total: number }>("paid_orders");
207
+ await assert.rejects(() => view.insert({ id: 99, total: 1 }));
208
+ });
209
+ });
210
+
211
+ test("schema excludes internal tables AND internal views", async () => {
212
+ await withGateway(async (src) => {
213
+ await src.exec("CREATE VIEW _urban_hidden AS SELECT 1 AS x");
214
+ await src.exec("CREATE VIEW _nano_hidden AS SELECT 1 AS x");
215
+ await src.exec("CREATE VIEW visible AS SELECT id FROM orders");
216
+ const names = (await src.schema()).map((t) => t.name);
217
+ assert.deepEqual(names, ["orders", "visible"]);
218
+ });
219
+ });
220
+
128
221
  test("query rejects (not throws synchronously) on invalid SQL", async () => {
129
222
  await withGateway(async (src) => {
130
223
  await assert.rejects(src.query("SELECT * FROM does_not_exist"));
@@ -47,10 +47,17 @@ export interface ForeignKeyMeta {
47
47
  onDelete: string;
48
48
  }
49
49
 
50
- /** One table: its columns, index names, and foreign keys. Powers domain-type projection and
51
- * the page runtime's list/detail binding. */
50
+ /** One introspected datasource object: its columns, index names, and foreign keys. Powers
51
+ * domain-type projection and the page runtime's list/detail binding. `kind` distinguishes a
52
+ * base `table` (read/write) from a SQL `view` (read-only): a view is readable through the same
53
+ * `SELECT * FROM <name>` path as a table, but Urban treats every view as read-only (a plain
54
+ * SQLite view rejects writes unless someone attaches INSTEAD OF triggers, which Urban never
55
+ * does), so write surfaces (DB Manager, forms, the domain writer bindings) must not offer
56
+ * insert/update/delete against a view. */
52
57
  export interface TableMeta {
53
58
  name: string;
59
+ /** `table` for a base table (read/write); `view` for a read-only SQL VIEW. */
60
+ kind: "table" | "view";
54
61
  columns: ColumnMeta[];
55
62
  indexes: string[];
56
63
  foreignKeys: ForeignKeyMeta[];
@@ -248,16 +255,26 @@ class SqliteGateway implements DataSource {
248
255
  }
249
256
 
250
257
  async schema(): Promise<TableMeta[]> {
258
+ // Introspect base tables AND views (`type IN ('table','view')`): the datasource read path
259
+ // (`SELECT * FROM <name>`) works verbatim on a view, so a page datasource / domain read must
260
+ // be able to see one. A view is tagged `kind:'view'` below so write surfaces know not to
261
+ // offer insert/update/delete against it (Urban treats every view as read-only; a plain
262
+ // SQLite view rejects writes absent INSTEAD OF triggers, which Urban never attaches).
263
+ //
251
264
  // Exclude SQLite internals (`sqlite_%`) and Nano's own bookkeeping tables (`_urban_%` /
252
- // `_nano_%`, e.g. the migrations ledger): neither is a user/domain table, so they must
253
- // never surface in the domain model, DB Manager, or forms.
254
- const tables = this.#db.all<{ name: string }>(
255
- "SELECT name FROM sqlite_master WHERE type = 'table' " +
265
+ // `_nano_%`, e.g. the migrations ledger): neither is a user/domain object, so they must
266
+ // never surface in the domain model, DB Manager, or forms. The same exclusions apply to
267
+ // views.
268
+ const tables = this.#db.all<{ name: string; type: string }>(
269
+ "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') " +
256
270
  "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_urban\\_%' ESCAPE '\\' " +
257
271
  "AND name NOT LIKE '\\_nano\\_%' ESCAPE '\\' ORDER BY name",
258
272
  );
259
273
  const out: TableMeta[] = [];
260
274
  for (const t of tables) {
275
+ // `PRAGMA table_info(<view>)` returns the view's columns (pk/notnull report 0, which is
276
+ // correct — a view has no primary key); `PRAGMA index_list` / `foreign_key_list` return
277
+ // empty for a view (harmless).
261
278
  const cols = this.#db.all<{ name: string; type: string; notnull: number; pk: number }>(
262
279
  `PRAGMA table_info(${quoteIdent(t.name)})`,
263
280
  );
@@ -270,6 +287,7 @@ class SqliteGateway implements DataSource {
270
287
  }>(`PRAGMA foreign_key_list(${quoteIdent(t.name)})`);
271
288
  out.push({
272
289
  name: t.name,
290
+ kind: t.type === "view" ? "view" : "table",
273
291
  columns: cols.map((c) => ({
274
292
  name: c.name,
275
293
  type: c.type,
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { emitDomainBindings } from "./domain.ts";
3
+ import { emitDomainBindings, type SourceSchema } from "./domain.ts";
4
4
  import { emitWorkerBindings, emitWorkerBindingsRuntime } from "./worker-io.ts";
5
5
  import { emitMessageBindings, emitMessageBindingsRuntime } from "./messages.ts";
6
6
 
@@ -9,12 +9,13 @@ import { emitMessageBindings, emitMessageBindingsRuntime } from "./messages.ts";
9
9
  // output to the console byte-for-byte so the two can never drift while both exist.
10
10
  const GOLDENS: Record<string, string> = JSON.parse("{\"bindings\":\"// AUTO-GENERATED by nanobpmn from the datasource schema (ADR 0029 §4.1/§6).\\n// The typed data-object layer: `openDomain()` → one `Table<T>` per table, so\\n// workers manipulate typed rows instead of hand-writing SQL. Do not edit —\\n// regenerated from the live tables. Erased to plain JS at `deno compile`.\\n// eslint-disable\\n\\nimport { openDataSource, type DataSource, type Table } from \\\"./data-sdk.ts\\\";\\nimport type { Customers, Orders } from \\\"./domain-rows.d.ts\\\";\\n\\nexport interface Domain {\\n /** The underlying datasource handle — the raw-SQL escape hatch. */\\n readonly raw: DataSource;\\n readonly orders: Table<Orders>;\\n readonly customers: Table<Customers>;\\n /** Close the underlying connection. */\\n close(): void;\\n}\\n\\n/**\\n * Open the App's datasource as a typed domain: each table is\\n * a `Table<T>` gateway (`db.orders.insert(...)`, `db.orders.get(id)`), with the\\n * raw `DataSource` at `db.raw` for anything the gateway doesn't cover.\\n */\\nexport async function openDomain(source?: string): Promise<Domain> {\\n const raw = await openDataSource(source);\\n return {\\n raw,\\n orders: raw.table<Orders>(\\\"orders\\\", \\\"id\\\"),\\n customers: raw.table<Customers>(\\\"customers\\\", \\\"id\\\"),\\n close: () => raw.close(),\\n };\\n}\\n\\nexport type { Table };\\n\",\"workerRuntime\":\"// AUTO-GENERATED by nanobpmn (ADR 0033 §3): the typed `defineWorker`.\\n// Re-exports the worker SDK and overrides `defineWorker` with a taskType-keyed\\n// typed signature (job.variables, job.customHeaders + result typed from the\\n// worker's declared input/output/header contract). Erased to a pass-through at\\n// runtime. Do not edit.\\n// eslint-disable\\n\\nimport { defineWorker as defineWorkerRaw } from \\\"./worker-sdk.ts\\\";\\nimport type { WorkerOptions } from \\\"./worker-sdk.ts\\\";\\nimport type { WorkerInputs, WorkerOutputs, WorkerHeaders, WorkerTaskType, WorkerVars, WorkerHdrs } from \\\"./worker-io.d.ts\\\";\\n\\nexport * from \\\"./worker-sdk.ts\\\";\\n\\ntype InFor<K extends WorkerTaskType> = K extends keyof WorkerInputs ? WorkerInputs[K] : WorkerVars;\\ntype OutFor<K extends WorkerTaskType> = K extends keyof WorkerOutputs ? WorkerOutputs[K] : WorkerVars;\\ntype HdrFor<K extends WorkerTaskType> = K extends keyof WorkerHeaders ? WorkerHeaders[K] : WorkerHdrs;\\n\\n/**\\n * Typed `defineWorker`: `type` is constrained to the model's declared job types\\n * (`WorkerTaskType`, ADR 0033 §3) so it autocompletes and rejects unknown jobs,\\n * and the handler's `job.variables`, `job.customHeaders` + result are typed from\\n * the worker's declared `inputType`/`outputType`/header keys. A declared job type\\n * with no declared type falls back to WorkerVars / WorkerHdrs.\\n */\\nexport function defineWorker<K extends WorkerTaskType>(\\n opts: { type: K } & WorkerOptions<InFor<K> & object, OutFor<K> & object, HdrFor<K> & object>,\\n): void {\\n defineWorkerRaw(opts as unknown as WorkerOptions);\\n}\\n\",\"messageRuntime\":\"// AUTO-GENERATED by nanobpmn (ADR 0040 slice 2): the typed `publishMessage`.\\n// Re-exports the worker SDK's `publishMessage` and overrides it with a\\n// messageName-keyed typed signature (variables typed from the message's declared\\n// envelope). Erased to a pass-through at runtime. Do not edit.\\n// eslint-disable\\n\\nimport { publishMessage as publishMessageRaw } from \\\"./worker-sdk.ts\\\";\\nimport type { PublishMessageOptions, PublishMessageResult } from \\\"./worker-sdk.ts\\\";\\nimport type { MessageName, MessagePayloads, MessageVars } from \\\"./message-io.d.ts\\\";\\n\\nexport type { PublishMessageOptions, PublishMessageResult } from \\\"./worker-sdk.ts\\\";\\n\\ntype PayloadFor<K extends MessageName> = K extends keyof MessagePayloads ? MessagePayloads[K] : MessageVars;\\n\\n/**\\n * Typed `publishMessage`: `name` is constrained to the model's declared message\\n * names (`MessageName`, ADR 0040 slice 2) so it autocompletes and rejects unknown\\n * messages, and `variables` are typed from the message's declared envelope. A\\n * declared message with no domain type falls back to MessageVars.\\n */\\nexport function publishMessage<K extends MessageName>(\\n name: K,\\n opts?: PublishMessageOptions<PayloadFor<K> & object>,\\n): Promise<PublishMessageResult> {\\n return publishMessageRaw(name, opts as unknown as PublishMessageOptions);\\n}\\n\",\"workerBindingsWithHeaders\":\"// AUTO-GENERATED by nanobpmn from the App manifest (ADR 0033 §3).\\n// The bridge from the process model to the worker type system: each declared\\n// worker's `taskType` maps to the TS type of its input payload (`job.variables`)\\n// and result, so the typed `defineWorker` types a handler by its job type. Do\\n// not edit — regenerated from the manifest. Erased to plain JS at compile.\\n// eslint-disable\\n\\nimport type { DomainTypes } from \\\"./domain-rows.d.ts\\\";\\n\\n/** Untyped fallback for a job whose worker declares no input/output type. */\\nexport type WorkerVars = Record<string, unknown>;\\n\\n/** Untyped fallback for a job whose worker declares no custom headers. */\\nexport type WorkerHdrs = Record<string, unknown>;\\n\\n/** Every declared worker `taskType` (ADR 0033 §3): the model-derived set the\\n * typed `defineWorker` accepts, so `type` autocompletes and rejects unknown jobs. */\\nexport type WorkerTaskType = \\\"review\\\";\\n\\n/** Input payload (`job.variables`) per declared worker, keyed by `taskType`. */\\nexport interface WorkerInputs {\\n \\\"review\\\": DomainTypes[\\\"Order\\\"];\\n}\\n\\n/** Output payload (worker result) per declared worker, keyed by `taskType`. */\\nexport interface WorkerOutputs {\\n \\\"review\\\": DomainTypes[\\\"Order\\\"];\\n}\\n\\n/** Custom headers (`job.customHeaders`) per declared worker, keyed by `taskType`.\\n * Header values are strings on the wire, so each declared key maps to `string`;\\n * the extra index signature keeps undeclared headers accessible (non-breaking). */\\nexport interface WorkerHeaders {\\n \\\"review\\\": { \\\"priority\\\": string; \\\"x-flag\\\": string; [key: string]: string };\\n}\\n\",\"messageBindings\":\"// AUTO-GENERATED by nanobpmn from the process model (ADR 0040 slice 2).\\n// The bridge from a `bpmn:message`'s data envelope to the message type system:\\n// each named message maps to the TS type of its payload, so the typed\\n// `publishMessage` types a call by its message name. Do not edit — regenerated\\n// from the model. Erased to plain JS at compile.\\n// eslint-disable\\n\\nimport type { DomainTypes } from \\\"./domain-rows.d.ts\\\";\\n\\n/** Untyped fallback payload for a message that declares no envelope type. */\\nexport type MessageVars = Record<string, unknown>;\\n\\n/** Every declared message `name` (ADR 0040 slice 2): the model-derived set the\\n * typed `publishMessage` accepts, so `name` autocompletes and rejects unknown messages. */\\nexport type MessageName = \\\"approved\\\";\\n\\n/** Payload (`variables`) per declared message, keyed by `name`. */\\nexport interface MessagePayloads {\\n \\\"approved\\\": DomainTypes[\\\"Order\\\"];\\n}\\n\"}\n");
11
11
 
12
- const sources = [
12
+ const sources: SourceSchema[] = [
13
13
  {
14
14
  source: "app",
15
15
  tables: [
16
16
  {
17
17
  name: "orders",
18
+ kind: "table",
18
19
  columns: [
19
20
  { name: "id", type: "INTEGER", notNull: true, primaryKey: true },
20
21
  { name: "total", type: "INTEGER", notNull: true, primaryKey: false },
@@ -24,6 +25,7 @@ const sources = [
24
25
  },
25
26
  {
26
27
  name: "customers",
28
+ kind: "table",
27
29
  columns: [
28
30
  { name: "id", type: "INTEGER", notNull: true, primaryKey: true },
29
31
  { name: "name", type: "TEXT", notNull: true, primaryKey: false },
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import {
4
4
  emitDomainModel,
5
5
  emitDomain,
6
+ emitDomainBindings,
6
7
  deriveDomain,
7
8
  sourcesFromManifest,
8
9
  registryFromManifest,
@@ -20,7 +21,7 @@ const GOLDENS: Record<string, string> = JSON.parse("{\n \"empty\": \"// AUTO-GE
20
21
 
21
22
  const col = (name: string, type: string, notNull: boolean, primaryKey: boolean) =>
22
23
  ({ name, type, notNull, primaryKey });
23
- const table = (name: string, cols: TableMeta["columns"]): TableMeta => ({ name, columns: cols, indexes: [], foreignKeys: [] });
24
+ const table = (name: string, cols: TableMeta["columns"], kind: TableMeta["kind"] = "table"): TableMeta => ({ name, kind, columns: cols, indexes: [], foreignKeys: [] });
24
25
 
25
26
  test("emitDomainModel: empty app → bare DomainTables (byte-parity)", () => {
26
27
  assert.equal(emitDomainModel([], undefined, {}), GOLDENS.empty);
@@ -58,6 +59,37 @@ test("emitDomainModel: multi-source → DomainSources + DomainTables alias to de
58
59
  assert.equal(emitDomainModel(sources, "analytics", {}), GOLDENS.multi);
59
60
  });
60
61
 
62
+ test("emitDomainModel: a VIEW yields a read-only row interface (readonly fields)", () => {
63
+ // The datasource read path works verbatim on a view, so consumers still get a typed row —
64
+ // but Urban treats every view as read-only (it never attaches INSTEAD OF triggers), so its
65
+ // fields are emitted `readonly`.
66
+ const sources: SourceSchema[] = [{ source: "app", tables: [
67
+ table("orders", [col("id","INTEGER",false,true), col("total","INTEGER",true,false)]),
68
+ table("paid_orders", [col("id","INTEGER",false,false), col("total","INTEGER",false,false)], "view"),
69
+ ]}];
70
+ const out = emitDomainModel(sources, "app", {});
71
+ // A base table stays writable (non-readonly fields).
72
+ assert.match(out, /export interface Orders \{\n {2}\/\*\* INTEGER \(primary key\) \*\/\n {2}id: number;/);
73
+ // The view's row interface is entirely read-only.
74
+ assert.match(out, /export interface PaidOrders \{\n {2}\/\*\* INTEGER \*\/\n {2}readonly id: number \| null;\n {2}\/\*\* INTEGER \*\/\n {2}readonly total: number \| null;\n\}/);
75
+ // Both appear in the DomainTables lookup — a view is a readable object.
76
+ assert.match(out, /"paid_orders": PaidOrders;/);
77
+ });
78
+
79
+ test("emitDomainBindings: a VIEW gets NO Table<T> writer binding; base tables do", () => {
80
+ const sources: SourceSchema[] = [{ source: "app", tables: [
81
+ table("orders", [col("id","INTEGER",false,true), col("total","INTEGER",true,false)]),
82
+ table("paid_orders", [col("id","INTEGER",false,false), col("total","INTEGER",false,false)], "view"),
83
+ ]}];
84
+ const bindings = emitDomainBindings(sources, "app");
85
+ // The base table gets a typed Table<T> gateway (insert/update/delete available).
86
+ assert.match(bindings, /readonly orders: Table<Orders>;/);
87
+ assert.match(bindings, /orders: raw\.table<Orders>\("orders", "id"\),/);
88
+ // The view is NOT offered a writer binding — it never appears as a Table<T> field or bind.
89
+ assert.equal(bindings.includes("paid_orders"), false);
90
+ assert.equal(bindings.includes("PaidOrders"), false);
91
+ });
92
+
61
93
  // --- manifest bridge: code-first derivation reproduces the console output --------------------
62
94
 
63
95
  test("emitDomain: manifest types drive BOTH the table spine and the registry (byte-parity)", () => {
@@ -76,13 +76,17 @@ function fieldType(col: ColumnMeta): string {
76
76
  return col.notNull || col.primaryKey ? base : `${base} | null`;
77
77
  }
78
78
 
79
- /** One `export interface` block for a table under an explicit interface name. */
79
+ /** One `export interface` block for a table under an explicit interface name. Urban treats a
80
+ * VIEW as read-only (a plain SQLite view rejects writes absent INSTEAD OF triggers, which Urban
81
+ * never attaches), so its fields are emitted `readonly` — consumers get typed reads and the
82
+ * `readonly` modifier signals/guards against mutating the row through the generated type. */
80
83
  function tableInterfaceNamed(name: string, t: TableMeta): string {
84
+ const ro = t.kind === "view" ? "readonly " : "";
81
85
  const fields = t.columns
82
86
  .map((c) => {
83
87
  const key = isIdent(c.name) ? c.name : JSON.stringify(c.name);
84
88
  const pk = c.primaryKey ? " (primary key)" : "";
85
- return ` /** ${c.type || "?"}${pk} */\n ${key}: ${fieldType(c)};`;
89
+ return ` /** ${c.type || "?"}${pk} */\n ${ro}${key}: ${fieldType(c)};`;
86
90
  })
87
91
  .join("\n");
88
92
  return `export interface ${name} {\n${fields}\n}`;
@@ -276,6 +280,15 @@ function primaryKeyOf(t: TableMeta): string {
276
280
  return t.columns.find((c) => c.primaryKey)?.name ?? "id";
277
281
  }
278
282
 
283
+ /** Urban treats a view as read-only (a plain SQLite view rejects writes absent INSTEAD OF
284
+ * triggers, which Urban never attaches), so it must not get a `Table<T>` writer binding
285
+ * in the generated `domain.ts`; its typed row still lives in `domain-rows.d.ts` (read-only) and
286
+ * stays reachable for reads via `db.raw`. A reserved-named object is dropped too, so it can't
287
+ * clobber the `raw`/`close` escape hatch. */
288
+ function isWriterBound(t: TableMeta): boolean {
289
+ return t.kind !== "view" && !RESERVED_DOMAIN_MEMBERS.has(t.name);
290
+ }
291
+
279
292
  /**
280
293
  * Emit `domain.ts`: a typed data-object accessor over the App's default datasource, so a worker
281
294
  * writes `db.orders.insert({...})` / `db.orders.get(id)` instead of hand-writing SQL (ADR 0029 §6).
@@ -303,9 +316,7 @@ export function emitDomainBindings(
303
316
  ? defaultSource
304
317
  : sources[0]?.source;
305
318
  const primary = sources.find((s) => s.source === def);
306
- const tables = (primary?.tables ?? []).filter(
307
- (t) => !RESERVED_DOMAIN_MEMBERS.has(t.name),
308
- );
319
+ const tables = (primary?.tables ?? []).filter(isWriterBound);
309
320
 
310
321
  // Multi-source apps: emit a *keyed* accessor so `openDomain("analytics")` is typed against that
311
322
  // source's tables, not the default's. The row-type spine (`DomainSources`, keyed by alias then
@@ -375,7 +386,7 @@ function emitKeyedDomainBindings(
375
386
  const tableDescriptors = sources
376
387
  .map((s) => {
377
388
  const rows = s.tables
378
- .filter((t) => !RESERVED_DOMAIN_MEMBERS.has(t.name))
389
+ .filter(isWriterBound)
379
390
  .map((t) =>
380
391
  `{ name: ${JSON.stringify(t.name)}, pk: ${JSON.stringify(primaryKeyOf(t))} }`
381
392
  )
@@ -431,6 +442,7 @@ function tableMetaFromType(typeName: string, def: ToolkitType): TableMeta {
431
442
  const { table, columns } = tableSchemaForType(typeName, def);
432
443
  return {
433
444
  name: table,
445
+ kind: "table",
434
446
  columns: columns.map((c) => ({
435
447
  name: c.name,
436
448
  type: c.sqlType,
@@ -20,6 +20,7 @@ test("emitDomainModelJson fuses tables, manifest types, shapes, and metadata", (
20
20
  tables: [
21
21
  {
22
22
  name: "orders",
23
+ kind: "table",
23
24
  columns: [{ name: "id", type: "INTEGER", notNull: true, primaryKey: true }],
24
25
  indexes: [],
25
26
  foreignKeys: [],
@@ -121,6 +122,7 @@ test("resolveShapes treats a qualified-alias carry of the colliding leaf as comp
121
122
  tables: [
122
123
  {
123
124
  name: "orders",
125
+ kind: "table",
124
126
  columns: [{ name: "id", type: "INTEGER", notNull: true, primaryKey: true }],
125
127
  indexes: [],
126
128
  foreignKeys: [],