Slow instance commands (#19431)

# Introduction
This PR introduces the slow instance commands pattern, that allow
migrating data in prior of the schema migration, that would fail if not.

Slow instance commands runs after the fast instance commands and before
the workspace commands.
On twenty instance that do not has any active or suspended workspace the
data migration part is skipped but the migration still runs, especially
for fresh installs

We were previously hacking through typeorm transaction system to gain
such granularity using save points:
```ts
export class AddPayloadToCommandMenuItem1775129635528
  implements MigrationInterface
{
  name = 'AddPayloadToCommandMenuItem1775129635528';

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "core"."commandMenuItem" ADD "payload" jsonb`,
    );

    const savepointName =
      'sp_add_payload_check_constraint_to_command_menu_item';

    try {
      await queryRunner.query(`SAVEPOINT ${savepointName}`);

      await addPayloadCheckConstraintToCommandMenuItem(queryRunner);

      await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`);
    } catch (e) {
      try {
        await queryRunner.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
        await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`);
      } catch (rollbackError) {
        // oxlint-disable-next-line no-console
        console.error(
          'Failed to rollback to savepoint in AddPayloadToCommandMenuItem1775129635528',
          rollbackError,
        );
        throw rollbackError;
      }

      // oxlint-disable-next-line no-console
      console.error(
        'Swallowing AddPayloadToCommandMenuItem1775129635528 error',
        e,
      );
    }
  }
```

It was afterwards re-applied within an workspace commands, it was hacky
and missleading for the self host having false positive in logs

## New pattern

Generate the slow instance command
```
npx nx database:migrate:generate twenty-server -- --name add-foo-bar-columns --type slow
```

```ts
import { DataSource, QueryRunner } from 'typeorm';

import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';

@RegisteredInstanceCommand('1.21.0', 1775640902366, { type: 'slow' })
export class AddPrastoinColToWorkspaceSlowInstanceCommand implements SlowInstanceCommand {
  async runDataMigration(dataSource: DataSource): Promise<void> {
    // TODO: implement data backfill before the DDL migration
  }

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query('ALTER TABLE "core"."workspace" ADD "prastoin" character varying');
  }

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

### Why `run-instance-commands` and `upgrade` remain separate commands

These two commands serve fundamentally different purposes with
incompatible scoping semantics:
The run-instance-commands iterates over all the legacy typeorm and the
instance commands of all versions, used for database init and so on. we
could be centralizing both but the readability tradeoff isn't worth it

In the future thanks to the cross-version pattern we will be able to
centralize them but not right now

