envio 3.11.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/index.d.ts +3 -0
  2. package/package.json +6 -6
  3. package/src/Batch.res +111 -78
  4. package/src/Batch.res.mjs +53 -46
  5. package/src/BatchProcessing.res +1 -11
  6. package/src/BatchProcessing.res.mjs +1 -4
  7. package/src/ChainFetching.res +7 -3
  8. package/src/ChainFetching.res.mjs +1 -1
  9. package/src/ChainState.res +82 -20
  10. package/src/ChainState.res.mjs +51 -13
  11. package/src/ChainState.resi +6 -1
  12. package/src/Config.res +58 -37
  13. package/src/Config.res.mjs +63 -53
  14. package/src/CrossChainState.res +50 -53
  15. package/src/CrossChainState.res.mjs +25 -23
  16. package/src/CrossChainState.resi +6 -13
  17. package/src/EffectState.res +13 -4
  18. package/src/EffectState.resi +7 -1
  19. package/src/EventProcessing.res +6 -6
  20. package/src/EventProcessing.res.mjs +6 -6
  21. package/src/FetchState.res +65 -46
  22. package/src/FetchState.res.mjs +68 -36
  23. package/src/FinalizeBackfill.res +16 -12
  24. package/src/FinalizeBackfill.res.mjs +3 -3
  25. package/src/HistoryPolicy.res +46 -0
  26. package/src/HistoryPolicy.res.mjs +50 -0
  27. package/src/InMemoryStore.res +30 -32
  28. package/src/InMemoryStore.res.mjs +33 -28
  29. package/src/InMemoryTable.res +155 -67
  30. package/src/InMemoryTable.res.mjs +151 -60
  31. package/src/IndexerState.res +66 -45
  32. package/src/IndexerState.res.mjs +57 -29
  33. package/src/IndexerState.resi +8 -13
  34. package/src/Internal.res +4 -2
  35. package/src/LoadLayer.res +68 -38
  36. package/src/LoadLayer.res.mjs +50 -64
  37. package/src/LoadLayer.resi +1 -1
  38. package/src/Main.res +25 -24
  39. package/src/Main.res.mjs +24 -25
  40. package/src/Metrics.res +49 -1
  41. package/src/Metrics.res.mjs +26 -3
  42. package/src/Persistence.res +55 -16
  43. package/src/Persistence.res.mjs +18 -3
  44. package/src/PgStorage.res +286 -180
  45. package/src/PgStorage.res.mjs +209 -122
  46. package/src/PruneStaleHistory.res +9 -15
  47. package/src/PruneStaleHistory.res.mjs +11 -20
  48. package/src/Rollback.res +9 -14
  49. package/src/Rollback.res.mjs +5 -3
  50. package/src/Sink.res +7 -5
  51. package/src/Sink.res.mjs +4 -4
  52. package/src/Staging.res +298 -0
  53. package/src/Staging.res.mjs +257 -0
  54. package/src/TestIndexer.res +14 -30
  55. package/src/TestIndexer.res.mjs +11 -9
  56. package/src/UserContext.res +20 -36
  57. package/src/UserContext.res.mjs +6 -9
  58. package/src/Utils.res +10 -4
  59. package/src/Utils.res.mjs +16 -16
  60. package/src/Writing.res +34 -20
  61. package/src/Writing.res.mjs +15 -20
  62. package/src/bindings/ClickHouse.res +41 -34
  63. package/src/bindings/ClickHouse.res.mjs +36 -30
  64. package/src/bindings/ClickHouseSink.res +45 -186
  65. package/src/bindings/ClickHouseSink.res.mjs +21 -174
  66. package/src/db/CheckpointSequence.res +109 -0
  67. package/src/db/CheckpointSequence.res.mjs +99 -0
  68. package/src/db/EntityFilter.res +487 -275
  69. package/src/db/EntityFilter.res.mjs +557 -309
  70. package/src/db/EntityHistory.res +6 -5
  71. package/src/db/EntityHistory.res.mjs +5 -5
  72. package/src/db/Frontier.res +86 -0
  73. package/src/db/Frontier.res.mjs +126 -0
  74. package/src/db/InternalTable.res +145 -27
  75. package/src/db/InternalTable.res.mjs +115 -29
  76. package/src/db/RollbackFloors.res +34 -41
  77. package/src/db/RollbackFloors.res.mjs +27 -71
  78. package/src/db/Table.res +21 -6
  79. package/src/db/Table.res.mjs +13 -4
  80. package/src/sources/BlockStore.res +8 -0
  81. package/src/sources/EvmHyperSyncSource.res +5 -1
  82. package/src/sources/EvmHyperSyncSource.res.mjs +4 -3
  83. package/src/sources/FuelHyperSyncSource.res +1 -0
  84. package/src/sources/FuelHyperSyncSource.res.mjs +1 -1
  85. package/src/sources/HyperSync.res +6 -0
  86. package/src/sources/HyperSync.res.mjs +5 -2
  87. package/src/sources/HyperSync.resi +2 -0
  88. package/src/sources/HyperSyncClient.res +6 -0
  89. package/src/sources/HyperSyncSSE.res +1 -1
  90. package/src/sources/HyperSyncSSE.res.mjs +4 -10
  91. package/src/sources/RequestStat.res +8 -1
  92. package/src/sources/RpcSource.res +1 -0
  93. package/src/sources/RpcSource.res.mjs +1 -1
  94. package/src/sources/SimulateSource.res +1 -0
  95. package/src/sources/SimulateSource.res.mjs +1 -1
  96. package/src/sources/Source.res +11 -3
  97. package/src/sources/SourceManager.res +34 -13
  98. package/src/sources/SourceManager.res.mjs +26 -9
  99. package/src/sources/SourceManager.resi +2 -0
  100. package/src/sources/SvmHyperSyncClient.res +5 -0
  101. package/src/sources/SvmHyperSyncSource.res +3 -0
  102. package/src/sources/SvmHyperSyncSource.res.mjs +4 -2
  103. package/src/tui/Tui.res +56 -143
  104. package/src/tui/Tui.res.mjs +70 -166
  105. package/src/tui/components/TuiData.res +61 -16
  106. package/src/tui/components/TuiData.res.mjs +53 -15
  107. package/svm.schema.json +0 -7
  108. package/src/db/CheckpointBounds.res +0 -53
  109. package/src/db/CheckpointBounds.res.mjs +0 -44
