update to use msg-v3

This commit is contained in:
Andre Staltz 2023-09-14 13:56:55 +03:00
parent ccf1b73cdd
commit 69f5ce52c3
No known key found for this signature in database
GPG Key ID: 9EDE23EA7E8A4890
6 changed files with 468 additions and 260 deletions

View File

@ -1,79 +1,149 @@
const FeedV1 = require('ppppp-db/feed-v1') const MsgV3 = require('ppppp-db/msg-v3')
const PREFIX = 'set_v1__' const PREFIX = 'set_v1__'
/** @typedef {string} Subtype */ /**
* @typedef {ReturnType<import('ppppp-db').init>} PPPPPDB
/** @typedef {string} MsgHash */ * @typedef {import('ppppp-db').RecPresent} RecPresent
* @typedef {{
/** @typedef {`${Subtype}.${string}`} SubtypeItem */ * hook: (
* cb: (
* this: any,
* fn: (this: any, ...a: Array<any>) => any,
* args: Array<any>
* ) => void
* ) => void
* }} ClosableHook
* @typedef {string} Subdomain
* @typedef {string} MsgID
* @typedef {`${Subdomain}/${string}`} SubdomainItem
* @typedef {{
* add: Array<string>,
* del: Array<string>,
* supersedes: Array<MsgID>,
* }} SetData
*/
/** /**
* @param {string} type * @template [T = any]
* @returns {Subtype} * @typedef {import('ppppp-db/msg-v3').Msg<T>} Msg<T>
*/ */
function toSubtype(type) {
return type.slice(PREFIX.length) /**
* @template T
* @typedef {T extends void ?
* (...args: [Error] | []) => void :
* (...args: [Error] | [null, T]) => void
* } CB
*/
/**
* @param {string} domain
* @returns {Subdomain}
*/
function toSubdomain(domain) {
return domain.slice(PREFIX.length)
} }
/** /**
* @param {Subtype} subtype * @param {Subdomain} subdomain
* @returns {string} * @returns {string}
*/ */
function fromSubtype(subtype) { function fromSubdomain(subdomain) {
return PREFIX + subtype return PREFIX + subdomain
}
/**
* @param {{
* db: PPPPPDB | null,
* close: ClosableHook,
* }} peer
* @returns {asserts peer is { db: PPPPPDB, close: ClosableHook }}
*/
function assertDBExists(peer) {
if (!peer.db) throw new Error('record plugin requires ppppp-db plugin')
}
/**
* @param {unknown} check
* @param {string} message
* @returns {asserts check}
*/
function assert(check, message) {
if (!check) throw new Error(message)
} }
module.exports = { module.exports = {
name: 'set', name: 'set',
manifest: { 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() * @param {{ db: PPPPPDB | null, close: ClosableHook }} peer
* @param {any} config
*/
init(peer, config) {
assertDBExists(peer)
//#region state
let accountID = /** @type {string | null} */ (null)
let loadPromise = /** @type {Promise<void> | null} */ (null)
let cancelOnRecordAdded = /** @type {CallableFunction | null} */ (null)
const tangles = /** @type {Map<Subdomain, MsgV3.Tangle>} */ (new Map())
const itemRoots = { const itemRoots = {
/** @type {Map<SubtypeItem, Set<MsgHash>} */ _map: /** @type {Map<SubdomainItem, Set<MsgID>>} */ (new Map()),
_map: new Map(), /**
_getKey(subtype, item) { * @param {string} subdomain
return subtype + '/' + item * @param {string} item
* @returns {SubdomainItem}
*/
_getKey(subdomain, item) {
return `${subdomain}/${item}`
}, },
get(subtype, item = null) { /**
if (item) { * @param {string} subdomain
const key = this._getKey(subtype, item) * @returns {Record<string, Array<MsgID>>}
return this._map.get(key) */
} else { getAll(subdomain) {
const out = {} const out = /** @type {Record<string, Array<MsgID>>} */ ({})
for (const [key, value] of this._map.entries()) { for (const [key, value] of this._map.entries()) {
if (key.startsWith(subtype + '/')) { if (key.startsWith(subdomain + '/')) {
const item = key.slice(subtype.length + 1) const item = key.slice(subdomain.length + 1)
out[item] = [...value] out[item] = [...value]
} }
} }
return out return out
}
}, },
add(subtype, item, msgHash) { /**
const key = this._getKey(subtype, item) * @param {string} subdomain
* @param {string} item
* @returns {Set<MsgID> | 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() const set = this._map.get(key) ?? new Set()
set.add(msgHash) set.add(msgID)
return this._map.set(key, set) return this._map.set(key, set)
}, },
del(subtype, item, msgHash) { /**
const key = this._getKey(subtype, item) * @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) const set = this._map.get(key)
if (!set) return false if (!set) return false
set.delete(msgHash) set.delete(msgID)
if (set.size === 0) this._map.delete(key) if (set.size === 0) this._map.delete(key)
return true return true
}, },
@ -84,53 +154,58 @@ module.exports = {
//#endregion //#endregion
//#region active processes //#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) { peer.close.hook(function (fn, args) {
cancelListeningToRecordAdded() cancelOnRecordAdded?.()
fn.apply(this, args) fn.apply(this, args)
}) })
//#endregion //#endregion
//#region internal methods //#region internal methods
function isValidSetRootMsg(msg) { /**
* @private
* @param {Msg | null | undefined} msg
* @returns {msg is Msg}
*/
function isValidSetMoot(msg) {
if (!msg) return false if (!msg) return false
if (msg.metadata.who !== myWho) return false if (msg.metadata.account !== accountID) return false
const type = msg.metadata.type const domain = msg.metadata.domain
if (!type.startsWith(PREFIX)) return false if (!domain.startsWith(PREFIX)) return false
return FeedV1.isFeedRoot(msg, config.keys.id, type) return MsgV3.isMoot(msg, accountID, domain)
} }
/**
* @private
* @param {Msg | null | undefined} msg
* @returns {msg is Msg<SetData>}
*/
function isValidSetMsg(msg) { function isValidSetMsg(msg) {
if (!msg) return false if (!msg) return false
if (!msg.content) return false if (!msg.data) return false
if (msg.metadata.who !== myWho) return false if (msg.metadata.account !== accountID) return false
if (!msg.metadata.type.startsWith(PREFIX)) return false if (!msg.metadata.domain.startsWith(PREFIX)) return false
if (!Array.isArray(msg.content.add)) return false if (!Array.isArray(msg.data.add)) return false
if (!Array.isArray(msg.content.del)) return false if (!Array.isArray(msg.data.del)) return false
if (!Array.isArray(msg.content.supersedes)) return false if (!Array.isArray(msg.data.supersedes)) return false
return true return true
} }
function readSet(authorId, subtype) { /**
const type = fromSubtype(subtype) * @param {string} id
const rootHash = FeedV1.getFeedRootHash(authorId, type) * @param {string} subdomain
const tangle = peer.db.getTangle(rootHash) */
if (!tangle || tangle.size() === 0) return new Set() function readSet(id, subdomain) {
const msgHashes = tangle.topoSort() assertDBExists(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() const set = new Set()
for (const msgHash of msgHashes) { for (const msgID of msgIDs) {
const msg = peer.db.get(msgHash) const msg = peer.db.get(msgID)
if (isValidSetMsg(msg)) { if (isValidSetMsg(msg)) {
const { add, del } = msg.content const { add, del } = msg.data
for (const value of add) set.add(value) for (const value of add) set.add(value)
for (const value of del) set.delete(value) for (const value of del) set.delete(value)
} }
@ -138,67 +213,88 @@ module.exports = {
return set return set
} }
function learnSetRoot(hash, msg) { /**
const { type } = msg.metadata * @param {string} mootID
const subtype = toSubtype(type) * @param {Msg} moot
const tangle = tangles.get(subtype) ?? new FeedV1.Tangle(hash) */
tangle.add(hash, msg) function learnSetMoot(mootID, moot) {
tangles.set(subtype, tangle) 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)
} }
function learnSetUpdate(hash, msg) { /**
const { who, type } = msg.metadata * @param {string} msgID
const rootHash = FeedV1.getFeedRootHash(who, type) * @param {Msg<SetData>} msg
const subtype = toSubtype(type) */
const tangle = tangles.get(subtype) ?? new FeedV1.Tangle(rootHash) function learnSetUpdate(msgID, msg) {
tangle.add(hash, msg) const { account, domain } = msg.metadata
tangles.set(subtype, tangle) const mootID = MsgV3.getMootID(account, domain)
const addOrRemove = [].concat(msg.content.add, msg.content.del) const subdomain = toSubdomain(domain)
for (const item of addOrRemove) { const tangle = tangles.get(subdomain) ?? new MsgV3.Tangle(mootID)
const existing = itemRoots.get(subtype, item) 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) { if (!existing || existing.size === 0) {
itemRoots.add(subtype, item, hash) itemRoots.add(subdomain, item, msgID)
} else { } else {
for (const existingHash of existing) { for (const existingID of existing) {
if (tangle.precedes(existingHash, hash)) { if (tangle.precedes(existingID, msgID)) {
itemRoots.del(subtype, item, existingHash) itemRoots.del(subdomain, item, existingID)
itemRoots.add(subtype, item, hash) itemRoots.add(subdomain, item, msgID)
} else { } else {
itemRoots.add(subtype, item, hash) itemRoots.add(subdomain, item, msgID)
} }
} }
} }
} }
} }
function maybeLearnAboutSet(hash, msg) { /**
if (msg.metadata.who !== myWho) return * @param {string} msgID
if (isValidSetRootMsg(msg)) { * @param {Msg} msg
learnSetRoot(hash, msg) */
function maybeLearnAboutSet(msgID, msg) {
if (msg.metadata.account !== accountID) return
if (isValidSetMoot(msg)) {
learnSetMoot(msgID, msg)
return return
} }
if (isValidSetMsg(msg)) { if (isValidSetMsg(msg)) {
learnSetUpdate(hash, msg) learnSetUpdate(msgID, msg)
return return
} }
} }
/**
* @private
* @param {CB<void>} cb
*/
function loaded(cb) { function loaded(cb) {
if (cb === void 0) return loadPromise if (cb === void 0) return loadPromise
else loadPromise.then(() => cb(null), cb) else loadPromise?.then(() => cb(), cb)
} }
function _squeezePotential(subtype) { /**
* @param {string} subdomain
*/
function _squeezePotential(subdomain) {
assertDBExists(peer)
if (!accountID) throw new Error('Cannot squeeze potential before loading')
// TODO: improve this so that the squeezePotential is the size of the // TODO: improve this so that the squeezePotential is the size of the
// tangle suffix built as a slice from the fieldRoots // tangle suffix built as a slice from the fieldRoots
const rootHash = FeedV1.getFeedRootHash(myWho, fromSubtype(subtype)) const mootID = MsgV3.getMootID(accountID, fromSubdomain(subdomain))
const tangle = peer.db.getTangle(rootHash) const tangle = peer.db.getTangle(mootID)
const maxDepth = tangle.getMaxDepth() const maxDepth = tangle.maxDepth
const currentItemRoots = itemRoots.get(subtype) const currentItemRoots = itemRoots.getAll(subdomain)
let minDepth = Infinity let minDepth = Infinity
for (const item in currentItemRoots) { for (const item in currentItemRoots) {
for (const msgHash of currentItemRoots[item]) { for (const msgID of currentItemRoots[item]) {
const depth = tangle.getDepth(msgHash) const depth = tangle.getDepth(msgID)
if (depth < minDepth) minDepth = depth if (depth < minDepth) minDepth = depth
} }
} }
@ -207,117 +303,191 @@ module.exports = {
//#endregion //#endregion
//#region public methods //#region public methods
function add(authorId, subtype, value, cb) { /**
const who = FeedV1.stripAuthor(authorId) * @param {string} id
* @param {CB<void>} cb
*/
function load(id, cb) {
assertDBExists(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<boolean>} cb
*/
function add(id, subdomain, value, cb) {
assertDBExists(peer)
assert(!!accountID, 'Cannot add to Set before loading')
// prettier-ignore // prettier-ignore
if (who !== myWho) return cb(new Error(`Cannot add to another user's Set (${authorId}/${subtype})`)) if (id !== accountID) return cb(new Error(`Cannot add to another user's Set (${id}/${subdomain})`))
loaded(() => { loaded(() => {
const currentSet = readSet(authorId, subtype) assert(!!accountID, 'Cannot add to Set before loading')
const currentSet = readSet(id, subdomain)
if (currentSet.has(value)) return cb(null, false) if (currentSet.has(value)) return cb(null, false)
const type = fromSubtype(subtype) const domain = fromSubdomain(subdomain)
// Populate supersedes // Populate supersedes
const supersedes = [] const supersedes = []
const toDeleteFromItemRoots = new Map() const toDeleteFromItemRoots = new Map()
const currentItemRoots = itemRoots.get(subtype) const currentItemRoots = itemRoots.getAll(subdomain)
for (const item in currentItemRoots) { for (const item in currentItemRoots) {
// If we are re-adding this item, OR if this item has been deleted, // If we are re-adding this item, OR if this item has been deleted,
// then we should update roots // then we should update roots
if (item === value || !currentSet.has(item)) { if (item === value || !currentSet.has(item)) {
supersedes.push(...currentItemRoots[item]) supersedes.push(...currentItemRoots[item])
for (const msgHash of currentItemRoots[item]) { for (const msgID of currentItemRoots[item]) {
toDeleteFromItemRoots.set(msgHash, item) toDeleteFromItemRoots.set(msgID, item)
} }
} }
} }
const content = { add: [value], del: [], supersedes } const data = { add: [value], del: [], supersedes }
peer.db.create({ type, content }, (err) => { peer.db.feed.publish(
{ account: accountID, domain, data },
(err, rec) => {
// prettier-ignore // prettier-ignore
if (err) return cb(new Error(`Failed to create msg when adding to Set (${authorId}/${subtype})`, { cause: err })) if (err) return cb(new Error(`Failed to create msg when adding to Set (${id}/${subdomain})`, { cause: err }))
for (const [msgHash, item] of toDeleteFromItemRoots) { for (const [msgID, item] of toDeleteFromItemRoots) {
itemRoots.del(subtype, item, msgHash) itemRoots.del(subdomain, item, msgID)
} }
// @ts-ignore
cb(null, true) cb(null, true)
}) }
)
}) })
} }
function del(authorId, subtype, value, cb) { /**
const who = FeedV1.stripAuthor(authorId) * @param {string} id
* @param {string} subdomain
* @param {string} value
* @param {CB<boolean>} cb
*/
function del(id, subdomain, value, cb) {
assertDBExists(peer)
assert(!!accountID, 'Cannot add to Set before loading')
// prettier-ignore // prettier-ignore
if (who !== myWho) return cb(new Error(`Cannot delete from another user's Set (${authorId}/${subtype})`)) if (id !== accountID) return cb(new Error(`Cannot delete from another user's Set (${id}/${subdomain})`))
loaded(() => { loaded(() => {
const currentSet = readSet(authorId, subtype) assert(!!accountID, 'Cannot add to Set before loading')
const currentSet = readSet(id, subdomain)
if (!currentSet.has(value)) return cb(null, false) if (!currentSet.has(value)) return cb(null, false)
const type = fromSubtype(subtype) const domain = fromSubdomain(subdomain)
// Populate supersedes // Populate supersedes
const supersedes = [] const supersedes = []
const currentItemRoots = itemRoots.get(subtype) const currentItemRoots = itemRoots.getAll(subdomain)
for (const item in currentItemRoots) { for (const item in currentItemRoots) {
if (item === value || !currentSet.has(item)) { if (item === value || !currentSet.has(item)) {
supersedes.push(...currentItemRoots[item]) supersedes.push(...currentItemRoots[item])
} }
} }
const content = { add: [], del: [value], supersedes } const data = { add: [], del: [value], supersedes }
peer.db.create({ type, content }, (err) => { peer.db.feed.publish(
{ account: accountID, domain, data },
(err, rec) => {
// prettier-ignore // prettier-ignore
if (err) return cb(new Error(`Failed to create msg when deleting from Set (${authorId}/${subtype})`, { cause: err })) if (err) return cb(new Error(`Failed to create msg when deleting from Set (${id}/${subdomain})`, { cause: err }))
// @ts-ignore
cb(null, true) cb(null, true)
}) }
)
}) })
} }
function has(authorId, subtype, value) { /**
const set = readSet(authorId, subtype) * @param {string} id
* @param {string} subdomain
* @param {any} value
*/
function has(id, subdomain, value) {
const set = readSet(id, subdomain)
return set.has(value) return set.has(value)
} }
function values(authorId, subtype) { /**
const set = readSet(authorId, subtype) * @param {string} id
* @param {string} subdomain
*/
function values(id, subdomain) {
const set = readSet(id, subdomain)
return [...set] return [...set]
} }
function getItemRoots(authorId, subtype) { /**
const who = FeedV1.stripAuthor(authorId) * @param {string} id
* @param {any} subdomain
*/
function getItemRoots(id, subdomain) {
// prettier-ignore // prettier-ignore
if (who !== myWho) return cb(new Error(`Cannot getItemRoots of another user's Set. (${authorId}/${subtype})`)) if (id !== accountID) throw new Error(`Cannot getItemRoots of another user's Set. (${id}/${subdomain})`)
return itemRoots.get(subtype) return itemRoots.getAll(subdomain)
} }
function squeeze(authorId, subtype, cb) { /**
const who = FeedV1.stripAuthor(authorId) * @param {string} id
* @param {string} subdomain
* @param {CB<boolean>} cb
*/
function squeeze(id, subdomain, cb) {
assertDBExists(peer)
assert(!!accountID, 'Cannot squeeze Set before loading')
// prettier-ignore // prettier-ignore
if (who !== myWho) return cb(new Error(`Cannot squeeze another user's Set (${authorId}/${subtype})`)) if (id !== accountID) return cb(new Error(`Cannot squeeze another user's Set (${id}/${subdomain})`))
const potential = _squeezePotential(subtype) const potential = _squeezePotential(subdomain)
if (potential < 1) return cb(null, false) if (potential < 1) return cb(null, false)
loaded(() => { loaded(() => {
const type = fromSubtype(subtype) assert(!!accountID, 'Cannot squeeze Set before loading')
const currentSet = readSet(authorId, subtype) const domain = fromSubdomain(subdomain)
const currentSet = readSet(id, subdomain)
const supersedes = [] const supersedes = []
const currentItemRoots = itemRoots.get(subtype) const currentItemRoots = itemRoots.getAll(subdomain)
for (const item in currentItemRoots) { for (const item in currentItemRoots) {
supersedes.push(...currentItemRoots[item]) supersedes.push(...currentItemRoots[item])
} }
const content = { add: [...currentSet], del: [], supersedes } const data = { add: [...currentSet], del: [], supersedes }
peer.db.create({ type, content }, (err) => { peer.db.feed.publish(
{ account: accountID, domain, data },
(err, rec) => {
// prettier-ignore // prettier-ignore
if (err) return cb(new Error(`Failed to create msg when squeezing Set (${authorId}/${subtype})`, { cause: err })) if (err) return cb(new Error(`Failed to create msg when squeezing Set (${id}/${subdomain})`, { cause: err }))
// @ts-ignore
cb(null, true) cb(null, true)
}) }
)
}) })
} }
//#endregion //#endregion
return { return {
load,
add, add,
del, del,
has, has,

View File

@ -28,19 +28,20 @@
"devDependencies": { "devDependencies": {
"bs58": "^5.0.0", "bs58": "^5.0.0",
"c8": "7", "c8": "7",
"ppppp-db": "github:staltz/ppppp-db#rev1", "ppppp-db": "github:staltz/ppppp-db",
"ppppp-caps": "github:staltz/ppppp-caps",
"ppppp-keypair": "github:staltz/ppppp-keypair",
"rimraf": "^4.4.0", "rimraf": "^4.4.0",
"secret-stack": "^6.4.1", "secret-stack": "~7.1.0",
"secret-handshake-ext": "^0.0.8",
"ssb-box": "^1.0.1", "ssb-box": "^1.0.1",
"ssb-caps": "^1.1.0", "typescript": "^5.1.3"
"ssb-classic": "^1.1.0",
"ssb-keys": "^8.5.0",
"ssb-uri2": "^2.4.1",
"tap-arc": "^0.3.5",
"tape": "^5.6.3"
}, },
"scripts": { "scripts": {
"test": "tape test/*.js | tap-arc --bail", "clean-check": "tsc --build --clean",
"prepublishOnly": "npm run clean-check && tsc --build",
"postpublish": "npm run clean-check",
"test": "npm run clean-check && node --test",
"format-code": "prettier --write \"(lib|test)/**/*.js\"", "format-code": "prettier --write \"(lib|test)/**/*.js\"",
"format-code-staged": "pretty-quick --staged --pattern \"(lib|test)/**/*.js\"", "format-code-staged": "pretty-quick --staged --pattern \"(lib|test)/**/*.js\"",
"coverage": "c8 --reporter=lcov npm run test" "coverage": "c8 --reporter=lcov npm run test"

View File

@ -30,10 +30,10 @@ Reducing the tangle above in a topological sort allows you to build an array
`msg.content` format: `msg.content` format:
```typescript ```typescript
interface MsgContent { interface MsgData {
add: Array<string>, add: Array<string>,
del: Array<string>, del: Array<string>,
supersedes: Array<MsgHash>, supersedes: Array<MsgID>,
} }
``` ```

View File

@ -1,103 +1,101 @@
const test = require('tape') const test = require('node:test')
const path = require('path') const assert = require('node:assert')
const os = require('os') const path = require('node:path')
const os = require('node:os')
const rimraf = require('rimraf') const rimraf = require('rimraf')
const SecretStack = require('secret-stack') const p = require('node:util').promisify
const FeedV1 = require('ppppp-db/feed-v1') const { createPeer } = require('./util')
const caps = require('ssb-caps') const Keypair = require('ppppp-keypair')
const p = require('util').promisify
const { generateKeypair } = require('./util')
const DIR = path.join(os.tmpdir(), 'ppppp-set') const DIR = path.join(os.tmpdir(), 'ppppp-set')
rimraf.sync(DIR) rimraf.sync(DIR)
const aliceKeys = generateKeypair('alice') const aliceKeypair = Keypair.generate('ed25519', 'alice')
const who = aliceKeys.id
let peer let peer
let aliceID
test('setup', async (t) => { test('setup', async (t) => {
peer = SecretStack({ appKey: caps.shs }) peer = createPeer({ keypair: aliceKeypair, path: DIR })
.use(require('ppppp-db'))
.use(require('ssb-box'))
.use(require('../lib'))
.call(null, {
keys: aliceKeys,
path: DIR,
})
await peer.db.loaded() await peer.db.loaded()
aliceID = await p(peer.db.account.create)({
domain: 'account',
_nonce: 'alice',
})
await p(peer.set.load)(aliceID)
}) })
function lastMsgHash() { function lastMsgID() {
let last let last
for (const item of peer.db.records()) { for (const item of peer.db.records()) {
last = item last = item
} }
return last.hash return last.id
} }
let add1, add2, del1, add3, del2 let add1, add2, del1, add3, del2
test('Set add(), del(), has()', async (t) => { test('Set add(), del(), has()', async (t) => {
// Add 1st // Add 1st
t.false(peer.set.has(who, 'follows', '1st'), 'doesnt have 1st') assert.equal(peer.set.has(aliceID, 'follows', '1st'), false, 'doesnt have 1st')
t.ok(await p(peer.set.add)(who, 'follows', '1st'), 'add 1st') assert(await p(peer.set.add)(aliceID, 'follows', '1st'), 'add 1st')
t.true(peer.set.has(who, 'follows', '1st'), 'has 1st') assert.equal(peer.set.has(aliceID, 'follows', '1st'), true, 'has 1st')
add1 = lastMsgHash() add1 = lastMsgID()
t.deepEquals( assert.deepEqual(
peer.set.getItemRoots(who, 'follows'), peer.set.getItemRoots(aliceID, 'follows'),
{ '1st': [add1] }, { '1st': [add1] },
'itemRoots' 'itemRoots'
) )
// Add 2nd // Add 2nd
t.false(peer.set.has(who, 'follows', '2nd'), 'doesnt have 2nd') assert.equal(peer.set.has(aliceID, 'follows', '2nd'), false, 'doesnt have 2nd')
t.ok(await p(peer.set.add)(who, 'follows', '2nd'), 'add 2nd') assert(await p(peer.set.add)(aliceID, 'follows', '2nd'), 'add 2nd')
t.true(peer.set.has(who, 'follows', '2nd'), 'has 2nd') assert.equal(peer.set.has(aliceID, 'follows', '2nd'), true, 'has 2nd')
add2 = lastMsgHash() add2 = lastMsgID()
t.deepEquals( assert.deepEqual(
peer.set.getItemRoots(who, 'follows'), peer.set.getItemRoots(aliceID, 'follows'),
{ '1st': [add1], '2nd': [add2] }, { '1st': [add1], '2nd': [add2] },
'itemRoots' 'itemRoots'
) )
// Del 1st // Del 1st
t.true(peer.set.has(who, 'follows', '1st'), 'has 1st') assert.equal(peer.set.has(aliceID, 'follows', '1st'), true, 'has 1st')
t.ok(await p(peer.set.del)(who, 'follows', '1st'), 'del 1st') assert(await p(peer.set.del)(aliceID, 'follows', '1st'), 'del 1st')
t.false(peer.set.has(who, 'follows', '1st'), 'doesnt have 1st') assert.equal(peer.set.has(aliceID, 'follows', '1st'), false, 'doesnt have 1st')
del1 = lastMsgHash() del1 = lastMsgID()
t.deepEquals( assert.deepEqual(
peer.set.getItemRoots(who, 'follows'), peer.set.getItemRoots(aliceID, 'follows'),
{ '1st': [del1], '2nd': [add2] }, { '1st': [del1], '2nd': [add2] },
'itemRoots' 'itemRoots'
) )
// Add 3rd // Add 3rd
t.false(peer.set.has(who, 'follows', '3rd'), 'doesnt have 3rd') assert.equal(peer.set.has(aliceID, 'follows', '3rd'), false, 'doesnt have 3rd')
t.ok(await p(peer.set.add)(who, 'follows', '3rd'), 'add 3rd') assert(await p(peer.set.add)(aliceID, 'follows', '3rd'), 'add 3rd')
t.true(peer.set.has(who, 'follows', '3rd'), 'has 3rd') assert.equal(peer.set.has(aliceID, 'follows', '3rd'), true, 'has 3rd')
add3 = lastMsgHash() add3 = lastMsgID()
t.deepEquals( assert.deepEqual(
peer.set.getItemRoots(who, 'follows'), peer.set.getItemRoots(aliceID, 'follows'),
{ '3rd': [add3], '2nd': [add2] }, { '3rd': [add3], '2nd': [add2] },
'itemRoots' 'itemRoots'
) )
// Del 2nd // Del 2nd
t.true(peer.set.has(who, 'follows', '2nd'), 'has 2nd') assert.equal(peer.set.has(aliceID, 'follows', '2nd'), true, 'has 2nd')
t.ok(await p(peer.set.del)(who, 'follows', '2nd'), 'del 2nd') // msg seq 4 assert(await p(peer.set.del)(aliceID, 'follows', '2nd'), 'del 2nd') // msg seq 4
t.false(peer.set.has(who, 'follows', '2nd'), 'doesnt have 2nd') assert.equal(peer.set.has(aliceID, 'follows', '2nd'), false, 'doesnt have 2nd')
del2 = lastMsgHash() del2 = lastMsgID()
t.deepEquals( assert.deepEqual(
peer.set.getItemRoots(who, 'follows'), peer.set.getItemRoots(aliceID, 'follows'),
{ '3rd': [add3], '2nd': [del2] }, { '3rd': [add3], '2nd': [del2] },
'itemRoots' 'itemRoots'
) )
// Del 2nd (idempotent) // Del 2nd (idempotent)
t.notOk(await p(peer.set.del)(who, 'follows', '2nd'), 'del 2nd idempotent') assert.equal(await p(peer.set.del)(aliceID, 'follows', '2nd'), false, 'del 2nd idempotent')
t.false(peer.set.has(who, 'follows', '2nd'), 'doesnt have 2nd') assert.equal(peer.set.has(aliceID, 'follows', '2nd'), false, 'doesnt have 2nd')
t.deepEquals( assert.deepEqual(
peer.set.getItemRoots(who, 'follows'), peer.set.getItemRoots(aliceID, 'follows'),
{ '3rd': [add3], '2nd': [del2] }, { '3rd': [add3], '2nd': [del2] },
'itemRoots' 'itemRoots'
) )
@ -105,44 +103,44 @@ test('Set add(), del(), has()', async (t) => {
let add4, add5 let add4, add5
test('Set values()', async (t) => { test('Set values()', async (t) => {
t.ok(await p(peer.set.add)(who, 'follows', '4th'), 'add 4th') assert(await p(peer.set.add)(aliceID, 'follows', '4th'), 'add 4th')
add4 = lastMsgHash() add4 = lastMsgID()
t.ok(await p(peer.set.add)(who, 'follows', '5th'), 'add 5th') assert(await p(peer.set.add)(aliceID, 'follows', '5th'), 'add 5th')
add5 = lastMsgHash() add5 = lastMsgID()
const expected = new Set(['3rd', '4th', '5th']) const expected = new Set(['3rd', '4th', '5th'])
for (const item of peer.set.values(who, 'follows')) { for (const item of peer.set.values(aliceID, 'follows')) {
t.true(expected.has(item), 'values() item') assert.equal(expected.has(item), true, 'values() item')
expected.delete(item) expected.delete(item)
} }
t.equals(expected.size, 0, 'all items') assert.equal(expected.size, 0, 'all items')
}) })
test('predsl Set squeeze', async (t) => { test('predsl Set squeeze', async (t) => {
t.deepEquals( assert.deepEqual(
peer.set.getItemRoots(who, 'follows'), peer.set.getItemRoots(aliceID, 'follows'),
{ '3rd': [add3], '4th': [add4], '5th': [add5] }, { '3rd': [add3], '4th': [add4], '5th': [add5] },
'itemRoots before squeeze' 'itemRoots before squeeze'
) )
t.equals(peer.set._squeezePotential('follows'), 3, 'squeezePotential=3') assert.equal(peer.set._squeezePotential('follows'), 3, 'squeezePotential=3')
t.true(await p(peer.set.squeeze)(who, 'follows'), 'squeezed') assert.equal(await p(peer.set.squeeze)(aliceID, 'follows'), true, 'squeezed')
const squeezed = lastMsgHash() const squeezed = lastMsgID()
t.equals(peer.set._squeezePotential('follows'), 0, 'squeezePotential=0') assert.equal(peer.set._squeezePotential('follows'), 0, 'squeezePotential=0')
t.deepEquals( assert.deepEqual(
peer.set.getItemRoots(who, 'follows'), peer.set.getItemRoots(aliceID, 'follows'),
{ '3rd': [squeezed], '4th': [squeezed], '5th': [squeezed] }, { '3rd': [squeezed], '4th': [squeezed], '5th': [squeezed] },
'itemRoots after squeeze' 'itemRoots after squeeze'
) )
t.false(await p(peer.set.squeeze)(who, 'follows'), 'squeeze again idempotent') assert.equal(await p(peer.set.squeeze)(aliceID, 'follows'), false, 'squeeze again idempotent')
const squeezed2 = lastMsgHash() const squeezed2 = lastMsgID()
t.equals(squeezed, squeezed2, 'squeezed msg hash is same') assert.equal(squeezed, squeezed2, 'squeezed msgID is same')
}) })
test('teardown', (t) => { test('teardown', async (t) => {
peer.close(t.end) await p(peer.close)(true)
}) })

View File

@ -1,14 +1,37 @@
const ssbKeys = require('ssb-keys') const os = require('node:os')
const SSBURI = require('ssb-uri2') const path = require('node:path')
const base58 = require('bs58') const rimraf = require('rimraf')
const caps = require('ppppp-caps')
const Keypair = require('ppppp-keypair')
function generateKeypair(seed) { function createPeer(opts) {
const keys = ssbKeys.generate('ed25519', seed, 'buttwoo-v1') if (opts.name) {
const { data } = SSBURI.decompose(keys.id) opts.path ??= path.join(os.tmpdir(), 'ppppp-set-' + opts.name)
keys.id = `ppppp:feed/v1/${base58.encode(Buffer.from(data, 'base64'))}` opts.keypair ??= Keypair.generate('ed25519', opts.name)
return keys opts.name = undefined
}
if (!opts.path) throw new Error('need opts.path in createPeer()')
if (!opts.keypair) throw new Error('need opts.keypair in createPeer()')
rimraf.sync(opts.path)
return require('secret-stack/bare')()
.use(require('secret-stack/plugins/net'))
.use(require('secret-handshake-ext/secret-stack'))
.use(require('ppppp-db'))
.use(require('ssb-box'))
.use(require('../lib'))
.call(null, {
caps,
connections: {
incoming: {
net: [{ scope: 'device', transform: 'shse', port: null }],
},
outgoing: {
net: [{ transform: 'shse' }],
},
},
...opts,
})
} }
module.exports = { module.exports = { createPeer }
generateKeypair,
}

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"include": ["lib/**/*.js"],
"exclude": ["coverage/", "node_modules/", "test/"],
"compilerOptions": {
"checkJs": true,
"declaration": true,
"emitDeclarationOnly": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"lib": ["es2022", "dom"],
"module": "node16",
"skipLibCheck": true,
"strict": true,
"target": "es2021"
}
}