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-256:export async function computeChecksum(migration: MigrationDescriptor): Promise<string> { 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则把这类条目报告为missing,journal 表不存在时把所有迁移报告为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是包含目标的回滚;- 迁移目标既可写裸 id(
20260301113000)也可写完整目录名(20260301113000_add_user_status),resolveTargetOption会归一化为裸 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.ts;journal 实现 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.ts;API 总览 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),仅供参考