Skip to content

Commit 45a8100

Browse files
MajorTalclaude
andcommitted
Add migrations_file manifest option to avoid JSON escaping pain
When SQL contains JSONB literals, escaping them inside a JSON string field is a nightmare. migrations_file reads SQL from a .sql file on disk instead, resolved relative to the manifest directory. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3930bf6 commit 45a8100

3 files changed

Lines changed: 72 additions & 4 deletions

File tree

cli/lib/deploy.mjs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { readFileSync } from "fs";
22
import { dirname, resolve } from "path";
33
import { API, allowanceAuthHeaders, findProject } from "./config.mjs";
4-
import { resolveFilePathsInManifest } from "./manifest.mjs";
4+
import { resolveFilePathsInManifest, resolveMigrationsFile } from "./manifest.mjs";
55

66
const HELP = `run402 deploy — Deploy to an existing project on Run402
77
@@ -17,7 +17,8 @@ Options:
1717
Manifest format (JSON):
1818
{
1919
"project_id": "prj_...",
20-
"migrations": "CREATE TABLE items (id serial PRIMARY KEY, title text NOT NULL, done boolean DEFAULT false)",
20+
"migrations": "CREATE TABLE items (...)",
21+
"migrations_file": "setup.sql",
2122
"rls": {
2223
"template": "public_read_write",
2324
"tables": [{ "table": "items" }]
@@ -37,6 +38,14 @@ Manifest format (JSON):
3738
project_id is required (provision first with 'run402 provision').
3839
All other fields are optional.
3940
41+
Migrations can be inline or read from a file:
42+
"migrations": "CREATE TABLE ..." ← inline SQL
43+
"migrations_file": "setup.sql" ← read from disk
44+
Use migrations_file when your SQL contains JSONB literals or other
45+
characters that are painful to escape inside a JSON string.
46+
Paths are resolved relative to the manifest file's directory.
47+
If both are present, migrations_file wins.
48+
4049
Files can use either inline "data" or a local "path":
4150
{ "file": "index.html", "data": "<html>...</html>" } ← inline content
4251
{ "file": "style.css", "path": "./dist/style.css" } ← read from disk
@@ -83,7 +92,11 @@ export async function run(args) {
8392

8493
const raw = opts.manifest ? readFileSync(opts.manifest, "utf-8") : await readStdin();
8594
const manifest = JSON.parse(raw);
86-
if (opts.manifest) resolveFilePathsInManifest(manifest, dirname(resolve(opts.manifest)));
95+
if (opts.manifest) {
96+
const baseDir = dirname(resolve(opts.manifest));
97+
resolveMigrationsFile(manifest, baseDir);
98+
resolveFilePathsInManifest(manifest, baseDir);
99+
}
87100

88101
// --project flag overrides manifest's project_id
89102
if (opts.project) manifest.project_id = opts.project;

cli/lib/manifest.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@ const TEXT_EXTS = new Set([
66
".json", ".svg", ".xml", ".txt", ".md", ".yaml", ".yml", ".toml", ".csv",
77
]);
88

9+
/**
10+
* If the manifest has `migrations_file` instead of (or in addition to) `migrations`,
11+
* read the SQL from that file path and set `migrations` to its contents.
12+
* `migrations_file` is resolved relative to `baseDir`.
13+
*
14+
* @param {object} manifest Parsed manifest JSON (mutated in place)
15+
* @param {string} baseDir Directory to resolve relative paths from
16+
* @returns {object} The same manifest object
17+
*/
18+
export function resolveMigrationsFile(manifest, baseDir) {
19+
if (!manifest.migrations_file) return manifest;
20+
const abs = resolve(baseDir, manifest.migrations_file);
21+
manifest.migrations = readFileSync(abs, "utf-8");
22+
delete manifest.migrations_file;
23+
return manifest;
24+
}
25+
926
/**
1027
* Resolve `path` fields in a manifest's files array.
1128
*

cli/lib/manifest.test.mjs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
33
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
44
import { join } from "node:path";
55
import { tmpdir } from "node:os";
6-
import { resolveFilePathsInManifest } from "./manifest.mjs";
6+
import { resolveFilePathsInManifest, resolveMigrationsFile } from "./manifest.mjs";
77

88
let tempDir;
99

@@ -13,6 +13,7 @@ before(() => {
1313
writeFileSync(join(tempDir, "index.html"), "<!DOCTYPE html><html><body>Hello</body></html>");
1414
writeFileSync(join(tempDir, "style.css"), "body { margin: 0; }");
1515
writeFileSync(join(tempDir, "logo.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); // PNG header
16+
writeFileSync(join(tempDir, "setup.sql"), "CREATE TABLE items (id serial PRIMARY KEY, data jsonb);\nINSERT INTO items (data) VALUES ('[{\"x\":0.5}]');");
1617
});
1718

1819
after(() => {
@@ -88,3 +89,40 @@ describe("resolveFilePathsInManifest", () => {
8889
);
8990
});
9091
});
92+
93+
describe("resolveMigrationsFile", () => {
94+
it("reads SQL from migrations_file and sets migrations", () => {
95+
const manifest = { migrations_file: "setup.sql" };
96+
resolveMigrationsFile(manifest, tempDir);
97+
assert.ok(manifest.migrations.includes("CREATE TABLE items"));
98+
assert.ok(manifest.migrations.includes('[{"x":0.5}]'), "should preserve JSON literals without escaping issues");
99+
assert.equal(manifest.migrations_file, undefined, "migrations_file should be removed");
100+
});
101+
102+
it("overwrites inline migrations when migrations_file is present", () => {
103+
const manifest = { migrations: "SELECT 1", migrations_file: "setup.sql" };
104+
resolveMigrationsFile(manifest, tempDir);
105+
assert.ok(manifest.migrations.includes("CREATE TABLE items"));
106+
assert.equal(manifest.migrations_file, undefined);
107+
});
108+
109+
it("leaves manifest untouched when no migrations_file", () => {
110+
const manifest = { migrations: "SELECT 1" };
111+
resolveMigrationsFile(manifest, tempDir);
112+
assert.equal(manifest.migrations, "SELECT 1");
113+
});
114+
115+
it("handles manifest with neither migrations nor migrations_file", () => {
116+
const manifest = { files: [] };
117+
resolveMigrationsFile(manifest, tempDir);
118+
assert.equal(manifest.migrations, undefined);
119+
});
120+
121+
it("throws on missing migrations file", () => {
122+
const manifest = { migrations_file: "does-not-exist.sql" };
123+
assert.throws(
124+
() => resolveMigrationsFile(manifest, tempDir),
125+
/ENOENT/,
126+
);
127+
});
128+
});

0 commit comments

Comments
 (0)