import { type SQL, sql } from 'drizzle-orm' import type { PgColumn } from 'drizzle-orm/pg-core' import type { Cursor } from '@/lib/data-drains/types' /** * Composite cursor for time-ordered tables. Pairs a timestamp with the row's id * so chunks split across rows that share a timestamp pick up cleanly without * skipping or duplicating. */ export interface TimeCursor { ts: string id: string } export function encodeTimeCursor(value: TimeCursor): Cursor { return JSON.stringify(value) } export function decodeTimeCursor(cursor: Cursor): TimeCursor | null { if (!cursor) return null try { const parsed = JSON.parse(cursor) as TimeCursor if (typeof parsed?.ts !== 'string' || typeof parsed?.id !== 'string') return null return parsed } catch { return null } } /** * Builds a strict-greater-than predicate over a `(timestampCol, idCol)` pair. * * Postgres `timestamp` columns store microsecond precision but JS `Date` * round-trips at millisecond precision, so the cursor only ever captures * millisecond-truncated timestamps. We compare in millisecond buckets via * `date_trunc('milliseconds', col)` so the predicate's notion of order matches * `timeCursorOrderBy` exactly. If ORDER BY used raw microseconds while the * predicate used millisecond buckets, a row sorted later by µs but with a * lexicographically earlier id than the cursor row would be skipped forever. */ export function timeCursorPredicate( timestampCol: PgColumn, idCol: PgColumn, cursor: TimeCursor | null ): SQL | undefined { if (!cursor) return undefined return sql`(date_trunc('milliseconds', ${timestampCol}), ${idCol}) > (${new Date(cursor.ts)}, ${cursor.id})` } /** * ORDER BY fragments paired with `timeCursorPredicate`. Both must agree on * millisecond bucketing so cursor advancement never skips rows. */ export function timeCursorOrderBy(timestampCol: PgColumn, idCol: PgColumn): [SQL, SQL] { return [sql`date_trunc('milliseconds', ${timestampCol}) asc`, sql`${idCol} asc`] } /** * Excludes rows newer than a short stability window. Timestamp cursors assume * rows become visible in timestamp order, but out-of-order commits and replica * lag can surface an earlier-stamped row after the cursor has advanced past it * — permanently skipping it. Leaving the freshest rows for the next run bounds * both. */ export function timeCursorStabilityBound(timestampCol: PgColumn): SQL { return sql`${timestampCol} <= now() - interval '5 minutes'` }