UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

537 lines (481 loc) • 19 kB
// This file was copied from https://github.com/qwertie/btree-typescript/tree/master and adapted to our needs. // We removed methods that we don't need. // B+ tree by David Piepgrass. License: MIT // Informative microbenchmarks & stuff: // http://www.jayconrod.com/posts/52/a-tour-of-v8-object-representation (very educational) // https://blog.mozilla.org/luke/2012/10/02/optimizing-javascript-variable-access/ (local vars are faster than properties) // http://benediktmeurer.de/2017/12/13/an-introduction-to-speculative-optimization-in-v8/ (other stuff) // https://jsperf.com/js-in-operator-vs-alternatives (avoid 'in' operator; `.p!==undefined` faster than `hasOwnProperty('p')` in all browsers) // https://jsperf.com/instanceof-vs-typeof-vs-constructor-vs-member (speed of type tests varies wildly across browsers) // https://jsperf.com/detecting-arrays-new (a.constructor===Array is best across browsers, assuming a is an object) // https://jsperf.com/shallow-cloning-methods (a constructor is faster than Object.create; hand-written clone faster than Object.assign) // https://jsperf.com/ways-to-fill-an-array (slice-and-replace is fastest) // https://jsperf.com/math-min-max-vs-ternary-vs-if (Math.min/max is slow on Edge) // https://jsperf.com/array-vs-property-access-speed (v.x/v.y is faster than a[0]/a[1] in major browsers IF hidden class is constant) // https://jsperf.com/detect-not-null-or-undefined (`x==null` slightly slower than `x===null||x===undefined` on all browsers) // Overall, microbenchmarks suggest Firefox is the fastest browser for JavaScript and Edge is the slowest. // Lessons from https://v8project.blogspot.com/2017/09/elements-kinds-in-v8.html: // - Avoid holes in arrays. Avoid `new Array(N)`, it will be "holey" permanently. // - Don't read outside bounds of an array (it scans prototype chain). // - Small integer arrays are stored differently from doubles // - Adding non-numbers to an array deoptimizes it permanently into a general array // - Objects can be used like arrays (e.g. have length property) but are slower // - V8 source (NewElementsCapacity in src/objects.h): arrays grow by 50% + 16 elements /** * Mutable B+ tree used by BTreeIndex for sorted value buckets. Keys use the * supplied comparator, which must return a number that is not NaN; BTreeIndex * checks every comparator result. Point operations cost O(log size). This fork has * no copy-on-write sharing, cloning, optional-value storage, early-exit range * callbacks, or in-place range edits: only the operations BTreeIndex uses, * plus `has()` and the `get()` fallback that the Map oracle observes * (tests/btree-map-oracle.test.ts). * @author David Piepgrass */ export class BTree<K = any, V = any> { private _root: BNode<K, V> = new BNode<K, V>() _size = 0 _maxNodeSize: number /** * provides a total order over keys (and a strict partial order over the type K) * @returns a negative value if a < b, 0 if a === b and a positive value if a > b */ _compare: (a: K, b: K) => number /** * Initializes an empty B+ tree. * @param compare Custom function to compare pairs of elements in the tree. * @param maxNodeSize Branching factor (maximum items or children per node) * Must be in range 4..256. If undefined or <4 then default is used; if >256 then 256. */ public constructor(compare: (a: K, b: K) => number, maxNodeSize?: number) { this._maxNodeSize = maxNodeSize! >= 4 ? Math.min(maxNodeSize!, 256) : 32 this._compare = compare } /** Gets the number of key-value pairs in the tree. */ get size() { return this._size } /** Releases the tree so that its size is 0. */ clear() { this._root = new BNode<K, V>() this._size = 0 } /** * Finds a pair in the tree and returns the associated value. * @param defaultValue a value to return if the key was not found. * @returns the value, or defaultValue if the key was not found. * @description Computational complexity: O(log size) */ get(key: K, defaultValue?: V): V | undefined { return this._root.get(key, defaultValue, this) } /** Returns true if the key exists in the B+ tree. */ has(key: K): boolean { const missing = {} as V return this.get(key, missing) !== missing } /** * Adds or overwrites a key-value pair in the B+ tree. Overwriting also * replaces the stored key. * @returns true if a new key-value pair was added. * @description Computational complexity: O(log size) */ set(key: K, value: V): boolean { const result = this._root.set(key, value, this) if (result === true || result === false) return result // Root node has split, so create a new root node. this._root = new BNodeInternal<K, V>([this._root, result]) return true } /** * Removes a single key-value pair from the B+ tree. * @returns true if a pair was found and removed, false otherwise. * @description Computational complexity: O(log size) */ delete(key: K): boolean { const size = this._size let root = this._root root.forRange(key, key, true, true, this) // Collapse roots left with at most one child by the deletion. while (root.keys.length <= 1 && !root.isLeaf) { this._root = root = root.keys.length === 0 ? new BNode<K, V>() : (root as any as BNodeInternal<K, V>).children[0]! } return this._size !== size } /** Gets the lowest key in the tree. Complexity: O(log size) */ minKey(): K | undefined { return this._root.minKey() } /** Gets the highest key in the tree. Complexity: O(1) */ maxKey(): K | undefined { return this._root.maxKey() } /** Returns the next pair whose key is larger than the specified key (or undefined if there is none). * If key === undefined, this function returns the lowest pair. */ nextHigherPair(key: K | undefined): [K, V] | undefined { return key === undefined ? this._root.minPair() : this._root.getPairOrNextHigher(key, this._compare) } /** Returns the next pair whose key is smaller than the specified key (or undefined if there is none). * If key === undefined, this function returns the highest pair. */ nextLowerPair(key: K | undefined): [K, V] | undefined { return key === undefined ? this._root.maxPair() : this._root.getPairOrNextLower(key, this._compare) } /** * Scans the specified range of keys, in ascending order by key. * Note: the callback `onFound` must not insert or remove items in the * collection. Doing so may cause incorrect data to be sent to the * callback afterward. * @param low The first key scanned will be greater than or equal to `low`. * @param high Scanning stops when a key larger than this is reached. * @param includeHigh If the `high` key is present, `onFound` is called for * that final pair if and only if this parameter is true. * @description Computational complexity: O(number of items scanned + log size) */ forRange( low: K, high: K, includeHigh: boolean, onFound: (k: K, v: V) => void, ): void { this._root.forRange(low, high, includeHigh, false, this, onFound) } } /** Leaf node / base class. **************************************************/ class BNode<K, V> { // If this is an internal node, _keys[i] is the highest key in children[i]. keys: Array<K> values: Array<V> get isLeaf() { return (this as any).children === undefined } constructor(keys: Array<K> = [], values: Array<V> = []) { this.keys = keys this.values = values } // ///////////////////////////////////////////////////////////////////////// // Shared methods ///////////////////////////////////////////////////////// maxKey() { return this.keys[this.keys.length - 1] } // If key not found, returns i^failXor where i is the insertion index. // Callers that don't care whether there was a match will set failXor=0. indexOf(key: K, failXor: number, cmp: (a: K, b: K) => number): number { const keys = this.keys let lo = 0, hi = keys.length, mid = hi >> 1 while (lo < hi) { const c = cmp(keys[mid]!, key) if (c < 0) lo = mid + 1 else if (c > 0) // key < keys[mid] hi = mid else return mid mid = (lo + hi) >> 1 } return mid ^ failXor } // /////////////////////////////////////////////////////////////////////////// // Leaf Node: misc ////////////////////////////////////////////////////////// minKey(): K | undefined { return this.keys[0] } /** Returns the pair at index `i`, or undefined when `i` is out of range. */ pairAt(i: number): [K, V] | undefined { return i >= 0 && i < this.keys.length ? [this.keys[i]!, this.values[i]!] : undefined } minPair(): [K, V] | undefined { return this.pairAt(0) } maxPair(): [K, V] | undefined { return this.pairAt(this.keys.length - 1) } get(key: K, defaultValue: V | undefined, tree: BTree<K, V>): V | undefined { const i = this.indexOf(key, -1, tree._compare) return i < 0 ? defaultValue : this.values[i] } // Strictly lower / higher neighbours of `key` within this leaf. getPairOrNextLower( key: K, compare: (a: K, b: K) => number, ): [K, V] | undefined { const i = this.indexOf(key, -1, compare) return this.pairAt(i < 0 ? ~i - 1 : i - 1) } getPairOrNextHigher( key: K, compare: (a: K, b: K) => number, ): [K, V] | undefined { const i = this.indexOf(key, -1, compare) return this.pairAt(i < 0 ? ~i : i + 1) } // /////////////////////////////////////////////////////////////////////////// // Leaf Node: set & node splitting ////////////////////////////////////////// set(key: K, value: V, tree: BTree<K, V>): boolean | BNode<K, V> { let i = this.indexOf(key, -1, tree._compare) if (i >= 0) { // Key already exists. Overwrite both key and value. this.keys[i] = key this.values[i] = value return false } i = ~i tree._size++ let target: BNode<K, V> = this let newRightSibling: BNode<K, V> | undefined if (this.keys.length >= tree._maxNodeSize) { // This leaf node is full and must split newRightSibling = this.splitOffRightSide() if (i > this.keys.length) { i -= this.keys.length target = newRightSibling } } target.keys.splice(i, 0, key) target.values.splice(i, 0, value) return newRightSibling ?? true } takeFromRight(rhs: BNode<K, V>) { // Reminder: parent node must update its copy of key for this node this.values.push(rhs.values.shift()!) this.keys.push(rhs.keys.shift()!) } splitOffRightSide(): BNode<K, V> { // Reminder: parent node must update its copy of key for this node const half = this.keys.length >> 1 return new BNode<K, V>(this.keys.splice(half), this.values.splice(half)) } // /////////////////////////////////////////////////////////////////////////// // Leaf Node: scanning & deletions ////////////////////////////////////////// // Visits [low, high] (or [low, high)) with `onFound`, or deletes that range // when `deleteMode` is set. forRange( low: K, high: K, includeHigh: boolean, deleteMode: boolean, tree: BTree<K, V>, onFound?: (k: K, v: V) => void, ): void { const cmp = tree._compare let iLow, iHigh if (high === low) { // A point range (as used by delete) needs only one search. if (!includeHigh) return iHigh = (iLow = this.indexOf(low, -1, cmp)) + 1 if (iLow < 0) return } else { iLow = this.indexOf(low, 0, cmp) iHigh = this.indexOf(high, -1, cmp) if (iHigh < 0) iHigh = ~iHigh else if (includeHigh) iHigh++ } if (deleteMode) { if (iHigh > iLow) { this.keys.splice(iLow, iHigh - iLow) this.values.splice(iLow, iHigh - iLow) tree._size -= iHigh - iLow } } else { for (let i = iLow; i < iHigh; i++) onFound!(this.keys[i]!, this.values[i]!) } } /** Adds entire contents of right-hand sibling (rhs is left unchanged) */ mergeSibling(rhs: BNode<K, V>, _: number) { this.keys.push.apply(this.keys, rhs.keys) this.values.push.apply(this.values, rhs.values) } } /** Internal node (non-leaf node) ********************************************/ class BNodeInternal<K, V> extends BNode<K, V> { // Note: conventionally B+ trees have one fewer key than the number of // children, but I find it easier to keep the array lengths equal: each // keys[i] caches the value of children[i].maxKey(). children: Array<BNode<K, V>> constructor(children: Array<BNode<K, V>>, keys?: Array<K>) { super(keys ?? children.map((child) => child.maxKey()!)) this.children = children } minKey() { return this.children[0]!.minKey() } minPair(): [K, V] | undefined { return this.children[0]!.minPair() } maxPair(): [K, V] | undefined { return this.children[this.children.length - 1]!.maxPair() } get(key: K, defaultValue: V | undefined, tree: BTree<K, V>): V | undefined { const i = this.indexOf(key, 0, tree._compare), children = this.children return i < children.length ? children[i]!.get(key, defaultValue, tree) : defaultValue } getPairOrNextLower( key: K, compare: (a: K, b: K) => number, ): [K, V] | undefined { const i = this.indexOf(key, 0, compare), children = this.children if (i >= children.length) return this.maxPair() return ( children[i]!.getPairOrNextLower(key, compare) ?? (i > 0 ? children[i - 1]!.maxPair() : undefined) ) } getPairOrNextHigher( key: K, compare: (a: K, b: K) => number, ): [K, V] | undefined { const i = this.indexOf(key, 0, compare), children = this.children if (i >= children.length) return undefined return ( children[i]!.getPairOrNextHigher(key, compare) ?? children[i + 1]?.minPair() ) } // /////////////////////////////////////////////////////////////////////////// // Internal Node: set & node splitting ////////////////////////////////////// set(key: K, value: V, tree: BTree<K, V>): boolean | BNodeInternal<K, V> { const c = this.children, max = tree._maxNodeSize, cmp = tree._compare let i = Math.min(this.indexOf(key, 0, cmp), c.length - 1) const child = c[i]! if (child.keys.length >= max) { // child is full; inserting anything else will cause a split. // Shifting an item to the left sibling may avoid a split. We can do a // shift if that sibling is not full and if the current key can still be // placed in the same node after the shift. A right shift would need a // key above child.maxKey(), and that only reaches the last child. let other: BNode<K, V> | undefined if ( i > 0 && (other = c[i - 1]!).keys.length < max && cmp(child.keys[0]!, key) < 0 ) { other.takeFromRight(child) this.keys[i - 1] = other.maxKey()! } } const result = child.set(key, value, tree) if (result === false) return false this.keys[i] = child.maxKey()! if (result === true) return true // The child has split and `result` is a new right child... does it fit? let target: BNodeInternal<K, V> = this let newRightSibling: BNodeInternal<K, V> | undefined if (this.keys.length >= max) { // no, we must split also newRightSibling = this.splitOffRightSide() // The new child follows i; no comparison is needed after mutation. if (i + 1 >= this.keys.length) { target = newRightSibling i -= this.keys.length } } target.children.splice(i + 1, 0, result) target.keys.splice(i + 1, 0, result.maxKey()!) return newRightSibling ?? true } /** * Split this node. * Modifies this to remove the second half of the items, returning a separate node containing them. */ splitOffRightSide() { const half = this.children.length >> 1 return new BNodeInternal<K, V>( this.children.splice(half), this.keys.splice(half), ) } takeFromRight(rhs: BNode<K, V>) { // Reminder: parent node must update its copy of key for this node this.keys.push(rhs.keys.shift()!) this.children.push((rhs as BNodeInternal<K, V>).children.shift()!) } // /////////////////////////////////////////////////////////////////////////// // Internal Node: scanning & deletions ////////////////////////////////////// forRange( low: K, high: K, includeHigh: boolean, deleteMode: boolean, tree: BTree<K, V>, onFound?: (k: K, v: V) => void, ): void { const cmp = tree._compare const keys = this.keys, children = this.children let iLow = this.indexOf(low, 0, cmp), i = iLow // A point range (as used by delete) needs only one search. const iHigh = Math.min( high === low ? iLow : this.indexOf(high, 0, cmp), keys.length - 1, ) for (; i <= iHigh; i++) { children[i]!.forRange(low, high, includeHigh, deleteMode, tree, onFound) // Note: if children[i] is empty then keys[i]=undefined. // This is an invalid state, but it is fixed below. if (deleteMode) keys[i] = children[i]!.maxKey()! } if (deleteMode) { // Deletions may have occurred, so look for opportunities to merge nodes. const half = tree._maxNodeSize >> 1 if (iLow > 0) iLow-- for (i = iHigh; i >= iLow; i--) { if (children[i]!.keys.length <= half) { if (children[i]!.keys.length !== 0) { this.tryMerge(i, tree._maxNodeSize) } else { // child is empty! delete it! keys.splice(i, 1) children.splice(i, 1) } } } } } /** Merges child i with child i+1 if their combined size is not too large */ tryMerge(i: number, maxSize: number): void { const children = this.children if ( i >= 0 && i + 1 < children.length && children[i]!.keys.length + children[i + 1]!.keys.length <= maxSize ) { children[i]!.mergeSibling(children[i + 1]!, maxSize) children.splice(i + 1, 1) this.keys.splice(i + 1, 1) this.keys[i] = children[i]!.maxKey()! } } /** * Move children from `rhs` into this. * `rhs` must be part of this tree, and be removed from it after this call. */ mergeSibling(rhs: BNode<K, V>, maxNodeSize: number) { const oldLength = this.keys.length this.keys.push.apply(this.keys, rhs.keys) const rhsChildren = (rhs as any as BNodeInternal<K, V>).children this.children.push.apply(this.children, rhsChildren) // If our children are themselves almost empty due to a mass-delete, // they may need to be merged too (but only the oldLength-1 and its // right sibling should need this). this.tryMerge(oldLength - 1, maxNodeSize) } }