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:
+1
-1
@@ -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
|
||||
{
|
||||
+16
@@ -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 {}
|
||||
+240
@@ -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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+2
@@ -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 {}
|
||||
|
||||
+31
@@ -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
|
||||
|
||||
+16
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user