pzp-goals/lib/index.js

350 lines
9.0 KiB
JavaScript

// @ts-ignore
const Obz = require('obz')
// @ts-ignore
const p = (fn) => (...args) => {
return new Promise((res, rej) => {
// @ts-ignore
fn(...args, (err, val) => {
if (err) return rej(err)
return res(val)
})
})
}
/**
* @typedef {ReturnType<import('pzp-db').init>} PZPDB
* @typedef {ReturnType<import('pzp-dict').init>} PZPDict
* @typedef {ReturnType<import('pzp-set').init>} PZPSet
* @typedef {import('pzp-db').RecPresent} RecPresent
* @typedef {import('pzp-db').Tangle} Tangle
* @typedef {import('pzp-db').Msg} Msg
* @typedef {NonNullable<ReturnType<PZPDB['getTangle']>>} DBTangle
* @typedef {string} MsgID
* @typedef {'none'|'all'|`newest-${number}`|'dict'|'set'} GoalDSL
* @typedef {'none'|'all'|'newest'|'dict'|'set'} GoalType
* @typedef {[number, number]} Range
* @typedef {{ id: string, type: GoalType, count: number }} Goal
* @typedef {{ tangleID: MsgID, span: number }} GhostDetails
*/
/**
* @template T
* @typedef {[T] extends [void] ?
* (...args: [Error] | []) => void :
* (...args: [Error] | [null, T]) => void
* } CB
*/
/**
* @template T
* @typedef {(args?: CB<Array<T>>) => any} Multicb
*/
/**
* 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.
* - "ghost" means the msg has no purpose, should not exist in the database, but
* we should still register it as a ghost so that we don't accidentally
* re-request it during replication.
* - "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" < "ghost" < "trail" < "goal", meaning that a
* msg with purpose "goal" may *also* fulfill the purpose of "trail", and a
* "trail" also prevents accidental re-request like "ghost" does.
* @typedef {['none']
* | ['ghost', GhostDetails]
* | ['trail']
* | ['goal']
* } PurposeWithDetails
*/
/**
* @implements {Goal}
*/
class GoalImpl {
/** @type {string} */
#id
/** @type {GoalType} */
#type
/** @type {number} */
#count
/**
* @param {string} tangleID
* @param {GoalDSL} goalDSL
* @returns
*/
constructor(tangleID, goalDSL) {
this.#id = tangleID
if (goalDSL === 'none') {
this.#type = 'none'
this.#count = 0
return
}
if (goalDSL === 'all') {
this.#type = 'all'
this.#count = Infinity
return
}
if (goalDSL === 'set') {
this.#type = 'set'
this.#count = Infinity
return
}
if (goalDSL === 'dict') {
this.#type = 'dict'
this.#count = Infinity
return
}
const matchN = goalDSL.match(/^newest-(\d+)$/)
if (matchN) {
this.#type = 'newest'
this.#count = Number(matchN[1])
return
}
throw new Error(`Unrecognized goal DSL: ${goalDSL}`)
}
get id() {
return this.#id
}
get type() {
return this.#type
}
get count() {
return this.#count
}
}
/**
* @param {{ db: PZPDB, dict: PZPDict, set: PZPSet }} peer
* @param {unknown} config
*/
function initGoals(peer, config) {
// Constants:
const EMPTY_RANGE = /** @type {Range} */ ([1, 0])
// State:
const goals = /** @type {Map<string, Goal>} */ (new Map())
const watch = Obz()
/**
* @param {Range} range
* @returns {boolean}
*/
function isEmptyRange(range) {
const [min, max] = range
return min > max
}
/**
* Determine the range of msg depths that are goalful for this given tangle.
* @private
* @param {Goal} goal
* @param {Tangle} tangle
* @param {CB<Range>} cb
*/
function crossGoalWithTangle(goal, tangle, cb) {
const maxDepth = tangle.maxDepth
switch (goal.type) {
case 'newest':
const start = Math.max(0, maxDepth - goal.count + 1)
return cb(null, [start, maxDepth])
case 'all':
return cb(null, [0, maxDepth])
case 'set':
return peer.set.minRequiredDepth(goal.id, (err, minSetDepth) => {
if (err) return cb(err)
return cb(null, [minSetDepth, maxDepth])
})
case 'dict':
return peer.dict.minRequiredDepth(goal.id, (err, minDictDepth) => {
if (err) return cb(err)
return cb(null, [minDictDepth, maxDepth])
})
case 'none':
return cb(null, EMPTY_RANGE)
default:
return cb(Error(`Unrecognized goal type: ${goal.type}`))
}
}
/**
* @public
* @param {GoalDSL} goalDSL
* @returns {Goal}
*/
function parse(goalDSL) {
return new GoalImpl('?', goalDSL)
}
/**
* @param {Pick<Goal, 'type' | 'count'>} goal
* @returns {GoalDSL}
*/
function serialize(goal) {
switch (goal.type) {
case 'newest':
return `newest-${goal.count}`
case 'all':
return 'all'
case 'set':
return 'set'
case 'dict':
return 'dict'
case 'none':
return 'none'
default:
throw new Error(`Unrecognized goal type: ${goal.type}`)
}
}
/**
* @public
* @param {string} tangleID
* @param {GoalDSL} goalDSL
* @returns {void}
*/
function set(tangleID, goalDSL) {
const goal = new GoalImpl(tangleID, goalDSL)
goals.set(tangleID, goal)
watch.set(goal)
}
/**
* @public
* @param {string} tangleID
* @returns {Goal | null}
*/
function get(tangleID) {
return goals.get(tangleID) ?? null
}
/**
* @public
* @param {MsgID} msgID
* @param {Msg} msg
* @param {CB<PurposeWithDetails>} cb
*/
function getMsgPurpose(msgID, msg, cb) {
let servesAsTrail = false
/** @return {Promise<PurposeWithDetails>} */
async function getPurpose() {
// Check whether this msg is a goalful root of some tangle:
asRoot: if (goals.has(msgID)) {
const goal = /** @type {GoalImpl} */ (goals.get(msgID))
if (goal.type === 'none') break asRoot
const tangle = await p(peer.db.getTangle)(msgID)
if (!tangle) break asRoot
const range = await p(crossGoalWithTangle)(goal, tangle)
if (isEmptyRange(range)) break asRoot
const [min] = range
if (min === 0) return ['goal']
if (min > 0) servesAsTrail = true
}
// Check whether this msg is a goalful affix of some tangle:
const validTangles =
/** @type {Array<[DBTangle, number, number, number, GoalType]>} */ ([])
asAffix: for (const tangleID in msg.metadata.tangles) {
if (!goals.has(tangleID)) continue asAffix
const goal = /** @type {GoalImpl} */ (goals.get(tangleID))
if (goal.type === 'none') continue asAffix
const tangle = await p(peer.db.getTangle)(tangleID)
if (!tangle) continue asAffix
const [min, max] = await p(crossGoalWithTangle)(goal, tangle)
if (min > max) continue asAffix
const recDepth = tangle.getDepth(msgID)
if (recDepth < 0) continue asAffix
validTangles.push([tangle, min, max, recDepth, goal.type])
}
// (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 msg *cannot* serve as 'goal',
// so if it serves as trail, that'll do:
if (servesAsTrail) return ['trail']
// Check whether this msg 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
// @ts-ignore
.topoSort()
// @ts-ignore
.filter((msgID) => tangle.getDepth(msgID) === min)
// @ts-ignore
const { erasables } = tangle.getDeletablesAndErasables(...minMsgIDs)
if (erasables.has(msgID)) return ['trail']
}
// Check whether this msg is a ghost affix of some tangle:
for (const [tangle, min, max, recDepth, goalType] of validTangles) {
if (goalType === 'dict') {
const span = peer.dict.getGhostSpan()
// @ts-ignore
if (await p(peer.dict.isGhostable)(msgID, tangle.id)) {
// @ts-ignore
return ['ghost', { tangleID: tangle.id, span }]
}
}
if (goalType === 'set') {
const span = peer.set.getGhostSpan()
// @ts-ignore
if (await p(peer.set.isGhostable)(msgID, tangle.id)) {
// @ts-ignore
return ['ghost', { tangleID: tangle.id, span }]
}
}
}
return ['none']
}
getPurpose()
.then(purpose => cb(null, purpose))
.catch(err => cb(err))
}
/**
* @public
* @returns {IterableIterator<Goal>}
*/
function list() {
return goals.values()
}
return {
parse,
serialize,
set,
get,
getMsgPurpose,
list,
watch,
}
}
exports.name = 'goals'
exports.needs = ['db', 'dict', 'set']
exports.init = initGoals