来源:
notes/documents/网页版表结构收敛:schema.ts_成为_DDL_唯一来源.md网页版表结构收敛:schema.ts 成为 DDL 唯一来源
2026-09-23。起点是一个判断:「同一张表写了两遍」——手写幂等 DDL 为了建表、schema.ts 为了运行时类型,
靠人守着。逐行核过之后发现实际比这句话更差一档,于是把它做掉了。工作票在
.scratch/web-drizzle-schema-source/(spec + 5 张,全部 resolved),提交 5ae963b。
结论
schema.ts 现在是唯一的表结构定义:运行时类型与建表 SQL 都从它来,
建表语句由 drizzle-kit generate 离线产出,仓库里不再有手打的 CREATE TABLE。
现状曾经是四张表、两种命运:
| 表 | 运行时类型 | 建表 DDL 在仓库里 |
|---|---|---|
users / identities / sessions |
schema.ts |
一遍都没有 |
calendar_entries |
schema.ts |
scripts/migrate-calendar-entries.mjs(手打,两处) |
三张表的 DDL 曾经存在,是 adee48e「笔记彻底离开数据库」删整套迁移机制时一起删掉的
(db/migrations/*.sql、generate-migrations.mjs、migrations.generated.ts、api/admin/migrate.ts)。
schema.ts 因此成了它们唯一的仓库内描述,而它不建表——新建一个 Neon branch 时无处可依。
第四张表的「两处必须一致」只有 schema.ts 里一句注释在守,没有任何测试。而且已经漂移过一次:
被删的 0001_init.sql 建了 sessions_expires_at_idx,schema.ts 从来没有它。
做了什么
- 接入
drizzle-kit@0.31.11:drizzle.config.ts+drizzle/(迁移 SQL +meta/快照 + journal) 进仓库。config 里不放连接串,dbCredentials只读环境变量。 - 存量库做了一次基线:线上四张表都在,第一条迁移是「从空库建表」,直接跑会在已存在的表上重放。
- 手写 DDL 退役:
migrate-calendar-entries.mjs的STATEMENTS删掉,改调drizzle-kit migrate。--truncate的配对语义(清远端表 + 清本地projection='neon'台账)、--dry-run、--drop-daily-events一字未动。迁移失败时不清台账。 calendar_entries的读路径回到类型里:daily-store.ts不再手写 SELECT,rawSql()这个出口因此删掉。以前schema.ts里那个calendarEntries定义零 import, 20 行只有「当 DDL 的对照物」一个作用。- 加了机器守卫:
npm run db:check(离线)+tests/migrations-in-sync.test.mjs,schema.ts与drizzle/漂移就退出 1。仓库没有 CI workflow,测试是唯一会自己跑起来的门。
三个不那么显然的决策
一、顺手删掉了 sessions_expires_at_idx。 它只存在于存量库里,schema.ts 从未声明过。
readSession 的 WHERE 是 token_hash = ? AND expires_at > ?,token_hash 的唯一索引已经把它收窄到
一行,expires_at 上再建索引帮不上忙。实测也印证:
sessions.sessions_expires_at_idx scans=0 tup_read=0
sessions.sessions_token_hash_key scans=458 tup_read=458
留着它等于让「唯一来源」长期对不上库。新库从 drizzle/ 建出来本来就没有它,删掉之后两者收敛。
将来真做过期清理时,通过 schema.ts 加回来只是一次 db:generate。
二、月/日列表仍然排在「格式化后的文本」上,不是排在 starts_at 列上。
原来那句 ORDER BY starts_at 里的 starts_at 是 to_char(...) 的输出别名,精度是秒;
改排在原始列上,同一秒内不同毫秒的条目顺序可能变,而返回顺序会进 aggregateMonth / dayDetail
的事件列表。想逐字节等价就得照旧排在文本上。
三、db:check 不信退出码。 两个坑是实测撞出来的:drizzle-kit generate 的 out
只接受相对路径(绝对路径会让它去开 .//abs/.../meta/snapshot.json);
更糟的是它 ENOENT 崩了之后仍然 exit 0。所以检查只认输出里出现
No schema changes(同步)或 Your SQL migration file(漂移),两者都没有就报「判断不了」并退出 1
——宁可误报,不可静默放行。
也不能用「跑一遍 generate 再 diff 内容」当检查:meta/*.json 里的 uuid 与 _journal.json 里的
时间戳每次重跑都变,逐字节对差必然假阳性。稳定的信号是「generate 想不想新增一个文件」。
基线是怎么做的
第一条迁移是「从空库建表」,而线上四张表都在,所以不能直接 migrate。做法是把存量库置为
「0000_init 已应用」——写进去的一行与 drizzle 在空库上会写的完全一致:
CREATE SCHEMA IF NOT EXISTS drizzle;
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint);
INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('a0bb8acd…d952e4', 1790135449645);
hash 取自 drizzle-orm/migrator 的 readMigrationFiles(),created_at 取自 _journal.json 的 when;
drizzle 判定「应用与否」只看 Number(last.created_at) < migration.folderMillis,
且只读 order by created_at desc limit 1 那一行,hash 在 migrate 时不校验、只做记账。
这一步只对存量库需要,新建库由 migrate 自己写 journal。
另一件事:迁移要用 direct(非 pooled)连接串(Neon 的建议)。本机 DATABASE_URL 是 pooled 端点,
脚本检测到 -pooler 会去掉再用并打印一行说明,DATABASE_URL_UNPOOLED 可以显式覆盖。
验证
逐字节对照(真库):把改造前那份手写 SQL 从 git 取回来,与新实现各跑一遍真实 Neon,
对 5 个月份的行集与 aggregateMonth 响应、这些月份里有条目的 34 天的行集与 dayDetail 响应——
ALL IDENTICAL,零处不一致(最大一条 7 KiB)。
真机:5ae963b 在 Vercel 上是 production READY。造了一个临时 session(1 小时后过期、
用完立刻撤销)打线上域名,3 个月 + 3 天与本地实现逐字节一致,
/api/notes 121 篇、/api/others 47 篇,/api/me 认出 owner。
npm test 55 passed,tsc -b、lint、build 全过。
下一步动作
- 把
db:check接进 CI。 仓库现在还没有.github/workflows,漂移守卫只靠npm test兜着, 而没有人会主动跑它。 - 真要做 session 过期清理时,把
sessions_expires_at_idx通过schema.ts加回来(db:generate一次即可)。 - 本地 SQLite 那套(
scripts/event_store.py十几张表)是另一个库、另一套迁移,不在这次范围内。