feat(workflow): soft-ref core workflow/version (backfill + dual-write) (#22821)

Replaces the shared-UUID model (core row reuses the workspace record id)
with a **soft-ref**: the workspace `workflow`/`workflowVersion` records
carry a nullable `coreWorkflowId`/`coreWorkflowVersionId` pointing to
their **own-id** core rows. This removes the assumption that workspace
record ids are globally unique - which is false, since prefilled/seeded
workflows share ids across workspaces. Supersedes #22776.

## In this PR
**Soft-ref columns (foundation):**
- **twenty-shared** `STANDARD_OBJECTS`:
`workflowVersion.coreWorkflowVersionId` + `workflow.coreWorkflowId` (+
snapshot test).
- **compute utils**: both as system, nullable UUID fields.
- **entity classes**: the bare fields.

**Version soft-ref sync:**
- Core `workflowVersion` rows get their own id, derived
deterministically from `workspaceId + record id` (uuidv5). Deterministic
so the upsert is idempotent: a failed write-back re-derives the same id
and self-heals instead of orphaning rows or colliding on the
one-active-per-workflow index.
- Sync = find-or-create keyed on the workspace record's
`coreWorkflowVersionId`, then write the core id back onto the workspace
record.
- Migrating over pre-soft-ref data: purges any core row whose id equals
the workspace record id before recreating, so old shared-UUID rows
aren't orphaned.
- Version dual-write listener reworked: delete is keyed by the core id
read off `before.coreWorkflowVersionId`.

Verified on a fresh `database:reset` (columns materialize, backfill
produces deterministic own-id rows linked back, idempotent re-run), a
simulated old shared-UUID state (stale rows purged, records re-linked),
and a simulated write-back failure (retry re-links to the same id, no
orphan, active-version index intact).

## Next steps (follow-up work, not in this PR)
1. Workflow-side soft-ref sync mirroring the version side (service,
module, dual-write listener, backfill command).
2. Workspace command to add the two columns to existing workspaces.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22821?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-15 17:50:00 +02:00
committed by GitHub
parent edd35c79d9
commit f67eb60c57
9 changed files with 469 additions and 331 deletions
@@ -17,6 +17,7 @@ export class WorkflowVersionWorkspaceEntity extends BaseWorkspaceEntity {
name: string | null;
trigger: WorkflowTrigger | null;
steps: WorkflowAction[] | null;
coreWorkflowVersionId: string | null;
status: WorkflowVersionStatus;
position: number;
searchVector: string;
@@ -17,6 +17,7 @@ export enum WorkflowStatus {
export class WorkflowWorkspaceEntity extends BaseWorkspaceEntity {
name: string | null;
lastPublishedVersionId: string | null;
coreWorkflowId: string | null;
statuses: WorkflowStatus[] | null;
position: number;
searchVector: string;
@@ -67,7 +67,9 @@ export class WorkflowVersionCoreDualWriteListener {
): Promise<void> {
await this.deleteFromCore(
batchEvent.workspaceId,
batchEvent.events.map((event) => event.properties.before.id),
batchEvent.events
.map((event) => event.properties.before.coreWorkflowVersionId)
.filter(isDefined),
);
}
@@ -79,7 +81,9 @@ export class WorkflowVersionCoreDualWriteListener {
): Promise<void> {
await this.deleteFromCore(
batchEvent.workspaceId,
batchEvent.events.map((event) => event.properties.before.id),
batchEvent.events
.map((event) => event.properties.before.coreWorkflowVersionId)
.filter(isDefined),
);
}
@@ -105,7 +109,7 @@ export class WorkflowVersionCoreDualWriteListener {
private async deleteFromCore(
workspaceId: string | undefined,
workflowVersionIds: string[],
coreWorkflowVersionIds: string[],
): Promise<void> {
if (!isDefined(workspaceId)) {
return;
@@ -114,7 +118,7 @@ export class WorkflowVersionCoreDualWriteListener {
try {
await this.workflowVersionCoreSyncService.deleteFromCore(
workspaceId,
workflowVersionIds,
coreWorkflowVersionIds,
);
} catch (error) {
this.exceptionHandlerService.captureExceptions([error], {