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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22039?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Paul Rastoin
2026-06-23 18:51:07 +02:00
committed by GitHub
parent b7350eba46
commit b768441c13
8 changed files with 328 additions and 7 deletions
@@ -0,0 +1,2 @@
export const ADD_UNIVERSAL_IDENTIFIER_AND_APPLICATION_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME =
'2.16.0_AddUniversalIdentifierAndApplicationIdToSearchFieldMetadataFastInstanceCommand_1782200000000';
@@ -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();
});
});
@@ -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 = (
@@ -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 })
@@ -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 },
@@ -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<string, number>;
columnPropertyNamesByEntityClass?: ReadonlyMap<Function, ReadonlySet<string>>;
}): 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');
@@ -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',
@@ -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<string>
>();
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) {