Skip to content

Commit 0fdf5c7

Browse files
j15zclaude
andcommitted
fix(tables): use native timestamp validation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d781866 commit 0fdf5c7

9 files changed

Lines changed: 81 additions & 42 deletions

File tree

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,13 @@ vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: signalChanged }))
2828
vi.mock('@/lib/table/trigger', () => ({ fireTableTrigger: fireTrigger }))
2929

3030
import { db } from '@sim/db'
31+
import { validatedTimestampSql } from '@/lib/table/column-types/timestamp-sql'
3132
import { updateColumnConstraints } from '@/lib/table/columns/service'
3233
import { getDeleteSnapshotBatchSize } from '@/lib/table/constants'
3334
import { replaceTableRowsWithTx } from '@/lib/table/rows/service'
3435
import { getTableById } from '@/lib/table/service'
3536
import { fieldPredicate } from '@/lib/table/sql'
36-
import { normalizeTtlTimestamp } from '@/lib/table/ttl-values'
37+
import { normalizeTtlTimestamp, TTL_TIMESTAMP_VALIDATION } from '@/lib/table/ttl-values'
3738
import type { TableSchema } from '@/lib/table/types'
3839
import { checkBatchUniqueConstraintsDb, coerceRowToSchema } from '@/lib/table/validation'
3940
import { runCleanupTableRowTtl } from '@/background/cleanup-table-row-ttl'
@@ -582,6 +583,12 @@ describe.skipIf(!url)('Expiration with real PostgreSQL transactions', () => {
582583
unnest(${samples}::text[], ${normalized}::text[]) AS instants(input, normalized)
583584
WHERE input::timestamptz != normalized::timestamptz`
584585
expect(result.mismatch).toBe(0)
586+
const guarded = await db.execute(sql`WITH samples AS MATERIALIZED (
587+
SELECT jsonb_array_elements_text(${JSON.stringify(samples)}::jsonb) AS value
588+
) SELECT count(*)::int AS mismatch FROM samples
589+
WHERE ${validatedTimestampSql(sql`samples.value`, TTL_TIMESTAMP_VALIDATION)}
590+
IS DISTINCT FROM samples.value::timestamptz`)
591+
expect(guarded[0].mismatch).toBe(0)
585592
measurements.postgresTimestampSamples = samples.length
586593
})
587594

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,17 @@ describe('table row TTL cleanup', () => {
134134
invalid_offset_day: '2026-02-30T12:00:00-07:00',
135135
invalid_offset: '2026-09-07T12:00:00+16:00',
136136
invalid_fraction: '2026-09-07T12:00:00.0000001Z',
137+
rounding_future: '2026-09-07T12:00:00.5000001Z',
138+
no_offset: '2020-01-01T00:00:00',
139+
day_only: '2020-01-01',
140+
relative_now: 'now',
141+
relative_today: 'today',
142+
relative_yesterday: 'yesterday',
143+
epoch_alias: 'epoch',
144+
past_infinity: '-infinity',
145+
compact_offset: '2020-01-01T00:00:00+0000',
146+
named_zone: '2020-01-01 00:00:00 America/Los_Angeles',
147+
trailing_newline: '2020-01-01T00:00:00Z\n',
137148
}
138149
for (const [id, value] of Object.entries(values)) {
139150
await client`INSERT INTO user_table_rows (id, table_id, workspace_id, data) VALUES (${id}, ${table.id}, ${table.workspaceId}, ${client.json({ 'col-ttl': value })})`
@@ -241,7 +252,7 @@ describe('table row TTL cleanup', () => {
241252
.sql.replace(/\s+/g, ' ')
242253
.replace(/\$\d+/g, '?')
243254
.trim()
244-
expect(query).toContain('THEN (table_row.data->>?)::timestamptz <= ?::timestamptz')
255+
expect(query).toContain('THEN (table_row.data->>?)::timestamptz END <= ?::timestamptz')
245256
expect(query).toContain('ORDER BY table_row.created_at, table_row.id')
246257
expect(query).toContain('octet_length(table_row.data::text) AS snapshot_bytes')
247258
expect(query).toContain('cumulative_snapshot_bytes <= ?')

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

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { task } from '@trigger.dev/sdk'
55
import { type SQL, sql } from 'drizzle-orm'
66
import { asOrchestrationError } from '@/lib/core/orchestration/types'
77
import { getColumnId } from '@/lib/table/column-keys'
8+
import { validatedTimestampSql } from '@/lib/table/column-types/timestamp-sql'
89
import { getDeleteSnapshotBatchSize, TABLE_LIMITS } from '@/lib/table/constants'
910
import { signalTableRowsChanged } from '@/lib/table/events'
1011
import { assertRowDelete, TableLockedError } from '@/lib/table/mutation-locks'
@@ -13,7 +14,7 @@ import type { DeletedTableRow } from '@/lib/table/rows/ordering'
1314
import { withLockedTable } from '@/lib/table/service'
1415
import { fireTableTrigger } from '@/lib/table/trigger'
1516
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
16-
import { TTL_TIMESTAMP_PATTERN } from '@/lib/table/ttl-values'
17+
import { TTL_TIMESTAMP_VALIDATION } from '@/lib/table/ttl-values'
1718
import type { RowData, TableSchema } from '@/lib/table/types'
1819

1920
const logger = createLogger('CleanupTableRowTtl')
@@ -59,20 +60,9 @@ export interface TableRowTtlCleanupResult {
5960
limitReached: boolean
6061
}
6162

62-
/** Guards the timestamp cast, including calendar validity, on PostgreSQL 14+. */
63+
/** Shares PostgreSQL's validated instant projection with Expiration comparisons. */
6364
function expiredTtlPredicate(cell: SQL, nowUtc: string): SQL {
64-
return sql`CASE
65-
WHEN ${cell} ~ ${TTL_TIMESTAMP_PATTERN}
66-
THEN CASE
67-
WHEN substring(${cell}, 9, 2)::int <= extract(day FROM (
68-
make_date(substring(${cell}, 1, 4)::int, substring(${cell}, 6, 2)::int, 1)
69-
+ interval '1 month - 1 day'
70-
))
71-
THEN (${cell})::timestamptz <= ${nowUtc}::timestamptz
72-
ELSE false
73-
END
74-
ELSE false
75-
END`
65+
return sql`${validatedTimestampSql(cell, TTL_TIMESTAMP_VALIDATION)} <= ${nowUtc}::timestamptz`
7666
}
7767

7868
async function listExpiredTtlTables(nowUtc: string): Promise<ExpiredTtlTableRef[]> {
Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
import { type SQL, sql } from 'drizzle-orm'
22
import { columnTypeOf } from '@/lib/table/column-types/registry'
3+
import { validatedTimestampSql } from '@/lib/table/column-types/timestamp-sql'
34
import type { ColumnDefinition } from '@/lib/table/types'
45

56
/** Casts stored text for equality, treating malformed explicit timestamps as absent instants. */
67
export function columnTextForEquality(cell: SQL, column: ColumnDefinition): SQL {
78
const definition = columnTypeOf(column)
89
if (!definition.valueForEquality || !definition.jsonbCast) return cell
9-
const cast = sql`(${cell})::${sql.raw(definition.jsonbCast)}`
10-
if (!definition.timestampPattern) return cast
11-
return sql`CASE WHEN ${cell} ~ ${definition.timestampPattern} THEN CASE
12-
WHEN substring(${cell}, 9, 2)::int <= extract(day FROM (
13-
make_date(substring(${cell}, 1, 4)::int, substring(${cell}, 6, 2)::int, 1)
14-
+ interval '1 month - 1 day'
15-
)) THEN ${cast} END END`
10+
if (definition.timestampValidation) {
11+
return validatedTimestampSql(cell, definition.timestampValidation)
12+
}
13+
return sql`(${cell})::${sql.raw(definition.jsonbCast)}`
1614
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { type SQL, sql } from 'drizzle-orm'
2+
import type { TimestampValidation } from '@/lib/table/column-types/types'
3+
4+
/** PostgreSQL 16+ validates the cast; the format and precision rules prevent implicit or rounded instants. */
5+
export function validatedTimestampSql(cell: SQL, validation: TimestampValidation): SQL {
6+
return sql`CASE
7+
WHEN ${cell} ~* ${validation.pattern}
8+
AND pg_input_is_valid(${cell}, 'timestamptz')
9+
AND COALESCE(length(substring(${cell} from '[.]([0-9]+)')), 0) <= ${validation.maxFractionDigits}
10+
THEN (${cell})::timestamptz
11+
END`
12+
}

‎apps/sim/lib/table/column-types/ttl.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@ describe('TTL column type', () => {
8989
'2026-09-07T14:30:00.0000001Z',
9090
'2026-09-07 14:30:00Z',
9191
'2026-09-07T14:30:00Z ',
92+
'2026-09-07T14:30:00Z\n',
93+
'2026-09-07T14:30:00+0700',
94+
'2026-09-07T14:30:00 America/Los_Angeles',
95+
'2026-09-07T14:30:00Z[UTC]',
96+
'now',
97+
'today',
98+
'epoch',
99+
'infinity',
100+
'-infinity',
92101
'2026-02-29T12:00:00Z',
93102
'2026-02-30T12:00:00Z',
94103
'2026-02-30T12:00:00-07:00',

‎apps/sim/lib/table/column-types/ttl.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
isTtlTimestamp,
55
normalizeTtlTimestamp,
66
TTL_FORMAT_ERROR,
7-
TTL_TIMESTAMP_PATTERN,
7+
TTL_TIMESTAMP_VALIDATION,
88
ttlInstantForComparison,
99
} from '@/lib/table/ttl-values'
1010

@@ -14,7 +14,7 @@ export const ttlColumnType: ColumnTypeDefinition = {
1414
maxPerTable: 1,
1515
icon: TypeTtl,
1616
jsonbCast: 'timestamptz',
17-
timestampPattern: TTL_TIMESTAMP_PATTERN,
17+
timestampValidation: TTL_TIMESTAMP_VALIDATION,
1818
storesOpaqueIds: false,
1919
supportsUnique: true,
2020
sampleValue: '2024-01-31T00:00:00-00:00',

‎apps/sim/lib/table/column-types/types.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ export type TypeSpecificColumnKey = (typeof TYPE_SPECIFIC_COLUMN_KEYS)[number]
7070
/** Result of coercing a raw value toward a column's declared type. */
7171
export type CoerceResult = { ok: true; value: JsonValue } | { ok: false }
7272

73+
/** Additional format and precision rules for native PostgreSQL timestamp validation. */
74+
export interface TimestampValidation {
75+
readonly pattern: string
76+
readonly maxFractionDigits: number
77+
}
78+
7379
export interface ColumnTypeDefinition {
7480
readonly id: ColumnType
7581

@@ -84,8 +90,8 @@ export interface ColumnTypeDefinition {
8490
* comparison is correct. Single source for both filter ranges and sort order.
8591
*/
8692
readonly jsonbCast: 'numeric' | 'timestamptz' | null
87-
/** Strict ISO shape guard for timestamp comparisons against potentially malformed stored cells. */
88-
readonly timestampPattern?: string
93+
/** Guards timestamp comparisons against malformed stored cells without guessing a timezone. */
94+
readonly timestampValidation?: TimestampValidation
8995

9096
/**
9197
* Wire operators a column of this type accepts, or `null` for "all

‎apps/sim/lib/table/ttl-values.ts‎

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,46 @@
1+
import { z } from 'zod'
2+
13
export const TTL_FORMAT_ERROR =
24
'Expiration must be an ISO timestamp with Z or an explicit UTC offset (for example, 2026-09-07T14:30:00-07:00), with at most 6 fractional second digits'
35

4-
/** Shared JavaScript/PostgreSQL shape guard; numeric offsets fit PostgreSQL's supported range. */
5-
export const TTL_TIMESTAMP_PATTERN =
6-
'^((?!0000)[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01]))[Tt]((?:0[0-9]|1[0-9]|2[0-3]):[0-5][0-9])(?::([0-5][0-9])(?:[.]([0-9]{1,6}))?)?([Zz]|[+-](?:0[0-9]|1[0-5]):[0-5][0-9])$'
6+
/** Zod owns the ISO format; SQL also checks native timestamptz validity before casting. */
7+
export const TTL_TIMESTAMP_VALIDATION = {
8+
pattern: z.regexes.datetime({ offset: true }).source,
9+
maxFractionDigits: 6,
10+
} as const
711

8-
const TTL_TIMESTAMP_REGEX = new RegExp(TTL_TIMESTAMP_PATTERN)
12+
const timestampSchema = z.iso.datetime({ offset: true })
913

1014
function parseTtlTimestamp(value: unknown) {
11-
if (typeof value !== 'string') return null
12-
const match = TTL_TIMESTAMP_REGEX.exec(value)
13-
if (!match) return null
15+
if (typeof value !== 'string' || value !== value.trim()) return null
16+
const result = timestampSchema.safeParse(value.toUpperCase())
17+
if (!result.success) return null
1418

15-
const [, day, time, second, fractionalSecond, offset] = match
16-
const wallClock = `${day}T${time}:${second ?? '00'}`
17-
const wallMilliseconds = Date.parse(`${wallClock}Z`)
19+
const timestamp = result.data
20+
const offset = timestamp.endsWith('Z') ? 'Z' : timestamp.slice(-6)
21+
const [dateTime, fractionalSecond = ''] = timestamp.slice(0, -offset.length).split('.')
1822
if (
19-
!Number.isFinite(wallMilliseconds) ||
20-
new Date(wallMilliseconds).toISOString() !== `${wallClock}.000Z`
23+
timestamp.startsWith('0000-') ||
24+
(offset !== 'Z' && Number(offset.slice(1, 3)) > 15) ||
25+
fractionalSecond.length > TTL_TIMESTAMP_VALIDATION.maxFractionDigits
2126
) {
2227
return null
2328
}
2429

25-
const instant = new Date(`${wallClock}${offset.toUpperCase()}`)
30+
const wallClock = dateTime.length === 16 ? `${dateTime}:00` : dateTime
31+
const instant = new Date(`${wallClock}${offset}`)
2632
if (
2733
!Number.isFinite(instant.getTime()) ||
2834
instant.getUTCFullYear() < 1 ||
2935
instant.getUTCFullYear() > 9999
3036
) {
3137
return null
3238
}
33-
const fraction = (fractionalSecond ?? '').replace(/0+$/, '')
39+
const fraction = fractionalSecond.replace(/0+$/, '')
3440
return {
3541
wallClock,
3642
fraction: fraction ? `.${fraction}` : '',
37-
offset: offset.toUpperCase() === 'Z' ? '-00:00' : offset,
43+
offset: offset === 'Z' ? '-00:00' : offset,
3844
instant,
3945
}
4046
}

0 commit comments

Comments
 (0)