fix: make D1 migrations reliable on Windows

This commit is contained in:
tianma-if committed 2026-07-21 01:21:03 +08:00
1 parent d0a9c918db
commit e91f9cadd8
5 files changed
+74 -8

No files matched your search

+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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`,否则旧哈希仍会优先生效。
+31 -2
View File
@@ -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);
}
+30 -4
View File
@@ -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) {
+11
View File
@@ -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",