feat(server): upgrade-aware entity decorators for cross-version upgrades (#20686)
## 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
This commit is contained in:
@@ -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: [],
|
||||
})
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const defineUpgradeMetadataOnClassOrProperty = <T>({
|
||||
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<string, T> =
|
||||
Reflect.getMetadata(propertyMetadataKey, constructor) ?? {};
|
||||
|
||||
Reflect.defineMetadata(
|
||||
propertyMetadataKey,
|
||||
{ ...existing, [String(propertyKey)]: value },
|
||||
constructor,
|
||||
);
|
||||
};
|
||||
+43
@@ -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,
|
||||
) ?? {};
|
||||
+42
@@ -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) ??
|
||||
{};
|
||||
+29
@@ -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<UpgradeSequenceRunnerReport> {
|
||||
const allActiveOrSuspendedWorkspaceIds =
|
||||
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
|
||||
|
||||
@@ -107,6 +134,8 @@ export class UpgradeSequenceRunnerService {
|
||||
skipDataMigration: allActiveOrSuspendedWorkspaceIds.length === 0,
|
||||
});
|
||||
|
||||
await this.upgradeAwareEntityMetadataAdapter.refresh();
|
||||
|
||||
cursor++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+185
@@ -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);
|
||||
});
|
||||
});
|
||||
+76
@@ -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<string, number> =>
|
||||
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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+19
@@ -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;
|
||||
};
|
||||
+85
@@ -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<string>;
|
||||
columnDatabaseNameRemap: ReadonlyMap<string, string>;
|
||||
};
|
||||
|
||||
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<string>();
|
||||
const columnDatabaseNameRemap = new Map<string, string>();
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
+154
@@ -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<string, number>;
|
||||
}): 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<string, number>;
|
||||
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');
|
||||
+5
@@ -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 {
|
||||
|
||||
+13
@@ -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;
|
||||
|
||||
|
||||
+67
@@ -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');
|
||||
});
|
||||
});
|
||||
+74
@@ -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<UnavailableEntity>;
|
||||
|
||||
const wrapped = wrapRepositoryWithUpgradeAwareProxy({
|
||||
repository,
|
||||
entityClass: UnavailableEntity,
|
||||
state: UpgradeAwareRepositoryState.getInstance(),
|
||||
});
|
||||
|
||||
await expect(wrapped.find()).resolves.toEqual([]);
|
||||
expect(find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+12
@@ -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';
|
||||
}
|
||||
}
|
||||
+92
@@ -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<object, object>();
|
||||
|
||||
export const installUpgradeAwareRepositoryProxy = (
|
||||
dataSource: DataSource,
|
||||
): void => {
|
||||
const state = UpgradeAwareRepositoryState.getInstance();
|
||||
|
||||
const wrapIfNeeded = <Entity extends object>(
|
||||
target: EntityTarget<Entity>,
|
||||
repository: Repository<Entity>,
|
||||
): Repository<Entity> => {
|
||||
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<Entity>) {
|
||||
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<Entity extends object>(
|
||||
this: EntityManager,
|
||||
target: EntityTarget<Entity>,
|
||||
) {
|
||||
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 = <Entity extends object>(
|
||||
target: EntityTarget<Entity>,
|
||||
): Function | undefined => {
|
||||
if (typeof target === 'function') {
|
||||
return target;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
+347
@@ -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<string, string>;
|
||||
columnSelectByPropertyName: ReadonlyMap<string, boolean>;
|
||||
};
|
||||
|
||||
@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<Function, boolean>();
|
||||
private readonly hiddenColumnsByEntityClass = new WeakMap<
|
||||
Function,
|
||||
ReadonlySet<string>
|
||||
>();
|
||||
|
||||
private stepNameToIndex: Map<string, number> = 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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
return this.hiddenColumnsByEntityClass.get(entityClass) ?? new Set();
|
||||
}
|
||||
|
||||
private captureCanonicalSnapshots(): void {
|
||||
for (const metadata of this.coreDataSource.entityMetadatas) {
|
||||
const columnDatabaseNamesByPropertyName = new Map<string, string>();
|
||||
const columnSelectByPropertyName = new Map<string, boolean>();
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+37
@@ -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<string> {
|
||||
if (!isDefined(this.metadataService)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.metadataService.getHiddenColumnPropertyNames(entityClass);
|
||||
}
|
||||
}
|
||||
+275
@@ -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<unknown>;
|
||||
}
|
||||
| { kind: 'throw-on-unavailable-write' };
|
||||
|
||||
const REPOSITORY_METHOD_BEHAVIORS = new Map<string, RepositoryMethodBehavior>([
|
||||
[
|
||||
'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<string>([
|
||||
'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<string, unknown> = {};
|
||||
|
||||
for (const [name, value] of Object.entries(
|
||||
withRelations.relations as Record<string, unknown>,
|
||||
)) {
|
||||
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 = <Entity extends object>({
|
||||
repository,
|
||||
entityClass,
|
||||
state,
|
||||
}: {
|
||||
repository: Repository<Entity>;
|
||||
entityClass: Function;
|
||||
state: UpgradeAwareRepositoryState;
|
||||
}): Repository<Entity> =>
|
||||
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 = <Entity extends object>({
|
||||
target,
|
||||
methodName,
|
||||
entityClass,
|
||||
state,
|
||||
behavior,
|
||||
args,
|
||||
}: {
|
||||
target: Repository<Entity>;
|
||||
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<Entity>] as unknown as (
|
||||
...callArgs: unknown[]
|
||||
) => unknown
|
||||
).apply(target, rewrittenArgs);
|
||||
};
|
||||
+9
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user