Skip to content

Commit 44f5e12

Browse files
committed
feat(tables): trigger workflows on row deletes
1 parent 8168a84 commit 44f5e12

11 files changed

Lines changed: 297 additions & 79 deletions

File tree

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

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ const {
1010
mockSignalTableRowsChanged,
1111
mockTask,
1212
mockWithLockedTable,
13+
mockFireTableTrigger,
1314
} = vi.hoisted(() => ({
1415
mockDeleteExecute: vi.fn(),
1516
mockListExecute: vi.fn(),
1617
mockIsTableRowTtlEnabled: vi.fn(),
1718
mockSignalTableRowsChanged: vi.fn(),
1819
mockTask: vi.fn((config: unknown) => config),
1920
mockWithLockedTable: vi.fn(),
21+
mockFireTableTrigger: vi.fn(),
2022
}))
2123

2224
vi.mock('@sim/db', () => ({
@@ -29,16 +31,25 @@ vi.mock('@/lib/table/service', () => ({ withLockedTable: mockWithLockedTable }))
2931
vi.mock('@/lib/table/ttl-availability', () => ({
3032
isTableRowTtlEnabled: mockIsTableRowTtlEnabled,
3133
}))
34+
vi.mock('@/lib/table/trigger', () => ({ fireTableTrigger: mockFireTableTrigger }))
3235

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

3538
const table = {
3639
id: 'table-1',
40+
name: 'Expiring rows',
3741
workspaceId: 'workspace-1',
3842
schema: { columns: [{ id: 'col-ttl', name: 'expires_at', type: 'ttl' }] },
3943
locks: { insertLocked: false, updateLocked: false, deleteLocked: false, schemaLocked: false },
4044
}
4145

46+
function deletedRows(count: number, start = 1) {
47+
return Array.from({ length: count }, (_, index) => {
48+
const number = start + index
49+
return { id: `row-${number}`, data: { value: number } }
50+
})
51+
}
52+
4253
describe('table row TTL cleanup', () => {
4354
beforeEach(() => {
4455
vi.clearAllMocks()
@@ -57,8 +68,8 @@ describe('table row TTL cleanup', () => {
5768

5869
it('deletes expired rows in locked, keyset batches and signals the table', async () => {
5970
mockDeleteExecute
60-
.mockResolvedValueOnce([{ count: 500, lastId: 'row-500' }])
61-
.mockResolvedValueOnce([{ count: 12, lastId: 'row-512' }])
71+
.mockResolvedValueOnce(deletedRows(500))
72+
.mockResolvedValueOnce(deletedRows(12, 501))
6273

6374
await expect(runCleanupTableRowTtl()).resolves.toEqual({
6475
batches: 2,
@@ -68,13 +79,24 @@ describe('table row TTL cleanup', () => {
6879
expect(mockWithLockedTable).toHaveBeenCalledTimes(2)
6980
expect(mockDeleteExecute).toHaveBeenCalledTimes(2)
7081
expect(mockSignalTableRowsChanged).toHaveBeenCalledWith(table.id)
82+
expect(mockFireTableTrigger).toHaveBeenCalledTimes(2)
83+
expect(mockFireTableTrigger).toHaveBeenNthCalledWith(
84+
1,
85+
table.id,
86+
table.name,
87+
'delete',
88+
deletedRows(500),
89+
null,
90+
table.schema,
91+
'ttl-cleanup'
92+
)
7193
})
7294

7395
it('compares TTL values with whole Date.now epoch seconds', async () => {
7496
const nowEpochMilliseconds = 1_700_000_000_999
7597
const nowEpochSeconds = 1_700_000_000
7698
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(nowEpochMilliseconds)
77-
mockDeleteExecute.mockResolvedValue([{ count: 0, lastId: null }])
99+
mockDeleteExecute.mockResolvedValue([])
78100

79101
try {
80102
await runCleanupTableRowTtl()
@@ -132,7 +154,7 @@ describe('table row TTL cleanup', () => {
132154
})
133155

134156
it('stops after one hundred full batches', async () => {
135-
mockDeleteExecute.mockResolvedValue([{ count: 500, lastId: 'row-cursor' }])
157+
mockDeleteExecute.mockResolvedValue(deletedRows(500))
136158

137159
await expect(runCleanupTableRowTtl()).resolves.toEqual({
138160
batches: 100,
@@ -162,12 +184,12 @@ describe('table row TTL cleanup', () => {
162184
const attempt = (tableAttempts.get(tableId) ?? 0) + 1
163185
tableAttempts.set(tableId, attempt)
164186
if (tableId === table.id && attempt === 1) {
165-
return [{ count: 500, lastId: 'row-500' }]
187+
return deletedRows(500)
166188
}
167189
if (tableId === secondTable.id) {
168-
return [{ count: 1, lastId: 'row-1' }]
190+
return deletedRows(1)
169191
}
170-
return [{ count: 0, lastId: null }]
192+
return []
171193
}),
172194
})
173195
})

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

Lines changed: 54 additions & 22 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
lastId: string | null
32+
rows: DeletedTableRow[]
3033
}
3134

35+
type DeletedTtlBatch =
36+
| { attempted: false; deleted: 0; lastId: null }
37+
| (DeletedTtlRows & {
38+
attempted: true
39+
tableName: string
40+
schema: TableSchema
41+
})
42+
3243
interface TtlTableCleanupState {
3344
ref: ExpiredTtlTableRef
3445
afterId?: string
@@ -80,20 +91,25 @@ async function listExpiredTtlTables(nowEpochSeconds: number): Promise<ExpiredTtl
8091
return Array.isArray(rows) ? rows : []
8192
}
8293

83-
function parseDeletedBatch(rows: unknown): Omit<DeletedTtlBatch, 'attempted'> {
84-
const [row] = Array.isArray(rows)
85-
? (rows as Array<{ count?: number | string; lastId?: string | null }>)
86-
: []
87-
if (!row) throw new Error('Table row TTL cleanup did not return a deleted count')
88-
89-
const deleted = Number(row.count)
90-
if (!Number.isSafeInteger(deleted) || deleted < 0 || deleted > TTL_CLEANUP_BATCH_SIZE) {
94+
function parseDeletedBatch(rows: unknown): DeletedTtlRows {
95+
if (!Array.isArray(rows)) {
96+
throw new Error('Table row TTL cleanup did not return deleted rows')
97+
}
98+
const deletedRows = rows as Array<{ id?: unknown; data?: unknown }>
99+
if (deletedRows.length > TTL_CLEANUP_BATCH_SIZE) {
91100
throw new Error('Table row TTL cleanup returned an invalid deleted count')
92101
}
93-
if (deleted > 0 && typeof row.lastId !== 'string') {
94-
throw new Error('Table row TTL cleanup did not return a row cursor')
102+
const parsed = deletedRows.map((row) => {
103+
if (typeof row.id !== 'string') {
104+
throw new Error('Table row TTL cleanup did not return a row cursor')
105+
}
106+
return { id: row.id, data: row.data as RowData }
107+
})
108+
return {
109+
deleted: parsed.length,
110+
lastId: parsed[parsed.length - 1]?.id ?? null,
111+
rows: parsed,
95112
}
96-
return { deleted, lastId: row.lastId ?? null }
97113
}
98114

99115
async function deleteExpiredTableRowBatch(
@@ -103,8 +119,8 @@ async function deleteExpiredTableRowBatch(
103119
columnKey: string,
104120
nowEpochSeconds: number,
105121
afterId?: string
106-
): Promise<Omit<DeletedTtlBatch, 'attempted'>> {
107-
const rows = await trx.execute<{ count: number | string; lastId: string | null }>(sql`
122+
): Promise<DeletedTtlRows> {
123+
const rows = await trx.execute<{ id: string; data: RowData }>(sql`
108124
WITH candidates AS MATERIALIZED (
109125
SELECT table_row.id
110126
FROM ${userTableRows} AS table_row
@@ -120,12 +136,11 @@ async function deleteExpiredTableRowBatch(
120136
DELETE FROM ${userTableRows} AS table_row
121137
USING candidates
122138
WHERE table_row.id = candidates.id
123-
RETURNING table_row.id
139+
RETURNING table_row.id, table_row.data
124140
)
125-
SELECT
126-
count(*)::integer AS count,
127-
max(id) AS "lastId"
141+
SELECT id, data
128142
FROM deleted
143+
ORDER BY id
129144
`)
130145
return parseDeletedBatch(rows)
131146
}
@@ -136,9 +151,9 @@ async function deleteExpiredRowsForTable(
136151
afterId?: string
137152
): Promise<DeletedTtlBatch> {
138153
try {
139-
return await withLockedTable(
154+
const batch = await withLockedTable(
140155
ref.id,
141-
async (table, trx) => {
156+
async (table, trx): Promise<DeletedTtlBatch> => {
142157
try {
143158
assertRowDelete(table)
144159
} catch (error) {
@@ -159,10 +174,27 @@ async function deleteExpiredRowsForTable(
159174
nowEpochSeconds,
160175
afterId
161176
)
162-
return { attempted: true, ...batch }
177+
return {
178+
attempted: true,
179+
...batch,
180+
tableName: table.name,
181+
schema: table.schema,
182+
} satisfies DeletedTtlBatch
163183
},
164184
{ expectedWorkspaceId: ref.workspaceId }
165185
)
186+
if (batch.attempted && batch.rows.length > 0) {
187+
void fireTableTrigger(
188+
ref.id,
189+
batch.tableName,
190+
'delete',
191+
batch.rows,
192+
null,
193+
batch.schema,
194+
'ttl-cleanup'
195+
)
196+
}
197+
return batch
166198
} catch (error) {
167199
if (asOrchestrationError(error)?.code === 'not_found') {
168200
return { attempted: false, deleted: 0, lastId: 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()

apps/sim/lib/table/__tests__/trigger.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ function firedPayloads(): Payload[] {
6969
}
7070

7171
async function fire(
72-
eventType: 'insert' | 'update',
72+
eventType: 'insert' | 'update' | 'delete',
7373
data: RowData,
7474
oldRows: Map<string, RowData> | null = null
7575
) {
@@ -166,6 +166,19 @@ describe('fireTableTrigger — payload shape', () => {
166166
const [payload] = firedPayloads()
167167
expect(payload.changedColumns).toEqual(['Status'])
168168
})
169+
170+
it('emits the deleted row snapshot as the event row and previous row', async () => {
171+
mockFetchActiveWebhooks.mockResolvedValue([webhookEntry({ eventType: 'delete' })])
172+
173+
await fire('delete', { col_title: 'Removed issue', col_status: 'opt_closed' })
174+
175+
const [payload] = firedPayloads()
176+
const deletedRow = { Title: 'Removed issue', Status: 'Closed' }
177+
expect(payload.rawRow).toEqual(deletedRow)
178+
expect(payload.row).toEqual({ ...deletedRow, Tags: null })
179+
expect(payload.previousRow).toEqual(deletedRow)
180+
expect(payload.changedColumns).toEqual([])
181+
})
169182
})
170183

171184
describe('fireTableTrigger — gating', () => {

0 commit comments

Comments
 (0)