Skip to content

Commit 0c47d64

Browse files
committed
fix(db): ensure deterministic iteration order for collections and indexes
- SortedMap: add key-based tie-breaking for deterministic ordering - SortedMap: optimize to skip value comparison when no comparator provided - BTreeIndex: sort keys within same indexed value for deterministic order - BTreeIndex: add fast paths for empty/single-key sets - CollectionStateManager: always use SortedMap for deterministic iteration - Extract compareKeys utility to utils/comparison.ts - Add comprehensive tests for deterministic ordering behavior
1 parent 48853db commit 0c47d64

File tree

8 files changed

+528
-57
lines changed

8 files changed

+528
-57
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Ensure deterministic iteration order for collections and indexes.
6+
7+
**SortedMap improvements:**
8+
9+
- Added key-based tie-breaking when values compare as equal, ensuring deterministic ordering
10+
- Optimized to skip value comparison entirely when no comparator is provided (key-only sorting)
11+
- Extracted `compareKeys` utility to `utils/comparison.ts` for reuse
12+
13+
**BTreeIndex improvements:**
14+
15+
- Keys within the same indexed value are now returned in deterministic sorted order
16+
- Optimized with fast paths for empty sets and single-key sets to avoid unnecessary allocations
17+
18+
**CollectionStateManager changes:**
19+
20+
- Collections now always use `SortedMap` for `syncedData`, ensuring deterministic iteration order
21+
- When no `compare` function is provided, entries are sorted by key only
22+
23+
This ensures that live queries with `orderBy` and `limit` produce stable, deterministic results even when multiple rows have equal sort values.

