@monygroupcorp/micro-web3 0.1.3 → 1.2.1

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,3642 @@
1
+ # EventIndexer Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add client-side event indexing to micro-web3 with three abstraction levels (Events, Entities, Patterns), persistent storage, and reactive component integration.
6
+
7
+ **Architecture:** EventIndexer is the main service coordinating four internal modules: StorageAdapter (IndexedDB/Memory), SyncEngine (historical + real-time), QueryEngine (filtering/pagination), and EntityResolver (domain objects from events). Components integrate via `useIndexer` hook with automatic subscription management.
8
+
9
+ **Tech Stack:** ethers.js ^5.7.2 (existing), IndexedDB (native), microact Component (peer dependency)
10
+
11
+ ---
12
+
13
+ ## Implementation Order
14
+
15
+ 1. Storage Layer (foundation)
16
+ 2. Query Engine (depends on storage)
17
+ 3. Sync Engine (depends on storage)
18
+ 4. Entity Resolver (depends on query engine)
19
+ 5. Patterns API (depends on entity resolver)
20
+ 6. EventIndexer Service (orchestrates all)
21
+ 7. Component Integration (useIndexer hook)
22
+ 8. UI Components (SyncProgressBar)
23
+ 9. **Settings Modal & User Storage Controls** (NEW)
24
+ 10. Existing Service Modifications
25
+ 11. Export Updates & Version Bump
26
+ 12. Tests (last, as requested)
27
+
28
+ ---
29
+
30
+ ## Task 1: Storage Adapter Interface
31
+
32
+ **Files:**
33
+ - Create: `src/storage/StorageAdapter.js`
34
+
35
+ **Step 1: Create the storage adapter base class**
36
+
37
+ ```javascript
38
+ // src/storage/StorageAdapter.js
39
+
40
+ /**
41
+ * Abstract storage adapter interface for EventIndexer.
42
+ * Implementations must handle all async operations.
43
+ */
44
+ class StorageAdapter {
45
+ /**
46
+ * Initialize storage with configuration.
47
+ * @param {Object} config - Storage configuration
48
+ * @param {string} config.dbName - Database name
49
+ * @param {number} config.version - Schema version
50
+ * @param {Object[]} config.eventTypes - Event types from ABI with indexed params
51
+ * @returns {Promise<void>}
52
+ */
53
+ async initialize(config) {
54
+ throw new Error('StorageAdapter.initialize() must be implemented');
55
+ }
56
+
57
+ /**
58
+ * Store multiple events atomically.
59
+ * @param {IndexedEvent[]} events - Events to store
60
+ * @returns {Promise<void>}
61
+ */
62
+ async putEvents(events) {
63
+ throw new Error('StorageAdapter.putEvents() must be implemented');
64
+ }
65
+
66
+ /**
67
+ * Get single event by type and ID.
68
+ * @param {string} type - Event type name
69
+ * @param {string} id - Event ID (txHash-logIndex)
70
+ * @returns {Promise<IndexedEvent|null>}
71
+ */
72
+ async getEvent(type, id) {
73
+ throw new Error('StorageAdapter.getEvent() must be implemented');
74
+ }
75
+
76
+ /**
77
+ * Query events with filters, sorting, pagination.
78
+ * @param {string} type - Event type name
79
+ * @param {QueryOptions} options - Query options
80
+ * @returns {Promise<QueryResult>}
81
+ */
82
+ async queryEvents(type, options) {
83
+ throw new Error('StorageAdapter.queryEvents() must be implemented');
84
+ }
85
+
86
+ /**
87
+ * Count events matching filter.
88
+ * @param {string} type - Event type name
89
+ * @param {Object} where - Filter conditions
90
+ * @returns {Promise<number>}
91
+ */
92
+ async countEvents(type, where) {
93
+ throw new Error('StorageAdapter.countEvents() must be implemented');
94
+ }
95
+
96
+ /**
97
+ * Delete events by IDs.
98
+ * @param {string} type - Event type name
99
+ * @param {string[]} ids - Event IDs to delete
100
+ * @returns {Promise<void>}
101
+ */
102
+ async deleteEvents(type, ids) {
103
+ throw new Error('StorageAdapter.deleteEvents() must be implemented');
104
+ }
105
+
106
+ /**
107
+ * Delete all events after a block number (for reorg recovery).
108
+ * @param {number} blockNumber - Delete events after this block
109
+ * @returns {Promise<void>}
110
+ */
111
+ async deleteEventsAfterBlock(blockNumber) {
112
+ throw new Error('StorageAdapter.deleteEventsAfterBlock() must be implemented');
113
+ }
114
+
115
+ /**
116
+ * Get sync state.
117
+ * @returns {Promise<SyncState|null>}
118
+ */
119
+ async getSyncState() {
120
+ throw new Error('StorageAdapter.getSyncState() must be implemented');
121
+ }
122
+
123
+ /**
124
+ * Update sync state.
125
+ * @param {SyncState} state - New sync state
126
+ * @returns {Promise<void>}
127
+ */
128
+ async setSyncState(state) {
129
+ throw new Error('StorageAdapter.setSyncState() must be implemented');
130
+ }
131
+
132
+ /**
133
+ * Get schema version.
134
+ * @returns {Promise<number>}
135
+ */
136
+ async getVersion() {
137
+ throw new Error('StorageAdapter.getVersion() must be implemented');
138
+ }
139
+
140
+ /**
141
+ * Set schema version.
142
+ * @param {number} version - New version
143
+ * @returns {Promise<void>}
144
+ */
145
+ async setVersion(version) {
146
+ throw new Error('StorageAdapter.setVersion() must be implemented');
147
+ }
148
+
149
+ /**
150
+ * Clear all data.
151
+ * @returns {Promise<void>}
152
+ */
153
+ async clear() {
154
+ throw new Error('StorageAdapter.clear() must be implemented');
155
+ }
156
+
157
+ /**
158
+ * Close connection and cleanup.
159
+ * @returns {Promise<void>}
160
+ */
161
+ async close() {
162
+ throw new Error('StorageAdapter.close() must be implemented');
163
+ }
164
+ }
165
+
166
+ export default StorageAdapter;
167
+ ```
168
+
169
+ **Step 2: Commit**
170
+
171
+ ```bash
172
+ git add src/storage/StorageAdapter.js
173
+ git commit -m "feat(storage): add StorageAdapter base class interface"
174
+ ```
175
+
176
+ ---
177
+
178
+ ## Task 2: Memory Storage Adapter
179
+
180
+ **Files:**
181
+ - Create: `src/storage/MemoryAdapter.js`
182
+
183
+ **Step 1: Implement MemoryAdapter**
184
+
185
+ ```javascript
186
+ // src/storage/MemoryAdapter.js
187
+
188
+ import StorageAdapter from './StorageAdapter.js';
189
+
190
+ /**
191
+ * In-memory storage adapter for EventIndexer.
192
+ * Used as fallback when IndexedDB unavailable (SSR, private browsing).
193
+ * No persistence across sessions.
194
+ */
195
+ class MemoryAdapter extends StorageAdapter {
196
+ constructor() {
197
+ super();
198
+ this.stores = new Map(); // eventType -> Map<id, event>
199
+ this.syncState = null;
200
+ this.version = 0;
201
+ this.config = null;
202
+ }
203
+
204
+ async initialize(config) {
205
+ this.config = config;
206
+
207
+ // Create store for each event type
208
+ for (const eventType of config.eventTypes) {
209
+ this.stores.set(eventType.name, new Map());
210
+ }
211
+
212
+ // Check version for migration
213
+ if (config.version > this.version) {
214
+ await this.clear();
215
+ this.version = config.version;
216
+ }
217
+ }
218
+
219
+ async putEvents(events) {
220
+ for (const event of events) {
221
+ const store = this.stores.get(event.type);
222
+ if (!store) {
223
+ // Lazily create store for unknown event types
224
+ this.stores.set(event.type, new Map());
225
+ }
226
+ this.stores.get(event.type).set(event.id, event);
227
+ }
228
+ }
229
+
230
+ async getEvent(type, id) {
231
+ const store = this.stores.get(type);
232
+ if (!store) return null;
233
+ return store.get(id) || null;
234
+ }
235
+
236
+ async queryEvents(type, options = {}) {
237
+ const store = this.stores.get(type);
238
+ if (!store) {
239
+ return { events: [], total: 0, hasMore: false };
240
+ }
241
+
242
+ let events = Array.from(store.values());
243
+
244
+ // Apply where filters
245
+ if (options.where) {
246
+ events = events.filter(event => this._matchesWhere(event, options.where));
247
+ }
248
+
249
+ const total = events.length;
250
+
251
+ // Sort
252
+ const orderBy = options.orderBy || 'blockNumber';
253
+ const order = options.order || 'desc';
254
+ events.sort((a, b) => {
255
+ const aVal = this._getNestedValue(a, orderBy);
256
+ const bVal = this._getNestedValue(b, orderBy);
257
+ if (aVal < bVal) return order === 'asc' ? -1 : 1;
258
+ if (aVal > bVal) return order === 'asc' ? 1 : -1;
259
+ return 0;
260
+ });
261
+
262
+ // Pagination
263
+ const offset = options.offset || 0;
264
+ const limit = options.limit || 100;
265
+ const paged = events.slice(offset, offset + limit);
266
+
267
+ return {
268
+ events: paged,
269
+ total,
270
+ hasMore: offset + limit < total
271
+ };
272
+ }
273
+
274
+ async countEvents(type, where) {
275
+ const store = this.stores.get(type);
276
+ if (!store) return 0;
277
+
278
+ if (!where) return store.size;
279
+
280
+ let count = 0;
281
+ for (const event of store.values()) {
282
+ if (this._matchesWhere(event, where)) {
283
+ count++;
284
+ }
285
+ }
286
+ return count;
287
+ }
288
+
289
+ async deleteEvents(type, ids) {
290
+ const store = this.stores.get(type);
291
+ if (!store) return;
292
+
293
+ for (const id of ids) {
294
+ store.delete(id);
295
+ }
296
+ }
297
+
298
+ async deleteEventsAfterBlock(blockNumber) {
299
+ for (const store of this.stores.values()) {
300
+ for (const [id, event] of store.entries()) {
301
+ if (event.blockNumber > blockNumber) {
302
+ store.delete(id);
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ async getSyncState() {
309
+ return this.syncState;
310
+ }
311
+
312
+ async setSyncState(state) {
313
+ this.syncState = { ...state };
314
+ }
315
+
316
+ async getVersion() {
317
+ return this.version;
318
+ }
319
+
320
+ async setVersion(version) {
321
+ this.version = version;
322
+ }
323
+
324
+ async clear() {
325
+ for (const store of this.stores.values()) {
326
+ store.clear();
327
+ }
328
+ this.syncState = null;
329
+ }
330
+
331
+ async close() {
332
+ // No-op for memory adapter
333
+ }
334
+
335
+ // Helper: check if event matches where clause
336
+ _matchesWhere(event, where) {
337
+ for (const [key, value] of Object.entries(where)) {
338
+ const eventValue = this._getNestedValue(event, key);
339
+
340
+ // Handle array values (OR condition)
341
+ if (Array.isArray(value)) {
342
+ if (!value.includes(eventValue)) return false;
343
+ }
344
+ // Handle comparison operators
345
+ else if (typeof value === 'object' && value !== null) {
346
+ if (value.$gt !== undefined && !(eventValue > value.$gt)) return false;
347
+ if (value.$gte !== undefined && !(eventValue >= value.$gte)) return false;
348
+ if (value.$lt !== undefined && !(eventValue < value.$lt)) return false;
349
+ if (value.$lte !== undefined && !(eventValue <= value.$lte)) return false;
350
+ if (value.$ne !== undefined && eventValue === value.$ne) return false;
351
+ }
352
+ // Direct equality (case-insensitive for addresses)
353
+ else {
354
+ const normalizedEvent = typeof eventValue === 'string' ? eventValue.toLowerCase() : eventValue;
355
+ const normalizedValue = typeof value === 'string' ? value.toLowerCase() : value;
356
+ if (normalizedEvent !== normalizedValue) return false;
357
+ }
358
+ }
359
+ return true;
360
+ }
361
+
362
+ // Helper: get nested value from object using dot notation
363
+ _getNestedValue(obj, path) {
364
+ // Check indexed params first, then data, then direct
365
+ if (path.startsWith('indexed.')) {
366
+ return obj.indexed?.[path.slice(8)];
367
+ }
368
+ if (path.startsWith('data.')) {
369
+ return obj.data?.[path.slice(5)];
370
+ }
371
+
372
+ // Try indexed, then data, then direct property
373
+ if (obj.indexed?.[path] !== undefined) return obj.indexed[path];
374
+ if (obj.data?.[path] !== undefined) return obj.data[path];
375
+ return obj[path];
376
+ }
377
+ }
378
+
379
+ export default MemoryAdapter;
380
+ ```
381
+
382
+ **Step 2: Commit**
383
+
384
+ ```bash
385
+ git add src/storage/MemoryAdapter.js
386
+ git commit -m "feat(storage): add MemoryAdapter for in-memory event storage"
387
+ ```
388
+
389
+ ---
390
+
391
+ ## Task 3: IndexedDB Storage Adapter
392
+
393
+ **Files:**
394
+ - Create: `src/storage/IndexedDBAdapter.js`
395
+
396
+ **Step 1: Implement IndexedDBAdapter**
397
+
398
+ ```javascript
399
+ // src/storage/IndexedDBAdapter.js
400
+
401
+ import StorageAdapter from './StorageAdapter.js';
402
+
403
+ /**
404
+ * IndexedDB storage adapter for EventIndexer.
405
+ * Provides persistent event storage with auto-generated indexes.
406
+ */
407
+ class IndexedDBAdapter extends StorageAdapter {
408
+ constructor() {
409
+ super();
410
+ this.db = null;
411
+ this.config = null;
412
+ this.dbName = null;
413
+ }
414
+
415
+ async initialize(config) {
416
+ this.config = config;
417
+ this.dbName = config.dbName;
418
+
419
+ // Check for existing version
420
+ const existingVersion = await this._getStoredVersion();
421
+
422
+ // Handle migration: if config version is higher, we need to clear and rebuild
423
+ const needsMigration = existingVersion !== null && config.version > existingVersion;
424
+
425
+ if (needsMigration) {
426
+ console.log(`[IndexedDBAdapter] Migrating from v${existingVersion} to v${config.version}`);
427
+ await this._deleteDatabase();
428
+ }
429
+
430
+ return new Promise((resolve, reject) => {
431
+ const request = indexedDB.open(this.dbName, config.version);
432
+
433
+ request.onerror = () => {
434
+ reject(new Error(`Failed to open IndexedDB: ${request.error?.message}`));
435
+ };
436
+
437
+ request.onsuccess = () => {
438
+ this.db = request.result;
439
+ resolve();
440
+ };
441
+
442
+ request.onupgradeneeded = (event) => {
443
+ const db = event.target.result;
444
+ this._createStores(db, config.eventTypes);
445
+ };
446
+ });
447
+ }
448
+
449
+ _createStores(db, eventTypes) {
450
+ // Create store for each event type
451
+ for (const eventType of eventTypes) {
452
+ const storeName = `events_${eventType.name}`;
453
+
454
+ if (!db.objectStoreNames.contains(storeName)) {
455
+ const store = db.createObjectStore(storeName, { keyPath: 'id' });
456
+
457
+ // Create indexes for indexed params (from ABI)
458
+ for (const param of eventType.indexedParams || []) {
459
+ store.createIndex(`param_${param}`, `indexed.${param}`, { unique: false });
460
+ }
461
+
462
+ // Always create blockNumber and timestamp indexes
463
+ store.createIndex('blockNumber', 'blockNumber', { unique: false });
464
+ store.createIndex('timestamp', 'timestamp', { unique: false });
465
+ }
466
+ }
467
+
468
+ // Create sync state store
469
+ if (!db.objectStoreNames.contains('syncState')) {
470
+ db.createObjectStore('syncState', { keyPath: 'id' });
471
+ }
472
+
473
+ // Create meta store for version tracking
474
+ if (!db.objectStoreNames.contains('meta')) {
475
+ db.createObjectStore('meta', { keyPath: 'key' });
476
+ }
477
+ }
478
+
479
+ async _getStoredVersion() {
480
+ return new Promise((resolve) => {
481
+ const request = indexedDB.open(this.dbName);
482
+ request.onsuccess = () => {
483
+ const db = request.result;
484
+ const version = db.version;
485
+ db.close();
486
+ resolve(version);
487
+ };
488
+ request.onerror = () => resolve(null);
489
+ });
490
+ }
491
+
492
+ async _deleteDatabase() {
493
+ return new Promise((resolve, reject) => {
494
+ if (this.db) {
495
+ this.db.close();
496
+ this.db = null;
497
+ }
498
+ const request = indexedDB.deleteDatabase(this.dbName);
499
+ request.onsuccess = () => resolve();
500
+ request.onerror = () => reject(request.error);
501
+ });
502
+ }
503
+
504
+ async putEvents(events) {
505
+ if (!this.db) throw new Error('IndexedDBAdapter not initialized');
506
+ if (events.length === 0) return;
507
+
508
+ // Group events by type for batch operations
509
+ const eventsByType = new Map();
510
+ for (const event of events) {
511
+ if (!eventsByType.has(event.type)) {
512
+ eventsByType.set(event.type, []);
513
+ }
514
+ eventsByType.get(event.type).push(event);
515
+ }
516
+
517
+ // Write each type in a transaction
518
+ const promises = [];
519
+ for (const [type, typeEvents] of eventsByType) {
520
+ promises.push(this._putEventsOfType(type, typeEvents));
521
+ }
522
+ await Promise.all(promises);
523
+ }
524
+
525
+ async _putEventsOfType(type, events) {
526
+ const storeName = `events_${type}`;
527
+
528
+ // Check if store exists, create if needed
529
+ if (!this.db.objectStoreNames.contains(storeName)) {
530
+ // Need to reopen with version upgrade to add new store
531
+ await this._addStore(type);
532
+ }
533
+
534
+ return new Promise((resolve, reject) => {
535
+ const tx = this.db.transaction(storeName, 'readwrite');
536
+ const store = tx.objectStore(storeName);
537
+
538
+ for (const event of events) {
539
+ store.put(event);
540
+ }
541
+
542
+ tx.oncomplete = () => resolve();
543
+ tx.onerror = () => reject(tx.error);
544
+ });
545
+ }
546
+
547
+ async _addStore(eventType) {
548
+ // Close current connection
549
+ const currentVersion = this.db.version;
550
+ this.db.close();
551
+
552
+ // Reopen with incremented version
553
+ return new Promise((resolve, reject) => {
554
+ const request = indexedDB.open(this.dbName, currentVersion + 1);
555
+
556
+ request.onupgradeneeded = (event) => {
557
+ const db = event.target.result;
558
+ const storeName = `events_${eventType}`;
559
+ if (!db.objectStoreNames.contains(storeName)) {
560
+ db.createObjectStore(storeName, { keyPath: 'id' });
561
+ }
562
+ };
563
+
564
+ request.onsuccess = () => {
565
+ this.db = request.result;
566
+ resolve();
567
+ };
568
+
569
+ request.onerror = () => reject(request.error);
570
+ });
571
+ }
572
+
573
+ async getEvent(type, id) {
574
+ if (!this.db) throw new Error('IndexedDBAdapter not initialized');
575
+
576
+ const storeName = `events_${type}`;
577
+ if (!this.db.objectStoreNames.contains(storeName)) {
578
+ return null;
579
+ }
580
+
581
+ return new Promise((resolve, reject) => {
582
+ const tx = this.db.transaction(storeName, 'readonly');
583
+ const store = tx.objectStore(storeName);
584
+ const request = store.get(id);
585
+
586
+ request.onsuccess = () => resolve(request.result || null);
587
+ request.onerror = () => reject(request.error);
588
+ });
589
+ }
590
+
591
+ async queryEvents(type, options = {}) {
592
+ if (!this.db) throw new Error('IndexedDBAdapter not initialized');
593
+
594
+ const storeName = `events_${type}`;
595
+ if (!this.db.objectStoreNames.contains(storeName)) {
596
+ return { events: [], total: 0, hasMore: false };
597
+ }
598
+
599
+ return new Promise((resolve, reject) => {
600
+ const tx = this.db.transaction(storeName, 'readonly');
601
+ const store = tx.objectStore(storeName);
602
+
603
+ // Get all events (we'll filter/sort in memory for complex queries)
604
+ // For simple indexed queries, we could use IDB indexes, but this is more flexible
605
+ const request = store.getAll();
606
+
607
+ request.onsuccess = () => {
608
+ let events = request.result;
609
+
610
+ // Apply where filters
611
+ if (options.where) {
612
+ events = events.filter(event => this._matchesWhere(event, options.where));
613
+ }
614
+
615
+ const total = events.length;
616
+
617
+ // Sort
618
+ const orderBy = options.orderBy || 'blockNumber';
619
+ const order = options.order || 'desc';
620
+ events.sort((a, b) => {
621
+ const aVal = this._getNestedValue(a, orderBy);
622
+ const bVal = this._getNestedValue(b, orderBy);
623
+ if (aVal < bVal) return order === 'asc' ? -1 : 1;
624
+ if (aVal > bVal) return order === 'asc' ? 1 : -1;
625
+ return 0;
626
+ });
627
+
628
+ // Pagination
629
+ const offset = options.offset || 0;
630
+ const limit = options.limit || 100;
631
+ const paged = events.slice(offset, offset + limit);
632
+
633
+ resolve({
634
+ events: paged,
635
+ total,
636
+ hasMore: offset + limit < total
637
+ });
638
+ };
639
+
640
+ request.onerror = () => reject(request.error);
641
+ });
642
+ }
643
+
644
+ async countEvents(type, where) {
645
+ if (!this.db) throw new Error('IndexedDBAdapter not initialized');
646
+
647
+ const storeName = `events_${type}`;
648
+ if (!this.db.objectStoreNames.contains(storeName)) {
649
+ return 0;
650
+ }
651
+
652
+ return new Promise((resolve, reject) => {
653
+ const tx = this.db.transaction(storeName, 'readonly');
654
+ const store = tx.objectStore(storeName);
655
+
656
+ if (!where) {
657
+ const request = store.count();
658
+ request.onsuccess = () => resolve(request.result);
659
+ request.onerror = () => reject(request.error);
660
+ } else {
661
+ // Need to filter manually
662
+ const request = store.getAll();
663
+ request.onsuccess = () => {
664
+ const count = request.result.filter(e => this._matchesWhere(e, where)).length;
665
+ resolve(count);
666
+ };
667
+ request.onerror = () => reject(request.error);
668
+ }
669
+ });
670
+ }
671
+
672
+ async deleteEvents(type, ids) {
673
+ if (!this.db) throw new Error('IndexedDBAdapter not initialized');
674
+
675
+ const storeName = `events_${type}`;
676
+ if (!this.db.objectStoreNames.contains(storeName)) return;
677
+
678
+ return new Promise((resolve, reject) => {
679
+ const tx = this.db.transaction(storeName, 'readwrite');
680
+ const store = tx.objectStore(storeName);
681
+
682
+ for (const id of ids) {
683
+ store.delete(id);
684
+ }
685
+
686
+ tx.oncomplete = () => resolve();
687
+ tx.onerror = () => reject(tx.error);
688
+ });
689
+ }
690
+
691
+ async deleteEventsAfterBlock(blockNumber) {
692
+ if (!this.db) throw new Error('IndexedDBAdapter not initialized');
693
+
694
+ const storeNames = Array.from(this.db.objectStoreNames)
695
+ .filter(name => name.startsWith('events_'));
696
+
697
+ for (const storeName of storeNames) {
698
+ await this._deleteEventsAfterBlockInStore(storeName, blockNumber);
699
+ }
700
+ }
701
+
702
+ async _deleteEventsAfterBlockInStore(storeName, blockNumber) {
703
+ return new Promise((resolve, reject) => {
704
+ const tx = this.db.transaction(storeName, 'readwrite');
705
+ const store = tx.objectStore(storeName);
706
+ const index = store.index('blockNumber');
707
+ const range = IDBKeyRange.lowerBound(blockNumber, true); // exclusive
708
+
709
+ const request = index.openCursor(range);
710
+ request.onsuccess = (event) => {
711
+ const cursor = event.target.result;
712
+ if (cursor) {
713
+ cursor.delete();
714
+ cursor.continue();
715
+ }
716
+ };
717
+
718
+ tx.oncomplete = () => resolve();
719
+ tx.onerror = () => reject(tx.error);
720
+ });
721
+ }
722
+
723
+ async getSyncState() {
724
+ if (!this.db) throw new Error('IndexedDBAdapter not initialized');
725
+
726
+ return new Promise((resolve, reject) => {
727
+ const tx = this.db.transaction('syncState', 'readonly');
728
+ const store = tx.objectStore('syncState');
729
+ const request = store.get('current');
730
+
731
+ request.onsuccess = () => resolve(request.result || null);
732
+ request.onerror = () => reject(request.error);
733
+ });
734
+ }
735
+
736
+ async setSyncState(state) {
737
+ if (!this.db) throw new Error('IndexedDBAdapter not initialized');
738
+
739
+ return new Promise((resolve, reject) => {
740
+ const tx = this.db.transaction('syncState', 'readwrite');
741
+ const store = tx.objectStore('syncState');
742
+ const request = store.put({ id: 'current', ...state });
743
+
744
+ request.onsuccess = () => resolve();
745
+ request.onerror = () => reject(request.error);
746
+ });
747
+ }
748
+
749
+ async getVersion() {
750
+ if (!this.db) return 0;
751
+ return this.db.version;
752
+ }
753
+
754
+ async setVersion(version) {
755
+ // Version is managed by IndexedDB upgrade mechanism
756
+ // This is a no-op, included for interface compliance
757
+ }
758
+
759
+ async clear() {
760
+ if (!this.db) return;
761
+
762
+ const storeNames = Array.from(this.db.objectStoreNames);
763
+
764
+ return new Promise((resolve, reject) => {
765
+ const tx = this.db.transaction(storeNames, 'readwrite');
766
+
767
+ for (const storeName of storeNames) {
768
+ tx.objectStore(storeName).clear();
769
+ }
770
+
771
+ tx.oncomplete = () => resolve();
772
+ tx.onerror = () => reject(tx.error);
773
+ });
774
+ }
775
+
776
+ async close() {
777
+ if (this.db) {
778
+ this.db.close();
779
+ this.db = null;
780
+ }
781
+ }
782
+
783
+ // Same helpers as MemoryAdapter
784
+ _matchesWhere(event, where) {
785
+ for (const [key, value] of Object.entries(where)) {
786
+ const eventValue = this._getNestedValue(event, key);
787
+
788
+ if (Array.isArray(value)) {
789
+ if (!value.includes(eventValue)) return false;
790
+ } else if (typeof value === 'object' && value !== null) {
791
+ if (value.$gt !== undefined && !(eventValue > value.$gt)) return false;
792
+ if (value.$gte !== undefined && !(eventValue >= value.$gte)) return false;
793
+ if (value.$lt !== undefined && !(eventValue < value.$lt)) return false;
794
+ if (value.$lte !== undefined && !(eventValue <= value.$lte)) return false;
795
+ if (value.$ne !== undefined && eventValue === value.$ne) return false;
796
+ } else {
797
+ const normalizedEvent = typeof eventValue === 'string' ? eventValue.toLowerCase() : eventValue;
798
+ const normalizedValue = typeof value === 'string' ? value.toLowerCase() : value;
799
+ if (normalizedEvent !== normalizedValue) return false;
800
+ }
801
+ }
802
+ return true;
803
+ }
804
+
805
+ _getNestedValue(obj, path) {
806
+ if (path.startsWith('indexed.')) {
807
+ return obj.indexed?.[path.slice(8)];
808
+ }
809
+ if (path.startsWith('data.')) {
810
+ return obj.data?.[path.slice(5)];
811
+ }
812
+
813
+ if (obj.indexed?.[path] !== undefined) return obj.indexed[path];
814
+ if (obj.data?.[path] !== undefined) return obj.data[path];
815
+ return obj[path];
816
+ }
817
+ }
818
+
819
+ export default IndexedDBAdapter;
820
+ ```
821
+
822
+ **Step 2: Commit**
823
+
824
+ ```bash
825
+ git add src/storage/IndexedDBAdapter.js
826
+ git commit -m "feat(storage): add IndexedDBAdapter for persistent event storage"
827
+ ```
828
+
829
+ ---
830
+
831
+ ## Task 4: Storage Index Export
832
+
833
+ **Files:**
834
+ - Create: `src/storage/index.js`
835
+
836
+ **Step 1: Create storage module exports**
837
+
838
+ ```javascript
839
+ // src/storage/index.js
840
+
841
+ import StorageAdapter from './StorageAdapter.js';
842
+ import IndexedDBAdapter from './IndexedDBAdapter.js';
843
+ import MemoryAdapter from './MemoryAdapter.js';
844
+
845
+ /**
846
+ * Select appropriate storage adapter based on config and environment.
847
+ * @param {Object} config - Persistence configuration
848
+ * @returns {StorageAdapter}
849
+ */
850
+ function selectAdapter(config) {
851
+ // Explicit memory mode
852
+ if (config?.type === 'memory') {
853
+ return new MemoryAdapter();
854
+ }
855
+
856
+ // Check IndexedDB availability
857
+ if (typeof indexedDB !== 'undefined') {
858
+ return new IndexedDBAdapter();
859
+ }
860
+
861
+ // Fallback to memory
862
+ console.warn('[EventIndexer] IndexedDB not available, falling back to memory storage');
863
+ return new MemoryAdapter();
864
+ }
865
+
866
+ export {
867
+ StorageAdapter,
868
+ IndexedDBAdapter,
869
+ MemoryAdapter,
870
+ selectAdapter
871
+ };
872
+ ```
873
+
874
+ **Step 2: Commit**
875
+
876
+ ```bash
877
+ git add src/storage/index.js
878
+ git commit -m "feat(storage): add storage module exports with adapter selection"
879
+ ```
880
+
881
+ ---
882
+
883
+ ## Task 5: Query Engine
884
+
885
+ **Files:**
886
+ - Create: `src/indexer/QueryEngine.js`
887
+
888
+ **Step 1: Implement QueryEngine**
889
+
890
+ ```javascript
891
+ // src/indexer/QueryEngine.js
892
+
893
+ /**
894
+ * Query engine for EventIndexer.
895
+ * Provides the Events API for direct event access.
896
+ */
897
+ class QueryEngine {
898
+ constructor(storage, eventBus) {
899
+ this.storage = storage;
900
+ this.eventBus = eventBus;
901
+ this.subscriptions = new Map(); // subscriptionId -> { eventTypes, callback, where }
902
+ this.nextSubscriptionId = 1;
903
+ }
904
+
905
+ /**
906
+ * Query events with filters.
907
+ * @param {string} eventName - Event type to query
908
+ * @param {QueryOptions} options - Query options
909
+ * @returns {Promise<QueryResult>}
910
+ */
911
+ async query(eventName, options = {}) {
912
+ return this.storage.queryEvents(eventName, {
913
+ where: options.where,
914
+ orderBy: options.orderBy || 'blockNumber',
915
+ order: options.order || 'desc',
916
+ limit: options.limit || 100,
917
+ offset: options.offset || 0
918
+ });
919
+ }
920
+
921
+ /**
922
+ * Get single event by ID.
923
+ * @param {string} eventName - Event type
924
+ * @param {string} id - Event ID (txHash-logIndex)
925
+ * @returns {Promise<IndexedEvent|null>}
926
+ */
927
+ async get(eventName, id) {
928
+ return this.storage.getEvent(eventName, id);
929
+ }
930
+
931
+ /**
932
+ * Subscribe to new events.
933
+ * @param {string|string[]} eventNames - Event type(s) to subscribe to
934
+ * @param {Function} callback - Called with new events
935
+ * @returns {Function} Unsubscribe function
936
+ */
937
+ subscribe(eventNames, callback) {
938
+ const types = Array.isArray(eventNames) ? eventNames : [eventNames];
939
+ const subscriptionId = this.nextSubscriptionId++;
940
+
941
+ this.subscriptions.set(subscriptionId, {
942
+ eventTypes: new Set(types),
943
+ callback
944
+ });
945
+
946
+ // Return unsubscribe function
947
+ return () => {
948
+ this.subscriptions.delete(subscriptionId);
949
+ };
950
+ }
951
+
952
+ /**
953
+ * Count events matching filter.
954
+ * @param {string} eventName - Event type
955
+ * @param {Object} where - Filter conditions
956
+ * @returns {Promise<number>}
957
+ */
958
+ async count(eventName, where) {
959
+ return this.storage.countEvents(eventName, where);
960
+ }
961
+
962
+ /**
963
+ * Called by SyncEngine when new events are indexed.
964
+ * Notifies subscribers.
965
+ * @param {string} eventType - Event type
966
+ * @param {IndexedEvent[]} events - New events
967
+ */
968
+ notifyNewEvents(eventType, events) {
969
+ for (const subscription of this.subscriptions.values()) {
970
+ if (subscription.eventTypes.has(eventType) || subscription.eventTypes.has('*')) {
971
+ try {
972
+ subscription.callback(events);
973
+ } catch (error) {
974
+ console.error('[QueryEngine] Subscription callback error:', error);
975
+ }
976
+ }
977
+ }
978
+ }
979
+
980
+ /**
981
+ * Get all events for entity resolution (used by EntityResolver).
982
+ * Returns a lightweight checker interface.
983
+ * @param {Object} relatedWhere - Filter for related events
984
+ * @returns {Promise<EventChecker>}
985
+ */
986
+ async getEventChecker(relatedWhere = {}) {
987
+ // Pre-fetch all potentially related events
988
+ const eventCache = new Map();
989
+
990
+ return {
991
+ has: (eventName, where = {}) => {
992
+ const events = this._getCachedEvents(eventCache, eventName);
993
+ return events.some(e => this._matchesWhere(e, { ...relatedWhere, ...where }));
994
+ },
995
+
996
+ get: (eventName, where = {}) => {
997
+ const events = this._getCachedEvents(eventCache, eventName);
998
+ return events.filter(e => this._matchesWhere(e, { ...relatedWhere, ...where }));
999
+ },
1000
+
1001
+ count: (eventName, where = {}) => {
1002
+ const events = this._getCachedEvents(eventCache, eventName);
1003
+ return events.filter(e => this._matchesWhere(e, { ...relatedWhere, ...where })).length;
1004
+ },
1005
+
1006
+ // Async method to prefetch events for a set of keys
1007
+ prefetch: async (eventNames) => {
1008
+ for (const eventName of eventNames) {
1009
+ if (!eventCache.has(eventName)) {
1010
+ const result = await this.storage.queryEvents(eventName, { limit: 10000 });
1011
+ eventCache.set(eventName, result.events);
1012
+ }
1013
+ }
1014
+ }
1015
+ };
1016
+ }
1017
+
1018
+ _getCachedEvents(cache, eventName) {
1019
+ return cache.get(eventName) || [];
1020
+ }
1021
+
1022
+ _matchesWhere(event, where) {
1023
+ for (const [key, value] of Object.entries(where)) {
1024
+ const eventValue = this._getNestedValue(event, key);
1025
+ const normalizedEvent = typeof eventValue === 'string' ? eventValue.toLowerCase() : eventValue;
1026
+ const normalizedValue = typeof value === 'string' ? value.toLowerCase() : value;
1027
+ if (normalizedEvent !== normalizedValue) return false;
1028
+ }
1029
+ return true;
1030
+ }
1031
+
1032
+ _getNestedValue(obj, path) {
1033
+ if (obj.indexed?.[path] !== undefined) return obj.indexed[path];
1034
+ if (obj.data?.[path] !== undefined) return obj.data[path];
1035
+ return obj[path];
1036
+ }
1037
+ }
1038
+
1039
+ export default QueryEngine;
1040
+ ```
1041
+
1042
+ **Step 2: Commit**
1043
+
1044
+ ```bash
1045
+ git add src/indexer/QueryEngine.js
1046
+ git commit -m "feat(indexer): add QueryEngine for event queries and subscriptions"
1047
+ ```
1048
+
1049
+ ---
1050
+
1051
+ ## Task 6: Sync Engine
1052
+
1053
+ **Files:**
1054
+ - Create: `src/indexer/SyncEngine.js`
1055
+
1056
+ **Step 1: Implement SyncEngine**
1057
+
1058
+ ```javascript
1059
+ // src/indexer/SyncEngine.js
1060
+
1061
+ /**
1062
+ * Sync engine for EventIndexer.
1063
+ * Handles historical sync, real-time updates, and reorg recovery.
1064
+ */
1065
+ class SyncEngine {
1066
+ constructor(storage, queryEngine, eventBus, config = {}) {
1067
+ this.storage = storage;
1068
+ this.queryEngine = queryEngine;
1069
+ this.eventBus = eventBus;
1070
+
1071
+ // Configuration with defaults
1072
+ this.batchSize = config.batchSize || 2000;
1073
+ this.confirmations = config.confirmations || 2;
1074
+ this.realTimeEnabled = config.realTimeEnabled !== false;
1075
+ this.retryAttempts = config.retryAttempts || 3;
1076
+ this.retryDelay = config.retryDelay || 1000;
1077
+ this.pollInterval = config.pollInterval || 12000;
1078
+ this.reorgTracking = config.reorgTracking || false; // Full reorg tracking (optional)
1079
+ this.reorgDepth = config.reorgDepth || 100; // How many blocks to track
1080
+
1081
+ // State
1082
+ this.provider = null;
1083
+ this.contract = null;
1084
+ this.eventTypes = [];
1085
+ this.deployBlock = 0;
1086
+ this.chainId = null;
1087
+
1088
+ this.state = 'initializing'; // initializing | syncing | synced | paused | error
1089
+ this.currentBlock = 0;
1090
+ this.latestBlock = 0;
1091
+ this.eventsIndexed = 0;
1092
+ this.lastSyncTime = null;
1093
+ this.error = null;
1094
+
1095
+ // Real-time sync
1096
+ this.eventListeners = [];
1097
+ this.pollTimer = null;
1098
+ this.isPaused = false;
1099
+
1100
+ // Reorg tracking (optional)
1101
+ this.recentBlocks = new Map(); // blockNumber -> blockHash
1102
+ }
1103
+
1104
+ /**
1105
+ * Initialize sync engine.
1106
+ * @param {Object} params
1107
+ * @param {ethers.Provider} params.provider - Ethereum provider
1108
+ * @param {ethers.Contract} params.contract - Contract instance
1109
+ * @param {Object[]} params.eventTypes - Event types from ABI
1110
+ * @param {number} params.deployBlock - Starting block
1111
+ * @param {number} params.chainId - Chain ID
1112
+ */
1113
+ async initialize({ provider, contract, eventTypes, deployBlock, chainId }) {
1114
+ this.provider = provider;
1115
+ this.contract = contract;
1116
+ this.eventTypes = eventTypes;
1117
+ this.deployBlock = deployBlock;
1118
+ this.chainId = chainId;
1119
+
1120
+ // Validate chain ID matches stored data
1121
+ await this._validateChainId();
1122
+
1123
+ // Load sync state
1124
+ const syncState = await this.storage.getSyncState();
1125
+ if (syncState) {
1126
+ this.currentBlock = syncState.lastSyncedBlock;
1127
+ this.eventsIndexed = Object.values(syncState.eventCounts || {}).reduce((a, b) => a + b, 0);
1128
+ this.lastSyncTime = syncState.lastSyncTime;
1129
+ } else {
1130
+ this.currentBlock = this.deployBlock - 1;
1131
+ }
1132
+ }
1133
+
1134
+ async _validateChainId() {
1135
+ const syncState = await this.storage.getSyncState();
1136
+ if (syncState && syncState.chainId !== this.chainId) {
1137
+ console.warn('[SyncEngine] Chain ID mismatch, clearing data');
1138
+ await this.storage.clear();
1139
+ }
1140
+ }
1141
+
1142
+ /**
1143
+ * Start syncing.
1144
+ */
1145
+ async start() {
1146
+ if (this.state === 'synced' || this.state === 'syncing') return;
1147
+
1148
+ try {
1149
+ // Historical sync
1150
+ await this._syncHistorical();
1151
+
1152
+ // Start real-time sync
1153
+ if (this.realTimeEnabled && !this.isPaused) {
1154
+ await this._startRealTimeSync();
1155
+ }
1156
+ } catch (error) {
1157
+ this.state = 'error';
1158
+ this.error = error;
1159
+ this.eventBus.emit('indexer:error', {
1160
+ code: 'SYNC_ERROR',
1161
+ message: error.message,
1162
+ cause: error,
1163
+ recoverable: true
1164
+ });
1165
+ }
1166
+ }
1167
+
1168
+ async _syncHistorical() {
1169
+ this.state = 'syncing';
1170
+ this.latestBlock = await this.provider.getBlockNumber();
1171
+
1172
+ // Account for confirmations
1173
+ const targetBlock = this.latestBlock - this.confirmations;
1174
+
1175
+ if (this.currentBlock >= targetBlock) {
1176
+ this.state = 'synced';
1177
+ this.eventBus.emit('indexer:syncComplete', {
1178
+ eventsIndexed: this.eventsIndexed,
1179
+ duration: 0
1180
+ });
1181
+ return;
1182
+ }
1183
+
1184
+ const startTime = Date.now();
1185
+ const startBlock = this.currentBlock + 1;
1186
+
1187
+ this.eventBus.emit('indexer:syncStarted', {
1188
+ fromBlock: startBlock,
1189
+ toBlock: targetBlock
1190
+ });
1191
+
1192
+ // Process in batches
1193
+ for (let fromBlock = startBlock; fromBlock <= targetBlock; fromBlock += this.batchSize) {
1194
+ if (this.isPaused) break;
1195
+
1196
+ const toBlock = Math.min(fromBlock + this.batchSize - 1, targetBlock);
1197
+
1198
+ await this._syncBatch(fromBlock, toBlock);
1199
+
1200
+ // Emit progress
1201
+ const progress = (toBlock - startBlock + 1) / (targetBlock - startBlock + 1);
1202
+ this.eventBus.emit('indexer:syncProgress', {
1203
+ progress,
1204
+ currentBlock: toBlock,
1205
+ latestBlock: this.latestBlock,
1206
+ eventsIndexed: this.eventsIndexed
1207
+ });
1208
+ }
1209
+
1210
+ if (!this.isPaused) {
1211
+ this.state = 'synced';
1212
+ this.lastSyncTime = Date.now();
1213
+
1214
+ this.eventBus.emit('indexer:syncComplete', {
1215
+ eventsIndexed: this.eventsIndexed,
1216
+ duration: Date.now() - startTime
1217
+ });
1218
+ }
1219
+ }
1220
+
1221
+ async _syncBatch(fromBlock, toBlock) {
1222
+ const events = await this._fetchEventsWithRetry(fromBlock, toBlock);
1223
+
1224
+ if (events.length > 0) {
1225
+ // Parse and index events
1226
+ const indexedEvents = events.map(e => this._parseEvent(e));
1227
+ await this.storage.putEvents(indexedEvents);
1228
+ this.eventsIndexed += indexedEvents.length;
1229
+
1230
+ // Notify query engine
1231
+ const eventsByType = this._groupByType(indexedEvents);
1232
+ for (const [type, typeEvents] of Object.entries(eventsByType)) {
1233
+ this.queryEngine.notifyNewEvents(type, typeEvents);
1234
+ }
1235
+ }
1236
+
1237
+ // Update sync state
1238
+ this.currentBlock = toBlock;
1239
+ await this._saveSyncState();
1240
+ }
1241
+
1242
+ async _fetchEventsWithRetry(fromBlock, toBlock) {
1243
+ for (let attempt = 0; attempt < this.retryAttempts; attempt++) {
1244
+ try {
1245
+ // Query all event types
1246
+ const allEvents = [];
1247
+ for (const eventType of this.eventTypes) {
1248
+ const filter = this.contract.filters[eventType.name]();
1249
+ const events = await this.contract.queryFilter(filter, fromBlock, toBlock);
1250
+ allEvents.push(...events);
1251
+ }
1252
+ return allEvents;
1253
+ } catch (error) {
1254
+ if (attempt === this.retryAttempts - 1) throw error;
1255
+
1256
+ // Exponential backoff
1257
+ const delay = this.retryDelay * Math.pow(2, attempt);
1258
+ await this._sleep(delay);
1259
+ }
1260
+ }
1261
+ return [];
1262
+ }
1263
+
1264
+ _parseEvent(rawEvent) {
1265
+ // Extract indexed and non-indexed params
1266
+ const indexed = {};
1267
+ const data = {};
1268
+
1269
+ if (rawEvent.args) {
1270
+ const eventFragment = rawEvent.eventFragment ||
1271
+ this.contract.interface.getEvent(rawEvent.event);
1272
+
1273
+ if (eventFragment) {
1274
+ for (let i = 0; i < eventFragment.inputs.length; i++) {
1275
+ const input = eventFragment.inputs[i];
1276
+ const value = rawEvent.args[i];
1277
+ const serialized = this._serializeValue(value);
1278
+
1279
+ if (input.indexed) {
1280
+ indexed[input.name] = serialized;
1281
+ }
1282
+ data[input.name] = serialized;
1283
+ }
1284
+ }
1285
+ }
1286
+
1287
+ return {
1288
+ id: `${rawEvent.transactionHash}-${rawEvent.logIndex}`,
1289
+ type: rawEvent.event,
1290
+ blockNumber: rawEvent.blockNumber,
1291
+ blockHash: rawEvent.blockHash,
1292
+ transactionHash: rawEvent.transactionHash,
1293
+ logIndex: rawEvent.logIndex,
1294
+ timestamp: 0, // Will be filled by block data if available
1295
+ data,
1296
+ indexed
1297
+ };
1298
+ }
1299
+
1300
+ _serializeValue(value) {
1301
+ if (value === null || value === undefined) return null;
1302
+ if (typeof value === 'bigint' || value._isBigNumber) {
1303
+ return value.toString();
1304
+ }
1305
+ if (Array.isArray(value)) {
1306
+ return value.map(v => this._serializeValue(v));
1307
+ }
1308
+ return value;
1309
+ }
1310
+
1311
+ _groupByType(events) {
1312
+ const grouped = {};
1313
+ for (const event of events) {
1314
+ if (!grouped[event.type]) {
1315
+ grouped[event.type] = [];
1316
+ }
1317
+ grouped[event.type].push(event);
1318
+ }
1319
+ return grouped;
1320
+ }
1321
+
1322
+ async _saveSyncState() {
1323
+ await this.storage.setSyncState({
1324
+ lastSyncedBlock: this.currentBlock,
1325
+ lastSyncTime: Date.now(),
1326
+ eventCounts: {}, // Could track per-type counts
1327
+ chainId: this.chainId
1328
+ });
1329
+ }
1330
+
1331
+ async _startRealTimeSync() {
1332
+ // Try WebSocket subscription first
1333
+ try {
1334
+ for (const eventType of this.eventTypes) {
1335
+ const filter = this.contract.filters[eventType.name]();
1336
+ const listener = async (...args) => {
1337
+ const event = args[args.length - 1]; // Last arg is the event object
1338
+ await this._handleNewEvent(event);
1339
+ };
1340
+
1341
+ this.contract.on(filter, listener);
1342
+ this.eventListeners.push({ filter, listener });
1343
+ }
1344
+ } catch (error) {
1345
+ // Fall back to polling
1346
+ console.warn('[SyncEngine] WebSocket subscription failed, falling back to polling');
1347
+ this._startPolling();
1348
+ }
1349
+
1350
+ // Also start polling as backup (handles missed events)
1351
+ this._startPolling();
1352
+ }
1353
+
1354
+ _startPolling() {
1355
+ if (this.pollTimer) return;
1356
+
1357
+ this.pollTimer = setInterval(async () => {
1358
+ if (this.isPaused) return;
1359
+
1360
+ try {
1361
+ const latestBlock = await this.provider.getBlockNumber();
1362
+ const targetBlock = latestBlock - this.confirmations;
1363
+
1364
+ if (targetBlock > this.currentBlock) {
1365
+ await this._syncBatch(this.currentBlock + 1, targetBlock);
1366
+ }
1367
+
1368
+ this.latestBlock = latestBlock;
1369
+
1370
+ // Optional: reorg tracking
1371
+ if (this.reorgTracking) {
1372
+ await this._checkForReorg(latestBlock);
1373
+ }
1374
+ } catch (error) {
1375
+ console.error('[SyncEngine] Polling error:', error);
1376
+ }
1377
+ }, this.pollInterval);
1378
+ }
1379
+
1380
+ async _handleNewEvent(rawEvent) {
1381
+ // Wait for confirmations
1382
+ if (this.confirmations > 0) {
1383
+ await this._waitForConfirmations(rawEvent.transactionHash);
1384
+ }
1385
+
1386
+ const indexedEvent = this._parseEvent(rawEvent);
1387
+ await this.storage.putEvents([indexedEvent]);
1388
+ this.eventsIndexed++;
1389
+
1390
+ // Update current block if this event is ahead
1391
+ if (indexedEvent.blockNumber > this.currentBlock) {
1392
+ this.currentBlock = indexedEvent.blockNumber;
1393
+ await this._saveSyncState();
1394
+ }
1395
+
1396
+ // Notify
1397
+ this.queryEngine.notifyNewEvents(indexedEvent.type, [indexedEvent]);
1398
+ this.eventBus.emit('indexer:newEvents', {
1399
+ eventType: indexedEvent.type,
1400
+ events: [indexedEvent]
1401
+ });
1402
+ }
1403
+
1404
+ async _waitForConfirmations(txHash) {
1405
+ let confirmations = 0;
1406
+ while (confirmations < this.confirmations) {
1407
+ const receipt = await this.provider.getTransactionReceipt(txHash);
1408
+ if (!receipt) {
1409
+ await this._sleep(1000);
1410
+ continue;
1411
+ }
1412
+
1413
+ const currentBlock = await this.provider.getBlockNumber();
1414
+ confirmations = currentBlock - receipt.blockNumber + 1;
1415
+
1416
+ if (confirmations < this.confirmations) {
1417
+ await this._sleep(1000);
1418
+ }
1419
+ }
1420
+ }
1421
+
1422
+ async _checkForReorg(currentBlock) {
1423
+ const block = await this.provider.getBlock(currentBlock);
1424
+ if (!block) return;
1425
+
1426
+ // Check if parent hash matches what we stored
1427
+ const storedParentHash = this.recentBlocks.get(currentBlock - 1);
1428
+ if (storedParentHash && storedParentHash !== block.parentHash) {
1429
+ // Reorg detected!
1430
+ await this._handleReorg(currentBlock - 1);
1431
+ }
1432
+
1433
+ // Track this block
1434
+ this.recentBlocks.set(currentBlock, block.hash);
1435
+
1436
+ // Prune old entries
1437
+ if (this.recentBlocks.size > this.reorgDepth) {
1438
+ const oldest = Math.min(...this.recentBlocks.keys());
1439
+ this.recentBlocks.delete(oldest);
1440
+ }
1441
+ }
1442
+
1443
+ async _handleReorg(forkBlock) {
1444
+ // Find common ancestor
1445
+ let checkBlock = forkBlock;
1446
+ while (checkBlock > this.deployBlock) {
1447
+ const storedHash = this.recentBlocks.get(checkBlock);
1448
+ const chainBlock = await this.provider.getBlock(checkBlock);
1449
+
1450
+ if (storedHash === chainBlock?.hash) break;
1451
+ checkBlock--;
1452
+ }
1453
+
1454
+ // Delete events from orphaned blocks
1455
+ await this.storage.deleteEventsAfterBlock(checkBlock);
1456
+
1457
+ // Update sync state
1458
+ this.currentBlock = checkBlock;
1459
+ await this._saveSyncState();
1460
+
1461
+ // Emit reorg event
1462
+ this.eventBus.emit('indexer:reorg', {
1463
+ forkBlock,
1464
+ commonAncestor: checkBlock,
1465
+ depth: forkBlock - checkBlock
1466
+ });
1467
+
1468
+ // Re-sync from common ancestor
1469
+ await this._syncHistorical();
1470
+ }
1471
+
1472
+ /**
1473
+ * Pause real-time sync.
1474
+ */
1475
+ pause() {
1476
+ this.isPaused = true;
1477
+ this.state = 'paused';
1478
+ this.eventBus.emit('indexer:paused', {});
1479
+ }
1480
+
1481
+ /**
1482
+ * Resume real-time sync.
1483
+ */
1484
+ resume() {
1485
+ this.isPaused = false;
1486
+ this.start();
1487
+ this.eventBus.emit('indexer:resumed', {});
1488
+ }
1489
+
1490
+ /**
1491
+ * Force re-sync from block.
1492
+ * @param {number} fromBlock - Block to start from (default: deployBlock)
1493
+ */
1494
+ async resync(fromBlock) {
1495
+ this.pause();
1496
+
1497
+ if (fromBlock !== undefined) {
1498
+ this.currentBlock = fromBlock - 1;
1499
+ } else {
1500
+ this.currentBlock = this.deployBlock - 1;
1501
+ }
1502
+
1503
+ await this.storage.clear();
1504
+ await this._saveSyncState();
1505
+
1506
+ this.eventsIndexed = 0;
1507
+ this.resume();
1508
+ }
1509
+
1510
+ /**
1511
+ * Get current sync status.
1512
+ * @returns {SyncStatus}
1513
+ */
1514
+ getStatus() {
1515
+ return {
1516
+ state: this.state,
1517
+ currentBlock: this.currentBlock,
1518
+ latestBlock: this.latestBlock,
1519
+ progress: this.latestBlock > 0
1520
+ ? Math.min(1, (this.currentBlock - this.deployBlock) / (this.latestBlock - this.deployBlock))
1521
+ : 0,
1522
+ eventsIndexed: this.eventsIndexed,
1523
+ lastSyncTime: this.lastSyncTime,
1524
+ error: this.error
1525
+ };
1526
+ }
1527
+
1528
+ /**
1529
+ * Stop sync and cleanup.
1530
+ */
1531
+ async stop() {
1532
+ this.isPaused = true;
1533
+
1534
+ // Remove event listeners
1535
+ for (const { filter, listener } of this.eventListeners) {
1536
+ this.contract.off(filter, listener);
1537
+ }
1538
+ this.eventListeners = [];
1539
+
1540
+ // Stop polling
1541
+ if (this.pollTimer) {
1542
+ clearInterval(this.pollTimer);
1543
+ this.pollTimer = null;
1544
+ }
1545
+ }
1546
+
1547
+ _sleep(ms) {
1548
+ return new Promise(resolve => setTimeout(resolve, ms));
1549
+ }
1550
+ }
1551
+
1552
+ export default SyncEngine;
1553
+ ```
1554
+
1555
+ **Step 2: Commit**
1556
+
1557
+ ```bash
1558
+ git add src/indexer/SyncEngine.js
1559
+ git commit -m "feat(indexer): add SyncEngine for historical and real-time sync"
1560
+ ```
1561
+
1562
+ ---
1563
+
1564
+ ## Task 7: Entity Resolver
1565
+
1566
+ **Files:**
1567
+ - Create: `src/indexer/EntityResolver.js`
1568
+
1569
+ **Step 1: Implement EntityResolver**
1570
+
1571
+ ```javascript
1572
+ // src/indexer/EntityResolver.js
1573
+
1574
+ /**
1575
+ * Entity resolver for EventIndexer.
1576
+ * Provides the Entities API for domain-level queries.
1577
+ */
1578
+ class EntityResolver {
1579
+ constructor(queryEngine, entityDefinitions = {}) {
1580
+ this.queryEngine = queryEngine;
1581
+ this.definitions = entityDefinitions;
1582
+ this.entityAPIs = {};
1583
+
1584
+ // Create API for each entity
1585
+ for (const [name, definition] of Object.entries(entityDefinitions)) {
1586
+ this.entityAPIs[name] = this._createEntityAPI(name, definition);
1587
+ }
1588
+ }
1589
+
1590
+ /**
1591
+ * Get entity API by name.
1592
+ * @param {string} name - Entity name
1593
+ * @returns {EntityQueryable}
1594
+ */
1595
+ getEntity(name) {
1596
+ return this.entityAPIs[name];
1597
+ }
1598
+
1599
+ /**
1600
+ * Get all entity APIs (for proxy access).
1601
+ * @returns {Object}
1602
+ */
1603
+ getAllEntities() {
1604
+ return this.entityAPIs;
1605
+ }
1606
+
1607
+ _createEntityAPI(name, definition) {
1608
+ const self = this;
1609
+
1610
+ return {
1611
+ /**
1612
+ * Query entities.
1613
+ * @param {EntityWhereClause} where - Filter conditions
1614
+ * @returns {Promise<Entity[]>}
1615
+ */
1616
+ async query(where = {}) {
1617
+ return self._queryEntities(name, definition, where);
1618
+ },
1619
+
1620
+ /**
1621
+ * Get single entity by key.
1622
+ * @param {string|number} key - Entity key value
1623
+ * @returns {Promise<Entity|null>}
1624
+ */
1625
+ async get(key) {
1626
+ const entities = await self._queryEntities(name, definition, {
1627
+ [definition.key]: key
1628
+ });
1629
+ return entities[0] || null;
1630
+ },
1631
+
1632
+ /**
1633
+ * Subscribe to entity changes.
1634
+ * @param {EntityWhereClause} where - Filter conditions
1635
+ * @param {Function} callback - Called when entities change
1636
+ * @returns {Function} Unsubscribe function
1637
+ */
1638
+ subscribe(where, callback) {
1639
+ // Subscribe to source event and related events
1640
+ const eventTypes = self._getRelevantEventTypes(definition);
1641
+
1642
+ return self.queryEngine.subscribe(eventTypes, async () => {
1643
+ const entities = await self._queryEntities(name, definition, where);
1644
+ callback(entities);
1645
+ });
1646
+ },
1647
+
1648
+ /**
1649
+ * Count entities.
1650
+ * @param {EntityWhereClause} where - Filter conditions
1651
+ * @returns {Promise<number>}
1652
+ */
1653
+ async count(where = {}) {
1654
+ const entities = await self._queryEntities(name, definition, where);
1655
+ return entities.length;
1656
+ }
1657
+ };
1658
+ }
1659
+
1660
+ async _queryEntities(name, definition, where = {}) {
1661
+ // Separate status filter from direct filters
1662
+ const statusFilter = where.status;
1663
+ const directFilters = { ...where };
1664
+ delete directFilters.status;
1665
+
1666
+ // Query source events
1667
+ const sourceResult = await this.queryEngine.query(definition.source, {
1668
+ where: directFilters,
1669
+ limit: 10000 // Get all matching source events
1670
+ });
1671
+
1672
+ // Get event checker for status/computed evaluation
1673
+ const eventChecker = await this.queryEngine.getEventChecker();
1674
+
1675
+ // Prefetch events needed for status evaluation
1676
+ const relevantEventTypes = this._getRelevantEventTypes(definition);
1677
+ await eventChecker.prefetch(relevantEventTypes);
1678
+
1679
+ // Build entities from source events
1680
+ const entities = [];
1681
+ for (const sourceEvent of sourceResult.events) {
1682
+ const entity = await this._buildEntity(name, definition, sourceEvent, eventChecker);
1683
+
1684
+ // Apply status filter
1685
+ if (statusFilter && entity.status !== statusFilter) {
1686
+ continue;
1687
+ }
1688
+
1689
+ entities.push(entity);
1690
+ }
1691
+
1692
+ return entities;
1693
+ }
1694
+
1695
+ async _buildEntity(name, definition, sourceEvent, eventChecker) {
1696
+ const entity = {
1697
+ _type: name,
1698
+ _sourceEvent: sourceEvent,
1699
+ ...sourceEvent.data
1700
+ };
1701
+
1702
+ // Add key field explicitly
1703
+ entity[definition.key] = sourceEvent.data[definition.key] || sourceEvent.indexed[definition.key];
1704
+
1705
+ // Evaluate status
1706
+ if (definition.status) {
1707
+ entity.status = this._evaluateStatus(entity, definition.status, eventChecker);
1708
+ }
1709
+
1710
+ // Evaluate computed fields
1711
+ if (definition.computed) {
1712
+ for (const [fieldName, computeFn] of Object.entries(definition.computed)) {
1713
+ try {
1714
+ entity[fieldName] = computeFn(entity, eventChecker);
1715
+ } catch (error) {
1716
+ console.error(`[EntityResolver] Error computing ${fieldName}:`, error);
1717
+ entity[fieldName] = null;
1718
+ }
1719
+ }
1720
+ }
1721
+
1722
+ // Resolve relations (lazy - only resolve when accessed)
1723
+ if (definition.relations) {
1724
+ for (const [relationName, relationDef] of Object.entries(definition.relations)) {
1725
+ Object.defineProperty(entity, relationName, {
1726
+ get: () => this._resolveRelation(entity, relationDef),
1727
+ enumerable: true
1728
+ });
1729
+ }
1730
+ }
1731
+
1732
+ return entity;
1733
+ }
1734
+
1735
+ _evaluateStatus(entity, statusDef, eventChecker) {
1736
+ // Check each status condition in order
1737
+ for (const [statusName, condition] of Object.entries(statusDef)) {
1738
+ if (statusName === 'default') continue;
1739
+
1740
+ try {
1741
+ if (condition(entity, eventChecker)) {
1742
+ return statusName;
1743
+ }
1744
+ } catch (error) {
1745
+ console.error(`[EntityResolver] Error evaluating status ${statusName}:`, error);
1746
+ }
1747
+ }
1748
+
1749
+ return statusDef.default || 'unknown';
1750
+ }
1751
+
1752
+ async _resolveRelation(entity, relationDef) {
1753
+ const relatedEntity = this.entityAPIs[relationDef.entity];
1754
+ if (!relatedEntity) return null;
1755
+
1756
+ const foreignKeyValue = entity[relationDef.foreignKey];
1757
+ if (foreignKeyValue === undefined) return null;
1758
+
1759
+ if (relationDef.type === 'many') {
1760
+ return relatedEntity.query({ [relationDef.foreignKey]: foreignKeyValue });
1761
+ } else {
1762
+ return relatedEntity.get(foreignKeyValue);
1763
+ }
1764
+ }
1765
+
1766
+ _getRelevantEventTypes(definition) {
1767
+ const types = new Set([definition.source]);
1768
+
1769
+ // Add events referenced in status conditions
1770
+ if (definition.status) {
1771
+ for (const condition of Object.values(definition.status)) {
1772
+ if (typeof condition === 'function') {
1773
+ // Try to extract event names from function source
1774
+ // This is a best-effort heuristic
1775
+ const fnStr = condition.toString();
1776
+ const matches = fnStr.match(/events\.has\(['"](\w+)['"]/g) || [];
1777
+ for (const match of matches) {
1778
+ const eventName = match.match(/['"](\w+)['"]/)?.[1];
1779
+ if (eventName) types.add(eventName);
1780
+ }
1781
+ }
1782
+ }
1783
+ }
1784
+
1785
+ return Array.from(types);
1786
+ }
1787
+ }
1788
+
1789
+ export default EntityResolver;
1790
+ ```
1791
+
1792
+ **Step 2: Commit**
1793
+
1794
+ ```bash
1795
+ git add src/indexer/EntityResolver.js
1796
+ git commit -m "feat(indexer): add EntityResolver for domain-level entity queries"
1797
+ ```
1798
+
1799
+ ---
1800
+
1801
+ ## Task 8: Patterns API
1802
+
1803
+ **Files:**
1804
+ - Create: `src/indexer/Patterns.js`
1805
+
1806
+ **Step 1: Implement Patterns**
1807
+
1808
+ ```javascript
1809
+ // src/indexer/Patterns.js
1810
+
1811
+ /**
1812
+ * Pre-built patterns for common dApp needs.
1813
+ * Zero-config solutions for activity feeds, leaderboards, etc.
1814
+ */
1815
+ class Patterns {
1816
+ constructor(queryEngine, entityResolver) {
1817
+ this.queryEngine = queryEngine;
1818
+ this.entityResolver = entityResolver;
1819
+ }
1820
+
1821
+ /**
1822
+ * User activity feed.
1823
+ * @param {string} address - User address
1824
+ * @param {Object} options - Options
1825
+ * @param {string[]} options.events - Event types to include
1826
+ * @param {string[]} options.userFields - Fields to match address against (auto-detected if omitted)
1827
+ * @param {number} options.limit - Max results
1828
+ * @param {number} options.offset - Skip N results
1829
+ * @returns {Promise<ActivityItem[]>}
1830
+ */
1831
+ async userActivity(address, options = {}) {
1832
+ const { events = [], userFields, limit = 50, offset = 0 } = options;
1833
+ const normalizedAddress = address.toLowerCase();
1834
+
1835
+ // Collect activity from all event types
1836
+ const allActivity = [];
1837
+
1838
+ for (const eventType of events) {
1839
+ const result = await this.queryEngine.query(eventType, {
1840
+ limit: 1000 // Get more to filter
1841
+ });
1842
+
1843
+ // Auto-detect user fields if not specified
1844
+ const fields = userFields || this._detectAddressFields(result.events[0]);
1845
+
1846
+ // Filter events involving this user
1847
+ for (const event of result.events) {
1848
+ const isUserEvent = fields.some(field => {
1849
+ const value = event.indexed[field] || event.data[field];
1850
+ return typeof value === 'string' && value.toLowerCase() === normalizedAddress;
1851
+ });
1852
+
1853
+ if (isUserEvent) {
1854
+ allActivity.push({
1855
+ type: event.type,
1856
+ timestamp: event.timestamp || event.blockNumber, // Fall back to block number
1857
+ blockNumber: event.blockNumber,
1858
+ transactionHash: event.transactionHash,
1859
+ data: event.data
1860
+ });
1861
+ }
1862
+ }
1863
+ }
1864
+
1865
+ // Sort by timestamp/block descending
1866
+ allActivity.sort((a, b) => (b.timestamp || b.blockNumber) - (a.timestamp || a.blockNumber));
1867
+
1868
+ // Paginate
1869
+ return allActivity.slice(offset, offset + limit);
1870
+ }
1871
+
1872
+ /**
1873
+ * Find refundable items (cancelled parent + unclaimed).
1874
+ * @param {Object} options - Options
1875
+ * @param {string} options.itemEvent - Event creating the item
1876
+ * @param {string} options.itemKey - Unique key field
1877
+ * @param {string} options.parentKey - Field linking to parent
1878
+ * @param {string} options.cancelEvent - Event that cancels parent
1879
+ * @param {string} options.claimEvent - Event that claims item
1880
+ * @param {string} options.userField - Field containing user address
1881
+ * @param {string} options.user - User address to filter
1882
+ * @returns {Promise<IndexedEvent[]>}
1883
+ */
1884
+ async refundable(options) {
1885
+ const {
1886
+ itemEvent,
1887
+ itemKey,
1888
+ parentKey,
1889
+ cancelEvent,
1890
+ claimEvent,
1891
+ userField,
1892
+ user
1893
+ } = options;
1894
+
1895
+ const normalizedUser = user.toLowerCase();
1896
+
1897
+ // Get all items for user
1898
+ const itemsResult = await this.queryEngine.query(itemEvent, { limit: 10000 });
1899
+ const userItems = itemsResult.events.filter(e => {
1900
+ const userValue = e.indexed[userField] || e.data[userField];
1901
+ return typeof userValue === 'string' && userValue.toLowerCase() === normalizedUser;
1902
+ });
1903
+
1904
+ // Get cancelled parents
1905
+ const cancelResult = await this.queryEngine.query(cancelEvent, { limit: 10000 });
1906
+ const cancelledParents = new Set(
1907
+ cancelResult.events.map(e => e.indexed[parentKey] || e.data[parentKey])
1908
+ );
1909
+
1910
+ // Get claimed items
1911
+ const claimResult = await this.queryEngine.query(claimEvent, { limit: 10000 });
1912
+ const claimedItems = new Set(
1913
+ claimResult.events.map(e => e.indexed[itemKey] || e.data[itemKey])
1914
+ );
1915
+
1916
+ // Filter: parent cancelled AND item not claimed
1917
+ return userItems.filter(item => {
1918
+ const itemKeyValue = item.indexed[itemKey] || item.data[itemKey];
1919
+ const parentKeyValue = item.indexed[parentKey] || item.data[parentKey];
1920
+
1921
+ return cancelledParents.has(parentKeyValue) && !claimedItems.has(itemKeyValue);
1922
+ });
1923
+ }
1924
+
1925
+ /**
1926
+ * Leaderboard aggregation.
1927
+ * @param {Object} options - Options
1928
+ * @param {string} options.event - Event to aggregate
1929
+ * @param {string} options.groupBy - Field to group by
1930
+ * @param {string} options.aggregate - 'count' or 'sum'
1931
+ * @param {string} options.sumField - Field to sum (if aggregate: 'sum')
1932
+ * @param {number} options.limit - Max results
1933
+ * @param {Object} options.where - Filter conditions
1934
+ * @returns {Promise<LeaderboardEntry[]>}
1935
+ */
1936
+ async leaderboard(options) {
1937
+ const { event, groupBy, aggregate, sumField, limit = 10, where } = options;
1938
+
1939
+ const result = await this.queryEngine.query(event, {
1940
+ where,
1941
+ limit: 10000
1942
+ });
1943
+
1944
+ // Group by field
1945
+ const groups = new Map();
1946
+ for (const e of result.events) {
1947
+ const key = e.indexed[groupBy] || e.data[groupBy];
1948
+ if (key === undefined) continue;
1949
+
1950
+ const normalizedKey = typeof key === 'string' ? key.toLowerCase() : key;
1951
+
1952
+ if (!groups.has(normalizedKey)) {
1953
+ groups.set(normalizedKey, { key, events: [] });
1954
+ }
1955
+ groups.get(normalizedKey).events.push(e);
1956
+ }
1957
+
1958
+ // Calculate aggregates
1959
+ const entries = [];
1960
+ for (const [normalizedKey, group] of groups) {
1961
+ let value;
1962
+ if (aggregate === 'count') {
1963
+ value = group.events.length;
1964
+ } else if (aggregate === 'sum') {
1965
+ value = group.events.reduce((sum, e) => {
1966
+ const fieldValue = e.indexed[sumField] || e.data[sumField];
1967
+ const num = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue;
1968
+ return sum + (isNaN(num) ? 0 : num);
1969
+ }, 0);
1970
+ }
1971
+
1972
+ entries.push({ key: group.key, value });
1973
+ }
1974
+
1975
+ // Sort by value descending
1976
+ entries.sort((a, b) => b.value - a.value);
1977
+
1978
+ // Add ranks and limit
1979
+ return entries.slice(0, limit).map((entry, index) => ({
1980
+ ...entry,
1981
+ rank: index + 1
1982
+ }));
1983
+ }
1984
+
1985
+ /**
1986
+ * Time-series aggregation.
1987
+ * @param {Object} options - Options
1988
+ * @param {string} options.event - Event to aggregate
1989
+ * @param {string} options.interval - 'hour' | 'day' | 'week'
1990
+ * @param {string} options.aggregate - 'count' or 'sum'
1991
+ * @param {string} options.sumField - Field to sum (if aggregate: 'sum')
1992
+ * @param {Object} options.where - Filter conditions
1993
+ * @param {number} options.periods - Number of periods (default: 30)
1994
+ * @returns {Promise<TimeSeriesPoint[]>}
1995
+ */
1996
+ async timeSeries(options) {
1997
+ const { event, interval, aggregate, sumField, where, periods = 30 } = options;
1998
+
1999
+ const result = await this.queryEngine.query(event, {
2000
+ where,
2001
+ limit: 100000
2002
+ });
2003
+
2004
+ // Calculate interval in milliseconds
2005
+ const intervalMs = {
2006
+ hour: 60 * 60 * 1000,
2007
+ day: 24 * 60 * 60 * 1000,
2008
+ week: 7 * 24 * 60 * 60 * 1000
2009
+ }[interval];
2010
+
2011
+ if (!intervalMs) {
2012
+ throw new Error(`Invalid interval: ${interval}`);
2013
+ }
2014
+
2015
+ // Group events by period
2016
+ const now = Date.now();
2017
+ const periodGroups = new Map();
2018
+
2019
+ // Initialize periods
2020
+ for (let i = 0; i < periods; i++) {
2021
+ const periodStart = now - (i + 1) * intervalMs;
2022
+ const periodKey = new Date(periodStart).toISOString();
2023
+ periodGroups.set(periodKey, []);
2024
+ }
2025
+
2026
+ // Assign events to periods
2027
+ for (const e of result.events) {
2028
+ const eventTime = e.timestamp ? e.timestamp * 1000 : now; // Assume timestamp is in seconds
2029
+ const periodsAgo = Math.floor((now - eventTime) / intervalMs);
2030
+
2031
+ if (periodsAgo >= 0 && periodsAgo < periods) {
2032
+ const periodStart = now - (periodsAgo + 1) * intervalMs;
2033
+ const periodKey = new Date(periodStart).toISOString();
2034
+
2035
+ if (periodGroups.has(periodKey)) {
2036
+ periodGroups.get(periodKey).push(e);
2037
+ }
2038
+ }
2039
+ }
2040
+
2041
+ // Calculate aggregates
2042
+ const points = [];
2043
+ for (const [period, events] of periodGroups) {
2044
+ let value;
2045
+ if (aggregate === 'count') {
2046
+ value = events.length;
2047
+ } else if (aggregate === 'sum') {
2048
+ value = events.reduce((sum, e) => {
2049
+ const fieldValue = e.indexed[sumField] || e.data[sumField];
2050
+ const num = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue;
2051
+ return sum + (isNaN(num) ? 0 : num);
2052
+ }, 0);
2053
+ }
2054
+
2055
+ points.push({ period, value });
2056
+ }
2057
+
2058
+ // Sort by period ascending (oldest first)
2059
+ points.sort((a, b) => new Date(a.period) - new Date(b.period));
2060
+
2061
+ return points;
2062
+ }
2063
+
2064
+ /**
2065
+ * Detect address fields in an event.
2066
+ * @param {IndexedEvent} event - Sample event
2067
+ * @returns {string[]} Field names that look like addresses
2068
+ */
2069
+ _detectAddressFields(event) {
2070
+ if (!event) return [];
2071
+
2072
+ const fields = [];
2073
+ const allFields = { ...event.indexed, ...event.data };
2074
+
2075
+ for (const [key, value] of Object.entries(allFields)) {
2076
+ if (typeof value === 'string' && value.match(/^0x[a-fA-F0-9]{40}$/)) {
2077
+ fields.push(key);
2078
+ }
2079
+ }
2080
+
2081
+ return fields;
2082
+ }
2083
+ }
2084
+
2085
+ export default Patterns;
2086
+ ```
2087
+
2088
+ **Step 2: Commit**
2089
+
2090
+ ```bash
2091
+ git add src/indexer/Patterns.js
2092
+ git commit -m "feat(indexer): add Patterns API for activity feeds, leaderboards, time series"
2093
+ ```
2094
+
2095
+ ---
2096
+
2097
+ ## Task 9: Indexer Module Export
2098
+
2099
+ **Files:**
2100
+ - Create: `src/indexer/index.js`
2101
+
2102
+ **Step 1: Create indexer module exports**
2103
+
2104
+ ```javascript
2105
+ // src/indexer/index.js
2106
+
2107
+ import QueryEngine from './QueryEngine.js';
2108
+ import SyncEngine from './SyncEngine.js';
2109
+ import EntityResolver from './EntityResolver.js';
2110
+ import Patterns from './Patterns.js';
2111
+
2112
+ export {
2113
+ QueryEngine,
2114
+ SyncEngine,
2115
+ EntityResolver,
2116
+ Patterns
2117
+ };
2118
+ ```
2119
+
2120
+ **Step 2: Commit**
2121
+
2122
+ ```bash
2123
+ git add src/indexer/index.js
2124
+ git commit -m "feat(indexer): add indexer module exports"
2125
+ ```
2126
+
2127
+ ---
2128
+
2129
+ ## Task 10: EventIndexer Service
2130
+
2131
+ **Files:**
2132
+ - Create: `src/services/EventIndexer.js`
2133
+
2134
+ **Step 1: Implement EventIndexer (main service)**
2135
+
2136
+ ```javascript
2137
+ // src/services/EventIndexer.js
2138
+
2139
+ import { selectAdapter } from '../storage/index.js';
2140
+ import { QueryEngine, SyncEngine, EntityResolver, Patterns } from '../indexer/index.js';
2141
+ import { ethers } from 'ethers';
2142
+
2143
+ /**
2144
+ * Error types for EventIndexer.
2145
+ */
2146
+ class EventIndexerError extends Error {
2147
+ constructor(message, code, cause) {
2148
+ super(message);
2149
+ this.name = 'EventIndexerError';
2150
+ this.code = code;
2151
+ this.cause = cause;
2152
+ }
2153
+ }
2154
+
2155
+ const ErrorCodes = {
2156
+ NOT_INITIALIZED: 'NOT_INITIALIZED',
2157
+ STORAGE_ERROR: 'STORAGE_ERROR',
2158
+ SYNC_ERROR: 'SYNC_ERROR',
2159
+ QUERY_ERROR: 'QUERY_ERROR',
2160
+ INVALID_CONFIG: 'INVALID_CONFIG',
2161
+ PROVIDER_ERROR: 'PROVIDER_ERROR',
2162
+ REORG_DETECTED: 'REORG_DETECTED'
2163
+ };
2164
+
2165
+ /**
2166
+ * EventIndexer - Client-side event indexing for dApps.
2167
+ *
2168
+ * Provides three levels of abstraction:
2169
+ * - Events: Raw indexed events with filtering
2170
+ * - Entities: Domain objects derived from events
2171
+ * - Patterns: Pre-built solutions (activity feeds, leaderboards)
2172
+ */
2173
+ class EventIndexer {
2174
+ /**
2175
+ * Create EventIndexer instance.
2176
+ * @param {EventBus} eventBus - microact EventBus instance
2177
+ * @param {Object} options - Options
2178
+ * @param {BlockchainService} options.blockchainService - For provider access (optional)
2179
+ */
2180
+ constructor(eventBus, options = {}) {
2181
+ if (!eventBus) {
2182
+ throw new EventIndexerError('EventBus is required', ErrorCodes.INVALID_CONFIG);
2183
+ }
2184
+
2185
+ this.eventBus = eventBus;
2186
+ this.blockchainService = options.blockchainService;
2187
+
2188
+ // Internal components (initialized in initialize())
2189
+ this.storage = null;
2190
+ this.queryEngine = null;
2191
+ this.syncEngine = null;
2192
+ this.entityResolver = null;
2193
+ this.patternsAPI = null;
2194
+ this.contract = null;
2195
+
2196
+ this.initialized = false;
2197
+ this.config = null;
2198
+ }
2199
+
2200
+ /**
2201
+ * Initialize the EventIndexer.
2202
+ * @param {EventIndexerConfig} config - Configuration
2203
+ */
2204
+ async initialize(config) {
2205
+ this._validateConfig(config);
2206
+ this.config = config;
2207
+
2208
+ try {
2209
+ // Get provider
2210
+ const provider = this._getProvider(config);
2211
+
2212
+ // Get chain ID
2213
+ const network = await provider.getNetwork();
2214
+ const chainId = Number(network.chainId);
2215
+
2216
+ // Extract event types from ABI
2217
+ const eventTypes = this._extractEventTypes(config.contract.abi);
2218
+
2219
+ // Initialize storage
2220
+ this.storage = selectAdapter(config.persistence);
2221
+ await this.storage.initialize({
2222
+ dbName: config.persistence?.dbName || `micro-web3-events-${chainId}`,
2223
+ version: config.persistence?.version || 1,
2224
+ eventTypes
2225
+ });
2226
+
2227
+ // Create contract instance
2228
+ this.contract = new ethers.Contract(
2229
+ config.contract.address,
2230
+ config.contract.abi,
2231
+ provider
2232
+ );
2233
+
2234
+ // Detect deploy block if not provided
2235
+ const deployBlock = config.contract.deployBlock || await this._detectDeployBlock(provider);
2236
+
2237
+ // Initialize components
2238
+ this.queryEngine = new QueryEngine(this.storage, this.eventBus);
2239
+
2240
+ this.syncEngine = new SyncEngine(
2241
+ this.storage,
2242
+ this.queryEngine,
2243
+ this.eventBus,
2244
+ config.sync || {}
2245
+ );
2246
+ await this.syncEngine.initialize({
2247
+ provider,
2248
+ contract: this.contract,
2249
+ eventTypes,
2250
+ deployBlock,
2251
+ chainId
2252
+ });
2253
+
2254
+ // Initialize entity resolver if entities defined
2255
+ if (config.entities) {
2256
+ this.entityResolver = new EntityResolver(this.queryEngine, config.entities);
2257
+ }
2258
+
2259
+ // Initialize patterns
2260
+ this.patternsAPI = new Patterns(this.queryEngine, this.entityResolver);
2261
+
2262
+ this.initialized = true;
2263
+ this.eventBus.emit('indexer:initialized', {});
2264
+
2265
+ // Start syncing
2266
+ await this.syncEngine.start();
2267
+
2268
+ } catch (error) {
2269
+ this.eventBus.emit('indexer:error', {
2270
+ code: ErrorCodes.STORAGE_ERROR,
2271
+ message: `Initialization failed: ${error.message}`,
2272
+ cause: error,
2273
+ recoverable: false
2274
+ });
2275
+ throw new EventIndexerError(
2276
+ `Failed to initialize EventIndexer: ${error.message}`,
2277
+ ErrorCodes.STORAGE_ERROR,
2278
+ error
2279
+ );
2280
+ }
2281
+ }
2282
+
2283
+ _validateConfig(config) {
2284
+ if (!config.contract) {
2285
+ throw new EventIndexerError('contract config is required', ErrorCodes.INVALID_CONFIG);
2286
+ }
2287
+ if (!config.contract.address) {
2288
+ throw new EventIndexerError('contract.address is required', ErrorCodes.INVALID_CONFIG);
2289
+ }
2290
+ if (!config.contract.abi || !Array.isArray(config.contract.abi)) {
2291
+ throw new EventIndexerError('contract.abi is required and must be an array', ErrorCodes.INVALID_CONFIG);
2292
+ }
2293
+ if (!config.provider && !this.blockchainService) {
2294
+ throw new EventIndexerError(
2295
+ 'Either provider in config or blockchainService in constructor is required',
2296
+ ErrorCodes.INVALID_CONFIG
2297
+ );
2298
+ }
2299
+ }
2300
+
2301
+ _getProvider(config) {
2302
+ if (config.provider) {
2303
+ return config.provider;
2304
+ }
2305
+ if (this.blockchainService?.getProvider) {
2306
+ return this.blockchainService.getProvider();
2307
+ }
2308
+ if (this.blockchainService?.provider) {
2309
+ return this.blockchainService.provider;
2310
+ }
2311
+ throw new EventIndexerError('No provider available', ErrorCodes.PROVIDER_ERROR);
2312
+ }
2313
+
2314
+ _extractEventTypes(abi) {
2315
+ const eventTypes = [];
2316
+
2317
+ for (const item of abi) {
2318
+ if (item.type === 'event') {
2319
+ const indexedParams = item.inputs
2320
+ .filter(input => input.indexed)
2321
+ .map(input => input.name);
2322
+
2323
+ eventTypes.push({
2324
+ name: item.name,
2325
+ inputs: item.inputs,
2326
+ indexedParams
2327
+ });
2328
+ }
2329
+ }
2330
+
2331
+ return eventTypes;
2332
+ }
2333
+
2334
+ async _detectDeployBlock(provider) {
2335
+ // Default to 0 if we can't detect
2336
+ // In production, you'd want to either require this or use a heuristic
2337
+ console.warn('[EventIndexer] No deployBlock specified, starting from block 0');
2338
+ return 0;
2339
+ }
2340
+
2341
+ /**
2342
+ * Events API - Level 1 (Low-level)
2343
+ * Direct access to raw indexed events.
2344
+ */
2345
+ get events() {
2346
+ this._checkInitialized();
2347
+ return {
2348
+ query: (eventName, options) => this.queryEngine.query(eventName, options),
2349
+ get: (eventName, id) => this.queryEngine.get(eventName, id),
2350
+ subscribe: (eventNames, callback) => this.queryEngine.subscribe(eventNames, callback),
2351
+ count: (eventName, where) => this.queryEngine.count(eventName, where)
2352
+ };
2353
+ }
2354
+
2355
+ /**
2356
+ * Entities API - Level 2 (Domain-level)
2357
+ * Access to domain objects derived from events.
2358
+ * Returns proxy for entity.EntityName access pattern.
2359
+ */
2360
+ get entities() {
2361
+ this._checkInitialized();
2362
+
2363
+ if (!this.entityResolver) {
2364
+ console.warn('[EventIndexer] No entities configured');
2365
+ return {};
2366
+ }
2367
+
2368
+ return new Proxy({}, {
2369
+ get: (target, prop) => {
2370
+ return this.entityResolver.getEntity(prop);
2371
+ }
2372
+ });
2373
+ }
2374
+
2375
+ /**
2376
+ * Patterns API - Level 3 (Zero-config)
2377
+ * Pre-built solutions for common needs.
2378
+ */
2379
+ get patterns() {
2380
+ this._checkInitialized();
2381
+ return this.patternsAPI;
2382
+ }
2383
+
2384
+ /**
2385
+ * Sync API - Control sync behavior.
2386
+ */
2387
+ get sync() {
2388
+ this._checkInitialized();
2389
+ return {
2390
+ getStatus: () => this.syncEngine.getStatus(),
2391
+ resync: (fromBlock) => this.syncEngine.resync(fromBlock),
2392
+ clear: () => this.storage.clear(),
2393
+ pause: () => this.syncEngine.pause(),
2394
+ resume: () => this.syncEngine.resume()
2395
+ };
2396
+ }
2397
+
2398
+ _checkInitialized() {
2399
+ if (!this.initialized) {
2400
+ throw new EventIndexerError(
2401
+ 'EventIndexer not initialized. Call initialize() first.',
2402
+ ErrorCodes.NOT_INITIALIZED
2403
+ );
2404
+ }
2405
+ }
2406
+
2407
+ /**
2408
+ * Cleanup and close connections.
2409
+ */
2410
+ async destroy() {
2411
+ if (this.syncEngine) {
2412
+ await this.syncEngine.stop();
2413
+ }
2414
+ if (this.storage) {
2415
+ await this.storage.close();
2416
+ }
2417
+ this.initialized = false;
2418
+ }
2419
+ }
2420
+
2421
+ // Export error types for external use
2422
+ EventIndexer.EventIndexerError = EventIndexerError;
2423
+ EventIndexer.ErrorCodes = ErrorCodes;
2424
+
2425
+ export default EventIndexer;
2426
+ ```
2427
+
2428
+ **Step 2: Commit**
2429
+
2430
+ ```bash
2431
+ git add src/services/EventIndexer.js
2432
+ git commit -m "feat(services): add EventIndexer main service orchestrating all components"
2433
+ ```
2434
+
2435
+ ---
2436
+
2437
+ ## Task 11: Component Integration (useIndexer hook)
2438
+
2439
+ **Files:**
2440
+ - Modify: `src/services/EventIndexer.js` (add static method for hook installation)
2441
+
2442
+ **Step 1: Add useIndexer hook installer to EventIndexer**
2443
+
2444
+ Add this method to the EventIndexer class (before the final export):
2445
+
2446
+ ```javascript
2447
+ // Add to EventIndexer class, before export
2448
+
2449
+ /**
2450
+ * Install useIndexer hook on Component class.
2451
+ * Call this once after importing your Component class.
2452
+ *
2453
+ * @param {typeof Component} ComponentClass - The Component class to extend
2454
+ *
2455
+ * @example
2456
+ * import { Component } from '@monygroupcorp/microact';
2457
+ * import { EventIndexer } from '@monygroupcorp/micro-web3';
2458
+ * EventIndexer.installHook(Component);
2459
+ */
2460
+ static installHook(ComponentClass) {
2461
+ if (ComponentClass.prototype.useIndexer) {
2462
+ console.warn('[EventIndexer] useIndexer hook already installed');
2463
+ return;
2464
+ }
2465
+
2466
+ /**
2467
+ * Reactive query hook for EventIndexer.
2468
+ * Auto-updates when indexed events change.
2469
+ *
2470
+ * @param {EntityQueryable|EventsAPI} queryable - What to query (entity or events)
2471
+ * @param {Object|Function} where - Filter conditions (can be reactive functions)
2472
+ * @param {Object} options - Hook options
2473
+ * @param {Function} options.onUpdate - Called when results change
2474
+ * @param {Function} options.onError - Called on query error
2475
+ * @returns {Proxy} Proxy that returns current results
2476
+ */
2477
+ ComponentClass.prototype.useIndexer = function(queryable, where = {}, options = {}) {
2478
+ const self = this;
2479
+ let result = null;
2480
+ let isLoading = true;
2481
+ let unsubscribe = null;
2482
+
2483
+ // Resolve reactive where values
2484
+ const resolveWhere = () => {
2485
+ const resolved = {};
2486
+ for (const [key, value] of Object.entries(where)) {
2487
+ resolved[key] = typeof value === 'function' ? value() : value;
2488
+ }
2489
+ return resolved;
2490
+ };
2491
+
2492
+ // Execute query
2493
+ const executeQuery = async () => {
2494
+ try {
2495
+ const whereValues = resolveWhere();
2496
+
2497
+ if (queryable.query) {
2498
+ // Entity or events queryable
2499
+ const queryResult = await queryable.query(whereValues);
2500
+ result = Array.isArray(queryResult) ? queryResult : queryResult.events;
2501
+ } else {
2502
+ result = [];
2503
+ }
2504
+
2505
+ isLoading = false;
2506
+
2507
+ if (options.onUpdate) {
2508
+ options.onUpdate(result);
2509
+ }
2510
+ } catch (error) {
2511
+ isLoading = false;
2512
+ console.error('[useIndexer] Query error:', error);
2513
+ if (options.onError) {
2514
+ options.onError(error);
2515
+ }
2516
+ }
2517
+ };
2518
+
2519
+ // Initial query
2520
+ executeQuery();
2521
+
2522
+ // Subscribe to changes
2523
+ if (queryable.subscribe) {
2524
+ unsubscribe = queryable.subscribe(resolveWhere(), () => {
2525
+ executeQuery();
2526
+ });
2527
+ }
2528
+
2529
+ // Register cleanup
2530
+ if (this.registerCleanup) {
2531
+ this.registerCleanup(() => {
2532
+ if (unsubscribe) unsubscribe();
2533
+ });
2534
+ }
2535
+
2536
+ // Return proxy for array-like access
2537
+ return new Proxy([], {
2538
+ get(target, prop) {
2539
+ if (prop === 'length') return result?.length || 0;
2540
+ if (prop === 'isLoading') return isLoading;
2541
+ if (prop === Symbol.iterator) {
2542
+ return function* () {
2543
+ if (result) yield* result;
2544
+ };
2545
+ }
2546
+ if (typeof prop === 'string' && !isNaN(prop)) {
2547
+ return result?.[parseInt(prop)];
2548
+ }
2549
+ // Array methods
2550
+ if (result && typeof result[prop] === 'function') {
2551
+ return result[prop].bind(result);
2552
+ }
2553
+ return result?.[prop];
2554
+ }
2555
+ });
2556
+ };
2557
+ }
2558
+ ```
2559
+
2560
+ **Step 2: Commit**
2561
+
2562
+ ```bash
2563
+ git add src/services/EventIndexer.js
2564
+ git commit -m "feat(indexer): add useIndexer hook installer for reactive component queries"
2565
+ ```
2566
+
2567
+ ---
2568
+
2569
+ ## Task 12: SyncProgressBar Component
2570
+
2571
+ **Files:**
2572
+ - Create: `src/components/SyncProgressBar/SyncProgressBar.js`
2573
+ - Create: `src/components/SyncProgressBar/SyncProgressBar.css`
2574
+
2575
+ **Step 1: Create SyncProgressBar component**
2576
+
2577
+ ```javascript
2578
+ // src/components/SyncProgressBar/SyncProgressBar.js
2579
+
2580
+ /**
2581
+ * SyncProgressBar - UI component showing indexer sync status.
2582
+ *
2583
+ * @example
2584
+ * import { SyncProgressBar } from '@monygroupcorp/micro-web3/components';
2585
+ * const progressBar = new SyncProgressBar({ indexer, eventBus });
2586
+ * progressBar.mount(document.getElementById('sync-status'));
2587
+ */
2588
+ class SyncProgressBar {
2589
+ constructor({ indexer, eventBus }) {
2590
+ if (!indexer) {
2591
+ throw new Error('SyncProgressBar requires indexer instance');
2592
+ }
2593
+ if (!eventBus) {
2594
+ throw new Error('SyncProgressBar requires eventBus instance');
2595
+ }
2596
+
2597
+ this.indexer = indexer;
2598
+ this.eventBus = eventBus;
2599
+ this.element = null;
2600
+ this.unsubscribers = [];
2601
+
2602
+ this.state = {
2603
+ status: 'initializing',
2604
+ progress: 0,
2605
+ currentBlock: 0,
2606
+ latestBlock: 0,
2607
+ error: null
2608
+ };
2609
+ }
2610
+
2611
+ mount(container) {
2612
+ if (typeof container === 'string') {
2613
+ container = document.querySelector(container);
2614
+ }
2615
+ if (!container) {
2616
+ throw new Error('SyncProgressBar: Invalid container');
2617
+ }
2618
+
2619
+ // Create element
2620
+ this.element = document.createElement('div');
2621
+ this.element.className = 'mw3-sync-progress';
2622
+ container.appendChild(this.element);
2623
+
2624
+ // Subscribe to events
2625
+ this._setupListeners();
2626
+
2627
+ // Initial render
2628
+ this._updateState(this.indexer.sync.getStatus());
2629
+ this._render();
2630
+
2631
+ return this;
2632
+ }
2633
+
2634
+ unmount() {
2635
+ // Cleanup subscriptions
2636
+ for (const unsub of this.unsubscribers) {
2637
+ unsub();
2638
+ }
2639
+ this.unsubscribers = [];
2640
+
2641
+ // Remove element
2642
+ if (this.element && this.element.parentNode) {
2643
+ this.element.parentNode.removeChild(this.element);
2644
+ }
2645
+ this.element = null;
2646
+ }
2647
+
2648
+ _setupListeners() {
2649
+ const listeners = [
2650
+ ['indexer:syncProgress', (data) => {
2651
+ this._updateState({
2652
+ status: 'syncing',
2653
+ progress: data.progress,
2654
+ currentBlock: data.currentBlock,
2655
+ latestBlock: data.latestBlock
2656
+ });
2657
+ }],
2658
+ ['indexer:syncComplete', () => {
2659
+ this._updateState({ status: 'synced', progress: 1 });
2660
+ }],
2661
+ ['indexer:error', (data) => {
2662
+ this._updateState({ status: 'error', error: data.message });
2663
+ }],
2664
+ ['indexer:paused', () => {
2665
+ this._updateState({ status: 'paused' });
2666
+ }],
2667
+ ['indexer:resumed', () => {
2668
+ this._updateState({ status: 'syncing' });
2669
+ }]
2670
+ ];
2671
+
2672
+ for (const [event, handler] of listeners) {
2673
+ const unsub = this.eventBus.on(event, handler);
2674
+ this.unsubscribers.push(unsub);
2675
+ }
2676
+ }
2677
+
2678
+ _updateState(updates) {
2679
+ this.state = { ...this.state, ...updates };
2680
+ this._render();
2681
+ }
2682
+
2683
+ _render() {
2684
+ if (!this.element) return;
2685
+
2686
+ const { status, progress, currentBlock, latestBlock, error } = this.state;
2687
+
2688
+ // Update class for state
2689
+ this.element.className = `mw3-sync-progress mw3-sync-progress--${status}`;
2690
+
2691
+ if (status === 'syncing') {
2692
+ const percent = Math.round(progress * 100);
2693
+ this.element.innerHTML = `
2694
+ <div class="mw3-sync-progress__bar" style="width: ${percent}%"></div>
2695
+ <span class="mw3-sync-progress__text">Syncing... ${percent}%</span>
2696
+ `;
2697
+ } else if (status === 'synced') {
2698
+ this.element.innerHTML = `
2699
+ <span class="mw3-sync-progress__text">Synced to block ${latestBlock.toLocaleString()}</span>
2700
+ `;
2701
+ } else if (status === 'error') {
2702
+ this.element.innerHTML = `
2703
+ <span class="mw3-sync-progress__text">Sync failed: ${error || 'Unknown error'}</span>
2704
+ <button class="mw3-sync-progress__retry">Retry</button>
2705
+ `;
2706
+
2707
+ // Add retry handler
2708
+ const retryBtn = this.element.querySelector('.mw3-sync-progress__retry');
2709
+ if (retryBtn) {
2710
+ retryBtn.onclick = () => this.indexer.sync.resync();
2711
+ }
2712
+ } else if (status === 'paused') {
2713
+ this.element.innerHTML = `
2714
+ <span class="mw3-sync-progress__text">Sync paused</span>
2715
+ <button class="mw3-sync-progress__resume">Resume</button>
2716
+ `;
2717
+
2718
+ const resumeBtn = this.element.querySelector('.mw3-sync-progress__resume');
2719
+ if (resumeBtn) {
2720
+ resumeBtn.onclick = () => this.indexer.sync.resume();
2721
+ }
2722
+ } else {
2723
+ this.element.innerHTML = `
2724
+ <span class="mw3-sync-progress__text">Initializing...</span>
2725
+ `;
2726
+ }
2727
+ }
2728
+
2729
+ /**
2730
+ * Inject default styles into document.
2731
+ * Call once at app startup if not using custom CSS.
2732
+ */
2733
+ static injectStyles() {
2734
+ if (document.getElementById('mw3-sync-progress-styles')) return;
2735
+
2736
+ const style = document.createElement('style');
2737
+ style.id = 'mw3-sync-progress-styles';
2738
+ style.textContent = `
2739
+ .mw3-sync-progress {
2740
+ position: relative;
2741
+ height: 24px;
2742
+ background: var(--mw3-sync-bg, #f0f0f0);
2743
+ border-radius: 4px;
2744
+ overflow: hidden;
2745
+ font-family: system-ui, sans-serif;
2746
+ font-size: 12px;
2747
+ }
2748
+
2749
+ .mw3-sync-progress__bar {
2750
+ position: absolute;
2751
+ top: 0;
2752
+ left: 0;
2753
+ height: 100%;
2754
+ background: var(--mw3-sync-bar, #4caf50);
2755
+ transition: width 0.3s ease;
2756
+ }
2757
+
2758
+ .mw3-sync-progress__text {
2759
+ position: relative;
2760
+ z-index: 1;
2761
+ display: flex;
2762
+ align-items: center;
2763
+ justify-content: center;
2764
+ height: 100%;
2765
+ color: var(--mw3-sync-text, #333);
2766
+ padding: 0 8px;
2767
+ }
2768
+
2769
+ .mw3-sync-progress--synced {
2770
+ background: var(--mw3-sync-bar, #4caf50);
2771
+ }
2772
+
2773
+ .mw3-sync-progress--synced .mw3-sync-progress__text {
2774
+ color: white;
2775
+ }
2776
+
2777
+ .mw3-sync-progress--error {
2778
+ background: var(--mw3-sync-error, #f44336);
2779
+ }
2780
+
2781
+ .mw3-sync-progress--error .mw3-sync-progress__text {
2782
+ color: white;
2783
+ justify-content: space-between;
2784
+ }
2785
+
2786
+ .mw3-sync-progress__retry,
2787
+ .mw3-sync-progress__resume {
2788
+ background: rgba(255,255,255,0.2);
2789
+ border: 1px solid rgba(255,255,255,0.4);
2790
+ border-radius: 3px;
2791
+ color: white;
2792
+ padding: 2px 8px;
2793
+ cursor: pointer;
2794
+ font-size: 11px;
2795
+ }
2796
+
2797
+ .mw3-sync-progress__retry:hover,
2798
+ .mw3-sync-progress__resume:hover {
2799
+ background: rgba(255,255,255,0.3);
2800
+ }
2801
+
2802
+ .mw3-sync-progress--paused {
2803
+ background: var(--mw3-sync-paused, #ff9800);
2804
+ }
2805
+
2806
+ .mw3-sync-progress--paused .mw3-sync-progress__text {
2807
+ color: white;
2808
+ justify-content: space-between;
2809
+ }
2810
+ `;
2811
+ document.head.appendChild(style);
2812
+ }
2813
+ }
2814
+
2815
+ export default SyncProgressBar;
2816
+ ```
2817
+
2818
+ **Step 2: Commit**
2819
+
2820
+ ```bash
2821
+ git add src/components/SyncProgressBar/SyncProgressBar.js
2822
+ git commit -m "feat(components): add SyncProgressBar UI component"
2823
+ ```
2824
+
2825
+ ---
2826
+
2827
+ ## Task 13: Settings Modal with Indexer Storage Controls
2828
+
2829
+ **Files:**
2830
+ - Create: `src/components/SettingsModal/SettingsModal.js`
2831
+ - Modify: `src/components/FloatingWalletButton/FloatingWalletButton.js`
2832
+ - Modify: `src/storage/index.js`
2833
+
2834
+ **Step 1: Create IndexerSettings utility for localStorage preferences**
2835
+
2836
+ ```javascript
2837
+ // src/storage/IndexerSettings.js
2838
+
2839
+ /**
2840
+ * User preferences for EventIndexer storage.
2841
+ * Persisted in localStorage.
2842
+ */
2843
+ const STORAGE_KEY = 'mw3_indexer_settings';
2844
+
2845
+ const IndexerSettings = {
2846
+ /**
2847
+ * Get all settings.
2848
+ * @returns {Object}
2849
+ */
2850
+ get() {
2851
+ try {
2852
+ const stored = localStorage.getItem(STORAGE_KEY);
2853
+ return stored ? JSON.parse(stored) : this.getDefaults();
2854
+ } catch {
2855
+ return this.getDefaults();
2856
+ }
2857
+ },
2858
+
2859
+ /**
2860
+ * Get default settings.
2861
+ * @returns {Object}
2862
+ */
2863
+ getDefaults() {
2864
+ return {
2865
+ storageEnabled: true, // Allow IndexedDB storage
2866
+ maxStorageMB: 50, // Max storage in MB (0 = unlimited)
2867
+ };
2868
+ },
2869
+
2870
+ /**
2871
+ * Update settings.
2872
+ * @param {Object} updates - Partial settings to update
2873
+ */
2874
+ set(updates) {
2875
+ const current = this.get();
2876
+ const updated = { ...current, ...updates };
2877
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
2878
+ return updated;
2879
+ },
2880
+
2881
+ /**
2882
+ * Check if IndexedDB storage is enabled.
2883
+ * @returns {boolean}
2884
+ */
2885
+ isStorageEnabled() {
2886
+ return this.get().storageEnabled;
2887
+ },
2888
+
2889
+ /**
2890
+ * Clear all indexer data from IndexedDB.
2891
+ * @returns {Promise<void>}
2892
+ */
2893
+ async clearAllData() {
2894
+ // Find and delete all micro-web3 databases
2895
+ if (typeof indexedDB === 'undefined') return;
2896
+
2897
+ const databases = await indexedDB.databases?.() || [];
2898
+ const mw3Databases = databases.filter(db =>
2899
+ db.name?.startsWith('micro-web3-events-') ||
2900
+ db.name?.includes('colasseum-')
2901
+ );
2902
+
2903
+ for (const db of mw3Databases) {
2904
+ await new Promise((resolve, reject) => {
2905
+ const request = indexedDB.deleteDatabase(db.name);
2906
+ request.onsuccess = resolve;
2907
+ request.onerror = reject;
2908
+ });
2909
+ }
2910
+ },
2911
+
2912
+ /**
2913
+ * Estimate current storage usage.
2914
+ * @returns {Promise<{used: number, quota: number}>} Bytes
2915
+ */
2916
+ async getStorageEstimate() {
2917
+ if (navigator.storage?.estimate) {
2918
+ return navigator.storage.estimate();
2919
+ }
2920
+ return { used: 0, quota: 0 };
2921
+ }
2922
+ };
2923
+
2924
+ export default IndexerSettings;
2925
+ ```
2926
+
2927
+ **Step 2: Update storage/index.js to respect user settings**
2928
+
2929
+ Modify the `selectAdapter` function in `src/storage/index.js`:
2930
+
2931
+ ```javascript
2932
+ // src/storage/index.js
2933
+
2934
+ import StorageAdapter from './StorageAdapter.js';
2935
+ import IndexedDBAdapter from './IndexedDBAdapter.js';
2936
+ import MemoryAdapter from './MemoryAdapter.js';
2937
+ import IndexerSettings from './IndexerSettings.js';
2938
+
2939
+ /**
2940
+ * Select appropriate storage adapter based on config, environment, and user settings.
2941
+ * @param {Object} config - Persistence configuration
2942
+ * @returns {StorageAdapter}
2943
+ */
2944
+ function selectAdapter(config) {
2945
+ // Explicit memory mode in config
2946
+ if (config?.type === 'memory') {
2947
+ return new MemoryAdapter();
2948
+ }
2949
+
2950
+ // Check user preference - if storage disabled, use memory
2951
+ if (!IndexerSettings.isStorageEnabled()) {
2952
+ console.log('[EventIndexer] User disabled IndexedDB storage, using memory');
2953
+ return new MemoryAdapter();
2954
+ }
2955
+
2956
+ // Check IndexedDB availability
2957
+ if (typeof indexedDB !== 'undefined') {
2958
+ return new IndexedDBAdapter();
2959
+ }
2960
+
2961
+ // Fallback to memory
2962
+ console.warn('[EventIndexer] IndexedDB not available, falling back to memory storage');
2963
+ return new MemoryAdapter();
2964
+ }
2965
+
2966
+ export {
2967
+ StorageAdapter,
2968
+ IndexedDBAdapter,
2969
+ MemoryAdapter,
2970
+ IndexerSettings,
2971
+ selectAdapter
2972
+ };
2973
+ ```
2974
+
2975
+ **Step 3: Create SettingsModal component**
2976
+
2977
+ ```javascript
2978
+ // src/components/SettingsModal/SettingsModal.js
2979
+
2980
+ import { IndexerSettings } from '../../storage/index.js';
2981
+
2982
+ /**
2983
+ * SettingsModal - Modal for user settings including indexer storage controls.
2984
+ *
2985
+ * @example
2986
+ * const modal = new SettingsModal({ eventBus });
2987
+ * modal.show();
2988
+ */
2989
+ class SettingsModal {
2990
+ constructor({ eventBus }) {
2991
+ this.eventBus = eventBus;
2992
+ this.element = null;
2993
+ this.overlay = null;
2994
+ this.isVisible = false;
2995
+ this.state = {
2996
+ storageEnabled: true,
2997
+ storageUsed: 0,
2998
+ storageQuota: 0,
2999
+ clearing: false
3000
+ };
3001
+ }
3002
+
3003
+ async show() {
3004
+ if (this.isVisible) return;
3005
+
3006
+ // Load current settings
3007
+ const settings = IndexerSettings.get();
3008
+ const estimate = await IndexerSettings.getStorageEstimate();
3009
+
3010
+ this.state = {
3011
+ storageEnabled: settings.storageEnabled,
3012
+ storageUsed: estimate.used || 0,
3013
+ storageQuota: estimate.quota || 0,
3014
+ clearing: false
3015
+ };
3016
+
3017
+ this._createModal();
3018
+ this.isVisible = true;
3019
+ }
3020
+
3021
+ hide() {
3022
+ if (!this.isVisible) return;
3023
+
3024
+ if (this.overlay && this.overlay.parentNode) {
3025
+ this.overlay.parentNode.removeChild(this.overlay);
3026
+ }
3027
+ this.overlay = null;
3028
+ this.element = null;
3029
+ this.isVisible = false;
3030
+ }
3031
+
3032
+ _createModal() {
3033
+ // Inject styles if needed
3034
+ SettingsModal.injectStyles();
3035
+
3036
+ // Create overlay
3037
+ this.overlay = document.createElement('div');
3038
+ this.overlay.className = 'mw3-settings-overlay';
3039
+ this.overlay.addEventListener('click', (e) => {
3040
+ if (e.target === this.overlay) this.hide();
3041
+ });
3042
+
3043
+ // Create modal
3044
+ this.element = document.createElement('div');
3045
+ this.element.className = 'mw3-settings-modal';
3046
+ this.element.innerHTML = this._render();
3047
+
3048
+ this.overlay.appendChild(this.element);
3049
+ document.body.appendChild(this.overlay);
3050
+
3051
+ this._setupEventListeners();
3052
+ }
3053
+
3054
+ _render() {
3055
+ const { storageEnabled, storageUsed, storageQuota, clearing } = this.state;
3056
+ const usedMB = (storageUsed / (1024 * 1024)).toFixed(1);
3057
+ const quotaMB = (storageQuota / (1024 * 1024)).toFixed(0);
3058
+
3059
+ return `
3060
+ <div class="mw3-settings-header">
3061
+ <h2>Settings</h2>
3062
+ <button class="mw3-settings-close" data-action="close">&times;</button>
3063
+ </div>
3064
+
3065
+ <div class="mw3-settings-content">
3066
+ <div class="mw3-settings-section">
3067
+ <h3>Data Storage</h3>
3068
+ <p class="mw3-settings-description">
3069
+ Event data is cached locally to improve performance. You can disable this to save space.
3070
+ </p>
3071
+
3072
+ <div class="mw3-settings-row">
3073
+ <label class="mw3-settings-toggle">
3074
+ <input type="checkbox" ${storageEnabled ? 'checked' : ''} data-setting="storageEnabled">
3075
+ <span class="mw3-toggle-slider"></span>
3076
+ <span class="mw3-toggle-label">Enable local event storage</span>
3077
+ </label>
3078
+ </div>
3079
+
3080
+ <div class="mw3-settings-info">
3081
+ <span>Storage used: ${usedMB} MB${quotaMB > 0 ? ` / ${quotaMB} MB` : ''}</span>
3082
+ </div>
3083
+
3084
+ <div class="mw3-settings-row">
3085
+ <button class="mw3-settings-btn mw3-settings-btn--danger" data-action="clearData" ${clearing ? 'disabled' : ''}>
3086
+ ${clearing ? 'Clearing...' : 'Clear All Cached Data'}
3087
+ </button>
3088
+ </div>
3089
+
3090
+ <p class="mw3-settings-note">
3091
+ Disabling storage means event history will need to be re-fetched each session.
3092
+ </p>
3093
+ </div>
3094
+ </div>
3095
+
3096
+ <div class="mw3-settings-footer">
3097
+ <button class="mw3-settings-btn mw3-settings-btn--primary" data-action="close">Done</button>
3098
+ </div>
3099
+ `;
3100
+ }
3101
+
3102
+ _setupEventListeners() {
3103
+ // Close button
3104
+ this.element.querySelectorAll('[data-action="close"]').forEach(btn => {
3105
+ btn.addEventListener('click', () => this.hide());
3106
+ });
3107
+
3108
+ // Storage toggle
3109
+ const toggle = this.element.querySelector('[data-setting="storageEnabled"]');
3110
+ if (toggle) {
3111
+ toggle.addEventListener('change', (e) => {
3112
+ const enabled = e.target.checked;
3113
+ IndexerSettings.set({ storageEnabled: enabled });
3114
+ this.state.storageEnabled = enabled;
3115
+
3116
+ // Emit event so indexer can react
3117
+ this.eventBus.emit('settings:storageChanged', { enabled });
3118
+ });
3119
+ }
3120
+
3121
+ // Clear data button
3122
+ const clearBtn = this.element.querySelector('[data-action="clearData"]');
3123
+ if (clearBtn) {
3124
+ clearBtn.addEventListener('click', async () => {
3125
+ if (this.state.clearing) return;
3126
+
3127
+ this.state.clearing = true;
3128
+ this._updateContent();
3129
+
3130
+ try {
3131
+ await IndexerSettings.clearAllData();
3132
+
3133
+ // Update storage estimate
3134
+ const estimate = await IndexerSettings.getStorageEstimate();
3135
+ this.state.storageUsed = estimate.used || 0;
3136
+ this.state.storageQuota = estimate.quota || 0;
3137
+
3138
+ // Emit event so indexer knows to resync
3139
+ this.eventBus.emit('settings:dataCleared', {});
3140
+ } catch (error) {
3141
+ console.error('[SettingsModal] Failed to clear data:', error);
3142
+ }
3143
+
3144
+ this.state.clearing = false;
3145
+ this._updateContent();
3146
+ });
3147
+ }
3148
+
3149
+ // ESC key to close
3150
+ this._escHandler = (e) => {
3151
+ if (e.key === 'Escape') this.hide();
3152
+ };
3153
+ document.addEventListener('keydown', this._escHandler);
3154
+ }
3155
+
3156
+ _updateContent() {
3157
+ if (this.element) {
3158
+ this.element.innerHTML = this._render();
3159
+ this._setupEventListeners();
3160
+ }
3161
+ }
3162
+
3163
+ static injectStyles() {
3164
+ if (document.getElementById('mw3-settings-styles')) return;
3165
+
3166
+ const style = document.createElement('style');
3167
+ style.id = 'mw3-settings-styles';
3168
+ style.textContent = `
3169
+ .mw3-settings-overlay {
3170
+ position: fixed;
3171
+ top: 0;
3172
+ left: 0;
3173
+ right: 0;
3174
+ bottom: 0;
3175
+ background: rgba(0, 0, 0, 0.7);
3176
+ display: flex;
3177
+ align-items: center;
3178
+ justify-content: center;
3179
+ z-index: 10000;
3180
+ backdrop-filter: blur(4px);
3181
+ }
3182
+
3183
+ .mw3-settings-modal {
3184
+ background: linear-gradient(135deg, #1a1a2e, #16213e);
3185
+ border: 1px solid rgba(218, 165, 32, 0.3);
3186
+ border-radius: 12px;
3187
+ width: 90%;
3188
+ max-width: 400px;
3189
+ color: #fff;
3190
+ font-family: 'Lato', system-ui, sans-serif;
3191
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
3192
+ }
3193
+
3194
+ .mw3-settings-header {
3195
+ display: flex;
3196
+ align-items: center;
3197
+ justify-content: space-between;
3198
+ padding: 1rem 1.25rem;
3199
+ border-bottom: 1px solid rgba(218, 165, 32, 0.2);
3200
+ }
3201
+
3202
+ .mw3-settings-header h2 {
3203
+ margin: 0;
3204
+ font-size: 1.125rem;
3205
+ font-weight: 600;
3206
+ color: rgba(218, 165, 32, 0.95);
3207
+ }
3208
+
3209
+ .mw3-settings-close {
3210
+ background: none;
3211
+ border: none;
3212
+ color: rgba(255, 255, 255, 0.6);
3213
+ font-size: 1.5rem;
3214
+ cursor: pointer;
3215
+ padding: 0;
3216
+ line-height: 1;
3217
+ }
3218
+
3219
+ .mw3-settings-close:hover {
3220
+ color: #fff;
3221
+ }
3222
+
3223
+ .mw3-settings-content {
3224
+ padding: 1.25rem;
3225
+ }
3226
+
3227
+ .mw3-settings-section h3 {
3228
+ margin: 0 0 0.5rem 0;
3229
+ font-size: 0.9rem;
3230
+ font-weight: 600;
3231
+ color: rgba(255, 255, 255, 0.9);
3232
+ }
3233
+
3234
+ .mw3-settings-description {
3235
+ margin: 0 0 1rem 0;
3236
+ font-size: 0.8rem;
3237
+ color: rgba(255, 255, 255, 0.6);
3238
+ line-height: 1.4;
3239
+ }
3240
+
3241
+ .mw3-settings-row {
3242
+ margin-bottom: 1rem;
3243
+ }
3244
+
3245
+ .mw3-settings-toggle {
3246
+ display: flex;
3247
+ align-items: center;
3248
+ gap: 0.75rem;
3249
+ cursor: pointer;
3250
+ }
3251
+
3252
+ .mw3-settings-toggle input {
3253
+ display: none;
3254
+ }
3255
+
3256
+ .mw3-toggle-slider {
3257
+ width: 44px;
3258
+ height: 24px;
3259
+ background: rgba(255, 255, 255, 0.2);
3260
+ border-radius: 12px;
3261
+ position: relative;
3262
+ transition: background 0.2s;
3263
+ }
3264
+
3265
+ .mw3-toggle-slider::after {
3266
+ content: '';
3267
+ position: absolute;
3268
+ top: 2px;
3269
+ left: 2px;
3270
+ width: 20px;
3271
+ height: 20px;
3272
+ background: #fff;
3273
+ border-radius: 50%;
3274
+ transition: transform 0.2s;
3275
+ }
3276
+
3277
+ .mw3-settings-toggle input:checked + .mw3-toggle-slider {
3278
+ background: rgba(218, 165, 32, 0.8);
3279
+ }
3280
+
3281
+ .mw3-settings-toggle input:checked + .mw3-toggle-slider::after {
3282
+ transform: translateX(20px);
3283
+ }
3284
+
3285
+ .mw3-toggle-label {
3286
+ font-size: 0.875rem;
3287
+ color: rgba(255, 255, 255, 0.9);
3288
+ }
3289
+
3290
+ .mw3-settings-info {
3291
+ font-size: 0.75rem;
3292
+ color: rgba(255, 255, 255, 0.5);
3293
+ margin-bottom: 1rem;
3294
+ }
3295
+
3296
+ .mw3-settings-note {
3297
+ font-size: 0.75rem;
3298
+ color: rgba(255, 255, 255, 0.4);
3299
+ margin: 0;
3300
+ font-style: italic;
3301
+ }
3302
+
3303
+ .mw3-settings-btn {
3304
+ padding: 0.625rem 1rem;
3305
+ border-radius: 6px;
3306
+ font-size: 0.875rem;
3307
+ font-weight: 500;
3308
+ cursor: pointer;
3309
+ border: none;
3310
+ transition: all 0.2s;
3311
+ }
3312
+
3313
+ .mw3-settings-btn--primary {
3314
+ background: rgba(218, 165, 32, 0.9);
3315
+ color: #1a1a2e;
3316
+ }
3317
+
3318
+ .mw3-settings-btn--primary:hover {
3319
+ background: rgba(218, 165, 32, 1);
3320
+ }
3321
+
3322
+ .mw3-settings-btn--danger {
3323
+ background: rgba(255, 99, 71, 0.2);
3324
+ color: rgba(255, 99, 71, 0.9);
3325
+ border: 1px solid rgba(255, 99, 71, 0.3);
3326
+ }
3327
+
3328
+ .mw3-settings-btn--danger:hover {
3329
+ background: rgba(255, 99, 71, 0.3);
3330
+ }
3331
+
3332
+ .mw3-settings-btn:disabled {
3333
+ opacity: 0.5;
3334
+ cursor: not-allowed;
3335
+ }
3336
+
3337
+ .mw3-settings-footer {
3338
+ padding: 1rem 1.25rem;
3339
+ border-top: 1px solid rgba(218, 165, 32, 0.2);
3340
+ display: flex;
3341
+ justify-content: flex-end;
3342
+ }
3343
+ `;
3344
+ document.head.appendChild(style);
3345
+ }
3346
+ }
3347
+
3348
+ export default SettingsModal;
3349
+ ```
3350
+
3351
+ **Step 4: Wire up FloatingWalletButton to open SettingsModal**
3352
+
3353
+ Modify `src/components/FloatingWalletButton/FloatingWalletButton.js`:
3354
+
3355
+ Add import at top:
3356
+ ```javascript
3357
+ import SettingsModal from '../SettingsModal/SettingsModal.js';
3358
+ ```
3359
+
3360
+ Add to constructor after `this.walletModal = null;`:
3361
+ ```javascript
3362
+ this.settingsModal = null;
3363
+ ```
3364
+
3365
+ Modify the settings action handler in `setupDOMEventListeners()`:
3366
+ ```javascript
3367
+ } else if (action === 'settings') {
3368
+ this.setState({ menuOpen: false });
3369
+ this.openSettings();
3370
+ }
3371
+ ```
3372
+
3373
+ Add new method:
3374
+ ```javascript
3375
+ openSettings() {
3376
+ if (!this.settingsModal) {
3377
+ this.settingsModal = new SettingsModal({ eventBus: eventBus });
3378
+ }
3379
+ this.settingsModal.show();
3380
+ }
3381
+ ```
3382
+
3383
+ Add cleanup in `onUnmount()`:
3384
+ ```javascript
3385
+ if (this.settingsModal) {
3386
+ this.settingsModal.hide();
3387
+ this.settingsModal = null;
3388
+ }
3389
+ ```
3390
+
3391
+ **Step 5: Commit**
3392
+
3393
+ ```bash
3394
+ git add src/storage/IndexerSettings.js src/storage/index.js src/components/SettingsModal/SettingsModal.js src/components/FloatingWalletButton/FloatingWalletButton.js
3395
+ git commit -m "feat(settings): add SettingsModal with indexer storage controls"
3396
+ ```
3397
+
3398
+ ---
3399
+
3400
+ ## Task 14: Modify BlockchainService
3401
+
3402
+ **Files:**
3403
+ - Modify: `src/services/BlockchainService.js`
3404
+
3405
+ **Step 1: Add methods to support EventIndexer**
3406
+
3407
+ Add these methods to BlockchainService class (find appropriate location in the class):
3408
+
3409
+ ```javascript
3410
+ // NEW: Get provider instance (for EventIndexer)
3411
+ getProvider() {
3412
+ return this.provider;
3413
+ }
3414
+
3415
+ // NEW: Create contract instance (for EventIndexer)
3416
+ getContract(address, abi) {
3417
+ if (!this.provider) {
3418
+ throw new Error('Provider not initialized');
3419
+ }
3420
+ return new ethers.Contract(address, abi, this.provider);
3421
+ }
3422
+
3423
+ // NEW: Get current chain ID (for EventIndexer)
3424
+ async getChainId() {
3425
+ if (!this.provider) {
3426
+ throw new Error('Provider not initialized');
3427
+ }
3428
+ const network = await this.provider.getNetwork();
3429
+ return Number(network.chainId);
3430
+ }
3431
+
3432
+ // NEW: Get block by number (for EventIndexer)
3433
+ async getBlock(blockNumber) {
3434
+ if (!this.provider) {
3435
+ throw new Error('Provider not initialized');
3436
+ }
3437
+ return this.provider.getBlock(blockNumber);
3438
+ }
3439
+ ```
3440
+
3441
+ **Step 2: Commit**
3442
+
3443
+ ```bash
3444
+ git add src/services/BlockchainService.js
3445
+ git commit -m "feat(BlockchainService): add getProvider, getContract, getChainId, getBlock methods"
3446
+ ```
3447
+
3448
+ ---
3449
+
3450
+ ## Task 15: Modify ContractCache
3451
+
3452
+ **Files:**
3453
+ - Modify: `src/services/ContractCache.js`
3454
+
3455
+ **Step 1: Add event-driven invalidation support**
3456
+
3457
+ Add to ContractCache constructor (after existing initialization):
3458
+
3459
+ ```javascript
3460
+ // NEW: Optional event-driven invalidation from EventIndexer
3461
+ if (options.eventInvalidation) {
3462
+ this.setupEventInvalidation(options.eventInvalidation);
3463
+ }
3464
+ ```
3465
+
3466
+ Add this method to ContractCache class:
3467
+
3468
+ ```javascript
3469
+ // NEW: Configure which events invalidate which cache patterns
3470
+ setupEventInvalidation(config) {
3471
+ // config: { 'Transfer': ['balance'], 'Approval': ['allowance'] }
3472
+ this.eventBus.on('indexer:newEvents', ({ eventType }) => {
3473
+ const patterns = config[eventType];
3474
+ if (patterns && Array.isArray(patterns)) {
3475
+ this.invalidateByPattern(...patterns);
3476
+ }
3477
+ });
3478
+ }
3479
+ ```
3480
+
3481
+ **Step 2: Commit**
3482
+
3483
+ ```bash
3484
+ git add src/services/ContractCache.js
3485
+ git commit -m "feat(ContractCache): add event-driven invalidation from EventIndexer"
3486
+ ```
3487
+
3488
+ ---
3489
+
3490
+ ## Task 16: Update Main Exports
3491
+
3492
+ **Files:**
3493
+ - Modify: `src/index.js`
3494
+
3495
+ **Step 1: Add new exports**
3496
+
3497
+ Update src/index.js to include new exports:
3498
+
3499
+ ```javascript
3500
+ // Services
3501
+ export { default as BlockchainService } from './services/BlockchainService.js';
3502
+ export { default as WalletService } from './services/WalletService.js';
3503
+ export { default as ContractCache } from './services/ContractCache.js';
3504
+ export { default as PriceService } from './services/PriceService.js';
3505
+ export { default as IpfsService } from './services/IpfsService.js';
3506
+ export { default as WETHService } from './services/WETHService.js';
3507
+
3508
+ // NEW in v1.2.0: EventIndexer
3509
+ export { default as EventIndexer } from './services/EventIndexer.js';
3510
+
3511
+ // NEW: Storage adapters and settings (for custom implementations)
3512
+ export { IndexedDBAdapter, MemoryAdapter, IndexerSettings } from './storage/index.js';
3513
+
3514
+ // UI Components (existing)
3515
+ export { default as FloatingWalletButton } from './components/FloatingWalletButton/FloatingWalletButton.js';
3516
+ export { default as WalletButton } from './components/WalletButton/WalletButton.js';
3517
+ export { default as WalletModal } from './components/Wallet/WalletModal.js';
3518
+ export { default as IpfsImage } from './components/Ipfs/IpfsImage.js';
3519
+ export { default as SwapInterface } from './components/Swap/SwapInterface.js';
3520
+ export { default as SwapInputs } from './components/Swap/SwapInputs.js';
3521
+ export { default as SwapButton } from './components/Swap/SwapButton.js';
3522
+ export { default as TransactionOptions } from './components/Swap/TransactionOptions.js';
3523
+ export { default as ApprovalModal } from './components/Modal/ApprovalModal.js';
3524
+ export { default as BondingCurve } from './components/BondingCurve/BondingCurve.js';
3525
+ export { default as PriceDisplay } from './components/Display/PriceDisplay.js';
3526
+ export { default as BalanceDisplay } from './components/Display/BalanceDisplay.js';
3527
+ export { default as MessagePopup } from './components/Util/MessagePopup.js';
3528
+
3529
+ // NEW in v1.2.0: Sync progress component and settings modal
3530
+ export { default as SyncProgressBar } from './components/SyncProgressBar/SyncProgressBar.js';
3531
+ export { default as SettingsModal } from './components/SettingsModal/SettingsModal.js';
3532
+ ```
3533
+
3534
+ **Step 2: Commit**
3535
+
3536
+ ```bash
3537
+ git add src/index.js
3538
+ git commit -m "feat(exports): add EventIndexer, storage adapters, and SyncProgressBar exports"
3539
+ ```
3540
+
3541
+ ---
3542
+
3543
+ ## Task 17: Version Bump
3544
+
3545
+ **Files:**
3546
+ - Modify: `package.json`
3547
+
3548
+ **Step 1: Update version to 1.2.0**
3549
+
3550
+ Change version field in package.json:
3551
+
3552
+ ```json
3553
+ {
3554
+ "name": "@monygroupcorp/micro-web3",
3555
+ "version": "1.2.0",
3556
+ ...
3557
+ }
3558
+ ```
3559
+
3560
+ **Step 2: Commit**
3561
+
3562
+ ```bash
3563
+ git add package.json
3564
+ git commit -m "chore: bump version to 1.2.0"
3565
+ ```
3566
+
3567
+ ---
3568
+
3569
+ ## Task 18: Build and Verify
3570
+
3571
+ **Step 1: Run build**
3572
+
3573
+ ```bash
3574
+ cd micro-web3 && npm run build
3575
+ ```
3576
+
3577
+ Expected: Build succeeds with no errors.
3578
+
3579
+ **Step 2: Verify exports**
3580
+
3581
+ ```bash
3582
+ node -e "import('./dist/micro-web3.esm.js').then(m => console.log(Object.keys(m)))"
3583
+ ```
3584
+
3585
+ Expected: Should list all exports including `EventIndexer`, `SyncProgressBar`, `IndexedDBAdapter`, `MemoryAdapter`.
3586
+
3587
+ **Step 3: Commit build artifacts (if tracked)**
3588
+
3589
+ ```bash
3590
+ git add dist/
3591
+ git commit -m "chore: build v1.2.0"
3592
+ ```
3593
+
3594
+ ---
3595
+
3596
+ ## Summary of Files Created/Modified
3597
+
3598
+ ### New Files (15)
3599
+ - `src/storage/StorageAdapter.js` - Base class interface
3600
+ - `src/storage/MemoryAdapter.js` - In-memory implementation
3601
+ - `src/storage/IndexedDBAdapter.js` - IndexedDB implementation
3602
+ - `src/storage/IndexerSettings.js` - User preferences for storage
3603
+ - `src/storage/index.js` - Storage module exports
3604
+ - `src/indexer/QueryEngine.js` - Events API
3605
+ - `src/indexer/SyncEngine.js` - Sync management
3606
+ - `src/indexer/EntityResolver.js` - Entities API
3607
+ - `src/indexer/Patterns.js` - Patterns API
3608
+ - `src/indexer/index.js` - Indexer module exports
3609
+ - `src/services/EventIndexer.js` - Main service
3610
+ - `src/components/SyncProgressBar/SyncProgressBar.js` - Sync status UI
3611
+ - `src/components/SettingsModal/SettingsModal.js` - Settings UI with storage controls
3612
+
3613
+ ### Modified Files (5)
3614
+ - `src/services/BlockchainService.js` - Added getProvider, getContract, getChainId, getBlock
3615
+ - `src/services/ContractCache.js` - Added setupEventInvalidation
3616
+ - `src/components/FloatingWalletButton/FloatingWalletButton.js` - Wire up SettingsModal
3617
+ - `src/index.js` - Added new exports
3618
+ - `package.json` - Version bump to 1.2.0
3619
+
3620
+ ---
3621
+
3622
+ ## Tests (Deferred)
3623
+
3624
+ Per your request, tests go last. When ready to add tests:
3625
+
3626
+ 1. Set up test infrastructure (Jest or Vitest)
3627
+ 2. Add unit tests for storage adapters
3628
+ 3. Add unit tests for query engine
3629
+ 4. Add unit tests for entity resolver
3630
+ 5. Add unit tests for patterns
3631
+ 6. Add integration tests for full sync cycle
3632
+ 7. Add mock provider tests
3633
+
3634
+ ---
3635
+
3636
+ **Plan complete and saved to `docs/plans/2026-01-22-event-indexer.md`. Two execution options:**
3637
+
3638
+ **1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
3639
+
3640
+ **2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
3641
+
3642
+ **Which approach?**