@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.
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/dist/components/StorachaAuth.svelte +801 -0
- package/dist/components/StorachaIntegration.svelte +965 -0
- package/dist/components/WebAuthnDIDProvider.js +562 -0
- package/dist/components/storacha-backup.js +325 -0
- package/dist/components/theme.js +79 -0
- package/lib/backends/aleph-pin.js +161 -0
- package/lib/backends/aleph.js +197 -0
- package/lib/backends/lighthouse.js +325 -0
- package/lib/backends/memory.js +140 -0
- package/lib/backends/pinata.js +330 -0
- package/lib/backends/resolve.js +72 -0
- package/lib/backends/storacha.js +178 -0
- package/lib/backends/types.js +187 -0
- package/lib/backup-car.js +1286 -0
- package/lib/backup-helpers.js +87 -0
- package/lib/backup-metadata.js +40 -0
- package/lib/backup.js +249 -0
- package/lib/block-bytes.js +39 -0
- package/lib/car-storage.js +342 -0
- package/lib/courier-sync.js +886 -0
- package/lib/dehydrate.js +146 -0
- package/lib/extract-blocks.js +258 -0
- package/lib/gateway-fetch.js +116 -0
- package/lib/ipns-helpers.js +324 -0
- package/lib/logger.js +128 -0
- package/lib/memory-courier.js +105 -0
- package/lib/orbitdb-storacha-bridge.js +2789 -0
- package/lib/pointer-ipns.js +234 -0
- package/lib/restore-cid.js +302 -0
- package/lib/ucan-bridge.js +921 -0
- package/lib/utils.js +316 -0
- package/package.json +172 -0
|
@@ -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
|
+
};
|