packages/db/src/SortedMap.ts

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,81 @@
1+
import { compareKeys } from "./utils/comparison.js"
2+
13
/**
24
* A Map implementation that keeps its entries sorted based on a comparator function
3-
* @template TKey - The type of keys in the map
5+
* @template TKey - The type of keys in the map (must be string | number)
46
* @template TValue - The type of values in the map
57
*/
6-
export class SortedMap<TKey, TValue> {
8+
export class SortedMap<TKey extends string | number, TValue> {
79
private map: Map<TKey, TValue>
810
private sortedKeys: Array<TKey>
9-
private comparator: (a: TValue, b: TValue) => number
11+
private comparator: ((a: TValue, b: TValue) => number) | undefined
1012

1113
/**
1214
* Creates a new SortedMap instance
1315
*
14-
* @param comparator - Optional function to compare values for sorting
16+
* @param comparator - Optional function to compare values for sorting.
17+
* If not provided, entries are sorted by key only.
1518
*/
1619
constructor(comparator?: (a: TValue, b: TValue) => number) {
1720
this.map = new Map<TKey, TValue>()
1821
this.sortedKeys = []
19-
this.comparator = comparator || this.defaultComparator
20-
}
21-
22-
/**
23-
* Default comparator function used when none is provided
24-
*
25-
* @param a - First value to compare
26-
* @param b - Second value to compare
27-
* @returns -1 if a < b, 1 if a > b, 0 if equal
28-
*/
29-
private defaultComparator(a: TValue, b: TValue): number {
30-
if (a < b) return -1
31-
if (a > b) return 1
32-
return 0
22+
this.comparator = comparator
3323
}
3424

3525
/**
3626
* Finds the index where a key-value pair should be inserted to maintain sort order.
37-
* Uses binary search to find the correct position based on the value.
38-
* Hence, it is in O(log n) time.
27+
* Uses binary search to find the correct position based on the value (if comparator provided),
28+
* with key-based tie-breaking for deterministic ordering when values compare as equal.
29+
* If no comparator is provided, sorts by key only.
30+
* Runs in O(log n) time.
3931
*
40-
* @param key - The key to find position for
41-
* @param value - The value to compare against
32+
* @param key - The key to find position for (used as tie-breaker or primary sort when no comparator)
33+
* @param value - The value to compare against (only used if comparator is provided)
4234
* @returns The index where the key should be inserted
4335
*/
44-
private indexOf(value: TValue): number {
36+
private indexOf(key: TKey, value: TValue): number {
4537
let left = 0
4638
let right = this.sortedKeys.length
4739

40+
// Fast path: no comparator means sort by key only
41+
if (!this.comparator) {
42+
while (left < right) {
43+
const mid = Math.floor((left + right) / 2)
44+
const midKey = this.sortedKeys[mid]!
45+
const keyComparison = compareKeys(key, midKey)
46+
if (keyComparison < 0) {
47+
right = mid
48+
} else if (keyComparison > 0) {
49+
left = mid + 1
50+
} else {
51+
return mid
52+
}
53+
}
54+
return left
55+
}
56+
57+
// With comparator: sort by value first, then key as tie-breaker
4858
while (left < right) {
4959
const mid = Math.floor((left + right) / 2)
5060
const midKey = this.sortedKeys[mid]!
5161
const midValue = this.map.get(midKey)!
52-
const comparison = this.comparator(value, midValue)
62+
const valueComparison = this.comparator(value, midValue)
5363

54-
if (comparison < 0) {
64+
if (valueComparison < 0) {
5565
right = mid
56-
} else if (comparison > 0) {
66+
} else if (valueComparison > 0) {
5767
left = mid + 1
5868
} else {
59-
return mid
69+
// Values are equal, use key as tie-breaker for deterministic ordering
70+
const keyComparison = compareKeys(key, midKey)
71+
if (keyComparison < 0) {
72+
right = mid
73+
} else if (keyComparison > 0) {
74+
left = mid + 1
75+
} else {
76+
// Same key (shouldn't happen during insert, but handle for lookups)
77+
return mid
78+
}
6079
}
6180
}
6281

@@ -74,12 +93,12 @@ export class SortedMap<TKey, TValue> {
7493
if (this.map.has(key)) {
7594
// Need to remove the old key from the sorted keys array
7695
const oldValue = this.map.get(key)!
77-
const oldIndex = this.indexOf(oldValue)
96+
const oldIndex = this.indexOf(key, oldValue)
7897
this.sortedKeys.splice(oldIndex, 1)
7998
}
8099

81100
// Insert the new key at the correct position
82-
const index = this.indexOf(value)
101+
const index = this.indexOf(key, value)
83102
this.sortedKeys.splice(index, 0, key)
84103

85104
this.map.set(key, value)
@@ -106,7 +125,7 @@ export class SortedMap<TKey, TValue> {
106125
delete(key: TKey): boolean {
107126
if (this.map.has(key)) {
108127
const oldValue = this.map.get(key)
109-
const index = this.indexOf(oldValue!)
128+
const index = this.indexOf(key, oldValue!)
110129
this.sortedKeys.splice(index, 1)
111130
return this.map.delete(key)
112131
}

packages/db/src/collection/state.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export class CollectionStateManager<
4343
public pendingSyncedTransactions: Array<
4444
PendingSyncedTransaction<TOutput, TKey>
4545
> = []
46-
public syncedData: Map<TKey, TOutput> | SortedMap<TKey, TOutput>
46+
public syncedData: SortedMap<TKey, TOutput>
4747
public syncedMetadata = new Map<TKey, unknown>()
4848

4949
// Optimistic state tracking - make public for testing
@@ -69,12 +69,9 @@ export class CollectionStateManager<
6969
a.compareCreatedAt(b)
7070
)
7171

72-
// Set up data storage with optional comparison function
73-
if (config.compare) {
74-
this.syncedData = new SortedMap<TKey, TOutput>(config.compare)
75-
} else {
76-
this.syncedData = new Map<TKey, TOutput>()
77-
}
72+
// Set up data storage - always use SortedMap for deterministic iteration.
73+
// If a custom compare function is provided, use it; otherwise entries are sorted by key only.
74+
this.syncedData = new SortedMap<TKey, TOutput>(config.compare)
7875
}
7976

8077
setDeps(deps: {

packages/db/src/indexes/btree-index.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { BTree } from "../utils/btree.js"
2-
import { defaultComparator, normalizeValue } from "../utils/comparison.js"
2+
import {
3+
compareKeys,
4+
defaultComparator,
5+
normalizeValue,
6+
} from "../utils/comparison.js"
37
import { BaseIndex } from "./base-index.js"
48
import type { CompareOptions } from "../query/builder/types.js"
59
import type { BasicExpression } from "../query/ir.js"
@@ -261,7 +265,8 @@ export class BTreeIndex<
261265
n: number,
262266
nextPair: (k?: any) => [any, any] | undefined,
263267
from?: any,
264-
filterFn?: (key: TKey) => boolean
268+
filterFn?: (key: TKey) => boolean,
269+
reversed: boolean = false
265270
): Array<TKey> {
266271
const keysInResult: Set<TKey> = new Set()
267272
const result: Array<TKey> = []
@@ -271,14 +276,25 @@ export class BTreeIndex<
271276
while ((pair = nextPair(key)) !== undefined && result.length < n) {
272277
key = pair[0]
273278
const keys = this.valueMap.get(key)
274-
if (keys) {
275-
const it = keys.values()
276-
let ks: TKey | undefined
277-
while (result.length < n && (ks = it.next().value)) {
279+
if (keys && keys.size > 0) {
280+
// Fast path: single key doesn't need sorting
281+
if (keys.size === 1) {
282+
const ks = keys.values().next().value as TKey
278283
if (!keysInResult.has(ks) && (filterFn?.(ks) ?? true)) {
279284
result.push(ks)
280285
keysInResult.add(ks)
281286
}
287+
} else {
288+
// Sort keys for deterministic order, reverse if needed
289+
const sorted = Array.from(keys).sort(compareKeys)
290+
if (reversed) sorted.reverse()
291+
for (const ks of sorted) {
292+
if (result.length >= n) break
293+
if (!keysInResult.has(ks) && (filterFn?.(ks) ?? true)) {
294+
result.push(ks)
295+
keysInResult.add(ks)
296+
}
297+
}
282298
}
283299
}
284300
}
@@ -309,7 +325,7 @@ export class BTreeIndex<
309325
filterFn?: (key: TKey) => boolean
310326
): Array<TKey> {
311327
const nextPair = (k?: any) => this.orderedEntries.nextLowerPair(k)
312-
return this.takeInternal(n, nextPair, from, filterFn)
328+
return this.takeInternal(n, nextPair, from, filterFn, true)
313329
}
314330

315331
/**

packages/db/src/utils/comparison.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,21 @@ export function areValuesEqual(a: any, b: any): boolean {
189189
// Different types or not Uint8Arrays
190190
return false
191191
}
192+
193+
/**
194+
* Compares two keys (string | number) for deterministic ordering.
195+
* Strings come before numbers, then sorted within type.
196+
*/
197+
export function compareKeys<TKey extends string | number>(
198+
a: TKey,
199+
b: TKey
200+
): number {
201+
// Same type: compare directly
202+
if (typeof a === typeof b) {
203+
if (a < b) return -1
204+
if (a > b) return 1
205+
return 0
206+
}
207+
// Different types: strings come before numbers
208+
return typeof a === `string` ? -1 : 1
209+
}

0 commit comments

Comments
 (0)