@@ -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}`}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx
index 54a2c7f2dea..1511332da6b 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx
@@ -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
@@ -38,6 +39,7 @@ export function CellContent({
exec,
column,
workspaceId,
+ timeZone,
isEditing,
initialCharacter,
onSave,
@@ -52,6 +54,7 @@ export function CellContent({
waitingOnLabels,
isEnrichmentOutput,
currentWorkspaceId: workspaceId,
+ timeZone,
})
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.ts
new file mode 100644
index 00000000000..dee543bf4fb
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.ts
@@ -0,0 +1,32 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { resolveCellRender } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render'
+import type { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types'
+
+function column(type: DisplayColumn['type']): DisplayColumn {
+ return {
+ key: 'expires_at',
+ name: 'expires_at',
+ type,
+ groupSize: 1,
+ groupStartColIndex: 0,
+ headerLabel: 'expires_at',
+ isGroupStart: true,
+ }
+}
+
+describe('resolveCellRender', () => {
+ it('renders TTL epoch seconds through the date presentation', () => {
+ expect(
+ resolveCellRender({
+ value: 1_700_000_000,
+ exec: undefined,
+ column: column('ttl'),
+ waitingOnLabels: undefined,
+ timeZone: 'America/New_York',
+ })
+ ).toEqual({ kind: 'date', text: '2023-11-14T17:13:20-05:00' })
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx
index 4e16d03912b..9ebaacf233f 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx
@@ -53,6 +53,8 @@ interface ResolveCellRenderInput {
/** Current workspace id — a URL pointing to a resource in this workspace
* renders as a tagged-resource chip rather than a plain external link. */
currentWorkspaceId?: string
+ /** Effective viewer timezone for instant-like column presentations. */
+ timeZone?: string
}
export function resolveCellRender({
@@ -62,6 +64,7 @@ export function resolveCellRender({
waitingOnLabels,
isEnrichmentOutput,
currentWorkspaceId,
+ timeZone,
}: ResolveCellRenderInput): CellRenderKind {
const isNull = value === null || value === undefined
const isEmpty = isNull || value === ''
@@ -137,7 +140,10 @@ export function resolveCellRender({
return { kind: 'text', text: columnTypeOf(column).formatForDisplay(value, column) }
}
if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) }
- if (column.type === 'date') return { kind: 'date', text: String(value) }
+ const definition = columnTypeOf(column)
+ if (definition.editor === 'date') {
+ return { kind: 'date', text: definition.formatForInput(value, column, { timezone: timeZone }) }
+ }
if (column.type === 'string') {
const text = stringifyValue(value)
return resolveLinkKind(text, currentWorkspaceId) ?? { kind: 'text', text }
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx
index b93cea863d5..f5c8526173b 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx
@@ -70,7 +70,7 @@ function InlineDateEditor({
const popoverPointerAtRef = useRef(0)
const timeZone = useTimezone()
- const storedValue = formatValueForInput(value, column.type)
+ const storedValue = formatValueForInput(value, column.type, timeZone)
const initialDraft =
initialCharacter !== undefined
? initialCharacter
@@ -115,7 +115,7 @@ function InlineDateEditor({
// silently shifting the instant of a value someone else wrote.
if (storageVal === undefined && initialCharacter === undefined && current === initialDraft) {
doneRef.current = true
- onSave(storedValue || null, reason)
+ onSave(storedValue ? cleanCellValue(storedValue, column, timeZone) : null, reason)
return
}
const raw = storageVal ?? displayToStorage(current, timeZone) ?? current
@@ -132,9 +132,9 @@ function InlineDateEditor({
return
}
doneRef.current = true
- onSave(raw || null, reason)
+ onSave(raw ? cleanCellValue(raw, column, timeZone) : null, reason)
},
- [invalid, onSave, onCancel, timeZone, initialDraft, initialCharacter, storedValue]
+ [invalid, onSave, onCancel, timeZone, initialDraft, initialCharacter, storedValue, column]
)
const handleKeyDown = useCallback(
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx
index 077f73fb2e5..abc6828ea77 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx
@@ -26,6 +26,8 @@ export interface DataRowProps {
/** Current workspace id — forwarded to cells so in-workspace resource URLs
* render as tagged-resource chips. */
workspaceId: string
+ /** Effective viewer timezone used to render TTL instants. */
+ timeZone: string
rowIndex: number
isFirstRow: boolean
editingColumnName: string | null
@@ -114,6 +116,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
prev.row !== next.row ||
prev.columns !== next.columns ||
prev.workspaceId !== next.workspaceId ||
+ prev.timeZone !== next.timeZone ||
prev.rowIndex !== next.rowIndex ||
prev.isFirstRow !== next.isFirstRow ||
prev.editingColumnName !== next.editingColumnName ||
@@ -161,6 +164,7 @@ export const DataRow = React.memo(function DataRow({
row,
columns,
workspaceId,
+ timeZone,
rowIndex,
isFirstRow,
editingColumnName,
@@ -396,6 +400,7 @@ export const DataRow = React.memo(function DataRow({
getColumnId(c) === columnConfig.columnName) ?? null)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts
index 146fb11cc23..a4e051aed2e 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts
@@ -194,4 +194,16 @@ describe('formatValueForInput', () => {
)
expect(formatValueForInput('2026-07-06', 'date')).toBe('2026-07-06')
})
+
+ it('renders TTL instants in the editor timezone without changing the instant', () => {
+ expect(formatValueForInput(1_700_000_000, 'ttl', 'America/New_York')).toBe(
+ '2023-11-14T17:13:20-05:00'
+ )
+ expect(
+ cleanCellValue('2023-11-14 17:13:20', { name: 'expires_at', type: 'ttl' }, 'America/New_York')
+ ).toBe(1_700_000_000)
+ expect(
+ cleanCellValue('2023-11-14', { name: 'expires_at', type: 'ttl' }, 'America/New_York')
+ ).toBe(1_699_938_000)
+ })
})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts
index 69f7722d11d..b31b5f1ea48 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts
@@ -55,7 +55,7 @@ export function cleanCellValue(
// Everything else runs the SAME coercion the server will run, so the
// optimistic cache holds exactly the value that gets persisted.
const columnType = columnTypeOf(column)
- const coerced = columnType.coerce(value as JsonValue, column)
+ const coerced = columnType.coerce(value as JsonValue, column, { timezone: timeZone })
if (coerced.ok) return coerced.value
const salvaged = columnType.salvage?.(value as JsonValue, column)
return salvaged?.ok ? salvaged.value : null
@@ -68,7 +68,7 @@ export function cleanCellValue(
* row data already has the new mapping's value) would otherwise render
* `[object Object]` via `String(value)`.
*/
-export function formatValueForInput(value: unknown, type: string): string {
+export function formatValueForInput(value: unknown, type: string, timeZone?: string): string {
if (value === null || value === undefined) return ''
const definition = columnTypeById(type)
// Shape-drift guard, kept ahead of the registry: a column whose declared type
@@ -78,7 +78,11 @@ export function formatValueForInput(value: unknown, type: string): string {
if (typeof value === 'object' && !definition.storesOpaqueIds && type !== 'json') {
return JSON.stringify(value)
}
- return definition.formatForInput(value, { name: '', type: type as ColumnType })
+ return definition.formatForInput(
+ value,
+ { name: '', type: type as ColumnType },
+ { timezone: timeZone }
+ )
}
/** A canonical date-cell value split into its wall-clock editing parts. */
diff --git a/apps/sim/background/cleanup-table-row-ttl.test.ts b/apps/sim/background/cleanup-table-row-ttl.test.ts
new file mode 100644
index 00000000000..d810119b839
--- /dev/null
+++ b/apps/sim/background/cleanup-table-row-ttl.test.ts
@@ -0,0 +1,136 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockDeleteExecute,
+ mockListExecute,
+ mockSignalTableRowsChanged,
+ mockTask,
+ mockWithLockedTable,
+} = vi.hoisted(() => ({
+ mockDeleteExecute: vi.fn(),
+ mockListExecute: vi.fn(),
+ mockSignalTableRowsChanged: vi.fn(),
+ mockTask: vi.fn((config: unknown) => config),
+ mockWithLockedTable: vi.fn(),
+}))
+
+vi.mock('@sim/db', () => ({
+ dbFor: vi.fn(() => ({ execute: mockListExecute })),
+}))
+
+vi.mock('@trigger.dev/sdk', () => ({ task: mockTask }))
+vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalTableRowsChanged }))
+vi.mock('@/lib/table/service', () => ({ withLockedTable: mockWithLockedTable }))
+
+import { cleanupTableRowTtlTask, runCleanupTableRowTtl } from '@/background/cleanup-table-row-ttl'
+
+const table = {
+ id: 'table-1',
+ workspaceId: 'workspace-1',
+ schema: { columns: [{ id: 'col-ttl', name: 'expires_at', type: 'ttl' }] },
+ locks: { insertLocked: false, updateLocked: false, deleteLocked: false, schemaLocked: false },
+}
+
+describe('table row TTL cleanup', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockListExecute.mockResolvedValue([{ id: table.id, workspaceId: table.workspaceId }])
+ mockWithLockedTable.mockImplementation(
+ async (
+ _tableId: string,
+ mutate: (
+ fresh: typeof table,
+ trx: { execute: typeof mockDeleteExecute }
+ ) => Promise
+ ) => mutate(table, { execute: mockDeleteExecute })
+ )
+ })
+
+ it('deletes expired rows in locked, keyset batches and signals the table', async () => {
+ mockDeleteExecute
+ .mockResolvedValueOnce([{ count: 500, lastId: 'row-500' }])
+ .mockResolvedValueOnce([{ count: 12, lastId: 'row-512' }])
+
+ await expect(runCleanupTableRowTtl()).resolves.toEqual({
+ batches: 2,
+ deleted: 512,
+ limitReached: false,
+ })
+ expect(mockWithLockedTable).toHaveBeenCalledTimes(2)
+ expect(mockDeleteExecute).toHaveBeenCalledTimes(2)
+ expect(mockSignalTableRowsChanged).toHaveBeenCalledWith(table.id)
+ })
+
+ it('compares TTL values with whole Date.now epoch seconds', async () => {
+ const nowEpochMilliseconds = 1_700_000_000_123
+ const nowEpochSeconds = 1_700_000_000
+ const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(nowEpochMilliseconds)
+ mockDeleteExecute.mockResolvedValue([{ count: 0, lastId: null }])
+
+ try {
+ await runCleanupTableRowTtl()
+ } finally {
+ nowSpy.mockRestore()
+ }
+
+ expect(mockListExecute.mock.calls[0][0]).toMatchObject({
+ values: expect.arrayContaining([nowEpochSeconds]),
+ })
+ expect(mockDeleteExecute.mock.calls[0][0]).toMatchObject({
+ values: expect.arrayContaining([nowEpochSeconds]),
+ })
+ })
+
+ it('does no work when already aborted', async () => {
+ const controller = new AbortController()
+ controller.abort()
+
+ await expect(runCleanupTableRowTtl(controller.signal)).resolves.toEqual({
+ batches: 0,
+ deleted: 0,
+ limitReached: false,
+ })
+ expect(mockListExecute).not.toHaveBeenCalled()
+ })
+
+ it('honors a delete lock re-read inside the table advisory lock', async () => {
+ mockWithLockedTable.mockImplementationOnce(async (_tableId, mutate) =>
+ mutate(
+ { ...table, locks: { ...table.locks, deleteLocked: true } },
+ { execute: mockDeleteExecute }
+ )
+ )
+
+ await expect(runCleanupTableRowTtl()).resolves.toEqual({
+ batches: 0,
+ deleted: 0,
+ limitReached: false,
+ })
+ expect(mockDeleteExecute).not.toHaveBeenCalled()
+ expect(mockSignalTableRowsChanged).not.toHaveBeenCalled()
+ })
+
+ it('stops after one hundred full batches', async () => {
+ mockDeleteExecute.mockResolvedValue([{ count: 500, lastId: 'row-cursor' }])
+
+ await expect(runCleanupTableRowTtl()).resolves.toEqual({
+ batches: 100,
+ deleted: 50_000,
+ limitReached: true,
+ })
+ expect(mockDeleteExecute).toHaveBeenCalledTimes(100)
+ expect(mockSignalTableRowsChanged).toHaveBeenCalledTimes(1)
+ })
+
+ it('registers one serialized Trigger.dev task', () => {
+ expect(cleanupTableRowTtlTask).toEqual(
+ expect.objectContaining({
+ id: 'cleanup-table-row-ttl',
+ queue: { concurrencyLimit: 1 },
+ })
+ )
+ })
+})
diff --git a/apps/sim/background/cleanup-table-row-ttl.ts b/apps/sim/background/cleanup-table-row-ttl.ts
new file mode 100644
index 00000000000..82e8abf1d41
--- /dev/null
+++ b/apps/sim/background/cleanup-table-row-ttl.ts
@@ -0,0 +1,207 @@
+import { dbFor } from '@sim/db'
+import { userTableDefinitions, userTableRows } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { task } from '@trigger.dev/sdk'
+import { sql } from 'drizzle-orm'
+import { asOrchestrationError } from '@/lib/core/orchestration/types'
+import { getColumnId } from '@/lib/table/column-keys'
+import { signalTableRowsChanged } from '@/lib/table/events'
+import { assertRowDelete, TableLockedError } from '@/lib/table/mutation-locks'
+import type { DbTransaction } from '@/lib/table/planner'
+import { withLockedTable } from '@/lib/table/service'
+
+const logger = createLogger('CleanupTableRowTtl')
+const cleanupDb = dbFor('cleanup')
+
+const TTL_CLEANUP_BATCH_SIZE = 500
+const TTL_CLEANUP_MAX_BATCHES = 100
+
+interface ExpiredTtlTableRef {
+ [key: string]: unknown
+ id: string
+ workspaceId: string
+}
+
+interface DeletedTtlBatch {
+ attempted: boolean
+ deleted: number
+ lastId: string | null
+}
+
+export interface TableRowTtlCleanupResult {
+ batches: number
+ deleted: number
+ limitReached: boolean
+}
+
+async function listExpiredTtlTables(nowEpochSeconds: number): Promise {
+ const rows = await cleanupDb.execute(sql`
+ SELECT
+ ${userTableDefinitions.id} AS id,
+ ${userTableDefinitions.workspaceId} AS "workspaceId"
+ FROM ${userTableDefinitions}
+ WHERE ${userTableDefinitions.archivedAt} IS NULL
+ AND ${userTableDefinitions.deleteLocked} = false
+ AND EXISTS (
+ SELECT 1
+ FROM jsonb_array_elements(
+ COALESCE(${userTableDefinitions.schema}->'columns', '[]'::jsonb)
+ ) AS ttl_column(column_definition)
+ JOIN ${userTableRows} AS table_row
+ ON table_row.table_id = ${userTableDefinitions.id}
+ AND table_row.workspace_id = ${userTableDefinitions.workspaceId}
+ WHERE ttl_column.column_definition->>'type' = 'ttl'
+ AND jsonb_typeof(
+ table_row.data->COALESCE(
+ ttl_column.column_definition->>'id',
+ ttl_column.column_definition->>'name'
+ )
+ ) = 'number'
+ AND (
+ table_row.data->>COALESCE(
+ ttl_column.column_definition->>'id',
+ ttl_column.column_definition->>'name'
+ )
+ )::numeric <= ${nowEpochSeconds}
+ )
+ ORDER BY ${userTableDefinitions.id}
+ LIMIT ${TTL_CLEANUP_MAX_BATCHES}
+ `)
+ return Array.isArray(rows) ? rows : []
+}
+
+function parseDeletedBatch(rows: unknown): Omit {
+ const [row] = Array.isArray(rows)
+ ? (rows as Array<{ count?: number | string; lastId?: string | null }>)
+ : []
+ if (!row) throw new Error('Table row TTL cleanup did not return a deleted count')
+
+ const deleted = Number(row.count)
+ if (!Number.isSafeInteger(deleted) || deleted < 0 || deleted > TTL_CLEANUP_BATCH_SIZE) {
+ throw new Error('Table row TTL cleanup returned an invalid deleted count')
+ }
+ if (deleted > 0 && typeof row.lastId !== 'string') {
+ throw new Error('Table row TTL cleanup did not return a row cursor')
+ }
+ return { deleted, lastId: row.lastId ?? null }
+}
+
+async function deleteExpiredTableRowBatch(
+ trx: DbTransaction,
+ tableId: string,
+ workspaceId: string,
+ columnKey: string,
+ nowEpochSeconds: number,
+ afterId?: string
+): Promise> {
+ const rows = await trx.execute<{ count: number | string; lastId: string | null }>(sql`
+ WITH candidates AS MATERIALIZED (
+ SELECT table_row.id
+ FROM ${userTableRows} AS table_row
+ WHERE table_row.table_id = ${tableId}
+ AND table_row.workspace_id = ${workspaceId}
+ ${afterId ? sql`AND table_row.id > ${afterId}` : sql``}
+ AND jsonb_typeof(table_row.data->${columnKey}) = 'number'
+ AND (table_row.data->>${columnKey})::numeric <= ${nowEpochSeconds}
+ ORDER BY table_row.id
+ LIMIT ${TTL_CLEANUP_BATCH_SIZE}
+ FOR UPDATE OF table_row SKIP LOCKED
+ ), deleted AS (
+ DELETE FROM ${userTableRows} AS table_row
+ USING candidates
+ WHERE table_row.id = candidates.id
+ RETURNING table_row.id
+ )
+ SELECT
+ count(*)::integer AS count,
+ max(id) AS "lastId"
+ FROM deleted
+ `)
+ return parseDeletedBatch(rows)
+}
+
+async function deleteExpiredRowsForTable(
+ ref: ExpiredTtlTableRef,
+ nowEpochSeconds: number,
+ afterId?: string
+): Promise {
+ try {
+ return await withLockedTable(
+ ref.id,
+ async (table, trx) => {
+ try {
+ assertRowDelete(table)
+ } catch (error) {
+ if (error instanceof TableLockedError) {
+ return { attempted: false, deleted: 0, lastId: null }
+ }
+ throw error
+ }
+
+ const ttlColumn = table.schema.columns.find((column) => column.type === 'ttl')
+ if (!ttlColumn) return { attempted: false, deleted: 0, lastId: null }
+
+ const batch = await deleteExpiredTableRowBatch(
+ trx,
+ table.id,
+ table.workspaceId,
+ getColumnId(ttlColumn),
+ nowEpochSeconds,
+ afterId
+ )
+ return { attempted: true, ...batch }
+ },
+ { expectedWorkspaceId: ref.workspaceId }
+ )
+ } catch (error) {
+ if (asOrchestrationError(error)?.code === 'not_found') {
+ return { attempted: false, deleted: 0, lastId: null }
+ }
+ throw error
+ }
+}
+
+/** Deletes rows whose table TTL cell is at or before the current Unix epoch second. */
+export async function runCleanupTableRowTtl(
+ signal?: AbortSignal
+): Promise {
+ if (signal?.aborted) return { batches: 0, deleted: 0, limitReached: false }
+
+ const nowEpochSeconds = Math.floor(Date.now() / 1000)
+ const tableRefs = await listExpiredTtlTables(nowEpochSeconds)
+ let deleted = 0
+ let batches = 0
+ let lastBatchDeleted = 0
+
+ for (const ref of tableRefs) {
+ let afterId: string | undefined
+ let tableDeleted = 0
+
+ while (batches < TTL_CLEANUP_MAX_BATCHES && !signal?.aborted) {
+ const batch = await deleteExpiredRowsForTable(ref, nowEpochSeconds, afterId)
+ if (!batch.attempted) break
+
+ batches++
+ deleted += batch.deleted
+ tableDeleted += batch.deleted
+ lastBatchDeleted = batch.deleted
+ afterId = batch.lastId ?? undefined
+ if (batch.deleted < TTL_CLEANUP_BATCH_SIZE) break
+ }
+
+ if (tableDeleted > 0) signalTableRowsChanged(ref.id)
+ if (batches === TTL_CLEANUP_MAX_BATCHES || signal?.aborted) break
+ }
+
+ const limitReached =
+ batches === TTL_CLEANUP_MAX_BATCHES &&
+ (lastBatchDeleted === TTL_CLEANUP_BATCH_SIZE || tableRefs.length === TTL_CLEANUP_MAX_BATCHES)
+ logger.info('Table row TTL cleanup completed', { batches, deleted, limitReached })
+ return { batches, deleted, limitReached }
+}
+
+export const cleanupTableRowTtlTask = task({
+ id: 'cleanup-table-row-ttl',
+ queue: { concurrencyLimit: 1 },
+ run: () => runCleanupTableRowTtl(),
+})
diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
index bbb44542619..af8b00e5893 100644
--- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
@@ -71,6 +71,7 @@ export interface ToolCatalogEntry {
| 'load_deployment'
| 'load_integration_tool'
| 'load_skill'
+ | 'load_slide_layout'
| 'manage_credential'
| 'manage_custom_tool'
| 'manage_knowledge_base'
@@ -199,6 +200,7 @@ export interface ToolCatalogEntry {
| 'load_deployment'
| 'load_integration_tool'
| 'load_skill'
+ | 'load_slide_layout'
| 'manage_credential'
| 'manage_custom_tool'
| 'manage_knowledge_base'
@@ -3143,6 +3145,24 @@ export const LoadSkill: ToolCatalogEntry = {
},
}
+export const LoadSlideLayout: ToolCatalogEntry = {
+ id: 'load_slide_layout',
+ name: 'load_slide_layout',
+ route: 'go',
+ mode: 'sync',
+ parameters: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ description:
+ "Layout name exactly as it appears in the Layout Library index (e.g. 'metric-cards').",
+ },
+ },
+ required: ['name'],
+ },
+}
+
export const ManageCredential: ToolCatalogEntry = {
id: 'manage_credential',
name: 'manage_credential',
@@ -4171,7 +4191,7 @@ export const QueryUserTable: ToolCatalogEntry = {
filter: {
type: 'object',
description:
- 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.',
+ 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. TTL filter values are absolute whole Unix epoch seconds, never milliseconds. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.',
},
limit: {
type: 'number',
@@ -5433,7 +5453,7 @@ export const TableColumns: ToolCatalogEntry = {
column: {
type: 'object',
description:
- 'Column definition for add_column: { name, type, unique?, position? }; select (enum) columns also take { options: [names], multiple?: true } — options is required for select.',
+ 'Column definition for add_column: { name, type, unique?, position? }; type may be string, number, boolean, date, json, select, or ttl. Select (enum) columns also take { options: [names], multiple?: true } — options is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.',
},
columnName: {
type: 'string',
@@ -5454,7 +5474,7 @@ export const TableColumns: ToolCatalogEntry = {
newType: {
type: 'string',
description:
- 'New column type for update_column: string, number, boolean, date, json, select. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.',
+ 'New column type for update_column: string, number, boolean, date, json, select, ttl. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.',
},
options: {
type: 'array',
@@ -5633,7 +5653,7 @@ export const TableManage: ToolCatalogEntry = {
schema: {
type: 'object',
description:
- 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; a select (enum) column also requires options (display names) and takes multiple?.',
+ 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; types are string, number, boolean, date, json, select, and ttl. A select (enum) column also requires options (display names) and takes multiple?. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.',
},
tableId: {
type: 'string',
@@ -5679,12 +5699,12 @@ export const TableRows: ToolCatalogEntry = {
data: {
type: 'object',
description:
- 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME.',
+ 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME. TTL cells take an absolute whole Unix epoch timestamp in seconds, never JavaScript milliseconds; missing or null TTL means no expiration.',
},
filter: {
type: 'object',
description:
- 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES.',
+ 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES. TTL filter values are absolute whole Unix epoch seconds.',
},
limit: {
type: 'number',
@@ -5709,7 +5729,8 @@ export const TableRows: ToolCatalogEntry = {
},
rows: {
type: 'array',
- description: 'Array of row data objects (required for batch_insert_rows)',
+ description:
+ 'Array of row data objects (required for batch_insert_rows). TTL cells take absolute whole Unix epoch timestamps in seconds, never JavaScript milliseconds.',
},
tableId: { type: 'string', description: 'Table ID (required for every operation)' },
updates: {
@@ -6038,7 +6059,7 @@ export const UserTable: ToolCatalogEntry = {
column: {
type: 'object',
description:
- 'Column definition for add_column: { name, type, unique?, position? }. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.',
+ 'Column definition for add_column: { name, type, unique?, position? }. Type may be string, number, boolean, date, json, select, or ttl. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.',
},
columnName: {
type: 'string',
@@ -6057,7 +6078,8 @@ export const UserTable: ToolCatalogEntry = {
},
data: {
type: 'object',
- description: 'Row data as key-value pairs (required for insert_row, update_row)',
+ description:
+ 'Row data as key-value pairs (required for insert_row, update_row). TTL cells take an absolute whole Unix epoch timestamp in seconds, never JavaScript milliseconds; missing or null TTL means no expiration.',
},
dependencies: {
type: 'object',
@@ -6092,7 +6114,7 @@ export const UserTable: ToolCatalogEntry = {
filter: {
type: 'object',
description:
- 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.',
+ 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. TTL filter values are absolute whole Unix epoch seconds, never milliseconds. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.',
},
groupId: {
type: 'string',
@@ -6181,7 +6203,7 @@ export const UserTable: ToolCatalogEntry = {
newType: {
type: 'string',
description:
- 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.',
+ 'New column type (optional for update_column). Types: string, number, boolean, date, json, select, ttl. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.',
},
options: {
type: 'array',
@@ -6259,7 +6281,8 @@ export const UserTable: ToolCatalogEntry = {
},
rows: {
type: 'array',
- description: 'Array of row data objects (required for batch_insert_rows)',
+ description:
+ 'Array of row data objects (required for batch_insert_rows). TTL cells take absolute whole Unix epoch timestamps in seconds, never JavaScript milliseconds.',
},
runMode: {
type: 'string',
@@ -6270,7 +6293,7 @@ export const UserTable: ToolCatalogEntry = {
schema: {
type: 'object',
description:
- 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.',
+ 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. Types are string, number, boolean, date, json, select, and ttl. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.',
},
scope: {
type: 'string',
@@ -7041,6 +7064,7 @@ export const TOOL_CATALOG: Record = {
[LoadDeployment.id]: LoadDeployment,
[LoadIntegrationTool.id]: LoadIntegrationTool,
[LoadSkill.id]: LoadSkill,
+ [LoadSlideLayout.id]: LoadSlideLayout,
[ManageCredential.id]: ManageCredential,
[ManageCustomTool.id]: ManageCustomTool,
[ManageKnowledgeBase.id]: ManageKnowledgeBase,
diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
index c7f0cfcd3bf..d35d624dfd2 100644
--- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
@@ -3028,6 +3028,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
+ load_slide_layout: {
+ parameters: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ description:
+ "Layout name exactly as it appears in the Layout Library index (e.g. 'metric-cards').",
+ },
+ },
+ required: ['name'],
+ },
+ resultSchema: undefined,
+ },
manage_credential: {
parameters: {
type: 'object',
@@ -4066,7 +4080,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
filter: {
type: 'object',
description:
- 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.',
+ 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. TTL filter values are absolute whole Unix epoch seconds, never milliseconds. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.',
},
limit: {
type: 'number',
@@ -5325,7 +5339,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
column: {
type: 'object',
description:
- 'Column definition for add_column: { name, type, unique?, position? }; select (enum) columns also take { options: [names], multiple?: true } — options is required for select.',
+ 'Column definition for add_column: { name, type, unique?, position? }; type may be string, number, boolean, date, json, select, or ttl. Select (enum) columns also take { options: [names], multiple?: true } — options is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.',
},
columnName: {
type: 'string',
@@ -5349,7 +5363,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
newType: {
type: 'string',
description:
- 'New column type for update_column: string, number, boolean, date, json, select. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.',
+ 'New column type for update_column: string, number, boolean, date, json, select, ttl. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.',
},
options: {
type: 'array',
@@ -5555,7 +5569,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
schema: {
type: 'object',
description:
- 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; a select (enum) column also requires options (display names) and takes multiple?.',
+ 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; types are string, number, boolean, date, json, select, and ttl. A select (enum) column also requires options (display names) and takes multiple?. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.',
},
tableId: {
type: 'string',
@@ -5605,12 +5619,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
data: {
type: 'object',
description:
- 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME.',
+ 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME. TTL cells take an absolute whole Unix epoch timestamp in seconds, never JavaScript milliseconds; missing or null TTL means no expiration.',
},
filter: {
type: 'object',
description:
- 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES.',
+ 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES. TTL filter values are absolute whole Unix epoch seconds.',
},
limit: {
type: 'number',
@@ -5640,7 +5654,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
rows: {
type: 'array',
- description: 'Array of row data objects (required for batch_insert_rows)',
+ description:
+ 'Array of row data objects (required for batch_insert_rows). TTL cells take absolute whole Unix epoch timestamps in seconds, never JavaScript milliseconds.',
},
tableId: {
type: 'string',
@@ -5984,7 +5999,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
column: {
type: 'object',
description:
- 'Column definition for add_column: { name, type, unique?, position? }. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.',
+ 'Column definition for add_column: { name, type, unique?, position? }. Type may be string, number, boolean, date, json, select, or ttl. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.',
},
columnName: {
type: 'string',
@@ -6003,7 +6018,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
data: {
type: 'object',
- description: 'Row data as key-value pairs (required for insert_row, update_row)',
+ description:
+ 'Row data as key-value pairs (required for insert_row, update_row). TTL cells take an absolute whole Unix epoch timestamp in seconds, never JavaScript milliseconds; missing or null TTL means no expiration.',
},
dependencies: {
type: 'object',
@@ -6043,7 +6059,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
filter: {
type: 'object',
description:
- 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.',
+ 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. TTL filter values are absolute whole Unix epoch seconds, never milliseconds. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.',
},
groupId: {
type: 'string',
@@ -6140,7 +6156,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
newType: {
type: 'string',
description:
- 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.',
+ 'New column type (optional for update_column). Types: string, number, boolean, date, json, select, ttl. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.',
},
options: {
type: 'array',
@@ -6228,7 +6244,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
rows: {
type: 'array',
- description: 'Array of row data objects (required for batch_insert_rows)',
+ description:
+ 'Array of row data objects (required for batch_insert_rows). TTL cells take absolute whole Unix epoch timestamps in seconds, never JavaScript milliseconds.',
},
runMode: {
type: 'string',
@@ -6239,7 +6256,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
schema: {
type: 'object',
description:
- 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.',
+ 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. Types are string, number, boolean, date, json, select, and ttl. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.',
},
scope: {
type: 'string',
diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
index 4b3960740de..3b103314a62 100644
--- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
+++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
@@ -181,6 +181,7 @@ const JOB_TYPE_TO_TASK_ID: Record = {
'workflow-group-cell': 'workflow-group-cell',
'cleanup-logs': 'cleanup-logs',
'cleanup-soft-deletes': 'cleanup-soft-deletes',
+ 'cleanup-table-row-ttl': 'cleanup-table-row-ttl',
'cleanup-tasks': 'cleanup-tasks',
'run-data-drain': 'run-data-drain',
}
diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts
index fb5facc4b13..793ce0d938a 100644
--- a/apps/sim/lib/core/async-jobs/types.ts
+++ b/apps/sim/lib/core/async-jobs/types.ts
@@ -44,6 +44,7 @@ export type JobType =
| 'workflow-group-cell'
| 'cleanup-logs'
| 'cleanup-soft-deletes'
+ | 'cleanup-table-row-ttl'
| 'cleanup-tasks'
| 'run-data-drain'
diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts
index 91ec369e31e..647af08c7e0 100644
--- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts
+++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts
@@ -121,6 +121,62 @@ describe('conversion write-back', () => {
})
})
+describe('ttl columns', () => {
+ const column = { name: 'expires_at', type: 'ttl' } as ColumnDefinition
+
+ it('stores integer epoch seconds while accepting date-shaped input', () => {
+ expect(COLUMN_TYPE_REGISTRY.ttl.coerce('2023-11-14T22:13:20Z', column)).toEqual({
+ ok: true,
+ value: 1_700_000_000,
+ })
+ expect(COLUMN_TYPE_REGISTRY.ttl.coerce(1_700_000_000, column)).toEqual({
+ ok: true,
+ value: 1_700_000_000,
+ })
+ expect(COLUMN_TYPE_REGISTRY.ttl.coerce('1700000000', column)).toEqual({
+ ok: true,
+ value: 1_700_000_000,
+ })
+ expect(COLUMN_TYPE_REGISTRY.ttl.coerce('2023-11-14T22:13:20.123Z', column)).toEqual({
+ ok: true,
+ value: 1_700_000_000,
+ })
+ expect(COLUMN_TYPE_REGISTRY.ttl.coerce('not-a-date', column)).toEqual({ ok: false })
+ expect(COLUMN_TYPE_REGISTRY.ttl.coerce(1_700_000_000.5, column)).toEqual({ ok: false })
+ })
+
+ it('renders and edits epoch seconds as a date', () => {
+ expect(COLUMN_TYPE_REGISTRY.ttl.formatForDisplay(1_700_000_000, column)).toBe(
+ '11/14/2023 10:13:20 PM'
+ )
+ expect(COLUMN_TYPE_REGISTRY.ttl.formatForInput(1_700_000_000, column)).toBe(
+ '2023-11-14T22:13:20Z'
+ )
+ expect(
+ COLUMN_TYPE_REGISTRY.ttl.formatForInput(1_700_000_000, column, {
+ timezone: 'America/New_York',
+ })
+ ).toBe('2023-11-14T17:13:20-05:00')
+ })
+
+ it('preserves the exact instant across both sides of a daylight-saving fold', () => {
+ expect(
+ COLUMN_TYPE_REGISTRY.ttl.formatForInput(1_699_162_200, column, {
+ timezone: 'America/New_York',
+ })
+ ).toBe('2023-11-05T01:30:00-04:00')
+ expect(
+ COLUMN_TYPE_REGISTRY.ttl.formatForInput(1_699_165_800, column, {
+ timezone: 'America/New_York',
+ })
+ ).toBe('2023-11-05T01:30:00-05:00')
+ })
+
+ it('limits a table to one ttl column', () => {
+ expect(COLUMN_TYPE_REGISTRY.ttl.maxPerTable).toBe(1)
+ })
+})
+
describe('intentional divergences from the pre-registry behavior', () => {
// A differential run of the registry against the pre-refactor implementations
// (55 values x 7 column shapes) found ZERO coercion differences and exactly
diff --git a/apps/sim/lib/table/__tests__/validation.test.ts b/apps/sim/lib/table/__tests__/validation.test.ts
index 9d698c9d96b..ff39e690d87 100644
--- a/apps/sim/lib/table/__tests__/validation.test.ts
+++ b/apps/sim/lib/table/__tests__/validation.test.ts
@@ -195,6 +195,18 @@ describe('Validation', () => {
expect(result.errors).toContain('Duplicate column names found')
})
+ it('rejects more than one TTL column', () => {
+ const result = validateTableSchema({
+ columns: [
+ { name: 'expires_at', type: 'ttl' },
+ { name: 'delete_at', type: 'ttl' },
+ ],
+ } as TableSchema)
+
+ expect(result.valid).toBe(false)
+ expect(result.errors).toContain('A table can have at most 1 TTL column')
+ })
+
it('should reject null schema', () => {
const result = validateTableSchema(null as unknown as TableSchema)
expect(result.valid).toBe(false)
diff --git a/apps/sim/lib/table/column-types/index.ts b/apps/sim/lib/table/column-types/index.ts
index 9aebb2b1002..8594decab5a 100644
--- a/apps/sim/lib/table/column-types/index.ts
+++ b/apps/sim/lib/table/column-types/index.ts
@@ -14,6 +14,7 @@ export * from '@/lib/table/column-types/registry'
export type {
CoerceResult,
ColumnCellEditor,
+ ColumnImportCoerceOptions,
ColumnType,
ColumnTypeDefinition,
TypeSpecificColumnKey,
diff --git a/apps/sim/lib/table/column-types/registry.server.ts b/apps/sim/lib/table/column-types/registry.server.ts
index 6ba27f5c616..5a6e23791ea 100644
--- a/apps/sim/lib/table/column-types/registry.server.ts
+++ b/apps/sim/lib/table/column-types/registry.server.ts
@@ -273,6 +273,7 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record = {
number: numberColumnType,
boolean: booleanColumnType,
date: dateColumnType,
+ ttl: ttlColumnType,
json: jsonColumnType,
select: selectColumnType,
currency: currencyColumnType,
@@ -90,6 +92,15 @@ export function isValueCompatible(value: unknown, target: ColumnDefinition): boo
return definition.coerce(value as JsonValue, target).ok
}
+/** Applies source-owned normalization before a value is converted to another type. */
+export function valueForTypeConversion(
+ value: JsonValue,
+ source: ColumnDefinition,
+ target: ColumnDefinition
+): JsonValue {
+ return columnTypeOf(source).valueForConversion?.(value, target) ?? value
+}
+
/** This type's own metadata errors; types carrying no metadata report none. */
export function validateTypeMetadata(column: ColumnDefinition): string[] {
return columnTypeOf(column).validateDefinition?.(column) ?? []
@@ -115,3 +126,31 @@ export function typeMetadataOf(column: ColumnDefinition): Partial | null {
return columnTypeOf(column).filterOperatorsFor?.(column) ?? null
}
+
+/** Schema-level cardinality errors declared by column type definitions. */
+export function validateColumnTypeLimits(columns: readonly ColumnDefinition[]): string[] {
+ const errors: string[] = []
+ for (const definition of ALL_COLUMN_TYPES) {
+ if (definition.maxPerTable === undefined) continue
+ if (wouldExceedColumnTypeLimit(columns, definition.id)) {
+ errors.push(`A table can have at most ${definition.maxPerTable} ${definition.label} column`)
+ }
+ }
+ return errors
+}
+
+/** Whether adding columns of a type would exceed its registry-declared table limit. */
+export function wouldExceedColumnTypeLimit(
+ columns: readonly ColumnDefinition[],
+ type: ColumnType,
+ additionalColumns = 0
+): boolean {
+ const definition = COLUMN_TYPE_REGISTRY[type]
+ if (definition.maxPerTable === undefined) return false
+
+ const count = columns.reduce(
+ (total, column) => total + (column.type === type ? 1 : 0),
+ additionalColumns
+ )
+ return count > definition.maxPerTable
+}
diff --git a/apps/sim/lib/table/column-types/ttl.ts b/apps/sim/lib/table/column-types/ttl.ts
new file mode 100644
index 00000000000..b893544af7a
--- /dev/null
+++ b/apps/sim/lib/table/column-types/ttl.ts
@@ -0,0 +1,114 @@
+import { TypeTtl } from '@sim/emcn/icons'
+import type { ColumnTypeDefinition } from '@/lib/table/column-types/types'
+import {
+ formatDateCellDisplay,
+ formatInstantInTimeZone,
+ type NormalizeDateCellOptions,
+ normalizeDateCellValue,
+} from '@/lib/table/dates'
+import type { ColumnDefinition } from '@/lib/table/types'
+
+const NUMERIC_VALUE_PATTERN = /^-?\d+(?:\.\d+)?$/
+const EXPLICIT_OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/i
+
+function isRepresentableEpochSeconds(value: number): boolean {
+ return Number.isSafeInteger(value) && !Number.isNaN(new Date(value * 1000).getTime())
+}
+
+/** Converts a TTL cell input to integer Unix epoch seconds. */
+export function parseTtlEpochSeconds(
+ value: unknown,
+ options?: NormalizeDateCellOptions
+): number | null {
+ if (typeof value === 'number') return isRepresentableEpochSeconds(value) ? value : null
+
+ if (value instanceof Date) {
+ const milliseconds = value.getTime()
+ return Number.isNaN(milliseconds) ? null : Math.floor(milliseconds / 1000)
+ }
+
+ if (typeof value !== 'string') return null
+ const trimmed = value.trim()
+ if (!trimmed) return null
+
+ if (NUMERIC_VALUE_PATTERN.test(trimmed)) {
+ const numeric = Number(trimmed)
+ return isRepresentableEpochSeconds(numeric) ? numeric : null
+ }
+
+ if (EXPLICIT_OFFSET_PATTERN.test(trimmed)) {
+ const milliseconds = Date.parse(trimmed)
+ if (Number.isNaN(milliseconds)) return null
+ const seconds = Math.floor(milliseconds / 1000)
+ return isRepresentableEpochSeconds(seconds) ? seconds : null
+ }
+
+ const normalized = normalizeDateCellValue(trimmed, options)
+ if (normalized === null) return null
+ const instant = /^\d{4}-\d{2}-\d{2}$/.test(normalized)
+ ? normalizeDateCellValue(`${normalized}T00:00:00`, options)
+ : normalized
+ if (instant === null) return null
+ const milliseconds = Date.parse(instant)
+ if (Number.isNaN(milliseconds)) return null
+ const seconds = Math.floor(milliseconds / 1000)
+ return isRepresentableEpochSeconds(seconds) ? seconds : null
+}
+
+function epochSecondsToIso(value: unknown): string | null {
+ const seconds = typeof value === 'number' ? value : Number(value)
+ if (!isRepresentableEpochSeconds(seconds)) return null
+ return new Date(seconds * 1000).toISOString().replace('.000Z', 'Z')
+}
+
+function epochSecondsToEditable(value: unknown, timeZone?: string): string | null {
+ const iso = epochSecondsToIso(value)
+ if (!iso || !timeZone) return iso
+ return formatInstantInTimeZone(new Date(iso), timeZone)
+}
+
+export const ttlColumnType: ColumnTypeDefinition = {
+ id: 'ttl',
+ label: 'TTL',
+ maxPerTable: 1,
+ icon: TypeTtl,
+ jsonbCast: 'numeric',
+ storesOpaqueIds: false,
+ supportsUnique: true,
+ sampleValue: 1_706_659_200,
+ ownedMetadata: [],
+ workflowInputType: 'number',
+ editor: 'date',
+ expandable: false,
+ typeaheadPattern: /[\d\-/]/,
+ parseErrorMessage: 'Invalid expiration date',
+
+ coerce(value, _column, context) {
+ const seconds = parseTtlEpochSeconds(value, context)
+ return seconds === null ? { ok: false } : { ok: true, value: seconds }
+ },
+
+ coerceImport(value, options) {
+ return parseTtlEpochSeconds(value, options) ?? String(value)
+ },
+
+ valueForConversion(value, target: ColumnDefinition) {
+ if (target.type !== 'date') return value
+ return epochSecondsToIso(value) ?? value
+ },
+
+ validateCell(value, column) {
+ return typeof value === 'number' && isRepresentableEpochSeconds(value)
+ ? null
+ : `${column.name} must be valid epoch seconds`
+ },
+
+ formatForDisplay(value) {
+ const iso = epochSecondsToIso(value)
+ return iso === null ? String(value) : formatDateCellDisplay(iso, { seconds: true })
+ },
+
+ formatForInput(value, _column, context) {
+ return epochSecondsToEditable(value, context?.timezone) ?? String(value)
+ },
+}
diff --git a/apps/sim/lib/table/column-types/types.ts b/apps/sim/lib/table/column-types/types.ts
index f481cea0a28..dd45be544eb 100644
--- a/apps/sim/lib/table/column-types/types.ts
+++ b/apps/sim/lib/table/column-types/types.ts
@@ -20,6 +20,7 @@
*/
import type React from 'react'
+import type { NormalizeDateCellOptions } from '@/lib/table/dates'
import type { ColumnDefinition, JsonValue } from '@/lib/table/types'
/**
@@ -36,6 +37,7 @@ export const COLUMN_TYPES = [
'currency',
'boolean',
'date',
+ 'ttl',
'json',
'select',
] as const
@@ -67,11 +69,17 @@ export type TypeSpecificColumnKey = (typeof TYPE_SPECIFIC_COLUMN_KEYS)[number]
/** Result of coercing a raw value toward a column's declared type. */
export type CoerceResult = { ok: true; value: JsonValue } | { ok: false }
+export interface ColumnImportCoerceOptions extends NormalizeDateCellOptions {
+ currencyCode?: string
+}
+
export interface ColumnTypeDefinition {
readonly id: ColumnType
/** Human label in the type picker, column header menu, and docs. */
readonly label: string
+ /** Maximum columns of this type a table may contain. Omitted when unlimited. */
+ readonly maxPerTable?: number
/** Type icon. A component reference only — never invoked server-side. */
readonly icon: React.ComponentType<{ className?: string }>
/**
@@ -157,7 +165,17 @@ export interface ColumnTypeDefinition {
* implementation — the server calls it before persisting and the grid calls
* it to fill the optimistic cache, so the two can no longer disagree.
*/
- coerce(value: JsonValue, column: ColumnDefinition): CoerceResult
+ coerce(
+ value: JsonValue,
+ column: ColumnDefinition,
+ context?: NormalizeDateCellOptions
+ ): CoerceResult
+
+ /** CSV-specific coercion when invalid input must survive for row-level validation. */
+ coerceImport?(value: unknown, options?: ColumnImportCoerceOptions): Exclude
+
+ /** Source-owned normalization applied before checking or rewriting a type conversion. */
+ valueForConversion?(value: JsonValue, target: ColumnDefinition): JsonValue
/** Validates a stored cell's shape. Returns an error message, or null when valid. */
validateCell(value: JsonValue, column: ColumnDefinition): string | null
@@ -203,7 +221,11 @@ export interface ColumnTypeDefinition {
formatForDisplay(value: unknown, column: ColumnDefinition): string
/** Stored value → the text an editor input starts with. */
- formatForInput(value: unknown, column: ColumnDefinition): string
+ formatForInput(
+ value: unknown,
+ column: ColumnDefinition,
+ context?: NormalizeDateCellOptions
+ ): string
/**
* Metadata stamped onto a newly created column of this type, so the schema
diff --git a/apps/sim/lib/table/columns/retype-cell.test.ts b/apps/sim/lib/table/columns/retype-cell.test.ts
index 479563fc74b..a153d971e9f 100644
--- a/apps/sim/lib/table/columns/retype-cell.test.ts
+++ b/apps/sim/lib/table/columns/retype-cell.test.ts
@@ -32,6 +32,12 @@ describe('retypeCellRewrite', () => {
expect(retypeCellRewrite('true', column({ type: 'boolean' }))).toEqual({ value: true })
})
+ it('converts TTL epoch seconds to an ISO date before retyping', () => {
+ expect(
+ retypeCellRewrite(1_700_000_000, column({ type: 'date' }), column({ type: 'ttl' }))
+ ).toEqual({ value: '2023-11-14T22:13:20Z' })
+ })
+
it('skips a cell whose stored value already matches the coercion', () => {
expect(retypeCellRewrite('kept', column({ type: 'json' }))).toBeNull()
expect(retypeCellRewrite(3, column({ type: 'json' }))).toBeNull()
diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts
index b68ce8393ac..48ff0ec708f 100644
--- a/apps/sim/lib/table/columns/service.ts
+++ b/apps/sim/lib/table/columns/service.ts
@@ -27,6 +27,7 @@ import {
columnTypeOf,
isValueCompatible,
TYPE_SPECIFIC_COLUMN_KEYS,
+ valueForTypeConversion,
} from '@/lib/table/column-types'
import {
migrationFrom,
@@ -767,17 +768,22 @@ export function applyPendingRename(
*/
export function retypeCellRewrite(
value: unknown,
- target: ColumnDefinition
+ target: ColumnDefinition,
+ source?: ColumnDefinition
): { value: JsonValue } | null {
if (value === null || value === undefined) return null
- if (!isValueCompatibleWithColumn(value, target)) {
+ const effective = source
+ ? valueForTypeConversion(value as JsonValue, source, target)
+ : (value as JsonValue)
+
+ if (!isValueCompatibleWithColumn(effective, target)) {
// Incompatible non-blanks never reach here: the compatibility scan already
// refused the whole conversion for them.
- return value === '' ? { value: null } : null
+ return effective === '' ? { value: null } : null
}
- const coerced = columnTypeById(target.type).coerce(value as JsonValue, target)
+ const coerced = columnTypeById(target.type).coerce(effective, target)
if (coerced.ok && !Object.is(coerced.value, value)) return { value: coerced.value }
return null
}
@@ -944,6 +950,12 @@ export async function updateColumnType(
isSelectType,
targetMultiple: !!targetMultiple,
})
+ const renamedColumns = schema.columns.map((c, i) => (i === columnIndex ? convertedColumn : c))
+ const updatedColumns = renamedColumns.map((c, i) =>
+ i === columnIndex ? applyPendingRename(renamedColumns, columnIndex, data.newName) : c
+ )
+ const updatedSchema: TableSchema = { ...schema, columns: updatedColumns }
+ assertValidSchema(updatedSchema, table.metadata?.columnOrder)
let incompatibleCount = 0
let blankCount = 0
@@ -972,7 +984,7 @@ export async function updateColumnType(
const effective = convertingAwayFromSelect
? selectValueForConversion(column, value)
- : value
+ : valueForTypeConversion(value as JsonValue, column, convertedColumn)
if (!isValueCompatibleWithColumn(effective, convertedColumn)) {
if (effective === null || effective === '') {
@@ -1000,11 +1012,6 @@ export async function updateColumnType(
)
}
- const renamedColumns = schema.columns.map((c, i) => (i === columnIndex ? convertedColumn : c))
- const updatedColumns = renamedColumns.map((c, i) =>
- i === columnIndex ? applyPendingRename(renamedColumns, columnIndex, data.newName) : c
- )
-
const columnValidation = validateColumnDefinition(updatedColumns[columnIndex])
if (!columnValidation.valid) {
throw new OrchestrationError(
@@ -1013,7 +1020,6 @@ export async function updateColumnType(
)
}
- const updatedSchema: TableSchema = { ...schema, columns: updatedColumns }
const now = new Date()
// Cell rewrites are owned by the column-type registry, keyed by direction.
@@ -1045,7 +1051,7 @@ export async function updateColumnType(
if (rows.length === 0) break
const coercedByRowId = new Map()
for (const row of rows) {
- const rewrite = retypeCellRewrite(row.value, convertedColumn)
+ const rewrite = retypeCellRewrite(row.value, convertedColumn, column)
if (rewrite) coercedByRowId.set(row.id, rewrite.value)
}
await writeBackCoercedCells(
diff --git a/apps/sim/lib/table/columns/ttl-limit.test.ts b/apps/sim/lib/table/columns/ttl-limit.test.ts
new file mode 100644
index 00000000000..30319a27843
--- /dev/null
+++ b/apps/sim/lib/table/columns/ttl-limit.test.ts
@@ -0,0 +1,74 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { TableDefinition, TableLocks } from '@/lib/table/types'
+
+const { mockTimeoutExecute, mockWithLockedTable } = vi.hoisted(() => ({
+ mockTimeoutExecute: vi.fn(),
+ mockWithLockedTable: vi.fn(),
+}))
+
+vi.mock('@/lib/table/service', () => ({ withLockedTable: mockWithLockedTable }))
+
+import { addTableColumn, updateColumnType } from '@/lib/table/columns/service'
+
+const UNLOCKED: TableLocks = {
+ schemaLocked: false,
+ insertLocked: false,
+ updateLocked: false,
+ deleteLocked: false,
+}
+
+function makeTable(): TableDefinition {
+ return {
+ id: 'table-1',
+ name: 'Tasks',
+ schema: {
+ columns: [
+ { id: 'col-name', name: 'name', type: 'string' },
+ { id: 'col-ttl', name: 'expires_at', type: 'ttl' },
+ ],
+ },
+ rowCount: 0,
+ maxRows: 100,
+ workspaceId: 'workspace-1',
+ createdBy: 'user-1',
+ locks: UNLOCKED,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }
+}
+
+const transaction = new Proxy(
+ { execute: mockTimeoutExecute },
+ {
+ get(target, property) {
+ if (property in target) return target[property as keyof typeof target]
+ throw new Error(`Unexpected transaction method: ${String(property)}`)
+ },
+ }
+)
+
+describe('TTL column mutation limit', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTimeoutExecute.mockResolvedValue([])
+ mockWithLockedTable.mockImplementation(async (_tableId, mutate) =>
+ mutate(makeTable(), transaction)
+ )
+ })
+
+ it('rejects adding a second TTL column before persistence', async () => {
+ await expect(
+ addTableColumn('table-1', { name: 'another_expiry', type: 'ttl' }, 'request-1')
+ ).rejects.toThrow('A table can have at most 1 TTL column')
+ })
+
+ it('rejects retyping another column to TTL before scanning cells', async () => {
+ await expect(
+ updateColumnType({ tableId: 'table-1', columnName: 'name', newType: 'ttl' }, 'request-1')
+ ).rejects.toThrow('A table can have at most 1 TTL column')
+ expect(mockTimeoutExecute).toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/table/dates.ts b/apps/sim/lib/table/dates.ts
index a38eca7f21e..0c6360f63fb 100644
--- a/apps/sim/lib/table/dates.ts
+++ b/apps/sim/lib/table/dates.ts
@@ -164,6 +164,21 @@ function formatOffsetSuffix(offsetMinutes: number): string {
return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
}
+/** Formats an instant as canonical wall time in an IANA timezone. */
+export function formatInstantInTimeZone(date: Date, timeZone: string): string {
+ const wall = getWallClockParts(date, timeZone)
+ const wallAsUtc = Date.UTC(
+ wall.year,
+ wall.month - 1,
+ wall.day,
+ wall.hour,
+ wall.minute,
+ wall.second
+ )
+ const offsetMinutes = Math.round((wallAsUtc - date.getTime()) / 60_000)
+ return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatOffsetSuffix(offsetMinutes)}`
+}
+
/**
* Trailing offset (minutes east of UTC) of a datetime string, or null when
* naive. Recognizes exactly what `Date.parse` recognizes: numeric offsets,
diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts
index 46904728f69..b40c4044deb 100644
--- a/apps/sim/lib/table/import.test.ts
+++ b/apps/sim/lib/table/import.test.ts
@@ -169,6 +169,15 @@ describe('import', () => {
)
expect(coerceValue('not-a-date', 'date')).toBe('not-a-date')
})
+
+ it('coerces TTL imports to epoch seconds and preserves invalid input for row validation', () => {
+ expect(coerceValue('2023-11-14T22:13:20Z', 'ttl')).toBe(1_700_000_000)
+ expect(coerceValue('1700000000', 'ttl')).toBe(1_700_000_000)
+ expect(coerceValue('2023-11-14 17:13:20', 'ttl', { timezone: 'America/New_York' })).toBe(
+ 1_700_000_000
+ )
+ expect(coerceValue('not-a-date', 'ttl')).toBe('not-a-date')
+ })
})
describe('buildAutoMapping', () => {
diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts
index e3707b74a99..06b8933ee5b 100644
--- a/apps/sim/lib/table/import.ts
+++ b/apps/sim/lib/table/import.ts
@@ -15,6 +15,7 @@ import type { Options as CsvParseOptions } from 'csv-parse'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { getColumnId } from '@/lib/table/column-keys'
import type { ColumnType } from '@/lib/table/column-types'
+import { columnTypeById } from '@/lib/table/column-types'
import { parseCurrencyInput } from '@/lib/table/currency'
import { type NormalizeDateCellOptions, normalizeDateCellValue } from '@/lib/table/dates'
import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types'
@@ -395,6 +396,9 @@ export function coerceValue(
options?: NormalizeDateCellOptions & { currencyCode?: string }
): string | number | boolean | null | Record | unknown[] {
if (value === null || value === undefined || value === '') return null
+ const definition = columnTypeById(colType)
+ if (definition.coerceImport) return definition.coerceImport(value, options)
+
switch (colType) {
case 'number': {
const n = Number(value)
diff --git a/apps/sim/lib/table/schema-invariants.ts b/apps/sim/lib/table/schema-invariants.ts
index 132b343dddc..ffb7407f1b5 100644
--- a/apps/sim/lib/table/schema-invariants.ts
+++ b/apps/sim/lib/table/schema-invariants.ts
@@ -11,6 +11,7 @@
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { getColumnId } from '@/lib/table/column-keys'
+import { validateColumnTypeLimits } from '@/lib/table/column-types'
import type { TableSchema, WorkflowGroup } from '@/lib/table/types'
/**
@@ -19,7 +20,7 @@ import type { TableSchema, WorkflowGroup } from '@/lib/table/types'
* etc. Returns a list of human-readable errors (empty if valid).
*/
export function validateSchema(schema: TableSchema, columnOrder: string[] | undefined): string[] {
- const errors: string[] = []
+ const errors = validateColumnTypeLimits(schema.columns)
// Group refs and columnOrder hold stable column ids (not display names).
const columnsById = new Map(schema.columns.map((c) => [getColumnId(c), c]))
const groups = schema.workflowGroups ?? []
diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts
index d77c71457f1..d770d72d96b 100644
--- a/apps/sim/lib/table/service.ts
+++ b/apps/sim/lib/table/service.ts
@@ -824,6 +824,7 @@ export async function addTableColumnsWithTx(
...table.schema,
columns: [...table.schema.columns, ...additions],
}
+ assertValidSchema(updatedSchema, table.metadata?.columnOrder)
const now = new Date()
await trx
diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts
index aa9a918beb8..e5fd89c3d2d 100644
--- a/apps/sim/lib/table/validation.ts
+++ b/apps/sim/lib/table/validation.ts
@@ -14,6 +14,7 @@ import {
columnTypeOf,
isColumnType,
TYPE_SPECIFIC_COLUMN_KEYS,
+ validateColumnTypeLimits,
validateTypeMetadata,
} from '@/lib/table/column-types'
import {
@@ -244,6 +245,8 @@ export function validateTableSchema(schema: TableSchema): ValidationResult {
errors.push('Duplicate column names found')
}
+ errors.push(...validateColumnTypeLimits(schema.columns))
+
return { valid: errors.length === 0, errors }
}
diff --git a/docker/crontab b/docker/crontab
index e12bb729a0b..5cedea901ac 100644
--- a/docker/crontab
+++ b/docker/crontab
@@ -39,6 +39,9 @@ SHELL=/bin/sh
# Enterprise data drains
0 * * * * curl -fsS -m 300 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/run-data-drains"
+# Deletes table rows whose TTL column has expired
+*/5 * * * * curl -fsS -m 60 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/cleanup-table-row-ttl"
+
# Microsoft Graph subscription renewal (Teams chat triggers expire after ~3 days)
0 */12 * * * curl -fsS -m 120 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/renew-subscriptions"
diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml
index 88a5a1f77c2..9d28eb4f0aa 100644
--- a/helm/sim/values.yaml
+++ b/helm/sim/values.yaml
@@ -1444,6 +1444,16 @@ cronjobs:
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
+ # Deletes table rows whose TTL column contains an expired Unix timestamp.
+ cleanupTableRowTtl:
+ enabled: true
+ name: cleanup-table-row-ttl
+ schedule: "*/5 * * * *"
+ path: "/api/cron/cleanup-table-row-ttl"
+ concurrencyPolicy: Forbid
+ successfulJobsHistoryLimit: 3
+ failedJobsHistoryLimit: 1
+
# Deletes prebuilt sandbox images that no workspace sandbox references and that
# have gone unused past the retention window, from the provider and locally.
# A no-op on deployments whose sandbox provider installs at run time.
diff --git a/packages/emcn/src/icons/index.ts b/packages/emcn/src/icons/index.ts
index 35fb8f96792..80a9efcbe2f 100644
--- a/packages/emcn/src/icons/index.ts
+++ b/packages/emcn/src/icons/index.ts
@@ -160,6 +160,7 @@ export { TypeCurrency } from './type-currency'
export { TypeJson } from './type-json'
export { TypeNumber } from './type-number'
export { TypeText } from './type-text'
+export { TypeTtl } from './type-ttl'
export { Undo } from './undo'
export { Unlink } from './unlink'
export { Unlock } from './unlock'
diff --git a/packages/emcn/src/icons/type-ttl.tsx b/packages/emcn/src/icons/type-ttl.tsx
new file mode 100644
index 00000000000..3f7451ff1a7
--- /dev/null
+++ b/packages/emcn/src/icons/type-ttl.tsx
@@ -0,0 +1,22 @@
+import type { SVGProps } from 'react'
+
+export function TypeTtl(props: SVGProps) {
+ return (
+
+ )
+}
diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts
index bb66f38e3f8..416c90efed6 100644
--- a/scripts/check-api-validation-contracts.ts
+++ b/scripts/check-api-validation-contracts.ts
@@ -20,8 +20,8 @@ const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
* file whose other seven baselines depend on that habit holding.
*/
const BASELINE = {
- totalRoutes: 1162,
- zodRoutes: 1162,
+ totalRoutes: 1163,
+ zodRoutes: 1163,
nonZodRoutes: 0,
} as const
@@ -86,6 +86,7 @@ const INDIRECT_ZOD_ROUTES = new Set([
'apps/sim/app/api/settings/allowed-mcp-domains/route.ts',
'apps/sim/app/api/cron/cleanup-tasks/route.ts',
'apps/sim/app/api/cron/cleanup-soft-deletes/route.ts',
+ 'apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts',
'apps/sim/app/api/cron/cleanup-stale-executions/route.ts',
'apps/sim/app/api/cron/cleanup-sandbox-images/route.ts',
'apps/sim/app/api/cron/renew-subscriptions/route.ts',
From 036d0f60ca76b7f88070607f6d192cb57d9c413b Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Tue, 25 Aug 2026 12:11:25 -0700
Subject: [PATCH 2/6] chore(api): regenerate table API artifacts
---
apps/docs/openapi-v2-tables.json | 90 +++++++++++++++++++++---
packages/sim-cli/src/generated/v2-api.ts | 30 ++++----
2 files changed, 96 insertions(+), 24 deletions(-)
diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json
index 561ced08d0f..ccbd943a484 100644
--- a/apps/docs/openapi-v2-tables.json
+++ b/apps/docs/openapi-v2-tables.json
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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.",
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts
index e8f9bed0ce8..a1ca9aff829 100644
--- a/packages/sim-cli/src/generated/v2-api.ts
+++ b/packages/sim-cli/src/generated/v2-api.ts
@@ -123,7 +123,7 @@ export type AddTableColumnBody = {
column: {
id?: string
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required?: boolean
unique?: boolean
options?: Array<{
@@ -140,7 +140,7 @@ type AddTableColumnResponseRef0 = {
columns: Array<{
id?: string
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required: boolean
unique: boolean
workflowGroupId?: string
@@ -190,7 +190,7 @@ export type AddWorkflowGroupBody = {
}
outputColumns: Array<{
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required?: boolean
unique?: boolean
}>
@@ -225,7 +225,7 @@ type AddWorkflowGroupResponseRef1 = {
columns: Array<{
id?: string
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required: boolean
unique: boolean
workflowGroupId?: string
@@ -1225,7 +1225,7 @@ export type CreateTableBody = {
columns: Array<{
id?: string
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required?: boolean
unique?: boolean
options?: Array<{
@@ -1256,7 +1256,7 @@ type CreateTableResponseRef1 = {
columns: Array<{
id?: string
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required: boolean
unique: boolean
workflowGroupId?: string
@@ -2002,7 +2002,7 @@ type DeleteTableColumnResponseRef0 = {
columns: Array<{
id?: string
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required: boolean
unique: boolean
workflowGroupId?: string
@@ -2246,7 +2246,7 @@ type DeleteWorkflowGroupResponseRef0 = {
columns: Array<{
id?: string
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required: boolean
unique: boolean
workflowGroupId?: string
@@ -2960,7 +2960,7 @@ type GetTableResponseRef1 = {
columns: Array<{
id?: string
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required: boolean
unique: boolean
workflowGroupId?: string
@@ -4147,7 +4147,7 @@ type ListTablesResponseRef0 = {
columns: Array<{
id?: string
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required: boolean
unique: boolean
workflowGroupId?: string
@@ -5691,7 +5691,7 @@ type UpdateTableResponseRef1 = {
columns: Array<{
id?: string
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required: boolean
unique: boolean
workflowGroupId?: string
@@ -5733,7 +5733,7 @@ export type UpdateTableColumnBody = {
columnName: string
updates: {
name?: string
- type?: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type?: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required?: boolean
unique?: boolean
options?: Array<{
@@ -5749,7 +5749,7 @@ type UpdateTableColumnResponseRef0 = {
columns: Array<{
id?: string
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required: boolean
unique: boolean
workflowGroupId?: string
@@ -6000,7 +6000,7 @@ export type UpdateWorkflowGroupBody = {
}>
newOutputColumns?: Array<{
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required?: boolean
unique?: boolean
}>
@@ -6046,7 +6046,7 @@ type UpdateWorkflowGroupResponseRef1 = {
columns: Array<{
id?: string
name: string
- type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select'
+ type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select'
required: boolean
unique: boolean
workflowGroupId?: string
From 9469c1093d18407fb9f0616b5cb99094024b8420 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Tue, 25 Aug 2026 12:14:51 -0700
Subject: [PATCH 3/6] fix(tables): make TTL cleanup fair across tables
---
.../background/cleanup-table-row-ttl.test.ts | 39 ++++++++++++++
apps/sim/background/cleanup-table-row-ttl.ts | 52 +++++++++++++------
2 files changed, 75 insertions(+), 16 deletions(-)
diff --git a/apps/sim/background/cleanup-table-row-ttl.test.ts b/apps/sim/background/cleanup-table-row-ttl.test.ts
index d810119b839..509d7432625 100644
--- a/apps/sim/background/cleanup-table-row-ttl.test.ts
+++ b/apps/sim/background/cleanup-table-row-ttl.test.ts
@@ -125,6 +125,45 @@ describe('table row TTL cleanup', () => {
expect(mockSignalTableRowsChanged).toHaveBeenCalledTimes(1)
})
+ it('gives each table one batch before returning to a backlogged table', async () => {
+ const secondTable = {
+ ...table,
+ id: 'table-2',
+ }
+ const attemptedTableIds: string[] = []
+ const tableAttempts = new Map()
+ mockListExecute.mockResolvedValue([
+ { id: table.id, workspaceId: table.workspaceId },
+ { id: secondTable.id, workspaceId: secondTable.workspaceId },
+ ])
+ mockWithLockedTable.mockImplementation(async (tableId, mutate) => {
+ const freshTable = tableId === secondTable.id ? secondTable : table
+ return mutate(freshTable, {
+ execute: vi.fn(async () => {
+ attemptedTableIds.push(tableId)
+ const attempt = (tableAttempts.get(tableId) ?? 0) + 1
+ tableAttempts.set(tableId, attempt)
+ if (tableId === table.id && attempt === 1) {
+ return [{ count: 500, lastId: 'row-500' }]
+ }
+ if (tableId === secondTable.id) {
+ return [{ count: 1, lastId: 'row-1' }]
+ }
+ return [{ count: 0, lastId: null }]
+ }),
+ })
+ })
+
+ await expect(runCleanupTableRowTtl()).resolves.toEqual({
+ batches: 3,
+ deleted: 501,
+ limitReached: false,
+ })
+ expect(attemptedTableIds).toEqual([table.id, secondTable.id, table.id])
+ expect(mockSignalTableRowsChanged).toHaveBeenCalledWith(table.id)
+ expect(mockSignalTableRowsChanged).toHaveBeenCalledWith(secondTable.id)
+ })
+
it('registers one serialized Trigger.dev task', () => {
expect(cleanupTableRowTtlTask).toEqual(
expect.objectContaining({
diff --git a/apps/sim/background/cleanup-table-row-ttl.ts b/apps/sim/background/cleanup-table-row-ttl.ts
index 82e8abf1d41..3243dbb2b99 100644
--- a/apps/sim/background/cleanup-table-row-ttl.ts
+++ b/apps/sim/background/cleanup-table-row-ttl.ts
@@ -28,6 +28,13 @@ interface DeletedTtlBatch {
lastId: string | null
}
+interface TtlTableCleanupState {
+ ref: ExpiredTtlTableRef
+ afterId?: string
+ deleted: number
+ complete: boolean
+}
+
export interface TableRowTtlCleanupResult {
batches: number
deleted: number
@@ -64,7 +71,9 @@ async function listExpiredTtlTables(nowEpochSeconds: number): Promise ({
+ ref,
+ deleted: 0,
+ complete: false,
+ }))
let deleted = 0
let batches = 0
- let lastBatchDeleted = 0
-
- for (const ref of tableRefs) {
- let afterId: string | undefined
- let tableDeleted = 0
- while (batches < TTL_CLEANUP_MAX_BATCHES && !signal?.aborted) {
- const batch = await deleteExpiredRowsForTable(ref, nowEpochSeconds, afterId)
- if (!batch.attempted) break
+ while (
+ batches < TTL_CLEANUP_MAX_BATCHES &&
+ !signal?.aborted &&
+ tableStates.some((state) => !state.complete)
+ ) {
+ for (const state of tableStates) {
+ if (state.complete) continue
+ if (batches === TTL_CLEANUP_MAX_BATCHES || signal?.aborted) break
+
+ const batch = await deleteExpiredRowsForTable(state.ref, nowEpochSeconds, state.afterId)
+ if (!batch.attempted) {
+ state.complete = true
+ continue
+ }
batches++
deleted += batch.deleted
- tableDeleted += batch.deleted
- lastBatchDeleted = batch.deleted
- afterId = batch.lastId ?? undefined
- if (batch.deleted < TTL_CLEANUP_BATCH_SIZE) break
+ state.deleted += batch.deleted
+ state.afterId = batch.lastId ?? undefined
+ if (batch.deleted < TTL_CLEANUP_BATCH_SIZE) state.complete = true
}
+ }
- if (tableDeleted > 0) signalTableRowsChanged(ref.id)
- if (batches === TTL_CLEANUP_MAX_BATCHES || signal?.aborted) break
+ for (const state of tableStates) {
+ if (state.deleted > 0) signalTableRowsChanged(state.ref.id)
}
const limitReached =
batches === TTL_CLEANUP_MAX_BATCHES &&
- (lastBatchDeleted === TTL_CLEANUP_BATCH_SIZE || tableRefs.length === TTL_CLEANUP_MAX_BATCHES)
+ (tableStates.some((state) => !state.complete) || tableRefs.length === TTL_CLEANUP_MAX_BATCHES)
logger.info('Table row TTL cleanup completed', { batches, deleted, limitReached })
return { batches, deleted, limitReached }
}
From 32b02dd188fc4b350fc33ab31520a243f6388606 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Tue, 25 Aug 2026 12:35:13 -0700
Subject: [PATCH 4/6] fix(tables): reject rolled-over TTL dates
---
.../__tests__/column-type-registry.test.ts | 20 +++++++++++++++++++
apps/sim/lib/table/column-types/ttl.ts | 11 +++-------
2 files changed, 23 insertions(+), 8 deletions(-)
diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts
index 647af08c7e0..7fcdceeb148 100644
--- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts
+++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts
@@ -145,6 +145,26 @@ describe('ttl columns', () => {
expect(COLUMN_TYPE_REGISTRY.ttl.coerce(1_700_000_000.5, column)).toEqual({ ok: false })
})
+ it.each(['2023-02-29', '2023-02-29T12:00:00', '2023-02-29T12:00:00-05:00'])(
+ 'rejects a nonexistent ISO calendar input: %s',
+ (value) => {
+ expect(COLUMN_TYPE_REGISTRY.ttl.coerce(value, column, { timezone: 'UTC' })).toEqual({
+ ok: false,
+ })
+ }
+ )
+
+ it.each([
+ ['2024-02-29', '2024-02-29T00:00:00Z'],
+ ['2024-02-29T12:00:00', '2024-02-29T12:00:00Z'],
+ ['2024-02-29T12:00:00-05:00', '2024-02-29T17:00:00Z'],
+ ])('accepts a valid leap-day ISO calendar input: %s', (value, expectedInstant) => {
+ expect(COLUMN_TYPE_REGISTRY.ttl.coerce(value, column, { timezone: 'UTC' })).toEqual({
+ ok: true,
+ value: Math.floor(Date.parse(expectedInstant) / 1000),
+ })
+ })
+
it('renders and edits epoch seconds as a date', () => {
expect(COLUMN_TYPE_REGISTRY.ttl.formatForDisplay(1_700_000_000, column)).toBe(
'11/14/2023 10:13:20 PM'
diff --git a/apps/sim/lib/table/column-types/ttl.ts b/apps/sim/lib/table/column-types/ttl.ts
index b893544af7a..854a04b2c23 100644
--- a/apps/sim/lib/table/column-types/ttl.ts
+++ b/apps/sim/lib/table/column-types/ttl.ts
@@ -9,7 +9,7 @@ import {
import type { ColumnDefinition } from '@/lib/table/types'
const NUMERIC_VALUE_PATTERN = /^-?\d+(?:\.\d+)?$/
-const EXPLICIT_OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/i
+const ISO_DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})(?:$|[T ])/i
function isRepresentableEpochSeconds(value: number): boolean {
return Number.isSafeInteger(value) && !Number.isNaN(new Date(value * 1000).getTime())
@@ -36,19 +36,14 @@ export function parseTtlEpochSeconds(
return isRepresentableEpochSeconds(numeric) ? numeric : null
}
- if (EXPLICIT_OFFSET_PATTERN.test(trimmed)) {
- const milliseconds = Date.parse(trimmed)
- if (Number.isNaN(milliseconds)) return null
- const seconds = Math.floor(milliseconds / 1000)
- return isRepresentableEpochSeconds(seconds) ? seconds : null
- }
-
const normalized = normalizeDateCellValue(trimmed, options)
if (normalized === null) return null
const instant = /^\d{4}-\d{2}-\d{2}$/.test(normalized)
? normalizeDateCellValue(`${normalized}T00:00:00`, options)
: normalized
if (instant === null) return null
+ const inputIsoDate = trimmed.match(ISO_DATE_PREFIX_PATTERN)?.[1]
+ if (inputIsoDate && instant.slice(0, 10) !== inputIsoDate) return null
const milliseconds = Date.parse(instant)
if (Number.isNaN(milliseconds)) return null
const seconds = Math.floor(milliseconds / 1000)
From c730737280049f4623ec4b6356de6f5d1e3cae85 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Tue, 25 Aug 2026 12:45:24 -0700
Subject: [PATCH 5/6] fix(copilot): remove unrelated catalog drift
---
.../lib/copilot/generated/tool-catalog-v1.ts | 21 -------------------
.../lib/copilot/generated/tool-schemas-v1.ts | 14 -------------
2 files changed, 35 deletions(-)
diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
index af8b00e5893..866e43f4ac3 100644
--- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
@@ -71,7 +71,6 @@ export interface ToolCatalogEntry {
| 'load_deployment'
| 'load_integration_tool'
| 'load_skill'
- | 'load_slide_layout'
| 'manage_credential'
| 'manage_custom_tool'
| 'manage_knowledge_base'
@@ -200,7 +199,6 @@ export interface ToolCatalogEntry {
| 'load_deployment'
| 'load_integration_tool'
| 'load_skill'
- | 'load_slide_layout'
| 'manage_credential'
| 'manage_custom_tool'
| 'manage_knowledge_base'
@@ -3145,24 +3143,6 @@ export const LoadSkill: ToolCatalogEntry = {
},
}
-export const LoadSlideLayout: ToolCatalogEntry = {
- id: 'load_slide_layout',
- name: 'load_slide_layout',
- route: 'go',
- mode: 'sync',
- parameters: {
- type: 'object',
- properties: {
- name: {
- type: 'string',
- description:
- "Layout name exactly as it appears in the Layout Library index (e.g. 'metric-cards').",
- },
- },
- required: ['name'],
- },
-}
-
export const ManageCredential: ToolCatalogEntry = {
id: 'manage_credential',
name: 'manage_credential',
@@ -7064,7 +7044,6 @@ export const TOOL_CATALOG: Record = {
[LoadDeployment.id]: LoadDeployment,
[LoadIntegrationTool.id]: LoadIntegrationTool,
[LoadSkill.id]: LoadSkill,
- [LoadSlideLayout.id]: LoadSlideLayout,
[ManageCredential.id]: ManageCredential,
[ManageCustomTool.id]: ManageCustomTool,
[ManageKnowledgeBase.id]: ManageKnowledgeBase,
diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
index d35d624dfd2..a7c9619e182 100644
--- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
@@ -3028,20 +3028,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- load_slide_layout: {
- parameters: {
- type: 'object',
- properties: {
- name: {
- type: 'string',
- description:
- "Layout name exactly as it appears in the Layout Library index (e.g. 'metric-cards').",
- },
- },
- required: ['name'],
- },
- resultSchema: undefined,
- },
manage_credential: {
parameters: {
type: 'object',
From 9a2228411d5751f08d42bbfb448bd97749671522 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Tue, 25 Aug 2026 12:58:58 -0700
Subject: [PATCH 6/6] chore(helm): bump chart for TTL cron
---
helm/sim/Chart.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/helm/sim/Chart.yaml b/helm/sim/Chart.yaml
index c60e2c89ce9..2b2172ad5f1 100644
--- a/helm/sim/Chart.yaml
+++ b/helm/sim/Chart.yaml
@@ -2,7 +2,7 @@ apiVersion: v2
name: sim
description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents
type: application
-version: 1.5.4
+version: 1.5.5
appVersion: "v0.7.44"
kubeVersion: ">=1.25.0-0"
home: https://sim.ai