来源: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/*.sqlgenerate-migrations.mjsmigrations.generated.tsapi/admin/migrate.ts)。 schema.ts 因此成了它们唯一的仓库内描述,而它不建表——新建一个 Neon branch 时无处可依。

第四张表的「两处必须一致」只有 schema.ts 里一句注释在守,没有任何测试。而且已经漂移过一次: 被删的 0001_init.sql 建了 sessions_expires_at_idxschema.ts 从来没有它。

做了什么

  • 接入 drizzle-kit@0.31.11drizzle.config.ts + drizzle/(迁移 SQL + meta/ 快照 + journal) 进仓库。config 里不放连接串,dbCredentials 只读环境变量。
  • 存量库做了一次基线:线上四张表都在,第一条迁移是「从空库建表」,直接跑会在已存在的表上重放。
  • 手写 DDL 退役migrate-calendar-entries.mjsSTATEMENTS 删掉,改调 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.mjsschema.tsdrizzle/ 漂移就退出 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_atto_char(...) 的输出别名,精度是秒; 改排在原始列上,同一秒内不同毫秒的条目顺序可能变,而返回顺序会进 aggregateMonth / dayDetail 的事件列表。想逐字节等价就得照旧排在文本上。

三、db:check 不信退出码。 两个坑是实测撞出来的:drizzle-kit generateout 只接受相对路径(绝对路径会让它去开 .//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/migratorreadMigrationFiles()created_at 取自 _journal.jsonwhen; 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 -blintbuild 全过。

下一步动作

  • db:check 接进 CI。 仓库现在还没有 .github/workflows,漂移守卫只靠 npm test 兜着, 而没有人会主动跑它。
  • 真要做 session 过期清理时,把 sessions_expires_at_idx 通过 schema.ts 加回来(db:generate 一次即可)。
  • 本地 SQLite 那套(scripts/event_store.py 十几张表)是另一个库、另一套迁移,不在这次范围内