@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.
- package/README.md +77 -316
- package/lib/backends/aleph-pin.js +147 -6
- package/lib/courier-sync.js +299 -13
- package/lib/extract-blocks.js +73 -0
- package/lib/restore-cid.js +147 -55
- package/package.json +3 -1
package/lib/restore-cid.js
CHANGED
|
@@ -157,7 +157,8 @@ async function headsIn(blocks) {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
|
-
* Restore
|
|
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
|
|
237
|
-
if (verify
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
//
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
381
|
+
return { blocks: stored, databases };
|
|
382
|
+
}
|
|
291
383
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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.
|
|
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*",
|