首页
/ Twenty 数据库迁移治理:为什么 legacy-typeorm-migrations-do-not-add 目录被冻结,以及 Instance Command 如何取代 TypeORM 迁移

Twenty 数据库迁移治理:为什么 legacy-typeorm-migrations-do-not-add 目录被冻结,以及 Instance Command 如何取代 TypeORM 迁移

2026-09-07 17:46:35作者:昌雅子Ethen

Twenty 的数据库 schema 演进在历史上由 TypeORM 迁移文件驱动,但这些迁移文件如今被冻结在一个专门的目录中:legacy-typeorm-migrations-do-not-add。该目录承载 common/billing/ 两组历史迁移,仅用于让旧部署能够基于 _typeorm_migrations 表重放已执行过的迁移;当前所有新的 schema 与数据变更都必须改为编写 instance commands(fast / slow)或 workspace commands。读完本文,你将理解 Twenty 冻结 TypeORM 迁移系统的工程动机、旧迁移在数据源中的加载方式,以及如何用 npx nx run twenty-server:database:migrate:generate 生成并理解新的升级命令。

目录定位:一份"只读历史"的自述

该目录的 README 只有几段话,但把关键治理决策说得很清楚:

  • 目录内是 历史 TypeORM 迁移,分为 common/billing/ 两组,保留它们的唯一目的是让老部署可以对照 _typeorm_migrations 表继续重放;
  • TypeORM 迁移系统已被冻结(frozen),禁止再向这里添加新文件
  • 当前活跃的升级系统是 instance commands(fast / slow)与 workspace commands,通过 @RegisteredInstanceCommand@RegisteredWorkspaceCommand 装饰器注册,并用如下命令生成:
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>

README 还特别澄清了一个容易误判的点:../migrations/utils/ 目录 不属于 legacy 范围——那些 SQL 辅助函数仍被当前的 instance / workspace 命令 import,因此它们被有意放在本目录之外(即 migrations/utils/ 下),继续作为活跃代码维护。

从目录内容看,冻结并非一蹴而就:common/ 下的迁移时间戳跨度从 1700140427984(最早的 setupMetadataTables)一直延伸到 1769196250679addNavigationMenuItemViewForeignKey 等),文件命名遵循 TypeORM 迁移的"时间戳-描述"惯例(如 1765499361805-addRLS.ts1767876112877-removeWorkspaceMigration.ts);billing/ 下仅有 3 个文件(1708535112230-addBillingCoreTables.ts 等),对应计费模块的早期 schema。这些文件从此只作为"已执行历史"存在,不再增长。

源码印证:旧迁移为何仍要加载

冻结的目录之所以不能删,根源在 core 数据源的配置。core.datasource.ts 中的关键设置包括:

export const typeORMCoreModuleOptions: TypeOrmModuleOptions = {
  url: process.env.PG_DATABASE_URL,
  type: 'postgres',
  schema: 'core',
  synchronize: false,
  migrationsRun: false,
  migrationsTableName: '_typeorm_migrations',
  // The TypeORM migration system is frozen — historical migrations live in
  // `legacy-typeorm-migrations-do-not-add/` and are loaded here only so the
  // `_typeorm_migrations` table stays consistent for older deployments.
  // Do NOT add new files there: write a fast/slow instance command instead.
  migrations:
    process.env.IS_BILLING_ENABLED === 'true'
      ? [
          `${distOrSrc}database/typeorm/core/legacy-typeorm-migrations-do-not-add/common/*{.ts,.js}`,
          `${distOrSrc}database/typeorm/core/legacy-typeorm-migrations-do-not-add/billing/*{.ts,.js}`,
        ]
      : [
          `${distOrSrc}database/typeorm/core/legacy-typeorm-migrations-do-not-add/common/*{.ts,.js}`,
        ],
  // ...
};

这段配置解释了三层事实:

  1. 加载即声明migrations 数组显式指向 legacy-typeorm-migrations-do-not-add/common(计费开启时额外加载 billing),TypeORM 由此知道完整的历史迁移清单;migrationsTableName: '_typeorm_migrations' 决定了重放记录落在哪张表。这与 README 中"kept so that older deployments can still replay them against the _typeorm_migrations table"的描述一一对应。
  2. 只声明、不自动执行migrationsRun: falsesynchronize: false 意味着服务启动时不会自动跑迁移,schema 变更全部走显式的命令入口(见下节的 database:migrate target)。
  3. 目录名即规范。源码注释与 README 互为镜像:注释里直接写明 "Do NOT add new files there: write a fast/slow instance command instead",把治理规则固化在代码评审可见的位置。

从源码结构看,这一加载还带有条件性:仅当 IS_BILLING_ENABLED === 'true' 时才把 billing/ 迁移纳入清单,说明 billing 迁移属于可选部署路径的历史包袱,进一步印证了"legacy 只服务于旧部署"的定位。

新的升级体系:Instance / Workspace Commands 如何接管

README 指向的 UPGRADE_COMMANDS.md 定义了取代 TypeORM 迁移的完整体系,核心要点如下。

生成命令与注册机制

npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>

该命令在 project.json 中映射到 database:migrate:generate target,实际执行 node dist/command/command.js generate:instance-command(依赖 build),对应实现为 generate-instance-command.command.tsinstance-command-generation.service.ts。文档明确说明:生成器会创建一个带时间戳的文件,并自动注册进 instance-commands.constant.ts——不要手动编辑该文件

