fix: make D1 migrations reliable on Windows
This commit is contained in:
1 parent
d0a9c918db
commit
e91f9cadd8
5 files changed
+74
-8
No files matched your search
@@ -69,7 +69,7 @@ Before running `bun run deploy`, copy the D1 `database_id` and R2 bucket name in
|
||||
|
||||
`.env.local` is read only by the local EdgeEver scripts. It is not automatically uploaded to Cloudflare as a file, and it must never be uploaded manually or committed to Git. A build started directly from Cloudflare Dashboard does not automatically receive the D1, R2, or password settings in this file. Use `bun run deploy` for the first deployment, then use `bun run deploy:builds:setup` to copy the required settings securely into Workers Builds.
|
||||
|
||||
The deployment command now runs `deploy:doctor` first and stops when the D1 ID, R2 bucket, or login password is missing. Remote D1 migrations run automatically in non-interactive mode, so no confirmation is required and a cancelled Wrangler prompt cannot be mistaken for success. After deployment, `deploy:verify` checks the remote D1 schema and the Worker's authentication Secret; a missing requirement makes deployment exit as failed. The Worker also fails closed: when a production instance has neither a password Secret nor an enabled user, it returns `auth_not_configured` instead of opening an unauthenticated workspace. `/api/health` checks D1 and authentication initialization; the instance is ready only when it returns `200` with `"ok": true`.
|
||||
The deployment command now runs `deploy:doctor` first and stops when the D1 ID, R2 bucket, or login password is missing. Remote D1 migrations run automatically in non-interactive mode, so no confirmation is required and a cancelled Wrangler prompt cannot be mistaken for success. Before invoking Wrangler, the migration script also verifies that local SQL migrations exist and uses a cross-platform absolute migration directory, preventing successful-looking Windows commands that migrate no tables. After deployment, `deploy:verify` checks the remote D1 schema and the Worker's authentication Secret; a missing requirement makes deployment exit as failed. The Worker also fails closed: when a production instance has neither a password Secret nor an enabled user, it returns `auth_not_configured` instead of opening an unauthenticated workspace. `/api/health` checks D1 and authentication initialization; the instance is ready only when it returns `200` with `"ok": true`.
|
||||
|
||||
Existing installations do not need to migrate. If you intentionally switch from the hash setting to `EDGE_EVER_AUTH_PASSWORD`, remove the old `EDGE_EVER_AUTH_PASSWORD_HASH` from `.env.local`, Workers Builds, and the Worker's runtime Secrets; otherwise the legacy hash remains authoritative.
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ bun run deploy
|
||||
|
||||
`.env.local` 只供本机 EdgeEver 脚本读取,不会作为文件自动上传到 Cloudflare,也不应手工上传或提交到 Git。若从 Cloudflare Dashboard 直接触发构建,该文件中的 D1、R2 和密码配置不会自动生效;请使用 `bun run deploy` 完成首次部署,再使用 `bun run deploy:builds:setup` 将必要配置安全地写入 Workers Builds。
|
||||
|
||||
部署命令会先自动执行 `deploy:doctor`,缺少 D1 ID、R2 bucket 或登录密码时会直接停止。远程 D1 migration 会以非交互模式自动执行,不需要用户确认,避免取消操作被 Wrangler 误报为成功。部署完成后还会执行 `deploy:verify`,检查远程 D1 的关键数据表和 Worker 鉴权 Secret;任一缺失都会让部署以失败退出。Worker 也采用安全关闭策略:生产实例既没有密码 Secret、又没有有效用户时,会返回 `auth_not_configured`,不会降级成免登录工作区。`/api/health` 会检查 D1 和鉴权初始化状态;只有返回 `200` 且包含 `"ok": true` 才表示实例真正可用。
|
||||
部署命令会先自动执行 `deploy:doctor`,缺少 D1 ID、R2 bucket 或登录密码时会直接停止。远程 D1 migration 会以非交互模式自动执行,不需要用户确认,避免取消操作被 Wrangler 误报为成功。迁移脚本还会在调用 Wrangler 前确认本地 SQL migration 存在,并使用跨平台的绝对迁移目录,避免 Windows 上出现命令成功但没有迁移任何表的情况。部署完成后还会执行 `deploy:verify`,检查远程 D1 的关键数据表和 Worker 鉴权 Secret;任一缺失都会让部署以失败退出。Worker 也采用安全关闭策略:生产实例既没有密码 Secret、又没有有效用户时,会返回 `auth_not_configured`,不会降级成免登录工作区。`/api/health` 会检查 D1 和鉴权初始化状态;只有返回 `200` 且包含 `"ok": true` 才表示实例真正可用。
|
||||
|
||||
已有实例无需迁移。如果确实要从哈希配置切换为 `EDGE_EVER_AUTH_PASSWORD`,需要同时从 `.env.local`、Workers Builds 和 Worker 运行时 Secrets 中移除旧的 `EDGE_EVER_AUTH_PASSWORD_HASH`,否则旧哈希仍会优先生效。
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { runWranglerSync } from "./wrangler-runner.mjs";
|
||||
|
||||
const PLACEHOLDER_D1_ID = "00000000-0000-0000-0000-000000000000";
|
||||
@@ -57,6 +57,7 @@ if (requestedInstance !== undefined) {
|
||||
}
|
||||
|
||||
const baseConfigPath = resolve(process.env.WRANGLER_CONFIG ?? "wrangler.toml");
|
||||
const baseConfigDirectory = dirname(baseConfigPath);
|
||||
const instance = process.env.EDGE_EVER_INSTANCE?.trim();
|
||||
const instanceKey = instance?.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase();
|
||||
const generatedConfigPath = resolve(
|
||||
@@ -73,6 +74,27 @@ const generatedLocalDevEnvPath = resolve(".env.wrangler.generated.local");
|
||||
let config = readFileSync(baseConfigPath, "utf8");
|
||||
let changed = false;
|
||||
|
||||
const migrationCommand =
|
||||
wranglerArgs[0] === "d1"
|
||||
&& wranglerArgs[1] === "migrations"
|
||||
&& ["apply", "list"].includes(wranglerArgs[2]);
|
||||
const configuredMigrationsDirectory = config.match(/^migrations_dir\s*=\s*"([^"]+)"/m)?.[1]
|
||||
?? "migrations";
|
||||
const migrationsDirectory = resolve(baseConfigDirectory, configuredMigrationsDirectory);
|
||||
|
||||
if (migrationCommand) {
|
||||
const migrationFiles = existsSync(migrationsDirectory)
|
||||
? readdirSync(migrationsDirectory).filter((name) => name.endsWith(".sql")).sort()
|
||||
: [];
|
||||
if (migrationFiles.length === 0) {
|
||||
console.error(
|
||||
`No D1 migration files found in ${migrationsDirectory}. Update the repository checkout before retrying.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[ok] local D1 migrations: ${migrationFiles.length} files`);
|
||||
}
|
||||
|
||||
const replaceTomlValue = (source, key, value) => {
|
||||
if (!value) {
|
||||
return source;
|
||||
@@ -215,6 +237,13 @@ if (isRemoteCommand && config.includes(`database_id = "${PLACEHOLDER_D1_ID}"`))
|
||||
|
||||
const configPath = changed ? generatedConfigPath : baseConfigPath;
|
||||
if (changed) {
|
||||
// Wrangler resolves migrations_dir relative to its config. Use an absolute,
|
||||
// slash-normalized path so generated configs behave consistently in Windows
|
||||
// Git Bash, PowerShell, Linux, and macOS.
|
||||
config = config.replace(
|
||||
/^migrations_dir\s*=\s*"[^"]+"/m,
|
||||
`migrations_dir = ${tomlString(migrationsDirectory.replaceAll("\\", "/"))}`,
|
||||
);
|
||||
writeFileSync(generatedConfigPath, config);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,19 +14,38 @@ export const REQUIRED_TABLES = [
|
||||
export const buildSchemaVerificationSql = () =>
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${REQUIRED_TABLES.map((name) => `'${name}'`).join(", ")}) ORDER BY name`;
|
||||
|
||||
export const parseJsonOutput = (output, description) => {
|
||||
if (!output.trim()) {
|
||||
throw new Error(`Wrangler returned no JSON while checking ${description}.`);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Wrangler returned invalid JSON while checking ${description}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const parseD1Rows = (output) => {
|
||||
const parsed = JSON.parse(output);
|
||||
// Some Wrangler versions emit an empty stdout for a successful D1 query
|
||||
// with zero rows instead of the documented empty JSON result.
|
||||
if (!output.trim()) {
|
||||
return [];
|
||||
}
|
||||
const parsed = parseJsonOutput(output, "the remote D1 schema");
|
||||
const results = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return results.flatMap((result) => result?.results ?? []);
|
||||
};
|
||||
|
||||
export const parseSecretNames = (output) => {
|
||||
const parsed = JSON.parse(output);
|
||||
const parsed = parseJsonOutput(output, "Worker Secrets");
|
||||
const secrets = Array.isArray(parsed) ? parsed : parsed?.secrets ?? parsed?.result ?? [];
|
||||
return new Set(secrets.map((secret) => secret?.name).filter(Boolean));
|
||||
};
|
||||
|
||||
const runWrangler = (args) => {
|
||||
const runWrangler = (args, options = {}) => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[resolve("scripts/run-wrangler.mjs"), ...args],
|
||||
@@ -39,6 +58,13 @@ const runWrangler = (args) => {
|
||||
throw new Error(`Wrangler verification command exited with status ${result.status ?? 1}.`);
|
||||
}
|
||||
|
||||
if (!result.stdout.trim() && !options.allowEmptyOutput) {
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
throw new Error(
|
||||
"Wrangler completed without returning verification data. Re-run bun run deploy:doctor, then retry.",
|
||||
);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
};
|
||||
|
||||
@@ -51,7 +77,7 @@ const main = () => {
|
||||
"--command",
|
||||
buildSchemaVerificationSql(),
|
||||
"--json",
|
||||
]);
|
||||
], { allowEmptyOutput: true });
|
||||
const tableNames = new Set(parseD1Rows(schemaOutput).map((row) => row.name));
|
||||
const missingTables = REQUIRED_TABLES.filter((table) => !tableNames.has(table));
|
||||
if (missingTables.length > 0) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildSchemaVerificationSql,
|
||||
parseD1Rows,
|
||||
parseJsonOutput,
|
||||
parseSecretNames,
|
||||
REQUIRED_TABLES,
|
||||
} from "../scripts/verify-deployment.mjs";
|
||||
@@ -18,6 +19,16 @@ describe("deployment verification", () => {
|
||||
expect(parseD1Rows(JSON.stringify([{ results: [{ name: "users" }] }]))).toEqual([{ name: "users" }]);
|
||||
});
|
||||
|
||||
test("reports empty Wrangler JSON output clearly", () => {
|
||||
expect(() => parseJsonOutput("", "the remote D1 schema")).toThrow(
|
||||
"Wrangler returned no JSON while checking the remote D1 schema.",
|
||||
);
|
||||
});
|
||||
|
||||
test("treats an empty D1 result as zero rows", () => {
|
||||
expect(parseD1Rows("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("parses current and wrapped Wrangler secret output", () => {
|
||||
expect(parseSecretNames(JSON.stringify([{ name: "EDGE_EVER_AUTH_PASSWORD" }]))).toContain(
|
||||
"EDGE_EVER_AUTH_PASSWORD",
|
||||
|
||||
Reference in new issue
Block a user