Add @WasRemovedInUpgrade decorator (#20729)

## Summary

Adds the symmetric counterpart to `@WasIntroducedInUpgrade` for the
upgrade-aware ORM. Today the framework can describe "this column will
exist once upgrade X applies" but not "this
column will stop existing once upgrade X applies". Plain field deletion
only works when nothing writes to the table during the mid-state window
between the binary booting and the drop
migration completing for a given workspace — fine for sparse tables
(`DropWorkspaceVersionColumn`, `DropPostgresCredentialsTable`), risky
for hot-write tables.

This PR ships the primitive on its own so the upcoming
`rolePermissionFlag.flag` drop has the framework support it needs. No
in-tree consumer yet — coverage is via unit tests against
synthetic entities.

### What's in it

- **New `@WasRemovedInUpgrade({ upgradeCommandName })` decorator**
(class- or property-scope) — mirrors `@WasIntroducedInUpgrade`, uses the
shared
`defineUpgradeMetadataOnClassOrProperty` helper, exposes class +
property getters.
- **`resolveEntityShapeAtUpgradeCursor`** now folds applied-removals
into the existing `hiddenPropertyNames` set. Intro-pending and
removal-applied share one hide bucket — both ask
TypeORM for the same thing.
- **`UpgradeAwareEntityMetadataAdapter`** now disables `isSelect`,
`isInsert`, **and** `isUpdate` for any hidden column, restoring
canonical values when the column comes back.
Previously only `isSelect` was flipped, which left an
INSERT-into-nonexistent-column hole the intro path was tacitly relying
on application code to avoid; this PR closes that hole for
both directions.
- **`validateUpgradeAwareEntityDecorators`** validates
`@WasRemovedInUpgrade` `upgradeCommandName` references, and surfaces a
new `removal-before-introduction` problem when a property
has both decorators with the removal step preceding the introduction
step.
This commit is contained in:
Weiko
2026-05-19 16:57:05 +02:00
committed by GitHub
parent e463a09e17
commit ac432d3195
8 changed files with 465 additions and 5 deletions
@@ -0,0 +1,42 @@
import {
WasRemovedInUpgrade,
getWasRemovedInUpgradeClassMetadata,
getWasRemovedInUpgradePropertyMetadata,
} from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
describe('WasRemovedInUpgrade', () => {
it('records class-level metadata', () => {
@WasRemovedInUpgrade({ upgradeCommandName: 'upgrade-step-class' })
class Example {}
expect(getWasRemovedInUpgradeClassMetadata(Example)).toEqual({
upgradeCommandName: 'upgrade-step-class',
});
expect(getWasRemovedInUpgradePropertyMetadata(Example)).toEqual({});
});
it('records property-level metadata keyed by property name', () => {
class Example {
@WasRemovedInUpgrade({ upgradeCommandName: 'upgrade-step-foo' })
foo!: string;
@WasRemovedInUpgrade({ upgradeCommandName: 'upgrade-step-bar' })
bar!: string;
untouched!: string;
}
expect(getWasRemovedInUpgradePropertyMetadata(Example)).toEqual({
foo: { upgradeCommandName: 'upgrade-step-foo' },
bar: { upgradeCommandName: 'upgrade-step-bar' },
});
expect(getWasRemovedInUpgradeClassMetadata(Example)).toBeUndefined();
});
it('returns an empty map for classes with no decorated properties', () => {
class Example {}
expect(getWasRemovedInUpgradePropertyMetadata(Example)).toEqual({});
expect(getWasRemovedInUpgradeClassMetadata(Example)).toBeUndefined();
});
});
@@ -0,0 +1,41 @@
import 'reflect-metadata';
import { defineUpgradeMetadataOnClassOrProperty } from 'src/engine/core-modules/upgrade/decorators/upgrade-decorator-metadata.util';
export type WasRemovedInUpgradeOptions = {
upgradeCommandName: string;
};
export const WAS_REMOVED_IN_UPGRADE_CLASS_METADATA_KEY =
'WAS_REMOVED_IN_UPGRADE_CLASS';
export const WAS_REMOVED_IN_UPGRADE_PROPERTIES_METADATA_KEY =
'WAS_REMOVED_IN_UPGRADE_PROPERTIES';
export type WasRemovedInUpgradePropertyMap = Record<
string,
WasRemovedInUpgradeOptions
>;
export const WasRemovedInUpgrade =
(options: WasRemovedInUpgradeOptions) =>
(target: object, propertyKey?: string | symbol): void => {
defineUpgradeMetadataOnClassOrProperty({
classMetadataKey: WAS_REMOVED_IN_UPGRADE_CLASS_METADATA_KEY,
propertyMetadataKey: WAS_REMOVED_IN_UPGRADE_PROPERTIES_METADATA_KEY,
value: options,
target,
propertyKey,
});
};
export const getWasRemovedInUpgradeClassMetadata = (
target: Function,
): WasRemovedInUpgradeOptions | undefined =>
Reflect.getMetadata(WAS_REMOVED_IN_UPGRADE_CLASS_METADATA_KEY, target);
export const getWasRemovedInUpgradePropertyMetadata = (
target: Function,
): WasRemovedInUpgradePropertyMap =>
Reflect.getMetadata(WAS_REMOVED_IN_UPGRADE_PROPERTIES_METADATA_KEY, target) ??
{};
@@ -1,4 +1,5 @@
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { WasRemovedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-removed-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';
@@ -6,6 +7,7 @@ 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 PROP_REMOVE_CMD = '2.7.0_DropColumnCommand_1800000000002';
const buildPredicate = (applied: string[]) => {
const set = new Set(applied);
@@ -167,6 +169,100 @@ describe('resolveEntityShapeAtUpgradeCursor', () => {
});
});
describe('property-level @WasRemovedInUpgrade', () => {
class EntityWithRemovedColumn {
@WasRemovedInUpgrade({ upgradeCommandName: PROP_REMOVE_CMD })
removedColumn!: string;
untouchedColumn!: string;
}
const currentColumns = [
{ propertyName: 'removedColumn', databaseName: 'removedColumn' },
{ propertyName: 'untouchedColumn', databaseName: 'untouchedColumn' },
];
it('should not hide the column before its removal step applied', () => {
const result = resolveEntityShapeAtUpgradeCursor({
entityClass: EntityWithRemovedColumn,
currentTableName: 'entityWithRemovedColumn',
currentColumns,
isStepApplied: buildPredicate([]),
});
expect(result.hiddenPropertyNames.size).toBe(0);
});
it('should hide the column once its removal step applied', () => {
const result = resolveEntityShapeAtUpgradeCursor({
entityClass: EntityWithRemovedColumn,
currentTableName: 'entityWithRemovedColumn',
currentColumns,
isStepApplied: buildPredicate([PROP_REMOVE_CMD]),
});
expect(result.hiddenPropertyNames).toEqual(new Set(['removedColumn']));
});
it('should leave undecorated siblings untouched at every cursor', () => {
for (const applied of [[], [PROP_REMOVE_CMD]] as string[][]) {
const result = resolveEntityShapeAtUpgradeCursor({
entityClass: EntityWithRemovedColumn,
currentTableName: 'entityWithRemovedColumn',
currentColumns,
isStepApplied: buildPredicate(applied),
});
expect(result.hiddenPropertyNames.has('untouchedColumn')).toBe(false);
}
});
});
describe('property-level intro + remove combined', () => {
class EntityWithIntroAndRemove {
@WasIntroducedInUpgrade({ upgradeCommandName: PROP_INTRODUCE_CMD })
@WasRemovedInUpgrade({ upgradeCommandName: PROP_REMOVE_CMD })
transientColumn!: string;
}
const currentColumns = [
{ propertyName: 'transientColumn', databaseName: 'transientColumn' },
];
it('hides the column before intro applied', () => {
const result = resolveEntityShapeAtUpgradeCursor({
entityClass: EntityWithIntroAndRemove,
currentTableName: 'entityWithIntroAndRemove',
currentColumns,
isStepApplied: buildPredicate([]),
});
expect(result.hiddenPropertyNames).toEqual(new Set(['transientColumn']));
});
it('exposes the column between intro and removal', () => {
const result = resolveEntityShapeAtUpgradeCursor({
entityClass: EntityWithIntroAndRemove,
currentTableName: 'entityWithIntroAndRemove',
currentColumns,
isStepApplied: buildPredicate([PROP_INTRODUCE_CMD]),
});
expect(result.hiddenPropertyNames.size).toBe(0);
});
it('hides the column once removal applied', () => {
const result = resolveEntityShapeAtUpgradeCursor({
entityClass: EntityWithIntroAndRemove,
currentTableName: 'entityWithIntroAndRemove',
currentColumns,
isStepApplied: buildPredicate([PROP_INTRODUCE_CMD, PROP_REMOVE_CMD]),
});
expect(result.hiddenPropertyNames).toEqual(new Set(['transientColumn']));
});
});
it('should treat an entity with no decorators as available and unchanged', () => {
class Plain {}
@@ -1,4 +1,5 @@
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { WasRemovedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-removed-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';
@@ -48,6 +49,72 @@ describe('validateUpgradeAwareEntityDecorators', () => {
]);
});
it('should report an unknown step name on @WasRemovedInUpgrade', () => {
class BrokenRemoved {
@WasRemovedInUpgrade({ upgradeCommandName: UNKNOWN_CMD })
doomedColumn!: string;
}
const problems = validateUpgradeAwareEntityDecorators({
entityClasses: [BrokenRemoved],
stepNameToIndex: buildStepNameToIndex([KNOWN_CMD]),
});
expect(problems).toEqual([
{
kind: 'unknown-step-name',
entityName: 'BrokenRemoved',
decorator: '@WasRemovedInUpgrade',
scope: 'property:doomedColumn',
upgradeCommandName: UNKNOWN_CMD,
},
]);
});
it('should report when a property is removed before it is introduced', () => {
class BackwardsLifecycle {
@WasIntroducedInUpgrade({ upgradeCommandName: KNOWN_LATER_RENAME_CMD })
@WasRemovedInUpgrade({ upgradeCommandName: KNOWN_CMD })
transientColumn!: string;
}
const problems = validateUpgradeAwareEntityDecorators({
entityClasses: [BackwardsLifecycle],
stepNameToIndex: buildStepNameToIndex([
KNOWN_CMD,
KNOWN_LATER_RENAME_CMD,
]),
});
expect(problems).toEqual([
{
kind: 'removal-before-introduction',
entityName: 'BackwardsLifecycle',
scope: 'property:transientColumn',
introductionUpgradeCommandName: KNOWN_LATER_RENAME_CMD,
removalUpgradeCommandName: KNOWN_CMD,
},
]);
});
it('accepts a property that is introduced before being removed', () => {
class ProperLifecycle {
@WasIntroducedInUpgrade({ upgradeCommandName: KNOWN_CMD })
@WasRemovedInUpgrade({ upgradeCommandName: KNOWN_LATER_RENAME_CMD })
transientColumn!: string;
}
const problems = validateUpgradeAwareEntityDecorators({
entityClasses: [ProperLifecycle],
stepNameToIndex: buildStepNameToIndex([
KNOWN_CMD,
KNOWN_LATER_RENAME_CMD,
]),
});
expect(problems).toEqual([]);
});
it('should report a rename history that is out of order versus the sequence', () => {
@WasRenamedInUpgrade([
{ previousName: 'first', upgradeCommandName: KNOWN_LATER_RENAME_CMD },
@@ -4,6 +4,7 @@ import {
getWasIntroducedInUpgradeClassMetadata,
getWasIntroducedInUpgradePropertyMetadata,
} from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { getWasRemovedInUpgradePropertyMetadata } from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
import {
getWasRenamedInUpgradeClassMetadata,
getWasRenamedInUpgradePropertyMetadata,
@@ -43,6 +44,8 @@ export const resolveEntityShapeAtUpgradeCursor = ({
const propertyIntroductionMap =
getWasIntroducedInUpgradePropertyMetadata(entityClass);
const propertyRemovalMap =
getWasRemovedInUpgradePropertyMetadata(entityClass);
const propertyRenameMap = getWasRenamedInUpgradePropertyMetadata(entityClass);
const hiddenPropertyNames = new Set<string>();
@@ -59,6 +62,13 @@ export const resolveEntityShapeAtUpgradeCursor = ({
continue;
}
const removed = propertyRemovalMap[column.propertyName];
if (isDefined(removed) && isStepApplied(removed.upgradeCommandName)) {
hiddenPropertyNames.add(column.propertyName);
continue;
}
const renameHistory = propertyRenameMap[column.propertyName] ?? [];
if (renameHistory.length === 0) {
@@ -4,6 +4,10 @@ import {
getWasIntroducedInUpgradeClassMetadata,
getWasIntroducedInUpgradePropertyMetadata,
} from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import {
getWasRemovedInUpgradeClassMetadata,
getWasRemovedInUpgradePropertyMetadata,
} from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
import {
getWasRenamedInUpgradeClassMetadata,
getWasRenamedInUpgradePropertyMetadata,
@@ -14,7 +18,10 @@ export type UpgradeAwareDecoratorReferenceProblem =
| {
kind: 'unknown-step-name';
entityName: string;
decorator: '@WasIntroducedInUpgrade' | '@WasRenamedInUpgrade';
decorator:
| '@WasIntroducedInUpgrade'
| '@WasRemovedInUpgrade'
| '@WasRenamedInUpgrade';
scope: 'class' | `property:${string}`;
upgradeCommandName: string;
}
@@ -24,6 +31,13 @@ export type UpgradeAwareDecoratorReferenceProblem =
scope: 'class' | `property:${string}`;
offendingUpgradeCommandName: string;
precedingUpgradeCommandName: string;
}
| {
kind: 'removal-before-introduction';
entityName: string;
scope: 'class' | `property:${string}`;
introductionUpgradeCommandName: string;
removalUpgradeCommandName: string;
};
export const validateUpgradeAwareEntityDecorators = ({
@@ -53,6 +67,30 @@ export const validateUpgradeAwareEntityDecorators = ({
});
}
const classRemoved = getWasRemovedInUpgradeClassMetadata(entityClass);
if (
isDefined(classRemoved) &&
!stepNameToIndex.has(classRemoved.upgradeCommandName)
) {
problems.push({
kind: 'unknown-step-name',
entityName,
decorator: '@WasRemovedInUpgrade',
scope: 'class',
upgradeCommandName: classRemoved.upgradeCommandName,
});
}
checkRemovalAfterIntroduction({
entityName,
scope: 'class',
introduced: classIntroduced,
removed: classRemoved,
stepNameToIndex,
problems,
});
const classRenameHistory =
getWasRenamedInUpgradeClassMetadata(entityClass) ?? [];
@@ -79,6 +117,29 @@ export const validateUpgradeAwareEntityDecorators = ({
}
}
const propRemovedMap = getWasRemovedInUpgradePropertyMetadata(entityClass);
for (const [propertyName, options] of Object.entries(propRemovedMap)) {
if (!stepNameToIndex.has(options.upgradeCommandName)) {
problems.push({
kind: 'unknown-step-name',
entityName,
decorator: '@WasRemovedInUpgrade',
scope: `property:${propertyName}`,
upgradeCommandName: options.upgradeCommandName,
});
}
checkRemovalAfterIntroduction({
entityName,
scope: `property:${propertyName}`,
introduced: propIntroducedMap[propertyName],
removed: options,
stepNameToIndex,
problems,
});
}
const propRenameMap = getWasRenamedInUpgradePropertyMetadata(entityClass);
for (const [propertyName, history] of Object.entries(propRenameMap)) {
@@ -140,6 +201,43 @@ const checkHistoryForReferenceAndOrder = ({
}
};
const checkRemovalAfterIntroduction = ({
entityName,
scope,
introduced,
removed,
stepNameToIndex,
problems,
}: {
entityName: string;
scope: 'class' | `property:${string}`;
introduced: { upgradeCommandName: string } | undefined;
removed: { upgradeCommandName: string } | undefined;
stepNameToIndex: ReadonlyMap<string, number>;
problems: UpgradeAwareDecoratorReferenceProblem[];
}): void => {
if (!isDefined(introduced) || !isDefined(removed)) {
return;
}
const introducedIndex = stepNameToIndex.get(introduced.upgradeCommandName);
const removedIndex = stepNameToIndex.get(removed.upgradeCommandName);
if (!isDefined(introducedIndex) || !isDefined(removedIndex)) {
return;
}
if (removedIndex <= introducedIndex) {
problems.push({
kind: 'removal-before-introduction',
entityName,
scope,
introductionUpgradeCommandName: introduced.upgradeCommandName,
removalUpgradeCommandName: removed.upgradeCommandName,
});
}
};
export const formatUpgradeAwareDecoratorReferenceProblems = (
problems: UpgradeAwareDecoratorReferenceProblem[],
): string =>
@@ -149,6 +247,10 @@ export const formatUpgradeAwareDecoratorReferenceProblems = (
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`;
if (problem.kind === 'rename-history-out-of-order') {
return ` - ${problem.entityName} @WasRenamedInUpgrade (${problem.scope}): "${problem.offendingUpgradeCommandName}" must come after "${problem.precedingUpgradeCommandName}" in the upgrade sequence`;
}
return ` - ${problem.entityName} @WasRemovedInUpgrade (${problem.scope}): removal step "${problem.removalUpgradeCommandName}" must come after introduction step "${problem.introductionUpgradeCommandName}" in the upgrade sequence`;
})
.join('\n');