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
@@ -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);
};