ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Remix data-table 迁移系统深度解析:为什么用 .sql 文件替代 TypeScript 迁移

Remix data-table 迁移系统深度解析:为什么用 .sql 文件替代 TypeScript 迁移 Remix contenteditable="false">【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix本篇围绕 Remix 仓库的架构决策记录 006-sql-migrations.md 展开回答一个核心问题Remix 的data-table包为什么放弃 TypeScript 迁移文件转而采用纯 SQL 的up.sql/down.sql文件。读完本文你会理解迁移文件不可变性这一数据库工程原则背后的事故场景并掌握remix db全套生命周期命令、remix.json配置、journal 校验机制与事务模式的完整用法这些内容均有>import { column as c, table } from remix/data-table import { createMigration } from remix/data-table/migrations let users table(/* ... */) export default createMigration({ async up({ db, schema }) { await schema.createTable(users) }, async down({ schema }) { await schema.dropTable(users, { ifExists: true }) }, })这个方案对人和 AI Agent 都有一个共同的陷阱从app/schema.ts误引入表定义。决策文档中展示了这样一个手滑场景import { users } from ../app/schema.ts // Whoops! export default createMigration({ async up({ db, schema }) { await schema.createTable(users) }, async down({ schema }) { await schema.dropTable(users, { ifExists: true }) }, })从当前仓库的 migrations 包入口 可以看到旧的createMigration辅助函数已不复存在remix/data-table/migrations现在只导出createMigrationRegistry和parseMigrationDirectoryName——这印证了迁移编写方式已经彻底转向 SQL 文件。事故链条是这样的一开始一切正常。之后你在本地迭代不断修改schema.ts并创建新的迁移。但每一条迁移本应被当作不可变的制品immutable artifact——一旦应用于生产数据库其内容就永远不应再变化。当迁移引用的schema.ts被修改时每条已应用的迁移实际上都在随时间漂移。在最坏的情况下重放一条漂移后的迁移可能导致生产数据库的意外数据丢失这是不可接受的。即便通过 lint 规则或其他静态分析手段保证app/schema.ts永远不被引入也阻止不了其他被迁移拉入的依赖和导入发生变化。TypeScript 是图灵完备的通用语言任何import都可能成为不稳定的来源。解决方案.sql文件——从文件格式上杜绝导入要保证迁移稳定需要一个从格式上就排除 import 的可能、仅依赖数据库中的数据的文件格式——.sql文件├── app/ │ └── schema.ts └── db/ └── migrations/ ├── 20260228090000_create_bookstore_schema/ │ ├── up.sql │ └── down.sql └── 20260301083000_add_books_search_index/ └── up.sql这个目录结构不是纸面约定仓库中 bookstore 示例 就是这样组织的。其up.sql创建books、users、orders、order_items、password_reset_tokens五张表带完整的外键约束如on delete cascade、on delete restrict和索引配套的 down.sql 按依赖顺序反向drop table。整个迁移不 import 任何模块运行结果只由文件字节和数据库当前状态决定。ADR 还说明了当前的过渡状态计划支持基于schema.ts变化的迁移.sql文件自动生成届时开发者不必手写 SQL在此之前.sql文件可以手写也可以让 Agent 代写。SQL 文件同样适合让 Agent 生成——因为它无需理解 TS 模块图只需产出确定性的 DDL。目录命名与加载机制迁移目录的命名遵循YYYYMMDDHHmmss_slug格式。在 directory-name.ts 中可以看到严格的解析规则const migrationDirectoryPattern /^(\d{14})_(.)$/ export function parseMigrationDirectoryName(name: string): { id: string; name: string } { let match name.match(migrationDirectoryPattern) if (!match) { throw new Error( Invalid migration directory name name . Expected format YYYYMMDDHHmmss_name, ) } return { id: match[1], name: match[2] } }14 位数字前缀被解析为id其余部分为name。排序与去重逻辑在 registry.ts 中sortMigrations()按id字典序排序时间戳格式保证字典序即时间序createMigrationRegistry()用Map存储并对重复 id 抛错。这意味着所有迁移目录必须放在同一个父目录下每个目录的id全局唯一up.sql必填down.sql可选省略即不可逆迁移一个迁移脚本可以包含多条语句id和name从目录名推断。对于非文件系统运行时如 Workers可以通过 registry API 直接注册内存中的迁移import { createMigrationRegistry } from remix/data-table/migrations let registry createMigrationRegistry() registry.register({ id: 20260228090000, name: create_users, up: create table users (id serial primary key, email text not null);, down: drop table users;, }) await db.migrate(registry)Journal应用记录、校验和与漂移检测迁移执行器runner.ts依赖一张 journal 表记录已应用的迁移。在 journal-store.ts 中journal 表结构为create table if not exists data_table_migrations ( id varchar(64) not null primary key, name varchar(255) not null, checksum varchar(128) not null, batch integer not null, applied_at timestamp not null default current_timestamp )关键防御机制有两层校验和checksum检测内容漂移computeChecksum对up.sql的原始文本计算 SHA-256export async function computeChecksum(migration: MigrationDescriptor): Promisestring { let digest await crypto.subtle.digest(SHA-256, new TextEncoder().encode(migration.up)) return bytesToHex(new Uint8Array(digest)) }这正是 ADR 论点的运行时落地——即使迁移文件碰巧能重放只要字节变了assertMigrationIntegrity就会抛出Migration checksum drift detected错误并中止把迁移被事后篡改变成显式失败而非静默事故。缺失检测missing migration正向迁移执行前若 journal 中存在某条已应用记录但对应迁移文件已不存在runner 直接抛错Applied migration ... is missing from current migrations而回滚方向会跳过这些孤儿记录if (direction down) { continue }保证删文件后仍能把剩余迁移回滚掉的可恢复性。remix db status则把这类条目报告为missingjournal 表不存在时把所有迁移报告为pending而不创建表。执行时的错误处理同样严谨每个迁移若处于事务中SQL 或 journal 写入失败会rollbackTransaction如果回滚本身也失败则抛出AggregateError(Migration and rollback both failed)避免吞掉任何一侧的错误。remix db命令与remix.json配置数据库生命周期命令通过remix.json静态配置。bookstore 示例的实际配置demos/bookstore/remix.json展示了最小可用形态{ db: { adapter: { type: sqlite, filename: ./db/bookstore.sqlite, foreignKeys: true }, migrations: { directory: ./db/migrations }, seed: ./db/seed.sql } }更完整的配置还可为 PostgreSQL 指定连接串环境变量与自定义 journal 表{ $schema: ./node_modules/remix/schema/remix.json, db: { adapter: { type: postgres, connectionString: { env: DATABASE_URL } }, migrations: { directory: ./db/migrations, journalTable: app_migrations }, seed: ./db/seed.sql } }路径相对于remix.json解析连接机密在命令运行时才从命名环境变量读取。完整命令集remix db status remix db migrate remix db migrate --to 20260301113000_add_user_status remix db rollback remix db rollback --step 2 remix db rollback --to 20260301113000_add_user_status remix db rollback --dry-run remix db seed remix db reset --force remix db wipe --force参数语义与 runner.ts 中的校验逻辑一一对应rollback默认回滚最近一次应用的迁移可用--step count或--to migration限定范围--to是包含目标的回滚迁移目标既可写裸 id20260301113000也可写完整目录名20260301113000_add_user_statusresolveTargetOption会归一化为裸 id 再比较遇到未知目标抛Unknown migration target多个匹配则抛Ambiguous migration target--to与--step互斥assertMigrationOperationOptions强制--step必须是正整数--dry-run只报告计划执行的 SQL不改动数据库wipe和reset是破坏性命令必须基于remix.json的配置数据库因为它需要关闭、重建并重新连接。事务模式与多语句执行两个容易踩坑的细节值得单独说明。多语句脚本runner 把每条迁移作为单个多语句脚本发给数据库driver.executeScript。这对驱动配置有要求better-sqlite3开箱即用pg在不传参数数组时开箱即用mysql2必须在连接/池上开启multipleStatements: true。事务包裹默认情况下当数据库支持事务性 DDL 时每个迁移都被包裹在事务中脚本 journal 写入原子提交失败整体回滚。可以通过up.sql第一行非空行处的指令覆盖-->import { loadMigrations } from remix/data-table/migrations/node let migrations await loadMigrations(./db/migrations) await db.migrate(migrations)Database.migrate()支持方向、目标/步数边界、dry-run 和自定义 journal 表await db.migrate(migrations) await db.migrate(migrations, { to: 20260301113000_add_user_status }) await db.migrate(migrations, { step: 1 }) await db.migrate(migrations, { direction: down }) await db.migrate(migrations, { direction: down, to: 20260301113000 }) await db.migrate(migrations, { journalTable: app_migrations }) let plan await db.migrate(migrations, { dryRun: true }) for (let script of plan.sql) console.log(script)省略journalTable时默认使用data_table_migrations。嵌入数据库 CLI 的宿主程序还可以通过runRemixDb({ command: rollback, db, migrations, step: 2, dryRun: true })调用同样的回滚行为。若驱动声明了migrationLock能力runner 会通过withMigrationLock()把整个迁移journal 生命周期绑定到持有咨询锁advisory lock的那条连接上执行即使连接池只有单连接也能正确配对锁。小结格式即约束这份 ADR 的核心洞察可以概括为一句话不要用运行时保证不可变性用文件格式。TypeScript 迁移的不稳定来自语言本身模块图、依赖、副作用任何 lint 规则都只能是尽力而为而.sql文件在格式层面就排除了 import其全部语义输入只有文件字节与数据库状态。再叠加 journal 的 SHA-256 校验和漂移检测、missing迁移的前置中止、逐迁移事务包裹Remix 把迁移一旦应用就不可变从团队纪律升级成了系统不变量。这也解释了为什么计划中的schema.ts差异自动生成方案产出目标同样是.sql文件而非代码——生成可以自动化但稳定性边界必须由文件格式来守住。参考路径决策记录 decisions/006-sql-migrations.md执行器 packages/data-table/src/lib/migrations/runner.tsjournal 实现 packages/data-table/src/lib/migrations/journal-store.ts目录名解析 packages/data-table/src/lib/migrations/directory-name.ts注册表 packages/data-table/src/lib/migrations/registry.tsAPI 总览 packages/data-table/README.md真实迁移示例 demos/bookstore/db/migrations/20260228090000_create_bookstore_schema/up.sql配置示例 demos/bookstore/remix.json。【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进