Strapi 数据库迁移机制解析:Internal 与 User 迁移、存储表与 Runner 源码实现
Strapi 的数据库模块通过“自动同步 schema + 迁移文件”双轨制管理数据库演进:前者尽量让表结构与内容类型配置保持一致,后者则负责处理不可自动推导的 schema 变更和数据迁移。本文以官方文档 Migrations 为主线,完整覆盖其中“Internal 迁移的创建方式与文件格式”等核心内容,并结合 migrations 源码目录 深入讲解迁移调度顺序、状态存储表结构、Runner 执行逻辑与用户迁移文件的发现规则,帮助你在 Strapi 中正确地编写、注册和回滚迁移。
Strapi 为什么需要迁移机制
官方文档开篇明确了设计背景:
Strapi manages schema and data migrations in multiple ways. As much as possible we try to automatically sync the DB schema with the application configuration. However this is not sufficient to manage data migrations or schema migrations that are not reconcilable.
也就是说,Strapi 会尽可能自动地将数据库 schema 与应用配置(内容类型、组件定义)对齐,但以下场景无法靠自动同步解决,必须依赖迁移文件:
- 数据迁移:对已有记录做清洗、拆分、回填,这类操作与 schema 定义无关;
- 不可调和的 schema 迁移:某些结构性变更(如复杂的关系重组、列合并)无法由 diff 自动推导,需要显式 SQL/JS 逻辑完成。
迁移机制在 migrations/index.ts 中通过 createMigrationsProvider 统一对外暴露,内部由两个 Provider 组成:
export const createMigrationsProvider = (db: Database): MigrationProvider => {
const userProvider = createUserMigrationProvider(db);
const internalProvider = createInternalMigrationProvider(db);
const providers = [userProvider, internalProvider];
// ...
};
两个 Provider 的 up() / down() 会按数组顺序依次执行,即 用户迁移先于内部迁移。这一顺序有专门的测试用例保证,见 providers.test.ts 中 “runs user migrations before internal migrations” 一项。
Internal 迁移:创建方式与文件格式
创建 Internal 迁移的步骤
官方文档给出了在 Strapi 核心内新增一条内部迁移的完整流程:
- 在
packages/core/database/src/migrations/internal-migrations目录下新增一个迁移文件; - 在同目录的
index.ts中导入它,并作为数组的最后一个元素追加到导出数组中。
“必须追加到末尾”这一约定不是随意的:从 internal.ts 的实现看,内部迁移的执行顺序就是数组定义顺序——
const migrations: Migration[] = [...internalMigrations];
// runner 按该数组顺序过滤出 pending 后逐个 up()
新迁移永远代表最新变更,追加到末尾才能保证它只在旧迁移之后执行;同时 Provider 还预留了 register(migration) 方法,允许运行期动态注册内部迁移(按注册顺序排在静态数组之后)。
迁移文件的标准 API
每条迁移都应遵循文档定义的格式:
export default {
name: 'name-of-migration',
async up(knex: Knex, db: Database): void {},
async down(knex: Knex, db: Database): void {},
};
对照 common.ts 中的类型定义,可以确认这个 API 的精确语义:
export type MigrationFn = (knex: Knex.Transaction, db: Database) => Promise<void>;
export type Migration = {
name: string;
up: MigrationFn;
down: MigrationFn;
};
两个关键细节:
-
第一个参数实际是事务句柄。文档写的是
Knex,但类型上它是Knex.Transaction。wrapTransaction负责包装:export const wrapTransaction = (db: Database) => (fn: MigrationFn) => () => { return db.transaction(({ trx }) => Promise.resolve(fn(trx, db))); };即每条迁移的
up()/down()都会在db.transaction中运行,迁移体内的写操作具备事务回滚能力。 -
name是幂等性的唯一依据。Runner 依据name判断某条迁移是否已执行,因此改名等同于“重放”迁移,命名后不应再修改。
迁移状态存储:两张记录表
执行过的迁移会被记录到数据库表中,从而实现“只跑未执行的”幂等行为。storage.ts 定义了通用存储层:
const createMigrationTable = () => {
return db.getSchemaConnection().createTable(tableName, (table) => {
table.increments('id');
table.string('name');
table.datetime('time', { useTz: false });
});
};
表结构为三列:自增 id、迁移名 name、执行时间 time。三类操作:
| 方法 | 作用 | 实现 |
|---|---|---|
logMigration({ name }) |
迁移成功后写入一条执行记录(含 new Date()) |
insert({ name, time }).into(tableName) |
unlogMigration({ name }) |
回滚后删除对应记录 | del().where({ name }) |
executed() |
返回已执行迁移名列表;若表不存在则先建表并返回空数组 | select().from(tableName).orderBy('time') |
两个 Provider 使用各自独立的记录表(见 internal.ts 与 users.ts):
- 内部迁移 →
strapi_migrations_internal - 用户迁移 →
strapi_migrations
这种隔离意味着两类迁移的进度互不干扰;例如内部迁移全部完成而用户迁移尚未执行时,Strapi 启动仍能正确识别待办项。internal-upgrade-simulation.test.ts 专门验证了“当 strapi_migrations_internal 中已存在全部迁移名时,不再重复执行”的升级场景。
迁移 Runner:pending / up / down 的执行逻辑
两条迁移链(内部与用户)共用同一个 Runner 实现 runner.ts,其核心行为如下:
pending():并发获取“全部迁移”与“已执行名单”,过滤掉已执行的,得到待执行列表。
up():按顺序逐条执行每条待迁移——
for (const migration of toBeApplied) {
logEvent(opts.logger, 'migrating', migration.name);
try {
await migration.up();
} catch (error) {
throw wrapMigrationError(migration.name, 'up', error);
}
await opts.storage.logMigration({ name: migration.name });
const durationSeconds = (Date.now() - start) / 1000;
logEvent(opts.logger, 'migrated', migration.name, { durationSeconds });
}
要点:
- 执行前先打
migrating日志、成功后打migrated日志并附带durationSeconds,方便在生产环境定位慢迁移; - 先执行、成功后才写记录:若
up()抛错,logMigration不会被调用,下次启动会重试该迁移,错误则被包装为Migration <name> (up) failed: <原因>(wrapMigrationError会保留原始cause)。
down():行为与直觉不同,值得特别留意——
const executedReversed = (await getExecutedMigrations()).slice().reverse();
const toBeReverted = executedReversed.slice(0, 1);
down() 每次只回滚最近执行的一条迁移(反转已执行列表后取第一条),成功后调用 unlogMigration 删除其记录并打 reverted 日志。因此回滚多条迁移需要多次调用,这是 Strapi 当前迁移系统的明确语义,操作时不能假定 down() 会一次性回滚全部。
用户迁移:目录发现规则与 runMigrations 开关
内部迁移由核心代码静态维护,而用户迁移面向应用开发者,由 users.ts 提供的 Provider 管理:
export const createUserMigrationProvider = (db: Database): UserMigrationProvider => {
const dir = db.config.settings.migrations.dir;
fse.ensureDirSync(dir);
// ...
return {
async shouldRun() {
const pendingMigrations = await runner.pending();
return pendingMigrations.length > 0 && db.config?.settings?.runMigrations === true;
},
// ...
};
};
两个关键配置:
migrations.dir:用户迁移文件所在目录,Provider 初始化时会ensureDirSync确保目录存在(数据库默认配置中该值为migrations,见 database/src/index.ts 中的默认 settings);runMigrations:总开关。只有在runMigrations === true且存在待执行迁移时,用户迁移 Provider 的shouldRun()才返回真(默认值为true)。设为false可以在不删除迁移文件的前提下阻止 Strapi 自动执行用户迁移,例如把迁移执行交给部署流水线。
文件发现逻辑在 discover.ts,规则非常明确:
- 非递归:只扫描目录第一层;
- 仅识别
.js和.sql两种扩展名; - 忽略 dotfiles(以
.开头的文件); - 符号链接仅在指向文件时生效(对齐 umzug/fast-glob 的默认行为);
- 结果按文件名字母序排序,该顺序即执行顺序。
也就是说,迁移文件应使用 001-xxx.js、002-yyy.sql 这类带序号前缀的命名来控制执行次序。providers.test.ts 中的 “runs multiple user migrations in alphabetical filename order” 用例直接验证了这一行为。对于 .sql 迁移,测试用例 “applies sql migrations and records the filename” 表明执行成功后记录的 name 是文件名本身。
测试用例印证的关键行为汇总
以下结论均可在 migrations/tests 目录中找到对应验证,可作为行为边界参考:
| 行为 | 验证位置 |
|---|---|
| 用户迁移先于内部迁移执行 | providers.test.ts |
runMigrations: false 时用户迁移不执行,改回 true 后执行 |
providers.test.ts |
| 已记录的迁移(内部/用户)不会重复执行,只补跑 pending | providers.test.ts、internal-upgrade-simulation.test.ts |
| JS 迁移在事务内运行;SQL 迁移按文件名记录 | resolver.test.ts、providers.test.ts |
| Runner 的顺序执行、pending 过滤与日志事件 | runner.test.ts |
| 存储表自动创建并跟踪执行记录 | storage 相关用例(resolver.test.ts) |
小结
回到官方文档的核心信息,Strapi 的迁移体系可以概括为:
- 两类迁移:内部迁移(核心代码内维护,记录于
strapi_migrations_internal)与用户迁移(应用目录下.js/.sql文件,记录于strapi_migrations),用户迁移先行; - 统一 API:
{ name, up, down },up/down在事务中执行,name决定幂等; - 执行语义:
up顺序执行全部 pending 迁移且成功后才记账;down每次仅回滚最近一条; - 可控开关:
runMigrations与migrations.dir决定用户迁移是否自动执行及文件来源; - 新增内部迁移:在
packages/core/database/src/migrations/internal-migrations下建文件,并在同目录index.ts中作为数组末尾元素导入导出。
理解这些机制后,无论是为核心贡献迁移,还是在自己的 Strapi 应用中编写数据迁移文件,都能准确预期其执行时机、顺序与回滚边界。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
