UNPKG

@fireproof/database

Version:
184 lines (175 loc) 7.48 kB
import type { ClockHead, DocUpdate, MapFn, IndexUpdate, QueryOpts, IdxMeta, IdxCarHeader } from './types' import { IndexBlockstore } from './transaction' import { bulkIndex, indexEntriesForChanges, byIdOpts, byKeyOpts, IndexTree, applyQuery, encodeRange, encodeKey, loadIndex } from './indexer-helpers' import { CRDT } from './crdt' export function index({ _crdt }: { _crdt: CRDT}, name: string, mapFn?: MapFn, meta?: IdxMeta): Index { if (mapFn && meta) throw new Error('cannot provide both mapFn and meta') if (mapFn && mapFn.constructor.name !== 'Function') throw new Error('mapFn must be a function') if (_crdt.indexers.has(name)) { const idx = _crdt.indexers.get(name)! idx.applyMapFn(name, mapFn, meta) } else { const idx = new Index(_crdt, name, mapFn, meta) _crdt.indexers.set(name, idx) } return _crdt.indexers.get(name)! } export class Index { blocks: IndexBlockstore crdt: CRDT name: string | null = null mapFn: MapFn | null = null mapFnString: string = '' byKey = new IndexTree() byId = new IndexTree() indexHead: ClockHead | undefined = undefined includeDocsDefault: boolean = false initError: Error | null = null ready: Promise<void> constructor(crdt: CRDT, name: string, mapFn?: MapFn, meta?: IdxMeta) { this.blocks = crdt.indexBlocks this.crdt = crdt this.applyMapFn(name, mapFn, meta) if (!(this.mapFnString || this.initError)) throw new Error('missing mapFnString') this.ready = this.blocks.ready.then((header: IdxCarHeader) => { // @ts-ignore if (header.head) throw new Error('cannot have head in idx header') if (header.indexes === undefined) throw new Error('missing indexes in idx header') for (const [name, idx] of Object.entries(header.indexes)) { index({ _crdt: crdt }, name, undefined, idx as IdxMeta) } }) } applyMapFn(name: string, mapFn?: MapFn, meta?: IdxMeta) { if (mapFn && meta) throw new Error('cannot provide both mapFn and meta') if (this.name && this.name !== name) throw new Error('cannot change name') this.name = name try { if (meta) { // hydrating from header if (this.indexHead && this.indexHead.map(c => c.toString()).join() !== meta.head.map(c => c.toString()).join()) { throw new Error('cannot apply meta to existing index') } this.byId.cid = meta.byId this.byKey.cid = meta.byKey this.indexHead = meta.head if (this.mapFnString) { // we already initialized from application code if (this.mapFnString !== meta.map) throw new Error('cannot apply different mapFn meta') } else { // we are first this.mapFnString = meta.map } } else { if (this.mapFn) { // we already initialized from application code if (mapFn) { if (this.mapFn.toString() !== mapFn.toString()) throw new Error('cannot apply different mapFn app2') } } else { // application code is creating an index if (!mapFn) { mapFn = makeMapFnFromName(name) } if (this.mapFnString) { // we already loaded from a header if (this.mapFnString !== mapFn.toString()) throw new Error('cannot apply different mapFn app') } else { // we are first this.mapFnString = mapFn.toString() } this.mapFn = mapFn } } const matches = /=>\s*(.*)/.test(this.mapFnString) this.includeDocsDefault = matches } catch (e) { this.initError = e as Error } } async query(opts: QueryOpts = {}) { await this._updateIndex() await this._hydrateIndex() if (!this.byKey.root) return await applyQuery(this.crdt, { result: [] }, opts) if (this.includeDocsDefault && opts.includeDocs === undefined) opts.includeDocs = true if (opts.range) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call const { result, ...all } = await this.byKey.root.range(...encodeRange(opts.range)) return await applyQuery(this.crdt, { result, ...all }, opts) } if (opts.key) { const encodedKey = encodeKey(opts.key) // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call return await applyQuery(this.crdt, await this.byKey.root.get(encodedKey), opts) } if (opts.prefix) { if (!Array.isArray(opts.prefix)) opts.prefix = [opts.prefix] const start = [...opts.prefix, NaN] const end = [...opts.prefix, Infinity] const encodedR = encodeRange([start, end]) return await applyQuery(this.crdt, await this.byKey.root.range(...encodedR), opts) } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call const { result, ...all } = await this.byKey.root.getAllEntries() // funky return type return await applyQuery(this.crdt, { result: result.map(({ key: [k, id], value }) => ({ key: k, id, value })), ...all }, opts) } async _hydrateIndex() { if (this.byId.root && this.byKey.root) return if (!this.byId.cid || !this.byKey.cid) return this.byId.root = await loadIndex(this.blocks, this.byId.cid, byIdOpts) this.byKey.root = await loadIndex(this.blocks, this.byKey.cid, byKeyOpts) } async _updateIndex() { await this.ready if (this.initError) throw this.initError if (!this.mapFn) throw new Error('No map function defined') const { result, head } = await this.crdt.changes(this.indexHead) if (result.length === 0) { this.indexHead = head return { byId: this.byId, byKey: this.byKey } } let staleKeyIndexEntries: IndexUpdate[] = [] let removeIdIndexEntries: IndexUpdate[] = [] if (this.byId.root) { const removeIds = result.map(({ key }) => key) const { result: oldChangeEntries } = await this.byId.root.getMany(removeIds) as { result: Array<[string, string] | string> } staleKeyIndexEntries = oldChangeEntries.map(key => ({ key, del: true })) removeIdIndexEntries = oldChangeEntries.map((key) => ({ key: key[1], del: true })) } const indexEntries = indexEntriesForChanges(result, this.mapFn) // use a getter to translate from string const byIdIndexEntries: DocUpdate[] = indexEntries.map(({ key }) => ({ key: key[1], value: key })) const indexerMeta: Map<string, IdxMeta> = new Map() for (const [name, indexer] of this.crdt.indexers) { if (indexer.indexHead) { indexerMeta.set(name, { byId: indexer.byId.cid, byKey: indexer.byKey.cid, head: indexer.indexHead, map: indexer.mapFnString, name: indexer.name } as IdxMeta) } } return await this.blocks.transaction(async (tblocks): Promise<IdxMeta> => { this.byId = await bulkIndex( tblocks, this.byId, removeIdIndexEntries.concat(byIdIndexEntries), byIdOpts ) this.byKey = await bulkIndex(tblocks, this.byKey, staleKeyIndexEntries.concat(indexEntries), byKeyOpts) this.indexHead = head return { byId: this.byId.cid, byKey: this.byKey.cid, head, map: this.mapFnString, name: this.name } as IdxMeta }, indexerMeta) } } function makeMapFnFromName(name: string): MapFn { return (doc) => { if (doc[name]) return doc[name] } }