From b768441c13aed1c978522f59d2cc326f3bd5a708 Mon Sep 17 00:00:00 2001
From: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Date: Tue, 23 Jun 2026 18:51:07 +0200
Subject: [PATCH] Fix 2.16 search field metadata cross version upgrade (#22039)
# Introduction
Allow decorating at class scope the properties introduced in specific
upgrade command
```
@WasIntroducedInUpgrade({
upgradeCommandName:
ADD_UNIVERSAL_IDENTIFIER_AND_APPLICATION_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME,
properties: ['universalIdentifier', 'applicationId', 'position'],
})
```
Here the search field metadata has been created as it without extending
the syncableEntity a previous PR I've created now extends it, but
nothing has been protected the fact they're not decorated. Also having
to re-declare the properties would be redundant to me
---
...-metadata-upgrade-command-name.constant.ts | 2 +
...as-introduced-in-upgrade.decorator.spec.ts | 83 ++++++++++++++++++
.../was-introduced-in-upgrade.decorator.ts | 56 +++++++++++-
...ntity-shape-at-upgrade-cursor.util.spec.ts | 52 +++++++++++
...grade-aware-entity-decorators.util.spec.ts | 86 +++++++++++++++++++
...te-upgrade-aware-entity-decorators.util.ts | 26 ++++++
.../search-field-metadata.entity.ts | 7 ++
.../upgrade-aware-entity-metadata.adapter.ts | 23 ++++-
8 files changed, 328 insertions(+), 7 deletions(-)
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-16/add-universal-identifier-and-application-id-to-search-field-metadata-upgrade-command-name.constant.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/upgrade/decorators/__tests__/was-introduced-in-upgrade.decorator.spec.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/add-universal-identifier-and-application-id-to-search-field-metadata-upgrade-command-name.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/add-universal-identifier-and-application-id-to-search-field-metadata-upgrade-command-name.constant.ts
new file mode 100644
index 0000000000..cbbbe28575
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/add-universal-identifier-and-application-id-to-search-field-metadata-upgrade-command-name.constant.ts
@@ -0,0 +1,2 @@
+export const ADD_UNIVERSAL_IDENTIFIER_AND_APPLICATION_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME =
+ '2.16.0_AddUniversalIdentifierAndApplicationIdToSearchFieldMetadataFastInstanceCommand_1782200000000';
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/decorators/__tests__/was-introduced-in-upgrade.decorator.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/decorators/__tests__/was-introduced-in-upgrade.decorator.spec.ts
new file mode 100644
index 0000000000..ac2fcc859c
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/decorators/__tests__/was-introduced-in-upgrade.decorator.spec.ts
@@ -0,0 +1,83 @@
+import {
+ WasIntroducedInUpgrade,
+ getWasIntroducedInUpgradeClassMetadata,
+ getWasIntroducedInUpgradePropertyMetadata,
+} from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
+
+describe('WasIntroducedInUpgrade', () => {
+ it('records class-level entity introduction metadata', () => {
+ @WasIntroducedInUpgrade({ upgradeCommandName: 'upgrade-step-class' })
+ class Example {}
+
+ expect(getWasIntroducedInUpgradeClassMetadata(Example)).toEqual({
+ upgradeCommandName: 'upgrade-step-class',
+ });
+ expect(getWasIntroducedInUpgradePropertyMetadata(Example)).toEqual({});
+ });
+
+ it('records property-level metadata keyed by property name', () => {
+ class Example {
+ @WasIntroducedInUpgrade({ upgradeCommandName: 'upgrade-step-foo' })
+ foo!: string;
+
+ @WasIntroducedInUpgrade({ upgradeCommandName: 'upgrade-step-bar' })
+ bar!: string;
+
+ untouched!: string;
+ }
+
+ expect(getWasIntroducedInUpgradePropertyMetadata(Example)).toEqual({
+ foo: { upgradeCommandName: 'upgrade-step-foo' },
+ bar: { upgradeCommandName: 'upgrade-step-bar' },
+ });
+ expect(getWasIntroducedInUpgradeClassMetadata(Example)).toBeUndefined();
+ });
+
+ describe('class-level properties form (for inherited columns)', () => {
+ it('registers the listed properties in the property-introduction map', () => {
+ @WasIntroducedInUpgrade({
+ upgradeCommandName: 'upgrade-step-columns',
+ properties: ['inheritedA', 'inheritedB'],
+ })
+ class Example {}
+
+ expect(getWasIntroducedInUpgradePropertyMetadata(Example)).toEqual({
+ inheritedA: { upgradeCommandName: 'upgrade-step-columns' },
+ inheritedB: { upgradeCommandName: 'upgrade-step-columns' },
+ });
+ });
+
+ it('does not mark the entity itself as introduced', () => {
+ @WasIntroducedInUpgrade({
+ upgradeCommandName: 'upgrade-step-columns',
+ properties: ['inheritedA'],
+ })
+ class Example {}
+
+ expect(getWasIntroducedInUpgradeClassMetadata(Example)).toBeUndefined();
+ });
+
+ it('merges with property-level decorators on the same entity', () => {
+ @WasIntroducedInUpgrade({
+ upgradeCommandName: 'upgrade-step-inherited',
+ properties: ['inheritedA'],
+ })
+ class Example {
+ @WasIntroducedInUpgrade({ upgradeCommandName: 'upgrade-step-local' })
+ localColumn!: string;
+ }
+
+ expect(getWasIntroducedInUpgradePropertyMetadata(Example)).toEqual({
+ localColumn: { upgradeCommandName: 'upgrade-step-local' },
+ inheritedA: { upgradeCommandName: 'upgrade-step-inherited' },
+ });
+ });
+ });
+
+ it('returns an empty map for classes with no decorated properties', () => {
+ class Example {}
+
+ expect(getWasIntroducedInUpgradePropertyMetadata(Example)).toEqual({});
+ expect(getWasIntroducedInUpgradeClassMetadata(Example)).toBeUndefined();
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator.ts b/packages/twenty-server/src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator.ts
index 1631391cb9..58e74fde52 100644
--- a/packages/twenty-server/src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator.ts
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator.ts
@@ -1,9 +1,19 @@
import 'reflect-metadata';
+import { isDefined } from 'twenty-shared/utils';
+
import { defineUpgradeMetadataOnClassOrProperty } from 'src/engine/core-modules/upgrade/decorators/upgrade-decorator-metadata.util';
export type WasIntroducedInUpgradeOptions = {
upgradeCommandName: string;
+ // Class-level escape hatch for columns inherited from a base entity (e.g.
+ // SyncableEntity.universalIdentifier) that cannot carry a property decorator
+ // in place
+ properties?: readonly string[];
+};
+
+export type WasIntroducedInUpgradeClassMetadata = {
+ upgradeCommandName: string;
};
export const WAS_INTRODUCED_IN_UPGRADE_CLASS_METADATA_KEY =
@@ -14,24 +24,64 @@ export const WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY =
export type WasIntroducedInUpgradePropertyMap = Record<
string,
- WasIntroducedInUpgradeOptions
+ WasIntroducedInUpgradeClassMetadata
>;
export const WasIntroducedInUpgrade =
(options: WasIntroducedInUpgradeOptions) =>
(target: object, propertyKey?: string | symbol): void => {
+ const value: WasIntroducedInUpgradeClassMetadata = {
+ upgradeCommandName: options.upgradeCommandName,
+ };
+
+ if (!isDefined(propertyKey) && isDefined(options.properties)) {
+ defineIntroducedPropertiesOnClass({
+ entityClass: target as Function,
+ propertyNames: options.properties,
+ value,
+ });
+
+ return;
+ }
+
defineUpgradeMetadataOnClassOrProperty({
classMetadataKey: WAS_INTRODUCED_IN_UPGRADE_CLASS_METADATA_KEY,
propertyMetadataKey: WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY,
- value: options,
+ value,
target,
propertyKey,
});
};
+const defineIntroducedPropertiesOnClass = ({
+ entityClass,
+ propertyNames,
+ value,
+}: {
+ entityClass: Function;
+ propertyNames: readonly string[];
+ value: WasIntroducedInUpgradeClassMetadata;
+}): void => {
+ const existing: WasIntroducedInUpgradePropertyMap =
+ Reflect.getMetadata(
+ WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY,
+ entityClass,
+ ) ?? {};
+
+ const additions: WasIntroducedInUpgradePropertyMap = Object.fromEntries(
+ propertyNames.map((propertyName) => [propertyName, value]),
+ );
+
+ Reflect.defineMetadata(
+ WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY,
+ { ...existing, ...additions },
+ entityClass,
+ );
+};
+
export const getWasIntroducedInUpgradeClassMetadata = (
target: Function,
-): WasIntroducedInUpgradeOptions | undefined =>
+): WasIntroducedInUpgradeClassMetadata | undefined =>
Reflect.getMetadata(WAS_INTRODUCED_IN_UPGRADE_CLASS_METADATA_KEY, target);
export const getWasIntroducedInUpgradePropertyMetadata = (
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/resolve-entity-shape-at-upgrade-cursor.util.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/resolve-entity-shape-at-upgrade-cursor.util.spec.ts
index 9fb1a2bd97..c992c65961 100644
--- a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/resolve-entity-shape-at-upgrade-cursor.util.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/resolve-entity-shape-at-upgrade-cursor.util.spec.ts
@@ -169,6 +169,58 @@ describe('resolveEntityShapeAtUpgradeCursor', () => {
});
});
+ describe('class-level properties form (inherited columns)', () => {
+ @WasIntroducedInUpgrade({
+ upgradeCommandName: PROP_INTRODUCE_CMD,
+ properties: ['inheritedColumn', 'localColumn'],
+ })
+ class EntityWithInheritedColumns {}
+
+ const currentColumns = [
+ { propertyName: 'inheritedColumn', databaseName: 'inheritedColumn' },
+ { propertyName: 'localColumn', databaseName: 'localColumn' },
+ { propertyName: 'baselineColumn', databaseName: 'baselineColumn' },
+ ];
+
+ it('hides the listed inherited columns before introduction applied', () => {
+ const result = resolveEntityShapeAtUpgradeCursor({
+ entityClass: EntityWithInheritedColumns,
+ currentTableName: 'entityWithInheritedColumns',
+ currentColumns,
+ isStepApplied: buildPredicate([]),
+ });
+
+ expect(result.isAvailable).toBe(true);
+ expect(result.hiddenPropertyNames).toEqual(
+ new Set(['inheritedColumn', 'localColumn']),
+ );
+ });
+
+ it('exposes the listed columns once introduction applied', () => {
+ const result = resolveEntityShapeAtUpgradeCursor({
+ entityClass: EntityWithInheritedColumns,
+ currentTableName: 'entityWithInheritedColumns',
+ currentColumns,
+ isStepApplied: buildPredicate([PROP_INTRODUCE_CMD]),
+ });
+
+ expect(result.hiddenPropertyNames.size).toBe(0);
+ });
+
+ it('never hides undecorated baseline columns', () => {
+ for (const applied of [[], [PROP_INTRODUCE_CMD]] as string[][]) {
+ const result = resolveEntityShapeAtUpgradeCursor({
+ entityClass: EntityWithInheritedColumns,
+ currentTableName: 'entityWithInheritedColumns',
+ currentColumns,
+ isStepApplied: buildPredicate(applied),
+ });
+
+ expect(result.hiddenPropertyNames.has('baselineColumn')).toBe(false);
+ }
+ });
+ });
+
describe('property-level @WasRemovedInUpgrade', () => {
class EntityWithRemovedColumn {
@WasRemovedInUpgrade({ upgradeCommandName: PROP_REMOVE_CMD })
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/validate-upgrade-aware-entity-decorators.util.spec.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/validate-upgrade-aware-entity-decorators.util.spec.ts
index 1c20e9f730..896b7d1975 100644
--- a/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/validate-upgrade-aware-entity-decorators.util.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/__tests__/validate-upgrade-aware-entity-decorators.util.spec.ts
@@ -115,6 +115,92 @@ describe('validateUpgradeAwareEntityDecorators', () => {
expect(problems).toEqual([]);
});
+ describe('column property name validation', () => {
+ it('reports an introduced property name that does not exist on the entity', () => {
+ @WasIntroducedInUpgrade({
+ upgradeCommandName: KNOWN_CMD,
+ properties: ['universalIdentifier', 'typoColumn'],
+ })
+ class EntityWithTypo {}
+
+ const problems = validateUpgradeAwareEntityDecorators({
+ entityClasses: [EntityWithTypo],
+ stepNameToIndex: buildStepNameToIndex([KNOWN_CMD]),
+ columnPropertyNamesByEntityClass: new Map([
+ [EntityWithTypo, new Set(['universalIdentifier', 'position'])],
+ ]),
+ });
+
+ expect(problems).toEqual([
+ {
+ kind: 'unknown-property-name',
+ entityName: 'EntityWithTypo',
+ decorator: '@WasIntroducedInUpgrade',
+ propertyName: 'typoColumn',
+ },
+ ]);
+ });
+
+ it('reports a relation property name (resolution only acts on columns)', () => {
+ @WasIntroducedInUpgrade({
+ upgradeCommandName: KNOWN_CMD,
+ properties: ['application'],
+ })
+ class EntityTargetingRelation {}
+
+ const problems = validateUpgradeAwareEntityDecorators({
+ entityClasses: [EntityTargetingRelation],
+ stepNameToIndex: buildStepNameToIndex([KNOWN_CMD]),
+ // 'application' is a relation, not a column, so it is not in the set.
+ columnPropertyNamesByEntityClass: new Map([
+ [EntityTargetingRelation, new Set(['applicationId'])],
+ ]),
+ });
+
+ expect(problems).toEqual([
+ {
+ kind: 'unknown-property-name',
+ entityName: 'EntityTargetingRelation',
+ decorator: '@WasIntroducedInUpgrade',
+ propertyName: 'application',
+ },
+ ]);
+ });
+
+ it('reports no problem when all introduced property names are columns', () => {
+ @WasIntroducedInUpgrade({
+ upgradeCommandName: KNOWN_CMD,
+ properties: ['universalIdentifier', 'position'],
+ })
+ class ValidEntity {}
+
+ const problems = validateUpgradeAwareEntityDecorators({
+ entityClasses: [ValidEntity],
+ stepNameToIndex: buildStepNameToIndex([KNOWN_CMD]),
+ columnPropertyNamesByEntityClass: new Map([
+ [ValidEntity, new Set(['universalIdentifier', 'position'])],
+ ]),
+ });
+
+ expect(problems).toEqual([]);
+ });
+
+ it('skips property name validation when no column property names are provided', () => {
+ @WasIntroducedInUpgrade({
+ upgradeCommandName: KNOWN_CMD,
+ properties: ['anythingGoes'],
+ })
+ class UnvalidatedEntity {}
+
+ const problems = validateUpgradeAwareEntityDecorators({
+ entityClasses: [UnvalidatedEntity],
+ stepNameToIndex: buildStepNameToIndex([KNOWN_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 },
diff --git a/packages/twenty-server/src/engine/core-modules/upgrade/utils/validate-upgrade-aware-entity-decorators.util.ts b/packages/twenty-server/src/engine/core-modules/upgrade/utils/validate-upgrade-aware-entity-decorators.util.ts
index 4ee9508167..7fe29d3a0b 100644
--- a/packages/twenty-server/src/engine/core-modules/upgrade/utils/validate-upgrade-aware-entity-decorators.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/upgrade/utils/validate-upgrade-aware-entity-decorators.util.ts
@@ -38,19 +38,29 @@ export type UpgradeAwareDecoratorReferenceProblem =
scope: 'class' | `property:${string}`;
introductionUpgradeCommandName: string;
removalUpgradeCommandName: string;
+ }
+ | {
+ kind: 'unknown-property-name';
+ entityName: string;
+ decorator: '@WasIntroducedInUpgrade';
+ propertyName: string;
};
export const validateUpgradeAwareEntityDecorators = ({
entityClasses,
stepNameToIndex,
+ columnPropertyNamesByEntityClass,
}: {
entityClasses: Function[];
stepNameToIndex: ReadonlyMap;
+ columnPropertyNamesByEntityClass?: ReadonlyMap>;
}): UpgradeAwareDecoratorReferenceProblem[] => {
const problems: UpgradeAwareDecoratorReferenceProblem[] = [];
for (const entityClass of entityClasses) {
const entityName = entityClass.name;
+ const columnPropertyNames =
+ columnPropertyNamesByEntityClass?.get(entityClass);
const classIntroduced = getWasIntroducedInUpgradeClassMetadata(entityClass);
@@ -115,6 +125,18 @@ export const validateUpgradeAwareEntityDecorators = ({
upgradeCommandName: options.upgradeCommandName,
});
}
+
+ if (
+ isDefined(columnPropertyNames) &&
+ !columnPropertyNames.has(propertyName)
+ ) {
+ problems.push({
+ kind: 'unknown-property-name',
+ entityName,
+ decorator: '@WasIntroducedInUpgrade',
+ propertyName,
+ });
+ }
}
const propRemovedMap = getWasRemovedInUpgradePropertyMetadata(entityClass);
@@ -251,6 +273,10 @@ export const formatUpgradeAwareDecoratorReferenceProblems = (
return ` - ${problem.entityName} @WasRenamedInUpgrade (${problem.scope}): "${problem.offendingUpgradeCommandName}" must come after "${problem.precedingUpgradeCommandName}" in the upgrade sequence`;
}
+ if (problem.kind === 'unknown-property-name') {
+ return ` - ${problem.entityName} ${problem.decorator} (property:${problem.propertyName}): "${problem.propertyName}" is not a known property on the entity (check the spelling in the properties array)`;
+ }
+
return ` - ${problem.entityName} @WasRemovedInUpgrade (${problem.scope}): removal step "${problem.removalUpgradeCommandName}" must come after introduction step "${problem.introductionUpgradeCommandName}" in the upgrade sequence`;
})
.join('\n');
diff --git a/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts
index 10dcbc08af..ea6a12d814 100644
--- a/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity.ts
@@ -11,11 +11,18 @@ import {
UpdateDateColumn,
} from 'typeorm';
+import { ADD_UNIVERSAL_IDENTIFIER_AND_APPLICATION_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-16/add-universal-identifier-and-application-id-to-search-field-metadata-upgrade-command-name.constant';
+import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'searchFieldMetadata', schema: 'core' })
+@WasIntroducedInUpgrade({
+ upgradeCommandName:
+ ADD_UNIVERSAL_IDENTIFIER_AND_APPLICATION_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME,
+ properties: ['universalIdentifier', 'applicationId', 'position'],
+})
@Unique('IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE', [
'objectMetadataId',
'fieldMetadataId',
diff --git a/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter.ts b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter.ts
index 88cee8685f..ff09bc5a10 100644
--- a/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter.ts
+++ b/packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter.ts
@@ -4,8 +4,8 @@ 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 { DataSource } from 'typeorm';
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';
@@ -346,13 +346,28 @@ export class UpgradeAwareEntityMetadataAdapter implements OnModuleInit {
}
private validateDecoratorsAgainstSequence(): void {
- const entityClasses = this.coreDataSource.entityMetadatas
- .map((metadata) => metadata.target)
- .filter((target): target is Function => typeof target === 'function');
+ const entityClasses: Function[] = [];
+ const columnPropertyNamesByEntityClass = new Map<
+ Function,
+ ReadonlySet
+ >();
+
+ for (const metadata of this.coreDataSource.entityMetadatas) {
+ if (typeof metadata.target !== 'function') {
+ continue;
+ }
+
+ entityClasses.push(metadata.target);
+ columnPropertyNamesByEntityClass.set(
+ metadata.target,
+ new Set(metadata.columns.map((column) => column.propertyName)),
+ );
+ }
const problems = validateUpgradeAwareEntityDecorators({
entityClasses,
stepNameToIndex: this.stepNameToIndex,
+ columnPropertyNamesByEntityClass,
});
if (problems.length === 0) {