feat(server): add 2.18 recompute-search-vectors upgrade command (#22355)

## 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


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22355?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:
Paul Rastoin
2026-06-30 14:48:26 +02:00
committed by GitHub
parent 82516d65e4
commit 3d00dd4066
2 changed files with 98 additions and 1 deletions
@@ -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 {}
@@ -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<void> {
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}`,
);
}
}