pzp-goals/lib/index.js

350 lines
9.2 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 {import('pzp-db/db-tangle')} 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
.topoSort()
.filter((msgID) => tangle.getDepth(msgID) === min)
const deletablesErasables = tangle.getDeletablesAndErasables(...minMsgIDs)
// TODO: returning 'trail' to be on the safe side and not throw away too much. but i'm confused why the algo would've gotten this far but then this still manages to be null
if (!deletablesErasables) return ['trail']
const { erasables } = deletablesErasables
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