@le-space/orbitdb-storage-bridge 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/lib/courier-sync.js +155 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -329,3 +329,20 @@ and how to run each suite.
|
|
|
329
329
|
## License
|
|
330
330
|
|
|
331
331
|
MIT License
|
|
332
|
+
|
|
333
|
+
## Trademarks
|
|
334
|
+
|
|
335
|
+
LoRa® is a trademark of Semtech Corporation. Meshtastic® is a registered
|
|
336
|
+
trademark of Meshtastic LLC. Other names used here — OrbitDB, IPFS, Filecoin,
|
|
337
|
+
Storacha, Pinata, Lighthouse, Aleph — belong to their respective owners and
|
|
338
|
+
appear only to say what this package interoperates with. This project is not
|
|
339
|
+
affiliated with or endorsed by any of them.
|
|
340
|
+
|
|
341
|
+
The first two appear in this repository only in prose about what the courier
|
|
342
|
+
seam is *for*: `courier-sync` is transport-neutral and this package contains no
|
|
343
|
+
code from either project and depends on neither. That is deliberate rather than
|
|
344
|
+
incidental. The Meshtastic client libraries (`@meshtastic/core`,
|
|
345
|
+
`@meshtastic/transport-web-bluetooth`) are **GPL-3.0-only**, and the courier
|
|
346
|
+
that drives them lives in [funkpost](https://github.com/NiKrause/funkpost), on
|
|
347
|
+
the GPL side of that line. This package is MIT and stays that way; the door
|
|
348
|
+
only opens one way.
|
package/lib/courier-sync.js
CHANGED
|
@@ -165,6 +165,61 @@ function isOplogEntry(value) {
|
|
|
165
165
|
);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* What the peer can be assumed to hold, given the heads it named.
|
|
170
|
+
*
|
|
171
|
+
* The heads alone are not a stop set. An OrbitDB entry's `refs` are skip-list
|
|
172
|
+
* back-references that point *past* its parent, so a walk that stops only at
|
|
173
|
+
* the named hashes follows a ref around them and carries on to the root: a peer
|
|
174
|
+
* missing one entry was sent the whole log, measured at 12 blocks and 8742 B
|
|
175
|
+
* where 2 blocks and 1533 B were owed (funkpost's two phones over LoRa, #127).
|
|
176
|
+
* At half a kilobyte a minute that is the difference between a list that syncs
|
|
177
|
+
* and one that cannot.
|
|
178
|
+
*
|
|
179
|
+
* A head is a claim about everything below it, so the closure below those heads
|
|
180
|
+
* is what the peer holds. It is walked over blocks *we* hold; where we cannot
|
|
181
|
+
* follow, that ancestry stays unknown and so stays out of the stop set, which
|
|
182
|
+
* errs towards sending — the direction that costs bytes rather than
|
|
183
|
+
* correctness.
|
|
184
|
+
*
|
|
185
|
+
* Reading the whole ancestry locally to avoid transmitting it is a good trade
|
|
186
|
+
* on any carrier: the reads are a blockstore away, the bytes are airtime.
|
|
187
|
+
*/
|
|
188
|
+
async function reachableFrom(db, roots) {
|
|
189
|
+
const held = new Set();
|
|
190
|
+
const queue = [...roots];
|
|
191
|
+
while (queue.length > 0) {
|
|
192
|
+
const hash = queue.shift();
|
|
193
|
+
if (held.has(hash)) continue;
|
|
194
|
+
held.add(hash);
|
|
195
|
+
// The index first, and only then the blocks. `IPFSBlockStorage.get` on a
|
|
196
|
+
// miss waits out a network timeout — measured at 20 s against a Helia node
|
|
197
|
+
// with no peers, which is every phone in the field — and the peer's heads
|
|
198
|
+
// are precisely where misses live: their newest entry is the one we have
|
|
199
|
+
// not got. `has` reads the log's index and answers at once.
|
|
200
|
+
//
|
|
201
|
+
// The rule is already written down twenty lines below, in the closure
|
|
202
|
+
// check, and this walk broke it: 0.14.0 built correct deltas and took
|
|
203
|
+
// twenty seconds to do it, which stalled the exchange past every timeout
|
|
204
|
+
// around it (funkpost#170). The suite could not see it, because its nodes
|
|
205
|
+
// are built offline and a miss there fails instantly.
|
|
206
|
+
if (!(await db.log.has(hash))) continue; // theirs, not ours: stop here
|
|
207
|
+
const bytes = await db.log.storage.get(hash).catch(() => null);
|
|
208
|
+
if (!bytes) continue; // not ours to follow; their ancestry ends here for us
|
|
209
|
+
let value;
|
|
210
|
+
try {
|
|
211
|
+
value = dagCbor.decode(bytes);
|
|
212
|
+
} catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!isOplogEntry(value)) continue;
|
|
216
|
+
for (const parent of [...value.next, ...(value.refs || [])]) {
|
|
217
|
+
if (!held.has(parent)) queue.push(parent);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return held;
|
|
221
|
+
}
|
|
222
|
+
|
|
168
223
|
/**
|
|
169
224
|
* Compute the delta a peer with `theirHeads` is missing: entry blocks from our
|
|
170
225
|
* heads down to their heads, the identity blocks those entries reference, and
|
|
@@ -184,10 +239,19 @@ function isOplogEntry(value) {
|
|
|
184
239
|
* @returns {Promise<{heads: Array<string>, blocks: Array<{hash: string, bytes: Uint8Array}>}>}
|
|
185
240
|
*/
|
|
186
241
|
export async function createDelta({ db, theirHeads = [] }) {
|
|
187
|
-
const stop = new Set(theirHeads);
|
|
188
242
|
const heads = await db.log.heads();
|
|
189
243
|
const headHashes = heads.map((entry) => entry.hash);
|
|
190
244
|
|
|
245
|
+
// The peer stands exactly where we do: nothing is owed, and the ancestry
|
|
246
|
+
// need not be read to find that out. This is the steady state between two
|
|
247
|
+
// quiet peers, so it is worth answering before the walk below.
|
|
248
|
+
const named = new Set(theirHeads);
|
|
249
|
+
if (headHashes.length > 0 && headHashes.every((hash) => named.has(hash))) {
|
|
250
|
+
return { heads: headHashes, blocks: [] };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const stop = await reachableFrom(db, theirHeads);
|
|
254
|
+
|
|
191
255
|
const seen = new Set();
|
|
192
256
|
const identityHashes = new Set();
|
|
193
257
|
const entryBlocks = [];
|
|
@@ -267,7 +331,9 @@ export async function createDelta({ db, theirHeads = [] }) {
|
|
|
267
331
|
* @param {Object} params
|
|
268
332
|
* @param {Object} params.db An open OrbitDB database
|
|
269
333
|
* @param {{heads: Array<string>, blocks: Array<{hash: string, bytes: Uint8Array}>}} params.delta
|
|
270
|
-
* @returns {Promise<{complete: boolean, joined: number, missing: Array<string>,
|
|
334
|
+
* @returns {Promise<{complete: boolean, joined: number, missing: Array<string>,
|
|
335
|
+
* entries: Array<Object>, heads: number, outcome: Object}>} `heads` is how
|
|
336
|
+
* many were offered and `outcome` says what became of each — see `noJoins`.
|
|
271
337
|
*/
|
|
272
338
|
export async function applyDelta({ db, delta }) {
|
|
273
339
|
return applyDeltaToStores({
|
|
@@ -289,7 +355,39 @@ function dbBlockstore(db) {
|
|
|
289
355
|
};
|
|
290
356
|
}
|
|
291
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Why a head did not join.
|
|
360
|
+
*
|
|
361
|
+
* The join loop below has exactly five exits, and from outside four of them
|
|
362
|
+
* look the same: no join, and — since "synced" only fires when something
|
|
363
|
+
* joined — no event at all. That silence is what left a day of field logs
|
|
364
|
+
* unreadable. Two phones over LoRa received five complete deltas and joined
|
|
365
|
+
* nothing all day, and the log could not say whether the courier was working
|
|
366
|
+
* or broken, because the two findings that matter produce identical silence:
|
|
367
|
+
*
|
|
368
|
+
* held the entry was already in the log. Another route brought it first;
|
|
369
|
+
* the courier delivered something nobody needed, which is wasteful
|
|
370
|
+
* but correct.
|
|
371
|
+
* absent the sender named a head and did not send it. A defect in the
|
|
372
|
+
* delta it built, and the database does not move.
|
|
373
|
+
*
|
|
374
|
+
* Opposite repairs, one symptom. `malformed` and `refused` should not happen
|
|
375
|
+
* at all; they are counted separately rather than folded into `absent` so
|
|
376
|
+
* that "should not happen" stays falsifiable in a field log.
|
|
377
|
+
*
|
|
378
|
+
* Reported for every delivery through the "applied" event, including the
|
|
379
|
+
* deliveries that came to nothing — those are the interesting ones.
|
|
380
|
+
*/
|
|
381
|
+
const noJoins = () => ({
|
|
382
|
+
joined: 0,
|
|
383
|
+
held: 0,
|
|
384
|
+
absent: 0,
|
|
385
|
+
malformed: 0,
|
|
386
|
+
refused: 0,
|
|
387
|
+
});
|
|
388
|
+
|
|
292
389
|
async function applyDeltaToStores({ blockstore, log, events, delta }) {
|
|
390
|
+
const heads = delta.heads || [];
|
|
293
391
|
const inDelta = new Map();
|
|
294
392
|
for (const block of delta.blocks || []) {
|
|
295
393
|
inDelta.set(block.hash, block.bytes);
|
|
@@ -312,32 +410,56 @@ async function applyDeltaToStores({ blockstore, log, events, delta }) {
|
|
|
312
410
|
}
|
|
313
411
|
}
|
|
314
412
|
if (missing.length > 0) {
|
|
315
|
-
return {
|
|
413
|
+
return {
|
|
414
|
+
complete: false,
|
|
415
|
+
joined: 0,
|
|
416
|
+
missing,
|
|
417
|
+
entries: [],
|
|
418
|
+
heads: heads.length,
|
|
419
|
+
outcome: noJoins(),
|
|
420
|
+
};
|
|
316
421
|
}
|
|
317
422
|
|
|
318
|
-
|
|
423
|
+
const outcome = noJoins();
|
|
319
424
|
const entries = [];
|
|
320
|
-
for (const hash of
|
|
321
|
-
if (await log.has(hash))
|
|
425
|
+
for (const hash of heads) {
|
|
426
|
+
if (await log.has(hash)) {
|
|
427
|
+
outcome.held++;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
322
430
|
const bytes = inDelta.get(hash);
|
|
323
|
-
if (!bytes)
|
|
431
|
+
if (!bytes) {
|
|
432
|
+
outcome.absent++;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
324
435
|
const value = dagCbor.decode(bytes);
|
|
325
|
-
if (!isOplogEntry(value))
|
|
436
|
+
if (!isOplogEntry(value)) {
|
|
437
|
+
outcome.malformed++;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
326
440
|
const entry = { ...value, hash };
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
entries.push(entry);
|
|
441
|
+
if (!(await log.joinEntry(entry))) {
|
|
442
|
+
outcome.refused++;
|
|
443
|
+
continue;
|
|
331
444
|
}
|
|
445
|
+
outcome.joined++;
|
|
446
|
+
entries.push(entry);
|
|
332
447
|
}
|
|
333
448
|
|
|
334
449
|
// Database.applyOperation emits 'update' when the pubsub Sync delivers an
|
|
335
450
|
// entry; a courier delivery is the same event from the application's side.
|
|
336
|
-
if (events && joined > 0) {
|
|
451
|
+
if (events && outcome.joined > 0) {
|
|
337
452
|
for (const entry of entries) events.emit("update", entry);
|
|
338
453
|
}
|
|
339
454
|
|
|
340
|
-
return {
|
|
455
|
+
return {
|
|
456
|
+
complete: true,
|
|
457
|
+
joined: outcome.joined,
|
|
458
|
+
missing: [],
|
|
459
|
+
entries,
|
|
460
|
+
heads: heads.length,
|
|
461
|
+
outcome,
|
|
462
|
+
};
|
|
341
463
|
}
|
|
342
464
|
|
|
343
465
|
/**
|
|
@@ -373,6 +495,14 @@ async function applyDeltaToStores({ blockstore, log, events, delta }) {
|
|
|
373
495
|
* carrier that cannot keep up before the oldest is dropped.
|
|
374
496
|
* @returns {Promise<Object>} sync handle: { start, stop, announce, hello,
|
|
375
497
|
* presence, forgetPeers, db(), events }
|
|
498
|
+
*
|
|
499
|
+
* Events, via `sync.on(name, cb)`:
|
|
500
|
+
* "message" { direction, type, bytes } one message on or off the carrier
|
|
501
|
+
* "synced" { joined, entries } the database moved
|
|
502
|
+
* "applied" { complete, heads, missing, joined, held, absent, malformed,
|
|
503
|
+
* refused } what a `blocks` delivery came to,
|
|
504
|
+
* fired even when it came to nothing
|
|
505
|
+
* "error" Error a delivery that threw
|
|
376
506
|
*/
|
|
377
507
|
export async function createCourierSync({
|
|
378
508
|
db = null,
|
|
@@ -411,7 +541,7 @@ export async function createCourierSync({
|
|
|
411
541
|
const pendingBlocks = new Map(); // hash -> bytes, parked until the database can open
|
|
412
542
|
const peers = new Map(); // sender id (hex) -> when we last heard it
|
|
413
543
|
let lastHeardAt = null; // any traffic for this database, identified or not
|
|
414
|
-
const listeners = { synced: [], message: [], error: [] };
|
|
544
|
+
const listeners = { synced: [], applied: [], message: [], error: [] };
|
|
415
545
|
let database = db;
|
|
416
546
|
// Opened on first contact but not handed out: the bootstrap is not in it yet.
|
|
417
547
|
// The protocol works on it all the same, so repair stays incremental.
|
|
@@ -723,6 +853,16 @@ export async function createCourierSync({
|
|
|
723
853
|
} finally {
|
|
724
854
|
applying = false;
|
|
725
855
|
}
|
|
856
|
+
// What the delivery came to, always — including when it came to nothing.
|
|
857
|
+
// A delta that moves the database is a "synced"; a delta that moves
|
|
858
|
+
// nothing is either a courier doing no harm or a courier doing no good,
|
|
859
|
+
// and this is the only line that tells them apart.
|
|
860
|
+
emit("applied", {
|
|
861
|
+
complete: result.complete,
|
|
862
|
+
heads: result.heads,
|
|
863
|
+
missing: result.missing.length,
|
|
864
|
+
...result.outcome,
|
|
865
|
+
});
|
|
726
866
|
if (!result.complete) {
|
|
727
867
|
post(
|
|
728
868
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@le-space/orbitdb-storage-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Back up, restore and replicate OrbitDB databases through pluggable storage backends, with hash and identity preservation",
|
|
5
5
|
"main": "lib/orbitdb-storacha-bridge.js",
|
|
6
6
|
"svelte": "dist/components/",
|