fix(workflow): skip core version id write-back when the field is missing (#22940)

## 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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22940?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. -->
This commit is contained in:
Thomas Trompette
2026-07-16 14:25:11 +02:00
committed by GitHub
parent 18b746d525
commit e9bc06a830
2 changed files with 125 additions and 1 deletions
@@ -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<WorkflowVersionEntity>,
workspaceRepository as unknown as Repository<WorkspaceEntity>,
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);
});
});
@@ -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<WorkflowVersionEntity>,
@@ -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<WorkflowVersionWorkspaceEntity>(
@@ -147,6 +158,22 @@ export class WorkflowVersionCoreSyncService {
}, buildSystemAuthContext(workspaceId));
}
private async workspaceHasCoreWorkflowVersionIdField(
workspaceId: string,
): Promise<boolean> {
const { flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
]);
return isDefined(
flatFieldMetadataMaps.byUniversalIdentifier[
STANDARD_OBJECTS.workflowVersion.fields.coreWorkflowVersionId
.universalIdentifier
],
);
}
private async getCustomApplicationIdOrThrow(
workspaceId: string,
): Promise<string> {