fix: add workspace command to backfill missing AGENT source enum values (#22593)

**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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22593?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Etienne
2026-07-08 14:05:15 +02:00
committed by GitHub
parent 6bc4d8efac
commit 4b9f393167
9 changed files with 310 additions and 5 deletions
@@ -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
{
@@ -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 {}
@@ -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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): 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<void> {
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<void> {
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})`,
);
}
}
@@ -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',
});
});
@@ -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';
@@ -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';
@@ -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 {}
@@ -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
@@ -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<void> {
const sql = `ALTER TYPE ${escapeIdentifier(schemaName)}.${escapeIdentifier(enumName)} ADD VALUE IF NOT EXISTS ${escapeLiteral(value)}`;
await queryRunner.query(sql);
}
async renameEnumValue({
queryRunner,
schemaName,