@nanobpm/urban 0.5.1 → 0.7.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.
@@ -3,6 +3,7 @@
3
3
  // linked `.form` and posts completion back. The `chat` surface is stubbed as a mount point
4
4
  // (LLM wiring is a separate concern). Surfaces contribute routes to the shared server.
5
5
  import { html, json, normalizeRoutePath } from "../router.js";
6
+ import { mountPages } from "./pages.js";
6
7
  /** Escape HTML-significant characters before embedding a value in markup. */
7
8
  function escapeHtml(s) {
8
9
  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -83,6 +84,12 @@ export function mountSurfaces(ctx, app) {
83
84
  <p>Chat surface mount point (agent: <code>${escapeHtml(chat.agent ?? "?")}</code>). LLM wiring pending.</p>`),
84
85
  });
85
86
  }
87
+ // The pages surface (the schema-driven page runtime) mounts its own routes.
88
+ const pages = mountPages(ctx, app);
89
+ if (pages.routes.length > 0) {
90
+ routes.push(...pages.routes);
91
+ enabled.push("pages@/");
92
+ }
86
93
  ctx.host.log("info", "surfaces mounted", { enabled });
87
94
  return { name: "surfaces", routes, describe: () => ({ enabled }) };
88
95
  }
@@ -53,6 +53,9 @@ export interface NanoSdkClient {
53
53
  variables?: Record<string, unknown>;
54
54
  awaitCompletion?: boolean;
55
55
  }, options?: unknown): Promise<Record<string, unknown>>;
56
+ cancelProcessInstance(input: {
57
+ processInstanceKey: string | number;
58
+ }, options?: unknown): Promise<unknown>;
56
59
  publishMessage(input: {
57
60
  name: string;
58
61
  correlationKey?: string;
@@ -101,6 +104,9 @@ export declare class SdkEngineClient implements EngineClient {
101
104
  processInstanceKey: string;
102
105
  variables?: Record<string, unknown>;
103
106
  }>;
107
+ cancelInstance(input: {
108
+ processInstanceKey: string;
109
+ }): Promise<void>;
104
110
  publishMessage(input: {
105
111
  name: string;
106
112
  correlationKey?: string;
@@ -66,6 +66,9 @@ export class SdkEngineClient {
66
66
  variables: body.variables ?? undefined,
67
67
  };
68
68
  }
69
+ async cancelInstance(input) {
70
+ await this.client.cancelProcessInstance({ processInstanceKey: input.processInstanceKey });
71
+ }
69
72
  async publishMessage(input) {
70
73
  await this.client.publishMessage({
71
74
  name: input.name,
@@ -8,6 +8,10 @@ export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, Ht
8
8
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
9
9
  export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
10
10
  export type { ProvisionedSource } from "./core/modules/datasource.ts";
11
+ export { makeGateway, Table } from "./core/modules/gateway.ts";
12
+ export type { ColumnMeta, DataSource as GatewayDataSource, ExecResult, ForeignKeyMeta, Row, TableMeta, } from "./core/modules/gateway.ts";
13
+ export { createPagesRoutes, mountPages } from "./core/modules/pages.ts";
14
+ export type { PagesDataSource, PagesDeps, PagesHandle, PagesOptions } from "./core/modules/pages.ts";
11
15
  export type { AppJobHandler } from "./core/modules/workers.ts";
12
16
  export { resolveHandler } from "./core/modules/workers.ts";
13
17
  export { evalCorrelation } from "./core/modules/triggers.ts";
@@ -7,6 +7,8 @@ export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType,
7
7
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.js";
8
8
  // Data layer
9
9
  export { DataLayer, TypeRepo } from "./core/modules/datasource.js";
10
+ export { makeGateway, Table } from "./core/modules/gateway.js";
11
+ export { createPagesRoutes, mountPages } from "./core/modules/pages.js";
10
12
  export { resolveHandler } from "./core/modules/workers.js";
11
13
  export { evalCorrelation } from "./core/modules/triggers.js";
12
14
  // Adapters + engine + run entrypoint
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.5.1",
3
+ "version": "0.7.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",
@@ -105,6 +105,8 @@ export interface EngineClient {
105
105
  variables?: Record<string, unknown>;
106
106
  awaitCompletion?: boolean;
107
107
  }): Promise<{ processInstanceKey: string; variables?: Record<string, unknown> }>;
108
+ /** Cancel a running process instance (the pages surface's row-cancel action). */
109
+ cancelInstance(input: { processInstanceKey: string }): Promise<void>;
108
110
  /** Publish a message for correlation. */
109
111
  publishMessage(input: {
110
112
  name: string;
@@ -44,6 +44,14 @@ export interface SurfaceDecl {
44
44
  enabled?: boolean;
45
45
  path?: string;
46
46
  agent?: string;
47
+ /** pages surface: directory of `*.page.json` (relative to app root). Default `pages`. */
48
+ pagesDir?: string;
49
+ /** pages surface: the page served at `/`. Default `home`. */
50
+ homePage?: string;
51
+ /** pages surface: max rows a `dataGrid` fetch returns. Default 200. */
52
+ rowLimit?: number;
53
+ /** pages surface: the injected default datasource name. Default `app`. */
54
+ sourceName?: string;
47
55
  }
48
56
 
49
57
  export interface AppManifest {
@@ -6,6 +6,7 @@
6
6
  import type { RuntimeContext } from "../context.ts";
7
7
  import type { HostContext, SqliteDb } from "../host.ts";
8
8
  import type { AppManifest, DataSource, DomainType } from "../manifest.ts";
9
+ import { makeGateway, Table, type DataSource as GatewayDataSource } from "./gateway.ts";
9
10
 
10
11
  function sqlitePathFromUrl(url: string): string {
11
12
  // Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
@@ -89,6 +90,8 @@ export interface ProvisionedSource {
89
90
  readonly name: string;
90
91
  readonly driver: string;
91
92
  readonly db: SqliteDb;
93
+ /** The record-oriented gateway over this source — the RAD `Table<T>` surface (ADR 0055). */
94
+ readonly source: GatewayDataSource;
92
95
  readonly migrationsApplied: string[];
93
96
  close(): void;
94
97
  }
@@ -123,6 +126,22 @@ export class DataLayer {
123
126
  return new TypeRepo(this.source(sourceName).db, typeName, def);
124
127
  }
125
128
 
129
+ /** The record-oriented `DataSource` gateway for a source (the default when omitted) — the
130
+ * raw-SQL + `Table<T>` surface app handlers bind to (ADR 0055). */
131
+ open(sourceName?: string): GatewayDataSource {
132
+ return this.source(sourceName).source;
133
+ }
134
+
135
+ /** A typed `Table<T>` gateway over one table on a source (the default when omitted). `pk` is
136
+ * the primary-key column (default "id"). */
137
+ table<T extends object = Record<string, unknown>>(
138
+ name: string,
139
+ pk?: string,
140
+ sourceName?: string,
141
+ ): Table<T> {
142
+ return this.open(sourceName).table<T>(name, pk);
143
+ }
144
+
126
145
  describe(): Record<string, unknown> {
127
146
  return {
128
147
  default: this.defaultSource,
@@ -198,6 +217,7 @@ async function provisionSqlite(
198
217
  name,
199
218
  driver: "sqlite",
200
219
  db,
220
+ source: makeGateway(db),
201
221
  migrationsApplied,
202
222
  close: () => db.close(),
203
223
  };
@@ -0,0 +1,164 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createNodeHost } from "../../adapters/node.ts";
7
+ import { makeGateway, Table, type DataSource } from "./gateway.ts";
8
+
9
+ interface Order {
10
+ id: number;
11
+ status: string;
12
+ total: number;
13
+ }
14
+
15
+ async function withGateway(fn: (src: DataSource) => Promise<void>): Promise<void> {
16
+ const dir = await mkdtemp(join(tmpdir(), "urban-gateway-"));
17
+ const host = createNodeHost({ cwd: dir, log: () => {} });
18
+ const db = host.openSqlite(join(dir, "test.db"));
19
+ db.exec(
20
+ "CREATE TABLE orders (id INTEGER PRIMARY KEY, status TEXT NOT NULL, total INTEGER NOT NULL DEFAULT 0)",
21
+ );
22
+ try {
23
+ await fn(makeGateway(db));
24
+ } finally {
25
+ db.close();
26
+ await rm(dir, { recursive: true, force: true });
27
+ }
28
+ }
29
+
30
+ test("Table insert/get returns the row by primary key", async () => {
31
+ await withGateway(async (src) => {
32
+ const orders = src.table<Order>("orders");
33
+ const id = await orders.insert({ status: "new", total: 10 });
34
+ const row = await orders.get(id);
35
+ assert.equal(row?.status, "new");
36
+ assert.equal(row?.total, 10);
37
+ assert.equal(Number(row?.id), Number(id));
38
+ });
39
+ });
40
+
41
+ test("Table update patches and returns rows changed", async () => {
42
+ await withGateway(async (src) => {
43
+ const orders = src.table<Order>("orders");
44
+ const id = await orders.insert({ status: "new", total: 10 });
45
+ const changed = await orders.update(id, { status: "shipped" });
46
+ assert.equal(changed, 1);
47
+ assert.equal((await orders.get(id))?.status, "shipped");
48
+ });
49
+ });
50
+
51
+ test("Table find/findOne filter by equality", async () => {
52
+ await withGateway(async (src) => {
53
+ const orders = src.table<Order>("orders");
54
+ await orders.insert({ status: "new", total: 1 });
55
+ await orders.insert({ status: "new", total: 2 });
56
+ await orders.insert({ status: "done", total: 3 });
57
+ const news = await orders.find({ status: "new" });
58
+ assert.equal(news.length, 2);
59
+ const one = await orders.findOne({ status: "done" });
60
+ assert.equal(one?.total, 3);
61
+ assert.equal(await orders.findOne({ status: "missing" }), undefined);
62
+ });
63
+ });
64
+
65
+ test("Table count respects the filter", async () => {
66
+ await withGateway(async (src) => {
67
+ const orders = src.table<Order>("orders");
68
+ await orders.insert({ status: "new", total: 1 });
69
+ await orders.insert({ status: "done", total: 2 });
70
+ assert.equal(await orders.count(), 2);
71
+ assert.equal(await orders.count({ status: "new" }), 1);
72
+ });
73
+ });
74
+
75
+ test("Table all lists rows and honours limit", async () => {
76
+ await withGateway(async (src) => {
77
+ const orders = src.table<Order>("orders");
78
+ await orders.insert({ status: "a", total: 1 });
79
+ await orders.insert({ status: "b", total: 2 });
80
+ assert.equal((await orders.all()).length, 2);
81
+ assert.equal((await orders.all(1)).length, 1);
82
+ });
83
+ });
84
+
85
+ test("Table delete removes the row", async () => {
86
+ await withGateway(async (src) => {
87
+ const orders = src.table<Order>("orders");
88
+ const id = await orders.insert({ status: "new", total: 1 });
89
+ assert.equal(await orders.delete(id), 1);
90
+ assert.equal(await orders.get(id), undefined);
91
+ });
92
+ });
93
+
94
+ test("tx commits on success and rolls back on throw", async () => {
95
+ await withGateway(async (src) => {
96
+ const orders = src.table<Order>("orders");
97
+ await src.tx(async (t) => {
98
+ await t.exec("INSERT INTO orders (status, total) VALUES (?, ?)", ["tx", 5]);
99
+ });
100
+ assert.equal(await orders.count({ status: "tx" }), 1);
101
+
102
+ await assert.rejects(
103
+ src.tx(async (t) => {
104
+ await t.exec("INSERT INTO orders (status, total) VALUES (?, ?)", ["bad", 9]);
105
+ throw new Error("boom");
106
+ }),
107
+ /boom/,
108
+ );
109
+ assert.equal(await orders.count({ status: "bad" }), 0);
110
+ });
111
+ });
112
+
113
+ test("schema introspects columns/pk and excludes internal tables", async () => {
114
+ await withGateway(async (src) => {
115
+ await src.exec("CREATE TABLE _urban_migrations (id TEXT PRIMARY KEY)");
116
+ await src.exec("CREATE TABLE _nano_ledger (id TEXT PRIMARY KEY)");
117
+ const meta = await src.schema();
118
+ const names = meta.map((t) => t.name);
119
+ assert.deepEqual(names, ["orders"]);
120
+ const orders = meta[0];
121
+ const idCol = orders.columns.find((c) => c.name === "id");
122
+ assert.equal(idCol?.primaryKey, true);
123
+ const statusCol = orders.columns.find((c) => c.name === "status");
124
+ assert.equal(statusCol?.notNull, true);
125
+ });
126
+ });
127
+
128
+ test("query rejects (not throws synchronously) on invalid SQL", async () => {
129
+ await withGateway(async (src) => {
130
+ await assert.rejects(src.query("SELECT * FROM does_not_exist"));
131
+ await assert.rejects(src.exec("INSERT INTO does_not_exist (x) VALUES (1)"));
132
+ });
133
+ });
134
+
135
+ test("Table.insert throws when the driver reports no lastInsertId", async () => {
136
+ const fake: DataSource = {
137
+ query: async () => [],
138
+ exec: async () => ({ changed: 1 }),
139
+ tx: async (fn) => fn(fake),
140
+ schema: async () => [],
141
+ table: (name, pk) => new Table(fake, name, pk),
142
+ };
143
+ const t = fake.table("orders");
144
+ await assert.rejects(t.insert({ status: "x" } as Record<string, unknown>), /no lastInsertId/);
145
+ });
146
+
147
+ test("Table.all ignores a non-finite limit", async () => {
148
+ await withGateway(async (src) => {
149
+ const orders = src.table<Order>("orders");
150
+ await orders.insert({ status: "a", total: 1 });
151
+ await orders.insert({ status: "b", total: 2 });
152
+ assert.equal((await orders.all(Number.NaN)).length, 2);
153
+ assert.equal((await orders.all(Number.POSITIVE_INFINITY)).length, 2);
154
+ });
155
+ });
156
+
157
+ test("query/exec run raw parameterised SQL", async () => {
158
+ await withGateway(async (src) => {
159
+ const r = await src.exec("INSERT INTO orders (status, total) VALUES (?, ?)", ["raw", 42]);
160
+ assert.equal(r.changed, 1);
161
+ const rows = await src.query("SELECT total FROM orders WHERE status = ?", ["raw"]);
162
+ assert.equal(Number((rows[0] as { total: number }).total), 42);
163
+ });
164
+ });
@@ -0,0 +1,274 @@
1
+ // The record-oriented datasource gateway (ADR 0024 / ADR 0029 §6, ADR 0055 phase 1).
2
+ //
3
+ // This is the runtime port of the console-generated `data-sdk.ts` `DataSource` + `Table<T>`:
4
+ // the RAD "TTable" a handler binds to instead of hand-writing SQL. It sits on the runtime's
5
+ // synchronous `SqliteDb` host seam but keeps the *async* app-facing contract (every method
6
+ // returns a Promise), so an app-hosted worker/action/surface handler reaches typed records the
7
+ // same way regardless of which driver backs the source underneath.
8
+ //
9
+ // const db = app.data.open(); // the default source as a DataSource
10
+ // await db.exec("INSERT INTO orders(id) VALUES (?)", [id]);
11
+ // const orders = db.table<Order>("orders"); // a typed gateway over one table
12
+ // await orders.insert({ id, status: "new" });
13
+ // const o = await orders.get(id);
14
+
15
+ import type { SqliteDb } from "../host.ts";
16
+
17
+ export type Row = Record<string, unknown>;
18
+
19
+ export interface ExecResult {
20
+ /** Rows changed by an INSERT/UPDATE/DELETE. */
21
+ changed: number;
22
+ /** Rowid of the last inserted row, when the driver reports one. */
23
+ lastInsertId?: number | bigint;
24
+ }
25
+
26
+ /** One column of a table, from the datasource's introspected schema. */
27
+ export interface ColumnMeta {
28
+ name: string;
29
+ type: string;
30
+ notNull: boolean;
31
+ primaryKey: boolean;
32
+ }
33
+
34
+ /** One foreign-key constraint: `column` references `refTable(refColumn)`. `refColumn` is empty
35
+ * when the FK targets the parent's primary key without naming a column; `onDelete` is the
36
+ * referential action (e.g. `CASCADE`), empty when none was declared. */
37
+ export interface ForeignKeyMeta {
38
+ column: string;
39
+ refTable: string;
40
+ refColumn: string;
41
+ onDelete: string;
42
+ }
43
+
44
+ /** One table: its columns, index names, and foreign keys. Powers domain-type projection and
45
+ * the page runtime's list/detail binding. */
46
+ export interface TableMeta {
47
+ name: string;
48
+ columns: ColumnMeta[];
49
+ indexes: string[];
50
+ foreignKeys: ForeignKeyMeta[];
51
+ }
52
+
53
+ /** The one thin, uniform interface behind every driver (ADR 0024 §2) — the `TDataSet`
54
+ * equivalent. Every consumer shares exactly this surface, so the driver underneath is
55
+ * interchangeable. */
56
+ export interface DataSource {
57
+ /** Run a SELECT (or any row-returning statement) and collect the rows. */
58
+ query(sql: string, params?: unknown[]): Promise<Row[]>;
59
+ /** Run a non-row statement (INSERT/UPDATE/DELETE/DDL). */
60
+ exec(sql: string, params?: unknown[]): Promise<ExecResult>;
61
+ /** Run `fn` inside a transaction, committing on success and rolling back on throw. The
62
+ * handle passed to `fn` targets the same connection. */
63
+ tx<T>(fn: (t: DataSource) => Promise<T>): Promise<T>;
64
+ /** Introspect the datasource's tables/columns/indexes/foreign keys. */
65
+ schema(): Promise<TableMeta[]>;
66
+ /** A typed gateway over one table — the RAD "TTable". `pk` is the primary-key column
67
+ * (default "id"). */
68
+ table<T extends object = Row>(name: string, pk?: string): Table<T>;
69
+ }
70
+
71
+ /** Double-quote a SQL identifier (table/column), escaping embedded quotes. */
72
+ export function quoteIdent(name: string): string {
73
+ return `"${name.replaceAll('"', '""')}"`;
74
+ }
75
+
76
+ /** Build a parameterised ` WHERE a = ? AND b = ?` clause from an equality map; an empty map
77
+ * yields an empty clause (matches all rows). Takes `object` (not `Row`) so a `Partial<T>` for a
78
+ * generated `interface` row type (which lacks a string index signature) is accepted. */
79
+ function whereClause(where: object): { clause: string; params: unknown[] } {
80
+ const w = where as Row;
81
+ const keys = Object.keys(w);
82
+ if (keys.length === 0) return { clause: "", params: [] };
83
+ const clause = " WHERE " + keys.map((k) => `${quoteIdent(k)} = ?`).join(" AND ");
84
+ return { clause, params: keys.map((k) => w[k]) };
85
+ }
86
+
87
+ /** A typed gateway over a single table — the record-oriented data object a handler binds to
88
+ * instead of hand-writing SQL (the Delphi `TTable`/data-module idea, ADR 0029 §6). It builds
89
+ * parameterised SQL from a typed row object's own keys, so callers manipulate rows as records.
90
+ * `T` comes from a generated row type; this class is generic *runtime* and knows nothing about
91
+ * any specific schema. `pk` is the primary-key column (default `id`). */
92
+ export class Table<T extends object = Row> {
93
+ readonly name: string;
94
+ readonly pk: string;
95
+ readonly #src: DataSource;
96
+
97
+ constructor(src: DataSource, name: string, pk = "id") {
98
+ this.#src = src;
99
+ this.name = name;
100
+ this.pk = pk;
101
+ }
102
+
103
+ /** Insert one row (only the present keys are written); returns the new primary-key value
104
+ * (the inserted rowid for an INTEGER PRIMARY KEY). */
105
+ async insert(row: Partial<T>): Promise<number | bigint> {
106
+ const keys = Object.keys(row);
107
+ if (keys.length === 0) {
108
+ throw new Error(`Table(${this.name}).insert: no columns to insert`);
109
+ }
110
+ const cols = keys.map(quoteIdent).join(", ");
111
+ const ph = keys.map(() => "?").join(", ");
112
+ const r = await this.#src.exec(
113
+ `INSERT INTO ${quoteIdent(this.name)} (${cols}) VALUES (${ph})`,
114
+ keys.map((k) => (row as Row)[k]),
115
+ );
116
+ if (r.lastInsertId == null) {
117
+ // The driver reported no rowid — treat as a failed/ambiguous insert rather than
118
+ // silently returning 0, which a caller could mistake for a real primary key.
119
+ throw new Error(`Table(${this.name}).insert: driver reported no lastInsertId`);
120
+ }
121
+ return r.lastInsertId;
122
+ }
123
+
124
+ /** Fetch the row with the given primary key, or `undefined`. */
125
+ async get(id: unknown): Promise<T | undefined> {
126
+ const rows = await this.#src.query(
127
+ `SELECT * FROM ${quoteIdent(this.name)} WHERE ${quoteIdent(this.pk)} = ? LIMIT 1`,
128
+ [id],
129
+ );
130
+ return rows[0] as T | undefined;
131
+ }
132
+
133
+ /** Every row (optionally capped at `limit`). */
134
+ async all(limit?: number): Promise<T[]> {
135
+ const lim =
136
+ typeof limit === "number" && Number.isFinite(limit)
137
+ ? ` LIMIT ${Math.max(0, Math.floor(limit))}`
138
+ : "";
139
+ return (await this.#src.query(`SELECT * FROM ${quoteIdent(this.name)}${lim}`)) as T[];
140
+ }
141
+
142
+ /** Rows matching an equality filter (keys ANDed). An empty filter matches all rows. */
143
+ async find(where: Partial<T> = {}): Promise<T[]> {
144
+ const { clause, params } = whereClause(where as Row);
145
+ return (await this.#src.query(
146
+ `SELECT * FROM ${quoteIdent(this.name)}${clause}`,
147
+ params,
148
+ )) as T[];
149
+ }
150
+
151
+ /** The first row matching an equality filter, or `undefined`. */
152
+ async findOne(where: Partial<T> = {}): Promise<T | undefined> {
153
+ const { clause, params } = whereClause(where as Row);
154
+ const rows = await this.#src.query(
155
+ `SELECT * FROM ${quoteIdent(this.name)}${clause} LIMIT 1`,
156
+ params,
157
+ );
158
+ return rows[0] as T | undefined;
159
+ }
160
+
161
+ /** Patch the row with the given primary key; returns rows changed. */
162
+ async update(id: unknown, patch: Partial<T>): Promise<number> {
163
+ const keys = Object.keys(patch);
164
+ if (keys.length === 0) return 0;
165
+ const set = keys.map((k) => `${quoteIdent(k)} = ?`).join(", ");
166
+ const r = await this.#src.exec(
167
+ `UPDATE ${quoteIdent(this.name)} SET ${set} WHERE ${quoteIdent(this.pk)} = ?`,
168
+ [...keys.map((k) => (patch as Row)[k]), id],
169
+ );
170
+ return r.changed;
171
+ }
172
+
173
+ /** Delete the row with the given primary key; returns rows changed. */
174
+ async delete(id: unknown): Promise<number> {
175
+ const r = await this.#src.exec(
176
+ `DELETE FROM ${quoteIdent(this.name)} WHERE ${quoteIdent(this.pk)} = ?`,
177
+ [id],
178
+ );
179
+ return r.changed;
180
+ }
181
+
182
+ /** Count rows matching an equality filter (all rows when omitted). */
183
+ async count(where: Partial<T> = {}): Promise<number> {
184
+ const { clause, params } = whereClause(where as Row);
185
+ const rows = await this.#src.query(
186
+ `SELECT COUNT(*) AS n FROM ${quoteIdent(this.name)}${clause}`,
187
+ params,
188
+ );
189
+ return Number((rows[0] as Row)?.n ?? 0);
190
+ }
191
+ }
192
+
193
+ /** A `DataSource` implemented over the runtime's synchronous `SqliteDb` host seam. The methods
194
+ * are async to hold the app-facing contract; the work underneath is synchronous. */
195
+ class SqliteGateway implements DataSource {
196
+ readonly #db: SqliteDb;
197
+ constructor(db: SqliteDb) {
198
+ this.#db = db;
199
+ }
200
+
201
+ async query(sql: string, params: unknown[] = []): Promise<Row[]> {
202
+ return this.#db.all<Row>(sql, params);
203
+ }
204
+
205
+ async exec(sql: string, params: unknown[] = []): Promise<ExecResult> {
206
+ const r = this.#db.run(sql, params);
207
+ return { changed: Number(r.changes), lastInsertId: r.lastInsertRowid };
208
+ }
209
+
210
+ async tx<T>(fn: (t: DataSource) => Promise<T>): Promise<T> {
211
+ this.#db.exec("BEGIN");
212
+ try {
213
+ const out = await fn(this);
214
+ this.#db.exec("COMMIT");
215
+ return out;
216
+ } catch (e) {
217
+ this.#db.exec("ROLLBACK");
218
+ throw e;
219
+ }
220
+ }
221
+
222
+ async schema(): Promise<TableMeta[]> {
223
+ // Exclude SQLite internals (`sqlite_%`) and Nano's own bookkeeping tables (`_urban_%` /
224
+ // `_nano_%`, e.g. the migrations ledger): neither is a user/domain table, so they must
225
+ // never surface in the domain model, DB Manager, or forms.
226
+ const tables = this.#db.all<{ name: string }>(
227
+ "SELECT name FROM sqlite_master WHERE type = 'table' " +
228
+ "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_urban\\_%' ESCAPE '\\' " +
229
+ "AND name NOT LIKE '\\_nano\\_%' ESCAPE '\\' ORDER BY name",
230
+ );
231
+ const out: TableMeta[] = [];
232
+ for (const t of tables) {
233
+ const cols = this.#db.all<{ name: string; type: string; notnull: number; pk: number }>(
234
+ `PRAGMA table_info(${quoteIdent(t.name)})`,
235
+ );
236
+ const idx = this.#db.all<{ name: string }>(`PRAGMA index_list(${quoteIdent(t.name)})`);
237
+ const fks = this.#db.all<{
238
+ from: string;
239
+ table: string;
240
+ to: string | null;
241
+ on_delete?: string;
242
+ }>(`PRAGMA foreign_key_list(${quoteIdent(t.name)})`);
243
+ out.push({
244
+ name: t.name,
245
+ columns: cols.map((c) => ({
246
+ name: c.name,
247
+ type: c.type,
248
+ notNull: !!c.notnull,
249
+ primaryKey: !!c.pk,
250
+ })),
251
+ indexes: idx.map((i) => String(i.name)),
252
+ foreignKeys: fks.map((f) => ({
253
+ column: f.from,
254
+ refTable: f.table,
255
+ refColumn: f.to ?? "",
256
+ onDelete:
257
+ f.on_delete && f.on_delete.toUpperCase() !== "NO ACTION"
258
+ ? f.on_delete.toUpperCase()
259
+ : "",
260
+ })),
261
+ });
262
+ }
263
+ return out;
264
+ }
265
+
266
+ table<T extends object = Row>(name: string, pk = "id"): Table<T> {
267
+ return new Table<T>(this, name, pk);
268
+ }
269
+ }
270
+
271
+ /** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway. */
272
+ export function makeGateway(db: SqliteDb): DataSource {
273
+ return new SqliteGateway(db);
274
+ }