@sschepis/magazine 0.1.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,443 @@
1
+ import Gun from 'gun';
2
+ import 'gun/lib/load.js';
3
+ import 'gun/lib/open.js';
4
+ import Logger from './Logger.js';
5
+ import EventFilter from './EventFilter.js';
6
+ import Paginator from './Paginator.js';
7
+ import DataCompressor from './DataCompressor.js';
8
+
9
+ /**
10
+ * EventStore - Handles Gun.js data storage operations
11
+ */
12
+ export default class EventStore {
13
+ constructor(gunConfig, retryOperation, networkManager = null) {
14
+ this.gun = Gun(gunConfig);
15
+ this.retryOperation = retryOperation;
16
+ this.networkManager = networkManager;
17
+ this.logger = new Logger({
18
+ context: 'EventStore',
19
+ level: gunConfig?.logLevel || 'info'
20
+ });
21
+
22
+ // Initialize components
23
+ this.eventFilter = new EventFilter(this.logger);
24
+ this.paginator = new Paginator(this.logger);
25
+ this.dataCompressor = new DataCompressor(this.logger);
26
+
27
+ // Compression settings
28
+ this.compressionEnabled = gunConfig?.compression?.enabled ?? true;
29
+ this.compressionStrategy = gunConfig?.compression?.strategy ?? 'optimized';
30
+
31
+ // Track active Gun listeners for cleanup
32
+ this._activeListeners = [];
33
+
34
+ this.logger.info('EventStore initialized', {
35
+ gunConfig: this._sanitizeGunConfig(gunConfig),
36
+ compressionEnabled: this.compressionEnabled,
37
+ compressionStrategy: this.compressionStrategy
38
+ });
39
+ }
40
+
41
+ // Sanitize Gun config for logging
42
+ _sanitizeGunConfig(config) {
43
+ if (!config || typeof config !== 'object') return config;
44
+ // Remove potentially sensitive information
45
+ const sanitized = { ...config };
46
+ delete sanitized.peers;
47
+ delete sanitized.localStorage;
48
+ return sanitized;
49
+ }
50
+
51
+ // Get the start block number for syncing events from the blockchain
52
+ async getStartBlockNumber() {
53
+ return this.retryOperation(async () => {
54
+ return new Promise((resolve) => {
55
+ this.gun.get('config').get('startBlockNumber').once((data) => {
56
+ resolve(data || 1);
57
+ });
58
+ });
59
+ }, 'Getting start block number');
60
+ }
61
+
62
+ // Set the start block number for syncing events from the blockchain
63
+ async setStartBlockNumber(startBlockNumber) {
64
+ return this.retryOperation(async () => {
65
+ return new Promise((resolve, reject) => {
66
+ this.gun.get('config').get('startBlockNumber').put(startBlockNumber, (ack) => {
67
+ if (ack.err) {
68
+ reject(new Error(ack.err));
69
+ } else {
70
+ resolve(ack);
71
+ }
72
+ });
73
+ });
74
+ }, `Setting start block number to ${startBlockNumber}`);
75
+ }
76
+
77
+ // Store an event in the decentralized database
78
+ async storeEvent(eventName, eventKey, eventData) {
79
+ return this.logger.time('storeEvent', async () => {
80
+ this.logger.logDatabaseActivity('storeEvent', {
81
+ eventName,
82
+ eventKey,
83
+ blockNumber: eventData.blockNumber,
84
+ transactionHash: eventData.transactionHash
85
+ });
86
+
87
+ // Compress data if enabled
88
+ let dataToStore = eventData;
89
+ if (this.compressionEnabled) {
90
+ const compressed = this.dataCompressor.compress(eventData, this.compressionStrategy);
91
+ dataToStore = {
92
+ _compressed: true,
93
+ _data: compressed.data,
94
+ _metadata: compressed.metadata
95
+ };
96
+ }
97
+
98
+ return this.retryOperation(
99
+ () => new Promise(resolve => {
100
+ this.gun.get('events').get(eventName).get(eventKey).put(dataToStore, resolve);
101
+ }),
102
+ `Storing event ${eventName}`
103
+ );
104
+ }, { eventName, eventKey });
105
+ }
106
+
107
+ // Get events by name with optional filtering
108
+ async getEvents(eventName, filter = {}) {
109
+ return this.logger.time('getEvents', async () => {
110
+ this.logger.logDatabaseActivity('getEvents', {
111
+ eventName,
112
+ filter: Object.keys(filter)
113
+ });
114
+
115
+ return this.retryOperation(async () => {
116
+ return new Promise((resolve) => {
117
+ let resolved = false;
118
+ const node = this.gun.get('events').get(eventName);
119
+
120
+ const timeout = setTimeout(() => {
121
+ if (!resolved) {
122
+ resolved = true;
123
+ this.logger.debug('getEvents timed out, returning empty', { eventName });
124
+ resolve([]);
125
+ }
126
+ }, 3000);
127
+
128
+ node.open((data) => {
129
+ if (resolved) return;
130
+ resolved = true;
131
+ clearTimeout(timeout);
132
+
133
+ const events = [];
134
+ if (data && typeof data === 'object') {
135
+ Object.entries(data).forEach(([key, value]) => {
136
+ if (key === '_' || !value || typeof value !== 'object') return;
137
+ if (this._matchesFilter(value, filter)) {
138
+ events.push({ key, ...value });
139
+ }
140
+ });
141
+ }
142
+
143
+ this.logger.debug('Events retrieved', {
144
+ eventName,
145
+ eventCount: events.length,
146
+ filterApplied: Object.keys(filter).length > 0
147
+ });
148
+ resolve(events);
149
+ });
150
+ });
151
+ }, `Getting events for ${eventName}`);
152
+ }, { eventName });
153
+ }
154
+
155
+ // Get all events with optional filtering and pagination
156
+ async getAllEvents(options = {}) {
157
+ return this.logger.time('getAllEvents', async () => {
158
+ const { filter = {}, pagination = {}, sort = {} } = options;
159
+
160
+ // Get all events from Gun
161
+ const allEventsMap = await this._fetchAllEventsMap();
162
+
163
+ // Flatten events into array
164
+ const allEvents = [];
165
+ Object.entries(allEventsMap).forEach(([eventName, events]) => {
166
+ events.forEach(event => {
167
+ allEvents.push({
168
+ ...event,
169
+ eventName
170
+ });
171
+ });
172
+ });
173
+
174
+ // Apply filters
175
+ let filteredEvents = allEvents;
176
+ if (Object.keys(filter).length > 0) {
177
+ const filterCriteria = this.eventFilter.buildFilter(filter);
178
+ filteredEvents = this.eventFilter.applyFilter(allEvents, filterCriteria);
179
+ }
180
+
181
+ // Apply sorting
182
+ if (sort.by) {
183
+ filteredEvents = this.eventFilter.sortEvents(filteredEvents, sort);
184
+ }
185
+
186
+ // Apply pagination
187
+ if (pagination.page || pagination.pageSize) {
188
+ const paginatedResult = this.paginator.paginate(filteredEvents, pagination);
189
+ this.logger.debug('Query completed', {
190
+ totalEvents: allEvents.length,
191
+ filteredEvents: filteredEvents.length,
192
+ returnedEvents: paginatedResult.data.length
193
+ });
194
+ return paginatedResult;
195
+ }
196
+
197
+ // Return unpaginated results
198
+ return {
199
+ data: filteredEvents,
200
+ meta: {
201
+ totalItems: filteredEvents.length
202
+ }
203
+ };
204
+ });
205
+ }
206
+
207
+ // Internal method to fetch all events as a map
208
+ async _fetchAllEventsMap() {
209
+ return this.retryOperation(async () => {
210
+ return new Promise((resolve) => {
211
+ let resolved = false;
212
+ const node = this.gun.get('events');
213
+
214
+ const timeout = setTimeout(() => {
215
+ if (!resolved) {
216
+ resolved = true;
217
+ this.logger.debug('_fetchAllEventsMap timed out, returning empty');
218
+ resolve({});
219
+ }
220
+ }, 5000);
221
+
222
+ node.open((data) => {
223
+ if (resolved) return;
224
+ resolved = true;
225
+ clearTimeout(timeout);
226
+
227
+ const allEvents = {};
228
+ if (data && typeof data === 'object') {
229
+ Object.entries(data).forEach(([eventName, eventGroup]) => {
230
+ if (eventName === '_' || !eventGroup || typeof eventGroup !== 'object') return;
231
+ allEvents[eventName] = [];
232
+ Object.entries(eventGroup).forEach(([key, eventData]) => {
233
+ if (key === '_' || !eventData || typeof eventData !== 'object') return;
234
+ let decompressed = eventData;
235
+ if (eventData._compressed && eventData._data) {
236
+ try {
237
+ decompressed = this.dataCompressor.decompress({
238
+ data: eventData._data,
239
+ metadata: eventData._metadata
240
+ });
241
+ } catch (error) {
242
+ this.logger.error('Failed to decompress event', {
243
+ eventName,
244
+ key,
245
+ error: error.message
246
+ });
247
+ }
248
+ }
249
+ allEvents[eventName].push({ key, ...decompressed });
250
+ });
251
+ });
252
+ }
253
+ resolve(allEvents);
254
+ });
255
+ });
256
+ }, 'Getting all events');
257
+ }
258
+
259
+ // Publish magazine metadata to the decentralized network
260
+ async publishMagazine(alias, magazineData, url) {
261
+ return this.logger.time('publishMagazine', async () => {
262
+ this.logger.info('Publishing magazine', {
263
+ alias,
264
+ url,
265
+ magazineData: {
266
+ name: magazineData.name,
267
+ address: magazineData.address,
268
+ networkId: magazineData.networkId
269
+ }
270
+ });
271
+
272
+ return this.retryOperation(async () => {
273
+ const magazineNode = this.gun.back(-1).get(alias);
274
+
275
+ const magazineId = await new Promise((resolve, reject) => {
276
+ magazineNode.put(magazineData, (ack) => {
277
+ if (ack.err) {
278
+ this.logger.error('Failed to publish magazine metadata', {
279
+ alias,
280
+ error: ack.err
281
+ });
282
+ reject(new Error(ack.err));
283
+ } else {
284
+ this.logger.debug('Magazine metadata published', { alias });
285
+ resolve(ack);
286
+ }
287
+ });
288
+ });
289
+
290
+ await new Promise((resolve, reject) => {
291
+ magazineNode.get('url').put(url, (ack) => {
292
+ if (ack.err) {
293
+ this.logger.error('Failed to publish magazine URL', {
294
+ alias,
295
+ url,
296
+ error: ack.err
297
+ });
298
+ reject(new Error(ack.err));
299
+ } else {
300
+ this.logger.debug('Magazine URL published', { alias, url });
301
+ resolve(ack);
302
+ }
303
+ });
304
+ });
305
+
306
+ this.logger.info('Magazine published successfully', { alias, url });
307
+ return magazineId;
308
+ }, 'Publishing magazine');
309
+ }, { alias });
310
+ }
311
+
312
+ // Subscribe to a magazine on the decentralized network
313
+ async subscribeMagazine(magazineId) {
314
+ return this.retryOperation(async () => {
315
+ return new Promise((resolve, reject) => {
316
+ this.gun.get(magazineId).once((data, key) => {
317
+ if (data) {
318
+ resolve(data);
319
+ } else {
320
+ reject(new Error(`Magazine with ID ${magazineId} not found`));
321
+ }
322
+ });
323
+ });
324
+ }, `Subscribing to magazine ${magazineId}`);
325
+ }
326
+
327
+ // Helper method to check if event data matches filter criteria
328
+ _matchesFilter(data, filter) {
329
+ if (!filter || Object.keys(filter).length === 0) {
330
+ return true;
331
+ }
332
+
333
+ for (const [key, value] of Object.entries(filter)) {
334
+ if (data[key] !== value) {
335
+ return false;
336
+ }
337
+ }
338
+
339
+ return true;
340
+ }
341
+
342
+ // Query events using fluent API
343
+ async query() {
344
+ const allEventsMap = await this._fetchAllEventsMap();
345
+
346
+ // Flatten events
347
+ const allEvents = [];
348
+ Object.entries(allEventsMap).forEach(([eventName, events]) => {
349
+ events.forEach(event => {
350
+ allEvents.push({
351
+ ...event,
352
+ eventName
353
+ });
354
+ });
355
+ });
356
+
357
+ return this.eventFilter.query(allEvents);
358
+ }
359
+
360
+ // Get events by transaction hash
361
+ async getEventsByTransactionHash(transactionHash) {
362
+ return this.logger.time('getEventsByTransactionHash', async () => {
363
+ const result = await this.getAllEvents({
364
+ filter: { transactionHash }
365
+ });
366
+
367
+ this.logger.debug('Found events by transaction hash', {
368
+ transactionHash,
369
+ count: result.data.length
370
+ });
371
+
372
+ return result.data;
373
+ });
374
+ }
375
+
376
+ // Get events by block number
377
+ async getEventsByBlockNumber(blockNumber) {
378
+ return this.logger.time('getEventsByBlockNumber', async () => {
379
+ const result = await this.getAllEvents({
380
+ filter: {
381
+ fromBlock: blockNumber,
382
+ toBlock: blockNumber
383
+ }
384
+ });
385
+
386
+ this.logger.debug('Found events by block number', {
387
+ blockNumber,
388
+ count: result.data.length
389
+ });
390
+
391
+ return result.data;
392
+ });
393
+ }
394
+
395
+ // Create async iterator for events
396
+ async *iterateEvents(options = {}) {
397
+ const { filter = {}, sort = {}, pageSize = 100 } = options;
398
+
399
+ // Get filtered and sorted events
400
+ const result = await this.getAllEvents({ filter, sort });
401
+ const events = result.data;
402
+
403
+ // Use paginator's iterator
404
+ yield* this.paginator.iterate(events, { pageSize });
405
+ }
406
+
407
+ // Get compression statistics
408
+ async getCompressionStats() {
409
+ const allEventsMap = await this._fetchAllEventsMap();
410
+ let totalOriginal = 0;
411
+ let totalCompressed = 0;
412
+ let compressedCount = 0;
413
+
414
+ Object.values(allEventsMap).forEach(events => {
415
+ events.forEach(event => {
416
+ if (event._compressed && event._metadata) {
417
+ totalOriginal += event._metadata.originalSize || 0;
418
+ totalCompressed += event._metadata.compressedSize || 0;
419
+ compressedCount++;
420
+ }
421
+ });
422
+ });
423
+
424
+ return {
425
+ compressedEvents: compressedCount,
426
+ totalOriginalSize: totalOriginal,
427
+ totalCompressedSize: totalCompressed,
428
+ compressionRatio: totalOriginal > 0 ? (totalCompressed / totalOriginal).toFixed(3) : '1.000',
429
+ spaceSaved: totalOriginal > 0 ? `${((1 - totalCompressed / totalOriginal) * 100).toFixed(1)}%` : '0%'
430
+ };
431
+ }
432
+
433
+ // Clean up all active listeners
434
+ destroy() {
435
+ this._activeListeners = [];
436
+ this.logger.info('EventStore destroyed, listeners cleaned up');
437
+ }
438
+
439
+ // Get Gun instance for advanced operations
440
+ getGunInstance() {
441
+ return this.gun;
442
+ }
443
+ }