diff --git a/lib/index.js b/lib/index.js index c51927b..ede9514 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,18 +2,47 @@ const Obz = require('obz') /** + * @typedef {ReturnType} PPPPPDB * @typedef {import('ppppp-db').RecPresent} RecPresent * @typedef {import('ppppp-db').Tangle} Tangle + * @typedef {ReturnType} DBTangle * @typedef {'none'|'all'|`newest-${number}`|`oldest-${number}`|'record'|'set'} GoalDSL + * @typedef {'none'|'all'|'newest'|'oldest'|'record'|'set'} GoalType * @typedef {[number, number]} Range - * @typedef {Goal_} Goal + * @typedef {{ id: string, type: GoalType, count: number }} Goal */ -class Goal_ { +/** + * A *purpose* is a tag that explains why a msg exists in the database. + * - "none" means the msg has no purpose, and should not exist in the database. + * - "trail" means the msg does not meet any goal, but it is required to be in + * the database because it is along the path of goalful msgs to the root of the + * tangle. See "Lipmaa certificate pool" concept from Bamboo. + * - "goal" means the msg perfectly meets the requirements of some goal. + * + * These tags are ordered, "none" < "trail" < "goal", meaning that a msg with + * purpose "goal" may *also* fulfill the purpose of "trail". + * @typedef {'none' | 'trail' | 'goal'} Purpose + */ + +/** + * @param {{ + * db: PPPPPDB | null, + * }} peer + * @returns {asserts peer is { db: PPPPPDB }} + */ +function assertDBExists(peer) { + if (!peer.db) throw new Error('goals plugin requires ppppp-db plugin') +} + +/** + * @implements {Goal} + */ +class GoalImpl { /** @type {string} */ #id - /** @type {'none' | 'all' | 'set' | 'record' | 'newest' | 'oldest'} */ + /** @type {GoalType} */ #type /** @type {number} */ @@ -89,20 +118,21 @@ module.exports = { }, /** - * @param {any} peer + * @param {{ db: PPPPPDB | null, close: ClosableHook }} peer * @param {{ path: string; keypair: Keypair; }} config */ init(peer, config) { + assertDBExists(peer) // Constants: const EMPTY_RANGE = /** @type {Range} */ ([1, 0]) // State: - const goals = /** @type {Map} */ (new Map()) + const goals = /** @type {Map} */ (new Map()) const listen = Obz() /** * @private - * @param {Goal_} goal + * @param {Goal} goal * @param {Tangle} tangle * @returns {Range} */ @@ -131,50 +161,79 @@ module.exports = { * @returns {void} */ function set(tangleID, goalDSL) { - const goal = new Goal_(tangleID, goalDSL) + const goal = new GoalImpl(tangleID, goalDSL) goals.set(tangleID, goal) listen.set(goal) } /** * @public - * @param {string} msgID - * @returns {Goal_ | null} + * @param {string} tangleID + * @returns {Goal | null} */ - function getByID(msgID) { - return goals.get(msgID) ?? null + function get(tangleID) { + return goals.get(tangleID) ?? null } /** * @public * @param {RecPresent} rec - * @returns {Array} + * @returns {Purpose} */ - function getByRec(rec) { - const arr = [] - if (goals.has(rec.id)) { - const goal = /** @type {Goal_} */ (goals.get(rec.id)) - arr.push(goal) + function getRecordPurpose(rec) { + assertDBExists(peer) + let servesAsTrail = false + + // Check whether this record is a goalful root of some tangle: + asRoot: if (goals.has(rec.id)) { + const goal = /** @type {GoalImpl} */ (goals.get(rec.id)) + if (goal.type === 'none') break asRoot + const tangle = peer.db.getTangle(rec.id) + if (!tangle) break asRoot + const [min, max] = crossGoalWithTangle(goal, tangle) + if (min > max) break asRoot + if (min === 0) return 'goal' + if (min > 0) servesAsTrail = true } - if (rec.msg) { - for (const tangleID in rec.msg.metadata.tangles) { - if (goals.has(tangleID)) { - const goal = /** @type {Goal_} */ (goals.get(tangleID)) - const tangle = peer.db.getTangle(tangleID) - if (tangle) { - const [min, max] = crossGoalWithTangle(goal, tangle) - const depth = tangle.getDepth(rec.id) - if (depth >= 0 && min <= depth && depth <= max) arr.push(goal) - } - } - } + + // Check whether this record is a goalful affix of some tangle: + const validTangles = + /** @type {Array<[DBTangle, number, number, number]>} */ ([]) + asAffix: for (const tangleID in rec.msg.metadata.tangles) { + if (!goals.has(tangleID)) continue asAffix + const goal = /** @type {GoalImpl} */ (goals.get(tangleID)) + if (goal.type === 'none') continue asAffix + const tangle = peer.db.getTangle(tangleID) + if (!tangle) continue asAffix + const [min, max] = crossGoalWithTangle(goal, tangle) + if (min > max) continue asAffix + const recDepth = tangle.getDepth(rec.id) + if (recDepth < 0) continue asAffix + validTangles.push([tangle, min, max, recDepth]) } - return arr + // (Loop over once without heavy computations and maybe return early:) + for (const [, min, max, recDepth] of validTangles) { + if (min <= recDepth && recDepth <= max) return 'goal' + } + // At this point we know that the record *cannot* serve as 'goal', + // so if it serves as trail, that'll do: + if (servesAsTrail) return 'trail' + // Check whether this record is a trail affix of some tangle: + // (Loop again with heavy computations now that it's inevitable:) + for (const [tangle, min] of validTangles) { + const minMsgIDs = tangle + .topoSort() + .filter((msgID) => tangle.getDepth(msgID) === min) + const { erasables } = tangle.getDeletablesAndErasables(...minMsgIDs) + if (erasables.has(rec.id)) return 'trail' + } + + return 'none' } /** * @public - * @returns {IterableIterator} + * @returns {IterableIterator} */ function list() { return goals.values() @@ -182,8 +241,8 @@ module.exports = { return { set, - getByID, - getByRec, + get, + getRecordPurpose, list, listen, } diff --git a/package.json b/package.json index a94cf6a..148764d 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "*.js", "lib/*.js" ], + "types": "types/index.d.ts", "exports": { ".": { "require": "./lib/index.js" diff --git a/test/goals.test.js b/test/goals.test.js index 96daf25..dc60fee 100644 --- a/test/goals.test.js +++ b/test/goals.test.js @@ -27,18 +27,15 @@ test('set, getByID, list, listen', async (t) => { } { - const goal = alice.goals.getByID(aliceID) + const goal = alice.goals.get(aliceID) assert.strictEqual(goal.id, aliceID, 'gotten goal id is correct') assert.strictEqual(goal.type, 'newest', 'gotten goal type is correct') assert.strictEqual(goal.count, 5, 'gotten goal count is correct') } { - const goals = alice.goals.getByRec(aliceAccountRoot) - assert(Array.isArray(goals), 'gotten rec goals is an array') - assert.strictEqual(goals.length, 1, 'gotten rec goals has one item') - const goal = goals[0] - assert.strictEqual(goal.id, aliceID, 'gotten rec goal id is correct') + const purpose = alice.goals.getRecordPurpose(aliceAccountRoot) + assert.equal(purpose, 'goal', 'rec purpose is "goal"') } { @@ -63,7 +60,7 @@ test('set, getByID, list, listen', async (t) => { await p(alice.close)(true) }) -test('getByRec', async (t) => { +test('getRecordPurpose', async (t) => { const alice = createPeer({ name: 'alice' }) await alice.db.loaded() @@ -86,20 +83,16 @@ test('getByRec', async (t) => { const feedID = alice.db.feed.getID(aliceID, 'post') alice.goals.set(feedID, 'all') - const gottenGoal = alice.goals.getByID(feedID) + const gottenGoal = alice.goals.get(feedID) assert.strictEqual(gottenGoal.id, feedID, 'gotten goal id is correct') - const recGoals = alice.goals.getByRec(post2) - assert(Array.isArray(recGoals), 'recGoals is an array') - assert.strictEqual(recGoals.length, 1, 'recGoals has one item') - const recGoal = recGoals[0] - assert.strictEqual(recGoal.id, feedID, 'recGoal id is correct') + const purpose = alice.goals.getRecordPurpose(post2) + assert.equal(purpose, 'goal', 'purpose is "goal"') alice.goals.set(feedID, 'oldest-1') assert('set goal to oldest-1') - const recGoals2 = alice.goals.getByRec(post2) - assert(Array.isArray(recGoals2), 'recGoals is an array') - assert.strictEqual(recGoals2.length, 0, 'recGoals2 has zero items') + const purpose2 = alice.goals.getRecordPurpose(post2) + assert.equal(purpose2, 'none', 'purpose2 is "none"') await p(alice.close)(true) })