Skip to content

Commit 98d4558

Browse files
committed
fix(tables): prevent early TTL expiration
1 parent 1b39fa7 commit 98d4558

15 files changed

Lines changed: 512 additions & 62 deletions

File tree

apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,23 @@ describe('table row TTL cleanup route', () => {
7070
expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId)
7171
})
7272

73+
it('uses a new id immediately after the next fifteen-minute window begins', async () => {
74+
const request = () =>
75+
createMockRequest(
76+
'GET',
77+
undefined,
78+
{},
79+
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
80+
)
81+
82+
vi.setSystemTime(new Date('2026-08-22T17:14:59.999Z'))
83+
await GET(request())
84+
vi.setSystemTime(new Date('2026-08-22T17:15:00.000Z'))
85+
await GET(request())
86+
87+
expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).not.toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId)
88+
})
89+
7390
it('returns the cron auth refusal without touching the queue', async () => {
7491
mockVerifyCronAuth.mockReturnValue(new Response(null, { status: 401 }))
7592

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, createElement, type ReactNode } from 'react'
5+
import { createRoot } from 'react-dom/client'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { ColumnDefinition } from '@/lib/table'
8+
import {
9+
dateEditorRawValue,
10+
InlineEditor,
11+
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors'
12+
import { cleanCellValue } from '@/app/workspace/[workspaceId]/tables/[tableId]/utils'
13+
14+
const { mockUseTimezone } = vi.hoisted(() => ({ mockUseTimezone: vi.fn() }))
15+
16+
vi.mock('@/hooks/queries/general-settings', () => ({ useTimezone: mockUseTimezone }))
17+
vi.mock('@sim/emcn', () => {
18+
const passthrough = ({ children }: { children?: ReactNode }) => children ?? null
19+
return {
20+
Calendar: () => null,
21+
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
22+
DropdownMenu: passthrough,
23+
DropdownMenuContent: passthrough,
24+
DropdownMenuItem: passthrough,
25+
DropdownMenuTrigger: passthrough,
26+
Popover: passthrough,
27+
PopoverAnchor: () => null,
28+
PopoverContent: passthrough,
29+
toast: { error: vi.fn() },
30+
}
31+
})
32+
const column = (type: ColumnDefinition['type']): ColumnDefinition => ({ name: 'expires_at', type })
33+
34+
describe('dateEditorRawValue', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks()
37+
mockUseTimezone.mockReturnValue('America/Los_Angeles')
38+
})
39+
40+
it('leaves TTL drafts for TTL coercion to resolve safely', () => {
41+
const ttlColumn = column('ttl')
42+
const timezone = 'America/New_York'
43+
const repeatedWallClock = '11/01/2026 1:30:00 AM'
44+
45+
const repeatedRaw = dateEditorRawValue(repeatedWallClock, ttlColumn, timezone)
46+
expect(repeatedRaw).toBe(repeatedWallClock)
47+
expect(cleanCellValue(repeatedRaw, ttlColumn, timezone)).toBe(
48+
Date.parse('2026-11-01T06:30:00Z') / 1000
49+
)
50+
51+
const fractionalRaw = dateEditorRawValue('2023-11-14t22:13:20.001Z', ttlColumn, timezone)
52+
expect(cleanCellValue(fractionalRaw, ttlColumn, timezone)).toBe(1_700_000_001)
53+
})
54+
55+
it('keeps ordinary date drafts on their existing display parser', () => {
56+
expect(dateEditorRawValue('11/01/2026 1:30:00 AM', column('date'), 'America/New_York')).toBe(
57+
'2026-11-01T01:30:00-04:00'
58+
)
59+
})
60+
61+
it('keeps an open TTL edit in its starting timezone when the setting changes', () => {
62+
const container = document.createElement('div')
63+
document.body.appendChild(container)
64+
const root = createRoot(container)
65+
const onSave = vi.fn()
66+
const value = Date.parse('2026-06-15T13:00:30Z') / 1000
67+
const props = {
68+
value,
69+
column: column('ttl'),
70+
onSave,
71+
onCancel: vi.fn(),
72+
}
73+
74+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
75+
act(() => root.render(createElement(InlineEditor, props)))
76+
mockUseTimezone.mockReturnValue('America/New_York')
77+
act(() => root.render(createElement(InlineEditor, props)))
78+
79+
const input = container.querySelector('input')
80+
expect(input?.value).toBe('06/15/2026 6:00:30 AM')
81+
act(() => {
82+
input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
83+
})
84+
85+
expect(onSave).toHaveBeenCalledWith(value, 'enter')
86+
act(() => root.unmount())
87+
container.remove()
88+
})
89+
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ interface InlineEditorProps {
3737
onCancel: () => void
3838
}
3939

40+
/**
41+
* Produces the raw draft that the column type will coerce on save. Ordinary
42+
* date columns keep their display parser for partial dates; other date-editor
43+
* types receive the untouched draft so their own safety rules are not erased.
44+
*/
45+
export function dateEditorRawValue(
46+
draft: string,
47+
column: ColumnDefinition,
48+
timeZone: string,
49+
storageValue?: string
50+
): string {
51+
if (storageValue !== undefined) return storageValue
52+
return column.type === 'date' ? (displayToStorage(draft, timeZone) ?? draft) : draft
53+
}
54+
4055
/** Redirect wheel gestures over an inline editor to the surrounding table scroll container. */
4156
function handleEditorWheel(e: React.WheelEvent<HTMLInputElement>) {
4257
e.preventDefault()
@@ -68,7 +83,10 @@ function InlineDateEditor({
6883
* and refocuses while a popover interaction is in flight (covers browsers
6984
* where buttons don't take focus on click). */
7085
const popoverPointerAtRef = useRef(0)
71-
const timeZone = useTimezone()
86+
const effectiveTimeZone = useTimezone()
87+
/** Keep one wall-clock interpretation for the lifetime of this edit. */
88+
const editTimeZoneRef = useRef(effectiveTimeZone)
89+
const timeZone = editTimeZoneRef.current
7290

7391
const storedValue = formatValueForInput(value, column.type, timeZone)
7492
const initialDraft =
@@ -118,7 +136,7 @@ function InlineDateEditor({
118136
onSave(storedValue ? cleanCellValue(storedValue, column, timeZone) : null, reason)
119137
return
120138
}
121-
const raw = storageVal ?? displayToStorage(current, timeZone) ?? current
139+
const raw = dateEditorRawValue(current, column, timeZone, storageVal)
122140
if (raw && Number.isNaN(Date.parse(raw))) {
123141
if (reason === 'blur') {
124142
if (!invalid) toast.error('Invalid date')

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,4 +206,19 @@ describe('formatValueForInput', () => {
206206
cleanCellValue('2023-11-14', { name: 'expires_at', type: 'ttl' }, 'America/New_York')
207207
).toBe(1_699_938_000)
208208
})
209+
210+
it('uses the latest effective timezone for each TTL edit', () => {
211+
const column = { name: 'expires_at', type: 'ttl' } as const
212+
const input = '2026-06-15 09:00:30'
213+
214+
expect(cleanCellValue(input, column, 'America/New_York')).toBe(
215+
Date.parse('2026-06-15T13:00:30Z') / 1000
216+
)
217+
expect(cleanCellValue(input, column, 'Asia/Kathmandu')).toBe(
218+
Date.parse('2026-06-15T03:15:30Z') / 1000
219+
)
220+
expect(cleanCellValue(input, column, 'America/New_York')).toBe(
221+
Date.parse('2026-06-15T13:00:30Z') / 1000
222+
)
223+
})
209224
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts

Lines changed: 3 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -147,46 +147,12 @@ export function storageToDisplay(stored: string, options?: { seconds?: boolean }
147147
*/
148148
export function displayToStorage(display: string, timeZone?: string): string | null {
149149
const trimmed = display.trim()
150-
const withTime = trimmed.match(
151-
/^(\d{1,2})\/(\d{1,2})\/(\d{4})[ ,]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i
152-
)
153-
if (withTime) {
154-
const [, m, d, y, h, min, sec, meridiem] = withTime
155-
let hours = Number(h)
156-
if (meridiem) {
157-
if (hours < 1 || hours > 12) return null
158-
hours = (hours % 12) + (meridiem.toUpperCase() === 'PM' ? 12 : 0)
159-
} else if (hours > 23) {
160-
return null
161-
}
162-
if (Number(min) > 59 || Number(sec ?? 0) > 59) return null
163-
if (!isValidCalendarDay(Number(y), Number(m), Number(d))) return null
164-
const pad = (n: string) => n.padStart(2, '0')
165-
// Route through the shared normalizer so the wall time resolves in the
166-
// effective zone.
167-
return normalizeDateCellValue(
168-
`${y}-${pad(m)}-${pad(d)}T${String(hours).padStart(2, '0')}:${min}:${sec ?? '00'}`,
169-
{ timezone: timeZone }
170-
)
171-
}
172-
const full = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
173-
if (full) {
174-
if (!isValidCalendarDay(Number(full[3]), Number(full[1]), Number(full[2]))) return null
175-
return `${full[3]}-${full[1].padStart(2, '0')}-${full[2].padStart(2, '0')}`
176-
}
177150
const partial = trimmed.match(/^(\d{1,2})\/(\d{1,2})$/)
178151
if (partial) {
179152
const year = Number(todayLocalCalendarDate(timeZone).slice(0, 4))
180-
if (!isValidCalendarDay(year, Number(partial[1]), Number(partial[2]))) return null
181-
return `${year}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}`
153+
return normalizeDateCellValue(
154+
`${year}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}`
155+
)
182156
}
183157
return normalizeDateCellValue(trimmed, { timezone: timeZone })
184158
}
185-
186-
/** True when Y/M/D is a real calendar day — `Date` rolls impossible days over
187-
* (02/30 → 03/02) instead of rejecting them, so compare the round-trip. */
188-
function isValidCalendarDay(year: number, month: number, day: number): boolean {
189-
if (month < 1 || month > 12 || day < 1 || day > 31) return false
190-
const check = new Date(year, month - 1, day)
191-
return check.getMonth() === month - 1 && check.getDate() === day
192-
}

apps/sim/background/cleanup-table-row-ttl.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ describe('table row TTL cleanup', () => {
6565
})
6666

6767
it('compares TTL values with whole Date.now epoch seconds', async () => {
68-
const nowEpochMilliseconds = 1_700_000_000_123
68+
const nowEpochMilliseconds = 1_700_000_000_999
6969
const nowEpochSeconds = 1_700_000_000
7070
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(nowEpochMilliseconds)
7171
mockDeleteExecute.mockResolvedValue([{ count: 0, lastId: null }])
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockGetBrowserTimezone, mockUseQuery } = vi.hoisted(() => ({
7+
mockGetBrowserTimezone: vi.fn(),
8+
mockUseQuery: vi.fn(),
9+
}))
10+
11+
vi.mock('@tanstack/react-query', () => ({
12+
useMutation: vi.fn(),
13+
useQuery: mockUseQuery,
14+
useQueryClient: vi.fn(),
15+
}))
16+
vi.mock('@/lib/core/utils/timezone', () => ({ getBrowserTimezone: mockGetBrowserTimezone }))
17+
18+
import { useTimezone } from '@/hooks/queries/general-settings'
19+
20+
describe('useTimezone', () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks()
23+
mockGetBrowserTimezone.mockReturnValue('America/Los_Angeles')
24+
})
25+
26+
it('uses the browser timezone while no preference is saved', () => {
27+
mockUseQuery.mockReturnValue({ data: { timezone: null } })
28+
29+
expect(useTimezone()).toBe('America/Los_Angeles')
30+
})
31+
32+
it('uses a saved timezone instead of the browser fallback', () => {
33+
mockUseQuery.mockReturnValue({ data: { timezone: 'Asia/Kathmandu' } })
34+
35+
expect(useTimezone()).toBe('Asia/Kathmandu')
36+
expect(mockGetBrowserTimezone).not.toHaveBeenCalled()
37+
})
38+
39+
it('reads the current setting again after it changes', () => {
40+
let timezone: string | null = 'America/New_York'
41+
mockUseQuery.mockImplementation(() => ({ data: { timezone } }))
42+
43+
expect(useTimezone()).toBe('America/New_York')
44+
timezone = 'Asia/Tokyo'
45+
expect(useTimezone()).toBe('Asia/Tokyo')
46+
timezone = null
47+
expect(useTimezone()).toBe('America/Los_Angeles')
48+
})
49+
})

apps/sim/lib/core/utils/timezone.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
zonedClockDate,
99
zonedWallClockToUtc,
1010
zonedWallClockWithOffset,
11-
} from './timezone'
11+
} from '@/lib/core/utils/timezone'
1212

1313
describe('formatInstantInTimeZone', () => {
1414
it.each([
@@ -150,6 +150,53 @@ describe('zonedWallClockToUtc', () => {
150150
expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant)
151151
}
152152
)
153+
154+
it.each([
155+
['America/New_York', '2026-11-01T01:30', '2026-11-01T05:30:00.000Z', '-04:00'],
156+
['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T00:30:00.000Z', '+02:00'],
157+
['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T14:45:00.000Z', '+11:00'],
158+
])(
159+
'can choose the earlier instant for an ambiguous fall-back wall-clock in %s',
160+
(timeZone, wallClock, expectedInstant, expectedOffset) => {
161+
const options = { ambiguousTime: 'earlier' as const }
162+
const instant = zonedWallClockToUtc(wallClock, timeZone, options)
163+
const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone, options)
164+
165+
expect(instant.toISOString()).toBe(expectedInstant)
166+
expect(stampedWallClock).toBe(`${wallClock}${expectedOffset}`)
167+
expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant)
168+
}
169+
)
170+
171+
it('does not retain timezone state between consecutive resolutions', () => {
172+
const wallClock = '2026-06-15T09:00:30'
173+
174+
expect(zonedWallClockToUtc(wallClock, 'America/New_York').toISOString()).toBe(
175+
'2026-06-15T13:00:30.000Z'
176+
)
177+
expect(zonedWallClockToUtc(wallClock, 'Asia/Kathmandu').toISOString()).toBe(
178+
'2026-06-15T03:15:30.000Z'
179+
)
180+
expect(zonedWallClockToUtc(wallClock, 'America/New_York').toISOString()).toBe(
181+
'2026-06-15T13:00:30.000Z'
182+
)
183+
})
184+
185+
it('can serialize historical sub-minute offsets toward a later instant', () => {
186+
const wallClock = '1970-01-01T00:00:00'
187+
const timezone = 'Africa/Monrovia'
188+
const exactInstant = zonedWallClockToUtc(wallClock, timezone)
189+
const options = { offsetMinuteRounding: 'floor' as const }
190+
191+
expect(exactInstant.toISOString()).toBe('1970-01-01T00:44:30.000Z')
192+
expect(zonedWallClockWithOffset(wallClock, timezone, options)).toBe('1970-01-01T00:00:00-00:45')
193+
expect(formatInstantInTimeZone(exactInstant, timezone, options)).toBe(
194+
'1970-01-01T00:00:00-00:45'
195+
)
196+
expect(
197+
Date.parse(zonedWallClockWithOffset(wallClock, timezone, options))
198+
).toBeGreaterThanOrEqual(exactInstant.getTime())
199+
})
153200
})
154201

155202
describe('wallClockNow', () => {

0 commit comments

Comments
 (0)