From e9bc06a83011fe4add0c47d8e533d6a4a601dbdd Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Thu, 16 Jul 2026 14:25:11 +0200 Subject: [PATCH] fix(workflow): skip core version id write-back when the field is missing (#22940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What The version soft-ref sync (#22821, on main / the 2.22 line, not yet released) added a **write-back** step: after copying a `workflowVersion` into `core.workflowVersion`, it sets `coreWorkflowVersionId` on the workspace record. On workspaces that predate that field (new standard fields aren't auto-provisioned onto existing workspaces), the write-back throws: `Field metadata for field "coreWorkflowVersionId" is missing in object metadata workflowVersion` (from `formatData`). This breaks the sync wherever it runs on an unprovisioned workspace — the backfill (when it runs under the new code) and the dual-write (on any workflow-version create/update). Observed on staging while upgrading to 2.22: 2 of 70 workspaces. **2.20 itself was fine** — it ran the old shared-UUID sync, which had no write-back. ## Fix Guard the write-back: check the workspace's `workflowVersion` object for the `coreWorkflowVersionId` field (via `workspaceCacheService` flat field maps) and skip it with a warning if absent, instead of throwing. The core row is still upserted; the workspace gets linked later once the field is provisioned. ## Follow-up Provisioning `coreWorkflowVersionId` onto existing workspaces and linking them is #22944 (version side). The workflow-side equivalent follows with the workflow-side sync PR. ## Test Unit test covers both branches: field absent → write-back skipped, no throw, core upsert still runs; field present → write-back runs. Typecheck + lint clean. Review in cubic --- ...workflow-version-core-sync.service.spec.ts | 97 +++++++++++++++++++ .../workflow-version-core-sync.service.ts | 29 +++++- 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-server/src/engine/core-modules/workflow/services/__tests__/workflow-version-core-sync.service.spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/workflow/services/__tests__/workflow-version-core-sync.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workflow/services/__tests__/workflow-version-core-sync.service.spec.ts new file mode 100644 index 0000000000..a0cac3c708 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workflow/services/__tests__/workflow-version-core-sync.service.spec.ts @@ -0,0 +1,97 @@ +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { type Repository } from 'typeorm'; + +import { type GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { type WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; +import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { WorkflowVersionCoreSyncService } from 'src/engine/core-modules/workflow/services/workflow-version-core-sync.service'; +import { type WorkflowVersionEntity } from 'src/engine/core-modules/workflow/entities/workflow-version.entity'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; + +const CORE_VERSION_ID_FIELD = + STANDARD_OBJECTS.workflowVersion.fields.coreWorkflowVersionId + .universalIdentifier; + +describe('WorkflowVersionCoreSyncService', () => { + const workspaceId = '20202020-1c25-4d02-bf25-6aeccf7ea419'; + + let service: WorkflowVersionCoreSyncService; + let workflowVersionRepository: { upsert: jest.Mock; delete: jest.Mock }; + let workspaceRepository: { findOne: jest.Mock }; + let globalWorkspaceOrmManager: { executeInWorkspaceContext: jest.Mock }; + let workspaceCacheService: { + getOrRecompute: jest.Mock; + invalidateAndRecompute: jest.Mock; + }; + + const buildVersion = (): WorkflowVersionWorkspaceEntity => + ({ + id: '1dddc806-4144-5020-898f-b1ab287b89d5', + workflowId: 'c95c78b4-48d2-56f6-8e15-36ff8572f1d8', + status: 'DRAFT', + trigger: null, + steps: null, + coreWorkflowVersionId: null, + }) as unknown as WorkflowVersionWorkspaceEntity; + + const mockFieldPresence = (present: boolean) => + workspaceCacheService.getOrRecompute.mockResolvedValue({ + flatFieldMetadataMaps: { + byUniversalIdentifier: present ? { [CORE_VERSION_ID_FIELD]: {} } : {}, + }, + }); + + beforeEach(() => { + workflowVersionRepository = { upsert: jest.fn(), delete: jest.fn() }; + workspaceRepository = { + findOne: jest.fn().mockResolvedValue({ + id: workspaceId, + workspaceCustomApplicationId: 'application-1', + }), + }; + globalWorkspaceOrmManager = { + executeInWorkspaceContext: jest.fn().mockResolvedValue(undefined), + }; + workspaceCacheService = { + getOrRecompute: jest.fn(), + invalidateAndRecompute: jest.fn().mockResolvedValue(undefined), + }; + + service = new WorkflowVersionCoreSyncService( + workflowVersionRepository as unknown as WorkspaceScopedRepository, + workspaceRepository as unknown as Repository, + globalWorkspaceOrmManager as unknown as GlobalWorkspaceOrmManager, + workspaceCacheService as unknown as WorkspaceCacheService, + ); + + jest.spyOn(service['logger'], 'warn').mockImplementation(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('skips the core id write-back when the workspace lacks the coreWorkflowVersionId field', async () => { + mockFieldPresence(false); + + await expect( + service.upsertToCore(workspaceId, [buildVersion()]), + ).resolves.toBeUndefined(); + + expect(workflowVersionRepository.upsert).toHaveBeenCalledTimes(1); + expect( + globalWorkspaceOrmManager.executeInWorkspaceContext, + ).not.toHaveBeenCalled(); + }); + + it('writes the core id back when the field is present', async () => { + mockFieldPresence(true); + + await service.upsertToCore(workspaceId, [buildVersion()]); + + expect( + globalWorkspaceOrmManager.executeInWorkspaceContext, + ).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/workflow/services/workflow-version-core-sync.service.ts b/packages/twenty-server/src/engine/core-modules/workflow/services/workflow-version-core-sync.service.ts index a31dcc9bba..14286d8bf4 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/services/workflow-version-core-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/services/workflow-version-core-sync.service.ts @@ -1,6 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; import { isDefined } from 'twenty-shared/utils'; import { In, Repository } from 'typeorm'; import { v4 as uuidv4, v5 as uuidv5 } from 'uuid'; @@ -25,6 +26,8 @@ const CORE_WORKFLOW_VERSION_ID_NAMESPACE = @Injectable() export class WorkflowVersionCoreSyncService { + private readonly logger = new Logger(WorkflowVersionCoreSyncService.name); + constructor( @InjectWorkspaceScopedRepository(WorkflowVersionEntity) private readonly workflowVersionRepository: WorkspaceScopedRepository, @@ -128,6 +131,14 @@ export class WorkflowVersionCoreSyncService { return; } + if (!(await this.workspaceHasCoreWorkflowVersionIdField(workspaceId))) { + this.logger.warn( + `workflowVersion.coreWorkflowVersionId field missing for workspace ${workspaceId}, skipping core id write-back`, + ); + + return; + } + await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => { const workflowVersionRepository = await this.globalWorkspaceOrmManager.getRepository( @@ -147,6 +158,22 @@ export class WorkflowVersionCoreSyncService { }, buildSystemAuthContext(workspaceId)); } + private async workspaceHasCoreWorkflowVersionIdField( + workspaceId: string, + ): Promise { + const { flatFieldMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatFieldMetadataMaps', + ]); + + return isDefined( + flatFieldMetadataMaps.byUniversalIdentifier[ + STANDARD_OBJECTS.workflowVersion.fields.coreWorkflowVersionId + .universalIdentifier + ], + ); + } + private async getCustomApplicationIdOrThrow( workspaceId: string, ): Promise {