@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,234 @@
1
+ /**
2
+ * A pointer a second device can find with nothing but a key.
3
+ *
4
+ * The problem this solves is not storage — a backup's CID is enough to fetch
5
+ * it from anywhere — but *naming*: a device that has lost everything cannot be
6
+ * told a CID, because there is nobody left to tell it. So the name has to be
7
+ * computable from the one thing that survived. Derive a key from a seed, and
8
+ * the IPNS name follows from the key; two devices holding the same seed
9
+ * compute the same name without ever exchanging anything.
10
+ *
11
+ * The seed is the caller's business. A passkey's PRF output is the case this
12
+ * was written for (funkpost#93), and it never passes through here as anything
13
+ * but bytes to stretch.
14
+ *
15
+ * Publication goes over **delegated routing** — `PUT /routing/v1/ipns/{name}`
16
+ * — because a browser cannot join the DHT, and `w3name`, which used to stand
17
+ * in for this, was Storacha's and Storacha is gone. Measured against
18
+ * `delegated-ipfs.dev` on 2026-09-19, from Node and from a page on a foreign
19
+ * origin: the preflight allows `PUT` from anywhere, the PUT is accepted, and
20
+ * the GET returns the record byte for byte
21
+ * (`test/helpers/probe-ipns-routing.js`).
22
+ *
23
+ * Two things that probe could not establish, and this module therefore does
24
+ * not promise: that a record travels beyond the endpoint that accepted it, and
25
+ * how long it is kept. Publish to more than one endpoint where that matters,
26
+ * and treat a pointer as a shortcut for a device that comes back soon rather
27
+ * than as an archive.
28
+ */
29
+
30
+ import {
31
+ createIPNSRecord,
32
+ marshalIPNSRecord,
33
+ unmarshalIPNSRecord,
34
+ multihashToIPNSRoutingKey,
35
+ } from "ipns";
36
+ import { ipnsValidator } from "ipns/validator";
37
+ import { generateKeyPairFromSeed } from "@libp2p/crypto/keys";
38
+ import { peerIdFromPrivateKey, peerIdFromString } from "@libp2p/peer-id";
39
+ import { CID } from "multiformats/cid";
40
+ import { base36 } from "multiformats/bases/base36";
41
+ import logger from "./logger.js";
42
+
43
+ const log = logger.child ? logger.child({ module: "pointer-ipns" }) : logger;
44
+
45
+ /** libp2p-key, the codec an IPNS name is a CID of. */
46
+ const LIBP2P_KEY_CODEC = 0x72;
47
+
48
+ /**
49
+ * Bumping this changes every name a seed produces, so treat it as a breaking
50
+ * change: pointers published under the old one become unfindable.
51
+ */
52
+ export const POINTER_INFO = "orbitdb-storage-bridge:pointer-ipns:v1";
53
+
54
+ /** Public endpoints that speak delegated routing v1. */
55
+ export const DEFAULT_ENDPOINTS = ["https://delegated-ipfs.dev"];
56
+
57
+ const DEFAULT_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; // what a record claims for itself
58
+ const DEFAULT_TIMEOUT_MS = 30_000;
59
+ const RECORD_CONTENT_TYPE = "application/vnd.ipfs.ipns-record";
60
+
61
+ /**
62
+ * Stretch a seed into the key whose name the pointer lives under.
63
+ *
64
+ * HKDF-SHA256, with `label` mixed into the info string so one seed can name
65
+ * several pointers without their keys being related in any usable way.
66
+ *
67
+ * @param {Uint8Array} seed Secret bytes — a PRF output, say. Never published.
68
+ * @param {Object} [options]
69
+ * @param {string} [options.label=""] Distinguishes pointers of one seed.
70
+ * @param {string} [options.info=POINTER_INFO] Domain separation.
71
+ * @returns {Promise<Object>} A libp2p Ed25519 private key.
72
+ */
73
+ export async function derivePointerKey(
74
+ seed,
75
+ { label = "", info = POINTER_INFO } = {},
76
+ ) {
77
+ if (!(seed instanceof Uint8Array) || seed.length === 0) {
78
+ throw new Error("derivePointerKey needs seed bytes");
79
+ }
80
+ const base = await globalThis.crypto.subtle.importKey(
81
+ "raw",
82
+ seed,
83
+ "HKDF",
84
+ false,
85
+ ["deriveBits"],
86
+ );
87
+ const bits = await globalThis.crypto.subtle.deriveBits(
88
+ {
89
+ name: "HKDF",
90
+ hash: "SHA-256",
91
+ salt: new Uint8Array(0),
92
+ info: new TextEncoder().encode(label ? `${info}:${label}` : info),
93
+ },
94
+ base,
95
+ 256,
96
+ );
97
+ return generateKeyPairFromSeed("Ed25519", new Uint8Array(bits));
98
+ }
99
+
100
+ /**
101
+ * The IPNS name of a key, as routing endpoints and gateways spell it.
102
+ *
103
+ * @param {Object} key A libp2p private or public key.
104
+ * @returns {string} base36 `k51…`
105
+ */
106
+ export function pointerName(key) {
107
+ const publicKey = key.publicKey ?? key;
108
+ const peerId = key.publicKey
109
+ ? peerIdFromPrivateKey(key)
110
+ : (publicKey.toPeerId?.() ?? publicKey);
111
+ return CID.createV1(LIBP2P_KEY_CODEC, peerId.toMultihash()).toString(base36);
112
+ }
113
+
114
+ const multihashOfName = (name) => peerIdFromString(name).toMultihash();
115
+
116
+ /**
117
+ * Put a CID under the name this key derives, at every endpoint given.
118
+ *
119
+ * Resolves as soon as one endpoint has taken it; the rest are still tried, and
120
+ * what each of them said comes back in `results` — a pointer that reached one
121
+ * endpoint is published, and a pointer that reached three is more likely to be
122
+ * found later.
123
+ *
124
+ * @param {Object} params
125
+ * @param {Object} params.privateKey From `derivePointerKey`.
126
+ * @param {string|Object} params.cid What the pointer points at.
127
+ * @param {string[]} [params.endpoints]
128
+ * @param {bigint|number} [params.sequence] Must only ever grow for a name.
129
+ * Seconds since the epoch by default, which is monotonic enough for a
130
+ * pointer written by hand and readable in a log.
131
+ * @param {number} [params.lifetimeMs]
132
+ * @param {AbortSignal} [params.signal]
133
+ * @returns {Promise<{name: string, sequence: bigint, results: Array<{endpoint: string, ok: boolean, status?: number, error?: string}>}>}
134
+ */
135
+ export async function publishPointer({
136
+ privateKey,
137
+ cid,
138
+ endpoints = DEFAULT_ENDPOINTS,
139
+ sequence = BigInt(Math.floor(Date.now() / 1000)),
140
+ lifetimeMs = DEFAULT_LIFETIME_MS,
141
+ signal,
142
+ }) {
143
+ const value = typeof cid === "string" ? CID.parse(cid) : cid;
144
+ const record = await createIPNSRecord(
145
+ privateKey,
146
+ value,
147
+ BigInt(sequence),
148
+ lifetimeMs,
149
+ );
150
+ const body = marshalIPNSRecord(record);
151
+ const name = pointerName(privateKey);
152
+
153
+ const results = await Promise.all(
154
+ endpoints.map(async (endpoint) => {
155
+ try {
156
+ const response = await fetch(pointerUrl(endpoint, name), {
157
+ method: "PUT",
158
+ headers: { "Content-Type": RECORD_CONTENT_TYPE },
159
+ body,
160
+ signal: signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
161
+ });
162
+ return { endpoint, ok: response.ok, status: response.status };
163
+ } catch (error) {
164
+ return { endpoint, ok: false, error: error.message };
165
+ }
166
+ }),
167
+ );
168
+
169
+ if (!results.some((result) => result.ok)) {
170
+ const reasons = results
171
+ .map((result) => `${result.endpoint}: ${result.error ?? result.status}`)
172
+ .join("; ");
173
+ throw new Error(`no endpoint took the pointer (${reasons})`);
174
+ }
175
+ log.info?.("📍 pointer %s → %s", name, value.toString());
176
+ return { name, sequence: BigInt(sequence), results };
177
+ }
178
+
179
+ /**
180
+ * Read a pointer back, from whichever endpoint answers with a valid record.
181
+ *
182
+ * Every record is checked against the name it was asked for before it is
183
+ * believed: the name is the hash of the key that signs, so an endpoint cannot
184
+ * hand back somebody else's pointer, or an altered one, without being caught.
185
+ *
186
+ * @param {Object} params
187
+ * @param {string} [params.name] The name, if the caller has it.
188
+ * @param {Object} [params.privateKey] Or the key, and the name follows.
189
+ * @param {string[]} [params.endpoints]
190
+ * @param {AbortSignal} [params.signal]
191
+ * @returns {Promise<{name: string, cid: string, sequence: bigint, validity: string|undefined}>}
192
+ */
193
+ export async function resolvePointer({
194
+ name,
195
+ privateKey,
196
+ endpoints = DEFAULT_ENDPOINTS,
197
+ signal,
198
+ }) {
199
+ const pointer = name ?? (privateKey ? pointerName(privateKey) : null);
200
+ if (!pointer) throw new Error("resolvePointer needs a name or a key");
201
+ const routingKey = multihashToIPNSRoutingKey(multihashOfName(pointer));
202
+
203
+ const failures = [];
204
+ for (const endpoint of endpoints) {
205
+ try {
206
+ const response = await fetch(pointerUrl(endpoint, pointer), {
207
+ headers: { Accept: RECORD_CONTENT_TYPE },
208
+ signal: signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
209
+ });
210
+ if (!response.ok) {
211
+ failures.push(`${endpoint}: ${response.status}`);
212
+ continue;
213
+ }
214
+ const bytes = new Uint8Array(await response.arrayBuffer());
215
+ await ipnsValidator(routingKey, bytes); // signed by this name, or nothing
216
+ const record = unmarshalIPNSRecord(bytes);
217
+ return {
218
+ name: pointer,
219
+ cid: String(record.value).replace(/^\/ipfs\//, ""),
220
+ sequence: record.sequence,
221
+ validity: record.validity,
222
+ };
223
+ } catch (error) {
224
+ failures.push(`${endpoint}: ${error.message}`);
225
+ }
226
+ }
227
+ throw new Error(
228
+ `no endpoint had a valid pointer for ${pointer} (${failures.join("; ")})`,
229
+ );
230
+ }
231
+
232
+ function pointerUrl(endpoint, name) {
233
+ return `${endpoint.replace(/\/$/, "")}/routing/v1/ipns/${name}`;
234
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Restore an OrbitDB database from a single CID — no Storacha client.
3
+ *
4
+ * `restoreFromSpaceCAR` can already do this: hand it a `metadataCID` and it
5
+ * resolves the blocks CAR out of the metadata and fetches both from public
6
+ * gateways, with no credentials on the restoring side. The problem was never
7
+ * the capability, it was the import — `backup-car.js` reaches into
8
+ * `orbitdb-storacha-bridge.js`, which pulls in `@storacha/client` at module
9
+ * level, so a peer that only ever *reads* paid for the whole backup SDK.
10
+ *
11
+ * Measured against a real consumer (funkpost's `mesh-todo`, 561 kB gzipped):
12
+ * importing `restoreFromSpaceCAR` added **617 kB gzipped**; what the steps
13
+ * below actually need adds about **11 kB**, because a consumer of this package
14
+ * already ships `multiformats` and `@ipld/dag-cbor`, leaving `@ipld/car`.
15
+ *
16
+ * So this module imports no client, no space, no proof, no Helia — and no
17
+ * logger, which is not a detail: the package's logger reaches `@libp2p/logger`,
18
+ * and pulling a logging framework in behind a progress line would undo most of
19
+ * the saving. Both the fetching and the logging are **injectable** instead: the
20
+ * default fetch is `fetch` against public gateways, the default log is silence,
21
+ * and a caller that wants either of the package's own versions passes it.
22
+ *
23
+ * ## Why a peer that only restores is a real shape
24
+ *
25
+ * Backing up needs an account. Reading a backup does not — a CID is a name that
26
+ * anyone can resolve. Alice keeps the space; Bob has a link. Over a courier
27
+ * where a byte is rationed this is the difference between announcing a whole
28
+ * database and announcing where one is: a CID fits in a single LoRa frame.
29
+ *
30
+ * @module restore-cid
31
+ */
32
+
33
+ import { CarReader } from "@ipld/car";
34
+ import { CID } from "multiformats/cid";
35
+ import { base58btc } from "multiformats/bases/base58";
36
+ import * as Block from "multiformats/block";
37
+ import * as dagCbor from "@ipld/dag-cbor";
38
+ import { sha256, sha512 } from "multiformats/hashes/sha2";
39
+ import { isValidMetadata } from "./backup-metadata.js";
40
+ // Re-exported below, so `restore-cid`'s surface is unchanged by the move.
41
+ import { DEFAULT_GATEWAYS, SILENT, fetchFromGateways } from "./gateway-fetch.js";
42
+
43
+ export { DEFAULT_GATEWAYS, SILENT, fetchFromGateways };
44
+
45
+ const sameBytes = (a, b) => a.length === b.length && a.every((byte, i) => byte === b[i]);
46
+
47
+ /**
48
+ * One spelling of a CID, so two of them can be compared.
49
+ *
50
+ * The same block is called `zdpuAw…` by OrbitDB, which addresses in base58btc,
51
+ * and `bafyrei…` inside a CAR, which uses the CID's own base32. Both are the
52
+ * same 32 bytes wearing different clothes, and comparing the strings says they
53
+ * are different — a mistake that reads as "the block is missing" and is
54
+ * therefore very easy to believe.
55
+ */
56
+ const canonical = (value) => {
57
+ try {
58
+ return CID.parse(String(value)).toV1().toString();
59
+ } catch {
60
+ return null;
61
+ }
62
+ };
63
+
64
+ /** dag-cbor. Only these blocks can be OrbitDB log entries. */
65
+ const DAG_CBOR = 0x71;
66
+
67
+ /** By multihash code, so a block can be checked against the CID that names it. */
68
+ const HASHERS = new Map([
69
+ [sha256.code, sha256],
70
+ [sha512.code, sha512],
71
+ ]);
72
+
73
+ /**
74
+ * Every block in a CAR, by CID string — each one checked against its own name.
75
+ *
76
+ * **`CarReader` does not do this**, and the omission is easy to miss because it
77
+ * looks like it must: it hands back whatever CID the file claims for whatever
78
+ * bytes sit next to it, unverified. A CAR can therefore carry a block labelled
79
+ * with one CID and containing something else entirely, and a restore would put
80
+ * the attacker's bytes into the blockstore under a name the application trusts.
81
+ *
82
+ * That risk used to be bounded by where the bytes came from — your own space,
83
+ * on a service you had an account with. This module deliberately removed that
84
+ * boundary: it fetches from public gateways and takes an injected fetch, so the
85
+ * bytes can come from anywhere. Verification is what makes that safe, and it is
86
+ * the property worth having rather than a mitigation: **once every block is
87
+ * checked against its CID, where it came from stops mattering.** A hostile
88
+ * mirror, a stale gateway, a file on a USB stick — none of them can forge a
89
+ * block, only fail to provide one.
90
+ *
91
+ * @param {Uint8Array} carBytes
92
+ * @param {Object} [options]
93
+ * @param {boolean} [options.verify=true] Only turn this off for bytes you
94
+ * produced yourself and have not let out of your sight.
95
+ * @returns {Promise<Map<string, { bytes: Uint8Array }>>}
96
+ */
97
+ export async function readBlocksFromCAR(carBytes, { verify = true } = {}) {
98
+ const reader = await CarReader.fromBytes(carBytes);
99
+ const blocks = new Map();
100
+
101
+ for await (const block of reader.blocks()) {
102
+ const name = block.cid.toString();
103
+
104
+ if (verify) {
105
+ const hasher = HASHERS.get(block.cid.multihash.code);
106
+ // Refused rather than waved through: a hash we cannot compute is a block
107
+ // we cannot vouch for, and silently accepting it would defeat the point.
108
+ if (!hasher) {
109
+ throw new Error(
110
+ `Block ${name} uses multihash 0x${block.cid.multihash.code.toString(16)}, which this reader cannot verify`,
111
+ );
112
+ }
113
+ const digest = await hasher.digest(block.bytes);
114
+ if (!sameBytes(digest.digest, block.cid.multihash.digest)) {
115
+ throw new Error(`Block ${name} does not hash to its own CID — the CAR has been tampered with`);
116
+ }
117
+ }
118
+
119
+ blocks.set(name, { bytes: block.bytes });
120
+ }
121
+
122
+ return blocks;
123
+ }
124
+
125
+ /**
126
+ * The heads of a hash-linked log: entries nothing else points back to.
127
+ *
128
+ * Derived from the blocks rather than carried in the metadata, because the
129
+ * blocks are the truth and a stated head can be stale. A block counts as a log
130
+ * entry only if it is dag-cbor *and* carries a signature, key and identity —
131
+ * the manifest and the access controller are dag-cbor too.
132
+ */
133
+ async function headsIn(blocks) {
134
+ const entries = [];
135
+ const pointedAt = new Set();
136
+
137
+ for (const [cidString, { bytes }] of blocks) {
138
+ let cid;
139
+ try {
140
+ cid = CID.parse(cidString);
141
+ } catch {
142
+ continue;
143
+ }
144
+ if (cid.code !== DAG_CBOR) continue;
145
+
146
+ try {
147
+ const { value } = await Block.decode({ cid, bytes, codec: dagCbor, hasher: sha256 });
148
+ if (!value?.sig || !value?.key || !value?.identity) continue;
149
+ entries.push({ hash: cid.toV1().toString(base58btc), value });
150
+ if (Array.isArray(value.next)) for (const next of value.next) pointedAt.add(next);
151
+ } catch {
152
+ /* not a block we can read; the CAR may hold more than this log */
153
+ }
154
+ }
155
+
156
+ return entries.filter((entry) => !pointedAt.has(entry.hash));
157
+ }
158
+
159
+ /**
160
+ * Restore a database from the CID of a CAR backup's metadata.
161
+ *
162
+ * Everything hangs off that one CID: the metadata names the CAR, the CAR holds
163
+ * the blocks, and the blocks carry their own addresses. Nothing here talks to
164
+ * Storacha, so the caller needs no space, key or proof — only a way to fetch
165
+ * bytes, which by default is `fetch` against public gateways.
166
+ *
167
+ * **The returned `database` replaces any handle the caller already held on this
168
+ * address.** The log has to be reopened to read the blocks put underneath it,
169
+ * and `orbitdb.open` hands out one instance per address — so the instance that
170
+ * gets closed here is the caller's own, and writing through it afterwards fails
171
+ * on an aborted signal.
172
+ *
173
+ * @param {Object} orbitdb an OrbitDB instance to restore into
174
+ * @param {Object} options
175
+ * @param {string} options.metadataCID the pointer — what `backupDatabaseCAR`
176
+ * returns as `backupFiles.metadataCID`
177
+ * @param {string[]} [options.gateways]
178
+ * @param {number} [options.timeout] per request, in ms
179
+ * @param {AbortSignal} [options.signal]
180
+ * @param {boolean} [options.verify=true] Check every block against its own
181
+ * CID, and that the CAR really holds the manifest this backup names. Leaving
182
+ * it on is what makes an untrusted source acceptable.
183
+ * @param {{ info: Function, warn: Function, debug: Function }} [options.log]
184
+ * where to report progress; silent unless given. Pass the package's own
185
+ * `logger` for the verbose behaviour `restoreFromSpaceCAR` has.
186
+ * @param {(cid: string, options: Object) => Promise<Uint8Array>} [options.fetchBytes]
187
+ * how to resolve a CID. Injected rather than imported so that a caller with
188
+ * its own IPFS node can use it without every caller bundling one.
189
+ * @param {Object} [options.open] options for `orbitdb.open`, merged over the
190
+ * type the backup names. A node without pubsub needs `{ sync: false }` here:
191
+ * OrbitDB's Sync subscribes on open, and on a libp2p built without a pubsub
192
+ * service that throws before the database is ever handed back.
193
+ * @returns {Promise<{ address: string, database: Object, blocks: number,
194
+ * entries: number, heads: number, joined: number }>}
195
+ */
196
+ export async function restoreFromCID(orbitdb, options = {}) {
197
+ const { metadataCID, fetchBytes = fetchFromGateways, log = SILENT, verify = true, open = {}, ...rest } = options;
198
+
199
+ if (!orbitdb?.ipfs?.blockstore) throw new Error("An OrbitDB instance is required");
200
+ if (!metadataCID) throw new Error("A metadataCID is required");
201
+
202
+ log.info(`🔄 Restoring from ${metadataCID}`);
203
+
204
+ // 1 · the metadata names everything else
205
+ const metadataBytes = await fetchBytes(metadataCID, { ...rest, log });
206
+ let metadata;
207
+ try {
208
+ metadata = JSON.parse(new TextDecoder().decode(metadataBytes));
209
+ } catch (error) {
210
+ throw new Error(`Backup metadata at ${metadataCID} is not JSON: ${error.message}`, { cause: error });
211
+ }
212
+ if (!isValidMetadata(metadata)) throw new Error("Invalid backup metadata");
213
+
214
+ // Everything the metadata must name, checked before anything is downloaded —
215
+ // a CAR is the large fetch here, and finding out afterwards that there is no
216
+ // address to open it into wastes the whole transfer.
217
+ const carCID = metadata.carCID;
218
+ if (!carCID) throw new Error("Backup metadata names no CAR file");
219
+
220
+ const dbInfo = metadata.databases?.[0];
221
+ if (!dbInfo?.address) {
222
+ // `isValidMetadata` also accepts the older `{ root, path }` shape, which
223
+ // predates CAR backups and has no address to open. Say which it is.
224
+ throw new Error(
225
+ dbInfo ? "This is a pre-CAR backup; restoreFromCID needs a CAR backup" : "Backup metadata names no database",
226
+ );
227
+ }
228
+
229
+ // 2 · the CAR holds the blocks, and each one is checked against its own CID
230
+ const blocks = await readBlocksFromCAR(await fetchBytes(carCID, { ...rest, log }), { verify });
231
+ log.info(` ✅ ${blocks.size} blocks`);
232
+
233
+ // The blocks verify individually; this is what ties them to *this* backup.
234
+ // Without it a CAR full of perfectly valid blocks from some other database
235
+ // would restore happily, because every block would be honest about itself.
236
+ const manifestCID = dbInfo.manifestCID ?? metadata.manifestCID;
237
+ if (verify && manifestCID) {
238
+ const wanted = canonical(manifestCID);
239
+ const present = new Set([...blocks.keys()].map(canonical));
240
+ if (!wanted || !present.has(wanted)) {
241
+ throw new Error(
242
+ `The CAR does not contain the manifest ${manifestCID} that this backup names`,
243
+ );
244
+ }
245
+ }
246
+
247
+ // 3 · into the blockstore, and into the log's own storage
248
+ //
249
+ // Both, and not by accident: the blockstore is where Helia looks, while
250
+ // OrbitDB's log reads from its own store and addresses blocks in base58btc
251
+ // rather than the CAR's base32. A restore that fills only one of them opens
252
+ // a database that is empty in a way nothing reports.
253
+ let stored = 0;
254
+ for (const [cidString, { bytes }] of blocks) {
255
+ try {
256
+ await orbitdb.ipfs.blockstore.put(CID.parse(cidString), bytes);
257
+ stored++;
258
+ } catch (error) {
259
+ log.warn(` ⚠️ could not store ${cidString.slice(0, 12)}…: ${error.message}`);
260
+ }
261
+ }
262
+
263
+ const opened = await orbitdb.open(dbInfo.address, { type: dbInfo.type, ...open });
264
+ for (const [cidString, { bytes }] of blocks) {
265
+ try {
266
+ await opened.log.storage.put(CID.parse(cidString).toV1().toString(base58btc), bytes);
267
+ } catch (error) {
268
+ log.warn(` ⚠️ could not copy ${cidString.slice(0, 12)}… to log storage: ${error.message}`);
269
+ }
270
+ }
271
+
272
+ // Reopened so the log reads what we just put underneath it.
273
+ await opened.close();
274
+ const database = await orbitdb.open(dbInfo.address, { type: dbInfo.type, ...open });
275
+
276
+ // 4 · tell the log where its history ends
277
+ const heads = await headsIn(blocks);
278
+ let joined = 0;
279
+ for (const head of heads) {
280
+ try {
281
+ const { v, id, key, sig, next, refs, clock, payload, identity } = head.value;
282
+ if (await database.log.joinEntry({ hash: head.hash, v, id, key, sig, next, refs, clock, payload, identity })) {
283
+ joined++;
284
+ }
285
+ } catch (error) {
286
+ log.warn(` ⚠️ could not join head ${head.hash.slice(0, 12)}…: ${error.message}`);
287
+ }
288
+ }
289
+
290
+ log.info(`✅ Restored ${dbInfo.address} — ${stored} blocks, ${joined}/${heads.length} heads`);
291
+
292
+ return {
293
+ address: dbInfo.address,
294
+ database,
295
+ blocks: stored,
296
+ entries: metadata.totalEntries ?? dbInfo.entryCount ?? null,
297
+ heads: heads.length,
298
+ joined,
299
+ };
300
+ }
301
+
302
+ export default { restoreFromCID, fetchFromGateways, readBlocksFromCAR, DEFAULT_GATEWAYS, SILENT };