diff --git a/lib/tangle.js b/lib/tangle.js new file mode 100644 index 0000000..3b97bd2 --- /dev/null +++ b/lib/tangle.js @@ -0,0 +1,166 @@ +/** + * @typedef {import("./plugin").Rec} Rec + */ + +function lipmaa(n) { + let m = 1 + let po3 = 3 + let u = n + + // find k such that (3^k - 1)/2 >= n + while (m < n) { + po3 *= 3 + m = (po3 - 1) / 2 + } + + // find longest possible backjump + po3 /= 3 + if (m !== n) { + while (u !== 0) { + m = (po3 - 1) / 2 + po3 /= 3 + u %= m + } + + if (m !== po3) { + po3 = m + } + } + + return n - po3 +} + +/** + * @param {string} a + * @param {string} b + * @returns number + */ +function compareMsgHashes(a, b) { + return a.localeCompare(b) +} + +class Tangle { + /** + * @type {Set} + */ + #tips = new Set() + + /** + * @type {Map>} + */ + #prev = new Map() + + /** + * @type {Map} + */ + #depth = new Map() + + /** + * @type {Map>} + */ + #perDepth = new Map() + + /** + * + * @param {string} rootHash + * @param {Iterable} recordsIter + */ + constructor(rootHash, recordsIter) { + for (const rec of recordsIter) { + const msgHash = rec.hash + const tangles = rec.msg.metadata.tangles + if (msgHash === rootHash) { + this.#tips.add(msgHash) + this.#perDepth.set(0, [msgHash]) + this.#depth.set(msgHash, 0) + } else if (tangles[rootHash]) { + this.#tips.add(msgHash) + const prev = tangles[rootHash].prev + for (const p of prev) { + this.#tips.delete(p) + } + this.#prev.set(msgHash, prev) + const depth = tangles[rootHash].depth + this.#depth.set(msgHash, depth) + const atDepth = this.#perDepth.get(depth) ?? [] + atDepth.push(msgHash) + atDepth.sort(compareMsgHashes) + this.#perDepth.set(depth, atDepth) + } + } + } + + /** + * @param {number} depth + * @returns {Array} + */ + #getAllAtDepth(depth) { + return this.#perDepth.get(depth) ?? [] + } + + /** + * @returns {Array} + */ + topoSort() { + const sorted = [] + for (let i = 0; i < 1e9; i++) { + const atDepth = this.#getAllAtDepth(i) + if (atDepth.length === 0) break + for (const msgHash of atDepth) { + sorted.push(msgHash) + } + } + return sorted + } + + /** + * @returns {Array} + */ + getTips() { + return [...this.#tips] + } + + /** + * @param {number} depth + * @returns {Array} + */ + getLipmaa(depth) { + const lipmaaDepth = lipmaa(depth + 1) - 1 + return this.#getAllAtDepth(lipmaaDepth) + } + + #shortestPathToRoot(msgHash) { + const path = [] + let current = msgHash + while (true) { + const prev = this.#prev.get(current) + if (!prev) break + let minDepth = this.#depth.get(current) + let min = current + for (const p of prev) { + const d = this.#depth.get(p) + if (d < minDepth) { + minDepth = d + min = p + } else if (d === minDepth && compareMsgHashes(p, min) < 0) { + min = p + } + } + path.push(min) + current = min + } + return path + } + + getDeletablesAndEmptyables(msgHash) { + const emptyables = this.#shortestPathToRoot(msgHash) + const sorted = this.topoSort() + const index = sorted.indexOf(msgHash) + const deletables = sorted.filter( + (msgHash, i) => i < index && !emptyables.includes(msgHash) + ) + return { deletables, emptyables } + } +} + +module.exports = Tangle diff --git a/test/tangle.test.js b/test/tangle.test.js new file mode 100644 index 0000000..c635e52 --- /dev/null +++ b/test/tangle.test.js @@ -0,0 +1,145 @@ +const test = require('tape') +const path = require('path') +const os = require('os') +const rimraf = require('rimraf') +const SecretStack = require('secret-stack') +const caps = require('ssb-caps') +const p = require('util').promisify +const { generateKeypair } = require('./util') +const Tangle = require('../lib/tangle') + +const DIR = path.join(os.tmpdir(), 'ppppp-db-tangle') +rimraf.sync(DIR) + +let peer +let rootPost, reply1Lo, reply1Hi, reply2A, reply3Lo, reply3Hi +test('setup', async (t) => { + const keysA = generateKeypair('alice') + const keysB = generateKeypair('bob') + const keysC = generateKeypair('carol') + + peer = SecretStack({ appKey: caps.shs }) + .use(require('../')) + .use(require('ssb-box')) + .call(null, { keys: keysA, path: DIR }) + + await peer.db.loaded() + + // Slow down append so that we can create msgs in parallel + const originalAppend = peer.db._getLog().append + peer.db._getLog().append = function (...args) { + setTimeout(originalAppend, 20, ...args) + } + + rootPost = ( + await p(peer.db.create)({ + keys: keysA, + type: 'comment', + content: { text: 'root' }, + }) + ).hash + + const [{ hash: reply1B }, { hash: reply1C }] = await Promise.all([ + p(peer.db.create)({ + keys: keysB, + type: 'comment', + content: { text: 'reply 1' }, + tangles: [rootPost], + }), + p(peer.db.create)({ + keys: keysC, + type: 'comment', + content: { text: 'reply 1' }, + tangles: [rootPost], + }), + ]) + reply1Lo = reply1B.localeCompare(reply1C) < 0 ? reply1B : reply1C + reply1Hi = reply1B.localeCompare(reply1C) < 0 ? reply1C : reply1B + + reply2A = ( + await p(peer.db.create)({ + keys: keysA, + type: 'comment', + content: { text: 'reply 2' }, + tangles: [rootPost], + }) + ).hash + + const [{ hash: reply3B }, { hash: reply3C }] = await Promise.all([ + p(peer.db.create)({ + keys: keysB, + type: 'comment', + content: { text: 'reply 3' }, + tangles: [rootPost], + }), + p(peer.db.create)({ + keys: keysC, + type: 'comment', + content: { text: 'reply 3' }, + tangles: [rootPost], + }), + ]) + reply3Lo = reply3B.localeCompare(reply3C) < 0 ? reply3B : reply3C + reply3Hi = reply3B.localeCompare(reply3C) < 0 ? reply3C : reply3B +}) + +test('Tangle.topoSort', (t) => { + const tangle = new Tangle(rootPost, peer.db.records()) + const sorted = tangle.topoSort() + + t.deepEquals(sorted, [ + rootPost, + reply1Lo, + reply1Hi, + reply2A, + reply3Lo, + reply3Hi, + ]) + console.log(sorted); + t.end() +}) + +test('Tangle.getTips', (t) => { + const tangle = new Tangle(rootPost, peer.db.records()) + const tips = tangle.getTips() + + t.equals(tips.length, 2, 'there are 2 tips') + t.true(tips.includes(reply3Lo), 'tips contains reply3Lo') + t.true(tips.includes(reply3Hi), 'tips contains reply3Hi') + t.end() +}) + +test('Tangle.getLipmaa', (t) => { + const tangle = new Tangle(rootPost, peer.db.records()) + t.deepEquals(tangle.getLipmaa(0), [], 'lipmaa 0 (empty)') + t.deepEquals(tangle.getLipmaa(1), [rootPost], 'lipmaa 1 (-1)') + t.deepEquals(tangle.getLipmaa(2), [reply1Lo, reply1Hi], 'lipmaa 2 (-1)') + t.deepEquals(tangle.getLipmaa(3), [rootPost], 'lipmaa 3 (leap!)') + t.deepEquals(tangle.getLipmaa(4), [reply3Lo, reply3Hi], 'lipmaa 4 (-1)') + t.deepEquals(tangle.getLipmaa(5), [], 'lipmaa 5 (empty)') + + t.end() +}) + +test('Tangle.getDeletablesAndEmptyables basic', t => { + const tangle = new Tangle(rootPost, peer.db.records()) + const { deletables, emptyables } = tangle.getDeletablesAndEmptyables(reply2A) + + t.deepEquals(deletables, [reply1Hi], 'deletables') + t.deepEquals(emptyables, [reply1Lo, rootPost], 'emptyables') + t.end() +}) + + +test('Tangle.getDeletablesAndEmptyables with lipmaa', t => { + const tangle = new Tangle(rootPost, peer.db.records()) + const { deletables, emptyables } = tangle.getDeletablesAndEmptyables(reply3Lo) + + t.deepEquals(deletables, [reply1Lo, reply1Hi, reply2A], 'deletables') + t.deepEquals(emptyables, [rootPost], 'emptyables') + t.end() +}) + +test('teardown', async (t) => { + await p(peer.close)(true) +})