fix(server): repair 2.13 isUIReadOnly→isUIEditable rename fallout (#21504) (#21537)

## Context

Follow-up to #21504 ("Rename isUIReadOnly to isUIEditable, add
isUICreatable…"), which surfaced two issues:

1. **`column FieldMetadataEntity.isUIReadOnly does not exist`** on
twenty-main.com.
2. **`cross-version-upgrade` CI failure** —
`SyncStandardUiCapabilityFlags` aborts the v1.22 → 2.13 upgrade.

## Fix 1 — don't drop `isUIReadOnly` in the 2.13 rename command

The 2.13 fast instance command physically dropped `isUIReadOnly` from
`core."fieldMetadata"` and `core."objectMetadata"`. But migrations run
in an ArgoCD **PreSync** hook **before** the new pods roll out
(`charts/prod-eu/apps/twenty-server` migration Job is `hook: PreSync`,
sync-wave `2`; the api/worker Deployments are sync-wave `10`). So the
**previous** release's pods keep serving and still `SELECT
isUIReadOnly`, throwing `column ... does not exist` from the moment the
column is dropped until the rollout finishes.

This keeps the column (already hidden from the app via
`@WasRemovedInUpgrade` on both entities) and **defers the physical
drop** to a later release. Since 2.13 hasn't shipped to self-hosters
yet, the committed command is amended in place. Both tables handled;
`isUICreatable` (new, additive column) is unaffected.

The eventual physical drop + GraphQL-compat removals are tracked in
twentyhq/core-team-issues#2542.

## Fix 2 — allow `isUIEditable` updates on relation field metadata

`SyncStandardUiCapabilityFlags` re-syncs `isUIEditable` on standard
fields, including morph/relation fields (the activityTargets `target*`
relations). The flat-field-metadata validator only permits a fixed
property allow-list on relation fields, which omitted `isUIEditable`, so
the command failed with `FIELD_MUTATION_NOT_ALLOWED` and aborted the
upgrade (leaving workspaces in a FAILED state). `isUIEditable` is a
per-field UI-affordance flag that applies to relation fields too, so
it's added to the relation-field updatable properties (a constant used
**only** by that validator — no diff-engine side effects).

## Coherence notes

- Object-level is covered: the drop is deferred on **both** tables, and
`ObjectMetadataEntity` has the identical decorators.
- `isUICreatable` needs no change: object-only and additive (no drop →
no rolling-deploy hazard), and never reaches the field relation
allow-list.
- The object-metadata validator has no relation allow-list, so there's
no object-level analog to change.

## Verification

- `nx typecheck twenty-server`  (the `satisfies` guard holds —
`isUIEditable` is a `toCompare` property of `fieldMetadata`)
- `oxlint --type-aware` + `oxfmt --check`  on changed files
- Fix 2's path is exercised end-to-end by the `cross-version-upgrade` CI
that originally caught it.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-06-14 05:51:23 +02:00
committed by GitHub
parent fefb6cdb94
commit c05f01f2a3
4 changed files with 21 additions and 29 deletions
@@ -3,9 +3,19 @@ import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
// Replaces the negative isUIReadOnly flag with the positive isUIEditable flag
// (inverted polarity) on both objectMetadata and fieldMetadata, and adds the
// Adds the positive isUIEditable flag (inverted polarity of the legacy
// isUIReadOnly flag) on both objectMetadata and fieldMetadata, and adds the
// object-level isUICreatable flag.
//
// The legacy isUIReadOnly column is intentionally NOT dropped here. The
// migration runs in an ArgoCD PreSync hook before the new pods roll out, so the
// previous release's pods keep serving while the schema changes — and they
// still SELECT isUIReadOnly on every fieldMetadata read. Dropping it in the
// same release that stops using it makes those pods throw "column isUIReadOnly
// does not exist" until the rollout completes. isUIReadOnly stays a plain
// column on both entities (no longer @WasRemovedInUpgrade, so it isn't
// dropped); its removal — decorator + physical drop — is deferred to a later
// release once no running code references it (core-team-issues#2542).
@RegisteredInstanceCommand('2.13.0', 1781277453604)
export class RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand
implements FastInstanceCommand
@@ -17,9 +27,6 @@ export class RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand
await queryRunner.query(
`UPDATE "core"."objectMetadata" SET "isUIEditable" = false WHERE "isUIReadOnly" = true`,
);
await queryRunner.query(
'ALTER TABLE "core"."objectMetadata" DROP COLUMN IF EXISTS "isUIReadOnly"',
);
await queryRunner.query(
'ALTER TABLE "core"."objectMetadata" ADD COLUMN IF NOT EXISTS "isUICreatable" boolean NOT NULL DEFAULT true',
@@ -31,18 +38,9 @@ export class RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand
await queryRunner.query(
`UPDATE "core"."fieldMetadata" SET "isUIEditable" = false WHERE "isUIReadOnly" = true`,
);
await queryRunner.query(
'ALTER TABLE "core"."fieldMetadata" DROP COLUMN IF EXISTS "isUIReadOnly"',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."fieldMetadata" ADD COLUMN IF NOT EXISTS "isUIReadOnly" boolean NOT NULL DEFAULT false',
);
await queryRunner.query(
`UPDATE "core"."fieldMetadata" SET "isUIReadOnly" = true WHERE "isUIEditable" = false`,
);
await queryRunner.query(
'ALTER TABLE "core"."fieldMetadata" DROP COLUMN IF EXISTS "isUIEditable"',
);
@@ -50,13 +48,6 @@ export class RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand
await queryRunner.query(
'ALTER TABLE "core"."objectMetadata" DROP COLUMN IF EXISTS "isUICreatable"',
);
await queryRunner.query(
'ALTER TABLE "core"."objectMetadata" ADD COLUMN IF NOT EXISTS "isUIReadOnly" boolean NOT NULL DEFAULT false',
);
await queryRunner.query(
`UPDATE "core"."objectMetadata" SET "isUIReadOnly" = true WHERE "isUIEditable" = false`,
);
await queryRunner.query(
'ALTER TABLE "core"."objectMetadata" DROP COLUMN IF EXISTS "isUIEditable"',
);
@@ -129,10 +129,10 @@ export class FieldMetadataEntity<
@Column({ default: true })
isUIEditable: boolean;
@WasRemovedInUpgrade({
upgradeCommandName:
RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME,
})
// 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>;
@@ -8,4 +8,5 @@ export const FLAT_FIELD_METADATA_RELATION_PROPERTIES_TO_COMPARE = [
'icon',
'name',
'universalSettings',
'isUIEditable',
] as const satisfies (typeof ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY.fieldMetadata.propertiesToCompare)[number][];
@@ -95,10 +95,10 @@ export class ObjectMetadataEntity
@Column({ default: true })
isUIEditable: boolean;
@WasRemovedInUpgrade({
upgradeCommandName:
RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME,
})
// 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>;