diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command.ts index e414063e7b..9ed24c1465 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-workspace-command-1799100000000-backfill-search-field-metadata.command.ts @@ -34,11 +34,16 @@ export class BackfillSearchFieldMetadataCommand extends ProvisionedWorkspaceComm const isDryRun = options.dryRun ?? false; // The migration runner only invalidates the flat-maps keys a migration touched, - // so during a cross-version upgrade earlier commands can leave this map stale. - // A stale map breaks the existing-rows dedupe below and re-inserts rows, - // tripping IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE. Recompute from the - // database before deriving the create-set. + // so during a cross-version upgrade earlier commands can leave these maps stale. + // The existing-rows dedupe below compares (objectMetadataId, fieldMetadataId) + // pairs across maps: candidate ids resolved from a stale object/field map won't + // match the fresh search map's ids, so already-created rows are re-emitted and + // the runner (which re-resolves universal identifiers against fresh maps) trips + // IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE. Recompute every map the dedupe + // depends on from the database before deriving the create-set. await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', 'flatSearchFieldMetadataMaps', ]); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts index 4955883268..ff6e684014 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/__tests__/build-search-field-metadata-backfill-operations.util.spec.ts @@ -1,4 +1,7 @@ -import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application'; +import { + getSearchFieldUniversalIdentifier, + TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, +} from 'twenty-shared/application'; import { FieldMetadataType } from 'twenty-shared/types'; import { buildSearchFieldMetadataBackfillOperations } from 'src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util'; @@ -622,6 +625,54 @@ describe('buildSearchFieldMetadataBackfillOperations', () => { ).toHaveLength(0); }); + it('skips a row whose deterministic universal identifier already exists even when the existing row carries stale metadata ids', () => { + const { customObject, nameField, nameDescriptionField, searchVectorField } = + buildCustomObjectFixture(); + + // Simulates a retry after a partial run during a cross-version upgrade: the + // previously committed row still points at the ids the metadata had at insert + // time, while the object/field maps now expose new ids (id churn from earlier + // upgrade commands). The (objectMetadataId, fieldMetadataId) dedupe misses the + // pair, but the deterministic universal identifier is unchanged and must + // prevent re-emitting the row. + const existingSearchFieldMetadata = buildSearchFieldMetadata({ + id: 'existing-search-field-id', + universalIdentifier: getSearchFieldUniversalIdentifier({ + applicationUniversalIdentifier: + CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: nameField.universalIdentifier, + }), + objectMetadataId: 'stale-object-metadata-id', + fieldMetadataId: 'stale-field-metadata-id', + objectMetadataUniversalIdentifier: customObject.universalIdentifier, + fieldMetadataUniversalIdentifier: nameField.universalIdentifier, + applicationUniversalIdentifier: CUSTOM_APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const { flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier } = + buildSearchFieldMetadataBackfillOperations({ + flatObjectMetadataMaps: buildFlatObjectMetadataMaps([customObject]), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps([ + nameField, + nameDescriptionField, + searchVectorField, + ]), + flatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps([ + existingSearchFieldMetadata, + ]), + standardFlatSearchFieldMetadataMaps: buildFlatSearchFieldMetadataMaps( + [], + ), + customApplicationId: CUSTOM_APPLICATION_ID, + }); + + expect( + Object.keys( + flatSearchFieldMetadatasToCreateByApplicationUniversalIdentifier, + ), + ).toHaveLength(0); + }); + it('skips a custom object whose name field is not a searchable type', () => { const customObjectId = 'relation-name-object-id'; const customObjectUniversalIdentifier = 'relation-name-object-uid'; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts index 41220b32e6..5631940e82 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts @@ -94,16 +94,32 @@ export const buildSearchFieldMetadataBackfillOperations = ({ return; } - candidateSearchFieldMetadataKeys.add(searchFieldMetadataKey); - - flatSearchFieldMetadatasToCreate.push( - buildFlatSearchFieldMetadataForField({ + const universalFlatSearchFieldMetadata = buildFlatSearchFieldMetadataForField( + { flatObjectMetadata, flatFieldMetadata, tsVectorFlatFieldMetadata, position, - }), + }, ); + + // Second dedupe layer, immune to metadata-id churn: rows created by a previous + // partial run of this command carry the same deterministic universal identifier + // (unique per workspace). The id-based check above can miss them when earlier + // upgrade commands recreated objects/fields under new ids. + if ( + isDefined( + flatSearchFieldMetadataMaps.byUniversalIdentifier[ + universalFlatSearchFieldMetadata.universalIdentifier + ], + ) + ) { + return; + } + + candidateSearchFieldMetadataKeys.add(searchFieldMetadataKey); + + flatSearchFieldMetadatasToCreate.push(universalFlatSearchFieldMetadata); }; // Standard objects: mirror exactly what provisioning/standard-sync creates.