feat(workflow): backfill + dual-write for core workflow entity (#22776)

Workflow-side soft-ref sync — the `coreWorkflowId` mirror of the merged
version side (#22821 / #22940 / #22944 / #22961). Rebased onto current
main; supersedes the original shared-UUID version of this PR.

## What
Gives `core.workflow` a per-workspace copy of each workflow (`name`,
`lastPublishedVersionId`), soft-reffed from the workspace record via
`coreWorkflowId`, so app-shipped workflows have a core home. Does not
touch reads/dispatch (that's Phase B).

- **Sync service** (`WorkflowCoreSyncService`): core rows get their own
id (`uuidv5(workspaceId:recordId)`), the workspace record links via
`coreWorkflowId` (written back after the upsert), and the write-back is
**guarded** on the `coreWorkflowId` field being present (skips with a
warning otherwise — mirrors #22940). Injected repo renamed
`coreWorkflowRepository`.
- **Dual-write listener** on the `workflow` object:
CREATED/UPDATED/RESTORED upsert, DELETED/DESTROYED delete by
`coreWorkflowId`. Always-on; failures routed to Sentry so they never
break the user write.
- **2-20 backfill** (`backfill-workflow-to-core`): reads via the
provided `RunOnWorkspaceArgs.dataSource`, upserts each workspace
workflow into core.
- **2-22 provisioning** (mirrors #22944/#22961):
- `add-workflow-core-soft-ref-field`: adds the `coreWorkflowId` system
field on existing workspaces (flat-entity legacy migration).
- `backfill-workflow-core-links`: full rebuild — per workspace, in one
raw-SQL transaction, wipes all `core.workflow` rows, inserts a fresh
own-id row per workflow, and re-links every record. (No trigger-map
cache to invalidate on `core.workflow`.)

Simpler than the version side: `core.workflow` has no
one-active-per-workflow index and no trigger-map cache, and it was never
backfilled in prod, so there are no legacy shared-id rows.

## Test
Fresh `database:reset` + full sequence (2-20 backfill → 2-22 add-field →
2-22 rebuild link): 4/4 workspace records linked via `coreWorkflowId`
(id != coreWorkflowId), links resolve, **0 dangling**, no duplicate core
rows, names populated. Typecheck + lint + oxfmt clean.
This commit is contained in:
Thomas Trompette
2026-07-20 14:04:32 +02:00
committed by GitHub
parent c04714b9e0
commit 87729a2822
12 changed files with 768 additions and 38 deletions
@@ -0,0 +1,126 @@
import { Injectable } from '@nestjs/common';
import {
type ObjectRecordCreateEvent,
type ObjectRecordDeleteEvent,
type ObjectRecordDestroyEvent,
type ObjectRecordRestoreEvent,
type ObjectRecordUpdateEvent,
} from 'twenty-shared/database-events';
import { isDefined } from 'twenty-shared/utils';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { WorkflowCoreSyncService } from 'src/engine/core-modules/workflow/services/workflow-core-sync.service';
import { type CustomWorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/custom-workspace-batch-event.type';
import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
@Injectable()
export class WorkflowCoreDualWriteListener {
constructor(
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly workflowCoreSyncService: WorkflowCoreSyncService,
) {}
@OnDatabaseBatchEvent('workflow', DatabaseEventAction.CREATED)
async handleCreated(
batchEvent: CustomWorkspaceEventBatch<
ObjectRecordCreateEvent<WorkflowWorkspaceEntity>
>,
): Promise<void> {
await this.upsertToCore(
batchEvent.workspaceId,
batchEvent.events.map((event) => event.properties.after),
);
}
@OnDatabaseBatchEvent('workflow', DatabaseEventAction.UPDATED)
async handleUpdated(
batchEvent: CustomWorkspaceEventBatch<
ObjectRecordUpdateEvent<WorkflowWorkspaceEntity>
>,
): Promise<void> {
await this.upsertToCore(
batchEvent.workspaceId,
batchEvent.events.map((event) => event.properties.after),
);
}
@OnDatabaseBatchEvent('workflow', DatabaseEventAction.RESTORED)
async handleRestored(
batchEvent: CustomWorkspaceEventBatch<
ObjectRecordRestoreEvent<WorkflowWorkspaceEntity>
>,
): Promise<void> {
await this.upsertToCore(
batchEvent.workspaceId,
batchEvent.events.map((event) => event.properties.after),
);
}
@OnDatabaseBatchEvent('workflow', DatabaseEventAction.DELETED)
async handleDeleted(
batchEvent: CustomWorkspaceEventBatch<
ObjectRecordDeleteEvent<WorkflowWorkspaceEntity>
>,
): Promise<void> {
await this.deleteFromCore(
batchEvent.workspaceId,
batchEvent.events
.map((event) => event.properties.before.coreWorkflowId)
.filter(isDefined),
);
}
@OnDatabaseBatchEvent('workflow', DatabaseEventAction.DESTROYED)
async handleDestroyed(
batchEvent: CustomWorkspaceEventBatch<
ObjectRecordDestroyEvent<WorkflowWorkspaceEntity>
>,
): Promise<void> {
await this.deleteFromCore(
batchEvent.workspaceId,
batchEvent.events
.map((event) => event.properties.before.coreWorkflowId)
.filter(isDefined),
);
}
private async upsertToCore(
workspaceId: string | undefined,
workflows: WorkflowWorkspaceEntity[],
): Promise<void> {
if (!isDefined(workspaceId)) {
return;
}
try {
await this.workflowCoreSyncService.upsertToCore(workspaceId, workflows);
} catch (error) {
this.exceptionHandlerService.captureExceptions([error], {
workspace: { id: workspaceId },
});
}
}
private async deleteFromCore(
workspaceId: string | undefined,
coreWorkflowIds: string[],
): Promise<void> {
if (!isDefined(workspaceId)) {
return;
}
try {
await this.workflowCoreSyncService.deleteFromCore(
workspaceId,
coreWorkflowIds,
);
} catch (error) {
this.exceptionHandlerService.captureExceptions([error], {
workspace: { id: workspaceId },
});
}
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WorkflowCoreModule } from 'src/engine/core-modules/workflow/workflow-core.module';
import { WorkflowCoreDualWriteListener } from 'src/modules/workflow/workflow-core-sync/listeners/workflow-core-dual-write.listener';
@Module({
imports: [WorkflowCoreModule],
providers: [WorkflowCoreDualWriteListener],
})
export class WorkflowCoreSyncModule {}
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { WorkflowCoreSyncModule } from 'src/modules/workflow/workflow-core-sync/workflow-core-sync.module';
import { WorkflowStatusModule } from 'src/modules/workflow/workflow-status/workflow-status.module';
import { WorkflowTriggerModule } from 'src/modules/workflow/workflow-trigger/workflow-trigger.module';
import { WorkflowVersionCoreSyncModule } from 'src/modules/workflow/workflow-version-core-sync/workflow-version-core-sync.module';
@@ -9,6 +10,7 @@ import { WorkflowVersionCoreSyncModule } from 'src/modules/workflow/workflow-ver
WorkflowTriggerModule,
WorkflowStatusModule,
WorkflowVersionCoreSyncModule,
WorkflowCoreSyncModule,
],
})
export class WorkflowModule {}