关于 n8n:db-migrations
n8n:db-migrations 是面向 n8n 贡献者的数据库迁移技能,涵盖跨数据库设计规则、提交审查前的检查清单,以及测试和回退迁移的指引。
主要功能
- 01迁移审查前检查清单
- 02模式与数据迁移指引
- 03PostgreSQL 和 SQLite 兼容规则
- 04回退与测试说明
适用场景
- 01准备迁移以提交审查
- 02选择迁移类型和目录
- 03核对跨数据库模式变更
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ n8n-io/ n8n --skill n8n:db-migrations安装后在 Claude Code 中输入/n8n:db-migrations确认已加载。
详细介绍
n8n 迁移指南
经验法则:@n8n-io/migrations-review 团队会审核每个迁移 PR。他们要求的修复内容是可以预料的——请求审核前,请先完成预检清单。本文档的其余部分将解释每项要求背后的原因,并介绍更深入的主题。
目录
概览
目录结构
packages/@n8n/db/src/migrations/
├── common/ # Default — DSL handles SQLite + Postgres
├── postgresdb/ # PostgreSQL-specific migrations
├── sqlite/ # SQLite-specific migrations
├── dsl/ # Schema builder DSL (table, column, indices)
├── __tests__/ # Migration tests
├── migration-types.ts
└── migration-helpers.ts
迁移类型
| 接口 | 使用场景 |
|---|---|
ReversibleMigration | 可以干净撤销的架构变更(添加/删除列、创建/删除表)。需要实现可用的 down()。 |
IrreversibleMigration | 数据转换、破坏性变更,或任何调用 down() 会导致数据丢失的情况。不允许使用 down()。 |
MigrationContext API
权威来源:packages/@n8n/db/src/migrations/migration-types.ts。如有疑问,请查看源代码以确认准确的签名。
interface MigrationContext {
// Database info
dbType: 'postgresdb' | 'sqlite';
isSqlite: boolean;
isPostgres: boolean;
tablePrefix: string;
dbName: string;
// Schema DSL
schemaBuilder: { createTable, dropTable, addColumns, dropColumns, column,
createIndex, dropIndex, addForeignKey, dropForeignKey,
addNotNull, dropNotNull };
// Query execution
runQuery<T>(sql: string, namedParameters?: object): Promise<T>;
runInBatches<T>(query: string, operation: (rows: T[]) => Promise<void>, limit?: number): Promise<void>;
copyTable(from: string, to: string, fromFields?: string[], toFields?: string[], batchSize?: number): Promise<void>;
// Utilities
escape: { tableName(n: string): string; columnName(n: string): string; indexName(n: string): string };
parseJson<T>(data: string | T): T;
loadSurveyFromDisk(): string | null;
logger: Logger;
migrationName: string;
queryRunner: QueryRunner; // Avoid direct use — prefer runQuery()
}
DSL 类型映射参考
权威来源:packages/@n8n/db/src/migrations/dsl/column.ts。
| DSL 类型 | PostgreSQL | SQLite |
|---|---|---|
int | int | integer |
bigint | bigint | integer |
smallint | smallint | integer |
varchar(N) | varchar(N) | varchar(N) (不强制执行长度限制) |
text | text | text |
json | json | text |
uuid | uuid | varchar |
bool | boolean | boolean |
double | double precision | real |
binary | bytea | blob |
timestampTimezone | timestamptz | datetime |
timestampNoTimezone | timestamp | datetime |
timestamp (已弃用) | timestamp | datetime |
时间戳类型变体的默认精度为 3 ms;使用 .timestampTimezone(6) 可覆盖此设置。
预检清单
请求审核前,请逐项检查。每一项都是审核者反复提出的问题;链接指向说明相应规则的章节。
- 使用
pnpm --filter=@n8n/db migration:new搭建迁移(时间戳和注册会自动完成;migration-timestamplint 规则会检测偏差)。— 创建迁移 - 标识符必须通过
escape.tableName(...)/escape.columnName(...)处理。绝不要手动编写n8n_table前缀。— 始终转义标识符 - 列类型要与值的语义相匹配。 UUID 使用原生
uuid,时间戳使用timestampTimezone(),数字使用数值类型,布尔值使用bool,结构化数据使用json。绝不要把varchar当作通用类型。— 列类型 - 在相应类别中选择合理且最精简的类型: 范围允许时使用
int/smallint,而不是bigint;无长度限制的字符串使用text,而不是varchar(255);绝不要用double存储版本号。— 列类型 - 自动生成的
id键使用整数(int.primary.autoGenerate2)。UUID 键使用 UUIDv7。对于 UUID 或字符串键,请在 PR 描述中说明原因。联接表使用外键列作为键,而不是id列。— 主键 - 默认使用
notNull,只有理由充分时才放宽限制。PK 隐式为 NOT NULL。迁移中的notNull要与实体的可空性一致。— NOT NULL 与实体一致性 - 类似枚举的列必须包含
.withEnumCheck([...])和.comment('说明取值')。不透明 ID、Unix 时间戳和 JSON 结构也要添加.comment()。— 约束类似枚举的字符串,为列添加注释 - 每个引用列都必须有明确的外键,并审慎设置
onDelete。如果 SQLite 重建可能导致外键重复,请为外键显式命名。避免使用多态(typeCol, idCol)模式。— 外键约束,一般设计指南 - 索引要符合实际查询模式。 唯一约束已经会创建索引;复合 PK 会为其前缀创建索引。让
withIndexOn(...)与实体的@Index(...)保持一致。— 索引管理 - 稀疏唯一列: 使用部分索引
WHERE col IS NOT NULL。— 索引管理 - 复合索引的列顺序要符合实际的
WHERE/ORDER BY用法。— 索引管理 - 实体 ↔ 迁移保持一致: 列类型、
notNull、默认值、外键和@Index装饰器都要匹配。— 架构与实体偏差 - 如果使用
addColumns、dropColumns、addNotNull、dropNotNull、addEnumCheck或dropEnumCheck: 请确认目标表是否有指向它的外键。如果有,则设置withFKsDisabled = true as const(若这是common/迁移,请在sqlite/子类中设置),或者对可空列或有默认值的列使用原始ALTER TABLE ADD COLUMN。— SQLite 表重建风险 - 迁移主体中不导入运行中应用的值。 在本地内联类型或工具代码。— 绝不将实体作为值导入
- 已在本地测试
async down(): 在 SQLite 和 Postgres 上都运行pnpm start && pnpm start -- db:revert && pnpm start。— 可逆性 - 每个迁移只包含一项逻辑变更;将无关的表变更拆分到不同文件中。— 不要合并互不相关的架构变更
-
up()/down()应以意图列表的形式清晰呈现。 如果任一方法体超过一屏,或将架构操作与多语句原始 SQL 数据迁移混在一起,请将数据迁移提取到同一类的private async方法中(例如private async backfillFromX(ctx))。顶层方法负责协调,而不是具体实现。 - 已有先例是需要修正的标准,而不是延续的理由。 当清单与旧迁移的做法冲突时(例如多余的
.primary.notNull、手动引用标识符、缺少.comment()),新代码应以清单为准,不要继续沿用违规做法。如果发现旧代码中的此类问题,请在 PR 中注明。 - 已使用
pnpm db:schema:docs重新生成架构文档,并提交docs/generated/中的变更。DB Tests CI 任务会因文档过期而失败。— 架构文档 将此检查清单视为最低要求,而非上限。 如果有任何一项未通过,请先修复,再发起审查。
通用指南
适用于所有迁移的规则——无论是架构还是数据迁移,无论是通用迁移还是特定数据库迁移。开始编写前,请阅读本节。
创建迁移
临时时间戳解决方法: 此仓库目前有未来日期的迁移,最新迁移的时间戳为
1784000000008(2026-07-14T03:33:20.008Z)。在当前时间超过该时间戳之前,使用Date.now()创建的迁移会排在已部署的最新迁移之前,导致已应用后续迁移的数据库上出现执行顺序错误。在此期间请使用生成器——必要时它会选取max + 1。背景信息请参阅 PR #30511。
迁移文件的命名格式为 {TIMESTAMP}-{DescriptiveName}.ts。时间戳必须严格大于此软件包中所有现有迁移的时间戳(包括 common/、postgresdb/ 和 sqlite/)。TypeORM 会按时间戳顺序运行尚未记录的迁移,因此,如果插入的值小于当前最大值,就会破坏已执行后续迁移的数据库上的执行顺序。
请使用生成器——它会选取安全的时间戳、生成脚手架,并重新生成迁移索引文件(sqlite/index.ts 和 postgresdb/index.ts 是被 git 忽略的构建产物,由 scripts/generate-migration-index.mjs 根据磁盘上的文件生成——切勿编辑或提交这些文件):
pnpm --filter=@n8n/db migration:new <Name> [--folder=common|postgresdb|sqlite]
<Name> 使用 PascalCase,并描述所做的更改(例如 AddTracingToExecution)。--folder 默认为 common;只有特定方言的迁移才使用 postgresdb 或 sqlite。如果 Date.now() 大于当前最新时间戳,生成器就会使用它;否则会选取 max + 1。
@n8n/code-health 中的 migration-timestamp 规则会在 lint 时检查这两项约束(时间戳严格递增,且不得伪造过于靠后的时间戳);生成器是便捷方式,该规则则是安全网。
应用和回滚迁移
n8n 正常启动时会应用待处理的迁移。在本地检出版本中,运行 pnpm start,并使用目标代码版本手动应用迁移。
若要回滚最近应用的可回滚迁移,请使用 CLI 命令:
n8n db:revert
在本地检出版本中,通过软件包脚本运行相同的命令:
pnpm start -- db:revert
不要通过编辑迁移表或运行手写 SQL 来回滚迁移。db:revert 会运行迁移的 down() 方法,并保留 TypeORM 的迁移记录。
选择哪个目录
single schema change, DSL covers it → common/
Postgres-only feature (gen_random_uuid,
ALTER COLUMN TYPE, partial expr index) → postgresdb/
SQLite needs different recipe or to skip
CASCADE on table recreate → sqlite/ (subclass common/, set withFKsDisabled = true as const)
如果更改仅适用于 Postgres,只需将文件放在 postgresdb/ 下——不要编写带有 if (isPostgres) 守卫的 SQLite 空操作迁移。何时需要按数据库拆分,请参阅跨数据库兼容性。
类的结构
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddFooBar1700000000000 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column, createIndex }, escape }: MigrationContext) {
// ...
}
async down({ schemaBuilder: { dropIndex, dropColumns } }: MigrationContext) {
// ...
}
}
ReversibleMigration(默认)要求同时实现up和down。- 只有当
down()会导致数据不可恢复地丢失时,才使用IrreversibleMigration——请参阅可逆性。 - 仅在会重建被外键引用的表的
sqlite/子类中设置withFKsDisabled = true as const(否则 SQLite 的 CASCADE 会吞掉数据)。
遵循良好的代码规范
迁移类也是类——up() 不应是一个 200 行的过程。将较长的逻辑步骤拆分为私有方法,并用能描述其作用的名称命名(如 backfillSlugs)。这样,up() 就会变成简短的步骤调用列表。不要提取只有一行的步骤。 方法体只有一个 DSL 调用并不会增加信息——调用处本身已经清楚地说明了用途。
// 🚫: everything inline in up()
export class MigrateThing1234567890000 implements IrreversibleMigration {
async up(ctx: MigrationContext) {
// 80 lines of mixed DDL, raw SQL, batched updates, logging...
}
}
// ✅: up() is a table of contents; only multi-step work gets its own method
export class MigrateThing1234567890000 implements IrreversibleMigration {
async up(ctx: MigrationContext) {
const { schemaBuilder: { addColumns, column, createIndex } } = ctx;
// One-liner DSL calls stay inline — naming them adds no information.
await addColumns('my_table', [column('slug').varchar(255)], { recreatesOnSqlite: true });
// The non-trivial step gets a named method.
await this.backfillSlugs(ctx);
await createIndex('my_table', ['slug'], true);
}
private async backfillSlugs({ escape, runQuery, runInBatches, logger, migrationName }: MigrationContext) {
const table = escape.tableName('my_table');
await runInBatches<{ id: string; name: string }>(
`SELECT id, name FROM ${table} WHERE slug IS NULL`,
async (rows) => {
for (const row of rows) {
try {
const slug = row.name.toLowerCase().replace(/\s+/g, '-');
await runQuery(`UPDATE ${table} SET slug = :slug WHERE id = :id`, { slug, id: row.id });
} catch (error) {
logger.warn(`[${migrationName}] Failed to backfill row ${row.id}: ${(error as Error).message}`);
}
}
},
);
}
}
原因: 迁移被阅读的次数远多于编写次数——审查时、事故响应时,以及多年后有人需要理解某个列为何存在时。命名步骤本身就是文档,也能让差异更容易浏览:审查者一眼就能看出更改是“添加了新步骤”,还是“重写了现有步骤”。可逆迁移更能从中受益——down() 可以按相反顺序调用相同的私有辅助方法。
优先使用 runQuery(),而不是 queryRunner
在迁移中,通过 MigrationContext 的 runQuery() 执行 SQL。切勿在迁移中调用 queryRunner.query() 或 queryRunner.manager.*。
原因: runQuery() 能一致地处理命名参数绑定,而标识符仍需使用 escape.tableName()、escape.columnName() 和 escape.indexName()。queryRunner.query() 会绕过参数辅助工具。调用 queryRunner.manager 会使迁移依赖 TypeORM 实体定义,而实体定义会随时间变化——在 v1.0 中有效的迁移,如果实体结构发生变化,在 v2.0 中就可能失效。
切勿将实体作为值导入
不要使用 import { Entity } 并在其上调用 ORM 方法。请改用 runQuery() 执行原始 SQL。
// 🚫 value import; ties migration to current entity shape
import { ApiKey } from '../../entities';
await queryRunner.manager.update(ApiKey, { id }, { scopes });
// ✅ inline row type, raw SQL
type ApiKeyRow = { id: string; scopes: string };
await runQuery(`UPDATE ${table} SET scopes = :scopes WHERE id = :id`, { scopes, id });
仅类型导入(import type { Entity })可用于查询结果的类型标注,但更推荐使用内联类型,例如 type WorkflowRow = { id: string; nodes: string },以避免依赖可能会重命名或重构的实体。
原因: 迁移是历史记录,必须能够在迁移编写时的架构上运行。导入当前实体意味着后续重构会悄然改变旧迁移的含义。
始终转义标识符
每个标识符都要使用 escape.tableName()、escape.columnName() 和 escape.indexName()。不要自行拼接 ${tablePrefix}my_table,也不要硬编码 "model_tmp" 这样的带引号名称。
原因: Postgres 和 SQLite 的数据库类型、表前缀和引号规则各不相同。escape.* 辅助工具会应用正确的规则;手动插值最终总会在其中一个数据库上出错。
优先内联,而不是从同级软件包导入
@n8n/db 已依赖 n8n-workflow,但迁移从其他工作区软件包导入的内容越多,就越脆弱。能内联的小型常量和类型就内联。使用 MigrationContext 中的 parseJson(),而不是从 n8n-workflow 导入 jsonParse。
原因: 如果迁移从 n8n-workflow 导入 ERROR_TRIGGER_NODE_TYPE,就会永远依赖该常量的存在及其值。如果多年后重构时该常量被重命名或移除,那么在全新数据库上安装时迁移就会失败。
可接受的例外:语义稳定且内联实现较为繁琐的工具(例如 generateNanoId)。
日志记录
使用 MigrationContext 中的 logger,切勿使用 console.log。
logger.info(`[${migrationName}] Processing ${count} workflows`);
logger.warn(`[${migrationName}] Skipping row ${id}: missing required field`);
不要合并彼此独立的架构变更
每个文件只包含一个逻辑变更。涉及多个互不相关的表时,应拆分文件。审查者的意见是:“迁移名称具有误导性,因为它做了两件事。” 同时向 workflow_entity 添加列并创建 audit_log 的迁移应拆分为两个迁移。
不要修改已合并的迁移
迁移发布后即不可变。需要变更时,请编写新的迁移。要移除早期迁移添加的列,请在单独的后续迁移中操作(通常在后续版本中——请参阅弃用列,然后在后续迁移中删除)。
不要为非用户输入的值设置参数
如果值来自迁移本身,请直接内联字面量。命名参数用于运行时值;迁移正文中的常量可以直接写在 SQL 中。
命名和实体约定
- 表名:使用 snake_case;新表不使用
_entity后缀(仅旧约定使用)。 - 列名:代码中使用 camelCase;列名中不要重复表名(
user.userEmail→user.email)。 - 常量:使用 camelCase,而非 SCREAMING_CASE。
- 实体名称覆盖:当实体类名与表名不同时,显式设置
@Entity({ name: 'snake_case_name' })。 - TypeORM 关系:使用
Relation<T>而非直接引用,以避免已知的循环导入问题。 - 抽象实体:适用时继承
WithTimestamps或WithTimestampsAndStringId——这是既定标准。 - 不要在没有具体且受益的读取模式时进行反规范化。 在 PR 描述中说明任何重复列的理由。
架构迁移
使用 DSL 进行架构变更
进行新增、删除和修改时,请使用架构构建器 DSL。它会自动处理跨数据库类型映射。如果缺少某个辅助方法,请添加它或提出相关问题。
export class CreateMyTable1234567890000 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable('my_table')
.withColumns(
column('id').int.primary.autoGenerate2, // Use autoGenerate2, not autoGenerate
column('name').varchar(255).notNull,
column('workflowId').varchar(36).notNull,
column('config').json, // Maps to json (PG) / text (SQLite)
column('isActive').bool.notNull.default(false),
)
.withTimestamps // Adds createdAt + updatedAt
.withIndexOn(['workflowId'])
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE', // Always explicit
});
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('my_table');
}
}
SQLite 表重建风险
有 6 个 DSL 方法会在 SQLite 上触发完整的表重建——TypeORM 会在内部创建临时副本、删除原表并重命名:
| 方法 | TypeORM 内部调用 |
|---|---|
addColumns() | queryRunner.addColumns() |
dropColumns() | queryRunner.dropColumns() |
addNotNull() | queryRunner.changeColumn() |
dropNotNull() | queryRunner.changeColumn() |
addEnumCheck() | queryRunner.changeColumn() |
dropEnumCheck() | queryRunner.changeColumn() |
这 6 个方法都需要在末尾传入选项参数 recreatesOnSqlite: true——TypeScript 会拒绝省略该参数的调用。
风险:如果目标表有来自其他表、带有 CASCADE 的入站 FK 约束,重建期间执行的 DROP TABLE 会触发级联删除,并清空引用这些表的行。
决策树:
- 目标表是否有来自其他表的入站 FK 约束?
- 否 → 可以直接使用 DSL 方法(并传入确认参数)。
- 是 → 继续执行第 2 步。
- 这是
addColumns调用,且所有新列都允许为空或有默认值吗?- 是 → 改用原始
ALTER TABLE ADD COLUMN(完全避免重建表):
查看await runQuery( `ALTER TABLE ${escape.tableName('my_table')} ADD COLUMN ${escape.columnName('col')} TEXT`, );1733133775640-AddMockedNodesColumnToTestDefinition.ts中的实际示例。 - 否 → 继续执行第 3 步。
- 是 → 改用原始
- 在迁移类上设置
withFKsDisabled = true as const。对于通用迁移,在sqlite/中创建一个继承通用迁移并添加该标记的 SQLite 子类:// sqlite/1234567890000-MyMigration.ts import { MyMigration1234567890000 as BaseMigration } from '../common/1234567890000-MyMigration'; export class MyMigration1234567890000 extends BaseMigration { withFKsDisabled = true as const; }
**withFKsDisabled 的工作方式:**迁移包装器会在执行 up()/down() 之前调用 PRAGMA foreign_keys=OFF,在手动事务中运行迁移,然后重新启用外键。这可以防止内部删除表时触发 CASCADE。它还会设置 transaction = false,以避免使用 TypeORM 的默认事务(因为 SQLite 无法在 PRAGMA 变更期间嵌套事务)。
**注意:**在 Postgres 上,这些方法直接使用
ALTER TABLE,不会重建表。此风险仅适用于 SQLite。
列类型
让列类型与值的语义相匹配。 切勿将 varchar 当作非字符串值的万能类型——将数字存为字符串会丢失排序顺序、范围查询以及 SUM/AVG 聚合能力。
- 仅日期有意义时,使用
DATE,而不是timestamp。 - 字节数和测量值使用数值类型(
bigint、int、smallint)——切勿使用varchar。 - 当值实际是 UUID 时,优先使用原生
uuid,而不是varchar(36)。Postgres 将uuid存储为 16 字节,而varchar(36)约为 37 字节;在联接表和索引中,这种差异会不断累积。 - 布尔值使用
bool;结构化数据使用json。 - 有意选择
timestampTimezone()(默认精度为 3 毫秒)或timestampNoTimezone()。.timestamp()已弃用。
在相应类别中选择合理的最窄类型。
- 小范围计数器/枚举使用
smallint;除非可能溢出,否则优先使用int而不是bigint。 - 对于可能超出
int范围且会持续递增的计数器(洞察/用量计数器),应主动使用bigint。 - 不要对类似版本号的字段使用
double;浮点精度会带来问题。请使用字符串或拆分主版本号/次版本号。 - 对于长度不受限的用户输入字符串,除非有实际限制,否则优先使用
text而不是varchar(255)。(SQLite 完全忽略varchar(N)的长度;如有需要,请在应用层进行校验。)
NOT NULL 与实体一致性
- 主键隐式为
NOT NULL;不要重复声明。 - 迁移中的
notNull必须与实体的可空性注解一致。不一致会导致 TypeORM 无法协调的运行时空值。 - 默认使用
NOT NULL;只有在明确说明理由后才放宽限制(“是否需要允许为空?在什么情况下?”)。
为列添加注释
对于仅凭名称无法看出用途的列,应使用 .comment()——尤其是 JSON 数据块、标志、含义不明的 ID、Unix 时间戳,以及值来自外部系统的列。注释会写入架构;代码注释不会。
column('config').json.comment('Serialized node parameters at time of publish'),
column('isArchived').bool.notNull.default(false).comment('Soft-delete flag; filtered out in list queries'),
限制类似枚举的字符串
对于只能存储少数几种值的列,请在列上使用 .withEnumCheck([...])。通过原始 SQL 添加 CHECK 约束时,请显式命名约束。
默认值应反映真实的初始状态
不要将 status 列的默认值设为终态值——对于后续会发生状态转换的列,"running" 比 "done" 更合理。
主键
每张表都需要主键。默认使用整数键。 按顺序阅读以下选项,使用第一个符合条件的选项。不要仅仅因为更喜欢后面的选项就跳过某个选项。
-
整数(默认) —
column('id').int.primary.autoGenerate2。所有新表都使用此选项。只有满足其他选项的条件时,才使用其他选项。整数键占用空间小,JOIN 速度快。autoGenerate2使用 PostgresIDENTITY。不要使用autoGenerate。它使用已弃用的serial。 -
UUID —
column('id').uuid.primary。仅在满足以下任一条件时使用:- 应用必须在插入前知道 ID。例如,客户端生成 ID。
- ID 会出现在 URL 或公共 API 中。用户不能看到现有行数,也不能猜出其他 ID。
必须使用 UUIDv7。 使用
uuid包在应用代码中生成 ID:import { v7 as uuidv7 } from 'uuid'。原因:UUIDv7 以时间戳开头。因此,新行会排在索引末尾附近。UUIDv4 是随机的,例如
node:crypto中的randomUUID()。新行会被放到索引中的随机位置。这会拆分索引页并增大索引。UUIDv7 无法提供精确的创建顺序。同一毫秒内生成的两个 ID 可能排序错误。主机之间的时钟差异也可能导致此问题。如果需要精确顺序,请按时间戳列排序。
不要在
.uuid后链接.autoGenerate2。DSL 会抛出错误。数据库默认值会生成 UUIDv4。此外,uuid_generate_v4()需要uuid-ossp扩展。某些托管 Postgres 服务(例如 Supabase)没有此扩展。使用.uuid,不要使用.varchar(36)。 -
字符串 —
column('id').varchar(36).primary。仅在满足以下任一条件时使用:- 实体已使用非 UUID 格式的 ID。例如,该实体的公共 API 或 UI 已经显示 nanoid ID。
- UI 会向用户显示 ID。UUID 太长,不便于阅读、复制或输入。
实体 ID 通常是 16 个字符的 nanoid。
每个非整数键都必须说明原因。 在 PR 描述中写明原因。审核者会询问。以下理由不充分:
- “其他表使用字符串 ID。”
- “这个模块已经是这样做的。”
- “以后可能会需要。”
这些选项仅适用于生成的 id 列。 仅用于关联其他表的联接表不需要 id 列。请使用其外键列作为复合键(见下文)。外键列的类型始终与其引用的键相同。
相关表中的 ID 列类型要保持一致。 对“同类 ID”混用 uuid 和 varchar(36) 会导致 JOIN 隐患。
需要了解的 DSL 行为:
.primary已隐含notNull。不要将.notNull与.primary链接使用,这是多余的。.primary已创建主键索引。不要再为其单独添加.withIndexOn(['id'])。
复合主键是一等功能 — 在每个参与的列上链接 .primary。自然键适用时,请省略代理 id。
await createTable('membership')
.withColumns(
column('userId').uuid.primary,
column('roleId').uuid.primary,
);
外键约束
外键是默认选择;如要不使用,必须说明理由。 对于多态引用(一个列根据同级类型列指向不同的表),请参阅通用设计指南。
明确指定 onDelete。 不要依赖数据库默认值。在 PR 描述中回答“删除 [父项] 时会发生什么?”
| 关系类型 | onDelete | 示例 |
|---|---|---|
| 子项脱离父项后毫无意义 | CASCADE | annotation_tag_mapping → annotation |
| 子项应在父项删除后保留(留存历史记录) | SET NULL | workflow_publish_history.userId → user |
| 审计 / 统计 / 历史记录表 | NO ACTION 或 SET NULL | workflow_statistics → workflow_entity |
| 引用应阻止删除 | RESTRICT | (业务逻辑不允许产生孤立项时使用) |
对于 SET NULL,外键列必须允许 NULL。对于 CASCADE,请考虑级联深度是否有限——长级联链可能会在一次删除操作中锁定许多表。
SQLite 的特殊情况:
- 删除并重建表可能导致 FK 在多轮 up/down 操作中重复。明确命名 FK,以便 down 迁移能够定位它们。
- 在有些引擎上,down 迁移可能会因索引受 FK 保护而失败(
Cannot drop index 'X': needed in a foreign key constraint)。请在每种引擎上测试正向和反向迁移。
NULL 在连接和唯一性中的语义因引擎而异。 在至少 SQLite 中,将 NULL 与非 NULL 比较会过滤掉行。Postgres 和 SQLite 对跨 NULL 值的部分唯一性处理不同。当某列允许 NULL,且参与唯一约束或连接条件时,请谨慎处理。
索引管理
// Creating indices
await schemaBuilder.createIndex('my_table', ['columnA', 'columnB']);
await schemaBuilder.createIndex('my_table', ['email'], true); // unique
// Partial unique index — uniqueness only on non-null rows
await schemaBuilder.createIndex(
'my_table',
['externalRef'],
true, // isUnique
undefined, // customIndexName
'"externalRef" IS NOT NULL', // whereClause
);
// Dropping indices (defensively)
await schemaBuilder.dropIndex('my_table', ['columnA'], { skipIfMissing: true });
最佳实践:
- 谨慎添加索引,且只有在测得性能提升后才添加。 每个索引都会减慢插入 / 更新,并占用磁盘空间。不要“以防万一”就添加索引——用真实场景的数据集运行查询,确认查询缓慢后再添加索引,然后确认查询规划器确实使用了索引,且查询速度有所提升。如果无法证明有可测量的改进,就不要发布该索引。
- 唯一约束本身已会创建索引,不要重复添加。 复合主键会为其前缀列建立索引;单独为前缀建立索引是多余的。
- 为外键列添加索引。 每次操作中的连接和级联删除都会访问 FK;未建立索引的 FK 会导致对子表进行顺序扫描。
- 复合索引中的列顺序很重要。
(A, B)上的索引适用于WHERE A = ?和WHERE A = ? AND B = ?,不适用于WHERE B = ?。索引中的 ORDER BY 方向必须与查询的ORDER BY一致(例如(sessionId, createdAt ASC, id DESC))。 - 不要单独为基数较低的列建索引(布尔值、只有 2–3 种取值的状态枚举)。可以跳过索引,或将其设为部分索引——Postgres 和 SQLite(自 3.8.0 起)都支持在索引上使用
WHERE子句。 - 对稀疏唯一列使用部分唯一索引。 添加
WHERE col IS NOT NULL以排除 NULL 行:索引更小,也无需对 NULL 分组进行唯一性检查。 - 唯一索引既能强制唯一性,也能加速查找。 优先使用唯一索引,而不是单独的唯一约束加索引。
- 删除未使用的索引。 如果查询计划不再使用某个索引,请在后续迁移中将其删除。
- **通过 DSL 命名索引,**绝不要手动编造名称。DSL 会为名称添加一致的前缀,确保不同环境中的命名一致。
- 使用
@Index将 DSL 索引同步到实体上。 迁移会创建运行时索引;实体装饰器则确保新建数据库时配置保持同步。 - **定义新表时使用
.withIndexOn(...),**而不是单独调用createIndex(...)。
可逆性
ReversibleMigration:down()必须确实可用。如果要缩小列,需妥善截断数据。如果要删除表,请考虑该表可能已经有数据。IrreversibleMigration:当up()销毁了忠实的down()所需的信息时使用,而不是用来逃避繁琐的down()代码。例如:覆盖值却未保留原始状态的数据回填、不保留明文的加密操作、丢失行级细节的聚合。- 绝不要编写空的或有问题的
down()。 如果无法还原,请使用IrreversibleMigration。 down()必须还原之前的架构,而不只是删除新对象——其效果应当能让up()之后重新干净地运行。- 在两种引擎上测试回滚迁移:
pnpm start && pnpm start -- db:revert && pnpm start。回滚失败通常是因为受外键保护的索引阻止删除列。
架构/实体漂移
架构、实体和 OpenAPI 类型必须一致。经常发现的问题:
- 实体中丢失了
notNull,但迁移中存在(或反之)。 - 实体声明类型为
string,但列的类型并非如此。 - 实体中不存在对应的
@Index。 up与实体对默认值或约束的声明不一致。
当数据完整性要求新列必须有值时(例如,active 为 TRUE 时应设置 activeVersionId),请通过迁移中的 CHECK 约束以及应用代码中的运行时不变量来强制执行。
先弃用列,再在后续版本中删除
停止写入列的同一版本不要删除该列。等待一个版本后再删除。这样可以保护滚动部署,并在“停止写入”变更出现意外问题时提供快速回滚路径。
数据迁移
数据迁移会转换现有行:解析 JSON、回填列、在表之间迁移数据、清理无效数据。
始终处理脏数据/旧数据
这是迁移错误的头号来源。
- **使用 try/catch 包裹 JSON 解析。**记录警告并跳过该行——绝不要让整个迁移崩溃。使用
MigrationContext中的parseJson();它比直接使用JSON.parse更能处理边缘情况。 - 访问属性前检查 null/undefined:
node.type && isTriggerNode(node.type)。 - 遍历前使用
Array.isArray()。 - 考虑数据结构的所有历史版本,而不只是当前版本。今天发布的迁移可能会在两年前最后更新过的数据库上运行。
- 在 SQL 中过滤无效行:
WHERE workflowId IS NOT NULL。
await runInBatches<Row>(selectQuery, async (rows) => {
for (const row of rows) {
try {
const nodes = parseJson(row.nodes);
if (!Array.isArray(nodes)) continue; // guard against unexpected shape
for (const node of nodes) {
if (!node.type) continue; // skip nodes missing required fields
// ... transform ...
}
await runQuery(`UPDATE ${table} SET nodes = :nodes WHERE id = :id`, {
nodes: JSON.stringify(nodes),
id: row.id,
});
} catch (error) {
logger.warn(`[${migrationName}] Failed to process row ${row.id}: ${error.message}. Skipping.`);
}
}
});
将转换放在 SQL 中,而不是 Node 中
优先使用 INSERT … SELECT、UPDATE … FROM、DELETE … WHERE,而不是先将行读取到 Node,再写回去。将整张表加载到 JS 内存中,在大型实例上速度慢且容易导致 OOM;数据库可以在原处更快地完成相同工作。
当仅用 SQL 无法表达转换时,再退而使用 runInBatches。在 SQL 中尽早过滤(LIKE/WHERE),以减少在 Node 端解析的行数。
使用批量操作
对于可能包含数百万行的表,绝不要不加限制地执行 SELECT *。
// ✅: batched processing
await runInBatches<Workflow>(
`SELECT id, nodes FROM ${tableName} WHERE ${condition}`,
async (workflows) => {
for (const workflow of workflows) {
// ... process each workflow ...
}
},
100, // batch size (default: 100, use 100-500)
);
// ✅: batched table copy
await copyTable('old_table', 'new_table', ['col1', 'col2'], ['col1', 'col2'], 500);
在较大的实例上扫描整张表会非常慢。如果必须遍历,请分批处理。
有意安排回填插入顺序
当迁移写入的行顺序会影响下游可观察结果(自增 ID、默认排序顺序、“最新优先”的 UI 列表)时,请为源 SELECT 添加显式的 ORDER BY——通常按 updatedAt 或 createdAt 排序。若不指定,数据库会任意选择顺序,旧架构中隐含的时间顺序便会丢失。
混合架构与数据迁移
当一次迁移既添加列又回填数据时,应清晰地按职责拆分,每种职责对应一个方法:
export class AddAndBackfillColumn1234567890000 implements IrreversibleMigration {
async up(ctx: MigrationContext) {
await ctx.schemaBuilder.addColumns(
'my_table',
[ctx.schemaBuilder.column('newCol').text],
{ recreatesOnSqlite: true },
);
await this.backfillNewCol(ctx);
}
private async backfillNewCol({ escape, runQuery, runInBatches }: MigrationContext) {
const table = escape.tableName('my_table');
await runInBatches<{ id: string; oldCol: string }>(
`SELECT id, oldCol FROM ${table}`,
async (rows) => {
for (const row of rows) {
const transformed = transform(row.oldCol);
await runQuery(`UPDATE ${table} SET newCol = :val WHERE id = :id`, {
val: transformed,
id: row.id,
});
}
},
);
}
}
架构变更和数据回填有不同的失败模式、事务影响和测试需求——将它们拆分到不同方法中,更便于审查,也能让 down()(如果可逆)按相反顺序调用相同的辅助方法。
删除数据时,优先保留旧行作为后备
可自托管的实例可能存在意料之外的数据形态。如果迁移导致数据缺失或不一致,旧行就是唯一的恢复途径。
默认采用跨两个版本的扩展-收缩方式:
- **版本 N(本次迁移):**写入新位置,保留旧位置中的数据。
- **版本 N+1(单独的后续迁移,在新代码已于生产环境中运行一段时间后执行):**删除旧位置中的数据。
只有在旧位置中的数据确实可以丢弃(例如,本次迁移创建的临时表),或合规要求立即删除时,才跳过等待期——此时应将迁移标记为 IrreversibleMigration,并在 PR 描述中说明这一取舍。
保持反规范化列同步
当数据在两个表中重复存储(例如,workflow_entity.nodes 与 workflow_history.nodes)时,回填必须更新两份数据。反规范化数据不同步的问题往往要到几周后才会被发现,而且通常是在生产环境中。
不要在高频访问的表上添加 JSON 子串扫描
应添加真实列(例如 isDraft,类似于 isArchived),而不是使用 WHERE settings::text LIKE '%foo%'。对 JSON 数据执行子串扫描会退化为全表扫描,且无法使用索引。
避免在高频访问的行中内联存储大型数据块
将选择不存储的大型列移到旁侧表中,这有利于备份、复制和读取性能。行载荷变小后,高频访问表上的行级锁也会随之缩小。
复制表时验证数据完整性
交换表之前,应对比源表与临时表的行数;如果不一致,就抛出错误。复制过程中静默丢行是最糟糕的失败模式之一,因为只有在有人发现数据缺失时才会暴露。
在迁移的事务中执行原子 SQL
对于不允许在事务中执行大型 DDL 的数据库引擎,有些迁移会通过 transaction = false as const 覆写事务设置。当 withFKsDisabled = true 时,DSL/包装器会自动设置 transaction = false。其他情况下,不要改动事务设置——TypeORM 默认会将每个迁移包装在一个事务中。
跨数据库兼容性
SQLite 与 Postgres 使用单个迁移文件还是分别编写
- 差异较小(单条语句、CHECK 约束或语法略有不同):在
common/中保留一个迁移,并根据isSqlite/isPostgres进行分支处理。 - 差异较大(表重建策略不同、中间步骤不同或 SQL 有根本差异):在
postgresdb/和sqlite/中分别编写迁移文件。与两个职责明确的文件相比,充满if (isSqlite) { ... }代码块的通用迁移更难阅读和审查。
如果只需在 Postgres 中进行更改,就将文件放在 postgresdb/ 中;不要编写包含 if (isPostgres) 的无操作 SQLite 迁移。对于 SQLite 列添加,在判断通用迁移是否足够,或是否需要 SQLite 子类/原始 ALTER TABLE 路径之前,请先参照 SQLite 表重建风险决策树。
SQLite 支持现代语法
- SQLite 支持
UPSERT/ON CONFLICT DO NOTHING——应使用与 Postgres 相同的语法,而不是INSERT OR REPLACE。 - SQLite 有真正的
JSON类型。 - SQLite 支持
ALTER TABLE ... RENAME TO。
INSERT OR REPLACE ≠ ON CONFLICT DO NOTHING。 OR REPLACE 会覆盖;ON CONFLICT DO NOTHING 会忽略。SQLite 两者都支持——应选择与同一代码路径中 Postgres 语义相符的一种。
感知 Postgres 版本的 UUID 生成
gen_random_uuid() 需要 Postgres ≥ 13。n8n 已弃用 Postgres 12 — 优先使用它,而不是 uuid_generate_v4()(后者需要 uuid-ossp 扩展,并且在 Supabase 等托管服务上会出问题)。对于 UUID 主键,请在应用代码中生成 UUIDv7 — 参见主键。
SQLite 不会强制执行 varchar(N) 长度限制
如果长度很重要,请在应用层进行验证。
不同数据库引擎对布尔默认值的呈现方式不同
DEFAULT (false)、DEFAULT 0 与 DEFAULT FALSE。交给 DSL 处理;不要在原始 SQL 中手写布尔默认值。
优先使用 ALTER,而不是删除后重建
重命名时,请使用 ALTER TABLE ... RENAME TO。这样更快、操作原子化,也没有数据丢失风险。
测试
每个数据迁移都必须附带集成测试。 通常只需阅读 DSL 调用就能审查仅涉及架构的迁移。但数据迁移无法如此审查 — 它们包含对行结构、JSON 结构、NULL 处理和边缘情况的假设,而这些问题只有在迁移实际针对具有代表性的数据运行时才会显现。
数据迁移在每个数据库上只运行 一次,针对的是生产数据,而且没有干净重试的机会。糟糕迁移的代价是影响客户的事故;编写测试的代价则是十分钟。
测试位于 packages/cli/test/migration/,名称与迁移文件匹配(例如 1773000000000-create-credential-dependency-table.test.ts)。请使用 @n8n/backend-test-utils 中的辅助函数:
initDbUpToMigration(MigrationName)会运行所有 早于 当前迁移的迁移,让数据库处于当前迁移在生产环境中将看到的确切状态。runSingleMigration(MigrationName)只会在该状态之上运行当前迁移。
完整的辅助函数 API:packages/@n8n/backend-test-utils/MIGRATION_TESTING.md。
import { initDbUpToMigration, runSingleMigration } from '@n8n/backend-test-utils';
describe('AddAndBackfillColumn1234567890000', () => {
beforeEach(async () => {
await initDbUpToMigration('AddAndBackfillColumn1234567890000');
});
it('backfills newCol from oldCol', async () => {
// Seed rows in the pre-migration schema
await dataSource.query(`INSERT INTO my_table (id, oldCol) VALUES ('1', 'foo')`);
await runSingleMigration('AddAndBackfillColumn1234567890000');
const [row] = await dataSource.query(`SELECT newCol FROM my_table WHERE id = '1'`);
expect(row.newCol).toBe('transformed-foo');
});
it('skips rows with NULL oldCol without crashing', async () => {
await dataSource.query(`INSERT INTO my_table (id, oldCol) VALUES ('1', NULL)`);
await runSingleMigration('AddAndBackfillColumn1234567890000');
// assert no error and row still exists
});
});
仅通过原始 SQL 插入测试数据。 仓库会随着架构演进,时间久了会导致旧测试失效。请直接使用 context.escape.tableName(...) 和 context.runQuery(sql, params)。
测试名称应描述行为,而不是 SQL:'backfills newCol from oldCol',而不是 'runs UPDATE on my_table'。
测试应覆盖:
- 正常路径(正确转换典型行)。
- 迁移声称能处理的每一种边缘情况(NULL 字段、格式错误的 JSON、缺失键、旧版架构)。
- 适用时验证幂等性 — 迁移运行两次不应重复应用转换。
- 如果迁移针对不同数据库类型执行不同逻辑,则同时覆盖 SQLite 和 Postgres。
通用设计指导
这些是广泛适用的数据库设计原则,并非针对某一类反复出现的 PR 评论,但值得牢记,因为设计错误的 代价 会体现在代码库中(本可由数据库免费完成的工作,却需要手动编排)。
避免使用多态 (typeCol, idCol) 列对
“多态”列对是指一列根据同级类型列指向不同的表 — 例如,dependencyType: 'externalSecretProvider' | ... 加上 dependencyId: string。SQL 外键只能指向一个表,因此多态 idCol 无法声明外键。
后果:
- 插入时无法验证(可以插入一个不匹配任何行的
dependencyId)。 - 删除父记录时无法级联删除或限制删除 — 应用代码必须手动遍历所有可能指向被删行的表,并在事务中删除关联记录(实际案例可参见
credential_dependency+secrets_provider_connection的删除路径,了解这种做法的代价)。 - 这种设计本身就可能产生孤立行。
替代方案:
- 每种关系类型使用独立的关联表(
credential_external_secret_dependency、credential_node_dependency等)。每张表都有真正的外键。需要查询“所有依赖项”时,可使用 UNION。 - 每个可能的目标使用一个可空外键,并通过 CHECK 约束确保恰好有一列被设置。每一列都是真正的外键。
- 超类型表:将父记录提取到单一的
dependency_target表中,并在其中设置自己的类型列,然后只需一个外键指向该表。
架构文档
数据库架构文档位于 docs/generated/ 下的 docs/generated/sqlite-schema/ 和 docs/generated/postgres-schema/ 中,每个表对应一个 Markdown 页面,并附有一个 Mermaid ER 图。这些文档由 tbls 根据迁移自动生成(它会在临时数据库中运行每个迁移并检查结果),因此任何更改架构的迁移都会使文档过时。
请在提交迁移的同时重新生成并提交这些文档:
pnpm db:schema:docs # rewrites docs/generated/ — requires Docker (and `brew install tbls` locally)
pnpm db:schema:check # verify only; what the DB Tests CI job runs
如果提交的文档与迁移不一致,DB Tests CI 作业会使 PR 检查失败(每个矩阵任务都会验证自己的数据库)。请勿手动编辑 docs/generated/ 下的任何内容——每次重新生成时都会覆盖这些内容。