@@ -23,7 +23,10 @@ import {
2323 mutateTableRowsWithSecretProvenance ,
2424 updateTableRowsWithDerivedSecretProvenance ,
2525} from '@/lib/table/rows/secret-provenance'
26- import type { RowData , TableRowSecretProvenanceWrite } from '@/lib/table/types'
26+ import { getRowById , updateRow } from '@/lib/table/rows/service'
27+ import { fireTableTrigger } from '@/lib/table/trigger'
28+ import type { RowData , TableDefinition , TableRowSecretProvenanceWrite } from '@/lib/table/types'
29+ import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns'
2730import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
2831import type { ExecutionCallbacks } from '@/executor/execution/types'
2932
@@ -45,6 +48,10 @@ vi.mock('@sim/db', () => ({
4548 if ( ! database . current ) throw new Error ( 'PostgreSQL test database is not initialized' )
4649 return Reflect . apply ( database . current . select , database . current , args )
4750 } ,
51+ transaction : ( ...args : unknown [ ] ) => {
52+ if ( ! database . current ) throw new Error ( 'PostgreSQL test database is not initialized' )
53+ return Reflect . apply ( database . current . transaction , database . current , args )
54+ } ,
4855 } ,
4956} ) )
5057vi . mock ( '@sim/logger' , ( ) => ( {
@@ -54,6 +61,16 @@ vi.mock('@/lib/core/security/encryption', () => ({
5461 decryptSecret : vi . fn ( async ( ) => ( { decrypted : 'secret-value' } ) ) ,
5562} ) )
5663vi . mock ( '@/lib/table/events' , ( ) => ( { appendTableEvent : vi . fn ( ) } ) )
64+ vi . mock ( '@/lib/table/trigger' , ( ) => ( { fireTableTrigger : vi . fn ( ) } ) )
65+ vi . mock ( '@/lib/table/service' , ( ) => ( { getTableById : vi . fn ( async ( ) => table ) } ) )
66+ vi . mock ( '@/lib/core/async-jobs/config' , ( ) => ( {
67+ getJobQueue : vi . fn ( async ( ) => ( { cancelByKey : vi . fn ( ) , cancelJob : vi . fn ( ) } ) ) ,
68+ } ) )
69+ vi . mock ( '@/lib/table/dispatcher' , ( ) => ( {
70+ listActiveDispatches : vi . fn ( async ( ) => [ ] ) ,
71+ markActiveDispatchesCancelled : vi . fn ( async ( ) => [ ] ) ,
72+ } ) )
73+ vi . mock ( '@/lib/core/config/env-flags' , ( ) => ( { isTriggerDevEnabled : false } ) )
5774vi . mock ( '@/lib/logs/execution/logging-session' , ( ) => loggingSessionMock )
5875vi . mock ( '@/lib/posthog/server' , ( ) => ( { captureServerEvent : vi . fn ( ) } ) )
5976vi . mock ( '@/lib/workflows/executor/execution-core' , ( ) => ( {
@@ -74,6 +91,25 @@ if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databas
7491const connection = databaseUrl ? postgres ( databaseUrl , { max : 1 } ) : undefined
7592const updatedAt = new Date ( '2026-08-05T00:00:00.123Z' )
7693const secretEntry = { columnId : 'retained' , encryptedValue : 'encrypted-secret' , name : 'SECRET' }
94+ const scope = { userId : 'user-1' , workspaceId : 'workspace-1' }
95+ const table : TableDefinition = {
96+ id : 'table-1' ,
97+ workspaceId : 'workspace-1' ,
98+ name : 'Test table' ,
99+ description : null ,
100+ schema : {
101+ columns : [ 'retained' , 'removed' , 'derived' ] . map ( ( id ) => ( { id, name : id , type : 'string' } ) ) ,
102+ workflowGroups : [ { id : 'group-1' , workflowId : 'workflow-1' , outputs : [ ] } ] ,
103+ } ,
104+ metadata : null ,
105+ rowCount : 1 ,
106+ maxRows : 100 ,
107+ createdBy : 'user-1' ,
108+ locks : { schemaLocked : false , insertLocked : false , updateLocked : false , deleteLocked : false } ,
109+ archivedAt : null ,
110+ createdAt : updatedAt ,
111+ updatedAt,
112+ }
77113
78114interface Fixture {
79115 id ?: string
@@ -155,7 +191,17 @@ describe.skipIf(!databaseUrl)('table provenance in PostgreSQL', () => {
155191 CREATE TEMP TABLE user_table_definitions (id text PRIMARY KEY, workspace_id text NOT NULL, rows_version integer NOT NULL);
156192 CREATE TEMP TABLE user_table_rows (
157193 id text PRIMARY KEY, table_id text NOT NULL, workspace_id text NOT NULL,
158- data jsonb NOT NULL, updated_at timestamp NOT NULL, secret_provenance_version integer
194+ data jsonb NOT NULL, updated_at timestamp NOT NULL, secret_provenance_version integer,
195+ position integer NOT NULL DEFAULT 0, order_key text,
196+ created_at timestamp NOT NULL DEFAULT now(), created_by text
197+ );
198+ CREATE TEMP TABLE table_row_executions (
199+ table_id text NOT NULL, row_id text NOT NULL REFERENCES user_table_rows(id) ON DELETE CASCADE,
200+ group_id text NOT NULL, status text NOT NULL, execution_id text, job_id text,
201+ workflow_id text NOT NULL, error text, running_block_ids text[] NOT NULL DEFAULT '{}',
202+ block_errors jsonb NOT NULL DEFAULT '{}', cancelled_at timestamp,
203+ capability_governed_user_id text, enrichment_details jsonb,
204+ updated_at timestamp NOT NULL DEFAULT now(), PRIMARY KEY (row_id, group_id)
159205 );
160206 CREATE TEMP TABLE user_table_row_secret_provenance (
161207 row_id text PRIMARY KEY, content_updated_at timestamp NOT NULL,
@@ -180,7 +226,7 @@ describe.skipIf(!databaseUrl)('table provenance in PostgreSQL', () => {
180226 mockIsEnforced . mockReturnValue ( false )
181227 if ( ! connection ) throw new Error ( 'PostgreSQL test database is not initialized' )
182228 await connection . unsafe (
183- 'TRUNCATE user_table_rows, user_table_row_secret_provenance, user_table_definitions'
229+ 'TRUNCATE table_row_executions, user_table_rows, user_table_row_secret_provenance, user_table_definitions'
184230 )
185231 await connection `INSERT INTO user_table_definitions VALUES ('table-1', 'workspace-1', 7)`
186232 } )
@@ -189,6 +235,206 @@ describe.skipIf(!databaseUrl)('table provenance in PostgreSQL', () => {
189235 await connection ?. end ( )
190236 } )
191237
238+ it . each ( [ 'exact' , 'unknown' , 'legacy' , 'stale' ] as const ) (
239+ 'preserves %s row provenance through cancellation, restart, and a cell write' ,
240+ async ( baseStatus ) => {
241+ if ( ! connection ) throw new Error ( 'PostgreSQL fixture unavailable' )
242+ const boundEntry = {
243+ ...secretEntry ,
244+ sourceUserId : scope . userId ,
245+ sourceWorkspaceId : scope . workspaceId ,
246+ }
247+ await insertRow ( {
248+ version : baseStatus === 'legacy' ? null : 1 ,
249+ ...( baseStatus === 'legacy'
250+ ? { }
251+ : { status : baseStatus === 'stale' ? 'exact' : baseStatus } ) ,
252+ entries : baseStatus === 'unknown' ? [ ] : [ boundEntry ] ,
253+ stale : baseStatus === 'stale' ,
254+ } )
255+ await connection `
256+ INSERT INTO table_row_executions (table_id, row_id, group_id, status, workflow_id)
257+ VALUES ('table-1', 'row-1', 'group-1', 'pending', 'workflow-1')
258+ `
259+ const readStoredContent = ( ) => connection `
260+ SELECT r.data, r.updated_at::text, r.secret_provenance_version,
261+ p.content_updated_at::text, p.status, p.entries, p.updated_at::text AS provenance_updated_at
262+ FROM user_table_rows r LEFT JOIN user_table_row_secret_provenance p ON p.row_id = r.id
263+ WHERE r.id = 'row-1'
264+ `
265+ const before = await readStoredContent ( )
266+ expect ( await cancelWorkflowGroupRuns ( 'table-1' , 'row-1' ) ) . toBe ( 1 )
267+ expect ( await readStoredContent ( ) ) . toEqual ( before )
268+ expect (
269+ ( await getRowById ( 'table-1' , 'row-1' , 'workspace-1' ) ) ?. executions [ 'group-1' ]
270+ ) . toMatchObject ( {
271+ status : 'cancelled' ,
272+ } )
273+
274+ const guard = { groupId : 'group-1' , executionId : 'execution-2' }
275+ const running = {
276+ status : 'running' ,
277+ executionId : 'execution-2' ,
278+ jobId : null ,
279+ workflowId : 'workflow-1' ,
280+ error : null ,
281+ } as const
282+ const restarted = await updateRow (
283+ {
284+ tableId : 'table-1' ,
285+ rowId : 'row-1' ,
286+ workspaceId : 'workspace-1' ,
287+ data : { } ,
288+ secretProvenance : undefined ,
289+ capabilityGovernedUserId : null ,
290+ executionsPatch : { 'group-1' : running } ,
291+ cancellationGuard : { ...guard , allowNewExecution : true } ,
292+ } ,
293+ table ,
294+ 'restart-test'
295+ )
296+ expect ( restarted ?. updatedAt ) . toEqual ( updatedAt )
297+ expect ( await readStoredContent ( ) ) . toEqual ( before )
298+ expect ( fireTableTrigger ) . not . toHaveBeenCalled ( )
299+
300+ /** Usage-limit cleanup supplies no cells and must not stamp even explicit provenance. */
301+ const cleared = await updateRow (
302+ {
303+ tableId : 'table-1' ,
304+ rowId : 'row-1' ,
305+ workspaceId : 'workspace-1' ,
306+ data : { } ,
307+ secretProvenance : { complete : true , columns : { } } ,
308+ capabilityGovernedUserId : null ,
309+ executionsPatch : { 'group-1' : null } ,
310+ cancellationGuard : guard ,
311+ } ,
312+ table ,
313+ 'cleanup-test'
314+ )
315+ expect ( cleared ?. executions ) . toEqual ( { } )
316+ expect ( await readStoredContent ( ) ) . toEqual ( before )
317+ expect ( fireTableTrigger ) . not . toHaveBeenCalled ( )
318+
319+ const written = await updateRow (
320+ {
321+ tableId : 'table-1' ,
322+ rowId : 'row-1' ,
323+ workspaceId : 'workspace-1' ,
324+ data : { derived : 'public result' } ,
325+ secretProvenance : {
326+ complete : true ,
327+ columns : { derived : { version : 1 , complete : true , entries : [ ] , scope } } ,
328+ } ,
329+ capabilityGovernedUserId : null ,
330+ executionsPatch : { 'group-1' : { ...running , status : 'completed' } } ,
331+ cancellationGuard : guard ,
332+ } ,
333+ table ,
334+ 'write-test' ,
335+ { computedWrite : true }
336+ )
337+ expect ( written ) . not . toBeNull ( )
338+ expect ( written ?. updatedAt . getTime ( ) ) . toBeGreaterThan ( updatedAt . getTime ( ) )
339+ const [ stored ] = await readStoredContent ( )
340+ expect ( stored . data ) . toEqual ( { retained : 'value' , removed : 'other' , derived : 'public result' } )
341+ expect ( stored . content_updated_at ) . toBe ( stored . updated_at )
342+ expect ( stored . status ) . toBe (
343+ baseStatus === 'exact' || baseStatus === 'legacy' ? 'exact' : 'unknown'
344+ )
345+ expect ( stored . entries ) . toEqual ( baseStatus === 'exact' ? [ boundEntry ] : [ ] )
346+ expect ( fireTableTrigger ) . toHaveBeenCalledOnce ( )
347+ mockIsEnforced . mockReturnValue ( true )
348+ const provenance = await loadTableRowSecretProvenance (
349+ [ { id : 'row-1' , updatedAt : written ! . updatedAt } ] ,
350+ scope
351+ )
352+ expect ( provenance . complete ) . toBe ( baseStatus === 'exact' || baseStatus === 'legacy' )
353+ expect ( provenance . entries ) . toEqual (
354+ baseStatus === 'exact'
355+ ? [ { encryptedValue : secretEntry . encryptedValue , name : secretEntry . name } ]
356+ : [ ]
357+ )
358+ }
359+ )
360+
361+ it . each ( [ 'cancelled' , 'replaced' ] as const ) (
362+ 'rolls back execution-only cleanup rejected by a %s attempt' ,
363+ async ( attempt ) => {
364+ if ( ! connection ) throw new Error ( 'PostgreSQL fixture unavailable' )
365+ await insertRow ( { status : 'exact' , entries : [ secretEntry ] } )
366+ await connection `
367+ INSERT INTO table_row_executions (table_id, row_id, group_id, status, execution_id, workflow_id)
368+ VALUES ('table-1', 'row-1', 'group-1', ${ attempt === 'cancelled' ? 'cancelled' : 'running' } , ${ attempt === 'cancelled' ? null : 'new-execution' } , 'workflow-1'),
369+ ('table-1', 'row-1', 'other-group', 'pending', NULL, 'workflow-1')
370+ `
371+ const before = await getRowById ( 'table-1' , 'row-1' , 'workspace-1' )
372+ expect (
373+ await updateRow (
374+ {
375+ tableId : 'table-1' ,
376+ rowId : 'row-1' ,
377+ workspaceId : 'workspace-1' ,
378+ data : { } ,
379+ secretProvenance : undefined ,
380+ capabilityGovernedUserId : null ,
381+ executionsPatch : { 'other-group' : null , 'group-1' : null } ,
382+ cancellationGuard : { groupId : 'group-1' , executionId : 'old-execution' } ,
383+ } ,
384+ table ,
385+ 'stale-cleanup-test'
386+ )
387+ ) . toBeNull ( )
388+ expect ( await getRowById ( 'table-1' , 'row-1' , 'workspace-1' ) ) . toEqual ( before )
389+ expect (
390+ ( await loadTableRowSecretProvenance ( [ { id : 'row-1' , updatedAt } ] , scope ) ) . complete
391+ ) . toBe ( true )
392+ }
393+ )
394+
395+ it ( 'rolls back cell data and provenance when a cancelled worker writes late' , async ( ) => {
396+ if ( ! connection ) throw new Error ( 'PostgreSQL fixture unavailable' )
397+ await insertRow ( { status : 'exact' , entries : [ secretEntry ] } )
398+ await connection `
399+ INSERT INTO table_row_executions (table_id, row_id, group_id, status, workflow_id)
400+ VALUES ('table-1', 'row-1', 'group-1', 'cancelled', 'workflow-1')
401+ `
402+ const before = await getRowById ( 'table-1' , 'row-1' , 'workspace-1' )
403+ expect (
404+ await updateRow (
405+ {
406+ tableId : 'table-1' ,
407+ rowId : 'row-1' ,
408+ workspaceId : 'workspace-1' ,
409+ data : { retained : 'late result' } ,
410+ secretProvenance : {
411+ complete : true ,
412+ columns : { retained : { version : 1 , complete : true , entries : [ ] , scope } } ,
413+ } ,
414+ capabilityGovernedUserId : null ,
415+ executionsPatch : {
416+ 'group-1' : {
417+ status : 'completed' ,
418+ executionId : 'old-execution' ,
419+ jobId : null ,
420+ workflowId : 'workflow-1' ,
421+ error : null ,
422+ } ,
423+ } ,
424+ cancellationGuard : { groupId : 'group-1' , executionId : 'old-execution' } ,
425+ } ,
426+ table ,
427+ 'stale-write-test' ,
428+ { computedWrite : true }
429+ )
430+ ) . toBeNull ( )
431+ expect ( await getRowById ( 'table-1' , 'row-1' , 'workspace-1' ) ) . toEqual ( before )
432+ expect (
433+ ( await loadTableRowSecretProvenance ( [ { id : 'row-1' , updatedAt } ] , scope ) ) . entries
434+ ) . toEqual ( [ { encryptedValue : secretEntry . encryptedValue } ] )
435+ expect ( fireTableTrigger ) . not . toHaveBeenCalled ( )
436+ } )
437+
192438 it . each ( [ 'exact' , 'unknown' , 'legacy' ] as const ) (
193439 'persists executor callback provenance across a retried partial write over a %s row' ,
194440 async ( baseStatus ) => {
0 commit comments