UNPKG

slimsearch

Version:

Tiny but powerful full-text search engine for browser and Node

1 lines 127 kB
{"version":3,"file":"index.mjs","sources":["../src/SearchableMap/TreeIterator.ts","../src/SearchableMap/fuzzySearch.ts","../src/SearchableMap/SearchableMap.ts","../src/info.ts","../src/constant.ts","../src/utils.ts","../src/warning.ts","../src/term.ts","../src/add.ts","../src/defaults.ts","../src/symbols.ts","../src/results.ts","../src/search.ts","../src/autoSuggest.ts","../src/SearchIndex.ts","../src/init.ts","../src/vacuum.ts","../src/remove.ts","../src/replace.ts"],"sourcesContent":["import type { Entry, LeafType, RadixTree } from \"./typings.js\";\n\nexport const ENTRIES = \"ENTRIES\";\n\nexport const KEYS = \"KEYS\";\n\nexport const VALUES = \"VALUES\";\n\nexport const LEAF = \"\" as LeafType;\n\ninterface Iterators<T> {\n ENTRIES: Entry<T>;\n KEYS: string;\n VALUES: T;\n}\n\ntype Kind<T> = keyof Iterators<T>;\ntype Result<T, K extends keyof Iterators<T>> = Iterators<T>[K];\n\ntype IteratorPath<T> = {\n node: RadixTree<T>;\n keys: string[];\n}[];\n\nexport interface IterableSet<T> {\n _tree: RadixTree<T>;\n _prefix: string;\n}\n\n/**\n * @private\n */\nexport class TreeIterator<T, K extends Kind<T>>\n implements Iterator<Result<T, K>>\n{\n set: IterableSet<T>;\n _type: K;\n _path: IteratorPath<T>;\n\n constructor(set: IterableSet<T>, type: K) {\n const node = set._tree;\n const keys = Array.from(node.keys());\n\n this.set = set;\n this._type = type;\n this._path = keys.length > 0 ? [{ node, keys }] : [];\n }\n\n next(): IteratorResult<Result<T, K>> {\n const value = this.dive();\n\n this.backtrack();\n\n return value;\n }\n\n dive(): IteratorResult<Result<T, K>> {\n if (this._path.length === 0) return { done: true, value: undefined };\n\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n const { node, keys } = last(this._path)!;\n\n if (last(keys) === LEAF) return { done: false, value: this.result() };\n\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n const child = node.get(last(keys)!)!;\n\n this._path.push({ node: child, keys: Array.from(child.keys()) });\n\n return this.dive();\n }\n\n backtrack(): void {\n if (this._path.length === 0) return;\n\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n const keys = last(this._path)!.keys;\n\n keys.pop();\n if (keys.length > 0) return;\n\n this._path.pop();\n this.backtrack();\n }\n\n key(): string {\n return (\n this.set._prefix +\n this._path\n .map(({ keys }) => last(keys))\n .filter((key) => key !== LEAF)\n .join(\"\")\n );\n }\n\n value(): T {\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n return last(this._path)!.node.get(LEAF)!;\n }\n\n result(): Result<T, K> {\n switch (this._type) {\n case VALUES:\n return this.value() as Result<T, K>;\n case KEYS:\n return this.key() as Result<T, K>;\n default:\n return [this.key(), this.value()] as Result<T, K>;\n }\n }\n\n [Symbol.iterator](): this {\n return this;\n }\n}\n\nconst last = <T>(array: T[]): T | undefined => {\n return array[array.length - 1];\n};\n","import { LEAF } from \"./TreeIterator.js\";\nimport type { FuzzyResults, RadixTree } from \"./typings.js\";\n\nexport const fuzzySearch = <Value = any>(\n node: RadixTree<Value>,\n query: string,\n maxDistance: number,\n): FuzzyResults<Value> => {\n const results: FuzzyResults<Value> = new Map();\n\n if (typeof query !== \"string\") return results;\n\n // Number of columns in the Levenshtein matrix.\n const n = query.length + 1;\n\n // Matching terms can never be longer than N + maxDistance.\n const m = n + maxDistance;\n\n // Fill first matrix row and column with numbers: 0 1 2 3 ...\n const matrix = new Uint8Array(m * n).fill(maxDistance + 1);\n\n for (let j = 0; j < n; ++j) matrix[j] = j;\n for (let i = 1; i < m; ++i) matrix[i * n] = i;\n\n recurse(node, query, maxDistance, results, matrix, 1, n, \"\");\n\n return results;\n};\n\n// Modified version of http://stevehanov.ca/blog/?id=114\n\n// This builds a Levenshtein matrix for a given query and continuously updates\n// it for nodes in the radix tree that fall within the given maximum edit\n// distance. Keeping the same matrix around is beneficial especially for larger\n// edit distances.\n//\n// k a t e <-- query\n// 0 1 2 3 4\n// c 1 1 2 3 4\n// a 2 2 1 2 3\n// t 3 3 2 1 [2] <-- edit distance\n// ^\n// ^ term in radix tree, rows are added and removed as needed\n\nconst recurse = <Value = any>(\n node: RadixTree<Value>,\n query: string,\n maxDistance: number,\n results: FuzzyResults<Value>,\n matrix: Uint8Array,\n m: number,\n n: number,\n prefix: string,\n): void => {\n const offset = m * n;\n\n key: for (const key of node.keys())\n if (key === LEAF) {\n // We've reached a leaf node. Check if the edit distance acceptable and\n // store the result if it is.\n const distance = matrix[offset - 1];\n\n if (distance <= maxDistance)\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n results.set(prefix, [node.get(key)!, distance]);\n } else {\n // Iterate over all characters in the key. Update the Levenshtein matrix\n // and check if the minimum distance in the last row is still within the\n // maximum edit distance. If it is, we can recurse over all child nodes.\n let i = m;\n\n for (let pos = 0; pos < key.length; ++pos, ++i) {\n const char = key[pos];\n const thisRowOffset = n * i;\n const prevRowOffset = thisRowOffset - n;\n\n // Set the first column based on the previous row, and initialize the\n // minimum distance in the current row.\n let minDistance = matrix[thisRowOffset];\n\n const jmin = Math.max(0, i - maxDistance - 1);\n const jmax = Math.min(n - 1, i + maxDistance);\n\n // Iterate over remaining columns (characters in the query).\n for (let j = jmin; j < jmax; ++j) {\n const different = char !== query[j];\n\n // It might make sense to only read the matrix positions used for\n // deletion/insertion if the characters are different. But we want to\n // avoid conditional reads for performance reasons.\n const rpl = matrix[prevRowOffset + j] + +different;\n const del = matrix[prevRowOffset + j + 1] + 1;\n const ins = matrix[thisRowOffset + j] + 1;\n\n const dist = (matrix[thisRowOffset + j + 1] = Math.min(\n rpl,\n del,\n ins,\n ));\n\n if (dist < minDistance) minDistance = dist;\n }\n\n // Because distance will never decrease, we can stop. There will be no\n // matching child nodes.\n if (minDistance > maxDistance) continue key;\n }\n\n recurse(\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n node.get(key)!,\n query,\n maxDistance,\n results,\n matrix,\n i,\n n,\n prefix + key,\n );\n }\n};\n","import { ENTRIES, KEYS, LEAF, TreeIterator, VALUES } from \"./TreeIterator.js\";\nimport { fuzzySearch } from \"./fuzzySearch.js\";\nimport type { Entry, FuzzyResults, Path, RadixTree } from \"./typings.js\";\n\n/**\n * A class implementing the same interface as a standard JavaScript\n * [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map)\n * with string keys, but adding support for efficiently searching entries with\n * prefix or fuzzy search. This class is used internally by {@link SearchIndex} as\n * the inverted index data structure. The implementation is a radix tree\n * (compressed prefix tree).\n *\n * Since this class can be of general utility beyond _SlimSearch_, it is\n * exported by the `slimsearch` package and can be imported (or required) as\n * `slimsearch/SearchableMap`.\n *\n * @typeParam Value The type of the values stored in the map.\n */\nexport class SearchableMap<Value = any> {\n /**\n * @ignore\n */\n _tree: RadixTree<Value>;\n\n /**\n * @ignore\n */\n _prefix: string;\n\n private _size: number | undefined = undefined;\n\n /**\n * The constructor is normally called without arguments, creating an empty\n * map. In order to create a {@link SearchableMap} from an iterable or from an\n * object, check {@link SearchableMap.from} and {@link SearchableMap.fromObject}.\n *\n * The constructor arguments are for internal use, when creating derived\n * mutable views of a map at a prefix.\n */\n constructor(tree: RadixTree<Value> = new Map(), prefix = \"\") {\n this._tree = tree;\n this._prefix = prefix;\n }\n\n /**\n * Creates and returns a mutable view of this {@link SearchableMap}, containing only\n * entries that share the given prefix.\n *\n * ### Usage:\n *\n * ```js\n * const map = new SearchableMap()\n * map.set(\"unicorn\", 1)\n * map.set(\"universe\", 2)\n * map.set(\"university\", 3)\n * map.set(\"unique\", 4)\n * map.set(\"hello\", 5)\n *\n * const uni = map.atPrefix(\"uni\")\n * uni.get(\"unique\") // => 4\n * uni.get(\"unicorn\") // => 1\n * uni.get(\"hello\") // => undefined\n *\n * const univer = map.atPrefix(\"univer\")\n * univer.get(\"unique\") // => undefined\n * univer.get(\"universe\") // => 2\n * univer.get(\"university\") // => 3\n * ```\n *\n * @param prefix The prefix\n * @return A {@link SearchableMap} representing a mutable view of the original Map at the given prefix\n */\n atPrefix(prefix: string): SearchableMap<Value> {\n if (!prefix.startsWith(this._prefix)) throw new Error(\"Mismatched prefix\");\n\n const [node, path] = trackDown(\n this._tree,\n prefix.slice(this._prefix.length),\n );\n\n if (node === undefined) {\n const [parentNode, key] = last(path);\n\n for (const k of parentNode.keys())\n if (k !== LEAF && k.startsWith(key)) {\n const node = new Map();\n\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n node.set(k.slice(key.length), parentNode.get(k)!);\n\n return new SearchableMap<Value>(node, prefix);\n }\n }\n\n return new SearchableMap<Value>(node, prefix);\n }\n\n /**\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/clear\n */\n clear(): void {\n this._size = undefined;\n this._tree.clear();\n }\n\n /**\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/delete\n * @param key Key to delete\n */\n delete(key: string): void {\n this._size = undefined;\n\n return remove(this._tree, key);\n }\n\n /**\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/entries\n * @return An iterator iterating through `[key, value]` entries.\n */\n entries(): TreeIterator<Value, \"ENTRIES\"> {\n return new TreeIterator(this, ENTRIES);\n }\n\n /**\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/forEach\n * @param fn Iteration function\n */\n forEach(fn: (key: string, value: Value, map: SearchableMap) => void): void {\n for (const [key, value] of this) fn(key, value, this);\n }\n\n /**\n * Returns a Map of all the entries that have a key within the given edit\n * distance from the search key. The keys of the returned Map are the matching\n * keys, while the values are two-element arrays where the first element is\n * the value associated to the key, and the second is the edit distance of the\n * key to the search key.\n *\n * ### Usage:\n *\n * ```js\n * const map = new SearchableMap()\n * map.set('hello', 'world')\n * map.set('hell', 'yeah')\n * map.set('ciao', 'mondo')\n *\n * // Get all entries that match the key 'hallo' with a maximum edit distance of 2\n * map.fuzzyGet('hallo', 2)\n * // => Map(2) { 'hello' => ['world', 1], 'hell' => ['yeah', 2] }\n *\n * // In the example, the \"hello\" key has value \"world\" and edit distance of 1\n * // (change \"e\" to \"a\"), the key \"hell\" has value \"yeah\" and edit distance of 2\n * // (change \"e\" to \"a\", delete \"o\")\n * ```\n *\n * @param key The search key\n * @param maxEditDistance The maximum edit distance (Levenshtein)\n * @return A Map of the matching keys to their value and edit distance\n */\n fuzzyGet(key: string, maxEditDistance: number): FuzzyResults<Value> {\n return fuzzySearch<Value>(this._tree, key, maxEditDistance);\n }\n\n /**\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/get\n * @param key Key to get\n * @return Value associated to the key, or `undefined` if the key is not\n * found.\n */\n get(key: string): Value | undefined {\n const node = lookup<Value>(this._tree, key);\n\n return node !== undefined ? node.get(LEAF) : undefined;\n }\n\n /**\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/has\n * @param key Key\n * @return True if the key is in the map, false otherwise\n */\n has(key: string): boolean {\n const node = lookup(this._tree, key);\n\n return node?.has(LEAF) ?? false;\n }\n\n /**\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/keys\n * @return An `Iterable` iterating through keys\n */\n keys(): TreeIterator<Value, \"KEYS\"> {\n return new TreeIterator(this, KEYS);\n }\n\n /**\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/set\n * @param key Key to set\n * @param value Value to associate to the key\n * @return The {@link SearchableMap} itself, to allow chaining\n */\n set(key: string, value: Value): this {\n if (typeof key !== \"string\") throw new Error(\"key must be a string\");\n\n this._size = undefined;\n const node = createPath(this._tree, key);\n\n node.set(LEAF, value);\n\n return this;\n }\n\n /**\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/size\n */\n get size(): number {\n if (this._size) return this._size;\n\n /** @ignore */\n this._size = 0;\n\n const iter = this.entries();\n\n while (!iter.next().done) this._size += 1;\n\n return this._size;\n }\n\n /**\n * Updates the value at the given key using the provided function. The function\n * is called with the current value at the key, and its return value is used as\n * the new value to be set.\n *\n * ### Example:\n *\n * ```js\n * // Increment the current value by one\n * searchableMap.update('somekey', (currentValue) => currentValue == null ? 0 : currentValue + 1)\n * ```\n *\n * If the value at the given key is or will be an object, it might not require\n * re-assignment. In that case it is better to use `fetch()`, because it is\n * faster.\n *\n * @param key The key to update\n * @param fn The function used to compute the new value from the current one\n * @return The {@link SearchableMap} itself, to allow chaining\n */\n update(key: string, fn: (value: Value | undefined) => Value): this {\n if (typeof key !== \"string\") throw new Error(\"key must be a string\");\n\n this._size = undefined;\n const node = createPath(this._tree, key);\n\n node.set(LEAF, fn(node.get(LEAF)));\n\n return this;\n }\n\n /**\n * Fetches the value of the given key. If the value does not exist, calls the\n * given function to create a new value, which is inserted at the given key\n * and subsequently returned.\n *\n * ### Example:\n *\n * ```js\n * const map = searchableMap.fetch('somekey', () => new Map())\n * map.set('foo', 'bar')\n * ```\n *\n * @param key The key to update\n * @param initial A function that creates a new value if the key does not exist\n * @return The existing or new value at the given key\n */\n fetch(key: string, initial: () => Value): Value {\n if (typeof key !== \"string\") throw new Error(\"key must be a string\");\n\n this._size = undefined;\n const node = createPath(this._tree, key);\n\n let value = node.get(LEAF);\n\n if (value === undefined) node.set(LEAF, (value = initial()));\n\n return value;\n }\n\n /**\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/values\n * @return An `Iterable` iterating through values.\n */\n values(): TreeIterator<Value, \"VALUES\"> {\n return new TreeIterator(this, VALUES);\n }\n\n /**\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/@@iterator\n */\n [Symbol.iterator](): TreeIterator<Value, \"ENTRIES\"> {\n return this.entries();\n }\n\n /**\n * Creates a {@link SearchableMap} from an `Iterable` of entries\n *\n * @param entries Entries to be inserted in the {@link SearchableMap}\n * @return A new {@link SearchableMap} with the given entries\n */\n static from<T = any>(\n entries: Iterable<Entry<T>> | Entry<T>[],\n ): SearchableMap<T> {\n const tree = new SearchableMap<T>();\n\n for (const [key, value] of entries) tree.set(key, value);\n\n return tree;\n }\n\n /**\n * Creates a {@link SearchableMap} from the iterable properties of a JavaScript object\n *\n * @param object Object of entries for the {@link SearchableMap}\n * @return A new {@link SearchableMap} with the given entries\n */\n static fromObject<T = any>(object: Record<string, T>): SearchableMap<T> {\n return SearchableMap.from<T>(Object.entries(object));\n }\n}\n\nconst trackDown = <T = any>(\n tree: RadixTree<T> | undefined,\n key: string,\n path: Path<T> = [],\n): [RadixTree<T> | undefined, Path<T>] => {\n if (key.length === 0 || tree == null) return [tree, path];\n\n for (const treeKey of tree.keys())\n if (treeKey !== LEAF && key.startsWith(treeKey)) {\n path.push([tree, treeKey]); // performance: update in place\n\n return trackDown(tree.get(treeKey), key.slice(treeKey.length), path);\n }\n\n path.push([tree, key]); // performance: update in place\n\n return trackDown(undefined, \"\", path);\n};\n\nconst lookup = <T = any>(\n tree: RadixTree<T>,\n key: string,\n): RadixTree<T> | undefined => {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (key.length === 0 || !tree) return tree;\n\n for (const treeKey of tree.keys())\n if (treeKey !== LEAF && key.startsWith(treeKey))\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n return lookup(tree.get(treeKey)!, key.slice(treeKey.length));\n};\n\n// Create a path in the radix tree for the given key, and returns the deepest\n// node. This function is in the hot path for indexing. It avoids unnecessary\n// string operations and recursion for performance.\nconst createPath = <T = any>(node: RadixTree<T>, key: string): RadixTree<T> => {\n const keyLength = key.length;\n\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n outer: for (let pos = 0; node && pos < keyLength; ) {\n // Check whether this key is a candidate: the first characters must match.\n for (const k of node.keys())\n if (k !== LEAF && key[pos] === k[0]) {\n const len = Math.min(keyLength - pos, k.length);\n\n // Advance offset to the point where key and k no longer match.\n let offset = 1;\n\n while (offset < len && key[pos + offset] === k[offset]) ++offset;\n\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n const child = node.get(k)!;\n\n if (offset === k.length) {\n // The existing key is shorter than the key we need to create.\n node = child;\n } else {\n // Partial match: we need to insert an intermediate node to contain\n // both the existing subtree and the new node.\n const intermediate = new Map();\n\n intermediate.set(k.slice(offset), child);\n node.set(key.slice(pos, pos + offset), intermediate);\n node.delete(k);\n node = intermediate;\n }\n\n pos += offset;\n continue outer;\n }\n\n // Create a final child node to contain the final suffix of the key.\n const child = new Map();\n\n node.set(key.slice(pos), child);\n\n return child;\n }\n\n return node;\n};\n\nconst remove = <T = any>(tree: RadixTree<T>, key: string): void => {\n const [node, path] = trackDown(tree, key);\n\n if (node === undefined) return;\n\n node.delete(LEAF);\n\n if (node.size === 0) {\n cleanup(path);\n } else if (node.size === 1) {\n const [key, value] = (\n node.entries().next() as IteratorResult<\n [string, RadixTree<T>],\n [string, RadixTree<T>]\n >\n ).value;\n\n merge(path, key, value);\n }\n};\n\nconst cleanup = <T = any>(path: Path<T>): void => {\n if (path.length === 0) return;\n\n const [node, key] = last(path);\n\n node.delete(key);\n\n if (node.size === 0) {\n cleanup(path.slice(0, -1));\n } else if (node.size === 1) {\n const [key, value] = (\n node.entries().next() as IteratorResult<\n [string, RadixTree<T>],\n [string, RadixTree<T>]\n >\n ).value;\n\n if (key !== LEAF) merge(path.slice(0, -1), key, value);\n }\n};\n\nconst merge = <T = any>(\n path: Path<T>,\n key: string,\n value: RadixTree<T>,\n): void => {\n if (path.length === 0) return;\n\n const [node, nodeKey] = last(path);\n\n node.set(nodeKey + key, value);\n node.delete(nodeKey);\n};\n\nconst last = <T = any>(array: T[]): T => {\n return array[array.length - 1];\n};\n","import type { SearchIndex } from \"./SearchIndex.js\";\n\n/**\n * Returns `true` if a document with the given ID is present in the index and\n * available for search, `false` otherwise\n *\n * @typeParam ID The id type of the documents being indexed.\n * @typeParam Document The type of the documents being indexed.\n * @typeParam Index The type of the documents being indexed.\n *\n * @param searchIndex The search index\n * @param id The document ID\n */\nexport const has = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n id: ID,\n): boolean => searchIndex._idToShortId.has(id);\n\n/**\n * Returns the stored fields (as configured in the `storeFields` constructor\n * option) for the given document ID. Returns `undefined` if the document is\n * not present in the index.\n *\n * @typeParam ID The id type of the documents being indexed.\n * @typeParam Document The type of the documents being indexed.\n * @typeParam Index The type of the documents being indexed.\n *\n * @param searchIndex The search index\n * @param id The document ID\n * @returns The stored document index\n */\nexport const getStoredFields = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n id: ID,\n): Index | undefined => {\n const shortId = searchIndex._idToShortId.get(id);\n\n if (shortId == null) return undefined;\n\n return searchIndex._storedFields.get(shortId);\n};\n","// This regular expression matches any Unicode space, newline, or punctuation\n// character\nexport const SPACE_OR_PUNCTUATION = /[\\n\\r\\p{Z}\\p{P}]+/u;\n\nexport const OR = \"or\";\nexport const AND = \"and\";\nexport const AND_NOT = \"and_not\";\n","import { AND, AND_NOT, OR } from \"./constant.js\";\nimport type {\n BM25Params,\n LowercaseCombinationOperator,\n MatchInfo,\n SearchOptions,\n} from \"./typings.js\";\n\nexport const wait = (ms: number): Promise<void> =>\n new Promise((resolve) => setTimeout(resolve, ms));\n\nexport const assignUniqueTerm = (target: string[], term: string): void => {\n // Avoid adding duplicate terms.\n if (!target.includes(term)) target.push(term);\n};\n\nexport const assignUniqueTerms = (\n target: string[],\n source: readonly string[],\n): void => {\n // Avoid adding duplicate terms.\n for (const term of source) if (!target.includes(term)) target.push(term);\n};\n\ninterface Scored {\n score: number;\n}\n\nexport const byScore = ({ score: a }: Scored, { score: b }: Scored): number =>\n b - a;\n\nexport const createMap = <K, V>(): Map<K, V> => new Map<K, V>();\n\nexport const objectToNumericMap = <Value>(\n object: Record<string, Value>,\n): Map<number, Value> => {\n const map = new Map<number, Value>();\n\n for (const key of Object.keys(object))\n map.set(parseInt(key, 10), object[key]);\n\n return map;\n};\n\nexport const objectToNumericMapAsync = async <Value>(\n object: Record<string, Value>,\n): Promise<Map<number, Value>> => {\n const map = new Map();\n let count = 0;\n\n for (const key of Object.keys(object)) {\n map.set(parseInt(key, 10), object[key]);\n if (++count % 1000 === 0) {\n await wait(0);\n }\n }\n\n return map;\n};\n\nexport const getOwnProperty = (object: any, property: string): unknown =>\n Object.prototype.hasOwnProperty.call(object, property)\n ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n object[property]\n : undefined;\n\ninterface RawResultValue {\n // Intermediate score, before applying the final score based on number of\n // matched terms.\n score: number;\n\n // Set of all query terms that were matched. They may not be present in the\n // text exactly in the case of prefix/fuzzy matches. We must check for\n // uniqueness before adding a new term. This is much faster than using a set,\n // because the number of elements is relatively small.\n terms: string[];\n\n // All terms that were found in the content, including the fields in which\n // they were present. This object will be provided as part of the final search\n // results.\n match: MatchInfo;\n}\n\nexport type RawResult = Map<number, RawResultValue>;\n\nexport type CombinatorFunction = (a: RawResult, b: RawResult) => RawResult;\n\nexport const combinators: Record<\n LowercaseCombinationOperator,\n CombinatorFunction\n> = {\n [OR]: (a: RawResult, b: RawResult) => {\n for (const docId of b.keys()) {\n const existing = a.get(docId);\n\n if (existing == null) {\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n a.set(docId, b.get(docId)!);\n } else {\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n const { score, terms, match } = b.get(docId)!;\n\n existing.score = existing.score + score;\n existing.match = Object.assign(existing.match, match);\n assignUniqueTerms(existing.terms, terms);\n }\n }\n\n return a;\n },\n [AND]: (a: RawResult, b: RawResult) => {\n const combined = new Map();\n\n for (const docId of b.keys()) {\n const existing = a.get(docId);\n\n if (existing == null) continue;\n\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n const { score, terms, match } = b.get(docId)!;\n\n assignUniqueTerms(existing.terms, terms);\n combined.set(docId, {\n score: existing.score + score,\n terms: existing.terms,\n match: Object.assign(existing.match, match),\n });\n }\n\n return combined;\n },\n [AND_NOT]: (a: RawResult, b: RawResult) => {\n for (const docId of b.keys()) a.delete(docId);\n\n return a;\n },\n};\n\nexport const calcBM25Score = (\n termFreq: number,\n matchingCount: number,\n totalCount: number,\n fieldLength: number,\n avgFieldLength: number,\n bm25params: BM25Params,\n): number => {\n const { k, b, d } = bm25params;\n const invDocFreq = Math.log(\n 1 + (totalCount - matchingCount + 0.5) / (matchingCount + 0.5),\n );\n\n return (\n invDocFreq *\n (d +\n (termFreq * (k + 1)) /\n (termFreq + k * (1 - b + (b * fieldLength) / avgFieldLength)))\n );\n};\n\nexport interface QuerySpec {\n prefix: boolean;\n fuzzy: number | boolean;\n term: string;\n termBoost: number;\n}\n\nexport const termToQuerySpec =\n (options: SearchOptions) =>\n (term: string, index: number, terms: string[]): QuerySpec => ({\n term,\n fuzzy:\n typeof options.fuzzy === \"function\"\n ? options.fuzzy(term, index, terms)\n : (options.fuzzy ?? false),\n prefix:\n typeof options.prefix === \"function\"\n ? options.prefix(term, index, terms)\n : options.prefix === true,\n termBoost:\n typeof options.boostTerm === \"function\"\n ? options.boostTerm(term, index, terms)\n : 1,\n });\n","import type { SearchIndex } from \"./SearchIndex.js\";\n\nexport const warnDocumentChanged = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n shortDocumentId: number,\n fieldId: number,\n term: string,\n): void => {\n for (const fieldName of Object.keys(searchIndex._fieldIds))\n if (searchIndex._fieldIds[fieldName] === fieldId) {\n searchIndex._options.logger(\n \"warn\",\n // eslint-disable-next-line @typescript-eslint/restrict-template-expressions\n `SlimSearch: document with ID ${searchIndex._documentIds.get(\n shortDocumentId,\n )} has changed before removal: term \"${term}\" was not present in field \"${fieldName}\". Removing a document after it has changed can corrupt the index!`,\n \"version_conflict\",\n );\n\n return;\n }\n};\n","import type { SearchIndex } from \"./SearchIndex.js\";\nimport { createMap } from \"./utils.js\";\nimport { warnDocumentChanged } from \"./warning.js\";\n\n/**\n * @private\n */\nexport const addTerm = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n fieldId: number,\n documentId: number,\n term: string,\n): void => {\n const indexData = searchIndex._index.fetch(term, createMap);\n\n let fieldIndex = indexData.get(fieldId);\n\n if (fieldIndex == null) {\n fieldIndex = new Map();\n fieldIndex.set(documentId, 1);\n indexData.set(fieldId, fieldIndex);\n } else {\n const docs = fieldIndex.get(documentId);\n\n fieldIndex.set(documentId, (docs ?? 0) + 1);\n }\n};\n\n/**\n * @private\n */\nexport const removeTerm = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n fieldId: number,\n documentId: number,\n term: string,\n): void => {\n if (!searchIndex._index.has(term)) {\n warnDocumentChanged(searchIndex, documentId, fieldId, term);\n\n return;\n }\n\n const indexData = searchIndex._index.fetch(term, createMap);\n\n const fieldIndex = indexData.get(fieldId);\n\n const amount = fieldIndex?.get(documentId);\n\n if (!fieldIndex || typeof amount === \"undefined\")\n warnDocumentChanged(searchIndex, documentId, fieldId, term);\n else if (amount <= 1)\n if (fieldIndex.size <= 1) indexData.delete(fieldId);\n else fieldIndex.delete(documentId);\n else fieldIndex.set(documentId, amount - 1);\n\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n if (searchIndex._index.get(term)!.size === 0) searchIndex._index.delete(term);\n};\n","import type { SearchIndex } from \"./SearchIndex.js\";\nimport { has } from \"./info.js\";\nimport { addTerm } from \"./term.js\";\n\nconst addFieldLength = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n documentId: number,\n fieldId: number,\n count: number,\n length: number,\n): void => {\n let fieldLengths = searchIndex._fieldLength.get(documentId);\n\n if (fieldLengths == null)\n searchIndex._fieldLength.set(documentId, (fieldLengths = []));\n fieldLengths[fieldId] = length;\n\n const averageFieldLength = searchIndex._avgFieldLength[fieldId] || 0;\n const totalFieldLength = averageFieldLength * count + length;\n\n searchIndex._avgFieldLength[fieldId] = totalFieldLength / (count + 1);\n};\n\nconst addDocumentId = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n documentId: ID,\n): number => {\n const shortDocumentId = searchIndex._nextId;\n\n searchIndex._idToShortId.set(documentId, shortDocumentId);\n searchIndex._documentIds.set(shortDocumentId, documentId);\n searchIndex._documentCount += 1;\n searchIndex._nextId += 1;\n\n return shortDocumentId;\n};\n\nconst saveStoredFields = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n documentId: number,\n doc: Document,\n): void => {\n const { storeFields, extractField } = searchIndex._options;\n\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (storeFields?.length === 0) return;\n\n let documentFields: Record<string, unknown> | undefined =\n searchIndex._storedFields.get(documentId);\n\n if (documentFields === undefined)\n searchIndex._storedFields.set(documentId, (documentFields = {} as Index));\n\n for (const fieldName of storeFields) {\n const fieldValue = extractField(doc, fieldName);\n\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (fieldValue != null) documentFields[fieldName] = fieldValue;\n }\n};\n\n/**\n * Adds a document to the index\n *\n * @typeParam ID The id type of the documents being indexed.\n * @typeParam Document The type of the documents being indexed.\n * @typeParam Index The type of the documents being indexed.\n *\n * @param searchIndex The search index\n * @param document The document to be indexed\n */\nexport const add = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n document: Document,\n): void => {\n const { extractField, tokenize, processTerm, fields, idField } =\n searchIndex._options;\n const id = extractField(document, idField) as ID;\n\n if (id == null)\n throw new Error(`SlimSearch: document does not have ID field \"${idField}\"`);\n\n if (has(searchIndex, id))\n throw new Error(`SlimSearch: duplicate ID ${id as string}`);\n\n const shortDocumentId = addDocumentId(searchIndex, id);\n\n saveStoredFields(searchIndex, shortDocumentId, document);\n\n for (const field of fields) {\n const fieldValue = extractField(document, field);\n\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (fieldValue == null) continue;\n\n const tokens = tokenize(fieldValue.toString(), field);\n const fieldId = searchIndex._fieldIds[field];\n\n const uniqueTerms = new Set(tokens).size;\n\n addFieldLength(\n searchIndex,\n shortDocumentId,\n fieldId,\n searchIndex._documentCount - 1,\n uniqueTerms,\n );\n\n for (const term of tokens) {\n const processedTerm = processTerm(term, field);\n\n if (Array.isArray(processedTerm))\n for (const t of processedTerm)\n addTerm(searchIndex, fieldId, shortDocumentId, t);\n else if (processedTerm)\n addTerm(searchIndex, fieldId, shortDocumentId, processedTerm);\n }\n }\n};\n\n/**\n * Adds all the given documents to the index\n *\n * @typeParam ID The id type of the documents being indexed.\n * @typeParam Document The type of the documents being indexed.\n * @typeParam Index The type of the documents being indexed.\n *\n * @param searchIndex The search index\n * @param documents An array of documents to be indexed\n */\nexport const addAll = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n documents: readonly Document[],\n): void => {\n for (const document of documents) add(searchIndex, document);\n};\n\n/**\n * Adds all the given documents to the index asynchronously.\n *\n * Returns a promise that resolves (to `undefined`) when the indexing is done.\n * This method is useful when index many documents, to avoid blocking the main\n * thread. The indexing is performed asynchronously and in chunks.\n *\n * @typeParam ID The id type of the documents being indexed.\n * @typeParam Document The type of the documents being indexed.\n * @typeParam Index The type of the documents being indexed.\n *\n * @param searchIndex The search index\n * @param documents An array of documents to be indexed\n * @param options Configuration options\n * @return A promise resolving when the indexing is done\n */\nexport const addAllAsync = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n documents: readonly Document[],\n options: { chunkSize?: number } = {},\n): Promise<void> => {\n const { chunkSize = 10 } = options;\n const acc: { chunk: Document[]; promise: Promise<void> } = {\n chunk: [],\n promise: Promise.resolve(),\n };\n\n const { chunk, promise } = documents.reduce(\n ({ chunk, promise }, document, index) => {\n chunk.push(document);\n if ((index + 1) % chunkSize === 0)\n return {\n chunk: [],\n promise: promise\n .then(() => new Promise((resolve) => setTimeout(resolve, 0)))\n .then(() => addAll(searchIndex, chunk)),\n };\n\n return { chunk, promise };\n },\n acc,\n );\n\n return promise.then(() => addAll(searchIndex, chunk));\n};\n","import { AND, OR, SPACE_OR_PUNCTUATION } from \"./constant.js\";\nimport type { BM25Params, LogLevel } from \"./typings.js\";\nimport { getOwnProperty } from \"./utils.js\";\n\nexport const defaultBM25params: BM25Params = { k: 1.2, b: 0.7, d: 0.5 };\n\nexport const defaultOptions = {\n idField: \"id\",\n extractField: (document: any, fieldName: string): unknown =>\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n document[fieldName],\n tokenize: (text: string): string[] => text.split(SPACE_OR_PUNCTUATION),\n processTerm: (term: string): string => term.toLowerCase(),\n fields: undefined,\n searchOptions: undefined,\n storeFields: [],\n logger: (level: LogLevel, message: string): void => {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n console?.[level]?.(message);\n },\n autoVacuum: true,\n} as const;\n\nexport const defaultSearchOptions = {\n combineWith: OR,\n prefix: false,\n fuzzy: false,\n maxFuzzy: 6,\n boost: {},\n weights: { fuzzy: 0.45, prefix: 0.375 },\n bm25: defaultBM25params,\n} as const;\n\nexport const defaultAutoSuggestOptions = {\n combineWith: AND,\n prefix: (_term: string, index: number, terms: string[]): boolean =>\n index === terms.length - 1,\n} as const;\n\nexport const defaultVacuumOptions = { batchSize: 1000, batchWait: 10 };\nexport const defaultVacuumConditions = { minDirtFactor: 0.1, minDirtCount: 20 };\n\nexport const defaultAutoVacuumOptions = {\n ...defaultVacuumOptions,\n ...defaultVacuumConditions,\n};\n\n/**\n * Returns the default value of an option. It will throw an error if no option\n * with the given name exists.\n *\n * ### Usage:\n *\n * ```js\n * // Get default tokenizer\n * getDefaultValue('tokenize')\n *\n * // Get default term processor\n * getDefaultValue('processTerm')\n *\n * // Unknown options will throw an error\n * getDefaultValue('notExisting')\n * // => throws 'SlimSearch: unknown option \"notExisting\"'\n * ```\n *\n * @typeParam ID The id type of the documents being indexed.\n * @typeParam Document The type of the documents being indexed.\n * @typeParam Index The type of the documents being indexed.\n *\n * @param optionName Name of the option\n * @return The default value of the given option\n */\nexport const getDefaultValue = (optionName: string): unknown => {\n // eslint-disable-next-line no-prototype-builtins\n if (defaultOptions.hasOwnProperty(optionName))\n return getOwnProperty(defaultOptions, optionName);\n else throw new Error(`SlimSearch: unknown option \"${optionName}\"`);\n};\n","export const WILDCARD = Symbol(\"*\");\n","import type { FieldTermData, SearchIndex } from \"./SearchIndex.js\";\nimport { OR } from \"./constant.js\";\nimport { defaultSearchOptions } from \"./defaults.js\";\nimport { WILDCARD } from \"./symbols.js\";\nimport { removeTerm } from \"./term.js\";\nimport type {\n BM25Params,\n CombinationOperator,\n LowercaseCombinationOperator,\n Query,\n SearchOptions,\n} from \"./typings.js\";\nimport type { QuerySpec, RawResult } from \"./utils.js\";\nimport {\n assignUniqueTerm,\n calcBM25Score,\n combinators,\n getOwnProperty,\n termToQuerySpec,\n} from \"./utils.js\";\n\nexport interface SearchOptionsWithDefaults<\n ID = any,\n Index extends Record<string, any> = Record<string, never>,\n> extends SearchOptions<ID, Index> {\n boost: Record<string, number>;\n\n weights: { fuzzy: number; prefix: number };\n\n prefix: boolean | ((term: string, index: number, terms: string[]) => boolean);\n\n fuzzy:\n | boolean\n | number\n | ((term: string, index: number, terms: string[]) => boolean | number);\n\n maxFuzzy: number;\n\n combineWith: CombinationOperator;\n\n bm25: BM25Params;\n}\n\nexport type DocumentTermFrequencies = Map<number, number>;\n\nconst executeWildcardQuery = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n searchOptions: SearchOptions<ID, Index>,\n): RawResult => {\n const results = new Map() as RawResult;\n // @ts-expect-error: some option is optional\n const options: SearchOptionsWithDefaults<ID, Index> = {\n ...searchIndex._options.searchOptions,\n ...searchOptions,\n };\n\n for (const [shortId, id] of searchIndex._documentIds) {\n const score = options.boostDocument\n ? options.boostDocument(id, \"\", searchIndex._storedFields.get(shortId))\n : 1;\n\n results.set(shortId, {\n score,\n terms: [],\n match: {},\n });\n }\n\n return results;\n};\n\nconst combineResults = (\n results: RawResult[],\n combineWith: CombinationOperator = OR,\n): RawResult => {\n if (results.length === 0) return new Map();\n\n const operator = combineWith.toLowerCase() as LowercaseCombinationOperator;\n\n if (!(operator in combinators))\n throw new Error(`Invalid combination operator: ${combineWith}`);\n\n return results.reduce(combinators[operator]);\n};\n\nconst termResults = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n sourceTerm: string,\n derivedTerm: string,\n termWeight: number,\n termBoost: number,\n fieldTermData: FieldTermData | undefined,\n fieldBoosts: Record<string, number>,\n boostDocumentFn:\n | ((id: ID, term: string, storedFields?: Index) => number)\n | undefined,\n bm25params: BM25Params,\n results: RawResult = new Map(),\n): RawResult => {\n if (fieldTermData == null) return results;\n\n for (const field of Object.keys(fieldBoosts)) {\n const fieldBoost = fieldBoosts[field];\n const fieldId = searchIndex._fieldIds[field];\n\n const fieldTermFrequencies = fieldTermData.get(fieldId);\n\n if (fieldTermFrequencies == null) continue;\n\n let matchingFields = fieldTermFrequencies.size;\n const avgFieldLength = searchIndex._avgFieldLength[fieldId];\n\n for (const docId of fieldTermFrequencies.keys()) {\n if (!searchIndex._documentIds.has(docId)) {\n removeTerm(searchIndex, fieldId, docId, derivedTerm);\n matchingFields -= 1;\n continue;\n }\n\n const docBoost = boostDocumentFn\n ? boostDocumentFn(\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n searchIndex._documentIds.get(docId)!,\n derivedTerm,\n searchIndex._storedFields.get(docId),\n )\n : 1;\n\n if (!docBoost) continue;\n\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n const termFreq = fieldTermFrequencies.get(docId)!;\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n const fieldLength = searchIndex._fieldLength.get(docId)![fieldId];\n\n // NOTE: The total number of fields is set to the number of documents\n // `this._documentCount`. It could also make sense to use the number of\n // documents where the current field is non-blank as a normalization\n // factor. This will make a difference in scoring if the field is rarely\n // present. This is currently not supported, and may require further\n // analysis to see if it is a valid use case.\n const rawScore = calcBM25Score(\n termFreq,\n matchingFields,\n searchIndex._documentCount,\n fieldLength,\n avgFieldLength,\n bm25params,\n );\n const weightedScore =\n termWeight * termBoost * fieldBoost * docBoost * rawScore;\n\n const result = results.get(docId);\n\n if (result) {\n result.score += weightedScore;\n assignUniqueTerm(result.terms, sourceTerm);\n const match = getOwnProperty(result.match, derivedTerm) as\n | string[]\n | undefined;\n\n if (match) match.push(field);\n else result.match[derivedTerm] = [field];\n } else {\n results.set(docId, {\n score: weightedScore,\n terms: [sourceTerm],\n match: { [derivedTerm]: [field] },\n });\n }\n }\n }\n\n return results;\n};\n\nconst executeQuerySpec = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n query: QuerySpec,\n searchOptions: SearchOptions<ID, Index>,\n): RawResult => {\n // @ts-expect-error: some option is optional\n const options: SearchOptionsWithDefaults<ID, Index> = {\n ...searchIndex._options.searchOptions,\n ...searchOptions,\n };\n\n const boosts = (options.fields ?? searchIndex._options.fields).reduce(\n (boosts, field) => ({\n ...boosts,\n [field]: getOwnProperty(options.boost, field) || 1,\n }),\n {},\n );\n\n const { boostDocument, weights, maxFuzzy, bm25: bm25params } = options;\n\n const { fuzzy: fuzzyWeight, prefix: prefixWeight } = {\n ...defaultSearchOptions.weights,\n ...weights,\n };\n\n const data = searchIndex._index.get(query.term);\n const results = termResults(\n searchIndex,\n query.term,\n query.term,\n 1,\n query.termBoost,\n data,\n boosts,\n boostDocument,\n bm25params,\n );\n\n let prefixMatches;\n let fuzzyMatches;\n\n if (query.prefix) prefixMatches = searchIndex._index.atPrefix(query.term);\n\n if (query.fuzzy) {\n const fuzzy = query.fuzzy === true ? 0.2 : query.fuzzy;\n const maxDistance =\n fuzzy < 1\n ? Math.min(maxFuzzy, Math.round(query.term.length * fuzzy))\n : fuzzy;\n\n if (maxDistance)\n fuzzyMatches = searchIndex._index.fuzzyGet(query.term, maxDistance);\n }\n\n if (prefixMatches)\n for (const [term, data] of prefixMatches) {\n const distance = term.length - query.term.length;\n\n if (!distance) continue;\n // Skip exact match.\n\n // Delete the term from fuzzy results (if present) if it is also a\n // prefix result. This entry will always be scored as a prefix result.\n fuzzyMatches?.delete(term);\n\n // Weight gradually approaches 0 as distance goes to infinity, with the\n // weight for the hypothetical distance 0 being equal to prefixWeight.\n // The rate of change is much lower than that of fuzzy matches to\n // account for the fact that prefix matches stay more relevant than\n // fuzzy matches for longer distances.\n const weight =\n (prefixWeight * term.length) / (term.length + 0.3 * distance);\n\n termResults(\n searchIndex,\n query.term,\n term,\n weight,\n query.termBoost,\n data,\n boosts,\n boostDocument,\n bm25params,\n results,\n );\n }\n\n if (fuzzyMatches)\n for (const term of fuzzyMatches.keys()) {\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n const [data, distance] = fuzzyMatches.get(term)!;\n\n if (!distance) continue;\n // Skip exact match.\n\n // Weight gradually approaches 0 as distance goes to infinity, with the\n // weight for the hypothetical distance 0 being equal to fuzzyWeight.\n const weight = (fuzzyWeight * term.length) / (term.length + distance);\n\n termResults(\n searchIndex,\n query.term,\n term,\n weight,\n query.termBoost,\n data,\n boosts,\n boostDocument,\n bm25params,\n results,\n );\n }\n\n return results;\n};\n\nexport const executeQuery = <\n ID,\n Document,\n Index extends Record<string, any> = Record<never, never>,\n>(\n searchIndex: SearchIndex<ID, Document, Index>,\n query: Query,\n searchOptions: SearchOptions<ID, Index> = {},\n): RawResult => {\n if (query === WILDCARD)\n return executeWildcardQuery(searchIndex, searchOptions);\n\n if (typeof query !== \"string\") {\n const options = { ...searchOptions, ...query, queries: undefined };\n const results = query.queries.map((subQuery) =>\n executeQuery(searchIndex, subQuery, options),\n );\n\n return combineResults(results, options.combineWith);\n }\n\n const {\n tokeni