OpenSEO 本地 Postgres 开发指南:Docker 容器、Drizzle 迁移与 Hyperdrive 绑定的完整实操
【免费下载链接】open-seoOpen source alternative to Semrush and Ahrefs项目地址: https://gitcode.com/GitHub_Trending/op/open-seo
OpenSEO 默认运行在 Cloudflare D1(SQLite)之上,Postgres 是面向数据量突破 D1 存储上限场景的可选后端,两者通过DATABASE_PROVIDER标志和 provider-aware 的db层在运行时切换。本文基于仓库内的 LOCAL_POSTGRES.md 展开,带你用 Docker 快速搭起一套一次性 Postgres 环境,完成迁移、应用指向与数据验证,并结合src/db/下的源码讲清这条 Postgres 路径的底层实现。读完你可以独立完成 Postgres 本地开发环境的全部搭建与拆除,并理解 OpenSEO 双方言数据库层的运行机制。
为什么是 D1 默认、Postgres 可选
OpenSEO 的应用代码只写一份,直接面向src/db/中 provider-aware 的db句柄;运行时唯一的区别就是DATABASE_PROVIDER标志和一条连接串。这一点在 src/db/index.ts 中有直接体现:
export const db = (getDatabaseProvider() === "postgres" ? pgDb : d1Db) as unknown as typeof d1Db;即整库句柄被统一以 D1 客户端的类型导出(Postgres schema 与 SQLite schema 结构一致性由专门的 parity 测试保证,见下文),业务层因此无需感知方言。文档明确指出:普通开发不需要这套 Postgres 环境,D1 是默认路径,也是多数贡献者应该使用的路径。这套指南服务于两类场景:本地验证 Postgres 代码路径,以及为“D1 用尽后”的规模化部署做预演。
DATABASE_PROVIDER的解析逻辑在 src/db/provider.ts:
export function getDatabaseProvider(): DatabaseProvider { const provider = Reflect.get(env, "DATABASE_PROVIDER"); if (provider === "postgres") { return "postgres"; } if (provider === "d1" || provider === undefined || provider === "") { return "d1"; } throw new Error( `Unsupported DATABASE_PROVIDER "${String(provider)}". Expected "d1" or "postgres".`, ); }取值只有d1与postgres两种语义:未设置或空串时静默回落到 D1,出现其他值则直接抛错,避免静默走错后端。
前置条件
- Docker Desktop(或 Docker Engine);
- 常规的本地开发环境,即 LOCAL_DEVELOPMENT.md 中描述的 Node.js 20+ / Corepack 激活的 pnpm 环境,且已完成
corepack enable、pnpm install --frozen-lockfile与pnpm run db:migrate:local等基础步骤。
第一步:启动一次性 Postgres 容器
宿主机使用端口5433来避免与本机默认占用5432的系统 Postgres 冲突:
docker run --name openseo-postgres \ -e POSTGRES_USER=openseo \ -e POSTGRES_PASSWORD=openseo \ -e POSTGRES_DB=openseo \ -p 5433:5432 \ -d postgres:16容器内仍是标准5432,通过-p 5433:5432映射到宿主机的5433。等待其接受连接:
docker exec openseo-postgres pg_isready -U openseo -d openseo此时可用的连接串为:
postgres://openseo:openseo@localhost:5433/openseo第二步:应用 Postgres 迁移
Postgres 侧的 schema 是手写的——它是唯一一个db:generate不会重新生成的结构性产物;对应的迁移文件位于 drizzle-pg/ 目录(与 D1 的 drizzle/ 目录并存)。迁移通过pnpm db:migrate:pg应用,该命令定义在 package.json 中:
"db:migrate:pg": "drizzle-kit migrate --config drizzle-pg.config.ts"它要求POSTGRES_DATABASE_URL已设置,drizzle-kit会从 shell 环境读取。配置文件 drizzle-pg.config.ts 的关键内容:
import { defineConfig } from "drizzle-kit"; import { loadLocalEnv } from "./scripts/cli-utils"; // Pull POSTGRES_DATABASE_URL from .env.local (no-op if already in the shell env), // so the migration runbooks' single .env.local works for `db:migrate:pg` too. loadLocalEnv(); export default defineConfig({ dialect: "postgresql", schema: "./src/db/pg/schema.ts", out: "./drizzle-pg", dbCredentials: { url: process.env.POSTGRES_DATABASE_URL!, }, });注意loadLocalEnv():它来自 scripts/cli-utils.ts,会依次读取.env.local、.env,并把未设置的变量补进process.env。这意味着你可以把POSTGRES_DATABASE_URL只写进.env.local,而无需在每条命令前内联赋值——文档给出的标准命令则演示了内联方式:
POSTGRES_DATABASE_URL=postgres://openseo:openseo@localhost:5433/openseo \ pnpm db:migrate:pg
POSTGRES_DATABASE_URL只被 Node 侧工具读取(drizzle-kit与 scripts/migrate-d1-to-postgres.ts),应用本身会忽略它,这一点在排查“迁移成功了但应用连不上”之类的问题时尤其关键。
第三步:让应用指向 Postgres
Cloudflare Vite 运行时从.env.local读取 Worker vars,所以 provider 标志必须写在那里(而不是仅仅 export 到 shell):
# .env.local DATABASE_PROVIDER=postgres连接串则来自HYPERDRIVE绑定。wrangler.jsonc中的hyperdrive块随仓库以注释形式提供,需要先取消注释(见 wrangler.jsonc 中的相关注释块):
// "hyperdrive": [ // { // "binding": "HYPERDRIVE", // "id": "9d64ccfb559f44449ce52a143912f898", // "localConnectionString": "postgres://openseo:openseo@localhost:5433/openseo", // }, // ],本地开发时,Miniflare 会把这个绑定解析为localConnectionString——它已经指向第一步的 Docker 容器,且 Miniflare 从不接触真实的 Hyperdrive 服务。(在已部署的 Worker 中,同一个绑定解析到真正的 Hyperdrive,应用只有经过这个绑定才会连接 Postgres,不存在直连回退。)如果本地 Postgres 不在默认位置,可以不改配置直接覆盖:
CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE=postgres://... pnpm dev然后照常启动开发服务器:
pnpm dev要切回 D1,删除.env.local中那行(或改为DATABASE_PROVIDER=d1)再重启即可。
源码视角:应用如何真正访问 Postgres
这条绑定在运行时的消费入口是 src/db/provider.ts 的getPostgresConnectionString():
export function getPostgresConnectionString() { const hyperdrive = Reflect.get(env, "HYPERDRIVE") as | { connectionString?: string } | undefined; const hyperdriveUrl = hyperdrive?.connectionString?.trim(); if (hyperdriveUrl) { return hyperdriveUrl; } throw new Error( "DATABASE_PROVIDER=postgres requires a HYPERDRIVE binding (in local dev, its localConnectionString).", ); }即:只从HYPERDRIVE绑定取连接串,取不到就抛错,与文档“Hyperdrive is the ONLY way the app connects to Postgres”的表述一致。
拿到连接串之后,src/db/pg/client.ts 展示了 Workers 环境下 Postgres 的硬性约束:运行时禁止一个请求复用另一个请求创建的 socket("Cannot perform I/O on behalf of a different request"),所以每个请求都必须持有独立的 Postgres 客户端。实现上,pgDb是一个Proxy,它从AsyncLocalStorage中取出当前请求作用域的客户端;各入口点(fetch、scheduledcron、WorkflowEntrypointrun)统一用withPgClient()包裹(src/db/pg/client.ts):
export async function withPgClient<T>(fn: () => Promise<T>): Promise<T> { if (getDatabaseProvider() !== "postgres") { return fn(); // D1 模式下是空操作 } if (pgClientStore.getStore()) { return fn(); // 可重入:嵌套作用域复用环境中的客户端 } const sql = withQueryRetries( postgres(getPostgresConnectionString(), { max: 1, // Hyperdrive 在边缘池化源连接,客户端再建池只会引入陈旧连接风险 fetch_types: false, connect_timeout: 10, // 限制连接等待,让 per-query 重试在请求生命周期内生效 }), ); return pgClientStore.run({ sql, db: createPgDb(sql) }, fn); }几个值得注意的细节:
- 单连接(
max: 1)且不调用sql.end():Workers 与 Hyperdrive 之间的 socket 在调用结束时自动拆除,而源端连接保持热态供复用;不主动断开还允许流式响应在 handler 返回后继续查询; - 瞬时连接错误重试:src/db/pg/retry.ts 的
withQueryRetries包装了 postgres.js 的sql.unsafe入口,对连接类错误(ECONNREFUSED、08000等 Postgres class 08 错误码等)按250/1000/2500ms + 随机抖动的间隔重试。为保证写安全,只有“查询到达服务器之前”就失败的错误才允许对写语句重试,连接中途断开时仅SELECT会被重放,事务则完全不做重试(src/db/pg/retry.ts); - 原子批量写入必须走
runBatch:db.batch只存在于 D1 驱动,Postgres 驱动下调用会抛错。src/db/runBatch.ts 提供了双后端一致的原子批量语义——D1 走db.batch([...]),Postgres 走db.transaction内按数组顺序逐条执行,并配合DB_BATCH_SIZE = 100的executeInBatches分片(该批大小受 D1 每条语句约 100 个绑定参数的上限约束)。
第四步:验证
用psql直接检查迁移建出的表,以及应用写入的数据:
# 迁移创建的表 docker exec openseo-postgres psql -U openseo -d openseo -c "\dt" # 查看应用写入的行(例如创建项目 / 保存关键词之后) docker exec openseo-postgres psql -U openseo -d openseo -c "select count(*) from projects;"projects表来自迁移产物 drizzle-pg/ 下的 SQL 文件(目录内含0000至0042共 40 余个版本化迁移),如果\dt能看到对应表而select count(*)在你操作应用后仍然为 0,通常说明应用还在走 D1 路径——回到第三步检查.env.local与wrangler.jsonc的 hyperdrive 块。
Schema 变更:两个方言都要改
修改任何表时,需要同时更新两套方言:
- SQLite:
src/db/*.schema.ts(配合pnpm db:generate:d1,迁移输出到 drizzle/); - Postgres:
src/db/pg/*.schema.ts(配合pnpm db:generate:pg,迁移输出到 drizzle-pg/)。
两套命令都定义在 package.json 中,pnpm db:generate会顺序执行两者。防漂移的守门员是 src/db/schema-parity.test.ts,它会在 CI 中因两套方言漂移而失败。从测试源码看,它逐表比对的维度相当完整:
- 两套方言定义了相同的表集合;
- 每表的列(名称、可空性、方言无关的
dataType、默认值、enum 取值); - 主键(含复合主键);
- 唯一约束/唯一索引,且索引是否带
WHERE谓词(partial→full 的变更会被捕获,因为它会改变onConflict语义); - 外键(含
onDelete行为,repository 层的级联删除依赖这一不变量); - CHECK 约束名称;
- better-auth 表因为按方言生成(SQLite
integer时间戳 vs Postgrestimestamptz/jsonb),只比较结构而非列类型,但额外校验了一批 CLI 不会生成、需要手工补齐的二级索引(如session.user_id、organization.slug唯一索引)仍然存在。
还有一个容易踩的坑,文档特别强调:parity 测试比较的是schema 定义,而不是生成的迁移文件。所以修改 Postgres schema 后,务必运行pnpm db:generate:pg并提交新的drizzle-pg/迁移——否则即便 parity 测试是绿的,Postgres 部署也会漏掉这次变更。
此外,同一份测试还静态扫描src/目录,禁止任何文件在src/db/runBatch.ts之外直接调用.batch(,从 CI 层面固化了上文“原子多语句写入必须走runBatch”的约束。
拆除环境
docker rm -f openseo-postgres这会删除容器及其全部数据;需要干净环境时从第一步重新执行即可。同时记得清理.env.local中的DATABASE_PROVIDER=postgres,避免后续开发误连已不存在的容器。
小结与适用前提
| 要素 | 取值 / 位置 | 说明 |
|---|---|---|
| 本地端口 | 5433 | 避开系统 Postgres 的5432 |
| 容器 | openseo-postgres(postgres:16) | 一次性开发库,数据随容器销毁 |
| 连接串 | postgres://openseo:openseo@localhost:5433/openseo | 同时用于迁移与localConnectionString |
| 迁移命令 | POSTGRES_DATABASE_URL=... pnpm db:migrate:pg | 只被 Node 侧工具读取 |
| 应用开关 | .env.local中DATABASE_PROVIDER=postgres | 由 Vite/Miniflare 作为 Worker var 注入 |
| 绑定 | wrangler.jsonc中注释掉的hyperdrive块 | 取消注释后本地解析为localConnectionString |
| 一致性守门 | src/db/schema-parity.test.ts | 比较 schema 定义而非迁移文件,改 Postgres schema 后必须db:generate:pg |
适用前提:本指南针对 OpenSEO 当前的双方言数据库架构(src/db/provider 层 +drizzle/drizzle-pg双迁移目录)与postgres:16镜像;DATABASE_PROVIDER的合法取值仅限d1/postgres(见 src/db/provider.ts)。对绝大多数贡献者,D1 默认路径(pnpm run db:migrate:local+pnpm dev,见 LOCAL_DEVELOPMENT.md)仍然是更简单、推荐的选择。
【免费下载链接】open-seoOpen source alternative to Semrush and Ahrefs项目地址: https://gitcode.com/GitHub_Trending/op/open-seo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考