Skip to content

Commit cf3b70a

Browse files
committed
feat(tables): trigger workflows on row deletes
1 parent f951e1b commit cf3b70a

11 files changed

Lines changed: 313 additions & 105 deletions

File tree

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

Lines changed: 42 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', () => ({
@@ -34,18 +36,34 @@ vi.mock('@/lib/table/service', () => ({ withLockedTable: mockWithLockedTable }))
3436
vi.mock('@/lib/table/ttl-availability', () => ({
3537
isTableRowTtlEnabled: mockIsTableRowTtlEnabled,
3638
}))
39+
vi.mock('@/lib/table/trigger', () => ({ fireTableTrigger: mockFireTableTrigger }))
3740

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

4043
const dialect = new PgDialect()
4144

4245
const 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+
4967
describe('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
})

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

Lines changed: 57 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import { getColumnId } from '@/lib/table/column-keys'
88
import { signalTableRowsChanged } from '@/lib/table/events'
99
import { assertRowDelete, TableLockedError } from '@/lib/table/mutation-locks'
1010
import type { DbTransaction } from '@/lib/table/planner'
11+
import type { DeletedTableRow } from '@/lib/table/rows/ordering'
1112
import { withLockedTable } from '@/lib/table/service'
13+
import { fireTableTrigger } from '@/lib/table/trigger'
1214
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
15+
import type { RowData, TableSchema } from '@/lib/table/types'
1316

1417
const logger = createLogger('CleanupTableRowTtl')
1518
const cleanupDb = dbFor('cleanup')
@@ -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): 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 > TTL_CLEANUP_BATCH_SIZE) {
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

@@ -123,12 +130,8 @@ async function deleteExpiredTableRowBatch(
123130
columnKey: string,
124131
nowEpochSeconds: number,
125132
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`
133+
): Promise<DeletedTtlRows> {
134+
const rows = await trx.execute<{ id: string; data: RowData; createdAt: string }>(sql`
132135
WITH candidates AS MATERIALIZED (
133136
SELECT table_row.id
134137
FROM ${userTableRows} AS table_row
@@ -148,18 +151,14 @@ async function deleteExpiredTableRowBatch(
148151
DELETE FROM ${userTableRows} AS table_row
149152
USING candidates
150153
WHERE table_row.id = candidates.id
151-
RETURNING table_row.id, table_row.created_at
154+
RETURNING
155+
table_row.id,
156+
table_row.data,
157+
to_char(table_row.created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US') AS "createdAt"
152158
)
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"
159+
SELECT id, data, "createdAt"
162160
FROM deleted
161+
ORDER BY "createdAt", id
163162
`)
164163
return parseDeletedBatch(rows)
165164
}
@@ -170,9 +169,9 @@ async function deleteExpiredRowsForTable(
170169
after?: TtlCleanupCursor
171170
): Promise<DeletedTtlBatch> {
172171
try {
173-
return await withLockedTable(
172+
const batch = await withLockedTable(
174173
ref.id,
175-
async (table, trx) => {
174+
async (table, trx): Promise<DeletedTtlBatch> => {
176175
try {
177176
assertRowDelete(table)
178177
} catch (error) {
@@ -193,10 +192,27 @@ async function deleteExpiredRowsForTable(
193192
nowEpochSeconds,
194193
after
195194
)
196-
return { attempted: true, ...batch }
195+
return {
196+
attempted: true,
197+
...batch,
198+
tableName: table.name,
199+
schema: table.schema,
200+
} satisfies DeletedTtlBatch
197201
},
198202
{ expectedWorkspaceId: ref.workspaceId }
199203
)
204+
if (batch.attempted && batch.rows.length > 0) {
205+
void fireTableTrigger(
206+
ref.id,
207+
batch.tableName,
208+
'delete',
209+
batch.rows,
210+
null,
211+
batch.schema,
212+
'ttl-cleanup'
213+
)
214+
}
215+
return batch
200216
} catch (error) {
201217
if (asOrchestrationError(error)?.code === 'not_found') {
202218
return { attempted: false, deleted: 0, cursor: null }

apps/sim/lib/table/__tests__/service-filter-threading.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { decodeCursor } from '@/lib/table/rows/cursor'
1515
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
1616
import type { ColumnDefinition, TableDefinition } from '@/lib/table/types'
1717

18+
const { mockFireTableTrigger } = vi.hoisted(() => ({ mockFireTableTrigger: vi.fn() }))
19+
1820
vi.mock('@/lib/table/sql', () => ({
1921
buildFilterClause: vi.fn(() => sql`true`),
2022
buildSortClause: vi.fn(() => sql`true`),
@@ -23,7 +25,7 @@ vi.mock('@/lib/table/sql', () => ({
2325
}))
2426

2527
vi.mock('@/lib/table/trigger', () => ({
26-
fireTableTrigger: vi.fn(),
28+
fireTableTrigger: mockFireTableTrigger,
2729
}))
2830

2931
vi.mock('@/lib/table/workflow-group-deps', () => ({
@@ -64,7 +66,9 @@ vi.mock('@/lib/table/validation', () => ({
6466
}))
6567

6668
import {
69+
deleteRow,
6770
deleteRowsByFilter,
71+
deleteRowsByIds,
6872
queryRows,
6973
requireTableRowIds,
7074
updateRowsByFilter,
@@ -183,6 +187,55 @@ describe('service filter threading', () => {
183187
})
184188
})
185189

190+
describe('delete trigger dispatch', () => {
191+
beforeEach(() => {
192+
vi.clearAllMocks()
193+
resetDbChainMock()
194+
})
195+
196+
it('fires with the committed snapshot after deleting one row', async () => {
197+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'row-1', data: { name: 'Ada' } }])
198+
199+
await deleteRow(TABLE, 'row-1', 'req-delete-one')
200+
201+
expect(mockFireTableTrigger).toHaveBeenCalledWith(
202+
TABLE.id,
203+
TABLE.name,
204+
'delete',
205+
[{ id: 'row-1', data: { name: 'Ada' } }],
206+
null,
207+
TABLE.schema,
208+
'req-delete-one'
209+
)
210+
})
211+
212+
it('fires once with every committed snapshot in an ID batch', async () => {
213+
dbChainMockFns.returning.mockResolvedValueOnce([
214+
{ id: 'row-1', data: { name: 'Ada' } },
215+
{ id: 'row-2', data: { name: 'Grace' } },
216+
])
217+
218+
await deleteRowsByIds(
219+
TABLE,
220+
{ tableId: TABLE.id, workspaceId: TABLE.workspaceId, rowIds: ['row-1', 'row-2'] },
221+
'req-delete-many'
222+
)
223+
224+
expect(mockFireTableTrigger).toHaveBeenCalledWith(
225+
TABLE.id,
226+
TABLE.name,
227+
'delete',
228+
[
229+
{ id: 'row-1', data: { name: 'Ada' } },
230+
{ id: 'row-2', data: { name: 'Grace' } },
231+
],
232+
null,
233+
TABLE.schema,
234+
'req-delete-many'
235+
)
236+
})
237+
})
238+
186239
describe('bulk update/delete limited-subset ordering', () => {
187240
beforeEach(() => {
188241
vi.clearAllMocks()

0 commit comments

Comments
 (0)