@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.
- package/dist/runtime/core/host.d.ts +4 -0
- package/dist/runtime/core/manifest.d.ts +8 -0
- 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 +81 -0
- package/dist/runtime/core/modules/gateway.js +170 -0
- package/dist/runtime/core/modules/pages.d.ts +42 -0
- package/dist/runtime/core/modules/pages.js +670 -0
- package/dist/runtime/core/modules/surfaces.js +7 -0
- package/dist/runtime/engine/nanosdk.d.ts +6 -0
- package/dist/runtime/engine/nanosdk.js +3 -0
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +2 -0
- package/package.json +1 -1
- package/src/runtime/core/host.ts +2 -0
- package/src/runtime/core/manifest.ts +8 -0
- 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 +274 -0
- package/src/runtime/core/modules/pages.test.ts +224 -0
- package/src/runtime/core/modules/pages.ts +705 -0
- package/src/runtime/core/modules/surfaces.ts +8 -0
- package/src/runtime/core/runtime.test.ts +4 -0
- package/src/runtime/engine/nanosdk.test.ts +18 -0
- package/src/runtime/engine/nanosdk.ts +8 -0
- package/src/runtime/index.ts +11 -0
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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,
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/runtime/index.js
CHANGED
|
@@ -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.
|
|
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",
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -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
|
+
}
|