Skip to content

Commit 7262afe

Browse files
committed
feat(tables): add row TTL expiration
1 parent 981be93 commit 7262afe

45 files changed

Lines changed: 1149 additions & 73 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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+
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
1818
import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables'
1919
import { SelectOptionsEditor } from '../select-field'
20-
import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types'
20+
import { columnTypeOptionsForTable } from './column-types'
2121

2222
/** Whether a column type carries an option set. */
2323
function isSelectType(type: ColumnDefinition['type']): boolean {
@@ -52,6 +52,7 @@ interface ColumnConfigSidebarProps {
5252
onClose: () => void
5353
/** Existing column record for `mode: 'edit'`; ignored otherwise. */
5454
existingColumn: ColumnDefinition | null
55+
allColumns: readonly ColumnDefinition[]
5556
workspaceId: string
5657
tableId: string
5758
/** Notify parent of a rename so it can rewrite local `columnOrder` /
@@ -102,6 +103,7 @@ function ColumnConfigBody({
102103
config,
103104
onClose,
104105
existingColumn,
106+
allColumns,
105107
workspaceId,
106108
tableId,
107109
onColumnRename,
@@ -274,11 +276,14 @@ function ColumnConfigBody({
274276
<div className='flex flex-col gap-[9.5px]'>
275277
<RequiredLabel>Type</RequiredLabel>
276278
<ChipCombobox
277-
options={PLAIN_COLUMN_TYPE_OPTIONS.map((o) => ({
278-
label: o.label,
279-
value: o.type,
280-
icon: o.icon,
281-
}))}
279+
options={columnTypeOptionsForTable(allColumns, existingColumn)
280+
.filter((option) => option.type !== 'workflow')
281+
.map((option) => ({
282+
label: option.label,
283+
value: option.type,
284+
icon: option.icon,
285+
disabled: option.disabledReason !== undefined,
286+
}))}
282287
value={typeInput}
283288
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
284289
placeholder='Select type'
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/column-config-sidebar/column-types.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type React from 'react'
22
import { PlayOutline } from '@sim/emcn/icons'
33
import type { ColumnDefinition } from '@/lib/table'
4-
import { ALL_COLUMN_TYPES } from '@/lib/table/column-types'
4+
import { ALL_COLUMN_TYPES, wouldExceedColumnTypeLimit } from '@/lib/table/column-types'
55

66
/**
77
* UI-only column type. `'workflow'` is the virtual entry users pick from the
@@ -14,6 +14,8 @@ export interface ColumnTypeOption {
1414
type: SidebarColumnType
1515
label: string
1616
icon: React.ComponentType<{ className?: string }>
17+
maxPerTable?: number
18+
disabledReason?: string
1719
}
1820

1921
/**
@@ -26,9 +28,30 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [
2628
type: definition.id,
2729
label: definition.label,
2830
icon: definition.icon,
31+
maxPerTable: definition.maxPerTable,
2932
})),
3033
{ type: 'workflow', label: 'Workflow', icon: PlayOutline },
3134
]
3235

33-
/** Plain column types (no workflow). Used by `<ColumnConfigSidebar>`'s type combobox in edit mode. */
34-
export const PLAIN_COLUMN_TYPE_OPTIONS = COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow')
36+
function columnTypeLimitMessage(label: string, maxPerTable: number): string {
37+
return maxPerTable === 1
38+
? `Only one ${label} column allowed per table`
39+
: `Only ${maxPerTable} ${label} columns allowed per table`
40+
}
41+
42+
/** Picker entries with unavailable cardinality-limited types marked as disabled. */
43+
export function columnTypeOptionsForTable(
44+
columns: readonly ColumnDefinition[],
45+
currentColumn?: ColumnDefinition | null
46+
): ColumnTypeOption[] {
47+
return COLUMN_TYPE_OPTIONS.map((option) => {
48+
if (option.type === 'workflow') return option
49+
if (currentColumn?.type === option.type) return option
50+
if (!wouldExceedColumnTypeLimit(columns, option.type, 1)) return option
51+
if (option.maxPerTable === undefined) return option
52+
return {
53+
...option,
54+
disabledReason: columnTypeLimitMessage(option.label, option.maxPerTable),
55+
}
56+
})
57+
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ export { ColumnConfigSidebar } from './column-config-sidebar'
33
export {
44
COLUMN_TYPE_OPTIONS,
55
type ColumnTypeOption,
6-
PLAIN_COLUMN_TYPE_OPTIONS,
6+
columnTypeOptionsForTable,
77
type SidebarColumnType,
88
} from './column-types'

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@ import {
1111
DropdownMenuSeparator,
1212
DropdownMenuTrigger,
1313
Plus,
14+
Tooltip,
1415
} from '@sim/emcn'
1516
import { Sparkles } from '@sim/emcn/icons'
1617
import type { ColumnDefinition } from '@/lib/table'
17-
import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar'
18+
import { type ColumnTypeOption, columnTypeOptionsForTable } from '../column-config-sidebar'
1819

1920
const CELL_HEADER =
2021
'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle'
2122

2223
interface NewColumnDropdownProps {
24+
columns: readonly ColumnDefinition[]
2325
/** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders
2426
* the in-table column-header `<th>` trigger. Same dropdown content either way. */
2527
trigger: 'header' | 'inline-header'
@@ -37,12 +39,49 @@ interface NewColumnDropdownProps {
3739
onBlocked: () => void
3840
}
3941

42+
interface ColumnTypeMenuItemProps {
43+
option: ColumnTypeOption
44+
onSelect: () => void
45+
}
46+
47+
function ColumnTypeMenuItem({ option, onSelect }: ColumnTypeMenuItemProps) {
48+
const Icon = option.icon
49+
const item = (
50+
<DropdownMenuItem
51+
aria-disabled={option.disabledReason ? true : undefined}
52+
className={
53+
option.disabledReason ? 'cursor-not-allowed opacity-50 focus:bg-transparent' : undefined
54+
}
55+
onSelect={(event) => {
56+
if (option.disabledReason) {
57+
event.preventDefault()
58+
return
59+
}
60+
onSelect()
61+
}}
62+
>
63+
<Icon className='size-[14px] text-[var(--text-icon)]' />
64+
{option.label}
65+
</DropdownMenuItem>
66+
)
67+
68+
if (!option.disabledReason) return item
69+
70+
return (
71+
<Tooltip.Root>
72+
<Tooltip.Trigger asChild>{item}</Tooltip.Trigger>
73+
<Tooltip.Content>{option.disabledReason}</Tooltip.Content>
74+
</Tooltip.Root>
75+
)
76+
}
77+
4078
/**
4179
* "+ New column" dropdown — the single entry point for creating a column.
4280
* Lists every column type plus "Workflow" and "Enrichments"; picking a type
4381
* opens the right sidebar pre-seeded.
4482
*/
4583
export function NewColumnDropdown({
84+
columns,
4685
trigger,
4786
disabled,
4887
onPickType,
@@ -98,18 +137,12 @@ export function NewColumnDropdown({
98137
</DropdownMenuItem>
99138
<DropdownMenuSeparator />
100139
</>
101-
{COLUMN_TYPE_OPTIONS.map((option) => {
102-
const Icon = option.icon
140+
{columnTypeOptionsForTable(columns).map((option) => {
103141
const onSelect =
104142
option.type === 'workflow'
105143
? onPickWorkflow
106144
: () => onPickType(option.type as ColumnDefinition['type'])
107-
return (
108-
<DropdownMenuItem key={option.type} onSelect={onSelect}>
109-
<Icon className='size-[14px] text-[var(--text-icon)]' />
110-
{option.label}
111-
</DropdownMenuItem>
112-
)
145+
return <ColumnTypeMenuItem key={option.type} option={option} onSelect={onSelect} />
113146
})}
114147
</DropdownMenuContent>
115148
</DropdownMenu>

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
/>

0 commit comments

Comments
 (0)