@nanobpm/urban 0.20.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.
Files changed (97) hide show
  1. package/README.md +35 -0
  2. package/dist/cli.d.ts +26 -0
  3. package/dist/cli.js +80 -16
  4. package/dist/runtime/adapters/deno.js +19 -3
  5. package/dist/runtime/adapters/detect.js +2 -2
  6. package/dist/runtime/adapters/globals.d.ts +19 -0
  7. package/dist/runtime/adapters/globals.js +9 -0
  8. package/dist/runtime/adapters/node.js +25 -5
  9. package/dist/runtime/core/context.d.ts +4 -0
  10. package/dist/runtime/core/guards.d.ts +2 -0
  11. package/dist/runtime/core/guards.js +10 -0
  12. package/dist/runtime/core/host.js +5 -4
  13. package/dist/runtime/core/manifest.d.ts +8 -0
  14. package/dist/runtime/core/manifest.js +0 -1
  15. package/dist/runtime/core/modules/actions.js +9 -5
  16. package/dist/runtime/core/modules/connectors.js +6 -4
  17. package/dist/runtime/core/modules/dataops.d.ts +36 -0
  18. package/dist/runtime/core/modules/dataops.js +231 -0
  19. package/dist/runtime/core/modules/datasource.d.ts +51 -1
  20. package/dist/runtime/core/modules/datasource.js +98 -21
  21. package/dist/runtime/core/modules/deploy.d.ts +3 -1
  22. package/dist/runtime/core/modules/deploy.js +29 -7
  23. package/dist/runtime/core/modules/gateway.d.ts +1 -1
  24. package/dist/runtime/core/modules/gateway.js +16 -17
  25. package/dist/runtime/core/modules/llm.js +9 -7
  26. package/dist/runtime/core/modules/pages.js +86 -20
  27. package/dist/runtime/core/modules/security.js +5 -5
  28. package/dist/runtime/core/modules/templates.d.ts +18 -0
  29. package/dist/runtime/core/modules/templates.js +133 -0
  30. package/dist/runtime/core/modules/triggers.js +14 -5
  31. package/dist/runtime/core/modules/workers.js +15 -9
  32. package/dist/runtime/core/runtime.d.ts +5 -0
  33. package/dist/runtime/core/runtime.js +5 -5
  34. package/dist/runtime/core/validate.d.ts +2 -1
  35. package/dist/runtime/core/validate.js +38 -28
  36. package/dist/runtime/devserver.js +2 -1
  37. package/dist/runtime/engine/nanosdk.d.ts +1 -1
  38. package/dist/runtime/engine/nanosdk.js +15 -9
  39. package/dist/runtime/index.d.ts +5 -0
  40. package/dist/runtime/index.js +4 -0
  41. package/dist/runtime/run.js +10 -7
  42. package/dist/toolkit/addConnector.js +4 -3
  43. package/dist/toolkit/fsio.js +2 -1
  44. package/dist/toolkit/models.js +3 -5
  45. package/package.json +1 -1
  46. package/src/cli.test.ts +44 -0
  47. package/src/cli.ts +82 -16
  48. package/src/runtime/adapters/deno.ts +23 -3
  49. package/src/runtime/adapters/detect.ts +2 -2
  50. package/src/runtime/adapters/globals.ts +27 -0
  51. package/src/runtime/adapters/node.ts +27 -4
  52. package/src/runtime/connector-worker-sdk.test.ts +2 -1
  53. package/src/runtime/core/context.ts +4 -0
  54. package/src/runtime/core/guards.ts +10 -0
  55. package/src/runtime/core/host.ts +4 -4
  56. package/src/runtime/core/manifest.ts +19 -5
  57. package/src/runtime/core/modules/actions.test.ts +82 -31
  58. package/src/runtime/core/modules/actions.ts +11 -7
  59. package/src/runtime/core/modules/connectors.test.ts +29 -8
  60. package/src/runtime/core/modules/connectors.ts +6 -4
  61. package/src/runtime/core/modules/dataops.test.ts +306 -0
  62. package/src/runtime/core/modules/dataops.ts +292 -0
  63. package/src/runtime/core/modules/datasource.migrations.test.ts +12 -3
  64. package/src/runtime/core/modules/datasource.test.ts +129 -0
  65. package/src/runtime/core/modules/datasource.ts +105 -23
  66. package/src/runtime/core/modules/deploy.test.ts +258 -0
  67. package/src/runtime/core/modules/deploy.ts +29 -7
  68. package/src/runtime/core/modules/gateway.test.ts +5 -4
  69. package/src/runtime/core/modules/gateway.ts +29 -30
  70. package/src/runtime/core/modules/llm.test.ts +10 -12
  71. package/src/runtime/core/modules/llm.ts +11 -9
  72. package/src/runtime/core/modules/pages.test.ts +38 -2
  73. package/src/runtime/core/modules/pages.ts +89 -27
  74. package/src/runtime/core/modules/security.ts +5 -13
  75. package/src/runtime/core/modules/surfaces.test.ts +53 -10
  76. package/src/runtime/core/modules/templates.ts +153 -0
  77. package/src/runtime/core/modules/triggers.test.ts +51 -19
  78. package/src/runtime/core/modules/triggers.ts +14 -6
  79. package/src/runtime/core/modules/workers.test.ts +43 -20
  80. package/src/runtime/core/modules/workers.ts +18 -10
  81. package/src/runtime/core/runtime.test.ts +23 -10
  82. package/src/runtime/core/runtime.ts +15 -2
  83. package/src/runtime/core/validate.ts +43 -32
  84. package/src/runtime/devserver.test.ts +18 -5
  85. package/src/runtime/devserver.ts +2 -1
  86. package/src/runtime/engine/nanosdk.test.ts +16 -11
  87. package/src/runtime/engine/nanosdk.ts +22 -15
  88. package/src/runtime/index.ts +7 -0
  89. package/src/runtime/run.ts +10 -10
  90. package/src/toolkit/addConnector.ts +6 -5
  91. package/src/toolkit/derivers/messages.test.ts +4 -2
  92. package/src/toolkit/derivers/worker-io.test.ts +4 -2
  93. package/src/toolkit/fsio.ts +2 -1
  94. package/src/toolkit/gen.ts +4 -4
  95. package/src/toolkit/models.test.ts +1 -1
  96. package/src/toolkit/models.ts +3 -4
  97. package/src/toolkit/scaffold.ts +1 -1
