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:
+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');
|
||||
Reference in New Issue
Block a user