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) {