@@ -0,0 +1,306 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, mkdir, writeFile, 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 { isRecord } from "../guards.ts";
8
+ import { runDataOp } from "./dataops.ts";
9
+
10
+ // A project fixture: nano.app.json declaring one sqlite source with a migrations dir, plus a
11
+ // couple of migration files. runDataOp is called with root "." against a host anchored at `dir`
12
+ // (the same pattern `urban data` uses in the CLI), so paths resolve inside the fixture.
13
+ async function fixture(): Promise<{ dir: string; cleanup: () => Promise<void> }> {
14
+ const dir = await mkdtemp(join(tmpdir(), "urban-dataops-"));
15
+ await mkdir(join(dir, "db", "migrations"), { recursive: true });
16
+ await writeFile(
17
+ join(dir, "nano.app.json"),
18
+ JSON.stringify({
19
+ schemaVersion: 1,
20
+ id: "shop",
21
+ name: "Shop",
22
+ data: {
23
+ default: "app",
24
+ sources: { app: { driver: "sqlite", url: "file:./db/app.db", migrations: "db/migrations" } },
25
+ },
26
+ }),
27
+ );
28
+ await writeFile(
29
+ join(dir, "db", "migrations", "001_orders.sql"),
30
+ "CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER NOT NULL);",
31
+ );
32
+ await writeFile(
33
+ join(dir, "db", "migrations", "002_customers.sql"),
34
+ "CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, order_id INTEGER REFERENCES orders(id));",
35
+ );
36
+ return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
37
+ }
38
+
39
+ const run = (dir: string, req: Parameters<typeof runDataOp>[3]) =>
40
+ runDataOp(createNodeHost({ cwd: dir }), ".", "nano.app.json", req);
41
+
42
+ function expectRecords(value: unknown): Record<string, unknown>[] {
43
+ if (!Array.isArray(value) || !value.every(isRecord)) {
44
+ throw new TypeError("expected an array of records");
45
+ }
46
+ return value;
47
+ }
48
+
49
+ test("sources lists declared datasources and the default", async () => {
50
+ const { dir, cleanup } = await fixture();
51
+ try {
52
+ const r = await run(dir, { op: "sources" });
53
+ assert.equal(r.default, "app");
54
+ assert.deepEqual(r.sources, [
55
+ { name: "app", driver: "sqlite", url: "file:./db/app.db", migrations: "db/migrations" },
56
+ ]);
57
+ } finally {
58
+ await cleanup();
59
+ }
60
+ });
61
+
62
+ test("migrations lists pending files, migrate applies them, then schema reflects them", async () => {
63
+ const { dir, cleanup } = await fixture();
64
+ try {
65
+ // Before migrating: both files pending, schema empty.
66
+ const before = await run(dir, { op: "migrations" });
67
+ assert.equal(before.dir, "db/migrations");
68
+ const beforeEntries = expectRecords(before.entries);
69
+ assert.deepEqual(
70
+ beforeEntries.map((e) => [e.name, e.applied]),
71
+ [["001_orders.sql", false], ["002_customers.sql", false]],
72
+ );
73
+ assert.deepEqual(await run(dir, { op: "schema" }), { tables: [] });
74
+
75
+ // migrate applies both in filename order.
76
+ const migrated = await run(dir, { op: "migrate" });
77
+ assert.deepEqual(migrated, { applied: ["001_orders.sql", "002_customers.sql"], pending: 0 });
78
+
79
+ // migrations now reports them applied (with timestamps).
80
+ const after = await run(dir, { op: "migrations" });
81
+ assert.ok(expectRecords(after.entries).every((e) => e.applied && typeof e.appliedAt === "string"));
82
+
83
+ // A second migrate is idempotent.
84
+ assert.deepEqual(await run(dir, { op: "migrate" }), { applied: [], pending: 0 });
85
+
86
+ // schema introspects the live DB — the migrations ledger table is excluded, FKs surface.
87
+ const schema = await run(dir, { op: "schema" });
88
+ const tables = expectRecords(schema.tables);
89
+ assert.deepEqual(tables.map((t) => t.name), ["customers", "orders"]);
90
+ const customers = tables.find((t) => t.name === "customers");
91
+ assert.ok(customers);
92
+ assert.deepEqual(customers.foreignKeys, [
93
+ { column: "order_id", refTable: "orders", refColumn: "id", onDelete: "" },
94
+ ]);
95
+ } finally {
96
+ await cleanup();
97
+ }
98
+ });
99
+
100
+ test("migrations honours an absolute migrations dir (root is not wrongly prefixed)", async () => {
101
+ // A manifest may declare an absolute migrations path (e.g. a Node host pointing at a shared
102
+ // location). It must be used as-is, not joined onto the app root — this guards the
103
+ // `resolveAppPath` routing in both the `migrations` op and `applyMigrations`.
104
+ const dir = await mkdtemp(join(tmpdir(), "urban-dataops-abs-"));
105
+ try {
106
+ const absMigrations = join(dir, "db", "migrations");
107
+ await mkdir(absMigrations, { recursive: true });
108
+ await writeFile(
109
+ join(dir, "nano.app.json"),
110
+ JSON.stringify({
111
+ schemaVersion: 1,
112
+ id: "shop",
113
+ name: "Shop",
114
+ data: {
115
+ default: "app",
116
+ sources: {
117
+ app: { driver: "sqlite", url: "file:./db/app.db", migrations: absMigrations },
118
+ },
119
+ },
120
+ }),
121
+ );
122
+ await writeFile(
123
+ join(absMigrations, "001_orders.sql"),
124
+ "CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER NOT NULL);",
125
+ );
126
+
127
+ const before = await run(dir, { op: "migrations" });
128
+ assert.equal(before.dir, absMigrations);
129
+ const beforeEntries = expectRecords(before.entries);
130
+ assert.deepEqual(
131
+ beforeEntries.map((e) => [e.name, e.applied]),
132
+ [["001_orders.sql", false]],
133
+ );
134
+
135
+ const migrated = await run(dir, { op: "migrate" });
136
+ assert.deepEqual(migrated, { applied: ["001_orders.sql"], pending: 0 });
137
+ } finally {
138
+ await rm(dir, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ test("exec then query round-trips rows with JSON-safe values", async () => {
143
+ const { dir, cleanup } = await fixture();
144
+ try {
145
+ await run(dir, { op: "migrate" });
146
+ const ins = await run(dir, {
147
+ op: "exec",
148
+ sql: "INSERT INTO orders (id, total) VALUES (?, ?)",
149
+ params: [1, 4200],
150
+ });
151
+ assert.equal(ins.changed, 1);
152
+ assert.equal(ins.lastInsertId, 1);
153
+
154
+ const q = await run(dir, { op: "query", sql: "SELECT id, total FROM orders" });
155
+ assert.deepEqual(q.columns, ["id", "total"]);
156
+ assert.deepEqual(q.rows, [{ id: 1, total: 4200 }]);
157
+ } finally {
158
+ await cleanup();
159
+ }
160
+ });
161
+
162
+ test("script runs statements atomically and rolls back on failure", async () => {
163
+ const { dir, cleanup } = await fixture();
164
+ try {
165
+ await run(dir, { op: "migrate" });
166
+ await run(dir, { op: "exec", sql: "INSERT INTO orders (id, total) VALUES (1, 10)" });
167
+
168
+ // A failing script (second statement is invalid) rolls back the first.
169
+ await assert.rejects(
170
+ run(dir, {
171
+ op: "script",
172
+ statements: ["UPDATE orders SET total = 99 WHERE id = 1", "UPDATE nope SET x = 1"],
173
+ }),
174
+ );
175
+ const after = await run(dir, { op: "query", sql: "SELECT total FROM orders WHERE id = 1" });
176
+ assert.equal(expectRecords(after.rows)[0].total, 10, "the first UPDATE was rolled back");
177
+
178
+ // A valid multi-statement script commits and reports the changed-row count.
179
+ const ok = await run(dir, {
180
+ op: "script",
181
+ statements: ["UPDATE orders SET total = 20 WHERE id = 1"],
182
+ });
183
+ assert.equal(ok.changed, 1);
184
+ } finally {
185
+ await cleanup();
186
+ }
187
+ });
188
+
189
+ test("opening a read op does not apply migrations (schema stays empty until migrate)", async () => {
190
+ const { dir, cleanup } = await fixture();
191
+ try {
192
+ // `schema`/`query` must not silently migrate — the DB manager opens the panel read-only.
193
+ assert.deepEqual(await run(dir, { op: "schema" }), { tables: [] });
194
+ const mig = await run(dir, { op: "migrations" });
195
+ assert.ok(expectRecords(mig.entries).every((e) => !e.applied), "listing migrations did not apply them");
196
+ } finally {
197
+ await cleanup();
198
+ }
199
+ });
200
+
201
+ test("listing migrations is side-effect-free (does not create the ledger table)", async () => {
202
+ const { dir, cleanup } = await fixture();
203
+ try {
204
+ // Listing must not mutate the schema — the ledger table stays absent until `migrate`.
205
+ await run(dir, { op: "migrations" });
206
+ const ledger = await run(dir, {
207
+ op: "query",
208
+ sql: "SELECT name FROM sqlite_master WHERE type='table' AND name='_urban_migrations'",
209
+ });
210
+ assert.deepEqual(ledger.rows, [], "listing migrations must not create the ledger table");
211
+ } finally {
212
+ await cleanup();
213
+ }
214
+ });
215
+
216
+ test("migrations treats a missing migrations dir as an empty list", async () => {
217
+ const { dir, cleanup } = await fixture();
218
+ try {
219
+ await rm(join(dir, "db", "migrations"), { recursive: true, force: true });
220
+ const mig = await run(dir, { op: "migrations" });
221
+ assert.equal(mig.dir, "db/migrations");
222
+ assert.deepEqual(mig.entries, [], "a missing dir lists no migrations instead of throwing");
223
+ } finally {
224
+ await cleanup();
225
+ }
226
+ });
227
+
228
+ test("domaintypes is not yet implemented and errors clearly", async () => {
229
+ const { dir, cleanup } = await fixture();
230
+ try {
231
+ await assert.rejects(run(dir, { op: "domaintypes" }), /not yet implemented/);
232
+ } finally {
233
+ await cleanup();
234
+ }
235
+ });
236
+
237
+ test("an unknown op and an unknown source error", async () => {
238
+ const { dir, cleanup } = await fixture();
239
+ try {
240
+ // "nope" is deliberately not a member of DataOp — parse past the compile-time union to
241
+ // exercise runDataOp's runtime `default` guard against unknown ops reaching it (e.g. a
242
+ // hand-rolled JSON request off the wire).
243
+ const badRequest: Parameters<typeof runDataOp>[3] = JSON.parse('{"op":"nope"}');
244
+ await assert.rejects(run(dir, badRequest), /unknown op/);
245
+ await assert.rejects(run(dir, { op: "schema", source: "ghost" }), /no such data source/);
246
+ } finally {
247
+ await cleanup();
248
+ }
249
+ });
250
+
251
+ test("an invalid manifest fails with ManifestValidationError (consistent with the app runtime)", async () => {
252
+ const dir = await mkdtemp(join(tmpdir(), "urban-dataops-bad-"));
253
+ try {
254
+ // A source declared without a url: valid JSON, but the runtime binding rule rejects it —
255
+ // just as createUrbanApp/runFromEnv would, rather than failing confusingly downstream.
256
+ await writeFile(
257
+ join(dir, "nano.app.json"),
258
+ JSON.stringify({
259
+ schemaVersion: 1,
260
+ id: "shop",
261
+ name: "Shop",
262
+ data: { default: "app", sources: { app: { driver: "sqlite" } } },
263
+ }),
264
+ );
265
+ await assert.rejects(run(dir, { op: "sources" }), /Invalid Urban manifest[\s\S]*data\.sources\.app\.url/);
266
+ } finally {
267
+ await rm(dir, { recursive: true, force: true });
268
+ }
269
+ });
270
+
271
+ test("a failing source open names which datasource failed", async () => {
272
+ const dir = await mkdtemp(join(tmpdir(), "urban-dataops-openfail-"));
273
+ try {
274
+ // url points into a directory that does not exist: openSqliteSource fails fast, and
275
+ // openGateway prefixes the datasource name so the error is actionable (like provisionSqlite).
276
+ await writeFile(
277
+ join(dir, "nano.app.json"),
278
+ JSON.stringify({
279
+ schemaVersion: 1,
280
+ id: "shop",
281
+ name: "Shop",
282
+ data: { default: "app", sources: { app: { driver: "sqlite", url: "file:./nope/app.db" } } },
283
+ }),
284
+ );
285
+ await assert.rejects(run(dir, { op: "schema" }), /datasource "app":/);
286
+ } finally {
287
+ await rm(dir, { recursive: true, force: true });
288
+ }
289
+ });
290
+
291
+ test("an absolute --manifest path is honoured as-is (not re-prefixed with root)", async () => {
292
+ const { dir, cleanup } = await fixture();
293
+ try {
294
+ // root is irrelevant when the manifest path is absolute — it must not be prefixed onto it.
295
+ const r = await runDataOp(
296
+ createNodeHost({ cwd: dir }),
297
+ "/some/unrelated/root",
298
+ join(dir, "nano.app.json"),
299
+ { op: "sources" },
300
+ );
301
+ assert.equal(r.default, "app");
302
+ assert.deepEqual(expectRecords(r.sources).map((s) => s.name), ["app"]);
303
+ } finally {
304
+ await cleanup();
305
+ }
306
+ });
@@ -0,0 +1,292 @@
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
+
21
+ import type { HostContext, SqliteDb } from "../host.ts";
22
+ import { isRecord } from "../guards.ts";
23
+ import type { AppManifest, DataSource as ManifestDataSource } from "../manifest.ts";
24
+ import { loadManifest } from "../manifest.ts";
25
+ import { validateManifest } from "../validate.ts";
26
+ import { makeGateway, type DataSource as GatewayDataSource } from "./gateway.ts";
27
+ import { applyMigrations, MIGRATIONS_TABLE, openSqliteSource, resolveAppPath } from "./datasource.ts";
28
+
29
+ /** The DB-manager op protocol's known op set. `domaintypes` is part of the protocol but is not
30
+ * yet handled here (it errors clearly — see the file header); every other op is dispatched by
31
+ * `runDataOp`. Typing `op` as this union (instead of `string`) gives SDK/console callers
32
+ * type-safety and catches typos at compile time. */
33
+ export type DataOp =
34
+ | "sources"
35
+ | "schema"
36
+ | "query"
37
+ | "exec"
38
+ | "script"
39
+ | "migrations"
40
+ | "migrate"
41
+ | "domaintypes";
42
+
43
+ export interface DataRequest {
44
+ op: DataOp;
45
+ source?: string;
46
+ sql?: string;
47
+ params?: unknown[];
48
+ statements?: string[];
49
+ }
50
+
51
+ /** One datasource the manifest declares, resolved against the environment (env templates in
52
+ * `driver`/`url` are already expanded by `loadManifest`). Mirrors the console's `ResolvedSource`
53
+ * so the panel's datasource picker is unchanged. */
54
+ export interface ResolvedSource {
55
+ name: string;
56
+ driver: string;
57
+ url: string;
58
+ migrations?: string;
59
+ }
60
+
61
+ const DEFAULT_MIGRATIONS_DIR = "db/migrations";
62
+
63
+ /** JSON-safe a value: BigInt → number (when lossless) or string, recursing objects/arrays. SQLite
64
+ * reports `lastInsertRowid` (and INTEGER columns) as BigInt, which `JSON.stringify` cannot encode. */
65
+ export function jsonSafe(v: unknown): unknown {
66
+ if (typeof v === "bigint") {
67
+ return v >= BigInt(Number.MIN_SAFE_INTEGER) && v <= BigInt(Number.MAX_SAFE_INTEGER)
68
+ ? Number(v)
69
+ : v.toString();
70
+ }
71
+ if (Array.isArray(v)) return v.map(jsonSafe);
72
+ if (v && typeof v === "object") {
73
+ const out: Record<string, unknown> = {};
74
+ for (const [k, val] of Object.entries(v)) out[k] = jsonSafe(val);
75
+ return out;
76
+ }
77
+ return v;
78
+ }
79
+
80
+ /** Column order across a row set: first-seen order, unioned over every row (rows may be ragged). */
81
+ function columnsOf(rows: Record<string, unknown>[]): string[] {
82
+ const seen = new Set<string>();
83
+ const cols: string[] = [];
84
+ for (const r of rows) {
85
+ for (const k of Object.keys(r)) {
86
+ if (!seen.has(k)) {
87
+ seen.add(k);
88
+ cols.push(k);
89
+ }
90
+ }
91
+ }
92
+ return cols;
93
+ }
94
+
95
+ /** Split a script on `;` boundaries, dropping blank and comment-only fragments. Minimal DDL
96
+ * splitter (ADR 0024 "minimal dialect stance"); does not parse semicolons inside string literals. */
97
+ function splitStatements(sql: string): string[] {
98
+ return sql
99
+ .split(";")
100
+ .map((s) => s.trim())
101
+ .filter((s) => s.length > 0 && !s.split("\n").every((l) => l.trim().startsWith("--")));
102
+ }
103
+
104
+ /** List every declared datasource plus the default's name (no connection opened). */
105
+ export function listSources(manifest: AppManifest): { default?: string; sources: ResolvedSource[] } {
106
+ const data = manifest.data;
107
+ const sources: ResolvedSource[] = Object.entries(data?.sources ?? {}).map(([name, raw]) => ({
108
+ name,
109
+ driver: String(raw.driver),
110
+ url: raw.url,
111
+ migrations: raw.migrations,
112
+ }));
113
+ return { default: data?.default, sources };
114
+ }
115
+
116
+ /** Resolve the source a request names (the default, or the sole source, when unnamed). */
117
+ function pickSource(manifest: AppManifest, name?: string): { name: string; src: ManifestDataSource } {
118
+ const data = manifest.data;
119
+ const sources = data?.sources ?? {};
120
+ const chosen = name || data?.default || Object.keys(sources)[0];
121
+ if (!chosen) throw new Error("no data source specified and none declared in the manifest");
122
+ const src = sources[chosen];
123
+ if (!src) throw new Error(`no such data source "${chosen}"`);
124
+ return { name: chosen, src };
125
+ }
126
+
127
+ /** Open the requested source's gateway WITHOUT applying migrations (opening the DB manager must
128
+ * not mutate the schema). The caller closes the returned handle. */
129
+ async function openGateway(
130
+ host: HostContext,
131
+ root: string,
132
+ manifest: AppManifest,
133
+ name?: string,
134
+ ): Promise<{ gw: GatewayDataSource; db: SqliteDb }> {
135
+ const { name: chosen, src } = pickSource(manifest, name);
136
+ if (String(src.driver) !== "sqlite") {
137
+ throw new Error(
138
+ `data source "${chosen}" uses driver "${String(src.driver)}"; only "sqlite" is implemented`,
139
+ );
140
+ }
141
+ let db: SqliteDb;
142
+ try {
143
+ db = await openSqliteSource(host, root, src.url);
144
+ } catch (err) {
145
+ // Prefix the source name onto openSqliteSource's runtime-agnostic message so the CLI/console
146
+ // error names which datasource failed (mirrors provisionSqlite).
147
+ throw new Error(`datasource "${chosen}": ${err instanceof Error ? err.message : String(err)}`);
148
+ }
149
+ return { gw: makeGateway(db), db };
150
+ }
151
+
152
+ /**
153
+ * Resolve a `--manifest` argument against the app `root`. An absolute path (POSIX `/abs/…`, a
154
+ * Windows drive-letter `C:\…` or a UNC `\\host\share\…`) is honoured as-is; a relative path is
155
+ * joined onto `root`. Delegates to `resolveAppPath` so manifest and datasource path resolution
156
+ * share one cross-platform implementation and can't drift.
157
+ */
158
+ function resolveManifestPath(root: string, manifestPath: string): string {
159
+ return resolveAppPath(root, manifestPath);
160
+ }
161
+
162
+ /**
163
+ * Dispatch one DB-manager op against the app at `root`. Returns the op's result object (the CLI
164
+ * wraps it as `{ ok: true, ...result }`); throws on a handled error (wrapped as `{ ok: false }`).
165
+ */
166
+ export async function runDataOp(
167
+ host: HostContext,
168
+ root: string,
169
+ manifestPath: string,
170
+ req: DataRequest,
171
+ ): Promise<Record<string, unknown>> {
172
+ const manifest = validateManifest(
173
+ await loadManifest(host, resolveManifestPath(root, manifestPath)),
174
+ );
175
+
176
+ switch (req.op) {
177
+ case "sources": {
178
+ const { sources, default: def } = listSources(manifest);
179
+ return { default: def, sources };
180
+ }
181
+ case "schema": {
182
+ const { gw, db } = await openGateway(host, root, manifest, req.source);
183
+ try {
184
+ return { tables: await gw.schema() };
185
+ } finally {
186
+ db.close();
187
+ }
188
+ }
189
+ case "query": {
190
+ const { gw, db } = await openGateway(host, root, manifest, req.source);
191
+ try {
192
+ const rows: Record<string, unknown>[] = await gw.query(req.sql ?? "", req.params ?? []);
193
+ const safeRows: unknown[] = rows.map(jsonSafe);
194
+ return { columns: columnsOf(rows), rows: safeRows };
195
+ } finally {
196
+ db.close();
197
+ }
198
+ }
199
+ case "exec": {
200
+ const { gw, db } = await openGateway(host, root, manifest, req.source);
201
+ try {
202
+ const result = jsonSafe(await gw.exec(req.sql ?? "", req.params ?? []));
203
+ return isRecord(result) ? result : {};
204
+ } finally {
205
+ db.close();
206
+ }
207
+ }
208
+ case "script": {
209
+ // Run several statements atomically in one transaction — the console's structure editor
210
+ // uses this for the SQLite 12-step table rebuild (create → copy → drop → rename), so a
211
+ // mid-rebuild failure rolls back and leaves the table untouched. Statements come pre-split
212
+ // from the caller; `?`-params are not threaded (DDL needs none).
213
+ const { gw, db } = await openGateway(host, root, manifest, req.source);
214
+ try {
215
+ const statements = Array.isArray(req.statements)
216
+ ? req.statements
217
+ : splitStatements(req.sql ?? "");
218
+ let changed = 0;
219
+ await gw.tx(async (t) => {
220
+ for (const stmt of statements) {
221
+ if (stmt.trim()) changed += (await t.exec(stmt)).changed;
222
+ }
223
+ });
224
+ return { changed };
225
+ } finally {
226
+ db.close();
227
+ }
228
+ }
229
+ case "migrations": {
230
+ const { name, src } = pickSource(manifest, req.source);
231
+ const relDir = src.migrations ?? DEFAULT_MIGRATIONS_DIR;
232
+ const absDir = resolveAppPath(root, relDir);
233
+ // `migrations` is a read/list op: treat a missing dir as no migrations (mirrors
234
+ // applyMigrations) instead of throwing on host.listDir.
235
+ const files = (await host.exists(absDir))
236
+ ? (await host.listDir(absDir)).filter((f) => f.endsWith(".sql")).sort()
237
+ : [];
238
+ const { gw, db } = await openGateway(host, root, manifest, name);
239
+ try {
240
+ // Do not create the ledger table here — listing migrations must not mutate the
241
+ // schema. If the ledger doesn't exist yet, nothing has been applied.
242
+ const ledgerExists =
243
+ (
244
+ await gw.query(
245
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='${MIGRATIONS_TABLE}'`,
246
+ )
247
+ ).length > 0;
248
+ const applied = ledgerExists
249
+ ? new Map<string, string>(
250
+ (await gw.query(`SELECT name, applied_at FROM ${MIGRATIONS_TABLE}`)).map((r) => [
251
+ String(r.name),
252
+ String(r.applied_at),
253
+ ]),
254
+ )
255
+ : new Map<string, string>();
256
+ return {
257
+ dir: relDir,
258
+ entries: files.map((name) => ({
259
+ name,
260
+ applied: applied.has(name),
261
+ appliedAt: applied.get(name) ?? null,
262
+ })),
263
+ };
264
+ } finally {
265
+ db.close();
266
+ }
267
+ }
268
+ case "migrate": {
269
+ const { name, src } = pickSource(manifest, req.source);
270
+ const { db } = await openGateway(host, root, manifest, name);
271
+ try {
272
+ const applied = await applyMigrations(
273
+ host,
274
+ db,
275
+ root,
276
+ src.migrations ?? DEFAULT_MIGRATIONS_DIR,
277
+ );
278
+ return { applied, pending: 0 };
279
+ } finally {
280
+ db.close();
281
+ }
282
+ }
283
+ case "domaintypes": {
284
+ throw new Error(
285
+ "`domaintypes` is not yet implemented in `urban data` — the Nano console still serves it " +
286
+ "(tracked as PR 2 of nano-bpm#576)",
287
+ );
288
+ }
289
+ default:
290
+ throw new Error(`unknown op "${req.op}"`);
291
+ }
292
+ }
@@ -10,7 +10,16 @@ import type { EngineClient } from "../host.ts";
10
10
  import type { AppManifest } from "../manifest.ts";
11
11
 
12
12
  // provisionData never touches the engine — a bare stub is enough for these datasource tests.
13
- const noEngine = {} as EngineClient;
13
+ const noEngine: EngineClient = {
14
+ deployResources: async () => ({ deployed: 0 }),
15
+ createInstance: async () => ({ processInstanceKey: "pi" }),
16
+ cancelInstance: async () => {},
17
+ publishMessage: async () => {},
18
+ searchUserTasks: async () => [],
19
+ completeUserTask: async () => {},
20
+ registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
21
+ close: async () => {},
22
+ };
14
23
 
15
24
  async function fixture(migrations: Record<string, string>): Promise<string> {
16
25
  const dir = await mkdtemp(join(tmpdir(), "urban-mig-"));
@@ -22,7 +31,7 @@ async function fixture(migrations: Record<string, string>): Promise<string> {
22
31
  }
23
32
 
24
33
  function ctx(dir: string): RuntimeContext {
25
- const manifest = {
34
+ const manifest: AppManifest = {
26
35
  schemaVersion: 1,
27
36
  id: "mig-fixture",
28
37
  name: "Migration Fixture",
@@ -30,7 +39,7 @@ function ctx(dir: string): RuntimeContext {
30
39
  default: "app",
31
40
  sources: { app: { driver: "sqlite", url: "file:./app.db", migrations: "db/migrations" } },
32
41
  },
33
- } as unknown as AppManifest;
42
+ };
34
43
  return { manifest, host: createNodeHost({ cwd: dir, log: () => {} }), engine: noEngine, root: "." };
35
44
  }
36
45