UNPKG

basebee

Version:

Basebee is a powerful key-value store built on top of Autobase and Hyperbee, designed to efficiently manage data with customizable key/value encodings, prefix-based key organization, and batch operations. It integrates stream-based APIs for handling key-v

388 lines (327 loc) 12.8 kB
import {Autobase, Hyperbee, hypercoreId} from "./lib/prebundles/from-cjs.js"; import b4a from 'b4a'; import c from 'compact-encoding'; // For binary encoding import delegates from "delegates"; import {opEncoding} from "./lib/opEncoding.js"; import {encodeKey, timeStampEncoder} from "./lib/keyEncoders.js"; import {createPrefixFilteringStream} from "./lib/createPrefixFilteringStream.js"; import {applyPrefixToRange} from "./lib/applyPrefixToRange.js"; import {prepareOptions} from "./lib/prepareOptions.js"; import {encodeValue} from "./lib/encodeValue.js"; import EventEmitter from "tiny-emitter"; import {forwardEvents} from "./lib/forwardEvents.js"; export class Basebee extends EventEmitter { constructor(store, key, config) { super(); if (key && !b4a.isBuffer(key) && typeof key !== "string" && typeof key === "object") { config = key; key = undefined; } config ||= {}; config.name ||= "auto-db"; if (config.prefix === null || config.prefix === "") config.prefix = null; else config.prefix ??= "main"; this._useConflictStrategy = config?.useConflictStrategy ?? true; this._config = config; this._offListeners = []; this.autobase = new Autobase(...[store, key].filter(o => !!o), { valueEncoding: opEncoding, open: this.openView.bind(this), apply: this.applyChanges.bind(this) }); this._offListeners.push( forwardEvents( this.autobase, this, [ "error", "reindexing", "interrupt", "is-indexer", "is-non-indexer", "unwritable", "writable", "update", "upgrade-available", "fast-forward", "warning" ] ) ); delegates(this, "autobase") .method("update"); this._activeStreams = []; // Keep track of active streams } _trackStream(stream) { this._activeStreams.push(stream); stream.on('close', () => { this._activeStreams = this._activeStreams.filter(s => s !== stream); }); return stream; } openView(store) { const core = store.get({name: this._config.name}); const { keyEncoding, valueEncoding, name, prefix, ...restOpts } = this._config; const bee = new Hyperbee(core, { extension: false, ...restOpts, metadata: c.encode(c.json, { prefix: this._config.prefix }) }); delegates(this, "autobase") .getter("view"); delegates(this, "view") .method("snapshot") .method("getHeader") .getter("key") .getter("discoveryKey") .getter("writable") .getter("readable"); this._offListeners.push( forwardEvents( bee, this, [ "append", "truncate", "error", "update" ] ) ); return bee; } // Apply the transform to filter and strip prefixes for readable streams createReadStream(options = {}) { const preparedOptions = prepareOptions(null, options, this._config); const prefixedRange = applyPrefixToRange(preparedOptions, preparedOptions.prefix); const baseStream = this.view.createReadStream({ ...preparedOptions, ...prefixedRange }); return this._trackStream(createPrefixFilteringStream(preparedOptions.prefix, baseStream)); } peek(range = {}, options = {}) { const preparedOptions = prepareOptions(range, options, this._config); // Apply prefix to the range options const prefixedRange = applyPrefixToRange(range, preparedOptions.prefix); const baseStream = this.view.peek(prefixedRange); return createPrefixFilteringStream(preparedOptions.prefix, baseStream); } createHistoryStream(options = {}) { const preparedOptions = prepareOptions(null, options, this._config); const prefixedRange = applyPrefixToRange(preparedOptions, preparedOptions.prefix); // Create the base stream with the prefixed range const baseStream = this.view.createHistoryStream({ ...preparedOptions, ...prefixedRange }); // Apply prefix filtering return this._trackStream(createPrefixFilteringStream(preparedOptions.prefix, baseStream)); } createDiffStream(options = {}) { const preparedOptions = prepareOptions(null, options, this._config); const prefixedRange = applyPrefixToRange(preparedOptions, preparedOptions.prefix); // Create the base stream with the prefixed range const baseStream = this.view.createDiffStream({ ...preparedOptions, ...prefixedRange }); // Apply prefix filtering return this._trackStream(createPrefixFilteringStream(preparedOptions.prefix, baseStream)); } watch(options = {}) { const preparedOptions = prepareOptions(null, options, this._config); const prefixedRange = applyPrefixToRange(preparedOptions, preparedOptions.prefix); // Create the base watch stream with the prefixed range const baseStream = this.view.watch({ ...preparedOptions, ...prefixedRange }); // Apply prefix filtering return this._trackStream(createPrefixFilteringStream(preparedOptions.prefix, baseStream)); } getBySeq(seq, options = {}) { const preparedOptions = prepareOptions(null, options, this._config); return this.view.getBySeq(seq, preparedOptions); } async ready() { await this.autobase.ready(); } async put(key, value, options = {}) { const staged = options?.staged; let _valueBuf, _keyBuf; const timestamp = Date.now(); try { const preparedOptions = prepareOptions(null, options, this._config); _valueBuf = encodeValue(value, preparedOptions, this._config); _keyBuf = encodeKey(preparedOptions.prefix, key, preparedOptions); } catch (e) { this.emit('error', e, { operation: 'put', key }); throw e; // Ensure error is propagated } const op = { key: b4a.from(_keyBuf), value: b4a.from(_valueBuf), timestamp, op: "put" }; if (staged) return op; await this.autobase.append(op, { valueEncoding: opEncoding }); } async _put(change, view) { const {key, value, timestamp} = change; if (this._useConflictStrategy) { const encodedTimestampKey = c.encode(timeStampEncoder, key); const encodedTimestamp = c.encode(c.uint64, timestamp); await view.put(encodedTimestampKey, encodedTimestamp); } await view.put(key, value); } async del(key, options = {}) { const staged = options?.staged; const timestamp = Date.now(); let _keyBuf; try { const preparedOptions = prepareOptions(null, options, this._config); _keyBuf = encodeKey(preparedOptions.prefix, key, preparedOptions); } catch (e) { this.emit('error', e, { operation: 'del', key }); throw e; // Ensure error is propagated } const op = { key: b4a.from(_keyBuf), timestamp, op: "del" }; if (staged) return op; return this.autobase.append(op, { valueEncoding: opEncoding }); } async _del(delOp, view) { await view.del(delOp.key); } async get(key, config = {}) { try { const preparedOptions = prepareOptions(null, config, this._config); const keyBuf = encodeKey(preparedOptions.prefix, key, preparedOptions); const {keyEncoding, ...restOptions} = preparedOptions; const node = await this.autobase.view.get(keyBuf, restOptions); if (!node?.value) { return null; } return { value: node.value, seq: node.seq, key, prefix: preparedOptions.prefix }; } catch (e) { console.error('Failed to get key:', e); return null; } } async applyChanges(nodes, view) { for (const {value: rawValue} of nodes) { let ops = []; let to = view; if (rawValue?.op === "bch" && Array.isArray(rawValue.ops)) { ops = rawValue.ops; to = view.batch(); } else { ops = [rawValue]; } for (const operation of ops) { if (operation.op === 'add') { await this.autobase.addWriter(hypercoreId.decode(operation.key), {indexer: operation.index}); } else if (operation.op === 'rmv') { await this.autobase.removeWriter(hypercoreId.decode(operation.key)); } else if (operation.op === 'put') { await this._put(operation, to); } else if (operation.op === 'del') { await this._del(operation, to); } } if (rawValue?.op === "bch") { await to.flush(); // Ensure batch writes are committed } } } // Method to add a writer, now takes a buffer as input async addWriter(writerKey, index = false, options = {}) { const staged = options?.staged; const encodedWriterKey = hypercoreId.encode(writerKey); const op = { op: "add", key: encodedWriterKey, index }; if (staged) { // Return the addWriter operation for batch handling return op; } else { // If not batch, append directly to autobase await this.autobase.append(op, {valueEncoding: opEncoding}); } } // Method to remove a writer, takes a buffer as input async removeWriter(writerKey, options = {}) { const staged = options?.staged; const encodedWriterKey = hypercoreId.encode(writerKey); const op = { op: "rmv", key: encodedWriterKey }; if (staged) { // Return the removeWriter operation for batch handling return op; } else { // If not batch, append directly to autobase await this.autobase.append(op, {valueEncoding: opEncoding}); } } batch() { const batch = []; const to = this.autobase; return { put: async (key, value, options) => { const op = await this.put(key, value, {...options, staged: true}); batch.push(op); }, del: async (key, options) => { const op = await this.del(key, {...options, staged: true}); batch.push(op); }, addWriter: async (writerKey, index = false) => { const encodedWriterKey = hypercoreId.encode(writerKey); batch.push({ op: "add", key: encodedWriterKey, index }); }, removeWriter: async (writerKey) => { const encodedWriterKey = hypercoreId.encode(writerKey); batch.push({ op: "rmv", key: encodedWriterKey }); }, async flush() { if (batch.length === 0) return; await to.append({ op: "bch", // Denote it's a batch operation ops: batch, key: b4a.alloc(0) }, {valueEncoding: opEncoding}); // Clear the batch after flush batch.length = 0; } }; } // Close method to clean up resources async close() { this._offListeners.forEach(o => o()); await this.autobase.close(); // Close Autobase (if it has a close method) if (this.view) await this.view.close(); for (const stream of this._activeStreams) { if (!stream.destroyed) { stream.destroy(); } } this._activeStreams = []; } } export default Basebee;