news 2026/9/13 6:04:09

OpenSEO 本地 Postgres 开发指南:Docker 容器、Drizzle 迁移与 Hyperdrive 绑定的完整实操

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OpenSEO 本地 Postgres 开发指南:Docker 容器、Drizzle 迁移与 Hyperdrive 绑定的完整实操

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".`, ); }

取值只有d1postgres两种语义:未设置或空串时静默回落到 D1,出现其他值则直接抛错,避免静默走错后端。

前置条件

  • Docker Desktop(或 Docker Engine);
  • 常规的本地开发环境,即 LOCAL_DEVELOPMENT.md 中描述的 Node.js 20+ / Corepack 激活的 pnpm 环境,且已完成corepack enablepnpm install --frozen-lockfilepnpm 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中取出当前请求作用域的客户端;各入口点(fetchscheduledcron、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入口,对连接类错误(ECONNREFUSED08000等 Postgres class 08 错误码等)按250/1000/2500ms + 随机抖动的间隔重试。为保证写安全,只有“查询到达服务器之前”就失败的错误才允许对写语句重试,连接中途断开时仅SELECT会被重放,事务则完全不做重试(src/db/pg/retry.ts);
  • 原子批量写入必须走runBatchdb.batch只存在于 D1 驱动,Postgres 驱动下调用会抛错。src/db/runBatch.ts 提供了双后端一致的原子批量语义——D1 走db.batch([...]),Postgres 走db.transaction内按数组顺序逐条执行,并配合DB_BATCH_SIZE = 100executeInBatches分片(该批大小受 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 文件(目录内含00000042共 40 余个版本化迁移),如果\dt能看到对应表而select count(*)在你操作应用后仍然为 0,通常说明应用还在走 D1 路径——回到第三步检查.env.localwrangler.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 表因为按方言生成(SQLiteinteger时间戳 vs Postgrestimestamptz/jsonb),只比较结构而非列类型,但额外校验了一批 CLI 不会生成、需要手工补齐的二级索引(如session.user_idorganization.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-postgrespostgres:16一次性开发库,数据随容器销毁
连接串postgres://openseo:openseo@localhost:5433/openseo同时用于迁移与localConnectionString
迁移命令POSTGRES_DATABASE_URL=... pnpm db:migrate:pg只被 Node 侧工具读取
应用开关.env.localDATABASE_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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 6:03:13

具身智能如何跨越“演示”与“落地”的断层?

1. 一场完美Demo之后&#xff0c;为什么客户现场依然鸦雀无声先说一个我反复遇到的场景。某展会上&#xff0c;一台具身智能机械臂在标准展台上完成了叠衣服、抓取水杯、给人递饮料的三连操作&#xff0c;围观人群鼓掌&#xff0c;投资人在旁边点头&#xff0c;媒体镜头怼着机械…

作者头像 李华
网站建设 2026/9/13 6:00:49

基于 fx 与用户组(Groups)在 ToolJet 中条件显示组件

基于 fx 与用户组&#xff08;Groups&#xff09;在 ToolJet 中条件显示组件 【免费下载链接】ToolJet Open-source foundation of ToolJet AI - the enterprise app generation platform for internal tools, dashboards, business applications, workflows and AI agents. Bu…

作者头像 李华
网站建设 2026/9/13 6:00:06

MMC-VSG控制技术在新能源并网中的应用与实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 5:59:13

三款开箱即用的Web端ER图工具实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华