fix(server): write 2.13 UI capability flags directly, bypassing validation (#21543)

## Context

The `v1.22 → v2.13.x` cross-version upgrade test (twenty-infra) still
fails *after* #21537. This is the **same root cause from a deeper
layer**, and the fix here ends the class.

## What's actually happening

The 2.13 `SyncStandardUiCapabilityFlags` workspace command heals drifted
`isUIEditable`/`isUICreatable` on standard metadata by running a **bulk
update through `validateBuildAndRunWorkspaceMigration`** — the
validation pipeline meant for *user-initiated* metadata edits. That
pipeline has multiple "you may not mutate property X on entity Y"
guards, and `isSystemBuild: true` only bypasses **some** of them:

| guard | gated on `isSystemBuild`? |
|---|---|
| system-**field** allow-list (`flat-field-metadata-validator.ts:83`) |
 bypassed |
| system-**object** guard (`flat-object-metadata-validator.ts:63`) | 
bypassed |
| **relation-field** allow-list (`flat-field-metadata-validator.ts:143`)
|  not gated |

So the healing command fails on exactly the workspaces that have real
drift (the genuinely cross-version-upgraded ones). #21537 patched the
relation allow-list by adding `isUIEditable` to it — one guard — and the
build then failed on the next. From this run's logs: `Upgrade summary:
42 workspace(s) succeeded, 2 workspace(s) failed` (the 2 drifted
workspaces; the build returns `status=fail`, the per-workspace error
detail isn't surfaced in logs).

**Root cause:** a trusted system flag-backfill should not run through
the user-mutation validation layer at all.

## Fix (direct metadata write)

`isUIEditable`/`isUICreatable` are UI-affordance columns on
`core.fieldMetadata`/`core.objectMetadata` — changing them needs **no
workspace-schema migration**. The command now writes them **directly**
to those tables (mirroring the 2.13 slow backfill's raw `UPDATE
core."objectMetadata"`) and invalidates the flat-metadata cache,
bypassing the validation pipeline entirely. Drift detection is
unchanged. This removes the whole class of guard rejections instead of
patching guards one at a time.

## Verification

- `nx typecheck twenty-server` , `oxlint --type-aware`  (the file is
intentionally oxfmt-ignored via `**/upgrade-version-command/**`).
- ⚠️ I could **not** run a live cross-version repro from the dev
container (no Docker/Postgres available here). The fix categorically
can't hit the previous failure (the validation pipeline is gone), but
the definitive runtime gate is the twenty-infra `cross-version-upgrade`
job against a new image. Quick local repro to confirm: on a reset dev
DB, flip `isUIEditable` on a standard relation field (e.g. an
`activityTargets` `target*` field) so it drifts, run `yarn command:prod
upgrade:2-13:sync-standard-ui-capability-flags -w <workspaceId>`, and
confirm it completes (pre-fix it threw on the relation field).

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21543?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. -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-06-14 08:15:50 +02:00
committed by GitHub
parent 09f0c9e29a
commit 9901fa93d9
@@ -1,4 +1,7 @@
import { InjectDataSource } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { DataSource } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
@@ -8,7 +11,6 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
// Re-syncs the UI capability flags of standard objects and fields with their
// standard-application definitions. Covers two cases the rename instance
@@ -30,8 +32,9 @@ export class SyncStandardUiCapabilityFlagsCommand extends ActiveOrSuspendedWorks
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceIteratorService);
}
@@ -138,37 +141,76 @@ export class SyncStandardUiCapabilityFlagsCommand extends ActiveOrSuspendedWorks
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
objectMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: objectsToUpdate,
},
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: fieldsToUpdate,
},
},
workspaceId,
// workflowRun, workflowVersion and workspaceMember are system
// objects; without a system build the validator rejects the update.
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
// isUIEditable/isUICreatable are UI-affordance flags stored directly on
// core.objectMetadata/core.fieldMetadata — changing them needs no
// workspace-schema migration. We write them straight to the metadata
// tables instead of going through validateBuildAndRunWorkspaceMigration:
// that pipeline enforces user-facing mutation guards (system-field and
// relation-field property allow-lists) that reject this trusted system
// backfill on cross-version-upgraded workspaces.
const fieldIdsToSetEditable = fieldsToUpdate
.filter((field) => field.isUIEditable)
.map((field) => field.id);
const fieldIdsToSetNonEditable = fieldsToUpdate
.filter((field) => !field.isUIEditable)
.map((field) => field.id);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to sync standard UI capability flags:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
// All writes for a workspace run in one transaction so a mid-run failure
// can't leave the flags partially applied.
const queryRunner = this.coreDataSource.createQueryRunner();
throw new Error(
`Failed to sync standard UI capability flags for workspace ${workspaceId}`,
await queryRunner.connect();
await queryRunner.startTransaction();
try {
if (fieldIdsToSetEditable.length > 0) {
await queryRunner.query(
`UPDATE "core"."fieldMetadata" SET "isUIEditable" = true, "updatedAt" = now() WHERE "id" = ANY($1)`,
[fieldIdsToSetEditable],
);
}
if (fieldIdsToSetNonEditable.length > 0) {
await queryRunner.query(
`UPDATE "core"."fieldMetadata" SET "isUIEditable" = false, "updatedAt" = now() WHERE "id" = ANY($1)`,
[fieldIdsToSetNonEditable],
);
}
for (const objectToUpdate of objectsToUpdate) {
await queryRunner.query(
`UPDATE "core"."objectMetadata" SET "isUICreatable" = $1, "isUIEditable" = $2, "updatedAt" = now() WHERE "id" = $3`,
[
objectToUpdate.isUICreatable,
objectToUpdate.isUIEditable,
objectToUpdate.id,
],
);
}
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
// The raw writes bypass the metadata cache, so invalidate the flat maps the
// app reads these flags from (after the transaction has committed). The
// flags are already persisted, so a cache hiccup must not fail the upgrade —
// a stale cache self-heals on the next flush / version bump.
try {
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
]);
} catch (cacheError) {
this.logger.warn(
`Synced UI capability flags for workspace ${workspaceId} but failed to invalidate the metadata cache: ${
cacheError instanceof Error ? cacheError.message : String(cacheError)
}`,
);
}