feat(workflow): backfill workspace workflowVersion into core (phase A) (#22663)

## workflowVersion -> core, Phase A

Follows #21674 (Phase 0, merged). Base: `main`.

Populates core `workflowVersion` and keeps it in sync with the workspace
object, so a later phase can switch reads to core. Reads stay on the
workspace object in this PR.

### 1. Backfill (upgrade command)
`BackfillWorkflowVersionToCoreCommand`, a
`@RegisteredWorkspaceCommand('2.20.0', ...)`. Per workspace, reads all
workspace `workflowVersion` records and upserts them into core,
preserving ids (idempotent), dry-run aware.

### 2. Dual-write (always on, not flag-gated)
`WorkflowVersionCoreDualWriteListener` hooks
`@OnDatabaseBatchEvent('workflowVersion', CREATED/UPDATED/DELETED)`
(same mechanism as the existing workflow-version status listener) and
mirrors every mutation into core. Sync failures are logged, never break
the user's write; drift is repaired by re-running the backfill command.

Dual-write is deliberately not behind a flag: reading from core (next
phase) is only safe if core has been continuously in sync since the
backfill. An always-on mirror makes "core is fresh" an invariant, so the
read switch becomes a plain flag flip. The cost is one extra upsert on
infrequent workflowVersion writes.

`IS_WORKFLOW_VERSION_IN_CORE_ENABLED` is reserved for the read switch
(Phase B): dispatch from the `workflowAutomatedTriggerMaps` cache,
runner and builder reading trigger/steps from core. Until then it gates
nothing.

Both the backfill and the listener go through a single
`WorkflowVersionCoreSyncService` (`upsertToCore`/`deleteFromCore`): the
workspace-to-core mapping (`trigger` -> `triggers[]`, plus `steps`,
`status`, `workflowId`) and `workflowAutomatedTriggerMaps` invalidation
live in one place.

### Rollout plan (following phases)
- **B, read switch (flag per workspace):** reads move to core; writes
keep flowing workspace -> listener -> core. Rollback = flip the flag
back, workspace never stopped being source of truth.
- **C, contract (code change):** write paths write trigger/steps to core
directly; workspace `workflowVersion` stays as a thin shell
(nav/relations/search) but drops the trigger/steps columns; listener and
flag removed.

### Not in this PR
- Reconciliation tooling beyond re-running the backfill.
- The read switch (Phase B).
This commit is contained in:
Thomas Trompette
2026-07-08 18:45:57 +02:00
committed by GitHub
parent 0ea0c23556
commit ca90a9358f
7 changed files with 306 additions and 4 deletions
@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
import {
WorkflowVersionEntity,
WorkflowVersionStatus,
} from 'src/engine/core-modules/workflow/entities/workflow-version.entity';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
@Injectable()
export class WorkflowVersionCoreSyncService {
constructor(
@InjectWorkspaceScopedRepository(WorkflowVersionEntity)
private readonly workflowVersionRepository: WorkspaceScopedRepository<WorkflowVersionEntity>,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
async upsertToCore(
workspaceId: string,
workflowVersions: WorkflowVersionWorkspaceEntity[],
): Promise<void> {
if (workflowVersions.length === 0) {
return;
}
await this.workflowVersionRepository.upsert(
workspaceId,
workflowVersions.map((workflowVersion) => ({
id: workflowVersion.id,
workflowId: workflowVersion.workflowId,
triggers: isDefined(workflowVersion.trigger)
? [workflowVersion.trigger]
: null,
steps: workflowVersion.steps ?? null,
status: workflowVersion.status as unknown as WorkflowVersionStatus,
})),
['id'],
);
await this.invalidateAutomatedTriggerMaps(workspaceId);
}
async deleteFromCore(
workspaceId: string,
workflowVersionIds: string[],
): Promise<void> {
if (workflowVersionIds.length === 0) {
return;
}
await this.workflowVersionRepository.delete(workspaceId, {
id: In(workflowVersionIds),
});
await this.invalidateAutomatedTriggerMaps(workspaceId);
}
private async invalidateAutomatedTriggerMaps(
workspaceId: string,
): Promise<void> {
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'workflowAutomatedTriggerMaps',
]);
}
}
@@ -2,15 +2,25 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkflowVersionEntity } from 'src/engine/core-modules/workflow/entities/workflow-version.entity';
import { WorkflowVersionCoreSyncService } from 'src/engine/core-modules/workflow/services/workflow-version-core-sync.service';
import { WorkspaceWorkflowAutomatedTriggerMapCacheService } from 'src/engine/core-modules/workflow/services/workspace-workflow-automated-trigger-map-cache.service';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
imports: [TypeOrmModule.forFeature([WorkflowVersionEntity])],
imports: [
TypeOrmModule.forFeature([WorkflowVersionEntity]),
WorkspaceCacheModule,
],
providers: [
WorkspaceWorkflowAutomatedTriggerMapCacheService,
WorkflowVersionCoreSyncService,
provideWorkspaceScopedRepository(WorkflowVersionEntity),
],
exports: [TypeOrmModule, WorkspaceWorkflowAutomatedTriggerMapCacheService],
exports: [
TypeOrmModule,
WorkspaceWorkflowAutomatedTriggerMapCacheService,
WorkflowVersionCoreSyncService,
],
})
export class WorkflowVersionCoreModule {}