mcpField
N
n8n:db-migrations

n8n:db-migrations

github.com/n8n-io数据与数据库209.1k193 次安装1 天前更新
代码仓库

关于 n8n:db-migrations

n8n:db-migrations 是面向 n8n 贡献者的数据库迁移技能,涵盖跨数据库设计规则、提交审查前的检查清单,以及测试和回退迁移的指引。

主要功能

  1. 01迁移审查前检查清单
  2. 02模式与数据迁移指引
  3. 03PostgreSQL 和 SQLite 兼容规则
  4. 04回退与测试说明

适用场景

  1. 01准备迁移以提交审查
  2. 02选择迁移类型和目录
  3. 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 类型PostgreSQLSQLite
intintinteger
bigintbigintinteger
smallintsmallintinteger
varchar(N)varchar(N)varchar(N) (不强制执行长度限制)
texttexttext
jsonjsontext
uuiduuidvarchar
boolbooleanboolean
doubledouble precisionreal
binarybyteablob
timestampTimezonetimestamptzdatetime
timestampNoTimezonetimestampdatetime
timestamp (已弃用)timestampdatetime

时间戳类型变体的默认精度为 3 ms;使用 .timestampTimezone(6) 可覆盖此设置。


预检清单

请求审核前,请逐项检查。每一项都是审核者反复提出的问题;链接指向说明相应规则的章节。

  • 使用 pnpm --filter=@n8n/db migration:new 搭建迁移(时间戳和注册会自动完成;migration-timestamp lint 规则会检测偏差)。— 创建迁移
  • 标识符必须通过 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 会触发级联删除,并清空引用这些表的行。

决策树:

  1. 目标表是否有来自其他表的入站 FK 约束?
    • 否 → 可以直接使用 DSL 方法(并传入确认参数)。
    • 是 → 继续执行第 2 步。
  2. 这是 addColumns 调用,且所有新列都允许为空或有默认值吗?
    • 是 → 改用原始 ALTER TABLE ADD COLUMN(完全避免重建表):
      await runQuery(
        `ALTER TABLE ${escape.tableName('my_table')} ADD COLUMN ${escape.columnName('col')} TEXT`,
      );
      
      查看 1733133775640-AddMockedNodesColumnToTestDefinition.ts 中的实际示例。
    • 否 → 继续执行第 3 步。
  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" 更合理。

主键

每张表都需要主键。默认使用整数键。 按顺序阅读以下选项,使用第一个符合条件的选项。不要仅仅因为更喜欢后面的选项就跳过某个选项。

  1. 整数(默认) — column('id').int.primary.autoGenerate2。所有新表都使用此选项。只有满足其他选项的条件时,才使用其他选项。整数键占用空间小,JOIN 速度快。autoGenerate2 使用 Postgres IDENTITY。不要使用 autoGenerate。它使用已弃用的 serial。

  2. 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)。

  3. 字符串 — 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示例
子项脱离父项后毫无意义CASCADEannotation_tag_mapping → annotation
子项应在父项删除后保留(留存历史记录)SET NULLworkflow_publish_history.userId → user
审计 / 统计 / 历史记录表NO ACTION 或 SET NULLworkflow_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/ 下的任何内容——每次重新生成时都会覆盖这些内容。

评论

支持 Markdown