Skip to content

Commit b2b9eed

Browse files
j15zclaude
andauthored
fix(tables): use explicit timestamps for expiration (#7689)
* fix(tables): use explicit timestamps for expiration * fix(tables): preserve expiration timestamp offsets * fix(tables): preserve expiration precision during calendar edits * fix(tables): use native timestamp validation Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(tables): keep expiration QA notes local * fix(calendar): validate date and time with Zod * fix(docs): keep unreleased expiration hidden Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0734cd0 commit b2b9eed

49 files changed

Lines changed: 1818 additions & 957 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,30 @@ describe('table row TTL cleanup route', () => {
129129
})
130130
expect(mockGetJobQueue).not.toHaveBeenCalled()
131131
})
132+
133+
it.each(['initialization', 'enqueue'])(
134+
'reports a queue %s failure and permits a later retry',
135+
async (stage) => {
136+
if (stage === 'initialization') {
137+
mockGetJobQueue.mockRejectedValueOnce(new Error('queue unavailable'))
138+
} else {
139+
mockEnqueue.mockRejectedValueOnce(new Error('connection lost'))
140+
}
141+
const request = () =>
142+
createMockRequest(
143+
'GET',
144+
undefined,
145+
{},
146+
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
147+
)
148+
const failed = await GET(request())
149+
expect(failed.status).toBe(500)
150+
await expect(failed.json()).resolves.toEqual({
151+
error: 'Failed to dispatch table row TTL cleanup',
152+
})
153+
const retried = await GET(request())
154+
expect(retried.status).toBe(200)
155+
await expect(retried.json()).resolves.toEqual({ triggered: true, jobId: 'job-ttl-1' })
156+
}
157+
)
132158
})

