Skip to content

Commit f41ca09

Browse files
committed
feat(tables): add row TTL expiration
1 parent 5c8fc21 commit f41ca09

34 files changed

Lines changed: 975 additions & 37 deletions

File tree

apps/docs/content/docs/en/tables/index.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,16 @@ Every column has a type, which decides how its values are stored and validated.
2424
| **Currency** | An amount in a currency you pick per column | `$1,234.56` |
2525
| **Boolean** | `true` or `false` | `true` |
2626
| **Date** | A date | `2026-03-16` |
27+
| **TTL** | A row expiration date, stored as Unix epoch seconds | `2026-03-16 2:30 PM` |
2728
| **JSON** | An object or array | `{ "tier": "pro" }` |
2829
| **Select** | One of a fixed set of options, or several | `Pro` |
2930

3031
Types are enforced as you enter values, so a Number column only takes numbers.
3132

3233
A Currency column stores a plain number and renders it in the currency you choose for that column, so filters, sorts, and exports all see the amount itself. Changing a column's currency relabels it — it does not convert the amounts.
3334

35+
A table can have one TTL column. Adding it enables row expiration; rows with a non-empty TTL value are deleted after that time passes. Deleting the TTL column disables expiration for the table. TTL cells use the date editor, while APIs and workflows read and write integer Unix epoch seconds, matching DynamoDB TTL.
36+
3437
## Editing a table
3538

