Files
twenty/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts
T
Félix Malfait 38fbff465f chore(server): ship the 2.20 standardOverrides drop as a dormant command (#22448)
Follow-up to #22417, per [this
thread](https://github.com/twentyhq/twenty/pull/22417#discussion_r3512187719):
migrate the `2-20/README.md` placeholder into a real command using the
`TWENTY_NEXT_VERSIONS` mechanism.

### What

- Add `DropMetadataStandardOverridesColumnFastInstanceCommand`,
registered against `2.20.0`. It boots (`2.20.0` is in
`TWENTY_ALL_VERSIONS`) but stays **dormant** — the upgrade sequence only
runs `TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS` (previous + current), so
it never executes during the 2.19 deploy and activates automatically
when `nx version:bump` promotes 2.20 to current.
- Name constant + unit test (SQL parity, registration against `2.20.0`,
name-constant parity).
- Register it in `instance-commands.constant.ts`.
- Update the `standardOverrides` `@deprecated` comments on object/field
metadata to point at the shipped command.
- Delete `2-20/README.md`.
- Document the "ship a command for a future version" flow in
`docs/UPGRADE_COMMANDS.md` and `.cursor/rules/server-migrations.mdc`
(the mechanism was previously undocumented).

### Note / correction to the README's plan

The old README implied both the command **and** `@WasRemovedInUpgrade`
could be added at 2.20 time. Only the command can ship now: the
decorator's validator runs against the active sequence, so referencing a
still-dormant 2.20 step fails boot with `unknown-step-name`. So the
entity keeps its `WasRemovedInUpgrade<T>` type wrapper for now; the
decorator gets wired (one line, via the name constant) once 2.20 is
current — same deferred-drop shape as `isUIReadOnly`.

### Verification

Could not run `jest`/`typecheck`/`lint` in this environment: `yarn
install` is blocked by egress policy on a git-based transitive dep
(`github.com/electron/node-gyp.git`). Verified by review against the
sibling 2-19 add-column and 2-12 drop commands. **Please let CI run
before merge.**

https://claude.ai/code/session_01KMArJvdEmsX3eAmJLbS1b6

---
_Generated by [Claude
Code](https://claude.ai/code/session_01KMArJvdEmsX3eAmJLbS1b6)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22448?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. -->
2026-07-02 13:18:20 +02:00

262 lines
9.5 KiB
TypeScript

import {
FieldMetadataDefaultValue,
FieldMetadataOptions,
FieldMetadataSettings,
FieldMetadataType,
} from 'twenty-shared/types';
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
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 { ADD_IS_SYSTEM_SIDE_EFFECT_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-15/is-system-side-effect-upgrade-command-name.constant';
import { ADD_METADATA_OVERRIDES_COLUMN_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-19/add-metadata-overrides-column-upgrade-command-name.constant';
import { RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME } from 'src/engine/metadata-modules/object-metadata/constants/rename-is-ui-read-only-to-is-ui-editable-upgrade-command-name.constant';
import { type FieldMetadataOverrides } from 'src/engine/metadata-modules/field-metadata/types/field-metadata-overrides.type';
import { AssignIfIsGivenFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/assign-if-is-given-field-metadata-type.type';
import { AssignTypeIfIsMorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/assign-type-if-is-morph-or-relation-field-metadata-type.type';
import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity';
import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
// This entity is used as a reference test case for type utilities in:
// Modifying relations or properties may require updating type test expectations for Typecheck to pass.
@Entity('fieldMetadata')
@Check(
'CHK_FIELD_METADATA_MORPH_RELATION_REQUIRES_MORPH_ID',
`("type" != 'MORPH_RELATION') OR ("type" = 'MORPH_RELATION' AND "morphId" IS NOT NULL)`,
)
@Index('IDX_FIELD_METADATA_RELATION_TARGET_FIELD_METADATA_ID', [
'relationTargetFieldMetadataId',
])
@Index('IDX_FIELD_METADATA_RELATION_TARGET_OBJECT_METADATA_ID', [
'relationTargetObjectMetadataId',
])
@Unique('IDX_FIELD_METADATA_NAME_OBJECT_METADATA_ID_WORKSPACE_ID_UNIQUE', [
'name',
'objectMetadataId',
'workspaceId',
])
@Index('IDX_FIELD_METADATA_OBJECT_METADATA_ID_WORKSPACE_ID', [
'objectMetadataId',
'workspaceId',
])
@Index('IDX_FIELD_METADATA_WORKSPACE_ID', ['workspaceId'])
export class FieldMetadataEntity<
TFieldMetadataType extends FieldMetadataType = FieldMetadataType,
>
extends SyncableEntity
implements Required<FieldMetadataEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
objectMetadataId: string;
@ManyToOne(() => ObjectMetadataEntity, (object) => object.fields, {
onDelete: 'CASCADE',
nullable: false,
})
@JoinColumn({ name: 'objectMetadataId' })
@Index('IDX_FIELD_METADATA_OBJECT_METADATA_ID', ['objectMetadataId'])
object: Relation<ObjectMetadataEntity>;
@Column({
nullable: false,
type: 'varchar',
})
type: TFieldMetadataType;
@Column({ nullable: false })
name: string;
@Column({ nullable: false })
label: string;
@Column({ nullable: true, type: 'jsonb' })
defaultValue: JsonbProperty<FieldMetadataDefaultValue<TFieldMetadataType>>;
@Column({ nullable: true, type: 'text' })
description: string | null;
@Column({ nullable: true, type: 'varchar' })
icon: string | null;
@WasIntroducedInUpgrade({
upgradeCommandName: ADD_METADATA_OVERRIDES_COLUMN_UPGRADE_COMMAND_NAME,
})
@Column({ type: 'jsonb', nullable: true })
overrides: JsonbProperty<FieldMetadataOverrides> | null;
/**
* @deprecated Superseded by `overrides`; kept readable for previous-release
* pods during a rolling deploy. Dropped by the dormant
* DropMetadataStandardOverridesColumnFastInstanceCommand when 2.20 is current;
* add @WasRemovedInUpgrade then (its validator rejects a dormant 2.20 step).
*/
@Column({ type: 'jsonb', nullable: true })
standardOverrides: WasRemovedInUpgrade<JsonbProperty<FieldMetadataOverrides> | null>;
@Column('jsonb', { nullable: true })
options: JsonbProperty<FieldMetadataOptions<TFieldMetadataType>>;
@Column('jsonb', { nullable: true })
settings: JsonbProperty<FieldMetadataSettings<TFieldMetadataType>>;
@WasRemovedInUpgrade({
upgradeCommandName:
'2.12.0_DropIsCustomFromObjectAndFieldMetadataFastInstanceCommand_1780579070012',
})
@Column({ type: 'boolean', default: false })
isCustom: WasRemovedInUpgrade<boolean>;
@Column({ default: false })
isActive: boolean;
@Column({ default: false })
isSystem: boolean;
@WasIntroducedInUpgrade({
upgradeCommandName: ADD_IS_SYSTEM_SIDE_EFFECT_UPGRADE_COMMAND_NAME,
})
@Column({ nullable: false, default: false, type: 'boolean' })
isSystemSideEffect: boolean;
@WasIntroducedInUpgrade({
upgradeCommandName:
RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME,
})
@Column({ default: true })
isUIEditable: boolean;
// Superseded by isUIEditable. Intentionally NOT @WasRemovedInUpgrade: dropping
// it in 2.13 would break the previous release's pods mid rolling-deploy, since
// they still SELECT it. The WasRemovedInUpgrade<T> type is kept so callers may
// omit it; the decorator + physical drop are deferred (core-team-issues#2542).
@Column({ type: 'boolean', default: false })
isUIReadOnly: WasRemovedInUpgrade<boolean>;
// Is this really nullable ?
@Column({ nullable: true, default: true, type: 'boolean' })
isNullable: boolean | null;
// Derived at flat-entity cache build time from the existence of a
// single-field UNIQUE IndexMetadata covering this field — never persisted
// on this entity. Kept on the type so flat-entity consumers continue to
// read field.isUnique without per-call derivation; the PG column was
// dropped by 1798300000000-drop-field-metadata-is-unique-column.ts.
isUnique: boolean | null;
@Column({ default: false })
isLabelSyncedWithName: boolean;
@Column({ nullable: true, type: 'uuid' })
relationTargetFieldMetadataId: AssignTypeIfIsMorphOrRelationFieldMetadataType<
string,
TFieldMetadataType
>;
@OneToOne(
() => FieldMetadataEntity,
(fieldMetadata) => fieldMetadata.relationTargetFieldMetadataId,
{ nullable: true },
)
@JoinColumn({ name: 'relationTargetFieldMetadataId' })
relationTargetFieldMetadata: AssignTypeIfIsMorphOrRelationFieldMetadataType<
Relation<FieldMetadataEntity>,
TFieldMetadataType
>;
@Column({ nullable: true, type: 'uuid' })
relationTargetObjectMetadataId: AssignTypeIfIsMorphOrRelationFieldMetadataType<
string,
TFieldMetadataType
>;
@ManyToOne(() => ObjectMetadataEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'relationTargetObjectMetadataId' })
relationTargetObjectMetadata: AssignTypeIfIsMorphOrRelationFieldMetadataType<
Relation<ObjectMetadataEntity>,
TFieldMetadataType
>;
@Column({ nullable: true, type: 'uuid' })
morphId: AssignIfIsGivenFieldMetadataType<
string,
TFieldMetadataType,
FieldMetadataType.MORPH_RELATION
>;
@OneToMany(
() => IndexFieldMetadataEntity,
(indexFieldMetadata) => indexFieldMetadata.indexMetadata,
{
cascade: true,
},
)
indexFieldMetadatas: Relation<IndexFieldMetadataEntity[]>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@OneToMany(
() => FieldPermissionEntity,
(fieldPermission) => fieldPermission.fieldMetadata,
)
fieldPermissions: Relation<FieldPermissionEntity[]>;
@OneToMany(() => ViewFieldEntity, (viewField) => viewField.fieldMetadata)
viewFields: Relation<ViewFieldEntity[]>;
@OneToMany(() => ViewFilterEntity, (viewFilter) => viewFilter.fieldMetadata)
viewFilters: Relation<ViewFilterEntity[]>;
@OneToMany(
() => ViewEntity,
(view) => view.kanbanAggregateOperationFieldMetadata,
)
kanbanAggregateOperationViews: Relation<ViewEntity[]>;
@OneToMany(() => ViewEntity, (view) => view.calendarFieldMetadata)
calendarViews: Relation<ViewEntity[]>;
@OneToMany(() => ViewEntity, (view) => view.mainGroupByFieldMetadata)
mainGroupByFieldMetadataViews: Relation<ViewEntity[]>;
@OneToMany(() => ViewSortEntity, (viewSort) => viewSort.fieldMetadata)
viewSorts: Relation<ViewSortEntity[]>;
@OneToMany(
() => SearchFieldMetadataEntity,
(searchFieldMetadata) => searchFieldMetadata.fieldMetadata,
)
searchFieldMetadatas: Relation<SearchFieldMetadataEntity[]>;
}