package/src/PgStorage.res CHANGED
@@ -28,15 +28,46 @@ let formatSeconds = (timeRef: Performance.timeRef) =>
28
28
  // stalled-looking indexer is explainable from the logs alone.
29
29
  let slowOnLargeDatabaseNotice = "This can take a long time on a large database."
30
30
 
31
+ // A per-chain entity's rows are partitioned by the chain that owns them, so a
32
+ // chain-filtered read scans one chain's partition rather than the whole table.
33
+ // `$` can't occur in a GraphQL entity name, so a partition name can never
34
+ // collide with the table another entity claims; past the identifier limit the
35
+ // entity index keeps what survives truncation unique.
36
+ let partitionTableName = (~entityConfig: Internal.entityConfig, ~chainId: ChainId.t) => {
37
+ let chainIdStr = chainId->ChainId.toString
38
+ Table.fitPgTableName(
39
+ `${entityConfig.table.tableName}$${chainIdStr}`,
40
+ ~uniqueSuffix=`$${entityConfig.index->Int.toString}$${chainIdStr}`,
41
+ )
42
+ }
43
+
44
+ // The physical tables an index on the entity is built on. A per-chain entity's
45
+ // rows are partitioned by chain, and an index declared on the parent cascades
46
+ // to every partition, which is what a run driving every chain wants: one
47
+ // declaration, one build. An isolated run instead builds on its own chains'
48
+ // partitions only: the planner uses a partition's own index either way, and
49
+ // building one chain's index then neither waits for nor locks the rows of a
50
+ // chain a sibling process drives.
51
+ let indexTableNames = (entityConfig: Internal.entityConfig, ~partitionChainIds) =>
52
+ switch (entityConfig.table->Table.getChainIdField, partitionChainIds) {
53
+ | (Some(_), Some(chainIds)) =>
54
+ chainIds->Array.map(chainId => partitionTableName(~entityConfig, ~chainId))
55
+ | _ => [entityConfig.table.tableName]
56
+ }
57
+
31
58
  // Every index the entity schema promises: an `@index` field, a composite index,
32
- // or the index backing a derived relationship. Deferred past the initial DDL and
33
- // created in one transaction once backfill completes, so a resumed indexer that
34
- // reports itself ready always has all of them.
59
+ // or the index backing a derived relationship. Deferred past the initial DDL
60
+ // and created once backfill completes, so a chain that reports itself ready
61
+ // always has all of them. With `partitionChainIds`, a per-chain entity's index
62
+ // is one per partition of those chains rather than one on the parent.
35
63
  //
36
64
  // `entities` is the Postgres-backed set, and every `@derivedFrom` target within
37
65
  // it resolves: config parsing rejects a Postgres entity deriving from one that
38
66
  // isn't in Postgres (`validate_relationship_storage`).
