@monygroupcorp/micro-web3 0.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +6 -0
- package/dist/{micro-web3.cjs.js → micro-web3.cjs} +3 -3
- package/dist/micro-web3.cjs.map +1 -0
- package/dist/micro-web3.esm.js +2 -2
- package/dist/micro-web3.esm.js.map +1 -1
- package/dist/micro-web3.umd.js +2 -2
- package/dist/micro-web3.umd.js.map +1 -1
- package/docs/plans/2026-01-22-event-indexer.md +3642 -0
- package/monygroupcorp-micro-web3-0.1.3.tgz +0 -0
- package/package.json +2 -2
- package/rollup.config.cjs +1 -1
- package/src/components/FloatingWalletButton/FloatingWalletButton.js +26 -5
- package/src/components/SettingsModal/SettingsModal.js +371 -0
- package/src/components/SyncProgressBar/SyncProgressBar.js +238 -0
- package/src/components/WalletButton/WalletButton.js +213 -0
- package/src/index.js +18 -2
- package/src/indexer/EntityResolver.js +218 -0
- package/src/indexer/Patterns.js +277 -0
- package/src/indexer/QueryEngine.js +149 -0
- package/src/indexer/SyncEngine.js +494 -0
- package/src/indexer/index.js +13 -0
- package/src/services/BlockchainService.js +30 -0
- package/src/services/ContractCache.js +20 -2
- package/src/services/EventIndexer.js +399 -0
- package/src/storage/IndexedDBAdapter.js +423 -0
- package/src/storage/IndexerSettings.js +88 -0
- package/src/storage/MemoryAdapter.js +194 -0
- package/src/storage/StorageAdapter.js +129 -0
- package/src/storage/index.js +41 -0
- package/dist/micro-web3.cjs.js.map +0 -1
|
@@ -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">×</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?**
|