envio 3.6.1 → 3.7.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.
Files changed (80) hide show
  1. package/index.d.ts +197 -9
  2. package/package.json +6 -6
  3. package/src/Batch.res +64 -25
  4. package/src/Batch.res.mjs +69 -46
  5. package/src/ChainFetching.res +13 -17
  6. package/src/ChainFetching.res.mjs +9 -14
  7. package/src/ChainState.res +160 -119
  8. package/src/ChainState.res.mjs +75 -46
  9. package/src/ChainState.resi +15 -12
  10. package/src/Config.res +9 -2
  11. package/src/Config.res.mjs +4 -4
  12. package/src/Core.res +5 -0
  13. package/src/Ecosystem.res +0 -1
  14. package/src/Envio.res +16 -3
  15. package/src/EventConfigBuilder.res +155 -40
  16. package/src/EventConfigBuilder.res.mjs +76 -24
  17. package/src/EventProcessing.res +4 -8
  18. package/src/EventProcessing.res.mjs +4 -4
  19. package/src/EventUtils.res +3 -47
  20. package/src/FetchState.res +124 -104
  21. package/src/FetchState.res.mjs +116 -100
  22. package/src/HandlerRegister.res +108 -2
  23. package/src/HandlerRegister.res.mjs +59 -10
  24. package/src/HandlerRegister.resi +4 -0
  25. package/src/Internal.res +84 -24
  26. package/src/Internal.res.mjs +33 -2
  27. package/src/LazyLoader.res +12 -11
  28. package/src/LazyLoader.res.mjs +13 -7
  29. package/src/Main.res +22 -9
  30. package/src/Main.res.mjs +18 -5
  31. package/src/MemoryStorage.res +724 -0
  32. package/src/MemoryStorage.res.mjs +601 -0
  33. package/src/PgStorage.res +18 -7
  34. package/src/PgStorage.res.mjs +14 -11
  35. package/src/ReorgDetection.res +0 -222
  36. package/src/ReorgDetection.res.mjs +0 -163
  37. package/src/Rollback.res +14 -15
  38. package/src/Rollback.res.mjs +4 -5
  39. package/src/SimulateItems.res +2 -2
  40. package/src/Utils.res +3 -0
  41. package/src/bindings/ClickHouse.res +16 -11
  42. package/src/bindings/ClickHouse.res.mjs +10 -10
  43. package/src/bindings/Vitest.res +1 -1
  44. package/src/sources/BlockStore.res +115 -5
  45. package/src/sources/BlockStore.res.mjs +25 -0
  46. package/src/sources/Evm.res +0 -1
  47. package/src/sources/Evm.res.mjs +0 -1
  48. package/src/sources/EvmHyperSyncSource.res +19 -84
  49. package/src/sources/EvmHyperSyncSource.res.mjs +24 -60
  50. package/src/sources/Fuel.res +24 -4
  51. package/src/sources/Fuel.res.mjs +23 -3
  52. package/src/sources/FuelHyperSync.res +8 -3
  53. package/src/sources/FuelHyperSync.res.mjs +5 -4
  54. package/src/sources/FuelHyperSync.resi +3 -1
  55. package/src/sources/FuelHyperSyncClient.res +7 -10
  56. package/src/sources/FuelHyperSyncSource.res +18 -45
  57. package/src/sources/FuelHyperSyncSource.res.mjs +18 -35
  58. package/src/sources/HyperSync.res +49 -229
  59. package/src/sources/HyperSync.res.mjs +74 -191
  60. package/src/sources/HyperSync.resi +11 -35
  61. package/src/sources/HyperSyncClient.res +9 -79
  62. package/src/sources/HyperSyncClient.res.mjs +2 -6
  63. package/src/sources/RequestStat.res +5 -0
  64. package/src/sources/RequestStat.res.mjs +2 -0
  65. package/src/sources/RpcSource.res +149 -43
  66. package/src/sources/RpcSource.res.mjs +104 -41
  67. package/src/sources/SimulateSource.res +8 -5
  68. package/src/sources/SimulateSource.res.mjs +6 -6
  69. package/src/sources/Source.res +89 -16
  70. package/src/sources/Source.res.mjs +50 -1
  71. package/src/sources/SourceManager.res +255 -50
  72. package/src/sources/SourceManager.res.mjs +149 -55
  73. package/src/sources/SourceManager.resi +2 -6
  74. package/src/sources/Svm.res +0 -7
  75. package/src/sources/Svm.res.mjs +0 -8
  76. package/src/sources/SvmHyperSyncClient.res +21 -16
  77. package/src/sources/SvmHyperSyncClient.res.mjs +3 -4
  78. package/src/sources/SvmHyperSyncSource.res +25 -117
  79. package/src/sources/SvmHyperSyncSource.res.mjs +8 -121
  80. package/svm.schema.json +3 -2
