|
| 1 | +import { z } from 'zod' |
| 2 | + |
1 | 3 | export const TTL_FORMAT_ERROR = |
2 | 4 | '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' |
3 | 5 |
|
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 |
7 | 11 |
|
8 | | -const TTL_TIMESTAMP_REGEX = new RegExp(TTL_TIMESTAMP_PATTERN) |
| 12 | +const timestampSchema = z.iso.datetime({ offset: true }) |
9 | 13 |
|
10 | 14 | 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 |
14 | 18 |
|
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('.') |
18 | 22 | 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 |
21 | 26 | ) { |
22 | 27 | return null |
23 | 28 | } |
24 | 29 |
|
25 | | - const instant = new Date(`${wallClock}${offset.toUpperCase()}`) |
| 30 | + const wallClock = dateTime.length === 16 ? `${dateTime}:00` : dateTime |
| 31 | + const instant = new Date(`${wallClock}${offset}`) |
26 | 32 | if ( |
27 | 33 | !Number.isFinite(instant.getTime()) || |
28 | 34 | instant.getUTCFullYear() < 1 || |
29 | 35 | instant.getUTCFullYear() > 9999 |
30 | 36 | ) { |
31 | 37 | return null |
32 | 38 | } |
33 | | - const fraction = (fractionalSecond ?? '').replace(/0+$/, '') |
| 39 | + const fraction = fractionalSecond.replace(/0+$/, '') |
34 | 40 | return { |
35 | 41 | wallClock, |
36 | 42 | fraction: fraction ? `.${fraction}` : '', |
37 | | - offset: offset.toUpperCase() === 'Z' ? '-00:00' : offset, |
| 43 | + offset: offset === 'Z' ? '-00:00' : offset, |
38 | 44 | instant, |
39 | 45 | } |
40 | 46 | } |
|
0 commit comments