Please note that by default the `run-instance-commands` only run the
fast instance commands which is expected for our cloud prod CD
This commit is contained in:
Paul Rastoin
2026-04-08 14:09:29 +02:00
committed by GitHub
parent 8a84e32cf6
commit 85be463487
13 changed files with 873 additions and 406 deletions
@@ -2,14 +2,15 @@
exports[`InstanceCommandGenerationService should encode version correctly in file and class names 1`] = `
{
"className": "TestCommand",
"className": "TestFastInstanceCommand",
"fileName": "1-20-instance-command-fast-1775000000000-test.ts",
"fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.20.0', 1775000000000)
export class TestCommand implements MigrationInterface {
export class TestFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('SELECT 1');
}
@@ -24,14 +25,15 @@ export class TestCommand implements MigrationInterface {
exports[`InstanceCommandGenerationService should escape backslashes in SQL queries 1`] = `
{
"className": "UpdatePathCommand",
"className": "UpdatePathFastInstanceCommand",
"fileName": "1-21-instance-command-fast-1775000000000-update-path.ts",
"fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.21.0', 1775000000000)
export class UpdatePathCommand implements MigrationInterface {
export class UpdatePathFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('UPDATE "core"."config" SET "value" = E\\'path\\\\\\\\to\\\\\\\\file\\'');
}
@@ -46,14 +48,15 @@ export class UpdatePathCommand implements MigrationInterface {
exports[`InstanceCommandGenerationService should escape single quotes in SQL queries 1`] = `
{
"className": "UpdateConfigCommand",
"className": "UpdateConfigFastInstanceCommand",
"fileName": "1-21-instance-command-fast-1775000000000-update-config.ts",
"fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.21.0', 1775000000000)
export class UpdateConfigCommand implements MigrationInterface {
export class UpdateConfigFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('UPDATE "core"."config" SET "value" = \\'it\\'\\'s done\\'');
}
@@ -68,14 +71,15 @@ export class UpdateConfigCommand implements MigrationInterface {
exports[`InstanceCommandGenerationService should generate a migration with a single up/down query 1`] = `
{
"className": "AddFooColumnCommand",
"className": "AddFooColumnFastInstanceCommand",
"fileName": "1-21-instance-command-fast-1775000000000-add-foo-column.ts",
"fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.21.0', 1775000000000)
export class AddFooColumnCommand implements MigrationInterface {
export class AddFooColumnFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "core"."user" ADD "foo" varchar');
}
@@ -90,14 +94,15 @@ export class AddFooColumnCommand implements MigrationInterface {
exports[`InstanceCommandGenerationService should generate a migration with multiple queries 1`] = `
{
"className": "CreateTaskTableCommand",
"className": "CreateTaskTableFastInstanceCommand",
"fileName": "1-21-instance-command-fast-1775000000000-create-task-table.ts",
"fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.21.0', 1775000000000)
export class CreateTaskTableCommand implements MigrationInterface {
export class CreateTaskTableFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('CREATE TABLE "core"."task" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" varchar NOT NULL)');
await queryRunner.query('ALTER TABLE "core"."task" ADD CONSTRAINT "PK_task" PRIMARY KEY ("id")');
@@ -114,14 +119,15 @@ export class CreateTaskTableCommand implements MigrationInterface {
exports[`InstanceCommandGenerationService should generate a migration with query parameters 1`] = `
{
"className": "SeedSettingCommand",
"className": "SeedSettingFastInstanceCommand",
"fileName": "1-21-instance-command-fast-1775000000000-seed-setting.ts",
"fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.21.0', 1775000000000)
export class SeedSettingCommand implements MigrationInterface {
export class SeedSettingFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('INSERT INTO "core"."setting" ("key", "value") VALUES ($1, $2)', ["theme","dark"]);
}
@@ -134,16 +140,44 @@ export class SeedSettingCommand implements MigrationInterface {
}
`;
exports[`InstanceCommandGenerationService should use default migration name in class and file names 1`] = `
exports[`InstanceCommandGenerationService should generate a slow instance command with populated up/down 1`] = `
{
"className": "AutoGeneratedCommand",
"fileName": "1-21-instance-command-fast-1775000000000-auto-generated.ts",
"fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
"className": "MakeColumnNotNullableSlowInstanceCommand",
"fileName": "1-21-instance-command-slow-1775000000000-make-column-not-nullable.ts",
"fileTemplate": "import { DataSource, QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
@RegisteredInstanceCommand('1.21.0', 1775000000000, { type: 'slow' })
export class MakeColumnNotNullableSlowInstanceCommand implements SlowInstanceCommand {
async runDataMigration(dataSource: DataSource): Promise<void> {
// TODO: implement data backfill before the DDL migration
}
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "core"."user" ALTER COLUMN "email" SET NOT NULL');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "core"."user" ALTER COLUMN "email" DROP NOT NULL');
}
}
",
}
`;
exports[`InstanceCommandGenerationService should use default migration name in class and file names 1`] = `
{
"className": "AutoGeneratedFastInstanceCommand",
"fileName": "1-21-instance-command-fast-1775000000000-auto-generated.ts",
"fileTemplate": "import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.21.0', 1775000000000)
export class AutoGeneratedCommand implements MigrationInterface {
export class AutoGeneratedFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "core"."user" ADD "bar" integer');
}
@@ -37,7 +37,7 @@ describe('InstanceCommandGenerationService', () => {
it('should return null when no schema changes are detected', async () => {
const service = await buildService();
const result = await service.generate({
const result = await service.generateInstanceCommand({
migrationName: 'no-changes',
version: '1.21.0',
timestamp: FIXED_TIMESTAMP,
@@ -52,7 +52,7 @@ describe('InstanceCommandGenerationService', () => {
[{ query: 'ALTER TABLE "core"."user" DROP COLUMN "foo"' }],
);
const result = await service.generate({
const result = await service.generateInstanceCommand({
migrationName: 'add-foo-column',
version: '1.21.0',
timestamp: FIXED_TIMESTAMP,
@@ -79,7 +79,7 @@ describe('InstanceCommandGenerationService', () => {
],
);
const result = await service.generate({
const result = await service.generateInstanceCommand({
migrationName: 'create-task-table',
version: '1.21.0',
timestamp: FIXED_TIMESTAMP,
@@ -105,7 +105,7 @@ describe('InstanceCommandGenerationService', () => {
],
);
const result = await service.generate({
const result = await service.generateInstanceCommand({
migrationName: 'seed-setting',
version: '1.21.0',
timestamp: FIXED_TIMESTAMP,
@@ -120,7 +120,7 @@ describe('InstanceCommandGenerationService', () => {
[{ query: 'UPDATE "core"."config" SET "value" = \'original\'' }],
);
const result = await service.generate({
const result = await service.generateInstanceCommand({
migrationName: 'update-config',
version: '1.21.0',
timestamp: FIXED_TIMESTAMP,
@@ -139,7 +139,7 @@ describe('InstanceCommandGenerationService', () => {
[{ query: 'UPDATE "core"."config" SET "value" = NULL' }],
);
const result = await service.generate({
const result = await service.generateInstanceCommand({
migrationName: 'update-path',
version: '1.21.0',
timestamp: FIXED_TIMESTAMP,
@@ -154,7 +154,7 @@ describe('InstanceCommandGenerationService', () => {
[{ query: 'ALTER TABLE "core"."user" DROP COLUMN "bar"' }],
);
const result = await service.generate({
const result = await service.generateInstanceCommand({
migrationName: 'auto-generated',
version: '1.21.0',
timestamp: FIXED_TIMESTAMP,
@@ -169,7 +169,7 @@ describe('InstanceCommandGenerationService', () => {
[{ query: 'SELECT 1' }],
);
const result = await service.generate({
const result = await service.generateInstanceCommand({
migrationName: 'test',
version: '1.20.0',
timestamp: FIXED_TIMESTAMP,
@@ -177,4 +177,60 @@ describe('InstanceCommandGenerationService', () => {
expect(result).toMatchSnapshot();
});
it('should return null for slow type when no schema changes are detected', async () => {
const service = await buildService();
const result = await service.generateInstanceCommand({
migrationName: 'no-changes',
version: '1.21.0',
timestamp: FIXED_TIMESTAMP,
type: 'slow',
});
expect(result).toBeNull();
});
it('should generate a slow instance command with populated up/down', async () => {
const service = await buildService(
[
{
query: 'ALTER TABLE "core"."user" ALTER COLUMN "email" SET NOT NULL',
},
],
[
{
query: 'ALTER TABLE "core"."user" ALTER COLUMN "email" DROP NOT NULL',
},
],
);
const result = await service.generateInstanceCommand({
migrationName: 'make-column-not-nullable',
version: '1.21.0',
timestamp: FIXED_TIMESTAMP,
type: 'slow',
});
expect(result).toMatchSnapshot();
});
it('should use correct file naming for slow instance commands', async () => {
const service = await buildService(
[{ query: 'SELECT 1' }],
[{ query: 'SELECT 1' }],
);
const result = await service.generateInstanceCommand({
migrationName: 'backfill-data',
version: '1.20.0',
timestamp: FIXED_TIMESTAMP,
type: 'slow',
});
expect(result?.fileName).toBe(
'1-20-instance-command-slow-1775000000000-backfill-data.ts',
);
expect(result?.className).toBe('BackfillDataSlowInstanceCommand');
});
});