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:
+97
@@ -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);
|
||||
});
|
||||
});
|
||||
+28
-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> {
|
||||
|
||||
Reference in New Issue
Block a user