mirror of https://codeberg.org/pzp/pzp-sync.git
init from dagsync
This commit is contained in:
commit
a473c8fec1
|
@ -0,0 +1,25 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [16.x, 18.x]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
- run: npm install
|
||||||
|
- run: npm test
|
|
@ -0,0 +1,9 @@
|
||||||
|
.vscode
|
||||||
|
node_modules
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
coverage
|
||||||
|
*~
|
||||||
|
|
||||||
|
# For misc scripts and experiments:
|
||||||
|
/gitignored
|
|
@ -0,0 +1,7 @@
|
||||||
|
# SPDX-FileCopyrightText: 2021 Anders Rune Jensen
|
||||||
|
# SPDX-FileCopyrightText: 2021 Andre 'Staltz' Medeiros
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Unlicense
|
||||||
|
|
||||||
|
semi: false
|
||||||
|
singleQuote: true
|
|
@ -0,0 +1,121 @@
|
||||||
|
Creative Commons Legal Code
|
||||||
|
|
||||||
|
CC0 1.0 Universal
|
||||||
|
|
||||||
|
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
||||||
|
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
|
||||||
|
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
||||||
|
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
||||||
|
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
|
||||||
|
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
|
||||||
|
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
|
||||||
|
HEREUNDER.
|
||||||
|
|
||||||
|
Statement of Purpose
|
||||||
|
|
||||||
|
The laws of most jurisdictions throughout the world automatically confer
|
||||||
|
exclusive Copyright and Related Rights (defined below) upon the creator
|
||||||
|
and subsequent owner(s) (each and all, an "owner") of an original work of
|
||||||
|
authorship and/or a database (each, a "Work").
|
||||||
|
|
||||||
|
Certain owners wish to permanently relinquish those rights to a Work for
|
||||||
|
the purpose of contributing to a commons of creative, cultural and
|
||||||
|
scientific works ("Commons") that the public can reliably and without fear
|
||||||
|
of later claims of infringement build upon, modify, incorporate in other
|
||||||
|
works, reuse and redistribute as freely as possible in any form whatsoever
|
||||||
|
and for any purposes, including without limitation commercial purposes.
|
||||||
|
These owners may contribute to the Commons to promote the ideal of a free
|
||||||
|
culture and the further production of creative, cultural and scientific
|
||||||
|
works, or to gain reputation or greater distribution for their Work in
|
||||||
|
part through the use and efforts of others.
|
||||||
|
|
||||||
|
For these and/or other purposes and motivations, and without any
|
||||||
|
expectation of additional consideration or compensation, the person
|
||||||
|
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
|
||||||
|
is an owner of Copyright and Related Rights in the Work, voluntarily
|
||||||
|
elects to apply CC0 to the Work and publicly distribute the Work under its
|
||||||
|
terms, with knowledge of his or her Copyright and Related Rights in the
|
||||||
|
Work and the meaning and intended legal effect of CC0 on those rights.
|
||||||
|
|
||||||
|
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||||
|
protected by copyright and related or neighboring rights ("Copyright and
|
||||||
|
Related Rights"). Copyright and Related Rights include, but are not
|
||||||
|
limited to, the following:
|
||||||
|
|
||||||
|
i. the right to reproduce, adapt, distribute, perform, display,
|
||||||
|
communicate, and translate a Work;
|
||||||
|
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||||
|
iii. publicity and privacy rights pertaining to a person's image or
|
||||||
|
likeness depicted in a Work;
|
||||||
|
iv. rights protecting against unfair competition in regards to a Work,
|
||||||
|
subject to the limitations in paragraph 4(a), below;
|
||||||
|
v. rights protecting the extraction, dissemination, use and reuse of data
|
||||||
|
in a Work;
|
||||||
|
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||||
|
European Parliament and of the Council of 11 March 1996 on the legal
|
||||||
|
protection of databases, and under any national implementation
|
||||||
|
thereof, including any amended or successor version of such
|
||||||
|
directive); and
|
||||||
|
vii. other similar, equivalent or corresponding rights throughout the
|
||||||
|
world based on applicable law or treaty, and any national
|
||||||
|
implementations thereof.
|
||||||
|
|
||||||
|
2. Waiver. To the greatest extent permitted by, but not in contravention
|
||||||
|
of, applicable law, Affirmer hereby overtly, fully, permanently,
|
||||||
|
irrevocably and unconditionally waives, abandons, and surrenders all of
|
||||||
|
Affirmer's Copyright and Related Rights and associated claims and causes
|
||||||
|
of action, whether now known or unknown (including existing as well as
|
||||||
|
future claims and causes of action), in the Work (i) in all territories
|
||||||
|
worldwide, (ii) for the maximum duration provided by applicable law or
|
||||||
|
treaty (including future time extensions), (iii) in any current or future
|
||||||
|
medium and for any number of copies, and (iv) for any purpose whatsoever,
|
||||||
|
including without limitation commercial, advertising or promotional
|
||||||
|
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
|
||||||
|
member of the public at large and to the detriment of Affirmer's heirs and
|
||||||
|
successors, fully intending that such Waiver shall not be subject to
|
||||||
|
revocation, rescission, cancellation, termination, or any other legal or
|
||||||
|
equitable action to disrupt the quiet enjoyment of the Work by the public
|
||||||
|
as contemplated by Affirmer's express Statement of Purpose.
|
||||||
|
|
||||||
|
3. Public License Fallback. Should any part of the Waiver for any reason
|
||||||
|
be judged legally invalid or ineffective under applicable law, then the
|
||||||
|
Waiver shall be preserved to the maximum extent permitted taking into
|
||||||
|
account Affirmer's express Statement of Purpose. In addition, to the
|
||||||
|
extent the Waiver is so judged Affirmer hereby grants to each affected
|
||||||
|
person a royalty-free, non transferable, non sublicensable, non exclusive,
|
||||||
|
irrevocable and unconditional license to exercise Affirmer's Copyright and
|
||||||
|
Related Rights in the Work (i) in all territories worldwide, (ii) for the
|
||||||
|
maximum duration provided by applicable law or treaty (including future
|
||||||
|
time extensions), (iii) in any current or future medium and for any number
|
||||||
|
of copies, and (iv) for any purpose whatsoever, including without
|
||||||
|
limitation commercial, advertising or promotional purposes (the
|
||||||
|
"License"). The License shall be deemed effective as of the date CC0 was
|
||||||
|
applied by Affirmer to the Work. Should any part of the License for any
|
||||||
|
reason be judged legally invalid or ineffective under applicable law, such
|
||||||
|
partial invalidity or ineffectiveness shall not invalidate the remainder
|
||||||
|
of the License, and in such case Affirmer hereby affirms that he or she
|
||||||
|
will not (i) exercise any of his or her remaining Copyright and Related
|
||||||
|
Rights in the Work or (ii) assert any associated claims and causes of
|
||||||
|
action with respect to the Work, in either case contrary to Affirmer's
|
||||||
|
express Statement of Purpose.
|
||||||
|
|
||||||
|
4. Limitations and Disclaimers.
|
||||||
|
|
||||||
|
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||||
|
surrendered, licensed or otherwise affected by this document.
|
||||||
|
b. Affirmer offers the Work as-is and makes no representations or
|
||||||
|
warranties of any kind concerning the Work, express, implied,
|
||||||
|
statutory or otherwise, including without limitation warranties of
|
||||||
|
title, merchantability, fitness for a particular purpose, non
|
||||||
|
infringement, or the absence of latent or other defects, accuracy, or
|
||||||
|
the present or absence of errors, whether or not discoverable, all to
|
||||||
|
the greatest extent permissible under applicable law.
|
||||||
|
c. Affirmer disclaims responsibility for clearing rights of other persons
|
||||||
|
that may apply to the Work or any use thereof, including without
|
||||||
|
limitation any person's Copyright and Related Rights in the Work.
|
||||||
|
Further, Affirmer disclaims responsibility for obtaining any necessary
|
||||||
|
consents, permissions or other rights required for any use of the
|
||||||
|
Work.
|
||||||
|
d. Affirmer understands and acknowledges that Creative Commons is not a
|
||||||
|
party to this document and has no duty or obligation with respect to
|
||||||
|
this CC0 or use of the Work.
|
|
@ -0,0 +1 @@
|
||||||
|
module.exports = [require('./lib/feed-sync'), require('./lib/thread-sync')]
|
|
@ -0,0 +1,81 @@
|
||||||
|
const { BloomFilter } = require('bloom-filters')
|
||||||
|
const FeedV1 = require('ppppp-db/lib/feed-v1')
|
||||||
|
|
||||||
|
module.exports = function syncAlgorithm(opts = {}) {
|
||||||
|
const {
|
||||||
|
haveRange,
|
||||||
|
wantRange,
|
||||||
|
estimateMsgCount,
|
||||||
|
yieldMsgsIn,
|
||||||
|
commit,
|
||||||
|
} = opts
|
||||||
|
if (typeof haveRange !== 'function') {
|
||||||
|
throw new Error('function haveRange is required')
|
||||||
|
}
|
||||||
|
if (typeof wantRange !== 'function') {
|
||||||
|
throw new Error('function wantRange is required')
|
||||||
|
}
|
||||||
|
if (typeof estimateMsgCount !== 'function') {
|
||||||
|
throw new Error('function estimateMsgCount is required')
|
||||||
|
}
|
||||||
|
if (typeof yieldMsgsIn !== 'function') {
|
||||||
|
throw new Error('function yieldMsgsIn is required')
|
||||||
|
}
|
||||||
|
if (typeof commit !== 'function') {
|
||||||
|
throw new Error('function commit is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyRange(range) {
|
||||||
|
const [min, max] = range
|
||||||
|
return min > max
|
||||||
|
}
|
||||||
|
|
||||||
|
function countIter(iter) {
|
||||||
|
let count = 0
|
||||||
|
for (const _ of iter) count++
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
function betterWantRange(feedId, localHaveRange, remoteHaveRange) {
|
||||||
|
if (isEmptyRange(remoteHaveRange)) return [1, 0]
|
||||||
|
else return wantRange(feedId, localHaveRange, remoteHaveRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bloomFor(feedId, round, range, extraIds = []) {
|
||||||
|
const filterSize =
|
||||||
|
(isEmptyRange(range) ? 2 : estimateMsgCount(range)) + countIter(extraIds)
|
||||||
|
const filter = BloomFilter.create(2 * filterSize, 0.00001)
|
||||||
|
if (!isEmptyRange(range)) {
|
||||||
|
for (const msg of yieldMsgsIn(feedId, range)) {
|
||||||
|
filter.add('' + round + FeedV1.getMsgHash(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const msgId of extraIds) {
|
||||||
|
filter.add('' + round + msgId)
|
||||||
|
}
|
||||||
|
return filter.saveAsJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
function msgsMissing(feedId, round, range, remoteBloomJSON) {
|
||||||
|
if (isEmptyRange(range)) return []
|
||||||
|
const remoteFilter = BloomFilter.fromJSON(remoteBloomJSON)
|
||||||
|
const missing = []
|
||||||
|
for (const msg of yieldMsgsIn(feedId, range)) {
|
||||||
|
const msgHash = FeedV1.getMsgHash(msg)
|
||||||
|
if (!remoteFilter.has('' + round + msgHash)) {
|
||||||
|
missing.push(msgHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
haveRange,
|
||||||
|
wantRange: betterWantRange,
|
||||||
|
isEmptyRange,
|
||||||
|
bloomFor,
|
||||||
|
msgsMissing,
|
||||||
|
yieldMsgsIn,
|
||||||
|
commit,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
const p = require('util').promisify
|
||||||
|
const FeedV1 = require('ppppp-db/lib/feed-v1')
|
||||||
|
const syncPlugin = require('./plugin')
|
||||||
|
|
||||||
|
module.exports = syncPlugin('feedSync', (peer, config) => {
|
||||||
|
const limit = config.feedSync?.limit ?? 1000
|
||||||
|
|
||||||
|
function* take(n, iter) {
|
||||||
|
if (n === 0) return
|
||||||
|
let i = 0
|
||||||
|
for (const item of iter) {
|
||||||
|
yield item
|
||||||
|
if (++i >= n) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function* filter(iter, fn) {
|
||||||
|
for (const item of iter) {
|
||||||
|
if (fn(item)) yield item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
haveRange(feedId) {
|
||||||
|
let minDepth = Number.MAX_SAFE_INTEGER
|
||||||
|
let maxDepth = 0
|
||||||
|
for (const msg of peer.db.msgs()) {
|
||||||
|
if (FeedV1.getFeedId(msg) === feedId) {
|
||||||
|
minDepth = Math.min(minDepth, msg.metadata.depth)
|
||||||
|
maxDepth = Math.max(maxDepth, msg.metadata.depth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [minDepth, maxDepth]
|
||||||
|
},
|
||||||
|
|
||||||
|
wantRange(feedId, localHaveRange, remoteHaveRange) {
|
||||||
|
const [minLocalHave, maxLocalHave] = localHaveRange
|
||||||
|
const [minRemoteHave, maxRemoteHave] = remoteHaveRange
|
||||||
|
if (maxRemoteHave <= maxLocalHave) return [1, 0]
|
||||||
|
const maxWant = maxRemoteHave
|
||||||
|
const size = Math.max(maxWant - maxLocalHave, limit)
|
||||||
|
const minWant = Math.max(maxWant - size, maxLocalHave + 1, minRemoteHave)
|
||||||
|
return [minWant, maxWant]
|
||||||
|
},
|
||||||
|
|
||||||
|
estimateMsgCount(range) {
|
||||||
|
const [minDepth, maxDepth] = range
|
||||||
|
const estimate = 2 * (maxDepth - minDepth + 1)
|
||||||
|
if (estimate > 1000) return 1000
|
||||||
|
else if (estimate < 5) return 5
|
||||||
|
else return estimate
|
||||||
|
},
|
||||||
|
|
||||||
|
*yieldMsgsIn(feedId, range) {
|
||||||
|
const [minDepth, maxDepth] = range
|
||||||
|
for (const msg of peer.db.msgs()) {
|
||||||
|
if (
|
||||||
|
FeedV1.getFeedId(msg) === feedId &&
|
||||||
|
msg.metadata.depth >= minDepth &&
|
||||||
|
msg.metadata.depth <= maxDepth
|
||||||
|
) {
|
||||||
|
yield msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async commit(newMsgs, feedId, cb) {
|
||||||
|
newMsgs.sort((a, b) => a.metadata.depth - b.metadata.depth) // mutation
|
||||||
|
const isRelevantRec = (rec) => FeedV1.getFeedId(rec.msg) === feedId
|
||||||
|
|
||||||
|
// Find max sequence in the database
|
||||||
|
let oldLastDepth = 0
|
||||||
|
let oldCount = 0
|
||||||
|
for (const rec of peer.db.records()) {
|
||||||
|
if (!isRelevantRec(rec)) continue
|
||||||
|
oldCount += 1
|
||||||
|
oldLastDepth = Math.max(oldLastDepth, rec.msg.metadata.depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isContinuation = newMsgs[0].metadata.depth === oldLastDepth + 1
|
||||||
|
// Refuse creating holes in the feed
|
||||||
|
if (!isContinuation && newMsgs.length < limit) {
|
||||||
|
console.error(
|
||||||
|
`feedSync failed to persist msgs for ${feedId} because ` +
|
||||||
|
'they are not a continuation, and not enough messages'
|
||||||
|
)
|
||||||
|
return cb()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old messages in the database
|
||||||
|
if (isContinuation) {
|
||||||
|
// Delete just enough msgs to make room for the new ones
|
||||||
|
const N = Math.max(0, oldCount + newMsgs.length - limit)
|
||||||
|
for (const rec of take(N, filter(peer.db.records(), isRelevantRec))) {
|
||||||
|
await p(peer.db.del)(rec.hash)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Delete all the old ones
|
||||||
|
for (const rec of filter(peer.db.records(), isRelevantRec)) {
|
||||||
|
await p(peer.db.del)(rec.hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new messages
|
||||||
|
for (const msg of newMsgs) {
|
||||||
|
await p(peer.db.add)(msg)
|
||||||
|
}
|
||||||
|
cb()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
|
@ -0,0 +1,85 @@
|
||||||
|
const toPull = require('push-stream-to-pull-stream')
|
||||||
|
const pull = require('pull-stream')
|
||||||
|
const makeDebug = require('debug')
|
||||||
|
const getSeverity = require('ssb-network-errors')
|
||||||
|
const syncAlgorithm = require('./algorithm')
|
||||||
|
const SyncStream = require('./stream')
|
||||||
|
|
||||||
|
function isMuxrpcMissingError(err, namespace, methodName) {
|
||||||
|
const jsErrorMessage = `method:${namespace},${methodName} is not in list of allowed methods`
|
||||||
|
const goErrorMessage = `muxrpc: no such command: ${namespace}.${methodName}`
|
||||||
|
return err.message === jsErrorMessage || err.message === goErrorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function makeSyncPlugin(name, getOpts) {
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
manifest: {
|
||||||
|
connect: 'duplex',
|
||||||
|
request: 'sync',
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
anonymous: {
|
||||||
|
allow: ['connect'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
init(peer, config) {
|
||||||
|
const debug = makeDebug(`ppppp:${name}`)
|
||||||
|
const opts = getOpts(peer, config)
|
||||||
|
const algo = syncAlgorithm(opts)
|
||||||
|
|
||||||
|
algo.getMsgs = function getMsgs(msgIds) {
|
||||||
|
const msgs = []
|
||||||
|
for (const msgId of msgIds) {
|
||||||
|
const msg = peer.db.get(msgId)
|
||||||
|
if (msg) msgs.push(msg)
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
const streams = []
|
||||||
|
function createStream(remoteId, isClient) {
|
||||||
|
// prettier-ignore
|
||||||
|
debug('Opening a stream with remote %s %s', isClient ? 'server' : 'client', remoteId)
|
||||||
|
const stream = new SyncStream(peer.id, debug, algo)
|
||||||
|
streams.push(stream)
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
peer.on('rpc:connect', function onSyncRPCConnect(rpc, isClient) {
|
||||||
|
if (rpc.id === peer.id) return // local client connecting to local server
|
||||||
|
if (!isClient) return
|
||||||
|
const local = toPull.duplex(createStream(rpc.id, true))
|
||||||
|
|
||||||
|
const remote = rpc[name].connect((networkError) => {
|
||||||
|
if (networkError && getSeverity(networkError) >= 3) {
|
||||||
|
if (isMuxrpcMissingError(networkError, name, 'connect')) {
|
||||||
|
console.warn(`peer ${rpc.id} does not support sync connect`)
|
||||||
|
// } else if (isReconnectedError(networkError)) { // TODO: bring back
|
||||||
|
// Do nothing, this is a harmless error
|
||||||
|
} else {
|
||||||
|
console.error(`rpc.${name}.connect exception:`, networkError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
pull(local, remote, local)
|
||||||
|
})
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
// `this` refers to the remote peer who called this muxrpc API
|
||||||
|
return toPull.duplex(createStream(this.id, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(id) {
|
||||||
|
for (const stream of streams) {
|
||||||
|
stream.request(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
connect,
|
||||||
|
request,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,328 @@
|
||||||
|
const Pipeable = require('push-stream/pipeable')
|
||||||
|
|
||||||
|
class SyncStream extends Pipeable {
|
||||||
|
#myId
|
||||||
|
#debug
|
||||||
|
#algo
|
||||||
|
#requested
|
||||||
|
#remoteHave
|
||||||
|
#remoteWant
|
||||||
|
#receivableMsgs
|
||||||
|
#sendableMsgs
|
||||||
|
|
||||||
|
constructor(localId, debug, algo) {
|
||||||
|
super()
|
||||||
|
this.paused = false // TODO: should we start as paused=true?
|
||||||
|
this.ended = false
|
||||||
|
this.source = this.sink = null
|
||||||
|
this.#myId = localId.slice(0, 6)
|
||||||
|
this.#debug = debug
|
||||||
|
this.#algo = algo
|
||||||
|
this.#requested = new Set()
|
||||||
|
this.#remoteHave = new Map() // id => have-range by remote peer
|
||||||
|
this.#remoteWant = new Map() // id => want-range by remote peer
|
||||||
|
this.#receivableMsgs = new Map() // id => Set<msgIDs>
|
||||||
|
this.#sendableMsgs = new Map() // id => Set<msgIDs>
|
||||||
|
}
|
||||||
|
|
||||||
|
// public API
|
||||||
|
request(id) {
|
||||||
|
this.#requested.add(id)
|
||||||
|
this.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
#canSend() {
|
||||||
|
return this.sink && !this.sink.paused && !this.ended
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateSendableMsgs(id, msgs) {
|
||||||
|
const set = this.#sendableMsgs.get(id) ?? new Set()
|
||||||
|
for (const msg of msgs) {
|
||||||
|
set.add(msg)
|
||||||
|
}
|
||||||
|
this.#sendableMsgs.set(id, set)
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateReceivableMsgs(id, msgs) {
|
||||||
|
const set = this.#receivableMsgs.get(id) ?? new Set()
|
||||||
|
for (const msg of msgs) {
|
||||||
|
set.add(msg)
|
||||||
|
}
|
||||||
|
this.#receivableMsgs.set(id, set)
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendLocalHave(id) {
|
||||||
|
const localHaveRange = this.#algo.haveRange(id)
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream OUT: send local have-range %o for %s', this.#myId, localHaveRange, id)
|
||||||
|
this.sink.write({ id, phase: 1, payload: localHaveRange })
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendLocalHaveAndWant(id, remoteHaveRange) {
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream IN: received remote have-range %o for %s', this.#myId, remoteHaveRange, id)
|
||||||
|
this.#remoteHave.set(id, remoteHaveRange)
|
||||||
|
const haveRange = this.#algo.haveRange(id)
|
||||||
|
const wantRange = this.#algo.wantRange(id, haveRange, remoteHaveRange)
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream OUT: send local have-range %o and want-range %o for %s', this.#myId, haveRange, wantRange, id)
|
||||||
|
this.sink.write({ id, phase: 2, payload: { haveRange, wantRange } })
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendLocalWantAndInitBloom(id, remoteHaveRange, remoteWantRange) {
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream IN: received remote have-range %o and want-range %o for %s', this.#myId, remoteHaveRange, remoteWantRange, id)
|
||||||
|
this.#remoteHave.set(id, remoteHaveRange)
|
||||||
|
this.#remoteWant.set(id, remoteWantRange)
|
||||||
|
const haveRange = this.#algo.haveRange(id)
|
||||||
|
const wantRange = this.#algo.wantRange(id, haveRange, remoteHaveRange)
|
||||||
|
const localBloom0 = this.#algo.bloomFor(id, 0, remoteWantRange)
|
||||||
|
this.sink.write({
|
||||||
|
id,
|
||||||
|
phase: 3,
|
||||||
|
payload: { bloom: localBloom0, wantRange },
|
||||||
|
})
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream OUT: send local want-range %o and bloom round 0 for %s', this.#myId, wantRange, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendInitBloomRes(id, remoteWantRange, remoteBloom) {
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream IN: received remote want-range %o and bloom round 0 for %s', this.#myId, remoteWantRange, id)
|
||||||
|
this.#remoteWant.set(id, remoteWantRange)
|
||||||
|
const msgIDsForThem = this.#algo.msgsMissing(
|
||||||
|
id,
|
||||||
|
0,
|
||||||
|
remoteWantRange,
|
||||||
|
remoteBloom
|
||||||
|
)
|
||||||
|
this.#updateSendableMsgs(id, msgIDsForThem)
|
||||||
|
const localBloom = this.#algo.bloomFor(id, 0, remoteWantRange)
|
||||||
|
this.sink.write({
|
||||||
|
id,
|
||||||
|
phase: 4,
|
||||||
|
payload: { bloom: localBloom, msgIDs: msgIDsForThem },
|
||||||
|
})
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream OUT: send bloom round 0 plus msgIDs in %s: %o', this.#myId, id, msgIDsForThem)
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendBloomReq(id, phase, round, remoteBloom, msgIDsForMe) {
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream IN: received bloom round %s plus msgIDs in %s: %o', this.#myId, round-1, id, msgIDsForMe)
|
||||||
|
const remoteWantRange = this.#remoteWant.get(id)
|
||||||
|
this.#updateReceivableMsgs(id, msgIDsForMe)
|
||||||
|
const msgIDsForThem = this.#algo.msgsMissing(
|
||||||
|
id,
|
||||||
|
round - 1,
|
||||||
|
remoteWantRange,
|
||||||
|
remoteBloom
|
||||||
|
)
|
||||||
|
this.#updateSendableMsgs(id, msgIDsForThem)
|
||||||
|
const localBloom = this.#algo.bloomFor(
|
||||||
|
id,
|
||||||
|
round,
|
||||||
|
remoteWantRange,
|
||||||
|
this.#receivableMsgs.get(id)
|
||||||
|
)
|
||||||
|
this.sink.write({
|
||||||
|
id,
|
||||||
|
phase,
|
||||||
|
payload: { bloom: localBloom, msgIDs: msgIDsForThem },
|
||||||
|
})
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream OUT: send bloom round %s plus msgIDs in %s: %o', this.#myId, round, id, msgIDsForThem)
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendBloomRes(id, phase, round, remoteBloom, msgIDsForMe) {
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream IN: received bloom round %s plus msgIDs in %s: %o', this.#myId, round, id, msgIDsForMe)
|
||||||
|
const remoteWantRange = this.#remoteWant.get(id)
|
||||||
|
this.#updateReceivableMsgs(id, msgIDsForMe)
|
||||||
|
const msgIDsForThem = this.#algo.msgsMissing(
|
||||||
|
id,
|
||||||
|
round,
|
||||||
|
remoteWantRange,
|
||||||
|
remoteBloom
|
||||||
|
)
|
||||||
|
this.#updateSendableMsgs(id, msgIDsForThem)
|
||||||
|
const localBloom = this.#algo.bloomFor(
|
||||||
|
id,
|
||||||
|
round,
|
||||||
|
remoteWantRange,
|
||||||
|
this.#receivableMsgs.get(id)
|
||||||
|
)
|
||||||
|
this.sink.write({
|
||||||
|
id,
|
||||||
|
phase,
|
||||||
|
payload: { bloom: localBloom, msgIDs: msgIDsForThem },
|
||||||
|
})
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream OUT: send bloom round %s plus msgIDs in %s: %o', this.#myId, round, id, msgIDsForThem)
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendLastBloomRes(id, phase, round, remoteBloom, msgIDsForMe) {
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream IN: received bloom round %s plus msgIDs in %s: %o', this.#myId, round, id, msgIDsForMe)
|
||||||
|
const remoteWantRange = this.#remoteWant.get(id)
|
||||||
|
this.#updateReceivableMsgs(id, msgIDsForMe)
|
||||||
|
const msgIDsForThem = this.#algo.msgsMissing(
|
||||||
|
id,
|
||||||
|
round,
|
||||||
|
remoteWantRange,
|
||||||
|
remoteBloom
|
||||||
|
)
|
||||||
|
this.#updateSendableMsgs(id, msgIDsForThem)
|
||||||
|
this.sink.write({ id, phase, payload: msgIDsForThem })
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream OUT: send msgIDs in %s: %o', this.#myId, id, msgIDsForThem)
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendMissingMsgsReq(id, msgIDsForMe) {
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream IN: received msgIDs in %s: %o', this.#myId, id, msgIDsForMe)
|
||||||
|
this.#updateReceivableMsgs(id, msgIDsForMe)
|
||||||
|
const msgIDs = this.#sendableMsgs.has(id)
|
||||||
|
? [...this.#sendableMsgs.get(id)]
|
||||||
|
: []
|
||||||
|
const msgs = this.#algo.getMsgs(msgIDs)
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream OUT: send %s msgs in %s', this.#myId, msgs.length, id)
|
||||||
|
this.sink.write({ id, phase: 9, payload: msgs })
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendMissingMsgsRes(id, msgsForMe) {
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream IN: received %s msgs in %s', this.#myId, msgsForMe.length, id)
|
||||||
|
const msgIDs = this.#sendableMsgs.has(id)
|
||||||
|
? [...this.#sendableMsgs.get(id)]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const msgs = this.#algo.getMsgs(msgIDs)
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream OUT: send %s msgs in %s', this.#myId, msgs.length, id)
|
||||||
|
this.sink.write({ id, phase: 10, payload: msgs })
|
||||||
|
|
||||||
|
this.#requested.delete(id)
|
||||||
|
this.#remoteHave.delete(id)
|
||||||
|
this.#remoteWant.delete(id)
|
||||||
|
this.#receivableMsgs.delete(id)
|
||||||
|
this.#sendableMsgs.delete(id)
|
||||||
|
if (msgsForMe.length === 0) return
|
||||||
|
this.#algo.commit(msgsForMe, id, (err) => {
|
||||||
|
// prettier-ignore
|
||||||
|
if (err) throw new Error('sendMissingMsgsRes failed because sink failed', {cause: err})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#consumeMissingMsgs(id, msgsForMe) {
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream IN: received %s msgs in %s', this.#myId, msgsForMe.length, id)
|
||||||
|
|
||||||
|
this.#requested.delete(id)
|
||||||
|
this.#remoteHave.delete(id)
|
||||||
|
this.#remoteWant.delete(id)
|
||||||
|
this.#receivableMsgs.delete(id)
|
||||||
|
this.#sendableMsgs.delete(id)
|
||||||
|
if (msgsForMe.length === 0) return
|
||||||
|
this.#algo.commit(msgsForMe, id, (err) => {
|
||||||
|
// prettier-ignore
|
||||||
|
if (err) throw new Error('sendMissingMsgsRes failed because sink failed', {cause: err})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendMsgsInRemoteWant(id, remoteWantRange) {
|
||||||
|
const msgs = []
|
||||||
|
for (const msg of this.#algo.yieldMsgsIn(id, remoteWantRange)) {
|
||||||
|
msgs.push(msg)
|
||||||
|
}
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream OUT: send %s msgs in %s', this.#myId, msgs.length, id)
|
||||||
|
this.sink.write({ id, phase: 10, payload: msgs })
|
||||||
|
}
|
||||||
|
|
||||||
|
// as a source
|
||||||
|
resume() {
|
||||||
|
if (!this.sink || this.sink.paused) return
|
||||||
|
|
||||||
|
for (const id of this.#requested) {
|
||||||
|
if (!this.#canSend()) return
|
||||||
|
this.#sendLocalHave(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// as a sink
|
||||||
|
write(data) {
|
||||||
|
const { id, phase, payload } = data
|
||||||
|
|
||||||
|
switch (phase) {
|
||||||
|
case 1: {
|
||||||
|
return this.#sendLocalHaveAndWant(id, payload)
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
const { haveRange, wantRange } = payload
|
||||||
|
if (this.#algo.isEmptyRange(haveRange)) {
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream IN: received remote have-range %o and want-range %o for %s', this.#myId, haveRange, wantRange, id)
|
||||||
|
return this.#sendMsgsInRemoteWant(id, wantRange)
|
||||||
|
} else {
|
||||||
|
return this.#sendLocalWantAndInitBloom(id, haveRange, wantRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 3: {
|
||||||
|
const { wantRange, bloom } = payload
|
||||||
|
const haveRange = this.#remoteHave.get(id)
|
||||||
|
if (haveRange && this.#algo.isEmptyRange(haveRange)) {
|
||||||
|
// prettier-ignore
|
||||||
|
this.#debug('%s Stream IN: received remote want-range want-range %o and remember empty have-range %o for %s', this.#myId, wantRange, haveRange, id)
|
||||||
|
return this.#sendMsgsInRemoteWant(id, wantRange)
|
||||||
|
} else {
|
||||||
|
return this.#sendInitBloomRes(id, wantRange, bloom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 4: {
|
||||||
|
const { bloom, msgIDs } = payload
|
||||||
|
return this.#sendBloomReq(id, phase + 1, 1, bloom, msgIDs)
|
||||||
|
}
|
||||||
|
case 5: {
|
||||||
|
const { bloom, msgIDs } = payload
|
||||||
|
return this.#sendBloomRes(id, phase + 1, 1, bloom, msgIDs)
|
||||||
|
}
|
||||||
|
case 6: {
|
||||||
|
const { bloom, msgIDs } = payload
|
||||||
|
return this.#sendBloomReq(id, phase + 1, 2, bloom, msgIDs)
|
||||||
|
}
|
||||||
|
case 7: {
|
||||||
|
const { bloom, msgIDs } = payload
|
||||||
|
return this.#sendLastBloomRes(id, phase + 1, 2, bloom, msgIDs)
|
||||||
|
}
|
||||||
|
case 8: {
|
||||||
|
return this.#sendMissingMsgsReq(id, payload)
|
||||||
|
}
|
||||||
|
case 9: {
|
||||||
|
return this.#sendMissingMsgsRes(id, payload)
|
||||||
|
}
|
||||||
|
case 10: {
|
||||||
|
return this.#consumeMissingMsgs(id, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#debug('Stream IN: unknown %o', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// as a source
|
||||||
|
abort(err) {
|
||||||
|
this.ended = true
|
||||||
|
if (this.source && !this.source.ended) this.source.abort(err)
|
||||||
|
if (this.sink && !this.sink.ended) this.sink.end(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// as a sink
|
||||||
|
end(err) {
|
||||||
|
this.ended = true
|
||||||
|
if (this.source && !this.source.ended) this.source.abort(err)
|
||||||
|
if (this.sink && !this.sink.ended) this.sink.end(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SyncStream
|
|
@ -0,0 +1,61 @@
|
||||||
|
const p = require('util').promisify
|
||||||
|
const dagSyncPlugin = require('./plugin')
|
||||||
|
|
||||||
|
module.exports = dagSyncPlugin('threadSync', (peer, config) => ({
|
||||||
|
haveRange(rootMsgHash) {
|
||||||
|
const rootMsg = peer.db.get(rootMsgHash)
|
||||||
|
if (!rootMsg) return [1, 0]
|
||||||
|
let maxDepth = 0
|
||||||
|
for (const rec of peer.db.records()) {
|
||||||
|
const tangles = rec.msg.metadata.tangles
|
||||||
|
if (rec.hash !== rootMsgHash && tangles?.[rootMsgHash]) {
|
||||||
|
const depth = tangles[rootMsgHash].depth
|
||||||
|
maxDepth = Math.max(maxDepth, depth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [0, maxDepth]
|
||||||
|
},
|
||||||
|
|
||||||
|
wantRange(rootMsgId, localHaveRange, remoteHaveRange) {
|
||||||
|
const [minLocalHave, maxLocalHave] = localHaveRange
|
||||||
|
const [minRemoteHave, maxRemoteHave] = remoteHaveRange
|
||||||
|
if (minRemoteHave !== 0) throw new Error('minRemoteHave must be 0')
|
||||||
|
return [0, Math.max(maxLocalHave, maxRemoteHave)]
|
||||||
|
},
|
||||||
|
|
||||||
|
estimateMsgCount(range) {
|
||||||
|
const [minDepth, maxDepth] = range
|
||||||
|
const estimate = 2 * (maxDepth - minDepth + 1)
|
||||||
|
if (estimate > 1000) return 1000
|
||||||
|
else if (estimate < 5) return 5
|
||||||
|
else return estimate
|
||||||
|
},
|
||||||
|
|
||||||
|
*yieldMsgsIn(rootMsgId, range) {
|
||||||
|
const [minDepth, maxDepth] = range
|
||||||
|
const rootMsg = peer.db.get(rootMsgId)
|
||||||
|
if (!rootMsg) return
|
||||||
|
for (const msg of peer.db.msgs()) {
|
||||||
|
const tangles = msg.metadata.tangles
|
||||||
|
if (
|
||||||
|
tangles?.[rootMsgId] &&
|
||||||
|
tangles[rootMsgId].depth >= minDepth &&
|
||||||
|
tangles[rootMsgId].depth <= maxDepth
|
||||||
|
) {
|
||||||
|
yield msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async commit(newMsgs, rootMsgId, cb) {
|
||||||
|
newMsgs.sort((a, b) => {
|
||||||
|
const aDepth = a.metadata.tangles[rootMsgId].depth
|
||||||
|
const bDepth = b.metadata.tangles[rootMsgId].depth
|
||||||
|
return aDepth - bDepth
|
||||||
|
})
|
||||||
|
for (const msg of newMsgs) {
|
||||||
|
await p(peer.db.add)(msg)
|
||||||
|
}
|
||||||
|
cb()
|
||||||
|
},
|
||||||
|
}))
|
|
@ -0,0 +1,54 @@
|
||||||
|
{
|
||||||
|
"name": "dagsync",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "SSB replication using Kleppmann's hash graph sync",
|
||||||
|
"author": "Andre Staltz <contact@staltz.com>",
|
||||||
|
"license": "CC0-1.0",
|
||||||
|
"homepage": "https://github.com/staltz/dagsync",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git@github.com:staltz/dagsync.git"
|
||||||
|
},
|
||||||
|
"main": "index.js",
|
||||||
|
"files": [
|
||||||
|
"*.js",
|
||||||
|
"lib/*.js",
|
||||||
|
"compat/*.js"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bloom-filters": "^3.0.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"multicb": "^1.2.2",
|
||||||
|
"pull-stream": "^3.7.0",
|
||||||
|
"push-stream": "^11.2.0",
|
||||||
|
"push-stream-to-pull-stream": "^1.0.5",
|
||||||
|
"ssb-network-errors": "^1.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"bs58": "^5.0.0",
|
||||||
|
"ppppp-db": "../db",
|
||||||
|
"rimraf": "^4.4.0",
|
||||||
|
"secret-stack": "^6.4.1",
|
||||||
|
"ssb-box": "^1.0.1",
|
||||||
|
"ssb-caps": "^1.1.0",
|
||||||
|
"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": {
|
||||||
|
"test": "tape test/*.js | tap-arc --bail",
|
||||||
|
"format-code": "prettier --write \"*.js\" \"(test|compat|indexes|operators)/*.js\"",
|
||||||
|
"format-code-staged": "pretty-quick --staged --pattern \"*.js\" --pattern \"(test|compat|indexes|operators)/*.js\"",
|
||||||
|
"coverage": "c8 --reporter=lcov npm run test"
|
||||||
|
},
|
||||||
|
"husky": {
|
||||||
|
"hooks": {
|
||||||
|
"pre-commit": "npm run format-code-staged"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,153 @@
|
||||||
|
const test = require('tape')
|
||||||
|
const path = require('path')
|
||||||
|
const os = require('os')
|
||||||
|
const rimraf = require('rimraf')
|
||||||
|
const SecretStack = require('secret-stack')
|
||||||
|
const caps = require('ssb-caps')
|
||||||
|
const p = require('util').promisify
|
||||||
|
const { generateKeypair } = require('./util')
|
||||||
|
|
||||||
|
const createPeer = SecretStack({ appKey: caps.shs })
|
||||||
|
.use(require('ppppp-db'))
|
||||||
|
.use(require('ssb-box'))
|
||||||
|
.use(require('../'))
|
||||||
|
|
||||||
|
test('sync a sliced classic feed', async (t) => {
|
||||||
|
const ALICE_DIR = path.join(os.tmpdir(), 'dagsync-alice')
|
||||||
|
const BOB_DIR = path.join(os.tmpdir(), 'dagsync-bob')
|
||||||
|
|
||||||
|
rimraf.sync(ALICE_DIR)
|
||||||
|
rimraf.sync(BOB_DIR)
|
||||||
|
|
||||||
|
const alice = createPeer({
|
||||||
|
keys: generateKeypair('alice'),
|
||||||
|
path: ALICE_DIR,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bob = createPeer({
|
||||||
|
keys: generateKeypair('bob'),
|
||||||
|
path: BOB_DIR,
|
||||||
|
})
|
||||||
|
|
||||||
|
await alice.db.loaded()
|
||||||
|
await bob.db.loaded()
|
||||||
|
|
||||||
|
const carolKeys = generateKeypair('carol')
|
||||||
|
const carolMsgs = []
|
||||||
|
const carolID = carolKeys.id
|
||||||
|
const carolID_b58 = carolID.split('ppppp:feed/v1/')[1]
|
||||||
|
const carolPostFeedId = carolID + '/post'
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
const rec = await p(alice.db.create)({
|
||||||
|
type: 'post',
|
||||||
|
content: { text: 'm' + i },
|
||||||
|
keys: carolKeys,
|
||||||
|
})
|
||||||
|
carolMsgs.push(rec.msg)
|
||||||
|
}
|
||||||
|
t.pass('alice has msgs 1..10 from carol')
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
await p(bob.db.add)(carolMsgs[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const arr = [...bob.db.msgs()]
|
||||||
|
.filter((msg) => msg.metadata.who === carolID_b58)
|
||||||
|
.map((msg) => msg.content.text)
|
||||||
|
t.deepEquals(
|
||||||
|
arr,
|
||||||
|
['m1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7'],
|
||||||
|
'bob has msgs 1..7 from carol'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteAlice = await p(bob.connect)(alice.getAddress())
|
||||||
|
t.pass('bob connected to alice')
|
||||||
|
|
||||||
|
bob.feedSync.request(carolPostFeedId)
|
||||||
|
await p(setTimeout)(1000)
|
||||||
|
t.pass('feedSync!')
|
||||||
|
|
||||||
|
{
|
||||||
|
const arr = [...bob.db.msgs()]
|
||||||
|
.filter((msg) => msg.metadata.who === carolID_b58)
|
||||||
|
.map((msg) => msg.content.text)
|
||||||
|
t.deepEquals(
|
||||||
|
arr,
|
||||||
|
['m1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9', 'm10'],
|
||||||
|
'bob has msgs 1..10 from carol'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await p(remoteAlice.close)(true)
|
||||||
|
await p(alice.close)(true)
|
||||||
|
await p(bob.close)(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// FIXME:
|
||||||
|
test.skip('delete old msgs and sync latest msgs', async (t) => {
|
||||||
|
const ALICE_DIR = path.join(os.tmpdir(), 'dagsync-alice')
|
||||||
|
const BOB_DIR = path.join(os.tmpdir(), 'dagsync-bob')
|
||||||
|
|
||||||
|
rimraf.sync(ALICE_DIR)
|
||||||
|
rimraf.sync(BOB_DIR)
|
||||||
|
|
||||||
|
const alice = createPeer({
|
||||||
|
keys: generateKeypair('alice'),
|
||||||
|
path: ALICE_DIR,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bob = createPeer({
|
||||||
|
keys: generateKeypair('bob'),
|
||||||
|
path: BOB_DIR,
|
||||||
|
feedSync: {
|
||||||
|
limit: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await alice.db.loaded()
|
||||||
|
await bob.db.loaded()
|
||||||
|
|
||||||
|
const carolKeys = generateKeypair('carol')
|
||||||
|
const carolMsgs = []
|
||||||
|
const carolID = carolKeys.id
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
const msg = await p(alice.db.create)({
|
||||||
|
feedFormat: 'classic',
|
||||||
|
content: { type: 'post', text: 'm' + i },
|
||||||
|
keys: carolKeys,
|
||||||
|
})
|
||||||
|
carolMsgs.push(msg)
|
||||||
|
}
|
||||||
|
t.pass('alice has msgs 1..10 from carol')
|
||||||
|
|
||||||
|
await p(bob.db.add)(carolMsgs[5].value)
|
||||||
|
await p(bob.db.add)(carolMsgs[6].value)
|
||||||
|
await p(bob.db.add)(carolMsgs[7].value)
|
||||||
|
|
||||||
|
{
|
||||||
|
const arr = bob.db
|
||||||
|
.filterAsArray((msg) => msg?.value.author === carolID)
|
||||||
|
.map((msg) => msg.value.content.text)
|
||||||
|
t.deepEquals(arr, ['m6', 'm7', 'm8'], 'bob has msgs 6..8 from carol')
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteAlice = await p(bob.connect)(alice.getAddress())
|
||||||
|
t.pass('bob connected to alice')
|
||||||
|
|
||||||
|
bob.feedSync.request(carolID)
|
||||||
|
await p(setTimeout)(1000)
|
||||||
|
t.pass('feedSync!')
|
||||||
|
|
||||||
|
{
|
||||||
|
const arr = bob.db
|
||||||
|
.filterAsArray((msg) => msg?.value.author === carolID)
|
||||||
|
.map((msg) => msg.value.content.text)
|
||||||
|
t.deepEquals(arr, ['m8', 'm9', 'm10'], 'bob has msgs 8..10 from carol')
|
||||||
|
}
|
||||||
|
|
||||||
|
await p(remoteAlice.close)(true)
|
||||||
|
await p(alice.close)(true)
|
||||||
|
await p(bob.close)(true)
|
||||||
|
})
|
|
@ -0,0 +1,303 @@
|
||||||
|
const test = require('tape')
|
||||||
|
const ssbKeys = require('ssb-keys')
|
||||||
|
const path = require('path')
|
||||||
|
const os = require('os')
|
||||||
|
const rimraf = require('rimraf')
|
||||||
|
const SecretStack = require('secret-stack')
|
||||||
|
const caps = require('ssb-caps')
|
||||||
|
const FeedV1 = require('ppppp-db/lib/feed-v1')
|
||||||
|
const p = require('util').promisify
|
||||||
|
const { generateKeypair } = require('./util')
|
||||||
|
|
||||||
|
const createSSB = SecretStack({ appKey: caps.shs })
|
||||||
|
.use(require('ppppp-db'))
|
||||||
|
.use(require('ssb-box'))
|
||||||
|
.use(require('../'))
|
||||||
|
|
||||||
|
/*
|
||||||
|
BEFORE dagsync:
|
||||||
|
```mermaid
|
||||||
|
graph TB;
|
||||||
|
subgraph Bob
|
||||||
|
direction TB
|
||||||
|
rootAb[root by A]
|
||||||
|
replyB1b[reply by B]
|
||||||
|
replyB2b[reply by B]
|
||||||
|
replyD1b[reply by D]
|
||||||
|
rootAb-->replyB1b-->replyB2b & replyD1b
|
||||||
|
end
|
||||||
|
subgraph Alice
|
||||||
|
direction TB
|
||||||
|
rootAa[root by A]
|
||||||
|
replyB1a[reply by B]
|
||||||
|
replyB2a[reply by B]
|
||||||
|
replyC1a[reply by C]
|
||||||
|
rootAa-->replyB1a-->replyB2a
|
||||||
|
rootAa-->replyC1a
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
AFTER dagsync:
|
||||||
|
```mermaid
|
||||||
|
graph TB;
|
||||||
|
subgraph Bob
|
||||||
|
rootA[root by A]
|
||||||
|
replyB1[reply by B]
|
||||||
|
replyB2[reply by B]
|
||||||
|
replyC1[reply by C]
|
||||||
|
replyD1[reply by D]
|
||||||
|
rootA-->replyB1-->replyB2 & replyD1
|
||||||
|
rootA-->replyC1
|
||||||
|
end
|
||||||
|
```
|
||||||
|
*/
|
||||||
|
test('sync a thread where both peers have portions', async (t) => {
|
||||||
|
const ALICE_DIR = path.join(os.tmpdir(), 'dagsync-alice')
|
||||||
|
const BOB_DIR = path.join(os.tmpdir(), 'dagsync-bob')
|
||||||
|
|
||||||
|
rimraf.sync(ALICE_DIR)
|
||||||
|
rimraf.sync(BOB_DIR)
|
||||||
|
|
||||||
|
const alice = createSSB({
|
||||||
|
keys: generateKeypair('alice'),
|
||||||
|
path: ALICE_DIR,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bob = createSSB({
|
||||||
|
keys: generateKeypair('bob'),
|
||||||
|
path: BOB_DIR,
|
||||||
|
})
|
||||||
|
|
||||||
|
const carolKeys = generateKeypair('carol')
|
||||||
|
const carolID = carolKeys.id
|
||||||
|
|
||||||
|
const daveKeys = generateKeypair('dave')
|
||||||
|
const daveID = daveKeys.id
|
||||||
|
|
||||||
|
await alice.db.loaded()
|
||||||
|
await bob.db.loaded()
|
||||||
|
|
||||||
|
const rootA = await p(alice.db.create)({
|
||||||
|
type: 'post',
|
||||||
|
content: { text: 'A' },
|
||||||
|
keys: alice.config.keys,
|
||||||
|
})
|
||||||
|
await p(bob.db.add)(rootA.msg)
|
||||||
|
|
||||||
|
await p(setTimeout)(10)
|
||||||
|
|
||||||
|
const replyB1 = await p(bob.db.create)({
|
||||||
|
type: 'post',
|
||||||
|
content: { text: 'B1' },
|
||||||
|
tangles: [rootA.hash],
|
||||||
|
keys: bob.config.keys,
|
||||||
|
})
|
||||||
|
|
||||||
|
await p(setTimeout)(10)
|
||||||
|
|
||||||
|
const replyB2 = await p(bob.db.create)({
|
||||||
|
type: 'post',
|
||||||
|
content: { text: 'B2' },
|
||||||
|
tangles: [rootA.hash],
|
||||||
|
keys: bob.config.keys,
|
||||||
|
})
|
||||||
|
await p(alice.db.add)(replyB1.msg)
|
||||||
|
await p(alice.db.add)(replyB2.msg)
|
||||||
|
|
||||||
|
await p(setTimeout)(10)
|
||||||
|
|
||||||
|
const replyC1 = await p(alice.db.create)({
|
||||||
|
type: 'post',
|
||||||
|
content: { text: 'C1' },
|
||||||
|
tangles: [rootA.hash],
|
||||||
|
keys: carolKeys,
|
||||||
|
})
|
||||||
|
|
||||||
|
await p(setTimeout)(10)
|
||||||
|
|
||||||
|
const replyD1 = await p(bob.db.create)({
|
||||||
|
type: 'post',
|
||||||
|
content: { text: 'D1' },
|
||||||
|
tangles: [rootA.hash],
|
||||||
|
keys: daveKeys,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.deepEquals(
|
||||||
|
[...alice.db.msgs()].map((msg) => msg.content.text),
|
||||||
|
['A', 'B1', 'B2', 'C1'],
|
||||||
|
'alice has a portion of the thread'
|
||||||
|
)
|
||||||
|
|
||||||
|
t.deepEquals(
|
||||||
|
[...bob.db.msgs()].map((msg) => msg.content.text),
|
||||||
|
['A', 'B1', 'B2', 'D1'],
|
||||||
|
'bob has another portion of the thread'
|
||||||
|
)
|
||||||
|
|
||||||
|
const remoteAlice = await p(bob.connect)(alice.getAddress())
|
||||||
|
t.pass('bob connected to alice')
|
||||||
|
|
||||||
|
bob.threadSync.request(rootA.hash)
|
||||||
|
await p(setTimeout)(1000)
|
||||||
|
t.pass('threadSync!')
|
||||||
|
|
||||||
|
t.deepEquals(
|
||||||
|
[...bob.db.msgs()].map((msg) => msg.content.text),
|
||||||
|
['A', 'B1', 'B2', 'D1', 'C1'],
|
||||||
|
'bob has the full thread'
|
||||||
|
)
|
||||||
|
|
||||||
|
t.deepEquals(
|
||||||
|
[...alice.db.msgs()].map((msg) => msg.content.text),
|
||||||
|
['A', 'B1', 'B2', 'C1', 'D1'],
|
||||||
|
'alice has the full thread'
|
||||||
|
)
|
||||||
|
|
||||||
|
await p(remoteAlice.close)(true)
|
||||||
|
await p(alice.close)(true)
|
||||||
|
await p(bob.close)(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sync a thread where first peer does not have the root', async (t) => {
|
||||||
|
const ALICE_DIR = path.join(os.tmpdir(), 'dagsync-alice')
|
||||||
|
const BOB_DIR = path.join(os.tmpdir(), 'dagsync-bob')
|
||||||
|
|
||||||
|
rimraf.sync(ALICE_DIR)
|
||||||
|
rimraf.sync(BOB_DIR)
|
||||||
|
|
||||||
|
const alice = createSSB({
|
||||||
|
keys: ssbKeys.generate('ed25519', 'alice'),
|
||||||
|
path: ALICE_DIR,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bob = createSSB({
|
||||||
|
keys: ssbKeys.generate('ed25519', 'bob'),
|
||||||
|
path: BOB_DIR,
|
||||||
|
})
|
||||||
|
|
||||||
|
await alice.db.loaded()
|
||||||
|
await bob.db.loaded()
|
||||||
|
|
||||||
|
const rootA = await p(alice.db.create)({
|
||||||
|
feedFormat: 'classic',
|
||||||
|
content: { type: 'post', text: 'A' },
|
||||||
|
keys: alice.config.keys,
|
||||||
|
})
|
||||||
|
|
||||||
|
await p(setTimeout)(10)
|
||||||
|
|
||||||
|
const replyA1 = await p(alice.db.create)({
|
||||||
|
feedFormat: 'classic',
|
||||||
|
content: { type: 'post', text: 'A1', root: rootA.key, branch: rootA.key },
|
||||||
|
keys: alice.config.keys,
|
||||||
|
})
|
||||||
|
|
||||||
|
await p(setTimeout)(10)
|
||||||
|
|
||||||
|
const replyA2 = await p(alice.db.create)({
|
||||||
|
feedFormat: 'classic',
|
||||||
|
content: { type: 'post', text: 'A2', root: rootA.key, branch: replyA1.key },
|
||||||
|
keys: alice.config.keys,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.deepEquals(
|
||||||
|
alice.db.filterAsArray((msg) => true).map((msg) => msg.value.content.text),
|
||||||
|
['A', 'A1', 'A2'],
|
||||||
|
'alice has the full thread'
|
||||||
|
)
|
||||||
|
|
||||||
|
t.deepEquals(
|
||||||
|
bob.db.filterAsArray((msg) => true).map((msg) => msg.value.content.text),
|
||||||
|
[],
|
||||||
|
'bob has nothing'
|
||||||
|
)
|
||||||
|
|
||||||
|
const remoteAlice = await p(bob.connect)(alice.getAddress())
|
||||||
|
t.pass('bob connected to alice')
|
||||||
|
|
||||||
|
bob.threadSync.request(rootA.key)
|
||||||
|
await p(setTimeout)(1000)
|
||||||
|
t.pass('threadSync!')
|
||||||
|
|
||||||
|
t.deepEquals(
|
||||||
|
bob.db.filterAsArray((msg) => true).map((msg) => msg.value.content.text),
|
||||||
|
['A', 'A1', 'A2'],
|
||||||
|
'bob has the full thread'
|
||||||
|
)
|
||||||
|
|
||||||
|
await p(remoteAlice.close)(true)
|
||||||
|
await p(alice.close)(true)
|
||||||
|
await p(bob.close)(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sync a thread where second peer does not have the root', async (t) => {
|
||||||
|
const ALICE_DIR = path.join(os.tmpdir(), 'dagsync-alice')
|
||||||
|
const BOB_DIR = path.join(os.tmpdir(), 'dagsync-bob')
|
||||||
|
|
||||||
|
rimraf.sync(ALICE_DIR)
|
||||||
|
rimraf.sync(BOB_DIR)
|
||||||
|
|
||||||
|
const alice = createSSB({
|
||||||
|
keys: ssbKeys.generate('ed25519', 'alice'),
|
||||||
|
path: ALICE_DIR,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bob = createSSB({
|
||||||
|
keys: ssbKeys.generate('ed25519', 'bob'),
|
||||||
|
path: BOB_DIR,
|
||||||
|
})
|
||||||
|
|
||||||
|
await alice.db.loaded()
|
||||||
|
await bob.db.loaded()
|
||||||
|
|
||||||
|
const rootA = await p(alice.db.create)({
|
||||||
|
feedFormat: 'classic',
|
||||||
|
content: { type: 'post', text: 'A' },
|
||||||
|
keys: alice.config.keys,
|
||||||
|
})
|
||||||
|
|
||||||
|
await p(setTimeout)(10)
|
||||||
|
|
||||||
|
const replyA1 = await p(alice.db.create)({
|
||||||
|
feedFormat: 'classic',
|
||||||
|
content: { type: 'post', text: 'A1', root: rootA.key, branch: rootA.key },
|
||||||
|
keys: alice.config.keys,
|
||||||
|
})
|
||||||
|
|
||||||
|
await p(setTimeout)(10)
|
||||||
|
|
||||||
|
const replyA2 = await p(alice.db.create)({
|
||||||
|
feedFormat: 'classic',
|
||||||
|
content: { type: 'post', text: 'A2', root: rootA.key, branch: replyA1.key },
|
||||||
|
keys: alice.config.keys,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.deepEquals(
|
||||||
|
alice.db.filterAsArray((msg) => true).map((msg) => msg.value.content.text),
|
||||||
|
['A', 'A1', 'A2'],
|
||||||
|
'alice has the full thread'
|
||||||
|
)
|
||||||
|
|
||||||
|
t.deepEquals(
|
||||||
|
bob.db.filterAsArray((msg) => true).map((msg) => msg.value.content.text),
|
||||||
|
[],
|
||||||
|
'bob has nothing'
|
||||||
|
)
|
||||||
|
|
||||||
|
const remoteBob = await p(alice.connect)(bob.getAddress())
|
||||||
|
t.pass('alice connected to bob')
|
||||||
|
|
||||||
|
alice.threadSync.request(rootA.key)
|
||||||
|
await p(setTimeout)(1000)
|
||||||
|
t.pass('threadSync!')
|
||||||
|
|
||||||
|
t.deepEquals(
|
||||||
|
bob.db.filterAsArray((msg) => true).map((msg) => msg.value.content.text),
|
||||||
|
['A', 'A1', 'A2'],
|
||||||
|
'bob has the full thread'
|
||||||
|
)
|
||||||
|
|
||||||
|
await p(remoteBob.close)(true)
|
||||||
|
await p(alice.close)(true)
|
||||||
|
await p(bob.close)(true)
|
||||||
|
})
|
|
@ -0,0 +1,14 @@
|
||||||
|
const ssbKeys = require('ssb-keys')
|
||||||
|
const SSBURI = require('ssb-uri2')
|
||||||
|
const base58 = require('bs58')
|
||||||
|
|
||||||
|
function generateKeypair(seed) {
|
||||||
|
const keys = ssbKeys.generate('ed25519', seed, 'buttwoo-v1')
|
||||||
|
const { data } = SSBURI.decompose(keys.id)
|
||||||
|
keys.id = `ppppp:feed/v1/${base58.encode(Buffer.from(data, 'base64'))}`
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateKeypair,
|
||||||
|
}
|
Loading…
Reference in New Issue