|
1 | | -import { and, gt, gte, lt, or, type SQL } from 'drizzle-orm' |
| 1 | +import { type SQL, sql } from 'drizzle-orm' |
2 | 2 | import type { PgColumn } from 'drizzle-orm/pg-core' |
3 | 3 | import type { Cursor } from '@/lib/data-drains/types' |
4 | 4 |
|
@@ -29,29 +29,28 @@ export function decodeTimeCursor(cursor: Cursor): TimeCursor | null { |
29 | 29 |
|
30 | 30 | /** |
31 | 31 | * Builds a strict-greater-than predicate over a `(timestampCol, idCol)` pair. |
32 | | - * Semantically: `(timestampCol, idCol) > (cursor.ts, cursor.id)`. |
33 | 32 | * |
34 | 33 | * Postgres `timestamp` columns store microsecond precision but JS `Date` |
35 | | - * round-trips at millisecond precision, so a row with PG value |
36 | | - * `00:00:00.123456` survives the JS → cursor → JS round-trip as |
37 | | - * `00:00:00.123000`. A naive `eq(col, new Date(cursor.ts))` would never match |
38 | | - * such rows, and `gt(col, new Date(cursor.ts))` would match the cursor row |
39 | | - * itself — re-emitting the last row of every chunk forever. |
40 | | - * |
41 | | - * Fix: compare in millisecond buckets. Anything in a strictly later ms bucket |
42 | | - * is included; anything inside the cursor's own ms bucket is included only |
43 | | - * when `idCol > cursor.id`. |
| 34 | + * round-trips at millisecond precision, so the cursor only ever captures |
| 35 | + * millisecond-truncated timestamps. We compare in millisecond buckets via |
| 36 | + * `date_trunc('milliseconds', col)` so the predicate's notion of order matches |
| 37 | + * `timeCursorOrderBy` exactly. If ORDER BY used raw microseconds while the |
| 38 | + * predicate used millisecond buckets, a row sorted later by µs but with a |
| 39 | + * lexicographically earlier id than the cursor row would be skipped forever. |
44 | 40 | */ |
45 | 41 | export function timeCursorPredicate( |
46 | 42 | timestampCol: PgColumn, |
47 | 43 | idCol: PgColumn, |
48 | 44 | cursor: TimeCursor | null |
49 | 45 | ): SQL | undefined { |
50 | 46 | if (!cursor) return undefined |
51 | | - const cursorTs = new Date(cursor.ts) |
52 | | - const nextMs = new Date(cursorTs.getTime() + 1) |
53 | | - return or( |
54 | | - gte(timestampCol, nextMs), |
55 | | - and(gte(timestampCol, cursorTs), lt(timestampCol, nextMs), gt(idCol, cursor.id)) |
56 | | - ) |
| 47 | + return sql`(date_trunc('milliseconds', ${timestampCol}), ${idCol}) > (${new Date(cursor.ts)}, ${cursor.id})` |
| 48 | +} |
| 49 | + |
| 50 | +/** |
| 51 | + * ORDER BY fragments paired with `timeCursorPredicate`. Both must agree on |
| 52 | + * millisecond bucketing so cursor advancement never skips rows. |
| 53 | + */ |
| 54 | +export function timeCursorOrderBy(timestampCol: PgColumn, idCol: PgColumn): [SQL, SQL] { |
| 55 | + return [sql`date_trunc('milliseconds', ${timestampCol}) asc`, sql`${idCol} asc`] |
57 | 56 | } |
0 commit comments