effective-indexer 0.2.4 → 0.2.6

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/LICENSE CHANGED
@@ -1,7 +1,12 @@
1
+ Effective Indexer License
1
2
  Copyright (c) 2026 Aleksandr Shenshin
2
- All rights reserved.
3
3
 
4
- This software and associated documentation files are proprietary and confidential.
5
- No part of this software may be copied, modified, distributed, sublicensed,
6
- published, sold, or used in any form without prior written permission from
7
- the copyright holder.
4
+ This project is available under the PolyForm Noncommercial License 1.0.0.
5
+ License text: https://polyformproject.org/licenses/noncommercial/1.0.0/
6
+
7
+ You may use, modify, and distribute this software only for noncommercial purposes
8
+ as defined by that license.
9
+
10
+ Commercial use requires a separate paid commercial license agreement.
11
+ For commercial licensing inquiries, contact:
12
+ Aleksandr Shenshin <shenshin@me.com>
package/README.md CHANGED
@@ -20,6 +20,12 @@ npm install effective-indexer effect
20
20
 
21
21
  `effect` is a peer dependency.
22
22
 
23
+ ## License
24
+
25
+ Free for noncommercial use under PolyForm Noncommercial 1.0.0.
26
+ Commercial use requires a paid commercial license (see `LICENSE`).
27
+ Contact: Aleksandr Shenshin <shenshin@me.com>.
28
+
23
29
  ## 5-minute setup
24
30
 
25
31
  ### 1) Create `indexer.config.ts`
@@ -112,13 +118,13 @@ await indexer.stop()
112
118
 
113
119
  ## Public API
114
120
 
115
- - `defineIndexerConfig(config)`
121
+ - `defineIndexerConfig(config)`
116
122
  Identity helper for typed config files (Hardhat-style).
117
- - `resolveIndexerConfigFromEnv(config, options?)`
123
+ - `resolveIndexerConfigFromEnv(config, options?)`
118
124
  Resolves `{{ENV_VAR}}` placeholders and optional RPC URL override.
119
- - `runIndexerWorker(config, options?)`
125
+ - `runIndexerWorker(config, options?)`
120
126
  Runs long-lived worker with built-in DB directory creation and graceful shutdown.
121
- - `Indexer.create(config)`
127
+ - `Indexer.create(config)`
122
128
  Returns handle: `start()`, `stop()`, `query()`, `count()`.
123
129
 
124
130
  ## Config essentials
@@ -483,11 +489,3 @@ npm run typecheck
483
489
  npm run test
484
490
  npm run check
