fix(release): execute d1 migrations without shell
This commit is contained in:
1 parent
cb315f308d
commit
eec26a09a3
4 files changed
+270
-31
No files matched your search
@@ -7,6 +7,7 @@ import { loadEnvFile } from "./release/lib/dotenv.mjs";
|
||||
import { createRunner } from "./release/lib/process.mjs";
|
||||
import { createWorkflowHelpers } from "./release/lib/workflow.mjs";
|
||||
import { createSnapshotHelpers } from "./release/lib/snapshot.mjs";
|
||||
import { applyD1Migrations } from "./release/lib/d1-migrations.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
@@ -221,25 +222,7 @@ async function uploadWinAndLinuxToR2() {
|
||||
}
|
||||
|
||||
async function applyLandingMigrations() {
|
||||
const migrationsDir = join(landingDir, "migrations");
|
||||
if (!existsSync(migrationsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wranglerToml = await readFile(join(landingDir, "wrangler.toml"), "utf8");
|
||||
const match = wranglerToml.match(/database_name\s*=\s*"([^"]+)"/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
const databaseName = match[1];
|
||||
const migrationFiles = (await readdir(migrationsDir))
|
||||
.filter((fileName) => fileName.endsWith(".sql"))
|
||||
.sort();
|
||||
|
||||
for (const fileName of migrationFiles) {
|
||||
const command = `printf 'y\\n' | npx wrangler d1 execute ${databaseName} --remote --file migrations/${fileName}`;
|
||||
await run("bash", ["-lc", command], { cwd: landingDir });
|
||||
}
|
||||
await applyD1Migrations({ landingDir, run });
|
||||
}
|
||||
|
||||
async function buildAndDeployLanding() {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const databaseNamePattern = /^[A-Za-z0-9_-]+$/;
|
||||
const migrationFilePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*\.sql$/;
|
||||
|
||||
function stripTomlComment(line) {
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = 0; i < line.length; i += 1) {
|
||||
const char = line[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "\"") {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (char === "#" && !inString) {
|
||||
return line.slice(0, i);
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseTomlString(value, key) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("\"") || !trimmed.endsWith("\"")) {
|
||||
throw new Error(`wrangler.toml key "${key}" must be a quoted string.`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`wrangler.toml key "${key}" has invalid string syntax: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssignment(line) {
|
||||
const equals = line.indexOf("=");
|
||||
if (equals === -1) return null;
|
||||
const key = line.slice(0, equals).trim();
|
||||
const value = line.slice(equals + 1).trim();
|
||||
if (!key) return null;
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
export function validateD1DatabaseName(databaseName) {
|
||||
if (!databaseNamePattern.test(databaseName)) {
|
||||
throw new Error(
|
||||
`Invalid Cloudflare D1 database_name "${databaseName}". Use only letters, numbers, underscores, and hyphens.`,
|
||||
);
|
||||
}
|
||||
return databaseName;
|
||||
}
|
||||
|
||||
export function validateD1MigrationFileName(fileName) {
|
||||
if (!migrationFilePattern.test(fileName)) {
|
||||
throw new Error(
|
||||
`Invalid D1 migration file name "${fileName}". Use a plain .sql file name with letters, numbers, dots, underscores, and hyphens.`,
|
||||
);
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
export function parseD1DatabaseName(wranglerToml) {
|
||||
let inD1Database = false;
|
||||
for (const rawLine of wranglerToml.split(/\r?\n/)) {
|
||||
const line = stripTomlComment(rawLine).trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("[[")) {
|
||||
inD1Database = line === "[[d1_databases]]";
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("[")) {
|
||||
inD1Database = false;
|
||||
continue;
|
||||
}
|
||||
if (!inD1Database) continue;
|
||||
const assignment = parseAssignment(line);
|
||||
if (!assignment || assignment.key !== "database_name") continue;
|
||||
return validateD1DatabaseName(
|
||||
parseTomlString(assignment.value, assignment.key),
|
||||
);
|
||||
}
|
||||
throw new Error("landing/wrangler.toml does not define [[d1_databases]].database_name.");
|
||||
}
|
||||
|
||||
export async function listD1MigrationFiles(migrationsDir) {
|
||||
return (await readdir(migrationsDir))
|
||||
.filter((fileName) => fileName.endsWith(".sql"))
|
||||
.map(validateD1MigrationFileName)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export async function applyD1Migrations({ landingDir, run }) {
|
||||
const migrationsDir = join(landingDir, "migrations");
|
||||
if (!existsSync(migrationsDir)) {
|
||||
return { databaseName: null, applied: 0 };
|
||||
}
|
||||
|
||||
const wranglerToml = await readFile(join(landingDir, "wrangler.toml"), "utf8");
|
||||
const databaseName = parseD1DatabaseName(wranglerToml);
|
||||
const migrationFiles = await listD1MigrationFiles(migrationsDir);
|
||||
|
||||
for (const fileName of migrationFiles) {
|
||||
await run(
|
||||
"npx",
|
||||
[
|
||||
"wrangler",
|
||||
"d1",
|
||||
"execute",
|
||||
databaseName,
|
||||
"--remote",
|
||||
"--file",
|
||||
`migrations/${fileName}`,
|
||||
],
|
||||
{ cwd: landingDir, input: "y\n" },
|
||||
);
|
||||
}
|
||||
|
||||
return { databaseName, applied: migrationFiles.length };
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { prepareR2ReleaseHost, uploadReleaseFiles } from "./release-local/r2.mjs";
|
||||
import { loadEnvFile } from "./release/lib/dotenv.mjs";
|
||||
import { createRunner } from "./release/lib/process.mjs";
|
||||
import { createWorkflowHelpers } from "./release/lib/workflow.mjs";
|
||||
import { applyD1Migrations } from "./release/lib/d1-migrations.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
@@ -37,17 +38,7 @@ async function uploadWinAndLinuxToR2() {
|
||||
}
|
||||
|
||||
async function applyLandingMigrations() {
|
||||
const migrationsDir = join(landingDir, "migrations");
|
||||
if (!existsSync(migrationsDir)) return;
|
||||
const wranglerToml = await readFile(join(landingDir, "wrangler.toml"), "utf8");
|
||||
const match = wranglerToml.match(/database_name\s*=\s*"([^"]+)"/);
|
||||
if (!match) return;
|
||||
const databaseName = match[1];
|
||||
const migrationFiles = (await readdir(migrationsDir)).filter((n) => n.endsWith(".sql")).sort();
|
||||
for (const fileName of migrationFiles) {
|
||||
const command = `printf 'y\\n' | npx wrangler d1 execute ${databaseName} --remote --file migrations/${fileName}`;
|
||||
await run("bash", ["-lc", command], { cwd: landingDir });
|
||||
}
|
||||
await applyD1Migrations({ landingDir, run });
|
||||
}
|
||||
|
||||
async function buildAndDeployLanding() {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
interface D1MigrationModule {
|
||||
applyD1Migrations(input: {
|
||||
landingDir: string;
|
||||
run: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; input: string },
|
||||
) => Promise<unknown>;
|
||||
}): Promise<{ databaseName: string | null; applied: number }>;
|
||||
parseD1DatabaseName(source: string): string;
|
||||
validateD1MigrationFileName(fileName: string): string;
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function loadModule(): Promise<D1MigrationModule> {
|
||||
const moduleUrl = pathToFileURL(
|
||||
join(process.cwd(), "scripts/release/lib/d1-migrations.mjs"),
|
||||
).href;
|
||||
return await import(moduleUrl) as D1MigrationModule;
|
||||
}
|
||||
|
||||
async function createLandingFixture() {
|
||||
const landingDir = await mkdtemp(join(tmpdir(), "agentcompany-d1-test-"));
|
||||
tempDirs.push(landingDir);
|
||||
await mkdir(join(landingDir, "migrations"), { recursive: true });
|
||||
await writeFile(
|
||||
join(landingDir, "wrangler.toml"),
|
||||
[
|
||||
"name = \"agentcompany\"",
|
||||
"",
|
||||
"[[d1_databases]]",
|
||||
"binding = \"DOWNLOAD_COUNTER_DB\"",
|
||||
"database_name = \"agentcompany-landing-downloads\"",
|
||||
"database_id = \"dfa3f0f3-009b-4436-b519-5b50ae6b1cf9\"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
return landingDir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("Cloudflare D1 migration release helper", () => {
|
||||
it("parses the D1 database name from the wrangler D1 table", async () => {
|
||||
const { parseD1DatabaseName } = await loadModule();
|
||||
|
||||
expect(parseD1DatabaseName([
|
||||
"[vars]",
|
||||
"DOWNLOAD_COUNTER_SALT = \"salt\"",
|
||||
"",
|
||||
"[[d1_databases]]",
|
||||
"binding = \"DOWNLOAD_COUNTER_DB\"",
|
||||
"database_name = \"agentcompany-landing-downloads\" # production",
|
||||
].join("\n"))).toBe("agentcompany-landing-downloads");
|
||||
});
|
||||
|
||||
it("rejects shell-shaped database names", async () => {
|
||||
const { parseD1DatabaseName } = await loadModule();
|
||||
|
||||
expect(() => parseD1DatabaseName([
|
||||
"[[d1_databases]]",
|
||||
"database_name = \"prod; rm -rf /\"",
|
||||
].join("\n"))).toThrow("Invalid Cloudflare D1 database_name");
|
||||
});
|
||||
|
||||
it("rejects path-shaped migration file names", async () => {
|
||||
const { validateD1MigrationFileName } = await loadModule();
|
||||
|
||||
expect(() => validateD1MigrationFileName("../0001.sql")).toThrow(
|
||||
"Invalid D1 migration file name",
|
||||
);
|
||||
expect(() => validateD1MigrationFileName("0001;touch.sql")).toThrow(
|
||||
"Invalid D1 migration file name",
|
||||
);
|
||||
});
|
||||
|
||||
it("executes wrangler through argv and confirms through stdin", async () => {
|
||||
const { applyD1Migrations } = await loadModule();
|
||||
const landingDir = await createLandingFixture();
|
||||
await writeFile(
|
||||
join(landingDir, "migrations", "0001_download_counter.sql"),
|
||||
"SELECT 1;",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(landingDir, "migrations", "notes.txt"),
|
||||
"ignored",
|
||||
"utf8",
|
||||
);
|
||||
const calls: Array<{
|
||||
command: string;
|
||||
args: string[];
|
||||
options: { cwd: string; input: string };
|
||||
}> = [];
|
||||
|
||||
const result = await applyD1Migrations({
|
||||
landingDir,
|
||||
run: async (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
databaseName: "agentcompany-landing-downloads",
|
||||
applied: 1,
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: "npx",
|
||||
args: [
|
||||
"wrangler",
|
||||
"d1",
|
||||
"execute",
|
||||
"agentcompany-landing-downloads",
|
||||
"--remote",
|
||||
"--file",
|
||||
"migrations/0001_download_counter.sql",
|
||||
],
|
||||
options: { cwd: landingDir, input: "y\n" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user