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** | A row expiration date, stored as Unix epoch seconds | `2026-03-16 2:30 PM` |
| **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 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.

## 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 @@ -4088,7 +4088,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 @@ -4365,7 +4374,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 @@ -4544,7 +4562,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 @@ -4644,7 +4671,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 @@ -4741,7 +4777,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 @@ -6413,7 +6449,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 @@ -6613,7 +6658,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 @@ -6754,7 +6808,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 @@ -6872,7 +6935,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
Expand Up @@ -17,7 +17,7 @@ import {
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables'
import { SelectOptionsEditor } from '../select-field'
import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types'
import { columnTypeOptionsForTable } from './column-types'

/** Whether a column type carries an option set. */
function isSelectType(type: ColumnDefinition['type']): boolean {
Expand Down Expand Up @@ -52,6 +52,7 @@ interface ColumnConfigSidebarProps {
onClose: () => void
/** Existing column record for `mode: 'edit'`; ignored otherwise. */
existingColumn: ColumnDefinition | null
allColumns: readonly ColumnDefinition[]
workspaceId: string
tableId: string
/** Notify parent of a rename so it can rewrite local `columnOrder` /
Expand Down Expand Up @@ -102,6 +103,7 @@ function ColumnConfigBody({
config,
onClose,
existingColumn,
allColumns,
workspaceId,
tableId,
onColumnRename,
Expand Down Expand Up @@ -274,11 +276,14 @@ function ColumnConfigBody({
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel>Type</RequiredLabel>
<ChipCombobox
options={PLAIN_COLUMN_TYPE_OPTIONS.map((o) => ({
label: o.label,
value: o.type,
icon: o.icon,
}))}
options={columnTypeOptionsForTable(allColumns, existingColumn)
.filter((option) => option.type !== 'workflow')
.map((option) => ({
label: option.label,
value: option.type,
icon: option.icon,
disabled: option.disabledReason !== undefined,
}))}
value={typeInput}
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
placeholder='Select type'
Expand Down
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()
})
})
Loading
Loading