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/tables/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ 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** | A timestamp with an explicit timezone offset that schedules row deletion | `2026-03-16T14:30:00-07:00` |
| **JSON** | An object or array | `{ "tier": "pro" }` |
| **Select** | One of a fixed set of options, or several | `Pro` |

Expiration accepts valid ISO timestamps with `Z` or an explicit numeric UTC offset, such as `2026-03-16T14:30:00-07:00`. Seconds are optional; fractional seconds support up to six digits. Values preserve their supplied clock time and numeric offset without losing fractional precision; `Z` is stored as `-00:00`. For example, `2026-03-16T14:30:00-07:00` and `2026-03-16T21:30:00Z` represent the same deadline and compare equally. Epoch numbers and timezone-free dates are not accepted. The picker retains the stored offset; cells, clipboard values, and exports use that offset too. Picking a day without a time uses midnight in that offset. New picker values default to `-00:00`. Numeric offsets are fixed: editing a date does not automatically switch between summer and winter offsets. A table can have one Expiration column. An empty expiration leaves the row unexpired; on an update, omit the field to preserve it or set it to `null` to clear it. Expired rows are removed by periodic cleanup, subject to the table's delete lock.

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.
Expand Down
26 changes: 26 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
Expand Up @@ -129,4 +129,30 @@ describe('table row TTL cleanup route', () => {
})
expect(mockGetJobQueue).not.toHaveBeenCalled()
})

