diff --git a/lib/db-tangle.js b/lib/db-tangle.js new file mode 100644 index 0000000..dabc5cb --- /dev/null +++ b/lib/db-tangle.js @@ -0,0 +1,158 @@ +const pull = require('pull-stream') +const p = require('node:util').promisify +const MsgV4 = require('./msg-v4') + +/** + * @typedef {string} MsgID + * @typedef {import('./msg-v4').Msg} Msg + */ + +/** + * @typedef {{ + * id?: never; + * msg?: never; + * received?: never; + * }} RecDeleted + * + * @typedef {{ + * id: MsgID; + * msg: Msg; + * received: number; + * }} RecPresent + * + * @typedef {RecPresent | RecDeleted} Rec + */ + +/** + * @template T + * @typedef {[T] extends [void] ? + * (...args: [Error] | []) => void : + * (...args: [Error] | [null, T]) => void + * } CB + */ + +class DBTangle extends MsgV4.Tangle { + /** @type {(msgID: MsgID, cb: CB) => void} */ + #getMsg + + /** + * @param {MsgID} rootID + * @param {(msgID: MsgID, cb: CB) => void} getMsg + */ + constructor(rootID, getMsg) { + super(rootID) + this.#getMsg = getMsg + } + + /** + * @param {MsgID} rootID + * @param {AsyncIterable} recordsIter + * @param {(msgID: MsgID, cb: any) => void} getMsg + * @return {Promise} + */ + static async init(rootID, recordsIter, getMsg) { + const dbtangle = new DBTangle(rootID, getMsg) + for await (const rec of recordsIter) { + if (!rec.msg) continue + dbtangle.add(rec.id, rec.msg) + } + return dbtangle + } + + /** + * Given a set of msgs (`msgIDs`) in this tangle, find all "deletable" and + * "erasable" msgs that precede that set. + * + * *Deletables* are msgs that precede `msgsIDs` but are not important in any + * validation path toward the root, and thus can be deleted. + * + * *Erasables* are msgs that precede `msgsIDs` and can be erased without + * losing a validation path toward the root. + * @param {Array} msgIDs + * @returns {{ deletables: Set, erasables: Set } | null} + */ + getDeletablesAndErasables(...msgIDs) { + // Determine erasables + const erasables = new Set() + const minimum = this.getMinimumAmong(msgIDs) + for (const msgID of minimum) { + const trail = this.shortestPathToRoot(msgID) + if (!trail) return null + for (const id of trail) { + erasables.add(id) + } + } + + // Determine deletables + const deletables = new Set() + const sorted = this.topoSort() + for (const msgID of sorted) { + if (erasables.has(msgID)) continue + if (minimum.some((min) => this.precedes(msgID, min))) { + deletables.add(msgID) + } + } + + return { deletables, erasables } + } + + /** + * @param {Array=} minSet + * @param {Array=} maxSet + * @param {CB>=} cb + * @return {Promise>|void} + */ + slice(minSet = [], maxSet = [], cb) { + // @ts-ignore + if (cb === undefined) return p(this.slice).bind(this)(minSet, maxSet) + + const minSetGood = minSet.filter((msgID) => this.has(msgID)) + const maxSetGood = maxSet.filter((msgID) => this.has(msgID)) + const minSetTight = this.getMinimumAmong(minSetGood) + + const trail = new Set() + for (const msgID of minSetTight) { + const path = this.shortestPathToRoot(msgID) + if (!path) return cb(Error("Couldn't get shortest path to root when slicing dbtangle")) + for (const msgID of path) { + trail.add(msgID) + } + } + + const msgs = /**@type {Array}*/ ([]) + + pull( + pull.values(this.topoSort()), + pull.asyncMap((msgID, cb) => { + this.#getMsg(msgID, (err, msg) => { + if (err) return cb(err) + cb(null, { id: msgID, msg }) + }) + }), + pull.drain( + (rec) => { + if (trail.has(rec.id)) { + if (rec.msg) msgs.push({ ...rec.msg, data: null }) + } + const isMin = minSetGood.includes(rec.id) + const isMax = maxSetGood.includes(rec.id) + const isBeforeMin = minSetGood.some((min) => + this.precedes(rec.id, min) + ) + const isAfterMax = maxSetGood.some((max) => + this.precedes(max, rec.id) + ) + if (!isMin && isBeforeMin) return + if (!isMax && isAfterMax) return + if (rec.msg) msgs.push(rec.msg) + }, + (err) => { + if (err) return cb(Error('DBTangle.slice() failed', { cause: err })) + return cb(null, msgs) + } + ) + ) + } +} + +module.exports = DBTangle \ No newline at end of file diff --git a/lib/index.js b/lib/index.js index 73b8ffa..36c2738 100644 --- a/lib/index.js +++ b/lib/index.js @@ -8,6 +8,7 @@ const pull = require('pull-stream') const p = require('node:util').promisify const Log = require('./log') const MsgV4 = require('./msg-v4') +const DBTangle = require('./db-tangle') const { SIGNATURE_TAG_ACCOUNT_ADD, ACCOUNT_SELF, @@ -93,130 +94,6 @@ function assertValidConfig(config) { } } -class DBTangle extends MsgV4.Tangle { - /** @type {(msgID: MsgID, cb: CB) => void} */ - #getMsg - - /** - * @param {MsgID} rootID - * @param {(msgID: MsgID, cb: CB) => void} getMsg - */ - constructor(rootID, getMsg) { - super(rootID) - this.#getMsg = getMsg - } - - /** - * @param {MsgID} rootID - * @param {AsyncIterable} recordsIter - * @param {(msgID: MsgID, cb: any) => void} getMsg - * @return {Promise} - */ - static async init(rootID, recordsIter, getMsg) { - const dbtangle = new DBTangle(rootID, getMsg) - for await (const rec of recordsIter) { - if (!rec.msg) continue - dbtangle.add(rec.id, rec.msg) - } - return dbtangle - } - - /** - * Given a set of msgs (`msgIDs`) in this tangle, find all "deletable" and - * "erasable" msgs that precede that set. - * - * *Deletables* are msgs that precede `msgsIDs` but are not important in any - * validation path toward the root, and thus can be deleted. - * - * *Erasables* are msgs that precede `msgsIDs` and can be erased without - * losing a validation path toward the root. - * @param {Array} msgIDs - * @returns {{ deletables: Set, erasables: Set } | null} - */ - getDeletablesAndErasables(...msgIDs) { - // Determine erasables - const erasables = new Set() - const minimum = this.getMinimumAmong(msgIDs) - for (const msgID of minimum) { - const trail = this.shortestPathToRoot(msgID) - if (!trail) return null - for (const id of trail) { - erasables.add(id) - } - } - - // Determine deletables - const deletables = new Set() - const sorted = this.topoSort() - for (const msgID of sorted) { - if (erasables.has(msgID)) continue - if (minimum.some((min) => this.precedes(msgID, min))) { - deletables.add(msgID) - } - } - - return { deletables, erasables } - } - - /** - * @param {Array=} minSet - * @param {Array=} maxSet - * @param {CB>=} cb - * @return {Promise>|void} - */ - slice(minSet = [], maxSet = [], cb) { - // @ts-ignore - if (cb === undefined) return p(this.slice).bind(this)(minSet, maxSet) - - const minSetGood = minSet.filter((msgID) => this.has(msgID)) - const maxSetGood = maxSet.filter((msgID) => this.has(msgID)) - const minSetTight = this.getMinimumAmong(minSetGood) - - const trail = new Set() - for (const msgID of minSetTight) { - const path = this.shortestPathToRoot(msgID) - if (!path) return cb(Error("Couldn't get shortest path to root when slicing dbtangle")) - for (const msgID of path) { - trail.add(msgID) - } - } - - const msgs = /**@type {Array}*/ ([]) - - pull( - pull.values(this.topoSort()), - pull.asyncMap((msgID, cb) => { - this.#getMsg(msgID, (err, msg) => { - if (err) return cb(err) - cb(null, { id: msgID, msg }) - }) - }), - pull.drain( - (rec) => { - if (trail.has(rec.id)) { - if (rec.msg) msgs.push({ ...rec.msg, data: null }) - } - const isMin = minSetGood.includes(rec.id) - const isMax = maxSetGood.includes(rec.id) - const isBeforeMin = minSetGood.some((min) => - this.precedes(rec.id, min) - ) - const isAfterMax = maxSetGood.some((max) => - this.precedes(max, rec.id) - ) - if (!isMin && isBeforeMin) return - if (!isMax && isAfterMax) return - if (rec.msg) msgs.push(rec.msg) - }, - (err) => { - if (err) return cb(Error('DBTangle.slice() failed', { cause: err })) - return cb(null, msgs) - } - ) - ) - } -} - /** * @param {Peer} peer * @param {Config} config diff --git a/package.json b/package.json index 9e4e0ba..8dfdda3 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ }, "./msg-v4": { "require": "./lib/msg-v4/index.js" + }, + "./db-tangle": { + "require": "./lib/db-tangle.js" } }, "dependencies": {