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:
+73
-31
@@ -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)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user