UNPKG

@x5e/gink

Version:

an eventually consistent database

291 lines (277 loc) 10.7 kB
import { Container } from "./Container"; import { Value, Muid, ScalarKey, AsOf, StorageKey, Meta, Bundler, } from "./typedefs"; import { ensure, muidToString, muidTupleToMuid, fromStorageKey, valueToJson, } from "./utils"; import { interpret, construct } from "./factories"; import { Addressable } from "./Addressable"; import { storageKeyToString } from "./store_utils"; import { Behavior } from "./builders"; export class Keyed< GenericType extends ScalarKey | Addressable | [Addressable, Addressable], > extends Container { /** * Sets a key/value association in a directory. * If a bundler is supplied, the function will add the entry to that bundler * and return immediately (you'll need to addBundler manually in that case). * If the caller does not supply a bundler, then one is created on the fly, and * then this method will await on the bundler being added to the database instance. * This is to allow simple console usage like: * await myDirectory.set("foo", "bar"); * @param key * @param value * @param change an optional bundler to put this in. * @returns a promise that resolves to the address of the newly created entry */ set( key: GenericType, value: Container | Value, meta?: Meta, ): Promise<Muid> { if (value === undefined) { throw new Error("value is undefined"); } return this.addEntry(key, value, meta); } /** * Adds a deletion marker (tombstone) for a particular key in the directory. * The corresponding value will be seen to be unset in the data model. * @param key * @param change an optional bundler to put this in. * @returns a promise that resolves to the address of the newly created deletion entry */ async delete(key: GenericType, meta?: Meta): Promise<Muid> { return this.addEntry(key, Container.DELETION, meta); } /** * Returns a promise that resolves to the most recent value set for the given key, or undefined. * @param key * @param asOf * @returns undefined, a basic value, or a container */ async get( key: GenericType, asOf?: AsOf, ): Promise<Container | Value | undefined> { const entry = await this.database.store.getEntryByKey( this.address, key, asOf, ); return interpret(entry, this.database); } async size(asOf?: AsOf): Promise<number> { const entries = await this.database.store.getKeyedEntries( this.address, asOf, ); return entries.size; } async has(key: GenericType, asOf?: AsOf): Promise<boolean> { const result = await this.database.store.getEntryByKey( this.address, key, asOf, ); if (result !== undefined && result.deletion) { return false; } return result !== undefined; } async reset(toTime?: AsOf, recurse?, meta?: Meta): Promise<void> { if (this.behavior === Behavior.PROPERTY) { throw new Error( "Cannot directly reset a property. " + "Calling reset on a Container will reset its properties.", ); } if (recurse === true) { recurse = new Set(); } if (recurse instanceof Set) { recurse.add(muidToString(this.address)); } const bundler: Bundler = await this.database.startBundle(meta); if (!toTime) { // If no time is specified, we are resetting to epoch, which is just a clear this.clear(false, { bundler }); } else { const keys: Set<StorageKey> = new Set(); const thenEntries = await this.database.store.getKeyedEntries( this.address, toTime, ); for (const [key, entry] of thenEntries) { keys.add(entry.storageKey); } const nowEntries = await this.database.store.getKeyedEntries( this.address, ); for (const [key, entry] of nowEntries) { keys.add(entry.storageKey); } for (const key of keys) { const genericKey = fromStorageKey(key); const thenEntry = await this.database.store.getEntryByKey( this.address, genericKey, toTime, ); const thenValue: Value | Container = thenEntry?.pointeeList.length > 0 ? await construct( muidTupleToMuid(thenEntry.pointeeList[0]), this.database, ) : thenEntry?.value; const nowEntry = await this.database.store.getEntryByKey( this.address, genericKey, ); const nowValue: Value | Container = nowEntry?.pointeeList.length > 0 ? await construct( muidTupleToMuid(nowEntry.pointeeList[0]), this.database, ) : nowEntry?.value; if (!nowEntry) { // This key was present then, but not now, so we need to add it back if (thenEntry && !thenEntry.deletion) { ensure( thenValue, `missing for key: ${key}, ${JSON.stringify(genericKey)}`, ); await this.addEntry(genericKey, thenValue, { bundler }); } } else if (!thenEntry) { // This key is present now, but not then, so we need to delete it ensure(nowEntry && nowValue, "missing value?"); await this.addEntry(genericKey, Container.DELETION, { bundler, }); } else { // Present both then and now. Check if the values are different if (nowValue !== thenValue) { // Make sure the values are not the same container if ( !( nowValue instanceof Container && thenValue instanceof Container && muidToString(nowValue.address) === muidToString(thenValue.address) ) ) { // Update the entry await this.addEntry(genericKey, thenValue, { bundler, }); } } } if ( recurse && thenValue instanceof Container && !recurse.has(muidToString(thenValue.address)) ) { await thenValue.reset(toTime, recurse, { bundler }); } } } if (!meta?.bundler) { await bundler.commit(); } } /** * Dumps the contents of this directory into a javascript Map; mostly useful for * debugging though also could be used to create a backup of a database. * @param asOf effective time to get the dump for, or undefined for the present * @returns a javascript map from keys (numbers or strings) to values or containers */ async toMap(asOf?: AsOf): Promise<Map<StorageKey, Value | Container>> { const entries = await this.database.store.getKeyedEntries( this.address, asOf, ); const resultMap = new Map(); for (const [key, entry] of entries) { const pointee = entry.pointeeList.length > 0 ? muidTupleToMuid(entry.pointeeList[0]) : undefined; const val = entry.value !== undefined ? entry.value : await construct(pointee, this.database); resultMap.set(entry.storageKey, val); } return resultMap; } /** * Generates a JSON representation of the data in this container. * Mostly intended for demo/debug purposes. * @param indent true to pretty print * @param asOf effective time * @param seen (internal use only! This prevents cycles from breaking things) * @returns a JSON string */ async toJson( indent: number | boolean = false, asOf?: AsOf, seen?: Set<string>, ): Promise<string> { ensure(indent === false, "indent not implemented"); if (seen === undefined) seen = new Set(); const mySig = muidToString(this.address); if (seen.has(mySig)) return "null"; seen.add(mySig); const asMap = await this.toMap(asOf); let returning = "{"; let first = true; const entries = ( await this.database.store.getKeyedEntries(this.address, asOf) ).values(); for (const entry of entries) { if (first) { first = false; } else { returning += ","; } const storageKey: StorageKey = entry.storageKey; if (typeof storageKey === "string") { returning += JSON.stringify(storageKey); } else if (storageKey instanceof Uint8Array) { returning += '"' + storageKey.toString() + '"'; } else { returning += JSON.stringify(storageKeyToString(storageKey)); } returning += ":"; if (entry.value !== undefined) { returning += valueToJson(entry.value); } else if (entry.pointeeList.length > 0) { returning += await ( await construct( muidTupleToMuid(entry.pointeeList[0]), this.database, ) ).toJson(indent === false ? false : +indent + 1, asOf, seen); } else { throw new Error(`don't know how to interpret: ${entry}`); } } returning += "}"; return returning; } }