From ec5d0ff2b339b9dc7e9386dc35d66c1ec59dac75 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:05:42 -0700 Subject: [PATCH 1/5] fix(timezone): consolidate table wall-clock conversion --- .../[workspaceId]/tables/[tableId]/utils.ts | 3 +- apps/sim/lib/core/utils/timezone.test.ts | 105 ++++++++- apps/sim/lib/core/utils/timezone.ts | 159 ++++++++++---- .../__tests__/column-type-registry.test.ts | 62 ++++++ apps/sim/lib/table/column-types/ttl.ts | 2 +- apps/sim/lib/table/dates.test.ts | 44 ++++ apps/sim/lib/table/dates.ts | 203 ++++++++---------- 7 files changed, 424 insertions(+), 154 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts index b31b5f1ea48..1844dc3a07b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts @@ -1,7 +1,8 @@ +import { getWallClockParts } from '@/lib/core/utils/timezone' import type { ColumnDefinition, JsonValue } from '@/lib/table' import type { ColumnType } from '@/lib/table/column-types' import { columnTypeById, columnTypeOf } from '@/lib/table/column-types' -import { formatDateCellDisplay, getWallClockParts, normalizeDateCellValue } from '@/lib/table/dates' +import { formatDateCellDisplay, normalizeDateCellValue } from '@/lib/table/dates' /** * Pick a fresh "untitled[_N]" name not already taken by `columns`. Used by diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts index 935a9061cee..50085886c28 100644 --- a/apps/sim/lib/core/utils/timezone.test.ts +++ b/apps/sim/lib/core/utils/timezone.test.ts @@ -1,12 +1,63 @@ import { describe, expect, it } from 'vitest' import { + formatInstantInTimeZone, getSupportedTimezones, getTimezoneOptions, + getWallClockParts, wallClockNow, zonedClockDate, zonedWallClockToUtc, + zonedWallClockWithOffset, } from './timezone' +describe('formatInstantInTimeZone', () => { + it.each([ + ['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'], + ['Asia/Kathmandu', '2026-06-15T00:15:30Z', '2026-06-15T06:00:30+05:45'], + ['Australia/Lord_Howe', '2026-06-15T00:15:30Z', '2026-06-15T10:45:30+10:30'], + ])('formats an instant in %s with its exact offset', (timeZone, iso, expected) => { + expect(formatInstantInTimeZone(new Date(iso), timeZone)).toBe(expected) + }) + + it('distinguishes both copies of an autumn daylight-saving hour', () => { + expect(formatInstantInTimeZone(new Date('2026-11-01T05:30:00Z'), 'America/New_York')).toBe( + '2026-11-01T01:30:00-04:00' + ) + expect(formatInstantInTimeZone(new Date('2026-11-01T06:30:00Z'), 'America/New_York')).toBe( + '2026-11-01T01:30:00-05:00' + ) + }) + + it('round-trips the same instant after changing display timezones', () => { + const instant = new Date('2026-11-01T06:30:00Z') + for (const timeZone of [ + 'UTC', + 'America/Los_Angeles', + 'America/New_York', + 'Asia/Kathmandu', + 'Australia/Lord_Howe', + ]) { + const editable = formatInstantInTimeZone(instant, timeZone) + expect(new Date(editable).getTime()).toBe(instant.getTime()) + } + }) +}) + +describe('getWallClockParts', () => { + it('returns the calendar fields of an instant in the requested timezone', () => { + expect(getWallClockParts(new Date('2026-06-15T00:15:30Z'), 'America/Los_Angeles')).toEqual({ + year: 2026, + month: 6, + day: 14, + hour: 17, + minute: 15, + second: 30, + }) + }) +}) + describe('zonedWallClockToUtc', () => { it('treats a UTC wall-clock as the same instant', () => { expect(zonedWallClockToUtc('2026-06-15T09:00', 'UTC').toISOString()).toBe( @@ -48,11 +99,57 @@ describe('zonedWallClockToUtc', () => { }) it('resolves a spring-forward gap wall-clock forward by the DST shift', () => { - // 2026-03-08 02:00–02:59 does not exist in America/New_York (EST→EDT). - expect(zonedWallClockToUtc('2026-03-08T02:30', 'America/New_York').toISOString()).toBe( - '2026-03-08T07:30:00.000Z' - ) + const instant = zonedWallClockToUtc('2026-03-08T02:30', 'America/New_York') + const stampedWallClock = zonedWallClockWithOffset('2026-03-08T02:30', 'America/New_York') + + expect(instant.toISOString()).toBe('2026-03-08T07:30:00.000Z') + expect(stampedWallClock).toBe('2026-03-08T02:30-05:00') + expect(new Date(stampedWallClock).toISOString()).toBe(instant.toISOString()) }) + + it.each([ + [ + 'Europe/Berlin', + '2026-03-29T02:30', + '2026-03-29T01:30:00.000Z', + '2026-03-29T03:30:00+02:00', + '2026-03-29T02:30+01:00', + ], + [ + 'Australia/Lord_Howe', + '2026-10-04T02:15', + '2026-10-03T15:45:00.000Z', + '2026-10-04T02:45:00+11:00', + '2026-10-04T02:15+10:30', + ], + ])( + 'resolves an east-of-UTC spring-forward gap in %s to the first compatible wall-clock', + (timeZone, wallClock, expectedInstant, expectedRenderedWallClock, expectedStampedWallClock) => { + const instant = zonedWallClockToUtc(wallClock, timeZone) + const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone) + + expect(instant.toISOString()).toBe(expectedInstant) + expect(formatInstantInTimeZone(instant, timeZone)).toBe(expectedRenderedWallClock) + expect(stampedWallClock).toBe(expectedStampedWallClock) + expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant) + } + ) + + it.each([ + ['America/New_York', '2026-11-01T01:30', '2026-11-01T05:30:00.000Z', '-04:00'], + ['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T00:30:00.000Z', '+02:00'], + ['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T14:45:00.000Z', '+11:00'], + ])( + 'keeps the earlier instant for an ambiguous fall-back wall-clock in %s', + (timeZone, wallClock, expectedInstant, expectedOffset) => { + const instant = zonedWallClockToUtc(wallClock, timeZone) + const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone) + + expect(instant.toISOString()).toBe(expectedInstant) + expect(stampedWallClock).toBe(`${wallClock}${expectedOffset}`) + expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant) + } + ) }) describe('wallClockNow', () => { diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts index 5118061bb4d..f3802350bb8 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -21,6 +21,41 @@ const COMMON_TIMEZONES = [ 'Australia/Sydney', ] +/** A wall-clock reading of an instant in some timezone. */ +export interface WallClockParts { + year: number + /** 1-based month. */ + month: number + day: number + hour: number + minute: number + second: number +} + +function pad(value: number): string { + return String(value).padStart(2, '0') +} + +/** RFC 3339 offset suffix: `Z` for zero, else `±HH:MM`. */ +export function formatUtcOffsetSuffix(offsetMinutes: number): string { + if (offsetMinutes === 0) return 'Z' + const sign = offsetMinutes > 0 ? '+' : '-' + const absoluteMinutes = Math.abs(offsetMinutes) + return `${sign}${pad(Math.floor(absoluteMinutes / 60))}:${pad(absoluteMinutes % 60)}` +} + +function offsetMsFromWallClock(instant: Date, wall: WallClockParts): number { + const wallAsUtc = Date.UTC( + wall.year, + wall.month - 1, + wall.day, + wall.hour, + wall.minute, + wall.second + ) + return wallAsUtc - instant.getTime() +} + /** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */ export function getBrowserTimezone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone @@ -83,22 +118,57 @@ export function getTimezoneOptions(): TimezoneOption[] { } /** - * An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` - * string. Lets callers reason about a user's local date/time without UTC — e.g. - * to recover the local date/time a stored task instant represents in its zone. + * The wall-clock fields of `instant` in `timeZone`, or in the runtime's local + * timezone when omitted. */ -export function zonedWallClock(instant: Date, timeZone: string): string { - const parts = new Intl.DateTimeFormat('en-CA', { +export function getWallClockParts(instant: Date, timeZone?: string): WallClockParts { + if (!timeZone) { + return { + year: instant.getFullYear(), + month: instant.getMonth() + 1, + day: instant.getDate(), + hour: instant.getHours(), + minute: instant.getMinutes(), + second: instant.getSeconds(), + } + } + + const parts = new Intl.DateTimeFormat('en-US', { timeZone, + hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', - hourCycle: 'h23', + second: '2-digit', }).formatToParts(instant) - const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00' - return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}` + const get = (type: string) => Number(parts.find((part) => part.type === type)?.value) + return { + year: get('year'), + month: get('month'), + day: get('day'), + hour: get('hour'), + minute: get('minute'), + second: get('second'), + } +} + +/** Formats an instant as an RFC 3339 wall time in an IANA timezone. */ +export function formatInstantInTimeZone(instant: Date, timeZone: string): string { + const wall = getWallClockParts(instant, timeZone) + const offsetMinutes = Math.round(offsetMsFromWallClock(instant, wall) / 60_000) + return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatUtcOffsetSuffix(offsetMinutes)}` +} + +/** + * An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` + * string. Lets callers reason about a user's local date/time without UTC — e.g. + * to recover the local date/time a stored task instant represents in its zone. + */ +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)}` } /** The current wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` string. */ @@ -123,26 +193,40 @@ export function zonedClockDate(instant: Date, timeZone: string): Date { /** The UTC offset (ms, east-positive) of `timeZone` at a given instant. */ function timezoneOffsetMs(instant: Date, timeZone: string): number { - const parts = new Intl.DateTimeFormat('en-US', { - timeZone, - hourCycle: 'h23', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }).formatToParts(instant) - const get = (type: string) => Number(parts.find((p) => p.type === type)?.value) - const asUtc = Date.UTC( - get('year'), - get('month') - 1, - get('day'), - get('hour'), - get('minute'), - get('second') + return offsetMsFromWallClock(instant, getWallClockParts(instant, timeZone)) +} + +interface ZonedWallClockResolution { + instant: Date + offsetMinutes: number +} + +function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallClockResolution { + 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 dayMs = 24 * 60 * 60 * 1000 + const offsets = new Set( + [-dayMs, 0, dayMs].map((distance) => timezoneOffsetMs(new Date(utcGuess + distance), timeZone)) ) - return asUtc - instant.getTime() + const candidates = [...offsets].map((offset) => { + const instantMs = utcGuess - offset + const actualOffset = timezoneOffsetMs(new Date(instantMs), timeZone) + return { instantMs, wallClockMs: instantMs + actualOffset } + }) + const exactCandidate = candidates + .filter(({ wallClockMs }) => wallClockMs === utcGuess) + .sort((a, b) => a.instantMs - b.instantMs)[0] + const compatibleCandidate = candidates + .filter(({ wallClockMs }) => wallClockMs > utcGuess) + .sort((a, b) => a.wallClockMs - b.wallClockMs || a.instantMs - b.instantMs)[0] + const chosenCandidate = exactCandidate ?? compatibleCandidate ?? candidates[0] + const instantMs = chosenCandidate.instantMs + return { + instant: new Date(instantMs), + offsetMinutes: Math.round((utcGuess - instantMs) / 60_000), + } } /** @@ -152,22 +236,17 @@ function timezoneOffsetMs(instant: Date, timeZone: string): number { * date (including future ones whose offset differs from today's) and across DST: * a naive single pass reads the offset on the wrong side of a same-day boundary * — notably the autumn fall-back hour — and lands an hour off. For an ambiguous - * fall-back wall-clock the later (post-transition) instant is chosen; a + * fall-back wall-clock the earlier instant is chosen; a * wall-clock in the spring-forward gap (a nonexistent local hour) has no * self-consistent instant and resolves forward by the DST shift, matching how * calendar apps treat that once-a-year hour. */ export function zonedWallClockToUtc(wallClock: string, timeZone: string): Date { - 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 guessOffset = timezoneOffsetMs(new Date(utcGuess), timeZone) - const candidate = utcGuess - guessOffset - const candidateOffset = timezoneOffsetMs(new Date(candidate), timeZone) - if (candidateOffset === guessOffset) return new Date(candidate) - const adjusted = utcGuess - candidateOffset - return timezoneOffsetMs(new Date(adjusted), timeZone) === candidateOffset - ? new Date(adjusted) - : new Date(candidate) + return resolveZonedWallClock(wallClock, timeZone).instant +} + +/** Stamps a naive wall-clock with the offset selected by the shared timezone resolver. */ +export function zonedWallClockWithOffset(wallClock: string, timeZone: string): string { + const { offsetMinutes } = resolveZonedWallClock(wallClock, timeZone) + return `${wallClock}${formatUtcOffsetSuffix(offsetMinutes)}` } diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts index 7fcdceeb148..900575c2e50 100644 --- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts +++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts @@ -10,6 +10,7 @@ * here. */ import { describe, expect, it } from 'vitest' +import { zonedWallClockToUtc } from '@/lib/core/utils/timezone' import type { ColumnType } from '@/lib/table/column-types' import { ALL_COLUMN_TYPES, @@ -192,6 +193,67 @@ describe('ttl columns', () => { ).toBe('2023-11-05T01:30:00-05:00') }) + it('matches the shared wall-clock resolver in every effective timezone', () => { + const wallClock = '2026-06-15T09:00:30' + for (const timezone of [ + 'UTC', + 'America/Los_Angeles', + 'America/New_York', + 'Asia/Kathmandu', + 'Australia/Lord_Howe', + ]) { + const expected = Math.floor(zonedWallClockToUtc(wallClock, timezone).getTime() / 1000) + expect(COLUMN_TYPE_REGISTRY.ttl.coerce(wallClock, column, { timezone })).toEqual({ + ok: true, + value: expected, + }) + } + }) + + it.each([ + ['Europe/Berlin', '2026-03-29T02:30'], + ['Australia/Lord_Howe', '2026-10-04T02:15'], + ])('coerces a %s spring-forward gap wall clock to the compatible epoch', (timezone, input) => { + const expected = Math.floor(zonedWallClockToUtc(input, timezone).getTime() / 1000) + expect(COLUMN_TYPE_REGISTRY.ttl.coerce(input, column, { timezone })).toEqual({ + ok: true, + value: expected, + }) + }) + + it('coerces a localized month-name gap input in the explicit workspace timezone', () => { + const timezone = 'America/New_York' + const expected = Math.floor(zonedWallClockToUtc('2026-03-08T02:30', timezone).getTime() / 1000) + expect(COLUMN_TYPE_REGISTRY.ttl.coerce('March 8, 2026 2:30 AM', column, { timezone })).toEqual({ + ok: true, + value: expected, + }) + }) + + it('rejects an impossible ISO expiration date', () => { + expect( + COLUMN_TYPE_REGISTRY.ttl.coerce('2026-02-30T12:00:00', column, { timezone: 'UTC' }) + ).toEqual({ ok: false }) + }) + + it('round-trips epoch seconds after the editor timezone changes', () => { + for (const seconds of [1_700_000_000, 1_699_162_200, 1_699_165_800]) { + for (const timezone of [ + 'UTC', + 'America/Los_Angeles', + 'America/New_York', + 'Asia/Kathmandu', + 'Australia/Lord_Howe', + ]) { + const editable = COLUMN_TYPE_REGISTRY.ttl.formatForInput(seconds, column, { timezone }) + expect(COLUMN_TYPE_REGISTRY.ttl.coerce(editable, column, { timezone })).toEqual({ + ok: true, + value: seconds, + }) + } + } + }) + it('limits a table to one ttl column', () => { expect(COLUMN_TYPE_REGISTRY.ttl.maxPerTable).toBe(1) }) diff --git a/apps/sim/lib/table/column-types/ttl.ts b/apps/sim/lib/table/column-types/ttl.ts index 2303c852212..87081e9bc78 100644 --- a/apps/sim/lib/table/column-types/ttl.ts +++ b/apps/sim/lib/table/column-types/ttl.ts @@ -1,8 +1,8 @@ import { TypeTtl } from '@sim/emcn/icons' +import { formatInstantInTimeZone } from '@/lib/core/utils/timezone' import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' import { formatDateCellDisplay, - formatInstantInTimeZone, type NormalizeDateCellOptions, normalizeDateCellValue, } from '@/lib/table/dates' diff --git a/apps/sim/lib/table/dates.test.ts b/apps/sim/lib/table/dates.test.ts index 3ff51410e15..639d0544120 100644 --- a/apps/sim/lib/table/dates.test.ts +++ b/apps/sim/lib/table/dates.test.ts @@ -22,6 +22,8 @@ function localOffsetSuffix(local: Date): string { describe('isCalendarDateString', () => { it('accepts YYYY-MM-DD and rejects everything else', () => { expect(isCalendarDateString('2026-07-06')).toBe(true) + expect(isCalendarDateString('2024-02-29')).toBe(true) + expect(isCalendarDateString('2026-02-30')).toBe(false) expect(isCalendarDateString('2026-13-45')).toBe(false) expect(isCalendarDateString('2026-07-06T00:00:00Z')).toBe(false) expect(isCalendarDateString('07/06/2026')).toBe(false) @@ -80,6 +82,30 @@ describe('normalizeDateCellValue', () => { ) }) + 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' + ) + expect(normalizeDateCellValue('7/6/2026, 16:04:55', { timezone: 'Asia/Tokyo' })).toBe( + '2026-07-06T16:04:55+09:00' + ) + }) + + it('reads month-name wall clocks independently of the runtime timezone', () => { + expect(normalizeDateCellValue('March 8, 2026 2:30 AM', { timezone: 'America/New_York' })).toBe( + '2026-03-08T02:30:00-05:00' + ) + }) + + it.each([ + ['America/New_York', '2026-11-01 01:30:00', '2026-11-01T01:30:00-04:00'], + ['America/New_York', '2026-03-08 02:30:00', '2026-03-08T02:30:00-05:00'], + ['Asia/Kathmandu', '2026-06-15 09:00:00', '2026-06-15T09:00:00+05:45'], + ['Australia/Lord_Howe', '2026-06-15 09:00:00', '2026-06-15T09:00:00+10:30'], + ])('uses the shared timezone rules for %s', (timezone, input, expected) => { + expect(normalizeDateCellValue(input, { timezone })).toBe(expected) + }) + it('ignores the zone option when the input carries an explicit offset', () => { expect( normalizeDateCellValue('2026-07-06T23:04:55.000Z', { timezone: 'America/New_York' }) @@ -107,6 +133,24 @@ describe('normalizeDateCellValue', () => { expect(normalizeDateCellValue('2026-13-45')).toBeNull() expect(normalizeDateCellValue('13/06/2026')).toBeNull() }) + + it('rejects impossible ISO calendar and time fields', () => { + expect(normalizeDateCellValue('2026-02-30')).toBeNull() + expect(normalizeDateCellValue('2025-02-29T12:00:00Z')).toBeNull() + expect(normalizeDateCellValue('2026-02-30 12:00', { timezone: 'UTC' })).toBeNull() + expect(normalizeDateCellValue('2026-02-30 12:00 PDT')).toBeNull() + expect(normalizeDateCellValue('2026-07-06T24:00', { timezone: 'UTC' })).toBeNull() + expect(normalizeDateCellValue('2026-07-06 24:00+00')).toBeNull() + expect(normalizeDateCellValue('2026-07-06T12:60:00-04:00')).toBeNull() + }) + + it('accepts leap days and valid daylight-saving gap wall clocks', () => { + expect(normalizeDateCellValue('2024-02-29')).toBe('2024-02-29') + expect(normalizeDateCellValue('2024-02-29T12:00:00Z')).toBe('2024-02-29T12:00:00Z') + expect(normalizeDateCellValue('2026-03-08T02:30:00', { timezone: 'America/New_York' })).toBe( + '2026-03-08T02:30:00-05:00' + ) + }) }) describe('formatDateCellDisplay', () => { diff --git a/apps/sim/lib/table/dates.ts b/apps/sim/lib/table/dates.ts index 0c6360f63fb..f6c53ac30de 100644 --- a/apps/sim/lib/table/dates.ts +++ b/apps/sim/lib/table/dates.ts @@ -23,7 +23,9 @@ * barrel (the barrel is server-tainted). */ -const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ +import { formatUtcOffsetSuffix, zonedWallClockWithOffset } from '@/lib/core/utils/timezone' + +const CALENDAR_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/ /** * Canonical (or canonical-enough legacy) instant: a literal wall time with an @@ -31,7 +33,10 @@ const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ * groups are the wall-time fields display renders verbatim. */ const WALL_INSTANT_PATTERN = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/ + /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:\s*(?:Z|UTC?|GMT|[ECMP][SD]T)|[+-]\d{1,2}(?::?\d{2})?)?$/i + +const LOCALIZED_WALL_CLOCK_PATTERN = + /^(\d{1,2})\/(\d{1,2})\/(\d{4})[ ,]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i /** * Legacy shape: old CSV imports stored date-only columns as UTC-midnight @@ -67,81 +72,10 @@ const US_ABBREVIATION_OFFSET_MINUTES: Record = { /** True when `value` is a canonical timezone-free calendar date. */ export function isCalendarDateString(value: string): boolean { - return CALENDAR_DATE_PATTERN.test(value) && !Number.isNaN(Date.parse(value)) -} - -/** A wall-clock reading of an instant in some timezone. */ -export interface WallClockParts { - year: number - /** 1-based month. */ - month: number - day: number - hour: number - minute: number - second: number -} - -/** - * The wall-clock reading of `date` in `timeZone` — or in the runtime's local - * zone when omitted. Throws a RangeError on an invalid IANA zone — callers - * validate at the boundary. - */ -export function getWallClockParts(date: Date, timeZone?: string): WallClockParts { - if (!timeZone) { - return { - year: date.getFullYear(), - month: date.getMonth() + 1, - day: date.getDate(), - hour: date.getHours(), - minute: date.getMinutes(), - second: date.getSeconds(), - } - } - const parts = new Intl.DateTimeFormat('en-US', { - timeZone, - hourCycle: 'h23', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }).formatToParts(date) - const get = (type: string) => Number(parts.find((p) => p.type === type)?.value) - return { - year: get('year'), - month: get('month'), - day: get('day'), - hour: get('hour'), - minute: get('minute'), - second: get('second'), - } -} - -/** Offset of `timeZone` from UTC (ms east) at the moment `at`. */ -function zoneOffsetMs(timeZone: string, at: Date): number { - const wall = getWallClockParts(at, timeZone) - const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second) - return asUtc - at.getTime() -} - -/** - * Converts a wall-clock reading in `timeZone` to the UTC instant it denotes. - * Two-pass so readings near a DST transition resolve with the offset in - * force at that wall time. - */ -function wallTimeInZoneToUtc(wall: Date, timeZone: string): Date { - const guess = Date.UTC( - wall.getFullYear(), - wall.getMonth(), - wall.getDate(), - wall.getHours(), - wall.getMinutes(), - wall.getSeconds(), - wall.getMilliseconds() + const calendar = value.match(CALENDAR_DATE_PATTERN) + return Boolean( + calendar && isValidCalendarDay(Number(calendar[1]), Number(calendar[2]), Number(calendar[3])) ) - const adjusted = guess - zoneOffsetMs(timeZone, new Date(guess)) - return new Date(guess - zoneOffsetMs(timeZone, new Date(adjusted))) } function pad(n: number): string { @@ -156,29 +90,6 @@ function toUtcCalendarDate(date: Date): string { return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` } -/** `Z` for zero, else `±HH:MM`. */ -function formatOffsetSuffix(offsetMinutes: number): string { - if (offsetMinutes === 0) return 'Z' - const sign = offsetMinutes > 0 ? '+' : '-' - const abs = Math.abs(offsetMinutes) - return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}` -} - -/** Formats an instant as canonical wall time in an IANA timezone. */ -export function formatInstantInTimeZone(date: Date, timeZone: string): string { - const wall = getWallClockParts(date, timeZone) - const wallAsUtc = Date.UTC( - wall.year, - wall.month - 1, - wall.day, - wall.hour, - wall.minute, - wall.second - ) - const offsetMinutes = Math.round((wallAsUtc - date.getTime()) / 60_000) - return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${formatOffsetSuffix(offsetMinutes)}` -} - /** * Trailing offset (minutes east of UTC) of a datetime string, or null when * naive. Recognizes exactly what `Date.parse` recognizes: numeric offsets, @@ -201,14 +112,83 @@ function extractExplicitOffsetMinutes(value: string): number | null { function formatUtcFieldsAsWall(shifted: Date, offsetMinutes: number): string { return `${toUtcCalendarDate(shifted)}T${pad(shifted.getUTCHours())}:${pad( shifted.getUTCMinutes() - )}:${pad(shifted.getUTCSeconds())}${formatOffsetSuffix(offsetMinutes)}` + )}:${pad(shifted.getUTCSeconds())}${formatUtcOffsetSuffix(offsetMinutes)}` } /** Serializes local-read fields of `parsed` as a wall time with `offset`. */ function formatLocalFieldsAsWall(parsed: Date, offsetMinutes: number): string { return `${toLocalCalendarDate(parsed)}T${pad(parsed.getHours())}:${pad( parsed.getMinutes() - )}:${pad(parsed.getSeconds())}${formatOffsetSuffix(offsetMinutes)}` + )}:${pad(parsed.getSeconds())}${formatUtcOffsetSuffix(offsetMinutes)}` +} + +/** True when numeric year, month, and day fields describe a real calendar day. */ +function isValidCalendarDay(year: number, month: number, day: number): boolean { + if (month < 1 || month > 12 || day < 1) return false + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + return day <= daysInMonth[month - 1] +} + +/** Validates and formats numeric wall-clock fields as naive ISO. */ +function formatValidatedWallClock( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number +): string | null { + if ( + !isValidCalendarDay(year, month, day) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 || + second < 0 || + second > 59 + ) { + return null + } + return `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}:${pad(second)}` +} + +/** Reads an ISO-shaped wall clock literally, before runtime timezone normalization. */ +function parseIsoWallClock(match: RegExpMatchArray): string | null { + return formatValidatedWallClock( + Number(match[1]), + Number(match[2]), + Number(match[3]), + Number(match[4]), + Number(match[5]), + Number(match[6] ?? 0) + ) +} + +/** Reads a supported US numeric wall clock literally, including 12-hour input. */ +function parseLocalizedWallClock(match: RegExpMatchArray): string | null { + const meridiem = match[7]?.toUpperCase() + let hour = Number(match[4]) + if (meridiem) { + if (hour < 1 || hour > 12) return null + hour = (hour % 12) + (meridiem === 'PM' ? 12 : 0) + } + return formatValidatedWallClock( + Number(match[3]), + Number(match[1]), + Number(match[2]), + hour, + Number(match[5]), + Number(match[6] ?? 0) + ) +} + +/** Recovers broader naive `Date.parse` inputs without consulting the runtime timezone. */ +function parseNaiveWallClockAsUtc(value: string): string | null { + const ms = Date.parse(`${value} UTC`) + if (Number.isNaN(ms)) return null + const parsed = new Date(ms) + return `${toUtcCalendarDate(parsed)}T${pad(parsed.getUTCHours())}:${pad(parsed.getUTCMinutes())}:${pad(parsed.getUTCSeconds())}` } export interface NormalizeDateCellOptions { @@ -235,9 +215,18 @@ export function normalizeDateCellValue( ): string | null { const trimmed = raw.trim() if (!trimmed) return null - if (CALENDAR_DATE_PATTERN.test(trimmed)) { - return Number.isNaN(Date.parse(trimmed)) ? null : trimmed + const calendar = trimmed.match(CALENDAR_DATE_PATTERN) + if (calendar) { + return isValidCalendarDay(Number(calendar[1]), Number(calendar[2]), Number(calendar[3])) + ? trimmed + : null } + const isoMatch = trimmed.match(WALL_INSTANT_PATTERN) + const isoWallClock = isoMatch ? parseIsoWallClock(isoMatch) : undefined + if (isoWallClock === null) return null + const localizedMatch = trimmed.match(LOCALIZED_WALL_CLOCK_PATTERN) + const localizedWallClock = localizedMatch ? parseLocalizedWallClock(localizedMatch) : undefined + if (localizedWallClock === null) return null const ms = Date.parse(trimmed) if (Number.isNaN(ms)) return null const parsed = new Date(ms) @@ -253,11 +242,9 @@ export function normalizeDateCellValue( return formatUtcFieldsAsWall(new Date(ms + explicitOffset * 60_000), explicitOffset) } if (options?.timezone) { - // `parsed`'s local getters recover the wall-clock fields V8 read from the - // naive string; stamp them with the requested zone's offset at that time. - const instant = wallTimeInZoneToUtc(parsed, options.timezone) - const offsetMinutes = Math.round(zoneOffsetMs(options.timezone, instant) / 60_000) - return formatLocalFieldsAsWall(parsed, offsetMinutes) + const wallClock = isoWallClock ?? localizedWallClock ?? parseNaiveWallClockAsUtc(trimmed) + if (!wallClock) return null + return zonedWallClockWithOffset(wallClock, options.timezone) } return formatLocalFieldsAsWall(parsed, -parsed.getTimezoneOffset()) } From 1b39fa72368e742bc28d73154c7325182acbacdc Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:43:09 -0700 Subject: [PATCH 2/5] fix(timezone): preserve ambiguous date semantics --- apps/sim/lib/core/utils/timezone.test.ts | 8 ++-- apps/sim/lib/core/utils/timezone.ts | 4 +- apps/sim/lib/table/dates.test.ts | 20 +++++++- apps/sim/lib/table/dates.ts | 60 ++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts index 50085886c28..cc42da77aac 100644 --- a/apps/sim/lib/core/utils/timezone.test.ts +++ b/apps/sim/lib/core/utils/timezone.test.ts @@ -136,11 +136,11 @@ describe('zonedWallClockToUtc', () => { ) it.each([ - ['America/New_York', '2026-11-01T01:30', '2026-11-01T05:30:00.000Z', '-04:00'], - ['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T00:30:00.000Z', '+02:00'], - ['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T14:45:00.000Z', '+11:00'], + ['America/New_York', '2026-11-01T01:30', '2026-11-01T06:30:00.000Z', '-05:00'], + ['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T01:30:00.000Z', '+01:00'], + ['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T15:15:00.000Z', '+10:30'], ])( - 'keeps the earlier instant for an ambiguous fall-back wall-clock in %s', + 'chooses the later post-transition instant for an ambiguous fall-back wall-clock in %s', (timeZone, wallClock, expectedInstant, expectedOffset) => { const instant = zonedWallClockToUtc(wallClock, timeZone) const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone) diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts index f3802350bb8..4d1de490419 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -217,7 +217,7 @@ function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallCl }) const exactCandidate = candidates .filter(({ wallClockMs }) => wallClockMs === utcGuess) - .sort((a, b) => a.instantMs - b.instantMs)[0] + .sort((a, b) => b.instantMs - a.instantMs)[0] const compatibleCandidate = candidates .filter(({ wallClockMs }) => wallClockMs > utcGuess) .sort((a, b) => a.wallClockMs - b.wallClockMs || a.instantMs - b.instantMs)[0] @@ -236,7 +236,7 @@ function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallCl * date (including future ones whose offset differs from today's) and across DST: * a naive single pass reads the offset on the wrong side of a same-day boundary * — notably the autumn fall-back hour — and lands an hour off. For an ambiguous - * fall-back wall-clock the earlier instant is chosen; a + * fall-back wall-clock the later, post-transition instant is chosen; a * wall-clock in the spring-forward gap (a nonexistent local hour) has no * self-consistent instant and resolves forward by the DST shift, matching how * calendar apps treat that once-a-year hour. diff --git a/apps/sim/lib/table/dates.test.ts b/apps/sim/lib/table/dates.test.ts index 639d0544120..3ab6a905a1a 100644 --- a/apps/sim/lib/table/dates.test.ts +++ b/apps/sim/lib/table/dates.test.ts @@ -97,8 +97,26 @@ describe('normalizeDateCellValue', () => { ) }) + it('rejects impossible month-name calendar dates', () => { + expect( + normalizeDateCellValue('February 29, 2025 2:30 AM', { timezone: 'America/New_York' }) + ).toBeNull() + expect( + normalizeDateCellValue('April 31, 2026 4:04 PM', { timezone: 'America/New_York' }) + ).toBeNull() + }) + + it('accepts valid leap-day month-name wall clocks in either date order', () => { + expect( + normalizeDateCellValue('February 29, 2024 4:04 PM', { timezone: 'America/New_York' }) + ).toBe('2024-02-29T16:04:00-05:00') + expect(normalizeDateCellValue('29 Feb 2024 4:04 PM', { timezone: 'America/New_York' })).toBe( + '2024-02-29T16:04:00-05:00' + ) + }) + it.each([ - ['America/New_York', '2026-11-01 01:30:00', '2026-11-01T01:30:00-04:00'], + ['America/New_York', '2026-11-01 01:30:00', '2026-11-01T01:30:00-05:00'], ['America/New_York', '2026-03-08 02:30:00', '2026-03-08T02:30:00-05:00'], ['Asia/Kathmandu', '2026-06-15 09:00:00', '2026-06-15T09:00:00+05:45'], ['Australia/Lord_Howe', '2026-06-15 09:00:00', '2026-06-15T09:00:00+10:30'], diff --git a/apps/sim/lib/table/dates.ts b/apps/sim/lib/table/dates.ts index f6c53ac30de..041b984bfaa 100644 --- a/apps/sim/lib/table/dates.ts +++ b/apps/sim/lib/table/dates.ts @@ -38,6 +38,31 @@ const WALL_INSTANT_PATTERN = const LOCALIZED_WALL_CLOCK_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})[ ,]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i +const MONTH_NAME_PATTERN = + 'Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?' +const MONTH_FIRST_DATE_PATTERN = new RegExp( + `\\b(${MONTH_NAME_PATTERN})\\s+(\\d{1,2})(?:,)?\\s+(\\d{4})\\b`, + 'i' +) +const DAY_FIRST_DATE_PATTERN = new RegExp( + `\\b(\\d{1,2})\\s+(${MONTH_NAME_PATTERN})(?:,)?\\s+(\\d{4})\\b`, + 'i' +) +const MONTH_BY_ABBREVIATION: Record = { + JAN: 1, + FEB: 2, + MAR: 3, + APR: 4, + MAY: 5, + JUN: 6, + JUL: 7, + AUG: 8, + SEP: 9, + OCT: 10, + NOV: 11, + DEC: 12, +} + /** * Legacy shape: old CSV imports stored date-only columns as UTC-midnight * instants. Treated as calendar dates so historical rows render as pure days @@ -183,11 +208,46 @@ function parseLocalizedWallClock(match: RegExpMatchArray): string | null { ) } +interface CalendarFields { + year: number + month: number + day: number +} + +/** Extracts literal calendar fields from supported month-name date forms. */ +function extractMonthNameCalendar(value: string): CalendarFields | null { + const monthFirst = value.match(MONTH_FIRST_DATE_PATTERN) + if (monthFirst) { + return { + year: Number(monthFirst[3]), + month: MONTH_BY_ABBREVIATION[monthFirst[1].slice(0, 3).toUpperCase()], + day: Number(monthFirst[2]), + } + } + const dayFirst = value.match(DAY_FIRST_DATE_PATTERN) + if (!dayFirst) return null + return { + year: Number(dayFirst[3]), + month: MONTH_BY_ABBREVIATION[dayFirst[2].slice(0, 3).toUpperCase()], + day: Number(dayFirst[1]), + } +} + /** Recovers broader naive `Date.parse` inputs without consulting the runtime timezone. */ function parseNaiveWallClockAsUtc(value: string): string | null { + const calendar = extractMonthNameCalendar(value) + if (calendar && !isValidCalendarDay(calendar.year, calendar.month, calendar.day)) return null const ms = Date.parse(`${value} UTC`) if (Number.isNaN(ms)) return null const parsed = new Date(ms) + if ( + calendar && + (parsed.getUTCFullYear() !== calendar.year || + parsed.getUTCMonth() + 1 !== calendar.month || + parsed.getUTCDate() !== calendar.day) + ) { + return null + } return `${toUtcCalendarDate(parsed)}T${pad(parsed.getUTCHours())}:${pad(parsed.getUTCMinutes())}:${pad(parsed.getUTCSeconds())}` } From 98d455899895dea1906567d860f4bde58e3396fa Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:19:29 -0700 Subject: [PATCH 3/5] fix(tables): prevent early TTL expiration --- .../cron/cleanup-table-row-ttl/route.test.ts | 17 +++ .../table-grid/cells/inline-editors.test.ts | 89 ++++++++++++ .../table-grid/cells/inline-editors.tsx | 22 ++- .../tables/[tableId]/utils.test.ts | 15 ++ .../[workspaceId]/tables/[tableId]/utils.ts | 40 +----- .../background/cleanup-table-row-ttl.test.ts | 2 +- .../hooks/queries/general-settings.test.ts | 49 +++++++ apps/sim/lib/core/utils/timezone.test.ts | 49 ++++++- apps/sim/lib/core/utils/timezone.ts | 57 ++++++-- .../__tests__/column-type-registry.test.ts | 2 +- apps/sim/lib/table/column-types/ttl.test.ts | 135 ++++++++++++++++++ apps/sim/lib/table/column-types/ttl.ts | 29 +++- apps/sim/lib/table/dates.test.ts | 20 ++- apps/sim/lib/table/dates.ts | 36 ++++- apps/sim/lib/table/import.test.ts | 12 ++ 15 files changed, 512 insertions(+), 62 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts create mode 100644 apps/sim/hooks/queries/general-settings.test.ts diff --git a/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts index 3bec9061001..0b01e026aa3 100644 --- a/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts +++ b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts @@ -70,6 +70,23 @@ describe('table row TTL cleanup route', () => { expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId) }) + it('uses a new id immediately after the next fifteen-minute window begins', async () => { + const request = () => + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/cron/cleanup-table-row-ttl' + ) + + vi.setSystemTime(new Date('2026-08-22T17:14:59.999Z')) + await GET(request()) + vi.setSystemTime(new Date('2026-08-22T17:15:00.000Z')) + await GET(request()) + + expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).not.toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId) + }) + it('returns the cron auth refusal without touching the queue', async () => { mockVerifyCronAuth.mockReturnValue(new Response(null, { status: 401 })) 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 new file mode 100644 index 00000000000..40834ad8eed --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment jsdom + */ +import { act, createElement, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ColumnDefinition } from '@/lib/table' +import { + dateEditorRawValue, + InlineEditor, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors' +import { cleanCellValue } from '@/app/workspace/[workspaceId]/tables/[tableId]/utils' + +const { mockUseTimezone } = vi.hoisted(() => ({ mockUseTimezone: vi.fn() })) + +vi.mock('@/hooks/queries/general-settings', () => ({ useTimezone: mockUseTimezone })) +vi.mock('@sim/emcn', () => { + const passthrough = ({ children }: { children?: ReactNode }) => children ?? null + return { + Calendar: () => null, + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), + DropdownMenu: passthrough, + DropdownMenuContent: passthrough, + DropdownMenuItem: passthrough, + DropdownMenuTrigger: passthrough, + Popover: passthrough, + PopoverAnchor: () => null, + PopoverContent: passthrough, + toast: { error: vi.fn() }, + } +}) +const column = (type: ColumnDefinition['type']): ColumnDefinition => ({ name: 'expires_at', type }) + +describe('dateEditorRawValue', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseTimezone.mockReturnValue('America/Los_Angeles') + }) + + it('leaves TTL drafts for TTL coercion to resolve safely', () => { + const ttlColumn = column('ttl') + const timezone = 'America/New_York' + const repeatedWallClock = '11/01/2026 1:30:00 AM' + + const repeatedRaw = dateEditorRawValue(repeatedWallClock, ttlColumn, timezone) + expect(repeatedRaw).toBe(repeatedWallClock) + expect(cleanCellValue(repeatedRaw, ttlColumn, timezone)).toBe( + Date.parse('2026-11-01T06:30:00Z') / 1000 + ) + + const fractionalRaw = dateEditorRawValue('2023-11-14t22:13:20.001Z', ttlColumn, timezone) + expect(cleanCellValue(fractionalRaw, ttlColumn, timezone)).toBe(1_700_000_001) + }) + + it('keeps ordinary date drafts on their existing display parser', () => { + expect(dateEditorRawValue('11/01/2026 1:30:00 AM', column('date'), 'America/New_York')).toBe( + '2026-11-01T01:30:00-04:00' + ) + }) + + it('keeps an open TTL edit in its starting timezone when the setting changes', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onSave = vi.fn() + const value = Date.parse('2026-06-15T13:00:30Z') / 1000 + const props = { + value, + column: column('ttl'), + onSave, + onCancel: vi.fn(), + } + + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + act(() => root.render(createElement(InlineEditor, props))) + mockUseTimezone.mockReturnValue('America/New_York') + act(() => root.render(createElement(InlineEditor, props))) + + const input = container.querySelector('input') + expect(input?.value).toBe('06/15/2026 6:00:30 AM') + act(() => { + input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + + expect(onSave).toHaveBeenCalledWith(value, 'enter') + 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 f5c8526173b..8437480f01e 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 @@ -37,6 +37,21 @@ interface InlineEditorProps { onCancel: () => void } +/** + * Produces the raw draft that the column type will coerce on save. Ordinary + * date columns keep their display parser for partial dates; other date-editor + * types receive the untouched draft so their own safety rules are not erased. + */ +export function dateEditorRawValue( + draft: string, + column: ColumnDefinition, + timeZone: string, + storageValue?: string +): string { + if (storageValue !== undefined) return storageValue + return column.type === 'date' ? (displayToStorage(draft, timeZone) ?? draft) : draft +} + /** Redirect wheel gestures over an inline editor to the surrounding table scroll container. */ function handleEditorWheel(e: React.WheelEvent) { e.preventDefault() @@ -68,7 +83,10 @@ function InlineDateEditor({ * and refocuses while a popover interaction is in flight (covers browsers * where buttons don't take focus on click). */ const popoverPointerAtRef = useRef(0) - const timeZone = useTimezone() + const effectiveTimeZone = useTimezone() + /** Keep one wall-clock interpretation for the lifetime of this edit. */ + const editTimeZoneRef = useRef(effectiveTimeZone) + const timeZone = editTimeZoneRef.current const storedValue = formatValueForInput(value, column.type, timeZone) const initialDraft = @@ -118,7 +136,7 @@ function InlineDateEditor({ onSave(storedValue ? cleanCellValue(storedValue, column, timeZone) : null, reason) return } - const raw = storageVal ?? displayToStorage(current, timeZone) ?? current + const raw = dateEditorRawValue(current, column, timeZone, storageVal) if (raw && Number.isNaN(Date.parse(raw))) { if (reason === 'blur') { if (!invalid) toast.error('Invalid date') diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts index a4e051aed2e..c77ce7256e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts @@ -206,4 +206,19 @@ describe('formatValueForInput', () => { cleanCellValue('2023-11-14', { name: 'expires_at', type: 'ttl' }, 'America/New_York') ).toBe(1_699_938_000) }) + + it('uses the latest effective timezone for each TTL edit', () => { + const column = { name: 'expires_at', type: 'ttl' } as const + const input = '2026-06-15 09:00:30' + + expect(cleanCellValue(input, column, 'America/New_York')).toBe( + Date.parse('2026-06-15T13:00:30Z') / 1000 + ) + expect(cleanCellValue(input, column, 'Asia/Kathmandu')).toBe( + Date.parse('2026-06-15T03:15:30Z') / 1000 + ) + expect(cleanCellValue(input, column, 'America/New_York')).toBe( + Date.parse('2026-06-15T13:00:30Z') / 1000 + ) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts index 1844dc3a07b..c9892f8466e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts @@ -147,46 +147,12 @@ export function storageToDisplay(stored: string, options?: { seconds?: boolean } */ export function displayToStorage(display: string, timeZone?: string): string | null { const trimmed = display.trim() - const withTime = trimmed.match( - /^(\d{1,2})\/(\d{1,2})\/(\d{4})[ ,]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i - ) - if (withTime) { - const [, m, d, y, h, min, sec, meridiem] = withTime - let hours = Number(h) - if (meridiem) { - if (hours < 1 || hours > 12) return null - hours = (hours % 12) + (meridiem.toUpperCase() === 'PM' ? 12 : 0) - } else if (hours > 23) { - return null - } - if (Number(min) > 59 || Number(sec ?? 0) > 59) return null - if (!isValidCalendarDay(Number(y), Number(m), Number(d))) return null - const pad = (n: string) => n.padStart(2, '0') - // Route through the shared normalizer so the wall time resolves in the - // effective zone. - return normalizeDateCellValue( - `${y}-${pad(m)}-${pad(d)}T${String(hours).padStart(2, '0')}:${min}:${sec ?? '00'}`, - { timezone: timeZone } - ) - } - const full = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/) - if (full) { - if (!isValidCalendarDay(Number(full[3]), Number(full[1]), Number(full[2]))) return null - return `${full[3]}-${full[1].padStart(2, '0')}-${full[2].padStart(2, '0')}` - } const partial = trimmed.match(/^(\d{1,2})\/(\d{1,2})$/) if (partial) { const year = Number(todayLocalCalendarDate(timeZone).slice(0, 4)) - if (!isValidCalendarDay(year, Number(partial[1]), Number(partial[2]))) return null - return `${year}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}` + return normalizeDateCellValue( + `${year}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}` + ) } return normalizeDateCellValue(trimmed, { timezone: timeZone }) } - -/** True when Y/M/D is a real calendar day — `Date` rolls impossible days over - * (02/30 → 03/02) instead of rejecting them, so compare the round-trip. */ -function isValidCalendarDay(year: number, month: number, day: number): boolean { - if (month < 1 || month > 12 || day < 1 || day > 31) return false - const check = new Date(year, month - 1, day) - return check.getMonth() === month - 1 && check.getDate() === day -} diff --git a/apps/sim/background/cleanup-table-row-ttl.test.ts b/apps/sim/background/cleanup-table-row-ttl.test.ts index 509d7432625..4c033e25228 100644 --- a/apps/sim/background/cleanup-table-row-ttl.test.ts +++ b/apps/sim/background/cleanup-table-row-ttl.test.ts @@ -65,7 +65,7 @@ describe('table row TTL cleanup', () => { }) it('compares TTL values with whole Date.now epoch seconds', async () => { - const nowEpochMilliseconds = 1_700_000_000_123 + const nowEpochMilliseconds = 1_700_000_000_999 const nowEpochSeconds = 1_700_000_000 const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(nowEpochMilliseconds) mockDeleteExecute.mockResolvedValue([{ count: 0, lastId: null }]) diff --git a/apps/sim/hooks/queries/general-settings.test.ts b/apps/sim/hooks/queries/general-settings.test.ts new file mode 100644 index 00000000000..d22773644ac --- /dev/null +++ b/apps/sim/hooks/queries/general-settings.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetBrowserTimezone, mockUseQuery } = vi.hoisted(() => ({ + mockGetBrowserTimezone: vi.fn(), + mockUseQuery: vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ + useMutation: vi.fn(), + useQuery: mockUseQuery, + useQueryClient: vi.fn(), +})) +vi.mock('@/lib/core/utils/timezone', () => ({ getBrowserTimezone: mockGetBrowserTimezone })) + +import { useTimezone } from '@/hooks/queries/general-settings' + +describe('useTimezone', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBrowserTimezone.mockReturnValue('America/Los_Angeles') + }) + + it('uses the browser timezone while no preference is saved', () => { + mockUseQuery.mockReturnValue({ data: { timezone: null } }) + + expect(useTimezone()).toBe('America/Los_Angeles') + }) + + it('uses a saved timezone instead of the browser fallback', () => { + mockUseQuery.mockReturnValue({ data: { timezone: 'Asia/Kathmandu' } }) + + expect(useTimezone()).toBe('Asia/Kathmandu') + expect(mockGetBrowserTimezone).not.toHaveBeenCalled() + }) + + it('reads the current setting again after it changes', () => { + let timezone: string | null = 'America/New_York' + mockUseQuery.mockImplementation(() => ({ data: { timezone } })) + + expect(useTimezone()).toBe('America/New_York') + timezone = 'Asia/Tokyo' + expect(useTimezone()).toBe('Asia/Tokyo') + timezone = null + expect(useTimezone()).toBe('America/Los_Angeles') + }) +}) diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts index cc42da77aac..5ff9f6cef4d 100644 --- a/apps/sim/lib/core/utils/timezone.test.ts +++ b/apps/sim/lib/core/utils/timezone.test.ts @@ -8,7 +8,7 @@ import { zonedClockDate, zonedWallClockToUtc, zonedWallClockWithOffset, -} from './timezone' +} from '@/lib/core/utils/timezone' describe('formatInstantInTimeZone', () => { it.each([ @@ -150,6 +150,53 @@ describe('zonedWallClockToUtc', () => { expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant) } ) + + it.each([ + ['America/New_York', '2026-11-01T01:30', '2026-11-01T05:30:00.000Z', '-04:00'], + ['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T00:30:00.000Z', '+02:00'], + ['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T14:45:00.000Z', '+11:00'], + ])( + 'can choose the earlier instant for an ambiguous fall-back wall-clock in %s', + (timeZone, wallClock, expectedInstant, expectedOffset) => { + const options = { ambiguousTime: 'earlier' as const } + const instant = zonedWallClockToUtc(wallClock, timeZone, options) + const stampedWallClock = zonedWallClockWithOffset(wallClock, timeZone, options) + + expect(instant.toISOString()).toBe(expectedInstant) + expect(stampedWallClock).toBe(`${wallClock}${expectedOffset}`) + expect(new Date(stampedWallClock).toISOString()).toBe(expectedInstant) + } + ) + + it('does not retain timezone state between consecutive resolutions', () => { + const wallClock = '2026-06-15T09:00:30' + + expect(zonedWallClockToUtc(wallClock, 'America/New_York').toISOString()).toBe( + '2026-06-15T13:00:30.000Z' + ) + expect(zonedWallClockToUtc(wallClock, 'Asia/Kathmandu').toISOString()).toBe( + '2026-06-15T03:15:30.000Z' + ) + expect(zonedWallClockToUtc(wallClock, 'America/New_York').toISOString()).toBe( + '2026-06-15T13:00:30.000Z' + ) + }) + + it('can serialize historical sub-minute offsets toward a later instant', () => { + const wallClock = '1970-01-01T00:00:00' + const timezone = 'Africa/Monrovia' + const exactInstant = zonedWallClockToUtc(wallClock, timezone) + const options = { offsetMinuteRounding: 'floor' as const } + + expect(exactInstant.toISOString()).toBe('1970-01-01T00:44:30.000Z') + expect(zonedWallClockWithOffset(wallClock, timezone, options)).toBe('1970-01-01T00:00:00-00:45') + expect(formatInstantInTimeZone(exactInstant, timezone, options)).toBe( + '1970-01-01T00:00:00-00:45' + ) + expect( + Date.parse(zonedWallClockWithOffset(wallClock, timezone, options)) + ).toBeGreaterThanOrEqual(exactInstant.getTime()) + }) }) describe('wallClockNow', () => { diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts index 4d1de490419..5b4fe34344b 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -155,9 +155,15 @@ export function getWallClockParts(instant: Date, timeZone?: string): WallClockPa } /** Formats an instant as an RFC 3339 wall time in an IANA timezone. */ -export function formatInstantInTimeZone(instant: Date, timeZone: string): string { +export function formatInstantInTimeZone( + instant: Date, + timeZone: string, + options?: ZonedWallClockOptions +): string { const wall = getWallClockParts(instant, timeZone) - const offsetMinutes = Math.round(offsetMsFromWallClock(instant, wall) / 60_000) + 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)}` } @@ -201,7 +207,24 @@ interface ZonedWallClockResolution { offsetMinutes: number } -function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallClockResolution { +export interface ZonedWallClockOptions { + /** Which real instant to use when the wall clock occurs twice during a DST fall-back. */ + ambiguousTime?: 'earlier' | 'later' + /** How to serialize rare historical offsets containing seconds into RFC 3339 minutes. */ + offsetMinuteRounding?: 'nearest' | 'floor' +} + +function roundOffsetMinutes(exactOffsetMinutes: number, options?: ZonedWallClockOptions): number { + return options?.offsetMinuteRounding === 'floor' + ? Math.floor(exactOffsetMinutes) + : Math.round(exactOffsetMinutes) +} + +function resolveZonedWallClock( + wallClock: string, + timeZone: string, + options?: ZonedWallClockOptions +): ZonedWallClockResolution { const [datePart, timePart] = wallClock.split('T') const [year, month, day] = datePart.split('-').map(Number) const [hour, minute, second = 0] = timePart.split(':').map(Number) @@ -217,7 +240,9 @@ function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallCl }) const exactCandidate = candidates .filter(({ wallClockMs }) => wallClockMs === utcGuess) - .sort((a, b) => b.instantMs - a.instantMs)[0] + .sort((a, b) => + options?.ambiguousTime === 'earlier' ? a.instantMs - b.instantMs : b.instantMs - a.instantMs + )[0] const compatibleCandidate = candidates .filter(({ wallClockMs }) => wallClockMs > utcGuess) .sort((a, b) => a.wallClockMs - b.wallClockMs || a.instantMs - b.instantMs)[0] @@ -225,7 +250,7 @@ function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallCl const instantMs = chosenCandidate.instantMs return { instant: new Date(instantMs), - offsetMinutes: Math.round((utcGuess - instantMs) / 60_000), + offsetMinutes: (utcGuess - instantMs) / 60_000, } } @@ -235,18 +260,28 @@ function resolveZonedWallClock(wallClock: string, timeZone: string): ZonedWallCl * whose own offset reproduces the requested wall-clock, which is correct for any * date (including future ones whose offset differs from today's) and across DST: * a naive single pass reads the offset on the wrong side of a same-day boundary - * — notably the autumn fall-back hour — and lands an hour off. For an ambiguous - * fall-back wall-clock the later, post-transition instant is chosen; a + * — notably the autumn fall-back hour — and lands an hour off. An ambiguous + * fall-back wall-clock defaults to the later, post-transition instant, but + * callers preserving earlier semantics may request the earlier instant. A * wall-clock in the spring-forward gap (a nonexistent local hour) has no * self-consistent instant and resolves forward by the DST shift, matching how * calendar apps treat that once-a-year hour. */ -export function zonedWallClockToUtc(wallClock: string, timeZone: string): Date { - return resolveZonedWallClock(wallClock, timeZone).instant +export function zonedWallClockToUtc( + wallClock: string, + timeZone: string, + options?: ZonedWallClockOptions +): Date { + return resolveZonedWallClock(wallClock, timeZone, options).instant } /** Stamps a naive wall-clock with the offset selected by the shared timezone resolver. */ -export function zonedWallClockWithOffset(wallClock: string, timeZone: string): string { - const { offsetMinutes } = resolveZonedWallClock(wallClock, timeZone) +export function zonedWallClockWithOffset( + wallClock: string, + timeZone: string, + options?: ZonedWallClockOptions +): string { + const resolution = resolveZonedWallClock(wallClock, timeZone, options) + const offsetMinutes = roundOffsetMinutes(resolution.offsetMinutes, options) return `${wallClock}${formatUtcOffsetSuffix(offsetMinutes)}` } diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts index 900575c2e50..a63886a4a5c 100644 --- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts +++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts @@ -140,7 +140,7 @@ describe('ttl columns', () => { }) expect(COLUMN_TYPE_REGISTRY.ttl.coerce('2023-11-14T22:13:20.123Z', column)).toEqual({ ok: true, - value: 1_700_000_000, + value: 1_700_000_001, }) expect(COLUMN_TYPE_REGISTRY.ttl.coerce('not-a-date', column)).toEqual({ ok: false }) expect(COLUMN_TYPE_REGISTRY.ttl.coerce(1_700_000_000.5, column)).toEqual({ ok: false }) diff --git a/apps/sim/lib/table/column-types/ttl.test.ts b/apps/sim/lib/table/column-types/ttl.test.ts index db0984509db..f0f9c8e91e5 100644 --- a/apps/sim/lib/table/column-types/ttl.test.ts +++ b/apps/sim/lib/table/column-types/ttl.test.ts @@ -3,6 +3,12 @@ */ import { describe, expect, it } from 'vitest' +import { + formatInstantInTimeZone, + getSupportedTimezones, + zonedWallClockToUtc, +} 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' @@ -15,4 +21,133 @@ describe('TTL column type', () => { retypeCellRewrite(1_700_000_000, column({ type: 'date' }), column({ type: 'ttl' })) ).toEqual({ value: '2023-11-14T22:13:20Z' }) }) + + 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'], + ['America/New_York', '2026-01-15T09:00:30', '2026-01-15T14:00:30.000Z'], + ['Asia/Kathmandu', '2026-06-15T09:00:30', '2026-06-15T03:15:30.000Z'], + ['Australia/Lord_Howe', '2026-06-15T09:00:30', '2026-06-14T22:30:30.000Z'], + ])('stores %s wall-clock input as the expected epoch second', (timezone, input, iso) => { + expect(parseTtlEpochSeconds(input, { timezone })).toBe(Date.parse(iso) / 1000) + }) + + it.each([ + ['America/New_York', '2026-11-01T01:30', '2026-11-01T06:30:00.000Z'], + ['Europe/Berlin', '2026-10-25T02:30', '2026-10-25T01:30:00.000Z'], + ['Australia/Lord_Howe', '2026-04-05T01:45', '2026-04-04T15:15:00.000Z'], + ])( + 'chooses the later expiration when %s repeats a wall-clock time', + (timezone, input, laterInstant) => { + expect(parseTtlEpochSeconds(input, { timezone })).toBe(Date.parse(laterInstant) / 1000) + } + ) + + it.each([ + ['America/New_York', '2026-03-08T02:30', '2026-03-08T07:30:00.000Z'], + ['Europe/Berlin', '2026-03-29T02:30', '2026-03-29T01:30:00.000Z'], + ['Australia/Lord_Howe', '2026-10-04T02:15', '2026-10-03T15:45:00.000Z'], + ])( + 'moves a nonexistent %s wall-clock expiration forward across the gap', + (timezone, input, compatibleInstant) => { + expect(parseTtlEpochSeconds(input, { timezone })).toBe(Date.parse(compatibleInstant) / 1000) + } + ) + + it('rounds fractional instants up so expiration is never stored early', () => { + expect(parseTtlEpochSeconds('2023-11-14T22:13:20.001Z')).toBe(1_700_000_001) + expect(parseTtlEpochSeconds('2023-11-14T22:13:20.999Z')).toBe(1_700_000_001) + expect(parseTtlEpochSeconds('2023-11-14T22:13:20.0001Z')).toBe(1_700_000_001) + expect(parseTtlEpochSeconds('2023-11-14t22:13:20.001Z')).toBe(1_700_000_001) + expect(parseTtlEpochSeconds('2023-11-14t17:13:20.001', { timezone: 'America/New_York' })).toBe( + 1_700_000_001 + ) + expect(parseTtlEpochSeconds(new Date('2023-11-14T22:13:20.001Z'))).toBe(1_700_000_001) + expect(parseTtlEpochSeconds('2023-11-14T22:13:20.000Z')).toBe(1_700_000_000) + }) + + it('rounds historical sub-minute timezone offsets toward a later expiration', () => { + const timezone = 'Africa/Monrovia' + const exactInstant = Date.parse('1970-01-01T00:44:30Z') / 1000 + + expect(parseTtlEpochSeconds('1970-01-01T00:00:00', { timezone })).toBeGreaterThanOrEqual( + exactInstant + ) + + const editable = ttlColumnType.formatForInput(exactInstant, column({ type: 'ttl' }), { + timezone, + }) + expect(editable).toBe('1970-01-01T00:00:00-00:45') + expect(parseTtlEpochSeconds(editable, { timezone })).toBeGreaterThanOrEqual(exactInstant) + }) + + it('never resolves representative wall clocks early in any supported timezone', () => { + for (const timezone of getSupportedTimezones()) { + for (const wallClock of ['1970-01-01T00:00:00', '2026-06-15T09:00:30']) { + const exactSecond = Math.ceil( + zonedWallClockToUtc(wallClock, timezone, { ambiguousTime: 'later' }).getTime() / 1000 + ) + expect( + parseTtlEpochSeconds(wallClock, { timezone }), + `${timezone} ${wallClock}` + ).toBeGreaterThanOrEqual(exactSecond) + } + } + }) + + it('never moves stored epoch seconds earlier when formatted in any supported timezone', () => { + for (const timezone of getSupportedTimezones()) { + for (const seconds of [0, Date.parse('2026-11-01T06:30:00Z') / 1000]) { + const editable = ttlColumnType.formatForInput(seconds, column({ type: 'ttl' }), { + timezone, + }) + expect( + parseTtlEpochSeconds(editable, { timezone }), + `${timezone} ${editable}` + ).toBeGreaterThanOrEqual(seconds) + } + } + }) + + it('uses the timezone supplied for each call rather than a previous setting', () => { + const input = '2026-06-15T09:00:30' + + expect(parseTtlEpochSeconds(input, { timezone: 'America/New_York' })).toBe( + Date.parse('2026-06-15T13:00:30Z') / 1000 + ) + expect(parseTtlEpochSeconds(input, { timezone: 'Asia/Kathmandu' })).toBe( + Date.parse('2026-06-15T03:15:30Z') / 1000 + ) + expect(parseTtlEpochSeconds(input, { timezone: 'America/New_York' })).toBe( + Date.parse('2026-06-15T13:00:30Z') / 1000 + ) + }) + + it('round-trips the same epoch after the editor timezone changes', () => { + const seconds = Date.parse('2026-11-01T06:30:00Z') / 1000 + + for (const timezone of [ + 'UTC', + 'America/Los_Angeles', + 'America/New_York', + 'Asia/Kathmandu', + 'Australia/Lord_Howe', + ]) { + const editable = ttlColumnType.formatForInput(seconds, column({ type: 'ttl' }), { timezone }) + expect(editable).toBe(formatInstantInTimeZone(new Date(seconds * 1000), timezone)) + expect(parseTtlEpochSeconds(editable, { timezone })).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' + + expect(zonedWallClockToUtc(input, timezone, { ambiguousTime: 'earlier' }).toISOString()).toBe( + '2026-11-01T05:30:00.000Z' + ) + expect(parseTtlEpochSeconds(input, { timezone })).toBe( + Date.parse('2026-11-01T06:30:00Z') / 1000 + ) + }) }) diff --git a/apps/sim/lib/table/column-types/ttl.ts b/apps/sim/lib/table/column-types/ttl.ts index 87081e9bc78..35d66d888a2 100644 --- a/apps/sim/lib/table/column-types/ttl.ts +++ b/apps/sim/lib/table/column-types/ttl.ts @@ -10,11 +10,23 @@ import type { ColumnDefinition } from '@/lib/table/types' const NUMERIC_VALUE_PATTERN = /^-?\d+(?:\.\d+)?$/ const ISO_DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})(?:$|[T ])/i +const FRACTIONAL_SECONDS_PATTERN = /[T ]\d{1,2}:\d{2}:\d{2}\.(\d+)/i function isRepresentableEpochSeconds(value: number): boolean { return Number.isSafeInteger(value) && !Number.isNaN(new Date(value * 1000).getTime()) } +/** Rounds toward the future so integer-second storage can never expire an instant early. */ +function epochSecondAtOrAfter(milliseconds: number): number { + return Math.ceil(milliseconds / 1000) +} + +/** Whether an ISO-shaped input names any instant after its whole second. */ +function hasFractionalSecond(value: string): boolean { + const digits = value.match(FRACTIONAL_SECONDS_PATTERN)?.[1] + return digits ? /[1-9]/.test(digits) : false +} + /** Converts a TTL cell input to integer Unix epoch seconds. */ export function parseTtlEpochSeconds( value: unknown, @@ -24,7 +36,7 @@ export function parseTtlEpochSeconds( if (value instanceof Date) { const milliseconds = value.getTime() - return Number.isNaN(milliseconds) ? null : Math.floor(milliseconds / 1000) + return Number.isNaN(milliseconds) ? null : epochSecondAtOrAfter(milliseconds) } if (typeof value !== 'string') return null @@ -36,17 +48,22 @@ export function parseTtlEpochSeconds( return isRepresentableEpochSeconds(numeric) ? numeric : null } - const normalized = normalizeDateCellValue(trimmed, options) + const ttlOptions: NormalizeDateCellOptions = { + ...options, + ambiguousTime: 'later', + offsetMinuteRounding: 'floor', + } + const normalized = normalizeDateCellValue(trimmed, ttlOptions) if (normalized === null) return null const instant = /^\d{4}-\d{2}-\d{2}$/.test(normalized) - ? normalizeDateCellValue(`${normalized}T00:00:00`, options) + ? normalizeDateCellValue(`${normalized}T00:00:00`, ttlOptions) : normalized if (instant === null) return null const inputIsoDate = trimmed.match(ISO_DATE_PREFIX_PATTERN)?.[1] if (inputIsoDate && instant.slice(0, 10) !== inputIsoDate) return null - const milliseconds = Date.parse(instant) + const milliseconds = Date.parse(instant) + (hasFractionalSecond(trimmed) ? 1 : 0) if (Number.isNaN(milliseconds)) return null - const seconds = Math.floor(milliseconds / 1000) + const seconds = epochSecondAtOrAfter(milliseconds) return isRepresentableEpochSeconds(seconds) ? seconds : null } @@ -59,7 +76,7 @@ function epochSecondsToIso(value: unknown): string | null { function epochSecondsToEditable(value: unknown, timeZone?: string): string | null { const iso = epochSecondsToIso(value) if (!iso || !timeZone) return iso - return formatInstantInTimeZone(new Date(iso), timeZone) + return formatInstantInTimeZone(new Date(iso), timeZone, { offsetMinuteRounding: 'floor' }) } export const ttlColumnType: ColumnTypeDefinition = { diff --git a/apps/sim/lib/table/dates.test.ts b/apps/sim/lib/table/dates.test.ts index 3ab6a905a1a..5126c5d7724 100644 --- a/apps/sim/lib/table/dates.test.ts +++ b/apps/sim/lib/table/dates.test.ts @@ -116,7 +116,7 @@ describe('normalizeDateCellValue', () => { }) it.each([ - ['America/New_York', '2026-11-01 01:30:00', '2026-11-01T01:30:00-05:00'], + ['America/New_York', '2026-11-01 01:30:00', '2026-11-01T01:30:00-04:00'], ['America/New_York', '2026-03-08 02:30:00', '2026-03-08T02:30:00-05:00'], ['Asia/Kathmandu', '2026-06-15 09:00:00', '2026-06-15T09:00:00+05:45'], ['Australia/Lord_Howe', '2026-06-15 09:00:00', '2026-06-15T09:00:00+10:30'], @@ -124,6 +124,20 @@ describe('normalizeDateCellValue', () => { expect(normalizeDateCellValue(input, { timezone })).toBe(expected) }) + it('uses each provided timezone independently when the setting changes', () => { + const input = '2026-06-15 09:00:30' + + expect(normalizeDateCellValue(input, { timezone: 'America/New_York' })).toBe( + '2026-06-15T09:00:30-04:00' + ) + expect(normalizeDateCellValue(input, { timezone: 'Asia/Kathmandu' })).toBe( + '2026-06-15T09:00:30+05:45' + ) + expect(normalizeDateCellValue(input, { timezone: 'America/New_York' })).toBe( + '2026-06-15T09:00:30-04:00' + ) + }) + it('ignores the zone option when the input carries an explicit offset', () => { expect( normalizeDateCellValue('2026-07-06T23:04:55.000Z', { timezone: 'America/New_York' }) @@ -160,6 +174,10 @@ describe('normalizeDateCellValue', () => { expect(normalizeDateCellValue('2026-07-06T24:00', { timezone: 'UTC' })).toBeNull() expect(normalizeDateCellValue('2026-07-06 24:00+00')).toBeNull() expect(normalizeDateCellValue('2026-07-06T12:60:00-04:00')).toBeNull() + expect(normalizeDateCellValue('02/30/2026')).toBeNull() + expect(normalizeDateCellValue('February 29, 2025')).toBeNull() + expect(normalizeDateCellValue('February 29, 2025 12:00')).toBeNull() + expect(normalizeDateCellValue('February 29, 2025 12:00', { timezone: 'UTC' })).toBeNull() }) it('accepts leap days and valid daylight-saving gap wall clocks', () => { diff --git a/apps/sim/lib/table/dates.ts b/apps/sim/lib/table/dates.ts index 041b984bfaa..88bfa6c28c0 100644 --- a/apps/sim/lib/table/dates.ts +++ b/apps/sim/lib/table/dates.ts @@ -23,9 +23,14 @@ * barrel (the barrel is server-tainted). */ -import { formatUtcOffsetSuffix, zonedWallClockWithOffset } from '@/lib/core/utils/timezone' +import { + formatUtcOffsetSuffix, + type ZonedWallClockOptions, + zonedWallClockWithOffset, +} from '@/lib/core/utils/timezone' const CALENDAR_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/ +const LOCALIZED_CALENDAR_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/ /** * Canonical (or canonical-enough legacy) instant: a literal wall time with an @@ -260,6 +265,14 @@ export interface NormalizeDateCellOptions { * zone. */ timezone?: string + /** + * Which instant to use when a naive wall time occurs twice during a DST + * fall-back. Ordinary date cells preserve their historical earlier-instant + * behavior; instant-like callers may explicitly choose `later`. + */ + ambiguousTime?: ZonedWallClockOptions['ambiguousTime'] + /** How sub-minute historical offsets are serialized to RFC 3339 minutes. */ + offsetMinuteRounding?: ZonedWallClockOptions['offsetMinuteRounding'] } /** @@ -281,6 +294,15 @@ export function normalizeDateCellValue( ? trimmed : null } + const localizedCalendar = trimmed.match(LOCALIZED_CALENDAR_DATE_PATTERN) + if (localizedCalendar) { + const month = Number(localizedCalendar[1]) + const day = Number(localizedCalendar[2]) + const year = Number(localizedCalendar[3]) + return isValidCalendarDay(year, month, day) + ? `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}` + : null + } const isoMatch = trimmed.match(WALL_INSTANT_PATTERN) const isoWallClock = isoMatch ? parseIsoWallClock(isoMatch) : undefined if (isoWallClock === null) return null @@ -290,6 +312,13 @@ export function normalizeDateCellValue( const ms = Date.parse(trimmed) if (Number.isNaN(ms)) return null const parsed = new Date(ms) + const monthNameCalendar = extractMonthNameCalendar(trimmed) + if ( + monthNameCalendar && + !isValidCalendarDay(monthNameCalendar.year, monthNameCalendar.month, monthNameCalendar.day) + ) { + return null + } if (!TIME_COMPONENT_PATTERN.test(trimmed)) { return ISO_REDUCED_DATE_PATTERN.test(trimmed) ? toUtcCalendarDate(parsed) @@ -304,7 +333,10 @@ export function normalizeDateCellValue( if (options?.timezone) { const wallClock = isoWallClock ?? localizedWallClock ?? parseNaiveWallClockAsUtc(trimmed) if (!wallClock) return null - return zonedWallClockWithOffset(wallClock, options.timezone) + return zonedWallClockWithOffset(wallClock, options.timezone, { + ambiguousTime: options.ambiguousTime ?? 'earlier', + offsetMinuteRounding: options.offsetMinuteRounding, + }) } return formatLocalFieldsAsWall(parsed, -parsed.getTimezoneOffset()) } diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index 45463296049..463c18d42de 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -181,6 +181,18 @@ describe('import', () => { ) expect(coerceValue('not-a-date', 'ttl')).toBe('not-a-date') }) + + it('applies the timezone supplied to each TTL import independently', () => { + const input = '2026-06-15 09:00:30' + + expect(coerceValue(input, 'ttl', { timezone: 'America/New_York' })).toBe( + Date.parse('2026-06-15T13:00:30Z') / 1000 + ) + expect(coerceValue(input, 'ttl', { timezone: 'Asia/Kathmandu' })).toBe( + Date.parse('2026-06-15T03:15:30Z') / 1000 + ) + expect(coerceValue('2023-11-14T22:13:20.001Z', 'ttl')).toBe(1_700_000_001) + }) }) describe('buildAutoMapping', () => { From 9d9872ec985b861e0fd955d059a1336983871f05 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:21:38 -0700 Subject: [PATCH 4/5] fix(tables): wait for timezone before TTL edits --- .../table-grid/cells/inline-editors.test.ts | 151 +++++++++++++++++- .../table-grid/cells/inline-editors.tsx | 73 +++++++-- .../hooks/queries/general-settings.test.ts | 24 ++- apps/sim/hooks/queries/general-settings.ts | 21 ++- 4 files changed, 250 insertions(+), 19 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 40834ad8eed..ac704a96607 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 @@ -11,9 +11,12 @@ import { } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors' import { cleanCellValue } from '@/app/workspace/[workspaceId]/tables/[tableId]/utils' -const { mockUseTimezone } = vi.hoisted(() => ({ mockUseTimezone: vi.fn() })) +const { mockToastError, mockUseTimezoneState } = vi.hoisted(() => ({ + mockToastError: vi.fn(), + mockUseTimezoneState: vi.fn(), +})) -vi.mock('@/hooks/queries/general-settings', () => ({ useTimezone: mockUseTimezone })) +vi.mock('@/hooks/queries/general-settings', () => ({ useTimezoneState: mockUseTimezoneState })) vi.mock('@sim/emcn', () => { const passthrough = ({ children }: { children?: ReactNode }) => children ?? null return { @@ -26,15 +29,24 @@ vi.mock('@sim/emcn', () => { Popover: passthrough, PopoverAnchor: () => null, PopoverContent: passthrough, - toast: { error: vi.fn() }, + toast: { error: mockToastError }, } }) const column = (type: ColumnDefinition['type']): ColumnDefinition => ({ name: 'expires_at', type }) +function changeInput(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + describe('dateEditorRawValue', () => { beforeEach(() => { vi.clearAllMocks() - mockUseTimezone.mockReturnValue('America/Los_Angeles') + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/Los_Angeles', + status: 'ready', + }) }) it('leaves TTL drafts for TTL coercion to resolve safely', () => { @@ -73,7 +85,10 @@ describe('dateEditorRawValue', () => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true act(() => root.render(createElement(InlineEditor, props))) - mockUseTimezone.mockReturnValue('America/New_York') + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/New_York', + status: 'ready', + }) act(() => root.render(createElement(InlineEditor, props))) const input = container.querySelector('input') @@ -86,4 +101,130 @@ describe('dateEditorRawValue', () => { act(() => root.unmount()) container.remove() }) + + it('waits for the saved timezone before creating a TTL 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: Date.parse('2026-06-15T13:00:30Z') / 1000, + column: column('ttl'), + onSave, + onCancel: vi.fn(), + } + + act(() => root.render(createElement(InlineEditor, props))) + + expect(container.querySelector('input')).toMatchObject({ + disabled: true, + placeholder: 'Loading timezone...', + }) + + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/Los_Angeles', + status: 'ready', + }) + act(() => root.render(createElement(InlineEditor, props))) + + const input = container.querySelector('input') as HTMLInputElement + expect(input.disabled).toBe(false) + act(() => changeInput(input, '09/01/2026 9:00 AM')) + act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) + + expect(onSave).toHaveBeenCalledWith(Date.parse('2026-09-01T16:00:00Z') / 1000, '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) + const root = createRoot(container) + const onSave = vi.fn() + + act(() => + root.render( + createElement(InlineEditor, { + value: Date.parse('2026-06-15T13:00:30Z') / 1000, + column: column('ttl'), + onSave, + onCancel: vi.fn(), + }) + ) + ) + + const input = container.querySelector('input') as HTMLInputElement + act(() => changeInput(input, '02/30/2026 1:30 AM')) + act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) + + expect(onSave).not.toHaveBeenCalled() + expect(mockToastError).toHaveBeenCalledWith('Invalid expiration date') + act(() => root.unmount()) + container.remove() + }) + + it.each([ + { caseName: 'a historical sub-minute offset', timezone: 'Africa/Monrovia', value: 2670 }, + { + caseName: 'the far-future representable boundary', + timezone: 'Asia/Tokyo', + value: 253_402_300_799, + }, + ])('preserves the exact epoch for $caseName when untouched', ({ timezone, value }) => { + mockUseTimezoneState.mockReturnValue({ timezone, status: 'ready' }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onSave = vi.fn() + + act(() => + root.render( + createElement(InlineEditor, { + value, + column: column('ttl'), + onSave, + onCancel: vi.fn(), + }) + ) + ) + + const input = container.querySelector('input') as HTMLInputElement + act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) + + expect(onSave).toHaveBeenCalledWith(value, 'enter') + act(() => root.unmount()) + container.remove() + }) + + it('cancels TTL editing when the saved timezone cannot be loaded', () => { + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/Los_Angeles', + status: 'error', + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onCancel = vi.fn() + + act(() => + root.render( + createElement(InlineEditor, { + value: 2670, + column: column('ttl'), + onSave: vi.fn(), + onCancel, + }) + ) + ) + + expect(onCancel).toHaveBeenCalledOnce() + expect(mockToastError).toHaveBeenCalledWith('Could not load timezone') + 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 8437480f01e..1ed363c626c 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,7 +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 { useTimezone } from '@/hooks/queries/general-settings' +import { useTimezoneState } from '@/hooks/queries/general-settings' import type { SaveReason } from '../../../types' import { cleanCellValue, @@ -68,13 +68,46 @@ function handleEditorWheel(e: React.WheelEvent) { * edits update the draft in place — the day pick keeps the time-of-day * (including seconds), the time field keeps the day — and Enter/blur commits. */ -function InlineDateEditor({ +function InlineDateEditor(props: InlineEditorProps) { + const { column, onCancel } = props + const timezoneState = useTimezoneState() + const ttlTimezoneUnavailable = column.type === 'ttl' && timezoneState.status !== 'ready' + + useEffect(() => { + if (column.type !== 'ttl' || timezoneState.status !== 'error') return + toast.error('Could not load timezone') + onCancel() + }, [column.type, onCancel, timezoneState.status]) + + if (ttlTimezoneUnavailable) { + return ( + + ) + } + + return +} + +interface ReadyInlineDateEditorProps extends InlineEditorProps { + initialTimeZone: string +} + +function ReadyInlineDateEditor({ value, column, initialCharacter, onSave, onCancel, -}: InlineEditorProps) { + initialTimeZone, +}: ReadyInlineDateEditorProps) { const inputRef = useRef(null) const popoverRef = useRef(null) const doneRef = useRef(false) @@ -83,9 +116,8 @@ function InlineDateEditor({ * and refocuses while a popover interaction is in flight (covers browsers * where buttons don't take focus on click). */ const popoverPointerAtRef = useRef(0) - const effectiveTimeZone = useTimezone() /** Keep one wall-clock interpretation for the lifetime of this edit. */ - const editTimeZoneRef = useRef(effectiveTimeZone) + const editTimeZoneRef = useRef(initialTimeZone) const timeZone = editTimeZoneRef.current const storedValue = formatValueForInput(value, column.type, timeZone) @@ -133,26 +165,45 @@ function InlineDateEditor({ // silently shifting the instant of a value someone else wrote. if (storageVal === undefined && initialCharacter === undefined && current === initialDraft) { doneRef.current = true - onSave(storedValue ? cleanCellValue(storedValue, column, timeZone) : null, reason) + onSave( + column.type === 'ttl' + ? (value ?? null) + : storedValue + ? cleanCellValue(storedValue, column, timeZone) + : null, + reason + ) return } const raw = dateEditorRawValue(current, column, timeZone, storageVal) - if (raw && Number.isNaN(Date.parse(raw))) { + const cleaned = raw ? cleanCellValue(raw, column, timeZone) : null + const parseError = columnTypeOf(column).parseErrorMessage + if (raw && cleaned === null && parseError) { if (reason === 'blur') { - if (!invalid) toast.error('Invalid date') + if (!invalid) toast.error(parseError) doneRef.current = true onCancel() } else { - toast.error('Invalid date') + toast.error(parseError) setInvalid(true) inputRef.current?.focus() } return } doneRef.current = true - onSave(raw ? cleanCellValue(raw, column, timeZone) : null, reason) + onSave(cleaned, reason) }, - [invalid, onSave, onCancel, timeZone, initialDraft, initialCharacter, storedValue, column] + [ + invalid, + onSave, + onCancel, + timeZone, + initialDraft, + initialCharacter, + storedValue, + column, + value, + ] ) const handleKeyDown = useCallback( diff --git a/apps/sim/hooks/queries/general-settings.test.ts b/apps/sim/hooks/queries/general-settings.test.ts index d22773644ac..528bff952c7 100644 --- a/apps/sim/hooks/queries/general-settings.test.ts +++ b/apps/sim/hooks/queries/general-settings.test.ts @@ -15,7 +15,7 @@ vi.mock('@tanstack/react-query', () => ({ })) vi.mock('@/lib/core/utils/timezone', () => ({ getBrowserTimezone: mockGetBrowserTimezone })) -import { useTimezone } from '@/hooks/queries/general-settings' +import { useTimezone, useTimezoneState } from '@/hooks/queries/general-settings' describe('useTimezone', () => { beforeEach(() => { @@ -27,6 +27,10 @@ describe('useTimezone', () => { mockUseQuery.mockReturnValue({ data: { timezone: null } }) expect(useTimezone()).toBe('America/Los_Angeles') + expect(useTimezoneState()).toEqual({ + timezone: 'America/Los_Angeles', + status: 'ready', + }) }) it('uses a saved timezone instead of the browser fallback', () => { @@ -46,4 +50,22 @@ describe('useTimezone', () => { timezone = null expect(useTimezone()).toBe('America/Los_Angeles') }) + + it('distinguishes an unresolved preference from an explicit browser fallback', () => { + mockUseQuery.mockReturnValue({ data: undefined, isError: false }) + + expect(useTimezoneState()).toEqual({ + timezone: 'America/Los_Angeles', + status: 'loading', + }) + }) + + it('reports an unavailable preference instead of treating it as resolved', () => { + mockUseQuery.mockReturnValue({ data: undefined, isError: true }) + + expect(useTimezoneState()).toEqual({ + timezone: 'America/Los_Angeles', + status: 'error', + }) + }) }) diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index 2c3efa310ad..b23585307db 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -149,8 +149,25 @@ export function useBillingUsageNotifications(): boolean { * captured so scheduling honors the account preference rather than the device. */ export function useTimezone(): string { - const { data } = useGeneralSettings() - return data?.timezone ?? getBrowserTimezone() + return useTimezoneState().timezone +} + +export interface TimezoneState { + timezone: string + status: 'loading' | 'ready' | '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. + */ +export function useTimezoneState(): TimezoneState { + const { data, isError } = useGeneralSettings() + return { + timezone: data?.timezone ?? getBrowserTimezone(), + status: data ? 'ready' : isError ? 'error' : 'loading', + } } /** From 8168a84d8fd3f7ffd5c9609a0aa122213a175d00 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:21:56 -0700 Subject: [PATCH 5/5] feat(tables): gate row TTL expiration --- apps/sim/.env.example | 1 + .../cron/cleanup-table-row-ttl/route.test.ts | 37 +++++++++-- .../api/cron/cleanup-table-row-ttl/route.ts | 6 ++ .../app/workspace/[workspaceId]/layout.tsx | 61 ++++++++++--------- .../providers/feature-flags-provider.tsx | 26 ++++++++ .../column-config-sidebar.tsx | 6 +- .../column-type-limits.test.ts | 8 ++- .../column-types.test.ts | 31 +++++++--- .../column-config-sidebar/column-types.ts | 12 +++- .../new-column-dropdown.tsx | 4 +- .../components/table-grid/table-grid.tsx | 3 + .../[workspaceId]/tables/[tableId]/table.tsx | 5 ++ .../background/cleanup-table-row-ttl.test.ts | 18 ++++++ apps/sim/background/cleanup-table-row-ttl.ts | 5 ++ apps/sim/lib/core/config/env.ts | 1 + .../sim/lib/core/config/feature-flags.test.ts | 23 +++++++ apps/sim/lib/core/config/feature-flags.ts | 6 ++ apps/sim/lib/table/columns/service.ts | 5 ++ apps/sim/lib/table/columns/ttl-limit.test.ts | 33 ++++++++-- apps/sim/lib/table/service.test.ts | 18 ++++++ apps/sim/lib/table/service.ts | 5 ++ apps/sim/lib/table/ttl-availability.test.ts | 34 +++++++++++ apps/sim/lib/table/ttl-availability.ts | 13 ++++ helm/sim/values.yaml | 1 + 24 files changed, 310 insertions(+), 52 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx create mode 100644 apps/sim/lib/table/ttl-availability.test.ts create mode 100644 apps/sim/lib/table/ttl-availability.ts diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 443ff1d2da9..1e4c16df28c 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -201,6 +201,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams # FORKING_ENABLED= # Workspace forks # CREDENTIAL_GROUPS= # Enterprise managed OAuth collections +# TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup # ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only # Instance organization (Optional). Most enterprise features read their settings from the diff --git a/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts index 0b01e026aa3..0e37ac27f9f 100644 --- a/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts +++ b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts @@ -4,14 +4,20 @@ import { createMockRequest } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockEnqueue, mockGetJobQueue, mockVerifyCronAuth } = vi.hoisted(() => ({ - mockEnqueue: vi.fn(), - mockGetJobQueue: vi.fn(), - mockVerifyCronAuth: vi.fn(), -})) +const { mockEnqueue, mockGetJobQueue, mockIsTableRowTtlEnabled, mockVerifyCronAuth } = vi.hoisted( + () => ({ + mockEnqueue: vi.fn(), + mockGetJobQueue: vi.fn(), + mockIsTableRowTtlEnabled: vi.fn(), + mockVerifyCronAuth: vi.fn(), + }) +) vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth })) vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue })) +vi.mock('@/lib/table/ttl-availability', () => ({ + isTableRowTtlEnabled: mockIsTableRowTtlEnabled, +})) import { GET } from '@/app/api/cron/cleanup-table-row-ttl/route' @@ -21,6 +27,7 @@ describe('table row TTL cleanup route', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-08-22T17:12:00Z')) mockVerifyCronAuth.mockReturnValue(null) + mockIsTableRowTtlEnabled.mockResolvedValue(true) mockEnqueue.mockResolvedValue('job-ttl-1') mockGetJobQueue.mockResolvedValue({ enqueue: mockEnqueue }) }) @@ -102,4 +109,24 @@ describe('table row TTL cleanup route', () => { expect(response.status).toBe(401) expect(mockGetJobQueue).not.toHaveBeenCalled() }) + + it('does not enqueue cleanup while the feature is disabled', async () => { + mockIsTableRowTtlEnabled.mockResolvedValue(false) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/cron/cleanup-table-row-ttl' + ) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + triggered: false, + reason: 'feature-disabled', + }) + expect(mockGetJobQueue).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts index a8a62dd53b2..428cbd5ef68 100644 --- a/apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts +++ b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { getJobQueue } from '@/lib/core/async-jobs' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability' export const dynamic = 'force-dynamic' @@ -14,6 +15,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const authError = verifyCronAuth(request, 'table row TTL cleanup') if (authError) return authError + if (!(await isTableRowTtlEnabled())) { + logger.info('Table row TTL cleanup skipped because the feature is disabled') + return NextResponse.json({ triggered: false, reason: 'feature-disabled' }) + } + const queue = await getJobQueue() const scheduleWindow = Math.floor(Date.now() / TTL_CLEANUP_INTERVAL_MS) const jobId = await queue.enqueue( diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index eb58536782e..24603611303 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -4,6 +4,7 @@ import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' +import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired' @@ -16,6 +17,7 @@ import { import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader' import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader' import { DesktopOAuthConnectListener } from '@/app/workspace/[workspaceId]/providers/desktop-oauth-connect-listener' +import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader' @@ -45,7 +47,7 @@ export default async function WorkspaceLayout({ } const activeOrganizationId = getActiveOrganizationId(session) - const [cookieStore, initialOrgSettings] = await Promise.all([ + const [cookieStore, initialOrgSettings, , tableRowTtlEnabled] = await Promise.all([ cookies(), hostContext.hostOrganizationId ? getOrgWhitelabelSettings(hostContext.hostOrganizationId) @@ -57,38 +59,41 @@ export default async function WorkspaceLayout({ hostContext, activeOrganizationId ), + isTableRowTtlEnabled(), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' return ( - - - - - - - - - -
- - - - - - {children} - - -
-
-
-
-
+ + + + + + + + + + +
+ + + + + + {children} + + +
+
+
+
+
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx new file mode 100644 index 00000000000..631b6ed2094 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx @@ -0,0 +1,26 @@ +'use client' + +import { createContext, type ReactNode, useContext } from 'react' + +export interface WorkspaceFeatureFlags { + 'table-row-ttl': boolean +} + +const FeatureFlagsContext = createContext(null) + +interface FeatureFlagsProviderProps { + children: ReactNode + flags: WorkspaceFeatureFlags +} + +/** Makes server-resolved runtime flags available to workspace client surfaces. */ +export function FeatureFlagsProvider({ children, flags }: FeatureFlagsProviderProps) { + return {children} +} + +/** Reads one server-resolved runtime flag without exposing AppConfig to the browser. */ +export function useFeatureFlag(name: keyof WorkspaceFeatureFlags): boolean { + const flags = useContext(FeatureFlagsContext) + if (!flags) throw new Error('useFeatureFlag must be used within FeatureFlagsProvider') + return flags[name] +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx index 2cf4e8dd00d..303742fdd2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx @@ -53,6 +53,7 @@ interface ColumnConfigSidebarProps { /** Existing column record for `mode: 'edit'`; ignored otherwise. */ existingColumn: ColumnDefinition | null allColumns: readonly ColumnDefinition[] + tableRowTtlEnabled: boolean workspaceId: string tableId: string /** Notify parent of a rename so it can rewrite local `columnOrder` / @@ -104,6 +105,7 @@ function ColumnConfigBody({ onClose, existingColumn, allColumns, + tableRowTtlEnabled, workspaceId, tableId, onColumnRename, @@ -276,7 +278,9 @@ function ColumnConfigBody({
Type option.type !== 'workflow') .map((option) => ({ label: option.label, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-type-limits.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-type-limits.test.ts index f22a77cd149..f4325de8ae2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-type-limits.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-type-limits.test.ts @@ -33,7 +33,9 @@ describe('column type picker limits', () => { option.maxPerTable = 1 Object.assign(definition, { maxPerTable: 1 }) - const result = columnTypeOptionsForTable([{ name: 'first', type: 'string' }]) + const result = columnTypeOptionsForTable([{ name: 'first', type: 'string' }], undefined, { + tableRowTtlEnabled: true, + }) const stringOption = result.find((candidate) => candidate.type === 'string') expect(stringOption?.disabledReason).toBe('Only one Text column allowed per table') @@ -44,7 +46,9 @@ describe('column type picker limits', () => { Object.assign(definition, { maxPerTable: 1 }) const currentColumn = { name: 'first', type: 'string' } as const - const result = columnTypeOptionsForTable([currentColumn], currentColumn) + const result = columnTypeOptionsForTable([currentColumn], currentColumn, { + tableRowTtlEnabled: true, + }) const stringOption = result.find((candidate) => candidate.type === 'string') expect(stringOption?.disabledReason).toBeUndefined() diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.test.ts index 4ee012d24ce..c8c4d6b6fe7 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.test.ts @@ -9,22 +9,35 @@ describe('columnTypeOptionsForTable', () => { const ttlColumn: ColumnDefinition = { name: 'expires_at', type: 'ttl' } it('disables TTL with an explanation when the table already has one', () => { - const availableTtl = columnTypeOptionsForTable([{ name: 'name', type: 'string' }]).find( - (option) => option.type === 'ttl' - ) - const unavailableTtl = columnTypeOptionsForTable([ttlColumn]).find( - (option) => option.type === 'ttl' - ) + const availableTtl = columnTypeOptionsForTable([{ name: 'name', type: 'string' }], undefined, { + tableRowTtlEnabled: true, + }).find((option) => option.type === 'ttl') + const unavailableTtl = columnTypeOptionsForTable([ttlColumn], undefined, { + tableRowTtlEnabled: true, + }).find((option) => option.type === 'ttl') expect(availableTtl?.disabledReason).toBeUndefined() expect(unavailableTtl?.disabledReason).toBe('Only one TTL column allowed per table') }) it('keeps TTL enabled while editing the existing TTL column', () => { - const ttlOption = columnTypeOptionsForTable([ttlColumn], ttlColumn).find( - (option) => option.type === 'ttl' - ) + const ttlOption = columnTypeOptionsForTable([ttlColumn], ttlColumn, { + tableRowTtlEnabled: true, + }).find((option) => option.type === 'ttl') expect(ttlOption?.disabledReason).toBeUndefined() }) + + it('hides TTL while disabled unless editing an existing TTL column', () => { + expect( + columnTypeOptionsForTable([], undefined, { tableRowTtlEnabled: false }).some( + (option) => option.type === 'ttl' + ) + ).toBe(false) + expect( + columnTypeOptionsForTable([ttlColumn], ttlColumn, { tableRowTtlEnabled: false }).some( + (option) => option.type === 'ttl' + ) + ).toBe(true) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts index 4a4f7229113..5a4d0bf2a62 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts @@ -18,6 +18,10 @@ export interface ColumnTypeOption { disabledReason?: string } +interface ColumnTypeAvailability { + tableRowTtlEnabled: boolean +} + /** * Real column types come from the registry — adding one there makes it appear * in every picker automatically. `workflow` is appended because it is a UI @@ -42,9 +46,13 @@ function columnTypeLimitMessage(label: string, maxPerTable: number): string { /** Picker entries with unavailable cardinality-limited types marked as disabled. */ export function columnTypeOptionsForTable( columns: readonly ColumnDefinition[], - currentColumn?: ColumnDefinition | null + currentColumn: ColumnDefinition | null | undefined, + availability: ColumnTypeAvailability ): ColumnTypeOption[] { - return COLUMN_TYPE_OPTIONS.map((option) => { + return COLUMN_TYPE_OPTIONS.filter( + (option) => + option.type !== 'ttl' || availability.tableRowTtlEnabled || currentColumn?.type === 'ttl' + ).map((option) => { if (option.type === 'workflow') return option if (currentColumn?.type === option.type) return option if (option.maxPerTable === undefined) return option diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx index 3c79f2fb93d..2b814ecca15 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx @@ -22,6 +22,7 @@ const CELL_HEADER = interface NewColumnDropdownProps { columns: readonly ColumnDefinition[] + tableRowTtlEnabled: boolean /** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders * the in-table column-header `` trigger. Same dropdown content either way. */ trigger: 'header' | 'inline-header' @@ -82,6 +83,7 @@ function ColumnTypeMenuItem({ option, onSelect }: ColumnTypeMenuItemProps) { */ export function NewColumnDropdown({ columns, + tableRowTtlEnabled, trigger, disabled, onPickType, @@ -137,7 +139,7 @@ export function NewColumnDropdown({ - {columnTypeOptionsForTable(columns).map((option) => { + {columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled }).map((option) => { const onSelect = option.type === 'workflow' ? onPickWorkflow diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 8a002326e1e..3f11c945103 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -170,6 +170,7 @@ interface TableGridProps { workspaceId?: string tableId?: string embedded?: boolean + tableRowTtlEnabled: boolean /** Remote collaborators' cell selections, rendered as presence overlays. */ remoteSelections: RemoteTableSelection[] /** Broadcast the local viewer's cell selection to the table presence room. */ @@ -432,6 +433,7 @@ export function TableGrid({ workspaceId: propWorkspaceId, tableId: propTableId, embedded, + tableRowTtlEnabled, remoteSelections, emitCellSelection, locks, @@ -4834,6 +4836,7 @@ export function TableGrid({ {userPermissions.canEdit && ( ({ mockDeleteExecute: vi.fn(), mockListExecute: vi.fn(), + mockIsTableRowTtlEnabled: vi.fn(), mockSignalTableRowsChanged: vi.fn(), mockTask: vi.fn((config: unknown) => config), mockWithLockedTable: vi.fn(), @@ -24,6 +26,9 @@ vi.mock('@sim/db', () => ({ vi.mock('@trigger.dev/sdk', () => ({ task: mockTask })) vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalTableRowsChanged })) vi.mock('@/lib/table/service', () => ({ withLockedTable: mockWithLockedTable })) +vi.mock('@/lib/table/ttl-availability', () => ({ + isTableRowTtlEnabled: mockIsTableRowTtlEnabled, +})) import { cleanupTableRowTtlTask, runCleanupTableRowTtl } from '@/background/cleanup-table-row-ttl' @@ -37,6 +42,7 @@ const table = { describe('table row TTL cleanup', () => { beforeEach(() => { vi.clearAllMocks() + mockIsTableRowTtlEnabled.mockResolvedValue(true) mockListExecute.mockResolvedValue([{ id: table.id, workspaceId: table.workspaceId }]) mockWithLockedTable.mockImplementation( async ( @@ -96,6 +102,18 @@ describe('table row TTL cleanup', () => { expect(mockListExecute).not.toHaveBeenCalled() }) + it('does no work when the feature is disabled', async () => { + mockIsTableRowTtlEnabled.mockResolvedValue(false) + + await expect(runCleanupTableRowTtl()).resolves.toEqual({ + batches: 0, + deleted: 0, + limitReached: false, + }) + expect(mockListExecute).not.toHaveBeenCalled() + expect(mockWithLockedTable).not.toHaveBeenCalled() + }) + it('honors a delete lock re-read inside the table advisory lock', async () => { mockWithLockedTable.mockImplementationOnce(async (_tableId, mutate) => mutate( diff --git a/apps/sim/background/cleanup-table-row-ttl.ts b/apps/sim/background/cleanup-table-row-ttl.ts index 3243dbb2b99..20e4294afe2 100644 --- a/apps/sim/background/cleanup-table-row-ttl.ts +++ b/apps/sim/background/cleanup-table-row-ttl.ts @@ -9,6 +9,7 @@ import { signalTableRowsChanged } from '@/lib/table/events' import { assertRowDelete, TableLockedError } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' import { withLockedTable } from '@/lib/table/service' +import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability' const logger = createLogger('CleanupTableRowTtl') const cleanupDb = dbFor('cleanup') @@ -175,6 +176,10 @@ export async function runCleanupTableRowTtl( signal?: AbortSignal ): Promise { if (signal?.aborted) return { batches: 0, deleted: 0, limitReached: false } + if (!(await isTableRowTtlEnabled())) { + logger.info('Table row TTL cleanup skipped because the feature is disabled') + return { batches: 0, deleted: 0, limitReached: false } + } const nowEpochSeconds = Math.floor(Date.now() / 1000) const tableRefs = await listExpiredTtlTables(nowEpochSeconds) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 827c936ee4d..3ebc8275961 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -590,6 +590,7 @@ export const env = createEnv({ SESSION_POLICIES_ENABLED: z.boolean().optional(), // Enable org session policies on self-hosted (bypasses hosted requirements) FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements) TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route) + TABLE_ROW_TTL: z.boolean().optional(), // Enable table row expiration through TTL columns CREDENTIAL_GROUPS: z.boolean().optional(), // Enable enterprise Credential Groups globally // Organizations - for self-hosted deployments diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index b55b7b1ee0c..563a93f2866 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -12,6 +12,7 @@ const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ APPCONFIG_APPLICATION: 'sim-staging' as string | undefined, APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, TABLES_V2_API: undefined as boolean | undefined, + TABLE_ROW_TTL: undefined as boolean | undefined, CREDENTIAL_GROUPS: undefined as boolean | undefined, }, })) @@ -77,6 +78,7 @@ describe('getFeatureFlags', () => { // All registered flags should be present, disabled (env vars unset in test env) expect(flags['trigger-eu-region']).toEqual({ enabled: false }) expect(flags['tables-v2-api']).toEqual({ enabled: false }) + expect(flags['table-row-ttl']).toEqual({ enabled: false }) expect(flags['credential-groups']).toEqual({ enabled: false }) expect(mockFetch).not.toHaveBeenCalled() }) @@ -104,6 +106,7 @@ describe('getFeatureFlags', () => { const flags = await getFeatureFlags() expect(flags['trigger-eu-region']).toEqual({ enabled: false }) expect(flags['tables-v2-api']).toEqual({ enabled: false }) + expect(flags['table-row-ttl']).toEqual({ enabled: false }) expect(flags['credential-groups']).toEqual({ enabled: false }) }) @@ -224,3 +227,23 @@ describe('tables-v2-api flag', () => { expect(await isFeatureEnabled('tables-v2-api')).toBe(true) }) }) + +describe('table-row-ttl flag', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isAppConfigEnabled: false }) + envRef.TABLE_ROW_TTL = undefined + }) + + it('uses a global fallback switch off AppConfig', async () => { + expect(await isFeatureEnabled('table-row-ttl')).toBe(false) + + envRef.TABLE_ROW_TTL = true + expect(await isFeatureEnabled('table-row-ttl')).toBe(true) + }) + + it('uses the global AppConfig clause', async () => { + withAppConfig({ 'table-row-ttl': { enabled: true } }) + expect(await isFeatureEnabled('table-row-ttl')).toBe(true) + }) +}) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index b9f75143ff0..766e8106e50 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -72,6 +72,12 @@ const FEATURE_FLAGS = { 'AppConfig; off-AppConfig falls back to TABLES_V2_API.', fallback: 'TABLES_V2_API', }, + 'table-row-ttl': { + description: + 'Enable TTL columns and the scheduled cleanup that removes expired table rows. ' + + 'Global on/off only; existing TTL data remains readable when disabled.', + fallback: 'TABLE_ROW_TTL', + }, 'credential-groups': { description: 'Workspace-owned collections that gather managed OAuth credentials from external users. ' + diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index cfb5f349fa6..fa0a274d146 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -43,6 +43,7 @@ import { updateTableRowsWithDerivedSecretProvenance } from '@/lib/table/rows/sec import { assertValidSchema } from '@/lib/table/schema-invariants' import { selectValueToNames } from '@/lib/table/select-values' import { withLockedTable } from '@/lib/table/service' +import { assertTableRowTtlEnabled } from '@/lib/table/ttl-availability' import { scaledStatementTimeoutMs, setTableTxTimeouts } from '@/lib/table/tx' import type { ColumnDefinition, @@ -131,6 +132,8 @@ export async function addTableColumn( requestId: string, options?: ColumnMutationOptions ): Promise { + if (column.type === 'ttl') await assertTableRowTtlEnabled() + return withLockedTable( tableId, async (table, trx) => { @@ -857,6 +860,8 @@ export async function updateColumnType( requestId: string, options?: ColumnMutationOptions ): Promise { + if (data.newType === 'ttl') await assertTableRowTtlEnabled() + return withLockedTable( data.tableId, async (table, trx) => { diff --git a/apps/sim/lib/table/columns/ttl-limit.test.ts b/apps/sim/lib/table/columns/ttl-limit.test.ts index 30319a27843..10acbde287d 100644 --- a/apps/sim/lib/table/columns/ttl-limit.test.ts +++ b/apps/sim/lib/table/columns/ttl-limit.test.ts @@ -4,12 +4,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition, TableLocks } from '@/lib/table/types' -const { mockTimeoutExecute, mockWithLockedTable } = vi.hoisted(() => ({ - mockTimeoutExecute: vi.fn(), - mockWithLockedTable: vi.fn(), -})) +const { mockAssertTableRowTtlEnabled, mockTimeoutExecute, mockWithLockedTable } = vi.hoisted( + () => ({ + mockAssertTableRowTtlEnabled: vi.fn(), + mockTimeoutExecute: vi.fn(), + mockWithLockedTable: vi.fn(), + }) +) vi.mock('@/lib/table/service', () => ({ withLockedTable: mockWithLockedTable })) +vi.mock('@/lib/table/ttl-availability', () => ({ + assertTableRowTtlEnabled: mockAssertTableRowTtlEnabled, +})) import { addTableColumn, updateColumnType } from '@/lib/table/columns/service' @@ -53,12 +59,31 @@ const transaction = new Proxy( describe('TTL column mutation limit', () => { beforeEach(() => { vi.clearAllMocks() + mockAssertTableRowTtlEnabled.mockResolvedValue(undefined) mockTimeoutExecute.mockResolvedValue([]) mockWithLockedTable.mockImplementation(async (_tableId, mutate) => mutate(makeTable(), transaction) ) }) + it('rejects adding a TTL column before locking when the feature is disabled', async () => { + mockAssertTableRowTtlEnabled.mockRejectedValue(new Error('TTL columns are not enabled')) + + await expect( + addTableColumn('table-1', { name: 'expiry', type: 'ttl' }, 'request-1') + ).rejects.toThrow('TTL columns are not enabled') + expect(mockWithLockedTable).not.toHaveBeenCalled() + }) + + it('rejects retyping to TTL before locking when the feature is disabled', async () => { + mockAssertTableRowTtlEnabled.mockRejectedValue(new Error('TTL columns are not enabled')) + + await expect( + updateColumnType({ tableId: 'table-1', columnName: 'name', newType: 'ttl' }, 'request-1') + ).rejects.toThrow('TTL columns are not enabled') + expect(mockWithLockedTable).not.toHaveBeenCalled() + }) + it('rejects adding a second TTL column before persistence', async () => { await expect( addTableColumn('table-1', { name: 'another_expiry', type: 'ttl' }, 'request-1') diff --git a/apps/sim/lib/table/service.test.ts b/apps/sim/lib/table/service.test.ts index 3bde50fa497..7cd0f26478a 100644 --- a/apps/sim/lib/table/service.test.ts +++ b/apps/sim/lib/table/service.test.ts @@ -12,6 +12,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DbOrTx } from '@/lib/db/types' import type { TableSchema } from '@/lib/table/types' +const { mockAssertTableRowTtlEnabled } = vi.hoisted(() => ({ + mockAssertTableRowTtlEnabled: vi.fn(), +})) + vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceTablesChanged: vi.fn().mockResolvedValue(undefined), })) @@ -21,6 +25,10 @@ vi.mock('@/lib/table/billing', () => ({ notifyTableRowUsage: vi.fn(), })) +vi.mock('@/lib/table/ttl-availability', () => ({ + assertTableRowTtlEnabled: mockAssertTableRowTtlEnabled, +})) + import { createTable, getTableById } from '@/lib/table/service' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -58,6 +66,16 @@ describe('createTable schema invariants', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + mockAssertTableRowTtlEnabled.mockResolvedValue(undefined) + }) + + it('rejects a TTL schema before persistence when the feature is disabled', async () => { + mockAssertTableRowTtlEnabled.mockRejectedValue(new Error('TTL columns are not enabled')) + + await expect( + create({ columns: [{ name: 'expires_at', type: 'ttl' }] } as TableSchema) + ).rejects.toThrow('TTL columns are not enabled') + expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) /** diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 0f13f2a4b04..0899909732e 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -57,6 +57,7 @@ import { mutateTableRowsWithSecretProvenance, } from '@/lib/table/rows/secret-provenance' import { assertValidSchema } from '@/lib/table/schema-invariants' +import { assertTableRowTtlEnabled } from '@/lib/table/ttl-availability' import { setTableTxTimeouts } from '@/lib/table/tx' import { type CreateTableData, @@ -560,6 +561,10 @@ export async function createTable( ) } + if (data.schema.columns.some((column) => column.type === 'ttl')) { + await assertTableRowTtlEnabled() + } + const tableId = `tbl_${generateId().replace(/-/g, '')}` const now = new Date() diff --git a/apps/sim/lib/table/ttl-availability.test.ts b/apps/sim/lib/table/ttl-availability.test.ts new file mode 100644 index 00000000000..b1ea78646c3 --- /dev/null +++ b/apps/sim/lib/table/ttl-availability.test.ts @@ -0,0 +1,34 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsFeatureEnabled } = vi.hoisted(() => ({ mockIsFeatureEnabled: vi.fn() })) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: mockIsFeatureEnabled, +})) + +import { assertTableRowTtlEnabled, isTableRowTtlEnabled } from '@/lib/table/ttl-availability' + +describe('table row TTL availability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves the global table-row-ttl flag without rollout context', async () => { + mockIsFeatureEnabled.mockResolvedValue(true) + + await expect(isTableRowTtlEnabled()).resolves.toBe(true) + expect(mockIsFeatureEnabled).toHaveBeenCalledWith('table-row-ttl') + }) + + it('rejects TTL column creation while the flag is disabled', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + + await expect(assertTableRowTtlEnabled()).rejects.toMatchObject({ + code: 'validation', + message: 'TTL columns are not enabled', + }) + }) +}) diff --git a/apps/sim/lib/table/ttl-availability.ts b/apps/sim/lib/table/ttl-availability.ts new file mode 100644 index 00000000000..f5c2339a384 --- /dev/null +++ b/apps/sim/lib/table/ttl-availability.ts @@ -0,0 +1,13 @@ +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** Whether TTL columns and their cleanup behavior are enabled globally. */ +export function isTableRowTtlEnabled(): Promise { + return isFeatureEnabled('table-row-ttl') +} + +/** Rejects attempts to introduce a TTL column while the feature is disabled. */ +export async function assertTableRowTtlEnabled(): Promise { + if (await isTableRowTtlEnabled()) return + throw new OrchestrationError('validation', 'TTL columns are not enabled') +} diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 9f97d5dbbb1..bbc2a1c971d 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -102,6 +102,7 @@ app: # Optional: Scheduled Jobs Authentication # Generate using: openssl rand -hex 32 CRON_SECRET: "" # OPTIONAL - required only if cronjobs.enabled=true, authenticates scheduled job requests + TABLE_ROW_TTL: "" # Enable TTL columns and expired-row cleanup when AppConfig is unavailable # Optional: API Key Encryption (RECOMMENDED for production) # Generate with: openssl rand -hex 32 (produces the required 64-hex-char / 32-byte value).