@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,342 @@
1
+ /**
2
+ * @module CARStorage
3
+ * @description
4
+ * A custom storage implementation that uses CAR (Content Addressable Archive) files
5
+ * for persistent storage in OrbitDB. This storage can be used with ComposedStorage
6
+ * for hybrid memory/CAR file storage solutions.
7
+ */
8
+
9
+ import { CarWriter, CarReader } from "@ipld/car";
10
+ import { CID } from "multiformats/cid";
11
+ import { createWriteStream, createReadStream } from "fs";
12
+ import { promises as fs } from "fs";
13
+ import { join } from "path";
14
+ import { Readable } from "stream";
15
+
16
+ /**
17
+ * Creates an instance of CARStorage.
18
+ * @function
19
+ * @param {Object} options Configuration options for CAR storage
20
+ * @param {string} options.path The directory path where CAR files will be stored
21
+ * @param {string} [options.name='orbitdb-data'] The base name for CAR files
22
+ * @param {boolean} [options.autoFlush=true] Whether to automatically flush to CAR files
23
+ * @param {number} [options.flushThreshold=100] Number of operations before auto-flush
24
+ * @return {module:Storage} An instance of CARStorage
25
+ */
26
+
27
+ import { logger } from "./logger.js";
28
+ const CARStorage = async ({
29
+ path,
30
+ name = "orbitdb-data",
31
+ autoFlush = true,
32
+ flushThreshold = 100,
33
+ } = {}) => {
34
+ if (!path) {
35
+ throw new Error("CARStorage requires a path parameter");
36
+ }
37
+
38
+ // Ensure the directory exists
39
+ await fs.mkdir(path, { recursive: true });
40
+
41
+ // In-memory cache for fast access
42
+ const cache = new Map();
43
+
44
+ // Track modifications for auto-flush
45
+ let operationCount = 0;
46
+ let isModified = false;
47
+
48
+ // CAR file paths
49
+ const getCarPath = (suffix = "") => join(path, `${name}${suffix}.car`);
50
+ const primaryCarPath = getCarPath();
51
+
52
+ /**
53
+ * Loads existing data from CAR file into memory cache
54
+ */
55
+ const loadFromCAR = async () => {
56
+ try {
57
+ const stats = await fs.stat(primaryCarPath);
58
+ if (stats.isFile()) {
59
+ logger.info(`Loading existing CAR file: ${primaryCarPath}`);
60
+
61
+ const inStream = createReadStream(primaryCarPath);
62
+ const reader = await CarReader.fromIterable(inStream);
63
+
64
+ const { decode } = await import("@ipld/dag-cbor");
65
+
66
+ for await (const { cid, bytes } of reader.blocks()) {
67
+ try {
68
+ // Decode the wrapped data
69
+ const decoded = decode(bytes);
70
+ if (decoded && decoded.originalKey && decoded.value) {
71
+ // Restore the original key-value mapping
72
+ cache.set(decoded.originalKey, decoded.value);
73
+ } else {
74
+ // Fallback: use CID as key and raw value
75
+ cache.set(cid.toString(), bytes);
76
+ }
77
+ } catch {
78
+ // Fallback: use CID as key and raw value
79
+ cache.set(cid.toString(), bytes);
80
+ }
81
+ }
82
+
83
+ logger.info(`Loaded ${cache.size} entries from CAR file`);
84
+ }
85
+ } catch (error) {
86
+ if (error.code !== "ENOENT") {
87
+ logger.warn(`Error loading CAR file: ${error.message}`);
88
+ }
89
+ }
90
+ };
91
+
92
+ /**
93
+ * Flushes in-memory cache to CAR file
94
+ */
95
+ const flushToCAR = async () => {
96
+ if (!isModified || cache.size === 0) {
97
+ return;
98
+ }
99
+
100
+ logger.info(`Flushing ${cache.size} entries to CAR file`);
101
+
102
+ try {
103
+ // Create a temporary CAR file
104
+ const tempCarPath = getCarPath(".tmp");
105
+ const outStream = createWriteStream(tempCarPath);
106
+
107
+ // Collect all blocks for the CAR file
108
+ const blocks = [];
109
+ const roots = [];
110
+ const keyToCidMap = new Map();
111
+
112
+ // Import SHA-256 hasher
113
+ const { sha256 } = await import("multiformats/hashes/sha2");
114
+ const { encode } = await import("@ipld/dag-cbor");
115
+
116
+ for (const [key, value] of cache.entries()) {
117
+ try {
118
+ let cid;
119
+
120
+ if (typeof key === "string" && key.startsWith("zdpu")) {
121
+ // Try to convert OrbitDB hash format to CID
122
+ try {
123
+ const hash = key.slice(4); // Remove 'zdpu' prefix
124
+ cid = CID.parse(`bafyre${hash.slice(1)}`); // Convert to IPFS CID format
125
+ } catch {
126
+ // If conversion fails, generate a CID from the key
127
+ const keyData = new TextEncoder().encode(key);
128
+ const hash = await sha256.digest(keyData);
129
+ cid = CID.create(1, 0x55, hash); // version 1, raw codec, sha256 hash
130
+ }
131
+ } else {
132
+ try {
133
+ cid = CID.parse(key);
134
+ } catch {
135
+ // Generate a CID from the key for arbitrary strings
136
+ const keyData = new TextEncoder().encode(key);
137
+ const hash = await sha256.digest(keyData);
138
+ cid = CID.create(1, 0x55, hash); // version 1, raw codec, sha256 hash
139
+ }
140
+ }
141
+
142
+ // Store the mapping for later retrieval
143
+ keyToCidMap.set(key, cid.toString());
144
+
145
+ // Create a wrapper object that includes both key and value
146
+ const blockData = {
147
+ originalKey: key,
148
+ value: value,
149
+ };
150
+
151
+ const encodedBlock = encode(blockData);
152
+ blocks.push({ cid, bytes: encodedBlock });
153
+
154
+ // Add first block as root for CAR structure
155
+ if (roots.length === 0) {
156
+ roots.push(cid);
157
+ }
158
+ } catch (error) {
159
+ logger.warn(`Error processing entry ${key}:`, error.message);
160
+ }
161
+ }
162
+
163
+ if (blocks.length > 0) {
164
+ const { writer, out } = CarWriter.create(roots);
165
+
166
+ // Pipe to file
167
+ Readable.from(out).pipe(outStream);
168
+
169
+ // Add all blocks
170
+ for (const { cid, bytes } of blocks) {
171
+ await writer.put({ cid, bytes });
172
+ }
173
+
174
+ await writer.close();
175
+
176
+ // Wait for file write to complete
177
+ await new Promise((resolve, reject) => {
178
+ outStream.on("finish", resolve);
179
+ outStream.on("error", reject);
180
+ });
181
+
182
+ // Replace old CAR file with new one
183
+ try {
184
+ await fs.unlink(primaryCarPath);
185
+ } catch {
186
+ // Ignore if file doesn't exist
187
+ }
188
+
189
+ await fs.rename(tempCarPath, primaryCarPath);
190
+
191
+ isModified = false;
192
+ operationCount = 0;
193
+
194
+ logger.info(`Successfully flushed to CAR file: ${primaryCarPath}`);
195
+ }
196
+ } catch (error) {
197
+ logger.error(`Error flushing to CAR file: ${error.message}`);
198
+ throw error;
199
+ }
200
+ };
201
+
202
+ /**
203
+ * Auto-flush if threshold is reached
204
+ */
205
+ const checkAutoFlush = async () => {
206
+ operationCount++;
207
+ if (autoFlush && operationCount >= flushThreshold) {
208
+ await flushToCAR();
209
+ }
210
+ };
211
+
212
+ // Load existing data on initialization
213
+ await loadFromCAR();
214
+
215
+ /**
216
+ * Puts data to the CAR storage.
217
+ * @function
218
+ * @param {string} hash The hash of the data to put.
219
+ * @param {Uint8Array} data The data to store.
220
+ */
221
+ const put = async (hash, data) => {
222
+ if (!hash) {
223
+ throw new Error("Hash is required for put operation");
224
+ }
225
+
226
+ // Store data exactly as provided - no conversion
227
+ cache.set(hash, data);
228
+ isModified = true;
229
+
230
+ await checkAutoFlush();
231
+ };
232
+
233
+ /**
234
+ * Gets data from the CAR storage.
235
+ * @function
236
+ * @param {string} hash The hash of the data to get.
237
+ * @return {Uint8Array|undefined} The stored data or undefined if not found.
238
+ */
239
+ const get = async (hash) => {
240
+ // Return data exactly as stored - no conversion
241
+ return cache.get(hash);
242
+ };
243
+
244
+ /**
245
+ * Deletes data from the CAR storage.
246
+ * @function
247
+ * @param {string} hash The hash of the data to delete.
248
+ */
249
+ const del = async (hash) => {
250
+ const existed = cache.delete(hash);
251
+ if (existed) {
252
+ isModified = true;
253
+ await checkAutoFlush();
254
+ }
255
+ };
256
+
257
+ /**
258
+ * Iterates over records in the CAR storage.
259
+ * @function
260
+ * @param {Object} options Iterator options
261
+ * @param {number} [options.amount=-1] Number of items to return (-1 for all)
262
+ * @param {boolean} [options.reverse=false] Whether to reverse the iteration order
263
+ * @yields {Array} [key, value] pairs
264
+ */
265
+ const iterator = async function* ({ amount = -1, reverse = false } = {}) {
266
+ const entries = Array.from(cache.entries());
267
+
268
+ if (reverse) {
269
+ entries.reverse();
270
+ }
271
+
272
+ let count = 0;
273
+ for (const [key, value] of entries) {
274
+ if (amount > 0 && count >= amount) {
275
+ break;
276
+ }
277
+ yield [key, value];
278
+ count++;
279
+ }
280
+ };
281
+
282
+ /**
283
+ * Merges data from another storage into the CAR storage.
284
+ * @function
285
+ * @param {module:Storage} other Another storage instance.
286
+ */
287
+ const merge = async (other) => {
288
+ if (!other || typeof other.iterator !== "function") {
289
+ throw new Error("Other storage must implement iterator method");
290
+ }
291
+
292
+ for await (const [key, value] of other.iterator()) {
293
+ await put(key, value);
294
+ }
295
+ };
296
+
297
+ /**
298
+ * Clears all data from the CAR storage.
299
+ * @function
300
+ */
301
+ const clear = async () => {
302
+ cache.clear();
303
+ isModified = true;
304
+
305
+ // Remove CAR file
306
+ try {
307
+ await fs.unlink(primaryCarPath);
308
+ } catch {
309
+ // Ignore if file doesn't exist
310
+ }
311
+ };
312
+
313
+ /**
314
+ * Forces a flush to the CAR file (persist operation).
315
+ * @function
316
+ * @param {string} [hash] Optional specific hash to persist (currently ignored)
317
+ */
318
+ const persist = async (_hash) => {
319
+ await flushToCAR();
320
+ };
321
+
322
+ /**
323
+ * Closes the CAR storage and ensures all data is persisted.
324
+ * @function
325
+ */
326
+ const close = async () => {
327
+ await flushToCAR();
328
+ };
329
+
330
+ return {
331
+ put,
332
+ get,
333
+ del,
334
+ iterator,
335
+ merge,
336
+ clear,
337
+ persist,
338
+ close,
339
+ };
340
+ };
341
+
342
+ export default CARStorage;