@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,129 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { applyMigrations, isAbsolutePath, MIGRATIONS_TABLE, parentDir, resolveAppPath, resolveSqlitePath, sqlitePathFromUrl } from "./datasource.ts";
4
+ import type { HostContext, SqliteDb } from "../host.ts";
5
+
6
+ // `resolveSqlitePath` is the single source of truth for turning a datasource `url` into its
7
+ // on-disk SQLite path (absolute when `root` is absolute, relative when `root` is relative, e.g.
8
+ // `root === "."`), shared by `openSqliteSource` (which opens the file) and `provisionSqlite`
9
+ // (which logs `path`). These guard that the two can never drift: the logged path must be exactly
10
+ // the path that was opened.
11
+ test("resolveSqlitePath joins a relative url verbatim against root", () => {
12
+ assert.equal(resolveSqlitePath("/srv/app", "file:./db/app.db"), "/srv/app/./db/app.db");
13
+ assert.equal(resolveSqlitePath("/srv/app", "sqlite:db/app.db"), "/srv/app/db/app.db");
14
+ assert.equal(resolveSqlitePath(".", "sqlite:db/app.db"), "./db/app.db");
15
+ });
16
+
17
+ test("resolveSqlitePath passes an absolute path through unchanged", () => {
18
+ assert.equal(resolveSqlitePath("/srv/app", "file:/var/data/app.db"), "/var/data/app.db");
19
+ });
20
+
21
+ test("resolveSqlitePath honours Windows absolute paths (drive-letter + UNC) as-is", () => {
22
+ // A Node host on Windows can hand us a drive-letter or UNC absolute path; it must NOT be
23
+ // treated as relative and prefixed with `root`.
24
+ assert.equal(resolveSqlitePath("/srv/app", "file:C:\\data\\app.db"), "C:\\data\\app.db");
25
+ assert.equal(resolveSqlitePath("/srv/app", "sqlite:C:/data/app.db"), "C:/data/app.db");
26
+ assert.equal(
27
+ resolveSqlitePath("/srv/app", "file:\\\\server\\share\\app.db"),
28
+ "\\\\server\\share\\app.db",
29
+ );
30
+ });
31
+
32
+ test("isAbsolutePath recognises POSIX, drive-letter and UNC roots (and rejects relative)", () => {
33
+ assert.equal(isAbsolutePath("/var/data/app.db"), true);
34
+ assert.equal(isAbsolutePath("C:\\data\\app.db"), true);
35
+ assert.equal(isAbsolutePath("c:/data/app.db"), true);
36
+ assert.equal(isAbsolutePath("\\\\server\\share\\app.db"), true);
37
+ assert.equal(isAbsolutePath("\\data\\app.db"), true); // Windows drive-root, single leading backslash
38
+ assert.equal(isAbsolutePath("db/app.db"), false);
39
+ assert.equal(isAbsolutePath("./db/app.db"), false);
40
+ assert.equal(isAbsolutePath("C:relative.db"), false); // drive-relative, no root separator
41
+ });
42
+
43
+ test("resolveAppPath trims a trailing separator of either kind off root before joining", () => {
44
+ assert.equal(resolveAppPath("/srv/app/", "app.db"), "/srv/app/app.db");
45
+ // A Windows-style root (uses backslashes) joins with "\\" and normalizes the relative segment's
46
+ // separators to match, so the result is never mixed-separator ("C:\\srv\\app/app.db").
47
+ assert.equal(resolveAppPath("C:\\srv\\app\\", "app.db"), "C:\\srv\\app\\app.db");
48
+ assert.equal(resolveAppPath("C:\\srv\\app", "db/app.db"), "C:\\srv\\app\\db\\app.db");
49
+ assert.equal(resolveAppPath("\\\\server\\share", "db/migrations"), "\\\\server\\share\\db\\migrations");
50
+ // A drive-letter root that already uses forward slashes stays forward-slash (also non-mixed).
51
+ assert.equal(resolveAppPath("C:/srv/app/", "app.db"), "C:/srv/app/app.db");
52
+ // A forward-slash root joined with a backslash-containing relative segment normalizes the
53
+ // segment to "/", so the result is never mixed-separator ("C:/srv/app/db\\migrations").
54
+ assert.equal(resolveAppPath("C:/srv/app", "db\\migrations"), "C:/srv/app/db/migrations");
55
+ assert.equal(resolveAppPath(".", "db\\migrations"), "./db/migrations");
56
+ // A mixed-separator root is normalized to the chosen style too (it contains a backslash, so the
57
+ // whole path becomes backslash-style), so the result is never mixed even when `root` itself is.
58
+ assert.equal(resolveAppPath("C:/srv\\app", "db/app.db"), "C:\\srv\\app\\db\\app.db");
59
+ assert.equal(resolveAppPath("C:/srv\\app\\", "db/app.db"), "C:\\srv\\app\\db\\app.db");
60
+ });
61
+
62
+ test("parentDir keeps the trailing separator on a Windows drive root", () => {
63
+ // A file directly under a drive root must yield the drive root itself, not the bare volume:
64
+ // "C:" is a drive-relative reference, "C:\\" / "C:/" is the actual directory.
65
+ assert.equal(parentDir("C:\\app.db"), "C:\\");
66
+ assert.equal(parentDir("C:/app.db"), "C:/");
67
+ // Nested paths and the no-parent cases keep working.
68
+ assert.equal(parentDir("C:\\data\\app.db"), "C:\\data");
69
+ assert.equal(parentDir("/var/data/app.db"), "/var/data");
70
+ assert.equal(parentDir("app.db"), "");
71
+ });
72
+
73
+ test("resolveSqlitePath strips a trailing slash on root before joining", () => {
74
+ assert.equal(resolveSqlitePath("/srv/app/", "sqlite:app.db"), "/srv/app/app.db");
75
+ });
76
+
77
+ test("resolveSqlitePath resolves the same path openSqliteSource would open", () => {
78
+ const root = "/srv/app";
79
+ const url = "file:./db/app.db";
80
+ const bare = sqlitePathFromUrl(url);
81
+ assert.equal(resolveSqlitePath(root, url), `${root}/${bare}`);
82
+ });
83
+
84
+ test("MIGRATIONS_TABLE is the single canonical ledger name shared by application and listing", () => {
85
+ // `applyMigrations` (datasource) writes this ledger and dataops' `migrations` op reads it; both
86
+ // import this constant so the name can never drift between the two sites.
87
+ assert.equal(MIGRATIONS_TABLE, "_urban_migrations");
88
+ });
89
+
90
+ test("applyMigrations joins each migration file onto its dir without reintroducing mixed separators", async () => {
91
+ // When the resolved migrations dir is Windows/UNC-style (backslashes), the per-file read path
92
+ // must adopt that separator too — a literal "/" join would emit e.g.
93
+ // "C:\\app\\db\\migrations/001.sql", which breaks reads on Windows. `applyMigrations` routes the
94
+ // join through `resolveAppPath`, so we assert the exact path handed to `readTextFile`.
95
+ const readPaths: string[] = [];
96
+ const host: HostContext = {
97
+ runtime: "node",
98
+ env: () => undefined,
99
+ now: () => 0,
100
+ exists: async () => true,
101
+ listDir: async () => ["002_b.sql", "001_a.sql"],
102
+ readTextFile: async (path: string) => {
103
+ readPaths.push(path);
104
+ return "";
105
+ },
106
+ openSqlite: () => {
107
+ throw new Error("sqlite not used in this test");
108
+ },
109
+ importModule: async () => ({}),
110
+ serveHttp: async () => ({ port: 0, stop: async () => {} }),
111
+ log: () => {},
112
+ };
113
+ const db: SqliteDb = {
114
+ exec: () => {},
115
+ run: () => ({ changes: 0, lastInsertRowid: 0 }),
116
+ all: <T>() => {
117
+ const rows: T[] = [];
118
+ return rows;
119
+ },
120
+ close: () => {},
121
+ };
122
+ // A backslash root makes `resolveAppPath` pick "\\", so the migrations dir is backslash-style.
123
+ const applied = await applyMigrations(host, db, "C:\\srv\\app", "db\\migrations");
124
+ assert.deepEqual(applied, ["001_a.sql", "002_b.sql"]);
125
+ assert.deepEqual(readPaths, [
126
+ "C:\\srv\\app\\db\\migrations\\001_a.sql",
127
+ "C:\\srv\\app\\db\\migrations\\002_b.sql",
128
+ ]);
129
+ });
@@ -8,11 +8,85 @@ import type { HostContext, SqliteDb } from "../host.ts";
8
8
  import type { AppManifest, DataSource, DomainType } from "../manifest.ts";
