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