diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx
index bb6758aec4d..faa87c11096 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx
@@ -33,6 +33,10 @@ import {
generalViewParam,
generalViewUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/components/general/search-params'
+import {
+ getTimezonePickerPresentation,
+ timezonePreferenceFromPickerValue,
+} from '@/app/workspace/[workspaceId]/settings/components/general/timezone-picker'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -221,7 +225,12 @@ export function General() {
}
const handleTimezoneChange = async (value: string) => {
- await updateSetting.mutateAsync({ key: 'timezone', value })
+ const timezone = timezonePreferenceFromPickerValue(value)
+ if (timezone === undefined) return
+ await updateSetting.mutateAsync({
+ key: 'timezone',
+ value: timezone,
+ })
}
const handleAutoConnectChange = async (checked: boolean) => {
@@ -288,6 +297,14 @@ export function General() {
return
}
+ const browserTimezone = getBrowserTimezone()
+ const savedTimezone = settings?.timezone ?? null
+ const timezonePicker = getTimezonePickerPresentation(
+ savedTimezone,
+ browserTimezone,
+ TIMEZONE_OPTIONS
+ )
+
return (
<>
@@ -433,10 +450,10 @@ export function General() {
dropdownWidth={240}
searchable
searchPlaceholder='Search timezones'
- value={settings?.timezone ?? getBrowserTimezone()}
+ value={timezonePicker.value}
onChange={handleTimezoneChange}
placeholder='Select timezone'
- options={TIMEZONE_OPTIONS}
+ options={timezonePicker.options}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.test.ts
new file mode 100644
index 00000000000..9b336fe5cb2
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.test.ts
@@ -0,0 +1,63 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ AUTO_TIMEZONE_OPTION_VALUE,
+ getTimezonePickerPresentation,
+ INVALID_TIMEZONE_OPTION_VALUE,
+ timezonePreferenceFromPickerValue,
+} from '@/app/workspace/[workspaceId]/settings/components/general/timezone-picker'
+
+const timezoneOptions = [{ label: 'Los Angeles', value: 'America/Los_Angeles' }]
+
+describe('getTimezonePickerPresentation', () => {
+ it('shows an unset preference as an explicit browser-managed option', () => {
+ expect(getTimezonePickerPresentation(null, 'America/Los_Angeles', timezoneOptions)).toEqual({
+ value: AUTO_TIMEZONE_OPTION_VALUE,
+ options: [
+ { label: 'Auto: America/Los_Angeles', value: AUTO_TIMEZONE_OPTION_VALUE },
+ ...timezoneOptions,
+ ],
+ })
+ })
+
+ it('keeps a valid saved timezone selected independently of Auto', () => {
+ expect(
+ getTimezonePickerPresentation('America/Los_Angeles', 'America/Los_Angeles', timezoneOptions)
+ .value
+ ).toBe('America/Los_Angeles')
+ })
+
+ it('adds a valid saved timezone that is absent from the curated options', () => {
+ expect(getTimezonePickerPresentation('Etc/GMT+5', 'UTC', timezoneOptions)).toEqual({
+ value: 'Etc/GMT+5',
+ options: [
+ { label: 'Auto: UTC', value: AUTO_TIMEZONE_OPTION_VALUE },
+ { label: 'Etc/GMT+5', value: 'Etc/GMT+5' },
+ ...timezoneOptions,
+ ],
+ })
+ })
+
+ it('surfaces an invalid saved timezone without making it selectable', () => {
+ expect(getTimezonePickerPresentation('Mars/Olympus', 'UTC', timezoneOptions)).toEqual({
+ value: INVALID_TIMEZONE_OPTION_VALUE,
+ options: [
+ { label: 'Auto: UTC', value: AUTO_TIMEZONE_OPTION_VALUE },
+ {
+ label: 'Invalid: Mars/Olympus',
+ value: INVALID_TIMEZONE_OPTION_VALUE,
+ disabled: true,
+ },
+ ...timezoneOptions,
+ ],
+ })
+ })
+
+ it('persists Auto as an unset preference', () => {
+ expect(timezonePreferenceFromPickerValue(AUTO_TIMEZONE_OPTION_VALUE)).toBeNull()
+ expect(timezonePreferenceFromPickerValue('Asia/Tokyo')).toBe('Asia/Tokyo')
+ expect(timezonePreferenceFromPickerValue(INVALID_TIMEZONE_OPTION_VALUE)).toBeUndefined()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.ts
new file mode 100644
index 00000000000..ed2abe68e0a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.ts
@@ -0,0 +1,59 @@
+import type { ComboboxOption } from '@sim/emcn'
+import { isValidTimezone, sanitizeTimezoneForDisplay } from '@/lib/core/utils/timezone'
+
+export const AUTO_TIMEZONE_OPTION_VALUE = '__auto_timezone__'
+export const INVALID_TIMEZONE_OPTION_VALUE = '__invalid_timezone__'
+
+interface TimezonePickerPresentation {
+ value: string
+ options: ComboboxOption[]
+}
+
+/** Builds the picker state without making an unset browser fallback look persisted. */
+export function getTimezonePickerPresentation(
+ savedTimezone: string | null,
+ browserTimezone: string,
+ timezoneOptions: readonly ComboboxOption[]
+): TimezonePickerPresentation {
+ const hasInvalidTimezone = savedTimezone !== null && !isValidTimezone(savedTimezone)
+ const unlistedTimezone =
+ savedTimezone !== null &&
+ !hasInvalidTimezone &&
+ !timezoneOptions.some((option) => option.value === savedTimezone)
+ ? savedTimezone
+ : null
+ const safeInvalidTimezone =
+ savedTimezone === null ? '' : sanitizeTimezoneForDisplay(savedTimezone)
+
+ return {
+ value: hasInvalidTimezone
+ ? INVALID_TIMEZONE_OPTION_VALUE
+ : (savedTimezone ?? AUTO_TIMEZONE_OPTION_VALUE),
+ options: [
+ { label: `Auto: ${browserTimezone}`, value: AUTO_TIMEZONE_OPTION_VALUE },
+ ...(hasInvalidTimezone
+ ? [
+ {
+ label: `Invalid: ${safeInvalidTimezone || '(empty)'}`,
+ value: INVALID_TIMEZONE_OPTION_VALUE,
+ disabled: true,
+ },
+ ]
+ : []),
+ ...(unlistedTimezone
+ ? [
+ {
+ label: sanitizeTimezoneForDisplay(unlistedTimezone),
+ value: unlistedTimezone,
+ },
+ ]
+ : []),
+ ...timezoneOptions,
+ ],
+ }
+}
+
+export function timezonePreferenceFromPickerValue(value: string): string | null | undefined {
+ if (value === INVALID_TIMEZONE_OPTION_VALUE) return undefined
+ return value === AUTO_TIMEZONE_OPTION_VALUE ? null : value
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx
index 851a755b22a..1c0a37a21c2 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx
@@ -7,12 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableInfo, TableRow } from '@/lib/table'
import { RowModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal'
-const { mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } = vi.hoisted(() => ({
- mockUseTimezoneState: vi.fn(),
- mockUpdateRow: vi.fn(),
- mockDeleteRow: vi.fn(),
- mockDeleteRows: vi.fn(),
-}))
+const { mockToastError, mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } =
+ vi.hoisted(() => ({
+ mockToastError: vi.fn(),
+ mockUseTimezoneState: vi.fn(),
+ mockUpdateRow: vi.fn(),
+ mockDeleteRow: vi.fn(),
+ mockDeleteRows: vi.fn(),
+ }))
vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'workspace-1' }),
@@ -29,6 +31,8 @@ vi.mock('@sim/emcn', () => {
const passthrough = ({ children }: { children?: ReactNode }) => children ?? null
return {
Checkbox: () => null,
+ Chip: ({ children, ...props }: { children?: ReactNode }) =>
+ createElement('button', { type: 'button', ...props }, children),
ChipConfirmModal: passthrough,
ChipDatePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) =>
createElement(
@@ -39,7 +43,27 @@ vi.mock('@sim/emcn', () => {
ChipModal: passthrough,
ChipModalBody: passthrough,
ChipModalError: passthrough,
- ChipModalField: passthrough,
+ ChipModalField: ({
+ type,
+ value,
+ onChange,
+ children,
+ }: {
+ type?: string
+ value?: string
+ onChange?: (value: string) => void
+ children?: ReactNode | ((aria: Record) => ReactNode)
+ }) =>
+ type === 'input'
+ ? createElement('input', {
+ 'data-testid': 'modal-input',
+ value: value ?? '',
+ onChange: (event: { currentTarget: { value: string } }) =>
+ onChange?.(event.currentTarget.value),
+ })
+ : typeof children === 'function'
+ ? children({ 'aria-describedby': 'field-hint' })
+ : (children ?? null),
ChipModalFooter: ({
primaryAction,
}: {
@@ -64,6 +88,7 @@ vi.mock('@sim/emcn', () => {
onChange(event.currentTarget.value),
}),
Label: passthrough,
+ toast: { error: mockToastError },
}
})
@@ -111,7 +136,9 @@ describe('RowModal expiration editing', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
act(() => root.render(createElement(RowModal, props)))
- expect(container.querySelector('[role="status"]')?.textContent).toBe('Loading timezone…')
+ expect(container.querySelector('[aria-label="Edit expires_at"]')?.textContent).toBe(
+ 'Loading timezone…'
+ )
expect(container.querySelector('[data-testid="time"]')).toBeNull()
expect(container.querySelector('[data-testid="submit"]')?.disabled).toBe(
true
@@ -145,4 +172,129 @@ describe('RowModal expiration editing', () => {
act(() => root.unmount())
container.remove()
})
+
+ it('also waits for timezone settings on an ordinary Date column', () => {
+ mockUseTimezoneState.mockReturnValue({ timezone: 'Asia/Tokyo', status: 'loading' })
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const props = {
+ mode: 'edit' as const,
+ isOpen: true,
+ onClose: vi.fn(),
+ table: {
+ id: 'table-2',
+ name: 'Dates',
+ schema: { columns: [{ name: 'starts_at', type: 'date' as const }] },
+ },
+ row: { ...row, data: { starts_at: '2026-06-15T09:00:00+09:00' } },
+ onSuccess: vi.fn(),
+ }
+
+ act(() => root.render(createElement(RowModal, props)))
+
+ expect(container.querySelector('[aria-label="Edit starts_at"]')?.textContent).toBe(
+ 'Loading timezone…'
+ )
+ expect(container.querySelector('[data-testid="time"]')).toBeNull()
+
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'America/Los_Angeles',
+ status: 'ready',
+ })
+ act(() => root.render(createElement(RowModal, props)))
+
+ expect(container.querySelector('[data-testid="time"]')).not.toBeNull()
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it('blocks an invalid saved timezone with the plain-text guidance', () => {
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'America/Los_Angeles',
+ savedTimezone: 'Mars/Olympus',
+ status: 'invalid',
+ })
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const props = {
+ mode: 'edit' as const,
+ isOpen: true,
+ onClose: vi.fn(),
+ table,
+ row,
+ onSuccess: vi.fn(),
+ }
+
+ act(() => root.render(createElement(RowModal, props)))
+
+ const blockedField = container.querySelector(
+ '[aria-label="Edit expires_at"]'
+ )
+ expect(blockedField?.textContent).toBe(String(row.data.expires_at))
+ expect(container.querySelector('[data-testid="submit"]')?.disabled).toBe(
+ true
+ )
+ expect(mockToastError).not.toHaveBeenCalled()
+ act(() => blockedField?.click())
+ expect(mockToastError).toHaveBeenCalledWith(
+ 'Your saved timezone “Mars/Olympus” is invalid. Update it in Settings → General before editing Date or Expiration cells.'
+ )
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it('keeps unrelated fields editable and omits blocked date values from the update', async () => {
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'America/Los_Angeles',
+ savedTimezone: 'Mars/Olympus',
+ status: 'invalid',
+ })
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const mixedTable: TableInfo = {
+ ...table,
+ schema: {
+ columns: [
+ { name: 'name', type: 'string' },
+ { name: 'expires_at', type: 'ttl' },
+ ],
+ },
+ }
+ const mixedRow = { ...row, data: { name: 'Ada', expires_at: row.data.expires_at } }
+ const props = {
+ mode: 'edit' as const,
+ isOpen: true,
+ onClose: vi.fn(),
+ table: mixedTable,
+ row: mixedRow,
+ onSuccess: vi.fn(),
+ }
+
+ act(() => root.render(createElement(RowModal, props)))
+
+ const nameInput = container.querySelector('[data-testid="modal-input"]')
+ const blockedField = container.querySelector(
+ '[aria-label="Edit expires_at"]'
+ )
+ const submit = container.querySelector('[data-testid="submit"]')
+ expect(nameInput?.value).toBe('Ada')
+ expect(blockedField?.textContent).toBe(String(row.data.expires_at))
+ expect(submit?.disabled).toBe(false)
+
+ act(() => changeInput(nameInput as HTMLInputElement, 'Grace'))
+ await act(async () => submit?.click())
+
+ expect(mockUpdateRow).toHaveBeenCalledWith({
+ rowId: 'row-1',
+ data: { name: 'Grace' },
+ })
+ expect(props.onSuccess).toHaveBeenCalledTimes(1)
+ expect(mockToastError).not.toHaveBeenCalled()
+
+ act(() => root.unmount())
+ container.remove()
+ })
})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx
index e139b38b849..bbab5f353f2 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx
@@ -3,6 +3,7 @@
import { useId, useRef, useState } from 'react'
import {
Checkbox,
+ Chip,
ChipConfirmModal,
ChipDatePicker,
ChipModal,
@@ -13,6 +14,7 @@ import {
ChipModalHeader,
ChipTimePicker,
Label,
+ toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
@@ -20,7 +22,8 @@ import { useParams } from 'next/navigation'
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
import { columnTypeOf } from '@/lib/table/column-types'
import { resolveCurrencyCode } from '@/lib/table/currency'
-import { useTimezoneState } from '@/hooks/queries/general-settings'
+import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing'
+import { type TimezoneState, useTimezoneState } from '@/hooks/queries/general-settings'
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
import {
cleanCellValue,
@@ -46,12 +49,16 @@ export interface RowModalProps {
function cleanRowData(
columns: ColumnDefinition[],
rowData: Record,
- timeZone: string
+ timeZone: string,
+ dateEditorsReady: boolean
): Record {
const cleanData: Record = {}
columns.forEach((col) => {
const value = rowData[col.name]
+ if (columnTypeOf(col).editor === 'date' && !dateEditorsReady) {
+ return
+ }
try {
cleanData[col.name] = cleanCellValue(value, col, timeZone)
} catch {
@@ -83,8 +90,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
if (timezoneState.status === 'ready' && editTimeZoneRef.current === null) {
editTimeZoneRef.current = timezoneState.timezone
}
- const hasTtlColumn = mode === 'edit' && columns.some((column) => column.type === 'ttl')
- const ttlTimezoneUnavailable = hasTtlColumn && editTimeZoneRef.current === null
+ const dateEditorsReady = editTimeZoneRef.current !== null
const timeZone = editTimeZoneRef.current ?? timezoneState.timezone
const [rowData, setRowData] = useState>(() =>
mode === 'edit' && row ? row.data : {}
@@ -96,13 +102,18 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
const isSubmitting =
updateRowMutation.isPending || deleteRowMutation.isPending || deleteRowsMutation.isPending
+ const timezoneBlockedMessage = getTimezoneEditBlockedMessage(timezoneState)
+ const hasEditableColumn = columns.some(
+ (column) => columnTypeOf(column).editor !== 'date' || dateEditorsReady
+ )
+
const handleFormSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
setError(null)
- if (ttlTimezoneUnavailable) return
+ if (!hasEditableColumn) return
try {
- const cleanData = cleanRowData(columns, rowData, timeZone)
+ const cleanData = cleanRowData(columns, rowData, timeZone, dateEditorsReady)
if (row) {
await updateRowMutation.mutateAsync({ rowId: row.id, data: cleanData })
@@ -177,13 +188,19 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
Update values for {table?.name ?? 'table'}
{error}
@@ -202,7 +219,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
primaryAction={{
label: isSubmitting ? 'Updating...' : 'Update Row',
onClick: () => handleFormSubmit(),
- disabled: isSubmitting || ttlTimezoneUnavailable,
+ disabled: isSubmitting || !hasEditableColumn,
}}
/>
@@ -216,9 +233,8 @@ interface ColumnFieldProps {
onChange: (value: unknown) => void
}
-function ColumnField({ column, value, timeZone, onChange }: ColumnFieldProps) {
- const checkboxId = useId()
- const title = (
+function ColumnTitle({ column }: { column: ColumnDefinition }) {
+ return (
<>
{column.name}
{column.unique && (
@@ -226,13 +242,63 @@ function ColumnField({ column, value, timeZone, onChange }: ColumnFieldProps) {
)}
>
)
- // Currency names its code — the modal edits the bare amount, so without it
- // there is nothing on screen saying which currency the number is in.
+}
+
+function columnFieldHint(column: ColumnDefinition): string {
const typeLabel =
column.type === 'currency'
? `currency (${resolveCurrencyCode(column.currencyCode)})`
: column.type
- const hint = `Type: ${typeLabel}${column.required ? '' : ' (optional)'}`
+ return `Type: ${typeLabel}${column.required ? '' : ' (optional)'}`
+}
+
+interface TimezoneBlockedColumnFieldProps {
+ column: ColumnDefinition
+ value: unknown
+ status: TimezoneState['status']
+ onAttemptEdit: () => void
+}
+
+function TimezoneBlockedColumnField({
+ column,
+ value,
+ status,
+ onAttemptEdit,
+}: TimezoneBlockedColumnFieldProps) {
+ const rawValue =
+ typeof value === 'string'
+ ? value
+ : value === null || value === undefined
+ ? ''
+ : JSON.stringify(value)
+ const displayValue = status === 'loading' ? 'Loading timezone…' : rawValue || 'No value'
+
+ return (
+ }
+ required={column.required}
+ hint={columnFieldHint(column)}
+ >
+ {(aria) => (
+
+ {displayValue}
+
+ )}
+
+ )
+}
+
+function ColumnField({ column, value, timeZone, onChange }: ColumnFieldProps) {
+ const checkboxId = useId()
+ const title =
+ const hint = columnFieldHint(column)
const definition = columnTypeOf(column)
if (definition.editor === 'toggle') {
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 1511332da6b..bdb533d9773 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
@@ -1,6 +1,7 @@
'use client'
import type { RowExecutionMetadata } from '@/lib/table'
+import type { TimezoneState } from '@/hooks/queries/general-settings'
import type { SaveReason } from '../../../types'
import type { DisplayColumn } from '../types'
import { CellRender, resolveCellRender } from './cell-render'
@@ -14,6 +15,7 @@ interface CellContentProps {
* URL render as a tagged-resource chip instead of a plain external link. */
workspaceId: string
timeZone: string
+ timezoneStatus: TimezoneState['status']
isEditing: boolean
initialCharacter?: string | null
onSave: (value: unknown, reason: SaveReason) => void
@@ -40,6 +42,7 @@ export function CellContent({
column,
workspaceId,
timeZone,
+ timezoneStatus,
isEditing,
initialCharacter,
onSave,
@@ -55,6 +58,7 @@ export function CellContent({
isEnrichmentOutput,
currentWorkspaceId: workspaceId,
timeZone,
+ timezoneStatus,
})
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
index dee543bf4fb..ab0789ff2d4 100644
--- 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
@@ -1,8 +1,13 @@
/**
* @vitest-environment node
*/
+import { createElement } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
-import { resolveCellRender } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render'
+import {
+ CellRender,
+ 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 {
@@ -29,4 +34,46 @@ describe('resolveCellRender', () => {
})
).toEqual({ kind: 'date', text: '2023-11-14T17:13:20-05:00' })
})
+
+ it('renders raw epoch seconds when the saved timezone is invalid', () => {
+ expect(
+ resolveCellRender({
+ value: 1_700_000_000,
+ exec: undefined,
+ column: column('ttl'),
+ waitingOnLabels: undefined,
+ timeZone: 'America/Los_Angeles',
+ timezoneStatus: 'invalid',
+ })
+ ).toEqual({ kind: 'date', text: '1700000000', raw: true })
+ })
+
+ it('renders raw epoch seconds while timezone settings are loading', () => {
+ expect(
+ resolveCellRender({
+ value: 1_700_000_000,
+ exec: undefined,
+ column: column('ttl'),
+ waitingOnLabels: undefined,
+ timeZone: 'America/Los_Angeles',
+ timezoneStatus: 'loading',
+ })
+ ).toEqual({ kind: 'date', text: '1700000000', raw: true })
+ })
+
+ it('renders the exact stored Date value when timezone settings are unavailable', () => {
+ const stored = '2026-01-15T09:00:00-05:00'
+ const kind = resolveCellRender({
+ value: stored,
+ exec: undefined,
+ column: column('date'),
+ waitingOnLabels: undefined,
+ timeZone: 'America/Los_Angeles',
+ timezoneStatus: 'error',
+ })
+ expect(kind).toEqual({ kind: 'date', text: stored, raw: true })
+ expect(renderToStaticMarkup(createElement(CellRender, { kind, isEditing: false }))).toContain(
+ stored
+ )
+ })
})
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 9ebaacf233f..22971e399d0 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
@@ -8,6 +8,7 @@ import { faviconUrl } from '@/lib/core/utils/favicon'
import type { RowExecutionMetadata, SelectOption } from '@/lib/table'
import { columnTypeOf } from '@/lib/table/column-types'
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
+import type { TimezoneState } from '@/hooks/queries/general-settings'
import { storageToDisplay } from '../../../utils'
import { resolveSelectOptions, SelectPill } from '../../select-field'
import type { DisplayColumn } from '../types'
@@ -29,7 +30,7 @@ export type CellRenderKind =
| { kind: 'boolean'; checked: boolean }
| { kind: 'select'; options: SelectOption[] }
| { kind: 'json'; text: string }
- | { kind: 'date'; text: string }
+ | { kind: 'date'; text: string; raw?: boolean }
| { kind: 'url'; text: string; href: string; domain: string }
| {
kind: 'sim-resource'
@@ -55,6 +56,8 @@ interface ResolveCellRenderInput {
currentWorkspaceId?: string
/** Effective viewer timezone for instant-like column presentations. */
timeZone?: string
+ /** Invalid or unavailable preferences render time-based values without conversion. */
+ timezoneStatus?: TimezoneState['status']
}
export function resolveCellRender({
@@ -65,6 +68,7 @@ export function resolveCellRender({
isEnrichmentOutput,
currentWorkspaceId,
timeZone,
+ timezoneStatus,
}: ResolveCellRenderInput): CellRenderKind {
const isNull = value === null || value === undefined
const isEmpty = isNull || value === ''
@@ -142,6 +146,9 @@ export function resolveCellRender({
if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) }
const definition = columnTypeOf(column)
if (definition.editor === 'date') {
+ if (timezoneStatus !== undefined && timezoneStatus !== 'ready') {
+ return { kind: 'date', text: stringifyValue(value), raw: true }
+ }
return { kind: 'date', text: definition.formatForInput(value, column, { timezone: timeZone }) }
}
if (column.type === 'string') {
@@ -396,7 +403,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
case 'date':
return (
- {storageToDisplay(kind.text, { seconds: true })}
+ {kind.raw ? kind.text : storageToDisplay(kind.text, { seconds: true })}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts
index e5b3e8c4264..0439e39fc75 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts
@@ -140,6 +140,42 @@ describe('dateEditorRawValue', () => {
container.remove()
})
+ it('waits for the saved timezone before creating an ordinary date draft', () => {
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'Asia/Tokyo',
+ status: 'loading',
+ })
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const onSave = vi.fn()
+ const props = {
+ value: '2026-06-15T06:00:30-07:00',
+ column: column('date'),
+ onSave,
+ onCancel: vi.fn(),
+ }
+
+ act(() => root.render(createElement(InlineEditor, props)))
+
+ expect(container.querySelector('input')).toBeNull()
+ expect(container.querySelector('[role="status"]')?.textContent).toBe('Loading timezone…')
+
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'America/Los_Angeles',
+ status: 'ready',
+ })
+ act(() => root.render(createElement(InlineEditor, props)))
+
+ const input = container.querySelector('input') as HTMLInputElement
+ act(() => changeInput(input, '09/01/2026 9:00 AM'))
+ act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
+
+ expect(onSave).toHaveBeenCalledWith('2026-09-01T09:00:00-07:00', 'enter')
+ act(() => root.unmount())
+ container.remove()
+ })
+
it('rejects an impossible TTL draft without clearing the cell', () => {
const container = document.createElement('div')
document.body.appendChild(container)
@@ -222,7 +258,40 @@ describe('dateEditorRawValue', () => {
)
expect(onCancel).toHaveBeenCalledOnce()
- expect(mockToastError).toHaveBeenCalledWith('Could not load timezone')
+ expect(mockToastError).toHaveBeenCalledWith(
+ 'We couldn’t load your timezone setting. Try again before editing Date or Expiration cells.'
+ )
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it('rejects editing when the saved timezone is invalid', () => {
+ mockUseTimezoneState.mockReturnValue({
+ timezone: 'America/Los_Angeles',
+ savedTimezone: 'Mars/Olympus',
+ status: 'invalid',
+ })
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const onCancel = vi.fn()
+
+ act(() =>
+ root.render(
+ createElement(InlineEditor, {
+ value: '2026-01-15T09:00:00-05:00',
+ column: column('date'),
+ onSave: vi.fn(),
+ onCancel,
+ })
+ )
+ )
+
+ expect(container.querySelector('input')).toBeNull()
+ expect(onCancel).toHaveBeenCalledOnce()
+ expect(mockToastError).toHaveBeenCalledWith(
+ 'Your saved timezone “Mars/Olympus” is invalid. Update it in Settings → General before editing Date or Expiration cells.'
+ )
act(() => root.unmount())
container.remove()
})
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 b03c3aeb69e..ab5d4b4f912 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
@@ -17,6 +17,7 @@ import { Check } from '@sim/emcn/icons'
import type { ColumnDefinition } from '@/lib/table'
import { columnTypeOf } from '@/lib/table/column-types'
import { isCalendarDateString } from '@/lib/table/dates'
+import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing'
import { useTimezoneState } from '@/hooks/queries/general-settings'
import type { SaveReason } from '../../../types'
import {
@@ -69,20 +70,25 @@ function handleEditorWheel(e: React.WheelEvent) {
* (including seconds), the time field keeps the day — and Enter/blur commits.
*/
function InlineDateEditor(props: InlineEditorProps) {
- const { column, onCancel } = props
+ const { onCancel } = props
const timezoneState = useTimezoneState()
- const ttlTimezoneUnavailable = column.type === 'ttl' && timezoneState.status !== 'ready'
+ const timezoneUnavailable = timezoneState.status !== 'ready'
+ const timezoneBlockedMessage = getTimezoneEditBlockedMessage(timezoneState)
useEffect(() => {
- if (column.type !== 'ttl' || timezoneState.status !== 'error') return
- toast.error('Could not load timezone')
+ if (timezoneState.status !== 'error' && timezoneState.status !== 'invalid') return
+ if (timezoneBlockedMessage) toast.error(timezoneBlockedMessage)
onCancel()
- }, [column.type, onCancel, timezoneState.status])
+ }, [onCancel, timezoneBlockedMessage, timezoneState.status])
- if (ttlTimezoneUnavailable) {
+ if (timezoneUnavailable) {
return (
- {timezoneState.status === 'error' ? 'Timezone unavailable' : 'Loading timezone…'}
+ {timezoneState.status === 'loading'
+ ? 'Loading timezone…'
+ : timezoneState.status === 'invalid'
+ ? 'Invalid timezone'
+ : 'Timezone unavailable'}
)
}
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 abc6828ea77..51b38ab318d 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
@@ -6,6 +6,7 @@ import { PlayOutline, Square } from '@sim/emcn/icons'
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
import type { TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
import { getUnmetGroupDeps } from '@/lib/table/deps'
+import type { TimezoneState } from '@/hooks/queries/general-settings'
import type { SaveReason } from '../../types'
import { CellContent } from './cells'
import {
@@ -28,6 +29,8 @@ export interface DataRowProps {
workspaceId: string
/** Effective viewer timezone used to render TTL instants. */
timeZone: string
+ /** Whether Date and Expiration values can be formatted and edited safely. */
+ timezoneStatus: TimezoneState['status']
rowIndex: number
isFirstRow: boolean
editingColumnName: string | null
@@ -117,6 +120,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
prev.columns !== next.columns ||
prev.workspaceId !== next.workspaceId ||
prev.timeZone !== next.timeZone ||
+ prev.timezoneStatus !== next.timezoneStatus ||
prev.rowIndex !== next.rowIndex ||
prev.isFirstRow !== next.isFirstRow ||
prev.editingColumnName !== next.editingColumnName ||
@@ -165,6 +169,7 @@ export const DataRow = React.memo(function DataRow({
columns,
workspaceId,
timeZone,
+ timezoneStatus,
rowIndex,
isFirstRow,
editingColumnName,
@@ -401,6 +406,7 @@ export const DataRow = React.memo(function DataRow({
+ pasteRow.some((_, offset) => {
+ const column = currentCols[currentAnchor.colIndex + offset]
+ return column ? columnTypeOf(column).editor === 'date' : false
+ })
+ )
+ if (touchesDateEditor) {
+ const message = getTimezoneEditBlockedMessage(timezoneStateRef.current)
+ if (message) {
+ toast.error(message)
+ return
+ }
+ }
+
const undoCells: Array<{ rowId: string; data: Record }> = []
const updateBatch: Array<{ rowId: string; data: Record }> = []
const createBatchRows: Array> = []
@@ -3808,6 +3826,15 @@ export function TableGrid({
const oldValue = row.data[columnName] ?? null
const normalizedValue = value ?? null
const column = columnsRef.current.find((c) => c.key === columnName)
+ if (column && columnTypeOf(column).editor === 'date') {
+ const message = getTimezoneEditBlockedMessage(timezoneStateRef.current)
+ if (message) {
+ toast.error(message)
+ setEditingCell(null)
+ setInitialCharacter(null)
+ return
+ }
+ }
const changed = !cellValuesEqual(oldValue, normalizedValue, column)
if (changed) {
@@ -4918,6 +4945,7 @@ export function TableGrid({
columns={displayColumns}
workspaceId={workspaceId}
timeZone={timeZone}
+ timezoneStatus={timezoneState.status}
rowIndex={index}
isFirstRow={index === 0}
editingColumnName={
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing.ts
new file mode 100644
index 00000000000..bb256a507ee
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing.ts
@@ -0,0 +1,14 @@
+import { sanitizeTimezoneForDisplay } from '@/lib/core/utils/timezone'
+import type { TimezoneState } from '@/hooks/queries/general-settings'
+
+export function getTimezoneEditBlockedMessage(state: TimezoneState): string | null {
+ if (state.status === 'ready') return null
+ if (state.status === 'loading') {
+ return 'Your timezone setting is still loading. Try again in a moment.'
+ }
+ if (state.status === 'error') {
+ return 'We couldn’t load your timezone setting. Try again before editing Date or Expiration cells.'
+ }
+ const savedTimezone = sanitizeTimezoneForDisplay(state.savedTimezone ?? '')
+ return `Your saved timezone “${savedTimezone}” is invalid. Update it in Settings → General before editing Date or Expiration cells.`
+}
diff --git a/apps/sim/background/cleanup-table-row-ttl.test.ts b/apps/sim/background/cleanup-table-row-ttl.test.ts
index d49cf34eae5..b73e0c4748d 100644
--- a/apps/sim/background/cleanup-table-row-ttl.test.ts
+++ b/apps/sim/background/cleanup-table-row-ttl.test.ts
@@ -32,7 +32,10 @@ vi.mock('@sim/db', () => ({
vi.mock('@trigger.dev/sdk', () => ({ task: mockTask }))
vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalTableRowsChanged }))
-vi.mock('@/lib/table/constants', () => ({ getDeleteSnapshotBatchSize: () => 500 }))
+vi.mock('@/lib/table/constants', () => ({
+ getDeleteSnapshotBatchSize: () => 500,
+ TABLE_LIMITS: { DELETE_SNAPSHOT_BATCH_MAX_BYTES: 32 * 1024 * 1024 },
+}))
vi.mock('@/lib/table/service', () => ({ withLockedTable: mockWithLockedTable }))
vi.mock('@/lib/table/ttl-availability', () => ({
isTableRowTtlEnabled: mockIsTableRowTtlEnabled,
@@ -62,6 +65,7 @@ function returnedRows(count: number, start = 1, createdAt = '2026-01-01T00:00:00
return deletedRows(count, start).map((row) => ({
...row,
createdAt,
+ snapshotBytes: 20,
}))
}
@@ -88,14 +92,15 @@ describe('table row TTL cleanup', () => {
...returnedRows(1, 500, '2026-01-01T00:00:00.123456'),
])
.mockResolvedValueOnce(returnedRows(12, 501))
+ .mockResolvedValueOnce([])
await expect(runCleanupTableRowTtl()).resolves.toEqual({
- batches: 2,
+ batches: 3,
deleted: 512,
limitReached: false,
})
- expect(mockWithLockedTable).toHaveBeenCalledTimes(2)
- expect(mockDeleteExecute).toHaveBeenCalledTimes(2)
+ expect(mockWithLockedTable).toHaveBeenCalledTimes(3)
+ expect(mockDeleteExecute).toHaveBeenCalledTimes(3)
const secondQuery = dialect.sqlToQuery(mockDeleteExecute.mock.calls[1][0] as SQL)
expect(secondQuery.sql.replace(/\$\d+/g, '?').replace(/\s+/g, ' ')).toContain(
'AND (table_row.created_at, table_row.id) > (?::timestamp, ?)'
@@ -150,9 +155,13 @@ describe('table row TTL cleanup', () => {
.trim()
expect(query).toContain('AND (table_row.data->>?)::numeric <= ?')
expect(query).toContain('ORDER BY table_row.created_at, table_row.id')
+ expect(query).toContain('octet_length(table_row.data::text) AS snapshot_bytes')
+ expect(query).toContain('cumulative_snapshot_bytes <= ?')
+ expect(query).toContain('OR snapshot_order = 1')
expect(query).toContain(
`to_char(table_row.created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US') AS "createdAt"`
)
+ expect(query).toContain('candidates.snapshot_bytes AS "snapshotBytes"')
expect(query).not.toContain('table_row.created_by')
})
@@ -238,7 +247,7 @@ describe('table row TTL cleanup', () => {
if (tableId === table.id && attempt === 1) {
return returnedRows(500)
}
- if (tableId === secondTable.id) {
+ if (tableId === secondTable.id && attempt === 1) {
return returnedRows(1)
}
return []
@@ -247,15 +256,34 @@ describe('table row TTL cleanup', () => {
})
await expect(runCleanupTableRowTtl()).resolves.toEqual({
- batches: 3,
+ batches: 4,
deleted: 501,
limitReached: false,
})
- expect(attemptedTableIds).toEqual([table.id, secondTable.id, table.id])
+ expect(attemptedTableIds).toEqual([table.id, secondTable.id, table.id, secondTable.id])
expect(mockSignalTableRowsChanged).toHaveBeenCalledWith(table.id)
expect(mockSignalTableRowsChanged).toHaveBeenCalledWith(secondTable.id)
})
+ it('signals tables changed before a later table cleanup failure propagates', async () => {
+ const secondTable = {
+ ...table,
+ id: 'table-2',
+ }
+ mockListExecute.mockResolvedValue([
+ { id: table.id, workspaceId: table.workspaceId },
+ { id: secondTable.id, workspaceId: secondTable.workspaceId },
+ ])
+ mockWithLockedTable.mockImplementation(async (tableId, mutate) => {
+ if (tableId === secondTable.id) throw new Error('second table cleanup failed')
+ return mutate(table, { execute: vi.fn().mockResolvedValue(returnedRows(1)) })
+ })
+
+ await expect(runCleanupTableRowTtl()).rejects.toThrow('second table cleanup failed')
+ expect(mockSignalTableRowsChanged).toHaveBeenCalledTimes(1)
+ expect(mockSignalTableRowsChanged).toHaveBeenCalledWith(table.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 bd343a71970..9b05e26e702 100644
--- a/apps/sim/background/cleanup-table-row-ttl.ts
+++ b/apps/sim/background/cleanup-table-row-ttl.ts
@@ -5,7 +5,7 @@ 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 { getDeleteSnapshotBatchSize } from '@/lib/table/constants'
+import { getDeleteSnapshotBatchSize, TABLE_LIMITS } from '@/lib/table/constants'
import { signalTableRowsChanged } from '@/lib/table/events'
import { assertRowDelete, TableLockedError } from '@/lib/table/mutation-locks'
import type { DbTransaction } from '@/lib/table/planner'
@@ -100,7 +100,12 @@ function parseDeletedBatch(rows: unknown, batchSize: number): DeletedTtlRows {
if (!Array.isArray(rows)) {
throw new Error('Table row TTL cleanup did not return deleted rows')
}
- const deletedRows = rows as Array<{ id?: unknown; data?: unknown; createdAt?: unknown }>
+ const deletedRows = rows as Array<{
+ id?: unknown
+ data?: unknown
+ createdAt?: unknown
+ snapshotBytes?: unknown
+ }>
if (deletedRows.length > batchSize) {
throw new Error('Table row TTL cleanup returned an invalid deleted count')
}
@@ -111,6 +116,17 @@ function parseDeletedBatch(rows: unknown, batchSize: number): DeletedTtlRows {
if (typeof row.createdAt !== 'string') {
throw new Error('Table row TTL cleanup did not return a creation-time cursor')
}
+ const snapshotBytes = Number(row.snapshotBytes)
+ if (!Number.isFinite(snapshotBytes) || snapshotBytes < 0) {
+ throw new Error('Table row TTL cleanup did not return a valid snapshot size')
+ }
+ if (snapshotBytes > TABLE_LIMITS.DELETE_SNAPSHOT_BATCH_MAX_BYTES) {
+ logger.warn('Deleting oversized legacy TTL row in an isolated snapshot batch', {
+ rowId: row.id,
+ snapshotBytes,
+ maxBytes: TABLE_LIMITS.DELETE_SNAPSHOT_BATCH_MAX_BYTES,
+ })
+ }
return {
cursor: { createdAt: row.createdAt, id: row.id },
row: { id: row.id, data: row.data as RowData },
@@ -132,9 +148,14 @@ async function deleteExpiredTableRowBatch(
batchSize: number,
after?: TtlCleanupCursor
): Promise {
- const rows = await trx.execute<{ id: string; data: RowData; createdAt: string }>(sql`
- WITH candidates AS MATERIALIZED (
- SELECT table_row.id
+ const rows = await trx.execute<{
+ id: string
+ data: RowData
+ createdAt: string
+ snapshotBytes: number
+ }>(sql`
+ WITH locked_rows AS MATERIALIZED (
+ SELECT table_row.id, table_row.created_at, octet_length(table_row.data::text) AS snapshot_bytes
FROM ${userTableRows} AS table_row
WHERE table_row.table_id = ${tableId}
AND table_row.workspace_id = ${workspaceId}
@@ -148,6 +169,19 @@ async function deleteExpiredTableRowBatch(
ORDER BY table_row.created_at, table_row.id
LIMIT ${batchSize}
FOR UPDATE OF table_row SKIP LOCKED
+ ), ranked_rows AS (
+ SELECT
+ id,
+ created_at,
+ snapshot_bytes,
+ row_number() OVER (ORDER BY created_at, id) AS snapshot_order,
+ sum(snapshot_bytes) OVER (ORDER BY created_at, id) AS cumulative_snapshot_bytes
+ FROM locked_rows
+ ), candidates AS (
+ SELECT id, snapshot_bytes
+ FROM ranked_rows
+ WHERE cumulative_snapshot_bytes <= ${TABLE_LIMITS.DELETE_SNAPSHOT_BATCH_MAX_BYTES}
+ OR snapshot_order = 1
), deleted AS (
DELETE FROM ${userTableRows} AS table_row
USING candidates
@@ -155,9 +189,10 @@ async function deleteExpiredTableRowBatch(
RETURNING
table_row.id,
table_row.data,
- to_char(table_row.created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US') AS "createdAt"
+ to_char(table_row.created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US') AS "createdAt",
+ candidates.snapshot_bytes AS "snapshotBytes"
)
- SELECT id, data, "createdAt"
+ SELECT id, data, "createdAt", "snapshotBytes"
FROM deleted
ORDER BY "createdAt", id
`)
@@ -246,36 +281,38 @@ export async function runCleanupTableRowTtl(
let deleted = 0
let batches = 0
- 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
+ try {
+ 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,
- batchSize,
- state.after
- )
- if (!batch.attempted) {
- state.complete = true
- continue
- }
+ const batch = await deleteExpiredRowsForTable(
+ state.ref,
+ nowEpochSeconds,
+ batchSize,
+ state.after
+ )
+ if (!batch.attempted) {
+ state.complete = true
+ continue
+ }
- batches++
- deleted += batch.deleted
- state.deleted += batch.deleted
- state.after = batch.cursor ?? undefined
- if (batch.deleted < batchSize) state.complete = true
+ batches++
+ deleted += batch.deleted
+ state.deleted += batch.deleted
+ state.after = batch.cursor ?? undefined
+ if (batch.deleted === 0) state.complete = true
+ }
+ }
+ } finally {
+ for (const state of tableStates) {
+ if (state.deleted > 0) signalTableRowsChanged(state.ref.id)
}
- }
-
- for (const state of tableStates) {
- if (state.deleted > 0) signalTableRowsChanged(state.ref.id)
}
const limitReached =
diff --git a/apps/sim/hooks/queries/general-settings.test.ts b/apps/sim/hooks/queries/general-settings.test.ts
index 528bff952c7..bba242a5390 100644
--- a/apps/sim/hooks/queries/general-settings.test.ts
+++ b/apps/sim/hooks/queries/general-settings.test.ts
@@ -3,8 +3,9 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockGetBrowserTimezone, mockUseQuery } = vi.hoisted(() => ({
+const { mockGetBrowserTimezone, mockIsValidTimezone, mockUseQuery } = vi.hoisted(() => ({
mockGetBrowserTimezone: vi.fn(),
+ mockIsValidTimezone: vi.fn(),
mockUseQuery: vi.fn(),
}))
@@ -13,7 +14,10 @@ vi.mock('@tanstack/react-query', () => ({
useQuery: mockUseQuery,
useQueryClient: vi.fn(),
}))
-vi.mock('@/lib/core/utils/timezone', () => ({ getBrowserTimezone: mockGetBrowserTimezone }))
+vi.mock('@/lib/core/utils/timezone', () => ({
+ getBrowserTimezone: mockGetBrowserTimezone,
+ isValidTimezone: mockIsValidTimezone,
+}))
import { useTimezone, useTimezoneState } from '@/hooks/queries/general-settings'
@@ -21,6 +25,7 @@ describe('useTimezone', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetBrowserTimezone.mockReturnValue('America/Los_Angeles')
+ mockIsValidTimezone.mockReturnValue(true)
})
it('uses the browser timezone while no preference is saved', () => {
@@ -29,6 +34,7 @@ describe('useTimezone', () => {
expect(useTimezone()).toBe('America/Los_Angeles')
expect(useTimezoneState()).toEqual({
timezone: 'America/Los_Angeles',
+ savedTimezone: null,
status: 'ready',
})
})
@@ -37,9 +43,26 @@ describe('useTimezone', () => {
mockUseQuery.mockReturnValue({ data: { timezone: 'Asia/Kathmandu' } })
expect(useTimezone()).toBe('Asia/Kathmandu')
+ expect(useTimezoneState()).toEqual({
+ timezone: 'Asia/Kathmandu',
+ savedTimezone: 'Asia/Kathmandu',
+ status: 'ready',
+ })
expect(mockGetBrowserTimezone).not.toHaveBeenCalled()
})
+ it('uses the browser timezone for display while preserving an invalid preference', () => {
+ mockUseQuery.mockReturnValue({ data: { timezone: 'Not/AZone' } })
+ mockIsValidTimezone.mockReturnValue(false)
+
+ expect(useTimezoneState()).toEqual({
+ timezone: 'America/Los_Angeles',
+ savedTimezone: 'Not/AZone',
+ status: 'invalid',
+ })
+ expect(useTimezone()).toBe('America/Los_Angeles')
+ })
+
it('reads the current setting again after it changes', () => {
let timezone: string | null = 'America/New_York'
mockUseQuery.mockImplementation(() => ({ data: { timezone } }))
@@ -56,6 +79,7 @@ describe('useTimezone', () => {
expect(useTimezoneState()).toEqual({
timezone: 'America/Los_Angeles',
+ savedTimezone: null,
status: 'loading',
})
})
@@ -65,6 +89,7 @@ describe('useTimezone', () => {
expect(useTimezoneState()).toEqual({
timezone: 'America/Los_Angeles',
+ savedTimezone: null,
status: 'error',
})
})
diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts
index b23585307db..57dfa16780e 100644
--- a/apps/sim/hooks/queries/general-settings.ts
+++ b/apps/sim/hooks/queries/general-settings.ts
@@ -9,7 +9,7 @@ import {
updateUserSettingsContract,
} from '@/lib/api/contracts/user'
import { syncThemeToNextThemes } from '@/lib/core/utils/theme'
-import { getBrowserTimezone } from '@/lib/core/utils/timezone'
+import { getBrowserTimezone, isValidTimezone } from '@/lib/core/utils/timezone'
const logger = createLogger('GeneralSettingsQuery')
@@ -144,29 +144,52 @@ export function useBillingUsageNotifications(): boolean {
}
/**
- * The user's effective scheduling timezone: their saved preference, or the
- * browser-detected zone when unset. Use this wherever a task's timezone is
- * captured so scheduling honors the account preference rather than the device.
+ * The user's effective timezone: a valid saved preference, otherwise the browser zone.
+ * Callers that must distinguish Auto from invalid or unavailable settings use
+ * {@link useTimezoneState} instead.
*/
export function useTimezone(): string {
return useTimezoneState().timezone
}
export interface TimezoneState {
+ /** Effective, always-valid timezone used by read-only consumers. */
timezone: string
- status: 'loading' | 'ready' | 'error'
+ /** Raw saved preference, or `null` when the browser timezone is intentional. */
+ savedTimezone: string | null
+ status: 'loading' | 'ready' | 'invalid' | 'error'
}
/**
- * The effective timezone together with whether the saved preference is known.
- * Destructive time-based editors use the status to avoid capturing the browser
- * fallback while the preference request is still in flight.
+ * The effective timezone together with the raw preference's validity. Time-based
+ * editors use the status so only an intentional Auto preference may write with the
+ * browser fallback; loading, invalid, and unavailable preferences remain read-only.
*/
export function useTimezoneState(): TimezoneState {
const { data, isError } = useGeneralSettings()
+ if (!data) {
+ return {
+ timezone: getBrowserTimezone(),
+ savedTimezone: null,
+ status: isError ? 'error' : 'loading',
+ }
+ }
+
+ const savedTimezone = data.timezone
+ if (savedTimezone === null) {
+ return {
+ timezone: getBrowserTimezone(),
+ savedTimezone: null,
+ status: 'ready',
+ }
+ }
+ if (isValidTimezone(savedTimezone)) {
+ return { timezone: savedTimezone, savedTimezone, status: 'ready' }
+ }
return {
- timezone: data?.timezone ?? getBrowserTimezone(),
- status: data ? 'ready' : isError ? 'error' : 'loading',
+ timezone: getBrowserTimezone(),
+ savedTimezone,
+ status: 'invalid',
}
}
diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts
index 5ff9f6cef4d..64ce3f793d4 100644
--- a/apps/sim/lib/core/utils/timezone.test.ts
+++ b/apps/sim/lib/core/utils/timezone.test.ts
@@ -6,12 +6,14 @@ import {
getWallClockParts,
wallClockNow,
zonedClockDate,
+ zonedWallClock,
zonedWallClockToUtc,
zonedWallClockWithOffset,
} from '@/lib/core/utils/timezone'
describe('formatInstantInTimeZone', () => {
it.each([
+ ['UTC', '0050-01-15T12:00:00Z', '0050-01-15T12:00:00Z'],
['UTC', '2026-06-15T00:15:30Z', '2026-06-15T00:15:30Z'],
['America/Los_Angeles', '2026-06-15T00:15:30Z', '2026-06-14T17:15:30-07:00'],
['Asia/Tokyo', '2026-06-15T00:15:30Z', '2026-06-15T09:15:30+09:00'],
@@ -43,6 +45,10 @@ describe('formatInstantInTimeZone', () => {
expect(new Date(editable).getTime()).toBe(instant.getTime())
}
})
+
+ it('preserves a four-digit low year in naive wall-clock output', () => {
+ expect(zonedWallClock(new Date('0050-01-15T12:00:00Z'), 'UTC')).toBe('0050-01-15T12:00')
+ })
})
describe('getWallClockParts', () => {
@@ -56,6 +62,10 @@ describe('getWallClockParts', () => {
second: 30,
})
})
+
+ it('rejects an empty timezone instead of using the runtime local timezone', () => {
+ expect(() => getWallClockParts(new Date('2026-06-15T00:15:30Z'), '')).toThrow(RangeError)
+ })
})
describe('zonedWallClockToUtc', () => {
@@ -65,6 +75,26 @@ describe('zonedWallClockToUtc', () => {
)
})
+ it.each(['0000', '0001', '0050', '0099'])(
+ 'preserves the full year %s when resolving a wall-clock',
+ (year) => {
+ expect(zonedWallClockToUtc(`${year}-01-15T12:00`, 'UTC').toISOString()).toBe(
+ `${year}-01-15T12:00:00.000Z`
+ )
+ }
+ )
+
+ it('uses the requested low year when resolving historical timezone rules', () => {
+ const wallClock = '0050-01-15T12:00:00'
+
+ expect(zonedWallClockToUtc(wallClock, 'America/New_York').toISOString()).toBe(
+ '0050-01-15T16:56:02.000Z'
+ )
+ expect(zonedWallClockWithOffset(wallClock, 'America/New_York')).toBe(
+ '0050-01-15T12:00:00-04:56'
+ )
+ })
+
it('applies a positive (east-of-UTC) offset (Asia/Kolkata, UTC+5:30)', () => {
expect(zonedWallClockToUtc('2026-06-15T09:00', 'Asia/Kolkata').toISOString()).toBe(
'2026-06-15T03:30:00.000Z'
diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts
index 36cbcb98951..e4d2ffea799 100644
--- a/apps/sim/lib/core/utils/timezone.ts
+++ b/apps/sim/lib/core/utils/timezone.ts
@@ -38,6 +38,23 @@ function pad(value: number): string {
return String(value).padStart(2, '0')
}
+/** Formats years 0–9999 using ISO's four-digit representation. */
+export function formatIsoYear(year: number): string {
+ const serialized = String(year)
+ return year >= 0 && year <= 9999 ? serialized.padStart(4, '0') : serialized
+}
+
+/** Builds a UTC timestamp without `Date.UTC` remapping years 0–99 to 1900–1999. */
+function utcTimestamp(wall: WallClockParts): number {
+ if (wall.year < 0 || wall.year > 99) {
+ return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second)
+ }
+ const date = new Date(0)
+ date.setUTCFullYear(wall.year, wall.month - 1, wall.day)
+ date.setUTCHours(wall.hour, wall.minute, wall.second, 0)
+ return date.getTime()
+}
+
/** RFC 3339 offset suffix: `Z` for zero, else `±HH:MM`. */
export function formatUtcOffsetSuffix(offsetMinutes: number): string {
if (offsetMinutes === 0) return 'Z'
@@ -47,14 +64,7 @@ export function formatUtcOffsetSuffix(offsetMinutes: number): string {
}
function offsetMsFromWallClock(instant: Date, wall: WallClockParts): number {
- const wallAsUtc = Date.UTC(
- wall.year,
- wall.month - 1,
- wall.day,
- wall.hour,
- wall.minute,
- wall.second
- )
+ const wallAsUtc = utcTimestamp(wall)
return wallAsUtc - instant.getTime()
}
@@ -73,6 +83,11 @@ export function isValidTimezone(timezone: string): boolean {
}
}
+/** Removes control characters and bounds an untrusted timezone before displaying it. */
+export function sanitizeTimezoneForDisplay(timezone: string, maxLength = 64): string {
+ return truncate(timezone.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' '), maxLength)
+}
+
/**
* Rejects a timezone that is not an IANA name.
*
@@ -89,7 +104,7 @@ export function assertValidTimezone(timezone: string): void {
// Echoed back trimmed and stripped of line breaks: the rejected value came off
// a query string, and a raw one carrying newlines or U+2028/U+2029 would forge
// extra lines in whatever log or error surface renders the message.
- const safe = truncate(timezone.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' '), 64)
+ const safe = sanitizeTimezoneForDisplay(timezone)
throw new Error(`Invalid timezone: ${safe}. Use an IANA name like "America/Los_Angeles".`)
}
}
@@ -155,7 +170,7 @@ export function getTimezoneOptions(): TimezoneOption[] {
* timezone when omitted.
*/
export function getWallClockParts(instant: Date, timeZone?: string): WallClockParts {
- if (!timeZone) {
+ if (timeZone === undefined) {
return {
year: instant.getFullYear(),
month: instant.getMonth() + 1,
@@ -169,6 +184,7 @@ export function getWallClockParts(instant: Date, timeZone?: string): WallClockPa
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
+ era: 'short',
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -177,8 +193,10 @@ export function getWallClockParts(instant: Date, timeZone?: string): WallClockPa
second: '2-digit',
}).formatToParts(instant)
const get = (type: string) => Number(parts.find((part) => part.type === type)?.value)
+ const year = get('year')
+ const era = parts.find((part) => part.type === 'era')?.value
return {
- year: get('year'),
+ year: era === 'BC' ? 1 - year : year,
month: get('month'),
day: get('day'),
hour: get('hour'),
@@ -197,7 +215,7 @@ export function formatInstantInTimeZone(
const wholeSecondInstant = new Date(Math.floor(instant.getTime() / 1000) * 1000)
const exactOffsetMinutes = offsetMsFromWallClock(wholeSecondInstant, wall) / 60_000
const offsetMinutes = roundOffsetMinutes(exactOffsetMinutes, options)
- return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatUtcOffsetSuffix(offsetMinutes)}`
+ return `${formatIsoYear(wall.year)}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatUtcOffsetSuffix(offsetMinutes)}`
}
/**
@@ -207,7 +225,7 @@ export function formatInstantInTimeZone(
*/
export function zonedWallClock(instant: Date, timeZone: string): string {
const wall = getWallClockParts(instant, timeZone)
- return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}`
+ return `${formatIsoYear(wall.year)}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}`
}
/** The current wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` string. */
@@ -261,7 +279,7 @@ function resolveZonedWallClock(
const [datePart, timePart] = wallClock.split('T')
const [year, month, day] = datePart.split('-').map(Number)
const [hour, minute, second = 0] = timePart.split(':').map(Number)
- const utcGuess = Date.UTC(year, month - 1, day, hour, minute, second)
+ const utcGuess = utcTimestamp({ year, month, day, hour, minute, second })
const dayMs = 24 * 60 * 60 * 1000
const offsets = new Set(
[-dayMs, 0, dayMs].map((distance) => timezoneOffsetMs(new Date(utcGuess + distance), timeZone))
diff --git a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts
index e5b89dc7239..a7089a000f4 100644
--- a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts
+++ b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts
@@ -210,6 +210,28 @@ describe('delete trigger dispatch', () => {
)
})
+ it('returns after deleting one row without waiting for trigger dispatch', async () => {
+ dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'row-1', data: { name: 'Ada' } }])
+ let releaseTrigger: (() => void) | undefined
+ const triggerPending = new Promise((resolve) => {
+ releaseTrigger = resolve
+ })
+ mockFireTableTrigger.mockReturnValueOnce(triggerPending)
+ const deletion = deleteRow(TABLE, 'row-1', 'req-delete-one')
+ const onDeleteSettled = vi.fn()
+ void deletion.then(onDeleteSettled)
+
+ await vi.waitFor(() => expect(mockFireTableTrigger).toHaveBeenCalledTimes(1))
+ await Promise.resolve()
+
+ try {
+ expect(onDeleteSettled).toHaveBeenCalledTimes(1)
+ } finally {
+ releaseTrigger?.()
+ await deletion
+ }
+ })
+
it('fires once with every committed snapshot in an ID batch', async () => {
dbChainMockFns.returning.mockResolvedValueOnce([
{ id: 'row-1', data: { name: 'Ada' } },
diff --git a/apps/sim/lib/table/column-types/import-coercion.ts b/apps/sim/lib/table/column-types/import-coercion.ts
index 99a823efbf2..9bc0768c61e 100644
--- a/apps/sim/lib/table/column-types/import-coercion.ts
+++ b/apps/sim/lib/table/column-types/import-coercion.ts
@@ -7,7 +7,7 @@ type ImportValue = Exclude
type ImportCoercer = (value: unknown, options?: NormalizeDateCellOptions) => ImportValue
const IMPORT_COERCERS: Partial> = {
- ttl: (value, options) => parseTtlEpochSeconds(value, options) ?? String(value),
+ ttl: (value, options) => parseTtlEpochSeconds(value, options),
}
/** Applies lightweight type-specific CSV coercion without loading the full column registry. */
diff --git a/apps/sim/lib/table/column-types/ttl.test.ts b/apps/sim/lib/table/column-types/ttl.test.ts
index f0f9c8e91e5..717e96ebb8a 100644
--- a/apps/sim/lib/table/column-types/ttl.test.ts
+++ b/apps/sim/lib/table/column-types/ttl.test.ts
@@ -10,7 +10,7 @@ import {
} from '@/lib/core/utils/timezone'
import { parseTtlEpochSeconds, ttlColumnType } from '@/lib/table/column-types/ttl'
import { retypeCellRewrite } from '@/lib/table/columns/service'
-import type { ColumnDefinition } from '@/lib/table/types'
+import type { ColumnDefinition, JsonValue } from '@/lib/table/types'
const column = (over: Partial): ColumnDefinition =>
({ name: 'col', type: 'string', ...over }) as ColumnDefinition
@@ -22,6 +22,30 @@ describe('TTL column type', () => {
).toEqual({ value: '2023-11-14T22:13:20Z' })
})
+ it('keeps blank and malformed TTL values out of the epoch-zero formatter', () => {
+ const cases: Array<[unknown, string]> = [
+ [null, ''],
+ [undefined, ''],
+ ['', ''],
+ [' ', ' '],
+ [false, 'false'],
+ [[], ''],
+ ]
+ for (const [value, fallback] of cases) {
+ expect(ttlColumnType.formatForDisplay(value, column({ type: 'ttl' }))).toBe(fallback)
+ expect(ttlColumnType.formatForInput(value, column({ type: 'ttl' }))).toBe(fallback)
+ }
+ })
+
+ it('preserves blank and malformed TTL values when converting to a date', () => {
+ const target = column({ type: 'date' })
+ const values: JsonValue[] = [null, '', ' ', false, []]
+
+ for (const value of values) {
+ expect(ttlColumnType.valueForConversion?.(value, target)).toEqual(value)
+ }
+ })
+
it.each([
['UTC', '2026-06-15T09:00:30', '2026-06-15T09:00:30.000Z'],
['America/New_York', '2026-06-15T09:00:30', '2026-06-15T13:00:30.000Z'],
@@ -139,6 +163,18 @@ describe('TTL column type', () => {
}
})
+ it('round-trips a low-year expiration through the editor', () => {
+ const input = '0050-01-15T12:00:00'
+ const seconds = parseTtlEpochSeconds(input, { timezone: 'UTC' })
+
+ expect(seconds).toBe(Date.parse(`${input}Z`) / 1000)
+ const editable = ttlColumnType.formatForInput(seconds, column({ type: 'ttl' }), {
+ timezone: 'UTC',
+ })
+ expect(editable).toBe(`${input}Z`)
+ expect(parseTtlEpochSeconds(editable, { timezone: 'UTC' })).toBe(seconds)
+ })
+
it('keeps the TTL repeated-hour policy separate from ordinary date behavior', () => {
const input = '2026-11-01T01:30'
const timezone = 'America/New_York'
diff --git a/apps/sim/lib/table/column-types/ttl.ts b/apps/sim/lib/table/column-types/ttl.ts
index 00088fa1771..5b04023e05c 100644
--- a/apps/sim/lib/table/column-types/ttl.ts
+++ b/apps/sim/lib/table/column-types/ttl.ts
@@ -68,6 +68,12 @@ export function parseTtlEpochSeconds(
}
function epochSecondsToIso(value: unknown): string | null {
+ if (
+ typeof value !== 'number' &&
+ (typeof value !== 'string' || !NUMERIC_VALUE_PATTERN.test(value.trim()))
+ ) {
+ return null
+ }
const seconds = typeof value === 'number' ? value : Number(value)
if (!isRepresentableEpochSeconds(seconds)) return null
return new Date(seconds * 1000).toISOString().replace('.000Z', 'Z')
@@ -113,10 +119,10 @@ export const ttlColumnType: ColumnTypeDefinition = {
formatForDisplay(value) {
const iso = epochSecondsToIso(value)
- return iso === null ? String(value) : formatDateCellDisplay(iso, { seconds: true })
+ return iso === null ? String(value ?? '') : formatDateCellDisplay(iso, { seconds: true })
},
formatForInput(value, _column, context) {
- return epochSecondsToEditable(value, context?.timezone) ?? String(value)
+ return epochSecondsToEditable(value, context?.timezone) ?? String(value ?? '')
},
}
diff --git a/apps/sim/lib/table/constants.test.ts b/apps/sim/lib/table/constants.test.ts
index 68d0b1c723b..050246dddb2 100644
--- a/apps/sim/lib/table/constants.test.ts
+++ b/apps/sim/lib/table/constants.test.ts
@@ -41,6 +41,7 @@ import {
getBillingDisabledTableLimits,
getDeleteSnapshotBatchSize,
getMaxPageBytes,
+ getMaxRowSizeBytes,
TABLE_LIMITS,
} from '@/lib/table/constants?constants-test'
@@ -88,6 +89,18 @@ describe('getMaxPageBytes', () => {
})
})
+describe('getMaxRowSizeBytes', () => {
+ beforeEach(() => {
+ for (const key of Object.keys(mockEnv)) delete mockEnv[key]
+ })
+
+ it('caps overrides at the delete snapshot byte budget', () => {
+ mockEnv.TABLE_MAX_ROW_SIZE_BYTES = String(TABLE_LIMITS.DELETE_SNAPSHOT_BATCH_MAX_BYTES * 2)
+
+ expect(getMaxRowSizeBytes()).toBe(TABLE_LIMITS.DELETE_SNAPSHOT_BATCH_MAX_BYTES)
+ })
+})
+
describe('getDeleteSnapshotBatchSize', () => {
beforeEach(() => {
for (const key of Object.keys(mockEnv)) delete mockEnv[key]
diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts
index e5c514acd8e..56e1f25c05b 100644
--- a/apps/sim/lib/table/constants.ts
+++ b/apps/sim/lib/table/constants.ts
@@ -40,9 +40,11 @@ export const TABLE_LIMITS = {
/** Batch size for bulk delete operations */
DELETE_BATCH_SIZE: 1000,
/**
- * Maximum serialized row-data bytes returned from one committed delete for
- * post-commit trigger dispatch. The row-count cap is derived from this budget
- * and the configured maximum row size before a DELETE materializes snapshots.
+ * Serialized row-data budget for one committed delete snapshot batch. Batch
+ * deletes measure stored JSONB bytes while holding row locks and stop at this
+ * budget. A historical row already larger than the budget is deleted alone
+ * and logged; current writes cannot create another because row admission is
+ * capped at the same value.
*/
DELETE_SNAPSHOT_BATCH_MAX_BYTES: 32 * 1024 * 1024,
/** Maximum rows per batch insert */
@@ -146,19 +148,24 @@ export function getMaxPageBytes(): number {
/**
* Maximum serialized size in bytes of a single row. Defaults to
* `TABLE_LIMITS.MAX_ROW_SIZE_BYTES`; overridable via the
- * `TABLE_MAX_ROW_SIZE_BYTES` env var (server-only, read at call time).
+ * `TABLE_MAX_ROW_SIZE_BYTES` env var (server-only, read at call time), capped
+ * at the delete snapshot budget so every accepted row fits in one batch.
*/
export function getMaxRowSizeBytes(): number {
- return envNumber(env.TABLE_MAX_ROW_SIZE_BYTES, TABLE_LIMITS.MAX_ROW_SIZE_BYTES, {
- min: 1,
- integer: true,
- })
+ return Math.min(
+ envNumber(env.TABLE_MAX_ROW_SIZE_BYTES, TABLE_LIMITS.MAX_ROW_SIZE_BYTES, {
+ min: 1,
+ integer: true,
+ }),
+ TABLE_LIMITS.DELETE_SNAPSHOT_BATCH_MAX_BYTES
+ )
}
/**
- * Maximum rows one delete may materialize with their JSON data for trigger
- * dispatch. Uses the worst-case configured row size so every batch has an
- * explicit byte bound before PostgreSQL returns it to the app process.
+ * Initial row-count cap for a delete snapshot batch. Delete paths additionally
+ * measure the selected rows as stored and shorten each transaction to the byte
+ * budget; this count avoids scanning more candidate ids than current writes can
+ * possibly fit.
*/
export function getDeleteSnapshotBatchSize(): number {
return Math.max(
diff --git a/apps/sim/lib/table/dates.test.ts b/apps/sim/lib/table/dates.test.ts
index 5126c5d7724..36df539d5c3 100644
--- a/apps/sim/lib/table/dates.test.ts
+++ b/apps/sim/lib/table/dates.test.ts
@@ -82,6 +82,15 @@ describe('normalizeDateCellValue', () => {
)
})
+ it('uses the requested low year when applying IANA timezone rules', () => {
+ const normalized = normalizeDateCellValue('0050-01-15T12:00:00', {
+ timezone: 'America/New_York',
+ })
+
+ expect(normalized).toBe('0050-01-15T12:00:00-04:56')
+ expect(storedDateToEditable(normalized ?? '')).toBe('0050-01-15T12:00:00-04:56')
+ })
+
it('reads localized numeric wall clocks before applying the provided IANA zone', () => {
expect(normalizeDateCellValue('3/8/2026 2:30 AM', { timezone: 'America/New_York' })).toBe(
'2026-03-08T02:30:00-05:00'
diff --git a/apps/sim/lib/table/dates.ts b/apps/sim/lib/table/dates.ts
index 88bfa6c28c0..f112ad2f242 100644
--- a/apps/sim/lib/table/dates.ts
+++ b/apps/sim/lib/table/dates.ts
@@ -24,6 +24,7 @@
*/
import {
+ formatIsoYear,
formatUtcOffsetSuffix,
type ZonedWallClockOptions,
zonedWallClockWithOffset,
@@ -113,11 +114,11 @@ function pad(n: number): string {
}
function toLocalCalendarDate(date: Date): string {
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
+ return `${formatIsoYear(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function toUtcCalendarDate(date: Date): string {
- return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
+ return `${formatIsoYear(date.getUTCFullYear())}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
}
/**
diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts
index 463c18d42de..f8259213a45 100644
--- a/apps/sim/lib/table/import.test.ts
+++ b/apps/sim/lib/table/import.test.ts
@@ -173,13 +173,13 @@ 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', () => {
+ it('coerces TTL imports to epoch seconds and rejects invalid input', () => {
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')
+ expect(coerceValue('not-a-date', 'ttl')).toBeNull()
})
it('applies the timezone supplied to each TTL import independently', () => {
diff --git a/apps/sim/lib/table/orchestration/import.test.ts b/apps/sim/lib/table/orchestration/import.test.ts
index 405613b0de1..dfac5d90e8d 100644
--- a/apps/sim/lib/table/orchestration/import.test.ts
+++ b/apps/sim/lib/table/orchestration/import.test.ts
@@ -271,6 +271,39 @@ describe('performTableCsvImport', () => {
})
})
+ it('counts invalid TTL cells that the import blanks', async () => {
+ const result = await performTableCsvImport(
+ importParams({
+ table: {
+ ...TABLE,
+ schema: {
+ columns: [
+ {
+ id: 'col_expires_at',
+ name: 'expires_at',
+ type: 'ttl',
+ required: false,
+ unique: false,
+ },
+ ],
+ },
+ },
+ fileStream: csvStream('expires_at\n2023-11-14T22:13:20Z\nnot-a-date\n'),
+ })
+ )
+
+ expect(result.success).toBe(true)
+ expect(result.data?.rejections).toEqual({
+ rowsRejected: 0,
+ cellsRejected: 1,
+ rejectedSamples: [],
+ })
+ expect(mockImportAppendRows.mock.calls[0][2]).toEqual([
+ { col_expires_at: 1_700_000_000 },
+ { col_expires_at: null },
+ ])
+ })
+
it('omits the accounting entirely from a clean import', async () => {
const result = await performTableCsvImport(importParams())
diff --git a/apps/sim/lib/table/rows/__tests__/ordering-delete.test.ts b/apps/sim/lib/table/rows/__tests__/ordering-delete.test.ts
index eda638fcb47..c16b325af9d 100644
--- a/apps/sim/lib/table/rows/__tests__/ordering-delete.test.ts
+++ b/apps/sim/lib/table/rows/__tests__/ordering-delete.test.ts
@@ -6,9 +6,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MutationProof } from '@/lib/table/mutation-locks'
import type { DbTransaction } from '@/lib/table/planner'
+const { mockGetDeleteSnapshotBatchSize } = vi.hoisted(() => ({
+ mockGetDeleteSnapshotBatchSize: vi.fn(() => 1),
+}))
+
vi.mock('@/lib/table/constants', () => ({
- getDeleteSnapshotBatchSize: () => 1,
- TABLE_LIMITS: { UPDATE_BATCH_SIZE: 100 },
+ getDeleteSnapshotBatchSize: mockGetDeleteSnapshotBatchSize,
+ TABLE_LIMITS: { DELETE_SNAPSHOT_BATCH_MAX_BYTES: 100, UPDATE_BATCH_SIZE: 100 },
}))
vi.mock('@/lib/table/tx', () => ({ setTableTxTimeouts: vi.fn() }))
@@ -16,6 +20,7 @@ import {
type DeletedRowsHandler,
deleteOrderedRowsByIds,
deletePageByIds,
+ planDeleteSnapshotBatch,
} from '@/lib/table/rows/ordering'
const mockTransaction = databaseMock.db.transaction as ReturnType
@@ -26,6 +31,7 @@ type DeleteRunner = (onDeleted: DeletedRowsHandler) => Promise
describe('ordered row delete trigger handoff', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockGetDeleteSnapshotBatchSize.mockReturnValue(1)
})
it.each([
@@ -55,6 +61,15 @@ describe('ordered row delete trigger handoff', () => {
releaseFirstHandler = resolve
})
const trx = {
+ select: () => ({
+ from: () => ({
+ where: () => ({
+ orderBy: () => ({
+ for: async () => [{ id: `row-${batchIndex + 1}`, snapshotBytes: 20 }],
+ }),
+ }),
+ }),
+ }),
delete: () => ({
where: () => ({
returning: async () => {
@@ -95,4 +110,100 @@ describe('ordered row delete trigger handoff', () => {
])
}
)
+
+ it('splits one count-sized candidate batch at the snapshot byte budget', async () => {
+ mockGetDeleteSnapshotBatchSize.mockReturnValue(3)
+ const snapshots = [
+ [
+ { id: 'row-1', snapshotBytes: 60 },
+ { id: 'row-2', snapshotBytes: 60 },
+ { id: 'row-3', snapshotBytes: 10 },
+ ],
+ [
+ { id: 'row-2', snapshotBytes: 60 },
+ { id: 'row-3', snapshotBytes: 10 },
+ ],
+ ]
+ const deletedBatches = [
+ [{ id: 'row-1', data: { title: 'row-1' } }],
+ [
+ { id: 'row-2', data: { title: 'row-2' } },
+ { id: 'row-3', data: { title: 'row-3' } },
+ ],
+ ]
+ let transactionIndex = 0
+
+ mockTransaction.mockImplementation(
+ async (callback: (transaction: DbTransaction) => Promise) => {
+ const currentIndex = transactionIndex++
+ const trx = {
+ select: () => ({
+ from: () => ({
+ where: () => ({
+ orderBy: () => ({
+ for: async () => snapshots[currentIndex],
+ }),
+ }),
+ }),
+ }),
+ delete: () => ({
+ where: () => ({
+ returning: async () => deletedBatches[currentIndex],
+ }),
+ }),
+ } as unknown as DbTransaction
+ return callback(trx)
+ }
+ )
+ const onDeleted = vi.fn()
+
+ await expect(
+ deleteOrderedRowsByIds({
+ tableId: 'table-1',
+ workspaceId: 'workspace-1',
+ rowIds: ['row-1', 'row-2', 'row-3'],
+ proof,
+ onDeleted,
+ })
+ ).resolves.toEqual(['row-1', 'row-2', 'row-3'])
+
+ expect(mockTransaction).toHaveBeenCalledTimes(2)
+ expect(onDeleted.mock.calls.map(([rows]) => rows)).toEqual(deletedBatches)
+ })
+})
+
+describe('delete snapshot byte planning', () => {
+ it('stops before an existing row would exceed the byte budget', () => {
+ expect(
+ planDeleteSnapshotBatch(
+ ['missing-row', 'row-1', 'row-2'],
+ [
+ { id: 'row-1', snapshotBytes: 60 },
+ { id: 'row-2', snapshotBytes: 60 },
+ ],
+ 100
+ )
+ ).toEqual({
+ rowIds: ['missing-row', 'row-1'],
+ consumedCount: 2,
+ oversizedRow: undefined,
+ })
+ })
+
+ it('isolates an oversized legacy row so no other snapshot joins it', () => {
+ expect(
+ planDeleteSnapshotBatch(
+ ['legacy-row', 'row-2'],
+ [
+ { id: 'legacy-row', snapshotBytes: 150 },
+ { id: 'row-2', snapshotBytes: 10 },
+ ],
+ 100
+ )
+ ).toEqual({
+ rowIds: ['legacy-row'],
+ consumedCount: 1,
+ oversizedRow: { id: 'legacy-row', snapshotBytes: 150 },
+ })
+ })
})
diff --git a/apps/sim/lib/table/rows/ordering.ts b/apps/sim/lib/table/rows/ordering.ts
index eba6c7b2bc4..f20ebee0ef0 100644
--- a/apps/sim/lib/table/rows/ordering.ts
+++ b/apps/sim/lib/table/rows/ordering.ts
@@ -8,6 +8,7 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
import { and, asc, desc, eq, gt, inArray, lt, lte, type SQL, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { getDeleteSnapshotBatchSize, TABLE_LIMITS } from '@/lib/table/constants'
@@ -19,6 +20,8 @@ import { mutateTableRowsWithSecretProvenance } from '@/lib/table/rows/secret-pro
import { setTableTxTimeouts } from '@/lib/table/tx'
import type { RowData, TableDefinition, TableRowSecretProvenanceWrite } from '@/lib/table/types'
+const logger = createLogger('TableRowOrdering')
+
export interface DeletedTableRow {
id: string
data: RowData
@@ -29,6 +32,94 @@ export type DeletedRowsHandler = (
table?: TableDefinition
) => void | Promise
+interface DeleteSnapshotSize {
+ id: string
+ snapshotBytes: number
+}
+
+interface DeleteSnapshotBatchPlan {
+ rowIds: string[]
+ consumedCount: number
+ oversizedRow?: DeleteSnapshotSize
+}
+
+/**
+ * Selects the largest input-order prefix whose existing rows fit the snapshot
+ * byte budget. Missing ids are consumed without cost. A legacy row that already
+ * exceeds the budget is isolated as the only existing row in its transaction so
+ * deleting historical data remains possible without combining it with another
+ * snapshot.
+ */
+export function planDeleteSnapshotBatch(
+ candidateRowIds: readonly string[],
+ snapshotSizes: readonly DeleteSnapshotSize[],
+ maxBytes = TABLE_LIMITS.DELETE_SNAPSHOT_BATCH_MAX_BYTES
+): DeleteSnapshotBatchPlan {
+ const bytesById = new Map(snapshotSizes.map((row) => [row.id, row.snapshotBytes]))
+ let consumedCount = 0
+ let batchBytes = 0
+ let existingRows = 0
+ let oversizedRow: DeleteSnapshotSize | undefined
+
+ for (const id of candidateRowIds) {
+ const measuredBytes = bytesById.get(id)
+ if (measuredBytes === undefined) {
+ consumedCount++
+ continue
+ }
+ const snapshotBytes =
+ Number.isFinite(measuredBytes) && measuredBytes >= 0 ? measuredBytes : maxBytes + 1
+ if (existingRows > 0 && batchBytes + snapshotBytes > maxBytes) break
+
+ consumedCount++
+ existingRows++
+ batchBytes += snapshotBytes
+ if (snapshotBytes > maxBytes) {
+ oversizedRow = { id, snapshotBytes }
+ break
+ }
+ }
+
+ return {
+ rowIds: candidateRowIds.slice(0, consumedCount),
+ consumedCount,
+ oversizedRow,
+ }
+}
+
+async function planLockedDeleteSnapshotBatch(
+ trx: DbTransaction,
+ tableId: string,
+ workspaceId: string,
+ candidateRowIds: readonly string[]
+): Promise {
+ const snapshotSizes = await trx
+ .select({
+ id: userTableRows.id,
+ snapshotBytes: sql`octet_length(${userTableRows.data}::text)`.mapWith(Number),
+ })
+ .from(userTableRows)
+ .where(
+ and(
+ eq(userTableRows.tableId, tableId),
+ eq(userTableRows.workspaceId, workspaceId),
+ inArray(userTableRows.id, [...candidateRowIds])
+ )
+ )
+ .orderBy(asc(userTableRows.id))
+ .for('update')
+ return planDeleteSnapshotBatch(candidateRowIds, snapshotSizes)
+}
+
+function warnForOversizedLegacySnapshot(oversizedRow: DeleteSnapshotSize | undefined): void {
+ if (!oversizedRow) return
+ logger.warn('Deleting oversized legacy row in an isolated snapshot batch', {
+ rowId: oversizedRow.id,
+ snapshotBytes: oversizedRow.snapshotBytes,
+ maxBytes: TABLE_LIMITS.DELETE_SNAPSHOT_BATCH_MAX_BYTES,
+ })
+}
+
/**
* Starting `position` for an append import — `max(position) + 1`, or 0 when empty. Read once,
* unlocked, before streaming: the import worker is the table's sole writer, so it can assign
@@ -295,7 +386,7 @@ export async function deleteOrderedRow(params: {
proof: MutationProof<'delete'>
}): Promise {
const { tableId, rowId, workspaceId } = params
- return db.transaction(async (trx) => {
+ const deletedRow = await db.transaction(async (trx) => {
await setTableTxTimeouts(trx)
const [deleted] = await trx
.delete(userTableRows)
@@ -309,6 +400,15 @@ export async function deleteOrderedRow(params: {
.returning({ id: userTableRows.id, data: userTableRows.data })
return deleted ? { id: deleted.id, data: deleted.data as RowData } : null
})
+ if (deletedRow) {
+ const snapshotBytes = Buffer.byteLength(JSON.stringify(deletedRow.data), 'utf8')
+ warnForOversizedLegacySnapshot(
+ snapshotBytes > TABLE_LIMITS.DELETE_SNAPSHOT_BATCH_MAX_BYTES
+ ? { id: deletedRow.id, snapshotBytes }
+ : undefined
+ )
+ }
+ return deletedRow
}
/**
@@ -332,21 +432,26 @@ export async function deleteOrderedRowsByIds(params: {
if (rowIds.length === 0) return []
const batchSize = getDeleteSnapshotBatchSize()
const deletedIds: string[] = []
- for (let i = 0; i < rowIds.length; i += batchSize) {
- const batch = rowIds.slice(i, i + batchSize)
- const rows = await db.transaction(async (trx) => {
+ let index = 0
+ while (index < rowIds.length) {
+ const candidates = rowIds.slice(index, index + batchSize)
+ const { rows, plan } = await db.transaction(async (trx) => {
await setTableTxTimeouts(trx, { statementMs: 60_000 })
- return trx
+ const plan = await planLockedDeleteSnapshotBatch(trx, tableId, workspaceId, candidates)
+ const rows = await trx
.delete(userTableRows)
.where(
and(
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, workspaceId),
- inArray(userTableRows.id, batch)
+ inArray(userTableRows.id, plan.rowIds)
)
)
.returning({ id: userTableRows.id, data: userTableRows.data })
+ return { rows, plan }
})
+ index += plan.consumedCount
+ warnForOversizedLegacySnapshot(plan.oversizedRow)
const deletedRows = rows.map((row) => ({ id: row.id, data: row.data as RowData }))
deletedIds.push(...deletedRows.map((row) => row.id))
await onDeleted?.(deletedRows)
@@ -490,23 +595,27 @@ export async function deletePageByIds(
): Promise {
let deleted = 0
const batchSize = getDeleteSnapshotBatchSize()
- for (let i = 0; i < rowIds.length; i += batchSize) {
- const batch = rowIds.slice(i, i + batchSize)
- const { rows, table } = await db.transaction(async (trx) => {
+ let index = 0
+ while (index < rowIds.length) {
+ const candidates = rowIds.slice(index, index + batchSize)
+ const { rows, table, plan } = await db.transaction(async (trx) => {
await setTableTxTimeouts(trx, { statementMs: 60_000 })
const table = await guardBatch(trx, tableId, revalidate)
+ const plan = await planLockedDeleteSnapshotBatch(trx, tableId, workspaceId, candidates)
const rows = await trx
.delete(userTableRows)
.where(
and(
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, workspaceId),
- inArray(userTableRows.id, batch)
+ inArray(userTableRows.id, plan.rowIds)
)
)
.returning({ id: userTableRows.id, data: userTableRows.data })
- return { rows, table }
+ return { rows, table, plan }
})
+ index += plan.consumedCount
+ warnForOversizedLegacySnapshot(plan.oversizedRow)
const deletedRows = rows.map((row) => ({ id: row.id, data: row.data as RowData }))
deleted += deletedRows.length
await onDeleted?.(deletedRows, table)
diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts
index d322382ea9c..f036f1efce0 100644
--- a/apps/sim/lib/table/rows/service.ts
+++ b/apps/sim/lib/table/rows/service.ts
@@ -1918,7 +1918,7 @@ export async function deleteRow(
if (!deleted) throw new OrchestrationError('not_found', 'Row not found')
logger.info(`[${requestId}] Deleted row ${rowId} from table ${table.id}`)
- await dispatchDeleteTriggers(table, [deleted], requestId)
+ void dispatchDeleteTriggers(table, [deleted], requestId)
}
type BulkUpdateMatch = { id: string; data: RowData }