@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,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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 =
|
|
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
|
-
|
|
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 }>(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
|
|
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:
|
|
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 line2	with <b> & 'q'"');
|
|
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
|
-
|
|
34
|
-
content
|
|
35
|
-
|
|
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 });
|