@tanstack/db
Version:
A reactive client store for building super fast apps on sync
309 lines (270 loc) • 9.53 kB
text/typescript
import { compileSingleRowExpression } from '../query/compiler/evaluators.js'
import { comparisonFunctions } from '../query/builder/functions.js'
import { DEFAULT_COMPARE_OPTIONS, deepEquals } from '../utils.js'
import type { CompiledSingleRowExpression } from '../query/compiler/evaluators.js'
import type { RangeQueryOptions } from './btree-index.js'
import type { CompareOptions } from '../query/builder/types.js'
import type { BasicExpression, OrderByDirection } from '../query/ir.js'
function normalizeLocaleOptions(options: object | undefined): object {
return Object.fromEntries(
Object.entries(options ?? {}).filter(([, value]) => value !== undefined),
)
}
function canonicalizeLocale(locale: string | undefined): string | undefined {
return locale === undefined ? undefined : Intl.getCanonicalLocales(locale)[0]
}
type LocaleCompareOptions = CompareOptions & {
stringSort?: `locale`
locale?: string
localeOptions?: object
}
function usesLocaleCollation(
options: CompareOptions,
): options is LocaleCompareOptions {
return (options.stringSort ?? DEFAULT_COMPARE_OPTIONS.stringSort) === `locale`
}
/**
* Operations that indexes can support, imported from available comparison functions
*/
export const IndexOperation = comparisonFunctions
/**
* Type for index operation values
*/
export type IndexOperation = (typeof comparisonFunctions)[number]
/** The read-side surface consumers use on a resolved (possibly reversed) index. */
export type IndexReader<TKey extends string | number = string | number> = Pick<
IndexInterface<TKey>,
| `lookup`
| `rangeQuery`
| `take`
| `takeFromStart`
| `keyCount`
| `supports`
| `supportsRangeOptimization`
| `canOptimizeRangeFor`
>
export interface IndexInterface<
TKey extends string | number = string | number,
> {
add: (key: TKey, item: any) => void
remove: (key: TKey, item: any) => void
update: (key: TKey, oldItem: any, newItem: any) => void
build: (entries: Iterable<[TKey, any]>) => void
clear: () => void
lookup: (operation: IndexOperation, value: any) => Set<TKey>
equalityLookup: (value: any) => Set<TKey>
inArrayLookup: (values: Array<any>) => Set<TKey>
rangeQuery: (options: RangeQueryOptions) => Set<TKey>
rangeQueryReversed: (options: RangeQueryOptions) => Set<TKey>
take: (
n: number,
from: unknown,
filterFn?: (key: TKey) => boolean,
) => Array<TKey>
takeFromStart: (n: number, filterFn?: (key: TKey) => boolean) => Array<TKey>
takeReversed: (
n: number,
from: unknown,
filterFn?: (key: TKey) => boolean,
) => Array<TKey>
takeReversedFromEnd: (
n: number,
filterFn?: (key: TKey) => boolean,
) => Array<TKey>
get keyCount(): number
supports: (operation: IndexOperation) => boolean
/**
* Whether range lookups (gt/gte/lt/lte) on this index can be trusted to
* return every matching key. Range traversal relies on the index ordering, so
* it is unsafe when the index uses a custom comparator, whose order may not
* match the WHERE evaluator's relational operators. Callers must fall back to
* a full scan when this is `false`.
*/
get supportsRangeOptimization(): boolean
/**
* Whether the live values in this index share the predicate operand's
* relational domain. Mixed domains can sort differently in the index and
* WHERE evaluator, which can make a range lookup omit matching rows.
*/
canOptimizeRangeFor?: (value: unknown) => boolean
matchesField: (fieldPath: Array<string>) => boolean
matchesCompareOptions: (compareOptions: CompareOptions) => boolean
matchesDirection: (direction: OrderByDirection) => boolean
}
/**
* Base abstract class that all index types extend
*/
export abstract class BaseIndex<
TKey extends string | number = string | number,
> implements IndexInterface<TKey> {
public readonly id: number
public readonly name?: string
public readonly expression: BasicExpression
public abstract readonly supportedOperations: Set<IndexOperation>
protected compareOptions: CompareOptions
private compiledIndexEvaluator: CompiledSingleRowExpression | undefined
/**
* Set by subclasses when constructed with a user-supplied comparator, whose
* ordering may not match the WHERE evaluator's relational operators.
*/
protected hasCustomComparator = false
private rangeValueDomains = new Map<string, number>()
constructor(
id: number,
expression: BasicExpression,
name?: string,
options?: any,
) {
this.id = id
this.expression = expression
this.compareOptions = DEFAULT_COMPARE_OPTIONS
this.name = name
this.initialize(options)
}
// Abstract methods that each index type must implement
abstract add(key: TKey, item: any): void
abstract remove(key: TKey, item: any): void
abstract update(key: TKey, oldItem: any, newItem: any): void
abstract build(entries: Iterable<[TKey, any]>): void
abstract clear(): void
abstract lookup(operation: IndexOperation, value: any): Set<TKey>
abstract take(
n: number,
from: unknown,
filterFn?: (key: TKey) => boolean,
): Array<TKey>
abstract takeFromStart(
n: number,
filterFn?: (key: TKey) => boolean,
): Array<TKey>
abstract takeReversed(
n: number,
from: unknown,
filterFn?: (key: TKey) => boolean,
): Array<TKey>
abstract takeReversedFromEnd(
n: number,
filterFn?: (key: TKey) => boolean,
): Array<TKey>
abstract get keyCount(): number
abstract equalityLookup(value: any): Set<TKey>
abstract inArrayLookup(values: Array<any>): Set<TKey>
abstract rangeQuery(options: RangeQueryOptions): Set<TKey>
// Common methods
rangeQueryReversed(options: RangeQueryOptions = {}): Set<TKey> {
const { from, to, fromInclusive = true, toInclusive = true } = options
const reversed: RangeQueryOptions = {}
if (`to` in options) {
reversed.from = to
reversed.fromInclusive = toInclusive
}
if (`from` in options) {
reversed.to = from
reversed.toInclusive = fromInclusive
}
return this.rangeQuery(reversed)
}
supports(operation: IndexOperation): boolean {
return this.supportedOperations.has(operation)
}
get supportsRangeOptimization(): boolean {
return !this.hasCustomComparator
}
protected addRangeValue(value: unknown): void {
const domain = rangeValueDomain(value)
if (domain === undefined) return
this.rangeValueDomains.set(
domain,
(this.rangeValueDomains.get(domain) ?? 0) + 1,
)
}
protected removeRangeValue(value: unknown): void {
const domain = rangeValueDomain(value)
if (domain === undefined) return
const count = this.rangeValueDomains.get(domain)
if (count === undefined) return
if (count === 1) this.rangeValueDomains.delete(domain)
else this.rangeValueDomains.set(domain, count - 1)
}
protected clearRangeValues(): void {
this.rangeValueDomains.clear()
}
canOptimizeRangeFor(value: unknown): boolean {
const domain = rangeValueDomain(value)
if (domain === undefined) return true
if (!isNativeRangeDomain(domain)) return false
return (
this.rangeValueDomains.size === 0 ||
(this.rangeValueDomains.size === 1 && this.rangeValueDomains.has(domain))
)
}
matchesField(fieldPath: Array<string>): boolean {
return (
this.expression.type === `ref` &&
this.expression.path.length === fieldPath.length &&
this.expression.path.every((part, i) => part === fieldPath[i])
)
}
/**
* Checks if the compare options match the index's compare options.
* The direction is ignored because the index can be reversed if the direction is different.
*/
matchesCompareOptions(compareOptions: CompareOptions): boolean {
const indexCompareOptions = this.compareOptions
const indexUsesLocale = usesLocaleCollation(indexCompareOptions)
const requestedUsesLocale = usesLocaleCollation(compareOptions)
if (
indexCompareOptions.nulls !== compareOptions.nulls ||
indexUsesLocale !== requestedUsesLocale
) {
return false
}
if (!indexUsesLocale || !requestedUsesLocale) {
return true
}
return (
canonicalizeLocale(indexCompareOptions.locale) ===
canonicalizeLocale(compareOptions.locale) &&
deepEquals(
normalizeLocaleOptions(indexCompareOptions.localeOptions),
normalizeLocaleOptions(compareOptions.localeOptions),
)
)
}
/**
* Checks if the index matches the provided direction.
*/
matchesDirection(direction: OrderByDirection): boolean {
return this.compareOptions.direction === direction
}
protected abstract initialize(options?: any): void
protected evaluateIndexExpression(item: any): any {
const evaluator = (this.compiledIndexEvaluator ??=
compileSingleRowExpression(this.expression))
return evaluator(item as Record<string, unknown>)
}
}
function rangeValueDomain(value: unknown): string | undefined {
if (value == null) return undefined
if (value instanceof Date) return `date`
return typeof value
}
function isNativeRangeDomain(domain: string): boolean {
return (
domain === `number` ||
domain === `bigint` ||
domain === `boolean` ||
domain === `string` ||
domain === `date`
)
}
/**
* Type for index constructor
*/
export type IndexConstructor<TKey extends string | number = string | number> =
new (
id: number,
expression: BasicExpression,
name?: string,
options?: any,
) => BaseIndex<TKey>