UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

358 lines (312 loc) 10.2 kB
import { compareKeys } from '@tanstack/db-ivm' import { BTree } from '../utils/btree.js' import { defaultComparator, normalizeValue } 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 } /** * 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 is used for efficient range queries // The `valueMap` is used for O(1) lookups of PKs by indexed value private orderedEntries: BTree<any, undefined> // we don't associate values with the keys of the B+ tree (the keys are indexed values) private valueMap = new Map<any, Set<TKey>>() // instead we store a mapping of indexed values to a set of PKs 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) this.compareFn = options?.compareFn ?? defaultComparator if (options?.compareOptions) { this.compareOptions = options!.compareOptions } 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 = normalizeValue(indexedValue) // Check if this value already exists if (this.valueMap.has(normalizedValue)) { // Add to existing set this.valueMap.get(normalizedValue)!.add(key) } else { // Create new set for this value const keySet = new Set<TKey>([key]) this.valueMap.set(normalizedValue, keySet) this.orderedEntries.set(normalizedValue, undefined) } this.indexedKeys.add(key) this.updateTimestamp() } /** * 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 = normalizeValue(indexedValue) if (this.valueMap.has(normalizedValue)) { const keySet = this.valueMap.get(normalizedValue)! keySet.delete(key) // If set is now empty, remove the entry entirely if (keySet.size === 0) { this.valueMap.delete(normalizedValue) // Remove from ordered entries this.orderedEntries.delete(normalizedValue) } } this.indexedKeys.delete(key) this.updateTimestamp() } /** * Updates a value in the index */ update(key: TKey, oldItem: any, newItem: any): void { this.remove(key, oldItem) this.add(key, newItem) } /** * 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.updateTimestamp() } /** * Performs a lookup operation */ lookup(operation: IndexOperation, value: any): Set<TKey> { const startTime = performance.now() 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`) } this.trackLookup(startTime) 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 = normalizeValue(value) return new Set(this.valueMap.get(normalizedValue) ?? []) } /** * 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>() const normalizedFrom = normalizeValue(from) const normalizedTo = normalizeValue(to) const fromKey = normalizedFrom ?? this.orderedEntries.minKey() const toKey = normalizedTo ?? this.orderedEntries.maxKey() this.orderedEntries.forRange( fromKey, toKey, toInclusive, (indexedValue, _) => { if (!fromInclusive && this.compareFn(indexedValue, from) === 0) { // the B+ tree `forRange` method does not support exclusive lower bounds // so we need to exclude it manually return } const keys = this.valueMap.get(indexedValue) if (keys) { keys.forEach((key) => result.add(key)) } }, ) return result } /** * Performs a reversed range query */ rangeQueryReversed(options: RangeQueryOptions = {}): Set<TKey> { const { from, to, fromInclusive = true, toInclusive = true } = options return this.rangeQuery({ from: to ?? this.orderedEntries.maxKey(), to: from ?? this.orderedEntries.minKey(), fromInclusive: toInclusive, toInclusive: fromInclusive, }) } private takeInternal( n: number, nextPair: (k?: any) => [any, any] | undefined, from?: any, filterFn?: (key: TKey) => boolean, reversed: boolean = false, ): Array<TKey> { const keysInResult: Set<TKey> = new Set() const result: Array<TKey> = [] let pair: [any, any] | undefined let key = normalizeValue(from) while ((pair = nextPair(key)) !== undefined && result.length < n) { key = pair[0] const keys = this.valueMap.get(key) if (keys && keys.size > 0) { // Sort keys for deterministic order, reverse if needed const sorted = Array.from(keys).sort(compareKeys) if (reversed) sorted.reverse() for (const ks of sorted) { if (result.length >= n) break if (!keysInResult.has(ks) && (filterFn?.(ks) ?? true)) { result.push(ks) keysInResult.add(ks) } } } } return result } /** * Returns the next n items after the provided item or the first n items if no from item is provided. * @param n - The number of items to return * @param from - The item to start from (exclusive). Starts from the smallest item (inclusive) if not provided. * @returns The next n items after the provided key. Returns the first n items if no from item is provided. */ take(n: number, from?: any, filterFn?: (key: TKey) => boolean): Array<TKey> { const nextPair = (k?: any) => this.orderedEntries.nextHigherPair(k) return this.takeInternal(n, nextPair, from, filterFn) } /** * Returns the next n items **before** the provided item (in descending order) or the last n items if no from item is provided. * @param n - The number of items to return * @param from - The item to start from (exclusive). Starts from the largest item (inclusive) if not provided. * @returns The next n items **before** the provided key. Returns the last n items if no from item is provided. */ takeReversed( n: number, from?: any, filterFn?: (key: TKey) => boolean, ): Array<TKey> { const nextPair = (k?: any) => this.orderedEntries.nextLowerPair(k) return this.takeInternal(n, nextPair, from, 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 = normalizeValue(value) const keys = this.valueMap.get(normalizedValue) if (keys) { keys.forEach((key) => result.add(key)) } } return result } // Getter methods for testing compatibility get indexedKeysSet(): Set<TKey> { return this.indexedKeys } get orderedEntriesArray(): Array<[any, Set<TKey>]> { return this.orderedEntries .keysArray() .map((key) => [key, this.valueMap.get(key) ?? new Set()]) } get orderedEntriesArrayReversed(): Array<[any, Set<TKey>]> { return this.takeReversed(this.orderedEntries.size).map((key) => [ key, this.valueMap.get(key) ?? new Set(), ]) } get valueMapData(): Map<any, Set<TKey>> { return this.valueMap } }