@le-space/orbitdb-storage-bridge 0.10.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.
@@ -0,0 +1,886 @@
1
+ /**
2
+ * Courier Sync — transport-neutral OrbitDB replication over any byte courier.
3
+ *
4
+ * The load-bearing fact this module builds on is the one this bridge proves
5
+ * with Storacha: OrbitDB replication is "obtain the blocks, join the heads".
6
+ * The courier is interchangeable — Storacha, a LoRa mesh, a QR relay, a file.
7
+ * Design thread: https://github.com/NiKrause/funkpost/issues/1
8
+ * Seam requirements: https://github.com/NiKrause/orbitdb-storage-bridge/issues/50
9
+ *
10
+ * The courier contract (the seam):
11
+ * courier.send(bytes: Uint8Array): Promise<void>
12
+ * May be slow on purpose — it resolves when the courier has delivered or
13
+ * scheduled the message within whatever budget it has (a duty-cycled radio
14
+ * legally may not hurry). The sync layer treats that as backpressure.
15
+ * courier.onPayload(cb: (bytes: Uint8Array) => void): () => void
16
+ * Delivery may be lossy, reordered and duplicated; the protocol tolerates
17
+ * all three. Returns an unsubscribe function.
18
+ *
19
+ * Wire messages (dag-cbor encoded, one per courier payload):
20
+ * { v, tag, p, t: "announce", heads: [hash] }
21
+ * { v, tag, p, t: "want", cids: [hash], have: [hash] }
22
+ * { v, tag, p, t: "blocks", heads: [hash], blocks: [{ hash, bytes }] }
23
+ * { v, tag, p, t: "hello" } is anybody keeping this database out there?
24
+ * { v, tag, p, t: "here" } the answer
25
+ * `tag` is a short hash of the database address, so couriers can be shared
26
+ * between databases without cross-talk while the address itself stays off
27
+ * the air (the mesh reads everything).
28
+ *
29
+ * `p` is a four-byte sender id, and it is what makes *presence* possible: a
30
+ * carrier can tell you a radio is in range, which is not the question. The
31
+ * question is whether another program is keeping the same database, and only
32
+ * that program can answer it. Every message carries the id, so ordinary
33
+ * traffic already answers it for free; `hello` exists for the silence in
34
+ * between, when nothing has been written for a while and somebody wants to
35
+ * know before spending airtime on a whole delta.
36
+ *
37
+ * The id is per instance and says nothing about who you are — the tag already
38
+ * names the conversation, and a mesh reads everything. A peer on an older
39
+ * version sends no id and answers no `hello`: its traffic still counts as
40
+ * "somebody is out there" (`lastHeardAgoMs`), it just cannot be counted as a
41
+ * peer. Answers go out immediately and without jitter on purpose; the radio
42
+ * underneath already has a MAC, and backing off twice is worse than once.
43
+ */
44
+
45
+ /* global CompressionStream, DecompressionStream */
46
+ import { CID } from "multiformats/cid";
47
+ import { base58btc } from "multiformats/bases/base58";
48
+ import { sha256 } from "multiformats/hashes/sha2";
49
+ import * as dagCbor from "@ipld/dag-cbor";
50
+
51
+ export const COURIER_SYNC_VERSION = 1;
52
+
53
+ const TAG_LENGTH = 8;
54
+
55
+ // Four bytes of sender id: enough that two peers in one conversation collide
56
+ // with probability ~1 in 4 billion, small enough to ride on every message.
57
+ const PEER_ID_LENGTH = 4;
58
+ // How long a peer stays "present" after its last word. A mesh is slow and a
59
+ // budget is rationed, so silence for two minutes is ordinary, not absence.
60
+ const PEER_TIMEOUT_MS = 120_000;
61
+
62
+ // How long to wait for a courier to say a message went out. Deliberately far
63
+ // beyond any honest delivery: it is not a deadline, it is the way out of a
64
+ // carrier that has stopped answering altogether.
65
+ const SEND_TIMEOUT_MS = 300_000;
66
+ // How many messages may wait for a carrier that cannot keep up.
67
+ const MAX_OUTBOX = 32;
68
+
69
+ // Wire framing: one prefix byte in front of the dag-cbor message — 0 = raw,
70
+ // 1 = gzip. The first-contact bootstrap (manifest + access controller +
71
+ // identity + entries) is a couple of kilobytes of dag-cbor full of CIDs and
72
+ // signatures, which deflates by roughly half; on a slow, lossy carrier like a
73
+ // LoRa mesh that is the difference between a bootstrap that clears the ARQ's
74
+ // rounds and one that does not. Small messages (announce, want) skip it.
75
+ const GZIP_PREFIX = 1;
76
+ const RAW_PREFIX = 0;
77
+ const GZIP_THRESHOLD = 256;
78
+
79
+ const prefixBytes = (flag, body) => {
80
+ const out = new Uint8Array(body.length + 1);
81
+ out[0] = flag;
82
+ out.set(body, 1);
83
+ return out;
84
+ };
85
+
86
+ async function gzipBytes(input) {
87
+ const cs = new CompressionStream("gzip");
88
+ const writer = cs.writable.getWriter();
89
+ writer.write(input);
90
+ writer.close();
91
+ return new Uint8Array(await new Response(cs.readable).arrayBuffer());
92
+ }
93
+
94
+ async function gunzipBytes(input) {
95
+ const ds = new DecompressionStream("gzip");
96
+ const writer = ds.writable.getWriter();
97
+ writer.write(input);
98
+ writer.close();
99
+ return new Uint8Array(await new Response(ds.readable).arrayBuffer());
100
+ }
101
+
102
+ /** dag-cbor bytes → framed wire bytes (compressed when it helps). */
103
+ async function frameMessage(raw) {
104
+ if (typeof CompressionStream === "undefined" || raw.length < GZIP_THRESHOLD) {
105
+ return prefixBytes(RAW_PREFIX, raw);
106
+ }
107
+ try {
108
+ const z = await gzipBytes(raw);
109
+ // Only ship the compressed form if it is actually smaller.
110
+ if (z.length + 1 < raw.length) return prefixBytes(GZIP_PREFIX, z);
111
+ } catch {
112
+ /* fall through to raw */
113
+ }
114
+ return prefixBytes(RAW_PREFIX, raw);
115
+ }
116
+
117
+ /** framed wire bytes → dag-cbor bytes (throws on foreign/garbage input). */
118
+ async function unframeMessage(framed) {
119
+ if (!(framed instanceof Uint8Array) || framed.length === 0) {
120
+ throw new Error("empty frame");
121
+ }
122
+ const body = framed.subarray(1);
123
+ if (framed[0] === GZIP_PREFIX) return gunzipBytes(body);
124
+ if (framed[0] === RAW_PREFIX) return body;
125
+ throw new Error("unknown frame prefix"); // not ours
126
+ }
127
+
128
+ /**
129
+ * Short identifier for a database address: first bytes of its sha256.
130
+ * @param {string} address OrbitDB address (/orbitdb/zdpu...)
131
+ * @returns {Promise<Uint8Array>}
132
+ */
133
+ export async function databaseTag(address) {
134
+ const digest = await sha256.digest(new TextEncoder().encode(address));
135
+ return digest.digest.slice(0, TAG_LENGTH);
136
+ }
137
+
138
+ function sameTag(a, b) {
139
+ if (!(a instanceof Uint8Array) || a.length !== TAG_LENGTH) return false;
140
+ return a.every((byte, i) => byte === b[i]);
141
+ }
142
+
143
+ /**
144
+ * A sender id for one sync instance: four random bytes, not an identity.
145
+ * @returns {Uint8Array}
146
+ */
147
+ function randomPeerId() {
148
+ return globalThis.crypto.getRandomValues(new Uint8Array(PEER_ID_LENGTH));
149
+ }
150
+
151
+ const hex = (bytes) =>
152
+ [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
153
+
154
+ function manifestCidOf(address) {
155
+ return address.split("/").pop();
156
+ }
157
+
158
+ function isOplogEntry(value) {
159
+ return Boolean(
160
+ value &&
161
+ typeof value === "object" &&
162
+ value.sig &&
163
+ value.payload !== undefined &&
164
+ Array.isArray(value.next),
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Compute the delta a peer with `theirHeads` is missing: entry blocks from our
170
+ * heads down to their heads, the identity blocks those entries reference, and
171
+ * — on first contact (empty `theirHeads`) — the manifest and access controller
172
+ * blocks a fresh peer needs before it can even open the database.
173
+ *
174
+ * Blocks are returned parents-before-children so a receiver can verify the
175
+ * chain without ever reaching for a network that is not there.
176
+ *
177
+ * When the logs have diverged below `theirHeads`, the delta may include blocks
178
+ * the peer already has; applying is idempotent, so that costs bytes, not
179
+ * correctness. (A frontier/bloom exchange can shrink this later.)
180
+ *
181
+ * @param {Object} params
182
+ * @param {Object} params.db An open OrbitDB database
183
+ * @param {Array<string>} [params.theirHeads] Head hashes the peer announced
184
+ * @returns {Promise<{heads: Array<string>, blocks: Array<{hash: string, bytes: Uint8Array}>}>}
185
+ */
186
+ export async function createDelta({ db, theirHeads = [] }) {
187
+ const stop = new Set(theirHeads);
188
+ const heads = await db.log.heads();
189
+ const headHashes = heads.map((entry) => entry.hash);
190
+
191
+ const seen = new Set();
192
+ const identityHashes = new Set();
193
+ const entryBlocks = [];
194
+ const queue = headHashes.filter((hash) => !stop.has(hash));
195
+
196
+ while (queue.length > 0) {
197
+ const hash = queue.shift();
198
+ if (seen.has(hash) || stop.has(hash)) continue;
199
+ seen.add(hash);
200
+
201
+ const bytes = await db.log.storage.get(hash);
202
+ if (!bytes) continue;
203
+ entryBlocks.push({ hash, bytes });
204
+
205
+ const value = dagCbor.decode(bytes);
206
+ if (!isOplogEntry(value)) continue;
207
+ if (value.identity) identityHashes.add(value.identity);
208
+ for (const parent of [...value.next, ...(value.refs || [])]) {
209
+ if (!seen.has(parent) && !stop.has(parent)) queue.push(parent);
210
+ }
211
+ }
212
+
213
+ const staticBlocks = [];
214
+
215
+ // Identity blocks travel with the entries that reference them — a writer the
216
+ // peer has never seen costs one extra block, a known writer costs a
217
+ // duplicate put, which is free.
218
+ //
219
+ // Our own identity carries its block; the log's storage may never have held
220
+ // it. `Identities()` without `ipfs` — what an app with its own identity
221
+ // provider builds, a passkey or a DID — keeps identities in memory, and a
222
+ // Helia blockstore asked for one searches a network that, on the far side of
223
+ // a courier, is not there. The delta would then go out without the block the
224
+ // receiver needs to verify these very entries.
225
+ const ownIdentity = db.identity ?? db.log.identity;
226
+ for (const identityHash of identityHashes) {
227
+ const bytes =
228
+ ownIdentity?.hash === identityHash && ownIdentity.bytes
229
+ ? ownIdentity.bytes
230
+ : await db.log.storage.get(identityHash);
231
+ if (bytes) staticBlocks.push({ hash: identityHash, bytes });
232
+ }
233
+
234
+ // First contact additionally needs the manifest and the access controller,
235
+ // or the peer cannot open the address at all.
236
+ if (theirHeads.length === 0) {
237
+ const manifestCid = manifestCidOf(db.address);
238
+ const manifestBytes = await db.log.storage.get(manifestCid);
239
+ if (manifestBytes) {
240
+ staticBlocks.push({ hash: manifestCid, bytes: manifestBytes });
241
+ const manifest = dagCbor.decode(manifestBytes);
242
+ if (manifest && manifest.accessController) {
243
+ const accessCid = manifest.accessController.replace("/ipfs/", "");
244
+ const accessBytes = await db.log.storage.get(accessCid);
245
+ if (accessBytes)
246
+ staticBlocks.push({ hash: accessCid, bytes: accessBytes });
247
+ }
248
+ }
249
+ }
250
+
251
+ // Parents before children: entryBlocks were collected heads-first, so the
252
+ // reversed order is oldest-first; static blocks go before everything.
253
+ return {
254
+ heads: headHashes,
255
+ blocks: [...staticBlocks, ...entryBlocks.reverse()],
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Apply a delta to a local blockstore and join its heads into the log.
261
+ *
262
+ * All blocks are put first; then, before any join, the entry chain is checked
263
+ * for closure — every `next`/`refs` reference must be present in the delta or
264
+ * already in the log. `joinEntry` would otherwise reach into block storage for
265
+ * the missing parent and time out against a network that is not there.
266
+ *
267
+ * @param {Object} params
268
+ * @param {Object} params.db An open OrbitDB database
269
+ * @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>}>}
271
+ */
272
+ export async function applyDelta({ db, delta }) {
273
+ return applyDeltaToStores({
274
+ blockstore: dbBlockstore(db),
275
+ log: db.log,
276
+ events: db.events,
277
+ delta,
278
+ });
279
+ }
280
+
281
+ function dbBlockstore(db) {
282
+ // Database instances do not expose their Helia handle; the log's entry
283
+ // storage is Composed(LRU, IPFSBlockStorage) and writing through it lands in
284
+ // the same blockstore `joinEntry` reads from.
285
+ return {
286
+ put: async (hash, bytes) => {
287
+ await db.log.storage.put(hash, bytes);
288
+ },
289
+ };
290
+ }
291
+
292
+ async function applyDeltaToStores({ blockstore, log, events, delta }) {
293
+ const inDelta = new Map();
294
+ for (const block of delta.blocks || []) {
295
+ inDelta.set(block.hash, block.bytes);
296
+ await blockstore.put(block.hash, block.bytes);
297
+ }
298
+
299
+ // Closure check before joining anything. Only the delta itself and the
300
+ // log's own index are consulted — never raw block storage, whose `get`
301
+ // waits out a 30-second network timeout on a miss, against a network that
302
+ // is not there. Anything received in an earlier partial delivery is still
303
+ // in the delta, because the caller keeps blocks parked until completeness.
304
+ const missing = [];
305
+ for (const [, bytes] of inDelta) {
306
+ const value = dagCbor.decode(bytes);
307
+ if (!isOplogEntry(value)) continue;
308
+ for (const parent of [...value.next, ...(value.refs || [])]) {
309
+ if (inDelta.has(parent)) continue;
310
+ if (await log.has(parent)) continue;
311
+ missing.push(parent);
312
+ }
313
+ }
314
+ if (missing.length > 0) {
315
+ return { complete: false, joined: 0, missing, entries: [] };
316
+ }
317
+
318
+ let joined = 0;
319
+ const entries = [];
320
+ for (const hash of delta.heads || []) {
321
+ if (await log.has(hash)) continue;
322
+ const bytes = inDelta.get(hash);
323
+ if (!bytes) continue;
324
+ const value = dagCbor.decode(bytes);
325
+ if (!isOplogEntry(value)) continue;
326
+ const entry = { ...value, hash };
327
+ const updated = await log.joinEntry(entry);
328
+ if (updated) {
329
+ joined++;
330
+ entries.push(entry);
331
+ }
332
+ }
333
+
334
+ // Database.applyOperation emits 'update' when the pubsub Sync delivers an
335
+ // entry; a courier delivery is the same event from the application's side.
336
+ if (events && joined > 0) {
337
+ for (const entry of entries) events.emit("update", entry);
338
+ }
339
+
340
+ return { complete: true, joined, missing: [], entries };
341
+ }
342
+
343
+ /**
344
+ * Attach a database to a courier and keep the two ends converged.
345
+ *
346
+ * Can start without an open database: given `orbitdb` and `address`, the first
347
+ * complete delta (which carries the manifest on first contact) opens the
348
+ * database locally with `sync: false` — replication then runs entirely over
349
+ * the courier, no libp2p involved. `db` stays null until that delta is joined:
350
+ * an application writes as soon as it has a database, and a write racing the
351
+ * bootstrap join can drop out of the log's heads and never be sent.
352
+ *
353
+ * @param {Object} params
354
+ * @param {Object} [params.db] An open database (own-writes side)
355
+ * @param {Object} [params.orbitdb] OrbitDB instance, required when `db` is not given
356
+ * @param {string} [params.address] Database address, required when `db` is not given
357
+ * @param {Object} params.courier The byte courier (see module docs)
358
+ * @param {Object} [params.dbOptions] Extra options for the lazy `orbitdb.open`
359
+ * @param {boolean} [params.announceOnLocalUpdate=true] Announce as soon as a
360
+ * local write lands. Default keeps the eager behaviour. Set false where the
361
+ * courier is expensive — a duty-cycled radio, say — and the application would
362
+ * rather batch several writes and call `announce()` once, deliberately.
363
+ * @param {Uint8Array} [params.peerId] Four-byte sender id. Random per instance
364
+ * by default, which is what presence wants: it identifies this program on
365
+ * this carrier for as long as it runs, and nothing beyond that.
366
+ * @param {number} [params.peerTimeoutMs=120000] How long a peer counts as
367
+ * present after its last word.
368
+ * @param {number} [params.sendTimeoutMs=300000] How long to wait for the
369
+ * courier to confirm one message. Not a delivery deadline — a duty-cycled
370
+ * radio is slow by law — but a way out of a carrier that neither delivers
371
+ * nor fails. 0 waits for ever.
372
+ * @param {number} [params.maxOutbox=32] How many messages may wait for a
373
+ * carrier that cannot keep up before the oldest is dropped.
374
+ * @returns {Promise<Object>} sync handle: { start, stop, announce, hello,
375
+ * presence, forgetPeers, db(), events }
376
+ */
377
+ export async function createCourierSync({
378
+ db = null,
379
+ orbitdb = null,
380
+ address = null,
381
+ courier,
382
+ dbOptions = {},
383
+ rejoinIntervalMs = 15000,
384
+ announceOnLocalUpdate = true,
385
+ peerId = randomPeerId(),
386
+ peerTimeoutMs = PEER_TIMEOUT_MS,
387
+ sendTimeoutMs = SEND_TIMEOUT_MS,
388
+ maxOutbox = MAX_OUTBOX,
389
+ }) {
390
+ if (
391
+ !courier ||
392
+ typeof courier.send !== "function" ||
393
+ typeof courier.onPayload !== "function"
394
+ ) {
395
+ throw new Error("A courier with send() and onPayload() is required");
396
+ }
397
+ if (!(peerId instanceof Uint8Array) || peerId.length !== PEER_ID_LENGTH) {
398
+ throw new Error(`peerId must be ${PEER_ID_LENGTH} bytes`);
399
+ }
400
+ const databaseAddress = address || (db && db.address);
401
+ if (!databaseAddress) {
402
+ throw new Error("Either an open db or a database address is required");
403
+ }
404
+ if (!db && !orbitdb) {
405
+ throw new Error(
406
+ "An orbitdb instance is required to open the database on first contact",
407
+ );
408
+ }
409
+
410
+ const tag = await databaseTag(databaseAddress);
411
+ const pendingBlocks = new Map(); // hash -> bytes, parked until the database can open
412
+ const peers = new Map(); // sender id (hex) -> when we last heard it
413
+ let lastHeardAt = null; // any traffic for this database, identified or not
414
+ const listeners = { synced: [], message: [], error: [] };
415
+ let database = db;
416
+ // Opened on first contact but not handed out: the bootstrap is not in it yet.
417
+ // The protocol works on it all the same, so repair stays incremental.
418
+ let opening = null;
419
+ let unsubscribe = null;
420
+ let offUpdate = null;
421
+ let queue = Promise.resolve();
422
+ let started = false;
423
+ let applying = false;
424
+ let rejoinTimer = null;
425
+
426
+ const stopRejoin = () => {
427
+ if (rejoinTimer) {
428
+ clearInterval(rejoinTimer);
429
+ rejoinTimer = null;
430
+ }
431
+ };
432
+
433
+ const emit = (event, payload) => {
434
+ for (const cb of listeners[event] || []) {
435
+ try {
436
+ cb(payload);
437
+ } catch {
438
+ // listeners must not break the protocol
439
+ }
440
+ }
441
+ };
442
+
443
+ /** One message, actually on its way — framed, counted, handed to the courier. */
444
+ const transmit = async (message) => {
445
+ const raw = dagCbor.encode({
446
+ v: COURIER_SYNC_VERSION,
447
+ tag,
448
+ p: peerId,
449
+ ...message,
450
+ });
451
+ const framed = await frameMessage(raw);
452
+ // Report the wire size — what actually crosses the air and pays airtime.
453
+ emit("message", {
454
+ direction: "out",
455
+ type: message.t,
456
+ bytes: framed.length,
457
+ });
458
+ if (sendTimeoutMs <= 0) return courier.send(framed);
459
+ // A courier that neither delivers nor fails would hold the outbox for
460
+ // good. This is not a delivery deadline — a duty-cycled radio is slow by
461
+ // law, and the bound is far outside any honest delivery — it is the way
462
+ // out of a carrier that has simply stopped answering.
463
+ let timer;
464
+ try {
465
+ await Promise.race([
466
+ courier.send(framed),
467
+ new Promise((_, reject) => {
468
+ timer = setTimeout(
469
+ () =>
470
+ reject(
471
+ new Error(
472
+ `the courier neither delivered nor failed a ${message.t} within ${sendTimeoutMs} ms`,
473
+ ),
474
+ ),
475
+ sendTimeoutMs,
476
+ );
477
+ }),
478
+ ]);
479
+ } finally {
480
+ clearTimeout(timer);
481
+ }
482
+ };
483
+
484
+ /**
485
+ * The outbox: outgoing messages wait here, in order, and the receive path
486
+ * never waits with them.
487
+ *
488
+ * `courier.send` resolves on *delivery* — an end-to-end ARQ over a carrier
489
+ * that is slow by law. Awaiting it while handling an incoming message made
490
+ * one slow peer stall replication with every other peer: on hardware, a
491
+ * joiner asked fourteen times and got one answer, because the first reply
492
+ * was still in flight and every later message sat behind it in the same
493
+ * chain (funkpost#83). So handlers hand a message to this queue and go back
494
+ * to listening.
495
+ *
496
+ * A reply that has not gone out yet is *superseded* by a newer reply of the
497
+ * same kind to the same peer: the peer asked again, so the newer answer is
498
+ * the one it still needs, and the older one would spend airtime on what it
499
+ * already has. Peers on a version without a sender id cannot be told apart,
500
+ * so nothing of theirs is ever superseded.
501
+ */
502
+ const outbox = [];
503
+ let pumping = null; // the run that is emptying the outbox, while there is one
504
+
505
+ const pump = () => {
506
+ if (pumping) return pumping;
507
+ pumping = (async () => {
508
+ try {
509
+ while (outbox.length > 0) {
510
+ const item = outbox.shift();
511
+ try {
512
+ await transmit(item.message);
513
+ item.resolve();
514
+ } catch (error) {
515
+ item.reject(error);
516
+ }
517
+ }
518
+ } finally {
519
+ pumping = null;
520
+ }
521
+ })();
522
+ return pumping;
523
+ };
524
+
525
+ /** Resolves when nothing is waiting to go out and nothing is on its way. */
526
+ const outboxQuiet = async () => {
527
+ while (pumping) await pumping.catch(() => {});
528
+ };
529
+
530
+ /**
531
+ * Queue a message. Resolves when it has been delivered, so the caller of
532
+ * `announce()` still learns when the radio is done — handlers, which must
533
+ * not wait, use `post()`.
534
+ */
535
+ const send = (message, { to = null } = {}) =>
536
+ new Promise((resolve, reject) => {
537
+ if (to != null) {
538
+ const stale = outbox.findIndex(
539
+ (item) => item.to === to && item.message.t === message.t,
540
+ );
541
+ if (stale >= 0) outbox.splice(stale, 1)[0].resolve();
542
+ }
543
+ if (outbox.length >= maxOutbox) {
544
+ // A carrier that cannot keep up must not make us grow without limit.
545
+ // The oldest waiting message goes: everything here is re-derivable,
546
+ // and a peer that still wants it asks again.
547
+ emit(
548
+ "error",
549
+ new Error(`outbox full (${maxOutbox}) — dropping the oldest message`),
550
+ );
551
+ outbox.shift()?.resolve();
552
+ }
553
+ outbox.push({ message, to, resolve, reject });
554
+ pump();
555
+ });
556
+
557
+ /** Start a message on its way without waiting for it to arrive. */
558
+ const post = (message, options) => {
559
+ send(message, options).catch((error) => emit("error", error));
560
+ };
561
+
562
+ /**
563
+ * Somebody said something for this database.
564
+ *
565
+ * A message without an id is an older peer: it still proves the air is not
566
+ * empty, which is why `lastHeardAt` moves either way, but it cannot be
567
+ * counted. Our own id comes back when a mesh repeats us, and that proves
568
+ * nothing at all.
569
+ */
570
+ const noteHeard = (id) => {
571
+ const identified = id instanceof Uint8Array && id.length === PEER_ID_LENGTH;
572
+ if (identified && hex(id) === hex(peerId)) return; // a mesh repeating us
573
+ lastHeardAt = Date.now();
574
+ if (identified) peers.set(hex(id), lastHeardAt);
575
+ };
576
+
577
+ /** Who sent this, as far as the wire says — null on a peer without an id. */
578
+ const senderOf = (message) =>
579
+ message?.p instanceof Uint8Array && message.p.length === PEER_ID_LENGTH
580
+ ? hex(message.p)
581
+ : null;
582
+
583
+ /**
584
+ * Answer a peer asking whether anyone keeps this database. Answering means
585
+ * exactly that — listening on this tag — and nothing about having the log:
586
+ * a peer still bootstrapping is as present as one in step, and the asker
587
+ * finds out which by talking to it.
588
+ */
589
+ const handleHello = (message) => {
590
+ post({ t: "here" }, { to: senderOf(message) });
591
+ };
592
+
593
+ /** The database the protocol works on — handed out or not. */
594
+ const local = () => database || opening;
595
+
596
+ const ourHeadHashes = async (target = local()) =>
597
+ target ? (await target.log.heads()).map((entry) => entry.hash) : [];
598
+
599
+ const announce = async () => {
600
+ if (!local()) {
601
+ // Nothing local yet — not even the manifest. An announce of empty heads
602
+ // cannot get one from a peer whose log is also empty, so first contact
603
+ // is an explicit bootstrap request: a want with an empty frontier makes
604
+ // the peer send its static blocks even when it has no entries at all.
605
+ await send({ t: "want", cids: [], have: [] });
606
+ return;
607
+ }
608
+ await send({ t: "announce", heads: await ourHeadHashes() });
609
+ };
610
+
611
+ /**
612
+ * Announce without waiting for delivery — for callers that sit on the
613
+ * receive queue, where waiting for the radio is the head-of-line block this
614
+ * design exists to avoid. `announce()` itself still resolves on delivery,
615
+ * because an application that presses "send" wants to know when it is out.
616
+ */
617
+ const announceSoon = () => {
618
+ announce().catch((error) => emit("error", error));
619
+ };
620
+
621
+ /** The database to join a first-contact delta into, opened once the manifest is here. */
622
+ const openIfPossible = async () => {
623
+ if (opening || !orbitdb) return opening;
624
+ const manifestCid = manifestCidOf(databaseAddress);
625
+ if (!pendingBlocks.has(manifestCid)) return null;
626
+ // The blocks must be in the blockstore BEFORE the open: resolving the
627
+ // manifest (and later the access controller) reads through IPFS block
628
+ // storage, and a miss there waits out a 30-second timeout against a
629
+ // network that is not there.
630
+ for (const [hash, bytes] of pendingBlocks) {
631
+ await orbitdb.ipfs.blockstore.put(CID.parse(hash, base58btc), bytes);
632
+ }
633
+ opening = await orbitdb.open(databaseAddress, {
634
+ sync: false,
635
+ ...dbOptions,
636
+ });
637
+ stopRejoin(); // the manifest is here — repair from now on is incremental
638
+ return opening;
639
+ };
640
+
641
+ const watchLocalUpdates = () => {
642
+ // Opted out: the application announces when it decides to, not when a write
643
+ // happens. Incoming announces are still answered, so a peer asking for
644
+ // blocks is served — going quiet must not mean going deaf.
645
+ if (!announceOnLocalUpdate) return;
646
+ if (!database || offUpdate) return;
647
+ const onUpdate = () => {
648
+ if (applying) return; // courier-applied entries already end in an announce
649
+ queue = queue
650
+ .then(() => announceSoon())
651
+ .catch((error) => emit("error", error));
652
+ };
653
+ database.events.on("update", onUpdate);
654
+ offUpdate = () => database.events.off("update", onUpdate);
655
+ };
656
+
657
+ const handleAnnounce = async (message) => {
658
+ const theirHeads = message.heads || [];
659
+ const target = local();
660
+ if (!target) {
661
+ // Nothing local yet: ask for everything below their heads.
662
+ if (theirHeads.length > 0)
663
+ post(
664
+ { t: "want", cids: theirHeads, have: [] },
665
+ { to: senderOf(message) },
666
+ );
667
+ return;
668
+ }
669
+ const ours = await ourHeadHashes();
670
+ const theirSet = new Set(theirHeads);
671
+ const theyLack = ours.filter((hash) => !theirSet.has(hash));
672
+ const weLack = [];
673
+ for (const hash of theirHeads) {
674
+ if (!(await target.log.has(hash))) weLack.push(hash);
675
+ }
676
+ if (theyLack.length > 0) {
677
+ const delta = await createDelta({ db: target, theirHeads });
678
+ post(
679
+ { t: "blocks", heads: delta.heads, blocks: delta.blocks },
680
+ { to: senderOf(message) },
681
+ );
682
+ }
683
+ if (weLack.length > 0) {
684
+ post({ t: "want", cids: weLack, have: ours }, { to: senderOf(message) });
685
+ }
686
+ };
687
+
688
+ const handleWant = async (message) => {
689
+ const target = local();
690
+ if (!target) return;
691
+ const delta = await createDelta({
692
+ db: target,
693
+ theirHeads: message.have || [],
694
+ });
695
+ // Repair mode: a peer may ask for specific blocks (missing parents) that
696
+ // sit below both frontiers; include them explicitly if we hold them.
697
+ const included = new Set(delta.blocks.map((block) => block.hash));
698
+ for (const hash of message.cids || []) {
699
+ if (included.has(hash)) continue;
700
+ const bytes = await target.log.storage.get(hash).catch(() => null);
701
+ if (bytes) delta.blocks.unshift({ hash, bytes });
702
+ }
703
+ post(
704
+ { t: "blocks", heads: delta.heads, blocks: delta.blocks },
705
+ { to: senderOf(message) },
706
+ );
707
+ };
708
+
709
+ const handleBlocks = async (message) => {
710
+ for (const block of message.blocks || [])
711
+ pendingBlocks.set(block.hash, block.bytes);
712
+ const target = local() || (await openIfPossible());
713
+ if (!target) return;
714
+
715
+ const delta = {
716
+ heads: message.heads || [],
717
+ blocks: Array.from(pendingBlocks, ([hash, bytes]) => ({ hash, bytes })),
718
+ };
719
+ applying = true;
720
+ let result;
721
+ try {
722
+ result = await applyDelta({ db: target, delta });
723
+ } finally {
724
+ applying = false;
725
+ }
726
+ if (!result.complete) {
727
+ post(
728
+ {
729
+ t: "want",
730
+ cids: result.missing,
731
+ have: await ourHeadHashes(target),
732
+ },
733
+ { to: senderOf(message) },
734
+ );
735
+ return;
736
+ }
737
+ pendingBlocks.clear();
738
+ if (!database) {
739
+ // First contact is complete: only now is the database the application's.
740
+ // Handed out in the same synchronous step that emits "synced", so no
741
+ // turn of the event loop sees one without the other.
742
+ database = target;
743
+ opening = null;
744
+ watchLocalUpdates();
745
+ }
746
+ if (result.joined > 0) {
747
+ emit("synced", { joined: result.joined, entries: result.entries });
748
+ }
749
+ // Tells the peer where we now stand — their diff turns empty and the
750
+ // exchange goes quiet; doubles as an end-to-end acknowledgement.
751
+ await announce();
752
+ };
753
+
754
+ const handlePayload = (bytes) => {
755
+ queue = queue
756
+ .then(async () => {
757
+ let message;
758
+ try {
759
+ message = dagCbor.decode(await unframeMessage(bytes));
760
+ } catch {
761
+ return; // not ours (foreign traffic, garbage, or a bad frame)
762
+ }
763
+ if (
764
+ !message ||
765
+ message.v !== COURIER_SYNC_VERSION ||
766
+ !sameTag(message.tag, tag)
767
+ )
768
+ return;
769
+ emit("message", {
770
+ direction: "in",
771
+ type: message.t,
772
+ bytes: bytes.length,
773
+ });
774
+ noteHeard(message.p);
775
+ if (message.t === "hello") return handleHello(message);
776
+ if (message.t === "here") return; // the id in it was the whole message
777
+ if (message.t === "announce") return handleAnnounce(message);
778
+ if (message.t === "want") return handleWant(message);
779
+ if (message.t === "blocks") return handleBlocks(message);
780
+ })
781
+ .catch((error) => emit("error", error));
782
+ };
783
+
784
+ return {
785
+ get db() {
786
+ return database;
787
+ },
788
+ address: databaseAddress,
789
+ on(event, cb) {
790
+ (listeners[event] = listeners[event] || []).push(cb);
791
+ return () => listeners[event].splice(listeners[event].indexOf(cb), 1);
792
+ },
793
+ async start() {
794
+ if (started) return;
795
+ started = true;
796
+ unsubscribe = courier.onPayload(handlePayload);
797
+ watchLocalUpdates();
798
+ await announce();
799
+ // A joiner that has not bootstrapped keeps re-asking on its own until
800
+ // the database opens — so a bootstrap the lossy channel dropped heals
801
+ // without the user pressing "join" again. Cleared the moment the
802
+ // database opens (openIfPossible) or on stop().
803
+ if (!database && orbitdb && rejoinIntervalMs > 0) {
804
+ rejoinTimer = setInterval(() => {
805
+ if (local() || !started) {
806
+ stopRejoin();
807
+ return;
808
+ }
809
+ queue = queue
810
+ .then(() => announceSoon())
811
+ .catch((error) => emit("error", error));
812
+ }, rejoinIntervalMs);
813
+ }
814
+ },
815
+ /** Re-announce — recovery poke after suspected loss. */
816
+ announce: () => announce(),
817
+ /** This instance's sender id, as it appears on the wire. */
818
+ peerId: hex(peerId),
819
+ /**
820
+ * Ask whether anybody out there keeps this database, and let them answer.
821
+ *
822
+ * Two small messages, and the only way to tell an app apart from a radio:
823
+ * a carrier reports the radios in range, which says nothing about whether
824
+ * a program on the other end is listening for *this* database. Call it
825
+ * before spending airtime on a delta nobody is waiting for; read the
826
+ * answer from `presence()` a moment later, since an answer has to travel.
827
+ *
828
+ * Requires `start()` — a sync that is not subscribed hears no answers.
829
+ */
830
+ hello: () => send({ t: "hello" }),
831
+ /**
832
+ * Who has been heard lately, and when the air last carried anything at
833
+ * all for this database.
834
+ *
835
+ * Not a connection count: this carrier has no connections. It is the
836
+ * honest form of the question — these peers said something recently.
837
+ *
838
+ * @returns {{peers: Array<{id: string, agoMs: number}>, lastHeardAgoMs: number|null}}
839
+ */
840
+ presence() {
841
+ const at = Date.now();
842
+ for (const [id, seen] of peers) {
843
+ if (at - seen > peerTimeoutMs) peers.delete(id);
844
+ }
845
+ return {
846
+ peers: [...peers.entries()].map(([id, seen]) => ({
847
+ id,
848
+ agoMs: at - seen,
849
+ })),
850
+ lastHeardAgoMs: lastHeardAt == null ? null : at - lastHeardAt,
851
+ };
852
+ },
853
+ /**
854
+ * Forget everyone heard so far.
855
+ *
856
+ * For when the carrier itself changes underneath — a radio switched to
857
+ * another channel reaches other people, and peers heard on the old one
858
+ * are not evidence about the new one.
859
+ */
860
+ forgetPeers() {
861
+ peers.clear();
862
+ lastHeardAt = null;
863
+ },
864
+ /**
865
+ * Wait until in-flight message handling settles (mainly for tests).
866
+ *
867
+ * Both directions: the receive queue, and whatever it handed to the
868
+ * outbox. Since handlers no longer wait for the radio, a settled receive
869
+ * queue on its own would mean "decided", not "sent".
870
+ */
871
+ async idle() {
872
+ await queue;
873
+ await outboxQuiet();
874
+ await queue;
875
+ },
876
+ async stop() {
877
+ started = false;
878
+ stopRejoin();
879
+ if (unsubscribe) unsubscribe();
880
+ if (offUpdate) offUpdate();
881
+ unsubscribe = null;
882
+ offUpdate = null;
883
+ await queue.catch(() => {});
884
+ },
885
+ };
886
+ }