@@ -15,13 +15,15 @@ const {
1515 mockSignalTableRowsChanged,
1616 mockTask,
1717 mockWithLockedTable,
18+ mockFireTableTrigger,
1819} = vi . hoisted ( ( ) => ( {
1920 mockDeleteExecute : vi . fn ( ) ,
2021 mockListExecute : vi . fn ( ) ,
2122 mockIsTableRowTtlEnabled : vi . fn ( ) ,
2223 mockSignalTableRowsChanged : vi . fn ( ) ,
2324 mockTask : vi . fn ( ( config : unknown ) => config ) ,
2425 mockWithLockedTable : vi . fn ( ) ,
26+ mockFireTableTrigger : vi . fn ( ) ,
2527} ) )
2628
2729vi . mock ( '@sim/db' , ( ) => ( {
@@ -34,18 +36,34 @@ vi.mock('@/lib/table/service', () => ({ withLockedTable: mockWithLockedTable }))
3436vi . mock ( '@/lib/table/ttl-availability' , ( ) => ( {
3537 isTableRowTtlEnabled : mockIsTableRowTtlEnabled ,
3638} ) )
39+ vi . mock ( '@/lib/table/trigger' , ( ) => ( { fireTableTrigger : mockFireTableTrigger } ) )
3740
3841import { cleanupTableRowTtlTask , runCleanupTableRowTtl } from '@/background/cleanup-table-row-ttl'
3942
4043const dialect = new PgDialect ( )
4144
4245const table = {
4346 id : 'table-1' ,
47+ name : 'Expiring rows' ,
4448 workspaceId : 'workspace-1' ,
4549 schema : { columns : [ { id : 'col-ttl' , name : 'expires_at' , type : 'ttl' } ] } ,
4650 locks : { insertLocked : false , updateLocked : false , deleteLocked : false , schemaLocked : false } ,
4751}
4852
53+ function deletedRows ( count : number , start = 1 ) {
54+ return Array . from ( { length : count } , ( _ , index ) => {
55+ const number = start + index
56+ return { id : `row-${ number } ` , data : { value : number } }
57+ } )
58+ }
59+
60+ function returnedRows ( count : number , start = 1 , createdAt = '2026-01-01T00:00:00.000000' ) {
61+ return deletedRows ( count , start ) . map ( ( row ) => ( {
62+ ...row ,
63+ createdAt,
64+ } ) )
65+ }
66+
4967describe ( 'table row TTL cleanup' , ( ) => {
5068 beforeEach ( ( ) => {
5169 vi . clearAllMocks ( )
@@ -65,11 +83,10 @@ describe('table row TTL cleanup', () => {
6583 it ( 'deletes expired rows in locked, created-at keyset batches and signals the table' , async ( ) => {
6684 mockDeleteExecute
6785 . mockResolvedValueOnce ( [
68- { count : 500 , createdAt : '2026-01-01T00:00:00.123456' , lastId : 'row-500' } ,
69- ] )
70- . mockResolvedValueOnce ( [
71- { count : 12 , createdAt : '2026-01-02T00:00:00.000000' , lastId : 'row-512' } ,
86+ ...returnedRows ( 499 , 1 , '2026-01-01T00:00:00.123455' ) ,
87+ ...returnedRows ( 1 , 500 , '2026-01-01T00:00:00.123456' ) ,
7288 ] )
89+ . mockResolvedValueOnce ( returnedRows ( 12 , 501 ) )
7390
7491 await expect ( runCleanupTableRowTtl ( ) ) . resolves . toEqual ( {
7592 batches : 2 ,
@@ -86,13 +103,24 @@ describe('table row TTL cleanup', () => {
86103 expect . arrayContaining ( [ '2026-01-01T00:00:00.123456' , 'row-500' ] )
87104 )
88105 expect ( mockSignalTableRowsChanged ) . toHaveBeenCalledWith ( table . id )
106+ expect ( mockFireTableTrigger ) . toHaveBeenCalledTimes ( 2 )
107+ expect ( mockFireTableTrigger ) . toHaveBeenNthCalledWith (
108+ 1 ,
109+ table . id ,
110+ table . name ,
111+ 'delete' ,
112+ deletedRows ( 500 ) ,
113+ null ,
114+ table . schema ,
115+ 'ttl-cleanup'
116+ )
89117 } )
90118
91119 it ( 'compares TTL values with whole Date.now epoch seconds' , async ( ) => {
92120 const nowEpochMilliseconds = 1_700_000_000_999
93121 const nowEpochSeconds = 1_700_000_000
94122 const nowSpy = vi . spyOn ( Date , 'now' ) . mockReturnValue ( nowEpochMilliseconds )
95- mockDeleteExecute . mockResolvedValue ( [ { count : 0 , createdAt : null , lastId : null } ] )
123+ mockDeleteExecute . mockResolvedValue ( [ ] )
96124
97125 try {
98126 await runCleanupTableRowTtl ( )
@@ -109,7 +137,7 @@ describe('table row TTL cleanup', () => {
109137 } )
110138
111139 it ( 'checks the oldest expired rows first without using creation time as an expiry rule' , async ( ) => {
112- mockDeleteExecute . mockResolvedValue ( [ { count : 0 , createdAt : null , lastId : null } ] )
140+ mockDeleteExecute . mockResolvedValue ( [ ] )
113141
114142 await runCleanupTableRowTtl ( )
115143
@@ -120,12 +148,14 @@ describe('table row TTL cleanup', () => {
120148 . trim ( )
121149 expect ( query ) . toContain ( 'AND (table_row.data->>?)::numeric <= ?' )
122150 expect ( query ) . toContain ( 'ORDER BY table_row.created_at, table_row.id' )
123- expect ( query ) . toContain ( `to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US')` )
151+ expect ( query ) . toContain (
152+ `to_char(table_row.created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US') AS "createdAt"`
153+ )
124154 expect ( query ) . not . toContain ( 'table_row.created_by' )
125155 } )
126156
127157 it ( 'rejects a batch without a creation-time cursor' , async ( ) => {
128- mockDeleteExecute . mockResolvedValue ( [ { count : 1 , lastId : 'row-1' } ] )
158+ mockDeleteExecute . mockResolvedValue ( [ { id : 'row-1' , data : { value : 1 } } ] )
129159
130160 await expect ( runCleanupTableRowTtl ( ) ) . rejects . toThrow (
131161 'Table row TTL cleanup did not return a creation-time cursor'
@@ -174,9 +204,7 @@ describe('table row TTL cleanup', () => {
174204 } )
175205
176206 it ( 'stops after one hundred full batches' , async ( ) => {
177- mockDeleteExecute . mockResolvedValue ( [
178- { count : 500 , createdAt : '2026-01-01T00:00:00.000000' , lastId : 'row-cursor' } ,
179- ] )
207+ mockDeleteExecute . mockResolvedValue ( returnedRows ( 500 ) )
180208
181209 await expect ( runCleanupTableRowTtl ( ) ) . resolves . toEqual ( {
182210 batches : 100 ,
@@ -206,12 +234,12 @@ describe('table row TTL cleanup', () => {
206234 const attempt = ( tableAttempts . get ( tableId ) ?? 0 ) + 1
207235 tableAttempts . set ( tableId , attempt )
208236 if ( tableId === table . id && attempt === 1 ) {
209- return [ { count : 500 , createdAt : '2026-01-01T00:00:00.000000' , lastId : 'row-500' } ]
237+ return returnedRows ( 500 )
210238 }
211239 if ( tableId === secondTable . id ) {
212- return [ { count : 1 , createdAt : '2026-01-01T00:00:00.000000' , lastId : 'row-1' } ]
240+ return returnedRows ( 1 )
213241 }
214- return [ { count : 0 , createdAt : null , lastId : null } ]
242+ return [ ]
215243 } ) ,
216244 } )
217245 } )
0 commit comments