From 23874848a4b1258cbef0bbb914f258b36c2755a9 Mon Sep 17 00:00:00 2001
From: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Date: Tue, 7 Apr 2026 10:55:17 +0200
Subject: [PATCH] Instance commands and `upgrade_migrations` table (#19356)
# Introduction
Now only using typeorm to generate migrations up and down statement
We handle and maintain our own migration table history
## What's new
Now all the instance commands will live within the same module and
folder than the upgrade commands
Sequentiality comes from the timestamp located in the filename
Same sequentiality also applies to the workspace commands in the future,
for the moment still expected a as code explicit declaration
( below screen is an example see below section )
## Existing 1.21 migrations
We won't start following this pattern in 1.21 yet at least not with the
migration that has already been released as typeorm migrations in cloud
production as they would rerun
## Small duplication
Duplicating the legacy typeorm and instance commands run in the
`run-instance-commands` to avoid any merge of interest for the moment
## Concurrency
Not handling any run in parrallel of the upgrade for the moment
---
packages/twenty-server/package.json | 2 +-
packages/twenty-server/project.json | 4 +-
...ce-command-generation.service.spec.ts.snap | 171 +++++++++++
...stance-command-generation.service.spec.ts} | 8 +-
.../__tests__/upgrade.command-runner.spec.ts | 270 ++++++++++++-----
.../command-runners/upgrade.command-runner.ts | 279 +++++++-----------
...e-migration-generator.service.spec.ts.snap | 171 -----------
.../core-migration/core-migration.module.ts | 19 --
.../services/core-migration-runner.service.ts | 147 ---------
...istered-core-migration-registry.service.ts | 64 ----
.../commands/database-command.module.ts | 16 +-
.../generate-instance-command.command.ts | 121 ++++++++
.../generate-versioned-migration.command.ts | 81 -----
...=> instance-command-generation.service.ts} | 37 ++-
.../commands/run-core-migration.command.ts | 75 -----
.../commands/run-instance-commands.command.ts | 169 +++++++++++
.../1-21-upgrade-version-command.module.ts | 20 +-
...al-key-value-pair-unique-index.command.ts} | 0
...ckfill-datasource-to-workspace.command.ts} | 0
...ackfill-message-thread-subject.command.ts} | 0
...-and-fields-widget-view-fields.command.ts} | 0
...nd-deduplicate-engine-commands.command.ts} | 0
...d-drop-workspace-messaging-fks.command.ts} | 0
...-select-all-command-menu-items.command.ts} | 0
...t-text-to-json-response-format.command.ts} | 0
...older-parent-id-to-external-id.command.ts} | 0
...layout-command-menu-item-label.command.ts} | 0
.../instance-command-provider.module.ts | 8 +
.../instance-commands.constant.ts | 3 +
.../upgrade-version-command.module.ts | 7 +-
.../upgrade.command.ts | 46 +--
.../registered-core-migration.decorator.ts | 18 --
...registered-instance-migration.decorator.ts | 31 ++
...5487231605-add-upgrade-migrations-table.ts | 17 ++
...stance-migration-registry.service.spec.ts} | 39 ++-
.../services/instance-upgrade.service.ts | 121 ++++++++
...red-instance-migration-registry.service.ts | 100 +++++++
.../services/workspace-upgrade.service.ts | 115 ++++++++
.../upgrade/upgrade-migration.entity.ts | 31 ++
.../core-modules/upgrade/upgrade.module.ts | 27 ++
40 files changed, 1314 insertions(+), 903 deletions(-)
create mode 100644 packages/twenty-server/src/database/commands/__tests__/__snapshots__/instance-command-generation.service.spec.ts.snap
rename packages/twenty-server/src/database/commands/{core-migration/__tests__/core-migration-generator.service.spec.ts => __tests__/instance-command-generation.service.spec.ts} (94%)
delete mode 100644 packages/twenty-server/src/database/commands/core-migration/__tests__/__snapshots__/core-migration-generator.service.spec.ts.snap
delete mode 100644 packages/twenty-server/src/database/commands/core-migration/core-migration.module.ts
delete mode 100644 packages/twenty-server/src/database/commands/core-migration/services/core-migration-runner.service.ts
delete mode 100644 packages/twenty-server/src/database/commands/core-migration/services/registered-core-migration-registry.service.ts
create mode 100644 packages/twenty-server/src/database/commands/generate-instance-command.command.ts
delete mode 100644 packages/twenty-server/src/database/commands/generate-versioned-migration.command.ts
rename packages/twenty-server/src/database/commands/{core-migration/services/core-migration-generator.service.ts => instance-command-generation.service.ts} (73%)
delete mode 100644 packages/twenty-server/src/database/commands/run-core-migration.command.ts
create mode 100644 packages/twenty-server/src/database/commands/run-instance-commands.command.ts
rename packages/twenty-server/src/database/commands/upgrade-version-command/1-21/{1-21-add-global-key-value-pair-unique-index.command.ts => 1-21-workspace-command-add-global-key-value-pair-unique-index.command.ts} (100%)
rename packages/twenty-server/src/database/commands/upgrade-version-command/1-21/{1-21-backfill-datasource-to-workspace.command.ts => 1-21-workspace-command-backfill-datasource-to-workspace.command.ts} (100%)
rename packages/twenty-server/src/database/commands/upgrade-version-command/1-21/{1-21-backfill-message-thread-subject.command.ts => 1-21-workspace-command-backfill-message-thread-subject.command.ts} (100%)
rename packages/twenty-server/src/database/commands/upgrade-version-command/1-21/{1-21-backfill-page-layouts-and-fields-widget-view-fields.command.ts => 1-21-workspace-command-backfill-page-layouts-and-fields-widget-view-fields.command.ts} (100%)
rename packages/twenty-server/src/database/commands/upgrade-version-command/1-21/{1-21-deduplicate-engine-commands.command.ts => 1-21-workspace-command-deduplicate-engine-commands.command.ts} (100%)
rename packages/twenty-server/src/database/commands/upgrade-version-command/1-21/{1-21-drop-workspace-messaging-fks.command.ts => 1-21-workspace-command-drop-workspace-messaging-fks.command.ts} (100%)
rename packages/twenty-server/src/database/commands/upgrade-version-command/1-21/{1-21-fix-select-all-command-menu-items.command.ts => 1-21-workspace-command-fix-select-all-command-menu-items.command.ts} (100%)
rename packages/twenty-server/src/database/commands/upgrade-version-command/1-21/{1-21-migrate-ai-agent-text-to-json-response-format.command.ts => 1-21-workspace-command-migrate-ai-agent-text-to-json-response-format.command.ts} (100%)
rename packages/twenty-server/src/database/commands/upgrade-version-command/1-21/{1-21-migrate-message-folder-parent-id-to-external-id.command.ts => 1-21-workspace-command-migrate-message-folder-parent-id-to-external-id.command.ts} (100%)
rename packages/twenty-server/src/database/commands/upgrade-version-command/1-21/{1-21-update-edit-layout-command-menu-item-label.command.ts => 1-21-workspace-command-update-edit-layout-command-menu-item-label.command.ts} (100%)
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
delete mode 100644 packages/twenty-server/src/database/typeorm/core/decorators/registered-core-migration.decorator.ts
create mode 100644 packages/twenty-server/src/database/typeorm/core/decorators/registered-instance-migration.decorator.ts
create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1775487231605-add-upgrade-migrations-table.ts
rename packages/twenty-server/src/{database/commands/core-migration/__tests__/versioned-migration-registry.service.spec.ts => engine/core-modules/upgrade/services/__tests__/registered-instance-migration-registry.service.spec.ts} (75%)
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/services/instance-upgrade.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/services/workspace-upgrade.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/upgrade-migration.entity.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts
diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json
index 3a45668c4b..4faa81b7df 100644
--- a/packages/twenty-server/package.json
+++ b/packages/twenty-server/package.json
@@ -10,7 +10,7 @@
"command:prod": "node dist/command/command",
"worker:prod": "node dist/queue-worker/queue-worker",
"database:init:prod": "node dist/database/scripts/setup-db.js && yarn database:migrate:prod --force",
- "database:migrate:prod": "node dist/command/command run-core-migration",
+ "database:migrate:prod": "node dist/command/command run-instance-commands",
"clickhouse:migrate:prod": "node dist/database/clickHouse/migrations/run-migrations.js",
"typeorm": "../../node_modules/typeorm/.bin/typeorm"
},
diff --git a/packages/twenty-server/project.json b/packages/twenty-server/project.json
index 6a4e9184f6..62ca011e66 100644
--- a/packages/twenty-server/project.json
+++ b/packages/twenty-server/project.json
@@ -207,7 +207,7 @@
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
- "command": "node dist/command/command.js run-core-migration --force"
+ "command": "node dist/command/command.js run-instance-commands --force"
}
},
"database:migrate:generate": {
@@ -215,7 +215,7 @@
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
- "command": "node dist/command/command.js generate:versioned-migration"
+ "command": "node dist/command/command.js generate:instance-command"
}
},
"generate:integration-test": {
diff --git a/packages/twenty-server/src/database/commands/__tests__/__snapshots__/instance-command-generation.service.spec.ts.snap b/packages/twenty-server/src/database/commands/__tests__/__snapshots__/instance-command-generation.service.spec.ts.snap
new file mode 100644
index 0000000000..ab9a4b2551
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/__tests__/__snapshots__/instance-command-generation.service.spec.ts.snap
@@ -0,0 +1,171 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`InstanceCommandGenerationService should encode version correctly in file and class names 1`] = `
+{
+ "className": "V1_19_Test_1775000000000",
+ "fileName": "1-19-instance-command-fast-1775000000000-test.ts",
+ "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
+
+import { RegisteredInstanceMigration } from 'src/database/typeorm/core/decorators/registered-instance-migration.decorator';
+
+@RegisteredInstanceMigration('1.19.0', 1775000000000)
+export class V1_19_Test_1775000000000 implements MigrationInterface {
+ name = 'V1_19_Test_1775000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('SELECT 1');
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('SELECT 1');
+ }
+}
+",
+}
+`;
+
+exports[`InstanceCommandGenerationService should escape backslashes in SQL queries 1`] = `
+{
+ "className": "V1_21_UpdatePath_1775000000000",
+ "fileName": "1-21-instance-command-fast-1775000000000-update-path.ts",
+ "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
+
+import { RegisteredInstanceMigration } from 'src/database/typeorm/core/decorators/registered-instance-migration.decorator';
+
+@RegisteredInstanceMigration('1.21.0', 1775000000000)
+export class V1_21_UpdatePath_1775000000000 implements MigrationInterface {
+ name = 'V1_21_UpdatePath_1775000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('UPDATE "core"."config" SET "value" = E\\'path\\\\\\\\to\\\\\\\\file\\'');
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('UPDATE "core"."config" SET "value" = NULL');
+ }
+}
+",
+}
+`;
+
+exports[`InstanceCommandGenerationService should escape single quotes in SQL queries 1`] = `
+{
+ "className": "V1_21_UpdateConfig_1775000000000",
+ "fileName": "1-21-instance-command-fast-1775000000000-update-config.ts",
+ "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
+
+import { RegisteredInstanceMigration } from 'src/database/typeorm/core/decorators/registered-instance-migration.decorator';
+
+@RegisteredInstanceMigration('1.21.0', 1775000000000)
+export class V1_21_UpdateConfig_1775000000000 implements MigrationInterface {
+ name = 'V1_21_UpdateConfig_1775000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('UPDATE "core"."config" SET "value" = \\'it\\'\\'s done\\'');
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('UPDATE "core"."config" SET "value" = \\'original\\'');
+ }
+}
+",
+}
+`;
+
+exports[`InstanceCommandGenerationService should generate a migration with a single up/down query 1`] = `
+{
+ "className": "V1_21_AddFooColumn_1775000000000",
+ "fileName": "1-21-instance-command-fast-1775000000000-add-foo-column.ts",
+ "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
+
+import { RegisteredInstanceMigration } from 'src/database/typeorm/core/decorators/registered-instance-migration.decorator';
+
+@RegisteredInstanceMigration('1.21.0', 1775000000000)
+export class V1_21_AddFooColumn_1775000000000 implements MigrationInterface {
+ name = 'V1_21_AddFooColumn_1775000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('ALTER TABLE "core"."user" ADD "foo" varchar');
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('ALTER TABLE "core"."user" DROP COLUMN "foo"');
+ }
+}
+",
+}
+`;
+
+exports[`InstanceCommandGenerationService should generate a migration with multiple queries 1`] = `
+{
+ "className": "V1_21_CreateTaskTable_1775000000000",
+ "fileName": "1-21-instance-command-fast-1775000000000-create-task-table.ts",
+ "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
+
+import { RegisteredInstanceMigration } from 'src/database/typeorm/core/decorators/registered-instance-migration.decorator';
+
+@RegisteredInstanceMigration('1.21.0', 1775000000000)
+export class V1_21_CreateTaskTable_1775000000000 implements MigrationInterface {
+ name = 'V1_21_CreateTaskTable_1775000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ 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")');
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('DROP TABLE "core"."task"');
+ await queryRunner.query('ALTER TABLE "core"."task" DROP CONSTRAINT "PK_task"');
+ }
+}
+",
+}
+`;
+
+exports[`InstanceCommandGenerationService should generate a migration with query parameters 1`] = `
+{
+ "className": "V1_21_SeedSetting_1775000000000",
+ "fileName": "1-21-instance-command-fast-1775000000000-seed-setting.ts",
+ "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
+
+import { RegisteredInstanceMigration } from 'src/database/typeorm/core/decorators/registered-instance-migration.decorator';
+
+@RegisteredInstanceMigration('1.21.0', 1775000000000)
+export class V1_21_SeedSetting_1775000000000 implements MigrationInterface {
+ name = 'V1_21_SeedSetting_1775000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('INSERT INTO "core"."setting" ("key", "value") VALUES ($1, $2)', ["theme","dark"]);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('DELETE FROM "core"."setting" WHERE "key" = $1', ["theme"]);
+ }
+}
+",
+}
+`;
+
+exports[`InstanceCommandGenerationService should use default migration name in class and file names 1`] = `
+{
+ "className": "V1_21_AutoGenerated_1775000000000",
+ "fileName": "1-21-instance-command-fast-1775000000000-auto-generated.ts",
+ "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
+
+import { RegisteredInstanceMigration } from 'src/database/typeorm/core/decorators/registered-instance-migration.decorator';
+
+@RegisteredInstanceMigration('1.21.0', 1775000000000)
+export class V1_21_AutoGenerated_1775000000000 implements MigrationInterface {
+ name = 'V1_21_AutoGenerated_1775000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('ALTER TABLE "core"."user" ADD "bar" integer');
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('ALTER TABLE "core"."user" DROP COLUMN "bar"');
+ }
+}
+",
+}
+`;
diff --git a/packages/twenty-server/src/database/commands/core-migration/__tests__/core-migration-generator.service.spec.ts b/packages/twenty-server/src/database/commands/__tests__/instance-command-generation.service.spec.ts
similarity index 94%
rename from packages/twenty-server/src/database/commands/core-migration/__tests__/core-migration-generator.service.spec.ts
rename to packages/twenty-server/src/database/commands/__tests__/instance-command-generation.service.spec.ts
index b05429e5c6..f556e6372c 100644
--- a/packages/twenty-server/src/database/commands/core-migration/__tests__/core-migration-generator.service.spec.ts
+++ b/packages/twenty-server/src/database/commands/__tests__/instance-command-generation.service.spec.ts
@@ -1,7 +1,7 @@
import { Test } from '@nestjs/testing';
import { getDataSourceToken } from '@nestjs/typeorm';
-import { CoreMigrationGeneratorService } from 'src/database/commands/core-migration/services/core-migration-generator.service';
+import { InstanceCommandGenerationService } from 'src/database/commands/instance-command-generation.service';
const FIXED_TIMESTAMP = 1775000000000;
@@ -16,14 +16,14 @@ const buildMockDataSource = (
},
});
-describe('CoreMigrationGeneratorService', () => {
+describe('InstanceCommandGenerationService', () => {
const buildService = async (
upQueries: { query: string; parameters?: unknown[] }[] = [],
downQueries: { query: string; parameters?: unknown[] }[] = [],
) => {
const module = await Test.createTestingModule({
providers: [
- CoreMigrationGeneratorService,
+ InstanceCommandGenerationService,
{
provide: getDataSourceToken(),
useValue: buildMockDataSource(upQueries, downQueries),
@@ -31,7 +31,7 @@ describe('CoreMigrationGeneratorService', () => {
],
}).compile();
- return module.get(CoreMigrationGeneratorService);
+ return module.get(InstanceCommandGenerationService);
};
it('should return null when no schema changes are detected', async () => {
diff --git a/packages/twenty-server/src/database/commands/command-runners/__tests__/upgrade.command-runner.spec.ts b/packages/twenty-server/src/database/commands/command-runners/__tests__/upgrade.command-runner.spec.ts
index 437746ebb5..4423b0cda9 100644
--- a/packages/twenty-server/src/database/commands/command-runners/__tests__/upgrade.command-runner.spec.ts
+++ b/packages/twenty-server/src/database/commands/command-runners/__tests__/upgrade.command-runner.spec.ts
@@ -1,31 +1,33 @@
import { Test, type TestingModule } from '@nestjs/testing';
-import { getRepositoryToken } from '@nestjs/typeorm';
-
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import {
+ type DataSource,
type MigrationInterface,
type QueryRunner,
- type Repository,
} from 'typeorm';
+import { getDataSourceToken } from '@nestjs/typeorm';
+
import {
UpgradeCommandOptions,
UpgradeCommandRunner,
type AllCommands,
} from 'src/database/commands/command-runners/upgrade.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
-import { CoreMigrationRunnerService } from 'src/database/commands/core-migration/services/core-migration-runner.service';
-import { RegisteredCoreMigrationService } from 'src/database/commands/core-migration/services/registered-core-migration-registry.service';
-import { RegisteredCoreMigration } from 'src/database/typeorm/core/decorators/registered-core-migration.decorator';
+import { RegisteredInstanceMigrationService } from 'src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service';
+import { WorkspaceUpgradeService } from 'src/engine/core-modules/upgrade/services/workspace-upgrade.service';
+import { RegisteredInstanceMigration } from 'src/database/typeorm/core/decorators/registered-instance-migration.decorator';
import { UPGRADE_COMMAND_SUPPORTED_VERSIONS } from 'src/engine/constants/upgrade-command-supported-versions.constant';
import { CoreEngineVersionService } from 'src/engine/core-engine-version/services/core-engine-version.service';
import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+import { InstanceUpgradeService } from 'src/engine/core-modules/upgrade/services/instance-upgrade.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
+import { compareVersionMajorAndMinor } from 'src/utils/version/compare-version-minor-and-major';
const CURRENT_VERSION =
UPGRADE_COMMAND_SUPPORTED_VERSIONS[
@@ -76,12 +78,18 @@ const buildUpgradeCommandModule = async ({
}: BuildUpgradeCommandModuleArgs) => {
const registryProvider = migrations
? {
- provide: RegisteredCoreMigrationService,
+ provide: RegisteredInstanceMigrationService,
useFactory: () => {
- const fakeDataSource = {
- migrations,
- } as unknown as import('typeorm').DataSource;
- const registry = new RegisteredCoreMigrationService(fakeDataSource);
+ const fakeDiscoveryService = {
+ getProviders: () =>
+ migrations.map((migration) => ({
+ instance: migration,
+ metatype: migration.constructor,
+ })),
+ } as unknown as import('@nestjs/core').DiscoveryService;
+ const registry = new RegisteredInstanceMigrationService(
+ fakeDiscoveryService,
+ );
registry.onModuleInit();
@@ -89,7 +97,7 @@ const buildUpgradeCommandModule = async ({
},
}
: {
- provide: RegisteredCoreMigrationService,
+ provide: RegisteredInstanceMigrationService,
useValue: {
getInstanceCommandsForVersion: jest.fn().mockReturnValue([]),
},
@@ -97,47 +105,43 @@ const buildUpgradeCommandModule = async ({
const module: TestingModule = await Test.createTestingModule({
providers: [
+ {
+ provide: getDataSourceToken(),
+ useValue: {
+ runMigrations: jest.fn().mockResolvedValue([]),
+ },
+ },
{
provide: commandRunner,
useFactory: (
- workspaceRepository: Repository,
coreEngineVersionService: CoreEngineVersionService,
workspaceVersionService: WorkspaceVersionService,
- coreMigrationRunnerService: CoreMigrationRunnerService,
- versionedMigrationRegistryService: RegisteredCoreMigrationService,
+ registeredInstanceMigrationService: RegisteredInstanceMigrationService,
+ instanceUpgradeService: InstanceUpgradeService,
workspaceIteratorService: WorkspaceIteratorService,
+ workspaceUpgradeService: WorkspaceUpgradeService,
+ dataSource: DataSource,
) => {
return new commandRunner(
- workspaceRepository,
coreEngineVersionService,
workspaceVersionService,
- coreMigrationRunnerService,
- versionedMigrationRegistryService,
+ registeredInstanceMigrationService,
+ instanceUpgradeService,
workspaceIteratorService,
+ workspaceUpgradeService,
+ dataSource,
);
},
inject: [
- getRepositoryToken(WorkspaceEntity),
CoreEngineVersionService,
WorkspaceVersionService,
- CoreMigrationRunnerService,
- RegisteredCoreMigrationService,
+ RegisteredInstanceMigrationService,
+ InstanceUpgradeService,
WorkspaceIteratorService,
+ WorkspaceUpgradeService,
+ getDataSourceToken(),
],
},
- {
- provide: getRepositoryToken(WorkspaceEntity),
- useValue: {
- findOneByOrFail: jest
- .fn()
- .mockImplementation((args) =>
- workspaces.find((el) => el.id === args.id),
- ),
- update: jest.fn(),
- find: jest.fn().mockResolvedValue(workspaces),
- exists: jest.fn().mockResolvedValue(workspaces.length > 0),
- },
- },
{
provide: TwentyConfigService,
useValue: {
@@ -154,15 +158,49 @@ const buildUpgradeCommandModule = async ({
},
},
CoreEngineVersionService,
- WorkspaceVersionService,
{
- provide: CoreMigrationRunnerService,
+ provide: WorkspaceVersionService,
+ useValue: {
+ hasActiveOrSuspendedWorkspaces: jest
+ .fn()
+ .mockResolvedValue(workspaces.length > 0),
+ getWorkspacesBelowVersion: jest
+ .fn()
+ .mockImplementation((version: string) => {
+ return workspaces.filter((workspace) => {
+ if (
+ workspace.version === null ||
+ workspace.version === undefined
+ ) {
+ return true;
+ }
+
+ try {
+ return (
+ compareVersionMajorAndMinor(workspace.version, version) ===
+ 'lower'
+ );
+ } catch {
+ return true;
+ }
+ });
+ }),
+ },
+ },
+ {
+ provide: InstanceUpgradeService,
useValue: {
runSingleMigration: jest
.fn()
.mockResolvedValue({ status: 'success' }),
},
},
+ {
+ provide: WorkspaceUpgradeService,
+ useValue: {
+ upgradeWorkspace: jest.fn().mockResolvedValue(undefined),
+ },
+ },
registryProvider,
{
provide: WorkspaceIteratorService,
@@ -203,7 +241,6 @@ const buildUpgradeCommandModule = async ({
describe('UpgradeCommandRunner', () => {
let upgradeCommandRunner: BasicUpgradeCommandRunner;
- let workspaceRepository: Repository;
type BuildModuleAndSetupSpiesArgs = {
numberOfWorkspace?: number;
@@ -242,50 +279,39 @@ describe('UpgradeCommandRunner', () => {
jest.spyOn(upgradeCommandRunner['logger'], 'error').mockImplementation();
jest.spyOn(upgradeCommandRunner['logger'], 'warn').mockImplementation();
- workspaceRepository = module.get>(
- getRepositoryToken(WorkspaceEntity),
- );
-
return module;
};
- it('should ignore and list as successful upgrade on workspace with higher version', async () => {
- const higherVersionWorkspace = generateMockWorkspace({
- id: 'higher_version_workspace',
- version: '42.42.42',
- });
+ it('should delegate workspace upgrade to WorkspaceUpgradeService', async () => {
+ const module = await buildModuleAndSetupSpies({});
+
+ const workspaceUpgradeService = module.get(WorkspaceUpgradeService);
- await buildModuleAndSetupSpies({
- numberOfWorkspace: 0,
- workspaces: [higherVersionWorkspace],
- });
const passedParams: string[] = [];
const options: UpgradeCommandOptions = {};
await upgradeCommandRunner.run(passedParams, options);
- [workspaceRepository.update].forEach((fn) =>
- expect(fn).not.toHaveBeenCalled(),
- );
+ expect(workspaceUpgradeService.upgradeWorkspace).toHaveBeenCalledTimes(1);
});
- it('should run upgrade over several workspaces', async () => {
+ it('should call upgradeWorkspace for each workspace', async () => {
const numberOfWorkspace = 42;
- await buildModuleAndSetupSpies({
+ const module = await buildModuleAndSetupSpies({
numberOfWorkspace,
});
+
+ const workspaceUpgradeService = module.get(WorkspaceUpgradeService);
+
const passedParams: string[] = [];
const options: UpgradeCommandOptions = {};
await upgradeCommandRunner.run(passedParams, options);
- expect(workspaceRepository.update).toHaveBeenNthCalledWith(
+ expect(workspaceUpgradeService.upgradeWorkspace).toHaveBeenCalledTimes(
numberOfWorkspace,
- { id: expect.any(String) },
- { version: CURRENT_VERSION },
);
- expect(workspaceRepository.update).toHaveBeenCalledTimes(numberOfWorkspace);
});
describe('Workspace upgrade should succeed ', () => {
@@ -331,35 +357,34 @@ describe('UpgradeCommandRunner', () => {
it.each(eachTestingContextFilter(successfulTestUseCases))(
'$title',
async ({ context: { input } }) => {
- await buildModuleAndSetupSpies(input);
+ const module = await buildModuleAndSetupSpies(input);
+
+ const workspaceUpgradeService = module.get(WorkspaceUpgradeService);
const passedParams: string[] = [];
const options: UpgradeCommandOptions = {};
await upgradeCommandRunner.run(passedParams, options);
- expect(workspaceRepository.update).toHaveBeenCalledWith(
- { id: 'workspace_0' },
- { version: expect.any(String) },
- );
+ expect(workspaceUpgradeService.upgradeWorkspace).toHaveBeenCalled();
},
);
});
- it('should only run instance commands for the current version', async () => {
- @RegisteredCoreMigration(CURRENT_VERSION)
+ it('should call runSingleMigration for each current-version instance command', async () => {
+ @RegisteredInstanceMigration(CURRENT_VERSION, 1770000000000)
class AddIndexToUsers1770000000000 implements MigrationInterface {
async up(_queryRunner: QueryRunner) {}
async down(_queryRunner: QueryRunner) {}
}
- @RegisteredCoreMigration(CURRENT_VERSION)
+ @RegisteredInstanceMigration(CURRENT_VERSION, 1771000000000)
class AddColumnToAccounts1771000000000 implements MigrationInterface {
async up(_queryRunner: QueryRunner) {}
async down(_queryRunner: QueryRunner) {}
}
- @RegisteredCoreMigration(PREVIOUS_VERSION)
+ @RegisteredInstanceMigration(PREVIOUS_VERSION, 1769000000000)
class DropLegacyTable1769000000000 implements MigrationInterface {
async up(_queryRunner: QueryRunner) {}
async down(_queryRunner: QueryRunner) {}
@@ -370,30 +395,115 @@ describe('UpgradeCommandRunner', () => {
async down(_queryRunner: QueryRunner) {}
}
+ const addIndex = new AddIndexToUsers1770000000000();
+ const addColumn = new AddColumnToAccounts1771000000000();
+ const dropLegacy = new DropLegacyTable1769000000000();
+ const undecorated = new UndecoratedMigration1768000000000();
+
const module = await buildModuleAndSetupSpies({
- migrations: [
- new UndecoratedMigration1768000000000(),
- new DropLegacyTable1769000000000(),
- new AddIndexToUsers1770000000000(),
- new AddColumnToAccounts1771000000000(),
- ],
+ migrations: [undecorated, dropLegacy, addIndex, addColumn],
});
- const migrationRunnerService = module.get(CoreMigrationRunnerService);
+ const instanceUpgradeService = module.get(InstanceUpgradeService);
const passedParams: string[] = [];
const options: UpgradeCommandOptions = {};
await upgradeCommandRunner.run(passedParams, options);
- expect(migrationRunnerService.runSingleMigration).toHaveBeenCalledTimes(2);
- expect(migrationRunnerService.runSingleMigration).toHaveBeenNthCalledWith(
+ expect(instanceUpgradeService.runSingleMigration).toHaveBeenCalledTimes(2);
+ expect(instanceUpgradeService.runSingleMigration).toHaveBeenNthCalledWith(
1,
- 'AddIndexToUsers1770000000000',
+ addIndex,
);
- expect(migrationRunnerService.runSingleMigration).toHaveBeenNthCalledWith(
+ expect(instanceUpgradeService.runSingleMigration).toHaveBeenNthCalledWith(
2,
- 'AddColumnToAccounts1771000000000',
+ addColumn,
+ );
+ });
+
+ it('should skip already-executed instance commands', async () => {
+ @RegisteredInstanceMigration(CURRENT_VERSION, 1770000000000)
+ class AlreadyRunMigration1770000000000 implements MigrationInterface {
+ async up(_queryRunner: QueryRunner) {}
+ async down(_queryRunner: QueryRunner) {}
+ }
+
+ const alreadyRun = new AlreadyRunMigration1770000000000();
+
+ const module = await buildModuleAndSetupSpies({
+ migrations: [alreadyRun],
+ });
+
+ const instanceUpgradeService = module.get(InstanceUpgradeService);
+
+ (instanceUpgradeService.runSingleMigration as jest.Mock).mockResolvedValue({
+ status: 'already-executed',
+ });
+
+ const passedParams: string[] = [];
+ const options: UpgradeCommandOptions = {};
+
+ await upgradeCommandRunner.run(passedParams, options);
+
+ expect(upgradeCommandRunner['logger'].warn).toHaveBeenCalledWith(
+ expect.stringContaining('already executed'),
+ );
+ });
+
+ it('should throw when a migration fails', async () => {
+ @RegisteredInstanceMigration(CURRENT_VERSION, 1770000000000)
+ class FailingMigration1770000000000 implements MigrationInterface {
+ async up(_queryRunner: QueryRunner) {}
+ async down(_queryRunner: QueryRunner) {}
+ }
+
+ const failing = new FailingMigration1770000000000();
+
+ const module = await buildModuleAndSetupSpies({
+ migrations: [failing],
+ });
+
+ const instanceUpgradeService = module.get(InstanceUpgradeService);
+
+ (instanceUpgradeService.runSingleMigration as jest.Mock).mockResolvedValue({
+ status: 'failed',
+ error: new Error('SQL error'),
+ });
+
+ const passedParams: string[] = [];
+ const options: UpgradeCommandOptions = {};
+
+ await expect(
+ upgradeCommandRunner.run(passedParams, options),
+ ).rejects.toThrow('Core migration FailingMigration1770000000000 failed');
+ });
+
+ it('should log success when a migration succeeds', async () => {
+ @RegisteredInstanceMigration(CURRENT_VERSION, 1770000000000)
+ class SuccessMigration1770000000000 implements MigrationInterface {
+ async up(_queryRunner: QueryRunner) {}
+ async down(_queryRunner: QueryRunner) {}
+ }
+
+ const success = new SuccessMigration1770000000000();
+
+ const module = await buildModuleAndSetupSpies({
+ migrations: [success],
+ });
+
+ const instanceUpgradeService = module.get(InstanceUpgradeService);
+
+ const passedParams: string[] = [];
+ const options: UpgradeCommandOptions = {};
+
+ await upgradeCommandRunner.run(passedParams, options);
+
+ expect(instanceUpgradeService.runSingleMigration).toHaveBeenCalledWith(
+ success,
+ );
+ expect(upgradeCommandRunner['logger'].log).toHaveBeenCalledWith(
+ expect.stringContaining('executed successfully'),
);
});
diff --git a/packages/twenty-server/src/database/commands/command-runners/upgrade.command-runner.ts b/packages/twenty-server/src/database/commands/command-runners/upgrade.command-runner.ts
index 93401aad50..44572d585f 100644
--- a/packages/twenty-server/src/database/commands/command-runners/upgrade.command-runner.ts
+++ b/packages/twenty-server/src/database/commands/command-runners/upgrade.command-runner.ts
@@ -1,31 +1,19 @@
-import { InjectRepository } from '@nestjs/typeorm';
-
import chalk from 'chalk';
import { CommandRunner, Option } from 'nest-commander';
import { SemVer } from 'semver';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
-import { MigrationInterface, Repository } from 'typeorm';
+import { DataSource, MigrationInterface } from 'typeorm';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
-import {
- type WorkspaceIteratorContext,
- WorkspaceIteratorService,
-} from 'src/database/commands/command-runners/workspace-iterator.service';
-import {
- type RunOnWorkspaceArgs,
- WorkspaceCommandRunner,
-} from 'src/database/commands/command-runners/workspace.command-runner';
-import { CoreMigrationRunnerService } from 'src/database/commands/core-migration/services/core-migration-runner.service';
-import { RegisteredCoreMigrationService } from 'src/database/commands/core-migration/services/registered-core-migration-registry.service';
+import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
+import { WorkspaceCommandRunner } from 'src/database/commands/command-runners/workspace.command-runner';
import { CommandLogger } from 'src/database/commands/logger';
import { type UpgradeCommandVersion } from 'src/engine/constants/upgrade-command-supported-versions.constant';
import { CoreEngineVersionService } from 'src/engine/core-engine-version/services/core-engine-version.service';
-import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+import { InstanceUpgradeService } from 'src/engine/core-modules/upgrade/services/instance-upgrade.service';
+import { RegisteredInstanceMigrationService } from 'src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service';
+import { WorkspaceUpgradeService } from 'src/engine/core-modules/upgrade/services/workspace-upgrade.service';
import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
-import {
- type CompareVersionMajorAndMinorReturnType,
- compareVersionMajorAndMinor,
-} from 'src/utils/version/compare-version-minor-and-major';
export type VersionCommands = (
| WorkspaceCommandRunner
@@ -55,13 +43,13 @@ export abstract class UpgradeCommandRunner extends CommandRunner {
public abstract allCommands: AllCommands;
constructor(
- @InjectRepository(WorkspaceEntity)
- protected readonly workspaceRepository: Repository,
protected readonly coreEngineVersionService: CoreEngineVersionService,
protected readonly workspaceVersionService: WorkspaceVersionService,
- protected readonly coreMigrationRunnerService: CoreMigrationRunnerService,
- protected readonly versionedMigrationRegistryService: RegisteredCoreMigrationService,
+ protected readonly registeredInstanceMigrationService: RegisteredInstanceMigrationService,
+ protected readonly instanceUpgradeService: InstanceUpgradeService,
protected readonly workspaceIteratorService: WorkspaceIteratorService,
+ protected readonly workspaceUpgradeService: WorkspaceUpgradeService,
+ protected readonly dataSource: DataSource,
) {
super();
this.logger = new CommandLogger({
@@ -145,7 +133,6 @@ export abstract class UpgradeCommandRunner extends CommandRunner {
try {
const versionContext = this.resolveVersionContext();
-
this.logger.log(
chalk.blue(
[
@@ -158,17 +145,6 @@ export abstract class UpgradeCommandRunner extends CommandRunner {
),
);
- const hasWorkspaces =
- await this.workspaceVersionService.hasActiveOrSuspendedWorkspaces();
-
- if (!hasWorkspaces) {
- this.logger.log(
- chalk.blue('Fresh installation detected, skipping migration'),
- );
-
- return;
- }
-
const workspacesBelowMinimumVersion =
await this.workspaceVersionService.getWorkspacesBelowVersion(
versionContext.fromWorkspaceVersion.version,
@@ -186,25 +162,78 @@ Please roll back to that version and run the upgrade command again.`,
);
}
- for (const instanceCommand of versionContext.instanceCommands) {
- const migrationName = instanceCommand.constructor.name;
- const result =
- await this.coreMigrationRunnerService.runSingleMigration(
- migrationName,
+ await this.runLegacyPendingTypeOrmMigrations();
+ await this.runInstanceCommandsOrThrow(versionContext);
+
+ const hasWorkspaces =
+ await this.workspaceVersionService.hasActiveOrSuspendedWorkspaces();
+
+ if (!hasWorkspaces) {
+ this.logger.log(
+ chalk.blue(
+ 'Fresh installation detected, skipping workspace commands',
+ ),
+ );
+
+ return;
+ }
+
+ const iteratorReport = await this.runWorkspaceCommands(
+ options,
+ versionContext,
+ );
+
+ this.logger.log(
+ chalk.blue(
+ `Upgrade summary: ${iteratorReport.success.length} succeeded, ${iteratorReport.fail.length} failed`,
+ ),
+ );
+
+ if (iteratorReport.fail.length > 0) {
+ throw new Error(
+ `Upgrade completed with ${iteratorReport.fail.length} workspace failure(s)`,
+ );
+ }
+ } catch (error) {
+ this.logger.error(chalk.red(`Upgrade failed: ${error.message}`));
+ throw error;
+ }
+ }
+
+ private async runLegacyPendingTypeOrmMigrations(): Promise {
+ this.logger.log('Running legacy TypeORM migrations...');
+
+ const migrations = await this.dataSource.runMigrations({
+ transaction: 'each',
+ });
+
+ if (migrations.length === 0) {
+ this.logger.log('No pending legacy migrations');
+ } else {
+ this.logger.log(
+ `Executed ${migrations.length} legacy migration(s): ${migrations.map((migration) => migration.name).join(', ')}`,
+ );
+ }
+ }
+
+ private async runInstanceCommandsOrThrow(
+ versionContext: VersionContext,
+ ): Promise {
+ for (const instanceCommand of versionContext.instanceCommands) {
+ const migrationName = instanceCommand.constructor.name;
+ const result =
+ await this.instanceUpgradeService.runSingleMigration(instanceCommand);
+
+ switch (result.status) {
+ case 'already-executed': {
+ this.logger.warn(
+ `Core migration ${migrationName} already executed, skipping`,
);
- if (result.status === 'fail') {
- if (result.code === 'already-executed') {
- this.logger.warn(
- `Core migration ${migrationName} already executed, skipping`,
- );
-
- continue;
- }
-
- this.logger.error(
- `Core migration ${migrationName} failed with code: ${result.code}`,
- );
+ break;
+ }
+ case 'failed': {
+ this.logger.error(`Core migration ${migrationName} failed`);
if (isDefined(result.error)) {
this.logger.error(
@@ -214,46 +243,19 @@ Please roll back to that version and run the upgrade command again.`,
);
}
- throw new Error(
- `Core migration ${migrationName} failed: ${result.code}`,
- );
+ throw new Error(`Core migration ${migrationName} failed`);
}
+ case 'success': {
+ this.logger.log(
+ `Core migration ${migrationName} executed successfully`,
+ );
- this.logger.log(
- `Core migration ${migrationName} executed successfully`,
- );
+ break;
+ }
+ default: {
+ assertUnreachable(result);
+ }
}
-
- const iteratorReport = await this.workspaceIteratorService.iterate({
- workspaceIds:
- options.workspaceId && options.workspaceId.size > 0
- ? Array.from(options.workspaceId)
- : undefined,
- startFromWorkspaceId: options.startFromWorkspaceId,
- workspaceCountLimit: options.workspaceCountLimit,
- dryRun: options.dryRun,
- callback: async (context) => {
- await this.runOnWorkspace(context, options, versionContext);
- },
- });
-
- if (iteratorReport.fail.length > 0) {
- this.logger.error(
- chalk.red(
- `Upgrade completed with ${iteratorReport.fail.length} workspace failure(s)`,
- ),
- );
- }
-
- this.logger.log(
- chalk.blue(
- `Upgrade summary: ${iteratorReport.success.length} succeeded, ${iteratorReport.fail.length} failed`,
- ),
- );
- this.logger.log(chalk.blue('Command completed!'));
- } catch (error) {
- this.logger.error(chalk.red(`Upgrade failed: ${error.message}`));
- throw error;
}
}
@@ -273,7 +275,7 @@ Please roll back to that version and run the upgrade command again.`,
this.coreEngineVersionService.getPreviousVersion();
const instanceCommands =
- this.versionedMigrationRegistryService.getInstanceCommandsForVersion(
+ this.registeredInstanceMigrationService.getInstanceCommandsForVersion(
currentVersionMajorMinor,
);
@@ -286,88 +288,27 @@ Please roll back to that version and run the upgrade command again.`,
};
}
- private async runOnWorkspace(
- iteratorContext: WorkspaceIteratorContext,
+ private async runWorkspaceCommands(
options: UpgradeCommandOptions,
versionContext: VersionContext,
- ): Promise {
- const { workspaceId, index, total } = iteratorContext;
- const { fromWorkspaceVersion, currentAppVersion, workspaceCommands } =
- versionContext;
-
- this.logger.log(
- chalk.blue(
- `${options.dryRun ? '(dry run) ' : ''}Upgrading workspace ${workspaceId} from=${fromWorkspaceVersion} to=${currentAppVersion} ${index + 1}/${total}`,
- ),
- );
-
- const versionCompareResult =
- await this.compareWorkspaceVersionToFromVersion(
- workspaceId,
- fromWorkspaceVersion,
- );
-
- switch (versionCompareResult) {
- case 'lower': {
- throw new Error(
- `WORKSPACE_VERSION_MISSMATCH Upgrade for workspace ${workspaceId} failed as its version is beneath fromWorkspaceVersion=${fromWorkspaceVersion.version}`,
- );
- }
- case 'equal': {
- for (const workspaceCommand of workspaceCommands) {
- await workspaceCommand.runOnWorkspace({
- options: options as RunOnWorkspaceArgs['options'],
- workspaceId,
- dataSource: iteratorContext.dataSource,
- index,
- total,
- });
- }
-
- if (!options.dryRun) {
- await this.workspaceRepository.update(
- { id: workspaceId },
- { version: currentAppVersion.version },
- );
- }
-
- this.logger.log(
- chalk.blue(`Upgrade for workspace ${workspaceId} completed.`),
- );
-
- return;
- }
- case 'higher': {
- this.logger.log(
- chalk.blue(
- `Upgrade for workspace ${workspaceId} ignored as is already at a higher version.`,
- ),
- );
-
- return;
- }
- default: {
- assertUnreachable(versionCompareResult);
- }
- }
- }
-
- private async compareWorkspaceVersionToFromVersion(
- workspaceId: string,
- fromWorkspaceVersion: SemVer,
- ): Promise {
- const workspace = await this.workspaceRepository.findOneByOrFail({
- id: workspaceId,
+ ) {
+ return await this.workspaceIteratorService.iterate({
+ workspaceIds:
+ options.workspaceId && options.workspaceId.size > 0
+ ? Array.from(options.workspaceId)
+ : undefined,
+ startFromWorkspaceId: options.startFromWorkspaceId,
+ workspaceCountLimit: options.workspaceCountLimit,
+ dryRun: options.dryRun,
+ callback: async (context) => {
+ await this.workspaceUpgradeService.upgradeWorkspace({
+ iteratorContext: context,
+ options,
+ fromWorkspaceVersion: versionContext.fromWorkspaceVersion,
+ currentAppVersion: versionContext.currentAppVersion,
+ workspaceCommands: versionContext.workspaceCommands,
+ });
+ },
});
- const currentWorkspaceVersion = workspace.version;
-
- if (!isDefined(currentWorkspaceVersion)) {
- throw new Error(`WORKSPACE_VERSION_NOT_DEFINED workspace=${workspaceId}`);
- }
-
- return compareVersionMajorAndMinor(
- currentWorkspaceVersion,
- fromWorkspaceVersion.version,
- );
}
}
diff --git a/packages/twenty-server/src/database/commands/core-migration/__tests__/__snapshots__/core-migration-generator.service.spec.ts.snap b/packages/twenty-server/src/database/commands/core-migration/__tests__/__snapshots__/core-migration-generator.service.spec.ts.snap
deleted file mode 100644
index a4335f558b..0000000000
--- a/packages/twenty-server/src/database/commands/core-migration/__tests__/__snapshots__/core-migration-generator.service.spec.ts.snap
+++ /dev/null
@@ -1,171 +0,0 @@
-// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
-
-exports[`CoreMigrationGeneratorService should encode version correctly in file and class names 1`] = `
-{
- "className": "TestV11901775000000000",
- "fileName": "1775000000000-1-19-0-test.ts",
- "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
-
-import { RegisteredCoreMigration } from 'src/database/typeorm/core/decorators/registered-core-migration.decorator';
-
-@RegisteredCoreMigration('1.19.0')
-export class TestV11901775000000000 implements MigrationInterface {
- name = 'TestV11901775000000000';
-
- public async up(queryRunner: QueryRunner): Promise {
- await queryRunner.query('SELECT 1');
- }
-
- public async down(queryRunner: QueryRunner): Promise {
- await queryRunner.query('SELECT 1');
- }
-}
-",
-}
-`;
-
-exports[`CoreMigrationGeneratorService should escape backslashes in SQL queries 1`] = `
-{
- "className": "UpdatePathV12101775000000000",
- "fileName": "1775000000000-1-21-0-update-path.ts",
- "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
-
-import { RegisteredCoreMigration } from 'src/database/typeorm/core/decorators/registered-core-migration.decorator';
-
-@RegisteredCoreMigration('1.21.0')
-export class UpdatePathV12101775000000000 implements MigrationInterface {
- name = 'UpdatePathV12101775000000000';
-
- public async up(queryRunner: QueryRunner): Promise {
- await queryRunner.query('UPDATE "core"."config" SET "value" = E\\'path\\\\\\\\to\\\\\\\\file\\'');
- }
-
- public async down(queryRunner: QueryRunner): Promise {
- await queryRunner.query('UPDATE "core"."config" SET "value" = NULL');
- }
-}
-",
-}
-`;
-
-exports[`CoreMigrationGeneratorService should escape single quotes in SQL queries 1`] = `
-{
- "className": "UpdateConfigV12101775000000000",
- "fileName": "1775000000000-1-21-0-update-config.ts",
- "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
-
-import { RegisteredCoreMigration } from 'src/database/typeorm/core/decorators/registered-core-migration.decorator';
-
-@RegisteredCoreMigration('1.21.0')
-export class UpdateConfigV12101775000000000 implements MigrationInterface {
- name = 'UpdateConfigV12101775000000000';
-
- public async up(queryRunner: QueryRunner): Promise {
- await queryRunner.query('UPDATE "core"."config" SET "value" = \\'it\\'\\'s done\\'');
- }
-
- public async down(queryRunner: QueryRunner): Promise {
- await queryRunner.query('UPDATE "core"."config" SET "value" = \\'original\\'');
- }
-}
-",
-}
-`;
-
-exports[`CoreMigrationGeneratorService should generate a migration with a single up/down query 1`] = `
-{
- "className": "AddFooColumnV12101775000000000",
- "fileName": "1775000000000-1-21-0-add-foo-column.ts",
- "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
-
-import { RegisteredCoreMigration } from 'src/database/typeorm/core/decorators/registered-core-migration.decorator';
-
-@RegisteredCoreMigration('1.21.0')
-export class AddFooColumnV12101775000000000 implements MigrationInterface {
- name = 'AddFooColumnV12101775000000000';
-
- public async up(queryRunner: QueryRunner): Promise {
- await queryRunner.query('ALTER TABLE "core"."user" ADD "foo" varchar');
- }
-
- public async down(queryRunner: QueryRunner): Promise {
- await queryRunner.query('ALTER TABLE "core"."user" DROP COLUMN "foo"');
- }
-}
-",
-}
-`;
-
-exports[`CoreMigrationGeneratorService should generate a migration with multiple queries 1`] = `
-{
- "className": "CreateTaskTableV12101775000000000",
- "fileName": "1775000000000-1-21-0-create-task-table.ts",
- "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
-
-import { RegisteredCoreMigration } from 'src/database/typeorm/core/decorators/registered-core-migration.decorator';
-
-@RegisteredCoreMigration('1.21.0')
-export class CreateTaskTableV12101775000000000 implements MigrationInterface {
- name = 'CreateTaskTableV12101775000000000';
-
- public async up(queryRunner: QueryRunner): Promise {
- 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")');
- }
-
- public async down(queryRunner: QueryRunner): Promise {
- await queryRunner.query('DROP TABLE "core"."task"');
- await queryRunner.query('ALTER TABLE "core"."task" DROP CONSTRAINT "PK_task"');
- }
-}
-",
-}
-`;
-
-exports[`CoreMigrationGeneratorService should generate a migration with query parameters 1`] = `
-{
- "className": "SeedSettingV12101775000000000",
- "fileName": "1775000000000-1-21-0-seed-setting.ts",
- "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
-
-import { RegisteredCoreMigration } from 'src/database/typeorm/core/decorators/registered-core-migration.decorator';
-
-@RegisteredCoreMigration('1.21.0')
-export class SeedSettingV12101775000000000 implements MigrationInterface {
- name = 'SeedSettingV12101775000000000';
-
- public async up(queryRunner: QueryRunner): Promise {
- await queryRunner.query('INSERT INTO "core"."setting" ("key", "value") VALUES ($1, $2)', ["theme","dark"]);
- }
-
- public async down(queryRunner: QueryRunner): Promise {
- await queryRunner.query('DELETE FROM "core"."setting" WHERE "key" = $1', ["theme"]);
- }
-}
-",
-}
-`;
-
-exports[`CoreMigrationGeneratorService should use default migration name in class and file names 1`] = `
-{
- "className": "AutoGeneratedV12101775000000000",
- "fileName": "1775000000000-1-21-0-auto-generated.ts",
- "fileTemplate": "import { MigrationInterface, QueryRunner } from 'typeorm';
-
-import { RegisteredCoreMigration } from 'src/database/typeorm/core/decorators/registered-core-migration.decorator';
-
-@RegisteredCoreMigration('1.21.0')
-export class AutoGeneratedV12101775000000000 implements MigrationInterface {
- name = 'AutoGeneratedV12101775000000000';
-
- public async up(queryRunner: QueryRunner): Promise {
- await queryRunner.query('ALTER TABLE "core"."user" ADD "bar" integer');
- }
-
- public async down(queryRunner: QueryRunner): Promise {
- await queryRunner.query('ALTER TABLE "core"."user" DROP COLUMN "bar"');
- }
-}
-",
-}
-`;
diff --git a/packages/twenty-server/src/database/commands/core-migration/core-migration.module.ts b/packages/twenty-server/src/database/commands/core-migration/core-migration.module.ts
deleted file mode 100644
index 17b083fff1..0000000000
--- a/packages/twenty-server/src/database/commands/core-migration/core-migration.module.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Module } from '@nestjs/common';
-
-import { CoreMigrationGeneratorService } from 'src/database/commands/core-migration/services/core-migration-generator.service';
-import { CoreMigrationRunnerService } from 'src/database/commands/core-migration/services/core-migration-runner.service';
-import { RegisteredCoreMigrationService } from 'src/database/commands/core-migration/services/registered-core-migration-registry.service';
-
-@Module({
- providers: [
- CoreMigrationGeneratorService,
- CoreMigrationRunnerService,
- RegisteredCoreMigrationService,
- ],
- exports: [
- CoreMigrationGeneratorService,
- CoreMigrationRunnerService,
- RegisteredCoreMigrationService,
- ],
-})
-export class CoreMigrationModule {}
diff --git a/packages/twenty-server/src/database/commands/core-migration/services/core-migration-runner.service.ts b/packages/twenty-server/src/database/commands/core-migration/services/core-migration-runner.service.ts
deleted file mode 100644
index 5d05a19160..0000000000
--- a/packages/twenty-server/src/database/commands/core-migration/services/core-migration-runner.service.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import { Injectable, Logger } from '@nestjs/common';
-import { InjectDataSource } from '@nestjs/typeorm';
-
-import { DataSource, MigrationExecutor, type QueryRunner } from 'typeorm';
-
-export type RunSingleMigrationError =
- | 'already-executed'
- | 'migration-execution-failed'
- | 'migration-instance-not-defined'
- | 'migration-not-registered';
-
-export type RunSingleMigrationResult =
- | { status: 'success' }
- | { code: RunSingleMigrationError; error?: unknown; status: 'fail' };
-
-@Injectable()
-export class CoreMigrationRunnerService {
- private readonly logger = new Logger(CoreMigrationRunnerService.name);
-
- constructor(
- @InjectDataSource()
- private readonly dataSource: DataSource,
- ) {}
-
- async runAllPendingMigrations(): Promise {
- this.logger.log('Running core datasource migrations...');
-
- try {
- const migrations = await this.dataSource.runMigrations({
- transaction: 'each',
- });
-
- if (migrations.length === 0) {
- this.logger.log('No pending migrations');
- } else {
- this.logger.log(
- `Executed ${migrations.length} migration(s): ${migrations.map((migration) => migration.name).join(', ')}`,
- );
- }
-
- this.logger.log('Database migrations completed successfully');
- } catch (error) {
- this.logger.error('Error running database migrations:', error);
- throw error;
- }
- }
-
- async runSingleMigration(
- migrationName: string,
- ): Promise {
- this.logger.log(`Running core datasource migration ${migrationName}...`);
-
- const queryRunner = this.dataSource.createQueryRunner();
- const migrationExecutor = new MigrationExecutor(
- this.dataSource,
- queryRunner,
- );
-
- try {
- await queryRunner.connect();
-
- const pendingMigrations = await migrationExecutor.getPendingMigrations();
- const pendingMigration = pendingMigrations.find(
- (migration) => migration.name === migrationName,
- );
-
- if (!pendingMigration) {
- const registeredMigration = (
- await migrationExecutor.getAllMigrations()
- ).find((migration) => migration.name === migrationName);
-
- if (!registeredMigration) {
- return {
- code: 'migration-not-registered',
- status: 'fail',
- };
- }
-
- return {
- code: 'already-executed',
- status: 'fail',
- };
- }
-
- if (!pendingMigration.instance) {
- return {
- code: 'migration-instance-not-defined',
- status: 'fail',
- };
- }
-
- await this.createMetadataTableIfNecessary(queryRunner);
-
- const shouldRunInTransaction =
- pendingMigration.instance.transaction ?? true;
-
- await queryRunner.beforeMigration();
-
- try {
- if (shouldRunInTransaction) {
- await queryRunner.startTransaction();
- }
-
- await pendingMigration.instance.up(queryRunner);
- await migrationExecutor.insertMigration(pendingMigration);
-
- if (shouldRunInTransaction) {
- await queryRunner.commitTransaction();
- }
- } catch (error) {
- if (shouldRunInTransaction && queryRunner.isTransactionActive) {
- await queryRunner.rollbackTransaction();
- }
-
- throw error;
- } finally {
- await queryRunner.afterMigration();
- }
-
- return { status: 'success' };
- } catch (error) {
- this.logger.error('Error running database migration:', error);
-
- return {
- code: 'migration-execution-failed',
- error,
- status: 'fail',
- };
- } finally {
- await queryRunner.release();
- }
- }
-
- private async createMetadataTableIfNecessary(
- queryRunner: QueryRunner,
- ): Promise {
- const schemaBuilder = this.dataSource.driver.createSchemaBuilder() as {
- createMetadataTableIfNecessary?: (
- queryRunnerToUse: QueryRunner,
- ) => Promise;
- };
-
- if (schemaBuilder.createMetadataTableIfNecessary) {
- await schemaBuilder.createMetadataTableIfNecessary(queryRunner);
- }
- }
-}
diff --git a/packages/twenty-server/src/database/commands/core-migration/services/registered-core-migration-registry.service.ts b/packages/twenty-server/src/database/commands/core-migration/services/registered-core-migration-registry.service.ts
deleted file mode 100644
index 2dba9ae6a6..0000000000
--- a/packages/twenty-server/src/database/commands/core-migration/services/registered-core-migration-registry.service.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
-import { InjectDataSource } from '@nestjs/typeorm';
-
-import { DataSource, type MigrationInterface } from 'typeorm';
-
-import { getRegisteredCoreMigrationVersion } from 'src/database/typeorm/core/decorators/registered-core-migration.decorator';
-import {
- UPGRADE_COMMAND_SUPPORTED_VERSIONS,
- type UpgradeCommandVersion,
-} from 'src/engine/constants/upgrade-command-supported-versions.constant';
-
-@Injectable()
-export class RegisteredCoreMigrationService implements OnModuleInit {
- private readonly logger = new Logger(RegisteredCoreMigrationService.name);
-
- private readonly migrationsByVersion = new Map<
- UpgradeCommandVersion,
- MigrationInterface[]
- >();
-
- constructor(
- @InjectDataSource()
- private readonly dataSource: DataSource,
- ) {}
-
- onModuleInit(): void {
- for (const version of UPGRADE_COMMAND_SUPPORTED_VERSIONS) {
- this.migrationsByVersion.set(version, []);
- }
-
- // dataSource.migrations is already sorted by timestamp (TypeORM sorts
- // ascending by the 13-digit suffix of the class name)
- for (const migration of this.dataSource.migrations) {
- const constructor = migration.constructor;
- const version = getRegisteredCoreMigrationVersion(constructor);
-
- if (version === undefined) {
- continue;
- }
-
- const bucket = this.migrationsByVersion.get(version);
-
- if (!bucket) {
- continue;
- }
-
- bucket.push(migration);
- }
-
- for (const [version, migrations] of this.migrationsByVersion) {
- if (migrations.length > 0) {
- this.logger.log(
- `Registered ${migrations.length} versioned migration(s) for ${version}: ${migrations.map((migration) => migration.constructor.name).join(', ')}`,
- );
- }
- }
- }
-
- getInstanceCommandsForVersion(
- version: UpgradeCommandVersion,
- ): MigrationInterface[] {
- return this.migrationsByVersion.get(version) ?? [];
- }
-}
diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts
index 6a44c66f61..0b9149c4fb 100644
--- a/packages/twenty-server/src/database/commands/database-command.module.ts
+++ b/packages/twenty-server/src/database/commands/database-command.module.ts
@@ -3,15 +3,16 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.command';
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
-import { GenerateVersionedMigrationCommand } from 'src/database/commands/generate-versioned-migration.command';
+import { GenerateInstanceCommandCommand } from 'src/database/commands/generate-instance-command.command';
+import { InstanceCommandGenerationService } from 'src/database/commands/instance-command-generation.service';
import { ListOrphanedWorkspaceEntitiesCommand } from 'src/database/commands/list-and-delete-orphaned-workspace-entities.command';
import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question';
-import { RunCoreMigrationCommand } from 'src/database/commands/run-core-migration.command';
+import { RunInstanceCommandsCommand } from 'src/database/commands/run-instance-commands.command';
+import { InstanceCommandProviderModule } from 'src/database/commands/upgrade-version-command/instance-command-provider.module';
import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module';
import { WorkspaceExportModule } from 'src/database/commands/workspace-export/workspace-export.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { CoreEngineVersionModule } from 'src/engine/core-engine-version/core-engine-version.module';
-import { CoreMigrationModule } from 'src/database/commands/core-migration/core-migration.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/generate-api-key.command';
import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module';
@@ -24,6 +25,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
+import { UpgradeModule } from 'src/engine/core-modules/upgrade/upgrade.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
@@ -44,6 +46,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
@Module({
imports: [
UpgradeVersionCommandModule,
+ InstanceCommandProviderModule,
TypeOrmModule.forFeature([WorkspaceEntity]),
WorkspaceExportModule,
// Cron command dependencies
@@ -74,18 +77,19 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
ApplicationUpgradeModule,
StaleRegistrationCleanupModule,
CoreEngineVersionModule,
- CoreMigrationModule,
WorkspaceVersionModule,
+ UpgradeModule,
],
providers: [
DataSeedWorkspaceCommand,
ConfirmationQuestion,
CronRegisterAllCommand,
- GenerateVersionedMigrationCommand,
+ GenerateInstanceCommandCommand,
+ InstanceCommandGenerationService,
+ RunInstanceCommandsCommand,
ListOrphanedWorkspaceEntitiesCommand,
EnterpriseKeyValidationCronCommand,
GenerateApiKeyCommand,
- RunCoreMigrationCommand,
],
})
export class DatabaseCommandModule {}
diff --git a/packages/twenty-server/src/database/commands/generate-instance-command.command.ts b/packages/twenty-server/src/database/commands/generate-instance-command.command.ts
new file mode 100644
index 0000000000..a251087123
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/generate-instance-command.command.ts
@@ -0,0 +1,121 @@
+import * as fs from 'fs';
+import * as path from 'path';
+
+import { Logger } from '@nestjs/common';
+
+import { Command, CommandRunner, Option } from 'nest-commander';
+
+import { InstanceCommandGenerationService } from 'src/database/commands/instance-command-generation.service';
+import { UPGRADE_COMMAND_SUPPORTED_VERSIONS } from 'src/engine/constants/upgrade-command-supported-versions.constant';
+
+const UPGRADE_VERSION_COMMAND_DIR = path.resolve(
+ process.cwd(),
+ 'src/database/commands/upgrade-version-command',
+);
+
+type GenerateInstanceCommandOptions = {
+ name: string;
+};
+
+@Command({
+ name: 'generate:instance-command',
+ description:
+ 'Generate an instance command with @RegisteredInstanceMigration decorator for the latest supported version',
+})
+export class GenerateInstanceCommandCommand extends CommandRunner {
+ private readonly logger = new Logger(GenerateInstanceCommandCommand.name);
+
+ constructor(
+ private readonly instanceMigrationGenerationService: InstanceCommandGenerationService,
+ ) {
+ super();
+ }
+
+ @Option({
+ flags: '-n, --name ',
+ description: 'Migration name (kebab-case)',
+ defaultValue: 'auto-generated',
+ })
+ parseName(value: string): string {
+ return value;
+ }
+
+ async run(
+ _passedParams: string[],
+ options: GenerateInstanceCommandOptions,
+ ): Promise {
+ const migrationName = options.name;
+
+ const version = UPGRADE_COMMAND_SUPPORTED_VERSIONS.slice(-1)[0];
+
+ if (!version) {
+ throw new Error('No supported versions found');
+ }
+
+ this.logger.log(`Generating versioned migration for version ${version}...`);
+
+ const versionDir = this.getVersionDir(version);
+ const timestamp = Date.now();
+
+ const result = await this.instanceMigrationGenerationService.generate({
+ migrationName,
+ version,
+ timestamp,
+ });
+
+ if (!result) {
+ this.logger.warn(
+ 'No changes in database schema were found - cannot generate a migration.',
+ );
+
+ return;
+ }
+
+ const migrationFilePath = path.join(versionDir, result.fileName);
+
+ fs.writeFileSync(migrationFilePath, result.fileTemplate);
+
+ this.logger.log(`Migration generated successfully: ${migrationFilePath}`);
+ this.logger.log(` Class: ${result.className}`);
+ this.logger.log(` Version: ${version}`);
+
+ const versionSlug = version.split('.').slice(0, 2).join('-');
+ const newImportPath = `src/database/commands/upgrade-version-command/${versionSlug}/${result.fileName.replace('.ts', '')}`;
+
+ this.appendToInstanceCommandsConstant(result.className, newImportPath);
+ }
+
+ private getVersionDir(version: string): string {
+ const versionSlug = version.split('.').slice(0, 2).join('-');
+
+ return path.join(UPGRADE_VERSION_COMMAND_DIR, versionSlug);
+ }
+
+ private appendToInstanceCommandsConstant(
+ className: string,
+ importPath: string,
+ ): void {
+ const filePath = path.join(
+ UPGRADE_VERSION_COMMAND_DIR,
+ 'instance-commands.constant.ts',
+ );
+
+ const content = fs.readFileSync(filePath, 'utf-8');
+
+ if (content.includes(className)) {
+ throw new Error(
+ `${className} is already registered in instance-commands.constant.ts`,
+ );
+ }
+
+ const newImportLine = `import { ${className} } from '${importPath}';\n`;
+
+ const updatedContent = content
+ .replace(/\nexport const/, `${newImportLine}\nexport const`)
+ .replace(/\];/, ` ${className},\n];`);
+
+ fs.writeFileSync(filePath, updatedContent);
+
+ this.logger.log(`Added ${className} to instance-commands.constant.ts`);
+ }
+}
diff --git a/packages/twenty-server/src/database/commands/generate-versioned-migration.command.ts b/packages/twenty-server/src/database/commands/generate-versioned-migration.command.ts
deleted file mode 100644
index 753c70ed6c..0000000000
--- a/packages/twenty-server/src/database/commands/generate-versioned-migration.command.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import * as fs from 'fs';
-import * as path from 'path';
-
-import { Logger } from '@nestjs/common';
-
-import { Command, CommandRunner, Option } from 'nest-commander';
-
-import { CoreMigrationGeneratorService } from 'src/database/commands/core-migration/services/core-migration-generator.service';
-import { UPGRADE_COMMAND_SUPPORTED_VERSIONS } from 'src/engine/constants/upgrade-command-supported-versions.constant';
-
-const MIGRATIONS_DIR = path.resolve(
- process.cwd(),
- 'src/database/typeorm/core/migrations/common',
-);
-
-type GenerateVersionedMigrationCommandOptions = {
- name: string;
-};
-
-@Command({
- name: 'generate:versioned-migration',
- description:
- 'Generate a TypeORM migration with @RegisteredCoreMigration decorator for the latest supported version',
-})
-export class GenerateVersionedMigrationCommand extends CommandRunner {
- private readonly logger = new Logger(GenerateVersionedMigrationCommand.name);
-
- constructor(
- private readonly coreMigrationGeneratorService: CoreMigrationGeneratorService,
- ) {
- super();
- }
-
- @Option({
- flags: '-n, --name ',
- description: 'Migration name (kebab-case)',
- defaultValue: 'auto-generated',
- })
- parseName(value: string): string {
- return value;
- }
-
- async run(
- _passedParams: string[],
- options: GenerateVersionedMigrationCommandOptions,
- ): Promise {
- const migrationName = options.name;
-
- const version = UPGRADE_COMMAND_SUPPORTED_VERSIONS.slice(-1)[0];
-
- if (!version) {
- throw new Error('No supported versions found');
- }
-
- this.logger.log(`Generating versioned migration for version ${version}...`);
-
- const timestamp = Date.now();
-
- const result = await this.coreMigrationGeneratorService.generate({
- migrationName,
- version,
- timestamp,
- });
-
- if (!result) {
- this.logger.warn(
- 'No changes in database schema were found - cannot generate a migration.',
- );
-
- return;
- }
-
- const filePath = path.join(MIGRATIONS_DIR, result.fileName);
-
- fs.writeFileSync(filePath, result.fileTemplate);
-
- this.logger.log(`Migration generated successfully: ${filePath}`);
- this.logger.log(` Class: ${result.className}`);
- this.logger.log(` Version: ${version}`);
- }
-}
diff --git a/packages/twenty-server/src/database/commands/core-migration/services/core-migration-generator.service.ts b/packages/twenty-server/src/database/commands/instance-command-generation.service.ts
similarity index 73%
rename from packages/twenty-server/src/database/commands/core-migration/services/core-migration-generator.service.ts
rename to packages/twenty-server/src/database/commands/instance-command-generation.service.ts
index a64ae3209f..bf56a1348c 100644
--- a/packages/twenty-server/src/database/commands/core-migration/services/core-migration-generator.service.ts
+++ b/packages/twenty-server/src/database/commands/instance-command-generation.service.ts
@@ -19,7 +19,7 @@ export type GeneratedMigrationResult = {
};
@Injectable()
-export class CoreMigrationGeneratorService {
+export class InstanceCommandGenerationService {
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
@@ -52,14 +52,16 @@ export class CoreMigrationGeneratorService {
` await queryRunner.query('${this.escapeForSingleQuotedString(query)}'${this.formatQueryParams(parameters)});`,
);
- const fileTemplate = this.buildMigrationFileContent(
+ const fileTemplate = this.buildMigrationFileContent({
className,
version,
+ timestamp,
upStatements,
downStatements,
- );
+ });
- const fileName = `${timestamp}-${version.replace(/\./g, '-')}-${migrationName}.ts`;
+ const versionSlug = version.split('.').slice(0, 2).join('-');
+ const fileName = `${versionSlug}-instance-command-fast-${timestamp}-${migrationName}.ts`;
return { fileName, fileTemplate, className };
}
@@ -69,7 +71,9 @@ export class CoreMigrationGeneratorService {
version: string,
timestamp: number,
): string {
- return `${pascalCase(name)}V${version.replace(/\./g, '')}${timestamp}`;
+ const versionSlug = version.split('.').slice(0, 2).join('_');
+
+ return `V${versionSlug}_${pascalCase(name)}_${timestamp}`;
}
private formatQueryParams(parameters: unknown[] | undefined): string {
@@ -84,17 +88,24 @@ export class CoreMigrationGeneratorService {
return query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
- private buildMigrationFileContent(
- className: string,
- version: string,
- upStatements: string[],
- downStatements: string[],
- ): string {
+ private buildMigrationFileContent({
+ className,
+ version,
+ timestamp,
+ upStatements,
+ downStatements,
+ }: {
+ className: string;
+ version: string;
+ timestamp: number;
+ upStatements: string[];
+ downStatements: string[];
+ }): string {
return `import { MigrationInterface, QueryRunner } from 'typeorm';
-import { RegisteredCoreMigration } from 'src/database/typeorm/core/decorators/registered-core-migration.decorator';
+import { RegisteredInstanceMigration } from 'src/database/typeorm/core/decorators/registered-instance-migration.decorator';
-@RegisteredCoreMigration('${version}')
+@RegisteredInstanceMigration('${version}', ${timestamp})
export class ${className} implements MigrationInterface {
name = '${className}';
diff --git a/packages/twenty-server/src/database/commands/run-core-migration.command.ts b/packages/twenty-server/src/database/commands/run-core-migration.command.ts
deleted file mode 100644
index feee605bb4..0000000000
--- a/packages/twenty-server/src/database/commands/run-core-migration.command.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { Logger } from '@nestjs/common';
-
-import chalk from 'chalk';
-import { Command, CommandRunner, Option } from 'nest-commander';
-
-import { CoreMigrationRunnerService } from 'src/database/commands/core-migration/services/core-migration-runner.service';
-import { CoreEngineVersionService } from 'src/engine/core-engine-version/services/core-engine-version.service';
-import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
-
-type RunCoreMigrationCommandOptions = {
- force?: boolean;
-};
-
-@Command({
- name: 'run-core-migration',
- description:
- 'Run TypeORM core migrations with workspace version safety check',
-})
-export class RunCoreMigrationCommand extends CommandRunner {
- private readonly logger = new Logger(RunCoreMigrationCommand.name);
-
- constructor(
- private readonly coreEngineVersionService: CoreEngineVersionService,
- private readonly workspaceVersionService: WorkspaceVersionService,
- private readonly coreMigrationRunnerService: CoreMigrationRunnerService,
- ) {
- super();
- }
-
- @Option({
- flags: '-f, --force',
- description: 'Skip workspace version safety check',
- required: false,
- })
- parseForce(): boolean {
- return true;
- }
-
- async run(
- _passedParams: string[],
- options: RunCoreMigrationCommandOptions,
- ): Promise {
- if (options.force) {
- this.logger.warn(
- chalk.yellow('Skipping workspace version check (--force flag used)'),
- );
- } else {
- const previousVersion =
- this.coreEngineVersionService.getPreviousVersion();
-
- const workspacesBelow =
- await this.workspaceVersionService.getWorkspacesBelowVersion(
- previousVersion.version,
- );
-
- if (workspacesBelow.length > 0) {
- for (const workspace of workspacesBelow) {
- this.logger.error(
- chalk.red(
- `Workspace ${workspace.id} (${workspace.displayName}) is at version ${workspace.version ?? 'undefined'}, which is below the minimum required version.`,
- ),
- );
- }
-
- throw new Error(
- 'Unable to run TypeORM migrations. Some workspace(s) are below the minimum required version.\n' +
- 'Please ensure all workspaces are on at least the previous minor version before running migrations.\n' +
- 'Use --force to bypass this check (not recommended).',
- );
- }
- }
-
- await this.coreMigrationRunnerService.runAllPendingMigrations();
- }
-}
diff --git a/packages/twenty-server/src/database/commands/run-instance-commands.command.ts b/packages/twenty-server/src/database/commands/run-instance-commands.command.ts
new file mode 100644
index 0000000000..9b8c662085
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/run-instance-commands.command.ts
@@ -0,0 +1,169 @@
+import { Logger } from '@nestjs/common';
+import { InjectDataSource } from '@nestjs/typeorm';
+
+import chalk from 'chalk';
+import { Command, CommandRunner, Option } from 'nest-commander';
+import { isDefined } from 'twenty-shared/utils';
+import { DataSource } from 'typeorm';
+
+import { CoreEngineVersionService } from 'src/engine/core-engine-version/services/core-engine-version.service';
+import { InstanceUpgradeService } from 'src/engine/core-modules/upgrade/services/instance-upgrade.service';
+import { RegisteredInstanceMigrationService } from 'src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service';
+import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
+
+type RunInstanceCommandsOptions = {
+ force?: boolean;
+};
+
+@Command({
+ name: 'run-instance-commands',
+ description:
+ 'Run legacy TypeORM migrations and all registered instance commands',
+})
+export class RunInstanceCommandsCommand extends CommandRunner {
+ private readonly logger = new Logger(RunInstanceCommandsCommand.name);
+
+ constructor(
+ @InjectDataSource()
+ private readonly dataSource: DataSource,
+ private readonly coreEngineVersionService: CoreEngineVersionService,
+ private readonly workspaceVersionService: WorkspaceVersionService,
+ private readonly registeredInstanceMigrationService: RegisteredInstanceMigrationService,
+ private readonly instanceUpgradeService: InstanceUpgradeService,
+ ) {
+ super();
+ }
+
+ @Option({
+ flags: '-f, --force',
+ description: 'Skip workspace version safety check',
+ required: false,
+ })
+ parseForce(): boolean {
+ return true;
+ }
+
+ async run(
+ _passedParams: string[],
+ options: RunInstanceCommandsOptions,
+ ): Promise {
+ try {
+ await this.checkWorkspaceVersionSafety(options);
+ await this.runLegacyPendingTypeOrmMigrations();
+ await this.runAllInstanceCommands();
+
+ this.logger.log(chalk.green('Instance commands completed'));
+ } catch (error) {
+ this.logger.error(
+ chalk.red(`Instance commands failed: ${error.message}`),
+ );
+ throw error;
+ }
+ }
+
+ private async runLegacyPendingTypeOrmMigrations(): Promise {
+ this.logger.log('Running legacy TypeORM migrations...');
+
+ const migrations = await this.dataSource.runMigrations({
+ transaction: 'each',
+ });
+
+ if (migrations.length === 0) {
+ this.logger.log('No pending legacy migrations');
+ } else {
+ this.logger.log(
+ `Executed ${migrations.length} legacy migration(s): ${migrations.map((migration) => migration.name).join(', ')}`,
+ );
+ }
+ }
+
+ private async runAllInstanceCommands(): Promise {
+ const allInstanceCommands =
+ this.registeredInstanceMigrationService.getAllInstanceCommands();
+
+ if (allInstanceCommands.length === 0) {
+ this.logger.log('No registered instance commands');
+
+ return;
+ }
+
+ this.logger.log(
+ `Running ${allInstanceCommands.length} instance command(s) across all versions...`,
+ );
+
+ for (const { version, migration } of allInstanceCommands) {
+ const migrationName = migration.constructor.name;
+ const result =
+ await this.instanceUpgradeService.runSingleMigration(migration);
+
+ switch (result.status) {
+ case 'already-executed': {
+ this.logger.log(
+ `Instance command ${migrationName} (${version}) already executed, skipping`,
+ );
+
+ break;
+ }
+ case 'failed': {
+ this.logger.error(
+ `Instance command ${migrationName} (${version}) failed`,
+ );
+
+ if (isDefined(result.error)) {
+ this.logger.error(
+ result.error instanceof Error
+ ? (result.error.stack ?? result.error.message)
+ : String(result.error),
+ );
+ }
+
+ throw new Error(
+ `Instance command ${migrationName} (${version}) failed`,
+ );
+ }
+ case 'success': {
+ this.logger.log(
+ `Instance command ${migrationName} (${version}) executed successfully`,
+ );
+
+ break;
+ }
+ }
+ }
+ }
+
+ private async checkWorkspaceVersionSafety(
+ options: RunInstanceCommandsOptions,
+ ): Promise {
+ if (options.force) {
+ this.logger.warn(
+ chalk.yellow('Skipping workspace version check (--force flag used)'),
+ );
+
+ return;
+ }
+
+ const previousVersion = this.coreEngineVersionService.getPreviousVersion();
+
+ const workspacesBelow =
+ await this.workspaceVersionService.getWorkspacesBelowVersion(
+ previousVersion.version,
+ );
+
+ if (workspacesBelow.length > 0) {
+ for (const workspace of workspacesBelow) {
+ this.logger.error(
+ chalk.red(
+ `Workspace ${workspace.id} (${workspace.displayName}) is at version ${workspace.version ?? 'undefined'}, which is below the minimum required version.`,
+ ),
+ );
+ }
+
+ throw new Error(
+ 'Unable to run instance commands. Some workspace(s) are below the minimum required version.\n' +
+ 'Please ensure all workspaces are on at least the previous minor version before running migrations.\n' +
+ 'Use --force to bypass this check (not recommended).',
+ );
+ }
+ }
+}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-upgrade-version-command.module.ts
index 0ca1f69c98..34e3e1d3e1 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-upgrade-version-command.module.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-upgrade-version-command.module.ts
@@ -3,16 +3,16 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { AddComposeEmailCommandMenuItemCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-compose-email-command-menu-item.command';
-import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-message-thread-subject.command';
-import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command';
-import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command';
-import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command';
-import { DeduplicateEngineCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command';
-import { DropWorkspaceMessagingFksCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-drop-workspace-messaging-fks.command';
-import { FixSelectAllCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-fix-select-all-command-menu-items.command';
-import { MigrateMessageFolderParentIdToExternalIdCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-migrate-message-folder-parent-id-to-external-id.command';
-import { MigrateAiAgentTextToJsonResponseFormatCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-migrate-ai-agent-text-to-json-response-format.command';
-import { UpdateEditLayoutCommandMenuItemLabelCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-update-edit-layout-command-menu-item-label.command';
+import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-add-global-key-value-pair-unique-index.command';
+import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-datasource-to-workspace.command';
+import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-message-thread-subject.command';
+import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-page-layouts-and-fields-widget-view-fields.command';
+import { DeduplicateEngineCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-deduplicate-engine-commands.command';
+import { DropWorkspaceMessagingFksCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-drop-workspace-messaging-fks.command';
+import { FixSelectAllCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-fix-select-all-command-menu-items.command';
+import { MigrateAiAgentTextToJsonResponseFormatCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-migrate-ai-agent-text-to-json-response-format.command';
+import { MigrateMessageFolderParentIdToExternalIdCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-migrate-message-folder-parent-id-to-external-id.command';
+import { UpdateEditLayoutCommandMenuItemLabelCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-update-edit-layout-command-menu-item-label.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-add-global-key-value-pair-unique-index.command.ts
similarity index 100%
rename from packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command.ts
rename to packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-add-global-key-value-pair-unique-index.command.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-datasource-to-workspace.command.ts
similarity index 100%
rename from packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command.ts
rename to packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-datasource-to-workspace.command.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-backfill-message-thread-subject.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-message-thread-subject.command.ts
similarity index 100%
rename from packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-backfill-message-thread-subject.command.ts
rename to packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-message-thread-subject.command.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-page-layouts-and-fields-widget-view-fields.command.ts
similarity index 100%
rename from packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command.ts
rename to packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-page-layouts-and-fields-widget-view-fields.command.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-deduplicate-engine-commands.command.ts
similarity index 100%
rename from packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command.ts
rename to packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-deduplicate-engine-commands.command.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-drop-workspace-messaging-fks.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-drop-workspace-messaging-fks.command.ts
similarity index 100%
rename from packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-drop-workspace-messaging-fks.command.ts
rename to packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-drop-workspace-messaging-fks.command.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-fix-select-all-command-menu-items.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-fix-select-all-command-menu-items.command.ts
similarity index 100%
rename from packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-fix-select-all-command-menu-items.command.ts
rename to packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-fix-select-all-command-menu-items.command.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-migrate-ai-agent-text-to-json-response-format.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-migrate-ai-agent-text-to-json-response-format.command.ts
similarity index 100%
rename from packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-migrate-ai-agent-text-to-json-response-format.command.ts
rename to packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-migrate-ai-agent-text-to-json-response-format.command.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-migrate-message-folder-parent-id-to-external-id.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-migrate-message-folder-parent-id-to-external-id.command.ts
similarity index 100%
rename from packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-migrate-message-folder-parent-id-to-external-id.command.ts
rename to packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-migrate-message-folder-parent-id-to-external-id.command.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-update-edit-layout-command-menu-item-label.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-update-edit-layout-command-menu-item-label.command.ts
similarity index 100%
rename from packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-update-edit-layout-command-menu-item-label.command.ts
rename to packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-update-edit-layout-command-menu-item-label.command.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts
new file mode 100644
index 0000000000..b35cc44420
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-command-provider.module.ts
@@ -0,0 +1,8 @@
+import { Module } from '@nestjs/common';
+
+import { INSTANCE_COMMANDS } from 'src/database/commands/upgrade-version-command/instance-commands.constant';
+
+@Module({
+ providers: [...INSTANCE_COMMANDS],
+})
+export class InstanceCommandProviderModule {}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
new file mode 100644
index 0000000000..3e6215eb34
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
@@ -0,0 +1,3 @@
+// Auto-edited by generate:instance-command — do not edit manually
+
+export const INSTANCE_COMMANDS = [];
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts
index bae34fffa1..627d5256d6 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts
@@ -1,24 +1,21 @@
import { Module } from '@nestjs/common';
-import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
-import { CoreMigrationModule } from 'src/database/commands/core-migration/core-migration.module';
import { V1_20_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module';
import { V1_21_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-21/1-21-upgrade-version-command.module';
import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command';
import { CoreEngineVersionModule } from 'src/engine/core-engine-version/core-engine-version.module';
-import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+import { UpgradeModule } from 'src/engine/core-modules/upgrade/upgrade.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-version/workspace-version.module';
@Module({
imports: [
- TypeOrmModule.forFeature([WorkspaceEntity]),
V1_20_UpgradeVersionCommandModule,
V1_21_UpgradeVersionCommandModule,
DataSourceModule,
CoreEngineVersionModule,
- CoreMigrationModule,
+ UpgradeModule,
WorkspaceVersionModule,
WorkspaceIteratorModule,
],
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts
index a461260724..62c1ce6292 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts
@@ -1,7 +1,7 @@
-import { InjectRepository } from '@nestjs/typeorm';
+import { InjectDataSource } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
-import { type Repository } from 'typeorm';
+import { DataSource } from 'typeorm';
import {
type AllCommands,
@@ -9,8 +9,6 @@ import {
type VersionCommands,
} from 'src/database/commands/command-runners/upgrade.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
-import { CoreMigrationRunnerService } from 'src/database/commands/core-migration/services/core-migration-runner.service';
-import { RegisteredCoreMigrationService } from 'src/database/commands/core-migration/services/registered-core-migration-registry.service';
import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command';
import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command';
import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command';
@@ -27,19 +25,21 @@ import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-vers
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
import { UpdateStandardIndexViewNamesCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-update-standard-index-view-names.command';
import { AddComposeEmailCommandMenuItemCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-compose-email-command-menu-item.command';
-import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command';
-import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command';
-import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-message-thread-subject.command';
-import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command';
-import { DeduplicateEngineCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command';
-import { FixSelectAllCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-fix-select-all-command-menu-items.command';
-import { MigrateAiAgentTextToJsonResponseFormatCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-migrate-ai-agent-text-to-json-response-format.command';
-import { UpdateEditLayoutCommandMenuItemLabelCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-update-edit-layout-command-menu-item-label.command';
+import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-add-global-key-value-pair-unique-index.command';
+import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-datasource-to-workspace.command';
+import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-message-thread-subject.command';
+import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-page-layouts-and-fields-widget-view-fields.command';
+import { DeduplicateEngineCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-deduplicate-engine-commands.command';
+import { DropWorkspaceMessagingFksCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-drop-workspace-messaging-fks.command';
+import { FixSelectAllCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-fix-select-all-command-menu-items.command';
+import { MigrateAiAgentTextToJsonResponseFormatCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-migrate-ai-agent-text-to-json-response-format.command';
+import { MigrateMessageFolderParentIdToExternalIdCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-migrate-message-folder-parent-id-to-external-id.command';
+import { UpdateEditLayoutCommandMenuItemLabelCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-update-edit-layout-command-menu-item-label.command';
import { CoreEngineVersionService } from 'src/engine/core-engine-version/services/core-engine-version.service';
-import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+import { InstanceUpgradeService } from 'src/engine/core-modules/upgrade/services/instance-upgrade.service';
+import { RegisteredInstanceMigrationService } from 'src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service';
+import { WorkspaceUpgradeService } from 'src/engine/core-modules/upgrade/services/workspace-upgrade.service';
import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
-import { DropWorkspaceMessagingFksCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-drop-workspace-messaging-fks.command';
-import { MigrateMessageFolderParentIdToExternalIdCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-migrate-message-folder-parent-id-to-external-id.command';
@Command({
name: 'upgrade',
@@ -49,13 +49,14 @@ export class UpgradeCommand extends UpgradeCommandRunner {
override allCommands: AllCommands;
constructor(
- @InjectRepository(WorkspaceEntity)
- protected readonly workspaceRepository: Repository,
protected readonly coreEngineVersionService: CoreEngineVersionService,
protected readonly workspaceVersionService: WorkspaceVersionService,
- protected readonly coreMigrationRunnerService: CoreMigrationRunnerService,
- protected readonly versionedMigrationRegistryService: RegisteredCoreMigrationService,
+ protected readonly registeredInstanceMigrationService: RegisteredInstanceMigrationService,
+ protected readonly instanceUpgradeService: InstanceUpgradeService,
protected readonly workspaceIteratorService: WorkspaceIteratorService,
+ protected readonly workspaceUpgradeService: WorkspaceUpgradeService,
+ @InjectDataSource()
+ protected readonly dataSource: DataSource,
// 1.20 Commands
private readonly identifyPermissionFlagMetadataCommand: IdentifyPermissionFlagMetadataCommand,
@@ -88,12 +89,13 @@ export class UpgradeCommand extends UpgradeCommandRunner {
private readonly migrateMessageFolderParentIdToExternalIdCommand: MigrateMessageFolderParentIdToExternalIdCommand,
) {
super(
- workspaceRepository,
coreEngineVersionService,
workspaceVersionService,
- coreMigrationRunnerService,
- versionedMigrationRegistryService,
+ registeredInstanceMigrationService,
+ instanceUpgradeService,
workspaceIteratorService,
+ workspaceUpgradeService,
+ dataSource,
);
const commands_1200: VersionCommands = [
diff --git a/packages/twenty-server/src/database/typeorm/core/decorators/registered-core-migration.decorator.ts b/packages/twenty-server/src/database/typeorm/core/decorators/registered-core-migration.decorator.ts
deleted file mode 100644
index 9a70d02299..0000000000
--- a/packages/twenty-server/src/database/typeorm/core/decorators/registered-core-migration.decorator.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import 'reflect-metadata';
-
-import { type UpgradeCommandVersion } from 'src/engine/constants/upgrade-command-supported-versions.constant';
-
-const REGISTERED_CORE_MIGRATION_KEY = 'REGISTERED_CORE_MIGRATION_VERSION';
-
-// When dropping a version from UPGRADE_COMMAND_SUPPORTED_VERSIONS, also
-// remove the @RegisteredCoreMigration decorator from its associated migration files.
-export const RegisteredCoreMigration =
- (version: UpgradeCommandVersion): ClassDecorator =>
- (target) => {
- Reflect.defineMetadata(REGISTERED_CORE_MIGRATION_KEY, version, target);
- };
-
-export const getRegisteredCoreMigrationVersion = (
- target: Function,
-): UpgradeCommandVersion | undefined =>
- Reflect.getMetadata(REGISTERED_CORE_MIGRATION_KEY, target);
diff --git a/packages/twenty-server/src/database/typeorm/core/decorators/registered-instance-migration.decorator.ts b/packages/twenty-server/src/database/typeorm/core/decorators/registered-instance-migration.decorator.ts
new file mode 100644
index 0000000000..b7d9f5057a
--- /dev/null
+++ b/packages/twenty-server/src/database/typeorm/core/decorators/registered-instance-migration.decorator.ts
@@ -0,0 +1,31 @@
+import 'reflect-metadata';
+
+import { Injectable } from '@nestjs/common';
+
+import { type UpgradeCommandVersion } from 'src/engine/constants/upgrade-command-supported-versions.constant';
+
+export type RegisteredInstanceMigrationMetadata = {
+ version: UpgradeCommandVersion;
+ timestamp: number;
+};
+
+const REGISTERED_INSTANCE_MIGRATION_KEY = 'REGISTERED_INSTANCE_MIGRATION';
+
+// When dropping a version from UPGRADE_COMMAND_SUPPORTED_VERSIONS, also
+// remove the @RegisteredInstanceMigration decorator from its associated
+// migration files.
+export const RegisteredInstanceMigration =
+ (version: UpgradeCommandVersion, timestamp: number): ClassDecorator =>
+ (target) => {
+ Injectable()(target);
+ Reflect.defineMetadata(
+ REGISTERED_INSTANCE_MIGRATION_KEY,
+ { version, timestamp },
+ target,
+ );
+ };
+
+export const getRegisteredInstanceMigrationMetadata = (
+ target: Function,
+): RegisteredInstanceMigrationMetadata | undefined =>
+ Reflect.getMetadata(REGISTERED_INSTANCE_MIGRATION_KEY, target);
diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1775487231605-add-upgrade-migrations-table.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1775487231605-add-upgrade-migrations-table.ts
new file mode 100644
index 0000000000..7dd9545205
--- /dev/null
+++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1775487231605-add-upgrade-migrations-table.ts
@@ -0,0 +1,17 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddUpgradeMigrationsTable1775487231605
+ implements MigrationInterface
+{
+ name = 'AddUpgradeMigrationsTable1775487231605';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ 'CREATE TABLE "core"."upgradeMigration" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "status" character varying NOT NULL, "attempt" integer NOT NULL DEFAULT \'1\', "executedByVersion" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_upgrade_migration_name_attempt" UNIQUE ("name", "attempt"), CONSTRAINT "PK_a43ea44de07f51fdc55b88af2ad" PRIMARY KEY ("id"))',
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('DROP TABLE "core"."upgradeMigration"');
+ }
+}
diff --git a/packages/twenty-server/src/database/commands/core-migration/__tests__/versioned-migration-registry.service.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/registered-instance-migration-registry.service.spec.ts
similarity index 75%
rename from packages/twenty-server/src/database/commands/core-migration/__tests__/versioned-migration-registry.service.spec.ts
rename to packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/registered-instance-migration-registry.service.spec.ts
index ce02df1e90..b9a61c926a 100644
--- a/packages/twenty-server/src/database/commands/core-migration/__tests__/versioned-migration-registry.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/__tests__/registered-instance-migration-registry.service.spec.ts
@@ -1,14 +1,14 @@
import 'reflect-metadata';
import { Test } from '@nestjs/testing';
-import { getDataSourceToken } from '@nestjs/typeorm';
+import { DiscoveryService } from '@nestjs/core';
import { type MigrationInterface } from 'typeorm';
-import { RegisteredCoreMigrationService } from 'src/database/commands/core-migration/services/registered-core-migration-registry.service';
-import { RegisteredCoreMigration } from 'src/database/typeorm/core/decorators/registered-core-migration.decorator';
+import { RegisteredInstanceMigrationService } from 'src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service';
+import { RegisteredInstanceMigration } from 'src/database/typeorm/core/decorators/registered-instance-migration.decorator';
-@RegisteredCoreMigration('1.21.0')
+@RegisteredInstanceMigration('1.21.0', 1770000000000)
class MigrationA1770000000000 implements MigrationInterface {
name = 'MigrationA1770000000000';
@@ -16,7 +16,7 @@ class MigrationA1770000000000 implements MigrationInterface {
async down(): Promise {}
}
-@RegisteredCoreMigration('1.21.0')
+@RegisteredInstanceMigration('1.21.0', 1771000000000)
class MigrationB1771000000000 implements MigrationInterface {
name = 'MigrationB1771000000000';
@@ -24,7 +24,7 @@ class MigrationB1771000000000 implements MigrationInterface {
async down(): Promise {}
}
-@RegisteredCoreMigration('1.21.0')
+@RegisteredInstanceMigration('1.21.0', 1772000000000)
class MigrationC1772000000000 implements MigrationInterface {
name = 'MigrationC1772000000000';
@@ -32,7 +32,7 @@ class MigrationC1772000000000 implements MigrationInterface {
async down(): Promise {}
}
-@RegisteredCoreMigration('1.20.0')
+@RegisteredInstanceMigration('1.20.0', 1769000000000)
class MigrationD1769000000000 implements MigrationInterface {
name = 'MigrationD1769000000000';
@@ -47,27 +47,34 @@ class UndecoratedMigration1768000000000 implements MigrationInterface {
async down(): Promise {}
}
+const buildProviderWrapper = (migration: MigrationInterface) => ({
+ instance: migration,
+ metatype: migration.constructor,
+});
+
const buildRegistryService = async (
migrations: MigrationInterface[],
-): Promise => {
+): Promise => {
const module = await Test.createTestingModule({
providers: [
- RegisteredCoreMigrationService,
+ RegisteredInstanceMigrationService,
{
- provide: getDataSourceToken(),
- useValue: { migrations },
+ provide: DiscoveryService,
+ useValue: {
+ getProviders: () => migrations.map(buildProviderWrapper),
+ },
},
],
}).compile();
- const service = module.get(RegisteredCoreMigrationService);
+ const service = module.get(RegisteredInstanceMigrationService);
service.onModuleInit();
return service;
};
-describe('VersionedMigrationRegistryService', () => {
+describe('RegisteredInstanceMigrationService', () => {
it('should group migrations by version', async () => {
const service = await buildRegistryService([
new MigrationD1769000000000(),
@@ -90,10 +97,10 @@ describe('VersionedMigrationRegistryService', () => {
]);
});
- it('should preserve input order within a version bucket', async () => {
+ it('should sort migrations by timestamp within a version bucket', async () => {
const service = await buildRegistryService([
- new MigrationA1770000000000(),
new MigrationC1772000000000(),
+ new MigrationA1770000000000(),
new MigrationB1771000000000(),
]);
@@ -103,8 +110,8 @@ describe('VersionedMigrationRegistryService', () => {
expect(names).toStrictEqual([
'MigrationA1770000000000',
- 'MigrationC1772000000000',
'MigrationB1771000000000',
+ 'MigrationC1772000000000',
]);
});
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/instance-upgrade.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/instance-upgrade.service.ts
new file mode 100644
index 0000000000..e9b6709bae
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/instance-upgrade.service.ts
@@ -0,0 +1,121 @@
+import { Injectable } from '@nestjs/common';
+import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
+
+import {
+ DataSource,
+ MigrationInterface,
+ type QueryRunner,
+ Repository,
+} from 'typeorm';
+
+import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+import {
+ UpgradeMigrationEntity,
+ type UpgradeMigrationStatus,
+} from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
+
+export type RunSingleMigrationResult =
+ | { status: 'success' }
+ | { status: 'already-executed' }
+ | { status: 'failed'; error: unknown };
+
+@Injectable()
+export class InstanceUpgradeService {
+ constructor(
+ @InjectRepository(UpgradeMigrationEntity)
+ private readonly upgradeMigrationRepository: Repository,
+ @InjectDataSource()
+ private readonly dataSource: DataSource,
+ private readonly twentyConfigService: TwentyConfigService,
+ ) {}
+
+ async runSingleMigration(
+ migration: MigrationInterface,
+ ): Promise {
+ const migrationName = migration.constructor.name;
+ const executedByVersion =
+ this.twentyConfigService.get('APP_VERSION') ?? 'unknown';
+
+ const isAlreadyExecuted = await this.upgradeMigrationRepository.exists({
+ where: { name: migrationName, status: 'completed' },
+ });
+
+ if (isAlreadyExecuted) {
+ return { status: 'already-executed' };
+ }
+
+ const queryRunner = this.dataSource.createQueryRunner();
+
+ try {
+ await queryRunner.connect();
+ await queryRunner.startTransaction();
+
+ await migration.up(queryRunner);
+
+ await this.markAsCompleted({
+ queryRunner,
+ name: migrationName,
+ executedByVersion,
+ });
+
+ await queryRunner.commitTransaction();
+ } catch (error) {
+ if (queryRunner.isTransactionActive) {
+ await queryRunner.rollbackTransaction();
+ }
+
+ await this.markFailed({
+ name: migrationName,
+ executedByVersion,
+ });
+
+ return { status: 'failed', error };
+ } finally {
+ await queryRunner.release();
+ }
+
+ return { status: 'success' };
+ }
+
+ private async markAsCompleted({
+ queryRunner,
+ name,
+ executedByVersion,
+ }: {
+ queryRunner: QueryRunner;
+ name: string;
+ executedByVersion: string;
+ }): Promise {
+ const repository = queryRunner.manager.getRepository(
+ UpgradeMigrationEntity,
+ );
+
+ const previousAttempts = await repository.count({ where: { name } });
+
+ await repository.save({
+ name,
+ status: 'completed' as UpgradeMigrationStatus,
+ attempt: previousAttempts + 1,
+ executedByVersion,
+ });
+ }
+
+ private async markFailed({
+ name,
+ executedByVersion,
+ }: {
+ name: string;
+ executedByVersion: string;
+ }): Promise {
+ const previousAttempts = await this.upgradeMigrationRepository.count({
+ where: { name },
+ });
+
+ await this.upgradeMigrationRepository.save({
+ name,
+ status: 'failed' as UpgradeMigrationStatus,
+ attempt: previousAttempts + 1,
+ executedByVersion,
+ });
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service.ts
new file mode 100644
index 0000000000..f2f0e5929e
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service.ts
@@ -0,0 +1,100 @@
+import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
+import { DiscoveryService } from '@nestjs/core';
+
+import { type MigrationInterface } from 'typeorm';
+
+import { getRegisteredInstanceMigrationMetadata } from 'src/database/typeorm/core/decorators/registered-instance-migration.decorator';
+import {
+ UPGRADE_COMMAND_SUPPORTED_VERSIONS,
+ type UpgradeCommandVersion,
+} from 'src/engine/constants/upgrade-command-supported-versions.constant';
+
+type TimestampedMigration = {
+ migration: MigrationInterface;
+ timestamp: number;
+};
+
+@Injectable()
+export class RegisteredInstanceMigrationService implements OnModuleInit {
+ private readonly logger = new Logger(RegisteredInstanceMigrationService.name);
+
+ private readonly migrationsByVersion = new Map<
+ UpgradeCommandVersion,
+ TimestampedMigration[]
+ >();
+
+ constructor(private readonly discoveryService: DiscoveryService) {}
+
+ onModuleInit(): void {
+ for (const version of UPGRADE_COMMAND_SUPPORTED_VERSIONS) {
+ this.migrationsByVersion.set(version, []);
+ }
+
+ const providers = this.discoveryService.getProviders();
+
+ for (const wrapper of providers) {
+ const { instance, metatype } = wrapper;
+
+ if (!instance || !metatype) {
+ continue;
+ }
+
+ const metadata = getRegisteredInstanceMigrationMetadata(metatype);
+
+ if (metadata === undefined) {
+ continue;
+ }
+
+ const bucket = this.migrationsByVersion.get(metadata.version);
+
+ if (!bucket) {
+ continue;
+ }
+
+ bucket.push({
+ migration: instance as MigrationInterface,
+ timestamp: metadata.timestamp,
+ });
+ }
+
+ for (const [, bucket] of this.migrationsByVersion) {
+ bucket.sort((entryA, entryB) => entryA.timestamp - entryB.timestamp);
+ }
+
+ for (const [version, bucket] of this.migrationsByVersion) {
+ if (bucket.length > 0) {
+ this.logger.log(
+ `Registered ${bucket.length} versioned migration(s) for ${version}: ${bucket.map((entry) => entry.migration.constructor.name).join(', ')}`,
+ );
+ }
+ }
+ }
+
+ getInstanceCommandsForVersion(
+ version: UpgradeCommandVersion,
+ ): MigrationInterface[] {
+ return (this.migrationsByVersion.get(version) ?? []).map(
+ (entry) => entry.migration,
+ );
+ }
+
+ getAllInstanceCommands(): {
+ version: UpgradeCommandVersion;
+ migration: MigrationInterface;
+ }[] {
+ const result: {
+ version: UpgradeCommandVersion;
+ migration: MigrationInterface;
+ }[] = [];
+
+ for (const version of UPGRADE_COMMAND_SUPPORTED_VERSIONS) {
+ const bucket = this.migrationsByVersion.get(version) ?? [];
+
+ for (const entry of bucket) {
+ result.push({ version, migration: entry.migration });
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/workspace-upgrade.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/workspace-upgrade.service.ts
new file mode 100644
index 0000000000..7f385d6281
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/workspace-upgrade.service.ts
@@ -0,0 +1,115 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+
+import { SemVer } from 'semver';
+import { assertUnreachable, isDefined } from 'twenty-shared/utils';
+import { Repository } from 'typeorm';
+
+import { type WorkspaceIteratorContext } from 'src/database/commands/command-runners/workspace-iterator.service';
+import {
+ type UpgradeCommandOptions,
+ type VersionCommands,
+} from 'src/database/commands/command-runners/upgrade.command-runner';
+import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
+import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+import {
+ type CompareVersionMajorAndMinorReturnType,
+ compareVersionMajorAndMinor,
+} from 'src/utils/version/compare-version-minor-and-major';
+
+export type UpgradeWorkspaceArgs = {
+ iteratorContext: WorkspaceIteratorContext;
+ options: UpgradeCommandOptions;
+ fromWorkspaceVersion: SemVer;
+ currentAppVersion: SemVer;
+ workspaceCommands: VersionCommands;
+};
+
+@Injectable()
+export class WorkspaceUpgradeService {
+ private readonly logger = new Logger(WorkspaceUpgradeService.name);
+
+ constructor(
+ @InjectRepository(WorkspaceEntity)
+ private readonly workspaceRepository: Repository,
+ ) {}
+
+ async upgradeWorkspace({
+ iteratorContext,
+ options,
+ fromWorkspaceVersion,
+ currentAppVersion,
+ workspaceCommands,
+ }: UpgradeWorkspaceArgs): Promise {
+ const { workspaceId, index, total } = iteratorContext;
+
+ this.logger.log(
+ `${options.dryRun ? '(dry run) ' : ''}Upgrading workspace ${workspaceId} from=${fromWorkspaceVersion} to=${currentAppVersion} ${index + 1}/${total}`,
+ );
+
+ const versionCompareResult =
+ await this.compareWorkspaceVersionToFromVersion(
+ workspaceId,
+ fromWorkspaceVersion,
+ );
+
+ switch (versionCompareResult) {
+ case 'lower': {
+ throw new Error(
+ `WORKSPACE_VERSION_MISMATCH Upgrade for workspace ${workspaceId} failed as its version is beneath fromWorkspaceVersion=${fromWorkspaceVersion.version}`,
+ );
+ }
+ case 'equal': {
+ for (const workspaceCommand of workspaceCommands) {
+ await workspaceCommand.runOnWorkspace({
+ options: options as RunOnWorkspaceArgs['options'],
+ workspaceId,
+ dataSource: iteratorContext.dataSource,
+ index,
+ total,
+ });
+ }
+
+ if (!options.dryRun) {
+ await this.workspaceRepository.update(
+ { id: workspaceId },
+ { version: currentAppVersion.version },
+ );
+ }
+
+ this.logger.log(`Upgrade for workspace ${workspaceId} completed.`);
+
+ return;
+ }
+ case 'higher': {
+ this.logger.log(
+ `Upgrade for workspace ${workspaceId} ignored as is already at a higher version.`,
+ );
+
+ return;
+ }
+ default: {
+ assertUnreachable(versionCompareResult);
+ }
+ }
+ }
+
+ private async compareWorkspaceVersionToFromVersion(
+ workspaceId: string,
+ fromWorkspaceVersion: SemVer,
+ ): Promise {
+ const workspace = await this.workspaceRepository.findOneByOrFail({
+ id: workspaceId,
+ });
+ const currentWorkspaceVersion = workspace.version;
+
+ if (!isDefined(currentWorkspaceVersion)) {
+ throw new Error(`WORKSPACE_VERSION_NOT_DEFINED workspace=${workspaceId}`);
+ }
+
+ return compareVersionMajorAndMinor(
+ currentWorkspaceVersion,
+ fromWorkspaceVersion.version,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/upgrade-migration.entity.ts b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade-migration.entity.ts
new file mode 100644
index 0000000000..f68669fdcf
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade-migration.entity.ts
@@ -0,0 +1,31 @@
+import {
+ Column,
+ CreateDateColumn,
+ Entity,
+ PrimaryGeneratedColumn,
+ Unique,
+} from 'typeorm';
+
+export type UpgradeMigrationStatus = 'completed' | 'failed';
+
+@Entity({ name: 'upgradeMigration', schema: 'core' })
+@Unique('UQ_upgrade_migration_name_attempt', ['name', 'attempt'])
+export class UpgradeMigrationEntity {
+ @PrimaryGeneratedColumn('uuid')
+ id: string;
+
+ @Column({ type: 'varchar', nullable: false })
+ name: string;
+
+ @Column({ type: 'varchar', nullable: false })
+ status: UpgradeMigrationStatus;
+
+ @Column({ type: 'integer', nullable: false, default: 1 })
+ attempt: number;
+
+ @Column({ type: 'varchar', nullable: false })
+ executedByVersion: string;
+
+ @CreateDateColumn({ type: 'timestamptz' })
+ createdAt: Date;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts
new file mode 100644
index 0000000000..4b4fd8a439
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts
@@ -0,0 +1,27 @@
+import { Module } from '@nestjs/common';
+import { DiscoveryModule } from '@nestjs/core';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
+import { InstanceUpgradeService } from 'src/engine/core-modules/upgrade/services/instance-upgrade.service';
+import { RegisteredInstanceMigrationService } from 'src/engine/core-modules/upgrade/services/registered-instance-migration-registry.service';
+import { WorkspaceUpgradeService } from 'src/engine/core-modules/upgrade/services/workspace-upgrade.service';
+import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+
+@Module({
+ imports: [
+ DiscoveryModule,
+ TypeOrmModule.forFeature([UpgradeMigrationEntity, WorkspaceEntity]),
+ ],
+ providers: [
+ InstanceUpgradeService,
+ WorkspaceUpgradeService,
+ RegisteredInstanceMigrationService,
+ ],
+ exports: [
+ InstanceUpgradeService,
+ WorkspaceUpgradeService,
+ RegisteredInstanceMigrationService,
+ ],
+})
+export class UpgradeModule {}