@le-space/orbitdb-storage-bridge 0.13.0 → 0.14.0

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.
Files changed (2) hide show
  1. package/lib/courier-sync.js +143 -15
  2. package/package.json +1 -1
@@ -165,6 +165,49 @@ 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
+ const bytes = await db.log.storage.get(hash).catch(() => null);
196
+ if (!bytes) continue; // not ours to follow; their ancestry ends here for us
197
+ let value;
198
+ try {
199
+ value = dagCbor.decode(bytes);
200
+ } catch {
201
+ continue;
202
+ }
203
+ if (!isOplogEntry(value)) continue;
204
+ for (const parent of [...value.next, ...(value.refs || [])]) {
205
+ if (!held.has(parent)) queue.push(parent);
206
+ }
207
+ }
208
+ return held;
209
+ }
210
+
168
211
  /**
169
212
  * Compute the delta a peer with `theirHeads` is missing: entry blocks from our
170
213
  * heads down to their heads, the identity blocks those entries reference, and
@@ -184,10 +227,19 @@ function isOplogEntry(value) {
184
227
  * @returns {Promise<{heads: Array<string>, blocks: Array<{hash: string, bytes: Uint8Array}>}>}
185
228
  */
186
229
  export async function createDelta({ db, theirHeads = [] }) {
187
- const stop = new Set(theirHeads);
188
230
  const heads = await db.log.heads();
189
231
  const headHashes = heads.map((entry) => entry.hash);
190
232
 
233
+ // The peer stands exactly where we do: nothing is owed, and the ancestry
234
+ // need not be read to find that out. This is the steady state between two
235
+ // quiet peers, so it is worth answering before the walk below.
236
+ const named = new Set(theirHeads);
237
+ if (headHashes.length > 0 && headHashes.every((hash) => named.has(hash))) {
238
+ return { heads: headHashes, blocks: [] };
239
+ }
240
+
241
+ const stop = await reachableFrom(db, theirHeads);
242
+
191
243
  const seen = new Set();
192
244
  const identityHashes = new Set();
193
245
  const entryBlocks = [];
@@ -267,7 +319,9 @@ export async function createDelta({ db, theirHeads = [] }) {
267
319
  * @param {Object} params
268
320
  * @param {Object} params.db An open OrbitDB database
269
321
  * @param {{heads: Array<string>, blocks: Array<{hash: string, bytes: Uint8Array}>}} params.delta
270
- * @returns {Promise<{complete: boolean, joined: number, missing: Array<string>, entries: Array<Object>}>}
322
+ * @returns {Promise<{complete: boolean, joined: number, missing: Array<string>,
323
+ * entries: Array<Object>, heads: number, outcome: Object}>} `heads` is how
324
+ * many were offered and `outcome` says what became of each — see `noJoins`.
271
325
  */
272
326
  export async function applyDelta({ db, delta }) {
273
327
  return applyDeltaToStores({
@@ -289,7 +343,39 @@ function dbBlockstore(db) {
289
343
  };
290
344
  }
291
345
 
346
+ /**
347
+ * Why a head did not join.
348
+ *
349
+ * The join loop below has exactly five exits, and from outside four of them
350
+ * look the same: no join, and — since "synced" only fires when something
351
+ * joined — no event at all. That silence is what left a day of field logs
352
+ * unreadable. Two phones over LoRa received five complete deltas and joined
353
+ * nothing all day, and the log could not say whether the courier was working
354
+ * or broken, because the two findings that matter produce identical silence:
355
+ *
356
+ * held the entry was already in the log. Another route brought it first;
357
+ * the courier delivered something nobody needed, which is wasteful
358
+ * but correct.
359
+ * absent the sender named a head and did not send it. A defect in the
360
+ * delta it built, and the database does not move.
361
+ *
362
+ * Opposite repairs, one symptom. `malformed` and `refused` should not happen
363
+ * at all; they are counted separately rather than folded into `absent` so
364
+ * that "should not happen" stays falsifiable in a field log.
365
+ *
366
+ * Reported for every delivery through the "applied" event, including the
367
+ * deliveries that came to nothing — those are the interesting ones.
368
+ */
369
+ const noJoins = () => ({
370
+ joined: 0,
371
+ held: 0,
372
+ absent: 0,
373
+ malformed: 0,
374
+ refused: 0,
375
+ });
376
+
292
377
  async function applyDeltaToStores({ blockstore, log, events, delta }) {
378
+ const heads = delta.heads || [];
293
379
  const inDelta = new Map();
294
380
  for (const block of delta.blocks || []) {
295
381
  inDelta.set(block.hash, block.bytes);
@@ -312,32 +398,56 @@ async function applyDeltaToStores({ blockstore, log, events, delta }) {
312
398
  }
313
399
  }
314
400
  if (missing.length > 0) {
315
- return { complete: false, joined: 0, missing, entries: [] };
401
+ return {
402
+ complete: false,
403
+ joined: 0,
404
+ missing,
405
+ entries: [],
406
+ heads: heads.length,
407
+ outcome: noJoins(),
408
+ };
316
409
  }
317
410
 
318
- let joined = 0;
411
+ const outcome = noJoins();
319
412
  const entries = [];
320
- for (const hash of delta.heads || []) {
321
- if (await log.has(hash)) continue;
413
+ for (const hash of heads) {
414
+ if (await log.has(hash)) {
415
+ outcome.held++;
416
+ continue;
417
+ }
322
418
  const bytes = inDelta.get(hash);
323
- if (!bytes) continue;
419
+ if (!bytes) {
420
+ outcome.absent++;
421
+ continue;
422
+ }
324
423
  const value = dagCbor.decode(bytes);
325
- if (!isOplogEntry(value)) continue;
424
+ if (!isOplogEntry(value)) {
425
+ outcome.malformed++;
426
+ continue;
427
+ }
326
428
  const entry = { ...value, hash };
327
- const updated = await log.joinEntry(entry);
328
- if (updated) {
329
- joined++;
330
- entries.push(entry);
429
+ if (!(await log.joinEntry(entry))) {
430
+ outcome.refused++;
431
+ continue;
331
432
  }
433
+ outcome.joined++;
434
+ entries.push(entry);
332
435
  }
333
436
 
334
437
  // Database.applyOperation emits 'update' when the pubsub Sync delivers an
335
438
  // entry; a courier delivery is the same event from the application's side.
336
- if (events && joined > 0) {
439
+ if (events && outcome.joined > 0) {
337
440
  for (const entry of entries) events.emit("update", entry);
338
441
  }
339
442
 
340
- return { complete: true, joined, missing: [], entries };
443
+ return {
444
+ complete: true,
445
+ joined: outcome.joined,
446
+ missing: [],
447
+ entries,
448
+ heads: heads.length,
449
+ outcome,
450
+ };
341
451
  }
342
452
 
343
453
  /**
@@ -373,6 +483,14 @@ async function applyDeltaToStores({ blockstore, log, events, delta }) {
373
483
  * carrier that cannot keep up before the oldest is dropped.
374
484
  * @returns {Promise<Object>} sync handle: { start, stop, announce, hello,
375
485
  * presence, forgetPeers, db(), events }
486
+ *
487
+ * Events, via `sync.on(name, cb)`:
488
+ * "message" { direction, type, bytes } one message on or off the carrier
489
+ * "synced" { joined, entries } the database moved
490
+ * "applied" { complete, heads, missing, joined, held, absent, malformed,
491
+ * refused } what a `blocks` delivery came to,
492
+ * fired even when it came to nothing
493
+ * "error" Error a delivery that threw
376
494
  */
377
495
  export async function createCourierSync({
378
496
  db = null,
@@ -411,7 +529,7 @@ export async function createCourierSync({
411
529
  const pendingBlocks = new Map(); // hash -> bytes, parked until the database can open
412
530
  const peers = new Map(); // sender id (hex) -> when we last heard it
413
531
  let lastHeardAt = null; // any traffic for this database, identified or not
414
- const listeners = { synced: [], message: [], error: [] };
532
+ const listeners = { synced: [], applied: [], message: [], error: [] };
415
533
  let database = db;
416
534
  // Opened on first contact but not handed out: the bootstrap is not in it yet.
417
535
  // The protocol works on it all the same, so repair stays incremental.
@@ -723,6 +841,16 @@ export async function createCourierSync({
723
841
  } finally {
724
842
  applying = false;
725
843
  }
844
+ // What the delivery came to, always — including when it came to nothing.
845
+ // A delta that moves the database is a "synced"; a delta that moves
846
+ // nothing is either a courier doing no harm or a courier doing no good,
847
+ // and this is the only line that tells them apart.
848
+ emit("applied", {
849
+ complete: result.complete,
850
+ heads: result.heads,
851
+ missing: result.missing.length,
852
+ ...result.outcome,
853
+ });
726
854
  if (!result.complete) {
727
855
  post(
728
856
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-storage-bridge",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
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/",