9
9
  import { makeGateway, Table, type DataSource as GatewayDataSource } from "./gateway.ts";
10
10
 
11
- function sqlitePathFromUrl(url: string): string {
11
+ export function sqlitePathFromUrl(url: string): string {
12
12
  // Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
13
13
  return url.replace(/^(file|sqlite):(\/\/)?/, "");
14
14
  }
15
15
 
16
+ /** True when `p` is already an absolute path on any host `urban data` targets. `urban data` runs
17
+ * on a Node host that may be Windows, so absolute-path detection can't assume POSIX: this matches
18
+ * a POSIX root ("/…"), a Windows drive-letter root ("C:\…" or "C:/…"), a Windows drive-root path
19
+ * that starts with a single backslash ("\data\app.db") and a Windows UNC path
20
+ * ("\\\\server\\share"). A single leading backslash covers both the drive-root and UNC cases,
21
+ * matching Node's `path.win32.isAbsolute`. Used by `resolveAppPath` so a caller-supplied absolute
22
+ * path is never incorrectly prefixed with the app root. */
23
+ export function isAbsolutePath(p: string): boolean {
24
+ return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
25
+ }
26
+
27
+ /** Resolve `p` against the app `root`: an absolute `p` (see `isAbsolutePath`) is returned as-is;
28
+ * a relative `p` is joined onto `root`. Trims a trailing separator of either kind off `root` so
29
+ * we never emit a doubled separator. The join separator matches the root's own style so we never
30
+ * emit a mixed-separator path (e.g. "C:\\srv\\app/app.db" or "\\\\server\\share/db\\migrations"),
31
+ * which Windows/UNC resolution and some tooling mishandle: a root that already uses backslashes is
32
+ * Windows-style and joins with "\\" — normalizing the relative segment's forward slashes to match —
33
+ * while everything else (POSIX, a "C:/…" forward-slash root, or a relative ".") joins with "/" —
34
+ * normalizing the relative segment's backslashes to match. Both `root` AND the relative segment are
35
+ * rewritten to the chosen separator, so the result is never mixed even when `root` itself is mixed
36
+ * (e.g. "C:/srv\\app") or `p` is. The single canonical implementation shared by `resolveSqlitePath`
37
+ * (datasource urls) and `resolveManifestPath` (`--manifest`) — and by `applyMigrations` to join each
38
+ * migration file onto its dir — so those path resolutions can never drift and all behave the same
39
+ * cross-platform. */
40
+ export function resolveAppPath(root: string, p: string): string {
41
+ if (isAbsolutePath(p)) return p;
42
+ const sep = root.includes("\\") ? "\\" : "/";
43
+ const norm = (s: string): string => (sep === "\\" ? s.replace(/\//g, "\\") : s.replace(/\\/g, "/"));
44
+ const base = norm(root).replace(/[/\\]+$/, "");
45
+ const rel = norm(p);
46
+ return `${base}${sep}${rel}`;
47
+ }
48
+
49
+ /** Name of the SQLite ledger table that records applied migrations. The single source of truth
50
+ * for this identifier: `applyMigrations` writes it and `dataops`' `migrations` op reads it, so
51
+ * both import this constant and the ledger name can never drift between application and listing. */
52
+ export const MIGRATIONS_TABLE = "_urban_migrations";
53
+
54
+ /** Resolve a datasource `url` to its on-disk SQLite path against `root`. The result is absolute
55
+ * when the resolved path is absolute (either because `url` names an absolute path or `root` is
56
+ * absolute); if `root` is relative (e.g. "." as used by the CLI/tests) a relative `url` stays
57
+ * correspondingly relative. The single source of truth for this resolution, shared by
58
+ * `openSqliteSource` (to open the file) and `provisionSqlite` (to report where it provisioned),
59
+ * so the opened path and the logged path can never drift. */
60
+ export function resolveSqlitePath(root: string, url: string): string {
61
+ return resolveAppPath(root, sqlitePathFromUrl(url));
62
+ }
63
+
64
+ /**
65
+ * Open (creating if needed) the SQLite file a `file:`/`sqlite:` URL points at, resolved against
66
+ * `root`, with WAL journalling — but WITHOUT applying migrations. Provisioning (`provisionSqlite`)
67
+ * layers migrations on top; the `urban data` DB-manager gateway (dataops.ts) wants the raw handle
68
+ * so read/`migrations`-list ops don't silently mutate the schema on open.
69
+ */
70
+ export async function openSqliteSource(
71
+ host: HostContext,
72
+ root: string,
73
+ url: string,
74
+ ): Promise<SqliteDb> {
75
+ const abs = resolveSqlitePath(root, url);
76
+ const dir = parentDir(abs);
77
+ if (dir && !(await host.exists(dir))) {
78
+ // The host API doesn't expose mkdir and openSqlite won't create parent dirs,
79
+ // so fail fast with a clear, actionable message instead of a low-level
80
+ // "cannot open" error.
81
+ throw new Error(
82
+ `directory "${dir}" does not exist — create it before running (the SQLite file cannot be opened otherwise)`,
83
+ );
84
+ }
85
+ const db = host.openSqlite(abs);
86
+ db.exec("PRAGMA journal_mode=WAL");
87
+ return db;
88
+ }
89
+
16
90
  /** A safe unquoted SQL identifier. Table/column names are interpolated directly into SQL,
17
91
  * so reject anything that isn't a plain identifier to prevent invalid SQL / injection. */
18
92
  const SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -23,9 +97,20 @@ function assertSqlIdent(kind: string, name: string): string {
23
97
  return name;
24
98
  }
25
99
 
26
- function parentDir(path: string): string {
27
- const i = path.lastIndexOf("/");
28
- return i > 0 ? path.slice(0, i) : "";
100
+ /** The parent directory of `path`, or `""` when it has none (a bare filename or a filesystem
101
+ * root, for which the existence check in `openSqliteSource` is intentionally skipped). Splits on
102
+ * either separator so a Windows-style absolute path (e.g. "C:\data\app.db") yields a real parent
103
+ * dir rather than being treated as having none. Preserves the trailing separator on a Windows
104
+ * drive root so "C:\app.db" -> "C:\" (and "C:/app.db" -> "C:/"), not the bare volume "C:" — the
105
+ * latter is a drive-relative reference, not the drive root, and would make `openSqliteSource`
106
+ * fail fast against the wrong location. */
107
+ export function parentDir(path: string): string {
108
+ const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
109
+ if (i <= 0) return "";
110
+ // A Windows drive root ("C:\…" / "C:/…"): the separator sits right after the "C:" volume, so
111
+ // keep it — dropping it would yield the drive-relative "C:" rather than the drive root.
112
+ if (i === 2 && /^[A-Za-z]:$/.test(path.slice(0, 2))) return path.slice(0, i + 1);
113
+ return path.slice(0, i);
29
114
  }
30
115
 
31
116
  /** A typed accessor over a table declared in manifest `types`. */
@@ -169,25 +254,28 @@ export class DataLayer {
169
254
  }
170
255
  }
171
256
 
172
- async function applyMigrations(
257
+ export async function applyMigrations(
173
258
  host: HostContext,
174
259
  db: SqliteDb,
175
260
  root: string,
176
261
  migrationsDir: string,
177
262
  ): Promise<string[]> {
178
- const dir = `${root.replace(/\/+$/, "")}/${migrationsDir.replace(/^\/+/, "")}`;
263
+ const dir = resolveAppPath(root, migrationsDir);
179
264
  if (!(await host.exists(dir))) return [];
180
265
  const files = (await host.listDir(dir)).filter((f) => f.endsWith(".sql")).sort();
181
266
  db.exec(
182
- "CREATE TABLE IF NOT EXISTS _urban_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)",
267
+ `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`,
183
268
  );
184
269
  const applied = new Set(
185
- db.all<{ name: string }>("SELECT name FROM _urban_migrations").map((r) => r.name),
270
+ db.all<{ name: string }>(`SELECT name FROM ${MIGRATIONS_TABLE}`).map((r) => r.name),
186
271
  );
187
272
  const newlyApplied: string[] = [];
188
273
  for (const file of files) {
189
274
  if (applied.has(file)) continue;
190
- const sql = await host.readTextFile(`${dir}/${file}`);
275
+ // Join via `resolveAppPath` (not a literal "/") so the migration file path adopts `dir`'s own
276
+ // separator style; a Windows/UNC-style `dir` (backslashes) would otherwise reintroduce a
277
+ // mixed-separator path (e.g. "C:\\app\\db\\migrations/001.sql") that breaks reads on Windows.
278
+ const sql = await host.readTextFile(resolveAppPath(dir, file));
191
279
  // Apply the migration and record it in the ledger atomically. SQLite DDL is
192
280
  // transactional, so wrapping both in one BEGIN/COMMIT makes a migration all-or-nothing:
193
281
  // either the schema change AND its `_urban_migrations` row commit together, or neither
@@ -198,7 +286,7 @@ async function applyMigrations(
198
286
  db.exec("BEGIN");
199
287
  try {
200
288
  db.exec(sql);
201
- db.run("INSERT INTO _urban_migrations (name, applied_at) VALUES (?, ?)", [
289
+ db.run(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES (?, ?)`, [
202
290
  file,
203
291
  new Date(host.now()).toISOString(),
204
292
  ]);
@@ -222,25 +310,19 @@ async function provisionSqlite(
222
310
  name: string,
223
311
  src: DataSource,
224
312
  ): Promise<ProvisionedSource> {
225
- const dbPath = sqlitePathFromUrl(src.url);
226
- const abs = dbPath.startsWith("/") ? dbPath : `${ctx.root.replace(/\/+$/, "")}/${dbPath}`;
227
- const dir = parentDir(abs);
228
- if (dir && !(await ctx.host.exists(dir))) {
229
- // The host API doesn't expose mkdir and openSqlite won't create parent dirs,
230
- // so fail fast with a clear, actionable message instead of a low-level
231
- // "cannot open" error.
232
- throw new Error(
233
- `datasource "${name}": directory "${dir}" does not exist — create it before running (the SQLite file cannot be opened otherwise)`,
234
- );
313
+ let db: SqliteDb;
314
+ try {
315
+ db = await openSqliteSource(ctx.host, ctx.root, src.url);
316
+ } catch (err) {
317
+ // Prefix the source name onto openSqliteSource's runtime-agnostic message.
318
+ throw new Error(`datasource "${name}": ${err instanceof Error ? err.message : String(err)}`);
235
319
  }
236
- const db = ctx.host.openSqlite(abs);
237
- db.exec("PRAGMA journal_mode=WAL");
238
320
  const migrationsApplied = src.migrations
239
321
  ? await applyMigrations(ctx.host, db, ctx.root, src.migrations)
240
322
  : [];
241
323
  ctx.host.log("info", `datasource: provisioned "${name}"`, {
242
324
  driver: "sqlite",
243
- path: abs,
325
+ path: resolveSqlitePath(ctx.root, src.url),
244
326
  migrationsApplied,
245
327
  });
246
328
  return {
@@ -0,0 +1,258 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { RuntimeContext } from "../context.ts";
4
+ import type { HostContext } from "../host.ts";
5
+ import type { AppManifest } from "../manifest.ts";
6
+ import { deployModels } from "./deploy.ts";
7
+ import { applyTemplates, resolveTemplates, type TemplateSource } from "./templates.ts";
8
+
9
+ const ROOT = "/app";
10
+
11
+ function dirname(p: string): string {
12
+ const i = p.lastIndexOf("/");
13
+ return i > 0 ? p.slice(0, i) : "/";
14
+ }
15
+
16
+ interface DeployedResource {
17
+ name: string;
18
+ content: string;
19
+ contentType: string;
20
+ }
21
+
22
+ interface Harness {
23
+ ctx: RuntimeContext;
24
+ logs: Array<{ level: string; msg: string; fields?: Record<string, unknown> }>;
25
+ deployed: DeployedResource[];
26
+ }
27
+
28
+ /** A virtual-filesystem host over `files` (keyed by absolute path) plus a recording engine. */
29
+ function makeHarness(
30
+ files: Record<string, string>,
31
+ manifest: Partial<AppManifest>,
32
+ templates?: TemplateSource,
33
+ ): Harness {
34
+ const logs: Harness["logs"] = [];
35
+ const deployed: DeployedResource[] = [];
36
+ // biome-ignore lint/plugin: HostContext test double implementing only the fs/log members deployModels exercises; the rest are intentionally absent.
37
+ const host: HostContext = {
38
+ runtime: "node",
39
+ log: (level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>) =>
40
+ logs.push({ level, msg, fields }),
41
+ exists: async (p: string) =>
42
+ p in files || Object.keys(files).some((f) => f.startsWith(`${p}/`)),
43
+ readTextFile: async (p: string) => {
44
+ if (!(p in files)) throw new Error(`ENOENT ${p}`);
45
+ return files[p];
46
+ },
47
+ listDir: async (dir: string) =>
48
+ Object.keys(files)
49
+ .filter((f) => dirname(f) === dir)
50
+ .map((f) => f.slice(f.lastIndexOf("/") + 1)),
51
+ } as unknown as HostContext;
52
+ const engine = {
53
+ deployResources: async (resources: DeployedResource[]) => {
54
+ deployed.push(...resources);
55
+ return { deployed: resources.length };
56
+ },
57
+ };
58
+ // biome-ignore lint/plugin: RuntimeContext test double assembled from the mock host/engine/manifest below for deployModels.
59
+ const ctx = {
60
+ root: ROOT,
61
+ // biome-ignore lint/plugin: inline AppManifest fixture — a Partial<AppManifest> spread over the required base fields.
62
+ manifest: { schemaVersion: 1, id: "t", name: "T", ...manifest } as AppManifest,
63
+ // biome-ignore lint/plugin: EngineClient test double implementing only deployResources (the sole method deployModels calls).
64
+ engine: engine as unknown as RuntimeContext["engine"],
65
+ host,
66
+ templates,
67
+ } as unknown as RuntimeContext;
68
+ return { ctx, logs, deployed };
69
+ }
70
+
71
+ // ── applyTemplates ─────────────────────────────────────────────────────────
72
+
73
+ test("applyTemplates substitutes a named placeholder", () => {
74
+ const { content, unresolved } = applyTemplates("<a>{{greet}}</a>", "text/xml", {
75
+ greet: "hello",
76
+ });
77
+ assert.equal(content, "<a>hello</a>");
78
+ assert.deepEqual(unresolved, []);
79
+ });
80
+
81
+ test("applyTemplates tolerates inner whitespace in the placeholder", () => {
82
+ const { content } = applyTemplates("{{ greet }}", "text/xml", { greet: "hi" });
83
+ assert.equal(content, "hi");
84
+ });
85
+
86
+ test("applyTemplates encodes newlines/tabs as XML character references (survives attr normalization)", () => {
87
+ const { content } = applyTemplates('value="{{p}}"', "text/xml", {
88
+ p: "line1\nline2\twith <b> & 'q'",
89
+ });
90
+ assert.equal(content, 'value="line1&#10;line2&#9;with &lt;b&gt; &amp; &apos;q&apos;"');
91
+ // Critically, no literal newline/tab remains (those would collapse to spaces in an attribute).
92
+ assert.ok(!/[\n\t]/.test(content));
93
+ });
94
+
95
+ test("applyTemplates escapes JSON-string content for .form resources", () => {
96
+ const { content } = applyTemplates('{"q":"{{p}}"}', "application/json", {
97
+ p: 'a\nb"c',
98
+ });
99
+ assert.equal(content, '{"q":"a\\nb\\"c"}');
100
+ assert.deepEqual(JSON.parse(content), { q: 'a\nb"c' });
101
+ });
102
+
103
+ test("applyTemplates leaves unknown placeholders verbatim and reports them once", () => {
104
+ const { content, unresolved } = applyTemplates("{{x}} {{y}} {{x}}", "text/xml", {});
105
+ assert.equal(content, "{{x}} {{y}} {{x}}");
106
+ assert.deepEqual(unresolved, ["x", "y"]);
107
+ });
108
+
109
+ test("applyTemplates is non-recursive: a template's own braces are not re-expanded", () => {
110
+ const { content, unresolved } = applyTemplates("{{a}}", "text/xml", {
111
+ a: "{{b}}",
112
+ b: "SHOULD-NOT-APPEAR",
113
+ });
114
+ assert.equal(content, "{{b}}");
115
+ assert.deepEqual(unresolved, []);
116
+ });
117
+
118
+ // ── resolveTemplates ─────────────────────────────────────────────────────────
119
+
120
+ test("resolveTemplates reads a glob source, keying by file stem", async () => {
121
+ const { ctx } = makeHarness(
122
+ { "/app/prompts/review.md": "REVIEW", "/app/prompts/fix-ci.md": "FIXCI" },
123
+ {},
124
+ );
125
+ const map = await resolveTemplates(ctx.host, ROOT, [["prompts/*.md"]]);
126
+ assert.deepEqual({ ...map }, { review: "REVIEW", "fix-ci": "FIXCI" });
127
+ });
128
+
129
+ test("resolveTemplates scans a bare directory entry", async () => {
130
+ const { ctx } = makeHarness(
131
+ { "/app/prompts/a.md": "A", "/app/prompts/b.md": "B" },
132
+ {},
133
+ );
134
+ const map = await resolveTemplates(ctx.host, ROOT, [["prompts"]]);
135
+ assert.deepEqual({ ...map }, { a: "A", b: "B" });
136
+ });
137
+
138
+ test("resolveTemplates resolves a literal file entry", async () => {
139
+ const { ctx } = makeHarness({ "/app/prompts/one.md": "ONE" }, {});
140
+ const map = await resolveTemplates(ctx.host, ROOT, [["prompts/one.md"]]);
141
+ assert.deepEqual({ ...map }, { one: "ONE" });
142
+ });
143
+
144
+ test("resolveTemplates ignores a directory with no readable files without warning", async () => {
145
+ // A directory that exists (has nested content) but no files directly under it: previously the
146
+ // non-glob fallback treated it as a literal file and logged a misleading "unreadable entry" warn.
147
+ const { ctx, logs } = makeHarness({ "/app/prompts/nested/x.md": "X" }, {});
148
+ const map = await resolveTemplates(ctx.host, ROOT, [["prompts"]]);
149
+ assert.deepEqual({ ...map }, {});
150
+ assert.equal(logs.filter((l) => l.level === "warn").length, 0);
151
+ });
152
+
153
+ test("resolveTemplates merges a programmatic map, letting later sources win", async () => {
154
+ const { ctx } = makeHarness({ "/app/prompts/review.md": "FROM-FILE" }, {});
155
+ const map = await resolveTemplates(ctx.host, ROOT, [
156
+ ["prompts/*.md"],
157
+ { review: "FROM-MAP", extra: "X" },
158
+ ]);
159
+ assert.deepEqual({ ...map }, { review: "FROM-MAP", extra: "X" });
160
+ });
161
+
162
+ test("resolveTemplates returns a null-prototype map so `__proto__` keys can't pollute", async () => {
163
+ // A template file literally named `__proto__.md`, or a programmatic map keyed by `__proto__`,
164
+ // must set a plain own property rather than trip the magic prototype setter on `{}`.
165
+ const { ctx } = makeHarness({ "/app/prompts/__proto__.md": "PWN" }, {});
166
+ const map = await resolveTemplates(ctx.host, ROOT, [
167
+ ["prompts/__proto__.md"],
168
+ { extra: "X" },
169
+ ]);
170
+ assert.equal(Object.getPrototypeOf(map), null);
171
+ assert.equal(Object.getPrototypeOf({}), Object.prototype, "global prototype untouched");
172
+ assert.equal(map.extra, "X");
173
+ assert.equal(Object.prototype.hasOwnProperty.call(map, "__proto__"), true);
174
+ });
175
+
176
+ test("resolveTemplates skips a file whose stem is not a valid placeholder name, with a warning", async () => {
177
+ // `spaced name.md` yields the stem `spaced name`, which the `{{…}}` placeholder charset (`[\w.-]`)
178
+ // can never match — so it would be unreferenceable and silently ignored. Fail loudly instead.
179
+ const { ctx, logs } = makeHarness(
180
+ { "/app/prompts/spaced name.md": "NOPE", "/app/prompts/ok.md": "YES" },
181
+ {},
182
+ );
183
+ const map = await resolveTemplates(ctx.host, ROOT, [["prompts/*.md"]]);
184
+ assert.deepEqual({ ...map }, { ok: "YES" });
185
+ const warn = logs.find((l) => l.level === "warn" && l.msg === "template: skipped unsupported name");
186
+ assert.ok(warn, "expected an unsupported-name warning");
187
+ assert.equal(warn?.fields?.name, "spaced name");
188
+ });
189
+
190
+ // ── deployModels integration ─────────────────────────────────────────────────
191
+
192
+ test("deployModels substitutes manifest templates into deployed model content", async () => {
193
+ const { ctx, deployed } = makeHarness(
194
+ {
195
+ "/app/processes/agent.bpmn": '<x value="{{review}}" />',
196
+ "/app/prompts/review.md": "Do the review",
197
+ },
198
+ { models: { processes: ["processes/*.bpmn"], templates: ["prompts/*.md"] } },
199
+ );
200
+ const res = await deployModels(ctx);
201
+ assert.equal(res.deployed, 1);
202
+ assert.equal(deployed[0].name, "agent.bpmn");
203
+ assert.equal(deployed[0].content, '<x value="Do the review" />');
204
+ });
205
+
206
+ test("deployModels lets the programmatic templates option win over the manifest", async () => {
207
+ const { ctx, deployed } = makeHarness(
208
+ {
209
+ "/app/processes/agent.bpmn": '<x value="{{review}}" />',
210
+ "/app/prompts/review.md": "FROM-MANIFEST",
211
+ },
212
+ { models: { processes: ["processes/*.bpmn"], templates: ["prompts/*.md"] } },
213
+ { review: "FROM-OPTION" },
214
+ );
215
+ await deployModels(ctx);
216
+ assert.equal(deployed[0].content, '<x value="FROM-OPTION" />');
217
+ });
218
+
219
+ test("deployModels warns on an unresolved placeholder but still deploys", async () => {
220
+ const { ctx, deployed, logs } = makeHarness(
221
+ {
222
+ "/app/processes/agent.bpmn": '<x a="{{present}}" b="{{missing}}" />',
223
+ "/app/prompts/present.md": "HERE",
224
+ },
225
+ { models: { processes: ["processes/*.bpmn"], templates: ["prompts/*.md"] } },
226
+ );
227
+ const res = await deployModels(ctx);
228
+ assert.equal(res.deployed, 1);
229
+ assert.equal(deployed[0].content, '<x a="HERE" b="{{missing}}" />');
230
+ const warn = logs.find((l) => l.msg.includes("unresolved template placeholders"));
231
+ assert.ok(warn, "expected an unresolved-placeholder warning");
232
+ assert.deepEqual(warn?.fields?.unresolved, ["missing"]);
233
+ });
234
+
235
+ test("deployModels leaves unknown-extension resources untouched (no XML escaping)", async () => {
236
+ // Unknown extensions (application/octet-stream) have no safe escaper, so substitution must be
237
+ // skipped rather than run the default XML escaper and risk corrupting the resource.
238
+ const { ctx, deployed } = makeHarness(
239
+ {
240
+ "/app/models/notes.txt": "raw {{review}} & <stuff>",
241
+ "/app/prompts/review.md": "R",
242
+ },
243
+ { models: { processes: ["models/*.txt"], templates: ["prompts/*.md"] } },
244
+ );
245
+ await deployModels(ctx);
246
+ assert.equal(deployed[0].contentType, "application/octet-stream");
247
+ assert.equal(deployed[0].content, "raw {{review}} & <stuff>");
248
+ });
249
+
250
+ test("deployModels is a no-op on content when no templates are configured", async () => {
251
+ const { ctx, deployed } = makeHarness(
252
+ { "/app/processes/agent.bpmn": '<x value="{{review}}" />' },
253
+ { models: { processes: ["processes/*.bpmn"] } },
254
+ );
255
+ await deployModels(ctx);
256
+ // With no templates, placeholders are left untouched (no substitution pass runs).
257
+ assert.equal(deployed[0].content, '<x value="{{review}}" />');
258
+ });
@@ -1,7 +1,9 @@
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
 
3
4
  import type { RuntimeContext } from "../context.ts";
4
5
  import { expandPatterns } from "../glob.ts";
6
+ import { applyTemplates, resolveTemplates } from "./templates.ts";
5
7
 
6
8
  function contentTypeFor(path: string): string {
7
9
  if (path.endsWith(".bpmn")) return "text/xml";
@@ -15,7 +17,9 @@ function baseName(path: string): string {
15
17
  return i >= 0 ? path.slice(i + 1) : path;
16
18
  }
17
19
 
18
- /** Deploy all processes, decisions and forms declared under `models`. */
20
+ /** Deploy all processes, decisions and forms declared under `models`. Each resource has its
21
+ * `{{name}}` placeholders substituted (manifest `models.templates`, then the programmatic
22
+ * `ctx.templates`, the latter winning) before it is sent to the engine. */
19
23
  export async function deployModels(ctx: RuntimeContext): Promise<{ deployed: number; files: string[] }> {
20
24
  const models = ctx.manifest.models ?? {};
21
25
  const patterns = [
@@ -28,12 +32,30 @@ export async function deployModels(ctx: RuntimeContext): Promise<{ deployed: num
28
32
  ctx.host.log("info", "deploy: no model files matched", { patterns });
29
33
  return { deployed: 0, files: [] };
30
34
  }
35
+ // Resolve templates once (manifest globs/dirs, then the programmatic deploy option). An empty
36
+ // map makes substitution a no-op, so apps that use no templates are unaffected.
37
+ const templates = await resolveTemplates(ctx.host, ctx.root, [models.templates, ctx.templates]);
38
+ const hasTemplates = Object.keys(templates).length > 0;
31
39
  const resources = await Promise.all(
32
- files.map(async (path) => ({
33
- name: baseName(path),
34
- content: await ctx.host.readTextFile(path),
35
- contentType: contentTypeFor(path),
36
- })),
40
+ files.map(async (path) => {
41
+ const contentType = contentTypeFor(path);
42
+ let content = await ctx.host.readTextFile(path);
43
+ // Only substitute into the model content types we know how to escape (XML for .bpmn/.dmn,
44
+ // JSON for .form). Unknown extensions map to application/octet-stream; running the default
45
+ // XML escaper over them could deterministically corrupt a non-XML/JSON resource that a
46
+ // manifest pattern matched by accident, so leave those files untouched.
47
+ if (hasTemplates && contentType !== "application/octet-stream") {
48
+ const applied = applyTemplates(content, contentType, templates);
49
+ content = applied.content;
50
+ if (applied.unresolved.length > 0) {
51
+ ctx.host.log("warn", "deploy: unresolved template placeholders", {
52
+ file: path,
53
+ unresolved: applied.unresolved,
54
+ });
55
+ }
56
+ }
57
+ return { name: baseName(path), content, contentType };
58
+ }),
37
59
  );
38
60
  const { deployed } = await ctx.engine.deployResources(resources);
39
61
  ctx.host.log("info", "deploy: models deployed", { deployed, files });