Skip to content

Commit 954c8fe

Browse files
authored
Fix Date Precision Mismatch in Cursor-Based Pagination (#913)
* add test * fix * changeset
1 parent 4f8b028 commit 954c8fe

3 files changed

Lines changed: 173 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Fix pagination with Date orderBy values when backend has higher precision than JavaScript's millisecond precision. When loading duplicate values during cursor-based pagination, Date values now use a 1ms range query (`gte`/`lt`) instead of exact equality (`eq`) to correctly match all rows that fall within the same millisecond, even if the backend (e.g., PostgreSQL) stores them with microsecond precision.

packages/db/src/collection/subscription.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ensureIndexForExpression } from "../indexes/auto-index.js"
2-
import { and, eq, gt, lt } from "../query/builder/functions.js"
2+
import { and, eq, gt, gte, lt } from "../query/builder/functions.js"
33
import { Value } from "../query/ir.js"
44
import { EventEmitter } from "../event-emitter.js"
55
import {
@@ -361,7 +361,19 @@ export class CollectionSubscription
361361
// First promise: load all values equal to minValue
362362
if (typeof minValue !== `undefined`) {
363363
const { expression } = orderBy[0]!
364-
const exactValueFilter = eq(expression, new Value(minValue))
364+
365+
// For Date values, we need to handle precision differences between JS (ms) and backends (μs)
366+
// A JS Date represents a 1ms range, so we query for all values within that range
367+
let exactValueFilter
368+
if (minValue instanceof Date) {
369+
const minValuePlus1ms = new Date(minValue.getTime() + 1)
370+
exactValueFilter = and(
371+
gte(expression, new Value(minValue)),
372+
lt(expression, new Value(minValuePlus1ms))
373+
)
374+
} else {
375+
exactValueFilter = eq(expression, new Value(minValue))
376+
}
365377

366378
const loadOptions2: LoadSubsetOptions = {
367379
where: exactValueFilter,

packages/db/tests/query/order-by.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2923,3 +2923,157 @@ describe(`OrderBy with duplicate values`, () => {
29232923

29242924
createOrderByBugTests(`eager`)
29252925
})
2926+
2927+
describe(`OrderBy with Date values and precision differences`, () => {
2928+
type TestItemWithDate = {
2929+
id: number
2930+
createdAt: Date
2931+
keep: boolean
2932+
}
2933+
2934+
it(`should use range query for Date values to handle backend precision differences`, async () => {
2935+
// This test verifies that when paginating with Date orderBy values,
2936+
// the code uses a range query (gte/lt) instead of exact equality (eq)
2937+
// to handle backends with higher precision than JavaScript's millisecond precision.
2938+
//
2939+
// The bug: PostgreSQL stores timestamps with microsecond precision.
2940+
// When JS has a Date "2024-01-15T10:30:45.123Z", the backend might have multiple
2941+
// rows with 123.000μs, 123.100μs, 123.200μs, etc. Using eq() would only match
2942+
// 123.000μs, missing the others. The fix uses gte/lt to match the full 1ms range.
2943+
2944+
const baseTime = new Date(`2024-01-15T10:30:45.123Z`)
2945+
2946+
const testData: Array<TestItemWithDate> = [
2947+
{ id: 1, createdAt: new Date(`2024-01-15T10:30:45.120Z`), keep: true },
2948+
{ id: 2, createdAt: new Date(`2024-01-15T10:30:45.121Z`), keep: true },
2949+
{ id: 3, createdAt: new Date(`2024-01-15T10:30:45.122Z`), keep: true },
2950+
{ id: 4, createdAt: new Date(`2024-01-15T10:30:45.122Z`), keep: true },
2951+
{ id: 5, createdAt: baseTime, keep: true },
2952+
{ id: 6, createdAt: baseTime, keep: true },
2953+
{ id: 7, createdAt: baseTime, keep: true },
2954+
{ id: 8, createdAt: baseTime, keep: true },
2955+
{ id: 9, createdAt: baseTime, keep: true },
2956+
{ id: 10, createdAt: baseTime, keep: true },
2957+
{ id: 11, createdAt: new Date(`2024-01-15T10:30:45.124Z`), keep: true },
2958+
{ id: 12, createdAt: new Date(`2024-01-15T10:30:45.125Z`), keep: true },
2959+
]
2960+
2961+
const initialData = testData.slice(0, 5)
2962+
2963+
// Track the WHERE clauses sent to loadSubset
2964+
const loadSubsetWhereClauses: Array<any> = []
2965+
2966+
const sourceCollection = createCollection(
2967+
mockSyncCollectionOptions<TestItemWithDate>({
2968+
id: `test-date-precision-query`,
2969+
getKey: (item) => item.id,
2970+
initialData,
2971+
autoIndex: `eager`,
2972+
syncMode: `on-demand`,
2973+
sync: {
2974+
sync: ({ begin, write, commit, markReady }) => {
2975+
begin()
2976+
initialData.forEach((item) => {
2977+
write({ type: `insert`, value: item })
2978+
})
2979+
commit()
2980+
markReady()
2981+
2982+
return {
2983+
loadSubset: (options) => {
2984+
// Capture the WHERE clause for inspection
2985+
loadSubsetWhereClauses.push(options.where)
2986+
2987+
return new Promise<void>((resolve) => {
2988+
setTimeout(() => {
2989+
begin()
2990+
const sortedData = [...testData].sort(
2991+
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
2992+
)
2993+
2994+
let filteredData = sortedData
2995+
if (options.where) {
2996+
try {
2997+
const filterFn = createFilterFunctionFromExpression(
2998+
options.where
2999+
)
3000+
filteredData = sortedData.filter(filterFn)
3001+
} catch {
3002+
filteredData = sortedData
3003+
}
3004+
}
3005+
3006+
const { limit } = options
3007+
const dataToLoad = limit
3008+
? filteredData.slice(0, limit)
3009+
: filteredData
3010+
3011+
dataToLoad.forEach((item) => {
3012+
write({ type: `insert`, value: item })
3013+
})
3014+
3015+
commit()
3016+
resolve()
3017+
}, 10)
3018+
})
3019+
},
3020+
}
3021+
},
3022+
},
3023+
})
3024+
)
3025+
3026+
const collection = createLiveQueryCollection((q) =>
3027+
q
3028+
.from({ items: sourceCollection })
3029+
.where(({ items }) => eq(items.keep, true))
3030+
.orderBy(({ items }) => items.createdAt, `asc`)
3031+
.offset(0)
3032+
.limit(5)
3033+
.select(({ items }) => ({
3034+
id: items.id,
3035+
createdAt: items.createdAt,
3036+
keep: items.keep,
3037+
}))
3038+
)
3039+
await collection.preload()
3040+
3041+
// First page loads
3042+
const results = Array.from(collection.values()).sort((a, b) => a.id - b.id)
3043+
expect(results.map((r) => r.id)).toEqual([1, 2, 3, 4, 5])
3044+
3045+
// Clear tracked clauses before moving to next page
3046+
loadSubsetWhereClauses.length = 0
3047+
3048+
// Move to next page - this should trigger the Date precision handling
3049+
const moveToSecondPage = collection.utils.setWindow({ offset: 5, limit: 5 })
3050+
await moveToSecondPage
3051+
3052+
// Find the WHERE clause that queries for the "equal values" (the minValue query)
3053+
// With the fix, this should be: and(gte(createdAt, baseTime), lt(createdAt, baseTime+1ms))
3054+
// Without the fix, this would be: eq(createdAt, baseTime)
3055+
const equalValuesQuery = loadSubsetWhereClauses.find((clause) => {
3056+
if (!clause) return false
3057+
// Check if it's an 'and' with 'gte' and 'lt' (the fix)
3058+
if (clause.name === `and` && clause.args?.length === 2) {
3059+
const [first, second] = clause.args
3060+
return first?.name === `gte` && second?.name === `lt`
3061+
}
3062+
return false
3063+
})
3064+
3065+
// The fix should produce a range query (and(gte, lt)) for Date values
3066+
// instead of an exact equality query (eq)
3067+
expect(equalValuesQuery).toBeDefined()
3068+
expect(equalValuesQuery.name).toBe(`and`)
3069+
expect(equalValuesQuery.args[0].name).toBe(`gte`)
3070+
expect(equalValuesQuery.args[1].name).toBe(`lt`)
3071+
3072+
// Verify the range is exactly 1ms
3073+
const gteValue = equalValuesQuery.args[0].args[1].value
3074+
const ltValue = equalValuesQuery.args[1].args[1].value
3075+
expect(gteValue).toBeInstanceOf(Date)
3076+
expect(ltValue).toBeInstanceOf(Date)
3077+
expect(ltValue.getTime() - gteValue.getTime()).toBe(1) // 1ms difference
3078+
})
3079+
})

0 commit comments

Comments
 (0)