From fe644a0630a483025095723b69f469611b4065ed Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:00:45 +0200 Subject: [PATCH] fix(server): recreate searchVector GIN index on rebuild (#22349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes a pre-existing regression where rebuilding a `TS_VECTOR` (`searchVector`) generated column drops its GIN index without recreating it, leaving search correct but **unindexed** (sequential scan). Changing a generated column's expression requires `DROP COLUMN` + `ADD COLUMN` (Postgres can't `ALTER` a generated expression). The `searchVector`'s GIN index is a separate index-metadata entity built on that column, so the `DROP COLUMN` cascade-drops the physical index — and the rebuild branch never re-issued `CREATE INDEX`. This existed on `main` (triggered by `asExpression`/`generatedType` settings changes) and was inherited by the `rebuildSearchVector` refactor in #22287. This is the first, self-contained part of https://github.com/twentyhq/core-team-issues/issues/2620. The 2.18 recompute/backfill workspace command is intentionally left for a follow-up PR. ## What changed ### Runner loads the maps a rebuild needs `workspace-migration-runner.service.ts` — `fieldMetadata` declares neither `searchFieldMetadata` nor `index` as a related metadata name, so a `fieldMetadata`-only rebuild action had neither `flatSearchFieldMetadataMaps` (needed by the expression derivation) nor `flatIndexMaps` (needed to recreate the index) in context. The runner now detects `update` actions carrying `rebuildSearchVector === true` and loads those two maps — **only** when a rebuild is present, so ordinary field operations are unaffected. ### Handler recreates the index after re-adding the column `update-field-action-handler.service.ts` — in the rebuild branch, after `addColumns`, recreate the field's single GIN index: ```ts const [searchVectorFlatIndexMetadata] = findFieldRelatedIndexes({ flatFieldMetadata: optimisticFlatFieldMetadata, flatObjectMetadata, flatIndexMaps, }); if (isDefined(searchVectorFlatIndexMetadata)) { await createIndexInWorkspaceSchema({ flatIndexMetadata: searchVectorFlatIndexMetadata, ... }); } ``` - **Narrow lookup, not a workspace-wide scan.** The flat field has no index back-reference (`fieldMetadata.indexFieldMetadatas` is `null` in `ALL_ONE_TO_MANY_METADATA_RELATIONS`). The *object* does aggregate its indexes (`indexMetadataIds`), so we reuse the existing `findFieldRelatedIndexes` helper — already used by `handle-index-changes-during-field-update.util.ts` and the morph-rename path — which resolves only this object's indexes and filters to the one on the field. - A `TS_VECTOR` field has exactly one index (the standard `searchVectorGinIndex`), so we retrieve that single index rather than iterating. `createIndex` emits `CREATE INDEX IF NOT EXISTS` (idempotent). This makes the rebuild self-contained (column + index move together) and fixes every rebuild path: rename, label-identifier change, and `searchFieldMetadata` changes. ### Regression test Extends `update-one-field-metadata-search-vector-side-effect.integration-spec.ts` to query `pg_indexes` before and after the rename and assert the GIN index on the `searchVector` column persists (not just that search still returns the record). Fails without the fix, passes with it. ## Test plan - [x] `npx nx lint:diff-with-main twenty-server` — 0 warnings, 0 errors - [x] `npx nx typecheck twenty-server` — clean for changed files - [ ] Integration: extended rename-rebuild spec (GIN index present post-rebuild) Part of https://github.com/twentyhq/core-team-issues/issues/2620 --- .../update-field-action-handler.service.ts | 20 +++++++++++++ .../workspace-migration-runner.service.ts | 12 ++++++++ ...rch-vector-side-effect.integration-spec.ts | 28 +++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service.ts index fd42d0f5fc..fb5b86cfde 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service.ts @@ -9,6 +9,7 @@ import { isDefined } from 'twenty-shared/utils'; import { ColumnType, type QueryRunner } from 'typeorm'; import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util'; +import { createIndexInWorkspaceSchema } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils'; import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; @@ -17,6 +18,7 @@ import { getCompositeTypeOrThrow } from 'src/engine/metadata-modules/field-metad import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { findManyFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps.util'; import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; +import { findFieldRelatedIndexes } from 'src/engine/metadata-modules/flat-field-metadata/utils/find-field-related-index.util'; import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { isCompositeFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-composite-flat-field-metadata.util'; import { isEnumFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-flat-field-metadata.util'; @@ -168,6 +170,7 @@ export class UpdateFieldActionHandlerService extends WorkspaceMigrationRunnerAct flatObjectMetadataMaps, flatFieldMetadataMaps, flatSearchFieldMetadataMaps, + flatIndexMaps, }, workspaceId, } = context; @@ -368,6 +371,23 @@ export class UpdateFieldActionHandlerService extends WorkspaceMigrationRunnerAct tableName, columnDefinitions, }); + + const [searchVectorFlatIndexMetadata] = findFieldRelatedIndexes({ + flatFieldMetadata: optimisticFlatFieldMetadata, + flatObjectMetadata, + flatIndexMaps, + }); + + if (isDefined(searchVectorFlatIndexMetadata)) { + await createIndexInWorkspaceSchema({ + flatIndexMetadata: searchVectorFlatIndexMetadata, + flatObjectMetadata, + flatFieldMetadataMaps, + workspaceSchemaManagerService: this.workspaceSchemaManagerService, + queryRunner, + workspaceId, + }); + } } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts index ba59817138..c61caac76d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts @@ -230,6 +230,17 @@ export class WorkspaceMigrationRunnerService { const actionMetadataNames = [ ...new Set(actions.flatMap((action) => action.metadataName)), ]; + + const hasSearchVectorRebuildAction = actions.some( + (action) => + action.metadataName === 'fieldMetadata' && + action.type === 'update' && + action.rebuildSearchVector === true, + ); + + const searchVectorRebuildMetadataNames: AllMetadataName[] = + hasSearchVectorRebuildAction ? ['index'] : []; + const actionsMetadataAndRelatedMetadataNames: AllMetadataName[] = [ ...new Set([ ...actionMetadataNames, @@ -238,6 +249,7 @@ export class WorkspaceMigrationRunnerService { ...actionMetadataNames.flatMap( getMetadataRelatedMetadataNamesForValidation, ), + ...searchVectorRebuildMetadataNames, ]), ]; const allFlatEntityMapsKeys = actionsMetadataAndRelatedMetadataNames.map( diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-search-vector-side-effect.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-search-vector-side-effect.integration-spec.ts index a0f63f5c64..44d45a7788 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-search-vector-side-effect.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-search-vector-side-effect.integration-spec.ts @@ -8,10 +8,27 @@ import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; +import { computeTableName } from 'src/engine/utils/compute-table-name.util'; // ISO search surface: renaming an indexed field (the name field, the default search field of a // custom object) must rebuild the searchVector column so global search keeps working, // since the generated expression embeds the field's column name. +const TEST_SCHEMA_NAME = 'workspace_1wgvd1injqtife6y4rvfbu3h5'; + +const findSearchVectorGinIndexDefinitions = async ( + tableName: string, +): Promise => { + const rows: { indexdef: string }[] = await global.testDataSource.query( + `SELECT indexdef FROM pg_indexes + WHERE schemaname = $1 AND tablename = $2 + AND indexdef ILIKE '%using gin%' + AND indexdef ILIKE '%searchVector%'`, + [TEST_SCHEMA_NAME, tableName], + ); + + return rows.map((row) => row.indexdef); +}; + describe('Field metadata update - search vector rename rebuild', () => { let testObjectMetadataId: string; let nameFieldMetadataId: string; @@ -19,6 +36,7 @@ describe('Field metadata update - search vector rename rebuild', () => { const OBJECT_NAME_SINGULAR = 'searchVectorRenameObject'; const OBJECT_NAME_PLURAL = 'searchVectorRenameObjects'; + const OBJECT_TABLE_NAME = computeTableName(OBJECT_NAME_SINGULAR, true); const RENAMED_NAME_FIELD = 'searchableLabelColumn'; const RECORD_NAME_VALUE = 'RenameRecomputeSearchToken33'; @@ -89,6 +107,11 @@ describe('Field metadata update - search vector rename rebuild', () => { }); it('should rebuild the search vector and keep search working when the indexed name field is renamed', async () => { + const ginIndexesBeforeRename = + await findSearchVectorGinIndexDefinitions(OBJECT_TABLE_NAME); + + expect(ginIndexesBeforeRename.length).toBe(1); + await updateOneFieldMetadata({ input: { idToUpdate: nameFieldMetadataId, @@ -116,5 +139,10 @@ describe('Field metadata update - search vector rename rebuild', () => { expect(searchResult.data.search.edges[0].node.objectNameSingular).toBe( OBJECT_NAME_SINGULAR, ); + + const ginIndexesAfterRename = + await findSearchVectorGinIndexDefinitions(OBJECT_TABLE_NAME); + + expect(ginIndexesAfterRename).toEqual(ginIndexesBeforeRename); }); });