fix: make D1 migrations remote-compatible

This commit is contained in:
tianma-if committed 2026-07-21 01:39:22 +08:00
1 parent 12803f0ce4
commit 64c3f95991
7 files changed
+53 -70

No files matched your search

+3 -1
View File
@@ -15,6 +15,8 @@ If you are comfortable with Cloudflare and the command line, or prefer customize
cd edgeever
```
Install Node.js 22 or newer and Bun on the deployment machine. Wrangler is provided by the project dependencies and does not need to be installed globally.
3. **Deploy with the automated helper commands**:
```sh
# Copy the configuration template
@@ -69,7 +71,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 are confirmed automatically and require no user input. Before invoking Wrangler, the migration script also verifies that local SQL migrations exist, creates an LF-only copy, and uses a cross-platform absolute migration directory. This prevents both successful-looking Windows commands that migrate no tables and remote D1 misparsing multi-line triggers as `incomplete input`. 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 are confirmed automatically and require no user input. Before invoking Wrangler, the migration script verifies that local SQL migrations exist, creates an LF-only copy, and always runs Wrangler on its official Node.js runtime; database triggers use the single-line form that remote D1 parses correctly. This prevents Windows commands that migrate no tables, Bun exiting Wrangler early, and remote D1 misparsing triggers as `incomplete input`. 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.
+3 -1
View File
@@ -15,6 +15,8 @@
cd edgeever
```
本机需安装 Node.js 22 或更高版本以及 Bun。Wrangler 由项目依赖提供,无需全局安装。
3. **使用自动化辅助命令部署**:
```sh
# 复制配置文件模板
@@ -69,7 +71,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 前确认本地 SQL migration 存在、生成统一使用 LF 换行的副本,并使用跨平台的绝对迁移目录,避免 Windows 上出现未迁移任何表或远端 D1 将多行 Trigger 误报为 `incomplete input` 的情况。部署完成后还会执行 `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 前确认本地 SQL migration 存在、生成统一使用 LF 换行的副本,并让 Wrangler 始终运行在其官方 Node.js runtime 下;数据库 Trigger 使用远端 D1 可正确解析的单行格式。这样可以避免 Windows 上未迁移任何表、Bun 提前退出 Wrangler 进程或远端 D1 将 Trigger 误报为 `incomplete input`。部署完成后还会执行 `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`,否则旧哈希仍会优先生效。
+3 -24
View File
@@ -24,30 +24,9 @@ CREATE INDEX idx_notebooks_parent
CREATE INDEX idx_notebooks_deleted
ON notebooks(is_deleted, updated_at);
CREATE TRIGGER trg_notebooks_prevent_cycles
BEFORE UPDATE OF parent_id ON notebooks
FOR EACH ROW
WHEN NEW.parent_id IS NOT NULL
BEGIN
WITH RECURSIVE ancestors(id, parent_id) AS (
SELECT id, parent_id
FROM notebooks
WHERE id = NEW.parent_id
UNION ALL
SELECT n.id, n.parent_id
FROM notebooks n
INNER JOIN ancestors a ON n.id = a.parent_id
WHERE a.parent_id IS NOT NULL
)
SELECT RAISE(ABORT, 'notebook cycle detected')
WHERE EXISTS (
SELECT 1
FROM ancestors
WHERE id = NEW.id
);
END;
-- Keep triggers on one physical line. Remote D1 currently misparses
-- multi-line CREATE TRIGGER statements even though local D1 accepts them.
CREATE TRIGGER trg_notebooks_prevent_cycles BEFORE UPDATE OF parent_id ON notebooks FOR EACH ROW WHEN NEW.parent_id IS NOT NULL BEGIN WITH RECURSIVE ancestors(id, parent_id) AS (SELECT id, parent_id FROM notebooks WHERE id = NEW.parent_id UNION ALL SELECT n.id, n.parent_id FROM notebooks n INNER JOIN ancestors a ON n.id = a.parent_id WHERE a.parent_id IS NOT NULL) SELECT RAISE(ABORT, 'notebook cycle detected') WHERE EXISTS (SELECT 1 FROM ancestors WHERE id = NEW.id); END;
CREATE TABLE memos (
id TEXT PRIMARY KEY,
+7 -41
View File
@@ -12,44 +12,10 @@ CREATE TABLE mobile_sync_changes (
CREATE INDEX idx_mobile_sync_changes_workspace_cursor
ON mobile_sync_changes(workspace_id, id);
CREATE TRIGGER trg_mobile_sync_notebooks_insert
AFTER INSERT ON notebooks
BEGIN
INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation)
VALUES (NEW.workspace_id, 'notebook', NEW.id, 'upsert');
END;
CREATE TRIGGER trg_mobile_sync_notebooks_update
AFTER UPDATE ON notebooks
BEGIN
INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation)
VALUES (NEW.workspace_id, 'notebook', NEW.id, CASE WHEN NEW.is_deleted = 1 THEN 'delete' ELSE 'upsert' END);
END;
CREATE TRIGGER trg_mobile_sync_notebooks_delete
AFTER DELETE ON notebooks
BEGIN
INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation)
VALUES (OLD.workspace_id, 'notebook', OLD.id, 'delete');
END;
CREATE TRIGGER trg_mobile_sync_memos_insert
AFTER INSERT ON memos
BEGIN
INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation)
VALUES (NEW.workspace_id, 'memo', NEW.id, 'upsert');
END;
CREATE TRIGGER trg_mobile_sync_memos_update
AFTER UPDATE ON memos
BEGIN
INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation)
VALUES (NEW.workspace_id, 'memo', NEW.id, 'upsert');
END;
CREATE TRIGGER trg_mobile_sync_memos_delete
AFTER DELETE ON memos
BEGIN
INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation)
VALUES (OLD.workspace_id, 'memo', OLD.id, 'delete');
END;
-- Keep triggers on one physical line for remote D1 compatibility.
CREATE TRIGGER trg_mobile_sync_notebooks_insert AFTER INSERT ON notebooks BEGIN INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation) VALUES (NEW.workspace_id, 'notebook', NEW.id, 'upsert'); END;
CREATE TRIGGER trg_mobile_sync_notebooks_update AFTER UPDATE ON notebooks BEGIN INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation) VALUES (NEW.workspace_id, 'notebook', NEW.id, (CASE WHEN NEW.is_deleted = 1 THEN 'delete' ELSE 'upsert' END)); END;
CREATE TRIGGER trg_mobile_sync_notebooks_delete AFTER DELETE ON notebooks BEGIN INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation) VALUES (OLD.workspace_id, 'notebook', OLD.id, 'delete'); END;
CREATE TRIGGER trg_mobile_sync_memos_insert AFTER INSERT ON memos BEGIN INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation) VALUES (NEW.workspace_id, 'memo', NEW.id, 'upsert'); END;
CREATE TRIGGER trg_mobile_sync_memos_update AFTER UPDATE ON memos BEGIN INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation) VALUES (NEW.workspace_id, 'memo', NEW.id, 'upsert'); END;
CREATE TRIGGER trg_mobile_sync_memos_delete AFTER DELETE ON memos BEGIN INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation) VALUES (OLD.workspace_id, 'memo', OLD.id, 'delete'); END;
+2 -1
View File
@@ -89,6 +89,7 @@ const configuredMigrationsDirectory = config.match(/^migrations_dir\s*=\s*"([^"]
?? "migrations";
const migrationsDirectory = resolve(baseConfigDirectory, configuredMigrationsDirectory);
const generatedMigrationsDirectory = resolve(".wrangler.generated-migrations");
const generatedMigrationsConfigDirectory = ".wrangler.generated-migrations";
if (migrationCommand) {
const migrationFiles = existsSync(migrationsDirectory)
@@ -266,7 +267,7 @@ if (changed) {
config = config.replace(
/^migrations_dir\s*=\s*"[^"]+"/m,
`migrations_dir = ${tomlString(
(migrationCommand ? generatedMigrationsDirectory : migrationsDirectory).replaceAll("\\", "/"),
(migrationCommand ? generatedMigrationsConfigDirectory : migrationsDirectory).replaceAll("\\", "/"),
)}`,
);
writeFileSync(generatedConfigPath, config);
+14 -2
View File
@@ -5,8 +5,11 @@ import { resolve } from "node:path";
export const resolveWranglerCliPath = (cwd = resolve(".")) =>
resolve(cwd, "node_modules", "wrangler", "bin", "wrangler.js");
export const resolveWranglerRuntimeExecutable = (runtimeExecutable) =>
runtimeExecutable ?? (process.versions.bun ? "node" : process.execPath);
export const buildWranglerInvocation = (args, options = {}) => ({
executable: options.runtimeExecutable ?? process.execPath,
executable: resolveWranglerRuntimeExecutable(options.runtimeExecutable),
args: [resolveWranglerCliPath(options.cwd), ...args],
});
@@ -53,10 +56,19 @@ export const runWranglerSync = (args, options = {}) => {
const { runtimeExecutable, ...spawnOptions } = options;
const finalSpawnOptions = buildWranglerSpawnOptions(args, spawnOptions);
return spawnSync(runtimeExecutable ?? process.execPath, [cliPath, ...args], {
const runtime = resolveWranglerRuntimeExecutable(runtimeExecutable);
const result = spawnSync(runtime, [cliPath, ...args], {
cwd,
shell: false,
...finalSpawnOptions,
env: buildWranglerEnvironment(args, finalSpawnOptions.env),
});
if (result.error?.code === "ENOENT" && runtime === "node") {
result.error = new Error(
"Node.js 22 or newer is required to run Wrangler reliably. Install Node.js, reopen the terminal, and retry.",
);
}
return result;
};
+21
View File
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
buildWranglerInvocation,
buildWranglerEnvironment,
@@ -6,6 +8,7 @@ import {
isD1MigrationApplyCommand,
normalizeD1MigrationSql,
resolveWranglerCliPath,
resolveWranglerRuntimeExecutable,
runWranglerSync,
} from "../scripts/wrangler-runner.mjs";
@@ -29,6 +32,10 @@ describe("cross-platform Wrangler runner", () => {
expect(result.stdout).toMatch(/^\d+\.\d+\.\d+/);
});
test("runs Wrangler with Node when the project command itself uses Bun", () => {
expect(resolveWranglerRuntimeExecutable()).toBe(process.versions.bun ? "node" : process.execPath);
});
test("forces D1 migration apply into non-interactive CI mode", () => {
const args = ["--config", "generated.toml", "d1", "migrations", "apply", "DB", "--remote"];
@@ -49,4 +56,18 @@ describe("cross-platform Wrangler runner", () => {
"CREATE TABLE demo (id TEXT);\n\nSELECT 1;\n",
);
});
test("keeps every D1 trigger on one physical line for remote compatibility", () => {
const migrationSql = ["0001_initial.sql", "0013_mobile_sync_changes.sql"]
.map((file) => readFileSync(resolve("migrations", file), "utf8"))
.join("\n");
const triggerLines = migrationSql
.split("\n")
.filter((line) => line.startsWith("CREATE TRIGGER "));
expect(triggerLines).toHaveLength(7);
for (const triggerLine of triggerLines) {
expect(triggerLine).toEndWith(" END;");
}
});
});