@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,324 @@
1
+ /**
2
+ * IPNS Helper Functions for OrbitDB Restoration
3
+ *
4
+ * Provides functions to publish and resolve OrbitDB metadata via IPNS
5
+ * Uses the ipns package (already available as transitive dependency) and Helia's libp2p DHT
6
+ */
7
+
8
+ import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord } from "ipns";
9
+ import { generateKeyPair } from "@libp2p/crypto/keys";
10
+ import { CID } from "multiformats/cid";
11
+ import { logger } from "./logger.js";
12
+
13
+ /**
14
+ * Create an IPNS key pair
15
+ * @returns {Promise<Object>} - { privateKey, publicKey, peerId, ipnsKey }
16
+ */
17
+ export async function createIPNSKeyPair() {
18
+ const keyPair = await generateKeyPair("Ed25519");
19
+ const peerId = await keyPair.publicKey.toPeerId();
20
+
21
+ logger.info(`🔑 Created IPNS key pair: ${peerId.toString()}`);
22
+
23
+ return {
24
+ privateKey: keyPair.privateKey,
25
+ publicKey: keyPair.publicKey,
26
+ peerId: peerId,
27
+ ipnsKey: `/ipns/${peerId.toString()}`,
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Publish metadata to IPNS using Helia's libp2p DHT
33
+ * @param {Object} helia - Helia instance
34
+ * @param {Object} privateKey - Private key for signing
35
+ * @param {Object} metadata - Metadata object to publish
36
+ * @param {number} lifetime - Lifetime in nanoseconds (default: 1 hour = 3600000000000ns)
37
+ * @returns {Promise<Object>} - { ipnsKey, metadataCID, ipnsRecord }
38
+ */
39
+ export async function publishMetadataToIPNS(
40
+ helia,
41
+ privateKey,
42
+ metadata,
43
+ lifetime = 3600000000000,
44
+ ) {
45
+ // Convert metadata to JSON and store in IPFS
46
+ const { unixfs: unixfsModule } = await import("@helia/unixfs");
47
+ const fs = unixfsModule(helia);
48
+
49
+ const jsonString = JSON.stringify(metadata, null, 2);
50
+ const jsonBytes = new TextEncoder().encode(jsonString);
51
+
52
+ // Create async iterable
53
+ async function* dataGenerator() {
54
+ yield jsonBytes;
55
+ }
56
+
57
+ // Add to IPFS
58
+ const cid = await fs.addByteStream(dataGenerator());
59
+ const cidString = cid.toString();
60
+
61
+ logger.info(`📤 Stored metadata in IPFS: ${cidString}`);
62
+
63
+ // Parse CID for IPNS
64
+ const metadataCID = CID.parse(cidString);
65
+
66
+ // Create IPNS record
67
+ const sequence = 0;
68
+ const ipnsRecord = await createIPNSRecord(
69
+ privateKey,
70
+ metadataCID.bytes,
71
+ sequence,
72
+ lifetime,
73
+ );
74
+
75
+ // Marshal IPNS record
76
+ const ipnsRecordBytes = marshalIPNSRecord(ipnsRecord);
77
+
78
+ // Publish to DHT using libp2p
79
+ const _libp2p = helia.libp2p;
80
+ const peerId = await privateKey.publicKey.toPeerId();
81
+
82
+ // Get IPNS key (peer ID as IPNS key)
83
+ const ipnsKey = `/ipns/${peerId.toString()}`;
84
+
85
+ // Store IPNS record
86
+ // For now, we'll store it in the local datastore for testing
87
+ // In production, this would be published to DHT
88
+ try {
89
+ logger.info(`📤 Publishing IPNS record: ${ipnsKey}`);
90
+
91
+ // Try to use @helia/ipns if available
92
+ let published = false;
93
+ try {
94
+ // Try @helia/ipns first
95
+ const { ipns: ipnsModule } = await import("@helia/ipns");
96
+ const name = ipnsModule(helia);
97
+ await name.publish(peerId, metadataCID);
98
+ published = true;
99
+ logger.info(
100
+ `✅ Published IPNS record using @helia/ipns: ${ipnsKey} -> ${cidString}`,
101
+ );
102
+ } catch {
103
+ // @helia/ipns not available, store IPNS record in IPFS for cross-node access
104
+ logger.info(`@helia/ipns not available, storing IPNS record in IPFS`);
105
+
106
+ // Store IPNS record in IPFS so it can be accessed by other nodes
107
+ const { unixfs: unixfsModule } = await import("@helia/unixfs");
108
+ const fs = unixfsModule(helia);
109
+
110
+ const recordGenerator = async function* () {
111
+ yield ipnsRecordBytes;
112
+ };
113
+
114
+ const recordCID = await fs.addByteStream(recordGenerator());
115
+ const recordCIDString = recordCID.toString();
116
+
117
+ // Store mapping in IPFS as well, so other nodes can find it
118
+ // Create a mapping object: { peerId: recordCID }
119
+ const mapping = {
120
+ peerId: peerId.toString(),
121
+ recordCID: recordCIDString,
122
+ metadataCID: cidString,
123
+ };
124
+
125
+ const mappingGenerator = async function* () {
126
+ yield new TextEncoder().encode(JSON.stringify(mapping));
127
+ };
128
+
129
+ const mappingCID = await fs.addByteStream(mappingGenerator());
130
+ const mappingCIDString = mappingCID.toString();
131
+
132
+ // Store mapping CID in local datastore for quick lookup
133
+ const datastore = helia.datastore;
134
+ const ipnsKeyBytes = new TextEncoder().encode(
135
+ `/ipns/${peerId.toString()}`,
136
+ );
137
+ const mappingCIDBytes = new TextEncoder().encode(mappingCIDString);
138
+ await datastore.put(ipnsKeyBytes, mappingCIDBytes);
139
+
140
+ published = true;
141
+ logger.info(
142
+ `✅ Stored IPNS record in IPFS: ${ipnsKey} -> ${cidString} (record: ${recordCIDString}, mapping: ${mappingCIDString})`,
143
+ );
144
+ logger.info(
145
+ `⚠️ Note: Using IPFS storage for testing. For production, use DHT or @helia/ipns.`,
146
+ );
147
+
148
+ // Return mapping CID for cross-node access
149
+ return {
150
+ ipnsKey,
151
+ metadataCID: cidString,
152
+ ipnsRecord: ipnsRecordBytes,
153
+ published,
154
+ mappingCID: mappingCIDString, // For cross-node access
155
+ recordCID: recordCIDString,
156
+ };
157
+ }
158
+
159
+ return {
160
+ ipnsKey,
161
+ metadataCID: cidString,
162
+ ipnsRecord: ipnsRecordBytes,
163
+ published,
164
+ };
165
+ } catch (error) {
166
+ logger.error(`Error publishing to IPNS: ${error.message}`);
167
+ // Fallback: return the record even if publish fails
168
+ logger.warn(
169
+ `⚠️ IPNS publish failed, but IPNS record created: ${error.message}`,
170
+ );
171
+ return {
172
+ ipnsKey,
173
+ metadataCID: cidString,
174
+ ipnsRecord: ipnsRecordBytes,
175
+ published: false,
176
+ };
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Resolve IPNS key to get metadata CID
182
+ * @param {Object} helia - Helia instance
183
+ * @param {Object} publicKey - Public key or peer ID
184
+ * @returns {Promise<string>} - Metadata CID
185
+ */
186
+ export async function resolveIPNS(helia, publicKey) {
187
+ const _libp2p = helia.libp2p;
188
+ const peerId = await publicKey.toPeerId();
189
+ const ipnsKey = `/ipns/${peerId.toString()}`;
190
+
191
+ logger.info(`📥 Resolving IPNS key: ${ipnsKey}`);
192
+
193
+ try {
194
+ // Try to use @helia/ipns if available, otherwise use local datastore
195
+ try {
196
+ // Try @helia/ipns first
197
+ const { ipns: ipnsModule } = await import("@helia/ipns");
198
+ const name = ipnsModule(helia);
199
+ const resolvedCID = await name.resolve(peerId);
200
+ const cidString = resolvedCID.toString();
201
+ logger.info(`✅ Resolved IPNS using @helia/ipns to CID: ${cidString}`);
202
+ return cidString;
203
+ } catch (importError) {
204
+ // @helia/ipns not available, read from IPFS
205
+ logger.info(`@helia/ipns not available, reading from IPFS`);
206
+
207
+ // Try to get mapping CID from local datastore first
208
+ let mappingCIDString = null;
209
+ try {
210
+ const datastore = helia.datastore;
211
+ const ipnsKeyBytes = new TextEncoder().encode(
212
+ `/ipns/${peerId.toString()}`,
213
+ );
214
+ const mappingCIDBytes = await datastore.get(ipnsKeyBytes);
215
+ if (mappingCIDBytes && mappingCIDBytes.length > 0) {
216
+ mappingCIDString = new TextDecoder().decode(mappingCIDBytes);
217
+ }
218
+ } catch {
219
+ // Mapping not in local datastore, will need to search IPFS
220
+ logger.info(`Mapping not in local datastore, will search IPFS`);
221
+ }
222
+
223
+ // If we have mapping CID, use it; otherwise we need to find it
224
+ // For now, if mapping is not found, throw error
225
+ // In production, you would search DHT or use a different discovery mechanism
226
+ if (!mappingCIDString) {
227
+ throw new Error(
228
+ `IPNS record mapping not found for ${ipnsKey}. The mapping must be published first.`, { cause: importError },
229
+ );
230
+ }
231
+
232
+ // Download mapping from IPFS
233
+ const { unixfs: unixfsModule } = await import("@helia/unixfs");
234
+ const fs = unixfsModule(helia);
235
+ const mappingCID = CID.parse(mappingCIDString);
236
+
237
+ const mappingChunks = [];
238
+ for await (const chunk of fs.cat(mappingCID)) {
239
+ mappingChunks.push(chunk);
240
+ }
241
+
242
+ const mappingLength = mappingChunks.reduce(
243
+ (acc, chunk) => acc + chunk.length,
244
+ 0,
245
+ );
246
+ const mappingBytes = new Uint8Array(mappingLength);
247
+ let mappingOffset = 0;
248
+ for (const chunk of mappingChunks) {
249
+ mappingBytes.set(chunk, mappingOffset);
250
+ mappingOffset += chunk.length;
251
+ }
252
+
253
+ const mapping = JSON.parse(new TextDecoder().decode(mappingBytes));
254
+ const recordCIDString = mapping.recordCID;
255
+ const recordCID = CID.parse(recordCIDString);
256
+
257
+ // Download IPNS record from IPFS
258
+ const chunks = [];
259
+ for await (const chunk of fs.cat(recordCID)) {
260
+ chunks.push(chunk);
261
+ }
262
+
263
+ // Combine chunks
264
+ const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
265
+ const ipnsRecordBytes = new Uint8Array(totalLength);
266
+ let offset = 0;
267
+ for (const chunk of chunks) {
268
+ ipnsRecordBytes.set(chunk, offset);
269
+ offset += chunk.length;
270
+ }
271
+
272
+ // Unmarshal IPNS record
273
+ const ipnsRecord = unmarshalIPNSRecord(ipnsRecordBytes);
274
+
275
+ // Extract CID from IPNS record
276
+ const cidBytes = ipnsRecord.value;
277
+ const cid = CID.decode(cidBytes);
278
+ const cidString = cid.toString();
279
+
280
+ logger.info(`✅ Resolved IPNS from IPFS to CID: ${cidString}`);
281
+
282
+ return cidString;
283
+ }
284
+ } catch (error) {
285
+ logger.error(`Error resolving IPNS: ${error.message}`);
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Download and parse metadata from IPFS
292
+ * @param {Object} helia - Helia instance
293
+ * @param {string} cid - CID of metadata
294
+ * @returns {Promise<Object>} - Parsed metadata object
295
+ */
296
+ export async function getMetadataFromIPFS(helia, cid) {
297
+ const { unixfs: unixfsModule } = await import("@helia/unixfs");
298
+ const fs = unixfsModule(helia);
299
+
300
+ const cidObj = CID.parse(cid);
301
+
302
+ // Download from IPFS network
303
+ const chunks = [];
304
+ for await (const chunk of fs.cat(cidObj)) {
305
+ chunks.push(chunk);
306
+ }
307
+
308
+ // Combine chunks
309
+ const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
310
+ const allBytes = new Uint8Array(totalLength);
311
+ let offset = 0;
312
+ for (const chunk of chunks) {
313
+ allBytes.set(chunk, offset);
314
+ offset += chunk.length;
315
+ }
316
+
317
+ // Parse JSON
318
+ const jsonString = new TextDecoder().decode(allBytes);
319
+ const metadata = JSON.parse(jsonString);
320
+
321
+ logger.info(`📄 Retrieved metadata from IPFS`);
322
+
323
+ return metadata;
324
+ }
package/lib/logger.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * OrbitDB Storage Bridge - Logger
3
+ *
4
+ * Uses @libp2p/logger for consistent logging across the libp2p ecosystem.
5
+ *
6
+ * To enable logging:
7
+ * - Node.js: DEBUG=libp2p:orbitdb-storacha:* node script.js
8
+ * - Browser: localStorage.setItem('debug', 'libp2p:orbitdb-storacha:*')
9
+ *
10
+ * Available formatters:
11
+ * - %s - string
12
+ * - %o - object
13
+ * - %d - number
14
+ * - %p - peer ID
15
+ * - %b - base58btc encoded data
16
+ * - %t - base32 encoded data
17
+ */
18
+
19
+ import { logger as libp2pLogger } from "@libp2p/logger";
20
+
21
+ // The default logger. Its namespace keeps the package's old name, so existing DEBUG settings still work.
22
+ export const logger = libp2pLogger("libp2p:orbitdb-storacha:bridge");
23
+
24
+ /**
25
+ * Create a child logger with a specific namespace
26
+ * @param {string} namespace - Namespace for the child logger (will be appended to libp2p:orbitdb-storacha:)
27
+ * @returns {Function} Logger function
28
+ */
29
+ export function createChildLogger(namespace) {
30
+ return libp2pLogger(`libp2p:orbitdb-storacha:${namespace}`);
31
+ }
32
+
33
+ /**
34
+ * Create a logger for a specific component
35
+ * @param {string} component - Component name
36
+ * @returns {Function} Logger function
37
+ */
38
+ export function createLogger(component) {
39
+ return libp2pLogger(`libp2p:orbitdb-storacha:${component}`);
40
+ }
41
+
42
+ export const LOG_LEVELS = {
43
+ TRACE: "trace",
44
+ DEBUG: "debug",
45
+ INFO: "info",
46
+ WARN: "warn",
47
+ ERROR: "error",
48
+ FATAL: "fatal",
49
+ };
50
+
51
+ export const logUtils = {
52
+ /**
53
+ * Log function entry with parameters
54
+ * @param {string} functionName - Name of the function
55
+ * @param {Object} params - Function parameters
56
+ * @param {Function} childLogger - Optional child logger
57
+ */
58
+ functionEntry: (functionName, params = {}, childLogger = logger) => {
59
+ childLogger(`Entering ${functionName} with params: %o`, params);
60
+ },
61
+
62
+ /**
63
+ * Log function exit with result
64
+ * @param {string} functionName - Name of the function
65
+ * @param {*} result - Function result
66
+ * @param {Function} childLogger - Optional child logger
67
+ */
68
+ functionExit: (functionName, result, childLogger = logger) => {
69
+ childLogger(`Exiting ${functionName} with result: %o`, result);
70
+ },
71
+
72
+ /**
73
+ * Log operation progress
74
+ * @param {string} operation - Operation name
75
+ * @param {number} current - Current progress
76
+ * @param {number} total - Total items
77
+ * @param {Function} childLogger - Optional child logger
78
+ */
79
+ progress: (operation, current, total, childLogger = logger) => {
80
+ const percentage = Math.round((current / total) * 100);
81
+ childLogger(`${operation}: ${current}/${total} (${percentage}%)`);
82
+ },
83
+
84
+ /**
85
+ * Log timing information
86
+ * @param {string} operation - Operation name
87
+ * @param {number} startTime - Start time (from Date.now())
88
+ * @param {Function} childLogger - Optional child logger
89
+ */
90
+ timing: (operation, startTime, childLogger = logger) => {
91
+ const duration = Date.now() - startTime;
92
+ childLogger(`${operation} completed in ${duration}ms`);
93
+ },
94
+ };
95
+
96
+ /**
97
+ * Compatibility wrappers for common log levels
98
+ * libp2p logger is a function, so we add these for convenience
99
+ */
100
+ logger.info = logger;
101
+ logger.debug = logger;
102
+ logger.error = logger.error || logger;
103
+ logger.warn = logger;
104
+ logger.trace = logger;
105
+
106
+ /**
107
+ * No-op functions for compatibility
108
+ * libp2p logger is controlled via DEBUG environment variable
109
+ */
110
+ export function setLogLevel(level) {
111
+ // No-op: libp2p logger uses DEBUG env var
112
+ logger(
113
+ `setLogLevel called with ${level} - use DEBUG environment variable instead`,
114
+ );
115
+ }
116
+
117
+ export function disableLogging() {
118
+ // No-op: libp2p logger uses DEBUG env var
119
+ }
120
+
121
+ export function enableLogging(level = "info") {
122
+ // No-op: libp2p logger uses DEBUG env var
123
+ logger(
124
+ `enableLogging called with ${level} - use DEBUG environment variable instead`,
125
+ );
126
+ }
127
+
128
+ export default logger;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Memory Courier — an in-memory pair of byte couriers for testing courier-sync
3
+ * without any radio, network or filesystem.
4
+ *
5
+ * Implements the seam contract from courier-sync.js and misbehaves on request:
6
+ * messages can be dropped, duplicated and delivered out of order, because a
7
+ * real courier (a LoRa mesh above all) does all three. The GPL-side Meshtastic
8
+ * courier is expected to test its protocol against this same contract.
9
+ *
10
+ * Deliveries are asynchronous (macrotask) so both ends always see the same
11
+ * causality a real courier would give them; `pair.idle()` resolves when no
12
+ * message is in flight on either side.
13
+ */
14
+
15
+ /**
16
+ * @param {Object} [options]
17
+ * @param {"fifo"|"lifo"} [options.order="fifo"] "lifo" reverses each drained
18
+ * batch — a deterministic worst case for reordering tolerance.
19
+ * @param {(info: {seq: number, from: "a"|"b", bytes: Uint8Array}) => boolean} [options.dropFn]
20
+ * Return true to lose that message.
21
+ * @param {(info: {seq: number, from: "a"|"b", bytes: Uint8Array}) => boolean} [options.duplicateFn]
22
+ * Return true to deliver that message twice.
23
+ * @returns {{a: Object, b: Object, idle: () => Promise<void>, stats: Object}}
24
+ */
25
+ export function createMemoryCourierPair(options = {}) {
26
+ const { order = "fifo", dropFn = null, duplicateFn = null } = options;
27
+
28
+ const stats = { sent: 0, delivered: 0, dropped: 0, duplicated: 0 };
29
+ let seq = 0;
30
+ let inFlight = 0;
31
+ const idleWaiters = [];
32
+
33
+ const settle = () => {
34
+ if (inFlight === 0) {
35
+ while (idleWaiters.length > 0) idleWaiters.shift()();
36
+ }
37
+ };
38
+
39
+ const makeEnd = (name) => {
40
+ const listeners = new Set();
41
+ const queue = [];
42
+ let drainScheduled = false;
43
+
44
+ const drain = () => {
45
+ drainScheduled = false;
46
+ const batch =
47
+ order === "lifo" ? queue.splice(0).reverse() : queue.splice(0);
48
+ for (const bytes of batch) {
49
+ stats.delivered++;
50
+ for (const cb of listeners) cb(bytes);
51
+ inFlight--;
52
+ }
53
+ settle();
54
+ };
55
+
56
+ return {
57
+ listeners,
58
+ deliver(bytes) {
59
+ inFlight++;
60
+ queue.push(bytes);
61
+ if (!drainScheduled) {
62
+ drainScheduled = true;
63
+ setTimeout(drain, 0);
64
+ }
65
+ },
66
+ name,
67
+ };
68
+ };
69
+
70
+ const endA = makeEnd("a");
71
+ const endB = makeEnd("b");
72
+
73
+ const makeCourier = (from, remote) => ({
74
+ async send(bytes) {
75
+ stats.sent++;
76
+ const info = { seq: seq++, from, bytes };
77
+ if (dropFn && dropFn(info)) {
78
+ stats.dropped++;
79
+ return;
80
+ }
81
+ remote.deliver(bytes);
82
+ if (duplicateFn && duplicateFn(info)) {
83
+ stats.duplicated++;
84
+ remote.deliver(bytes);
85
+ }
86
+ },
87
+ onPayload(cb) {
88
+ const local = from === "a" ? endA : endB;
89
+ local.listeners.add(cb);
90
+ return () => local.listeners.delete(cb);
91
+ },
92
+ });
93
+
94
+ return {
95
+ a: makeCourier("a", endB),
96
+ b: makeCourier("b", endA),
97
+ stats,
98
+ idle() {
99
+ return new Promise((resolve) => {
100
+ idleWaiters.push(resolve);
101
+ settle();
102
+ });
103
+ },
104
+ };
105
+ }