@@ -2,7 +2,7 @@ import { db, workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@
22import { createLogger } from '@sim/logger'
33import type { BlockState , Loop , Parallel } from '@sim/workflow-types/workflow'
44import { SUBFLOW_TYPES } from '@sim/workflow-types/workflow'
5- import { and , eq , isNull } from 'drizzle-orm'
5+ import { and , eq , gte , isNull , lt } from 'drizzle-orm'
66import type { Edge } from 'reactflow'
77import { clampParallelBatchSize } from './subflow-helpers'
88import type { DbOrTx , NormalizedWorkflowData } from './types'
@@ -180,6 +180,27 @@ export async function loadWorkflowFromNormalizedTablesRaw(
180180 }
181181}
182182
183+ /**
184+ * Optimistic-concurrency guard matching a row's `updated_at` against the value
185+ * read earlier through Drizzle.
186+ *
187+ * Postgres stores `timestamp` columns with microsecond precision, but Drizzle's
188+ * default `date` mode surfaces them as JS `Date`s, which truncate to
189+ * milliseconds. A strict equality guard therefore never matches rows whose
190+ * `updated_at` was stamped by SQL `now()`/`defaultNow()` (non-zero
191+ * sub-millisecond digits are lost on read), silently turning the UPDATE into a
192+ * permanent no-op — migrations then re-run on every load without ever
193+ * persisting. Matching the enclosing millisecond restores the intended
194+ * semantics: any concurrent writer stamps a fresh `updated_at`, which falls
195+ * outside this window.
196+ */
197+ function updatedAtMatches ( expected : Date ) {
198+ return and (
199+ gte ( workflowBlocks . updatedAt , expected ) ,
200+ lt ( workflowBlocks . updatedAt , new Date ( expected . getTime ( ) + 1 ) )
201+ )
202+ }
203+
183204export async function persistMigratedBlocks (
184205 workflowId : string ,
185206 originalBlocks : Record < string , BlockState > ,
@@ -197,7 +218,7 @@ export async function persistMigratedBlocks(
197218 eq ( workflowBlocks . workflowId , workflowId ) ,
198219 expectedUpdatedAt === null
199220 ? isNull ( workflowBlocks . updatedAt )
200- : eq ( workflowBlocks . updatedAt , expectedUpdatedAt )
221+ : updatedAtMatches ( expectedUpdatedAt )
201222 )
202223 : and ( eq ( workflowBlocks . id , blockId ) , eq ( workflowBlocks . workflowId , workflowId ) )
203224
0 commit comments