@le-space/orbitdb-storage-bridge 0.14.1 → 0.16.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.
@@ -157,7 +157,8 @@ async function headsIn(blocks) {
157
157
  }
158
158
 
159
159
  /**
160
- * Restore a database from the CID of a CAR backup's metadata.
160
+ * Restore from the CID of a CAR backup's metadata: every database it names
161
+ * (`restoreFromBlocks` does the work once the blocks are fetched).
161
162
  *
162
163
  * Everything hangs off that one CID: the metadata names the CAR, the CAR holds
163
164
  * the blocks, and the blocks carry their own addresses. Nothing here talks to
@@ -191,7 +192,9 @@ async function headsIn(blocks) {
191
192
  * OrbitDB's Sync subscribes on open, and on a libp2p built without a pubsub
192
193
  * service that throws before the database is ever handed back.
193
194
  * @returns {Promise<{ address: string, database: Object, blocks: number,
194
- * entries: number, heads: number, joined: number }>}
195
+ * entries: number, heads: number, joined: number, databases: Object[] }>}
196
+ * the first database's at the top, as a one-database backup always had it;
197
+ * `databases` lists them all
195
198
  */
196
199
  export async function restoreFromCID(orbitdb, options = {}) {
197
200
  const { metadataCID, fetchBytes = fetchFromGateways, log = SILENT, verify = true, open = {}, ...rest } = options;
@@ -216,40 +219,97 @@ export async function restoreFromCID(orbitdb, options = {}) {
216
219
  // address to open it into wastes the whole transfer.
217
220
  const carCID = metadata.carCID;
218
221
  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
- }
222
+ assertRestorable(metadata);
228
223
 
229
224
  // 2 · the CAR holds the blocks, and each one is checked against its own CID
230
225
  const blocks = await readBlocksFromCAR(await fetchBytes(carCID, { ...rest, log }), { verify });
231
226
  log.info(` ✅ ${blocks.size} blocks`);
232
227
 
228
+ // 3 · every database the metadata names
229
+ const restored = await restoreFromBlocks(orbitdb, blocks, metadata, { log, verify, open });
230
+ const [first] = restored.databases;
231
+
232
+ // The one-database answer this always gave, and the whole list beside it.
233
+ return {
234
+ address: first.address,
235
+ database: first.database,
236
+ blocks: restored.blocks,
237
+ entries: metadata.totalEntries ?? first.entries,
238
+ heads: first.heads,
239
+ joined: first.joined,
240
+ databases: restored.databases,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Refuse metadata that names nothing a restore could open, and say which kind
246
+ * it is. `isValidMetadata` also accepts the older `{ root, path }` shape, which
247
+ * predates CAR backups and has no address to open.
248
+ */
249
+ function assertRestorable(metadata) {
250
+ const dbs = metadata.databases ?? [];
251
+ if (dbs.length === 0) throw new Error("Backup metadata names no database");
252
+ if (!dbs.every((db) => db?.address)) {
253
+ throw new Error("This is a pre-CAR backup; restoreFromCID needs a CAR backup");
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Restore every database a backup names, from blocks already in hand.
259
+ *
260
+ * `restoreFromCID` fetches and then calls this. An application that holds the
261
+ * blocks some other way — a backup it sealed itself and has just opened, a file
262
+ * on a USB stick, several small CARs written as it went — calls it directly.
263
+ * The blocks are checked against the metadata, never trusted for it: each
264
+ * database's manifest must be among them.
265
+ *
266
+ * Per database: the blocks go into the blockstore (once, for all of them) and
267
+ * into that database's own log storage (its dag-cbor blocks only — a receipt
268
+ * file's raw chunks are no log's business); the database is reopened so the
269
+ * log reads what was put underneath it; and its heads are joined. The heads
270
+ * are the ones the metadata states, or — for metadata that states none — the
271
+ * entries of this log nothing else points back to.
272
+ *
273
+ * **Each returned `database` replaces any handle the caller held on that
274
+ * address**, for the reason `restoreFromCID` gives.
275
+ *
276
+ * @param {Object} orbitdb an OrbitDB instance to restore into
277
+ * @param {Map<string, { bytes: Uint8Array }>} blocks by CID string, in any base —
278
+ * what `readBlocksFromCAR` returns
279
+ * @param {Object} metadata as `bundleDatabases` or `backupDatabaseCAR` write it
280
+ * @param {Object} [options]
281
+ * @param {boolean} [options.verify=true] each database's manifest must be among the blocks
282
+ * @param {{ info: Function, warn: Function, debug: Function }} [options.log]
283
+ * @param {Object} [options.open] options for `orbitdb.open`, merged over the type
284
+ * the backup names (a node without pubsub needs `{ sync: false }`)
285
+ * @param {(progress: { stage: "database", index: number, total: number,
286
+ * address: string, joined: number, heads: number }) => void} [options.onProgress]
287
+ * @returns {Promise<{ blocks: number, databases: { address: string, name?: string,
288
+ * database: Object, entries: number|null, heads: number, joined: number }[] }>}
289
+ */
290
+ export async function restoreFromBlocks(orbitdb, blocks, metadata, options = {}) {
291
+ const { log = SILENT, verify = true, open = {}, onProgress } = options;
292
+ if (!orbitdb?.ipfs?.blockstore) throw new Error("An OrbitDB instance is required");
293
+ if (!(blocks instanceof Map)) throw new Error("blocks must be a Map");
294
+ if (!isValidMetadata(metadata)) throw new Error("Invalid backup metadata");
295
+ assertRestorable(metadata);
296
+
233
297
  // The blocks verify individually; this is what ties them to *this* backup.
234
298
  // Without it a CAR full of perfectly valid blocks from some other database
235
299
  // 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
- );
300
+ const present = new Set([...blocks.keys()].map(canonical));
301
+ if (verify) {
302
+ for (const db of metadata.databases) {
303
+ const manifestCID = db.manifestCID ?? metadata.manifestCID;
304
+ if (!manifestCID) continue;
305
+ const wanted = canonical(manifestCID);
306
+ if (!wanted || !present.has(wanted)) {
307
+ throw new Error(`The CAR does not contain the manifest ${manifestCID} that this backup names`);
308
+ }
244
309
  }
245
310
  }
246
311
 
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.
312
+ // Into the blockstore, where Helia looks — once, whatever the databases.
253
313
  let stored = 0;
254
314
  for (const [cidString, { bytes }] of blocks) {
255
315
  try {
@@ -260,43 +320,75 @@ export async function restoreFromCID(orbitdb, options = {}) {
260
320
  }
261
321
  }
262
322
 
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}`);
323
+ const found = metadata.databases.some((db) => !Array.isArray(db.heads)) ? await headsIn(blocks) : [];
324
+ const byCanonical = new Map([...blocks].map(([name, block]) => [canonical(name), block]));
325
+
326
+ const databases = [];
327
+ for (const [index, dbInfo] of metadata.databases.entries()) {
328
+ const opened = await orbitdb.open(dbInfo.address, { type: dbInfo.type, ...open });
329
+ // OrbitDB's log reads from its own store and addresses blocks in base58btc
330
+ // rather than the CAR's base32. A restore that fills only the blockstore
331
+ // opens a database that is empty in a way nothing reports.
332
+ for (const [cidString, { bytes }] of blocks) {
333
+ try {
334
+ const cid = CID.parse(cidString);
335
+ if (cid.code !== DAG_CBOR) continue;
336
+ await opened.log.storage.put(cid.toV1().toString(base58btc), bytes);
337
+ } catch (error) {
338
+ log.warn(` ⚠️ could not copy ${cidString.slice(0, 12)}… to log storage: ${error.message}`);
339
+ }
269
340
  }
270
- }
271
341
 
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 });
342
+ // Reopened so the log reads what we just put underneath it.
343
+ await opened.close();
344
+ const database = await orbitdb.open(dbInfo.address, { type: dbInfo.type, ...open });
275
345
 
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++;
346
+ // Tell the log where its history ends.
347
+ const heads = Array.isArray(dbInfo.heads)
348
+ ? (await Promise.all(dbInfo.heads.map((hash) => headFrom(byCanonical, hash)))).filter(Boolean)
349
+ : found.filter((head) => head.value.id === dbInfo.address || metadata.databases.length === 1);
350
+ let joined = 0;
351
+ for (const head of heads) {
352
+ try {
353
+ const { v, id, key, sig, next, refs, clock, payload, identity } = head.value;
354
+ if (await database.log.joinEntry({ hash: head.hash, v, id, key, sig, next, refs, clock, payload, identity })) {
355
+ joined++;
356
+ }
357
+ } catch (error) {
358
+ log.warn(` ⚠️ could not join head ${head.hash.slice(0, 12)}…: ${error.message}`);
284
359
  }
285
- } catch (error) {
286
- log.warn(` ⚠️ could not join head ${head.hash.slice(0, 12)}…: ${error.message}`);
287
360
  }
361
+
362
+ log.info(`✅ Restored ${dbInfo.address} — ${joined}/${heads.length} heads`);
363
+ onProgress?.({
364
+ stage: "database",
365
+ index,
366
+ total: metadata.databases.length,
367
+ address: dbInfo.address,
368
+ joined,
369
+ heads: heads.length,
370
+ });
371
+ databases.push({
372
+ address: dbInfo.address,
373
+ name: dbInfo.name,
374
+ database,
375
+ entries: dbInfo.entryCount ?? null,
376
+ heads: heads.length,
377
+ joined,
378
+ });
288
379
  }
289
380
 
290
- log.info(`✅ Restored ${dbInfo.address} — ${stored} blocks, ${joined}/${heads.length} heads`);
381
+ return { blocks: stored, databases };
382
+ }
291
383
 
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
- };
384
+ /** A stated head, decoded from the blocks; null when the blocks lack it. */
385
+ async function headFrom(byCanonical, hash) {
386
+ const name = canonical(hash);
387
+ const block = name ? byCanonical.get(name) : undefined;
388
+ if (!block) return null;
389
+ const cid = CID.parse(name);
390
+ const { value } = await Block.decode({ cid, bytes: block.bytes, codec: dagCbor, hasher: sha256 });
391
+ return { hash: cid.toV1().toString(base58btc), value };
300
392
  }
301
393
 
302
- export default { restoreFromCID, fetchFromGateways, readBlocksFromCAR, DEFAULT_GATEWAYS, SILENT };
394
+ export default { restoreFromCID, restoreFromBlocks, fetchFromGateways, readBlocksFromCAR, DEFAULT_GATEWAYS, SILENT };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-storage-bridge",
3
- "version": "0.14.1",
3
+ "version": "0.16.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/",
@@ -15,6 +15,7 @@
15
15
  "./pointer-ipns": "./lib/pointer-ipns.js",
16
16
  "./dehydrate": "./lib/dehydrate.js",
17
17
  "./restore-cid": "./lib/restore-cid.js",
18
+ "./extract-blocks": "./lib/extract-blocks.js",
18
19
  "./gateway-fetch": "./lib/gateway-fetch.js",
19
20
  "./peer-fetch": "./lib/peer-fetch.js",
20
21
  "./backends/types": "./lib/backends/types.js",
@@ -46,6 +47,7 @@
46
47
  "clear-space": "node examples/storacha/clear-space.js",
47
48
  "car-demo": "node examples/storacha/car-backup-demo.js",
48
49
  "ucan-demo": "node examples/storacha/ucan-demo.js",
50
+ "check:links": "node scripts/check-links.mjs",
49
51
  "lint": "eslint --config eslint.config.js '{lib,examples,test}/**/*.js' --ignore-pattern 'examples/svelte/*/build/**' --ignore-pattern 'examples/svelte/*/dist/**' --ignore-pattern 'examples/svelte/**/.svelte-kit/**' --ignore-pattern 'node_modules/**'",
50
52
  "format": "prettier --write lib/ examples/ test/ --ignore-path '.prettierignore'",
51
53
  "clean": "rm -rf ./cid-bridge-test* ./demo-test*",