Files
twenty/packages/twenty-server/src/engine/metadata-modules/search-vector/search-vector.service.ts
Weiko 41becaaea4 Refactor migration runner within transaction (#12941)
Modifying the data-model can sometimes fail in the middle of your
operation, due to the way we handle both metadata update and schema
migration separately, a field can be created while the associated column
creation failed (same for object/table and such). This is also an issue
because WorkspaceMigrations are then stored as FAILED can never really
recovered by themselves so the schema is broken and we can't update the
models anymore.
This PR adds a executeMigrationFromPendingMigrationsWithinTransaction
method where we can (and must) pass a queryRunner executing a
transaction, which should come from the metadata services so that if
anything during metadata update OR schema update fails, it rolls back
everything (this also mean a workspaceMigration should never stay in a
failed state now).
This also fixes some issues with migration not running in the correct
order due to having the same timestamp and having to do some weird logic
to fix that.

This is a first step and fix before working on a much more reliable
solution in the upcoming weeks where we will refactor the way we
interact with the data model.

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2025-07-02 19:21:26 +02:00

181 lines
7.4 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { QueryRunner, Repository } from 'typeorm';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/object-metadata.constants';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
import { CUSTOM_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import {
FieldTypeAndNameMetadata,
getTsVectorColumnExpressionFromFields,
} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { SearchableFieldType } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-searchable-field.util';
@Injectable()
export class SearchVectorService {
constructor(
@InjectRepository(ObjectMetadataEntity, 'core')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly tsVectorColumnActionFactory: TsVectorColumnActionFactory,
private readonly indexMetadataService: IndexMetadataService,
@InjectRepository(FieldMetadataEntity, 'core')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
) {}
public async createSearchVectorFieldForObject(
objectMetadataInput: CreateObjectInput,
createdObjectMetadata: ObjectMetadataEntity,
queryRunner?: QueryRunner,
) {
const repository = queryRunner
? queryRunner.manager.getRepository(FieldMetadataEntity)
: this.fieldMetadataRepository;
const searchVectorFieldMetadata = await repository.save({
standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.searchVector,
objectMetadataId: createdObjectMetadata.id,
workspaceId: objectMetadataInput.workspaceId,
isCustom: false,
isActive: false,
isSystem: true,
type: FieldMetadataType.TS_VECTOR,
name: SEARCH_VECTOR_FIELD.name,
label: SEARCH_VECTOR_FIELD.label.message ?? '',
description: SEARCH_VECTOR_FIELD.description.message ?? '',
isNullable: true,
});
const searchableFieldForCustomObject =
createdObjectMetadata.labelIdentifierFieldMetadataId
? createdObjectMetadata.fields.find(
(field) =>
field.id === createdObjectMetadata.labelIdentifierFieldMetadataId,
)
: createdObjectMetadata.fields.find(
(field) => field.name === DEFAULT_LABEL_IDENTIFIER_FIELD_NAME,
);
if (!isDefined(searchableFieldForCustomObject)) {
throw new Error(
`No searchable field found for custom object (object name: ${createdObjectMetadata.nameSingular})`,
);
}
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`create-${createdObjectMetadata.nameSingular}`),
createdObjectMetadata.workspaceId,
[
{
name: computeTableName(
createdObjectMetadata.nameSingular,
createdObjectMetadata.isCustom,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.tsVectorColumnActionFactory.handleCreateAction({
...searchVectorFieldMetadata,
defaultValue: undefined,
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields([
{
type: searchableFieldForCustomObject.type as SearchableFieldType,
name: searchableFieldForCustomObject.name,
},
]),
options: undefined,
} as FieldMetadataInterface<FieldMetadataType.TS_VECTOR>),
},
],
queryRunner,
);
await this.indexMetadataService.createIndexMetadata({
workspaceId: objectMetadataInput.workspaceId,
objectMetadata: createdObjectMetadata,
fieldMetadataToIndex: [searchVectorFieldMetadata],
isUnique: false,
isCustom: false,
indexType: IndexType.GIN,
queryRunner,
});
}
public async updateSearchVector(
objectMetadataId: string,
fieldMetadataNameAndTypeForSearch: FieldTypeAndNameMetadata[],
workspaceId: string,
queryRunner?: QueryRunner,
) {
const repository = queryRunner
? queryRunner.manager.getRepository(ObjectMetadataEntity)
: this.objectMetadataRepository;
const objectMetadata = await repository.findOneByOrFail({
id: objectMetadataId,
});
const existingSearchVectorFieldMetadata =
await this.fieldMetadataRepository.findOneByOrFail({
name: SEARCH_VECTOR_FIELD.name,
objectMetadataId,
});
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`update-${objectMetadata.nameSingular}`),
workspaceId,
[
{
name: computeTableName(
objectMetadata.nameSingular,
objectMetadata.isCustom,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.ALTER,
existingSearchVectorFieldMetadata,
{
...existingSearchVectorFieldMetadata,
asExpression: getTsVectorColumnExpressionFromFields(
fieldMetadataNameAndTypeForSearch,
),
generatedType: 'STORED', // Not stored on fieldMetadata
options: undefined,
},
),
},
],
queryRunner,
);
// index needs to be recreated as typeorm deletes then recreates searchVector column at alter
await this.indexMetadataService.createIndexCreationMigration({
workspaceId,
objectMetadata,
fieldMetadataToIndex: [existingSearchVectorFieldMetadata],
isUnique: false,
indexType: IndexType.GIN,
queryRunner,
});
}
}