it.each(['initialization', 'enqueue'])(
'reports a queue %s failure and permits a later retry',
async (stage) => {
if (stage === 'initialization') {
mockGetJobQueue.mockRejectedValueOnce(new Error('queue unavailable'))
} else {
mockEnqueue.mockRejectedValueOnce(new Error('connection lost'))
}
const request = () =>
createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
)
const failed = await GET(request())
expect(failed.status).toBe(500)
await expect(failed.json()).resolves.toEqual({
error: 'Failed to dispatch table row TTL cleanup',
})
const retried = await GET(request())
expect(retried.status).toBe(200)
await expect(retried.json()).resolves.toEqual({ triggered: true, jobId: 'job-ttl-1' })
}
)
})
29 changes: 28 additions & 1 deletion apps/sim/app/api/table/[tableId]/columns/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ vi.mock('@/lib/table/wire', () => ({
vi.mock('@/app/api/table/utils', () => ({
accessError: () => new Response('denied', { status: 403 }),
checkAccess: mockCheckAccess,
orchestrationErrorResponse: (error: unknown) =>
error instanceof OrchestrationError
? NextResponse.json(
{ error: error.message },
{ status: statusForOrchestrationError(error.code) }
)
: null,
orchestrationOutcomeErrorResponse: (
outcome: { error?: string; errorCode?: OrchestrationErrorCode },
fallback: string
Expand All @@ -73,7 +80,7 @@ import {
type OrchestrationErrorCode,
statusForOrchestrationError,
} from '@/lib/core/orchestration/types'
import { PATCH } from '@/app/api/table/[tableId]/columns/route'
import { PATCH, POST } from '@/app/api/table/[tableId]/columns/route'

const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'

Expand Down Expand Up @@ -106,6 +113,26 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
mockRenameColumn.mockResolvedValue({ schema: { columns: [] } })
})

it.each([
'Schema validation failed: A table can have at most 1 Expiration column',
'Expiration columns are not enabled',
])('returns a validation response when adding a column fails: %s', async (message) => {
mockAddTableColumn.mockRejectedValueOnce(new OrchestrationError('validation', message))
const response = await POST(
new NextRequest('http://localhost/api/table/t1/columns', {
method: 'POST',
body: JSON.stringify({
workspaceId: WORKSPACE_ID,
column: { name: 'expires', type: 'ttl' },
}),
headers: { 'content-type': 'application/json' },
}),
{ params: Promise.resolve({ tableId: 't1' }) }
)
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: message })
})

it('rejects a currency code on a non-currency column without renaming first', async () => {
const response = await patch({ name: 'renamed', currencyCode: 'USD' })

Expand Down
5 changes: 3 additions & 2 deletions apps/sim/app/api/table/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { normalizeColumn } from '@/lib/table/wire'
import {
accessError,
checkAccess,
orchestrationErrorResponse,
orchestrationOutcomeErrorResponse,
rootErrorMessage,
tableLockErrorResponse,
Expand Down Expand Up @@ -63,8 +64,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
const classifiedError = orchestrationErrorResponse(error)
if (classifiedError) return classifiedError
if (isZodError(error)) {
return validationErrorResponse(error, 'Invalid request data')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const table: TableInfo = {

const row: TableRow = {
id: 'row-1',
data: { expires_at: Date.parse('2026-11-01T08:00:00Z') / 1000 },
data: { expires_at: '2026-11-01T01:00:00-07:00' },
executions: {},
position: 0,
createdAt: '2026-01-01T00:00:00Z',
Expand All @@ -119,7 +119,7 @@ describe('RowModal expiration editing', () => {
mockUpdateRow.mockResolvedValue(undefined)
})

it('waits for the saved timezone, freezes it, and chooses the later repeated hour', async () => {
it('preserves expiration offsets while timezone settings load or change', async () => {
mockUseTimezoneState.mockReturnValue({ timezone: 'Asia/Tokyo', status: 'loading' })
const container = document.createElement('div')
document.body.appendChild(container)
Expand All @@ -136,12 +136,9 @@ describe('RowModal expiration editing', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
act(() => root.render(createElement(RowModal, props)))

expect(container.querySelector('[aria-label="Edit expires_at"]')?.textContent).toBe(
'Loading timezone…'
)
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')).toBeNull()
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')?.value).toBe('01:00')
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
true
false
)

mockUseTimezoneState.mockReturnValue({
Expand All @@ -165,7 +162,7 @@ describe('RowModal expiration editing', () => {

expect(mockUpdateRow).toHaveBeenCalledWith({
rowId: 'row-1',
data: { expires_at: Date.parse('2026-11-01T09:30:00Z') / 1000 },
data: { expires_at: '2026-11-01T01:30:00-07:00' },
})
expect(props.onSuccess).toHaveBeenCalledTimes(1)

Expand Down Expand Up @@ -209,7 +206,7 @@ describe('RowModal expiration editing', () => {
container.remove()
})

it('blocks an invalid saved timezone with the plain-text guidance', () => {
it('allows expiration edits even when the saved timezone is invalid', () => {
mockUseTimezoneState.mockReturnValue({
timezone: 'America/Los_Angeles',
savedTimezone: 'Mars/Olympus',
Expand All @@ -229,18 +226,11 @@ describe('RowModal expiration editing', () => {

act(() => root.render(createElement(RowModal, props)))

const blockedField = container.querySelector<HTMLButtonElement>(
'[aria-label="Edit expires_at"]'
)
expect(blockedField?.textContent).toBe(String(row.data.expires_at))
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')?.value).toBe('01:00')
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
true
false
)
expect(mockToastError).not.toHaveBeenCalled()
act(() => blockedField?.click())
expect(mockToastError).toHaveBeenCalledWith(
'Your saved timezone “Mars/Olympus” is invalid. Update it in Settings → General before editing Date or Expiration cells.'
)
act(() => root.unmount())
container.remove()
})
Expand All @@ -259,11 +249,19 @@ describe('RowModal expiration editing', () => {
schema: {
columns: [
{ name: 'name', type: 'string' },
{ name: 'starts_at', type: 'date' },
{ name: 'expires_at', type: 'ttl' },
],
},
}
const mixedRow = { ...row, data: { name: 'Ada', expires_at: row.data.expires_at } }
const mixedRow = {
...row,
data: {
name: 'Ada',
expires_at: row.data.expires_at,
starts_at: '2026-09-07T12:00:00-07:00',
},
}
const props = {
mode: 'edit' as const,
isOpen: true,
Expand All @@ -276,20 +274,18 @@ describe('RowModal expiration editing', () => {
act(() => root.render(createElement(RowModal, props)))

const nameInput = container.querySelector<HTMLInputElement>('[data-testid="modal-input"]')
const blockedField = container.querySelector<HTMLButtonElement>(
'[aria-label="Edit expires_at"]'
)
const blockedField = container.querySelector<HTMLButtonElement>('[aria-label="Edit starts_at"]')
const submit = container.querySelector<HTMLButtonElement>('[data-testid="submit"]')
expect(nameInput?.value).toBe('Ada')
expect(blockedField?.textContent).toBe(String(row.data.expires_at))
expect(blockedField?.textContent).toBe(mixedRow.data.starts_at)
expect(submit?.disabled).toBe(false)

act(() => changeInput(nameInput as HTMLInputElement, 'Grace'))
await act(async () => submit?.click())

expect(mockUpdateRow).toHaveBeenCalledWith({
rowId: 'row-1',
data: { name: 'Grace' },
data: { name: 'Grace', expires_at: row.data.expires_at },
})
expect(props.onSuccess).toHaveBeenCalledTimes(1)
expect(mockToastError).not.toHaveBeenCalled()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useParams } from 'next/navigation'
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
import { columnTypeOf } from '@/lib/table/column-types'
import { resolveCurrencyCode } from '@/lib/table/currency'
import { todayAtTtlOffset, ttlValueFromPicker, ttlValueToPickerParts } from '@/lib/table/ttl-values'
import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing'
import { type TimezoneState, useTimezoneState } from '@/hooks/queries/general-settings'
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
Expand Down Expand Up @@ -332,36 +333,45 @@ function ColumnField({ column, value, timeZone, onChange }: ColumnFieldProps) {
required={column.required}
hint={hint}
mono
value={formatValueForInput(value, column.type, timeZone)}
value={formatValueForInput(value, column.type)}
onChange={onChange}
placeholder='{"key": "value"}'
rows={4}
/>
)
}

if (definition.editor === 'date') {
const parts = dateValueToLocalParts(formatValueForInput(value, column.type, timeZone))
if (definition.editor === 'date' || definition.editor === 'offset-date') {
const storedValue = formatValueForInput(value, column.type)
const offsetParts =
definition.editor === 'offset-date' ? ttlValueToPickerParts(storedValue) : null
const parts = offsetParts ?? dateValueToLocalParts(storedValue)
const pickerToday = offsetParts
? todayAtTtlOffset(offsetParts.offset)
: todayLocalCalendarDate(timeZone)
const valueFromParts = (day: string, time: string | null) =>
column.type === 'ttl' && time ? `${day}T${time}` : localPartsToDateValue(day, time, timeZone)
offsetParts
? ttlValueFromPicker(day, time, offsetParts.offset)
: localPartsToDateValue(day, time, timeZone)
return (
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
<div className='flex items-center gap-2'>
<ChipDatePicker
value={parts.day ?? undefined}
today={todayLocalCalendarDate(timeZone)}
today={pickerToday}
onChange={(day) => onChange(valueFromParts(day, parts.time))}
placeholder='Select date'
className='flex-1'
/>
<ChipTimePicker
value={parts.time?.slice(0, 5)}
onChange={(time) =>
onChange(valueFromParts(parts.day ?? todayLocalCalendarDate(timeZone), time))
}
onChange={(time) => onChange(valueFromParts(parts.day ?? pickerToday, time))}
placeholder='Add time'
className='w-[110px]'
/>
{offsetParts && (
<span className='text-[var(--text-tertiary)] text-small'>{offsetParts.offset}</span>
)}
</div>
</ChipModalField>
)
Expand All @@ -387,7 +397,7 @@ function ColumnField({ column, value, timeZone, onChange }: ColumnFieldProps) {
inputType={
definition.inputMode === 'decimal' && !definition.acceptsFormattedInput ? 'number' : 'text'
}
value={formatValueForInput(value, column.type, timeZone)}
value={formatValueForInput(value, column.type)}
onChange={onChange}
placeholder={`Enter ${column.name}`}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ 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
timezoneStatus: TimezoneState['status']
isEditing: boolean
initialCharacter?: string | null
Expand All @@ -41,7 +40,6 @@ export function CellContent({
exec,
column,
workspaceId,
timeZone,
timezoneStatus,
isEditing,
initialCharacter,
Expand All @@ -57,7 +55,6 @@ export function CellContent({
waitingOnLabels,
isEnrichmentOutput,
currentWorkspaceId: workspaceId,
timeZone,
timezoneStatus,
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,43 +23,23 @@ function column(type: DisplayColumn['type']): DisplayColumn {
}

describe('resolveCellRender', () => {
it('renders TTL epoch seconds through the date presentation', () => {
expect(
resolveCellRender({
value: 1_700_000_000,
it.each(['ready', 'loading', 'invalid', 'error'] as const)(
'renders TTL as the exact UTC string when timezone status is %s',
(timezoneStatus) => {
const value = '2026-06-15T09:00:30Z'
const kind = resolveCellRender({
value,
exec: undefined,
column: column('ttl'),
waitingOnLabels: undefined,
timeZone: 'America/New_York',
timezoneStatus,
})
).toEqual({ kind: 'date', text: '2023-11-14T17:13:20-05:00' })
})

it('renders raw epoch seconds when the saved timezone is invalid', () => {
expect(
resolveCellRender({
value: 1_700_000_000,
exec: undefined,
column: column('ttl'),
waitingOnLabels: undefined,
timeZone: 'America/Los_Angeles',
timezoneStatus: 'invalid',
})
).toEqual({ kind: 'date', text: '1700000000', raw: true })
})

it('renders raw epoch seconds while timezone settings are loading', () => {
expect(
resolveCellRender({
value: 1_700_000_000,
exec: undefined,
column: column('ttl'),
waitingOnLabels: undefined,
timeZone: 'America/Los_Angeles',
timezoneStatus: 'loading',
})
).toEqual({ kind: 'date', text: '1700000000', raw: true })
})
expect(kind).toEqual({ kind: 'text', text: value })
expect(renderToStaticMarkup(createElement(CellRender, { kind, isEditing: false }))).toContain(
value
)
}
)

it('renders the exact stored Date value when timezone settings are unavailable', () => {
const stored = '2026-01-15T09:00:00-05:00'
Expand All @@ -68,7 +48,6 @@ describe('resolveCellRender', () => {
exec: undefined,
column: column('date'),
waitingOnLabels: undefined,
timeZone: 'America/Los_Angeles',
timezoneStatus: 'error',
})
expect(kind).toEqual({ kind: 'date', text: stored, raw: true })
Expand Down
Loading
Loading