From eca92ca559a82e1d2137bf423365d3fb6d880c1c Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 15 May 2026 16:39:34 +0200 Subject: [PATCH] fix(server): rebuild unique phone indexes drops legacy non-empty partial WHERE clause (#20606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `RebuildUniquePhoneIndexesCommand` reuses each index's existing `indexWhereClause` when recreating the physical index. For workspaces whose unique phone indexes have a legacy clause like `"primaryPhoneNumber" != ''` (created before PR #18024 hardened the validator allowlist), the recreate path fails at `validateAndReturnIndexWhereClause` because the clause isn't in `ALLOWED_INDEX_WHERE_CLAUSES`. Two workspaces are hitting this on the 2.5 upgrade: - `3a797122-…` — `"companyPhonePrimaryPhoneNumber" != ''` - `ea74716f-…` — `"phonesPrimaryPhoneNumber" != ''` ## Fix Detect the legacy `"" != ''` shape via a strict regex. When it's there, before the existing drop+create, do three things inside the workspace transaction: 1. **Normalize the data** that the legacy partial clause was masking — `UPDATE ""."" SET "" = NULL WHERE "" = ''` for every column the index covers. Without this the next step would fail because the new plain-unique index would see duplicate `''` values across the rows the old partial clause was excluding. 2. **Null out `core."indexMetadata".indexWhereClause`** so the metadata row matches what the UI would have created (`indexWhereClause: null`) and doesn't carry the validator-rejected clause forward to any future re-emit. Uses the same workspace `queryRunner` (Postgres lets one connection write across schemas). 3. **Recreate** with an overridden flat index where `indexWhereClause: null`. `createIndexInWorkspaceSchema` → `indexManager.createIndex` → `validateAndReturnIndexWhereClause` short-circuits on null, no allowlist check. End state matches the shape a fresh "toggle unique in Settings UI" creates: plain unique index, no `WHERE`, NULL semantics doing the "exclude empty phones" work via PG's default NULL-distinct behaviour. For indexes whose clause is already allowlisted (`"deletedAt" IS NULL`) or null, behaviour is unchanged — just the column-list widening this command already does. --- ...00-rebuild-unique-phone-indexes.command.ts | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000000000-rebuild-unique-phone-indexes.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000000000-rebuild-unique-phone-indexes.command.ts index 9b6cdca5df..c0ad1778e7 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000000000-rebuild-unique-phone-indexes.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000000000-rebuild-unique-phone-indexes.command.ts @@ -1,6 +1,7 @@ import { Command } from 'nest-commander'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { isNonEmptyString } from '@sniptt/guards'; 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'; @@ -10,13 +11,17 @@ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/ import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-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 { + computeFlatIndexFieldColumnNames, createIndexInWorkspaceSchema, dropIndexFromWorkspaceSchema, } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/index/utils/index-action-handler.utils'; import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; +const LEGACY_NON_EMPTY_PARTIAL_INDEX_PATTERN = /^"[a-zA-Z][a-zA-Z0-9]*" != ''$/; + @RegisteredWorkspaceCommand('2.5.0', 1778000000000) @Command({ name: 'upgrade:2-5:rebuild-unique-phone-indexes', @@ -103,6 +108,35 @@ export class RebuildUniquePhoneIndexesCommand extends ActiveOrSuspendedWorkspace flatEntityMaps: flatObjectMetadataMaps, }); + const hasLegacyNonEmptyPartialClause = + isNonEmptyString(uniquePhoneIndex.indexWhereClause) && + LEGACY_NON_EMPTY_PARTIAL_INDEX_PATTERN.test( + uniquePhoneIndex.indexWhereClause, + ); + + if (hasLegacyNonEmptyPartialClause) { + const tableName = computeObjectTargetTable(flatObjectMetadata); + const columns = computeFlatIndexFieldColumnNames({ + flatIndexFieldMetadatas: uniquePhoneIndex.flatIndexFieldMetadatas, + flatFieldMetadataMaps, + }); + + for (const column of columns) { + await queryRunner.query( + `UPDATE "${schemaName}"."${tableName}" + SET "${column}" = NULL + WHERE "${column}" = ''`, + ); + } + + await queryRunner.query( + `UPDATE "core"."indexMetadata" + SET "indexWhereClause" = NULL + WHERE id = $1`, + [uniquePhoneIndex.id], + ); + } + await dropIndexFromWorkspaceSchema({ indexName: uniquePhoneIndex.name, workspaceSchemaManagerService: this.workspaceSchemaManagerService, @@ -110,8 +144,13 @@ export class RebuildUniquePhoneIndexesCommand extends ActiveOrSuspendedWorkspace schemaName, }); + const flatIndexMetadataForRebuild: FlatIndexMetadata = + hasLegacyNonEmptyPartialClause + ? { ...uniquePhoneIndex, indexWhereClause: null } + : uniquePhoneIndex; + await createIndexInWorkspaceSchema({ - flatIndexMetadata: uniquePhoneIndex, + flatIndexMetadata: flatIndexMetadataForRebuild, flatObjectMetadata, flatFieldMetadataMaps, workspaceSchemaManagerService: this.workspaceSchemaManagerService, @@ -120,7 +159,7 @@ export class RebuildUniquePhoneIndexesCommand extends ActiveOrSuspendedWorkspace }); this.logger.log( - `Rebuilt unique phone index ${uniquePhoneIndex.name} for workspace ${workspaceId}`, + `Rebuilt unique phone index ${uniquePhoneIndex.name} for workspace ${workspaceId}${hasLegacyNonEmptyPartialClause ? ' (dropped legacy non-empty partial WHERE clause)' : ''}`, ); } await queryRunner.commitTransaction();