@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,1286 @@
1
+ /**
2
+ * CAR-based OrbitDB Backup with Timestamps
3
+ *
4
+ * Alternative implementation using CAR (Content Addressable Archive) files
5
+ * for efficient timestamped backups. Works in both Node.js and browser environments.
6
+ *
7
+ * This is backward compatible with the existing individual block upload approach.
8
+ * Use this when you want:
9
+ * - Timestamped backups (multiple backup points)
10
+ * - More efficient upload (fewer files)
11
+ * - Better organization (grouped by timestamp)
12
+ */
13
+
14
+ import { CarWriter, CarReader } from "@ipld/car";
15
+ import { CID } from "multiformats/cid";
16
+ import { base58btc } from "multiformats/bases/base58";
17
+ import * as Block from "multiformats/block";
18
+ import * as dagCbor from "@ipld/dag-cbor";
19
+ import { sha256 } from "multiformats/hashes/sha2";
20
+ import {
21
+ generateBackupPrefix,
22
+ getBackupFilenames,
23
+ isValidMetadata,
24
+ } from "./backup-helpers.js";
25
+ import { extractDatabaseBlocks } from "./extract-blocks.js";
26
+ import { resolveBackend } from "./backends/resolve.js";
27
+
28
+ // The space-side helpers live in the main entry, which imports `@storacha/client`
29
+ // at the top — about 88 kB gzipped in a browser bundle. Backing up needs none of
30
+ // it, so they are fetched only when a Storacha space is actually consulted, and a
31
+ // bundler keeps them out of the page that never does.
32
+ const spaceHelpers = () => import("./orbitdb-storacha-bridge.js");
33
+ import { unixfs } from "@helia/unixfs";
34
+ import logger from "./logger.js";
35
+ import { readBlockBytes } from "./block-bytes.js";
36
+
37
+ /**
38
+ * Default configuration options
39
+ */
40
+ const DEFAULT_OPTIONS = {
41
+ timeout: 30000,
42
+ gateway: "https://w3s.link",
43
+ verbose: false,
44
+ // Network download options
45
+ useIPFSNetwork: true, // Enable network downloads by default
46
+ gatewayFallback: true, // Fallback to gateway if network fails
47
+ storachaKey: undefined,
48
+ storachaProof: undefined,
49
+ serviceConf: undefined,
50
+ receiptsEndpoint: undefined,
51
+ fallbackDatabaseName: undefined,
52
+ forceFallback: false,
53
+ };
54
+
55
+ /**
56
+ * Create a CAR file in memory from blocks
57
+ * @param {Map} blocks - Map of blocks to include
58
+ * @param {string} manifestCID - Root CID for the CAR file
59
+ * @returns {Promise<Uint8Array>} CAR file as bytes
60
+ */
61
+ export async function createCARFromBlocks(blocks, manifestCID) {
62
+ if (!blocks || !(blocks instanceof Map)) {
63
+ throw new Error("blocks must be a Map");
64
+ }
65
+ logger.debug(`Creating CAR file with ${blocks.size} blocks`);
66
+
67
+ // Parse the manifest CID as the root
68
+ const rootCID = CID.parse(manifestCID);
69
+
70
+ // Create an in-memory CAR writer
71
+ const { writer, out } = CarWriter.create([rootCID]);
72
+
73
+ // Read the output while the blocks go in: each put waits until what it
74
+ // wrote has been taken. An async loop, not Node's Readable.from, which the
75
+ // stream polyfill browsers get from their bundler does not have.
76
+ const collected = (async () => {
77
+ const chunks = [];
78
+ for await (const chunk of out) chunks.push(chunk);
79
+ return chunks;
80
+ })();
81
+
82
+ // Add all blocks to the CAR
83
+ for (const [cidString, blockData] of blocks.entries()) {
84
+ try {
85
+ const cid = CID.parse(cidString);
86
+ await writer.put({ cid, bytes: blockData.bytes });
87
+ } catch (error) {
88
+ logger.warn(`Failed to add block ${cidString} to CAR: ${error.message}`);
89
+ }
90
+ }
91
+
92
+ await writer.close();
93
+ const chunks = await collected;
94
+
95
+ // Concatenate all chunks into a single Uint8Array
96
+ const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
97
+ const carBytes = new Uint8Array(totalLength);
98
+ let offset = 0;
99
+ for (const chunk of chunks) {
100
+ carBytes.set(chunk, offset);
101
+ offset += chunk.length;
102
+ }
103
+
104
+ logger.info(`Created CAR file: ${carBytes.length} bytes`);
105
+ return carBytes;
106
+ }
107
+
108
+ /**
109
+ * Read blocks from a CAR file
110
+ * @param {Uint8Array|AsyncIterable} carData - CAR file data
111
+ * @returns {Promise<Map>} Map of CID -> block data
112
+ */
113
+ export async function readBlocksFromCAR(carData) {
114
+ logger.debug("Reading blocks from CAR file");
115
+
116
+ const blocks = new Map();
117
+
118
+ // Convert Uint8Array to async iterable if needed
119
+ const iterable =
120
+ carData instanceof Uint8Array
121
+ ? (async function* () {
122
+ yield carData;
123
+ })()
124
+ : carData;
125
+
126
+ const reader = await CarReader.fromIterable(iterable);
127
+
128
+ for await (const { cid, bytes } of reader.blocks()) {
129
+ blocks.set(cid.toString(), { cid, bytes });
130
+ }
131
+
132
+ logger.info(`Read ${blocks.size} blocks from CAR file`);
133
+ return blocks;
134
+ }
135
+
136
+ /**
137
+ * Backup an OrbitDB database using CAR format with timestamps
138
+ *
139
+ * @param {Object} orbitdb - OrbitDB instance
140
+ * @param {string} databaseAddress - Database address or name
141
+ * @param {Object} options - Backup options
142
+ * @param {string} [options.spaceName='default'] - Storacha space name for organizing backups
143
+ * @param {string} [options.storachaKey] - Storacha private key
144
+ * @param {string} [options.storachaProof] - Storacha proof
145
+ * @param {Object} [options.ucanClient] - Pre-configured UCAN client
146
+ * @param {string} [options.spaceDID] - Space DID for UCAN auth
147
+ * @param {EventEmitter} [options.eventEmitter] - Optional event emitter for progress
148
+ * @returns {Promise<Object>} Backup result with file info
149
+ */
150
+ export async function backupDatabaseCAR(
151
+ orbitdb,
152
+ databaseAddress,
153
+ options = {},
154
+ ) {
155
+ const config = { ...DEFAULT_OPTIONS, ...options };
156
+ const spaceName = config.spaceName || "default";
157
+ const eventEmitter = options.eventEmitter;
158
+
159
+ logger.info("🚀 Starting CAR-based OrbitDB Backup");
160
+ logger.info(`📍 Database: ${databaseAddress}`);
161
+ logger.info(`🗂️ Space: ${spaceName}`);
162
+
163
+ try {
164
+ const backend = await resolveBackend(config);
165
+
166
+ // Step 1: Extract database blocks
167
+ logger.info("📦 Step 1: Extracting database blocks...");
168
+
169
+ // Open database - if it's already open, OrbitDB will return the existing instance
170
+ const database = await orbitdb.open(databaseAddress, config.dbConfig);
171
+
172
+ const { blocks, blockSources, manifestCID } = await extractDatabaseBlocks(
173
+ database,
174
+ { logEntriesOnly: false },
175
+ );
176
+
177
+ // Defensive check: ensure blocks is a Map
178
+ if (!blocks || !(blocks instanceof Map)) {
179
+ throw new Error("Failed to extract database blocks: blocks is not a Map");
180
+ }
181
+
182
+ logger.info(` ✅ Extracted ${blocks.size} blocks`);
183
+
184
+ // Step 2: Generate timestamped filenames
185
+ const backupPrefix = generateBackupPrefix(spaceName);
186
+ const backupFiles = getBackupFilenames(backupPrefix);
187
+
188
+ logger.info(`📝 Step 2: Creating timestamped backup files...`);
189
+ logger.info(` Metadata: ${backupFiles.metadata}`);
190
+ logger.info(` Blocks: ${backupFiles.blocks}`);
191
+
192
+ // Step 3: Create metadata
193
+ // Get entry count for verification during restore
194
+ const entries = await database.all();
195
+ const entriesCount = Array.isArray(entries)
196
+ ? entries.length
197
+ : Object.keys(entries).length;
198
+
199
+ const metadata = {
200
+ version: "1.0",
201
+ timestamp: Date.now(),
202
+ spaceName: spaceName, // Add spaceName for filtering backups
203
+ databaseCount: 1,
204
+ totalBlocks: blocks.size,
205
+ totalEntries: entriesCount,
206
+ manifestCID: manifestCID,
207
+ databases: [
208
+ {
209
+ address: database.address,
210
+ name: database.name,
211
+ type: database.type,
212
+ manifestCID: manifestCID,
213
+ entryCount: entriesCount,
214
+ },
215
+ ],
216
+ blockSummary: Object.fromEntries(
217
+ Array.from(new Set(blockSources.values())).map((type) => [
218
+ type,
219
+ Array.from(blockSources.values()).filter((t) => t === type).length,
220
+ ]),
221
+ ),
222
+ };
223
+
224
+ // Validate metadata
225
+ if (!isValidMetadata(metadata)) {
226
+ throw new Error("Invalid metadata structure");
227
+ }
228
+
229
+ // Step 4: Create CAR file in memory
230
+ logger.info("🗜️ Step 3: Creating CAR archive...");
231
+
232
+ if (eventEmitter) {
233
+ eventEmitter.emit("backupProgress", {
234
+ type: "car-creation",
235
+ status: "creating",
236
+ totalBlocks: blocks.size,
237
+ });
238
+ }
239
+
240
+ const carBytes = await createCARFromBlocks(blocks, manifestCID);
241
+
242
+ logger.info(
243
+ ` ✅ Created CAR file: ${carBytes.length} bytes (${blocks.size} blocks)`,
244
+ );
245
+
246
+ // Step 5: Upload
247
+ logger.info("📤 Step 4: Uploading to %s...", backend.name ?? "storage");
248
+
249
+ if (eventEmitter) {
250
+ eventEmitter.emit("backupProgress", {
251
+ type: "upload",
252
+ status: "uploading-blocks",
253
+ size: carBytes.length,
254
+ });
255
+ }
256
+
257
+ const carHandle = await backend.putBlob(carBytes, {
258
+ name: backupFiles.blocks,
259
+ type: "application/vnd.ipld.car",
260
+ });
261
+ const carCID = carHandle.cid || carHandle.id;
262
+ logger.info(` ✅ CAR file uploaded: ${carCID}`);
263
+
264
+ // Add CAR CID to metadata
265
+ metadata.carCID = carCID;
266
+
267
+ // Now upload updated metadata with CAR CID
268
+ const metadataBytes = new TextEncoder().encode(
269
+ JSON.stringify(metadata, null, 2),
270
+ );
271
+
272
+ if (eventEmitter) {
273
+ eventEmitter.emit("backupProgress", {
274
+ type: "upload",
275
+ status: "uploading-metadata",
276
+ });
277
+ }
278
+
279
+ const metadataHandle = await backend.putBlob(metadataBytes, {
280
+ name: backupFiles.metadata,
281
+ type: "application/json",
282
+ });
283
+ const metadataCID = metadataHandle.cid || metadataHandle.id;
284
+ logger.info(` ✅ Metadata uploaded: ${metadataCID}`);
285
+
286
+ if (eventEmitter) {
287
+ eventEmitter.emit("backupProgress", {
288
+ type: "upload",
289
+ status: "completed",
290
+ metadataCID: metadataCID,
291
+ carCID: carCID,
292
+ });
293
+ }
294
+
295
+ logger.info("✅ CAR-based backup completed successfully!");
296
+ logger.info(" Note: Database is left open for caller to manage");
297
+
298
+ return {
299
+ success: true,
300
+ method: "car-timestamped",
301
+ manifestCID,
302
+ databaseAddress: database.address,
303
+ databaseName: database.name,
304
+ blocksTotal: blocks.size,
305
+ // Every block went up, in one piece. Reported because callers written
306
+ // against the per-block path read this field, and it would be true of
307
+ // them too — but it is not per-block accounting: there is one upload
308
+ // here, and a partial CAR is not a thing that can happen.
309
+ blocksUploaded: blocks.size,
310
+ carFileSize: carBytes.length,
311
+ blockSummary: metadata.blockSummary,
312
+ backupFiles: {
313
+ metadata: backupFiles.metadata,
314
+ blocks: backupFiles.blocks,
315
+ metadataCID: metadataCID,
316
+ carCID: carCID,
317
+ },
318
+ timestamp: metadata.timestamp,
319
+ };
320
+ } catch (error) {
321
+ logger.error(`❌ CAR-based backup failed: ${error.message}`);
322
+
323
+ if (eventEmitter) {
324
+ eventEmitter.emit("backupProgress", {
325
+ type: "upload",
326
+ status: "error",
327
+ error: error.message,
328
+ });
329
+ }
330
+
331
+ return {
332
+ success: false,
333
+ method: "car-timestamped",
334
+ error: error.message,
335
+ };
336
+ }
337
+ }
338
+
339
+ /**
340
+ * List all available backups in a space
341
+ *
342
+ * @param {Object} options - Options
343
+ * @param {string} [options.spaceName='default'] - Storacha space name
344
+ * @param {string} [options.storachaKey] - Storacha private key
345
+ * @param {string} [options.storachaProof] - Storacha proof
346
+ * @param {Object} [options.ucanClient] - Pre-configured UCAN client
347
+ * @param {string} [options.spaceDID] - Space DID for UCAN auth
348
+ * @param {Object} [options.ipfs] - IPFS/Helia instance for network downloads
349
+ * @param {boolean} [options.useIPFSNetwork=true] - Use IPFS network for downloads
350
+ * @param {boolean} [options.gatewayFallback=true] - Fallback to gateway if network fails
351
+ * @returns {Promise<Array>} List of available backups sorted by timestamp (newest first)
352
+ */
353
+ export async function listAvailableBackups(options = {}) {
354
+ const config = { ...DEFAULT_OPTIONS, ...options };
355
+ const spaceName = config.spaceName || "default";
356
+ const maxRetries = 5;
357
+ const retryDelay = 5000; // 5 seconds
358
+
359
+ logger.info(`Listing backups for space: ${spaceName}`);
360
+
361
+ // Storacha only returns CIDs, not original filenames
362
+ // We need to download and check each file to see if it's a backup metadata file
363
+
364
+ // Process files in parallel (batches of 10) with increased timeout
365
+ /**
366
+ * Checks if a file from Storacha space is a backup metadata file.
367
+ *
368
+ * Since Storacha only returns CIDs (not filenames), we need to download and inspect
369
+ * each file to identify backup metadata files. The space contains both:
370
+ * - Backup metadata files (JSON) - which we want to list
371
+ * - CAR files (binary) - which we want to skip
372
+ *
373
+ * This function performs multiple validation checks to efficiently filter out
374
+ * binary CAR files before attempting JSON parsing:
375
+ * 1. Downloads the file from IPFS network (if available) or gateway with timeout protection
376
+ * - Uses downloadBlock for gateway downloads (tries multiple gateways)
377
+ * 2. Checks file size (metadata files are small, < 100KB)
378
+ * 3. Validates JSON structure (starts with '{' or '[')
379
+ * 4. Detects binary data (non-printable characters)
380
+ * 5. Parses as JSON and validates backup metadata structure
381
+ * 6. Filters by spaceName if specified in metadata
382
+ *
383
+ * @param {Object} file - File object from Storacha space listing
384
+ * @param {string} [file.root] - Root CID of the file
385
+ * @param {string} [file.cid] - CID of the file
386
+ * @returns {Promise<Object|null>} Backup metadata object if valid, null otherwise
387
+ */
388
+ const checkFile = async (file) => {
389
+ try {
390
+ const cid = file.root?.toString() || file.cid?.toString();
391
+ if (!cid) return null;
392
+
393
+ let text;
394
+
395
+ // Try network download first if enabled and IPFS instance available
396
+ if (config.useIPFSNetwork && config.ipfs) {
397
+ try {
398
+ const fs = unixfs(config.ipfs);
399
+ const { downloadBlockFromIPFSNetwork } = await spaceHelpers();
400
+ const bytes = await downloadBlockFromIPFSNetwork(cid, config.ipfs, {
401
+ ...config,
402
+ unixfs: fs,
403
+ timeout: 5000,
404
+ });
405
+ text = new TextDecoder().decode(bytes);
406
+ } catch (error) {
407
+ logger.debug(
408
+ `Network download failed for ${cid.substring(0, 12)}..., ${config.gatewayFallback ? "falling back to gateway" : "no fallback"}: ${error.message}`,
409
+ );
410
+ if (!config.gatewayFallback) {
411
+ return null; // Network failed and no fallback
412
+ }
413
+ // Fall through to gateway
414
+ }
415
+ }
416
+
417
+ // Fallback to gateway if network failed or disabled
418
+ if (!text) {
419
+ // Use downloadBlock for better gateway fallback (tries multiple gateways)
420
+ try {
421
+ const { downloadBlock } = await spaceHelpers();
422
+ const bytes = await downloadBlock(cid, {
423
+ ...config,
424
+ useIPFSNetwork: false, // Force gateway-only mode
425
+ helia: config.ipfs, // Pass IPFS instance if available (won't be used when useIPFSNetwork is false)
426
+ timeout: 5000,
427
+ });
428
+
429
+ // Additional validation: check if we got an HTML error page
430
+ const textStart = new TextDecoder("utf-8", { fatal: false }).decode(
431
+ bytes.slice(0, Math.min(200, bytes.length)),
432
+ );
433
+ if (
434
+ textStart.trim().startsWith("<!DOCTYPE") ||
435
+ textStart.trim().startsWith("<html") ||
436
+ textStart.trim().startsWith("<?xml")
437
+ ) {
438
+ logger.debug(
439
+ `Gateway returned HTML error page for ${cid.substring(0, 12)}..., file may not be available on gateway yet`,
440
+ );
441
+ return null;
442
+ }
443
+
444
+ text = new TextDecoder().decode(bytes);
445
+ } catch (error) {
446
+ logger.debug(
447
+ `Gateway download failed for ${cid.substring(0, 12)}...: ${error.message}`,
448
+ );
449
+ return null;
450
+ }
451
+ }
452
+
453
+ // Quick size check - metadata files should be small
454
+ if (text.length > 100000) return null; // Skip files > 100KB
455
+
456
+ // Check if it looks like JSON before parsing (CAR files are binary and won't start with '{' or '[')
457
+ const trimmedText = text.trim();
458
+ if (!trimmedText.startsWith("{") && !trimmedText.startsWith("[")) {
459
+ // Not JSON, likely a CAR file or other binary format
460
+ logger.debug(
461
+ `Skipping non-JSON file ${cid.substring(0, 12)}... (doesn't start with '{' or '[')`,
462
+ );
463
+ return null;
464
+ }
465
+
466
+ // Check for binary data (non-printable characters in first few bytes)
467
+ // CAR files often have binary data that shows up as weird characters
468
+ const firstBytes = text.substring(0, Math.min(100, text.length));
469
+ // eslint-disable-next-line no-control-regex
470
+ if (/[\x00-\x08\x0E-\x1F]/.test(firstBytes)) {
471
+ // Contains binary data, likely not JSON
472
+ logger.debug(
473
+ `Skipping binary file ${cid.substring(0, 12)}... (contains binary data)`,
474
+ );
475
+ return null;
476
+ }
477
+
478
+ let data;
479
+ try {
480
+ data = JSON.parse(text);
481
+ } catch (parseError) {
482
+ // Not valid JSON, skip it
483
+ logger.debug(
484
+ `Skipping invalid JSON file ${cid.substring(0, 12)}...: ${parseError.message}`,
485
+ );
486
+ return null;
487
+ }
488
+
489
+ // Check if it's a backup metadata file
490
+ if (
491
+ data.version &&
492
+ data.timestamp &&
493
+ data.databases &&
494
+ data.databases.length > 0
495
+ ) {
496
+ // Filter by spaceName if it's in the metadata
497
+ const metadataSpaceName =
498
+ data.backupInfo?.spaceName || data.spaceName || "default";
499
+ logger.debug(
500
+ `Found backup with spaceName: ${metadataSpaceName}, looking for: ${spaceName}`,
501
+ );
502
+ if (metadataSpaceName !== spaceName) {
503
+ logger.debug(`Skipping backup from space ${metadataSpaceName}`);
504
+ return null; // Skip backups from other spaces
505
+ }
506
+ logger.info(`Matched backup from space ${metadataSpaceName}`);
507
+
508
+ const timestamp = new Date(data.timestamp)
509
+ .toISOString()
510
+ .replace(/\.\d{3}Z$/, "Z")
511
+ .replace(/:/g, "-")
512
+ .replace(/\..+$/, "");
513
+
514
+ return {
515
+ timestamp,
516
+ metadataCID: cid,
517
+ metadata: data,
518
+ date: new Date(data.timestamp).toISOString(),
519
+ };
520
+ }
521
+ return null;
522
+ } catch (error) {
523
+ logger.debug(
524
+ `Failed to check file ${file.root?.toString() || file.cid?.toString() || "unknown"}: ${error.message}`,
525
+ );
526
+ return null;
527
+ }
528
+ };
529
+
530
+ // Retry logic: try up to maxRetries times if no backups found
531
+ let backups = [];
532
+ let attempt = 0;
533
+
534
+ while (attempt < maxRetries) {
535
+ attempt++;
536
+
537
+ if (attempt > 1) {
538
+ logger.info(
539
+ `Retry attempt ${attempt}/${maxRetries} (waiting ${retryDelay}ms before retry)...`,
540
+ );
541
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
542
+ }
543
+
544
+ // Refresh uploads listing on every attempt because index propagation is eventual.
545
+ let spaceFiles;
546
+ try {
547
+ // listStorachaSpaceFiles handles authentication internally using config
548
+ const { listStorachaSpaceFiles } = await spaceHelpers();
549
+ spaceFiles = await listStorachaSpaceFiles(config);
550
+ logger.info(`Found ${spaceFiles.length} files in space`);
551
+ } catch (error) {
552
+ logger.warn(
553
+ `Failed to list uploads (attempt ${attempt}/${maxRetries}): ${error.message}`,
554
+ );
555
+ if (attempt === maxRetries) {
556
+ throw error;
557
+ }
558
+ continue;
559
+ }
560
+
561
+ // Process in batches of 10
562
+ const batchSize = 10;
563
+ backups = [];
564
+
565
+ for (let i = 0; i < spaceFiles.length; i += batchSize) {
566
+ const batch = spaceFiles.slice(i, i + batchSize);
567
+ const results = await Promise.all(batch.map(checkFile));
568
+ backups.push(...results.filter((r) => r !== null));
569
+
570
+ if (backups.length >= 20) {
571
+ // Found enough backups, stop checking
572
+ logger.info(`Found ${backups.length} backups, stopping search`);
573
+ break;
574
+ }
575
+ }
576
+
577
+ logger.info(
578
+ `Found ${backups.length} backups (attempt ${attempt}/${maxRetries})`,
579
+ );
580
+
581
+ // If we found backups, break out of retry loop
582
+ if (backups.length > 0) {
583
+ break;
584
+ }
585
+
586
+ // If this was the last attempt, log a warning
587
+ if (attempt === maxRetries) {
588
+ logger.warn(
589
+ `No backups found after ${maxRetries} attempts. Files may not be available on IPFS gateway yet.`,
590
+ );
591
+ }
592
+ }
593
+
594
+ // Sort by timestamp (newest first)
595
+ backups.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
596
+
597
+ return backups;
598
+ }
599
+
600
+ /**
601
+ * The restored entries, shaped the way `restoreDatabaseFromSpace` shapes them.
602
+ *
603
+ * Kept beside the CAR restore rather than shared with the block path, because
604
+ * the two modules already point one way and closing the loop for a six-line
605
+ * helper would be a poor trade. If a third caller ever needs it, that is the
606
+ * moment to lift it out.
607
+ *
608
+ * @param {Object} database an opened OrbitDB database
609
+ */
610
+ async function restoredEntriesOf(database) {
611
+ if (database.type === "keyvalue") {
612
+ const logEntries = await database.log.values();
613
+ return logEntries.map((entry) => ({ hash: entry.hash, payload: entry.payload }));
614
+ }
615
+ const all = await database.all();
616
+ return Array.isArray(all) ? all : [];
617
+ }
618
+
619
+ /**
620
+ * Restore from a CAR-based backup in a space
621
+ *
622
+ * @param {Object} orbitdb - OrbitDB instance
623
+ * @param {Object} options - Restore options
624
+ * @param {string} [options.spaceName='default'] - Storacha space name
625
+ * @param {string} [options.timestamp] - Specific backup timestamp to restore (e.g., '2025-10-27T14-30-00-123Z')
626
+ * If not provided, restores from latest backup
627
+ * @param {string} [options.storachaKey] - Storacha private key
628
+ * @param {string} [options.storachaProof] - Storacha proof
629
+ * @param {Object} [options.ucanClient] - Pre-configured UCAN client
630
+ * @param {string} [options.spaceDID] - Space DID for UCAN auth
631
+ * @param {EventEmitter} [options.eventEmitter] - Optional event emitter for progress
632
+ * @returns {Promise<Object>} Restore result
633
+ */
634
+ export async function restoreFromSpaceCAR(orbitdb, options = {}) {
635
+ const config = { ...DEFAULT_OPTIONS, ...options };
636
+ const spaceName = config.spaceName || "default";
637
+ const eventEmitter = options.eventEmitter;
638
+
639
+ logger.info("🔄 Starting CAR-based OrbitDB Restore");
640
+ logger.info(`🗂️ Space: ${spaceName}`);
641
+
642
+ try {
643
+ // Step 1: Find backup (specific timestamp or latest)
644
+ // Note: Authentication is handled by listAvailableBackups or via config for direct downloads
645
+ let backupToRestore;
646
+
647
+ if (options.timestamp) {
648
+ // Restore from specific timestamp
649
+ logger.info(
650
+ `📋 Step 1: Finding backup with timestamp: ${options.timestamp}...`,
651
+ );
652
+
653
+ backupToRestore = {
654
+ metadata: `backup-${options.timestamp}-metadata.json`,
655
+ blocks: `backup-${options.timestamp}-blocks.car`,
656
+ timestamp: options.timestamp,
657
+ };
658
+
659
+ logger.info(` ✅ Restoring from specific backup: ${options.timestamp}`);
660
+ } else {
661
+ // Find latest backup using listAvailableBackups
662
+ logger.info("📋 Step 1: Finding latest backup...");
663
+
664
+ const availableBackups = await listAvailableBackups({
665
+ ...config,
666
+ ipfs: orbitdb?.ipfs, // Pass IPFS instance for network downloads
667
+ });
668
+
669
+ if (!availableBackups || availableBackups.length === 0) {
670
+ throw new Error("No valid CAR backup found in space");
671
+ }
672
+
673
+ // Use the most recent backup (already sorted by timestamp)
674
+ const latestBackup = availableBackups[0];
675
+
676
+ backupToRestore = {
677
+ timestamp: latestBackup.timestamp,
678
+ metadataCID: latestBackup.metadataCID,
679
+ metadata: latestBackup.metadata,
680
+ };
681
+
682
+ logger.info(` ✅ Found latest backup: ${backupToRestore.timestamp}`);
683
+ }
684
+
685
+ if (eventEmitter) {
686
+ eventEmitter.emit("restoreProgress", {
687
+ type: "discovery",
688
+ status: "found",
689
+ backup: backupToRestore,
690
+ });
691
+ }
692
+
693
+ // Step 2: Get metadata (already have it if from listAvailableBackups)
694
+ logger.info("📥 Step 2: Loading metadata...");
695
+
696
+ let metadata;
697
+ if (
698
+ backupToRestore.metadata &&
699
+ typeof backupToRestore.metadata === "object"
700
+ ) {
701
+ // Already have metadata object from listAvailableBackups
702
+ metadata = backupToRestore.metadata;
703
+ logger.info(` ✅ Using cached metadata`);
704
+ } else {
705
+ // Download metadata by CID or filename
706
+ const metadataCID =
707
+ options.metadataCID ||
708
+ backupToRestore.metadataCID ||
709
+ backupToRestore.metadata;
710
+
711
+ // Try network download first if enabled and OrbitDB instance available
712
+ let metadataBytes = null;
713
+ if (config.useIPFSNetwork && orbitdb?.ipfs) {
714
+ try {
715
+ logger.info(
716
+ " 🌐 Attempting to download metadata from IPFS network...",
717
+ );
718
+ // Get unixfs from orbitdb if available (it should be from createHeliaOrbitDB)
719
+ const fs = unixfs(orbitdb.ipfs);
720
+ const { downloadBlockFromIPFSNetwork } = await spaceHelpers();
721
+ metadataBytes = await downloadBlockFromIPFSNetwork(
722
+ metadataCID,
723
+ orbitdb.ipfs,
724
+ { ...config, unixfs: fs },
725
+ );
726
+ metadata = JSON.parse(new TextDecoder().decode(metadataBytes));
727
+ logger.info(" ✅ Metadata downloaded from IPFS network");
728
+ } catch (error) {
729
+ logger.info(
730
+ ` ⚠️ Network download failed, ${config.gatewayFallback ? "falling back to gateway" : "no fallback"}: ${error.message}`,
731
+ );
732
+ if (!config.gatewayFallback) {
733
+ throw new Error(
734
+ `Failed to download metadata from IPFS network and gateway fallback is disabled: ${error.message}`, { cause: error },
735
+ );
736
+ }
737
+ }
738
+ }
739
+
740
+ // Fallback to gateway if network failed or disabled
741
+ if (!metadata) {
742
+ const metadataUrl = `${config.gateway}/ipfs/${metadataCID}`;
743
+ const metadataResponse = await fetch(metadataUrl);
744
+
745
+ if (!metadataResponse.ok) {
746
+ throw new Error(
747
+ `Failed to download metadata: ${metadataResponse.statusText}`,
748
+ );
749
+ }
750
+
751
+ metadata = await metadataResponse.json();
752
+ logger.info(" ✅ Metadata downloaded from gateway");
753
+ }
754
+ }
755
+
756
+ if (!isValidMetadata(metadata)) {
757
+ throw new Error("Invalid backup metadata");
758
+ }
759
+
760
+ logger.info(
761
+ ` ✅ Metadata validated: ${metadata.totalBlocks} blocks, ${new Date(metadata.timestamp).toISOString()}`,
762
+ );
763
+
764
+ // Step 3: Download CAR file
765
+ logger.info("📥 Step 3: Downloading CAR file...");
766
+
767
+ if (eventEmitter) {
768
+ eventEmitter.emit("restoreProgress", {
769
+ type: "download",
770
+ status: "downloading-blocks",
771
+ });
772
+ }
773
+
774
+ // Get CAR CID from metadata (preferred) or options/backup object
775
+ const carCID = metadata.carCID || options.carCID || backupToRestore.blocks;
776
+
777
+ if (!carCID) {
778
+ throw new Error("CAR file CID not found in metadata or backup info");
779
+ }
780
+
781
+ // Try network download first if enabled and OrbitDB instance available
782
+ let carBytes = null;
783
+ if (config.useIPFSNetwork && orbitdb?.ipfs) {
784
+ try {
785
+ logger.info(
786
+ " 🌐 Attempting to download CAR file from IPFS network...",
787
+ );
788
+ // Use provided unixfs or create one from orbitdb
789
+ let fs = config.unixfs;
790
+ if (!fs) {
791
+ fs = unixfs(orbitdb.ipfs);
792
+ }
793
+ const { downloadBlockFromIPFSNetwork } = await spaceHelpers();
794
+ carBytes = await downloadBlockFromIPFSNetwork(carCID, orbitdb.ipfs, {
795
+ ...config,
796
+ unixfs: fs,
797
+ });
798
+ logger.info(
799
+ ` ✅ Downloaded CAR file: ${carBytes.length} bytes from IPFS network`,
800
+ );
801
+ } catch (error) {
802
+ logger.info(
803
+ ` ⚠️ Network download failed, ${config.gatewayFallback ? "falling back to gateway" : "no fallback"}: ${error.message}`,
804
+ );
805
+ if (!config.gatewayFallback) {
806
+ throw new Error(
807
+ `Failed to download CAR file from IPFS network and gateway fallback is disabled: ${error.message}`, { cause: error },
808
+ );
809
+ }
810
+ }
811
+ }
812
+
813
+ // Fallback to gateway if network failed or disabled
814
+ if (!carBytes) {
815
+ // Try multiple gateways in order
816
+ const gateways = [
817
+ `${config.gateway}/ipfs`,
818
+ "https://storacha.link/ipfs",
819
+ "https://dweb.link/ipfs",
820
+ "https://ipfs.io/ipfs",
821
+ ];
822
+
823
+ let carResponse;
824
+ let lastError;
825
+
826
+ for (const gateway of gateways) {
827
+ const carUrl = `${gateway}/${carCID}`;
828
+ logger.info(` Trying gateway: ${gateway}...`);
829
+
830
+ // Retry logic for rate limiting
831
+ let attempts = 0;
832
+ const maxAttempts = 3;
833
+ let success = false;
834
+
835
+ while (attempts < maxAttempts && !success) {
836
+ try {
837
+ carResponse = await fetch(carUrl);
838
+
839
+ if (carResponse.ok) {
840
+ const contentType =
841
+ carResponse.headers?.get("content-type") || "";
842
+
843
+ // Check if we got HTML (error page)
844
+ if (
845
+ contentType.includes("text/html") ||
846
+ contentType.includes("application/xhtml")
847
+ ) {
848
+ // Decode and log the error page
849
+ const tempBytes = new Uint8Array(
850
+ await carResponse.arrayBuffer(),
851
+ );
852
+ const errorPageText = new TextDecoder("utf-8", {
853
+ fatal: false,
854
+ }).decode(tempBytes);
855
+ logger.warn(
856
+ ` ⚠️ Gateway ${gateway} returned HTML error page (Content-Type: ${contentType})`,
857
+ );
858
+ logger.debug(
859
+ ` Error page preview: ${errorPageText.substring(0, 200)}...`,
860
+ );
861
+ break; // Try next gateway
862
+ }
863
+
864
+ // Download the bytes
865
+ carBytes = new Uint8Array(await carResponse.arrayBuffer());
866
+
867
+ // Validate that we didn't get an HTML error page by content
868
+ const textStart = new TextDecoder("utf-8", {
869
+ fatal: false,
870
+ }).decode(carBytes.slice(0, Math.min(100, carBytes.length)));
871
+ if (
872
+ textStart.trim().startsWith("<!DOCTYPE") ||
873
+ textStart.trim().startsWith("<html") ||
874
+ textStart.trim().startsWith("<?xml")
875
+ ) {
876
+ // Decode and log the error page
877
+ const errorPageText = new TextDecoder("utf-8", {
878
+ fatal: false,
879
+ }).decode(carBytes);
880
+ logger.warn(
881
+ ` ⚠️ Gateway ${gateway} returned HTML error page (detected by content)`,
882
+ );
883
+ logger.debug(
884
+ ` Error page preview: ${errorPageText.substring(0, 200)}...`,
885
+ );
886
+ carBytes = null; // Reset to try next gateway
887
+ break; // Try next gateway
888
+ }
889
+
890
+ // Validate CAR file header (CAR files start with specific bytes)
891
+ // CAR v1 format starts with 0x3aa16726649a (varint-encoded header)
892
+ if (carBytes.length < 11) {
893
+ logger.warn(
894
+ ` ⚠️ Gateway ${gateway} returned file too small (${carBytes.length} bytes), trying next gateway...`,
895
+ );
896
+ carBytes = null; // Reset to try next gateway
897
+ break; // Try next gateway
898
+ }
899
+
900
+ // Success! We got a valid CAR file
901
+ logger.info(
902
+ ` ✅ Downloaded CAR file: ${carBytes.length} bytes from ${gateway}`,
903
+ );
904
+ success = true;
905
+ break;
906
+ }
907
+
908
+ // Handle rate limiting
909
+ if (carResponse.status === 429 && attempts < maxAttempts - 1) {
910
+ const retryAfter = carResponse.headers.get("Retry-After");
911
+ const rateLimitReset =
912
+ carResponse.headers.get("X-RateLimit-Reset");
913
+ const rateLimitRemaining = carResponse.headers.get(
914
+ "X-RateLimit-Remaining",
915
+ );
916
+
917
+ logger.warn(` ⚠️ Rate limited (429) on ${gateway}`);
918
+ logger.warn(` Retry-After: ${retryAfter || "not set"}`);
919
+ logger.warn(
920
+ ` X-RateLimit-Reset: ${rateLimitReset || "not set"}`,
921
+ );
922
+ logger.warn(
923
+ ` X-RateLimit-Remaining: ${rateLimitRemaining || "not set"}`,
924
+ );
925
+
926
+ // Calculate wait time
927
+ let waitTime = 2000 * (attempts + 1); // Default exponential backoff
928
+
929
+ if (retryAfter) {
930
+ const retrySeconds = parseInt(retryAfter);
931
+ if (!isNaN(retrySeconds)) {
932
+ waitTime = retrySeconds * 1000;
933
+ } else {
934
+ const retryDate = new Date(retryAfter);
935
+ if (!isNaN(retryDate.getTime())) {
936
+ waitTime = Math.max(0, retryDate.getTime() - Date.now());
937
+ }
938
+ }
939
+ } else if (rateLimitReset) {
940
+ const resetTime = parseInt(rateLimitReset);
941
+ if (!isNaN(resetTime)) {
942
+ waitTime = Math.max(0, resetTime * 1000 - Date.now());
943
+ }
944
+ }
945
+
946
+ logger.warn(
947
+ ` Waiting ${Math.round(waitTime / 1000)}s before retry (attempt ${attempts + 1}/${maxAttempts})...`,
948
+ );
949
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
950
+ attempts++;
951
+ continue;
952
+ }
953
+
954
+ // Other error status - try next gateway
955
+ logger.debug(
956
+ ` ⚠️ Gateway ${gateway} returned status ${carResponse.status}, trying next gateway...`,
957
+ );
958
+ break; // Try next gateway
959
+ } catch (error) {
960
+ lastError = error;
961
+ logger.debug(` ⚠️ Failed from ${gateway}: ${error.message}`);
962
+ attempts++;
963
+ if (attempts >= maxAttempts) {
964
+ break; // Try next gateway
965
+ }
966
+ }
967
+ }
968
+
969
+ // If we successfully downloaded, break out of gateway loop
970
+ if (success && carBytes) {
971
+ break;
972
+ }
973
+ }
974
+
975
+ // If we still don't have carBytes, all gateways failed
976
+ if (!carBytes) {
977
+ throw new Error(
978
+ `Could not download CAR file from any gateway. Last error: ${lastError?.message || "Unknown error"}`,
979
+ );
980
+ }
981
+ }
982
+
983
+ // Step 4: Extract blocks from CAR
984
+ logger.info("📦 Step 4: Extracting blocks from CAR...");
985
+
986
+ const blocks = await readBlocksFromCAR(carBytes);
987
+ logger.info(` ✅ Extracted ${blocks.size} blocks`);
988
+
989
+ // Step 5: Restore blocks to OrbitDB
990
+ logger.info("💾 Step 5: Restoring blocks to OrbitDB...");
991
+
992
+ if (eventEmitter) {
993
+ eventEmitter.emit("restoreProgress", {
994
+ type: "restore",
995
+ status: "restoring-blocks",
996
+ total: blocks.size,
997
+ });
998
+ }
999
+
1000
+ let restoredCount = 0;
1001
+ if (!blocks || !(blocks instanceof Map)) {
1002
+ throw new Error("blocks must be a Map");
1003
+ }
1004
+ for (const [cidString, blockData] of blocks.entries()) {
1005
+ try {
1006
+ const cid = CID.parse(cidString);
1007
+ // Put blocks into Helia's blockstore
1008
+ await orbitdb.ipfs.blockstore.put(cid, blockData.bytes);
1009
+ restoredCount++;
1010
+
1011
+ logger.info(
1012
+ ` ✓ Restored block to blockstore: ${cidString.substring(0, 12)}...`,
1013
+ );
1014
+ } catch (error) {
1015
+ logger.warn(`Failed to restore block ${cidString}: ${error.message}`);
1016
+ }
1017
+ }
1018
+
1019
+ logger.info(
1020
+ ` ✅ Restored ${restoredCount}/${blocks.size} blocks to blockstore`,
1021
+ );
1022
+
1023
+ // Step 6: Open database
1024
+ logger.info("🔓 Step 6: Opening database...");
1025
+
1026
+ const dbInfo = metadata.databases[0];
1027
+ const databaseAddress = dbInfo.address;
1028
+ const manifestCID = dbInfo.manifestCID || metadata.manifestCID;
1029
+
1030
+ logger.info(` Opening database with address: ${databaseAddress}`);
1031
+ logger.info(
1032
+ ` Expected entries: ${metadata.totalEntries || dbInfo.entryCount || "unknown"}`,
1033
+ );
1034
+
1035
+ // Verify manifest is in restored blocks and accessible
1036
+ if (manifestCID) {
1037
+ const manifestInBlocks = blocks.has(manifestCID);
1038
+ logger.info(
1039
+ ` 📋 Manifest CID: ${manifestCID.substring(0, 12)}... (in blocks: ${manifestInBlocks ? "✅" : "❌"})`,
1040
+ );
1041
+
1042
+ if (manifestInBlocks) {
1043
+ // Verify manifest is accessible in blockstore
1044
+ try {
1045
+ const manifestCid = CID.parse(manifestCID);
1046
+ const manifestBytes = await readBlockBytes(orbitdb.ipfs.blockstore, manifestCid);
1047
+ if (manifestBytes) {
1048
+ logger.info(` ✅ Manifest is accessible in blockstore`);
1049
+ } else {
1050
+ logger.warn(` ⚠️ Manifest not found in blockstore`);
1051
+ }
1052
+ } catch (error) {
1053
+ logger.warn(
1054
+ ` ⚠️ Could not verify manifest in blockstore: ${error.message}`,
1055
+ );
1056
+ }
1057
+ }
1058
+ }
1059
+
1060
+ const database = await orbitdb.open(databaseAddress, { type: dbInfo.type });
1061
+
1062
+ // Put blocks directly into the database's log storage as well
1063
+ // OrbitDB expects CIDs in base58btc format (starting with 'z'), so we need to convert
1064
+ logger.info(` 📝 Copying blocks to database log storage...`);
1065
+ let copiedToStorage = 0;
1066
+ if (!blocks || !(blocks instanceof Map)) {
1067
+ throw new Error("blocks must be a Map");
1068
+ }
1069
+ for (const [cidString, blockData] of blocks.entries()) {
1070
+ try {
1071
+ const cid = CID.parse(cidString);
1072
+ // Convert CID to base58btc format (what OrbitDB expects)
1073
+ const cidBase58btc = cid.toV1().toString(base58btc);
1074
+ await database.log.storage.put(cidBase58btc, blockData.bytes);
1075
+ copiedToStorage++;
1076
+ logger.info(
1077
+ ` ✓ Copied ${cidString.substring(0, 12)}... (as ${cidBase58btc.substring(0, 12)}...) to log storage`,
1078
+ );
1079
+ } catch (error) {
1080
+ logger.error(
1081
+ ` ❌ Failed to put ${cidString.substring(0, 12)}... to log storage: ${error.message}`,
1082
+ );
1083
+ }
1084
+ }
1085
+ logger.info(
1086
+ ` ✅ Copied ${copiedToStorage}/${blocks.size} blocks to log storage`,
1087
+ );
1088
+
1089
+ // Close and reopen the database to force it to reload from storage
1090
+ logger.info(` 🔄 Reopening database to load entries from storage...`);
1091
+ await database.close();
1092
+
1093
+ const reopenedDatabase = await orbitdb.open(databaseAddress, {
1094
+ type: dbInfo.type,
1095
+ });
1096
+
1097
+ // Step 7: Discover heads and join them to the log
1098
+ logger.info("🎯 Step 7: Discovering and joining log heads...");
1099
+
1100
+ // Decode blocks to find log entries and determine heads
1101
+ const logEntries = [];
1102
+ const logChain = new Map(); // Maps entry hash -> entries that reference it in "next"
1103
+
1104
+ if (!blocks || !(blocks instanceof Map)) {
1105
+ throw new Error("blocks must be a Map");
1106
+ }
1107
+ for (const [cidString, blockData] of blocks.entries()) {
1108
+ try {
1109
+ const cid = CID.parse(cidString);
1110
+ if (cid.code === 0x71) {
1111
+ // dag-cbor
1112
+ const block = await Block.decode({
1113
+ cid,
1114
+ bytes: blockData.bytes,
1115
+ codec: dagCbor,
1116
+ hasher: sha256,
1117
+ });
1118
+
1119
+ const content = block.value;
1120
+
1121
+ // Check if this is a log entry (has signature, key, identity)
1122
+ if (content && content.sig && content.key && content.identity) {
1123
+ const cidBase58btc = cid.toV1().toString(base58btc);
1124
+ logEntries.push({
1125
+ cid: cidBase58btc,
1126
+ content,
1127
+ });
1128
+
1129
+ // Track references for head detection
1130
+ if (content.next && Array.isArray(content.next)) {
1131
+ for (const nextHash of content.next) {
1132
+ logChain.set(nextHash, cidBase58btc);
1133
+ }
1134
+ }
1135
+ }
1136
+ }
1137
+ } catch {
1138
+ // Skip blocks that can't be decoded
1139
+ }
1140
+ }
1141
+
1142
+ logger.info(` Found ${logEntries.length} log entries`);
1143
+
1144
+ // Find heads: entries not referenced by any other entry's "next"
1145
+ const heads = logEntries.filter((entry) => !logChain.has(entry.cid));
1146
+ logger.info(` Found ${heads.length} heads`);
1147
+
1148
+ // Join heads to the database log
1149
+ let joinedCount = 0;
1150
+ for (const head of heads) {
1151
+ try {
1152
+ const entryData = {
1153
+ hash: head.cid,
1154
+ v: head.content.v,
1155
+ id: head.content.id,
1156
+ key: head.content.key,
1157
+ sig: head.content.sig,
1158
+ next: head.content.next,
1159
+ refs: head.content.refs,
1160
+ clock: head.content.clock,
1161
+ payload: head.content.payload,
1162
+ identity: head.content.identity,
1163
+ };
1164
+
1165
+ const updated = await reopenedDatabase.log.joinEntry(entryData);
1166
+ if (updated) {
1167
+ joinedCount++;
1168
+ logger.info(
1169
+ ` ✓ Joined head ${joinedCount}/${heads.length}: ${head.cid.substring(0, 12)}...`,
1170
+ );
1171
+ }
1172
+ } catch (error) {
1173
+ logger.warn(
1174
+ ` ⚠️ Failed to join head ${head.cid.substring(0, 12)}...: ${error.message}`,
1175
+ );
1176
+ }
1177
+ }
1178
+
1179
+ logger.info(` ✅ Joined ${joinedCount}/${heads.length} heads`);
1180
+
1181
+ // Wait for the log to be fully loaded
1182
+ // OrbitDB processes entries asynchronously, so we need to wait
1183
+ logger.info(" ⏳ Waiting for log entries to load...");
1184
+
1185
+ // Poll until entries are loaded or timeout
1186
+ const startTime = Date.now();
1187
+ const maxWaitTime = config.timeout / 2; // Use half of timeout (15 seconds)
1188
+ const expectedEntries = metadata.totalEntries || dbInfo.entryCount;
1189
+ let entriesCount = 0;
1190
+ let previousCount = -1;
1191
+
1192
+ while (Date.now() - startTime < maxWaitTime) {
1193
+ const entries = await reopenedDatabase.all();
1194
+ entriesCount = Array.isArray(entries)
1195
+ ? entries.length
1196
+ : Object.keys(entries).length;
1197
+
1198
+ // If we know how many entries to expect, wait for that count
1199
+ if (expectedEntries !== undefined && entriesCount >= expectedEntries) {
1200
+ logger.info(` ✅ All ${entriesCount} entries loaded`);
1201
+ break;
1202
+ }
1203
+
1204
+ // If count hasn't changed for a bit, assume loading is complete
1205
+ if (entriesCount > 0 && entriesCount === previousCount) {
1206
+ // Wait one more second to be sure
1207
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1208
+ const finalCheck = await reopenedDatabase.all();
1209
+ const finalCount = Array.isArray(finalCheck)
1210
+ ? finalCheck.length
1211
+ : Object.keys(finalCheck).length;
1212
+ if (finalCount === entriesCount) {
1213
+ logger.info(` ✅ Entries stabilized at ${entriesCount}`);
1214
+ break;
1215
+ }
1216
+ }
1217
+
1218
+ previousCount = entriesCount;
1219
+
1220
+ // Wait a bit before checking again
1221
+ await new Promise((resolve) => setTimeout(resolve, 100));
1222
+ }
1223
+
1224
+ // Final check
1225
+ const entries = await reopenedDatabase.all();
1226
+ entriesCount = Array.isArray(entries)
1227
+ ? entries.length
1228
+ : Object.keys(entries).length;
1229
+
1230
+ logger.info(` ✅ Database opened: ${entriesCount} entries`);
1231
+
1232
+ if (eventEmitter) {
1233
+ eventEmitter.emit("restoreProgress", {
1234
+ type: "restore",
1235
+ status: "completed",
1236
+ entriesRecovered: entriesCount,
1237
+ });
1238
+ }
1239
+
1240
+ logger.info("✅ CAR-based restore completed successfully!");
1241
+
1242
+ return {
1243
+ success: true,
1244
+ method: "car-timestamped",
1245
+ database: reopenedDatabase,
1246
+ databaseAddress,
1247
+ name: reopenedDatabase.name,
1248
+ type: reopenedDatabase.type,
1249
+ entriesRecovered: entriesCount,
1250
+ blocksRestored: restoredCount,
1251
+ // The restored entries, in the same shape the per-block path returns —
1252
+ // which is type-dependent, and quietly so: a keyvalue database's `all()`
1253
+ // is an object with no hashes in it, so that one comes from the log,
1254
+ // while events and documents already return the records callers expect.
1255
+ // A caller checking that a DEL survived is asking about the data, not
1256
+ // about how it travelled, and should not have to know which wrote it.
1257
+ entries: await restoredEntriesOf(reopenedDatabase),
1258
+ backupTimestamp: metadata.timestamp,
1259
+ backupUsed: backupToRestore,
1260
+ };
1261
+ } catch (error) {
1262
+ logger.error(`❌ CAR-based restore failed: ${error.message}`);
1263
+
1264
+ if (eventEmitter) {
1265
+ eventEmitter.emit("restoreProgress", {
1266
+ type: "restore",
1267
+ status: "error",
1268
+ error: error.message,
1269
+ });
1270
+ }
1271
+
1272
+ return {
1273
+ success: false,
1274
+ method: "car-timestamped",
1275
+ error: error.message,
1276
+ };
1277
+ }
1278
+ }
1279
+
1280
+ export default {
1281
+ backupDatabaseCAR,
1282
+ restoreFromSpaceCAR,
1283
+ listAvailableBackups,
1284
+ createCARFromBlocks,
1285
+ readBlocksFromCAR,
1286
+ };