From 53ea8f29d40928ddb5d5e917c7b6c0fabff3679d Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sun, 30 Aug 2026 14:13:19 -0700
Subject: [PATCH 01/11] fix(timezone): preserve low-year wall clocks
Avoid Date.UTC's 1900 remapping and retain four-digit years through date and TTL editing.
---
apps/sim/lib/core/utils/timezone.test.ts | 26 +++++++++++++++
apps/sim/lib/core/utils/timezone.ts | 37 ++++++++++++++-------
apps/sim/lib/table/column-types/ttl.test.ts | 12 +++++++
apps/sim/lib/table/dates.test.ts | 9 +++++
apps/sim/lib/table/dates.ts | 5 +--
5 files changed, 75 insertions(+), 14 deletions(-)
diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts
index 5ff9f6cef4d..66a438e4c65 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', () => {
@@ -65,6 +71,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..7f416d852f7 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()
}
@@ -169,6 +179,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 +188,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 +210,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 +220,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 +274,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/column-types/ttl.test.ts b/apps/sim/lib/table/column-types/ttl.test.ts
index f0f9c8e91e5..c42d7bf8bc2 100644
--- a/apps/sim/lib/table/column-types/ttl.test.ts
+++ b/apps/sim/lib/table/column-types/ttl.test.ts
@@ -139,6 +139,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/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())}`
}
/**
From feabe4db6b6280bf3463e9eb0343d0d0db927400 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sun, 30 Aug 2026 14:18:35 -0700
Subject: [PATCH 02/11] fix(timezone): fall back from invalid saved zones
---
.../hooks/queries/general-settings.test.ts | 19 +++++++++++++++++--
apps/sim/hooks/queries/general-settings.ts | 6 ++++--
2 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/apps/sim/hooks/queries/general-settings.test.ts b/apps/sim/hooks/queries/general-settings.test.ts
index 528bff952c7..88bb2c81095 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', () => {
@@ -40,6 +45,16 @@ describe('useTimezone', () => {
expect(mockGetBrowserTimezone).not.toHaveBeenCalled()
})
+ it('uses the browser timezone when the saved preference is invalid', () => {
+ mockUseQuery.mockReturnValue({ data: { timezone: 'Not/AZone' } })
+ mockIsValidTimezone.mockReturnValue(false)
+
+ expect(useTimezoneState()).toEqual({
+ timezone: 'America/Los_Angeles',
+ status: 'ready',
+ })
+ })
+
it('reads the current setting again after it changes', () => {
let timezone: string | null = 'America/New_York'
mockUseQuery.mockImplementation(() => ({ data: { timezone } }))
diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts
index b23585307db..d256143e5b8 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')
@@ -164,8 +164,10 @@ export interface TimezoneState {
*/
export function useTimezoneState(): TimezoneState {
const { data, isError } = useGeneralSettings()
+ const savedTimezone = data?.timezone
return {
- timezone: data?.timezone ?? getBrowserTimezone(),
+ timezone:
+ savedTimezone && isValidTimezone(savedTimezone) ? savedTimezone : getBrowserTimezone(),
status: data ? 'ready' : isError ? 'error' : 'loading',
}
}
From 50ea9de3f42756467b410e53d704ad3dcf6bbb96 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sun, 30 Aug 2026 14:22:03 -0700
Subject: [PATCH 03/11] fix(tables): report rejected TTL imports
---
.../lib/table/column-types/import-coercion.ts | 2 +-
apps/sim/lib/table/import.test.ts | 4 +--
.../lib/table/orchestration/import.test.ts | 33 +++++++++++++++++++
3 files changed, 36 insertions(+), 3 deletions(-)
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/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())
From f524db4c0756213deaba11989b8e5ee625a12996 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sun, 30 Aug 2026 14:24:12 -0700
Subject: [PATCH 04/11] fix(timezone): reject empty zone identifiers
---
apps/sim/lib/core/utils/timezone.test.ts | 4 ++++
apps/sim/lib/core/utils/timezone.ts | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts
index 66a438e4c65..64ce3f793d4 100644
--- a/apps/sim/lib/core/utils/timezone.test.ts
+++ b/apps/sim/lib/core/utils/timezone.test.ts
@@ -62,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', () => {
diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts
index 7f416d852f7..e6319cd59dd 100644
--- a/apps/sim/lib/core/utils/timezone.ts
+++ b/apps/sim/lib/core/utils/timezone.ts
@@ -165,7 +165,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,
From b7e563580f2d4bac9f728eeaa5818c635a8b2881 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sun, 30 Aug 2026 14:28:25 -0700
Subject: [PATCH 05/11] fix(tables): dispatch row delete triggers
asynchronously
---
.../service-filter-threading.test.ts | 22 +++++++++++++++++++
apps/sim/lib/table/rows/service.ts | 2 +-
2 files changed, 23 insertions(+), 1 deletion(-)
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/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 }
From 0d1686e31fe472f69b343bf66ea819141c747650 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sun, 30 Aug 2026 15:24:34 -0700
Subject: [PATCH 06/11] fix(tables): cap rows to delete snapshot budget
---
apps/sim/lib/table/constants.test.ts | 13 +++++++++++++
apps/sim/lib/table/constants.ts | 14 +++++++++-----
2 files changed, 22 insertions(+), 5 deletions(-)
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..c622cbbb9f4 100644
--- a/apps/sim/lib/table/constants.ts
+++ b/apps/sim/lib/table/constants.ts
@@ -146,13 +146,17 @@ 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
+ )
}
/**
From 2300445d28fc0a9965a52a1378f606fe4e4e7849 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sun, 30 Aug 2026 15:33:01 -0700
Subject: [PATCH 07/11] fix(tables): signal partial TTL cleanup changes
---
.../background/cleanup-table-row-ttl.test.ts | 19 +++++++
apps/sim/background/cleanup-table-row-ttl.ts | 56 ++++++++++---------
2 files changed, 48 insertions(+), 27 deletions(-)
diff --git a/apps/sim/background/cleanup-table-row-ttl.test.ts b/apps/sim/background/cleanup-table-row-ttl.test.ts
index d49cf34eae5..c26a5b664c3 100644
--- a/apps/sim/background/cleanup-table-row-ttl.test.ts
+++ b/apps/sim/background/cleanup-table-row-ttl.test.ts
@@ -256,6 +256,25 @@ describe('table row TTL cleanup', () => {
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..c0b201cfc5e 100644
--- a/apps/sim/background/cleanup-table-row-ttl.ts
+++ b/apps/sim/background/cleanup-table-row-ttl.ts
@@ -246,36 +246,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 < batchSize) 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 =
From d1bae6943d048bf29b92e35b8f67fe0c75330b49 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sun, 30 Aug 2026 15:37:45 -0700
Subject: [PATCH 08/11] fix(tables): wait for timezone before date edits
---
.../table-grid/cells/inline-editors.test.ts | 36 +++++++++++++++++++
.../table-grid/cells/inline-editors.tsx | 10 +++---
2 files changed, 41 insertions(+), 5 deletions(-)
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..7ca9f9d1d65 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)
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..2eff16abb18 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
@@ -69,17 +69,17 @@ 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'
useEffect(() => {
- if (column.type !== 'ttl' || timezoneState.status !== 'error') return
+ if (timezoneState.status !== 'error') return
toast.error('Could not load timezone')
onCancel()
- }, [column.type, onCancel, timezoneState.status])
+ }, [onCancel, timezoneState.status])
- if (ttlTimezoneUnavailable) {
+ if (timezoneUnavailable) {
return (
{timezoneState.status === 'error' ? 'Timezone unavailable' : 'Loading timezone…'}
From 66766bc66de58a8f139cd56817c37722dacb1589 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sun, 30 Aug 2026 15:43:58 -0700
Subject: [PATCH 09/11] fix(tables): preserve blank TTL values
---
apps/sim/lib/table/column-types/ttl.test.ts | 26 ++++++++++++++++++++-
apps/sim/lib/table/column-types/ttl.ts | 10 ++++++--
2 files changed, 33 insertions(+), 3 deletions(-)
diff --git a/apps/sim/lib/table/column-types/ttl.test.ts b/apps/sim/lib/table/column-types/ttl.test.ts
index c42d7bf8bc2..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'],
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 ?? '')
},
}
From 5551be8fa315207eea5ae6c9879a208d9c5b5757 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Sun, 30 Aug 2026 19:29:15 -0700
Subject: [PATCH 10/11] fix(tables): guard date edits against invalid timezones
---
.../settings/components/general/general.tsx | 23 +++++-
.../general/timezone-picker.test.ts | 52 ++++++++++++
.../components/general/timezone-picker.ts | 45 +++++++++++
.../components/row-modal/row-modal.test.tsx | 80 +++++++++++++++++--
.../components/row-modal/row-modal.tsx | 36 +++++++--
.../table-grid/cells/cell-content.tsx | 4 +
.../table-grid/cells/cell-render.test.ts | 36 ++++++++-
.../table-grid/cells/cell-render.tsx | 11 ++-
.../table-grid/cells/inline-editors.test.ts | 35 +++++++-
.../table-grid/cells/inline-editors.tsx | 14 +++-
.../components/table-grid/data-row.tsx | 6 ++
.../components/table-grid/table-grid.tsx | 32 +++++++-
.../[tableId]/components/timezone-editing.ts | 14 ++++
.../hooks/queries/general-settings.test.ts | 14 +++-
apps/sim/hooks/queries/general-settings.ts | 43 +++++++---
apps/sim/lib/core/utils/timezone.ts | 7 +-
16 files changed, 411 insertions(+), 41 deletions(-)
create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.test.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing.ts
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..d64658e74f8
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.test.ts
@@ -0,0 +1,52 @@
+/**
+ * @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('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..ef0dbbb761d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.ts
@@ -0,0 +1,45 @@
+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 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,
+ },
+ ]
+ : []),
+ ...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..773a6600639 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' }),
@@ -64,6 +66,7 @@ vi.mock('@sim/emcn', () => {
onChange(event.currentTarget.value),
}),
Label: passthrough,
+ toast: { error: mockToastError },
}
})
@@ -145,4 +148,69 @@ 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('[role="status"]')?.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)))
+
+ expect(container.querySelector('[role="status"]')?.textContent).toBe('Invalid timezone')
+ expect(container.querySelector('[data-testid="submit"]')?.disabled).toBe(
+ true
+ )
+ 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/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx
index e139b38b849..74229d5c9f2 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
@@ -1,6 +1,6 @@
'use client'
-import { useId, useRef, useState } from 'react'
+import { useEffect, useId, useRef, useState } from 'react'
import {
Checkbox,
ChipConfirmModal,
@@ -13,6 +13,7 @@ import {
ChipModalHeader,
ChipTimePicker,
Label,
+ toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
@@ -20,6 +21,7 @@ import { useParams } from 'next/navigation'
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
import { columnTypeOf } from '@/lib/table/column-types'
import { resolveCurrencyCode } from '@/lib/table/currency'
+import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing'
import { useTimezoneState } from '@/hooks/queries/general-settings'
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
import {
@@ -83,8 +85,9 @@ 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 hasDateEditorColumn =
+ mode === 'edit' && columns.some((column) => columnTypeOf(column).editor === 'date')
+ const timezoneUnavailable = hasDateEditorColumn && editTimeZoneRef.current === null
const timeZone = editTimeZoneRef.current ?? timezoneState.timezone
const [rowData, setRowData] = useState>(() =>
mode === 'edit' && row ? row.data : {}
@@ -96,10 +99,23 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
const isSubmitting =
updateRowMutation.isPending || deleteRowMutation.isPending || deleteRowsMutation.isPending
+ const timezoneBlockedMessage = getTimezoneEditBlockedMessage(timezoneState)
+ useEffect(() => {
+ if (
+ !isOpen ||
+ !timezoneUnavailable ||
+ (timezoneState.status !== 'invalid' && timezoneState.status !== 'error') ||
+ !timezoneBlockedMessage
+ ) {
+ return
+ }
+ toast.error(timezoneBlockedMessage)
+ }, [isOpen, timezoneBlockedMessage, timezoneState.status, timezoneUnavailable])
+
const handleFormSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
setError(null)
- if (ttlTimezoneUnavailable) return
+ if (timezoneUnavailable) return
try {
const cleanData = cleanRowData(columns, rowData, timeZone)
@@ -177,10 +193,14 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
Update values for {table?.name ?? 'table'}