UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

445 lines (394 loc) 13.4 kB
import { compareKeys } from '@tanstack/db-ivm' import { compareKeysReversed } from '../utils/array-utils.js' import { BTree } from '../utils/btree.js' import { areSameValueZeroEqual, defaultComparator, denormalizeUndefined, makeComparator, normalizeForBTree, } from '../utils/comparison.js' import { BaseIndex } from './base-index.js' import type { CompareOptions } from '../query/builder/types.js' import type { BasicExpression } from '../query/ir.js' import type { IndexOperation } from './base-index.js' /** * Options for Ordered index */ export interface BTreeIndexOptions { compareFn?: (a: any, b: any) => number compareOptions?: CompareOptions } /** * Options for range queries */ export interface RangeQueryOptions { from?: any to?: any fromInclusive?: boolean toInclusive?: boolean } type OrderedBucket<TKey> = { representative: unknown exactValues: Set<unknown> keys: Set<TKey> } /** * B+Tree index for sorted data with range queries * This maintains items in sorted order and provides efficient range operations */ export class BTreeIndex< TKey extends string | number = string | number, > extends BaseIndex<TKey> { public readonly supportedOperations = new Set<IndexOperation>([ `eq`, `gt`, `gte`, `lt`, `lte`, `in`, ]) // Internal data structures - private to hide implementation details // The `orderedEntries` B+ tree groups values that occupy the same comparator // position. The `valueMap` keeps exact values separate for equality lookups. private orderedEntries: BTree<any, OrderedBucket<TKey>> private valueMap = new Map< unknown, { keys: Set<TKey>; ordered: OrderedBucket<TKey> } >() private indexedKeys = new Set<TKey>() private compareFn: (a: any, b: any) => number = defaultComparator constructor( id: number, expression: BasicExpression, name?: string, options?: any, ) { super(id, expression, name, options) if (options?.compareOptions) { this.compareOptions = options!.compareOptions } // Get the base compare function const baseCompareFn = options?.compareFn ?? makeComparator(this.compareOptions) this.hasCustomComparator = options?.compareFn != null // Wrap it to denormalize sentinels before comparison // This ensures UNDEFINED_SENTINEL is converted back to undefined // before being passed to the baseCompareFn (which can be user-provided and is unaware of the UNDEFINED_SENTINEL) this.compareFn = (a: any, b: any) => baseCompareFn(denormalizeUndefined(a), denormalizeUndefined(b)) this.orderedEntries = new BTree(this.compareFn) } protected initialize(_options?: BTreeIndexOptions): void {} /** * Adds a value to the index */ add(key: TKey, item: any): void { let indexedValue: any try { indexedValue = this.evaluateIndexExpression(item) } catch (error) { throw new Error( `Failed to evaluate index expression for key ${key}: ${error}`, ) } // Normalize the value for Map key usage const normalizedValue = normalizeForBTree(indexedValue) this.addToBucket(key, normalizedValue) this.addRangeValue(indexedValue) this.indexedKeys.add(key) } private addToBucket(key: TKey, normalizedValue: unknown): void { const exact = this.valueMap.get(normalizedValue) if (exact) { exact.keys.add(key) exact.ordered.keys.add(key) return } let orderedBucket = this.orderedEntries.get(normalizedValue) if (orderedBucket) { orderedBucket.keys.add(key) orderedBucket.exactValues.add(normalizedValue) } else { orderedBucket = { representative: normalizedValue, exactValues: new Set([normalizedValue]), keys: new Set([key]), } this.orderedEntries.set(normalizedValue, orderedBucket) } this.valueMap.set(normalizedValue, { keys: new Set([key]), ordered: orderedBucket, }) } /** * Removes a value from the index */ remove(key: TKey, item: any): void { let indexedValue: any try { indexedValue = this.evaluateIndexExpression(item) } catch (error) { console.warn( `Failed to evaluate index expression for key ${key} during removal:`, error, ) return } // Normalize the value for Map key usage const normalizedValue = normalizeForBTree(indexedValue) this.removeFromBucket(key, normalizedValue) this.removeRangeValue(indexedValue) this.indexedKeys.delete(key) } private removeFromBucket(key: TKey, normalizedValue: unknown): void { const exact = this.valueMap.get(normalizedValue) if (!exact || !exact.keys.delete(key)) return const removedExactValue = exact.keys.size === 0 if (removedExactValue) this.valueMap.delete(normalizedValue) const orderedBucket = exact.ordered orderedBucket.keys.delete(key) if (removedExactValue) orderedBucket.exactValues.delete(normalizedValue) if (orderedBucket.keys.size === 0) { this.orderedEntries.delete(normalizedValue) } else if ( removedExactValue && areSameValueZeroEqual(orderedBucket.representative, normalizedValue) ) { this.orderedEntries.delete(normalizedValue) const representative = orderedBucket.exactValues.values().next().value orderedBucket.representative = representative this.orderedEntries.set(representative, orderedBucket) } } /** * Updates a value in the index */ update(key: TKey, oldItem: any, newItem: any): void { let oldIndexedValue: unknown let newIndexedValue: unknown try { oldIndexedValue = this.evaluateIndexExpression(oldItem) newIndexedValue = this.evaluateIndexExpression(newItem) } catch { this.remove(key, oldItem) this.add(key, newItem) return } const oldValue = normalizeForBTree(oldIndexedValue) const newValue = normalizeForBTree(newIndexedValue) if ( areSameValueZeroEqual(oldValue, newValue) && this.valueMap.get(newValue)?.keys.has(key) ) { this.removeRangeValue(oldIndexedValue) this.addRangeValue(newIndexedValue) return } this.removeFromBucket(key, oldValue) this.removeRangeValue(oldIndexedValue) this.addToBucket(key, newValue) this.addRangeValue(newIndexedValue) this.indexedKeys.add(key) } /** * Builds the index from a collection of entries */ build(entries: Iterable<[TKey, any]>): void { this.clear() for (const [key, item] of entries) { this.add(key, item) } } /** * Clears all data from the index */ clear(): void { this.orderedEntries.clear() this.valueMap.clear() this.indexedKeys.clear() this.clearRangeValues() } /** * Performs a lookup operation */ lookup(operation: IndexOperation, value: any): Set<TKey> { let result: Set<TKey> switch (operation) { case `eq`: result = this.equalityLookup(value) break case `gt`: result = this.rangeQuery({ from: value, fromInclusive: false }) break case `gte`: result = this.rangeQuery({ from: value, fromInclusive: true }) break case `lt`: result = this.rangeQuery({ to: value, toInclusive: false }) break case `lte`: result = this.rangeQuery({ to: value, toInclusive: true }) break case `in`: result = this.inArrayLookup(value) break default: throw new Error(`Operation ${operation} not supported by BTreeIndex`) } return result } /** * Gets the number of indexed keys */ get keyCount(): number { return this.indexedKeys.size } // Public methods for backward compatibility (used by tests) /** * Performs an equality lookup */ equalityLookup(value: any): Set<TKey> { const normalizedValue = normalizeForBTree(value) return new Set(this.valueMap.get(normalizedValue)?.keys ?? []) } /** * Performs a range query with options * This is more efficient for compound queries like "WHERE a > 5 AND a < 10" */ rangeQuery(options: RangeQueryOptions = {}): Set<TKey> { const { from, to, fromInclusive = true, toInclusive = true } = options const result = new Set<TKey>() // Check if from/to were explicitly provided (even if undefined) // vs not provided at all (should use min/max key) const hasFrom = `from` in options const hasTo = `to` in options const fromKey = hasFrom ? normalizeForBTree(from) : this.orderedEntries.minKey() const toKey = hasTo ? normalizeForBTree(to) : this.orderedEntries.maxKey() this.orderedEntries.forRange( fromKey, toKey, toInclusive, (indexedValue, bucket) => { // Only exclude the boundary when an exclusive lower bound was // actually provided. Without a `from` bound, `fromKey` defaults to // the minimum key and must not be dropped. Compare against the // normalized key since indexed values are stored normalized // (e.g. dates as timestamps), so the raw `from` would never match. if ( hasFrom && !fromInclusive && this.compareFn(indexedValue, fromKey) === 0 ) { // the B+ tree `forRange` method does not support exclusive lower bounds // so we need to exclude it manually return } bucket.keys.forEach((key) => result.add(key)) }, ) return result } /** * Internal method for taking items from the index. * @param n - The number of items to return * @param nextPair - Function to get the next pair from the BTree * @param from - Already normalized! undefined means "start from beginning/end", sentinel means "start from the key undefined" * @param filterFn - Optional filter function * @param reversed - Whether to reverse the order of keys within each value */ private takeInternal( n: number, nextPair: (k?: any) => [any, OrderedBucket<TKey>] | undefined, from: any, filterFn?: (key: TKey) => boolean, reversed: boolean = false, ): Array<TKey> { const result: Array<TKey> = [] let pair: [any, OrderedBucket<TKey>] | undefined let key = from // Use as-is - it's already normalized by the caller // Every key owns exactly one bucket, so the walk never repeats a key. while ((pair = nextPair(key)) !== undefined && result.length < n) { key = pair[0] // Sort keys for deterministic order within a comparator position. const sorted = Array.from(pair[1].keys).sort( reversed ? compareKeysReversed : compareKeys, ) for (const ks of sorted) { if (result.length >= n) break if (filterFn?.(ks) ?? true) result.push(ks) } } return result } /** * Returns the next n items after the provided item. * @param n - The number of items to return * @param from - The item to start from (exclusive). * @returns The next n items after the provided key. */ take(n: number, from: any, filterFn?: (key: TKey) => boolean): Array<TKey> { const nextPair = (k?: any) => this.orderedEntries.nextHigherPair(k) // Normalize the from value const normalizedFrom = normalizeForBTree(from) return this.takeInternal(n, nextPair, normalizedFrom, filterFn) } /** * Returns the first n items from the beginning. * @param n - The number of items to return * @param filterFn - Optional filter function * @returns The first n items */ takeFromStart(n: number, filterFn?: (key: TKey) => boolean): Array<TKey> { const nextPair = (k?: any) => this.orderedEntries.nextHigherPair(k) // Pass undefined to mean "start from beginning" (BTree's native behavior) return this.takeInternal(n, nextPair, undefined, filterFn) } /** * Returns the next n items **before** the provided item (in descending order). * @param n - The number of items to return * @param from - The item to start from (exclusive). Required. * @returns The next n items **before** the provided key. */ takeReversed( n: number, from: any, filterFn?: (key: TKey) => boolean, ): Array<TKey> { const nextPair = (k?: any) => this.orderedEntries.nextLowerPair(k) // Normalize the from value const normalizedFrom = normalizeForBTree(from) return this.takeInternal(n, nextPair, normalizedFrom, filterFn, true) } /** * Returns the last n items from the end. * @param n - The number of items to return * @param filterFn - Optional filter function * @returns The last n items */ takeReversedFromEnd( n: number, filterFn?: (key: TKey) => boolean, ): Array<TKey> { const nextPair = (k?: any) => this.orderedEntries.nextLowerPair(k) // Pass undefined to mean "start from end" (BTree's native behavior) return this.takeInternal(n, nextPair, undefined, filterFn, true) } /** * Performs an IN array lookup */ inArrayLookup(values: Array<any>): Set<TKey> { const result = new Set<TKey>() for (const value of values) { const normalizedValue = normalizeForBTree(value) const keys = this.valueMap.get(normalizedValue)?.keys if (keys) { keys.forEach((key) => result.add(key)) } } return result } }