@tanstack/db
Version:
A reactive client store for building super fast apps on sync
411 lines (372 loc) • 11.6 kB
text/typescript
import { compareKeys } from '@tanstack/db-ivm'
import { areSameValueZeroEqual, normalizeValue } from '../utils/comparison.js'
import { codedMessage, devBuild } from '../error-message.js'
import {
compareKeysReversed,
findInsertPositionInArray,
} from '../utils/array-utils.js'
import {
BaseIndex,
builtInIndexResolverNames,
warnRemovalFailure,
} from './base-index.js'
import type { IndexComparisonOptions, IndexOperation } from './base-index.js'
import type { RangeQueryOptions } from './btree-index.js'
export type { RangeQueryOptions } from './btree-index.js'
/**
* Options for Basic index
*/
export type BasicIndexOptions = IndexComparisonOptions
function orderValue(value: unknown): unknown {
return value instanceof Date ? value.getTime() : value
}
/**
* Basic index using Map + sorted Array.
*
* - Map for O(1) equality lookups
* - Sorted Array for O(log n) range queries via binary search
* - O(n) updates to maintain sort order
*
* Simpler and smaller than BTreeIndex, good for read-heavy workloads.
* Use BTreeIndex for write-heavy workloads with large collections.
*/
export class BasicIndex<
TKey extends string | number = string | number,
> extends BaseIndex<TKey> {
// Map for O(1) equality lookups: indexedValue -> Set of PKs
private valueMap = new Map<any, Set<TKey>>()
// Sorted array of unique indexed values for range queries
private sortedValues: Array<any> = []
private sortedExactValues: Array<unknown> = []
// Set of all indexed PKs
private indexedKeys = new Set<TKey>()
/**
* Adds a value to the index
*/
add(key: TKey, item: any): void {
const indexedValue = this.evaluateAddedValue(key, item)
const normalizedValue = normalizeValue(indexedValue)
this.addToBucket(key, normalizedValue, indexedValue)
this.addRangeValue(indexedValue)
this.indexedKeys.add(key)
}
private addToBucket(
key: TKey,
normalizedValue: unknown,
value: unknown,
): void {
const keySet = this.valueMap.get(normalizedValue)
if (keySet) {
// Value already exists, just add the key to the set
keySet.add(key)
} else {
// New value. Find its sorted position first: a comparator failure must
// throw before the index changes.
const orderedValue = orderValue(value)
const insertIdx = findInsertPositionInArray(
this.sortedValues,
orderedValue,
this.compareFn,
)
this.valueMap.set(normalizedValue, new Set([key]))
this.sortedValues.splice(insertIdx, 0, orderedValue)
this.sortedExactValues.splice(insertIdx, 0, normalizedValue)
}
}
/**
* Removes a value from the index
*/
remove(key: TKey, item: any): void {
let indexedValue: any
try {
indexedValue = this.evaluateIndexExpression(item)
} catch (error) {
warnRemovalFailure(key, error)
this.indexedKeys.delete(key)
return
}
const normalizedValue = normalizeValue(indexedValue)
this.removeFromBucket(key, normalizedValue, indexedValue)
this.removeRangeValue(indexedValue)
this.indexedKeys.delete(key)
}
private removeFromBucket(
key: TKey,
normalizedValue: unknown,
value: unknown,
): void {
const keySet = this.valueMap.get(normalizedValue)
if (keySet?.has(key)) {
// The last key for this value leaves the sorted array too. Locate it
// before any change, so a comparator failure leaves the index unchanged.
if (keySet.size === 1) {
const orderedValue = orderValue(value)
let sortedIndex = findInsertPositionInArray(
this.sortedValues,
orderedValue,
this.compareFn,
)
// Distinct equality keys may share one comparator position.
while (
sortedIndex < this.sortedValues.length &&
this.compareFn(this.sortedValues[sortedIndex], orderedValue) === 0
) {
if (
areSameValueZeroEqual(
this.sortedExactValues[sortedIndex],
normalizedValue,
)
) {
this.sortedValues.splice(sortedIndex, 1)
this.sortedExactValues.splice(sortedIndex, 1)
break
}
sortedIndex++
}
this.valueMap.delete(normalizedValue)
}
keySet.delete(key)
}
}
/**
* 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 = normalizeValue(oldIndexedValue)
const newValue = normalizeValue(newIndexedValue)
if (
areSameValueZeroEqual(oldValue, newValue) &&
this.valueMap.get(newValue)?.has(key) &&
this.indexedKeys.has(key)
) {
this.removeRangeValue(oldIndexedValue)
this.addRangeValue(newIndexedValue)
return
}
this.removeFromBucket(key, oldValue, oldIndexedValue)
this.removeRangeValue(oldIndexedValue)
this.addToBucket(key, newValue, newIndexedValue)
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) {
const indexedValue = this.evaluateAddedValue(key, item)
const value = normalizeValue(indexedValue)
const keys = this.valueMap.get(value)
if (keys) keys.add(key)
else {
this.valueMap.set(value, new Set([key]))
this.sortedValues.push(orderValue(indexedValue))
this.sortedExactValues.push(value)
}
this.addRangeValue(indexedValue)
this.indexedKeys.add(key)
}
// Array.sort moves raw undefined values last without comparing them.
// Wrap values so nulls-first comparison runs during a full build.
const sortable = this.sortedValues.map((value, index) => ({
value,
exact: this.sortedExactValues[index],
}))
sortable.sort((left, right) => this.compareFn(left.value, right.value))
this.sortedValues = sortable.map(({ value }) => value)
this.sortedExactValues = sortable.map(({ exact }) => exact)
}
/**
* Clears all data from the index
*/
clear(): void {
this.valueMap.clear()
this.sortedValues = []
this.sortedExactValues = []
this.indexedKeys.clear()
this.clearRangeValues()
}
lookup(operation: IndexOperation, value: any): Set<TKey> {
const result = this.lookupSupportedOperation(operation, value)
if (result !== undefined) return result
throw new Error(
devBuild() && process.env.NODE_ENV !== `production`
? `Operation ${operation} not supported by BasicIndex`
: codedMessage(100, { operation }),
)
}
/**
* Gets the number of indexed keys
*/
get keyCount(): number {
return this.indexedKeys.size
}
/**
* Performs an equality lookup - O(1)
*/
equalityLookup(value: any): Set<TKey> {
const normalizedValue = normalizeValue(value)
return this.valueMap.get(normalizedValue) ?? new Set()
}
/**
* Performs a range query using binary search - O(log n + m)
*/
rangeQuery(options: RangeQueryOptions = {}): Set<TKey> {
const { from, to, fromInclusive = true, toInclusive = true } = options
const result = new Set<TKey>()
if (this.sortedValues.length === 0) {
return result
}
const orderedFrom = orderValue(from)
const orderedTo = orderValue(to)
const hasFrom = `from` in options
const hasTo = `to` in options
// Find start index
let startIdx = 0
if (hasFrom) {
startIdx = findInsertPositionInArray(
this.sortedValues,
orderedFrom,
this.compareFn,
)
// Comparator-equal values form one range boundary even when they are
// distinct equality keys.
while (
!fromInclusive &&
startIdx < this.sortedValues.length &&
this.compareFn(this.sortedValues[startIdx], orderedFrom) === 0
) {
startIdx++
}
}
// Find end index
let endIdx = this.sortedValues.length
if (hasTo) {
endIdx = findInsertPositionInArray(
this.sortedValues,
orderedTo,
this.compareFn,
)
// Include the whole comparator group at an inclusive upper boundary.
while (
toInclusive &&
endIdx < this.sortedValues.length &&
this.compareFn(this.sortedValues[endIdx], orderedTo) === 0
) {
endIdx++
}
}
// Collect all keys in range
for (let i = startIdx; i < endIdx; i++) {
const keys = this.valueMap.get(this.sortedExactValues[i])
if (keys) {
keys.forEach((key) => result.add(key))
}
}
return result
}
/**
* Returns the next n items in sorted order
*/
take(n: number, from: any, filterFn?: (key: TKey) => boolean): Array<TKey> {
const orderedFrom = orderValue(from)
let startIdx = findInsertPositionInArray(
this.sortedValues,
orderedFrom,
this.compareFn,
)
// Skip past the 'from' value (exclusive)
while (
startIdx < this.sortedValues.length &&
this.compareFn(this.sortedValues[startIdx], orderedFrom) <= 0
) {
startIdx++
}
return this.takeFromIndex(n, startIdx, 1, filterFn)
}
/**
* Returns the next n items in reverse sorted order
*/
takeReversed(
n: number,
from: any,
filterFn?: (key: TKey) => boolean,
): Array<TKey> {
const orderedFrom = orderValue(from)
let startIdx =
findInsertPositionInArray(
this.sortedValues,
orderedFrom,
this.compareFn,
) - 1
// Skip past the 'from' value (exclusive)
while (
startIdx >= 0 &&
this.compareFn(this.sortedValues[startIdx], orderedFrom) >= 0
) {
startIdx--
}
return this.takeFromIndex(n, startIdx, -1, filterFn)
}
/**
* Returns the first n items in sorted order (from the start)
*/
takeFromStart(n: number, filterFn?: (key: TKey) => boolean): Array<TKey> {
return this.takeFromIndex(n, 0, 1, filterFn)
}
/**
* Returns the first n items in reverse sorted order (from the end)
*/
takeReversedFromEnd(
n: number,
filterFn?: (key: TKey) => boolean,
): Array<TKey> {
return this.takeFromIndex(n, this.sortedValues.length - 1, -1, filterFn)
}
private takeFromIndex(
n: number,
startIndex: number,
step: 1 | -1,
filterFn?: (key: TKey) => boolean,
): Array<TKey> {
const result: Array<TKey> = []
let index = startIndex
while (
index >= 0 &&
index < this.sortedValues.length &&
result.length < n
) {
const groupValue = this.sortedValues[index]
const groupKeys: Array<TKey> = []
do {
for (const key of this.valueMap.get(this.sortedExactValues[index]) ??
[]) {
groupKeys.push(key)
}
index += step
} while (
index >= 0 &&
index < this.sortedValues.length &&
this.compareFn(this.sortedValues[index], groupValue) === 0
)
groupKeys.sort(step === 1 ? compareKeys : compareKeysReversed)
for (const key of groupKeys) {
if (filterFn?.(key) ?? true) result.push(key)
if (result.length >= n) break
}
}
return result
}
}
builtInIndexResolverNames.set(BasicIndex, `BasicIndex`)