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:
Charles Bochet
2026-05-18 21:38:20 +02:00
committed by GitHub
parent 89579f5225
commit 1d3d3999e2
21 changed files with 1617 additions and 1 deletions
@@ -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: [],
})
@@ -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,
);
};
@@ -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,
) ?? {};
@@ -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) ??
{};
@@ -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,
@@ -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);
});
});
@@ -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,
},
]);
});
});
@@ -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;
};
@@ -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,
};
};
@@ -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');
@@ -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 {
@@ -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;
@@ -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');
});
});
@@ -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();
});
});
@@ -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';
}
}
@@ -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;
};
@@ -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}`,
);
}
}
@@ -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);
}
}
@@ -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);
};
@@ -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();