From 652adc3c032b8f38399f32c02fea258d62de1bb2 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:55:54 +0200 Subject: [PATCH] fix(server): backfill `isSystemSideEffect` on system fields provisioned before 2.15 (#22850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context The `isSystemSideEffect` column was introduced in **2.15** via a fast instance command that added it with `DEFAULT false`. That stamped `false` onto every pre-existing `fieldMetadata` row — including the 8 engine-owned system fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`, `searchVector`) of every object provisioned before 2.15, regardless of the creation path (API metadata **and** manifest sync). The per-workspace backfill that should have re-flagged those existing rows was explicitly deferred as out of scope in #21673 ("PR 2") and never shipped for `fieldMetadata`. Because `isSystemSideEffect` is configured with `toCompare: false`, no later sync ever repaired the stale value either. Since **2.20** the SDK no longer declares system fields in manifests. On an up-to-date instance, `twenty plan` against an unchanged app therefore diffs those stale-`false` system fields as **missing from the manifest**, and they fall through the `isSystemSideEffectFlatEntity` exclusion in `buildAllFlatEntityOperationRecordByMetadataNameFromFromTo`. Deletion inference then emits them as deletes, which the validator rejects: ``` Sync failed with 144 errors fieldMetadata: 144 errors 1..144. FIELD_MUTATION_NOT_ALLOWED: System fields cannot be deleted ``` (144 = 8 system fields × 18 custom objects, as reported on a production 2.20 instance.) ## What this PR does Adds a **2.21 workspace command** (`upgrade:2-21:backfill-system-field-is-system-side-effect`) that iterates active/suspended workspaces and flags the 8 system fields as `isSystemSideEffect: true`. - **Resolution by deterministic universal identifier**: for each object × reserved system field name it recomputes `getFieldUniversalIdentifier(applicationUID, objectUID, name)` and looks the row up in the flat maps. This is safe (and preferable to matching by `name`) because the 2.19 backfill already took over system field UIDs for every application, so an author-declared field reusing a reserved name keeps its own identifier and is never touched. An extra `isSystem` guard warn-and-skips any mismatch. - **All applications** are covered (installed apps, workspace custom app, twenty-standard): the stale flag is a function of *when* a row was provisioned, not *how*. Installed/custom apps are the acute `twenty plan` delete trap; twenty-standard has no trap today but flagging is a zero-diff no-op (`toCompare: false`) and a prerequisite for the end-state ownership invariant. - **`name` is intentionally excluded**: the 2.20 slow instance command deliberately flipped it to `false` (caller-provided default, not engine-owned); re-flagging it would undo that migration. - Supports `--dry-run`, updates only the collected rows, and invalidates the `flatFieldMetadataMaps` workspace cache after the write (a raw repository update does not invalidate it). ## Related - Resolves the pre-2.15 regression tail of twentyhq/core-team-issues#2635 - Follow-up to twentyhq/core-team-issues#2642 (system field side-effect engine migration) Review in cubic --------- Co-authored-by: Weiko --- .../2-21-upgrade-version-command.module.ts | 20 +++ ...tem-field-is-system-side-effect.command.ts | 153 ++++++++++++++++++ .../workspace-command-provider.module.ts | 2 + 3 files changed, 175 insertions(+) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-21/2-21-upgrade-version-command.module.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-21/2-21-workspace-command-1783925862946-backfill-system-field-is-system-side-effect.command.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-21/2-21-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-21/2-21-upgrade-version-command.module.ts new file mode 100644 index 0000000000..ce1e916550 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-21/2-21-upgrade-version-command.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; +import { BackfillSystemFieldIsSystemSideEffectCommand } from 'src/database/commands/upgrade-version-command/2-21/2-21-workspace-command-1783925862946-backfill-system-field-is-system-side-effect.command'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; +import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ApplicationEntity, FieldMetadataEntity]), + WorkspaceCacheModule, + WorkspaceIteratorModule, + WorkspaceMigrationRunnerModule, + ], + providers: [BackfillSystemFieldIsSystemSideEffectCommand], +}) +export class V2_21_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-21/2-21-workspace-command-1783925862946-backfill-system-field-is-system-side-effect.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-21/2-21-workspace-command-1783925862946-backfill-system-field-is-system-side-effect.command.ts new file mode 100644 index 0000000000..73b1db68d5 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-21/2-21-workspace-command-1783925862946-backfill-system-field-is-system-side-effect.command.ts @@ -0,0 +1,153 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { getFieldUniversalIdentifier } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; +import { In, Repository } from 'typeorm'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; +import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service'; + +const SYSTEM_FIELD_NAMES = new Set([ + PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.id.name, + PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.createdAt.name, + PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.updatedAt.name, + PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.deletedAt.name, + PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.createdBy.name, + PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.updatedBy.name, + PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.position.name, + PARTIAL_SYSTEM_FLAT_FIELD_METADATAS.searchVector.name, +]); + +@RegisteredWorkspaceCommand('2.21.0', 1783925862946) +@Command({ + name: 'upgrade:2-21:backfill-system-field-is-system-side-effect', + description: + 'Flag existing system fields (id, createdAt, updatedAt, deletedAt, createdBy, updatedBy, position, searchVector) as isSystemSideEffect: true so manifest sync deletion inference excludes them.', +}) +export class BackfillSystemFieldIsSystemSideEffectCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + @InjectRepository(FieldMetadataEntity) + private readonly fieldMetadataRepository: Repository, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const applications = await this.applicationRepository.find({ + select: ['id', 'universalIdentifier'], + where: { workspaceId }, + withDeleted: true, + }); + const applicationUniversalIdentifierById = new Map( + applications.map((application) => [ + application.id, + application.universalIdentifier, + ]), + ); + + const { flatFieldMetadataMaps, flatObjectMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatFieldMetadataMaps', + 'flatObjectMetadataMaps', + ]); + + const fieldMetadataIdsToFlag: string[] = []; + + for (const flatFieldMetadata of Object.values( + flatFieldMetadataMaps.byUniversalIdentifier, + )) { + if ( + !isDefined(flatFieldMetadata) || + !SYSTEM_FIELD_NAMES.has(flatFieldMetadata.name) || + flatFieldMetadata.isSystemSideEffect + ) { + continue; + } + + const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatObjectMetadataMaps, + flatEntityId: flatFieldMetadata.objectMetadataId, + }); + const applicationUniversalIdentifier = + applicationUniversalIdentifierById.get(flatFieldMetadata.applicationId); + + if ( + !isDefined(flatObjectMetadata) || + !isDefined(applicationUniversalIdentifier) + ) { + this.logger.warn( + `Missing object or application for field ${flatFieldMetadata.name} (${flatFieldMetadata.id}) in workspace ${workspaceId}, skipping`, + ); + continue; + } + + const systemFieldUniversalIdentifier = getFieldUniversalIdentifier({ + applicationUniversalIdentifier, + objectUniversalIdentifier: flatObjectMetadata.universalIdentifier, + name: flatFieldMetadata.name, + }); + + if ( + systemFieldUniversalIdentifier !== flatFieldMetadata.universalIdentifier + ) { + this.logger.warn( + `System field ${flatFieldMetadata.name} (${flatFieldMetadata.id}) does not carry its deterministic universal identifier in workspace ${workspaceId}, skipping`, + ); + + continue; + } + + fieldMetadataIdsToFlag.push(flatFieldMetadata.id); + } + + if (fieldMetadataIdsToFlag.length === 0) { + this.logger.log( + `No system field isSystemSideEffect flag to backfill for workspace ${workspaceId}`, + ); + + return; + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Flagging ${fieldMetadataIdsToFlag.length} system field(s) as isSystemSideEffect for workspace ${workspaceId}`, + ); + + if (isDryRun) { + return; + } + + await this.fieldMetadataRepository.update( + { id: In(fieldMetadataIdsToFlag), workspaceId }, + { isSystemSideEffect: true }, + ); + + await this.workspaceMigrationRunnerService.invalidateCache({ + allFlatEntityMapsKeys: [getMetadataFlatEntityMapsKey('fieldMetadata')], + workspaceId, + }); + + this.logger.log( + `Flagged ${fieldMetadataIdsToFlag.length} system field(s) as isSystemSideEffect for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts index 87e10edd2e..631ab4fe43 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts @@ -21,6 +21,7 @@ import { V2_17_UpgradeVersionCommandModule } from 'src/database/commands/upgrade import { V2_18_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-18/2-18-upgrade-version-command.module'; import { V2_19_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-19/2-19-upgrade-version-command.module'; import { V2_20_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-20/2-20-upgrade-version-command.module'; +import { V2_21_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-21/2-21-upgrade-version-command.module'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { V2_20_UpgradeVersionCommandModule } from 'src/database/commands/upgrade V2_18_UpgradeVersionCommandModule, V2_19_UpgradeVersionCommandModule, V2_20_UpgradeVersionCommandModule, + V2_21_UpgradeVersionCommandModule, ], }) export class WorkspaceCommandProviderModule {}