From 9901fa93d98eff3973a18e858a858621676c8bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sun, 14 Jun 2026 08:15:50 +0200 Subject: [PATCH] fix(server): write 2.13 UI capability flags directly, bypassing validation (#21543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `, and confirm it completes (pre-fix it threw on the relation field). --- _Generated by [Claude Code](https://claude.ai/code/session_013Az1etaGyxWRRVhgjhPWeB)_ Review in cubic --------- Co-authored-by: Claude --- ...nc-standard-ui-capability-flags.command.ts | 104 ++++++++++++------ 1 file changed, 73 insertions(+), 31 deletions(-) diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-13/2-13-workspace-command-1781277460000-sync-standard-ui-capability-flags.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-13/2-13-workspace-command-1781277460000-sync-standard-ui-capability-flags.command.ts index 14a35563e3..65a2c49819 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-13/2-13-workspace-command-1781277460000-sync-standard-ui-capability-flags.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-13/2-13-workspace-command-1781277460000-sync-standard-ui-capability-flags.command.ts @@ -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) + }`, ); }