@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.
- package/dist/runtime/core/modules/gateway.d.ts +9 -2
- package/dist/runtime/core/modules/gateway.js +14 -3
- package/dist/toolkit/derivers/domain.js +17 -4
- package/package.json +2 -2
- package/src/runtime/core/modules/gateway.test.ts +93 -0
- package/src/runtime/core/modules/gateway.ts +24 -6
- package/src/toolkit/derivers/bindings-parity.test.ts +4 -2
- package/src/toolkit/derivers/domain.test.ts +33 -1
- package/src/toolkit/derivers/domain.ts +18 -6
- package/src/toolkit/derivers/shapes.test.ts +2 -0
|
@@ -27,10 +27,17 @@ export interface ForeignKeyMeta {
|
|
|
27
27
|
refColumn: string;
|
|
28
28
|
onDelete: string;
|
|
29
29
|
}
|
|
30
|
-
/** One
|
|
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
|
|
154
|
-
// never surface in the domain model, DB Manager, or forms.
|
|
155
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
253
|
-
// never surface in the domain model, DB Manager, or forms.
|
|
254
|
-
|
|
255
|
-
|
|
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(
|
|
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: [],
|