diff --git a/lib/index.js b/lib/index.js index 4e0f935..0db8b30 100644 --- a/lib/index.js +++ b/lib/index.js @@ -73,429 +73,418 @@ function assert(check, message) { if (!check) throw new Error(message) } -module.exports = { - name: 'set', - manifest: {}, +/** + * @param {{ db: PPPPPDB | null, close: ClosableHook }} peer + * @param {any} config + */ +function initSet(peer, config) { + assertDBPlugin(peer) + + //#region state + let accountID = /** @type {string | null} */ (null) + let loadPromise = /** @type {Promise | null} */ (null) + let cancelOnRecordAdded = /** @type {CallableFunction | null} */ (null) + const tangles = /** @type {Map} */ (new Map()) + + const itemRoots = { + _map: /** @type {Map>} */ (new Map()), + /** + * @param {string} subdomain + * @param {string} item + * @returns {SubdomainItem} + */ + _getKey(subdomain, item) { + return `${subdomain}/${item}` + }, + /** + * @param {string} subdomain + * @returns {{[item in string]: Array}} + */ + getAll(subdomain) { + const out = /** @type {{[item in string]: Array}} */ ({}) + for (const [key, value] of this._map.entries()) { + if (key.startsWith(subdomain + '/')) { + const item = key.slice(subdomain.length + 1) + out[item] = [...value] + } + } + return out + }, + /** + * @param {string} subdomain + * @param {string} item + * @returns {Set | undefined} + */ + get(subdomain, item) { + const key = this._getKey(subdomain, item) + return this._map.get(key) + }, + /** + * @param {string} subdomain + * @param {string} item + * @param {string} msgID + */ + add(subdomain, item, msgID) { + const key = this._getKey(subdomain, item) + const set = this._map.get(key) ?? new Set() + set.add(msgID) + return this._map.set(key, set) + }, + /** + * @param {string} subdomain + * @param {string} item + * @param {string} msgID + */ + del(subdomain, item, msgID) { + const key = this._getKey(subdomain, item) + const set = this._map.get(key) + if (!set) return false + set.delete(msgID) + if (set.size === 0) this._map.delete(key) + return true + }, + toString() { + return this._map + }, + } + //#endregion + + //#region active processes + peer.close.hook(function (fn, args) { + cancelOnRecordAdded?.() + fn.apply(this, args) + }) + //#endregion + + //#region internal methods + /** + * @private + * @param {Msg | null | undefined} msg + * @returns {msg is Msg} + */ + function isValidSetMoot(msg) { + if (!msg) return false + if (msg.metadata.account !== accountID) return false + const domain = msg.metadata.domain + if (!domain.startsWith(PREFIX)) return false + return MsgV3.isMoot(msg, accountID, domain) + } /** - * @param {{ db: PPPPPDB | null, close: ClosableHook }} peer - * @param {any} config + * @private + * @param {Msg | null | undefined} msg + * @returns {msg is Msg} */ - init(peer, config) { + function isValidSetMsg(msg) { + if (!msg) return false + if (!msg.data) return false + if (msg.metadata.account !== accountID) return false + if (!msg.metadata.domain.startsWith(PREFIX)) return false + if (!Array.isArray(msg.data.add)) return false + if (!Array.isArray(msg.data.del)) return false + if (!Array.isArray(msg.data.supersedes)) return false + return true + } + + /** + * @param {string} id + * @param {string} subdomain + */ + function readSet(id, subdomain) { assertDBPlugin(peer) - - //#region state - let accountID = /** @type {string | null} */ (null) - let loadPromise = /** @type {Promise | null} */ (null) - let cancelOnRecordAdded = /** @type {CallableFunction | null} */ (null) - const tangles = /** @type {Map} */ (new Map()) - - const itemRoots = { - _map: /** @type {Map>} */ (new Map()), - /** - * @param {string} subdomain - * @param {string} item - * @returns {SubdomainItem} - */ - _getKey(subdomain, item) { - return `${subdomain}/${item}` - }, - /** - * @param {string} subdomain - * @returns {{[item in string]: Array}} - */ - getAll(subdomain) { - const out = /** @type {{[item in string]: Array}} */ ({}) - for (const [key, value] of this._map.entries()) { - if (key.startsWith(subdomain + '/')) { - const item = key.slice(subdomain.length + 1) - out[item] = [...value] - } - } - return out - }, - /** - * @param {string} subdomain - * @param {string} item - * @returns {Set | undefined} - */ - get(subdomain, item) { - const key = this._getKey(subdomain, item) - return this._map.get(key) - }, - /** - * @param {string} subdomain - * @param {string} item - * @param {string} msgID - */ - add(subdomain, item, msgID) { - const key = this._getKey(subdomain, item) - const set = this._map.get(key) ?? new Set() - set.add(msgID) - return this._map.set(key, set) - }, - /** - * @param {string} subdomain - * @param {string} item - * @param {string} msgID - */ - del(subdomain, item, msgID) { - const key = this._getKey(subdomain, item) - const set = this._map.get(key) - if (!set) return false - set.delete(msgID) - if (set.size === 0) this._map.delete(key) - return true - }, - toString() { - return this._map - }, - } - //#endregion - - //#region active processes - peer.close.hook(function (fn, args) { - cancelOnRecordAdded?.() - fn.apply(this, args) - }) - //#endregion - - //#region internal methods - /** - * @private - * @param {Msg | null | undefined} msg - * @returns {msg is Msg} - */ - function isValidSetMoot(msg) { - if (!msg) return false - if (msg.metadata.account !== accountID) return false - const domain = msg.metadata.domain - if (!domain.startsWith(PREFIX)) return false - return MsgV3.isMoot(msg, accountID, domain) - } - - /** - * @private - * @param {Msg | null | undefined} msg - * @returns {msg is Msg} - */ - function isValidSetMsg(msg) { - if (!msg) return false - if (!msg.data) return false - if (msg.metadata.account !== accountID) return false - if (!msg.metadata.domain.startsWith(PREFIX)) return false - if (!Array.isArray(msg.data.add)) return false - if (!Array.isArray(msg.data.del)) return false - if (!Array.isArray(msg.data.supersedes)) return false - return true - } - - /** - * @param {string} id - * @param {string} subdomain - */ - function readSet(id, subdomain) { - assertDBPlugin(peer) - const domain = fromSubdomain(subdomain) - const mootID = MsgV3.getMootID(id, domain) - const tangle = peer.db.getTangle(mootID) - if (!tangle || tangle.size === 0) return new Set() - const msgIDs = tangle.topoSort() - const set = new Set() - for (const msgID of msgIDs) { - const msg = peer.db.get(msgID) - if (isValidSetMsg(msg)) { - const { add, del } = msg.data - for (const value of add) set.add(value) - for (const value of del) set.delete(value) - } - } - return set - } - - /** - * @param {string} mootID - * @param {Msg} moot - */ - function learnSetMoot(mootID, moot) { - const { domain } = moot.metadata - const subdomain = toSubdomain(domain) - const tangle = tangles.get(subdomain) ?? new MsgV3.Tangle(mootID) - tangle.add(mootID, moot) - tangles.set(subdomain, tangle) - } - - /** - * @param {string} msgID - * @param {Msg} msg - */ - function learnSetUpdate(msgID, msg) { - const { account, domain } = msg.metadata - const mootID = MsgV3.getMootID(account, domain) - const subdomain = toSubdomain(domain) - const tangle = tangles.get(subdomain) ?? new MsgV3.Tangle(mootID) - tangle.add(msgID, msg) - tangles.set(subdomain, tangle) - const addOrDel = msg.data.add.concat(msg.data.del) - for (const item of addOrDel) { - const existing = itemRoots.get(subdomain, item) - if (!existing || existing.size === 0) { - itemRoots.add(subdomain, item, msgID) - } else { - for (const existingID of existing) { - if (tangle.precedes(existingID, msgID)) { - itemRoots.del(subdomain, item, existingID) - itemRoots.add(subdomain, item, msgID) - } else { - itemRoots.add(subdomain, item, msgID) - } - } - } - } - } - - /** - * @param {string} msgID - * @param {Msg} msg - */ - function maybeLearnAboutSet(msgID, msg) { - if (msg.metadata.account !== accountID) return - if (isValidSetMoot(msg)) { - learnSetMoot(msgID, msg) - return - } + const domain = fromSubdomain(subdomain) + const mootID = MsgV3.getMootID(id, domain) + const tangle = peer.db.getTangle(mootID) + if (!tangle || tangle.size === 0) return new Set() + const msgIDs = tangle.topoSort() + const set = new Set() + for (const msgID of msgIDs) { + const msg = peer.db.get(msgID) if (isValidSetMsg(msg)) { - learnSetUpdate(msgID, msg) - return + const { add, del } = msg.data + for (const value of add) set.add(value) + for (const value of del) set.delete(value) } } + return set + } - /** - * @private - * @param {CB} cb - */ - function loaded(cb) { - if (cb === void 0) return loadPromise - else loadPromise?.then(() => cb(), cb) + /** + * @param {string} mootID + * @param {Msg} moot + */ + function learnSetMoot(mootID, moot) { + const { domain } = moot.metadata + const subdomain = toSubdomain(domain) + const tangle = tangles.get(subdomain) ?? new MsgV3.Tangle(mootID) + tangle.add(mootID, moot) + tangles.set(subdomain, tangle) + } + + /** + * @param {string} msgID + * @param {Msg} msg + */ + function learnSetUpdate(msgID, msg) { + const { account, domain } = msg.metadata + const mootID = MsgV3.getMootID(account, domain) + const subdomain = toSubdomain(domain) + const tangle = tangles.get(subdomain) ?? new MsgV3.Tangle(mootID) + tangle.add(msgID, msg) + tangles.set(subdomain, tangle) + const addOrDel = msg.data.add.concat(msg.data.del) + for (const item of addOrDel) { + const existing = itemRoots.get(subdomain, item) + if (!existing || existing.size === 0) { + itemRoots.add(subdomain, item, msgID) + } else { + for (const existingID of existing) { + if (tangle.precedes(existingID, msgID)) { + itemRoots.del(subdomain, item, existingID) + itemRoots.add(subdomain, item, msgID) + } else { + itemRoots.add(subdomain, item, msgID) + } + } + } } + } - /** - * @param {string} subdomain - */ - function _squeezePotential(subdomain) { - assertDBPlugin(peer) - if (!accountID) throw new Error('Cannot squeeze potential before loading') - // TODO: improve this so that the squeezePotential is the size of the - // tangle suffix built as a slice from the fieldRoots - const mootID = MsgV3.getMootID(accountID, fromSubdomain(subdomain)) - const tangle = peer.db.getTangle(mootID) - const maxDepth = tangle.maxDepth + /** + * @param {string} msgID + * @param {Msg} msg + */ + function maybeLearnAboutSet(msgID, msg) { + if (msg.metadata.account !== accountID) return + if (isValidSetMoot(msg)) { + learnSetMoot(msgID, msg) + return + } + if (isValidSetMsg(msg)) { + learnSetUpdate(msgID, msg) + return + } + } + + /** + * @private + * @param {CB} cb + */ + function loaded(cb) { + if (cb === void 0) return loadPromise + else loadPromise?.then(() => cb(), cb) + } + + /** + * @param {string} subdomain + */ + function _squeezePotential(subdomain) { + assertDBPlugin(peer) + if (!accountID) throw new Error('Cannot squeeze potential before loading') + // TODO: improve this so that the squeezePotential is the size of the + // tangle suffix built as a slice from the fieldRoots + const mootID = MsgV3.getMootID(accountID, fromSubdomain(subdomain)) + const tangle = peer.db.getTangle(mootID) + const maxDepth = tangle.maxDepth + const currentItemRoots = itemRoots.getAll(subdomain) + let minDepth = Infinity + for (const item in currentItemRoots) { + for (const msgID of currentItemRoots[item]) { + const depth = tangle.getDepth(msgID) + if (depth < minDepth) minDepth = depth + } + } + return maxDepth - minDepth + } + //#endregion + + //#region public methods + /** + * @param {string} id + * @param {CB} cb + */ + function load(id, cb) { + assertDBPlugin(peer) + accountID = id + loadPromise = new Promise((resolve, reject) => { + for (const rec of peer.db.records()) { + if (!rec.msg) continue + maybeLearnAboutSet(rec.id, rec.msg) + } + cancelOnRecordAdded = peer.db.onRecordAdded( + (/** @type {RecPresent} */ rec) => { + try { + maybeLearnAboutSet(rec.id, rec.msg) + } catch (err) { + console.error(err) + } + } + ) + resolve() + cb() + }) + } + + /** + * @param {string} id + * @param {string} subdomain + * @param {string} value + * @param {CB} cb + */ + function add(id, subdomain, value, cb) { + assertDBPlugin(peer) + assert(!!accountID, 'Cannot add to Set before loading') + // prettier-ignore + if (id !== accountID) return cb(new Error(`Cannot add to another user's Set (${id}/${subdomain})`)) + + loaded(() => { + assert(!!accountID, 'Cannot add to Set before loading') + const currentSet = readSet(id, subdomain) + if (currentSet.has(value)) return cb(null, false) + const domain = fromSubdomain(subdomain) + + // Populate supersedes + const supersedes = [] + const toDeleteFromItemRoots = new Map() const currentItemRoots = itemRoots.getAll(subdomain) - let minDepth = Infinity for (const item in currentItemRoots) { - for (const msgID of currentItemRoots[item]) { - const depth = tangle.getDepth(msgID) - if (depth < minDepth) minDepth = depth + // 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 msgID of currentItemRoots[item]) { + toDeleteFromItemRoots.set(msgID, item) + } } } - return maxDepth - minDepth - } - //#endregion - //#region public methods - /** - * @param {string} id - * @param {CB} cb - */ - function load(id, cb) { - assertDBPlugin(peer) - accountID = id - loadPromise = new Promise((resolve, reject) => { - for (const rec of peer.db.records()) { - if (!rec.msg) continue - maybeLearnAboutSet(rec.id, rec.msg) + const data = { add: [value], del: [], supersedes } + peer.db.feed.publish({ account: accountID, domain, data }, (err, rec) => { + // prettier-ignore + if (err) return cb(new Error(`Failed to create msg when adding to Set (${id}/${subdomain})`, { cause: err })) + for (const [msgID, item] of toDeleteFromItemRoots) { + itemRoots.del(subdomain, item, msgID) } - cancelOnRecordAdded = peer.db.onRecordAdded( - (/** @type {RecPresent} */ rec) => { - try { - maybeLearnAboutSet(rec.id, rec.msg) - } catch (err) { - console.error(err) - } - } - ) - resolve() - cb() + // @ts-ignore + cb(null, true) }) - } + }) + } - /** - * @param {string} id - * @param {string} subdomain - * @param {string} value - * @param {CB} cb - */ - function add(id, subdomain, value, cb) { - assertDBPlugin(peer) + /** + * @param {string} id + * @param {string} subdomain + * @param {string} value + * @param {CB} cb + */ + function del(id, subdomain, value, cb) { + assertDBPlugin(peer) + assert(!!accountID, 'Cannot add to Set before loading') + // prettier-ignore + if (id !== accountID) return cb(new Error(`Cannot delete from another user's Set (${id}/${subdomain})`)) + + loaded(() => { assert(!!accountID, 'Cannot add to Set before loading') - // prettier-ignore - if (id !== accountID) return cb(new Error(`Cannot add to another user's Set (${id}/${subdomain})`)) + const currentSet = readSet(id, subdomain) + if (!currentSet.has(value)) return cb(null, false) + const domain = fromSubdomain(subdomain) - loaded(() => { - assert(!!accountID, 'Cannot add to Set before loading') - const currentSet = readSet(id, subdomain) - if (currentSet.has(value)) return cb(null, false) - const domain = fromSubdomain(subdomain) - - // Populate supersedes - const supersedes = [] - const toDeleteFromItemRoots = new Map() - const currentItemRoots = itemRoots.getAll(subdomain) - 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 msgID of currentItemRoots[item]) { - toDeleteFromItemRoots.set(msgID, item) - } - } - } - - const data = { add: [value], del: [], supersedes } - peer.db.feed.publish( - { account: accountID, domain, data }, - (err, rec) => { - // prettier-ignore - if (err) return cb(new Error(`Failed to create msg when adding to Set (${id}/${subdomain})`, { cause: err })) - for (const [msgID, item] of toDeleteFromItemRoots) { - itemRoots.del(subdomain, item, msgID) - } - // @ts-ignore - cb(null, true) - } - ) - }) - } - - /** - * @param {string} id - * @param {string} subdomain - * @param {string} value - * @param {CB} cb - */ - function del(id, subdomain, value, cb) { - assertDBPlugin(peer) - assert(!!accountID, 'Cannot add to Set before loading') - // prettier-ignore - if (id !== accountID) return cb(new Error(`Cannot delete from another user's Set (${id}/${subdomain})`)) - - loaded(() => { - assert(!!accountID, 'Cannot add to Set before loading') - const currentSet = readSet(id, subdomain) - if (!currentSet.has(value)) return cb(null, false) - const domain = fromSubdomain(subdomain) - - // Populate supersedes - const supersedes = [] - const currentItemRoots = itemRoots.getAll(subdomain) - for (const item in currentItemRoots) { - if (item === value || !currentSet.has(item)) { - supersedes.push(...currentItemRoots[item]) - } - } - - const data = { add: [], del: [value], supersedes } - peer.db.feed.publish( - { account: accountID, domain, data }, - (err, rec) => { - // prettier-ignore - if (err) return cb(new Error(`Failed to create msg when deleting from Set (${id}/${subdomain})`, { cause: err })) - // @ts-ignore - cb(null, true) - } - ) - }) - } - - /** - * @param {string} id - * @param {string} subdomain - * @param {any} value - */ - function has(id, subdomain, value) { - const set = readSet(id, subdomain) - return set.has(value) - } - - /** - * @param {string} id - * @param {string} subdomain - */ - function values(id, subdomain) { - const set = readSet(id, subdomain) - return [...set] - } - - /** - * @param {string} id - * @param {any} subdomain - */ - function getItemRoots(id, subdomain) { - // prettier-ignore - if (id !== accountID) throw new Error(`Cannot getItemRoots of another user's Set. (${id}/${subdomain})`) - return itemRoots.getAll(subdomain) - } - - /** - * @param {string} id - * @param {string} subdomain - * @param {CB} cb - */ - function squeeze(id, subdomain, cb) { - assertDBPlugin(peer) - assert(!!accountID, 'Cannot squeeze Set before loading') - // prettier-ignore - if (id !== accountID) return cb(new Error(`Cannot squeeze another user's Set (${id}/${subdomain})`)) - - const potential = _squeezePotential(subdomain) - if (potential < 1) return cb(null, false) - - loaded(() => { - assert(!!accountID, 'Cannot squeeze Set before loading') - const domain = fromSubdomain(subdomain) - const currentSet = readSet(id, subdomain) - - const supersedes = [] - const currentItemRoots = itemRoots.getAll(subdomain) - for (const item in currentItemRoots) { + // Populate supersedes + const supersedes = [] + const currentItemRoots = itemRoots.getAll(subdomain) + for (const item in currentItemRoots) { + if (item === value || !currentSet.has(item)) { supersedes.push(...currentItemRoots[item]) } + } - const data = { add: [...currentSet], del: [], supersedes } - peer.db.feed.publish( - { account: accountID, domain, data }, - (err, rec) => { - // prettier-ignore - if (err) return cb(new Error(`Failed to create msg when squeezing Set (${id}/${subdomain})`, { cause: err })) - // @ts-ignore - cb(null, true) - } - ) + const data = { add: [], del: [value], supersedes } + peer.db.feed.publish({ account: accountID, domain, data }, (err, rec) => { + // prettier-ignore + if (err) return cb(new Error(`Failed to create msg when deleting from Set (${id}/${subdomain})`, { cause: err })) + // @ts-ignore + cb(null, true) }) - } - //#endregion + }) + } - return { - load, - add, - del, - has, - values, - getItemRoots, - squeeze, + /** + * @param {string} id + * @param {string} subdomain + * @param {any} value + */ + function has(id, subdomain, value) { + const set = readSet(id, subdomain) + return set.has(value) + } - _squeezePotential, - } - }, + /** + * @param {string} id + * @param {string} subdomain + */ + function values(id, subdomain) { + const set = readSet(id, subdomain) + return [...set] + } + + /** + * @param {string} id + * @param {any} subdomain + */ + function getItemRoots(id, subdomain) { + // prettier-ignore + if (id !== accountID) throw new Error(`Cannot getItemRoots of another user's Set. (${id}/${subdomain})`) + return itemRoots.getAll(subdomain) + } + + /** + * @param {string} id + * @param {string} subdomain + * @param {CB} cb + */ + function squeeze(id, subdomain, cb) { + assertDBPlugin(peer) + assert(!!accountID, 'Cannot squeeze Set before loading') + // prettier-ignore + if (id !== accountID) return cb(new Error(`Cannot squeeze another user's Set (${id}/${subdomain})`)) + + const potential = _squeezePotential(subdomain) + if (potential < 1) return cb(null, false) + + loaded(() => { + assert(!!accountID, 'Cannot squeeze Set before loading') + const domain = fromSubdomain(subdomain) + const currentSet = readSet(id, subdomain) + + const supersedes = [] + const currentItemRoots = itemRoots.getAll(subdomain) + for (const item in currentItemRoots) { + supersedes.push(...currentItemRoots[item]) + } + + const data = { add: [...currentSet], del: [], supersedes } + peer.db.feed.publish({ account: accountID, domain, data }, (err, rec) => { + // prettier-ignore + if (err) return cb(new Error(`Failed to create msg when squeezing Set (${id}/${subdomain})`, { cause: err })) + // @ts-ignore + cb(null, true) + }) + }) + } + //#endregion + + return { + load, + add, + del, + has, + values, + getItemRoots, + squeeze, + + _squeezePotential, + } } + +exports.name = 'set' +exports.init = initSet