@tanstack/db
Version:
A reactive client store for building super fast apps on sync
256 lines (236 loc) • 8 kB
text/typescript
import { compareKeys } from '@tanstack/db-ivm'
/**
* A Map implementation that keeps its entries sorted based on a comparator function
* @template TKey - The type of keys in the map (must be string | number)
* @template TValue - The type of values in the map
*/
export class SortedMap<TKey extends string | number, TValue> {
private map: Map<TKey, TValue>
private sortedKeys: Array<TKey>
private comparator: ((a: TValue, b: TValue) => number) | undefined
// Keys set or deleted with deferOrder since the last restoreOrder. A dirty
// key can still hold a slot in sortedKeys that its old value ordered.
private dirtyKeys = new Set<TKey>()
/**
* Creates a new SortedMap instance
*
* @param comparator - Optional function to compare values for sorting.
* If not provided, entries are sorted by key only.
*/
constructor(comparator?: (a: TValue, b: TValue) => number) {
this.map = new Map<TKey, TValue>()
this.sortedKeys = []
this.comparator = comparator
}
/**
* Finds the index where a key-value pair should be inserted to maintain sort order.
* Uses binary search to find the correct position based on the value (if comparator provided),
* with key-based tie-breaking for deterministic ordering when values compare as equal.
* If no comparator is provided, sorts by key only.
* Runs in O(log n) time.
*
* @param key - The key to find position for (used as tie-breaker or primary sort when no comparator)
* @param value - The value to compare against (only used if comparator is provided)
* @param keys - The ordered keys to search
* @returns The index where the key should be inserted
*/
private indexOf(
key: TKey,
value: TValue,
keys: Array<TKey> = this.sortedKeys,
): number {
let left = 0
let right = keys.length
// Fast path: no comparator means sort by key only
if (!this.comparator) {
while (left < right) {
const mid = Math.floor((left + right) / 2)
const midKey = keys[mid]!
const keyComparison = compareKeys(key, midKey)
if (keyComparison < 0) {
right = mid
} else if (keyComparison > 0) {
left = mid + 1
} else {
return mid
}
}
return left
}
// With comparator: sort by value first, then key as tie-breaker
while (left < right) {
const mid = Math.floor((left + right) / 2)
const midKey = keys[mid]!
const midValue = this.map.get(midKey)!
const valueComparison = this.comparator(value, midValue)
if (valueComparison < 0) {
right = mid
} else if (valueComparison > 0) {
left = mid + 1
} else {
// Values are equal, use key as tie-breaker for deterministic ordering
const keyComparison = compareKeys(key, midKey)
if (keyComparison < 0) {
right = mid
} else if (keyComparison > 0) {
left = mid + 1
} else {
// Same key (shouldn't happen during insert, but handle for lookups)
return mid
}
}
}
return left
}
/**
* Restore the ordered view after a synchronous batch of deferred writes.
* Dirty keys leave their old slots by Set membership, so a deleted value or
* one a sync source mutated in place cannot strand a slot. The dirty keys
* still present are sorted and merged into the rest in one pass:
* O(n + k log n) for k dirty keys. The merge rewrites sortedKeys in place,
* so an open key iterator reads the restored order.
*/
restoreOrder(): void {
if (this.dirtyKeys.size === 0) return
const keys = this.sortedKeys
const kept = keys.filter((key) => !this.dirtyKeys.has(key))
const placed = [...this.dirtyKeys]
.filter((key) => this.map.has(key))
.sort(
(left, right) =>
(this.comparator?.(this.map.get(left)!, this.map.get(right)!) ?? 0) ||
compareKeys(left, right),
)
keys.length = 0
let next = 0
for (const key of placed) {
const index = this.indexOf(key, this.map.get(key)!, kept)
while (next < index) keys.push(kept[next++]!)
keys.push(key)
}
while (next < kept.length) keys.push(kept[next++]!)
this.dirtyKeys.clear()
}
/** Removes a key's slot while its placed value is still mapped. */
private unplace(key: TKey): void {
// Binary search misses a value that a sync source mutated in place after
// it was placed, so fall back to a scan. Slots compare as Map keys do, so
// a NaN key matches its own slot.
const sameKey = (candidate: TKey) =>
candidate === key || (candidate !== candidate && key !== key)
let index = this.indexOf(key, this.map.get(key)!)
if (!sameKey(this.sortedKeys[index]!)) {
index = this.sortedKeys.findIndex(sameKey)
}
this.sortedKeys.splice(index, 1)
}
/**
* Sets a key-value pair in the map and maintains sort order
*
* @param key - The key to set
* @param value - The value to associate with the key
* @param deferOrder - Defer ordering until restoreOrder or the next ordered read
* @returns This SortedMap instance for chaining
*/
set(key: TKey, value: TValue, deferOrder = false): this {
// Key order cannot change when an existing key gets a new value.
if (!this.comparator && this.map.has(key)) {
this.map.set(key, value)
return this
}
if (deferOrder) {
this.map.set(key, value)
this.dirtyKeys.add(key)
return this
}
this.restoreOrder()
// The old slot leaves before the search, since an in-place mutation can
// misorder it.
if (this.map.has(key)) this.unplace(key)
this.sortedKeys.splice(this.indexOf(key, value), 0, key)
this.map.set(key, value)
return this
}
/**
* Gets a value by its key
*
* @param key - The key to look up
* @returns The value associated with the key, or undefined if not found
*/
get(key: TKey): TValue | undefined {
return this.map.get(key)
}
/**
* Removes a key-value pair from the map
*
* @param key - The key to remove
* @param deferOrder - Defer ordering until restoreOrder or the next ordered read
* @returns True if the key was found and removed, false otherwise
*/
delete(key: TKey, deferOrder = false): boolean {
if (deferOrder) {
const deleted = this.map.delete(key)
if (deleted) this.dirtyKeys.add(key)
return deleted
}
this.restoreOrder()
if (this.map.has(key)) this.unplace(key)
return this.map.delete(key)
}
/**
* Checks if a key exists in the map
*
* @param key - The key to check
* @returns True if the key exists, false otherwise
*/
has(key: TKey): boolean {
return this.map.has(key)
}
/**
* Removes all key-value pairs from the map
*/
clear(): void {
this.map.clear()
this.sortedKeys = []
this.dirtyKeys.clear()
}
/**
* Gets the number of key-value pairs in the map
*/
get size(): number {
return this.map.size
}
/**
* Default iterator that returns entries in sorted order
*
* @returns An iterator for the map's entries
*/
*[Symbol.iterator](): IterableIterator<[TKey, TValue]> {
for (const key of this.keys()) yield [key, this.map.get(key)!]
}
/**
* Returns an iterator for the map's entries in sorted order
*
* @returns An iterator for the map's entries
*/
entries(): IterableIterator<[TKey, TValue]> {
return this[Symbol.iterator]()
}
/**
* Returns an iterator for the map's keys in sorted order
*
* @returns An iterator for the map's keys
*/
keys(): IterableIterator<TKey> {
this.restoreOrder()
return this.sortedKeys[Symbol.iterator]()
}
/**
* Returns an iterator for the map's values in sorted order
*
* @returns An iterator for the map's values
*/
*values(): IterableIterator<TValue> {
for (const key of this.keys()) yield this.map.get(key)!
}
}