pzp-set/lib/index.js

332 lines
10 KiB
JavaScript

const FeedV1 = require('ppppp-db/feed-v1')
const PREFIX = 'set_v1__'
/** @typedef {string} Subtype */
/** @typedef {string} MsgHash */
/** @typedef {`${Subtype}.${string}`} SubtypeItem */
/**
* @param {string} type
* @returns {Subtype}
*/
function toSubtype(type) {
return type.slice(PREFIX.length)
}
/**
* @param {Subtype} subtype
* @returns {string}
*/
function fromSubtype(subtype) {
return PREFIX + subtype
}
module.exports = {
name: 'set',
manifest: {
add: 'async',
del: 'async',
has: 'sync',
values: 'sync',
getItemRoots: 'sync',
squeeze: 'async',
},
init(peer, config) {
//#region state
const myWho = FeedV1.stripAuthor(config.keys.id)
let cancelListeningToRecordAdded = null
/** @type {Map<Subtype, unknown>} */
const tangles = new Map()
const itemRoots = {
/** @type {Map<SubtypeItem, Set<MsgHash>} */
_map: new Map(),
_getKey(subtype, item) {
return subtype + '/' + item
},
get(subtype, item = null) {
if (item) {
const key = this._getKey(subtype, item)
return this._map.get(key)
} else {
const out = {}
for (const [key, value] of this._map.entries()) {
if (key.startsWith(subtype + '/')) {
const item = key.slice(subtype.length + 1)
out[item] = [...value]
}
}
return out
}
},
add(subtype, item, msgHash) {
const key = this._getKey(subtype, item)
const set = this._map.get(key) ?? new Set()
set.add(msgHash)
return this._map.set(key, set)
},
del(subtype, item, msgHash) {
const key = this._getKey(subtype, item)
const set = this._map.get(key)
if (!set) return false
set.delete(msgHash)
if (set.size === 0) this._map.delete(key)
return true
},
toString() {
return this._map
},
}
//#endregion
//#region active processes
const loadPromise = new Promise((resolve, reject) => {
for (const { hash, msg } of peer.db.records()) {
maybeLearnAboutSet(hash, msg)
}
cancelListeningToRecordAdded = peer.db.onRecordAdded(({ hash, msg }) => {
maybeLearnAboutSet(hash, msg)
})
resolve()
})
peer.close.hook(function (fn, args) {
cancelListeningToRecordAdded()
fn.apply(this, args)
})
//#endregion
//#region internal methods
function isValidSetRootMsg(msg) {
if (!msg) return false
if (msg.metadata.who !== myWho) return false
const type = msg.metadata.type
if (!type.startsWith(PREFIX)) return false
return FeedV1.isFeedRoot(msg, config.keys.id, type)
}
function isValidSetMsg(msg) {
if (!msg) return false
if (!msg.content) return false
if (msg.metadata.who !== myWho) return false
if (!msg.metadata.type.startsWith(PREFIX)) return false
if (!Array.isArray(msg.content.add)) return false
if (!Array.isArray(msg.content.del)) return false
if (!Array.isArray(msg.content.supersedes)) return false
return true
}
function readSet(authorId, subtype) {
const type = fromSubtype(subtype)
const rootHash = FeedV1.getFeedRootHash(authorId, type)
const tangle = peer.db.getTangle(rootHash)
if (!tangle || tangle.size() === 0) return new Set()
const msgHashes = tangle.topoSort()
const set = new Set()
for (const msgHash of msgHashes) {
const msg = peer.db.get(msgHash)
if (isValidSetMsg(msg)) {
const { add, del } = msg.content
for (const value of add) set.add(value)
for (const value of del) set.delete(value)
}
}
return set
}
function learnSetRoot(hash, msg) {
const { type } = msg.metadata
const subtype = toSubtype(type)
const tangle = tangles.get(subtype) ?? new FeedV1.Tangle(hash)
tangle.add(hash, msg)
tangles.set(subtype, tangle)
}
function learnSetUpdate(hash, msg) {
const { who, type } = msg.metadata
const rootHash = FeedV1.getFeedRootHash(who, type)
const subtype = toSubtype(type)
const tangle = tangles.get(subtype) ?? new FeedV1.Tangle(rootHash)
tangle.add(hash, msg)
tangles.set(subtype, tangle)
const addOrRemove = [].concat(msg.content.add, msg.content.del)
for (const item of addOrRemove) {
const existing = itemRoots.get(subtype, item)
if (!existing || existing.size === 0) {
itemRoots.add(subtype, item, hash)
} else {
for (const existingHash of existing) {
if (tangle.precedes(existingHash, hash)) {
itemRoots.del(subtype, item, existingHash)
itemRoots.add(subtype, item, hash)
} else {
itemRoots.add(subtype, item, hash)
}
}
}
}
}
function maybeLearnAboutSet(hash, msg) {
if (msg.metadata.who !== myWho) return
if (isValidSetRootMsg(msg)) {
learnSetRoot(hash, msg)
return
}
if (isValidSetMsg(msg)) {
learnSetUpdate(hash, msg)
return
}
}
function loaded(cb) {
if (cb === void 0) return loadPromise
else loadPromise.then(() => cb(null), cb)
}
function _squeezePotential(subtype) {
// TODO: improve this so that the squeezePotential is the size of the
// tangle suffix built as a slice from the fieldRoots
const rootHash = FeedV1.getFeedRootHash(myWho, fromSubtype(subtype))
const tangle = peer.db.getTangle(rootHash)
const maxDepth = tangle.getMaxDepth()
const currentItemRoots = itemRoots.get(subtype)
let minDepth = Infinity
for (const item in currentItemRoots) {
for (const msgHash of currentItemRoots[item]) {
const depth = tangle.getDepth(msgHash)
if (depth < minDepth) minDepth = depth
}
}
return maxDepth - minDepth
}
//#endregion
//#region public methods
function add(authorId, subtype, value, cb) {
const who = FeedV1.stripAuthor(authorId)
// prettier-ignore
if (who !== myWho) return cb(new Error(`Cannot add to another user's Set (${authorId}/${subtype})`))
loaded(() => {
const currentSet = readSet(authorId, subtype)
if (currentSet.has(value)) return cb(null, false)
const type = fromSubtype(subtype)
// Populate supersedes
const supersedes = []
const toDeleteFromItemRoots = new Map()
const currentItemRoots = itemRoots.get(subtype)
for (const item in currentItemRoots) {
// If we are re-adding this item, OR if this item has been deleted,
// then we should update roots
if (item === value || !currentSet.has(item)) {
supersedes.push(...currentItemRoots[item])
for (const msgHash of currentItemRoots[item]) {
toDeleteFromItemRoots.set(msgHash, item)
}
}
}
const content = { add: [value], del: [], supersedes }
peer.db.create({ type, content }, (err) => {
// prettier-ignore
if (err) return cb(new Error(`Failed to create msg when adding to Set (${authorId}/${subtype})`, { cause: err }))
for (const [msgHash, item] of toDeleteFromItemRoots) {
itemRoots.del(subtype, item, msgHash)
}
cb(null, true)
})
})
}
function del(authorId, subtype, value, cb) {
const who = FeedV1.stripAuthor(authorId)
// prettier-ignore
if (who !== myWho) return cb(new Error(`Cannot delete from another user's Set (${authorId}/${subtype})`))
loaded(() => {
const currentSet = readSet(authorId, subtype)
if (!currentSet.has(value)) return cb(null, false)
const type = fromSubtype(subtype)
// Populate supersedes
const supersedes = []
const currentItemRoots = itemRoots.get(subtype)
for (const item in currentItemRoots) {
if (item === value || !currentSet.has(item)) {
supersedes.push(...currentItemRoots[item])
}
}
const content = { add: [], del: [value], supersedes }
peer.db.create({ type, content }, (err) => {
// prettier-ignore
if (err) return cb(new Error(`Failed to create msg when deleting from Set (${authorId}/${subtype})`, { cause: err }))
cb(null, true)
})
})
}
function has(authorId, subtype, value) {
const set = readSet(authorId, subtype)
return set.has(value)
}
function values(authorId, subtype) {
const set = readSet(authorId, subtype)
return [...set]
}
function getItemRoots(authorId, subtype) {
const who = FeedV1.stripAuthor(authorId)
// prettier-ignore
if (who !== myWho) return cb(new Error(`Cannot getItemRoots of another user's Set. (${authorId}/${subtype})`))
return itemRoots.get(subtype)
}
function squeeze(authorId, subtype, cb) {
const who = FeedV1.stripAuthor(authorId)
// prettier-ignore
if (who !== myWho) return cb(new Error(`Cannot squeeze another user's Set (${authorId}/${subtype})`))
const potential = _squeezePotential(subtype)
if (potential < 1) return cb(null, false)
loaded(() => {
const type = fromSubtype(subtype)
const currentSet = readSet(authorId, subtype)
const supersedes = []
const currentItemRoots = itemRoots.get(subtype)
for (const item in currentItemRoots) {
supersedes.push(...currentItemRoots[item])
}
const content = { add: [...currentSet], del: [], supersedes }
peer.db.create({ type, content }, (err) => {
// prettier-ignore
if (err) return cb(new Error(`Failed to create msg when squeezing Set (${authorId}/${subtype})`, { cause: err }))
cb(null, true)
})
})
}
//#endregion
return {
add,
del,
has,
values,
getItemRoots,
squeeze,
_squeezePotential,
}
},
}