485
491
  ```
486
-
487
- ### Live Integration Tests
488
-
489
- Integration tests read RPC URLs from `.env` (mainnet) and `.env.test` (testnet) using `EVM_RPC_URL`.
490
-
491
- ```bash
492
- npm run test:integration
493
- ```
package/dist/index.cjs CHANGED
@@ -53,22 +53,26 @@ var resolveTelemetry = (config) => {
53
53
  }
54
54
  };
55
55
  };
56
- var resolveConfig = (config) => {
56
+ var resolveConfigEffect = (config) => effect.Effect.gen(function* () {
57
57
  const network = resolveNetwork(config);
58
58
  const telemetry = resolveTelemetry(config);
59
59
  const pr = network.logs.parallelRequests;
60
60
  if (!Number.isInteger(pr) || pr < 1) {
61
- throw new ConfigError({
62
- reason: "parallelRequests must be an integer >= 1",
63
- field: "network.logs.parallelRequests"
64
- });
61
+ return yield* effect.Effect.fail(
62
+ new ConfigError({
63
+ reason: "parallelRequests must be an integer >= 1",
64
+ field: "network.logs.parallelRequests"
65
+ })
66
+ );
65
67
  }
66
68
  const pi = telemetry.progress.intervalMs;
67
69
  if (!Number.isInteger(pi) || !Number.isFinite(pi) || pi < 500) {
68
- throw new ConfigError({
69
- reason: "telemetry.progress.intervalMs must be an integer >= 500",
70
- field: "telemetry.progress.intervalMs"
71
- });
70
+ return yield* effect.Effect.fail(
71
+ new ConfigError({
72
+ reason: "telemetry.progress.intervalMs must be an integer >= 500",
73
+ field: "telemetry.progress.intervalMs"
74
+ })
75
+ );
72
76
  }
73
77
  return {
74
78
  rpcUrl: config.rpcUrl,
@@ -80,10 +84,11 @@ var resolveConfig = (config) => {
80
84
  logFormat: config.logFormat ?? "pretty",
81
85
  enableTelemetry: config.enableTelemetry ?? true
82
86
  };
83
- };
87
+ });
88
+ var resolveConfig = (config) => effect.Effect.runSync(resolveConfigEffect(config));
84
89
  var Config = class extends effect.Context.Tag("effective-indexer/Config")() {
85
90
  };
86
- var ConfigLive = (raw) => effect.Layer.succeed(Config, resolveConfig(raw));
91
+ var ConfigLive = (raw) => effect.Layer.effect(Config, resolveConfigEffect(raw));
87
92
  var parseLogLevel = (level) => {
88
93
  switch (level) {
89
94
  case "trace":
@@ -242,6 +247,14 @@ var wrapSqlError = (operation) => (e) => new StorageError({
242
247
  cause: e
243
248
  });
244
249
  var toJsonValue = (_key, value) => typeof value === "bigint" ? value.toString() : value;
250
+ var INSERT_BATCH_SIZE = 250;
251
+ var chunkEvents = (events, size) => {
252
+ const chunks = [];
253
+ for (let i = 0; i < events.length; i += size) {
254
+ chunks.push(events.slice(i, i + size));
255
+ }
256
+ return chunks;
257
+ };
245
258
  var Storage = class extends effect.Context.Tag("effective-indexer/Storage")() {
246
259
  };
247
260
  var StorageLive = effect.Layer.effect(
@@ -281,14 +294,30 @@ var StorageLive = effect.Layer.effect(
281
294
  yield* effect.Effect.logDebug("Storage schema initialized");
282
295
  }).pipe(effect.Effect.mapError(wrapSqlError("initialize")));
283
296
  const insertEvents = (events) => effect.Effect.gen(function* () {
284
- for (const event of events) {
285
- const argsJson = JSON.stringify(event.args, toJsonValue);
286
- const blockNum = Number(event.blockNumber);
287
- const ts = event.timestamp !== null ? Number(event.timestamp) : null;
288
- yield* sql$1`
289
- INSERT OR IGNORE INTO events (contract_name, event_name, block_number, tx_hash, log_index, timestamp, args)
290
- VALUES (${event.contractName}, ${event.eventName}, ${blockNum}, ${event.txHash}, ${event.logIndex}, ${ts}, ${argsJson})
291
- `;
297
+ if (events.length === 0) {
298
+ return;
299
+ }
300
+ for (const batch of chunkEvents(events, INSERT_BATCH_SIZE)) {
301
+ const placeholders = batch.map(() => "(?, ?, ?, ?, ?, ?, ?)").join(", ");
302
+ const params = batch.flatMap((event) => {
303
+ const argsJson = JSON.stringify(event.args, toJsonValue);
304
+ const blockNum = Number(event.blockNumber);
305
+ const ts = event.timestamp !== null ? Number(event.timestamp) : null;
306
+ return [
307
+ event.contractName,
308
+ event.eventName,
309
+ blockNum,
310
+ event.txHash,
311
+ event.logIndex,
312
+ ts,
313
+ argsJson
314
+ ];
315
+ });
316
+ yield* sql$1.unsafe(
317
+ `INSERT OR IGNORE INTO events (contract_name, event_name, block_number, tx_hash, log_index, timestamp, args)
318
+ VALUES ${placeholders}`,
319
+ params
320
+ );
292
321
  }
293
322
  }).pipe(effect.Effect.mapError(wrapSqlError("insertEvents")));
294
323
  const deleteEventsFrom = (blockNumber) => sql$1`DELETE FROM events WHERE block_number >= ${Number(blockNumber)}`.pipe(
@@ -479,8 +508,20 @@ var EventDecoderLive = effect.Layer.succeed(EventDecoder, {
479
508
  try: () => decodeLog(contractName, abi, log),
480
509
  catch: (e) => new DecodeError({ reason: String(e), log, cause: e })
481
510
  }),
482
- decodeBatch: (contractName, abi, logs) => effect.Effect.succeed(
483
- logs.map((log) => decodeLog(contractName, abi, log)).filter((e) => e !== null)
511
+ decodeBatch: (contractName, abi, logs) => effect.Effect.forEach(
512
+ logs,
513
+ (log) => effect.Effect.gen(function* () {
514
+ const decoded = decodeLog(contractName, abi, log);
515
+ if (decoded === null) {
516
+ return yield* effect.Effect.fail(
517
+ new DecodeError({
518
+ reason: "Failed to decode log with provided ABI",
519
+ log
520
+ })
521
+ );
522
+ }
523
+ return decoded;
524
+ })
484
525
  )
485
526
  });
486
527
  var computeSnapshot = (p) => {
@@ -672,22 +713,33 @@ var ProgressRendererLive = effect.Layer.effect(
672
713
  };
673
714
  })
674
715
  );
675
- var buildTopicFilter = (abi, eventNames) => {
676
- const topics = [];
677
- for (const name of eventNames) {
716
+ var buildTopicFilterEffect = (abi, eventNames) => effect.Effect.forEach(
717
+ eventNames,
718
+ (name) => effect.Effect.gen(function* () {
678
719
  const abiEvent = abi.find(
679
720
  (item) => item.type === "event" && item.name === name
680
721
  );
681
722
  if (!abiEvent || abiEvent.type !== "event") {
682
- throw new Error(`Event "${name}" not found in ABI`);
723
+ return yield* effect.Effect.fail(
724
+ new ConfigError({
725
+ reason: `Event "${name}" not found in ABI`,
726
+ field: "contracts.events"
727
+ })
728
+ );
683
729
  }
684
730
  const encoded = viem.encodeEventTopics({ abi: [abiEvent], eventName: name });
685
- if (encoded[0]) {
686
- topics.push(encoded[0]);
731
+ const topic = encoded[0];
732
+ if (topic === void 0) {
733
+ return yield* effect.Effect.fail(
734
+ new ConfigError({
735
+ reason: `Failed to encode topic for event "${name}"`,
736
+ field: "contracts.events"
737
+ })
738
+ );
687
739
  }
688
- }
689
- return topics;
690
- };
740
+ return topic;
741
+ })
742
+ );
691
743
  var fetchLogs = (params) => effect.Stream.unwrap(
692
744
  effect.Effect.gen(function* () {
693
745
  const config = yield* Config;
@@ -847,6 +899,28 @@ var indexContract = (contract) => effect.Stream.unwrap(
847
899
  const blockCursor = yield* BlockCursor;
848
900
  const progress = yield* ProgressReporter;
849
901
  const renderer = yield* ProgressRenderer;
902
+ const { baseDelayMs, maxDelayMs } = config.network.logs.retry;
903
+ const maxRetries = config.network.logs.maxRetries;
904
+ const blockRetrySchedule = effect.Schedule.exponential(
905
+ effect.Duration.millis(baseDelayMs)
906
+ ).pipe(
907
+ effect.Schedule.delayed(
908
+ (duration) => effect.Duration.millis(Math.min(effect.Duration.toMillis(duration), maxDelayMs))
909
+ ),
910
+ effect.Schedule.compose(effect.Schedule.recurs(maxRetries))
911
+ );
912
+ const getBlockWithRetry = (blockNumber) => rpc.getBlock(blockNumber).pipe(
913
+ effect.Effect.tapError(
914
+ (err) => effect.Effect.logDebug("RPC getBlock failed, retrying").pipe(
915
+ effect.Effect.annotateLogs({
916
+ block: blockNumber.toString(),
917
+ reason: err.reason,
918
+ method: "eth_getBlockByNumber"
919
+ })
920
+ )
921
+ ),
922
+ effect.Effect.retry(blockRetrySchedule)
923
+ );
850
924
  const startBlock = yield* checkpoint.getStartBlock(
851
925
  contract.name,
852
926
  contract.startBlock ?? 0n
@@ -858,7 +932,10 @@ var indexContract = (contract) => effect.Stream.unwrap(
858
932
  toBlock: currentHead.toString()
859
933
  })
860
934
  );
861
- const topics = buildTopicFilter(contract.abi, contract.events);
935
+ const topics = yield* buildTopicFilterEffect(
936
+ contract.abi,
937
+ contract.events
938
+ );
862
939
  const needsBackfill = startBlock <= currentHead;
863
940
  const totalBackfillBlocks = currentHead - startBlock + 1n;
864
941
  if (needsBackfill) {
@@ -884,7 +961,7 @@ var indexContract = (contract) => effect.Stream.unwrap(
884
961
  ];
885
962
  const withTimestamp = [];
886
963
  for (const bn of blockNumbers) {
887
- const blockInfo = yield* rpc.getBlock(bn);
964
+ const blockInfo = yield* getBlockWithRetry(bn);
888
965
  yield* reorgDetector.verifyBlock(blockInfo);
889
966
  for (const event of decoded) {
890
967
  if (event.blockNumber === bn) {
@@ -943,7 +1020,7 @@ var indexContract = (contract) => effect.Stream.unwrap(
943
1020
  const liveStream = blockCursor.liveBlocks.pipe(
944
1021
  effect.Stream.mapEffect(
945
1022
  (blockNumber) => effect.Effect.gen(function* () {
946
- const blockInfo = yield* rpc.getBlock(blockNumber);
1023
+ const blockInfo = yield* getBlockWithRetry(blockNumber);
947
1024
  const reorgResult = yield* effect.Effect.either(
948
1025
  reorgDetector.verifyBlock(blockInfo)
949
1026
  );
@@ -1062,6 +1139,14 @@ var parseStoredEvent = (row) => ({
1062
1139
  timestamp: row.timestamp,
1063
1140
  args: JSON.parse(row.args)
1064
1141
  });
1142
+ var parseStoredEventEffect = (row) => effect.Effect.try({
1143
+ try: () => parseStoredEvent(row),
1144
+ catch: (e) => new StorageError({
1145
+ reason: String(e),
1146
+ operation: "parseStoredEvent",
1147
+ cause: e
1148
+ })
1149
+ });
1065
1150
  var QueryApi = class extends effect.Context.Tag("effective-indexer/QueryApi")() {
1066
1151
  };
1067
1152
  var QueryApiLive = effect.Layer.effect(
@@ -1073,7 +1158,7 @@ var QueryApiLive = effect.Layer.effect(
1073
1158
  (_, v) => typeof v === "bigint" ? v.toString() : v
1074
1159
  );
1075
1160
  const getEvents = (query) => storage.queryEvents(query ?? {}).pipe(
1076
- effect.Effect.map((rows) => rows.map(parseStoredEvent)),
1161
+ effect.Effect.flatMap((rows) => effect.Effect.forEach(rows, parseStoredEventEffect)),
1077
1162
  effect.Effect.timed,
1078
1163
  effect.Effect.tap(
1079
1164
  ([duration, results]) => effect.Effect.logDebug("Query executed").pipe(
@@ -1165,6 +1250,21 @@ var resolveIndexerConfigFromEnv = (config, options) => {
1165
1250
  };
1166
1251
 
1167
1252
  // src/index.ts
1253
+ var createWebhookNotifier = (webhookUrl, init) => async (notification) => {
1254
+ const response = await fetch(webhookUrl, {
1255
+ method: "POST",
1256
+ headers: {
1257
+ "content-type": "application/json",
1258
+ ...init?.headers ?? {}
1259
+ },
1260
+ body: JSON.stringify(notification)
1261
+ });
1262
+ if (!response.ok) {
1263
+ throw new Error(
1264
+ `Notification webhook failed with status ${response.status}`
1265
+ );
1266
+ }
1267
+ };
1168
1268
  var buildLayers = (config) => {
1169
1269
  const resolved = resolveConfig(config);
1170
1270
  const ConfigLayer = ConfigLive(config);
@@ -1209,6 +1309,8 @@ var createIndexer = (config) => {
1209
1309
  let abortController = null;
1210
1310
  const runtime = effect.ManagedRuntime.make(ServicesLive);
1211
1311
  let runningPromise = null;
1312
+ let stopPromise = null;
1313
+ let disposed = false;
1212
1314
  return {
1213
1315
  start: async () => {
1214
1316
  if (runningPromise !== null) {
@@ -1226,21 +1328,41 @@ var createIndexer = (config) => {
1226
1328
  });
1227
1329
  },
1228
1330
  stop: async () => {
1229
- abortController?.abort();
1230
- const wasAborted = abortController?.signal.aborted ?? false;
1231
- if (runningPromise !== null) {
1232
- try {
1233
- await runningPromise;
1234
- } catch (error) {
1235
- if (!wasAborted) {
1236
- throw error;
1331
+ if (disposed) {
1332
+ return;
1333
+ }
1334
+ if (stopPromise !== null) {
1335
+ return stopPromise;
1336
+ }
1337
+ stopPromise = (async () => {
1338
+ abortController?.abort();
1339
+ const wasAborted = abortController?.signal.aborted ?? false;
1340
+ if (runningPromise !== null) {
1341
+ try {
1342
+ await runningPromise;
1343
+ } catch (error) {
1344
+ if (!wasAborted) {
1345
+ throw error;
1346
+ }
1347
+ } finally {
1348
+ runningPromise = null;
1237
1349
  }
1238
- } finally {
1239
- runningPromise = null;
1240
1350
  }
1351
+ await runtime.dispose();
1352
+ abortController = null;
1353
+ disposed = true;
1354
+ })();
1355
+ try {
1356
+ await stopPromise;
1357
+ } finally {
1358
+ stopPromise = null;
1241
1359
  }
1242
- await runtime.dispose();
1243
- abortController = null;
1360
+ },
1361
+ waitForExit: async () => {
1362
+ if (runningPromise === null) {
1363
+ return;
1364
+ }
1365
+ await runningPromise;
1244
1366
  },
1245
1367
  query: async (q) => {
1246
1368
  return runtime.runPromise(
@@ -1261,9 +1383,7 @@ var createIndexer = (config) => {
1261
1383
  };
1262
1384
  };
1263
1385
  var defaultWorkerRuntime = {
1264
- process,
1265
- setInterval,
1266
- clearInterval
1386
+ process
1267
1387
  };
1268
1388
  var resolveDbPath = (config) => config.dbPath ?? "./indexer.db";
1269
1389
  var ensureDbDirectory = async (config) => {
@@ -1280,46 +1400,92 @@ var runIndexerWorker = async (config, options) => {
1280
1400
  const runtime = options?.runtime ?? defaultWorkerRuntime;
1281
1401
  const create = options?.createIndexer ?? createIndexer;
1282
1402
  const signals = options?.shutdownSignals ?? ["SIGINT", "SIGTERM"];
1283
- const keepAliveIntervalMs = options?.keepAliveIntervalMs ?? 6e4;
1284
- const indexer = create(config);
1285
- await indexer.start();
1286
- await new Promise((resolve, reject) => {
1287
- const keepAliveTimer = runtime.setInterval(
1288
- () => void 0,
1289
- keepAliveIntervalMs
1290
- );
1291
- let stopping = false;
1292
- const signalHandlers = /* @__PURE__ */ new Map();
1293
- const cleanup = () => {
1294
- runtime.clearInterval(keepAliveTimer);
1295
- for (const signal of signals) {
1296
- const handler = signalHandlers.get(signal);
1297
- if (handler !== void 0) {
1298
- runtime.process.off(signal, handler);
1299
- }
1300
- }
1301
- };
1302
- const handleStop = async () => {
1303
- if (stopping) {
1304
- return;
1305
- }
1306
- stopping = true;
1307
- cleanup();
1308
- try {
1309
- await indexer.stop();
1310
- resolve();
1311
- } catch (error) {
1312
- reject(error);
1313
- }
1314
- };
1403
+ const webhookUrl = config.worker?.alert?.webhookUrl;
1404
+ const configNotifier = webhookUrl && webhookUrl.length > 0 ? createWebhookNotifier(webhookUrl) : null;
1405
+ const onRecoveryFailure = options?.onRecoveryFailure ?? configNotifier ?? void 0;
1406
+ const recovery = {
1407
+ enabled: options?.recovery?.enabled ?? config.worker?.recovery?.enabled ?? true,
1408
+ maxRecoveryDurationMs: options?.recovery?.maxRecoveryDurationMs ?? config.worker?.recovery?.maxRecoveryDurationMs ?? 15 * 60 * 1e3,
1409
+ initialRetryDelayMs: options?.recovery?.initialRetryDelayMs ?? config.worker?.recovery?.initialRetryDelayMs ?? 1e3,
1410
+ maxRetryDelayMs: options?.recovery?.maxRetryDelayMs ?? config.worker?.recovery?.maxRetryDelayMs ?? 3e4,
1411
+ backoffFactor: options?.recovery?.backoffFactor ?? config.worker?.recovery?.backoffFactor ?? 2
1412
+ };
1413
+ const signalHandlers = /* @__PURE__ */ new Map();
1414
+ let stopRequested = false;
1415
+ let activeIndexer = null;
1416
+ const stopSignalPromise = new Promise((resolve) => {
1315
1417
  for (const signal of signals) {
1316
1418
  const handler = () => {
1317
- void handleStop();
1419
+ stopRequested = true;
1420
+ resolve();
1318
1421
  };
1319
1422
  signalHandlers.set(signal, handler);
1320
1423
  runtime.process.on(signal, handler);
1321
1424
  }
1322
1425
  });
1426
+ const cleanup = () => {
1427
+ for (const signal of signals) {
1428
+ const handler = signalHandlers.get(signal);
1429
+ if (handler !== void 0) {
1430
+ runtime.process.off(signal, handler);
1431
+ }
1432
+ }
1433
+ };
1434
+ const stopActiveIndexer = async () => {
1435
+ if (activeIndexer !== null) {
1436
+ try {
1437
+ await activeIndexer.stop();
1438
+ } finally {
1439
+ activeIndexer = null;
1440
+ }
1441
+ }
1442
+ };
1443
+ let firstFailureAt = null;
1444
+ let attempts = 0;
1445
+ try {
1446
+ while (!stopRequested) {
1447
+ activeIndexer = create(config);
1448
+ try {
1449
+ await activeIndexer.start();
1450
+ await Promise.race([stopSignalPromise, activeIndexer.waitForExit()]);
1451
+ if (stopRequested) {
1452
+ break;
1453
+ }
1454
+ throw new Error("Indexer worker exited unexpectedly");
1455
+ } catch (error) {
1456
+ if (stopRequested) {
1457
+ break;
1458
+ }
1459
+ attempts += 1;
1460
+ firstFailureAt = firstFailureAt ?? Date.now();
1461
+ const recoveryDurationMs = Date.now() - firstFailureAt;
1462
+ await stopActiveIndexer();
1463
+ if (!recovery.enabled || recoveryDurationMs >= recovery.maxRecoveryDurationMs) {
1464
+ if (onRecoveryFailure) {
1465
+ await onRecoveryFailure({
1466
+ attempts,
1467
+ recoveryDurationMs,
1468
+ error,
1469
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1470
+ });
1471
+ }
1472
+ throw error;
1473
+ }
1474
+ const delay = Math.min(
1475
+ recovery.initialRetryDelayMs * recovery.backoffFactor ** Math.max(attempts - 1, 0),
1476
+ recovery.maxRetryDelayMs
1477
+ );
1478
+ console.error(
1479
+ `Indexer worker crashed, retrying in ${delay}ms (attempt ${attempts})`,
1480
+ error
1481
+ );
1482
+ await effect.Effect.runPromise(effect.Effect.sleep(effect.Duration.millis(delay)));
1483
+ }
1484
+ }
1485
+ } finally {
1486
+ await stopActiveIndexer();
1487
+ cleanup();
1488
+ }
1323
1489
  };
1324
1490
  var Indexer = {
1325
1491
  create: createIndexer
@@ -1349,8 +1515,10 @@ exports.Storage = Storage;
1349
1515
  exports.StorageLive = StorageLive;
1350
1516
  exports.computeSnapshot = computeSnapshot;
1351
1517
  exports.createIndexer = createIndexer;
1518
+ exports.createWebhookNotifier = createWebhookNotifier;
1352
1519
  exports.defineIndexerConfig = defineIndexerConfig;
1353
1520
  exports.resolveConfig = resolveConfig;
1521
+ exports.resolveConfigEffect = resolveConfigEffect;
1354
1522
  exports.resolveIndexerConfigFromEnv = resolveIndexerConfigFromEnv;
1355
1523
  exports.runIndexerWorker = runIndexerWorker;
1356
1524
  //# sourceMappingURL=index.cjs.map