39
- let getSchemaIndexes = (~entities: array<Internal.entityConfig>): array<IndexDefinition.t> => {
67
+ let getSchemaIndexes = (
68
+ ~entities: array<Internal.entityConfig>,
69
+ ~partitionChainIds: option<array<ChainId.t>>=?,
70
+ ): array<IndexDefinition.t> => {
40
71
  let derivedSchema = Schema.make(entities->Array.map(e => e.table))
41
72
  let all = []
42
73
 
@@ -70,7 +101,13 @@ let getSchemaIndexes = (~entities: array<Internal.entityConfig>): array<IndexDef
70
101
  // An `@index` field and a derived relationship pointing at it describe the
71
102
  // same index, so the list is deduped on identity rather than on name.
72
103
  let seen = Utils.Set.make()
73
- all->Array.filter(definition => {
104
+ let entityByTableName = Dict.make()
105
+ entities->Array.forEach(entityConfig =>
106
+ entityByTableName->Dict.set(entityConfig.table.tableName, entityConfig)
107
+ )
108
+
109
+ all
110
+ ->Array.filter(definition => {
74
111
  let key = definition->IndexDefinition.key
75
112
  if seen->Utils.Set.has(key) {
76
113
  false
@@ -79,6 +116,13 @@ let getSchemaIndexes = (~entities: array<Internal.entityConfig>): array<IndexDef
79
116
  true
80
117
  }
81
118
  })
119
+ ->Array.flatMap(definition =>
120
+ entityByTableName
121
+ ->Dict.get(definition.tableName)
122
+ ->Option.getOrThrow
123
+ ->indexTableNames(~partitionChainIds)
124
+ ->Array.map(tableName => {...definition, IndexDefinition.tableName})
125
+ )
82
126
  }
83
127
 
84
128
  let makeCreateTableQuery = (
@@ -122,19 +166,6 @@ let makeCreateTableQuery = (
122
166
  }};`
123
167
  }
124
168
 
125
- // A per-chain entity's rows are partitioned by the chain that owns them, so a
126
- // chain-filtered read scans one chain's partition rather than the whole table.
127
- // `$` can't occur in a GraphQL entity name, so a partition name can never
128
- // collide with the table another entity claims; past the identifier limit the
129
- // entity index keeps what survives truncation unique.
130
- let partitionTableName = (~entityConfig: Internal.entityConfig, ~chainId: ChainId.t) => {
131
- let chainIdStr = chainId->ChainId.toString
132
- Table.fitPgTableName(
133
- `${entityConfig.table.tableName}$${chainIdStr}`,
134
- ~uniqueSuffix=`$${entityConfig.index->Int.toString}$${chainIdStr}`,
135
- )
136
- }
137
-
138
169
  // The entity as it's stored: the handler-visible schema plus the chain-id
139
170
  // column a per-chain entity's table carries. The value for that column is
140
171
  // stamped from the flush group's scope right before serialization, so it never
@@ -277,6 +308,10 @@ let makeInitializeTransaction = (
277
308
  ~pgSchema,
278
309
  ~pgUser,
279
310
  ~isHasuraEnabled,
311
+ // The whole schema's sequence, not one derived from `entities`: those are the
312
+ // entities Postgres stores, and an entity kept only in a sink still decides
313
+ // how the run counts its checkpoints.
314
+ ~checkpointSequence: CheckpointSequence.t,
280
315
  ~chainConfigs=[],
281
316
  ~entities=[],
282
317
  ~enums=[],
@@ -292,7 +327,7 @@ let makeInitializeTransaction = (
292
327
  InternalTable.EnvioInfo.table,
293
328
  InternalTable.EnvioContracts.table,
294
329
  InternalTable.EnvioAddresses.table,
295
- InternalTable.Checkpoints.table,
330
+ InternalTable.Checkpoints.tableFor(checkpointSequence),
296
331
  InternalTable.RawEvents.table,
297
332
  ]
298
333
 
@@ -374,9 +409,10 @@ let makeLoadQuery = (~pgSchema, ~tableName, ~condition) => {
374
409
  // Field names are spliced as quoted identifiers only after the queryFields
375
410
  // lookup proves they exist on the table (and they originate from
376
411
  // codegen-validated schemas), so the interpolation can't be abused.
377
- let rec makeFilterCondition = (
412
+ let makeFilterCondition = (
378
413
  ~filter: EntityFilter.t,
379
414
  ~table: Table.table,
415
+ ~pgSchema,
380
416
  ~params: array<unknown>,
381
417
  ) => {
382
418
  // Filters reference fields by API name, while the SQL references columns
@@ -412,60 +448,89 @@ let rec makeFilterCondition = (
412
448
  params->Array.push(param)->ignore
413
449
  `$${params->Array.length->Int.toString}`
414
450
  }
415
- let scalarCondition = (~fieldName, ~fieldValue, ~op) => {
451
+
452
+ let condition = ref("")
453
+ filter
454
+ ->EntityFilter.entries
455
+ ->Utils.Dict.forEachWithKey((operators, fieldName) => {
416
456
  let queryField = getQueryFieldOrThrow(fieldName)
417
- `"${queryField.pgDbFieldName}" ${op} ${serializeParamOrThrow(
418
- ~queryField,
419
- ~fieldName,
420
- ~fieldValue,
421
- ~isArray=false,
422
- )}`
423
- }
424
- switch filter {
425
- // A per-chain entity's table is partitioned by its chain-id column, and
426
- // Postgres can only prune a plan it caches when that column is a constant in
427
- // the SQL. Bound, the cached plan has to keep every partition, and the
428
- // planner ends up throwing it away and re-planning on every execution
429
- // instead — measured at 315us per load against 218us with the id written in,
430
- // on 30 chains.
431
- //
432
- // The cost is that each chain gets its own query text, so Postgres caches a
433
- // prepared statement per (entity, chain, filter shape) rather than per
434
- // (entity, filter shape). Measured at ~8KB of plan cache each, which is ~10MB
435
- // per connection for 40 entities across 30 chains — accepted, since the
436
- // alternative is a cached plan that can't prune.
437
- //
438
- // `LoadLayer.scopeFilter` is what puts this filter here, and the value is
439
- // range-checked to a non-negative safe integer, so it can carry nothing but
440
- // digits.
441
- | Eq({fieldName, fieldValue}) if getQueryFieldOrThrow(fieldName).isChainId =>
442
- `"${getQueryFieldOrThrow(fieldName).pgDbFieldName}" = ${fieldValue
443
- ->ChainId.normalizeOrThrow
444
- ->ChainId.toString}`
445
- | Eq({fieldName, fieldValue}) => scalarCondition(~fieldName, ~fieldValue, ~op="=")
446
- | Gt({fieldName, fieldValue}) => scalarCondition(~fieldName, ~fieldValue, ~op=">")
447
- | Lt({fieldName, fieldValue}) => scalarCondition(~fieldName, ~fieldValue, ~op="<")
448
- | In({fieldName, fieldValue}) => {
449
- let queryField = getQueryFieldOrThrow(fieldName)
450
- `"${queryField.pgDbFieldName}" = ANY(${serializeParamOrThrow(
451
- ~queryField,
452
- ~fieldName,
453
- ~fieldValue=fieldValue->(Utils.magic: array<unknown> => unknown),
454
- ~isArray=true,
455
- )})`
456
- }
457
- | And({filters: []}) =>
458
- throw(
459
- Persistence.StorageError({
460
- message: `Failed loading "${table.tableName}" from storage. The "and" filter must contain at least one nested filter.`,
461
- reason: Utils.Error.make(`Empty "and" filter`),
462
- }),
463
- )
464
- | And({filters}) =>
465
- `(${filters
466
- ->Array.map(filter => makeFilterCondition(~filter, ~table, ~params))
467
- ->Array.join(" AND ")})`
468
- }
457
+ operators->Utils.Dict.forEachWithKey((fieldValue, operator) => {
458
+ let column = `"${queryField.pgDbFieldName}"`
459
+ let part = switch operator {
460
+ // A per-chain entity's table is partitioned by its chain-id column, and
461
+ // Postgres can only prune a plan it caches when that column is a constant
462
+ // in the SQL. Bound, the cached plan has to keep every partition, and the
463
+ // planner ends up throwing it away and re-planning on every execution
464
+ // instead — measured at 315us per load against 218us with the id written
465
+ // in, on 30 chains.
466
+ //
467
+ // The cost is that each chain gets its own query text, so Postgres caches
468
+ // a prepared statement per (entity, chain, filter shape) rather than per
469
+ // (entity, filter shape). Measured at ~8KB of plan cache each, which is
470
+ // ~10MB per connection for 40 entities across 30 chains — accepted, since
471
+ // the alternative is a cached plan that can't prune.
472
+ //
473
+ // `EntityFilter.scoped` is what puts this filter here, and the value is
474
+ // range-checked to a non-negative safe integer, so it can carry nothing
475
+ // but digits.
476
+ | "_eq" if queryField.isChainId =>
477
+ `${column} = ${fieldValue->ChainId.normalizeOrThrow->ChainId.toString}`
478
+ // Postgres arrays are rectangular, so candidates for a list column can't
479
+ // be bound as one array unless they all have the same length, and
480
+ // postgres.js can't bind a boolean array at all
481
+ // (https://github.com/porsager/postgres/issues/471). One equality per
482
+ // candidate has neither problem.
483
+ | "_in" if queryField.isArray || queryField.fieldType === Boolean =>
484
+ switch fieldValue->EntityFilter.asArray {
485
+ | [] => "FALSE"
486
+ | candidates =>
487
+ `(${candidates
488
+ ->Array.map(
489
+ candidate =>
490
+ `${column} = ${serializeParamOrThrow(
491
+ ~queryField,
492
+ ~fieldName,
493
+ ~fieldValue=candidate,
494
+ ~isArray=false,
495
+ )}`,
496
+ )
497
+ ->Array.join(" OR ")})`
498
+ }
499
+ | "_in" =>
500
+ let param = serializeParamOrThrow(~queryField, ~fieldName, ~fieldValue, ~isArray=true)
501
+ switch queryField.fieldType {
502
+ // A bound array of strings is text[], which has no equality with an
503
+ // enum. The insert casts the same way.
504
+ | Enum({config}) => `${column} = ANY(${param}::TEXT[]::"${pgSchema}".${config.name}[])`
505
+ | _ => `${column} = ANY(${param})`
506
+ }
507
+ | _ =>
508
+ let sqlOperator = switch operator {
509
+ | "_eq" => "="
510
+ | "_gt" => ">"
511
+ | "_lt" => "<"
512
+ | "_gte" => ">="
513
+ | "_lte" => "<="
514
+ | _ =>
515
+ throw(
516
+ Persistence.StorageError({
517
+ message: `Failed loading "${table.tableName}" from storage. Unknown filter operator "${operator}".`,
518
+ reason: Utils.Error.make(`Unknown filter operator "${operator}"`),
519
+ }),
520
+ )
521
+ }
522
+ `${column} ${sqlOperator} ${serializeParamOrThrow(
523
+ ~queryField,
524
+ ~fieldName,
525
+ ~fieldValue,
526
+ ~isArray=false,
527
+ )}`
528
+ }
529
+ condition := (condition.contents === "" ? part : condition.contents ++ " AND " ++ part)
530
+ })
531
+ })
532
+
533
+ condition.contents
469
534
  }
470
535
 
471
536
  // The chain-id predicate a per-chain entity's row-level SQL needs, already
@@ -1042,12 +1107,21 @@ let executeSet = (
1042
1107
  }
1043
1108
  }
1044
1109
 
1110
+ // The checkpoints a write inserts: every one the batch made, or those of the
1111
+ // chains whose history it keeps.
1112
+ type pickedCheckpoints = AllCheckpoints | CheckpointIndexes(array<int>)
1113
+
1114
+ let pickCheckpoints = (column, picked) =>
1115
+ switch picked {
1116
+ | AllCheckpoints => column
1117
+ | CheckpointIndexes(indexes) => indexes->Array.map(index => column->Array.getUnsafe(index))
1118
+ }
1119
+
1045
1120
  let rec writeBatch = async (
1046
1121
  sql,
1047
1122
  ~batch: Batch.t,
1048
1123
  ~pgSchema,
1049
1124
  ~rollback: option<Persistence.rollback>,
1050
- ~isInReorgThreshold,
1051
1125
  ~config: Config.t,
1052
1126
  ~allEntities: array<Internal.entityConfig>,
1053
1127
  ~setEffectCacheOrThrow,
@@ -1061,7 +1135,22 @@ let rec writeBatch = async (
1061
1135
  ) => {
1062
1136
  try {
1063
1137
  let chainIdMode = config.chainIdMode
1064
- let shouldSaveHistory = config->Config.shouldSaveHistory(~isInReorgThreshold)
1138
+ // A checkpoint anchors the history its chain keeps, so the batch's
1139
+ // decision picks the checkpoints chain by chain.
1140
+ let pickedCheckpoints = {
1141
+ let indexes =
1142
+ batch.checkpointChainIds->Array.filterMapWithIndex((chainId, index) =>
1143
+ batch.history->HistoryPolicy.forChain(chainId) ? Some(index) : None
1144
+ )
1145
+ if indexes->Utils.Array.isEmpty {
1146
+ None
1147
+ } else if indexes->Array.length === batch.checkpointIds->Array.length {
1148
+ Some(AllCheckpoints)
1149
+ } else {
1150
+ Some(CheckpointIndexes(indexes))
1151
+ }
1152
+ }
1153
+ let writtenFrontier = Persistence.writtenFrontier(~batch, ~rollback)
1065
1154
 
1066
1155
  let specificError = ref(None)
1067
1156
 
@@ -1111,7 +1200,12 @@ let rec writeBatch = async (
1111
1200
  }
1112
1201
  }
1113
1202
 
1114
- let setEntities = updatedEntities->Array.map(({entityConfig, scope, changes}) => {
1203
+ let setEntities = updatedEntities->Array.map(({
1204
+ entityConfig,
1205
+ scope,
1206
+ changes,
1207
+ shouldSaveHistory,
1208
+ }) => {
1115
1209
  let entitiesToSet = []
1116
1210
  let idsToDelete = []
1117
1211
 
@@ -1121,8 +1215,6 @@ let rec writeBatch = async (
1121
1215
  | Internal.CrossChain => None
1122
1216
  | Chain(chainId) => Some(chainId)
1123
1217
  }
1124
- let shouldSaveHistory =
1125
- config->Config.shouldSaveHistory(~isInReorgThreshold, ~chainId=?scopeChainId)
1126
1218
  let changes = switch (entityConfig.table->Table.getChainIdField, scopeChainId) {
1127
1219
  | (Some(field), Some(chainId)) =>
1128
1220
  changes->Array.map(change =>
@@ -1147,7 +1239,10 @@ let rec writeBatch = async (
1147
1239
 
1148
1240
  // The rollback-diff change is written to the entity table only, never the
1149
1241
  // history table; when present it is an id's oldest change.
1150
- let diffCheckpointId = rollback->Option.map(r => r.diffCheckpointId)
1242
+ let diffCheckpointId =
1243
+ rollback->Option.flatMap(r =>
1244
+ config.checkpointSequence->CheckpointSequence.findForScope(r.diffFrontier, ~scope)
1245
+ )
1151
1246
 
1152
1247
  // History batches, populated only when saving history.
1153
1248
  let batchSetUpdates = []
@@ -1384,6 +1479,7 @@ let rec writeBatch = async (
1384
1479
  ): InternalTable.Chains.progressedChain => {
1385
1480
  chainId: chainAfterBatch.fetchState.chainId,
1386
1481
  progressBlockNumber: chainAfterBatch.progressBlockNumber,
1482
+ progressBlockTime: chainAfterBatch.progressBlockTime,
1387
1483
  sourceBlockNumber: chainAfterBatch.sourceBlockNumber,
1388
1484
  totalEventsProcessed: chainAfterBatch.totalEventsProcessed,
1389
1485
  }),
@@ -1411,18 +1507,30 @@ let rec writeBatch = async (
1411
1507
  )
1412
1508
  }
1413
1509
 
1414
- if shouldSaveHistory {
1510
+ if !(writtenFrontier->Utils.Dict.isEmpty) {
1511
+ setOperations->Array.push(sql =>
1512
+ sql->InternalTable.Chains.setCheckpointFrontier(
1513
+ ~pgSchema,
1514
+ ~frontier=writtenFrontier,
1515
+ ~chainIdMode,
1516
+ )
1517
+ )
1518
+ }
1519
+
1520
+ switch pickedCheckpoints {
1521
+ | Some(picked) =>
1415
1522
  setOperations->Array.push(sql =>
1416
1523
  sql->InternalTable.Checkpoints.insert(
1417
1524
  ~pgSchema,
1418
- ~checkpointIds=batch.checkpointIds,
1419
- ~checkpointChainIds=batch.checkpointChainIds,
1420
- ~checkpointBlockNumbers=batch.checkpointBlockNumbers,
1421
- ~checkpointBlockHashes=batch.checkpointBlockHashes,
1422
- ~checkpointEventsProcessed=batch.checkpointEventsProcessed,
1525
+ ~checkpointIds=batch.checkpointIds->pickCheckpoints(picked),
1526
+ ~checkpointChainIds=batch.checkpointChainIds->pickCheckpoints(picked),
1527
+ ~checkpointBlockNumbers=batch.checkpointBlockNumbers->pickCheckpoints(picked),
1528
+ ~checkpointBlockHashes=batch.checkpointBlockHashes->pickCheckpoints(picked),
1529
+ ~checkpointEventsProcessed=batch.checkpointEventsProcessed->pickCheckpoints(picked),
1423
1530
  ~chainIdMode,
1424
1531
  )
1425
1532
  )
1533
+ | None => ()
1426
1534
  }
1427
1535
 
1428
1536
  await setOperations
@@ -1479,7 +1587,6 @@ let rec writeBatch = async (
1479
1587
  ~pgSchema,
1480
1588
  ~setQueryCache,
1481
1589
  ~rollback,
1482
- ~isInReorgThreshold,
1483
1590
  ~config,
1484
1591
  ~setEffectCacheOrThrow,
1485
1592
  ~updatedEffectsCache,
@@ -1532,7 +1639,7 @@ let makeGetRollbackPreTargetRowsQuery = (
1532
1639
  let keyMatch =
1533
1640
  keyColumns->Array.map(c => `h."${c}" = ${tableRef}."${c}"`)->Array.joinUnsafe(" AND ")
1534
1641
  let bounds =
1535
- floors.floors->CheckpointBounds.sql(
1642
+ floors.checkpointBounds->CheckpointSequence.sql(
1536
1643
  ~chainIdColumn=entityConfig.table->Table.getPgChainIdColumn,
1537
1644
  ~tableRef,
1538
1645
  )
@@ -1565,7 +1672,7 @@ let makeGetRollbackRemovedIdsQuery = (
1565
1672
  let keyMatch =
1566
1673
  keyColumns->Array.map(c => `h."${c}" = ${tableRef}."${c}"`)->Array.joinUnsafe(" AND ")
1567
1674
  let bounds =
1568
- floors.floors->CheckpointBounds.sql(
1675
+ floors.checkpointBounds->CheckpointSequence.sql(
1569
1676
  ~chainIdColumn=entityConfig.table->Table.getPgChainIdColumn,
1570
1677
  ~tableRef,
1571
1678
  )
@@ -1623,13 +1730,16 @@ let make = (
1623
1730
  // encoded at initialize and when stored rows are grouped on resume.
1624
1731
  ~ecosystem: Ecosystem.name,
1625
1732
  ~sink: option<Sink.t>=?,
1733
+ // An `envio start --chain` process: builds and looks for a per-chain entity's
1734
+ // indexes on its own chains' partitions, never on the parent table, so no
1735
+ // build reaches into the rows a sibling process drives.
1736
+ ~isolated=false,
1626
1737
  ~onInitialize=?,
1627
- ~onNewTables=?,
1628
1738
  ): Persistence.storage => {
1629
1739
  // Must match PG_CONTAINER in packages/cli/src/docker_env.rs
1630
1740
  let containerName = "envio-postgres"
1631
1741
  let psqlExecOptions: NodeJs.ChildProcess.execOptions = {
1632
- env: Dict.fromArray([("PGPASSWORD", pgPassword), ("PATH", %raw(`process.env.PATH`))]),
1742
+ env: dict{"PGPASSWORD": pgPassword, "PATH": %raw(`process.env.PATH`)},
1633
1743
  }
1634
1744
 
1635
1745
  let cacheDirPath = NodeJs.Path.resolve([
@@ -1784,19 +1894,6 @@ let make = (
1784
1894
 
1785
1895
  let cacheTableInfo = await queryCacheTableInfo()
1786
1896
 
1787
- if withUpload && cacheTableInfo->Utils.Array.notEmpty {
1788
- // Integration with other tools like Hasura
1789
- switch onNewTables {
1790
- | Some(onNewTables) =>
1791
- await onNewTables(
1792
- ~tableNames=cacheTableInfo->Array.map(info => {
1793
- info.tableName
1794
- }),
1795
- )
1796
- | None => ()
1797
- }
1798
- }
1799
-
1800
1897
  let cache = Dict.make()
1801
1898
  cacheTableInfo->Array.forEach(({tableName, count}) => {
1802
1899
  switch Internal.EffectCache.fromTableName(tableName) {
@@ -1856,6 +1953,7 @@ let make = (
1856
1953
  let queries = makeInitializeTransaction(
1857
1954
  ~pgSchema,
1858
1955
  ~pgUser,
1956
+ ~checkpointSequence=CheckpointSequence.fromEntities(entities),
1859
1957
  ~entities=pgEntities,
1860
1958
  ~enums,
1861
1959
  ~chainConfigs,
@@ -1925,15 +2023,16 @@ let make = (
1925
2023
  firstEventBlockNumber: None,
1926
2024
  timestampCaughtUpToHeadOrEndblock: None,
1927
2025
  addressRows: rowsByChain->Array.getUnsafe(idx)->AddressRows.seedRowsOf,
2026
+ progressBlockTime: None,
1928
2027
  sourceBlockNumber: 0,
1929
2028
  }),
1930
- checkpointId: InternalTable.Checkpoints.initialCheckpointId,
2029
+ checkpointFrontier: Frontier.empty(),
1931
2030
  }
1932
2031
  }
1933
2032
 
1934
2033
  let loadOrThrow = async (~filter: EntityFilter.t, ~table: Table.table) => {
1935
2034
  let params = []
1936
- let condition = makeFilterCondition(~filter, ~table, ~params)
2035
+ let condition = makeFilterCondition(~filter, ~table, ~pgSchema, ~params)
1937
2036
  switch await sql->Postgres.preparedUnsafe(
1938
2037
  makeLoadQuery(~pgSchema, ~tableName=table.tableName, ~condition),
1939
2038
  params->Obj.magic,
@@ -1964,9 +2063,10 @@ let make = (
1964
2063
  let queryFields = table->Table.queryFields
1965
2064
  let columns = []
1966
2065
  let seen = Utils.Set.make()
1967
- let rec collect = (filter: EntityFilter.t) =>
1968
- switch filter {
1969
- | Eq({fieldName}) | Gt({fieldName}) | Lt({fieldName}) | In({fieldName}) =>
2066
+ filters->Array.forEach(filter =>
2067
+ filter
2068
+ ->EntityFilter.entries
2069
+ ->Utils.Dict.forEachWithKey((_, fieldName) =>
1970
2070
  switch queryFields->Utils.Dict.dangerouslyGetNonOption(fieldName) {
1971
2071
  | Some({pgDbFieldName}) =>
1972
2072
  if !(seen->Utils.Set.has(pgDbFieldName)) {
@@ -1975,9 +2075,8 @@ let make = (
1975
2075
  }
1976
2076
  | None => ()
1977
2077
  }
1978
- | And({filters}) => filters->Array.forEach(collect)
1979
- }
1980
- filters->Array.forEach(collect)
2078
+ )
2079
+ )
1981
2080
  columns
1982
2081
  }
1983
2082
 
@@ -2012,11 +2111,31 @@ let make = (
2012
2111
  })
2013
2112
  }
2014
2113
 
2015
- let ensureQueryIndexes = async (~table: Table.table, ~filters: array<EntityFilter.t>) => {
2016
- let columns = filterColumns(~table, ~filters)
2114
+ let partitionChainIds = chainIds => isolated ? Some(chainIds) : None
2115
+
2116
+ // The physical table a query index for `scope` is built on. A per-chain
2117
+ // entity's query carries the scope's chain id, so it is planned against that
2118
+ // chain's partition, which an index on the parent covers by cascading.
2119
+ let queryTableName = (~entityConfig: Internal.entityConfig, ~scope: Internal.chainScope) =>
2120
+ switch scope {
2121
+ | Chain(chainId) =>
2122
+ indexTableNames(
2123
+ entityConfig,
2124
+ ~partitionChainIds=partitionChainIds([chainId]),
2125
+ )->Array.getUnsafe(0)
2126
+ | CrossChain => entityConfig.table.tableName
2127
+ }
2128
+
2129
+ let ensureQueryIndexes = async (
2130
+ ~entityConfig: Internal.entityConfig,
2131
+ ~scope: Internal.chainScope,
2132
+ ~filters: array<EntityFilter.t>,
2133
+ ) => {
2134
+ let tableName = queryTableName(~entityConfig, ~scope)
2135
+ let columns = filterColumns(~table=entityConfig.table, ~filters)
2017
2136
  let _ = await columns
2018
2137
  ->Array.map(column => {
2019
- let definition = IndexDefinition.single(~tableName=table.tableName, ~column)
2138
+ let definition = IndexDefinition.single(~tableName, ~column)
2020
2139
  indexManager
2021
2140
  ->IndexManager.ensure(~definition, ~coverage=LeadingColumns, ~build=async () => {
2022
2141
  // Resolved before logging so a rebuild is reported as one, and an
@@ -2033,7 +2152,7 @@ let make = (
2033
2152
  // actually creates the index, not the ones waiting on it.
2034
2153
  Logging.info({
2035
2154
  "storage": storageName,
2036
- "msg": `${verb} "${prepared.name}" to serve a getWhere query on "${table.tableName}". Writes to the table are paused until it completes. ${slowOnLargeDatabaseNotice}`,
2155
+ "msg": `${verb} "${prepared.name}" to serve a getWhere query on "${tableName}". Writes to the table are paused until it completes. ${slowOnLargeDatabaseNotice}`,
2037
2156
  })
2038
2157
  let timeRef = Performance.now()
2039
2158
  let entry = await sql->runAndVerify(prepared)
@@ -2049,7 +2168,7 @@ let make = (
2049
2168
  ->Promise.catch(async exn => {
2050
2169
  Logging.warn({
2051
2170
  "storage": storageName,
2052
- "msg": `Failed to create an index on "${table.tableName}"("${column}") for a getWhere query. The query runs without it.`,
2171
+ "msg": `Failed to create an index on "${tableName}"("${column}") for a getWhere query. The query runs without it.`,
2053
2172
  "err": exn->Utils.prettifyExn,
2054
2173
  })
2055
2174
  await resyncIndex(definition->IndexDefinition.name)
@@ -2063,9 +2182,10 @@ let make = (
2063
2182
  // is already ready, so handlers may be issuing getWhere queries alongside it
2064
2183
  // and the per-table queues are what keep the two from colliding. Nothing here
2065
2184
  // writes `ready_at` — the chains already carry theirs.
2066
- let ensureSchemaIndexes = async (~entities: array<Internal.entityConfig>) => {
2185
+ let ensureSchemaIndexes = async (~entities: array<Internal.entityConfig>, ~chainIds) => {
2067
2186
  let schemaIndexes = getSchemaIndexes(
2068
2187
  ~entities=entities->Array.filter((e: Internal.entityConfig) => e.storage.postgres),
2188
+ ~partitionChainIds=?partitionChainIds(chainIds),
2069
2189
  )
2070
2190
 
2071
2191
  let _ = await schemaIndexes
@@ -2116,6 +2236,7 @@ let make = (
2116
2236
  ) => {
2117
2237
  let schemaIndexes = getSchemaIndexes(
2118
2238
  ~entities=entities->Array.filter((e: Internal.entityConfig) => e.storage.postgres),
2239
+ ~partitionChainIds=?partitionChainIds(chainIds),
2119
2240
  )
2120
2241
 
2121
2242
  // Resolved up front so a name held by an unrelated index fails before any
@@ -2181,9 +2302,9 @@ let make = (
2181
2302
  // crash either leaves `ready_at` null and the retry finds the indexes
2182
2303
  // already built, or commits readiness the schema backs.
2183
2304
  //
2184
- // One transaction for the whole set: readiness is an indexer-wide fact, and
2185
- // a crash part way through would otherwise leave some chains stamped and
2186
- // some not, reporting the indexer as half ready.
2305
+ // One transaction for the whole set: the chains this process drives caught
2306
+ // up together, and a crash part way through would otherwise leave some of
2307
+ // them stamped and some not.
2187
2308
  let setReadyAtQuery = InternalTable.Chains.makeSetReadyAtQuery(~pgSchema)
2188
2309
  let _ = await sql->Postgres.beginSql(async sql => {
2189
2310
  for idx in 0 to chainIds->Array.length - 1 {
@@ -2232,11 +2353,6 @@ let make = (
2232
2353
  let _ = await sql->Postgres.unsafe(
2233
2354
  makeCreateTableQuery(table, ~pgSchema, ~isNumericArrayAsText=false),
2234
2355
  )
2235
- // Integration with other tools like Hasura
2236
- switch onNewTables {
2237
- | Some(onNewTables) => await onNewTables(~tableNames=[table.tableName])
2238
- | None => ()
2239
- }
2240
2356
  }
2241
2357
 
2242
2358
  await setOrThrow(~items, ~table, ~itemSchema)
@@ -2313,36 +2429,49 @@ let make = (
2313
2429
  }
2314
2430
  }
2315
2431
 
2316
- let resumeInitialState = async (~entities, ~throwIfIncompatible): Persistence.initialState => {
2432
+ let resumeInitialState = async (
2433
+ ~entities,
2434
+ ~chainIds,
2435
+ ~throwIfIncompatible,
2436
+ ): Persistence.initialState => {
2317
2437
  let (
2318
2438
  cache,
2319
- chains,
2320
- checkpointIdResult,
2439
+ (chains, checkpointFrontier),
2321
2440
  reorgCheckpoints,
2322
2441
  (storedEnvioInfo, storedContractMapping),
2323
- ) = await Promise.all5((
2442
+ ) = await Promise.all4((
2324
2443
  restoreEffectCache(~withUpload=false),
2325
2444
  InternalTable.Chains.getInitialState(
2326
2445
  sql,
2327
2446
  ~pgSchema,
2328
2447
  )->Promise.thenResolve(rawInitialStates => {
2329
- rawInitialStates->Array.map((rawInitialState): Persistence.initialChainState => {
2330
- id: rawInitialState.id,
2331
- startBlock: rawInitialState.startBlock,
2332
- endBlock: rawInitialState.endBlock->Null.toOption,
2333
- maxReorgDepth: rawInitialState.maxReorgDepth,
2334
- firstEventBlockNumber: rawInitialState.firstEventBlockNumber->Null.toOption,
2335
- timestampCaughtUpToHeadOrEndblock: rawInitialState.timestampCaughtUpToHeadOrEndblock->Null.toOption,
2336
- numEventsProcessed: rawInitialState.numEventsProcessed,
2337
- progressBlockNumber: rawInitialState.progressBlockNumber,
2338
- addressRows: rawInitialState.addressRows,
2339
- sourceBlockNumber: rawInitialState.sourceBlockNumber,
2340
- })
2448
+ let rawInitialStates =
2449
+ rawInitialStates->Array.filter(rawInitialState =>
2450
+ chainIds->Array.includes(rawInitialState.id)
2451
+ )
2452
+ (
2453
+ rawInitialStates->Array.map((rawInitialState): Persistence.initialChainState => {
2454
+ id: rawInitialState.id,
2455
+ startBlock: rawInitialState.startBlock,
2456
+ endBlock: rawInitialState.endBlock->Null.toOption,
2457
+ maxReorgDepth: rawInitialState.maxReorgDepth,
2458
+ firstEventBlockNumber: rawInitialState.firstEventBlockNumber->Null.toOption,
2459
+ timestampCaughtUpToHeadOrEndblock: rawInitialState.timestampCaughtUpToHeadOrEndblock->Null.toOption,
2460
+ numEventsProcessed: rawInitialState.numEventsProcessed,
2461
+ progressBlockNumber: rawInitialState.progressBlockNumber,
2462
+ progressBlockTime: rawInitialState.progressBlockTime->InternalTable.Chains.blockTimeFromDb,
2463
+ addressRows: rawInitialState.addressRows,
2464
+ sourceBlockNumber: rawInitialState.sourceBlockNumber,
2465
+ }),
2466
+ Frontier.fromEntries(
2467
+ rawInitialStates->Array.map(rawInitialState => (
2468
+ rawInitialState.id,
2469
+ rawInitialState.checkpointId->BigInt.fromStringOrThrow,
2470
+ )),
2471
+ ),
2472
+ )
2341
2473
  }),
2342
2474
  sql
2343
- ->Postgres.unsafe(InternalTable.Checkpoints.makeCommitedCheckpointIdQuery(~pgSchema))
2344
- ->(Utils.magic: promise<array<unknown>> => promise<array<{"id": string}>>),
2345
- sql
2346
2475
  ->Postgres.unsafe(InternalTable.Checkpoints.makeGetReorgCheckpointsQuery(~pgSchema))
2347
2476
  ->(
2348
2477
  Utils.magic: promise<array<unknown>> => promise<
@@ -2372,8 +2501,6 @@ let make = (
2372
2501
 
2373
2502
  await reloadIndexCatalog()
2374
2503
 
2375
- let checkpointId = (checkpointIdResult->Array.getUnsafe(0))["id"]->BigInt.fromStringOrThrow
2376
-
2377
2504
  // Convert string checkpoint IDs from DB to bigint
2378
2505
  let reorgCheckpoints = Array.map(reorgCheckpoints, (raw): Internal.reorgCheckpoint => {
2379
2506
  checkpointId: raw["id"]->BigInt.fromStringOrThrow,
@@ -2384,7 +2511,7 @@ let make = (
2384
2511
 
2385
2512
  // Resume sink if present - needed to rollback any reorg changes
2386
2513
  switch sink {
2387
- | Some(sink) => await sink.resume(~checkpointId, ~chains, ~entities)
2514
+ | Some(sink) => await sink.resume(~frontier=checkpointFrontier, ~chains, ~entities)
2388
2515
  | None => ()
2389
2516
  }
2390
2517
 
@@ -2393,7 +2520,7 @@ let make = (
2393
2520
  reorgCheckpoints,
2394
2521
  cache,
2395
2522
  chains,
2396
- checkpointId,
2523
+ checkpointFrontier,
2397
2524
  contractMapping: storedContractMapping,
2398
2525
  envioInfo: storedEnvioInfo,
2399
2526
  }
@@ -2434,7 +2561,7 @@ let make = (
2434
2561
  InternalTable.Checkpoints.getRollbackProgressDiff(sql, ~pgSchema, ~floors)
2435
2562
 
2436
2563
  let getRollbackData = async (~entityConfig: Internal.entityConfig, ~floors: RollbackFloors.t) => {
2437
- let params = floors.floors->CheckpointBounds.params
2564
+ let params = floors.checkpointBounds->CheckpointSequence.params
2438
2565
  let (removedIdRows, rollbackRows) = await Promise.all2((
2439
2566
  // Get IDs of entities that should be deleted (created after rollback target with no prior history)
2440
2567
  sql
@@ -2482,7 +2609,6 @@ let make = (
2482
2609
  let writeBatchMethod = async (
2483
2610
  ~batch,
2484
2611
  ~rollback,
2485
- ~isInReorgThreshold,
2486
2612
  ~config,
2487
2613
  ~allEntities,
2488
2614
  ~updatedEffectsCache,
@@ -2509,7 +2635,14 @@ let make = (
2509
2635
  | Some(sink) => {
2510
2636
  let timerRef = Performance.now()
2511
2637
  Some(
2512
- sink.writeBatch(~batch, ~updatedEntities=chUpdates)
2638
+ sink.writeBatch(
2639
+ ~batch,
2640
+ ~diffCheckpoints=switch (rollback: option<Persistence.rollback>) {
2641
+ | Some({diffCheckpoints}) => diffCheckpoints
2642
+ | None => []
2643
+ },
2644
+ ~updatedEntities=chUpdates,
2645
+ )
2513
2646
  ->Promise.thenResolve(_ => {
2514
2647
  onWrite(~storage=sink.name, ~timeSeconds=timerRef->Performance.secondsSince)
2515
2648
  None
@@ -2528,7 +2661,6 @@ let make = (
2528
2661
  ~pgSchema,
2529
2662
  ~setQueryCache,
2530
2663
  ~rollback,
2531
- ~isInReorgThreshold,
2532
2664
  ~config,
2533
2665
  ~allEntities,
2534
2666
  ~setEffectCacheOrThrow,
@@ -2581,6 +2713,7 @@ let makeStorageFromEnv = (
2581
2713
  ~pgPassword=Env.Db.password,
2582
2714
  ~chainIdMode=config.chainIdMode,
2583
2715
  ~ecosystem=config.ecosystem.name,
2716
+ ~isolated=config.isolated,
2584
2717
  ~sink=?{
2585
2718
  // Internally ClickHouse storage is implemented as a sync of the
2586
2719
  // Postgres storage. Required env vars are validated here only when
@@ -2613,6 +2746,7 @@ let makeStorageFromEnv = (
2613
2746
  ~database=database->Option.getUnsafe,
2614
2747
  ~username=username->Option.getUnsafe,
2615
2748
  ~password=password->Option.getUnsafe,
2749
+ ~sequence=config.checkpointSequence,
2616
2750
  ~chainIdMode=config.chainIdMode,
2617
2751
  ),
2618
2752
  )
@@ -2644,34 +2778,6 @@ let makeStorageFromEnv = (
2644
2778
  None
2645
2779
  }
2646
2780
  },
2647
- ~onNewTables=?{
2648
- if isHasuraEnabled {
2649
- Some(
2650
- (~tableNames) => {
2651
- Hasura.trackTables(
2652
- ~endpoint=Env.Hasura.graphqlEndpoint,
2653
- ~auth={
2654
- role: Env.Hasura.role,
2655
- secret: Env.Hasura.secret,
2656
- },
2657
- ~pgSchema,
2658
- ~tableConfigs=tableNames->Array.map(tableName => {
2659
- Hasura.tableName,
2660
- description: None,
2661
- columnConfigs: dict{},
2662
- }),
2663
- )->Promise.catch(err => {
2664
- Logging.errorWithExn(
2665
- err->Utils.prettifyExn,
2666
- `Error tracking new tables`,
2667
- )->Promise.resolve
2668
- })
2669
- },
2670
- )
2671
- } else {
2672
- None
2673
- }
2674
- },
2675
2781
  ~isHasuraEnabled,
2676
2782
  )
2677
2783
  }