apps/sim/app/api/table/[tableId]/columns/route.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ vi.mock('@/lib/table/wire', () => ({
5555
vi.mock('@/app/api/table/utils', () => ({
5656
accessError: () => new Response('denied', { status: 403 }),
5757
checkAccess: mockCheckAccess,
58+
orchestrationErrorResponse: (error: unknown) =>
59+
error instanceof OrchestrationError
60+
? NextResponse.json(
61+
{ error: error.message },
62+
{ status: statusForOrchestrationError(error.code) }
63+
)
64+
: null,
5865
orchestrationOutcomeErrorResponse: (
5966
outcome: { error?: string; errorCode?: OrchestrationErrorCode },
6067
fallback: string
@@ -73,7 +80,7 @@ import {
7380
type OrchestrationErrorCode,
7481
statusForOrchestrationError,
7582
} from '@/lib/core/orchestration/types'
76-
import { PATCH } from '@/app/api/table/[tableId]/columns/route'
83+
import { PATCH, POST } from '@/app/api/table/[tableId]/columns/route'
7784

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

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

116+
it.each([
117+
'Schema validation failed: A table can have at most 1 Expiration column',
118+
'Expiration columns are not enabled',
119+
])('returns a validation response when adding a column fails: %s', async (message) => {
120+
mockAddTableColumn.mockRejectedValueOnce(new OrchestrationError('validation', message))
121+
const response = await POST(
122+
new NextRequest('http://localhost/api/table/t1/columns', {
123+
method: 'POST',
124+
body: JSON.stringify({
125+
workspaceId: WORKSPACE_ID,
126+
column: { name: 'expires', type: 'ttl' },
127+
}),
128+
headers: { 'content-type': 'application/json' },
129+
}),
130+
{ params: Promise.resolve({ tableId: 't1' }) }
131+
)
132+
expect(response.status).toBe(400)
133+
expect(await response.json()).toEqual({ error: message })
134+
})
135+
109136
it('rejects a currency code on a non-currency column without renaming first', async () => {
110137
const response = await patch({ name: 'renamed', currencyCode: 'USD' })
111138

apps/sim/app/api/table/[tableId]/columns/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { normalizeColumn } from '@/lib/table/wire'
1717
import {
1818
accessError,
1919
checkAccess,
20+
orchestrationErrorResponse,
2021
orchestrationOutcomeErrorResponse,
2122
rootErrorMessage,
2223
tableLockErrorResponse,
@@ -63,8 +64,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
6364
},
6465
})
6566
} catch (error) {
66-
const lockError = tableLockErrorResponse(error)
67-
if (lockError) return lockError
67+
const classifiedError = orchestrationErrorResponse(error)
68+
if (classifiedError) return classifiedError
6869
if (isZodError(error)) {
6970
return validationErrorResponse(error, 'Invalid request data')
7071
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ const table: TableInfo = {
100100

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

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

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

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

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

@@ -209,7 +206,7 @@ describe('RowModal expiration editing', () => {
209206
container.remove()
210207
})
211208

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

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

232-
const blockedField = container.querySelector<HTMLButtonElement>(
233-
'[aria-label="Edit expires_at"]'
234-
)
235-
expect(blockedField?.textContent).toBe(String(row.data.expires_at))
229+
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')?.value).toBe('01:00')
236230
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
237-
true
231+
false
238232
)
239233
expect(mockToastError).not.toHaveBeenCalled()
240-
act(() => blockedField?.click())
241-
expect(mockToastError).toHaveBeenCalledWith(
242-
'Your saved timezone “Mars/Olympus” is invalid. Update it in Settings → General before editing Date or Expiration cells.'
243-
)
244234
act(() => root.unmount())
245235
container.remove()
246236
})
@@ -259,11 +249,19 @@ describe('RowModal expiration editing', () => {
259249
schema: {
260250
columns: [
261251
{ name: 'name', type: 'string' },
252+
{ name: 'starts_at', type: 'date' },
262253
{ name: 'expires_at', type: 'ttl' },
263254
],
264255
},
265256
}
266-
const mixedRow = { ...row, data: { name: 'Ada', expires_at: row.data.expires_at } }
257+
const mixedRow = {
258+
...row,
259+
data: {
260+
name: 'Ada',
261+
expires_at: row.data.expires_at,
262+
starts_at: '2026-09-07T12:00:00-07:00',
263+
},
264+
}
267265
const props = {
268266
mode: 'edit' as const,
269267
isOpen: true,
@@ -276,20 +274,18 @@ describe('RowModal expiration editing', () => {
276274
act(() => root.render(createElement(RowModal, props)))
277275

278276
const nameInput = container.querySelector<HTMLInputElement>('[data-testid="modal-input"]')
279-
const blockedField = container.querySelector<HTMLButtonElement>(
280-
'[aria-label="Edit expires_at"]'
281-
)
277+
const blockedField = container.querySelector<HTMLButtonElement>('[aria-label="Edit starts_at"]')
282278
const submit = container.querySelector<HTMLButtonElement>('[data-testid="submit"]')
283279
expect(nameInput?.value).toBe('Ada')
284-
expect(blockedField?.textContent).toBe(String(row.data.expires_at))
280+
expect(blockedField?.textContent).toBe(mixedRow.data.starts_at)
285281
expect(submit?.disabled).toBe(false)
286282

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

290286
expect(mockUpdateRow).toHaveBeenCalledWith({
291287
rowId: 'row-1',
292-
data: { name: 'Grace' },
288+
data: { name: 'Grace', expires_at: row.data.expires_at },
293289
})
294290
expect(props.onSuccess).toHaveBeenCalledTimes(1)
295291
expect(mockToastError).not.toHaveBeenCalled()

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { useParams } from 'next/navigation'
2222
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
2323
import { columnTypeOf } from '@/lib/table/column-types'
2424
import { resolveCurrencyCode } from '@/lib/table/currency'
25+
import { todayAtTtlOffset, ttlValueFromPicker, ttlValueToPickerParts } from '@/lib/table/ttl-values'
2526
import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing'
2627
import { type TimezoneState, useTimezoneState } from '@/hooks/queries/general-settings'
2728
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
@@ -332,36 +333,45 @@ function ColumnField({ column, value, timeZone, onChange }: ColumnFieldProps) {
332333
required={column.required}
333334
hint={hint}
334335
mono
335-
value={formatValueForInput(value, column.type, timeZone)}
336+
value={formatValueForInput(value, column.type)}
336337
onChange={onChange}
337338
placeholder='{"key": "value"}'
338339
rows={4}
339340
/>
340341
)
341342
}
342343

343-
if (definition.editor === 'date') {
344-
const parts = dateValueToLocalParts(formatValueForInput(value, column.type, timeZone))
344+
if (definition.editor === 'date' || definition.editor === 'offset-date') {
345+
const storedValue = formatValueForInput(value, column.type)
346+
const offsetParts =
347+
definition.editor === 'offset-date' ? ttlValueToPickerParts(storedValue) : null
348+
const parts = offsetParts ?? dateValueToLocalParts(storedValue)
349+
const pickerToday = offsetParts
350+
? todayAtTtlOffset(offsetParts.offset)
351+
: todayLocalCalendarDate(timeZone)
345352
const valueFromParts = (day: string, time: string | null) =>
346-
column.type === 'ttl' && time ? `${day}T${time}` : localPartsToDateValue(day, time, timeZone)
353+
offsetParts
354+
? ttlValueFromPicker(day, time, offsetParts.offset)
355+
: localPartsToDateValue(day, time, timeZone)
347356
return (
348357
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
349358
<div className='flex items-center gap-2'>
350359
<ChipDatePicker
351360
value={parts.day ?? undefined}
352-
today={todayLocalCalendarDate(timeZone)}
361+
today={pickerToday}
353362
onChange={(day) => onChange(valueFromParts(day, parts.time))}
354363
placeholder='Select date'
355364
className='flex-1'
356365
/>
357366
<ChipTimePicker
358367
value={parts.time?.slice(0, 5)}
359-
onChange={(time) =>
360-
onChange(valueFromParts(parts.day ?? todayLocalCalendarDate(timeZone), time))
361-
}
368+
onChange={(time) => onChange(valueFromParts(parts.day ?? pickerToday, time))}
362369
placeholder='Add time'
363370
className='w-[110px]'
364371
/>
372+
{offsetParts && (
373+
<span className='text-[var(--text-tertiary)] text-small'>{offsetParts.offset}</span>
374+
)}
365375
</div>
366376
</ChipModalField>
367377
)
@@ -387,7 +397,7 @@ function ColumnField({ column, value, timeZone, onChange }: ColumnFieldProps) {
387397
inputType={
388398
definition.inputMode === 'decimal' && !definition.acceptsFormattedInput ? 'number' : 'text'
389399
}
390-
value={formatValueForInput(value, column.type, timeZone)}
400+
value={formatValueForInput(value, column.type)}
391401
onChange={onChange}
392402
placeholder={`Enter ${column.name}`}
393403
/>

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ interface CellContentProps {
1414
/** Current workspace id — lets string cells holding an in-workspace resource
1515
* URL render as a tagged-resource chip instead of a plain external link. */
1616
workspaceId: string
17-
timeZone: string
1817
timezoneStatus: TimezoneState['status']
1918
isEditing: boolean
2019
initialCharacter?: string | null
@@ -41,7 +40,6 @@ export function CellContent({
4140
exec,
4241
column,
4342
workspaceId,
44-
timeZone,
4543
timezoneStatus,
4644
isEditing,
4745
initialCharacter,
@@ -57,7 +55,6 @@ export function CellContent({
5755
waitingOnLabels,
5856
isEnrichmentOutput,
5957
currentWorkspaceId: workspaceId,
60-
timeZone,
6158
timezoneStatus,
6259
})
6360

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.ts

Lines changed: 13 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -23,43 +23,23 @@ function column(type: DisplayColumn['type']): DisplayColumn {
2323
}
2424

2525
describe('resolveCellRender', () => {
26-
it('renders TTL epoch seconds through the date presentation', () => {
27-
expect(
28-
resolveCellRender({
29-
value: 1_700_000_000,
26+
it.each(['ready', 'loading', 'invalid', 'error'] as const)(
27+
'renders TTL as the exact UTC string when timezone status is %s',
28+
(timezoneStatus) => {
29+
const value = '2026-06-15T09:00:30Z'
30+
const kind = resolveCellRender({
31+
value,
3032
exec: undefined,
3133
column: column('ttl'),
3234
waitingOnLabels: undefined,
33-
timeZone: 'America/New_York',
35+
timezoneStatus,
3436
})
35-
).toEqual({ kind: 'date', text: '2023-11-14T17:13:20-05:00' })
36-
})
37-
38-
it('renders raw epoch seconds when the saved timezone is invalid', () => {
39-
expect(
40-
resolveCellRender({
41-
value: 1_700_000_000,
42-
exec: undefined,
43-
column: column('ttl'),
44-
waitingOnLabels: undefined,
45-
timeZone: 'America/Los_Angeles',
46-
timezoneStatus: 'invalid',
47-
})
48-
).toEqual({ kind: 'date', text: '1700000000', raw: true })
49-
})
50-
51-
it('renders raw epoch seconds while timezone settings are loading', () => {
52-
expect(
53-
resolveCellRender({
54-
value: 1_700_000_000,
55-
exec: undefined,
56-
column: column('ttl'),
57-
waitingOnLabels: undefined,
58-
timeZone: 'America/Los_Angeles',
59-
timezoneStatus: 'loading',
60-
})
61-
).toEqual({ kind: 'date', text: '1700000000', raw: true })
62-
})
37+
expect(kind).toEqual({ kind: 'text', text: value })
38+
expect(renderToStaticMarkup(createElement(CellRender, { kind, isEditing: false }))).toContain(
39+
value
40+
)
41+
}
42+
)
6343

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

0 commit comments

Comments
 (0)