From 1d3d3999e23dace000406d369a76dee660aca103 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Mon, 18 May 2026 21:38:20 +0200 Subject: [PATCH] feat(server): upgrade-aware entity decorators for cross-version upgrades (#20686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What When the same PR introduces a new core entity *and* adds a cache provider that queries it, every workspace step from older versions that runs before the introducing instance step hits `relation … does not exist` — the cause of the failed [v2.6.0 staging-ci run](https://github.com/twentyhq/twenty-infra/actions/runs/26042742000). Same class of failure for renamed core entities and for new FK columns hidden inside relation loads. This PR adds **upgrade-aware entity decorators** + a runtime that adapts TypeORM's view of the schema to the current `core.upgradeMigration` cursor. ## Strategy ``` ┌────────────────────────────────┐ │ @Entity classes (final shape) │ │ + @WasIntroducedInUpgrade │ │ + @WasRenamedInUpgrade │ └───────────────┬────────────────┘ │ UpgradeSequenceRunner.run() ┌─────────────────────┴─────────────────────┐ ▼ ▼ step N+1 begins step N just completed │ │ └────────► adapter.refresh() ◄──────────────┘ │ reads core.upgradeMigration via UpgradeMigrationService.getLastAttemptedInstanceCommand │ ▼ ┌────────────────────────────────────────────────────────┐ │ UpgradeAwareEntityMetadataAdapter │ │ • mutates EntityMetadata.tableName / tablePath │ │ -> historical name for renames not yet applied │ │ • flips column.isSelect = false for not-yet-introduced│ │ columns │ │ • tracks per-entity availability sidecar │ └─────────────────┬──────────────────────────────────────┘ │ ▼ DataSource.getRepository wrapped at TypeOrmModule.forRoot: repo.find() / findOne() / count() / … ┌─────────────────────────────────────────┐ │ wrapRepositoryWithUpgradeAwareProxy │ │ • entity unavailable -> short-circuit │ │ (find -> [], count -> 0, │ │ findOneOrFail -> EntityNotFound) │ │ • write -> Promise.reject( │ │ UpgradeUnavailableEntityWriteEx) │ │ • find({ relations: ['X'] }) with X │ │ unavailable -> X stripped │ └─────────────────────────────────────────┘ ``` The decorator strings reference real `core.upgradeMigration.name` values (`${version}_${className}_${timestamp}`). A boot-time validator walks the actual `UpgradeSequenceReaderService.getUpgradeSequence()` and fails fast on typos. ## Files - New decorators: `engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator.ts`, `was-renamed-in-upgrade.decorator.ts` - Runtime: `engine/twenty-orm/upgrade-aware/` (adapter, proxy, install hook, state singleton, exceptions) - Wired into `UpgradeSequenceRunnerService` (`refresh()` between steps) and `TypeOrmModule.forRoot` (proxy install) - 2-6 entity decorations: `RolePermissionFlagEntity` (rename history + new `permissionFlagId` column), `PermissionFlagEntity` (new catalog) ## Validation End-to-end local cross-version upgrade (v1.22 → HEAD): `28 workspace(s) succeeded, 0 failed`; `upgrade:status → Instance: Up to date, 4 up to date, 0 behind, 0 failed`. Full log excerpts and the second-failure-found-and-fixed (`WorkspaceRolesPermissionsCacheService` relation load) in [this comment](https://github.com/twentyhq/twenty/pull/20686#issuecomment-4480036816). ## Test plan - [x] Adapter spec covers rename mutation; proxy spec covers `find()` short-circuit on unavailable entity. Resolver + validator + decorators are covered by `resolve-entity-shape-at-upgrade-cursor.util.spec.ts` (integration-level via real decorator application). - [x] `nx lint:diff-with-main twenty-server` + `nx typecheck twenty-server` clean - [x] All 82 affected tests passing - [ ] Cross-version-upgrade CI re-runs after this lands; v2.6.0 retag once green ## Follow-ups deferred - v2.7 `connectionProvider` rename repro as a permanent end-to-end test artifact - Extending the proxy to also cover `EntityManager.getRepository` and `createQueryBuilder` if a non-`find()` upgrade-time consumer surfaces --- .../src/database/typeorm/typeorm.module.ts | 18 +- .../upgrade-decorator-metadata.util.ts | 33 ++ .../was-introduced-in-upgrade.decorator.ts | 43 +++ .../was-renamed-in-upgrade.decorator.ts | 42 +++ .../upgrade-sequence-runner.service.ts | 29 ++ .../core-modules/upgrade/upgrade.module.ts | 3 + ...ntity-shape-at-upgrade-cursor.util.spec.ts | 185 ++++++++++ ...grade-aware-entity-decorators.util.spec.ts | 76 ++++ ...effective-name-from-rename-history.util.ts | 19 + ...lve-entity-shape-at-upgrade-cursor.util.ts | 85 +++++ ...te-upgrade-aware-entity-decorators.util.ts | 154 ++++++++ .../permission-flag/permission-flag.entity.ts | 5 + .../role-permission-flag.entity.ts | 13 + ...rade-aware-entity-metadata.adapter.spec.ts | 67 ++++ .../upgrade-aware-repository.proxy.spec.ts | 74 ++++ ...rade-unavailable-entity-write.exception.ts | 12 + .../install-upgrade-aware-repository-proxy.ts | 92 +++++ .../upgrade-aware-entity-metadata.adapter.ts | 347 ++++++++++++++++++ .../upgrade-aware-repository-state.ts | 37 ++ .../upgrade-aware-repository.proxy.ts | 275 ++++++++++++++ ...e-sequence-runner-integration-test.util.ts | 9 + 21 files changed, 1617 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/decorators/upgrade-decorator-metadata.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator.ts create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator.ts create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/resolve-entity-shape-at-upgrade-cursor.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/validate-upgrade-aware-entity-decorators.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/resolve-effective-name-from-rename-history.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/resolve-entity-shape-at-upgrade-cursor.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/utils/validate-upgrade-aware-entity-decorators.util.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/upgrade-aware/__tests__/upgrade-aware-entity-metadata.adapter.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/upgrade-aware/__tests__/upgrade-aware-repository.proxy.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/upgrade-aware/exceptions/upgrade-unavailable-entity-write.exception.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/upgrade-aware/install-upgrade-aware-repository-proxy.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository-state.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository.proxy.ts diff --git a/packages/twenty-server/src/database/typeorm/typeorm.module.ts b/packages/twenty-server/src/database/typeorm/typeorm.module.ts index ee2691e651..cd47754df2 100644 --- a/packages/twenty-server/src/database/typeorm/typeorm.module.ts +++ b/packages/twenty-server/src/database/typeorm/typeorm.module.ts @@ -1,12 +1,28 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { DataSource, type DataSourceOptions } from 'typeorm'; + import { typeORMCoreModuleOptions } from 'src/database/typeorm/core/core.datasource'; import { DatabaseGaugeService } from 'src/database/typeorm/database-gauge.service'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; +import { installUpgradeAwareRepositoryProxy } from 'src/engine/twenty-orm/upgrade-aware/install-upgrade-aware-repository-proxy'; @Module({ - imports: [TypeOrmModule.forRoot(typeORMCoreModuleOptions), MetricsModule], + imports: [ + TypeOrmModule.forRootAsync({ + useFactory: () => typeORMCoreModuleOptions, + dataSourceFactory: async (options) => { + const dataSource = new DataSource(options as DataSourceOptions); + + await dataSource.initialize(); + installUpgradeAwareRepositoryProxy(dataSource); + + return dataSource; + }, + }), + MetricsModule, + ], providers: [DatabaseGaugeService], exports: [], }) diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/decorators/upgrade-decorator-metadata.util.ts b/packages/twenty-server/src/engine/core-modules/upgrade/decorators/upgrade-decorator-metadata.util.ts new file mode 100644 index 0000000000..9528976642 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/decorators/upgrade-decorator-metadata.util.ts @@ -0,0 +1,33 @@ +import 'reflect-metadata'; + +import { isDefined } from 'twenty-shared/utils'; + +export const defineUpgradeMetadataOnClassOrProperty = ({ + classMetadataKey, + propertyMetadataKey, + value, + target, + propertyKey, +}: { + classMetadataKey: string; + propertyMetadataKey: string; + value: T; + target: object; + propertyKey: string | symbol | undefined; +}): void => { + if (!isDefined(propertyKey)) { + Reflect.defineMetadata(classMetadataKey, value, target); + + return; + } + + const constructor = (target as { constructor: Function }).constructor; + const existing: Record = + Reflect.getMetadata(propertyMetadataKey, constructor) ?? {}; + + Reflect.defineMetadata( + propertyMetadataKey, + { ...existing, [String(propertyKey)]: value }, + constructor, + ); +}; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator.ts b/packages/twenty-server/src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator.ts new file mode 100644 index 0000000000..1631391cb9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; + +import { defineUpgradeMetadataOnClassOrProperty } from 'src/engine/core-modules/upgrade/decorators/upgrade-decorator-metadata.util'; + +export type WasIntroducedInUpgradeOptions = { + upgradeCommandName: string; +}; + +export const WAS_INTRODUCED_IN_UPGRADE_CLASS_METADATA_KEY = + 'WAS_INTRODUCED_IN_UPGRADE_CLASS'; + +export const WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY = + 'WAS_INTRODUCED_IN_UPGRADE_PROPERTIES'; + +export type WasIntroducedInUpgradePropertyMap = Record< + string, + WasIntroducedInUpgradeOptions +>; + +export const WasIntroducedInUpgrade = + (options: WasIntroducedInUpgradeOptions) => + (target: object, propertyKey?: string | symbol): void => { + defineUpgradeMetadataOnClassOrProperty({ + classMetadataKey: WAS_INTRODUCED_IN_UPGRADE_CLASS_METADATA_KEY, + propertyMetadataKey: WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY, + value: options, + target, + propertyKey, + }); + }; + +export const getWasIntroducedInUpgradeClassMetadata = ( + target: Function, +): WasIntroducedInUpgradeOptions | undefined => + Reflect.getMetadata(WAS_INTRODUCED_IN_UPGRADE_CLASS_METADATA_KEY, target); + +export const getWasIntroducedInUpgradePropertyMetadata = ( + target: Function, +): WasIntroducedInUpgradePropertyMap => + Reflect.getMetadata( + WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY, + target, + ) ?? {}; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator.ts b/packages/twenty-server/src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator.ts new file mode 100644 index 0000000000..bd340c5330 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator.ts @@ -0,0 +1,42 @@ +import 'reflect-metadata'; + +import { defineUpgradeMetadataOnClassOrProperty } from 'src/engine/core-modules/upgrade/decorators/upgrade-decorator-metadata.util'; + +export type WasRenamedInUpgradeHistoryEntry = { + previousName: string; + upgradeCommandName: string; +}; + +export const WAS_RENAMED_IN_UPGRADE_CLASS_METADATA_KEY = + 'WAS_RENAMED_IN_UPGRADE_CLASS'; + +export const WAS_RENAMED_IN_UPGRADE_PROPERTIES_METADATA_KEY = + 'WAS_RENAMED_IN_UPGRADE_PROPERTIES'; + +export type WasRenamedInUpgradePropertyMap = Record< + string, + WasRenamedInUpgradeHistoryEntry[] +>; + +export const WasRenamedInUpgrade = + (history: WasRenamedInUpgradeHistoryEntry[]) => + (target: object, propertyKey?: string | symbol): void => { + defineUpgradeMetadataOnClassOrProperty({ + classMetadataKey: WAS_RENAMED_IN_UPGRADE_CLASS_METADATA_KEY, + propertyMetadataKey: WAS_RENAMED_IN_UPGRADE_PROPERTIES_METADATA_KEY, + value: history, + target, + propertyKey, + }); + }; + +export const getWasRenamedInUpgradeClassMetadata = ( + target: Function, +): WasRenamedInUpgradeHistoryEntry[] | undefined => + Reflect.getMetadata(WAS_RENAMED_IN_UPGRADE_CLASS_METADATA_KEY, target); + +export const getWasRenamedInUpgradePropertyMetadata = ( + target: Function, +): WasRenamedInUpgradePropertyMap => + Reflect.getMetadata(WAS_RENAMED_IN_UPGRADE_PROPERTIES_METADATA_KEY, target) ?? + {}; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service.ts b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service.ts index 5e370e6e65..9f12b0fa23 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service.ts @@ -18,6 +18,7 @@ import { } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service'; import { WorkspaceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/workspace-command-runner.service'; import { formatUpgradeLog } from 'src/engine/core-modules/upgrade/utils/format-upgrade-log.util'; +import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter'; import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; @@ -35,6 +36,7 @@ export class UpgradeSequenceRunnerService { private readonly instanceCommandRunnerService: InstanceCommandRunnerService, private readonly workspaceCommandRunnerService: WorkspaceCommandRunnerService, private readonly upgradeSequenceReaderService: UpgradeSequenceReaderService, + private readonly upgradeAwareEntityMetadataAdapter: UpgradeAwareEntityMetadataAdapter, private readonly workspaceIteratorService: WorkspaceIteratorService, private readonly workspaceVersionService: WorkspaceVersionService, ) {} @@ -50,6 +52,31 @@ export class UpgradeSequenceRunnerService { return { totalSuccesses: 0, totalFailures: 0 }; } + await this.upgradeAwareEntityMetadataAdapter.refresh(); + + try { + return await this.runInner({ sequence, options }); + } finally { + try { + await this.upgradeAwareEntityMetadataAdapter.refresh(); + } catch (refreshError) { + this.logger.error( + `Failed to refresh upgrade-aware entity metadata after run`, + refreshError instanceof Error + ? refreshError.stack + : String(refreshError), + ); + } + } + } + + private async runInner({ + sequence, + options, + }: { + sequence: UpgradeStep[]; + options: ParsedUpgradeCommandOptions; + }): Promise { const allActiveOrSuspendedWorkspaceIds = await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds(); @@ -107,6 +134,8 @@ export class UpgradeSequenceRunnerService { skipDataMigration: allActiveOrSuspendedWorkspaceIds.length === 0, }); + await this.upgradeAwareEntityMetadataAdapter.refresh(); + cursor++; continue; } diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts index a9bad4f5bc..b57873efd5 100644 --- a/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts +++ b/packages/twenty-server/src/engine/core-modules/upgrade/upgrade.module.ts @@ -18,6 +18,7 @@ import { WorkspaceCommandRunnerService } from 'src/engine/core-modules/upgrade/s import { UpgradeGaugeService } from 'src/engine/core-modules/upgrade/upgrade-gauge.service'; import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter'; import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-version/workspace-version.module'; @Module({ @@ -36,6 +37,7 @@ import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-v InstanceCommandRunnerService, WorkspaceCommandRunnerService, UpgradeCommandRegistryService, + UpgradeAwareEntityMetadataAdapter, UpgradeSequenceReaderService, UpgradeSequenceRunnerService, UpgradeStatusService, @@ -47,6 +49,7 @@ import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-v InstanceCommandRunnerService, WorkspaceCommandRunnerService, UpgradeCommandRegistryService, + UpgradeAwareEntityMetadataAdapter, UpgradeSequenceReaderService, UpgradeSequenceRunnerService, UpgradeStatusService, diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/resolve-entity-shape-at-upgrade-cursor.util.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/resolve-entity-shape-at-upgrade-cursor.util.spec.ts new file mode 100644 index 0000000000..6e969b11b5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/resolve-entity-shape-at-upgrade-cursor.util.spec.ts @@ -0,0 +1,185 @@ +import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator'; +import { WasRenamedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator'; +import { resolveEntityShapeAtUpgradeCursor } from 'src/engine/core-modules/upgrade/utils/resolve-entity-shape-at-upgrade-cursor.util'; + +const INTRODUCE_CMD = '2.7.0_IntroduceCommand_1800000000000'; +const RENAME_CMD = '2.6.0_RenameCommand_1700000000000'; +const PROP_INTRODUCE_CMD = '2.7.0_AddColumnCommand_1800000000001'; +const PROP_RENAME_CMD = '2.6.0_RenameColumnCommand_1700000000001'; + +const buildPredicate = (applied: string[]) => { + const set = new Set(applied); + + return (stepName: string) => set.has(stepName); +}; + +describe('resolveEntityShapeAtUpgradeCursor', () => { + describe('class-level @WasIntroducedInUpgrade', () => { + @WasIntroducedInUpgrade({ upgradeCommandName: INTRODUCE_CMD }) + class IntroducedEntity {} + + it('should mark entity unavailable before its introduction step applied', () => { + const result = resolveEntityShapeAtUpgradeCursor({ + entityClass: IntroducedEntity, + currentTableName: 'introducedEntity', + currentColumns: [], + isStepApplied: buildPredicate([]), + }); + + expect(result.isAvailable).toBe(false); + }); + + it('should mark entity available once introduction applied', () => { + const result = resolveEntityShapeAtUpgradeCursor({ + entityClass: IntroducedEntity, + currentTableName: 'introducedEntity', + currentColumns: [], + isStepApplied: buildPredicate([INTRODUCE_CMD]), + }); + + expect(result.isAvailable).toBe(true); + }); + }); + + describe('class-level @WasRenamedInUpgrade', () => { + @WasRenamedInUpgrade([ + { previousName: 'oldEntity', upgradeCommandName: RENAME_CMD }, + ]) + class RenamedEntity {} + + it('should report historical table name when rename not yet applied', () => { + const result = resolveEntityShapeAtUpgradeCursor({ + entityClass: RenamedEntity, + currentTableName: 'newEntity', + currentColumns: [], + isStepApplied: buildPredicate([]), + }); + + expect(result.effectiveTableName).toBe('oldEntity'); + expect(result.isAvailable).toBe(true); + }); + + it('should report current table name once rename applied', () => { + const result = resolveEntityShapeAtUpgradeCursor({ + entityClass: RenamedEntity, + currentTableName: 'newEntity', + currentColumns: [], + isStepApplied: buildPredicate([RENAME_CMD]), + }); + + expect(result.effectiveTableName).toBe('newEntity'); + }); + + it('should walk a multi-step rename history chronologically', () => { + const FIRST_RENAME_CMD = '2.5.0_FirstRename_1600000000000'; + const SECOND_RENAME_CMD = '2.6.0_SecondRename_1700000000000'; + + @WasRenamedInUpgrade([ + { previousName: 'firstName', upgradeCommandName: FIRST_RENAME_CMD }, + { previousName: 'secondName', upgradeCommandName: SECOND_RENAME_CMD }, + ]) + class TwiceRenamedEntity {} + + expect( + resolveEntityShapeAtUpgradeCursor({ + entityClass: TwiceRenamedEntity, + currentTableName: 'thirdName', + currentColumns: [], + isStepApplied: buildPredicate([]), + }).effectiveTableName, + ).toBe('firstName'); + + expect( + resolveEntityShapeAtUpgradeCursor({ + entityClass: TwiceRenamedEntity, + currentTableName: 'thirdName', + currentColumns: [], + isStepApplied: buildPredicate([FIRST_RENAME_CMD]), + }).effectiveTableName, + ).toBe('secondName'); + + expect( + resolveEntityShapeAtUpgradeCursor({ + entityClass: TwiceRenamedEntity, + currentTableName: 'thirdName', + currentColumns: [], + isStepApplied: buildPredicate([FIRST_RENAME_CMD, SECOND_RENAME_CMD]), + }).effectiveTableName, + ).toBe('thirdName'); + }); + }); + + describe('property-level decorators', () => { + class EntityWithProperties { + @WasIntroducedInUpgrade({ upgradeCommandName: PROP_INTRODUCE_CMD }) + newColumn!: string; + + @WasRenamedInUpgrade([ + { previousName: 'oldColumn', upgradeCommandName: PROP_RENAME_CMD }, + ]) + renamedColumn!: string; + + untouchedColumn!: string; + } + + const currentColumns = [ + { propertyName: 'newColumn', databaseName: 'newColumn' }, + { propertyName: 'renamedColumn', databaseName: 'renamedColumn' }, + { propertyName: 'untouchedColumn', databaseName: 'untouchedColumn' }, + ]; + + it('should hide not-yet-introduced columns and remap not-yet-renamed columns', () => { + const result = resolveEntityShapeAtUpgradeCursor({ + entityClass: EntityWithProperties, + currentTableName: 'entityWithProperties', + currentColumns, + isStepApplied: buildPredicate([]), + }); + + expect(result.hiddenPropertyNames).toEqual(new Set(['newColumn'])); + expect(Object.fromEntries(result.columnDatabaseNameRemap)).toEqual({ + renamedColumn: 'oldColumn', + }); + }); + + it('should leave both alone once both steps applied', () => { + const result = resolveEntityShapeAtUpgradeCursor({ + entityClass: EntityWithProperties, + currentTableName: 'entityWithProperties', + currentColumns, + isStepApplied: buildPredicate([PROP_INTRODUCE_CMD, PROP_RENAME_CMD]), + }); + + expect(result.hiddenPropertyNames.size).toBe(0); + expect(result.columnDatabaseNameRemap.size).toBe(0); + }); + + it('should leave undecorated columns untouched in all cases', () => { + const result = resolveEntityShapeAtUpgradeCursor({ + entityClass: EntityWithProperties, + currentTableName: 'entityWithProperties', + currentColumns, + isStepApplied: buildPredicate([]), + }); + + expect(result.hiddenPropertyNames.has('untouchedColumn')).toBe(false); + expect(result.columnDatabaseNameRemap.has('untouchedColumn')).toBe(false); + }); + }); + + it('should treat an entity with no decorators as available and unchanged', () => { + class Plain {} + + const result = resolveEntityShapeAtUpgradeCursor({ + entityClass: Plain, + currentTableName: 'plain', + currentColumns: [{ propertyName: 'id', databaseName: 'id' }], + isStepApplied: buildPredicate([]), + }); + + expect(result.isAvailable).toBe(true); + expect(result.effectiveTableName).toBe('plain'); + expect(result.hiddenPropertyNames.size).toBe(0); + expect(result.columnDatabaseNameRemap.size).toBe(0); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/validate-upgrade-aware-entity-decorators.util.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/validate-upgrade-aware-entity-decorators.util.spec.ts new file mode 100644 index 0000000000..d1cd4a1889 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/validate-upgrade-aware-entity-decorators.util.spec.ts @@ -0,0 +1,76 @@ +import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator'; +import { WasRenamedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator'; +import { validateUpgradeAwareEntityDecorators } from 'src/engine/core-modules/upgrade/utils/validate-upgrade-aware-entity-decorators.util'; + +describe('validateUpgradeAwareEntityDecorators', () => { + const KNOWN_CMD = '2.6.0_KnownCommand_1700000000000'; + const KNOWN_RENAME_CMD = '2.6.0_KnownRename_1700000000001'; + const KNOWN_LATER_RENAME_CMD = '2.7.0_LaterRename_1800000000000'; + const UNKNOWN_CMD = '2.6.0_UnknownCommand_9999999999999'; + + const buildStepNameToIndex = (names: string[]): ReadonlyMap => + new Map(names.map((name, index) => [name, index])); + + it('should report no problems when every decorator references a known command', () => { + @WasIntroducedInUpgrade({ upgradeCommandName: KNOWN_CMD }) + class IntroducedEntity {} + + @WasRenamedInUpgrade([ + { previousName: 'oldName', upgradeCommandName: KNOWN_RENAME_CMD }, + ]) + class RenamedEntity {} + + expect( + validateUpgradeAwareEntityDecorators({ + entityClasses: [IntroducedEntity, RenamedEntity], + stepNameToIndex: buildStepNameToIndex([KNOWN_CMD, KNOWN_RENAME_CMD]), + }), + ).toEqual([]); + }); + + it('should report a class-level unknown upgradeCommandName', () => { + @WasIntroducedInUpgrade({ upgradeCommandName: UNKNOWN_CMD }) + class BrokenIntroduced {} + + const problems = validateUpgradeAwareEntityDecorators({ + entityClasses: [BrokenIntroduced], + stepNameToIndex: buildStepNameToIndex([KNOWN_CMD]), + }); + + expect(problems).toEqual([ + { + kind: 'unknown-step-name', + entityName: 'BrokenIntroduced', + decorator: '@WasIntroducedInUpgrade', + scope: 'class', + upgradeCommandName: UNKNOWN_CMD, + }, + ]); + }); + + it('should report a rename history that is out of order versus the sequence', () => { + @WasRenamedInUpgrade([ + { previousName: 'first', upgradeCommandName: KNOWN_LATER_RENAME_CMD }, + { previousName: 'second', upgradeCommandName: KNOWN_RENAME_CMD }, + ]) + class ReverseOrdered {} + + const problems = validateUpgradeAwareEntityDecorators({ + entityClasses: [ReverseOrdered], + stepNameToIndex: buildStepNameToIndex([ + KNOWN_RENAME_CMD, + KNOWN_LATER_RENAME_CMD, + ]), + }); + + expect(problems).toEqual([ + { + kind: 'rename-history-out-of-order', + entityName: 'ReverseOrdered', + scope: 'class', + offendingUpgradeCommandName: KNOWN_RENAME_CMD, + precedingUpgradeCommandName: KNOWN_LATER_RENAME_CMD, + }, + ]); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/resolve-effective-name-from-rename-history.util.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/resolve-effective-name-from-rename-history.util.ts new file mode 100644 index 0000000000..7585e81673 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/resolve-effective-name-from-rename-history.util.ts @@ -0,0 +1,19 @@ +import { type WasRenamedInUpgradeHistoryEntry } from 'src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator'; + +export const resolveEffectiveNameFromRenameHistory = ({ + currentName, + history, + isStepApplied, +}: { + currentName: string; + history: WasRenamedInUpgradeHistoryEntry[]; + isStepApplied: (stepName: string) => boolean; +}): string => { + for (const entry of history) { + if (!isStepApplied(entry.upgradeCommandName)) { + return entry.previousName; + } + } + + return currentName; +}; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/resolve-entity-shape-at-upgrade-cursor.util.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/resolve-entity-shape-at-upgrade-cursor.util.ts new file mode 100644 index 0000000000..c991c47d0b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/resolve-entity-shape-at-upgrade-cursor.util.ts @@ -0,0 +1,85 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { + getWasIntroducedInUpgradeClassMetadata, + getWasIntroducedInUpgradePropertyMetadata, +} from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator'; +import { + getWasRenamedInUpgradeClassMetadata, + getWasRenamedInUpgradePropertyMetadata, +} from 'src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator'; +import { resolveEffectiveNameFromRenameHistory } from 'src/engine/core-modules/upgrade/utils/resolve-effective-name-from-rename-history.util'; + +export type ResolvedEntityShapeAtUpgradeCursor = { + isAvailable: boolean; + effectiveTableName: string; + hiddenPropertyNames: ReadonlySet; + columnDatabaseNameRemap: ReadonlyMap; +}; + +export const resolveEntityShapeAtUpgradeCursor = ({ + entityClass, + currentTableName, + currentColumns, + isStepApplied, +}: { + entityClass: Function; + currentTableName: string; + currentColumns: { propertyName: string; databaseName: string }[]; + isStepApplied: (stepName: string) => boolean; +}): ResolvedEntityShapeAtUpgradeCursor => { + const classIntroduced = getWasIntroducedInUpgradeClassMetadata(entityClass); + const isAvailable = + !isDefined(classIntroduced) || + isStepApplied(classIntroduced.upgradeCommandName); + + const classRenameHistory = + getWasRenamedInUpgradeClassMetadata(entityClass) ?? []; + const effectiveTableName = resolveEffectiveNameFromRenameHistory({ + currentName: currentTableName, + history: classRenameHistory, + isStepApplied, + }); + + const propertyIntroductionMap = + getWasIntroducedInUpgradePropertyMetadata(entityClass); + const propertyRenameMap = getWasRenamedInUpgradePropertyMetadata(entityClass); + + const hiddenPropertyNames = new Set(); + const columnDatabaseNameRemap = new Map(); + + for (const column of currentColumns) { + const introduced = propertyIntroductionMap[column.propertyName]; + + if ( + isDefined(introduced) && + !isStepApplied(introduced.upgradeCommandName) + ) { + hiddenPropertyNames.add(column.propertyName); + continue; + } + + const renameHistory = propertyRenameMap[column.propertyName] ?? []; + + if (renameHistory.length === 0) { + continue; + } + + const effectiveColumnName = resolveEffectiveNameFromRenameHistory({ + currentName: column.databaseName, + history: renameHistory, + isStepApplied, + }); + + if (effectiveColumnName !== column.databaseName) { + columnDatabaseNameRemap.set(column.propertyName, effectiveColumnName); + } + } + + return { + isAvailable, + effectiveTableName, + hiddenPropertyNames, + columnDatabaseNameRemap, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/validate-upgrade-aware-entity-decorators.util.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/validate-upgrade-aware-entity-decorators.util.ts new file mode 100644 index 0000000000..25705602c7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/validate-upgrade-aware-entity-decorators.util.ts @@ -0,0 +1,154 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { + getWasIntroducedInUpgradeClassMetadata, + getWasIntroducedInUpgradePropertyMetadata, +} from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator'; +import { + getWasRenamedInUpgradeClassMetadata, + getWasRenamedInUpgradePropertyMetadata, + type WasRenamedInUpgradeHistoryEntry, +} from 'src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator'; + +export type UpgradeAwareDecoratorReferenceProblem = + | { + kind: 'unknown-step-name'; + entityName: string; + decorator: '@WasIntroducedInUpgrade' | '@WasRenamedInUpgrade'; + scope: 'class' | `property:${string}`; + upgradeCommandName: string; + } + | { + kind: 'rename-history-out-of-order'; + entityName: string; + scope: 'class' | `property:${string}`; + offendingUpgradeCommandName: string; + precedingUpgradeCommandName: string; + }; + +export const validateUpgradeAwareEntityDecorators = ({ + entityClasses, + stepNameToIndex, +}: { + entityClasses: Function[]; + stepNameToIndex: ReadonlyMap; +}): UpgradeAwareDecoratorReferenceProblem[] => { + const problems: UpgradeAwareDecoratorReferenceProblem[] = []; + + for (const entityClass of entityClasses) { + const entityName = entityClass.name; + + const classIntroduced = getWasIntroducedInUpgradeClassMetadata(entityClass); + + if ( + isDefined(classIntroduced) && + !stepNameToIndex.has(classIntroduced.upgradeCommandName) + ) { + problems.push({ + kind: 'unknown-step-name', + entityName, + decorator: '@WasIntroducedInUpgrade', + scope: 'class', + upgradeCommandName: classIntroduced.upgradeCommandName, + }); + } + + const classRenameHistory = + getWasRenamedInUpgradeClassMetadata(entityClass) ?? []; + + checkHistoryForReferenceAndOrder({ + entityName, + scope: 'class', + history: classRenameHistory, + stepNameToIndex, + problems, + }); + + const propIntroducedMap = + getWasIntroducedInUpgradePropertyMetadata(entityClass); + + for (const [propertyName, options] of Object.entries(propIntroducedMap)) { + if (!stepNameToIndex.has(options.upgradeCommandName)) { + problems.push({ + kind: 'unknown-step-name', + entityName, + decorator: '@WasIntroducedInUpgrade', + scope: `property:${propertyName}`, + upgradeCommandName: options.upgradeCommandName, + }); + } + } + + const propRenameMap = getWasRenamedInUpgradePropertyMetadata(entityClass); + + for (const [propertyName, history] of Object.entries(propRenameMap)) { + checkHistoryForReferenceAndOrder({ + entityName, + scope: `property:${propertyName}`, + history, + stepNameToIndex, + problems, + }); + } + } + + return problems; +}; + +const checkHistoryForReferenceAndOrder = ({ + entityName, + scope, + history, + stepNameToIndex, + problems, +}: { + entityName: string; + scope: 'class' | `property:${string}`; + history: WasRenamedInUpgradeHistoryEntry[]; + stepNameToIndex: ReadonlyMap; + problems: UpgradeAwareDecoratorReferenceProblem[]; +}): void => { + let previousIndex = -1; + let previousName: string | undefined; + + for (const entry of history) { + const index = stepNameToIndex.get(entry.upgradeCommandName); + + if (!isDefined(index)) { + problems.push({ + kind: 'unknown-step-name', + entityName, + decorator: '@WasRenamedInUpgrade', + scope, + upgradeCommandName: entry.upgradeCommandName, + }); + continue; + } + + if (index <= previousIndex) { + problems.push({ + kind: 'rename-history-out-of-order', + entityName, + scope, + offendingUpgradeCommandName: entry.upgradeCommandName, + precedingUpgradeCommandName: previousName ?? '', + }); + } + + previousIndex = index; + previousName = entry.upgradeCommandName; + } +}; + +export const formatUpgradeAwareDecoratorReferenceProblems = ( + problems: UpgradeAwareDecoratorReferenceProblem[], +): string => + problems + .map((problem) => { + if (problem.kind === 'unknown-step-name') { + return ` - ${problem.entityName} ${problem.decorator} (${problem.scope}): unknown upgradeCommandName "${problem.upgradeCommandName}"`; + } + + return ` - ${problem.entityName} @WasRenamedInUpgrade (${problem.scope}): "${problem.offendingUpgradeCommandName}" must come after "${problem.precedingUpgradeCommandName}" in the upgrade sequence`; + }) + .join('\n'); diff --git a/packages/twenty-server/src/engine/metadata-modules/permission-flag/permission-flag.entity.ts b/packages/twenty-server/src/engine/metadata-modules/permission-flag/permission-flag.entity.ts index 984b39bf3e..c0c4809dbe 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permission-flag/permission-flag.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permission-flag/permission-flag.entity.ts @@ -10,11 +10,16 @@ import { UpdateDateColumn, } from 'typeorm'; +import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator'; import { type PermissionFlagPermissionType } from 'src/engine/metadata-modules/permission-flag/constants/permission-flag-permission-type.constant'; import { RolePermissionFlagEntity } from 'src/engine/metadata-modules/role-permission-flag/role-permission-flag.entity'; import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; @Entity('permissionFlag') +@WasIntroducedInUpgrade({ + upgradeCommandName: + '2.6.0_PermissionFlagSyncableEntityFastInstanceCommand_1778235340021', +}) @Unique('IDX_PERMISSION_FLAG_KEY_WORKSPACE_ID_UNIQUE', ['key', 'workspaceId']) @Index('IDX_PERMISSION_FLAG_APPLICATION_ID', ['applicationId']) export class PermissionFlagEntity extends SyncableEntity { diff --git a/packages/twenty-server/src/engine/metadata-modules/role-permission-flag/role-permission-flag.entity.ts b/packages/twenty-server/src/engine/metadata-modules/role-permission-flag/role-permission-flag.entity.ts index 5a30dd022f..3a328c6f8e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role-permission-flag/role-permission-flag.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role-permission-flag/role-permission-flag.entity.ts @@ -12,11 +12,20 @@ import { UpdateDateColumn, } from 'typeorm'; +import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator'; +import { WasRenamedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator'; import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; @Entity('rolePermissionFlag') +@WasRenamedInUpgrade([ + { + previousName: 'permissionFlag', + upgradeCommandName: + '2.6.0_RenamePermissionFlagToRolePermissionFlagFastInstanceCommand_1778235340020', + }, +]) @Unique('IDX_ROLE_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE', ['flag', 'roleId']) @Unique('IDX_ROLE_PERMISSION_FLAG_PERMISSION_FLAG_ID_ROLE_ID_UNIQUE', [ 'permissionFlagId', @@ -40,6 +49,10 @@ export class RolePermissionFlagEntity extends SyncableEntity { @Column({ nullable: false, type: 'varchar' }) flag: PermissionFlagType; + @WasIntroducedInUpgrade({ + upgradeCommandName: + '2.6.0_LinkRolePermissionFlagToPermissionFlagFastInstanceCommand_1778235340022', + }) @Column({ nullable: true, type: 'uuid' }) permissionFlagId: string | null; diff --git a/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/__tests__/upgrade-aware-entity-metadata.adapter.spec.ts b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/__tests__/upgrade-aware-entity-metadata.adapter.spec.ts new file mode 100644 index 0000000000..2200076f50 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/__tests__/upgrade-aware-entity-metadata.adapter.spec.ts @@ -0,0 +1,67 @@ +import 'reflect-metadata'; + +import { Test } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; + +import { type DataSource } from 'typeorm'; +import { type EntityMetadata } from 'typeorm/metadata/EntityMetadata'; + +import { WasRenamedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-renamed-in-upgrade.decorator'; +import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; +import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service'; +import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter'; + +const RENAME_STEP = '2.6.0_Rename_1700000000000'; + +@WasRenamedInUpgrade([ + { previousName: 'oldEntity', upgradeCommandName: RENAME_STEP }, +]) +class RenamedEntity {} + +describe('UpgradeAwareEntityMetadataAdapter', () => { + it('rewrites tableName / tablePath / givenTableName when the rename step is not yet applied', async () => { + const metadata = { + target: RenamedEntity, + tableName: 'newEntity', + tablePath: 'core.newEntity', + givenTableName: 'newEntity', + schema: 'core', + columns: [], + } as unknown as EntityMetadata; + + const dataSource = { + entityMetadatas: [metadata], + } as unknown as DataSource; + + const moduleRef = await Test.createTestingModule({ + providers: [ + UpgradeAwareEntityMetadataAdapter, + { + provide: UpgradeMigrationService, + useValue: { + getLastAttemptedInstanceCommand: jest.fn().mockResolvedValue(null), + }, + }, + { + provide: UpgradeSequenceReaderService, + useValue: { + getUpgradeSequence: jest + .fn() + .mockReturnValue([{ name: RENAME_STEP }]), + }, + }, + { provide: getDataSourceToken(), useValue: dataSource }, + ], + }).compile(); + + const adapter = moduleRef.get(UpgradeAwareEntityMetadataAdapter); + + await adapter.onModuleInit(); + + await adapter.refresh(); + + expect(metadata.tableName).toBe('oldEntity'); + expect(metadata.tablePath).toBe('core.oldEntity'); + expect(metadata.givenTableName).toBe('oldEntity'); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/__tests__/upgrade-aware-repository.proxy.spec.ts b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/__tests__/upgrade-aware-repository.proxy.spec.ts new file mode 100644 index 0000000000..9961f9a3f7 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/__tests__/upgrade-aware-repository.proxy.spec.ts @@ -0,0 +1,74 @@ +import 'reflect-metadata'; + +import { Test } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; + +import { type DataSource, type Repository } from 'typeorm'; +import { type EntityMetadata } from 'typeorm/metadata/EntityMetadata'; + +import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator'; +import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; +import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service'; +import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter'; +import { UpgradeAwareRepositoryState } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository-state'; +import { wrapRepositoryWithUpgradeAwareProxy } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository.proxy'; + +const INTRODUCE_STEP = '2.7.0_Introduce_1800000000000'; + +@WasIntroducedInUpgrade({ upgradeCommandName: INTRODUCE_STEP }) +class UnavailableEntity {} + +describe('wrapRepositoryWithUpgradeAwareProxy', () => { + it('short-circuits find() to an empty array when the entity is unavailable', async () => { + const metadata = { + target: UnavailableEntity, + tableName: 'unavailableEntity', + tablePath: 'core.unavailableEntity', + givenTableName: 'unavailableEntity', + schema: 'core', + columns: [], + } as unknown as EntityMetadata; + + const dataSource = { + entityMetadatas: [metadata], + } as unknown as DataSource; + + const moduleRef = await Test.createTestingModule({ + providers: [ + UpgradeAwareEntityMetadataAdapter, + { + provide: UpgradeMigrationService, + useValue: { + getLastAttemptedInstanceCommand: jest.fn().mockResolvedValue(null), + }, + }, + { + provide: UpgradeSequenceReaderService, + useValue: { + getUpgradeSequence: jest + .fn() + .mockReturnValue([{ name: INTRODUCE_STEP }]), + }, + }, + { provide: getDataSourceToken(), useValue: dataSource }, + ], + }).compile(); + + const adapter = moduleRef.get(UpgradeAwareEntityMetadataAdapter); + + await adapter.onModuleInit(); + await adapter.refresh(); + + const find = jest.fn().mockResolvedValue([{ id: 1 }]); + const repository = { find } as unknown as Repository; + + const wrapped = wrapRepositoryWithUpgradeAwareProxy({ + repository, + entityClass: UnavailableEntity, + state: UpgradeAwareRepositoryState.getInstance(), + }); + + await expect(wrapped.find()).resolves.toEqual([]); + expect(find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/exceptions/upgrade-unavailable-entity-write.exception.ts b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/exceptions/upgrade-unavailable-entity-write.exception.ts new file mode 100644 index 0000000000..a7d2eef9f7 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/exceptions/upgrade-unavailable-entity-write.exception.ts @@ -0,0 +1,12 @@ +export class UpgradeUnavailableEntityWriteException extends Error { + constructor(entityName: string, method: string) { + super( + `Cannot ${method} on ${entityName}: this entity is decorated with ` + + `@WasIntroducedInUpgrade and the introducing command has not been ` + + `applied at the current upgrade position. Run the upgrade further ` + + `before writing to it, or move the write later in the sequence.`, + ); + + this.name = 'UpgradeUnavailableEntityWriteException'; + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/install-upgrade-aware-repository-proxy.ts b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/install-upgrade-aware-repository-proxy.ts new file mode 100644 index 0000000000..8f415118f9 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/install-upgrade-aware-repository-proxy.ts @@ -0,0 +1,92 @@ +import { Logger } from '@nestjs/common'; + +import { + type DataSource, + type EntityManager, + type EntityTarget, + type Repository, +} from 'typeorm'; +import { isDefined } from 'twenty-shared/utils'; + +import { UpgradeAwareRepositoryState } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository-state'; +import { wrapRepositoryWithUpgradeAwareProxy } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository.proxy'; + +const logger = new Logger('InstallUpgradeAwareRepositoryProxy'); + +const wrappedRepositoryCache = new WeakMap(); + +export const installUpgradeAwareRepositoryProxy = ( + dataSource: DataSource, +): void => { + const state = UpgradeAwareRepositoryState.getInstance(); + + const wrapIfNeeded = ( + target: EntityTarget, + repository: Repository, + ): Repository => { + const entityClass = resolveEntityClass(target); + + if (!isDefined(entityClass)) { + return repository; + } + + const cached = wrappedRepositoryCache.get(repository); + + if (isDefined(cached)) { + return cached as typeof repository; + } + + const wrapped = wrapRepositoryWithUpgradeAwareProxy({ + repository, + entityClass, + state, + }); + + wrappedRepositoryCache.set(repository, wrapped); + + return wrapped; + }; + + const originalDataSourceGetRepository = + dataSource.getRepository.bind(dataSource); + + dataSource.getRepository = function getRepositoryWithUpgradeAwareProxy< + Entity extends object, + >(target: EntityTarget) { + return wrapIfNeeded(target, originalDataSourceGetRepository(target)); + } as DataSource['getRepository']; + + const entityManagerPrototype = Object.getPrototypeOf(dataSource.manager) as { + getRepository: EntityManager['getRepository']; + }; + const originalEntityManagerGetRepository = + entityManagerPrototype.getRepository; + + entityManagerPrototype.getRepository = + function getRepositoryWithUpgradeAwareProxy( + this: EntityManager, + target: EntityTarget, + ) { + const repository = originalEntityManagerGetRepository.call(this, target); + + if (this.connection !== dataSource) { + return repository; + } + + return wrapIfNeeded(target, repository); + } as EntityManager['getRepository']; + + logger.log( + '[upgrade-proxy] installed getRepository proxy on core DataSource and EntityManager.prototype', + ); +}; + +const resolveEntityClass = ( + target: EntityTarget, +): Function | undefined => { + if (typeof target === 'function') { + return target; + } + + return undefined; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter.ts b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter.ts new file mode 100644 index 0000000000..3ecd73baed --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter.ts @@ -0,0 +1,347 @@ +import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; + +import { type ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; +import { type EntityMetadata } from 'typeorm/metadata/EntityMetadata'; + +import { DataSource } from 'typeorm'; +import { isDefined } from 'twenty-shared/utils'; + +import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service'; +import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service'; +import { + resolveEntityShapeAtUpgradeCursor, + type ResolvedEntityShapeAtUpgradeCursor, +} from 'src/engine/core-modules/upgrade/utils/resolve-entity-shape-at-upgrade-cursor.util'; +import { + formatUpgradeAwareDecoratorReferenceProblems, + validateUpgradeAwareEntityDecorators, +} from 'src/engine/core-modules/upgrade/utils/validate-upgrade-aware-entity-decorators.util'; +import { UpgradeAwareRepositoryState } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository-state'; + +type EntityMetadataSnapshot = { + tableName: string; + tablePath: string; + givenTableName: string | undefined; + columnDatabaseNamesByPropertyName: ReadonlyMap; + columnSelectByPropertyName: ReadonlyMap; +}; + +@Injectable() +export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit { + private readonly logger = new Logger(UpgradeAwareEntityMetadataAdapter.name); + + private readonly snapshotByMetadata = new WeakMap< + EntityMetadata, + EntityMetadataSnapshot + >(); + + private readonly availabilityByEntityClass = new WeakMap(); + private readonly hiddenColumnsByEntityClass = new WeakMap< + Function, + ReadonlySet + >(); + + private stepNameToIndex: Map = new Map(); + private currentCursor = Number.MAX_SAFE_INTEGER; + + constructor( + @InjectDataSource() + private readonly coreDataSource: DataSource, + private readonly upgradeSequenceReaderService: UpgradeSequenceReaderService, + private readonly upgradeMigrationService: UpgradeMigrationService, + ) {} + + async onModuleInit(): Promise { + const sequence = this.upgradeSequenceReaderService.getUpgradeSequence(); + + for (const [index, step] of sequence.entries()) { + this.stepNameToIndex.set(step.name, index); + } + + this.validateDecoratorsAgainstSequence(); + this.captureCanonicalSnapshots(); + + this.currentCursor = sequence.length; + this.applyCursorToMetadata(); + + UpgradeAwareRepositoryState.getInstance().setMetadataService(this); + + try { + await this.refresh(); + } catch (error) { + this.logger.log( + `[upgrade-metadata] initial refresh skipped (core.upgradeMigration not readable yet): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + async refresh(): Promise { + const lastAttempted = + await this.upgradeMigrationService.getLastAttemptedInstanceCommand(); + + let nextCursor: number; + + if (!isDefined(lastAttempted)) { + nextCursor = 0; + } else { + const index = this.stepNameToIndex.get(lastAttempted.name); + + if (!isDefined(index)) { + nextCursor = 0; + } else { + nextCursor = lastAttempted.status === 'completed' ? index + 1 : index; + } + } + + if (nextCursor === this.currentCursor) { + return; + } + + this.currentCursor = nextCursor; + this.applyCursorToMetadata(); + } + + isEntityAvailable(entityClass: Function): boolean { + return this.availabilityByEntityClass.get(entityClass) ?? true; + } + + getHiddenColumnPropertyNames(entityClass: Function): ReadonlySet { + return this.hiddenColumnsByEntityClass.get(entityClass) ?? new Set(); + } + + private captureCanonicalSnapshots(): void { + for (const metadata of this.coreDataSource.entityMetadatas) { + const columnDatabaseNamesByPropertyName = new Map(); + const columnSelectByPropertyName = new Map(); + + for (const column of metadata.columns) { + columnDatabaseNamesByPropertyName.set( + column.propertyName, + column.databaseName, + ); + columnSelectByPropertyName.set(column.propertyName, column.isSelect); + } + + this.snapshotByMetadata.set(metadata, { + tableName: metadata.tableName, + tablePath: metadata.tablePath, + givenTableName: metadata.givenTableName, + columnDatabaseNamesByPropertyName, + columnSelectByPropertyName, + }); + } + } + + private applyCursorToMetadata(): void { + const isStepApplied = this.buildIsStepAppliedPredicate(); + + let renamedCount = 0; + let unavailableCount = 0; + let hiddenColumnCount = 0; + + for (const metadata of this.coreDataSource.entityMetadatas) { + const applied = this.applyCursorToEntity({ metadata, isStepApplied }); + + if (!isDefined(applied)) { + continue; + } + + if (applied.resolved.effectiveTableName !== applied.snapshot.tableName) { + renamedCount++; + } + + if (!applied.resolved.isAvailable) { + unavailableCount++; + } + + hiddenColumnCount += applied.resolved.hiddenPropertyNames.size; + } + + this.logger.log( + `[upgrade-metadata] applied cursor=${this.currentCursor} renamed=${renamedCount} unavailable=${unavailableCount} hiddenColumns=${hiddenColumnCount}`, + ); + } + + private buildIsStepAppliedPredicate(): (stepName: string) => boolean { + return (stepName: string) => { + const index = this.stepNameToIndex.get(stepName); + + if (!isDefined(index)) { + return false; + } + + return index < this.currentCursor; + }; + } + + private applyCursorToEntity({ + metadata, + isStepApplied, + }: { + metadata: EntityMetadata; + isStepApplied: (stepName: string) => boolean; + }): + | { + snapshot: EntityMetadataSnapshot; + resolved: ResolvedEntityShapeAtUpgradeCursor; + } + | undefined { + const snapshot = this.snapshotByMetadata.get(metadata); + + if (!isDefined(snapshot) || typeof metadata.target !== 'function') { + return undefined; + } + + const entityClass = metadata.target; + + const currentColumns = [...snapshot.columnDatabaseNamesByPropertyName].map( + ([propertyName, databaseName]) => ({ propertyName, databaseName }), + ); + + const resolved = resolveEntityShapeAtUpgradeCursor({ + entityClass, + currentTableName: snapshot.tableName, + currentColumns, + isStepApplied, + }); + + this.applyResolvedShapeToMetadata({ metadata, snapshot, resolved }); + + this.availabilityByEntityClass.set(entityClass, resolved.isAvailable); + this.hiddenColumnsByEntityClass.set( + entityClass, + resolved.hiddenPropertyNames, + ); + + this.logResolvedShape({ entityClass, snapshot, resolved }); + + return { snapshot, resolved }; + } + + private logResolvedShape({ + entityClass, + snapshot, + resolved, + }: { + entityClass: Function; + snapshot: EntityMetadataSnapshot; + resolved: ResolvedEntityShapeAtUpgradeCursor; + }): void { + if (resolved.effectiveTableName !== snapshot.tableName) { + this.logger.log( + `[upgrade-metadata] rename ${entityClass.name} ${snapshot.tableName} -> ${resolved.effectiveTableName}`, + ); + } + + if (!resolved.isAvailable) { + this.logger.log(`[upgrade-metadata] unavailable ${entityClass.name}`); + } + + if (resolved.hiddenPropertyNames.size > 0) { + this.logger.log( + `[upgrade-metadata] hidden columns on ${entityClass.name}: ${[...resolved.hiddenPropertyNames].join(',')}`, + ); + } + } + + private applyResolvedShapeToMetadata({ + metadata, + snapshot, + resolved, + }: { + metadata: EntityMetadata; + snapshot: EntityMetadataSnapshot; + resolved: ResolvedEntityShapeAtUpgradeCursor; + }): void { + if (resolved.effectiveTableName === snapshot.tableName) { + metadata.tableName = snapshot.tableName; + metadata.tablePath = snapshot.tablePath; + metadata.givenTableName = snapshot.givenTableName; + } else { + metadata.tableName = resolved.effectiveTableName; + metadata.tablePath = this.computeTablePath({ + metadata, + effectiveTableName: resolved.effectiveTableName, + }); + metadata.givenTableName = resolved.effectiveTableName; + } + + for (const column of metadata.columns) { + this.applyColumnShape({ column, snapshot, resolved }); + } + } + + private applyColumnShape({ + column, + snapshot, + resolved, + }: { + column: ColumnMetadata; + snapshot: EntityMetadataSnapshot; + resolved: ResolvedEntityShapeAtUpgradeCursor; + }): void { + const canonicalName = snapshot.columnDatabaseNamesByPropertyName.get( + column.propertyName, + ); + + if (!isDefined(canonicalName)) { + return; + } + + const remappedName = resolved.columnDatabaseNameRemap.get( + column.propertyName, + ); + + column.databaseName = remappedName ?? canonicalName; + + const canonicalIsSelect = + snapshot.columnSelectByPropertyName.get(column.propertyName) ?? true; + + column.isSelect = resolved.hiddenPropertyNames.has(column.propertyName) + ? false + : canonicalIsSelect; + } + + private computeTablePath({ + metadata, + effectiveTableName, + }: { + metadata: EntityMetadata; + effectiveTableName: string; + }): string { + if (metadata.schema) { + return `${metadata.schema}.${effectiveTableName}`; + } + + if (metadata.database) { + return `${metadata.database}.${effectiveTableName}`; + } + + return effectiveTableName; + } + + private validateDecoratorsAgainstSequence(): void { + const entityClasses = this.coreDataSource.entityMetadatas + .map((metadata) => metadata.target) + .filter((target): target is Function => typeof target === 'function'); + + const problems = validateUpgradeAwareEntityDecorators({ + entityClasses, + stepNameToIndex: this.stepNameToIndex, + }); + + if (problems.length === 0) { + return; + } + + const formatted = formatUpgradeAwareDecoratorReferenceProblems(problems); + + throw new Error( + `Upgrade-aware entity decorators have problems. ` + + `Either fix the upgradeCommandName strings, register the missing steps, or reorder the rename history.\n${formatted}`, + ); + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository-state.ts b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository-state.ts new file mode 100644 index 0000000000..4157f31009 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository-state.ts @@ -0,0 +1,37 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter'; + +export class UpgradeAwareRepositoryState { + private static singleton: UpgradeAwareRepositoryState | undefined; + + private metadataService: UpgradeAwareEntityMetadataAdapter | undefined; + + static getInstance(): UpgradeAwareRepositoryState { + if (!isDefined(this.singleton)) { + this.singleton = new UpgradeAwareRepositoryState(); + } + + return this.singleton; + } + + setMetadataService(service: UpgradeAwareEntityMetadataAdapter): void { + this.metadataService = service; + } + + isEntityAvailable(entityClass: Function): boolean { + if (!isDefined(this.metadataService)) { + return true; + } + + return this.metadataService.isEntityAvailable(entityClass); + } + + getHiddenColumnPropertyNames(entityClass: Function): ReadonlySet { + if (!isDefined(this.metadataService)) { + return new Set(); + } + + return this.metadataService.getHiddenColumnPropertyNames(entityClass); + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository.proxy.ts b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository.proxy.ts new file mode 100644 index 0000000000..8f0de48bd4 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository.proxy.ts @@ -0,0 +1,275 @@ +import { Logger } from '@nestjs/common'; + +import { type Repository } from 'typeorm'; +import { EntityNotFoundError } from 'typeorm/error/EntityNotFoundError'; +import { type EntityMetadata } from 'typeorm/metadata/EntityMetadata'; +import { isDefined } from 'twenty-shared/utils'; + +import { type UpgradeAwareRepositoryState } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository-state'; +import { UpgradeUnavailableEntityWriteException } from 'src/engine/twenty-orm/upgrade-aware/exceptions/upgrade-unavailable-entity-write.exception'; + +const logger = new Logger('UpgradeAwareRepositoryProxy'); + +type RepositoryMethodBehavior = + | { + kind: 'short-circuit-read'; + produceEmpty: (entityClass: Function) => Promise; + } + | { kind: 'throw-on-unavailable-write' }; + +const REPOSITORY_METHOD_BEHAVIORS = new Map([ + [ + 'find', + { kind: 'short-circuit-read', produceEmpty: () => Promise.resolve([]) }, + ], + [ + 'findBy', + { kind: 'short-circuit-read', produceEmpty: () => Promise.resolve([]) }, + ], + [ + 'findAndCount', + { + kind: 'short-circuit-read', + produceEmpty: () => Promise.resolve([[], 0]), + }, + ], + [ + 'findAndCountBy', + { + kind: 'short-circuit-read', + produceEmpty: () => Promise.resolve([[], 0]), + }, + ], + [ + 'findOne', + { kind: 'short-circuit-read', produceEmpty: () => Promise.resolve(null) }, + ], + [ + 'findOneBy', + { kind: 'short-circuit-read', produceEmpty: () => Promise.resolve(null) }, + ], + [ + 'findOneOrFail', + { + kind: 'short-circuit-read', + produceEmpty: (entityClass) => + Promise.reject(new EntityNotFoundError(entityClass, undefined)), + }, + ], + [ + 'findOneByOrFail', + { + kind: 'short-circuit-read', + produceEmpty: (entityClass) => + Promise.reject(new EntityNotFoundError(entityClass, undefined)), + }, + ], + [ + 'count', + { kind: 'short-circuit-read', produceEmpty: () => Promise.resolve(0) }, + ], + [ + 'countBy', + { kind: 'short-circuit-read', produceEmpty: () => Promise.resolve(0) }, + ], + [ + 'exists', + { kind: 'short-circuit-read', produceEmpty: () => Promise.resolve(false) }, + ], + [ + 'existsBy', + { kind: 'short-circuit-read', produceEmpty: () => Promise.resolve(false) }, + ], + ['save', { kind: 'throw-on-unavailable-write' }], + ['insert', { kind: 'throw-on-unavailable-write' }], + ['update', { kind: 'throw-on-unavailable-write' }], + ['delete', { kind: 'throw-on-unavailable-write' }], + ['remove', { kind: 'throw-on-unavailable-write' }], + ['softRemove', { kind: 'throw-on-unavailable-write' }], + ['recover', { kind: 'throw-on-unavailable-write' }], + ['upsert', { kind: 'throw-on-unavailable-write' }], + ['increment', { kind: 'throw-on-unavailable-write' }], + ['decrement', { kind: 'throw-on-unavailable-write' }], + ['restore', { kind: 'throw-on-unavailable-write' }], + ['softDelete', { kind: 'throw-on-unavailable-write' }], +]); + +const METHODS_THAT_ACCEPT_FIND_OPTIONS = new Set([ + 'find', + 'findBy', + 'findAndCount', + 'findAndCountBy', + 'findOne', + 'findOneBy', + 'findOneOrFail', + 'findOneByOrFail', + 'count', + 'countBy', + 'exists', + 'existsBy', +]); + +const stripUnavailableRelations = ( + metadata: EntityMetadata, + state: UpgradeAwareRepositoryState, + options: unknown, +): unknown => { + if (!isDefined(options) || typeof options !== 'object') { + return options; + } + + const withRelations = options as { relations?: unknown }; + + if (!isDefined(withRelations.relations)) { + return options; + } + + if (Array.isArray(withRelations.relations)) { + const filtered = (withRelations.relations as string[]).filter((name) => + isRelationAvailable(metadata, state, name), + ); + + if (filtered.length === withRelations.relations.length) { + return options; + } + + return { ...withRelations, relations: filtered }; + } + + if (typeof withRelations.relations === 'object') { + const filtered: Record = {}; + + for (const [name, value] of Object.entries( + withRelations.relations as Record, + )) { + if (isRelationAvailable(metadata, state, name)) { + filtered[name] = value; + } + } + + return { ...withRelations, relations: filtered }; + } + + return options; +}; + +const isRelationAvailable = ( + metadata: EntityMetadata, + state: UpgradeAwareRepositoryState, + relationPropertyName: string, +): boolean => { + const relation = metadata.relations.find( + (candidate) => candidate.propertyName === relationPropertyName, + ); + + if (!isDefined(relation)) { + return true; + } + + const relatedTarget = relation.inverseEntityMetadata?.target; + + if (typeof relatedTarget !== 'function') { + return true; + } + + const available = state.isEntityAvailable(relatedTarget); + + if (!available) { + logger.log( + `[upgrade-proxy] strip relation ${metadata.targetName}.${relationPropertyName} -> ${relatedTarget.name}`, + ); + } + + return available; +}; + +const isClassConstructor = (fn: Function): boolean => + typeof fn.prototype === 'object' && + fn.prototype !== null && + fn.prototype.constructor === fn && + fn.toString().startsWith('class '); + +export const wrapRepositoryWithUpgradeAwareProxy = ({ + repository, + entityClass, + state, +}: { + repository: Repository; + entityClass: Function; + state: UpgradeAwareRepositoryState; +}): Repository => + new Proxy(repository, { + get(target, prop, receiver) { + const methodName = typeof prop === 'string' ? prop : undefined; + const behavior = isDefined(methodName) + ? REPOSITORY_METHOD_BEHAVIORS.get(methodName) + : undefined; + + if (isDefined(methodName) && isDefined(behavior)) { + return (...args: unknown[]) => + handleRepositoryMethodCall({ + target, + methodName, + entityClass, + state, + behavior, + args, + }); + } + + const value = Reflect.get(target, prop, receiver); + + if (typeof value === 'function' && !isClassConstructor(value)) { + return value.bind(target); + } + + return value; + }, + }); + +const handleRepositoryMethodCall = ({ + target, + methodName, + entityClass, + state, + behavior, + args, +}: { + target: Repository; + methodName: string; + entityClass: Function; + state: UpgradeAwareRepositoryState; + behavior: RepositoryMethodBehavior; + args: unknown[]; +}): unknown => { + if (!state.isEntityAvailable(entityClass)) { + if (behavior.kind === 'throw-on-unavailable-write') { + return Promise.reject( + new UpgradeUnavailableEntityWriteException( + entityClass.name, + methodName, + ), + ); + } + + logger.log( + `[upgrade-proxy] short-circuit ${entityClass.name}.${methodName}`, + ); + + return behavior.produceEmpty(entityClass); + } + + const rewrittenArgs = + METHODS_THAT_ACCEPT_FIND_OPTIONS.has(methodName) && args.length > 0 + ? [ + stripUnavailableRelations(target.metadata, state, args[0]), + ...args.slice(1), + ] + : args; + + return ( + target[methodName as keyof Repository] as unknown as ( + ...callArgs: unknown[] + ) => unknown + ).apply(target, rewrittenArgs); +}; diff --git a/packages/twenty-server/test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util.ts b/packages/twenty-server/test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util.ts index 947ce71895..1da628878e 100644 --- a/packages/twenty-server/test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util.ts +++ b/packages/twenty-server/test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util.ts @@ -14,6 +14,7 @@ import { type WorkspaceUpgradeStep, } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service'; import { UpgradeSequenceRunnerService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service'; +import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter'; import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service'; import { WorkspaceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/workspace-command-runner.service'; import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity'; @@ -178,6 +179,14 @@ export const createUpgradeSequenceRunnerIntegrationTestModule = async () => { }), }, }, + { + provide: UpgradeAwareEntityMetadataAdapter, + useValue: { + refresh: jest.fn().mockResolvedValue(undefined), + isEntityAvailable: jest.fn().mockReturnValue(true), + getHiddenColumnPropertyNames: jest.fn().mockReturnValue(new Set()), + }, + }, UpgradeSequenceRunnerService, ], }).compile();