UNPKG

synopsys

Version:

Synopsys is proof of concept datastore service. It stores facts in terms of entity attribute value triples and allows clients to subscribe to _(datomic inspired)_ queries pushing updates to them when new transactions affect results.

434 lines (392 loc) 10.9 kB
import { Constant, API } from 'datalogia' import * as Task from '../task.js' import * as DB from 'datalogia' import * as CBOR from '@ipld/dag-cbor' import { base58btc } from 'multiformats/bases/base58' import * as Attribute from '../datum/attribute.js' import * as Entity from '../datum/entity.js' import * as Reference from '../datum/reference.js' import * as Datum from '../datum.js' import * as Fact from '../fact.js' import * as Type from '../store/type.js' const { Bytes } = Constant export { Task, CBOR } /** * Represents an opaque database type with a methods corresponding to the * static functions exported by this module. * * @implements {Type.DataSource} */ export class DataSource { /** * * @param {Type.Store} store */ constructor(store) { this.store = store } /** * @param {API.FactsSelector} [selector] */ scan(selector) { return scan(this, selector) } /** * @param {API.Transaction} instructions */ transact(instructions) { return transact(this, instructions) } close() { return close(this) } /** @type {Type.Store['read']} */ read(read) { return this.store.read(read) } /** @type {Type.Store['write']} */ write(write) { return this.store.write(write) } } /** * Represents current revision of the database. */ class Revision { #root /** * @param {Type.Node} root */ constructor(root) { this.#root = root } /** * Hash of the merkle tree root of the database encoded as base58btc. */ get id() { return base58btc.baseEncode(this.#root.hash) } toJSON() { return { id: this.id } } } /** * * @param {Type.Store} tree */ export function* open(tree) { return new DataSource(tree) } /** * @template {API.Selector} Select * @param {object} source * @param {Type.Store} source.store * @param {API.Query<Select>} query */ export function* query({ store }, query) { return yield* DB.query(new DataSource(store), query) } /** * Closes the database instance. This is required to release filesystem lock * when using LMDB. * * @param {object} source * @param {Type.Store} source.store */ export function* close({ store }) { return yield* store.close() } /** * Scans the database for all the datums that match a given selector, which * may include entity, attribute, and value or any combination of them. Will * return all the datums that match the selector. * * @param {object} source * @param {Type.Store} source.store * @param {API.FactsSelector} [selector] * @returns {API.Task<API.Datum[], Error>} */ export const scan = ({ store }, { entity, attribute, value } = {}) => store.read((reader) => iterate(reader, { entity, attribute, value })) /** * @param {Type.Sequence<Type.Entry>} entries */ function* collectDatums(entries) { const results = [] while (true) { const result = yield* entries.next() if (result.error) { break } else { const [, value] = result.ok const datum = Datum.fromBytes(value) results.push(datum) } } return results } /** * @param {Type.Sequence<Type.Entry>} entries * @param {SearchPath} path */ function* collectMatchingDatums(entries, [_index, _entity, _attribute, value]) { const results = [] const suffix = /** @type {Uint8Array} */ (value) const offset = suffix.length + 1 while (true) { const result = yield* entries.next() if (result.error) { break } else { const [key, value] = result.ok if (Bytes.equal(key.subarray(-offset, -1), suffix)) { const datum = Datum.fromBytes(value) results.push(datum) } } } return results } /** * We may know entity and value but not the attribute. This is a rare case and * we do not have `EVAT` index to support it. In such case we `EAVT` index * which retrieve all datums for and will have to then filter out the ones * that do not match the value. * * @param {API.FactsSelector} selector * @return {selector is [entity: API.Entity, attribute: undefined, value: API.Constant]} */ const isCoarseSearch = ({ entity, attribute, value }) => entity != null && attribute === undefined && value != undefined /** * Derives a search path from the given selector, by choosing an appropriate * index to scan in. When `entity` is provided `EAVT` index is used. When * `entity` is not provided but `attribute` is provided it will use `AEVT` * when `value` is not provided or `VAET` otherwise. When only `value` is * provided it will use `VAET` index. * * @typedef {[index:Uint8Array, group: Uint8Array | null, subgroup: Uint8Array | null, member: Uint8Array | null]} SearchPath * @param {API.FactsSelector} selector * @returns {SearchPath} */ export const deriveSearchPath = ({ entity, attribute, value }) => { // If we know an this looks like primary key lookup in traditional databases // in this case we use EAVT index. if (entity) { return [ EAVT, Entity.toBytes(entity), attribute === undefined ? null : Attribute.toBytes(attribute), value === undefined ? null : Entity.toBytes(Reference.of(value)), ] } // If we do not know the entity but know a value we are most likely doing // a reverse lookup. In this case we use VAET index is used. else if (value !== undefined) { return [ VAET, Entity.toBytes(Reference.of(value)), attribute === undefined ? null : Attribute.toBytes(attribute), null, ] } // If we know neither entity nor value we have column-style access pattern // and we use AEVT index. else if (attribute !== undefined) { return [AEVT, Attribute.toBytes(attribute), null, null] } // If we know nothing we simply choose an EAVT index. else { return [EAVT, null, null, null] } } /** * @param {Uint8Array} source * @returns */ export const toUpperBound = (source) => { const key = source.slice() key[key.length - 1] = 1 return { key, inclusive: false } } /** * * @param {Uint8Array} key * @returns */ export const toLowerBound = (key) => { return { key, inclusive: true } } /** * * @param {[index:Uint8Array, group:Uint8Array|null, subgroup: Uint8Array|null, member: Uint8Array|null]} path */ export const toSearchKey = ([index, group, subgroup, member]) => { const size = index.length + 1 + (group?.length ?? 0) + 1 + (subgroup?.length ?? 0) + 1 + (member?.length ?? 0) + 1 const key = new Uint8Array(size) let offset = 0 key.set(index, offset) offset += index.length key.set([0], offset) offset += 1 if (group) { key.set(group, offset) offset += group.length key.set([0], offset) offset += 1 } else { return key.subarray(0, offset) } if (subgroup) { key.set(subgroup, offset) offset += subgroup.length key.set([0], offset) offset += 1 } else { return key.subarray(0, offset) } if (member) { key.set(member, offset) offset += member.length key.set([0], offset) offset += 1 } else { return key.subarray(0, offset) } return key } /** * @typedef {object} Change * @property {Uint8Array} origin * @property {number} time * @property {Type.Transaction} transaction * * * @param {object} source * @param {Type.Store} source.store * @param {Type.Transaction} changes * @returns {API.Task<Commit, Error>} */ export const transact = ({ store }, changes) => store.write(function* (writer) { const root = yield* writer.getRoot() const hash = root.hash const time = Date.now() /** @type {Change} */ const commit = { origin: hash, time, transaction: changes, } const cause = Reference.of(commit) yield* assert(writer, [cause, 'db/source', CBOR.encode(commit)], cause) for (const change of changes) { if (change.Retract) { yield* retract(writer, change.Retract, cause) } if (change.Upsert) { yield* upsert(writer, change.Upsert, cause) } if (change.Assert) { yield* assert(writer, change.Assert, cause) } if (change.Import) { for (const [entity, attribute, value] of Fact.iterate(change.Import)) { yield* assert(writer, [entity, attribute, value], cause) } } } return { before: new Revision(root), after: new Revision(yield* writer.getRoot()), cause: commit, } }) /** * Writes a fact into a database. * * @param {Type.StoreWriter} writer * @param {API.Fact} fact * @param {Reference.Reference<Change>} cause */ export function* assert(writer, fact, cause) { const [entity, attribute, value] = fact const datum = Datum.toBytes([entity, attribute, value, cause]) for (const key of keys(fact)) { yield* writer.set(key, datum) } } /** * @param {Type.StoreWriter} writer * @param {API.Fact} fact * @param {Reference.Reference<Change>} cause */ export function* retract(writer, fact, cause) { for (const key of keys(fact)) { yield* writer.delete(key) } } /** * @param {Type.StoreEditor} writer * @param {API.Fact} fact * @param {Reference.Reference<Change>} cause */ export function* upsert(writer, fact, cause) { const [entity, attribute, value] = fact const datums = yield* iterate(writer, { entity, attribute }) for (const [entity, attribute, value] of datums) { yield* retract(writer, [entity, attribute, value], cause) } yield* assert(writer, fact, cause) } /** * @param {Type.StoreReader} reader * @param {API.FactsSelector} [selector] */ export const iterate = (reader, { entity, attribute, value } = {}) => { // Derives a search key path from the given selector. That will choose an // appropriate index. const path = deriveSearchPath({ entity, attribute, value }) // Converts path into a key prefix to search by. const prefix = toSearchKey(path) const entries = reader.entries(toLowerBound(prefix), toUpperBound(prefix)) // When we know entity and value but not the attribute we have an atypical // access pattern for which we do not have a dedicated index. In this case // we use `EAVT` index to retrieve all datums for the entity and then filter // out the ones that do not match the value. return isCoarseSearch({ entity, attribute, value }) ? collectMatchingDatums(entries, path) : collectDatums(entries) } const DELETE = 0 const SET = 1 const REPLACE = 2 /** * @param {API.Fact} fact */ function* keys([entity, attribute, value]) { const e = Entity.toBytes(entity) const a = Attribute.toBytes(attribute) const v = Entity.toBytes(Reference.of(value)) yield toSearchKey([EAVT, e, a, v]) yield toSearchKey([AEVT, a, e, v]) yield toSearchKey([VAET, v, a, e]) } /** * * @typedef {object} Commit * @property {Revision} before * @property {Revision} after * @property {Change} cause */ const EAVT = new Uint8Array([0]) const AEVT = new Uint8Array([1]) const VAET = new Uint8Array([2])