数据库

Cloudflare D1 加 Drizzle:表定义在哪里、迁移流程、填充演示数据,以及在没有交互式事务的情况下如何安全写入。

ShipKit 的所有数据都存在一个 Cloudflare D1 数据库里(D1 就是由 Cloudflare 托管运行的 SQLite),通过 Drizzle ORM 访问。没有连接字符串,也没有数据库密码:Worker 通过 wrangler.jsonc 里声明的、名为 DB 的 binding 访问 D1。开发时,bun run dev 会提供一份同样结构的本地数据库,数据存在 .wrangler/state 下(已加入 gitignore)。

代价很明确:D1 只存在于 Cloudflare Workers 里。换托管平台,就意味着换数据库。

查询

服务端代码通过 getDb() 拿到 Drizzle 客户端:

import { desc, eq } from 'drizzle-orm'
import { getDb } from '@/core/db'
import { creditLedger } from '../schema'

const rows = await getDb()
  .select()
  .from(creditLedger)
  .where(eq(creditLedger.userId, userId))
  .orderBy(desc(creditLedger.createdAt))
  .limit(20)

getDb() 在第一次调用时才创建客户端,所以只有在处理请求时才会触碰 binding,构建期间永远不会。它可以在 server function、API 路由、事件处理和定时任务里调用,但绝不能在客户端代码里用。页面访问数据库要经过 服务端函数。

表定义在哪里

每个 feature 在自己的 schema.ts 里定义自己的表:积分在 src/features/credits/schema.ts,账单在 src/features/billing/schema.ts,依此类推。src/core/db/schema.ts 只是一个汇总导出的 barrel:

export * from '@/core/authz/schema'
export * from '@/core/payment/schema'
export * from '@/features/auth/schema'
export * from '@/features/billing/schema'
export * from '@/features/credits/schema'
// ……每个 feature 一行

Drizzle 读取这个 barrel 来生成迁移。删除一个 feature 就是删掉它这一行,之后 bun run db:generate 会写出删除这些表的迁移。新增 feature 则在这里加一行,见添加功能。

登录相关的表是个例外,不手写。src/features/auth/schema.ts 由 bun run auth:generate 根据 better-auth 的配置生成,见登录。

列的约定

现有的表都遵循同一套约定,新表也应如此。

值的类型存储方式
主键text('id').primaryKey().$defaultFn(() => crypto.randomUUID())
时间戳integer('created_at', { mode: 'timestamp_ms' }),毫秒;Drizzle 读出来是 Date
金额以最小货币单位(分)存的整数,绝不用浮点数
布尔值integer('banned', { mode: 'boolean' })
JSONtext('meta', { mode: 'json' }).$type<...>(),D1 没有 JSON 列类型

创建时间通常在 SQL 层设默认值,这样手动插入的行也会有:

createdAt: integer('created_at', { mode: 'timestamp_ms' })
  .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
  .notNull(),

列名在 SQL 里用 snake_case,在 TypeScript 里用 camelCase。要为表所服务的查询建索引;src/features/credits/schema.ts 里的 credit_ledger 有一个带注释的例子,展示了为最热查询设计的索引。

修改表结构

迁移是 drizzle/ 下的 SQL 文件,由 drizzle-kit 生成,由 wrangler 执行。

# 1. 改完某个 feature 的 schema.ts,生成 SQL
bun run db:generate

# 2. 应用到本地数据库
bun run db:migrate:local

# 3. 部署依赖它的代码之前,应用到生产
bun run db:migrate:remote

把 drizzle/ 里的新文件和 drizzle/meta/ 一起提交,drizzle-kit 靠后者推算下一次迁移。CI 会重新生成一遍迁移,如果表结构变了却没有提交对应的迁移,就会失败。

生产环境的迁移永远由你在本机手动执行。CI 从不跑迁移;配置了自动部署时,它会先检查是否有未应用的迁移,有的话拒绝部署,直到你执行 bun run db:migrate:remote。在 push 之前先跑它,并且让迁移兼容当前线上的代码,因为会有一小段时间是旧代码跑在新表结构上。完整的步骤顺序见部署。

只改数据的迁移(比如回填)是手写在 drizzle/ 里的 SQL 文件。它不改变表结构,所以不需要改动快照。

填充与查看本地数据

bun run db:seed 会往本地数据库填入 90 天的假用户、订阅、订单、积分流水和审计记录,让管理后台和图表有东西可看:

bun run db:seed            # 创建或刷新演示数据
bun run db:seed --reset    # 把它们删掉

每条演示数据的 id 都以 seed- 开头,而且只引用演示用户,所以 --reset 不会碰你自己创建的数据。数据是确定性的,日期相对于今天计算,重新跑一次图表就是最新的。脚本还支持 --remote,它只为公开演示站准备(见更多模块);绝不要对真实的生产数据库用它,否则假订单会算进你的收入数字。

要直接对本地数据库执行 SQL,用 wrangler:

bunx wrangler d1 execute DB --local \
  --command "UPDATE user SET role='admin' WHERE email='you@example.com'"

没有交互式事务时如何写入

D1 不支持交互式事务:db.transaction() 会直接抛错。你没法先读一个值、在 JavaScript 里判断、再原子地写回去。ShipKit 用两种手段代替。

多个必须一起成功的写入:db.batch()

db.batch([...]) 把一组语句作为一个整体交给 D1 执行:要么全部生效,要么全部不生效。一个操作要写多张表时用它。src/features/auth/server/fns.ts 里的注销账户就是例子:

const db = getDb()
await db.batch([
  db.update(user).set({ deletedAt: now }).where(eq(user.id, me.id)),
  db.delete(session).where(eq(session.userId, me.id)),
  db.delete(apikey).where(eq(apikey.referenceId, me.id)),
])

这些语句在 batch 执行前就已确定,后面的语句不能依赖前面语句读到的结果。

检查和写入合在一起:一条条件语句

当写入取决于当前数据,比如“只有余额够时才扣 10 积分”,就把检查放进执行写入的那条语句里。在 D1 中单条 SQL 语句是原子的,两个并发请求不可能都通过检查。

src/features/credits/server/credits.ts 里的 spendCredits 就是这么做的:它的 INSERT ... SELECT 只在汇总余额足够时才产生一行,并返回是否真的写入了。先查余额、再插入,会让两个并发请求把账户扣成负数。

用唯一键保证幂等

支付 webhook 和定时任务都可能执行不止一次,所以它们触发的写入都带键。积分流水有一个唯一的 ref_id,发放积分时用 onConflictDoNothing() 插入:

const result = await getDb()
  .insert(creditLedger)
  .values({ userId, delta, reason, refId: orderId })
  .onConflictDoNothing()
return result.meta.changes > 0 // false:这笔订单已经发过积分

webhook 去重也是同样的做法,靠的是 webhook_events 表上的唯一索引。只有写入真的发生了,才去做后续的副作用,比如发通知。

在真实的 D1 上测试

需要证明某条 SQL 到底做了什么的测试,尤其是支付和积分相关的路径,跑在一个内存中的 D1 上,并且应用了 drizzle/ 里的全部迁移。src/test/d1.ts 里的 startD1() 负责搭建,主要例子是 src/core/payment/money-path.test.ts。bun run test 会和其他单元测试一起运行它们。