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
@@ -4,20 +4,44 @@ import { Test } from '@nestjs/testing';
import { getDataSourceToken } from '@nestjs/typeorm';
import { type DataSource } from 'typeorm';
import { type ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
import { type EntityMetadata } from 'typeorm/metadata/EntityMetadata';
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 { 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';
const INTRODUCE_STEP = '2.7.0_AddColumn_1800000000000';
const REMOVE_STEP = '2.7.0_DropColumn_1800000000001';
@WasRenamedInUpgrade([
{ previousName: 'oldEntity', upgradeCommandName: RENAME_STEP },
])
class RenamedEntity {}
class EntityWithHideableColumns {
@WasIntroducedInUpgrade({ upgradeCommandName: INTRODUCE_STEP })
introducedColumn!: string;
@WasRemovedInUpgrade({ upgradeCommandName: REMOVE_STEP })
removedColumn!: string;
visibleColumn!: string;
}
const buildColumn = (propertyName: string): ColumnMetadata =>
({
propertyName,
databaseName: propertyName,
isSelect: true,
isInsert: true,
isUpdate: true,
}) as unknown as ColumnMetadata;
describe('UpgradeAwareEntityMetadataAdapter', () => {
it('rewrites tableName / tablePath / givenTableName when the rename step is not yet applied', async () => {
const metadata = {
@@ -64,4 +88,68 @@ describe('UpgradeAwareEntityMetadataAdapter', () => {
expect(metadata.tablePath).toBe('core.oldEntity');
expect(metadata.givenTableName).toBe('oldEntity');
});
it('disables isSelect, isInsert and isUpdate for hidden columns (intro pending + removal applied) while leaving the visible sibling untouched', async () => {
const introducedColumn = buildColumn('introducedColumn');
const removedColumn = buildColumn('removedColumn');
const visibleColumn = buildColumn('visibleColumn');
const metadata = {
target: EntityWithHideableColumns,
tableName: 'entityWithHideableColumns',
tablePath: 'core.entityWithHideableColumns',
givenTableName: 'entityWithHideableColumns',
schema: 'core',
columns: [introducedColumn, removedColumn, visibleColumn],
} 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({
name: REMOVE_STEP,
status: 'completed',
}),
},
},
{
provide: UpgradeSequenceReaderService,
useValue: {
getUpgradeSequence: jest
.fn()
.mockReturnValue([
{ name: REMOVE_STEP },
{ name: INTRODUCE_STEP },
]),
},
},
{ provide: getDataSourceToken(), useValue: dataSource },
],
}).compile();
const adapter = moduleRef.get(UpgradeAwareEntityMetadataAdapter);
await adapter.onModuleInit();
await adapter.refresh();
expect(introducedColumn.isSelect).toBe(false);
expect(introducedColumn.isInsert).toBe(false);
expect(introducedColumn.isUpdate).toBe(false);
expect(removedColumn.isSelect).toBe(false);
expect(removedColumn.isInsert).toBe(false);
expect(removedColumn.isUpdate).toBe(false);
expect(visibleColumn.isSelect).toBe(true);
expect(visibleColumn.isInsert).toBe(true);
expect(visibleColumn.isUpdate).toBe(true);
});
});
@@ -25,6 +25,8 @@ type EntityMetadataSnapshot = {
givenTableName: string | undefined;
columnDatabaseNamesByPropertyName: ReadonlyMap<string, string>;
columnSelectByPropertyName: ReadonlyMap<string, boolean>;
columnInsertByPropertyName: ReadonlyMap<string, boolean>;
columnUpdateByPropertyName: ReadonlyMap<string, boolean>;
};
@Injectable()
@@ -116,6 +118,8 @@ export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit {
for (const metadata of this.coreDataSource.entityMetadatas) {
const columnDatabaseNamesByPropertyName = new Map<string, string>();
const columnSelectByPropertyName = new Map<string, boolean>();
const columnInsertByPropertyName = new Map<string, boolean>();
const columnUpdateByPropertyName = new Map<string, boolean>();
for (const column of metadata.columns) {
columnDatabaseNamesByPropertyName.set(
@@ -123,6 +127,8 @@ export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit {
column.databaseName,
);
columnSelectByPropertyName.set(column.propertyName, column.isSelect);
columnInsertByPropertyName.set(column.propertyName, column.isInsert);
columnUpdateByPropertyName.set(column.propertyName, column.isUpdate);
}
this.snapshotByMetadata.set(metadata, {
@@ -131,6 +137,8 @@ export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit {
givenTableName: metadata.givenTableName,
columnDatabaseNamesByPropertyName,
columnSelectByPropertyName,
columnInsertByPropertyName,
columnUpdateByPropertyName,
});
}
}
@@ -297,12 +305,18 @@ export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit {
column.databaseName = remappedName ?? canonicalName;
const isHidden = resolved.hiddenPropertyNames.has(column.propertyName);
const canonicalIsSelect =
snapshot.columnSelectByPropertyName.get(column.propertyName) ?? true;
const canonicalIsInsert =
snapshot.columnInsertByPropertyName.get(column.propertyName) ?? true;
const canonicalIsUpdate =
snapshot.columnUpdateByPropertyName.get(column.propertyName) ?? true;
column.isSelect = resolved.hiddenPropertyNames.has(column.propertyName)
? false
: canonicalIsSelect;
column.isSelect = isHidden ? false : canonicalIsSelect;
column.isInsert = isHidden ? false : canonicalIsInsert;
column.isUpdate = isHidden ? false : canonicalIsUpdate;
}
private computeTablePath({