11/**
22 * @vitest -environment node
33 */
4+ import type { SQL } from 'drizzle-orm'
5+ import { PgDialect } from 'drizzle-orm/pg-core'
46import { beforeEach , describe , expect , it , vi } from 'vitest'
57
8+ vi . unmock ( '@sim/db/schema' )
9+ vi . unmock ( 'drizzle-orm' )
10+
611const {
712 mockDeleteExecute,
813 mockListExecute,
@@ -27,6 +32,8 @@ vi.mock('@/lib/table/service', () => ({ withLockedTable: mockWithLockedTable }))
2732
2833import { cleanupTableRowTtlTask , runCleanupTableRowTtl } from '@/background/cleanup-table-row-ttl'
2934
35+ const dialect = new PgDialect ( )
36+
3037const table = {
3138 id : 'table-1' ,
3239 workspaceId : 'workspace-1' ,
@@ -49,10 +56,14 @@ describe('table row TTL cleanup', () => {
4956 )
5057 } )
5158
52- it ( 'deletes expired rows in locked, keyset batches and signals the table' , async ( ) => {
59+ it ( 'deletes expired rows in locked, created-at keyset batches and signals the table' , async ( ) => {
5360 mockDeleteExecute
54- . mockResolvedValueOnce ( [ { count : 500 , lastId : 'row-500' } ] )
55- . mockResolvedValueOnce ( [ { count : 12 , lastId : 'row-512' } ] )
61+ . mockResolvedValueOnce ( [
62+ { count : 500 , createdAt : '2026-01-01T00:00:00.123456' , lastId : 'row-500' } ,
63+ ] )
64+ . mockResolvedValueOnce ( [
65+ { count : 12 , createdAt : '2026-01-02T00:00:00.000000' , lastId : 'row-512' } ,
66+ ] )
5667
5768 await expect ( runCleanupTableRowTtl ( ) ) . resolves . toEqual ( {
5869 batches : 2 ,
@@ -61,27 +72,58 @@ describe('table row TTL cleanup', () => {
6172 } )
6273 expect ( mockWithLockedTable ) . toHaveBeenCalledTimes ( 2 )
6374 expect ( mockDeleteExecute ) . toHaveBeenCalledTimes ( 2 )
75+ const secondQuery = dialect . sqlToQuery ( mockDeleteExecute . mock . calls [ 1 ] [ 0 ] as SQL )
76+ expect ( secondQuery . sql . replace ( / \$ \d + / g, '?' ) . replace ( / \s + / g, ' ' ) ) . toContain (
77+ 'AND (table_row.created_at, table_row.id) > (?::timestamp, ?)'
78+ )
79+ expect ( secondQuery . params ) . toEqual (
80+ expect . arrayContaining ( [ '2026-01-01T00:00:00.123456' , 'row-500' ] )
81+ )
6482 expect ( mockSignalTableRowsChanged ) . toHaveBeenCalledWith ( table . id )
6583 } )
6684
6785 it ( 'compares TTL values with whole Date.now epoch seconds' , async ( ) => {
6886 const nowEpochMilliseconds = 1_700_000_000_123
6987 const nowEpochSeconds = 1_700_000_000
7088 const nowSpy = vi . spyOn ( Date , 'now' ) . mockReturnValue ( nowEpochMilliseconds )
71- mockDeleteExecute . mockResolvedValue ( [ { count : 0 , lastId : null } ] )
89+ mockDeleteExecute . mockResolvedValue ( [ { count : 0 , createdAt : null , lastId : null } ] )
7290
7391 try {
7492 await runCleanupTableRowTtl ( )
7593 } finally {
7694 nowSpy . mockRestore ( )
7795 }
7896
79- expect ( mockListExecute . mock . calls [ 0 ] [ 0 ] ) . toMatchObject ( {
80- values : expect . arrayContaining ( [ nowEpochSeconds ] ) ,
81- } )
82- expect ( mockDeleteExecute . mock . calls [ 0 ] [ 0 ] ) . toMatchObject ( {
83- values : expect . arrayContaining ( [ nowEpochSeconds ] ) ,
84- } )
97+ expect ( dialect . sqlToQuery ( mockListExecute . mock . calls [ 0 ] [ 0 ] as SQL ) . params ) . toContain (
98+ nowEpochSeconds
99+ )
100+ expect ( dialect . sqlToQuery ( mockDeleteExecute . mock . calls [ 0 ] [ 0 ] as SQL ) . params ) . toContain (
101+ nowEpochSeconds
102+ )
103+ } )
104+
105+ it ( 'checks the oldest expired rows first without using creation time as an expiry rule' , async ( ) => {
106+ mockDeleteExecute . mockResolvedValue ( [ { count : 0 , createdAt : null , lastId : null } ] )
107+
108+ await runCleanupTableRowTtl ( )
109+
110+ const query = dialect
111+ . sqlToQuery ( mockDeleteExecute . mock . calls [ 0 ] [ 0 ] as SQL )
112+ . sql . replace ( / \s + / g, ' ' )
113+ . replace ( / \$ \d + / g, '?' )
114+ . trim ( )
115+ expect ( query ) . toContain ( 'AND (table_row.data->>?)::numeric <= ?' )
116+ expect ( query ) . toContain ( 'ORDER BY table_row.created_at, table_row.id' )
117+ expect ( query ) . toContain ( `to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US')` )
118+ expect ( query ) . not . toContain ( 'table_row.created_by' )
119+ } )
120+
121+ it ( 'rejects a batch without a creation-time cursor' , async ( ) => {
122+ mockDeleteExecute . mockResolvedValue ( [ { count : 1 , lastId : 'row-1' } ] )
123+
124+ await expect ( runCleanupTableRowTtl ( ) ) . rejects . toThrow (
125+ 'Table row TTL cleanup did not return a creation-time cursor'
126+ )
85127 } )
86128
87129 it ( 'does no work when already aborted' , async ( ) => {
@@ -114,7 +156,9 @@ describe('table row TTL cleanup', () => {
114156 } )
115157
116158 it ( 'stops after one hundred full batches' , async ( ) => {
117- mockDeleteExecute . mockResolvedValue ( [ { count : 500 , lastId : 'row-cursor' } ] )
159+ mockDeleteExecute . mockResolvedValue ( [
160+ { count : 500 , createdAt : '2026-01-01T00:00:00.000000' , lastId : 'row-cursor' } ,
161+ ] )
118162
119163 await expect ( runCleanupTableRowTtl ( ) ) . resolves . toEqual ( {
120164 batches : 100 ,
@@ -144,12 +188,12 @@ describe('table row TTL cleanup', () => {
144188 const attempt = ( tableAttempts . get ( tableId ) ?? 0 ) + 1
145189 tableAttempts . set ( tableId , attempt )
146190 if ( tableId === table . id && attempt === 1 ) {
147- return [ { count : 500 , lastId : 'row-500' } ]
191+ return [ { count : 500 , createdAt : '2026-01-01T00:00:00.000000' , lastId : 'row-500' } ]
148192 }
149193 if ( tableId === secondTable . id ) {
150- return [ { count : 1 , lastId : 'row-1' } ]
194+ return [ { count : 1 , createdAt : '2026-01-01T00:00:00.000000' , lastId : 'row-1' } ]
151195 }
152- return [ { count : 0 , lastId : null } ]
196+ return [ { count : 0 , createdAt : null , lastId : null } ]
153197 } ) ,
154198 } )
155199 } )
0 commit comments