diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-upgrade-version-command.module.ts new file mode 100644 index 0000000000..e5631e6beb --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-upgrade-version-command.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; +import { NormalizeLegacyIndexNamesCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command'; +import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; + +@Module({ + imports: [ + WorkspaceCacheModule, + WorkspaceIteratorModule, + WorkspaceSchemaManagerModule, + ], + providers: [NormalizeLegacyIndexNamesCommand], +}) +export class V2_18_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command.ts new file mode 100644 index 0000000000..010c626f3e --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command.ts @@ -0,0 +1,206 @@ +import { Command } from 'nest-commander'; +import { isDefined } from 'twenty-shared/utils'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { + type FlatIndexNameStatus, + planIndexNameNormalization, +} from 'src/database/commands/upgrade-version-command/2-18/utils/plan-index-name-normalization.util'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { findManyFlatEntityByUniversalIdentifierInUniversalFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-universal-identifier-in-universal-flat-entity-maps-or-throw.util'; +import { generateFlatIndexMetadataWithNameOrThrow } from 'src/engine/metadata-modules/index-metadata/utils/generate-flat-index.util'; +import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; +import { + deleteIndexMetadata, + dropIndexFromWorkspaceSchema, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils'; + +@RegisteredWorkspaceCommand('2.18.0', 1799200000000) +@Command({ + name: 'upgrade:2-18:normalize-legacy-index-names', + description: + 'Rename indexes whose stored name predates the v2 deterministic naming convention to their recomputed name, and drop redundant duplicates.', +}) +export class NormalizeLegacyIndexNamesCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceSchemaManagerService: WorkspaceSchemaManagerService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + dataSource, + options, + }: RunOnWorkspaceArgs): Promise { + if (!isDefined(dataSource)) { + this.logger.log(`No data source for workspace ${workspaceId}, skipping`); + + return; + } + + const { flatIndexMaps, flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatIndexMaps', + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + ]); + + const indexStatuses: FlatIndexNameStatus[] = []; + + for (const flatObjectMetadata of Object.values( + flatObjectMetadataMaps.byUniversalIdentifier, + )) { + if (!isDefined(flatObjectMetadata)) { + continue; + } + + const objectIndexes = + findManyFlatEntityByUniversalIdentifierInUniversalFlatEntityMapsOrThrow( + { + flatEntityMaps: flatIndexMaps, + universalIdentifiers: + flatObjectMetadata.indexMetadataUniversalIdentifiers, + }, + ); + + if (objectIndexes.length === 0) { + continue; + } + + const objectFlatFieldMetadatas = + findManyFlatEntityByUniversalIdentifierInUniversalFlatEntityMapsOrThrow( + { + flatEntityMaps: flatFieldMetadataMaps, + universalIdentifiers: flatObjectMetadata.fieldUniversalIdentifiers, + }, + ); + + for (const flatIndex of objectIndexes) { + try { + const expectedName = generateFlatIndexMetadataWithNameOrThrow({ + flatObjectMetadata, + objectFlatFieldMetadatas, + flatIndex, + }).name; + + indexStatuses.push({ + indexMetadataId: flatIndex.id, + objectMetadataId: flatIndex.objectMetadataId, + currentName: flatIndex.name, + expectedName, + }); + } catch (error) { + this.logger.warn( + `Could not recompute expected name for index ${flatIndex.name} (${flatIndex.id}) in workspace ${workspaceId}, skipping: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + const operations = planIndexNameNormalization(indexStatuses); + + if (operations.length === 0) { + this.logger.log( + `No legacy index names to normalize for workspace ${workspaceId}, skipping`, + ); + + return; + } + + if (options.dryRun) { + for (const operation of operations) { + if (operation.type === 'rename') { + this.logger.log( + `[DRY RUN] Would rename index ${operation.fromName} -> ${operation.toName} (workspace ${workspaceId})`, + ); + } else { + this.logger.log( + `[DRY RUN] Would drop redundant duplicate index ${operation.redundantName} (kept ${operation.keptName}) (workspace ${workspaceId})`, + ); + } + } + + return; + } + + const schemaName = getWorkspaceSchemaName(workspaceId); + const queryRunner = dataSource.createQueryRunner(); + let isQueryRunnerConnected = false; + let isTransactionStarted = false; + + try { + await queryRunner.connect(); + isQueryRunnerConnected = true; + + await queryRunner.startTransaction(); + isTransactionStarted = true; + + for (const operation of operations) { + if (operation.type === 'rename') { + await this.workspaceSchemaManagerService.indexManager.renameIndexWithoutRebuild( + { + queryRunner, + schemaName, + fromIndexName: operation.fromName, + toIndexName: operation.toName, + }, + ); + + await queryRunner.query( + `UPDATE "core"."indexMetadata" + SET "name" = $1 + WHERE "id" = $2 + AND "workspaceId" = $3`, + [operation.toName, operation.indexMetadataId, workspaceId], + ); + + this.logger.log( + `Renamed index ${operation.fromName} -> ${operation.toName} (workspace ${workspaceId})`, + ); + } else { + await dropIndexFromWorkspaceSchema({ + indexName: operation.redundantName, + workspaceSchemaManagerService: this.workspaceSchemaManagerService, + queryRunner, + schemaName, + }); + + await deleteIndexMetadata({ + entityId: operation.indexMetadataId, + queryRunner, + workspaceId, + }); + + this.logger.log( + `Dropped redundant duplicate index ${operation.redundantName} (kept ${operation.keptName}) (workspace ${workspaceId})`, + ); + } + } + + await queryRunner.commitTransaction(); + } catch (error) { + if (isTransactionStarted) { + await queryRunner.rollbackTransaction(); + } + + throw error; + } finally { + if (isQueryRunnerConnected) { + await queryRunner.release(); + } + } + + await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [ + 'flatIndexMaps', + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + ]); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/__tests__/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/__tests__/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command.spec.ts new file mode 100644 index 0000000000..cbbc207a0e --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/__tests__/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command.spec.ts @@ -0,0 +1,292 @@ +import { type DataSource, type QueryRunner } from 'typeorm'; + +import { NormalizeLegacyIndexNamesCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command'; +import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { type WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service'; +import { generateFlatIndexMetadataWithNameOrThrow } from 'src/engine/metadata-modules/index-metadata/utils/generate-flat-index.util'; +import { + deleteIndexMetadata, + dropIndexFromWorkspaceSchema, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils'; + +jest.mock( + 'src/engine/metadata-modules/index-metadata/utils/generate-flat-index.util', +); +jest.mock( + 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils', +); + +const generateFlatIndexMetadataWithNameOrThrowMock = + generateFlatIndexMetadataWithNameOrThrow as jest.Mock; +const deleteIndexMetadataMock = deleteIndexMetadata as jest.Mock; +const dropIndexFromWorkspaceSchemaMock = dropIndexFromWorkspaceSchema as jest.Mock; + +const WORKSPACE_ID = '20202020-0000-0000-0000-000000000001'; + +type IndexFixture = { + universalIdentifier: string; + id: string; + name: string; + expectedName: string; +}; + +const buildFlatEntityMaps = (indexes: IndexFixture[]) => { + const flatIndexMaps = { + byUniversalIdentifier: Object.fromEntries( + indexes.map((index) => [ + index.universalIdentifier, + { + universalIdentifier: index.universalIdentifier, + id: index.id, + name: index.name, + objectMetadataId: 'object-1', + }, + ]), + ), + }; + + const flatObjectMetadataMaps = { + byUniversalIdentifier: { + 'object-uid': { + universalIdentifier: 'object-uid', + nameSingular: 'myObject', + indexMetadataUniversalIdentifiers: indexes.map( + (index) => index.universalIdentifier, + ), + fieldUniversalIdentifiers: [], + }, + }, + }; + + const flatFieldMetadataMaps = { byUniversalIdentifier: {} }; + + // Drive expected names per index via the mocked generator. + generateFlatIndexMetadataWithNameOrThrowMock.mockImplementation( + ({ flatIndex }: { flatIndex: { id: string } }) => { + const match = indexes.find((index) => index.id === flatIndex.id); + + return { name: match?.expectedName ?? flatIndex.id }; + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { flatIndexMaps, flatObjectMetadataMaps, flatFieldMetadataMaps } as any; +}; + +const buildQueryRunner = (): { + queryRunner: QueryRunner; + query: jest.Mock; + commit: jest.Mock; + rollback: jest.Mock; +} => { + const query = jest.fn(); + const commit = jest.fn(); + const rollback = jest.fn(); + + const queryRunner = { + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: commit, + rollbackTransaction: rollback, + release: jest.fn(), + query, + } as unknown as QueryRunner; + + return { queryRunner, query, commit, rollback }; +}; + +describe('NormalizeLegacyIndexNamesCommand', () => { + let command: NormalizeLegacyIndexNamesCommand; + let renameIndexMock: jest.Mock; + let dropIndexMock: jest.Mock; + let getOrRecomputeMock: jest.Mock; + let invalidateAndRecomputeMock: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + renameIndexMock = jest.fn(); + dropIndexMock = jest.fn(); + getOrRecomputeMock = jest.fn(); + invalidateAndRecomputeMock = jest.fn(); + + const workspaceIteratorService = {} as WorkspaceIteratorService; + const workspaceCacheService = { + getOrRecompute: getOrRecomputeMock, + invalidateAndRecompute: invalidateAndRecomputeMock, + } as unknown as WorkspaceCacheService; + const workspaceSchemaManagerService = { + indexManager: { + renameIndexWithoutRebuild: renameIndexMock, + dropIndex: dropIndexMock, + }, + } as unknown as WorkspaceSchemaManagerService; + + command = new NormalizeLegacyIndexNamesCommand( + workspaceIteratorService, + workspaceCacheService, + workspaceSchemaManagerService, + ); + }); + + const runOnWorkspace = (dataSource: DataSource, dryRun = false) => + command.runOnWorkspace({ + workspaceId: WORKSPACE_ID, + dataSource: dataSource as never, + options: { dryRun }, + index: 0, + total: 1, + }); + + it('renames a legacy-named index and updates its metadata row, then invalidates the cache', async () => { + getOrRecomputeMock.mockResolvedValue( + buildFlatEntityMaps([ + { + universalIdentifier: 'idx-uid', + id: 'index-1', + name: 'legacyhash', + expectedName: 'IDX_UNIQUE_new', + }, + ]), + ); + + const { queryRunner, query, commit } = buildQueryRunner(); + const dataSource = { + createQueryRunner: () => queryRunner, + } as unknown as DataSource; + + await runOnWorkspace(dataSource); + + expect(renameIndexMock).toHaveBeenCalledWith( + expect.objectContaining({ + fromIndexName: 'legacyhash', + toIndexName: 'IDX_UNIQUE_new', + }), + ); + expect(query).toHaveBeenCalledWith(expect.stringContaining('UPDATE'), [ + 'IDX_UNIQUE_new', + 'index-1', + WORKSPACE_ID, + ]); + expect(dropIndexFromWorkspaceSchemaMock).not.toHaveBeenCalled(); + expect(commit).toHaveBeenCalled(); + expect(invalidateAndRecomputeMock).toHaveBeenCalledWith(WORKSPACE_ID, [ + 'flatIndexMaps', + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + ]); + }); + + it('drops the redundant duplicate and renames the survivor when two indexes collide', async () => { + getOrRecomputeMock.mockResolvedValue( + buildFlatEntityMaps([ + { + universalIdentifier: 'idx-a', + id: 'index-a', + name: 'legacyA', + expectedName: 'IDX_UNIQUE_shared', + }, + { + universalIdentifier: 'idx-b', + id: 'index-b', + name: 'legacyB', + expectedName: 'IDX_UNIQUE_shared', + }, + ]), + ); + + const { queryRunner } = buildQueryRunner(); + const dataSource = { + createQueryRunner: () => queryRunner, + } as unknown as DataSource; + + await runOnWorkspace(dataSource); + + expect(renameIndexMock).toHaveBeenCalledTimes(1); + expect(renameIndexMock).toHaveBeenCalledWith( + expect.objectContaining({ + fromIndexName: 'legacyA', + toIndexName: 'IDX_UNIQUE_shared', + }), + ); + expect(dropIndexFromWorkspaceSchemaMock).toHaveBeenCalledTimes(1); + expect(dropIndexFromWorkspaceSchemaMock).toHaveBeenCalledWith( + expect.objectContaining({ indexName: 'legacyB' }), + ); + expect(deleteIndexMetadataMock).toHaveBeenCalledWith( + expect.objectContaining({ entityId: 'index-b', workspaceId: WORKSPACE_ID }), + ); + }); + + it('does nothing when all index names already match', async () => { + getOrRecomputeMock.mockResolvedValue( + buildFlatEntityMaps([ + { + universalIdentifier: 'idx-uid', + id: 'index-1', + name: 'IDX_ok', + expectedName: 'IDX_ok', + }, + ]), + ); + + const createQueryRunner = jest.fn(); + const dataSource = { createQueryRunner } as unknown as DataSource; + + await runOnWorkspace(dataSource); + + expect(createQueryRunner).not.toHaveBeenCalled(); + expect(renameIndexMock).not.toHaveBeenCalled(); + expect(invalidateAndRecomputeMock).not.toHaveBeenCalled(); + }); + + it('does not write anything in dry-run mode', async () => { + getOrRecomputeMock.mockResolvedValue( + buildFlatEntityMaps([ + { + universalIdentifier: 'idx-uid', + id: 'index-1', + name: 'legacyhash', + expectedName: 'IDX_UNIQUE_new', + }, + ]), + ); + + const createQueryRunner = jest.fn(); + const dataSource = { createQueryRunner } as unknown as DataSource; + + await runOnWorkspace(dataSource, true); + + expect(createQueryRunner).not.toHaveBeenCalled(); + expect(renameIndexMock).not.toHaveBeenCalled(); + expect(dropIndexFromWorkspaceSchemaMock).not.toHaveBeenCalled(); + expect(invalidateAndRecomputeMock).not.toHaveBeenCalled(); + }); + + it('rolls back the transaction when an operation fails', async () => { + getOrRecomputeMock.mockResolvedValue( + buildFlatEntityMaps([ + { + universalIdentifier: 'idx-uid', + id: 'index-1', + name: 'legacyhash', + expectedName: 'IDX_UNIQUE_new', + }, + ]), + ); + + renameIndexMock.mockRejectedValueOnce(new Error('boom')); + + const { queryRunner, commit, rollback } = buildQueryRunner(); + const dataSource = { + createQueryRunner: () => queryRunner, + } as unknown as DataSource; + + await expect(runOnWorkspace(dataSource)).rejects.toThrow('boom'); + + expect(rollback).toHaveBeenCalled(); + expect(commit).not.toHaveBeenCalled(); + expect(invalidateAndRecomputeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/utils/__tests__/plan-index-name-normalization.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/utils/__tests__/plan-index-name-normalization.util.spec.ts new file mode 100644 index 0000000000..d37d5106e8 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/utils/__tests__/plan-index-name-normalization.util.spec.ts @@ -0,0 +1,169 @@ +import { + type FlatIndexNameStatus, + planIndexNameNormalization, +} from 'src/database/commands/upgrade-version-command/2-18/utils/plan-index-name-normalization.util'; + +const buildStatus = ( + overrides: Partial & + Pick, +): FlatIndexNameStatus => ({ + objectMetadataId: 'object-1', + ...overrides, +}); + +describe('planIndexNameNormalization', () => { + it('returns no operations for an empty input', () => { + expect(planIndexNameNormalization([])).toEqual([]); + }); + + it('returns no operations when every index name already matches', () => { + const statuses = [ + buildStatus({ + indexMetadataId: 'index-1', + currentName: 'IDX_UNIQUE_aaa', + expectedName: 'IDX_UNIQUE_aaa', + }), + buildStatus({ + indexMetadataId: 'index-2', + currentName: 'IDX_bbb', + expectedName: 'IDX_bbb', + }), + ]; + + expect(planIndexNameNormalization(statuses)).toEqual([]); + }); + + it('renames a single legacy-named index to its expected name', () => { + const statuses = [ + buildStatus({ + indexMetadataId: 'index-1', + currentName: 'legacyhash000000000000000000', + expectedName: 'IDX_UNIQUE_newhash', + }), + ]; + + expect(planIndexNameNormalization(statuses)).toEqual([ + { + type: 'rename', + indexMetadataId: 'index-1', + objectMetadataId: 'object-1', + fromName: 'legacyhash000000000000000000', + toName: 'IDX_UNIQUE_newhash', + }, + ]); + }); + + it('renames the survivor and drops the duplicate when two legacy indexes resolve to the same name', () => { + const statuses = [ + buildStatus({ + indexMetadataId: 'index-legacy-a', + currentName: 'legacyhashA', + expectedName: 'IDX_UNIQUE_shared', + }), + buildStatus({ + indexMetadataId: 'index-legacy-b', + currentName: 'legacyhashB', + expectedName: 'IDX_UNIQUE_shared', + }), + ]; + + expect(planIndexNameNormalization(statuses)).toEqual([ + { + type: 'rename', + indexMetadataId: 'index-legacy-a', + objectMetadataId: 'object-1', + fromName: 'legacyhashA', + toName: 'IDX_UNIQUE_shared', + }, + { + type: 'dropRedundant', + indexMetadataId: 'index-legacy-b', + objectMetadataId: 'object-1', + redundantName: 'legacyhashB', + keptName: 'IDX_UNIQUE_shared', + }, + ]); + }); + + it('keeps the already-correct twin and only drops the legacy duplicate (no rename)', () => { + const statuses = [ + buildStatus({ + indexMetadataId: 'index-legacy', + currentName: 'legacyhash', + expectedName: 'IDX_UNIQUE_shared', + }), + buildStatus({ + indexMetadataId: 'index-correct', + currentName: 'IDX_UNIQUE_shared', + expectedName: 'IDX_UNIQUE_shared', + }), + ]; + + expect(planIndexNameNormalization(statuses)).toEqual([ + { + type: 'dropRedundant', + indexMetadataId: 'index-legacy', + objectMetadataId: 'object-1', + redundantName: 'legacyhash', + keptName: 'IDX_UNIQUE_shared', + }, + ]); + }); + + it('handles a mix of correct, legacy, and duplicate indexes across objects', () => { + const statuses = [ + // already correct -> ignored + buildStatus({ + indexMetadataId: 'ok', + objectMetadataId: 'object-a', + currentName: 'IDX_ok', + expectedName: 'IDX_ok', + }), + // legacy single -> rename + buildStatus({ + indexMetadataId: 'legacy-single', + objectMetadataId: 'object-a', + currentName: 'oldname', + expectedName: 'IDX_renamed', + }), + // duplicate pair on another object -> rename + drop + buildStatus({ + indexMetadataId: 'dup-1', + objectMetadataId: 'object-b', + currentName: 'oldDup1', + expectedName: 'IDX_UNIQUE_dup', + }), + buildStatus({ + indexMetadataId: 'dup-2', + objectMetadataId: 'object-b', + currentName: 'oldDup2', + expectedName: 'IDX_UNIQUE_dup', + }), + ]; + + const operations = planIndexNameNormalization(statuses); + + expect(operations).toHaveLength(3); + expect(operations).toContainEqual({ + type: 'rename', + indexMetadataId: 'legacy-single', + objectMetadataId: 'object-a', + fromName: 'oldname', + toName: 'IDX_renamed', + }); + expect(operations).toContainEqual({ + type: 'rename', + indexMetadataId: 'dup-1', + objectMetadataId: 'object-b', + fromName: 'oldDup1', + toName: 'IDX_UNIQUE_dup', + }); + expect(operations).toContainEqual({ + type: 'dropRedundant', + indexMetadataId: 'dup-2', + objectMetadataId: 'object-b', + redundantName: 'oldDup2', + keptName: 'IDX_UNIQUE_dup', + }); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/utils/plan-index-name-normalization.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/utils/plan-index-name-normalization.util.ts new file mode 100644 index 0000000000..ea321a9954 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/utils/plan-index-name-normalization.util.ts @@ -0,0 +1,72 @@ +export type FlatIndexNameStatus = { + indexMetadataId: string; + objectMetadataId: string; + currentName: string; + expectedName: string; +}; + +export type RenameIndexNameOperation = { + type: 'rename'; + indexMetadataId: string; + objectMetadataId: string; + fromName: string; + toName: string; +}; + +export type DropRedundantIndexOperation = { + type: 'dropRedundant'; + indexMetadataId: string; + objectMetadataId: string; + redundantName: string; + keptName: string; +}; + +export type IndexNameNormalizationOperation = + | RenameIndexNameOperation + | DropRedundantIndexOperation; + +export const planIndexNameNormalization = ( + indexStatuses: FlatIndexNameStatus[], +): IndexNameNormalizationOperation[] => { + const operations: IndexNameNormalizationOperation[] = []; + + const statusesByExpectedName = new Map(); + + for (const status of indexStatuses) { + const group = statusesByExpectedName.get(status.expectedName) ?? []; + + group.push(status); + statusesByExpectedName.set(status.expectedName, group); + } + + for (const [expectedName, group] of statusesByExpectedName) { + const survivor = + group.find((status) => status.currentName === expectedName) ?? group[0]; + + if (survivor.currentName !== expectedName) { + operations.push({ + type: 'rename', + indexMetadataId: survivor.indexMetadataId, + objectMetadataId: survivor.objectMetadataId, + fromName: survivor.currentName, + toName: expectedName, + }); + } + + for (const status of group) { + if (status.indexMetadataId === survivor.indexMetadataId) { + continue; + } + + operations.push({ + type: 'dropRedundant', + indexMetadataId: status.indexMetadataId, + objectMetadataId: status.objectMetadataId, + redundantName: status.currentName, + keptName: expectedName, + }); + } + } + + return operations; +}; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts index 33112e8e57..852da026fe 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts @@ -18,6 +18,7 @@ import { V2_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade import { V2_15_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-15/2-15-upgrade-version-command.module'; import { V2_16_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-16/2-16-upgrade-version-command.module'; import { V2_17_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-17/2-17-upgrade-version-command.module'; +import { V2_18_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-18/2-18-upgrade-version-command.module'; @Module({ imports: [ @@ -39,6 +40,7 @@ import { V2_17_UpgradeVersionCommandModule } from 'src/database/commands/upgrade V2_15_UpgradeVersionCommandModule, V2_16_UpgradeVersionCommandModule, V2_17_UpgradeVersionCommandModule, + V2_18_UpgradeVersionCommandModule, ], }) export class WorkspaceCommandProviderModule {} diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service.ts index 528018aec2..6392e6a3d1 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-index-manager.service.ts @@ -90,4 +90,20 @@ export class WorkspaceSchemaIndexManagerService { await queryRunner.query(sql); } + + async renameIndexWithoutRebuild({ + queryRunner, + schemaName, + fromIndexName, + toIndexName, + }: { + queryRunner: QueryRunner; + schemaName: string; + fromIndexName: string; + toIndexName: string; + }): Promise { + const sql = `ALTER INDEX ${escapeIdentifier(schemaName)}.${escapeIdentifier(fromIndexName)} RENAME TO ${escapeIdentifier(toIndexName)}`; + + await queryRunner.query(sql); + } }