From 1c9fc94c1fa100a29b8a59a311ecccd7a03b853a Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:15:06 +0200 Subject: [PATCH] Workspace commands writes in`upgradeMigration` (#19379) # Introduction As for the instance commands we want to keep a track of what has been run for the workspace commands Note that the history will be updated only when the workspace command has been run through the upgrade directly and not when run atomically ## What's next Later we will use this history in order to determine the current workspace's version and instance's version getting rid of the version in database, that will be the last stone --- ...8-add-workspace-id-to-upgrade-migration.ts | 43 +++++++++ .../services/instance-upgrade.service.ts | 77 ++++------------ .../services/upgrade-migration.service.ts | 88 +++++++++++++++++++ .../services/workspace-upgrade.service.ts | 78 ++++++++++++++-- .../upgrade/upgrade-migration.entity.ts | 23 ++++- .../core-modules/upgrade/upgrade.module.ts | 5 +- 6 files changed, 242 insertions(+), 72 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1775553825848-add-workspace-id-to-upgrade-migration.ts create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1775553825848-add-workspace-id-to-upgrade-migration.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1775553825848-add-workspace-id-to-upgrade-migration.ts new file mode 100644 index 0000000000..ebd68d0958 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1775553825848-add-workspace-id-to-upgrade-migration.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWorkspaceIdToUpgradeMigration1775553825848 + implements MigrationInterface +{ + name = 'AddWorkspaceIdToUpgradeMigration1775553825848'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "core"."upgradeMigration" DROP CONSTRAINT "UQ_upgrade_migration_name_attempt"', + ); + await queryRunner.query( + 'ALTER TABLE "core"."upgradeMigration" ADD "workspaceId" uuid', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX "UQ_upgrade_migration_workspace" ON "core"."upgradeMigration" ("name", "attempt", "workspaceId") WHERE "workspaceId" IS NOT NULL', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX "UQ_upgrade_migration_instance" ON "core"."upgradeMigration" ("name", "attempt") WHERE "workspaceId" IS NULL', + ); + await queryRunner.query( + 'ALTER TABLE "core"."upgradeMigration" ADD CONSTRAINT "FK_77f64a697c55f8802592bd7eeba" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "core"."upgradeMigration" DROP CONSTRAINT "FK_77f64a697c55f8802592bd7eeba"', + ); + await queryRunner.query( + 'DROP INDEX "core"."UQ_upgrade_migration_instance"', + ); + await queryRunner.query( + 'DROP INDEX "core"."UQ_upgrade_migration_workspace"', + ); + await queryRunner.query( + 'ALTER TABLE "core"."upgradeMigration" DROP COLUMN "workspaceId"', + ); + await queryRunner.query( + 'ALTER TABLE "core"."upgradeMigration" ADD CONSTRAINT "UQ_upgrade_migration_name_attempt" UNIQUE ("name", "attempt")', + ); + } +} 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 index e9b6709bae..a5dfcdd16c 100644 --- 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 @@ -1,18 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; -import { - DataSource, - MigrationInterface, - type QueryRunner, - Repository, -} from 'typeorm'; +import { DataSource, MigrationInterface } 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'; +import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; export type RunSingleMigrationResult = | { status: 'success' } @@ -22,11 +14,10 @@ export type RunSingleMigrationResult = @Injectable() export class InstanceUpgradeService { constructor( - @InjectRepository(UpgradeMigrationEntity) - private readonly upgradeMigrationRepository: Repository, @InjectDataSource() private readonly dataSource: DataSource, private readonly twentyConfigService: TwentyConfigService, + private readonly upgradeMigrationService: UpgradeMigrationService, ) {} async runSingleMigration( @@ -36,11 +27,13 @@ export class InstanceUpgradeService { const executedByVersion = this.twentyConfigService.get('APP_VERSION') ?? 'unknown'; - const isAlreadyExecuted = await this.upgradeMigrationRepository.exists({ - where: { name: migrationName, status: 'completed' }, - }); + const isAlreadyCompleted = + await this.upgradeMigrationService.isLastAttemptCompleted({ + name: migrationName, + workspaceId: null, + }); - if (isAlreadyExecuted) { + if (isAlreadyCompleted) { return { status: 'already-executed' }; } @@ -52,10 +45,11 @@ export class InstanceUpgradeService { await migration.up(queryRunner); - await this.markAsCompleted({ - queryRunner, + await this.upgradeMigrationService.markAsCompleted({ name: migrationName, + workspaceId: null, executedByVersion, + queryRunner, }); await queryRunner.commitTransaction(); @@ -64,8 +58,9 @@ export class InstanceUpgradeService { await queryRunner.rollbackTransaction(); } - await this.markFailed({ + await this.upgradeMigrationService.markAsFailed({ name: migrationName, + workspaceId: null, executedByVersion, }); @@ -76,46 +71,4 @@ export class InstanceUpgradeService { 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/upgrade-migration.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts new file mode 100644 index 0000000000..2f5b22a0d5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-migration.service.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, type QueryRunner, Repository } from 'typeorm'; + +import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; + +@Injectable() +export class UpgradeMigrationService { + constructor( + @InjectRepository(UpgradeMigrationEntity) + private readonly upgradeMigrationRepository: Repository, + ) {} + + async isLastAttemptCompleted({ + name, + workspaceId, + }: { + name: string; + workspaceId: string | null; + }): Promise { + const latestAttempt = await this.upgradeMigrationRepository.findOne({ + where: { + name, + workspaceId: workspaceId === null ? IsNull() : workspaceId, + }, + order: { attempt: 'DESC' }, + }); + + return isDefined(latestAttempt) && latestAttempt.status === 'completed'; + } + + async markAsCompleted({ + name, + workspaceId, + executedByVersion, + queryRunner, + }: { + name: string; + workspaceId: string | null; + executedByVersion: string; + queryRunner?: QueryRunner; + }): Promise { + const repository = queryRunner + ? queryRunner.manager.getRepository(UpgradeMigrationEntity) + : this.upgradeMigrationRepository; + const previousAttempts = await repository.count({ + where: { + name, + workspaceId: workspaceId === null ? IsNull() : workspaceId, + }, + }); + + await repository.save({ + name, + status: 'completed', + attempt: previousAttempts + 1, + executedByVersion, + workspaceId, + }); + } + + async markAsFailed({ + name, + workspaceId, + executedByVersion, + }: { + name: string; + workspaceId: string | null; + executedByVersion: string; + }): Promise { + const previousAttempts = await this.upgradeMigrationRepository.count({ + where: { + name, + workspaceId: workspaceId === null ? IsNull() : workspaceId, + }, + }); + + await this.upgradeMigrationRepository.save({ + name, + status: 'failed', + attempt: previousAttempts + 1, + executedByVersion, + workspaceId, + }); + } +} 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 index 7f385d6281..80828fa649 100644 --- 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 @@ -5,12 +5,13 @@ 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 { type WorkspaceIteratorContext } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { type CompareVersionMajorAndMinorReturnType, @@ -32,6 +33,8 @@ export class WorkspaceUpgradeService { constructor( @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, + private readonly twentyConfigService: TwentyConfigService, + private readonly upgradeMigrationService: UpgradeMigrationService, ) {} async upgradeWorkspace({ @@ -60,13 +63,16 @@ export class WorkspaceUpgradeService { ); } case 'equal': { + const executedByVersion = + this.twentyConfigService.get('APP_VERSION') ?? 'unknown'; + for (const workspaceCommand of workspaceCommands) { - await workspaceCommand.runOnWorkspace({ - options: options as RunOnWorkspaceArgs['options'], + await this.runSingleWorkspaceCommandOrThrow({ + workspaceCommand, workspaceId, - dataSource: iteratorContext.dataSource, - index, - total, + executedByVersion, + options, + iteratorContext, }); } @@ -112,4 +118,62 @@ export class WorkspaceUpgradeService { fromWorkspaceVersion.version, ); } + + private async runSingleWorkspaceCommandOrThrow({ + workspaceCommand, + workspaceId, + executedByVersion, + options, + iteratorContext, + }: { + workspaceCommand: VersionCommands[number]; + workspaceId: string; + executedByVersion: string; + options: UpgradeCommandOptions; + iteratorContext: WorkspaceIteratorContext; + }): Promise { + const commandName = workspaceCommand.constructor.name; + + const isAlreadyCompleted = + await this.upgradeMigrationService.isLastAttemptCompleted({ + name: commandName, + workspaceId, + }); + + if (isAlreadyCompleted) { + this.logger.log( + `Workspace command ${commandName} already completed for workspace ${workspaceId}, skipping`, + ); + + return; + } + + try { + await workspaceCommand.runOnWorkspace({ + options, + workspaceId, + dataSource: iteratorContext.dataSource, + index: iteratorContext.index, + total: iteratorContext.total, + }); + + if (!options.dryRun) { + await this.upgradeMigrationService.markAsCompleted({ + name: commandName, + workspaceId, + executedByVersion, + }); + } + } catch (error) { + if (!options.dryRun) { + await this.upgradeMigrationService.markAsFailed({ + name: commandName, + workspaceId, + executedByVersion, + }); + } + + throw error; + } + } } 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 index f68669fdcf..e0391b2399 100644 --- 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 @@ -2,14 +2,26 @@ import { Column, CreateDateColumn, Entity, + Index, + JoinColumn, + ManyToOne, PrimaryGeneratedColumn, - Unique, + type Relation, } from 'typeorm'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + export type UpgradeMigrationStatus = 'completed' | 'failed'; @Entity({ name: 'upgradeMigration', schema: 'core' }) -@Unique('UQ_upgrade_migration_name_attempt', ['name', 'attempt']) +@Index('UQ_upgrade_migration_instance', ['name', 'attempt'], { + unique: true, + where: '"workspaceId" IS NULL', +}) +@Index('UQ_upgrade_migration_workspace', ['name', 'attempt', 'workspaceId'], { + unique: true, + where: '"workspaceId" IS NOT NULL', +}) export class UpgradeMigrationEntity { @PrimaryGeneratedColumn('uuid') id: string; @@ -26,6 +38,13 @@ export class UpgradeMigrationEntity { @Column({ type: 'varchar', nullable: false }) executedByVersion: string; + @ManyToOne(() => WorkspaceEntity, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation | null; + + @Column({ type: 'uuid', nullable: true }) + workspaceId: string | null; + @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 index 4b4fd8a439..6fab11052b 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts @@ -2,10 +2,11 @@ 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 { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; import { WorkspaceUpgradeService } from 'src/engine/core-modules/upgrade/services/workspace-upgrade.service'; +import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @Module({ @@ -14,11 +15,13 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent TypeOrmModule.forFeature([UpgradeMigrationEntity, WorkspaceEntity]), ], providers: [ + UpgradeMigrationService, InstanceUpgradeService, WorkspaceUpgradeService, RegisteredInstanceMigrationService, ], exports: [ + UpgradeMigrationService, InstanceUpgradeService, WorkspaceUpgradeService, RegisteredInstanceMigrationService,