Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/docs/content/docs/en/tables/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@ Every column has a type, which decides how its values are stored and validated.
| **Currency** | An amount in a currency you pick per column | `$1,234.56` |
| **Boolean** | `true` or `false` | `true` |
| **Date** | A date | `2026-03-16` |
| **TTL** | An absolute row expiration time, stored as Unix epoch seconds (seconds since January 1, 1970 UTC) | `1773671400` |
| **JSON** | An object or array | `{ "tier": "pro" }` |
| **Select** | One of a fixed set of options, or several | `Pro` |

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

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.

A table can have one TTL column. Adding it enables row expiration; rows with a non-empty TTL value become eligible for deletion after that time passes. Cleanup runs periodically, so actual row removal may happen after the TTL timestamp rather than exactly at it. 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.

## Editing a table

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).
Expand Down
90 changes: 81 additions & 9 deletions apps/docs/openapi-v2-tables.json
Original file line number Diff line number Diff line change
Expand Up @@ -4979,7 +4979,16 @@
},
"type": {
"type": "string",
"enum": ["string", "number", "currency", "boolean", "date", "json", "select"],
"enum": [
"string",
"number",
"currency",
"boolean",
"date",
"ttl",
"json",
"select"
],
"description": "Data type of values stored in the column."
},
"required": {
Expand Down Expand Up @@ -5257,7 +5266,16 @@
},
"type": {
"type": "string",
"enum": ["string", "number", "currency", "boolean", "date", "json", "select"],
"enum": [
"string",
"number",
"currency",
"boolean",
"date",
"ttl",
"json",
"select"
],
"description": "Column data type."
},
"required": {
Expand Down Expand Up @@ -5436,7 +5454,16 @@
},
"type": {
"type": "string",
"enum": ["string", "number", "currency", "boolean", "date", "json", "select"],
"enum": [
"string",
"number",
"currency",
"boolean",
"date",
"ttl",
"json",
"select"
],
"description": "Data type of values stored in the column."
},
"required": {
Expand Down Expand Up @@ -5536,7 +5563,16 @@
},
"type": {
"type": "string",
"enum": ["string", "number", "currency", "boolean", "date", "json", "select"],
"enum": [
"string",
"number",
"currency",
"boolean",
"date",
"ttl",
"json",
"select"
],
"description": "Column data type."
},
"required": {
Expand Down Expand Up @@ -5633,7 +5669,7 @@
"type": {
"description": "Replacement column data type.",
"type": "string",
"enum": ["string", "number", "currency", "boolean", "date", "json", "select"]
"enum": ["string", "number", "currency", "boolean", "date", "ttl", "json", "select"]
},
"required": {
"description": "Whether inserts must supply a value for this column.",
Expand Down Expand Up @@ -7397,7 +7433,16 @@
},
"type": {
"type": "string",
"enum": ["string", "number", "currency", "boolean", "date", "json", "select"],
"enum": [
"string",
"number",
"currency",
"boolean",
"date",
"ttl",
"json",
"select"
],
"description": "Data type of values stored in the column."
},
"required": {
Expand Down Expand Up @@ -7597,7 +7642,16 @@
},
"type": {
"type": "string",
"enum": ["string", "number", "currency", "boolean", "date", "json", "select"],
"enum": [
"string",
"number",
"currency",
"boolean",
"date",
"ttl",
"json",
"select"
],
"description": "Output column data type."
},
"required": {
Expand Down Expand Up @@ -7738,7 +7792,16 @@
},
"type": {
"type": "string",
"enum": ["string", "number", "currency", "boolean", "date", "json", "select"],
"enum": [
"string",
"number",
"currency",
"boolean",
"date",
"ttl",
"json",
"select"
],
"description": "Output column data type."
},
"required": {
Expand Down Expand Up @@ -7856,7 +7919,16 @@
},
"type": {
"type": "string",
"enum": ["string", "number", "currency", "boolean", "date", "json", "select"],
"enum": [
"string",
"number",
"currency",
"boolean",
"date",
"ttl",
"json",
"select"
],
"description": "Data type of values stored in the column."
},
"required": {
Expand Down
88 changes: 88 additions & 0 deletions apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockEnqueue, mockGetJobQueue, mockVerifyCronAuth } = vi.hoisted(() => ({
mockEnqueue: vi.fn(),
mockGetJobQueue: vi.fn(),
mockVerifyCronAuth: vi.fn(),
}))

vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth }))
vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue }))

import { GET } from '@/app/api/cron/cleanup-table-row-ttl/route'

describe('table row TTL cleanup route', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-22T17:12:00Z'))
mockVerifyCronAuth.mockReturnValue(null)
mockEnqueue.mockResolvedValue('job-ttl-1')
mockGetJobQueue.mockResolvedValue({ enqueue: mockEnqueue })
})

afterEach(() => {
vi.useRealTimers()
})

