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` |
| **Expiration** | 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 Expiration column. Adding it enables row expiration; rows with a non-empty expiration value become eligible for deletion after that time passes. Cleanup runs periodically, so actual row removal may happen after the expiration timestamp rather than exactly at it. Deleting the Expiration column disables expiration for the table. Expiration 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
1 change: 1 addition & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams
# FORKING_ENABLED= # Workspace forks
# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections
# TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only

# Instance organization (Optional). Most enterprise features read their settings from the
Expand Down
115 changes: 115 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,115 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

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

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

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:01:00Z'))
mockVerifyCronAuth.mockReturnValue(null)
mockIsTableRowTtlEnabled.mockResolvedValue(true)
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:1986020',
concurrencyKey: 'cleanup:table-row-ttl',
concurrencyLimit: 1,
runner: expect.any(Function),
})
)
})

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

await GET(request())
vi.advanceTimersByTime(13 * 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()
})

it('does not enqueue cleanup while the feature is disabled', async () => {
mockIsTableRowTtlEnabled.mockResolvedValue(false)

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: false,
reason: 'feature-disabled',
})
expect(mockGetJobQueue).not.toHaveBeenCalled()
})
})
47 changes: 47 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,47 @@
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'
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'

export const dynamic = 'force-dynamic'

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

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

if (!(await isTableRowTtlEnabled())) {
logger.info('Table row TTL cleanup skipped because the feature is disabled')
return NextResponse.json({ triggered: false, reason: 'feature-disabled' })
}

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