Skip to content

Commit f8f2d5f

Browse files
authored
feat(tables): trigger workflows on row deletes (#7161)
* feat(tables): trigger workflows on row deletes * fix(tables): bound delete trigger snapshots * fix(tables): scope and verify delete triggers
1 parent 674ca8d commit f8f2d5f

14 files changed

Lines changed: 614 additions & 133 deletions

apps/sim/background/cleanup-table-row-ttl.test.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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

2729
vi.mock('@sim/db', () => ({
@@ -30,22 +32,39 @@ vi.mock('@sim/db', () => ({
3032

3133
vi.mock('@trigger.dev/sdk', () => ({ task: mockTask }))
3234
vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalTableRowsChanged }))
35+
vi.mock('@/lib/table/constants', () => ({ getDeleteSnapshotBatchSize: () => 500 }))
3336
vi.mock('@/lib/table/service', () => ({ withLockedTable: mockWithLockedTable }))
3437
vi.mock('@/lib/table/ttl-availability', () => ({
3538
isTableRowTtlEnabled: mockIsTableRowTtlEnabled,
3639
}))
40+
vi.mock('@/lib/table/trigger', () => ({ fireTableTrigger: mockFireTableTrigger }))
3741

3842
import { cleanupTableRowTtlTask, runCleanupTableRowTtl } from '@/background/cleanup-table-row-ttl'
3943

4044
const dialect = new PgDialect()
4145

4246
const table = {
4347
id: 'table-1',
48+
name: 'Expiring rows',
4449
workspaceId: 'workspace-1',
4550
schema: { columns: [{ id: 'col-ttl', name: 'expires_at', type: 'ttl' }] },
4651
locks: { insertLocked: false, updateLocked: false, deleteLocked: false, schemaLocked: false },
4752
}
4853

54+
function deletedRows(count: number, start = 1) {
55+
return Array.from({ length: count }, (_, index) => {
56+
const number = start + index
57+
return { id: `row-${number}`, data: { value: number } }
58+
})
59+
}
60+
61+
function returnedRows(count: number, start = 1, createdAt = '2026-01-01T00:00:00.000000') {
62+
return deletedRows(count, start).map((row) => ({
63+
...row,
64+
createdAt,
65+
}))
66+
}
67+
4968
describe('table row TTL cleanup', () => {
5069
beforeEach(() => {
5170
vi.clearAllMocks()
@@ -65,11 +84,10 @@ describe('table row TTL cleanup', () => {
6584
it('deletes expired rows in locked, created-at keyset batches and signals the table', async () => {
6685
mockDeleteExecute
6786
.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' },
87+
...returnedRows(499, 1, '2026-01-01T00:00:00.123455'),
88+
...returnedRows(1, 500, '2026-01-01T00:00:00.123456'),
7289
])
90+
.mockResolvedValueOnce(returnedRows(12, 501))
7391

7492
await expect(runCleanupTableRowTtl()).resolves.toEqual({
7593
batches: 2,
@@ -86,13 +104,25 @@ describe('table row TTL cleanup', () => {
86104
expect.arrayContaining(['2026-01-01T00:00:00.123456', 'row-500'])
87105
)
88106
expect(mockSignalTableRowsChanged).toHaveBeenCalledWith(table.id)
107+
expect(mockFireTableTrigger).toHaveBeenCalledTimes(2)
108+
expect(mockFireTableTrigger).toHaveBeenNthCalledWith(
109+
1,
110+
table.id,
111+
table.workspaceId,
112+
table.name,
113+
'delete',
114+
deletedRows(500),
115+
null,
116+
table.schema,
117+
'ttl-cleanup'
118+
)
89119
})
90120

91121
it('compares TTL values with whole Date.now epoch seconds', async () => {
92122
const nowEpochMilliseconds = 1_700_000_000_999
93123
const nowEpochSeconds = 1_700_000_000
94124
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(nowEpochMilliseconds)
95-
mockDeleteExecute.mockResolvedValue([{ count: 0, createdAt: null, lastId: null }])
125+
mockDeleteExecute.mockResolvedValue([])
96126

97127
try {
98128
await runCleanupTableRowTtl()
@@ -109,7 +139,7 @@ describe('table row TTL cleanup', () => {
109139
})
110140

111141
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 }])
142+
mockDeleteExecute.mockResolvedValue([])
113143

114144
await runCleanupTableRowTtl()
115145

@@ -120,12 +150,14 @@ describe('table row TTL cleanup', () => {
120150
.trim()
121151
expect(query).toContain('AND (table_row.data->>?)::numeric <= ?')
122152
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')`)
153+
expect(query).toContain(
154+
`to_char(table_row.created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US') AS "createdAt"`
155+
)
124156
expect(query).not.toContain('table_row.created_by')
125157
})
126158

127159
it('rejects a batch without a creation-time cursor', async () => {
128-
mockDeleteExecute.mockResolvedValue([{ count: 1, lastId: 'row-1' }])
160+
mockDeleteExecute.mockResolvedValue([{ id: 'row-1', data: { value: 1 } }])
129161

130162
await expect(runCleanupTableRowTtl()).rejects.toThrow(
131163
'Table row TTL cleanup did not return a creation-time cursor'
@@ -174,9 +206,7 @@ describe('table row TTL cleanup', () => {
174206
})
175207

176208
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-
])
209+
mockDeleteExecute.mockResolvedValue(returnedRows(500))
180210

181211
await expect(runCleanupTableRowTtl()).resolves.toEqual({
182212
batches: 100,
@@ -206,12 +236,12 @@ describe('table row TTL cleanup', () => {
206236
const attempt = (tableAttempts.get(tableId) ?? 0) + 1
207237
tableAttempts.set(tableId, attempt)
208238
if (tableId === table.id && attempt === 1) {
209-
return [{ count: 500, createdAt: '2026-01-01T00:00:00.000000', lastId: 'row-500' }]
239+
return returnedRows(500)
210240
}
211241
if (tableId === secondTable.id) {
212-
return [{ count: 1, createdAt: '2026-01-01T00:00:00.000000', lastId: 'row-1' }]
242+
return returnedRows(1)
213243
}
214-
return [{ count: 0, createdAt: null, lastId: null }]
244+
return []
215245
}),
216246
})
217247
})

apps/sim/background/cleanup-table-row-ttl.ts

Lines changed: 72 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ import { task } from '@trigger.dev/sdk'
55
import { sql } from 'drizzle-orm'
66
import { asOrchestrationError } from '@/lib/core/orchestration/types'
77
import { getColumnId } from '@/lib/table/column-keys'
8+
import { getDeleteSnapshotBatchSize } from '@/lib/table/constants'
89
import { signalTableRowsChanged } from '@/lib/table/events'
910
import { assertRowDelete, TableLockedError } from '@/lib/table/mutation-locks'
1011
import type { DbTransaction } from '@/lib/table/planner'
12+
import type { DeletedTableRow } from '@/lib/table/rows/ordering'
1113
import { withLockedTable } from '@/lib/table/service'
14+
import { fireTableTrigger } from '@/lib/table/trigger'
1215
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
16+
import type { RowData, TableSchema } from '@/lib/table/types'
1317

1418
const logger = createLogger('CleanupTableRowTtl')
1519
const cleanupDb = dbFor('cleanup')
1620

17-
const TTL_CLEANUP_BATCH_SIZE = 500
1821
const TTL_CLEANUP_MAX_BATCHES = 100
1922

2023
interface ExpiredTtlTableRef {
@@ -23,12 +26,20 @@ interface ExpiredTtlTableRef {
2326
workspaceId: string
2427
}
2528

26-
interface DeletedTtlBatch {
27-
attempted: boolean
29+
interface DeletedTtlRows {
2830
deleted: number
2931
cursor: TtlCleanupCursor | null
32+
rows: DeletedTableRow[]
3033
}
3134

35+
type DeletedTtlBatch =
36+
| { attempted: false; deleted: 0; cursor: null }
37+
| (DeletedTtlRows & {
38+
attempted: true
39+
tableName: string
40+
schema: TableSchema
41+
})
42+
3243
interface TtlCleanupCursor {
3344
createdAt: string
3445
id: string
@@ -85,34 +96,30 @@ async function listExpiredTtlTables(nowEpochSeconds: number): Promise<ExpiredTtl
8596
return Array.isArray(rows) ? rows : []
8697
}
8798

88-
function parseDeletedBatch(rows: unknown): Omit<DeletedTtlBatch, 'attempted'> {
89-
const [row] = Array.isArray(rows)
90-
? (rows as Array<{
91-
count?: number | string
92-
createdAt?: string | null
93-
lastId?: string | null
94-
}>)
95-
: []
96-
if (!row) throw new Error('Table row TTL cleanup did not return a deleted count')
97-
98-
const deleted = Number(row.count)
99-
if (!Number.isSafeInteger(deleted) || deleted < 0 || deleted > TTL_CLEANUP_BATCH_SIZE) {
99+
function parseDeletedBatch(rows: unknown, batchSize: number): DeletedTtlRows {
100+
if (!Array.isArray(rows)) {
101+
throw new Error('Table row TTL cleanup did not return deleted rows')
102+
}
103+
const deletedRows = rows as Array<{ id?: unknown; data?: unknown; createdAt?: unknown }>
104+
if (deletedRows.length > batchSize) {
100105
throw new Error('Table row TTL cleanup returned an invalid deleted count')
101106
}
102-
if (deleted > 0) {
103-
if (typeof row.lastId !== 'string') {
107+
const parsed = deletedRows.map((row) => {
108+
if (typeof row.id !== 'string') {
104109
throw new Error('Table row TTL cleanup did not return a row cursor')
105110
}
106111
if (typeof row.createdAt !== 'string') {
107112
throw new Error('Table row TTL cleanup did not return a creation-time cursor')
108113
}
109-
}
114+
return {
115+
cursor: { createdAt: row.createdAt, id: row.id },
116+
row: { id: row.id, data: row.data as RowData },
117+
}
118+
})
110119
return {
111-
deleted,
112-
cursor:
113-
typeof row.createdAt === 'string' && typeof row.lastId === 'string'
114-
? { createdAt: row.createdAt, id: row.lastId }
115-
: null,
120+
deleted: parsed.length,
121+
cursor: parsed[parsed.length - 1]?.cursor ?? null,
122+
rows: parsed.map(({ row }) => row),
116123
}
117124
}
118125

@@ -122,13 +129,10 @@ async function deleteExpiredTableRowBatch(
122129
workspaceId: string,
123130
columnKey: string,
124131
nowEpochSeconds: number,
132+
batchSize: number,
125133
after?: TtlCleanupCursor
126-
): Promise<Omit<DeletedTtlBatch, 'attempted'>> {
127-
const rows = await trx.execute<{
128-
count: number | string
129-
createdAt: string | null
130-
lastId: string | null
131-
}>(sql`
134+
): Promise<DeletedTtlRows> {
135+
const rows = await trx.execute<{ id: string; data: RowData; createdAt: string }>(sql`
132136
WITH candidates AS MATERIALIZED (
133137
SELECT table_row.id
134138
FROM ${userTableRows} AS table_row
@@ -142,37 +146,34 @@ async function deleteExpiredTableRowBatch(
142146
AND jsonb_typeof(table_row.data->${columnKey}) = 'number'
143147
AND (table_row.data->>${columnKey})::numeric <= ${nowEpochSeconds}
144148
ORDER BY table_row.created_at, table_row.id
145-
LIMIT ${TTL_CLEANUP_BATCH_SIZE}
149+
LIMIT ${batchSize}
146150
FOR UPDATE OF table_row SKIP LOCKED
147151
), deleted AS (
148152
DELETE FROM ${userTableRows} AS table_row
149153
USING candidates
150154
WHERE table_row.id = candidates.id
151-
RETURNING table_row.id, table_row.created_at
155+
RETURNING
156+
table_row.id,
157+
table_row.data,
158+
to_char(table_row.created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US') AS "createdAt"
152159
)
153-
SELECT
154-
count(*)::integer AS count,
155-
(array_agg(id ORDER BY created_at DESC, id DESC))[1] AS "lastId",
156-
(
157-
array_agg(
158-
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US')
159-
ORDER BY created_at DESC, id DESC
160-
)
161-
)[1] AS "createdAt"
160+
SELECT id, data, "createdAt"
162161
FROM deleted
162+
ORDER BY "createdAt", id
163163
`)
164-
return parseDeletedBatch(rows)
164+
return parseDeletedBatch(rows, batchSize)
165165
}
166166

167167
async function deleteExpiredRowsForTable(
168168
ref: ExpiredTtlTableRef,
169169
nowEpochSeconds: number,
170+
batchSize: number,
170171
after?: TtlCleanupCursor
171172
): Promise<DeletedTtlBatch> {
172173
try {
173-
return await withLockedTable(
174+
const batch = await withLockedTable(
174175
ref.id,
175-
async (table, trx) => {
176+
async (table, trx): Promise<DeletedTtlBatch> => {
176177
try {
177178
assertRowDelete(table)
178179
} catch (error) {
@@ -191,12 +192,31 @@ async function deleteExpiredRowsForTable(
191192
table.workspaceId,
192193
getColumnId(ttlColumn),
193194
nowEpochSeconds,
195+
batchSize,
194196
after
195197
)
196-
return { attempted: true, ...batch }
198+
return {
199+
attempted: true,
200+
...batch,
201+
tableName: table.name,
202+
schema: table.schema,
203+
} satisfies DeletedTtlBatch
197204
},
198205
{ expectedWorkspaceId: ref.workspaceId }
199206
)
207+
if (batch.attempted && batch.rows.length > 0) {
208+
await fireTableTrigger(
209+
ref.id,
210+
ref.workspaceId,
211+
batch.tableName,
212+
'delete',
213+
batch.rows,
214+
null,
215+
batch.schema,
216+
'ttl-cleanup'
217+
)
218+
}
219+
return batch
200220
} catch (error) {
201221
if (asOrchestrationError(error)?.code === 'not_found') {
202222
return { attempted: false, deleted: 0, cursor: null }
@@ -216,6 +236,7 @@ export async function runCleanupTableRowTtl(
216236
}
217237

218238
const nowEpochSeconds = Math.floor(Date.now() / 1000)
239+
const batchSize = getDeleteSnapshotBatchSize()
219240
const tableRefs = await listExpiredTtlTables(nowEpochSeconds)
220241
const tableStates: TtlTableCleanupState[] = tableRefs.map((ref) => ({
221242
ref,
@@ -234,7 +255,12 @@ export async function runCleanupTableRowTtl(
234255
if (state.complete) continue
235256
if (batches === TTL_CLEANUP_MAX_BATCHES || signal?.aborted) break
236257

237-
const batch = await deleteExpiredRowsForTable(state.ref, nowEpochSeconds, state.after)
258+
const batch = await deleteExpiredRowsForTable(
259+
state.ref,
260+
nowEpochSeconds,
261+
batchSize,
262+
state.after
263+
)
238264
if (!batch.attempted) {
239265
state.complete = true
240266
continue
@@ -244,7 +270,7 @@ export async function runCleanupTableRowTtl(
244270
deleted += batch.deleted
245271
state.deleted += batch.deleted
246272
state.after = batch.cursor ?? undefined
247-
if (batch.deleted < TTL_CLEANUP_BATCH_SIZE) state.complete = true
273+
if (batch.deleted < batchSize) state.complete = true
248274
}
249275
}
250276

0 commit comments

Comments
 (0)