it('enqueues one serialized cleanup job', async () => {
const response = await GET(
createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
)
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ triggered: true, jobId: 'job-ttl-1' })
expect(mockEnqueue).toHaveBeenCalledWith(
'cleanup-table-row-ttl',
{},
expect.objectContaining({
maxAttempts: 1,
jobId: 'cleanup-table-row-ttl:5958062',
concurrencyKey: 'cleanup:table-row-ttl',
concurrencyLimit: 1,
runner: expect.any(Function),
})
)
})

it('deduplicates retries within the same five-minute schedule window', async () => {
const request = () =>
createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
)

await GET(request())
vi.advanceTimersByTime(2 * 60 * 1000)
await GET(request())

expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId)
})

it('returns the cron auth refusal without touching the queue', async () => {
mockVerifyCronAuth.mockReturnValue(new Response(null, { status: 401 }))

const response = await GET(
createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
)
)

expect(response.status).toBe(401)
expect(mockGetJobQueue).not.toHaveBeenCalled()
})
})
41 changes: 41 additions & 0 deletions apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { getJobQueue } from '@/lib/core/async-jobs'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

export const dynamic = 'force-dynamic'

const logger = createLogger('CleanupTableRowTtlApi')
const TTL_CLEANUP_INTERVAL_MS = 5 * 60 * 1000

export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const authError = verifyCronAuth(request, 'table row TTL cleanup')
if (authError) return authError

const queue = await getJobQueue()
const scheduleWindow = Math.floor(Date.now() / TTL_CLEANUP_INTERVAL_MS)
const jobId = await queue.enqueue(
'cleanup-table-row-ttl',
{},
{
maxAttempts: 1,
jobId: `cleanup-table-row-ttl:${scheduleWindow}`,
name: 'Table row TTL cleanup',
concurrencyKey: 'cleanup:table-row-ttl',
concurrencyLimit: 1,
runner: async (_payload, signal) => {
const { runCleanupTableRowTtl } = await import('@/background/cleanup-table-row-ttl')
return runCleanupTableRowTtl(signal)
},
}
)

logger.info('Table row TTL cleanup dispatched', { jobId })
return NextResponse.json({ triggered: true, jobId })
} catch (error) {
logger.error('Failed to dispatch table row TTL cleanup', { error })
return NextResponse.json({ error: 'Failed to dispatch table row TTL cleanup' }, { status: 500 })
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ColumnDefinition } from '@/lib/table'
import { columnTypeOptionsForTable } from './column-types'

describe('columnTypeOptionsForTable', () => {
const ttlColumn: ColumnDefinition = { name: 'expires_at', type: 'ttl' }

it('disables TTL with an explanation when the table already has one', () => {
const availableTtl = columnTypeOptionsForTable([{ name: 'name', type: 'string' }]).find(
(option) => option.type === 'ttl'
)
const unavailableTtl = columnTypeOptionsForTable([ttlColumn]).find(
(option) => option.type === 'ttl'
)

expect(availableTtl?.disabledReason).toBeUndefined()
expect(unavailableTtl?.disabledReason).toBe('Only one TTL column allowed per table')
})

it('keeps TTL enabled while editing the existing TTL column', () => {
const ttlOption = columnTypeOptionsForTable([ttlColumn], ttlColumn).find(
(option) => option.type === 'ttl'
)

expect(ttlOption?.disabledReason).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
required={column.required}
hint={hint}
mono
value={formatValueForInput(value, column.type)}
value={formatValueForInput(value, column.type, timeZone)}
onChange={onChange}
placeholder='{"key": "value"}'
rows={4}
Expand All @@ -260,7 +260,7 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
}

if (definition.editor === 'date') {
const parts = dateValueToLocalParts(formatValueForInput(value, 'date'))
const parts = dateValueToLocalParts(formatValueForInput(value, column.type, timeZone))
return (
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
<div className='flex items-center gap-2'>
Expand Down Expand Up @@ -306,7 +306,7 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
inputType={
definition.inputMode === 'decimal' && !definition.acceptsFormattedInput ? 'number' : 'text'
}
value={formatValueForInput(value, column.type)}
value={formatValueForInput(value, column.type, timeZone)}
onChange={onChange}
placeholder={`Enter ${column.name}`}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface CellContentProps {
/** Current workspace id — lets string cells holding an in-workspace resource
* URL render as a tagged-resource chip instead of a plain external link. */
workspaceId: string
timeZone: string
isEditing: boolean
initialCharacter?: string | null
onSave: (value: unknown, reason: SaveReason) => void
Expand All @@ -38,6 +39,7 @@ export function CellContent({
exec,
column,
workspaceId,
timeZone,
isEditing,
initialCharacter,
onSave,
Expand All @@ -52,6 +54,7 @@ export function CellContent({
waitingOnLabels,
isEnrichmentOutput,
currentWorkspaceId: workspaceId,
timeZone,
})

return (
Expand Down
Loading
Loading