@@ -0,0 +1,724 @@
1
+ // A `Persistence.storage` that keeps everything in process memory, including
2
+ // entity history and the rollback queries a reorg needs. It backs
3
+ // `createTestIndexer` and the scenario suite's in-memory leg, so a test that
4
+ // passes here means the same thing it means against Postgres.
5
+ //
6
+ // Entities are stored decoded — there's no worker boundary to serialize across —
7
+ // so loads and history comparisons work on real bigint/BigDecimal values.
8
+
9
+ type checkpointRow = {
10
+ id: Internal.checkpointId,
11
+ chainId: ChainId.t,
12
+ blockNumber: int,
13
+ blockHash: option<string>,
14
+ eventsProcessed: int,
15
+ }
16
+
17
+ // One entity change, kept the way the Postgres history table keeps it: the row
18
+ // as of the change, tagged with the checkpoint that produced it. A DELETE
19
+ // carries no entity — only the key it removed.
20
+ type historyRow = {
21
+ entityId: EntityId.t,
22
+ scope: Internal.chainScope,
23
+ checkpointId: Internal.checkpointId,
24
+ action: EntityHistory.RowAction.t,
25
+ entity: option<Internal.entity>,
26
+ }
27
+
28
+ type chainRow = {
29
+ id: ChainId.t,
30
+ startBlock: int,
31
+ endBlock: option<int>,
32
+ maxReorgDepth: int,
33
+ mutable progressBlockNumber: int,
34
+ mutable sourceBlockNumber: int,
35
+ mutable numEventsProcessed: float,
36
+ mutable firstEventBlockNumber: option<int>,
37
+ mutable latestFetchedBlockNumber: int,
38
+ mutable timestampCaughtUpToHeadOrEndblock: option<Date.t>,
39
+ }
40
+
41
+ type t = {
42
+ // tableName -> row key -> entity, the current state a load reads.
43
+ entities: dict<dict<Internal.entity>>,
44
+ entityConfigs: dict<Internal.entityConfig>,
45
+ // entityName -> changes in write order.
46
+ history: dict<array<historyRow>>,
47
+ mutable checkpoints: array<checkpointRow>,
48
+ chains: dict<chainRow>,
49
+ cache: dict<Persistence.effectCacheRecord>,
50
+ effectCache: dict<dict<Internal.effectCacheItem>>,
51
+ mutable envioInfo: option<JSON.t>,
52
+ mutable isInitialized: bool,
53
+ }
54
+
55
+ let make = (): t => {
56
+ entities: Dict.make(),
57
+ entityConfigs: Dict.make(),
58
+ history: Dict.make(),
59
+ checkpoints: [],
60
+ chains: Dict.make(),
61
+ cache: Dict.make(),
62
+ effectCache: Dict.make(),
63
+ envioInfo: None,
64
+ isInitialized: false,
65
+ }
66
+
67
+ // Rows of a per-chain entity are keyed per (chain, id): the same id exists
68
+ // independently on every chain.
69
+ let rowKey = (~scope: Internal.chainScope, ~entityId: EntityId.t) =>
70
+ switch scope {
71
+ | CrossChain => entityId->EntityId.toKey
72
+ | Chain(chainId) => `${chainId->ChainId.toString}|${entityId->EntityId.toKey}`
73
+ }
74
+
75
+ let historyKey = (~scope: Internal.chainScope, ~entityId: EntityId.t) => rowKey(~scope, ~entityId)
76
+
77
+ let readChainId = (entity: Internal.entity, ~field: Table.field): option<ChainId.t> =>
78
+ entity
79
+ ->(Utils.magic: Internal.entity => dict<ChainId.t>)
80
+ ->Utils.Dict.dangerouslyGetNonOption(field.fieldName)
81
+
82
+ // The store owns its entities. Copy on the boundary with user code — both when
83
+ // handing one out and when taking one in — so a user mutating a returned
84
+ // entity, or an object they passed to `set`, can't corrupt the store. The copy
85
+ // is shallow (matching InMemoryTable): scalar fields are immutable, but
86
+ // array-valued fields still share the backing array.
87
+ let copyEntity = (entity: Internal.entity): Internal.entity =>
88
+ entity
89
+ ->(Utils.magic: Internal.entity => dict<unknown>)
90
+ ->Utils.Dict.shallowCopy
91
+ ->(Utils.magic: dict<unknown> => Internal.entity)
92
+
93
+ let getEntityDict = (state: t, ~name) =>
94
+ switch state.entities->Dict.get(name) {
95
+ | Some(dict) => dict
96
+ | None =>
97
+ let dict = Dict.make()
98
+ state.entities->Dict.set(name, dict)
99
+ dict
100
+ }
101
+
102
+ let getHistory = (state: t, ~name) =>
103
+ switch state.history->Dict.get(name) {
104
+ | Some(rows) => rows
105
+ | None =>
106
+ let rows = []
107
+ state.history->Dict.set(name, rows)
108
+ rows
109
+ }
110
+
111
+ let registerEntities = (state: t, ~entities: array<Internal.entityConfig>) =>
112
+ entities->Array.forEach(entityConfig => {
113
+ state.entityConfigs->Dict.set(entityConfig.name, entityConfig)
114
+ state.entityConfigs->Dict.set(entityConfig.table.tableName, entityConfig)
115
+ let _ = state->getEntityDict(~name=entityConfig.name)
116
+ })
117
+
118
+ // Seeds the config's contract addresses, mirroring what PgStorage.initialize
119
+ // writes into `envio_addresses`.
120
+ let seedIndexingAddresses = (state: t, ~chainConfigs: array<Config.chain>) => {
121
+ let dict = state->getEntityDict(~name=InternalTable.EnvioAddresses.name)
122
+ chainConfigs->Array.forEach(chainConfig =>
123
+ chainConfig.contracts->Array.forEach(contract =>
124
+ contract.addresses->Array.forEach(
125
+ address => {
126
+ let entity: InternalTable.EnvioAddresses.t = {
127
+ id: Config.EnvioAddresses.makeId(~chainId=chainConfig.id, ~address),
128
+ chainId: chainConfig.id,
129
+ contractName: contract.name,
130
+ registrationBlock: -1,
131
+ registrationLogIndex: -1,
132
+ }
133
+ dict->Dict.set(entity.id, entity->Config.EnvioAddresses.castToInternal)
134
+ },
135
+ )
136
+ )
137
+ )
138
+ }
139
+
140
+ external castToEnvioAddresses: Internal.entity => InternalTable.EnvioAddresses.t = "%identity"
141
+
142
+ let toIndexingAddress = (dc: InternalTable.EnvioAddresses.t): Internal.indexingAddress => {
143
+ address: dc->Config.EnvioAddresses.getAddress,
144
+ contractName: dc.contractName,
145
+ registrationBlock: dc.registrationBlock,
146
+ }
147
+
148
+ // All indexing addresses (config-seeded + dynamically registered) grouped by
149
+ // chain id string, derived from the envio_addresses entities.
150
+ let getIndexingAddressesByChain = (state: t): dict<array<Internal.indexingAddress>> => {
151
+ let byChain = Dict.make()
152
+ switch state.entities->Dict.get(InternalTable.EnvioAddresses.name) {
153
+ | Some(dcDict) =>
154
+ dcDict
155
+ ->Dict.valuesToArray
156
+ ->Array.forEach(entity => {
157
+ let dc = entity->castToEnvioAddresses
158
+ let chainIdStr = dc.chainId->ChainId.toString
159
+ let contracts = switch byChain->Dict.get(chainIdStr) {
160
+ | Some(arr) => arr
161
+ | None =>
162
+ let arr = []
163
+ byChain->Dict.set(chainIdStr, arr)
164
+ arr
165
+ }
166
+ contracts->Array.push(dc->toIndexingAddress)->ignore
167
+ })
168
+ | None => ()
169
+ }
170
+ byChain
171
+ }
172
+
173
+ let toInitialChainStates = (state: t): array<Persistence.initialChainState> => {
174
+ let addressesByChain = state->getIndexingAddressesByChain
175
+ state.chains
176
+ ->Dict.valuesToArray
177
+ ->Array.map((chain): Persistence.initialChainState => {
178
+ id: chain.id,
179
+ startBlock: chain.startBlock,
180
+ endBlock: chain.endBlock,
181
+ maxReorgDepth: chain.maxReorgDepth,
182
+ progressBlockNumber: chain.progressBlockNumber,
183
+ numEventsProcessed: chain.numEventsProcessed,
184
+ firstEventBlockNumber: chain.firstEventBlockNumber,
185
+ timestampCaughtUpToHeadOrEndblock: chain.timestampCaughtUpToHeadOrEndblock,
186
+ indexingAddresses: addressesByChain
187
+ ->Dict.get(chain.id->ChainId.toString)
188
+ ->Option.getOr([]),
189
+ sourceBlockNumber: chain.sourceBlockNumber,
190
+ })
191
+ }
192
+
193
+ let committedCheckpointId = (state: t) =>
194
+ state.checkpoints->Array.reduce(InternalTable.Checkpoints.initialCheckpointId, (max, cp) =>
195
+ cp.id > max ? cp.id : max
196
+ )
197
+
198
+ // Mirrors `makeGetReorgCheckpointsQuery`: the hashed checkpoints still inside
199
+ // a reorg-capable chain's threshold, which is what reorg detection resumes from.
200
+ let reorgCheckpoints = (state: t): array<Internal.reorgCheckpoint> =>
201
+ state.checkpoints->Array.filterMap(cp =>
202
+ switch (cp.blockHash, state.chains->Dict.get(cp.chainId->ChainId.toString)) {
203
+ | (Some(blockHash), Some(chain)) =>
204
+ let safeBlock = chain.sourceBlockNumber - chain.maxReorgDepth
205
+ if (
206
+ chain.maxReorgDepth > 0 &&
207
+ chain.progressBlockNumber > safeBlock &&
208
+ cp.blockNumber >= safeBlock
209
+ ) {
210
+ Some({
211
+ Internal.checkpointId: cp.id,
212
+ chainId: cp.chainId,
213
+ blockNumber: cp.blockNumber,
214
+ blockHash,
215
+ })
216
+ } else {
217
+ None
218
+ }
219
+ | _ => None
220
+ }
221
+ )
222
+
223
+ let toInitialState = (state: t, ~cleanRun): Persistence.initialState => {
224
+ cleanRun,
225
+ cache: state.cache,
226
+ chains: state->toInitialChainStates,
227
+ checkpointId: state->committedCheckpointId,
228
+ reorgCheckpoints: state->reorgCheckpoints,
229
+ envioInfo: state.envioInfo,
230
+ }
231
+
232
+ let handleLoad = (state: t, ~tableName: string, ~filter: EntityFilter.t): array<
233
+ Internal.entity,
234
+ > => {
235
+ // Loads for non-entity tables (e.g. effect caches `envio_effect_<name>`) reach
236
+ // here too. Nothing persists those, so there's nothing to return — an empty
237
+ // result makes the effect recompute instead of crashing on a missing config.
238
+ switch state.entityConfigs->Dict.get(tableName) {
239
+ | None => []
240
+ | Some(entityConfig) =>
241
+ let entityDict = state.entities->Dict.get(entityConfig.name)->Option.getOr(Dict.make())
242
+ let matched =
243
+ entityDict
244
+ ->Dict.valuesToArray
245
+ ->Array.filter(entity => {
246
+ // The store holds decoded entities and the filter carries decoded values,
247
+ // so compare directly (same approach as InMemoryTable) — no JSON round-trip.
248
+ let entityAsDict = entity->(Utils.magic: Internal.entity => dict<EntityFilter.FieldValue.t>)
249
+ filter->EntityFilter.matches(~entity=entityAsDict)
250
+ })
251
+ // The chain is already fixed by the scope the load ran for, so the loaded
252
+ // entity is handed back in the shape the handlers see.
253
+ switch entityConfig.table->Table.getChainIdField {
254
+ | None => matched
255
+ | Some(field) =>
256
+ matched->Array.map(entity => {
257
+ let copy = entity->(Utils.magic: Internal.entity => dict<unknown>)->Utils.Dict.shallowCopy
258
+ copy->Utils.Dict.deleteInPlace(field.fieldName)
259
+ copy->(Utils.magic: dict<unknown> => Internal.entity)
260
+ })
261
+ }
262
+ }
263
+ }
264
+
265
+ // Postgres backfills a history row for an entity that predates history being
266
+ // kept, so a rollback can restore it. Same here: the current row is recorded as
267
+ // a SET at the initial checkpoint the first time the entity gains history.
268
+ let backfillHistory = (
269
+ state: t,
270
+ ~entityConfig: Internal.entityConfig,
271
+ ~scope,
272
+ ~entityId,
273
+ ~rows: array<historyRow>,
274
+ ) => {
275
+ let key = historyKey(~scope, ~entityId)
276
+ if !(rows->Array.some(row => historyKey(~scope=row.scope, ~entityId=row.entityId) === key)) {
277
+ switch state.entities
278
+ ->Dict.get(entityConfig.name)
279
+ ->Option.flatMap(dict => dict->Dict.get(rowKey(~scope, ~entityId))) {
280
+ | Some(entity) =>
281
+ rows
282
+ ->Array.push({
283
+ entityId,
284
+ scope,
285
+ checkpointId: InternalTable.Checkpoints.initialCheckpointId,
286
+ action: EntityHistory.RowAction.SET,
287
+ entity: Some(entity),
288
+ })
289
+ ->ignore
290
+ | None => ()
291
+ }
292
+ }
293
+ }
294
+
295
+ let applyRollback = (state: t, ~targetCheckpointId) => {
296
+ state.checkpoints = state.checkpoints->Array.filter(cp => cp.id <= targetCheckpointId)
297
+ state.history
298
+ ->Dict.toArray
299
+ ->Array.forEach(((name, rows)) =>
300
+ state.history->Dict.set(name, rows->Array.filter(row => row.checkpointId <= targetCheckpointId))
301
+ )
302
+ }
303
+
304
+ let writeBatch = (
305
+ state: t,
306
+ ~batch: Batch.t,
307
+ ~rollback: option<Persistence.rollback>,
308
+ ~isInReorgThreshold,
309
+ ~config: Config.t,
310
+ ~updatedEntities: array<Persistence.updatedEntity>,
311
+ ~updatedEffectsCache: array<Persistence.updatedEffectCache>,
312
+ ~chainMetaData: option<dict<InternalTable.Chains.metaFields>>,
313
+ ) => {
314
+ let shouldSaveHistory = config->Config.shouldSaveHistory(~isInReorgThreshold)
315
+
316
+ // Rollback first, exactly like the Postgres transaction: the batch being
317
+ // written is the reprocessed one, so its rows must land on the reverted state.
318
+ switch rollback {
319
+ | Some({targetCheckpointId}) => state->applyRollback(~targetCheckpointId)
320
+ | None => ()
321
+ }
322
+
323
+ updatedEntities->Array.forEach(({entityConfig, scope, changes}: Persistence.updatedEntity) => {
324
+ let entityDict = state->getEntityDict(~name=entityConfig.name)
325
+ let historyRows = state->getHistory(~name=entityConfig.name)
326
+ // The scope is what makes a per-chain row identifiable, so it's stamped
327
+ // onto the stored entity the same way the Postgres write path does.
328
+ let chainIdField = entityConfig.table->Table.getChainIdField
329
+
330
+ changes->Array.forEach(change => {
331
+ let entityId = change->Change.getEntityId
332
+ if shouldSaveHistory {
333
+ state->backfillHistory(~entityConfig, ~scope, ~entityId, ~rows=historyRows)
334
+ }
335
+ switch change {
336
+ | Set({entity, checkpointId}) =>
337
+ let storedEntity = switch (chainIdField, scope->Internal.chainScopeChainId) {
338
+ | (Some(field), Some(chainId)) =>
339
+ entity->Internal.stampChainId(~fieldName=field.fieldName, ~chainId)
340
+ | _ => entity
341
+ }
342
+ entityDict->Dict.set(rowKey(~scope, ~entityId), storedEntity)
343
+ if shouldSaveHistory {
344
+ historyRows
345
+ ->Array.push({
346
+ entityId,
347
+ scope,
348
+ checkpointId,
349
+ action: EntityHistory.RowAction.SET,
350
+ entity: Some(storedEntity),
351
+ })
352
+ ->ignore
353
+ }
354
+ | Delete({checkpointId}) =>
355
+ entityDict->Utils.Dict.deleteInPlace(rowKey(~scope, ~entityId))
356
+ if shouldSaveHistory {
357
+ historyRows
358
+ ->Array.push({
359
+ entityId,
360
+ scope,
361
+ checkpointId,
362
+ action: EntityHistory.RowAction.DELETE,
363
+ entity: None,
364
+ })
365
+ ->ignore
366
+ }
367
+ }
368
+ })
369
+ })
370
+
371
+ batch.progressedChainsById
372
+ ->Dict.valuesToArray
373
+ ->Array.forEach(chainAfterBatch => {
374
+ let key = chainAfterBatch.fetchState.chainId->ChainId.toString
375
+ switch state.chains->Dict.get(key) {
376
+ | Some(chain) =>
377
+ chain.progressBlockNumber = chainAfterBatch.progressBlockNumber
378
+ chain.sourceBlockNumber = chainAfterBatch.sourceBlockNumber
379
+ chain.numEventsProcessed = chainAfterBatch.totalEventsProcessed
380
+ | None => ()
381
+ }
382
+ })
383
+
384
+ switch chainMetaData {
385
+ | Some(chainsData) =>
386
+ chainsData
387
+ ->Dict.toArray
388
+ ->Array.forEach(((key, meta)) =>
389
+ switch state.chains->Dict.get(key) {
390
+ | Some(chain) =>
391
+ chain.firstEventBlockNumber = meta.firstEventBlockNumber->Null.toOption
392
+ chain.latestFetchedBlockNumber = meta.latestFetchedBlockNumber
393
+ chain.timestampCaughtUpToHeadOrEndblock =
394
+ meta.timestampCaughtUpToHeadOrEndblock->Null.toOption
395
+ | None => ()
396
+ }
397
+ )
398
+ | None => ()
399
+ }
400
+
401
+ if shouldSaveHistory {
402
+ for i in 0 to batch.checkpointIds->Array.length - 1 {
403
+ state.checkpoints
404
+ ->Array.push({
405
+ id: batch.checkpointIds->Array.getUnsafe(i),
406
+ chainId: batch.checkpointChainIds->Array.getUnsafe(i),
407
+ blockNumber: batch.checkpointBlockNumbers->Array.getUnsafe(i),
408
+ blockHash: batch.checkpointBlockHashes->Array.getUnsafe(i)->Null.toOption,
409
+ eventsProcessed: batch.checkpointEventsProcessed->Array.getUnsafe(i),
410
+ })
411
+ ->ignore
412
+ }
413
+ }
414
+
415
+ updatedEffectsCache->Array.forEach(({table, items}: Persistence.updatedEffectCache) => {
416
+ let cacheDict = switch state.effectCache->Dict.get(table.tableName) {
417
+ | Some(dict) => dict
418
+ | None =>
419
+ let dict = Dict.make()
420
+ state.effectCache->Dict.set(table.tableName, dict)
421
+ dict
422
+ }
423
+ items->Array.forEach(item => cacheDict->Dict.set(item.id, item))
424
+ switch state.cache->Dict.get(table.tableName) {
425
+ | Some(record) => record.count = cacheDict->Dict.keysToArray->Array.length
426
+ | None => ()
427
+ }
428
+ })
429
+ }
430
+
431
+ // The latest history row at or before the target, for keys changed after it —
432
+ // the memory equivalent of `makeGetRollbackPreTargetRowsQuery`.
433
+ let getRollbackData = (
434
+ state: t,
435
+ ~entityConfig: Internal.entityConfig,
436
+ ~rollbackTargetCheckpointId,
437
+ ) => {
438
+ let rows = state.history->Dict.get(entityConfig.name)->Option.getOr([])
439
+
440
+ let changedKeys = Dict.make()
441
+ rows->Array.forEach(row =>
442
+ if row.checkpointId > rollbackTargetCheckpointId {
443
+ changedKeys->Dict.set(historyKey(~scope=row.scope, ~entityId=row.entityId), row)
444
+ }
445
+ )
446
+
447
+ let removals = []
448
+ let restored = []
449
+
450
+ changedKeys
451
+ ->Dict.toArray
452
+ ->Array.forEach(((key, changedRow)) => {
453
+ let preTarget = rows->Array.reduce(None, (latest, row) =>
454
+ if (
455
+ historyKey(~scope=row.scope, ~entityId=row.entityId) === key &&
456
+ row.checkpointId <= rollbackTargetCheckpointId
457
+ ) {
458
+ switch latest {
459
+ | Some(current: historyRow) if current.checkpointId >= row.checkpointId => latest
460
+ | _ => Some(row)
461
+ }
462
+ } else {
463
+ latest
464
+ }
465
+ )
466
+ switch preTarget {
467
+ // Nothing before the target: the entity only ever existed after it.
468
+ | None =>
469
+ removals
470
+ ->Array.push({Persistence.entityId: changedRow.entityId, scope: changedRow.scope})
471
+ ->ignore
472
+ | Some({action: DELETE, entityId, scope}) =>
473
+ removals->Array.push({Persistence.entityId, scope})->ignore
474
+ | Some({action: SET, entity: Some(entity)}) =>
475
+ restored->Array.push(entity->(Utils.magic: Internal.entity => unknown))->ignore
476
+ | Some({action: SET, entity: None}) => ()
477
+ }
478
+ })
479
+
480
+ (removals, restored)
481
+ }
482
+
483
+ type progressDiff = {
484
+ chainId: ChainId.t,
485
+ mutable eventsProcessed: int,
486
+ mutable newProgressBlockNumber: int,
487
+ }
488
+
489
+ let getRollbackProgressDiff = (state: t, ~rollbackTargetCheckpointId) => {
490
+ let byChain = Dict.make()
491
+ state.checkpoints->Array.forEach(cp =>
492
+ if cp.id > rollbackTargetCheckpointId {
493
+ let key = cp.chainId->ChainId.toString
494
+ switch byChain->Dict.get(key) {
495
+ | Some(acc) =>
496
+ acc.eventsProcessed = acc.eventsProcessed + cp.eventsProcessed
497
+ acc.newProgressBlockNumber = Math.Int.min(acc.newProgressBlockNumber, cp.blockNumber - 1)
498
+ | None =>
499
+ byChain->Dict.set(
500
+ key,
501
+ {
502
+ chainId: cp.chainId,
503
+ eventsProcessed: cp.eventsProcessed,
504
+ newProgressBlockNumber: cp.blockNumber - 1,
505
+ },
506
+ )
507
+ }
508
+ }
509
+ )
510
+ byChain
511
+ ->Dict.valuesToArray
512
+ ->Array.map(acc =>
513
+ {
514
+ "chain_id": acc.chainId,
515
+ "events_processed_diff": acc.eventsProcessed->Int.toString,
516
+ "new_progress_block_number": acc.newProgressBlockNumber,
517
+ }
518
+ )
519
+ }
520
+
521
+ let toStorage = (state: t, ~config: Config.t): Persistence.storage => {
522
+ name: "memory",
523
+ isInitialized: async () => state.isInitialized,
524
+ initialize: async (~chainConfigs=[], ~entities=[], ~enums as _=[], ~envioInfo) => {
525
+ state->registerEntities(~entities)
526
+ state->seedIndexingAddresses(~chainConfigs)
527
+ chainConfigs->Array.forEach(chainConfig =>
528
+ state.chains->Dict.set(
529
+ chainConfig.id->ChainId.toString,
530
+ {
531
+ id: chainConfig.id,
532
+ startBlock: chainConfig.startBlock,
533
+ endBlock: chainConfig.endBlock,
534
+ maxReorgDepth: chainConfig.maxReorgDepth,
535
+ progressBlockNumber: -1,
536
+ sourceBlockNumber: 0,
537
+ numEventsProcessed: 0.,
538
+ firstEventBlockNumber: None,
539
+ latestFetchedBlockNumber: 0,
540
+ timestampCaughtUpToHeadOrEndblock: None,
541
+ },
542
+ )
543
+ )
544
+ state.envioInfo = Some(envioInfo)
545
+ state.isInitialized = true
546
+ state->toInitialState(~cleanRun=true)
547
+ },
548
+ resumeInitialState: async () => state->toInitialState(~cleanRun=false),
549
+ loadOrThrow: async (~filter, ~table: Table.table) =>
550
+ state
551
+ ->handleLoad(~tableName=table.tableName, ~filter)
552
+ ->(Utils.magic: array<Internal.entity> => array<unknown>),
553
+ // Nothing to index, and the store is always queryable.
554
+ ensureQueryIndexes: async (~table as _, ~filters as _) => (),
555
+ ensureSchemaIndexes: async (~entities as _) => (),
556
+ finalizeBackfill: async (~entities as _, ~chainIds, ~readyAt) =>
557
+ chainIds->Array.forEach(chainId =>
558
+ switch state.chains->Dict.get(chainId->ChainId.toString) {
559
+ | Some(chain) => chain.timestampCaughtUpToHeadOrEndblock = Some(readyAt)
560
+ | None => ()
561
+ }
562
+ ),
563
+ dumpEffectCache: async () => (),
564
+ reset: async () => {
565
+ let clear = dict =>
566
+ dict->Dict.keysToArray->Array.forEach(key => dict->Utils.Dict.deleteInPlace(key))
567
+ state.entities->clear
568
+ state.history->clear
569
+ state.chains->clear
570
+ state.effectCache->clear
571
+ state.cache->clear
572
+ state.checkpoints = []
573
+ state.envioInfo = None
574
+ state.isInitialized = false
575
+ },
576
+ setChainMeta: async chainsData => {
577
+ chainsData
578
+ ->Dict.toArray
579
+ ->Array.forEach(((key, meta)) =>
580
+ switch state.chains->Dict.get(key) {
581
+ | Some(chain) =>
582
+ chain.firstEventBlockNumber = meta.firstEventBlockNumber->Null.toOption
583
+ chain.latestFetchedBlockNumber = meta.latestFetchedBlockNumber
584
+ chain.timestampCaughtUpToHeadOrEndblock =
585
+ meta.timestampCaughtUpToHeadOrEndblock->Null.toOption
586
+ | None => ()
587
+ }
588
+ )
589
+ %raw(`undefined`)
590
+ },
591
+ pruneStaleCheckpoints: async (~safeCheckpointId) => {
592
+ state.checkpoints = state.checkpoints->Array.filter(cp => cp.id >= safeCheckpointId)
593
+ },
594
+ pruneStaleEntityHistory: async (
595
+ ~entityName,
596
+ ~entityIndex as _,
597
+ ~chainIdColumn as _,
598
+ ~safeCheckpointId,
599
+ ) => {
600
+ switch state.history->Dict.get(entityName) {
601
+ | None => ()
602
+ | Some(rows) =>
603
+ // Keep the newest row below the safe point per key: it's what a rollback
604
+ // to the safe checkpoint restores to.
605
+ let newestBelow = Dict.make()
606
+ rows->Array.forEach(row =>
607
+ if row.checkpointId < safeCheckpointId {
608
+ let key = historyKey(~scope=row.scope, ~entityId=row.entityId)
609
+ switch newestBelow->Dict.get(key) {
610
+ | Some(current: historyRow) if current.checkpointId >= row.checkpointId => ()
611
+ | _ => newestBelow->Dict.set(key, row)
612
+ }
613
+ }
614
+ )
615
+ state.history->Dict.set(
616
+ entityName,
617
+ rows->Array.filter(row => {
618
+ let key = historyKey(~scope=row.scope, ~entityId=row.entityId)
619
+ row.checkpointId >= safeCheckpointId ||
620
+ switch newestBelow->Dict.get(key) {
621
+ | Some(kept) => kept === row
622
+ | None => false
623
+ }
624
+ }),
625
+ )
626
+ }
627
+ },
628
+ getRollbackTargetCheckpoint: async (~reorgChainId, ~lastKnownValidBlockNumber) =>
629
+ state.checkpoints->Array.reduce(None, (target, cp) =>
630
+ if cp.chainId == reorgChainId && cp.blockNumber <= lastKnownValidBlockNumber {
631
+ switch target {
632
+ | Some(current) if current >= cp.id => target
633
+ | _ => Some(cp.id)
634
+ }
635
+ } else {
636
+ target
637
+ }
638
+ ),
639
+ getRollbackProgressDiff: async (~rollbackTargetCheckpointId) =>
640
+ state->getRollbackProgressDiff(~rollbackTargetCheckpointId),
641
+ getRollbackData: async (~entityConfig, ~rollbackTargetCheckpointId) =>
642
+ state->getRollbackData(~entityConfig, ~rollbackTargetCheckpointId),
643
+ writeBatch: async (
644
+ ~batch,
645
+ ~rollback,
646
+ ~isInReorgThreshold,
647
+ ~config as _,
648
+ ~allEntities as _,
649
+ ~updatedEffectsCache,
650
+ ~updatedEntities,
651
+ ~chainMetaData,
652
+ ~onWrite as _,
653
+ ) =>
654
+ state->writeBatch(
655
+ ~batch,
656
+ ~rollback,
657
+ ~isInReorgThreshold,
658
+ ~config,
659
+ ~updatedEntities,
660
+ ~updatedEffectsCache,
661
+ ~chainMetaData,
662
+ ),
663
+ close: async () => (),
664
+ }
665
+
666
+ // Read helpers for tests. They mirror what the Postgres-backed equivalents
667
+ // return, so one assertion can run against either backend.
668
+ let currentRows = (state: t, ~entityConfig: Internal.entityConfig): array<Internal.entity> =>
669
+ state.entities->Dict.get(entityConfig.name)->Option.getOr(Dict.make())->Dict.valuesToArray
670
+
671
+ let historyChanges = (state: t, ~entityConfig: Internal.entityConfig): array<
672
+ Change.t<Internal.entity>,
673
+ > =>
674
+ state.history
675
+ ->Dict.get(entityConfig.name)
676
+ ->Option.getOr([])
677
+ ->Array.map(row =>
678
+ switch row.action {
679
+ | SET =>
680
+ Change.Set({
681
+ entityId: row.entityId,
682
+ checkpointId: row.checkpointId,
683
+ entity: row.entity->Option.getOr(%raw(`{}`)),
684
+ })
685
+ | DELETE => Change.Delete({entityId: row.entityId, checkpointId: row.checkpointId})
686
+ }
687
+ )
688
+ ->Array.toSorted((a, b) =>
689
+ switch String.compare(
690
+ a->Change.getEntityId->EntityId.toKey,
691
+ b->Change.getEntityId->EntityId.toKey,
692
+ ) {
693
+ | 0. =>
694
+ Float.compare(
695
+ a->Change.getCheckpointId->BigInt.toFloat,
696
+ b->Change.getCheckpointId->BigInt.toFloat,
697
+ )
698
+ | order => order
699
+ }
700
+ )
701
+
702
+ let checkpointRows = (state: t): array<InternalTable.Checkpoints.t> =>
703
+ state.checkpoints->Array.map(cp => {
704
+ InternalTable.Checkpoints.id: cp.id,
705
+ chainId: cp.chainId,
706
+ blockNumber: cp.blockNumber,
707
+ blockHash: cp.blockHash->Null.fromOption,
708
+ eventsProcessed: cp.eventsProcessed,
709
+ })
710
+
711
+ let effectCacheRows = (state: t, ~tableName): array<{
712
+ "id": string,
713
+ "output": JSON.t,
714
+ }> =>
715
+ state.effectCache
716
+ ->Dict.get(tableName)
717
+ ->Option.getOr(Dict.make())
718
+ ->Dict.valuesToArray
719
+ ->Array.map(item =>
720
+ {
721
+ "id": item.id,
722
+ "output": item.output->(Utils.magic: Internal.effectOutput => JSON.t),
723
+ }
724
+ )