diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783511477234-drop-metadata-standard-overrides-column.ts similarity index 96% rename from packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column.ts rename to packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783511477234-drop-metadata-standard-overrides-column.ts index 10146c66d5..f6f4b9fe62 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783511477234-drop-metadata-standard-overrides-column.ts @@ -8,7 +8,7 @@ import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/ // (the sequence only runs previous + current versions). down() restores it. const TABLES = ['objectMetadata', 'fieldMetadata'] as const; -@RegisteredInstanceCommand('2.20.0', 1825000000000) +@RegisteredInstanceCommand('2.20.0', 1783511477234) export class DropMetadataStandardOverridesColumnFastInstanceCommand implements FastInstanceCommand { diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-upgrade-version-command.module.ts new file mode 100644 index 0000000000..86d5fac5ba --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-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 { BackfillActorSourceEnumValuesCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783499671542-backfill-actor-source-enum-values.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: [ + WorkspaceIteratorModule, + WorkspaceCacheModule, + WorkspaceSchemaManagerModule, + ], + providers: [BackfillActorSourceEnumValuesCommand], +}) +export class V2_20_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783499671542-backfill-actor-source-enum-values.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783499671542-backfill-actor-source-enum-values.command.ts new file mode 100644 index 0000000000..936ff17a87 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783499671542-backfill-actor-source-enum-values.command.ts @@ -0,0 +1,240 @@ +import { Command } from 'nest-commander'; +import { + FieldMetadataType, + compositeTypeDefinitions, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { type QueryRunner } from 'typeorm'; + +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 { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service'; +import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; +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 { computePostgresEnumName } from 'src/engine/workspace-manager/workspace-migration/utils/compute-postgres-enum-name.util'; + +export type ActorSourceEnumBackfillTarget = { + objectNameSingular: string; + fieldName: string; + tableName: string; + columnName: string; + enumName: string; + expectedValues: string[]; +}; + +export const buildActorSourceEnumBackfillTargets = ({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, +}: { + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; +}): ActorSourceEnumBackfillTarget[] => { + const targets: ActorSourceEnumBackfillTarget[] = []; + + const actorFlatFieldMetadatas = Object.values( + flatFieldMetadataMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter( + (flatFieldMetadata) => flatFieldMetadata.type === FieldMetadataType.ACTOR, + ); + + for (const flatFieldMetadata of actorFlatFieldMetadatas) { + const flatObjectMetadata = + flatObjectMetadataMaps.byUniversalIdentifier[ + flatFieldMetadata.objectMetadataUniversalIdentifier + ]; + + if (!isDefined(flatObjectMetadata)) { + continue; + } + + const compositeType = compositeTypeDefinitions.get(flatFieldMetadata.type); + + if (!isDefined(compositeType)) { + continue; + } + + const tableName = computeObjectTargetTable(flatObjectMetadata); + + for (const property of compositeType.properties) { + if (!isEnumFieldMetadataType(property.type)) { + continue; + } + + const expectedValues = + property.options?.map((option) => option.value) ?? []; + + if (expectedValues.length === 0) { + continue; + } + + const columnName = computeCompositeColumnName( + flatFieldMetadata.name, + property, + ); + + targets.push({ + objectNameSingular: flatObjectMetadata.nameSingular, + fieldName: flatFieldMetadata.name, + tableName, + columnName, + enumName: computePostgresEnumName({ tableName, columnName }), + expectedValues, + }); + } + } + + return targets; +}; + +@RegisteredWorkspaceCommand('2.20.0', 1783499671542) +@Command({ + name: 'upgrade:2-20:backfill-actor-source-enum-values', + description: + 'Backfill missing AGENT FieldActorSource values into the Postgres enums backing ACTOR fields (createdBy/updatedBy) of existing workspaces.', +}) +export class BackfillActorSourceEnumValuesCommand 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 { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + ]); + + const targets = buildActorSourceEnumBackfillTargets({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); + + if (targets.length === 0) { + this.logger.log( + `No ACTOR fields found for workspace ${workspaceId}, skipping`, + ); + + return; + } + + const schemaName = getWorkspaceSchemaName(workspaceId); + const queryRunner = dataSource.createQueryRunner(); + + try { + await queryRunner.connect(); + + await queryRunner.startTransaction(); + + const companyCreatedBySourceTarget = targets.find( + (target) => + target.objectNameSingular === 'company' && + target.columnName === 'createdBySource', + ); + + if (isDefined(companyCreatedBySourceTarget)) { + const rows: { enumlabel: string }[] = await queryRunner.query( + `SELECT e.enumlabel + FROM pg_catalog.pg_type t + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + JOIN pg_catalog.pg_enum e ON e.enumtypid = t.oid + WHERE n.nspname = $1 + AND t.typname = $2`, + [schemaName, companyCreatedBySourceTarget.enumName], + ); + + const existingValues = new Set(rows.map((row) => row.enumlabel)); + + if (existingValues.has('AGENT')) { + this.logger.log( + `AGENT already present on ${schemaName}.${companyCreatedBySourceTarget.enumName}, skipping workspace ${workspaceId}`, + ); + + await queryRunner.commitTransaction(); + + return; + } + } + + const isDryRun = options.dryRun ?? false; + + for (const target of targets) { + await this.backfillEnumTarget({ + queryRunner, + schemaName, + target, + workspaceId, + isDryRun, + }); + } + + await queryRunner.commitTransaction(); + } catch (error) { + if (queryRunner.isTransactionActive) { + await queryRunner.rollbackTransaction(); + } + + throw error; + } finally { + await queryRunner.release(); + } + } + + private async backfillEnumTarget({ + queryRunner, + schemaName, + target, + workspaceId, + isDryRun, + }: { + queryRunner: QueryRunner; + schemaName: string; + target: ActorSourceEnumBackfillTarget; + workspaceId: string; + isDryRun: boolean; + }): Promise { + if (isDryRun) { + this.logger.log( + `[DRY RUN] Would ensure [${target.expectedValues.join(', ')}] exist on ${schemaName}.${target.enumName} (workspace ${workspaceId})`, + ); + + return; + } + + for (const value of target.expectedValues) { + await this.workspaceSchemaManagerService.enumManager.upsertEnumValue({ + queryRunner, + schemaName, + enumName: target.enumName, + value, + }); + } + + this.logger.log( + `Ensured actor source values on ${schemaName}.${target.enumName} (workspace ${workspaceId})`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/__tests__/drop-metadata-standard-overrides-column.instance-command.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/__tests__/drop-metadata-standard-overrides-column.instance-command.spec.ts index 535f242758..df6c1f541d 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/__tests__/drop-metadata-standard-overrides-column.instance-command.spec.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/__tests__/drop-metadata-standard-overrides-column.instance-command.spec.ts @@ -1,6 +1,6 @@ import { type QueryRunner } from 'typeorm'; -import { DropMetadataStandardOverridesColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column'; +import { DropMetadataStandardOverridesColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783511477234-drop-metadata-standard-overrides-column'; import { DROP_METADATA_STANDARD_OVERRIDES_COLUMN_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-20/drop-metadata-standard-overrides-column-upgrade-command-name.constant'; import { getRegisteredInstanceCommandMetadata } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; @@ -19,7 +19,7 @@ describe('DropMetadataStandardOverridesColumnFastInstanceCommand', () => { expect(metadata).toEqual({ version: '2.20.0', - timestamp: 1825000000000, + timestamp: 1783511477234, type: 'fast', }); }); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/drop-metadata-standard-overrides-column-upgrade-command-name.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/drop-metadata-standard-overrides-column-upgrade-command-name.constant.ts index d85d376080..458790c57b 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/drop-metadata-standard-overrides-column-upgrade-command-name.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/drop-metadata-standard-overrides-column-upgrade-command-name.constant.ts @@ -1,4 +1,4 @@ // Registered name of the 2.20 drop command. Wire to @WasRemovedInUpgrade on the // standardOverrides columns once 2.20 is current. export const DROP_METADATA_STANDARD_OVERRIDES_COLUMN_UPGRADE_COMMAND_NAME = - '2.20.0_DropMetadataStandardOverridesColumnFastInstanceCommand_1825000000000'; + '2.20.0_DropMetadataStandardOverridesColumnFastInstanceCommand_1783511477234'; 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 index 4252acc8cd..cb8cb57485 100644 --- 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 @@ -93,7 +93,7 @@ import { AddPendingQuestionMessageIdToAgentChatThreadFastInstanceCommand } from import { AddWorkspaceDiscoverabilityToWorkspaceFastInstanceCommand } from './2-19/2-19-instance-command-fast-1783004140000-add-workspace-discoverability-to-workspace'; import { AddStatusToFileFastInstanceCommand } from './2-19/2-19-instance-command-fast-1783082964705-add-status-to-file'; import { AddPendingMimeCheckToFileFastInstanceCommand } from './2-19/2-19-instance-command-fast-1783094691548-add-pending-mime-check-to-file'; -import { DropMetadataStandardOverridesColumnFastInstanceCommand } from './2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column'; +import { DropMetadataStandardOverridesColumnFastInstanceCommand } from './2-20/2-20-instance-command-fast-1783511477234-drop-metadata-standard-overrides-column'; import { AddLogoToApplicationRegistrationFastInstanceCommand } from './2-19/2-19-instance-command-fast-1783069672191-add-logo-to-application-registration'; import { BackfillLogoOnApplicationRegistrationSlowInstanceCommand } from './2-19/2-19-instance-command-slow-1783069673191-backfill-logo-on-application-registration'; import { AddDisplayFieldsToApplicationRegistrationFastInstanceCommand } from './2-19/2-19-instance-command-fast-1783073776590-add-display-fields-to-application-registration'; 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 3e6f0c0066..87e10edd2e 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 @@ -20,6 +20,7 @@ import { V2_16_UpgradeVersionCommandModule } from 'src/database/commands/upgrade 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'; import { V2_19_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-19/2-19-upgrade-version-command.module'; +import { V2_20_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-20/2-20-upgrade-version-command.module'; @Module({ imports: [ @@ -43,6 +44,7 @@ import { V2_19_UpgradeVersionCommandModule } from 'src/database/commands/upgrade V2_17_UpgradeVersionCommandModule, V2_18_UpgradeVersionCommandModule, V2_19_UpgradeVersionCommandModule, + V2_20_UpgradeVersionCommandModule, ], }) export class WorkspaceCommandProviderModule {} diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-enum-manager.service.spec.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-enum-manager.service.spec.ts index 0aedea86de..bea40d54cb 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-enum-manager.service.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/__tests__/workspace-schema-enum-manager.service.spec.ts @@ -32,6 +32,37 @@ describe('WorkspaceSchemaEnumManagerService', () => { >; }); + describe('addEnumValue', () => { + it('emits a plain ADD VALUE statement', async () => { + await service.addEnumValue({ + queryRunner: queryRunner as unknown as QueryRunner, + schemaName: 'workspace_adhj7eaegq93fzpgbfpdm8ok3', + enumName: 'company_createdBySource_enum', + value: 'AGENT', + }); + + expect(executedSql).toHaveLength(1); + expect(executedSql[0]).toContain('ADD VALUE '); + expect(executedSql[0]).not.toContain('IF NOT EXISTS'); + expect(executedSql[0]).toContain("'AGENT'"); + }); + }); + + describe('upsertEnumValue', () => { + it('emits ADD VALUE IF NOT EXISTS', async () => { + await service.upsertEnumValue({ + queryRunner: queryRunner as unknown as QueryRunner, + schemaName: 'workspace_adhj7eaegq93fzpgbfpdm8ok3', + enumName: 'company_createdBySource_enum', + value: 'AGENT', + }); + + expect(executedSql).toHaveLength(1); + expect(executedSql[0]).toContain('ADD VALUE IF NOT EXISTS'); + expect(executedSql[0]).toContain("'AGENT'"); + }); + }); + describe('alterEnumValues', () => { it('should not generate an enum rename whose target collides with the source when the name exceeds the identifier length limit', async () => { // Real-world case: long object + field names produce an enum name over diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service.ts index ff075c7398..2816edf6d2 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-enum-manager.service.ts @@ -109,6 +109,22 @@ export class WorkspaceSchemaEnumManagerService { await queryRunner.query(sql); } + async upsertEnumValue({ + queryRunner, + schemaName, + enumName, + value, + }: { + queryRunner: QueryRunner; + schemaName: string; + enumName: string; + value: string; + }): Promise { + const sql = `ALTER TYPE ${escapeIdentifier(schemaName)}.${escapeIdentifier(enumName)} ADD VALUE IF NOT EXISTS ${escapeLiteral(value)}`; + + await queryRunner.query(sql); + } + async renameEnumValue({ queryRunner, schemaName,