@nanobpm/urban 0.5.1 → 0.6.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.
@@ -1,6 +1,7 @@
1
1
  import type { RuntimeContext } from "../context.ts";
2
2
  import type { SqliteDb } from "../host.ts";
3
3
  import type { DomainType } from "../manifest.ts";
4
+ import { Table, type DataSource as GatewayDataSource } from "./gateway.ts";
4
5
  /** A typed accessor over a table declared in manifest `types`. */
5
6
  export declare class TypeRepo {
6
7
  private readonly db;
@@ -20,6 +21,8 @@ export interface ProvisionedSource {
20
21
  readonly name: string;
21
22
  readonly driver: string;
22
23
  readonly db: SqliteDb;
24
+ /** The record-oriented gateway over this source — the RAD `Table<T>` surface (ADR 0055). */
25
+ readonly source: GatewayDataSource;
23
26
  readonly migrationsApplied: string[];
24
27
  close(): void;
25
28
  }
@@ -31,6 +34,12 @@ export declare class DataLayer {
31
34
  source(name?: string): ProvisionedSource;
32
35
  /** A typed accessor for a declared domain type (uses the default source). */
33
36
  repo(typeName: string, sourceName?: string): TypeRepo;
37
+ /** The record-oriented `DataSource` gateway for a source (the default when omitted) — the
38
+ * raw-SQL + `Table<T>` surface app handlers bind to (ADR 0055). */
39
+ open(sourceName?: string): GatewayDataSource;
40
+ /** A typed `Table<T>` gateway over one table on a source (the default when omitted). `pk` is
41
+ * the primary-key column (default "id"). */
42
+ table<T extends object = Record<string, unknown>>(name: string, pk?: string, sourceName?: string): Table<T>;
34
43
  describe(): Record<string, unknown>;
35
44
  closeAll(): void;
36
45
  }
@@ -2,6 +2,7 @@
2
2
  // `types` domain model. Only the `sqlite` driver is implemented; other drivers hit an
3
3
  // explicit seam (they are declared in the manifest but not yet provisioned). This is the
4
4
  // runtime side of ADR 0024 (datasource) + ADR 0040 (domain model), scoped to what an app needs.
5
+ import { makeGateway } from "./gateway.js";
5
6
  function sqlitePathFromUrl(url) {
6
7
  // Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
7
8
  return url.replace(/^(file|sqlite):(\/\/)?/, "");
@@ -93,6 +94,16 @@ export class DataLayer {
93
94
  throw new Error(`no such type "${typeName}"`);
94
95
  return new TypeRepo(this.source(sourceName).db, typeName, def);
95
96
  }
97
+ /** The record-oriented `DataSource` gateway for a source (the default when omitted) — the
98
+ * raw-SQL + `Table<T>` surface app handlers bind to (ADR 0055). */
99
+ open(sourceName) {
100
+ return this.source(sourceName).source;
101
+ }
102
+ /** A typed `Table<T>` gateway over one table on a source (the default when omitted). `pk` is
103
+ * the primary-key column (default "id"). */
104
+ table(name, pk, sourceName) {
105
+ return this.open(sourceName).table(name, pk);
106
+ }
96
107
  describe() {
97
108
  return {
98
109
  default: this.defaultSource,
@@ -153,6 +164,7 @@ async function provisionSqlite(ctx, name, src) {
153
164
  name,
154
165
  driver: "sqlite",
155
166
  db,
167
+ source: makeGateway(db),
156
168
  migrationsApplied,
157
169
  close: () => db.close(),
158
170
  };
@@ -0,0 +1,79 @@
1
+ import type { SqliteDb } from "../host.ts";
2
+ export type Row = Record<string, unknown>;
3
+ export interface ExecResult {
4
+ /** Rows changed by an INSERT/UPDATE/DELETE. */
5
+ changed: number;
6
+ /** Rowid of the last inserted row, when the driver reports one. */
7
+ lastInsertId?: number | bigint;
8
+ }
9
+ /** One column of a table, from the datasource's introspected schema. */
10
+ export interface ColumnMeta {
11
+ name: string;
12
+ type: string;
13
+ notNull: boolean;
14
+ primaryKey: boolean;
15
+ }
16
+ /** One foreign-key constraint: `column` references `refTable(refColumn)`. `refColumn` is empty
17
+ * when the FK targets the parent's primary key without naming a column; `onDelete` is the
18
+ * referential action (e.g. `CASCADE`), empty when none was declared. */
19
+ export interface ForeignKeyMeta {
20
+ column: string;
21
+ refTable: string;
22
+ refColumn: string;
23
+ onDelete: string;
24
+ }
25
+ /** One table: its columns, index names, and foreign keys. Powers domain-type projection and
26
+ * the page runtime's list/detail binding. */
27
+ export interface TableMeta {
28
+ name: string;
29
+ columns: ColumnMeta[];
30
+ indexes: string[];
31
+ foreignKeys: ForeignKeyMeta[];
32
+ }
33
+ /** The one thin, uniform interface behind every driver (ADR 0024 §2) — the `TDataSet`
34
+ * equivalent. Every consumer shares exactly this surface, so the driver underneath is
35
+ * interchangeable. */
36
+ export interface DataSource {
37
+ /** Run a SELECT (or any row-returning statement) and collect the rows. */
38
+ query(sql: string, params?: unknown[]): Promise<Row[]>;
39
+ /** Run a non-row statement (INSERT/UPDATE/DELETE/DDL). */
40
+ exec(sql: string, params?: unknown[]): Promise<ExecResult>;
41
+ /** Run `fn` inside a transaction, committing on success and rolling back on throw. The
42
+ * handle passed to `fn` targets the same connection. */
43
+ tx<T>(fn: (t: DataSource) => Promise<T>): Promise<T>;
44
+ /** Introspect the datasource's tables/columns/indexes/foreign keys. */
45
+ schema(): Promise<TableMeta[]>;
46
+ /** A typed gateway over one table — the RAD "TTable". `pk` is the primary-key column
47
+ * (default "id"). */
48
+ table<T extends object = Row>(name: string, pk?: string): Table<T>;
49
+ }
50
+ /** A typed gateway over a single table — the record-oriented data object a handler binds to
51
+ * instead of hand-writing SQL (the Delphi `TTable`/data-module idea, ADR 0029 §6). It builds
52
+ * parameterised SQL from a typed row object's own keys, so callers manipulate rows as records.
53
+ * `T` comes from a generated row type; this class is generic *runtime* and knows nothing about
54
+ * any specific schema. `pk` is the primary-key column (default `id`). */
55
+ export declare class Table<T extends object = Row> {
56
+ #private;
57
+ readonly name: string;
58
+ readonly pk: string;
59
+ constructor(src: DataSource, name: string, pk?: string);
60
+ /** Insert one row (only the present keys are written); returns the new primary-key value
61
+ * (the inserted rowid for an INTEGER PRIMARY KEY). */
62
+ insert(row: Partial<T>): Promise<number | bigint>;
63
+ /** Fetch the row with the given primary key, or `undefined`. */
64
+ get(id: unknown): Promise<T | undefined>;
65
+ /** Every row (optionally capped at `limit`). */
66
+ all(limit?: number): Promise<T[]>;
67
+ /** Rows matching an equality filter (keys ANDed). An empty filter matches all rows. */
68
+ find(where?: Partial<T>): Promise<T[]>;
69
+ /** The first row matching an equality filter, or `undefined`. */
70
+ findOne(where?: Partial<T>): Promise<T | undefined>;
71
+ /** Patch the row with the given primary key; returns rows changed. */
72
+ update(id: unknown, patch: Partial<T>): Promise<number>;
73
+ /** Delete the row with the given primary key; returns rows changed. */
74
+ delete(id: unknown): Promise<number>;
75
+ /** Count rows matching an equality filter (all rows when omitted). */
76
+ count(where?: Partial<T>): Promise<number>;
77
+ }
78
+ /** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway. */
79
+ export declare function makeGateway(db: SqliteDb): DataSource;
@@ -0,0 +1,169 @@
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
+ function quoteIdent(name) {
15
+ return `"${name.replaceAll('"', '""')}"`;
16
+ }
17
+ /** Build a parameterised ` WHERE a = ? AND b = ?` clause from an equality map; an empty map
18
+ * yields an empty clause (matches all rows). Takes `object` (not `Row`) so a `Partial<T>` for a
19
+ * generated `interface` row type (which lacks a string index signature) is accepted. */
20
+ function whereClause(where) {
21
+ const w = where;
22
+ const keys = Object.keys(w);
23
+ if (keys.length === 0)
24
+ return { clause: "", params: [] };
25
+ const clause = " WHERE " + keys.map((k) => `${quoteIdent(k)} = ?`).join(" AND ");
26
+ return { clause, params: keys.map((k) => w[k]) };
27
+ }
28
+ /** A typed gateway over a single table — the record-oriented data object a handler binds to
29
+ * instead of hand-writing SQL (the Delphi `TTable`/data-module idea, ADR 0029 §6). It builds
30
+ * parameterised SQL from a typed row object's own keys, so callers manipulate rows as records.
31
+ * `T` comes from a generated row type; this class is generic *runtime* and knows nothing about
32
+ * any specific schema. `pk` is the primary-key column (default `id`). */
33
+ export class Table {
34
+ name;
35
+ pk;
36
+ #src;
37
+ constructor(src, name, pk = "id") {
38
+ this.#src = src;
39
+ this.name = name;
40
+ this.pk = pk;
41
+ }
42
+ /** Insert one row (only the present keys are written); returns the new primary-key value
43
+ * (the inserted rowid for an INTEGER PRIMARY KEY). */
44
+ async insert(row) {
45
+ const keys = Object.keys(row);
46
+ if (keys.length === 0) {
47
+ throw new Error(`Table(${this.name}).insert: no columns to insert`);
48
+ }
49
+ const cols = keys.map(quoteIdent).join(", ");
50
+ const ph = keys.map(() => "?").join(", ");
51
+ const r = await this.#src.exec(`INSERT INTO ${quoteIdent(this.name)} (${cols}) VALUES (${ph})`, keys.map((k) => row[k]));
52
+ if (r.lastInsertId == null) {
53
+ // The driver reported no rowid — treat as a failed/ambiguous insert rather than
54
+ // silently returning 0, which a caller could mistake for a real primary key.
55
+ throw new Error(`Table(${this.name}).insert: driver reported no lastInsertId`);
56
+ }
57
+ return r.lastInsertId;
58
+ }
59
+ /** Fetch the row with the given primary key, or `undefined`. */
60
+ async get(id) {
61
+ const rows = await this.#src.query(`SELECT * FROM ${quoteIdent(this.name)} WHERE ${quoteIdent(this.pk)} = ? LIMIT 1`, [id]);
62
+ return rows[0];
63
+ }
64
+ /** Every row (optionally capped at `limit`). */
65
+ async all(limit) {
66
+ const lim = typeof limit === "number" && Number.isFinite(limit)
67
+ ? ` LIMIT ${Math.max(0, Math.floor(limit))}`
68
+ : "";
69
+ return (await this.#src.query(`SELECT * FROM ${quoteIdent(this.name)}${lim}`));
70
+ }
71
+ /** Rows matching an equality filter (keys ANDed). An empty filter matches all rows. */
72
+ async find(where = {}) {
73
+ const { clause, params } = whereClause(where);
74
+ return (await this.#src.query(`SELECT * FROM ${quoteIdent(this.name)}${clause}`, params));
75
+ }
76
+ /** The first row matching an equality filter, or `undefined`. */
77
+ async findOne(where = {}) {
78
+ const { clause, params } = whereClause(where);
79
+ const rows = await this.#src.query(`SELECT * FROM ${quoteIdent(this.name)}${clause} LIMIT 1`, params);
80
+ return rows[0];
81
+ }
82
+ /** Patch the row with the given primary key; returns rows changed. */
83
+ async update(id, patch) {
84
+ const keys = Object.keys(patch);
85
+ if (keys.length === 0)
86
+ return 0;
87
+ const set = keys.map((k) => `${quoteIdent(k)} = ?`).join(", ");
88
+ const r = await this.#src.exec(`UPDATE ${quoteIdent(this.name)} SET ${set} WHERE ${quoteIdent(this.pk)} = ?`, [...keys.map((k) => patch[k]), id]);
89
+ return r.changed;
90
+ }
91
+ /** Delete the row with the given primary key; returns rows changed. */
92
+ async delete(id) {
93
+ const r = await this.#src.exec(`DELETE FROM ${quoteIdent(this.name)} WHERE ${quoteIdent(this.pk)} = ?`, [id]);
94
+ return r.changed;
95
+ }
96
+ /** Count rows matching an equality filter (all rows when omitted). */
97
+ async count(where = {}) {
98
+ const { clause, params } = whereClause(where);
99
+ const rows = await this.#src.query(`SELECT COUNT(*) AS n FROM ${quoteIdent(this.name)}${clause}`, params);
100
+ return Number(rows[0]?.n ?? 0);
101
+ }
102
+ }
103
+ /** A `DataSource` implemented over the runtime's synchronous `SqliteDb` host seam. The methods
104
+ * are async to hold the app-facing contract; the work underneath is synchronous. */
105
+ class SqliteGateway {
106
+ #db;
107
+ constructor(db) {
108
+ this.#db = db;
109
+ }
110
+ async query(sql, params = []) {
111
+ return this.#db.all(sql, params);
112
+ }
113
+ async exec(sql, params = []) {
114
+ const r = this.#db.run(sql, params);
115
+ return { changed: Number(r.changes), lastInsertId: r.lastInsertRowid };
116
+ }
117
+ async tx(fn) {
118
+ this.#db.exec("BEGIN");
119
+ try {
120
+ const out = await fn(this);
121
+ this.#db.exec("COMMIT");
122
+ return out;
123
+ }
124
+ catch (e) {
125
+ this.#db.exec("ROLLBACK");
126
+ throw e;
127
+ }
128
+ }
129
+ async schema() {
130
+ // Exclude SQLite internals (`sqlite_%`) and Nano's own bookkeeping tables (`_urban_%` /
131
+ // `_nano_%`, e.g. the migrations ledger): neither is a user/domain table, so they must
132
+ // never surface in the domain model, DB Manager, or forms.
133
+ const tables = this.#db.all("SELECT name FROM sqlite_master WHERE type = 'table' " +
134
+ "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_urban\\_%' ESCAPE '\\' " +
135
+ "AND name NOT LIKE '\\_nano\\_%' ESCAPE '\\' ORDER BY name");
136
+ const out = [];
137
+ for (const t of tables) {
138
+ const cols = this.#db.all(`PRAGMA table_info(${quoteIdent(t.name)})`);
139
+ const idx = this.#db.all(`PRAGMA index_list(${quoteIdent(t.name)})`);
140
+ const fks = this.#db.all(`PRAGMA foreign_key_list(${quoteIdent(t.name)})`);
141
+ out.push({
142
+ name: t.name,
143
+ columns: cols.map((c) => ({
144
+ name: c.name,
145
+ type: c.type,
146
+ notNull: !!c.notnull,
147
+ primaryKey: !!c.pk,
148
+ })),
149
+ indexes: idx.map((i) => String(i.name)),
150
+ foreignKeys: fks.map((f) => ({
151
+ column: f.from,
152
+ refTable: f.table,
153
+ refColumn: f.to ?? "",
154
+ onDelete: f.on_delete && f.on_delete.toUpperCase() !== "NO ACTION"
155
+ ? f.on_delete.toUpperCase()
156
+ : "",
157
+ })),
158
+ });
159
+ }
160
+ return out;
161
+ }
162
+ table(name, pk = "id") {
163
+ return new Table(this, name, pk);
164
+ }
165
+ }
166
+ /** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway. */
167
+ export function makeGateway(db) {
168
+ return new SqliteGateway(db);
169
+ }
@@ -8,6 +8,8 @@ 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";
11
13
  export type { AppJobHandler } from "./core/modules/workers.ts";
12
14
  export { resolveHandler } from "./core/modules/workers.ts";
13
15
  export { evalCorrelation } from "./core/modules/triggers.ts";
@@ -7,6 +7,7 @@ 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";
10
11
  export { resolveHandler } from "./core/modules/workers.js";
11
12
  export { evalCorrelation } from "./core/modules/triggers.js";
12
13
  // 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.6.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",
@@ -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,273 @@
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
+ function quoteIdent(name: string): string {
72
+ return `"${name.replaceAll('"', '""')}"`;
73
+ }
74
+
75
+ /** Build a parameterised ` WHERE a = ? AND b = ?` clause from an equality map; an empty map
76
+ * yields an empty clause (matches all rows). Takes `object` (not `Row`) so a `Partial<T>` for a
77
+ * generated `interface` row type (which lacks a string index signature) is accepted. */
78
+ function whereClause(where: object): { clause: string; params: unknown[] } {
79
+ const w = where as Row;
80
+ const keys = Object.keys(w);
81
+ if (keys.length === 0) return { clause: "", params: [] };
82
+ const clause = " WHERE " + keys.map((k) => `${quoteIdent(k)} = ?`).join(" AND ");
83
+ return { clause, params: keys.map((k) => w[k]) };
84
+ }
85
+
86
+ /** A typed gateway over a single table — the record-oriented data object a handler binds to
87
+ * instead of hand-writing SQL (the Delphi `TTable`/data-module idea, ADR 0029 §6). It builds
88
+ * parameterised SQL from a typed row object's own keys, so callers manipulate rows as records.
89
+ * `T` comes from a generated row type; this class is generic *runtime* and knows nothing about
90
+ * any specific schema. `pk` is the primary-key column (default `id`). */
91
+ export class Table<T extends object = Row> {
92
+ readonly name: string;
93
+ readonly pk: string;
94
+ readonly #src: DataSource;
95
+
96
+ constructor(src: DataSource, name: string, pk = "id") {
97
+ this.#src = src;
98
+ this.name = name;
99
+ this.pk = pk;
100
+ }
101
+
102
+ /** Insert one row (only the present keys are written); returns the new primary-key value
103
+ * (the inserted rowid for an INTEGER PRIMARY KEY). */
104
+ async insert(row: Partial<T>): Promise<number | bigint> {
105
+ const keys = Object.keys(row);
106
+ if (keys.length === 0) {
107
+ throw new Error(`Table(${this.name}).insert: no columns to insert`);
108
+ }
109
+ const cols = keys.map(quoteIdent).join(", ");
110
+ const ph = keys.map(() => "?").join(", ");
111
+ const r = await this.#src.exec(
112
+ `INSERT INTO ${quoteIdent(this.name)} (${cols}) VALUES (${ph})`,
113
+ keys.map((k) => (row as Row)[k]),
114
+ );
115
+ if (r.lastInsertId == null) {
116
+ // The driver reported no rowid — treat as a failed/ambiguous insert rather than
117
+ // silently returning 0, which a caller could mistake for a real primary key.
118
+ throw new Error(`Table(${this.name}).insert: driver reported no lastInsertId`);
119
+ }
120
+ return r.lastInsertId;
121
+ }
122
+
123
+ /** Fetch the row with the given primary key, or `undefined`. */
124
+ async get(id: unknown): Promise<T | undefined> {
125
+ const rows = await this.#src.query(
126
+ `SELECT * FROM ${quoteIdent(this.name)} WHERE ${quoteIdent(this.pk)} = ? LIMIT 1`,
127
+ [id],
128
+ );
129
+ return rows[0] as T | undefined;
130
+ }
131
+
132
+ /** Every row (optionally capped at `limit`). */
133
+ async all(limit?: number): Promise<T[]> {
134
+ const lim =
135
+ typeof limit === "number" && Number.isFinite(limit)
136
+ ? ` LIMIT ${Math.max(0, Math.floor(limit))}`
137
+ : "";
138
+ return (await this.#src.query(`SELECT * FROM ${quoteIdent(this.name)}${lim}`)) as T[];
139
+ }
140
+
141
+ /** Rows matching an equality filter (keys ANDed). An empty filter matches all rows. */
142
+ async find(where: Partial<T> = {}): Promise<T[]> {
143
+ const { clause, params } = whereClause(where as Row);
144
+ return (await this.#src.query(
145
+ `SELECT * FROM ${quoteIdent(this.name)}${clause}`,
146
+ params,
147
+ )) as T[];
148
+ }
149
+
150
+ /** The first row matching an equality filter, or `undefined`. */
151
+ async findOne(where: Partial<T> = {}): Promise<T | undefined> {
152
+ const { clause, params } = whereClause(where as Row);
153
+ const rows = await this.#src.query(
154
+ `SELECT * FROM ${quoteIdent(this.name)}${clause} LIMIT 1`,
155
+ params,
156
+ );
157
+ return rows[0] as T | undefined;
158
+ }
159
+
160
+ /** Patch the row with the given primary key; returns rows changed. */
161
+ async update(id: unknown, patch: Partial<T>): Promise<number> {
162
+ const keys = Object.keys(patch);
163
+ if (keys.length === 0) return 0;
164
+ const set = keys.map((k) => `${quoteIdent(k)} = ?`).join(", ");
165
+ const r = await this.#src.exec(
166
+ `UPDATE ${quoteIdent(this.name)} SET ${set} WHERE ${quoteIdent(this.pk)} = ?`,
167
+ [...keys.map((k) => (patch as Row)[k]), id],
168
+ );
169
+ return r.changed;
170
+ }
171
+
172
+ /** Delete the row with the given primary key; returns rows changed. */
173
+ async delete(id: unknown): Promise<number> {
174
+ const r = await this.#src.exec(
175
+ `DELETE FROM ${quoteIdent(this.name)} WHERE ${quoteIdent(this.pk)} = ?`,
176
+ [id],
177
+ );
178
+ return r.changed;
179
+ }
180
+
181
+ /** Count rows matching an equality filter (all rows when omitted). */
182
+ async count(where: Partial<T> = {}): Promise<number> {
183
+ const { clause, params } = whereClause(where as Row);
184
+ const rows = await this.#src.query(
185
+ `SELECT COUNT(*) AS n FROM ${quoteIdent(this.name)}${clause}`,
186
+ params,
187
+ );
188
+ return Number((rows[0] as Row)?.n ?? 0);
189
+ }
190
+ }
191
+
192
+ /** A `DataSource` implemented over the runtime's synchronous `SqliteDb` host seam. The methods
193
+ * are async to hold the app-facing contract; the work underneath is synchronous. */
194
+ class SqliteGateway implements DataSource {
195
+ readonly #db: SqliteDb;
196
+ constructor(db: SqliteDb) {
197
+ this.#db = db;
198
+ }
199
+
200
+ async query(sql: string, params: unknown[] = []): Promise<Row[]> {
201
+ return this.#db.all<Row>(sql, params);
202
+ }
203
+
204
+ async exec(sql: string, params: unknown[] = []): Promise<ExecResult> {
205
+ const r = this.#db.run(sql, params);
206
+ return { changed: Number(r.changes), lastInsertId: r.lastInsertRowid };
207
+ }
208
+
209
+ async tx<T>(fn: (t: DataSource) => Promise<T>): Promise<T> {
210
+ this.#db.exec("BEGIN");
211
+ try {
212
+ const out = await fn(this);
213
+ this.#db.exec("COMMIT");
214
+ return out;
215
+ } catch (e) {
216
+ this.#db.exec("ROLLBACK");
217
+ throw e;
218
+ }
219
+ }
220
+
221
+ async schema(): Promise<TableMeta[]> {
222
+ // Exclude SQLite internals (`sqlite_%`) and Nano's own bookkeeping tables (`_urban_%` /
223
+ // `_nano_%`, e.g. the migrations ledger): neither is a user/domain table, so they must
224
+ // never surface in the domain model, DB Manager, or forms.
225
+ const tables = this.#db.all<{ name: string }>(
226
+ "SELECT name FROM sqlite_master WHERE type = 'table' " +
227
+ "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_urban\\_%' ESCAPE '\\' " +
228
+ "AND name NOT LIKE '\\_nano\\_%' ESCAPE '\\' ORDER BY name",
229
+ );
230
+ const out: TableMeta[] = [];
231
+ for (const t of tables) {
232
+ const cols = this.#db.all<{ name: string; type: string; notnull: number; pk: number }>(
233
+ `PRAGMA table_info(${quoteIdent(t.name)})`,
234
+ );
235
+ const idx = this.#db.all<{ name: string }>(`PRAGMA index_list(${quoteIdent(t.name)})`);
236
+ const fks = this.#db.all<{
237
+ from: string;
238
+ table: string;
239
+ to: string | null;
240
+ on_delete?: string;
241
+ }>(`PRAGMA foreign_key_list(${quoteIdent(t.name)})`);
242
+ out.push({
243
+ name: t.name,
244
+ columns: cols.map((c) => ({
245
+ name: c.name,
246
+ type: c.type,
247
+ notNull: !!c.notnull,
248
+ primaryKey: !!c.pk,
249
+ })),
250
+ indexes: idx.map((i) => String(i.name)),
251
+ foreignKeys: fks.map((f) => ({
252
+ column: f.from,
253
+ refTable: f.table,
254
+ refColumn: f.to ?? "",
255
+ onDelete:
256
+ f.on_delete && f.on_delete.toUpperCase() !== "NO ACTION"
257
+ ? f.on_delete.toUpperCase()
258
+ : "",
259
+ })),
260
+ });
261
+ }
262
+ return out;
263
+ }
264
+
265
+ table<T extends object = Row>(name: string, pk = "id"): Table<T> {
266
+ return new Table<T>(this, name, pk);
267
+ }
268
+ }
269
+
270
+ /** Wrap a provisioned `SqliteDb` as the record-oriented `DataSource` gateway. */
271
+ export function makeGateway(db: SqliteDb): DataSource {
272
+ return new SqliteGateway(db);
273
+ }
@@ -52,6 +52,15 @@ export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
52
52
  // Data layer
53
53
  export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
54
54
  export type { ProvisionedSource } from "./core/modules/datasource.ts";
55
+ export { makeGateway, Table } from "./core/modules/gateway.ts";
56
+ export type {
57
+ ColumnMeta,
58
+ DataSource as GatewayDataSource,
59
+ ExecResult,
60
+ ForeignKeyMeta,
61
+ Row,
62
+ TableMeta,
63
+ } from "./core/modules/gateway.ts";
55
64
  export type { AppJobHandler } from "./core/modules/workers.ts";
56
65
  export { resolveHandler } from "./core/modules/workers.ts";
57
66
  export { evalCorrelation } from "./core/modules/triggers.ts";