From 3d00dd4066edccbee3a6b4c80e79e156b5f2374e Mon Sep 17 00:00:00 2001
From: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Date: Tue, 30 Jun 2026 14:48:26 +0200
Subject: [PATCH] feat(server): add 2.18 recompute-search-vectors upgrade
command (#22355)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
closes https://github.com/twentyhq/core-team-issues/issues/2620
Adds the 2.18 `recompute-search-vectors` workspace upgrade command —
Part 2 of https://github.com/twentyhq/core-team-issues/issues/2620 (Part
1, the GIN-index rebuild fix, merged in #22349).
It uniformizes every workspace's `TS_VECTOR` (`searchVector`) columns
onto the new derive-from-`searchFieldMetadata` model and drops the
now-dead cached settings:
- **Recomputes** every searchVector column (re-derives its generated
expression from the `searchFieldMetadata` rows) and **recreates its GIN
index** — relying on the Part 1 rebuild fix.
- **Clears** the deprecated cached `TS_VECTOR` settings (`asExpression`
/ `generatedType`), which nothing reads anymore.
## How
New command `RecomputeSearchVectorsCommand`
(`@RegisteredWorkspaceCommand('2.18.0', 1799200001000)`), per
active/suspended workspace:
1. Load `flatFieldMetadataMaps`, enumerate every
`FieldMetadataType.TS_VECTOR` field. Skip (log) if none.
2. Support `--dry-run` (log the count, no writes).
3. Build one `update-field` action per TS_VECTOR field and run them in a
**single migration** via `workspaceMigrationRunnerService.run(...)`:
```ts
update: { universalSettings: null }, // clears the deprecated cached settings
rebuildSearchVector: true, // re-derive column + recreate GIN index
```
`universalSettings: null` transpiles to `settings: null`, so one atomic
action both clears settings and triggers the rebuild; `runner.run`
invalidates the field-metadata cache afterward.
## Why these choices
- **Single migration under the standard app** covers standard, custom,
and installed-app search vectors at once — the runner operates per
workspace-schema table regardless of a field's owning application, and
the update-field handler doesn't use `flatApplication`.
- **Ordering is safe**: the upgrade sequence runs fast-instance →
slow-instance → workspace commands per version, so the 2.18
`tsVectorFieldMetadataId` backfill (which the expression derivation
depends on) is guaranteed to have run first.
- **Cost**: this is a deliberate full rebuild — it drops/re-adds every
searchVector STORED column (table rewrite per searchable object) and
recreates each GIN index, per workspace, under the workspace iterator.
Intentional ("uniformize for everyone"), as noted in the issue.
## Test plan
- [x] `npx nx typecheck twenty-server`
- [x] `npx nx lint:diff-with-main twenty-server`
Closes Part 2 of
https://github.com/twentyhq/core-team-issues/issues/2620
---
.../2-18-upgrade-version-command.module.ts | 9 +-
...001000-recompute-search-vectors.command.ts | 90 +++++++++++++++++++
2 files changed, 98 insertions(+), 1 deletion(-)
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200001000-recompute-search-vectors.command.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-upgrade-version-command.module.ts
index 515548774f..a184edc960 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-upgrade-version-command.module.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-upgrade-version-command.module.ts
@@ -3,10 +3,12 @@ import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { AddMessageIsDraftFieldCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1810000005000-add-message-is-draft-field.command';
import { NormalizeLegacyIndexNamesCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command';
+import { RecomputeSearchVectorsCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200001000-recompute-search-vectors.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
+import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
@Module({
imports: [
@@ -15,7 +17,12 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceIteratorModule,
WorkspaceMigrationModule,
WorkspaceSchemaManagerModule,
+ WorkspaceMigrationRunnerModule,
+ ],
+ providers: [
+ AddMessageIsDraftFieldCommand,
+ NormalizeLegacyIndexNamesCommand,
+ RecomputeSearchVectorsCommand,
],
- providers: [AddMessageIsDraftFieldCommand, NormalizeLegacyIndexNamesCommand],
})
export class V2_18_UpgradeVersionCommandModule {}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200001000-recompute-search-vectors.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200001000-recompute-search-vectors.command.ts
new file mode 100644
index 0000000000..3099400d74
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200001000-recompute-search-vectors.command.ts
@@ -0,0 +1,90 @@
+import { Command } from 'nest-commander';
+import { FieldMetadataType } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+
+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 { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
+import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
+import { type UniversalUpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
+import { WORKSPACE_MIGRATION_ACTION_TYPE } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/constants/workspace-migration-action-type.constant';
+import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
+
+@RegisteredWorkspaceCommand('2.18.0', 1799200001000)
+@Command({
+ name: 'upgrade:2-18:recompute-search-vectors',
+ description:
+ 'Recompute every TS_VECTOR (searchVector) column from its searchFieldMetadata rows, recreate its GIN index, and clear the deprecated cached TS_VECTOR settings. Idempotent.',
+})
+export class RecomputeSearchVectorsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
+ constructor(
+ protected readonly workspaceIteratorService: WorkspaceIteratorService,
+ private readonly workspaceCacheService: WorkspaceCacheService,
+ private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
+ ) {
+ super(workspaceIteratorService);
+ }
+
+ override async runOnWorkspace({
+ workspaceId,
+ options,
+ }: RunOnWorkspaceArgs): Promise {
+ const isDryRun = options.dryRun ?? false;
+
+ const { flatFieldMetadataMaps } =
+ await this.workspaceCacheService.getOrRecompute(workspaceId, [
+ 'flatFieldMetadataMaps',
+ ]);
+
+ const tsVectorFlatFieldMetadatas = Object.values(
+ flatFieldMetadataMaps.byUniversalIdentifier,
+ )
+ .filter(isDefined)
+ .filter(
+ (flatFieldMetadata) =>
+ flatFieldMetadata.type === FieldMetadataType.TS_VECTOR,
+ );
+
+ if (tsVectorFlatFieldMetadatas.length === 0) {
+ this.logger.log(
+ `No TS_VECTOR fields for workspace ${workspaceId}, skipping`,
+ );
+
+ return;
+ }
+
+ this.logger.log(
+ `${isDryRun ? '[DRY RUN] ' : ''}Recomputing ${tsVectorFlatFieldMetadatas.length} search vector(s) and clearing their settings for workspace ${workspaceId}`,
+ );
+
+ if (isDryRun) {
+ return;
+ }
+
+ const actions: UniversalUpdateFieldAction[] = tsVectorFlatFieldMetadatas.map(
+ (flatFieldMetadata) => ({
+ type: WORKSPACE_MIGRATION_ACTION_TYPE.update,
+ metadataName: 'fieldMetadata',
+ universalIdentifier: flatFieldMetadata.universalIdentifier,
+ update: { universalSettings: null },
+ rebuildSearchVector: true,
+ }),
+ );
+
+ await this.workspaceMigrationRunnerService.run({
+ workspaceMigration: {
+ // Cross-app actions; this is only the runner's existence gate, not a scope filter.
+ applicationUniversalIdentifier:
+ TWENTY_STANDARD_APPLICATION.universalIdentifier,
+ actions,
+ },
+ workspaceId,
+ });
+
+ this.logger.log(
+ `Successfully recomputed ${tsVectorFlatFieldMetadatas.length} search vector(s) for workspace ${workspaceId}`,
+ );
+ }
+}