@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.
- package/dist/runtime/core/modules/datasource.d.ts +9 -0
- package/dist/runtime/core/modules/datasource.js +12 -0
- package/dist/runtime/core/modules/gateway.d.ts +79 -0
- package/dist/runtime/core/modules/gateway.js +169 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/package.json +1 -1
- package/src/runtime/core/modules/datasource.ts +20 -0
- package/src/runtime/core/modules/gateway.test.ts +164 -0
- package/src/runtime/core/modules/gateway.ts +273 -0
- package/src/runtime/index.ts +9 -0
|
@@ -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
|
+
}
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/runtime/index.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -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";
|