3639
Open the **Tables** section in the sidebar and click **New table** to create one. Add columns from the column header, type into a cell to edit it, and paste rows from a spreadsheet to bulk-load. Filter and sort from the toolbar without changing the underlying data. The editor has full keyboard support; see [keyboard shortcuts](/keyboard-shortcuts).
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockEnqueue, mockGetJobQueue, mockVerifyCronAuth } = vi.hoisted(() => ({
8+
mockEnqueue: vi.fn(),
9+
mockGetJobQueue: vi.fn(),
10+
mockVerifyCronAuth: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth }))
14+
vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue }))
15+
16+
import { GET } from '@/app/api/cron/cleanup-table-row-ttl/route'
17+
18+
describe('table row TTL cleanup route', () => {
19+
beforeEach(() => {
20+
vi.clearAllMocks()
21+
vi.useFakeTimers()
22+
vi.setSystemTime(new Date('2026-08-22T17:12:00Z'))
23+
mockVerifyCronAuth.mockReturnValue(null)
24+
mockEnqueue.mockResolvedValue('job-ttl-1')
25+
mockGetJobQueue.mockResolvedValue({ enqueue: mockEnqueue })
26+
})
27+
28+
afterEach(() => {
29+
vi.useRealTimers()
30+
})
31+
32+
it('enqueues one serialized cleanup job', async () => {
33+
const response = await GET(
34+
createMockRequest(
35+
'GET',
36+
undefined,
37+
{},
38+
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
39+
)
40+
)
41+
42+
expect(response.status).toBe(200)
43+
await expect(response.json()).resolves.toEqual({ triggered: true, jobId: 'job-ttl-1' })
44+
expect(mockEnqueue).toHaveBeenCalledWith(
45+
'cleanup-table-row-ttl',
46+
{},
47+
expect.objectContaining({
48+
maxAttempts: 1,
49+
jobId: 'cleanup-table-row-ttl:5958062',
50+
concurrencyKey: 'cleanup:table-row-ttl',
51+
concurrencyLimit: 1,
52+
runner: expect.any(Function),
53+
})
54+
)
55+
})
56+
57+
it('deduplicates retries within the same five-minute schedule window', async () => {
58+
const request = () =>
59+
createMockRequest(
60+
'GET',
61+
undefined,
62+
{},
63+
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
64+
)
65+
66+
await GET(request())
67+
vi.advanceTimersByTime(2 * 60 * 1000)
68+
await GET(request())
69+
70+
expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId)
71+
})
72+
73+
it('returns the cron auth refusal without touching the queue', async () => {
74+
mockVerifyCronAuth.mockReturnValue(new Response(null, { status: 401 }))
75+
76+
const response = await GET(
77+
createMockRequest(
78+
'GET',
79+
undefined,
80+
{},
81+
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
82+
)
83+
)
84+
85+
expect(response.status).toBe(401)
86+
expect(mockGetJobQueue).not.toHaveBeenCalled()
87+
})
88+
})
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { verifyCronAuth } from '@/lib/auth/internal'
4+
import { getJobQueue } from '@/lib/core/async-jobs'
5+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
7+
export const dynamic = 'force-dynamic'
8+
9+
const logger = createLogger('CleanupTableRowTtlApi')
10+
const TTL_CLEANUP_INTERVAL_MS = 5 * 60 * 1000
11+
12+
export const GET = withRouteHandler(async (request: NextRequest) => {
13+
try {
14+
const authError = verifyCronAuth(request, 'table row TTL cleanup')
15+
if (authError) return authError
16+
17+
const queue = await getJobQueue()
18+
const scheduleWindow = Math.floor(Date.now() / TTL_CLEANUP_INTERVAL_MS)
19+
const jobId = await queue.enqueue(
20+
'cleanup-table-row-ttl',
21+
{},
22+
{
23+
maxAttempts: 1,
24+
jobId: `cleanup-table-row-ttl:${scheduleWindow}`,
25+
name: 'Table row TTL cleanup',
26+
concurrencyKey: 'cleanup:table-row-ttl',
27+
concurrencyLimit: 1,
28+
runner: async (_payload, signal) => {
29+
const { runCleanupTableRowTtl } = await import('@/background/cleanup-table-row-ttl')
30+
return runCleanupTableRowTtl(signal)
31+
},
32+
}
33+
)
34+
35+
logger.info('Table row TTL cleanup dispatched', { jobId })
36+
return NextResponse.json({ triggered: true, jobId })
37+
} catch (error) {
38+
logger.error('Failed to dispatch table row TTL cleanup', { error })
39+
return NextResponse.json({ error: 'Failed to dispatch table row TTL cleanup' }, { status: 500 })
40+
}
41+
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import type { ColumnDefinition } from '@/lib/table'
6+
import { columnTypeOptionsForTable } from './column-types'
7+
8+
describe('columnTypeOptionsForTable', () => {
9+
const ttlColumn: ColumnDefinition = { name: 'expires_at', type: 'ttl' }
10+
11+
it('disables TTL with an explanation when the table already has one', () => {
12+
const availableTtl = columnTypeOptionsForTable([{ name: 'name', type: 'string' }]).find(
13+
(option) => option.type === 'ttl'
14+
)
15+
const unavailableTtl = columnTypeOptionsForTable([ttlColumn]).find(
16+
(option) => option.type === 'ttl'
17+
)
18+
19+
expect(availableTtl?.disabledReason).toBeUndefined()
20+
expect(unavailableTtl?.disabledReason).toBe('Only one TTL column allowed per table')
21+
})
22+
23+
it('keeps TTL enabled while editing the existing TTL column', () => {
24+
const ttlOption = columnTypeOptionsForTable([ttlColumn], ttlColumn).find(
25+
(option) => option.type === 'ttl'
26+
)
27+
28+
expect(ttlOption?.disabledReason).toBeUndefined()
29+
})
30+
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
251251
required={column.required}
252252
hint={hint}
253253
mono
254-
value={formatValueForInput(value, column.type)}
254+
value={formatValueForInput(value, column.type, timeZone)}
255255
onChange={onChange}
256256
placeholder='{"key": "value"}'
257257
rows={4}
@@ -260,7 +260,7 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
260260
}
261261

262262
if (definition.editor === 'date') {
263-
const parts = dateValueToLocalParts(formatValueForInput(value, 'date'))
263+
const parts = dateValueToLocalParts(formatValueForInput(value, column.type, timeZone))
264264
return (
265265
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
266266
<div className='flex items-center gap-2'>
@@ -306,7 +306,7 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
306306
inputType={
307307
definition.inputMode === 'decimal' && !definition.acceptsFormattedInput ? 'number' : 'text'
308308
}
309-
value={formatValueForInput(value, column.type)}
309+
value={formatValueForInput(value, column.type, timeZone)}
310310
onChange={onChange}
311311
placeholder={`Enter ${column.name}`}
312312
/>

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface CellContentProps {
1313
/** Current workspace id — lets string cells holding an in-workspace resource
1414
* URL render as a tagged-resource chip instead of a plain external link. */
1515
workspaceId: string
16+
timeZone: string
1617
isEditing: boolean
1718
initialCharacter?: string | null
1819
onSave: (value: unknown, reason: SaveReason) => void
@@ -38,6 +39,7 @@ export function CellContent({
3839
exec,
3940
column,
4041
workspaceId,
42+
timeZone,
4143
isEditing,
4244
initialCharacter,
4345
onSave,
@@ -52,6 +54,7 @@ export function CellContent({
5254
waitingOnLabels,
5355
isEnrichmentOutput,
5456
currentWorkspaceId: workspaceId,
57+
timeZone,
5558
})
5659

5760
return (
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { resolveCellRender } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render'
6+
import type { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types'
7+
8+
function column(type: DisplayColumn['type']): DisplayColumn {
9+
return {
10+
key: 'expires_at',
11+
name: 'expires_at',
12+
type,
13+
groupSize: 1,
14+
groupStartColIndex: 0,
15+
headerLabel: 'expires_at',
16+
isGroupStart: true,
17+
}
18+
}
19+
20+
describe('resolveCellRender', () => {
21+
it('renders TTL epoch seconds through the date presentation', () => {
22+
expect(
23+
resolveCellRender({
24+
value: 1_700_000_000,
25+
exec: undefined,
26+
column: column('ttl'),
27+
waitingOnLabels: undefined,
28+
timeZone: 'America/New_York',
29+
})
30+
).toEqual({ kind: 'date', text: '2023-11-14T17:13:20-05:00' })
31+
})
32+
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ interface ResolveCellRenderInput {
5353
/** Current workspace id — a URL pointing to a resource in this workspace
5454
* renders as a tagged-resource chip rather than a plain external link. */
5555
currentWorkspaceId?: string
56+
/** Effective viewer timezone for instant-like column presentations. */
57+
timeZone?: string
5658
}
5759

5860
export function resolveCellRender({
@@ -62,6 +64,7 @@ export function resolveCellRender({
6264
waitingOnLabels,
6365
isEnrichmentOutput,
6466
currentWorkspaceId,
67+
timeZone,
6568
}: ResolveCellRenderInput): CellRenderKind {
6669
const isNull = value === null || value === undefined
6770
const isEmpty = isNull || value === ''
@@ -137,7 +140,10 @@ export function resolveCellRender({
137140
return { kind: 'text', text: columnTypeOf(column).formatForDisplay(value, column) }
138141
}
139142
if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) }
140-
if (column.type === 'date') return { kind: 'date', text: String(value) }
143+
const definition = columnTypeOf(column)
144+
if (definition.editor === 'date') {
145+
return { kind: 'date', text: definition.formatForInput(value, column, { timezone: timeZone }) }
146+
}
141147
if (column.type === 'string') {
142148
const text = stringifyValue(value)
143149
return resolveLinkKind(text, currentWorkspaceId) ?? { kind: 'text', text }

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ function InlineDateEditor({
7070
const popoverPointerAtRef = useRef(0)
7171
const timeZone = useTimezone()
7272

73-
const storedValue = formatValueForInput(value, column.type)
73+
const storedValue = formatValueForInput(value, column.type, timeZone)
7474
const initialDraft =
7575
initialCharacter !== undefined
7676
? initialCharacter
@@ -115,7 +115,7 @@ function InlineDateEditor({
115115
// silently shifting the instant of a value someone else wrote.
116116
if (storageVal === undefined && initialCharacter === undefined && current === initialDraft) {
117117
doneRef.current = true
118-
onSave(storedValue || null, reason)
118+
onSave(storedValue ? cleanCellValue(storedValue, column, timeZone) : null, reason)
119119
return
120120
}
121121
const raw = storageVal ?? displayToStorage(current, timeZone) ?? current
@@ -132,9 +132,9 @@ function InlineDateEditor({
132132
return
133133
}
134134
doneRef.current = true
135-
onSave(raw || null, reason)
135+
onSave(raw ? cleanCellValue(raw, column, timeZone) : null, reason)
136136
},
137-
[invalid, onSave, onCancel, timeZone, initialDraft, initialCharacter, storedValue]
137+
[invalid, onSave, onCancel, timeZone, initialDraft, initialCharacter, storedValue, column]
138138
)
139139

140140
const handleKeyDown = useCallback(

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface DataRowProps {
2626
/** Current workspace id — forwarded to cells so in-workspace resource URLs
2727
* render as tagged-resource chips. */
2828
workspaceId: string
29+
/** Effective viewer timezone used to render TTL instants. */
30+
timeZone: string
2931
rowIndex: number
3032
isFirstRow: boolean
3133
editingColumnName: string | null
@@ -114,6 +116,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
114116
prev.row !== next.row ||
115117
prev.columns !== next.columns ||
116118
prev.workspaceId !== next.workspaceId ||
119+
prev.timeZone !== next.timeZone ||
117120
prev.rowIndex !== next.rowIndex ||
118121
prev.isFirstRow !== next.isFirstRow ||
119122
prev.editingColumnName !== next.editingColumnName ||
@@ -161,6 +164,7 @@ export const DataRow = React.memo(function DataRow({
161164
row,
162165
columns,
163166
workspaceId,
167+
timeZone,
164168
rowIndex,
165169
isFirstRow,
166170
editingColumnName,
@@ -396,6 +400,7 @@ export const DataRow = React.memo(function DataRow({
396400
<div className={CELL_CONTENT}>
397401
<CellContent
398402
workspaceId={workspaceId}
403+
timeZone={timeZone}
399404
value={
400405
pendingCellValue && column.key in pendingCellValue
401406
? pendingCellValue[column.key]

0 commit comments

Comments
 (0)