From 4b9f393167e523a9678e3f68a30a89a50253b9ae Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:05:15 +0200 Subject: [PATCH] fix: add workspace command to backfill missing AGENT source enum values (#22593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **What** Adds a 2.19 workspace upgrade command that backfills missing FieldActorSource enum values (AGENT) into the Postgres enums backing every ACTOR composite field (createdBy, updatedBy) across all existing workspaces. Each ACTOR field stores its source sub-field as a Postgres enum scoped to its own table and schema (e.g. workspace_abc.company_createdBySource_enum). Workspaces created before these enum values were introduced are missing them, which causes runtime errors when those sources are used. **How** buildActorSourceEnumBackfillTargets collects all ACTOR fields from the workspace metadata cache and maps each enum sub-property to its (tableName, columnName, enumName, expectedValues) tuple. Before iterating over all targets, the command performs a single fast pg_catalog.pg_enum lookup on company.createdBySource as a representative sentinel. If AGENT is already present there, the workspace is skipped entirely (idempotency fast-path). For each remaining target, ALTER TYPE … ADD VALUE IF NOT EXISTS is issued per missing value via WorkspaceSchemaEnumManagerService.addEnumValue, making the command fully idempotent and safe to re-run. Fixes https://discord.com/channels/1130383047699738754/1522507190949118003 Review in cubic --- ...rop-metadata-standard-overrides-column.ts} | 2 +- .../2-20-upgrade-version-command.module.ts | 16 ++ ...ckfill-actor-source-enum-values.command.ts | 240 ++++++++++++++++++ ...-overrides-column.instance-command.spec.ts | 4 +- ...es-column-upgrade-command-name.constant.ts | 2 +- .../instance-commands.constant.ts | 2 +- .../workspace-command-provider.module.ts | 2 + ...kspace-schema-enum-manager.service.spec.ts | 31 +++ .../workspace-schema-enum-manager.service.ts | 16 ++ 9 files changed, 310 insertions(+), 5 deletions(-) rename packages/twenty-server/src/database/commands/upgrade-version-command/2-20/{2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column.ts => 2-20-instance-command-fast-1783511477234-drop-metadata-standard-overrides-column.ts} (96%) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-upgrade-version-command.module.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-workspace-command-1783499671542-backfill-actor-source-enum-values.command.ts 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,