news 2026/9/10 10:02:53

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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现在只导出createMigrationRegistryparseMigrationDirectoryName——这印证了迁移编写方式已经彻底转向 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创建booksusersordersorder_itemspassword_reset_tokens五张表,带完整的外键约束(如on delete cascadeon 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可选(省略即不可逆迁移);
  • 一个迁移脚本可以包含多条语句,idname从目录名推断。

对于非文件系统运行时(如 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 )

关键防御机制有两层:

  1. 校验和(checksum)检测内容漂移computeChecksumup.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错误并中止,把"迁移被事后篡改"变成显式失败而非静默事故。

  2. 缺失检测(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,不改动数据库;
  • wipereset是破坏性命令,必须基于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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 10:02:39

Semgrep快速指南:用30+语言模式匹配找出安全漏洞

Semgrep快速指南&#xff1a;用30语言模式匹配找出安全漏洞 【免费下载链接】semgrep Lightweight static analysis for many languages. Find bug variants with patterns that look like source code. 项目地址: https://gitcode.com/GitHub_Trending/se/semgrep Semg…

作者头像 李华
网站建设 2026/9/10 10:02:35

Simulink锂离子电池建模技术与工程实践

1. Simulink锂离子电池模型的价值与应用场景锂离子电池作为当前储能领域的核心技术&#xff0c;其充放电特性建模一直是科研和工程实践的重点难点。传统建模方法往往存在精度不足、计算复杂或难以实时仿真等问题。而基于Simulink的建模方案&#xff0c;恰好能平衡这三方面的需求…

作者头像 李华
网站建设 2026/9/10 10:02:26

Koodo Reader:免费跨平台电子书阅读器

Koodo Reader&#xff1a;免费跨平台电子书阅读器 【免费下载链接】koodo-reader A modern ebook manager and reader with sync and backup capacities for Windows, macOS, Linux, Android, iOS and Web 项目地址: https://gitcode.com/GitHub_Trending/koo/koodo-reader …

作者头像 李华
网站建设 2026/9/10 10:02:05

用 Rufus 给 Windows 11 做启动盘:跳过 TPM 2.0 的完整步骤

用 Rufus 给 Windows 11 做启动盘&#xff1a;跳过 TPM 2.0 的完整步骤 【免费下载链接】rufus The Reliable USB Formatting Utility 项目地址: https://gitcode.com/GitHub_Trending/ru/rufus U盘插上就进 setup&#xff0c;屏幕闪一行英文&#xff0c;直接退了。问题…

作者头像 李华
网站建设 2026/9/10 10:01:19

AI 模型训练数据怎么采购?国内主流训练数据服务商全景盘点

AI 模型训练数据怎么采购&#xff1f;国内主流训练数据服务商全景盘点在人工智能模型快速迭代的当下&#xff0c;AI 模型训练数据怎么采购已成为企业研发决策中的核心议题。面对海量且分散的数据资源&#xff0c;企业不仅需要规模庞大的原始素材&#xff0c;更需要具备版权合规…

作者头像 李华
网站建设 2026/9/10 10:00:52

多传感器时间同步与超帧聚合:实时系统时序问题解决指南

做实时视觉和机器人控制这几年&#xff0c;我被高帧率数据的时序问题折磨过太多次。摄像头 30 帧、激光雷达 10Hz、IMU 200Hz、控制回路 1kHz&#xff0c;每个设备都在自己的节奏里跑&#xff0c;一旦要把它们凑在一起做融合&#xff0c;帧率不匹配、时间戳漂移、数据抖动这些问…

作者头像 李华