@nanobpm/urban 0.21.0 → 0.22.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/README.md +35 -0
- package/dist/cli.d.ts +26 -0
- package/dist/cli.js +80 -16
- package/dist/runtime/adapters/deno.js +19 -3
- package/dist/runtime/adapters/detect.js +2 -2
- package/dist/runtime/adapters/globals.d.ts +19 -0
- package/dist/runtime/adapters/globals.js +9 -0
- package/dist/runtime/adapters/node.js +25 -5
- package/dist/runtime/core/context.d.ts +4 -0
- package/dist/runtime/core/guards.d.ts +2 -0
- package/dist/runtime/core/guards.js +10 -0
- package/dist/runtime/core/host.js +5 -4
- package/dist/runtime/core/manifest.d.ts +8 -0
- package/dist/runtime/core/manifest.js +0 -1
- package/dist/runtime/core/modules/actions.js +9 -5
- package/dist/runtime/core/modules/connectors.js +6 -4
- package/dist/runtime/core/modules/dataops.d.ts +36 -0
- package/dist/runtime/core/modules/dataops.js +231 -0
- package/dist/runtime/core/modules/datasource.d.ts +51 -1
- package/dist/runtime/core/modules/datasource.js +98 -21
- package/dist/runtime/core/modules/deploy.d.ts +3 -1
- package/dist/runtime/core/modules/deploy.js +29 -7
- package/dist/runtime/core/modules/gateway.d.ts +1 -1
- package/dist/runtime/core/modules/gateway.js +16 -17
- package/dist/runtime/core/modules/llm.js +9 -7
- package/dist/runtime/core/modules/pages.js +42 -15
- package/dist/runtime/core/modules/security.js +5 -5
- package/dist/runtime/core/modules/templates.d.ts +18 -0
- package/dist/runtime/core/modules/templates.js +133 -0
- package/dist/runtime/core/modules/triggers.js +14 -5
- package/dist/runtime/core/modules/workers.js +15 -9
- package/dist/runtime/core/runtime.d.ts +5 -0
- package/dist/runtime/core/runtime.js +5 -5
- package/dist/runtime/core/validate.d.ts +2 -1
- package/dist/runtime/core/validate.js +38 -28
- package/dist/runtime/devserver.js +2 -1
- package/dist/runtime/engine/nanosdk.d.ts +1 -1
- package/dist/runtime/engine/nanosdk.js +15 -9
- package/dist/runtime/index.d.ts +5 -0
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/run.js +10 -7
- package/dist/toolkit/addConnector.js +4 -3
- package/dist/toolkit/fsio.js +2 -1
- package/dist/toolkit/models.js +3 -5
- package/package.json +1 -1
- package/src/cli.test.ts +44 -0
- package/src/cli.ts +82 -16
- package/src/runtime/adapters/deno.ts +23 -3
- package/src/runtime/adapters/detect.ts +2 -2
- package/src/runtime/adapters/globals.ts +27 -0
- package/src/runtime/adapters/node.ts +27 -4
- package/src/runtime/connector-worker-sdk.test.ts +2 -1
- package/src/runtime/core/context.ts +4 -0
- package/src/runtime/core/guards.ts +10 -0
- package/src/runtime/core/host.ts +4 -4
- package/src/runtime/core/manifest.ts +19 -5
- package/src/runtime/core/modules/actions.test.ts +82 -31
- package/src/runtime/core/modules/actions.ts +11 -7
- package/src/runtime/core/modules/connectors.test.ts +29 -8
- package/src/runtime/core/modules/connectors.ts +6 -4
- package/src/runtime/core/modules/dataops.test.ts +306 -0
- package/src/runtime/core/modules/dataops.ts +292 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +12 -3
- package/src/runtime/core/modules/datasource.test.ts +129 -0
- package/src/runtime/core/modules/datasource.ts +105 -23
- package/src/runtime/core/modules/deploy.test.ts +258 -0
- package/src/runtime/core/modules/deploy.ts +29 -7
- package/src/runtime/core/modules/gateway.test.ts +5 -4
- package/src/runtime/core/modules/gateway.ts +29 -30
- package/src/runtime/core/modules/llm.test.ts +10 -12
- package/src/runtime/core/modules/llm.ts +11 -9
- package/src/runtime/core/modules/pages.test.ts +22 -2
- package/src/runtime/core/modules/pages.ts +45 -22
- package/src/runtime/core/modules/security.ts +5 -13
- package/src/runtime/core/modules/surfaces.test.ts +53 -10
- package/src/runtime/core/modules/templates.ts +153 -0
- package/src/runtime/core/modules/triggers.test.ts +51 -19
- package/src/runtime/core/modules/triggers.ts +14 -6
- package/src/runtime/core/modules/workers.test.ts +43 -20
- package/src/runtime/core/modules/workers.ts +18 -10
- package/src/runtime/core/runtime.test.ts +23 -10
- package/src/runtime/core/runtime.ts +15 -2
- package/src/runtime/core/validate.ts +43 -32
- package/src/runtime/devserver.test.ts +18 -5
- package/src/runtime/devserver.ts +2 -1
- package/src/runtime/engine/nanosdk.test.ts +16 -11
- package/src/runtime/engine/nanosdk.ts +22 -15
- package/src/runtime/index.ts +7 -0
- package/src/runtime/run.ts +10 -10
- package/src/toolkit/addConnector.ts +6 -5
- package/src/toolkit/derivers/messages.test.ts +4 -2
- package/src/toolkit/derivers/worker-io.test.ts +4 -2
- package/src/toolkit/fsio.ts +2 -1
- package/src/toolkit/gen.ts +4 -4
- package/src/toolkit/models.test.ts +1 -1
- package/src/toolkit/models.ts +3 -4
- package/src/toolkit/scaffold.ts +1 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// dataops — the DB-manager op protocol behind `urban data`. A single JSON request is
|
|
2
|
+
// dispatched against a manifest datasource and a single JSON result is returned; the CLI
|
|
3
|
+
// (`urban data`, cli.ts) wraps it in the `{ ok, ... }` stdin/stdout envelope. This is the
|
|
4
|
+
// runtime-agnostic core the Nano console re-points its **Data** panel (Tables / SQL /
|
|
5
|
+
// Migrations) at, replacing the vendored `nano-generated/data-cli.ts` (host dry-out #576).
|
|
6
|
+
//
|
|
7
|
+
// Every op runs THROUGH the same datasource seam the app runtime uses (`openSqliteSource` +
|
|
8
|
+
// `makeGateway`), so the panel browses exactly what the named source resolves to — never a
|
|
9
|
+
// parallel path. Read/list ops open the source WITHOUT applying migrations (opening the DB
|
|
10
|
+
// manager must not silently mutate the schema); `migrate` applies pending migrations on the
|
|
11
|
+
// shared `_urban_migrations` ledger.
|
|
12
|
+
//
|
|
13
|
+
// Request: { op, source?, sql?, params?, statements? }
|
|
14
|
+
// op = "sources" | "schema" | "query" | "exec" | "script" | "migrations" | "migrate"
|
|
15
|
+
// | "domaintypes"
|
|
16
|
+
//
|
|
17
|
+
// `domaintypes` (live-schema reification + shape resolution) is a known protocol op but is
|
|
18
|
+
// intentionally NOT handled here yet — it is ported to `urban data` in a follow-up (PR 2 of
|
|
19
|
+
// #576) and errors clearly for now.
|
|
20
|
+
import { isRecord } from "../guards.js";
|
|
21
|
+
import { loadManifest } from "../manifest.js";
|
|
22
|
+
import { validateManifest } from "../validate.js";
|
|
23
|
+
import { makeGateway } from "./gateway.js";
|
|
24
|
+
import { applyMigrations, MIGRATIONS_TABLE, openSqliteSource, resolveAppPath } from "./datasource.js";
|
|
25
|
+
const DEFAULT_MIGRATIONS_DIR = "db/migrations";
|
|
26
|
+
/** JSON-safe a value: BigInt → number (when lossless) or string, recursing objects/arrays. SQLite
|
|
27
|
+
* reports `lastInsertRowid` (and INTEGER columns) as BigInt, which `JSON.stringify` cannot encode. */
|
|
28
|
+
export function jsonSafe(v) {
|
|
29
|
+
if (typeof v === "bigint") {
|
|
30
|
+
return v >= BigInt(Number.MIN_SAFE_INTEGER) && v <= BigInt(Number.MAX_SAFE_INTEGER)
|
|
31
|
+
? Number(v)
|
|
32
|
+
: v.toString();
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(v))
|
|
35
|
+
return v.map(jsonSafe);
|
|
36
|
+
if (v && typeof v === "object") {
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const [k, val] of Object.entries(v))
|
|
39
|
+
out[k] = jsonSafe(val);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
return v;
|
|
43
|
+
}
|
|
44
|
+
/** Column order across a row set: first-seen order, unioned over every row (rows may be ragged). */
|
|
45
|
+
function columnsOf(rows) {
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const cols = [];
|
|
48
|
+
for (const r of rows) {
|
|
49
|
+
for (const k of Object.keys(r)) {
|
|
50
|
+
if (!seen.has(k)) {
|
|
51
|
+
seen.add(k);
|
|
52
|
+
cols.push(k);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return cols;
|
|
57
|
+
}
|
|
58
|
+
/** Split a script on `;` boundaries, dropping blank and comment-only fragments. Minimal DDL
|
|
59
|
+
* splitter (ADR 0024 "minimal dialect stance"); does not parse semicolons inside string literals. */
|
|
60
|
+
function splitStatements(sql) {
|
|
61
|
+
return sql
|
|
62
|
+
.split(";")
|
|
63
|
+
.map((s) => s.trim())
|
|
64
|
+
.filter((s) => s.length > 0 && !s.split("\n").every((l) => l.trim().startsWith("--")));
|
|
65
|
+
}
|
|
66
|
+
/** List every declared datasource plus the default's name (no connection opened). */
|
|
67
|
+
export function listSources(manifest) {
|
|
68
|
+
const data = manifest.data;
|
|
69
|
+
const sources = Object.entries(data?.sources ?? {}).map(([name, raw]) => ({
|
|
70
|
+
name,
|
|
71
|
+
driver: String(raw.driver),
|
|
72
|
+
url: raw.url,
|
|
73
|
+
migrations: raw.migrations,
|
|
74
|
+
}));
|
|
75
|
+
return { default: data?.default, sources };
|
|
76
|
+
}
|
|
77
|
+
/** Resolve the source a request names (the default, or the sole source, when unnamed). */
|
|
78
|
+
function pickSource(manifest, name) {
|
|
79
|
+
const data = manifest.data;
|
|
80
|
+
const sources = data?.sources ?? {};
|
|
81
|
+
const chosen = name || data?.default || Object.keys(sources)[0];
|
|
82
|
+
if (!chosen)
|
|
83
|
+
throw new Error("no data source specified and none declared in the manifest");
|
|
84
|
+
const src = sources[chosen];
|
|
85
|
+
if (!src)
|
|
86
|
+
throw new Error(`no such data source "${chosen}"`);
|
|
87
|
+
return { name: chosen, src };
|
|
88
|
+
}
|
|
89
|
+
/** Open the requested source's gateway WITHOUT applying migrations (opening the DB manager must
|
|
90
|
+
* not mutate the schema). The caller closes the returned handle. */
|
|
91
|
+
async function openGateway(host, root, manifest, name) {
|
|
92
|
+
const { name: chosen, src } = pickSource(manifest, name);
|
|
93
|
+
if (String(src.driver) !== "sqlite") {
|
|
94
|
+
throw new Error(`data source "${chosen}" uses driver "${String(src.driver)}"; only "sqlite" is implemented`);
|
|
95
|
+
}
|
|
96
|
+
let db;
|
|
97
|
+
try {
|
|
98
|
+
db = await openSqliteSource(host, root, src.url);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
// Prefix the source name onto openSqliteSource's runtime-agnostic message so the CLI/console
|
|
102
|
+
// error names which datasource failed (mirrors provisionSqlite).
|
|
103
|
+
throw new Error(`datasource "${chosen}": ${err instanceof Error ? err.message : String(err)}`);
|
|
104
|
+
}
|
|
105
|
+
return { gw: makeGateway(db), db };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Resolve a `--manifest` argument against the app `root`. An absolute path (POSIX `/abs/…`, a
|
|
109
|
+
* Windows drive-letter `C:\…` or a UNC `\\host\share\…`) is honoured as-is; a relative path is
|
|
110
|
+
* joined onto `root`. Delegates to `resolveAppPath` so manifest and datasource path resolution
|
|
111
|
+
* share one cross-platform implementation and can't drift.
|
|
112
|
+
*/
|
|
113
|
+
function resolveManifestPath(root, manifestPath) {
|
|
114
|
+
return resolveAppPath(root, manifestPath);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Dispatch one DB-manager op against the app at `root`. Returns the op's result object (the CLI
|
|
118
|
+
* wraps it as `{ ok: true, ...result }`); throws on a handled error (wrapped as `{ ok: false }`).
|
|
119
|
+
*/
|
|
120
|
+
export async function runDataOp(host, root, manifestPath, req) {
|
|
121
|
+
const manifest = validateManifest(await loadManifest(host, resolveManifestPath(root, manifestPath)));
|
|
122
|
+
switch (req.op) {
|
|
123
|
+
case "sources": {
|
|
124
|
+
const { sources, default: def } = listSources(manifest);
|
|
125
|
+
return { default: def, sources };
|
|
126
|
+
}
|
|
127
|
+
case "schema": {
|
|
128
|
+
const { gw, db } = await openGateway(host, root, manifest, req.source);
|
|
129
|
+
try {
|
|
130
|
+
return { tables: await gw.schema() };
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
db.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
case "query": {
|
|
137
|
+
const { gw, db } = await openGateway(host, root, manifest, req.source);
|
|
138
|
+
try {
|
|
139
|
+
const rows = await gw.query(req.sql ?? "", req.params ?? []);
|
|
140
|
+
const safeRows = rows.map(jsonSafe);
|
|
141
|
+
return { columns: columnsOf(rows), rows: safeRows };
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
db.close();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
case "exec": {
|
|
148
|
+
const { gw, db } = await openGateway(host, root, manifest, req.source);
|
|
149
|
+
try {
|
|
150
|
+
const result = jsonSafe(await gw.exec(req.sql ?? "", req.params ?? []));
|
|
151
|
+
return isRecord(result) ? result : {};
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
db.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
case "script": {
|
|
158
|
+
// Run several statements atomically in one transaction — the console's structure editor
|
|
159
|
+
// uses this for the SQLite 12-step table rebuild (create → copy → drop → rename), so a
|
|
160
|
+
// mid-rebuild failure rolls back and leaves the table untouched. Statements come pre-split
|
|
161
|
+
// from the caller; `?`-params are not threaded (DDL needs none).
|
|
162
|
+
const { gw, db } = await openGateway(host, root, manifest, req.source);
|
|
163
|
+
try {
|
|
164
|
+
const statements = Array.isArray(req.statements)
|
|
165
|
+
? req.statements
|
|
166
|
+
: splitStatements(req.sql ?? "");
|
|
167
|
+
let changed = 0;
|
|
168
|
+
await gw.tx(async (t) => {
|
|
169
|
+
for (const stmt of statements) {
|
|
170
|
+
if (stmt.trim())
|
|
171
|
+
changed += (await t.exec(stmt)).changed;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
return { changed };
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
db.close();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
case "migrations": {
|
|
181
|
+
const { name, src } = pickSource(manifest, req.source);
|
|
182
|
+
const relDir = src.migrations ?? DEFAULT_MIGRATIONS_DIR;
|
|
183
|
+
const absDir = resolveAppPath(root, relDir);
|
|
184
|
+
// `migrations` is a read/list op: treat a missing dir as no migrations (mirrors
|
|
185
|
+
// applyMigrations) instead of throwing on host.listDir.
|
|
186
|
+
const files = (await host.exists(absDir))
|
|
187
|
+
? (await host.listDir(absDir)).filter((f) => f.endsWith(".sql")).sort()
|
|
188
|
+
: [];
|
|
189
|
+
const { gw, db } = await openGateway(host, root, manifest, name);
|
|
190
|
+
try {
|
|
191
|
+
// Do not create the ledger table here — listing migrations must not mutate the
|
|
192
|
+
// schema. If the ledger doesn't exist yet, nothing has been applied.
|
|
193
|
+
const ledgerExists = (await gw.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='${MIGRATIONS_TABLE}'`)).length > 0;
|
|
194
|
+
const applied = ledgerExists
|
|
195
|
+
? new Map((await gw.query(`SELECT name, applied_at FROM ${MIGRATIONS_TABLE}`)).map((r) => [
|
|
196
|
+
String(r.name),
|
|
197
|
+
String(r.applied_at),
|
|
198
|
+
]))
|
|
199
|
+
: new Map();
|
|
200
|
+
return {
|
|
201
|
+
dir: relDir,
|
|
202
|
+
entries: files.map((name) => ({
|
|
203
|
+
name,
|
|
204
|
+
applied: applied.has(name),
|
|
205
|
+
appliedAt: applied.get(name) ?? null,
|
|
206
|
+
})),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
db.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
case "migrate": {
|
|
214
|
+
const { name, src } = pickSource(manifest, req.source);
|
|
215
|
+
const { db } = await openGateway(host, root, manifest, name);
|
|
216
|
+
try {
|
|
217
|
+
const applied = await applyMigrations(host, db, root, src.migrations ?? DEFAULT_MIGRATIONS_DIR);
|
|
218
|
+
return { applied, pending: 0 };
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
db.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
case "domaintypes": {
|
|
225
|
+
throw new Error("`domaintypes` is not yet implemented in `urban data` — the Nano console still serves it " +
|
|
226
|
+
"(tracked as PR 2 of nano-bpm#576)");
|
|
227
|
+
}
|
|
228
|
+
default:
|
|
229
|
+
throw new Error(`unknown op "${req.op}"`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -1,7 +1,56 @@
|
|
|
1
1
|
import type { RuntimeContext } from "../context.ts";
|
|
2
|
-
import type { SqliteDb } from "../host.ts";
|
|
2
|
+
import type { HostContext, SqliteDb } from "../host.ts";
|
|
3
3
|
import type { DomainType } from "../manifest.ts";
|
|
4
4
|
import { Table, type DataSource as GatewayDataSource } from "./gateway.ts";
|
|
5
|
+
export declare function sqlitePathFromUrl(url: string): string;
|
|
6
|
+
/** True when `p` is already an absolute path on any host `urban data` targets. `urban data` runs
|
|
7
|
+
* on a Node host that may be Windows, so absolute-path detection can't assume POSIX: this matches
|
|
8
|
+
* a POSIX root ("/…"), a Windows drive-letter root ("C:\…" or "C:/…"), a Windows drive-root path
|
|
9
|
+
* that starts with a single backslash ("\data\app.db") and a Windows UNC path
|
|
10
|
+
* ("\\\\server\\share"). A single leading backslash covers both the drive-root and UNC cases,
|
|
11
|
+
* matching Node's `path.win32.isAbsolute`. Used by `resolveAppPath` so a caller-supplied absolute
|
|
12
|
+
* path is never incorrectly prefixed with the app root. */
|
|
13
|
+
export declare function isAbsolutePath(p: string): boolean;
|
|
14
|
+
/** Resolve `p` against the app `root`: an absolute `p` (see `isAbsolutePath`) is returned as-is;
|
|
15
|
+
* a relative `p` is joined onto `root`. Trims a trailing separator of either kind off `root` so
|
|
16
|
+
* we never emit a doubled separator. The join separator matches the root's own style so we never
|
|
17
|
+
* emit a mixed-separator path (e.g. "C:\\srv\\app/app.db" or "\\\\server\\share/db\\migrations"),
|
|
18
|
+
* which Windows/UNC resolution and some tooling mishandle: a root that already uses backslashes is
|
|
19
|
+
* Windows-style and joins with "\\" — normalizing the relative segment's forward slashes to match —
|
|
20
|
+
* while everything else (POSIX, a "C:/…" forward-slash root, or a relative ".") joins with "/" —
|
|
21
|
+
* normalizing the relative segment's backslashes to match. Both `root` AND the relative segment are
|
|
22
|
+
* rewritten to the chosen separator, so the result is never mixed even when `root` itself is mixed
|
|
23
|
+
* (e.g. "C:/srv\\app") or `p` is. The single canonical implementation shared by `resolveSqlitePath`
|
|
24
|
+
* (datasource urls) and `resolveManifestPath` (`--manifest`) — and by `applyMigrations` to join each
|
|
25
|
+
* migration file onto its dir — so those path resolutions can never drift and all behave the same
|
|
26
|
+
* cross-platform. */
|
|
27
|
+
export declare function resolveAppPath(root: string, p: string): string;
|
|
28
|
+
/** Name of the SQLite ledger table that records applied migrations. The single source of truth
|
|
29
|
+
* for this identifier: `applyMigrations` writes it and `dataops`' `migrations` op reads it, so
|
|
30
|
+
* both import this constant and the ledger name can never drift between application and listing. */
|
|
31
|
+
export declare const MIGRATIONS_TABLE = "_urban_migrations";
|
|
32
|
+
/** Resolve a datasource `url` to its on-disk SQLite path against `root`. The result is absolute
|
|
33
|
+
* when the resolved path is absolute (either because `url` names an absolute path or `root` is
|
|
34
|
+
* absolute); if `root` is relative (e.g. "." as used by the CLI/tests) a relative `url` stays
|
|
35
|
+
* correspondingly relative. The single source of truth for this resolution, shared by
|
|
36
|
+
* `openSqliteSource` (to open the file) and `provisionSqlite` (to report where it provisioned),
|
|
37
|
+
* so the opened path and the logged path can never drift. */
|
|
38
|
+
export declare function resolveSqlitePath(root: string, url: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Open (creating if needed) the SQLite file a `file:`/`sqlite:` URL points at, resolved against
|
|
41
|
+
* `root`, with WAL journalling — but WITHOUT applying migrations. Provisioning (`provisionSqlite`)
|
|
42
|
+
* layers migrations on top; the `urban data` DB-manager gateway (dataops.ts) wants the raw handle
|
|
43
|
+
* so read/`migrations`-list ops don't silently mutate the schema on open.
|
|
44
|
+
*/
|
|
45
|
+
export declare function openSqliteSource(host: HostContext, root: string, url: string): Promise<SqliteDb>;
|
|
46
|
+
/** The parent directory of `path`, or `""` when it has none (a bare filename or a filesystem
|
|
47
|
+
* root, for which the existence check in `openSqliteSource` is intentionally skipped). Splits on
|
|
48
|
+
* either separator so a Windows-style absolute path (e.g. "C:\data\app.db") yields a real parent
|
|
49
|
+
* dir rather than being treated as having none. Preserves the trailing separator on a Windows
|
|
50
|
+
* drive root so "C:\app.db" -> "C:\" (and "C:/app.db" -> "C:/"), not the bare volume "C:" — the
|
|
51
|
+
* latter is a drive-relative reference, not the drive root, and would make `openSqliteSource`
|
|
52
|
+
* fail fast against the wrong location. */
|
|
53
|
+
export declare function parentDir(path: string): string;
|
|
5
54
|
/** A typed accessor over a table declared in manifest `types`. */
|
|
6
55
|
export declare class TypeRepo {
|
|
7
56
|
private readonly db;
|
|
@@ -43,5 +92,6 @@ export declare class DataLayer {
|
|
|
43
92
|
describe(): Record<string, unknown>;
|
|
44
93
|
closeAll(): void;
|
|
45
94
|
}
|
|
95
|
+
export declare function applyMigrations(host: HostContext, db: SqliteDb, root: string, migrationsDir: string): Promise<string[]>;
|
|
46
96
|
/** Provision every declared source and return the typed data layer. */
|
|
47
97
|
export declare function provisionData(ctx: RuntimeContext): Promise<DataLayer>;
|
|
@@ -3,10 +3,74 @@
|
|
|
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
5
|
import { makeGateway } from "./gateway.js";
|
|
6
|
-
function sqlitePathFromUrl(url) {
|
|
6
|
+
export function sqlitePathFromUrl(url) {
|
|
7
7
|
// Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
|
|
8
8
|
return url.replace(/^(file|sqlite):(\/\/)?/, "");
|
|
9
9
|
}
|
|
10
|
+
/** True when `p` is already an absolute path on any host `urban data` targets. `urban data` runs
|
|
11
|
+
* on a Node host that may be Windows, so absolute-path detection can't assume POSIX: this matches
|
|
12
|
+
* a POSIX root ("/…"), a Windows drive-letter root ("C:\…" or "C:/…"), a Windows drive-root path
|
|
13
|
+
* that starts with a single backslash ("\data\app.db") and a Windows UNC path
|
|
14
|
+
* ("\\\\server\\share"). A single leading backslash covers both the drive-root and UNC cases,
|
|
15
|
+
* matching Node's `path.win32.isAbsolute`. Used by `resolveAppPath` so a caller-supplied absolute
|
|
16
|
+
* path is never incorrectly prefixed with the app root. */
|
|
17
|
+
export function isAbsolutePath(p) {
|
|
18
|
+
return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
|
|
19
|
+
}
|
|
20
|
+
/** Resolve `p` against the app `root`: an absolute `p` (see `isAbsolutePath`) is returned as-is;
|
|
21
|
+
* a relative `p` is joined onto `root`. Trims a trailing separator of either kind off `root` so
|
|
22
|
+
* we never emit a doubled separator. The join separator matches the root's own style so we never
|
|
23
|
+
* emit a mixed-separator path (e.g. "C:\\srv\\app/app.db" or "\\\\server\\share/db\\migrations"),
|
|
24
|
+
* which Windows/UNC resolution and some tooling mishandle: a root that already uses backslashes is
|
|
25
|
+
* Windows-style and joins with "\\" — normalizing the relative segment's forward slashes to match —
|
|
26
|
+
* while everything else (POSIX, a "C:/…" forward-slash root, or a relative ".") joins with "/" —
|
|
27
|
+
* normalizing the relative segment's backslashes to match. Both `root` AND the relative segment are
|
|
28
|
+
* rewritten to the chosen separator, so the result is never mixed even when `root` itself is mixed
|
|
29
|
+
* (e.g. "C:/srv\\app") or `p` is. The single canonical implementation shared by `resolveSqlitePath`
|
|
30
|
+
* (datasource urls) and `resolveManifestPath` (`--manifest`) — and by `applyMigrations` to join each
|
|
31
|
+
* migration file onto its dir — so those path resolutions can never drift and all behave the same
|
|
32
|
+
* cross-platform. */
|
|
33
|
+
export function resolveAppPath(root, p) {
|
|
34
|
+
if (isAbsolutePath(p))
|
|
35
|
+
return p;
|
|
36
|
+
const sep = root.includes("\\") ? "\\" : "/";
|
|
37
|
+
const norm = (s) => (sep === "\\" ? s.replace(/\//g, "\\") : s.replace(/\\/g, "/"));
|
|
38
|
+
const base = norm(root).replace(/[/\\]+$/, "");
|
|
39
|
+
const rel = norm(p);
|
|
40
|
+
return `${base}${sep}${rel}`;
|
|
41
|
+
}
|
|
42
|
+
/** Name of the SQLite ledger table that records applied migrations. The single source of truth
|
|
43
|
+
* for this identifier: `applyMigrations` writes it and `dataops`' `migrations` op reads it, so
|
|
44
|
+
* both import this constant and the ledger name can never drift between application and listing. */
|
|
45
|
+
export const MIGRATIONS_TABLE = "_urban_migrations";
|
|
46
|
+
/** Resolve a datasource `url` to its on-disk SQLite path against `root`. The result is absolute
|
|
47
|
+
* when the resolved path is absolute (either because `url` names an absolute path or `root` is
|
|
48
|
+
* absolute); if `root` is relative (e.g. "." as used by the CLI/tests) a relative `url` stays
|
|
49
|
+
* correspondingly relative. The single source of truth for this resolution, shared by
|
|
50
|
+
* `openSqliteSource` (to open the file) and `provisionSqlite` (to report where it provisioned),
|
|
51
|
+
* so the opened path and the logged path can never drift. */
|
|
52
|
+
export function resolveSqlitePath(root, url) {
|
|
53
|
+
return resolveAppPath(root, sqlitePathFromUrl(url));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Open (creating if needed) the SQLite file a `file:`/`sqlite:` URL points at, resolved against
|
|
57
|
+
* `root`, with WAL journalling — but WITHOUT applying migrations. Provisioning (`provisionSqlite`)
|
|
58
|
+
* layers migrations on top; the `urban data` DB-manager gateway (dataops.ts) wants the raw handle
|
|
59
|
+
* so read/`migrations`-list ops don't silently mutate the schema on open.
|
|
60
|
+
*/
|
|
61
|
+
export async function openSqliteSource(host, root, url) {
|
|
62
|
+
const abs = resolveSqlitePath(root, url);
|
|
63
|
+
const dir = parentDir(abs);
|
|
64
|
+
if (dir && !(await host.exists(dir))) {
|
|
65
|
+
// The host API doesn't expose mkdir and openSqlite won't create parent dirs,
|
|
66
|
+
// so fail fast with a clear, actionable message instead of a low-level
|
|
67
|
+
// "cannot open" error.
|
|
68
|
+
throw new Error(`directory "${dir}" does not exist — create it before running (the SQLite file cannot be opened otherwise)`);
|
|
69
|
+
}
|
|
70
|
+
const db = host.openSqlite(abs);
|
|
71
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
72
|
+
return db;
|
|
73
|
+
}
|
|
10
74
|
/** A safe unquoted SQL identifier. Table/column names are interpolated directly into SQL,
|
|
11
75
|
* so reject anything that isn't a plain identifier to prevent invalid SQL / injection. */
|
|
12
76
|
const SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -16,9 +80,22 @@ function assertSqlIdent(kind, name) {
|
|
|
16
80
|
}
|
|
17
81
|
return name;
|
|
18
82
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
83
|
+
/** The parent directory of `path`, or `""` when it has none (a bare filename or a filesystem
|
|
84
|
+
* root, for which the existence check in `openSqliteSource` is intentionally skipped). Splits on
|
|
85
|
+
* either separator so a Windows-style absolute path (e.g. "C:\data\app.db") yields a real parent
|
|
86
|
+
* dir rather than being treated as having none. Preserves the trailing separator on a Windows
|
|
87
|
+
* drive root so "C:\app.db" -> "C:\" (and "C:/app.db" -> "C:/"), not the bare volume "C:" — the
|
|
88
|
+
* latter is a drive-relative reference, not the drive root, and would make `openSqliteSource`
|
|
89
|
+
* fail fast against the wrong location. */
|
|
90
|
+
export function parentDir(path) {
|
|
91
|
+
const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
92
|
+
if (i <= 0)
|
|
93
|
+
return "";
|
|
94
|
+
// A Windows drive root ("C:\…" / "C:/…"): the separator sits right after the "C:" volume, so
|
|
95
|
+
// keep it — dropping it would yield the drive-relative "C:" rather than the drive root.
|
|
96
|
+
if (i === 2 && /^[A-Za-z]:$/.test(path.slice(0, 2)))
|
|
97
|
+
return path.slice(0, i + 1);
|
|
98
|
+
return path.slice(0, i);
|
|
22
99
|
}
|
|
23
100
|
/** A typed accessor over a table declared in manifest `types`. */
|
|
24
101
|
export class TypeRepo {
|
|
@@ -128,18 +205,21 @@ export class DataLayer {
|
|
|
128
205
|
s.close();
|
|
129
206
|
}
|
|
130
207
|
}
|
|
131
|
-
async function applyMigrations(host, db, root, migrationsDir) {
|
|
132
|
-
const dir =
|
|
208
|
+
export async function applyMigrations(host, db, root, migrationsDir) {
|
|
209
|
+
const dir = resolveAppPath(root, migrationsDir);
|
|
133
210
|
if (!(await host.exists(dir)))
|
|
134
211
|
return [];
|
|
135
212
|
const files = (await host.listDir(dir)).filter((f) => f.endsWith(".sql")).sort();
|
|
136
|
-
db.exec(
|
|
137
|
-
const applied = new Set(db.all(
|
|
213
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`);
|
|
214
|
+
const applied = new Set(db.all(`SELECT name FROM ${MIGRATIONS_TABLE}`).map((r) => r.name));
|
|
138
215
|
const newlyApplied = [];
|
|
139
216
|
for (const file of files) {
|
|
140
217
|
if (applied.has(file))
|
|
141
218
|
continue;
|
|
142
|
-
|
|
219
|
+
// Join via `resolveAppPath` (not a literal "/") so the migration file path adopts `dir`'s own
|
|
220
|
+
// separator style; a Windows/UNC-style `dir` (backslashes) would otherwise reintroduce a
|
|
221
|
+
// mixed-separator path (e.g. "C:\\app\\db\\migrations/001.sql") that breaks reads on Windows.
|
|
222
|
+
const sql = await host.readTextFile(resolveAppPath(dir, file));
|
|
143
223
|
// Apply the migration and record it in the ledger atomically. SQLite DDL is
|
|
144
224
|
// transactional, so wrapping both in one BEGIN/COMMIT makes a migration all-or-nothing:
|
|
145
225
|
// either the schema change AND its `_urban_migrations` row commit together, or neither
|
|
@@ -150,7 +230,7 @@ async function applyMigrations(host, db, root, migrationsDir) {
|
|
|
150
230
|
db.exec("BEGIN");
|
|
151
231
|
try {
|
|
152
232
|
db.exec(sql);
|
|
153
|
-
db.run(
|
|
233
|
+
db.run(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES (?, ?)`, [
|
|
154
234
|
file,
|
|
155
235
|
new Date(host.now()).toISOString(),
|
|
156
236
|
]);
|
|
@@ -165,23 +245,20 @@ async function applyMigrations(host, db, root, migrationsDir) {
|
|
|
165
245
|
return newlyApplied;
|
|
166
246
|
}
|
|
167
247
|
async function provisionSqlite(ctx, name, src) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
throw new Error(`datasource "${name}": directory "${dir}" does not exist — create it before running (the SQLite file cannot be opened otherwise)`);
|
|
248
|
+
let db;
|
|
249
|
+
try {
|
|
250
|
+
db = await openSqliteSource(ctx.host, ctx.root, src.url);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
// Prefix the source name onto openSqliteSource's runtime-agnostic message.
|
|
254
|
+
throw new Error(`datasource "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
176
255
|
}
|
|
177
|
-
const db = ctx.host.openSqlite(abs);
|
|
178
|
-
db.exec("PRAGMA journal_mode=WAL");
|
|
179
256
|
const migrationsApplied = src.migrations
|
|
180
257
|
? await applyMigrations(ctx.host, db, ctx.root, src.migrations)
|
|
181
258
|
: [];
|
|
182
259
|
ctx.host.log("info", `datasource: provisioned "${name}"`, {
|
|
183
260
|
driver: "sqlite",
|
|
184
|
-
path:
|
|
261
|
+
path: resolveSqlitePath(ctx.root, src.url),
|
|
185
262
|
migrationsApplied,
|
|
186
263
|
});
|
|
187
264
|
return {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { RuntimeContext } from "../context.ts";
|
|
2
|
-
/** Deploy all processes, decisions and forms declared under `models`.
|
|
2
|
+
/** Deploy all processes, decisions and forms declared under `models`. Each resource has its
|
|
3
|
+
* `{{name}}` placeholders substituted (manifest `models.templates`, then the programmatic
|
|
4
|
+
* `ctx.templates`, the latter winning) before it is sent to the engine. */
|
|
3
5
|
export declare function deployModels(ctx: RuntimeContext): Promise<{
|
|
4
6
|
deployed: number;
|
|
5
7
|
files: string[];
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// deploy — resolve the manifest `models` patterns and deploy the resources to the engine
|
|
1
|
+
// deploy — resolve the manifest `models` patterns and deploy the resources to the engine,
|
|
2
|
+
// substituting any `{{name}}` templates into each resource first (see templates.ts).
|
|
2
3
|
import { expandPatterns } from "../glob.js";
|
|
4
|
+
import { applyTemplates, resolveTemplates } from "./templates.js";
|
|
3
5
|
function contentTypeFor(path) {
|
|
4
6
|
if (path.endsWith(".bpmn"))
|
|
5
7
|
return "text/xml";
|
|
@@ -13,7 +15,9 @@ function baseName(path) {
|
|
|
13
15
|
const i = path.lastIndexOf("/");
|
|
14
16
|
return i >= 0 ? path.slice(i + 1) : path;
|
|
15
17
|
}
|
|
16
|
-
/** Deploy all processes, decisions and forms declared under `models`.
|
|
18
|
+
/** Deploy all processes, decisions and forms declared under `models`. Each resource has its
|
|
19
|
+
* `{{name}}` placeholders substituted (manifest `models.templates`, then the programmatic
|
|
20
|
+
* `ctx.templates`, the latter winning) before it is sent to the engine. */
|
|
17
21
|
export async function deployModels(ctx) {
|
|
18
22
|
const models = ctx.manifest.models ?? {};
|
|
19
23
|
const patterns = [
|
|
@@ -26,11 +30,29 @@ export async function deployModels(ctx) {
|
|
|
26
30
|
ctx.host.log("info", "deploy: no model files matched", { patterns });
|
|
27
31
|
return { deployed: 0, files: [] };
|
|
28
32
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
// Resolve templates once (manifest globs/dirs, then the programmatic deploy option). An empty
|
|
34
|
+
// map makes substitution a no-op, so apps that use no templates are unaffected.
|
|
35
|
+
const templates = await resolveTemplates(ctx.host, ctx.root, [models.templates, ctx.templates]);
|
|
36
|
+
const hasTemplates = Object.keys(templates).length > 0;
|
|
37
|
+
const resources = await Promise.all(files.map(async (path) => {
|
|
38
|
+
const contentType = contentTypeFor(path);
|
|
39
|
+
let content = await ctx.host.readTextFile(path);
|
|
40
|
+
// Only substitute into the model content types we know how to escape (XML for .bpmn/.dmn,
|
|
41
|
+
// JSON for .form). Unknown extensions map to application/octet-stream; running the default
|
|
42
|
+
// XML escaper over them could deterministically corrupt a non-XML/JSON resource that a
|
|
43
|
+
// manifest pattern matched by accident, so leave those files untouched.
|
|
44
|
+
if (hasTemplates && contentType !== "application/octet-stream") {
|
|
45
|
+
const applied = applyTemplates(content, contentType, templates);
|
|
46
|
+
content = applied.content;
|
|
47
|
+
if (applied.unresolved.length > 0) {
|
|
48
|
+
ctx.host.log("warn", "deploy: unresolved template placeholders", {
|
|
49
|
+
file: path,
|
|
50
|
+
unresolved: applied.unresolved,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { name: baseName(path), content, contentType };
|
|
55
|
+
}));
|
|
34
56
|
const { deployed } = await ctx.engine.deployResources(resources);
|
|
35
57
|
ctx.host.log("info", "deploy: models deployed", { deployed, files });
|
|
36
58
|
return { deployed, files };
|
|
@@ -35,7 +35,7 @@ export interface TableMeta {
|
|
|
35
35
|
* interchangeable. */
|
|
36
36
|
export interface DataSource {
|
|
37
37
|
/** Run a SELECT (or any row-returning statement) and collect the rows. */
|
|
38
|
-
query(sql: string, params?: unknown[]): Promise<
|
|
38
|
+
query<T extends object = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
39
39
|
/** Run a non-row statement (INSERT/UPDATE/DELETE/DDL). */
|
|
40
40
|
exec(sql: string, params?: unknown[]): Promise<ExecResult>;
|
|
41
41
|
/** Run `fn` inside a transaction, committing on success and rolling back on throw. The
|
|
@@ -19,12 +19,11 @@ export function quoteIdent(name) {
|
|
|
19
19
|
* yields an empty clause (matches all rows). Takes `object` (not `Row`) so a `Partial<T>` for a
|
|
20
20
|
* generated `interface` row type (which lacks a string index signature) is accepted. */
|
|
21
21
|
function whereClause(where) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
if (keys.length === 0)
|
|
22
|
+
const entries = Object.entries(where);
|
|
23
|
+
if (entries.length === 0)
|
|
25
24
|
return { clause: "", params: [] };
|
|
26
|
-
const clause = " WHERE " +
|
|
27
|
-
return { clause, params:
|
|
25
|
+
const clause = " WHERE " + entries.map(([k]) => `${quoteIdent(k)} = ?`).join(" AND ");
|
|
26
|
+
return { clause, params: entries.map(([, v]) => v) };
|
|
28
27
|
}
|
|
29
28
|
/** A typed gateway over a single table — the record-oriented data object a handler binds to
|
|
30
29
|
* instead of hand-writing SQL (the Delphi `TTable`/data-module idea, ADR 0029 §6). It builds
|
|
@@ -45,16 +44,16 @@ export class Table {
|
|
|
45
44
|
* `DEFAULT`/`NULL` governs — `undefined` means "not provided, let the schema decide", never
|
|
46
45
|
* a bound value. An explicit `null` is preserved (it stores `NULL`). */
|
|
47
46
|
async insert(row) {
|
|
48
|
-
const provided = Object.
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
47
|
+
const provided = Object.entries(row);
|
|
48
|
+
const entries = provided.filter(([, v]) => v !== undefined);
|
|
49
|
+
if (entries.length === 0) {
|
|
51
50
|
throw new Error(provided.length === 0
|
|
52
51
|
? `Table(${this.name}).insert: no columns to insert (empty row)`
|
|
53
52
|
: `Table(${this.name}).insert: no columns to insert (all values were undefined)`);
|
|
54
53
|
}
|
|
55
|
-
const cols =
|
|
56
|
-
const ph =
|
|
57
|
-
const r = await this.#src.exec(`INSERT INTO ${quoteIdent(this.name)} (${cols}) VALUES (${ph})`,
|
|
54
|
+
const cols = entries.map(([k]) => quoteIdent(k)).join(", ");
|
|
55
|
+
const ph = entries.map(() => "?").join(", ");
|
|
56
|
+
const r = await this.#src.exec(`INSERT INTO ${quoteIdent(this.name)} (${cols}) VALUES (${ph})`, entries.map(([, v]) => v));
|
|
58
57
|
if (r.lastInsertId == null) {
|
|
59
58
|
// The driver reported no rowid — treat as a failed/ambiguous insert rather than
|
|
60
59
|
// silently returning 0, which a caller could mistake for a real primary key.
|
|
@@ -72,12 +71,12 @@ export class Table {
|
|
|
72
71
|
const lim = typeof limit === "number" && Number.isFinite(limit)
|
|
73
72
|
? ` LIMIT ${Math.max(0, Math.floor(limit))}`
|
|
74
73
|
: "";
|
|
75
|
-
return
|
|
74
|
+
return this.#src.query(`SELECT * FROM ${quoteIdent(this.name)}${lim}`);
|
|
76
75
|
}
|
|
77
76
|
/** Rows matching an equality filter (keys ANDed). An empty filter matches all rows. */
|
|
78
77
|
async find(where = {}) {
|
|
79
78
|
const { clause, params } = whereClause(where);
|
|
80
|
-
return
|
|
79
|
+
return this.#src.query(`SELECT * FROM ${quoteIdent(this.name)}${clause}`, params);
|
|
81
80
|
}
|
|
82
81
|
/** The first row matching an equality filter, or `undefined`. */
|
|
83
82
|
async findOne(where = {}) {
|
|
@@ -89,11 +88,11 @@ export class Table {
|
|
|
89
88
|
* `undefined` are skipped (that column is left unchanged); an explicit `null` clears the
|
|
90
89
|
* column to `NULL`. A patch with no defined keys is a no-op. */
|
|
91
90
|
async update(id, patch) {
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
91
|
+
const entries = Object.entries(patch).filter(([, v]) => v !== undefined);
|
|
92
|
+
if (entries.length === 0)
|
|
94
93
|
return 0;
|
|
95
|
-
const set =
|
|
96
|
-
const r = await this.#src.exec(`UPDATE ${quoteIdent(this.name)} SET ${set} WHERE ${quoteIdent(this.pk)} = ?`, [...
|
|
94
|
+
const set = entries.map(([k]) => `${quoteIdent(k)} = ?`).join(", ");
|
|
95
|
+
const r = await this.#src.exec(`UPDATE ${quoteIdent(this.name)} SET ${set} WHERE ${quoteIdent(this.pk)} = ?`, [...entries.map(([, v]) => v), id]);
|
|
97
96
|
return r.changed;
|
|
98
97
|
}
|
|
99
98
|
/** Delete the row with the given primary key; returns rows changed. */
|