Files
twenty/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-repository.proxy.ts
T
Charles Bochet 1d3d3999e2 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
2026-05-18 21:38:20 +02:00

276 lines
7.1 KiB
TypeScript

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