按版本组织的真实实现可以在 upgrade-version-command 目录 中找到,例如 1-22 版本的 fast 命令示例

Fast 命令:升级期立即执行的 schema 变更

Fast 命令在升级过程中立即运行,适用于"如果延迟执行会在数据库与服务之间造成破坏性不一致"的 schema 变更。它实现 FastInstanceCommand,提供 up / down 两个方法:

@RegisteredInstanceCommand('1.22.0', 1775758621017)
export class AddWorkspaceIdToTotoFastInstanceCommand
  implements FastInstanceCommand
{
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "core"."toto" ADD "workspaceId" uuid`,
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "core"."toto" DROP COLUMN "workspaceId"`,
    );
  }
}

Slow 命令:先做慢速数据迁移,再做 schema 变更

Slow 命令用于"在 schema 变更之前必须完成、且可能非常耗时"的数据迁移,只有显式传入 --include-slow 时才会执行。它实现 SlowInstanceCommand(继承自 FastInstanceCommand),额外提供在 up 之前执行的 runDataMigration 方法:

@RegisteredInstanceCommand('1.22.0', 1775758621018, { type: 'slow' })
export class BackfillWorkspaceIdSlowInstanceCommand
  implements SlowInstanceCommand
{
  async runDataMigration(dataSource: DataSource): Promise<void> {
    // Backfill logic (can be slow — e.g. iterating over workspaces, cache recomputation)
  }

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "core"."toto" ALTER COLUMN "workspaceId" SET NOT NULL`,
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "core"."toto" ALTER COLUMN "workspaceId" DROP NOT NULL`,
    );
  }
}

文档给出的常用组合模式:一个 fast 命令先加可空列,一个 slow 命令回填存量行后再置为 NOT NULL。仓库中的 1-22-instance-command-slow 示例 正是这一"回填 workspaceId"模式的真实落地。

Workspace 命令:遍历所有活跃/挂起工作区

Workspace 命令用于逐工作区(per-workspace)的逻辑,通过 @RegisteredWorkspaceCommand 装饰器与 nest-commander 的 @Command 装饰器联合注册,并继承 ActiveOrSuspendedWorkspaceCommandRunner

@RegisteredWorkspaceCommand('1.22.0', 1780000002000)
@Command({
  name: 'upgrade:1-22:backfill-standard-skills',
  description: 'Backfill standard skills for existing workspaces',
})
export class BackfillStandardSkillsCommand
  extends ActiveOrSuspendedWorkspaceCommandRunner
{
  constructor(
    protected readonly workspaceIteratorService: WorkspaceIteratorService,
  ) {
    super(workspaceIteratorService);
  }

  override async runOnWorkspace({
    workspaceId,
    options,
  }: RunOnWorkspaceArgs): Promise<void> {
    // Per-workspace logic goes here
    // options.dryRun, options.verbose are available for free
  }
}

基类自动处理工作区迭代,并免费提供了 --dry-run--verbose 与工作区过滤选项。文档还特别指出元数据迁移的两条路径(validateBuildAndRunWorkspaceMigration 默认走 side-effect 引擎,validateBuildAndRunLegacyWorkspaceMigration 跳过引擎、按字面矩阵执行),经验法则为:目标版本 < 2.19 用 legacy 方法,>= 2.19 用默认 side-effect 方法。

执行顺序与运行方式

同一版本内的升级流水线按如下顺序执行,组内按时间戳排序:

  1. Instance fast 命令
  2. Instance slow 命令
  3. Workspace 命令(在所有活跃/挂起工作区上顺序执行)

对应的执行入口在 project.json 中定义:database:migrate target 执行 node dist/command/command.js run-instance-commands --force,且存在一条依赖 nx database:migrate -- --include-slow 的组合流程(先 setup-db.js 建库,再带 slow 命令跑完整迁移)。文档同时说明了 Ctrl+C/SIGTERM 下的优雅中断语义(首次 Ctrl+C 在当前工作区步骤结束后停住,再次 Ctrl+C 强制退出)、断点续跑(从 upgradeMigration 表记录的最后一个命令恢复,不回滚),以及 yarn command:prod:background 系列命令用于 kubectl exec 场景的脱离式运行与日志重连。

对开发者的实操含义

结合 README 与源码,维护 Twenty 数据库 schema 时的正确姿势可以归纳为:

  1. 不要在 legacy-typeorm-migrations-do-not-add 下新建任何迁移文件——那里的清单只用于维持 _typeorm_migrations 表的向后一致性;
  2. 新的 schema / 数据变更npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow> 生成 instance 命令,按版本目录组织在 upgrade-version-command 下;
  3. 逐工作区逻辑@RegisteredWorkspaceCommand 编写 workspace 命令;
  4. 复用 SQL 辅助逻辑应参照 migrations/utils/ 中仍被活跃命令 import 的工具函数,而不是向 legacy 目录添加新工具;
  5. 理解数据源行为时记住 migrationsRun: false:TypeORM 不会自动执行任何东西,所有升级都由显式的 command 入口(run-instance-commandsupgrade 等)驱动,这也是"冻结迁移系统"在运行期真正生效的机制。

这一套治理让 Twenty 的升级流程从"TypeORM 黑盒重放"演进为"按版本、按阶段、可断点续跑、支持 dry-run 的显式命令流水线",而 legacy 目录则是这次演进留下的、被有意保留的历史账本。

登录后查看全文
热门项目推荐
相关项目推荐