@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,87 @@
1
+ /**
2
+ * Helper functions for timestamped backup feature
3
+ */
4
+ import { logger } from "./logger.js";
5
+
6
+ /**
7
+ * Generate backup prefix with timestamp
8
+ *
9
+ * @param {string} spaceName - Name of the Storacha space
10
+ * @returns {string} - Backup prefix including timestamp
11
+ */
12
+ export function generateBackupPrefix(spaceName) {
13
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
14
+ return `${spaceName}/backup-${timestamp}`;
15
+ }
16
+
17
+ // `getBackupFilenames` and `isValidMetadata` live in `backup-metadata.js`,
18
+ // which imports nothing — so a browser consumer can read a backup without
19
+ // pulling a logging framework in behind a predicate (issue #58). Re-exported
20
+ // here so every existing importer keeps working.
21
+ export { getBackupFilenames, isValidMetadata } from "./backup-metadata.js";
22
+
23
+ /**
24
+ * Find the latest valid backup in a space
25
+ *
26
+ * @param {Array} spaceFiles - List of files in the space
27
+ * @param {Object} options - Options
28
+ * @param {Function} options.onWarning - Warning callback
29
+ * @returns {Object|null} - Latest valid backup or null
30
+ */
31
+ export function findLatestBackup(spaceFiles, options = {}) {
32
+ const { onWarning, verbose = false } = options;
33
+ const warn =
34
+ onWarning ||
35
+ (verbose
36
+ ? (message) => {
37
+ logger.debug(message);
38
+ }
39
+ : () => {});
40
+ const filenames = spaceFiles.map((f) => f.root.toString());
41
+
42
+ // Group files by full backup path (preserving directory structure)
43
+ const backupGroups = new Map();
44
+ for (const file of filenames) {
45
+ // Match pattern: (optional-prefix/)backup-(timestamp)-(metadata.json|blocks.car)
46
+ const match = file.match(/^(.*)backup-(.*?)-(metadata\.json|blocks\.car)$/);
47
+ if (match) {
48
+ const [, prefix, timestamp, type] = match;
49
+ const backupKey = `${prefix}backup-${timestamp}`;
50
+
51
+ if (!backupGroups.has(backupKey)) {
52
+ backupGroups.set(backupKey, { files: new Set(), timestamp, prefix });
53
+ }
54
+ backupGroups.get(backupKey).files.add(type);
55
+ }
56
+ }
57
+
58
+ // Find complete and valid backups
59
+ const completeBackups = Array.from(backupGroups.entries())
60
+ .filter(([backupKey, { files }]) => {
61
+ const isComplete = files.has("metadata.json") && files.has("blocks.car");
62
+ if (!isComplete) {
63
+ warn(`⚠️ Incomplete backup found: ${backupKey}-*`);
64
+ files.forEach((file) => {
65
+ warn(` Orphaned file: ${backupKey}-${file}`);
66
+ });
67
+ }
68
+ return isComplete;
69
+ })
70
+ .sort((a, b) => b[1].timestamp.localeCompare(a[1].timestamp)); // Sort descending by timestamp
71
+
72
+ if (completeBackups.length === 0) {
73
+ if (backupGroups.size > 0) {
74
+ warn(
75
+ "⚠️ No complete backup sets found (missing metadata or blocks files)",
76
+ );
77
+ }
78
+ return null;
79
+ }
80
+
81
+ const [backupKey, { timestamp }] = completeBackups[0];
82
+ return {
83
+ metadata: `${backupKey}-metadata.json`,
84
+ blocks: `${backupKey}-blocks.car`,
85
+ timestamp: timestamp,
86
+ };
87
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What a backup's metadata is, with no dependencies at all.
3
+ *
4
+ * Split out of `backup-helpers.js` for one reason: that module logs, so
5
+ * importing a *predicate* from it pulls in the logging framework and, through
6
+ * it, `@libp2p/logger`. Fine in Node; measurable in a browser bundle, where
7
+ * `restore-cid.js` exists precisely so that reading a backup costs almost
8
+ * nothing (issue #58).
9
+ *
10
+ * Nothing here imports anything. That is the point, and it is worth keeping
11
+ * true: anything added below that needs a logger belongs in `backup-helpers.js`
12
+ * instead.
13
+ */
14
+
15
+ /** Names are `<prefix>-metadata.json` and `<prefix>-blocks.car`. */
16
+ export function getBackupFilenames(prefix) {
17
+ return { metadata: `${prefix}-metadata.json`, blocks: `${prefix}-blocks.car` };
18
+ }
19
+
20
+ /**
21
+ * Whether this is metadata we can act on.
22
+ *
23
+ * Accepts both shapes deliberately: the pre-CAR `{ root, path }` databases and
24
+ * the CAR-era `{ address, manifestCID }` ones. A reader that only handles one
25
+ * of them checks for its own fields after this, so that "not a backup" and
26
+ * "a backup of the other kind" stay different answers.
27
+ */
28
+ export function isValidMetadata(metadata) {
29
+ if (
30
+ !metadata ||
31
+ typeof metadata.version !== "string" ||
32
+ typeof metadata.timestamp !== "number" ||
33
+ !Array.isArray(metadata.databases)
34
+ ) {
35
+ return false;
36
+ }
37
+ return metadata.databases.every(
38
+ (db) => Boolean(db?.root && db?.path) || Boolean(db?.address && db?.manifestCID),
39
+ );
40
+ }
package/lib/backup.js ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * OrbitDB database backup with timestamps and error handling
3
+ * NOTE: This file is a placeholder/example and is incomplete
4
+ */
5
+
6
+ /* eslint-disable no-undef, no-unused-vars */
7
+ import { Readable } from "stream";
8
+ import { CarReader } from "@ipld/car";
9
+ import * as Signer from "@storacha/client/principal/ed25519";
10
+ import { createWritableCarReader } from "@storacha/client/utils";
11
+ import { logger } from "./logger.js";
12
+ import {
13
+ generateBackupPrefix,
14
+ getBackupFilenames,
15
+ isValidMetadata,
16
+ findLatestBackup,
17
+ } from "./backup-helpers.js";
18
+
19
+ const DEFAULT_OPTIONS = {
20
+ verbose: false,
21
+ };
22
+
23
+ /**
24
+ * Backup an OrbitDB database with timestamps and error handling
25
+ *
26
+ * @param {Object} orbitdb - OrbitDB instance
27
+ * @param {string} databaseAddress - Database address or name
28
+ * @param {Object} options - Backup options
29
+ * @returns {Promise<Object>} - Backup result
30
+ */
31
+ export async function backupDatabase(orbitdb, databaseAddress, options = {}) {
32
+ const config = { ...DEFAULT_OPTIONS, ...options };
33
+ const { verbose } = config;
34
+ const { spaceName } = config;
35
+ const eventEmitter = options.eventEmitter;
36
+ const logDebug = (message) => {
37
+ if (verbose) {
38
+ logger.debug(message);
39
+ }
40
+ };
41
+
42
+ logDebug("🚀 Starting OrbitDB Database Backup");
43
+ logDebug(`📍 Database: ${databaseAddress}`);
44
+
45
+ try {
46
+ // Open database and extract blocks
47
+ const database = await orbitdb.open(databaseAddress, options.dbConfig);
48
+ const { blocks, blockSources, manifestCID } = await extractDatabaseBlocks(
49
+ database,
50
+ { logEntriesOnly: false },
51
+ );
52
+
53
+ // Generate timestamped backup paths
54
+ const backupPrefix = generateBackupPrefix(spaceName);
55
+ const backupFiles = getBackupFilenames(backupPrefix);
56
+
57
+ // Create metadata
58
+ const metadata = {
59
+ version: "1.0",
60
+ timestamp: Date.now(),
61
+ databaseCount: 1,
62
+ totalEntries: blocks.size,
63
+ databases: [
64
+ {
65
+ root: database.address.root,
66
+ path: database.address.path,
67
+ },
68
+ ],
69
+ };
70
+
71
+ // Validate metadata structure
72
+ if (!isValidMetadata(metadata)) {
73
+ throw new Error("Invalid metadata structure");
74
+ }
75
+
76
+ // Write metadata with error handling
77
+ try {
78
+ await Signer.writeFile(
79
+ backupFiles.metadata,
80
+ JSON.stringify(metadata, null, 2),
81
+ );
82
+ } catch (error) {
83
+ throw new Error(`Failed to write backup metadata: ${error.message}`, { cause: error });
84
+ }
85
+
86
+ // Write blocks with error handling
87
+ try {
88
+ // Create CAR storage and add blocks
89
+ const storage = await CARStorage({
90
+ path: ".",
91
+ name: "backup-temp",
92
+ autoFlush: false,
93
+ });
94
+
95
+ for (const [key, value] of blocks) {
96
+ await storage.put(key, value);
97
+ }
98
+
99
+ await storage.persist();
100
+ const reader = await CarReader.fromIterable(storage.iterator());
101
+ const stream = Readable.from(reader.blocks());
102
+
103
+ await stream.pipe(
104
+ await createWritableCarReader({
105
+ name: backupFiles.blocks,
106
+ comment: `OrbitDB backup blocks (created at ${new Date().toISOString()})`,
107
+ space: spaceName,
108
+ }),
109
+ );
110
+
111
+ // Clean up temp storage
112
+ await storage.close();
113
+ await storage.clear();
114
+ } catch (error) {
115
+ // Try to clean up failed metadata
116
+ try {
117
+ await Signer.rm(backupFiles.metadata);
118
+ } catch (cleanupError) {
119
+ logDebug(
120
+ `Failed to clean up metadata after error: ${cleanupError.message}`,
121
+ );
122
+ }
123
+ throw new Error(`Failed to write backup blocks: ${error.message}`, { cause: error });
124
+ }
125
+
126
+ // Return success with file info
127
+ return {
128
+ success: true,
129
+ manifestCID,
130
+ databaseAddress: database.address,
131
+ databaseName: database.name,
132
+ blocksTotal: blocks.size,
133
+ blocksUploaded: blocks.size,
134
+ blockSummary: blockSources,
135
+ backupFiles,
136
+ };
137
+ } catch (error) {
138
+ return {
139
+ success: false,
140
+ error: error.message,
141
+ };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Restore from the latest valid backup in a space
147
+ *
148
+ * @param {Object} orbitdb - OrbitDB instance
149
+ * @param {Object} options - Restore options
150
+ * @returns {Promise<Object>} - Restore result
151
+ */
152
+ export async function restoreFromSpace(orbitdb, options = {}) {
153
+ const config = { ...DEFAULT_OPTIONS, ...options };
154
+ const { verbose } = config;
155
+ const { spaceName } = config;
156
+ const logDebug = (message) => {
157
+ if (verbose) {
158
+ logger.debug(message);
159
+ }
160
+ };
161
+
162
+ logDebug("🔄 Starting OrbitDB Restore");
163
+
164
+ try {
165
+ // Find latest valid backup
166
+ const spaceFiles = await listStorachaSpaceFiles(config);
167
+ const latestBackup = findLatestBackup(spaceFiles, {
168
+ verbose,
169
+ });
170
+
171
+ if (!latestBackup) {
172
+ throw new Error("No valid backup found in space");
173
+ }
174
+
175
+ // Read and validate metadata
176
+ let metadata;
177
+ try {
178
+ const metadataJson = await Signer.readFile(
179
+ `${spaceName}/${latestBackup.metadata}`,
180
+ );
181
+ metadata = JSON.parse(metadataJson);
182
+ if (!isValidMetadata(metadata)) {
183
+ throw new Error("Invalid backup metadata structure");
184
+ }
185
+ } catch (error) {
186
+ throw new Error(`Failed to read/validate metadata: ${error.message}`, { cause: error });
187
+ }
188
+
189
+ // Create storage and load blocks
190
+ const storage = await CARStorage({
191
+ path: ".",
192
+ name: "restore-temp",
193
+ autoFlush: false,
194
+ });
195
+
196
+ try {
197
+ // Get blocks stream
198
+ const carStream = await Signer.readStream(
199
+ `${spaceName}/${latestBackup.blocks}`,
200
+ );
201
+ const reader = await fromReadableStream(carStream);
202
+
203
+ // Load blocks with validation
204
+ let validBlocks = 0;
205
+ for await (const block of reader.blocks()) {
206
+ try {
207
+ await storage.put(block.cid.toString(), block.bytes);
208
+ validBlocks++;
209
+ } catch (blockError) {
210
+ logDebug(`⚠️ Failed to restore block: ${blockError.message}`);
211
+ }
212
+ }
213
+
214
+ if (validBlocks === 0) {
215
+ throw new Error("No valid blocks found in backup");
216
+ }
217
+
218
+ // Open database using metadata
219
+ const databaseAddress = `/orbitdb/${metadata.databases[0].root}`;
220
+ const database = await orbitdb.open(databaseAddress);
221
+
222
+ // Replace storage and load entries
223
+ await database._index.replaceStorage(storage);
224
+ await database.load();
225
+
226
+ const entries = await database.all();
227
+
228
+ return {
229
+ success: true,
230
+ database,
231
+ databaseAddress,
232
+ entriesRecovered: entries.length,
233
+ blocksRestored: validBlocks,
234
+ backupUsed: latestBackup,
235
+ };
236
+ } catch (error) {
237
+ throw new Error(`Failed to restore blocks: ${error.message}`, { cause: error });
238
+ } finally {
239
+ // Clean up temp storage
240
+ await storage.close();
241
+ await storage.clear();
242
+ }
243
+ } catch (error) {
244
+ return {
245
+ success: false,
246
+ error: error.message,
247
+ };
248
+ }
249
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @fileoverview Read a block's bytes from any Helia blockstore.
3
+ *
4
+ * `interface-blockstore` 7 — what Helia 6 and later hand out — streams a block
5
+ * in chunks: `blockstore.get(cid)` returns an async iterable rather than a
6
+ * promise of the bytes. Code written for the earlier interface still runs,
7
+ * `await`s the iterable, and passes an object where bytes belong; the decode
8
+ * after it fails far from the cause. This reads both.
9
+ *
10
+ * Imports nothing, so the light restore path can use it without pulling in a
11
+ * node stack.
12
+ */
13
+
14
+ /**
15
+ * @param {{ get: (cid: any, options?: object) => any }} blockstore
16
+ * @param {any} cid
17
+ * @param {object} [options] - passed through, e.g. `{ signal }`
18
+ * @returns {Promise<Uint8Array>}
19
+ */
20
+ export async function readBlockBytes(blockstore, cid, options) {
21
+ const result = blockstore.get(cid, options);
22
+ if (result == null || typeof result[Symbol.asyncIterator] !== "function") {
23
+ return await result;
24
+ }
25
+ const chunks = [];
26
+ let length = 0;
27
+ for await (const chunk of result) {
28
+ chunks.push(chunk);
29
+ length += chunk.length;
30
+ }
31
+ if (chunks.length === 1) return chunks[0];
32
+ const bytes = new Uint8Array(length);
33
+ let offset = 0;
34
+ for (const chunk of chunks) {
35
+ bytes.set(chunk, offset);
36
+ offset += chunk.length;
37
+ }
38
+ return bytes;
39
+ }