envio 3.5.0-alpha.1 → 3.5.0-alpha.3

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 (99) hide show
  1. package/index.d.ts +10 -6
  2. package/package.json +6 -6
  3. package/src/BatchProcessing.res +27 -0
  4. package/src/BatchProcessing.res.mjs +12 -0
  5. package/src/ChainFetching.res +5 -5
  6. package/src/ChainFetching.res.mjs +3 -4
  7. package/src/ChainState.res +48 -25
  8. package/src/ChainState.res.mjs +23 -29
  9. package/src/ChainState.resi +2 -6
  10. package/src/Change.res +3 -3
  11. package/src/Config.res +2 -6
  12. package/src/Config.res.mjs +2 -12
  13. package/src/Core.res +3 -0
  14. package/src/CrossChainState.res +33 -10
  15. package/src/CrossChainState.res.mjs +21 -6
  16. package/src/CrossChainState.resi +3 -0
  17. package/src/EntityId.res +15 -0
  18. package/src/EntityId.res.mjs +9 -0
  19. package/src/EventConfigBuilder.res +1 -58
  20. package/src/EventConfigBuilder.res.mjs +1 -33
  21. package/src/FetchState.res +510 -460
  22. package/src/FetchState.res.mjs +413 -428
  23. package/src/FinalizeBackfill.res +34 -0
  24. package/src/FinalizeBackfill.res.mjs +26 -0
  25. package/src/HandlerRegister.res +45 -0
  26. package/src/HandlerRegister.res.mjs +43 -0
  27. package/src/InMemoryStore.res +11 -4
  28. package/src/InMemoryTable.res +26 -24
  29. package/src/InMemoryTable.res.mjs +19 -18
  30. package/src/IndexerState.res +71 -2
  31. package/src/IndexerState.res.mjs +67 -3
  32. package/src/IndexerState.resi +5 -0
  33. package/src/Internal.res +17 -8
  34. package/src/LoadLayer.res +9 -1
  35. package/src/LoadLayer.res.mjs +1 -0
  36. package/src/LogSelection.res +2 -1
  37. package/src/Metrics.res +37 -6
  38. package/src/Metrics.res.mjs +5 -1
  39. package/src/Persistence.res +12 -1
  40. package/src/PgStorage.res +411 -57
  41. package/src/PgStorage.res.mjs +262 -41
  42. package/src/SimulateItems.res +2 -2
  43. package/src/TestIndexer.res +9 -5
  44. package/src/TestIndexer.res.mjs +5 -2
  45. package/src/UserContext.res +3 -3
  46. package/src/Writing.res +12 -2
  47. package/src/Writing.res.mjs +13 -2
  48. package/src/bindings/ClickHouse.res +5 -3
  49. package/src/bindings/ClickHouse.res.mjs +2 -5
  50. package/src/bindings/Vitest.res +22 -1
  51. package/src/bindings/Vitest.res.mjs +15 -0
  52. package/src/db/EntityFilter.res +0 -4
  53. package/src/db/EntityFilter.res.mjs +1 -8
  54. package/src/db/EntityHistory.res +16 -7
  55. package/src/db/EntityHistory.res.mjs +7 -6
  56. package/src/db/IndexRegistry.res +219 -0
  57. package/src/db/IndexRegistry.res.mjs +195 -0
  58. package/src/db/InternalTable.res +7 -0
  59. package/src/db/InternalTable.res.mjs +7 -0
  60. package/src/db/Table.res +53 -22
  61. package/src/db/Table.res.mjs +56 -18
  62. package/src/sources/AddressSet.res +48 -0
  63. package/src/sources/AddressSet.res.mjs +11 -0
  64. package/src/sources/AddressStore.res +152 -0
  65. package/src/sources/AddressStore.res.mjs +97 -0
  66. package/src/sources/EvmChain.res +3 -0
  67. package/src/sources/EvmChain.res.mjs +4 -2
  68. package/src/sources/EvmHyperSyncSource.res +6 -3
  69. package/src/sources/EvmHyperSyncSource.res.mjs +3 -3
  70. package/src/sources/EvmRpcClient.res +6 -3
  71. package/src/sources/EvmRpcClient.res.mjs +3 -3
  72. package/src/sources/FuelHyperSync.res +4 -3
  73. package/src/sources/FuelHyperSync.res.mjs +3 -3
  74. package/src/sources/FuelHyperSync.resi +2 -1
  75. package/src/sources/FuelHyperSyncClient.res +12 -6
  76. package/src/sources/FuelHyperSyncClient.res.mjs +2 -2
  77. package/src/sources/FuelHyperSyncSource.res +7 -4
  78. package/src/sources/FuelHyperSyncSource.res.mjs +3 -3
  79. package/src/sources/HyperSync.res +6 -4
  80. package/src/sources/HyperSync.res.mjs +2 -3
  81. package/src/sources/HyperSync.resi +1 -1
  82. package/src/sources/HyperSyncClient.res +18 -9
  83. package/src/sources/HyperSyncClient.res.mjs +4 -4
  84. package/src/sources/RpcSource.res +15 -10
  85. package/src/sources/RpcSource.res.mjs +3 -4
  86. package/src/sources/SimulateSource.res +26 -11
  87. package/src/sources/SimulateSource.res.mjs +13 -5
  88. package/src/sources/Source.res +4 -2
  89. package/src/sources/SourceManager.res +2 -3
  90. package/src/sources/SourceManager.res.mjs +2 -3
  91. package/src/sources/Svm.res +1 -2
  92. package/src/sources/Svm.res.mjs +1 -1
  93. package/src/sources/SvmHyperSyncClient.res +12 -3
  94. package/src/sources/SvmHyperSyncClient.res.mjs +2 -2
  95. package/src/sources/SvmHyperSyncSource.res +10 -5
  96. package/src/sources/SvmHyperSyncSource.res.mjs +5 -4
  97. package/src/IndexingAddresses.res +0 -151
  98. package/src/IndexingAddresses.res.mjs +0 -130
  99. package/src/IndexingAddresses.resi +0 -39
package/src/PgStorage.res CHANGED
@@ -58,7 +58,7 @@ let makeCreateCompositeIndexQuery = (
58
58
  `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${pgSchema}"."${tableName}"(${index});`
59
59
  }
60
60
 
61
- let makeCreateTableIndicesQuery = (table: Table.table, ~pgSchema) => {
61
+ let makeCreateTableIndexesQuery = (table: Table.table, ~pgSchema) => {
62
62
  let tableName = table.tableName
63
63
  let createIndex = indexField =>
64
64
  makeCreateIndexQuery(~tableName, ~indexFields=[indexField], ~pgSchema)
@@ -66,11 +66,138 @@ let makeCreateTableIndicesQuery = (table: Table.table, ~pgSchema) => {
66
66
  makeCreateCompositeIndexQuery(~tableName, ~indexFields, ~pgSchema)
67
67
  }
68
68
 
69
- let singleIndices = table->Table.getSingleIndices
70
- let compositeIndices = table->Table.getCompositeIndices
69
+ let singleIndexes = table->Table.getSingleIndexes
70
+ let compositeIndexes = table->Table.getCompositeIndexes
71
71
 
72
- singleIndices->Array.map(createIndex)->Array.joinUnsafe("\n") ++
73
- compositeIndices->Array.map(createCompositeIndex)->Array.joinUnsafe("\n")
72
+ singleIndexes->Array.map(createIndex)->Array.joinUnsafe("\n") ++
73
+ compositeIndexes->Array.map(createCompositeIndex)->Array.joinUnsafe("\n")
74
+ }
75
+
76
+ // One index the schema promises: an `@index` field, a composite index, or the
77
+ // index backing a derived relationship. Carries enough to derive its name, its
78
+ // DDL and its registry key, so the deferred creation at the end of backfill and
79
+ // the registry stay described by the same value.
80
+ type schemaIndex = {
81
+ tableName: string,
82
+ indexFields: array<Table.compositeIndexField>,
83
+ }
84
+
85
+ // `<Entity>_<column>` for a single column, with each further column appended in
86
+ // order (and `_desc` where it applies). The full description, before Postgres'
87
+ // identifier limit is applied — `validateIndexNamesOrThrow` compares these, so
88
+ // it can tell two descriptions apart after they truncate to the same name.
89
+ let schemaIndexDescription = ({tableName, indexFields}: schemaIndex) =>
90
+ tableName ++
91
+ "_" ++
92
+ indexFields
93
+ ->Array.map(f => f.fieldName ++ directionToIndexName(f.direction))
94
+ ->Array.joinUnsafe("_")
95
+
96
+ // The identifier Postgres actually stores. Automatic and schema-defined indexes
97
+ // share it, so both are named the same way and both stay reachable through
98
+ // `CREATE INDEX IF NOT EXISTS`.
99
+ let schemaIndexName = schemaIndex =>
100
+ schemaIndex->schemaIndexDescription->IndexRegistry.truncateIndexName
101
+
102
+ let schemaIndexKey = ({tableName, indexFields}: schemaIndex) =>
103
+ IndexRegistry.makeKey(
104
+ ~tableName,
105
+ ~columns=indexFields->IndexRegistry.fromIndexFields,
106
+ ~method=IndexRegistry.btree,
107
+ )
108
+
109
+ // Plain DDL, not CONCURRENTLY: it builds from a single table scan instead of
110
+ // two, and the SHARE lock it takes blocks writes but not reads, so queries keep
111
+ // being served while it runs. The indexer is the only writer, and both callers
112
+ // are happy to wait on it.
113
+ let makeCreateSchemaIndexQuery = (schemaIndex, ~pgSchema) => {
114
+ let {tableName, indexFields} = schemaIndex
115
+ let columns =
116
+ indexFields
117
+ ->Array.map(f => `"${f.fieldName}"${directionToSql(f.direction)}`)
118
+ ->Array.joinUnsafe(", ")
119
+ `CREATE INDEX IF NOT EXISTS "${schemaIndex->schemaIndexName}" ON "${pgSchema}"."${tableName}"(${columns});`
120
+ }
121
+
122
+ let formatSeconds = (timeRef: Performance.timeRef) =>
123
+ (Math.round(timeRef->Performance.secondsSince *. 100.) /. 100.)->Float.toString
124
+
125
+ // Every index build blocks writes to its table for as long as it runs, and on a
126
+ // large database that is not quick. Both build paths say so up front, so a
127
+ // stalled-looking indexer is explainable from the logs alone.
128
+ let slowOnLargeDatabaseNotice = "This can take a long time on a large database."
129
+
130
+ // An index Postgres reports as INVALID still owns its name, so
131
+ // `CREATE INDEX IF NOT EXISTS` would quietly skip it and leave the query
132
+ // unserved. REINDEX rebuilds it from its own stored definition — a repair, not
133
+ // a replacement — which is right only when that definition is the one we
134
+ // wanted. Runs inside a transaction, unlike REINDEX CONCURRENTLY.
135
+ let makeReindexQuery = (~pgSchema, ~indexName) => `REINDEX INDEX "${pgSchema}"."${indexName}";`
136
+
137
+ // A name taken by an invalid index the indexer didn't create isn't ours to
138
+ // touch: rebuilding it reuses its own definition, so it would come back valid
139
+ // but still not serve the query, and dropping it could take out a unique
140
+ // constraint someone else relies on.
141
+ let nameCollisionError = (~indexName, ~pgSchema) =>
142
+ Utils.Error.make(
143
+ `Cannot create the index "${indexName}" in schema "${pgSchema}". An index of that name already exists and PostgreSQL reports it as invalid, but it isn't one the indexer created — it covers different columns, or it is a unique or partial index — so it is left untouched. Drop or repair it by hand, then restart the indexer.`,
144
+ )
145
+
146
+ let makeSingleColumnSchemaIndex = (~tableName, ~column): schemaIndex => {
147
+ tableName,
148
+ indexFields: [{fieldName: column, direction: Table.Asc}],
149
+ }
150
+
151
+ // Every index the entity schema promises. Deferred past the initial DDL and
152
+ // created in one transaction once backfill completes, so a resumed indexer that
153
+ // reports itself ready always has all of them.
154
+ //
155
+ // `entities` is the Postgres-backed set, and every `@derivedFrom` target within
156
+ // it resolves: config parsing rejects a Postgres entity deriving from one that
157
+ // isn't in Postgres (`validate_relationship_storage`).
158
+ let getSchemaIndexes = (~entities: array<Internal.entityConfig>): array<schemaIndex> => {
159
+ let derivedSchema = Schema.make(entities->Array.map(e => e.table))
160
+ let all = []
161
+
162
+ entities->Array.forEach(({table}) => {
163
+ table
164
+ ->Table.getSingleIndexes
165
+ ->Array.forEach(column =>
166
+ all->Array.push(makeSingleColumnSchemaIndex(~tableName=table.tableName, ~column))->ignore
167
+ )
168
+ table
169
+ ->Table.getCompositeIndexes
170
+ ->Array.forEach(indexFields =>
171
+ all->Array.push({tableName: table.tableName, indexFields})->ignore
172
+ )
173
+ })
174
+
175
+ entities->Array.forEach(({table}) =>
176
+ table
177
+ ->Table.getDerivedFromFields
178
+ ->Array.forEach(derivedFromField => {
179
+ let column =
180
+ derivedSchema->Schema.getDerivedFromPgFieldName(derivedFromField)->Utils.unwrapResultExn
181
+ all
182
+ ->Array.push(
183
+ makeSingleColumnSchemaIndex(~tableName=derivedFromField.derivedFromEntity, ~column),
184
+ )
185
+ ->ignore
186
+ })
187
+ )
188
+
189
+ // An `@index` field and a derived relationship pointing at it describe the
190
+ // same index, so the list is deduped on identity rather than on name.
191
+ let seen = Utils.Set.make()
192
+ all->Array.filter(schemaIndex => {
193
+ let key = schemaIndex->schemaIndexKey
194
+ if seen->Utils.Set.has(key) {
195
+ false
196
+ } else {
197
+ seen->Utils.Set.add(key)->ignore
198
+ true
199
+ }
200
+ })
74
201
  }
75
202
 
76
203
  let makeCreateTableQuery = (table: Table.table, ~pgSchema, ~isNumericArrayAsText) => {
@@ -152,13 +279,16 @@ let getEntityHistory = (~entityConfig: Internal.entityConfig): EntityHistory.pgE
152
279
  ~entityName=entityTableName,
153
280
  ~entityIndex=entityConfig.index,
154
281
  )
155
- //ignore composite indices
282
+ //ignore composite indexes
156
283
  let table = Table.mkTable(
157
284
  historyTableName,
158
285
  ~fields=dataFields->Array.concat([checkpointIdField, actionField]),
159
286
  )
160
287
 
161
- let setChangeSchema = EntityHistory.makeSetUpdateSchema(entityConfig.schema)
288
+ let setChangeSchema = EntityHistory.makeSetUpdateSchema(
289
+ ~idSchema=entityConfig.table->Table.getIdSchema,
290
+ entityConfig.schema,
291
+ )
162
292
 
163
293
  {
164
294
  EntityHistory.table,
@@ -180,6 +310,10 @@ let makeInitializeTransaction = (
180
310
  ~entities=[],
181
311
  ~enums=[],
182
312
  ~isEmptyPgSchema=false,
313
+ // Backfill writes are far cheaper without the schema's read indexes, so the
314
+ // initial DDL creates only tables, primary keys, views and chain rows; the
315
+ // rest is created by `finalizeBackfill` before the indexer reports ready.
316
+ ~deferSchemaIndexes=false,
183
317
  ) => {
184
318
  let generalTables = [
185
319
  InternalTable.Chains.table,
@@ -189,13 +323,13 @@ let makeInitializeTransaction = (
189
323
  ]
190
324
 
191
325
  let allTables = generalTables->Array.copy
192
- let allEntityTables = []
193
326
  entities->Array.forEach((entityConfig: Internal.entityConfig) => {
194
- allEntityTables->Array.push(entityConfig.table)->ignore
195
327
  allTables->Array.push(entityConfig.table)->ignore
196
328
  allTables->Array.push(getEntityHistory(~entityConfig).table)->ignore
197
329
  })
198
- let derivedSchema = Schema.make(allEntityTables)
330
+
331
+ let schemaIndexes = getSchemaIndexes(~entities)
332
+ IndexRegistry.validateIndexNamesOrThrow(schemaIndexes->Array.map(schemaIndexDescription))
199
333
 
200
334
  let query = ref(
201
335
  (
@@ -229,31 +363,12 @@ GRANT ALL ON SCHEMA "${pgSchema}" TO public;`,
229
363
  makeCreateTableQuery(table, ~pgSchema, ~isNumericArrayAsText=isHasuraEnabled)
230
364
  })
231
365
 
232
- // Then batch all indices (better performance when tables exist)
233
- allTables->Array.forEach((table: Table.table) => {
234
- let indices = makeCreateTableIndicesQuery(table, ~pgSchema)
235
- if indices !== "" {
236
- query := query.contents ++ "\n" ++ indices
237
- }
238
- })
239
-
240
- // Add derived indices
241
- entities->Array.forEach((entity: Internal.entityConfig) => {
242
- entity.table
243
- ->Table.getDerivedFromFields
244
- ->Array.forEach(derivedFromField => {
245
- let indexField =
246
- derivedSchema->Schema.getDerivedFromPgFieldName(derivedFromField)->Utils.unwrapResultExn
247
- query :=
248
- query.contents ++
249
- "\n" ++
250
- makeCreateIndexQuery(
251
- ~tableName=derivedFromField.derivedFromEntity,
252
- ~indexFields=[indexField],
253
- ~pgSchema,
254
- )
366
+ // Then batch all indexes (better performance when tables exist)
367
+ if !deferSchemaIndexes {
368
+ schemaIndexes->Array.forEach(schemaIndex => {
369
+ query := query.contents ++ "\n" ++ makeCreateSchemaIndexQuery(schemaIndex, ~pgSchema)
255
370
  })
256
- })
371
+ }
257
372
 
258
373
  // Create views for Hasura integration
259
374
  query := query.contents ++ "\n" ++ InternalTable.Views.makeMetaViewQuery(~pgSchema)
@@ -355,8 +470,8 @@ let makeDeleteByIdQuery = (~pgSchema, ~tableName) => {
355
470
  `DELETE FROM "${pgSchema}"."${tableName}" WHERE id = $1;`
356
471
  }
357
472
 
358
- let makeDeleteByIdsQuery = (~pgSchema, ~tableName) => {
359
- `DELETE FROM "${pgSchema}"."${tableName}" WHERE id = ANY($1::text[]);`
473
+ let makeDeleteByIdsQuery = (~pgSchema, ~tableName, ~idPgType) => {
474
+ `DELETE FROM "${pgSchema}"."${tableName}" WHERE id = ANY($1::${idPgType}[]);`
360
475
  }
361
476
 
362
477
  let makeLoadAllQuery = (~pgSchema, ~tableName) => {
@@ -744,18 +859,26 @@ let getConnectedPsqlExec = {
744
859
  }
745
860
  }
746
861
 
747
- let deleteByIdsOrThrow = async (sql, ~pgSchema, ~ids, ~table: Table.table) => {
862
+ let deleteByIdsOrThrow = async (sql, ~pgSchema, ~ids: array<EntityId.t>, ~table: Table.table) => {
863
+ // A JSON array of the serialized ids. For a single id the query binds it as
864
+ // `$1` directly (the array is the positional-params array); for many it binds
865
+ // the whole array to `$1` behind an `ANY(...)`.
866
+ let idsJson = table->Table.encodeIdsToJson(ids)
748
867
  switch await (
749
868
  switch ids {
750
869
  | [_] =>
751
870
  sql->Postgres.preparedUnsafe(
752
871
  makeDeleteByIdQuery(~pgSchema, ~tableName=table.tableName),
753
- ids->Obj.magic,
872
+ idsJson->Obj.magic,
754
873
  )
755
874
  | _ =>
756
875
  sql->Postgres.preparedUnsafe(
757
- makeDeleteByIdsQuery(~pgSchema, ~tableName=table.tableName),
758
- [ids]->Obj.magic,
876
+ makeDeleteByIdsQuery(
877
+ ~pgSchema,
878
+ ~tableName=table.tableName,
879
+ ~idPgType=table->Table.getIdPgFieldType(~pgSchema),
880
+ ),
881
+ [idsJson]->Obj.magic,
759
882
  )
760
883
  }
761
884
  ) {
@@ -811,9 +934,11 @@ let makeInsertDeleteUpdatesQuery = (~entityConfig: Internal.entityConfig, ~pgSch
811
934
  ~isNullable=false,
812
935
  )
813
936
 
937
+ let idPgType = entityConfig.table->Table.getIdPgFieldType(~pgSchema)
938
+
814
939
  `INSERT INTO "${pgSchema}"."${historyTableName}" (${allHistoryFieldNamesStr})
815
940
  SELECT ${selectPartsStr}
816
- FROM UNNEST($1::text[], $2::${checkpointIdPgType}[]) AS u(${Table.idFieldName}, ${EntityHistory.checkpointIdFieldName})`
941
+ FROM UNNEST($1::${idPgType}[], $2::${checkpointIdPgType}[]) AS u(${Table.idFieldName}, ${EntityHistory.checkpointIdFieldName})`
817
942
  }
818
943
 
819
944
  let executeSet = (
@@ -912,17 +1037,21 @@ let rec writeBatch = async (
912
1037
  // Single pass over the change log: track each id's latest change (the last
913
1038
  // one seen) and, when saving history, fan every non-diff change out to the
914
1039
  // history-table batches.
1040
+ // Keyed/deduped in memory by the id's string key (toKey), while the
1041
+ // batches sent to SQL keep the real id values so they serialize with the
1042
+ // id column's type.
915
1043
  let latestChangeById = Dict.make()
916
1044
  let orderedIds = []
917
1045
  changes->Array.forEach(change => {
918
1046
  let entityId = change->Change.getEntityId
919
- if latestChangeById->Utils.Dict.dangerouslyGetNonOption(entityId)->Option.isNone {
1047
+ let entityKey = entityId->EntityId.toKey
1048
+ if latestChangeById->Utils.Dict.dangerouslyGetNonOption(entityKey)->Option.isNone {
920
1049
  orderedIds->Array.push(entityId)
921
1050
  }
922
- latestChangeById->Dict.set(entityId, change)
1051
+ latestChangeById->Dict.set(entityKey, change)
923
1052
  if shouldSaveHistory {
924
1053
  if Some(change->Change.getCheckpointId) === diffCheckpointId {
925
- idsWithDiff->Utils.Set.add(entityId)->ignore
1054
+ idsWithDiff->Utils.Set.add(entityKey)->ignore
926
1055
  } else {
927
1056
  switch change {
928
1057
  | Delete({entityId, checkpointId}) =>
@@ -936,13 +1065,14 @@ let rec writeBatch = async (
936
1065
 
937
1066
  let backfillHistoryIds = Utils.Set.make()
938
1067
  orderedIds->Array.forEach(entityId => {
939
- switch latestChangeById->Dict.getUnsafe(entityId) {
1068
+ let entityKey = entityId->EntityId.toKey
1069
+ switch latestChangeById->Dict.getUnsafe(entityKey) {
940
1070
  | Set({entity}) => entitiesToSet->Array.push(entity)
941
1071
  | Delete({entityId}) => idsToDelete->Array.push(entityId)
942
1072
  }
943
1073
 
944
1074
  // An id needs a history backfill iff none of its changes is the diff.
945
- if shouldSaveHistory && !(idsWithDiff->Utils.Set.has(entityId)) {
1075
+ if shouldSaveHistory && !(idsWithDiff->Utils.Set.has(entityKey)) {
946
1076
  backfillHistoryIds->Utils.Set.add(entityId)->ignore
947
1077
  }
948
1078
  })
@@ -962,7 +1092,7 @@ let rec writeBatch = async (
962
1092
  await EntityHistory.backfillHistory(
963
1093
  sql,
964
1094
  ~pgSchema,
965
- ~entityName=entityConfig.name,
1095
+ ~table=entityConfig.table,
966
1096
  ~entityIndex=entityConfig.index,
967
1097
  ~ids=backfillHistoryIds->Utils.Set.toArray,
968
1098
  )
@@ -974,7 +1104,7 @@ let rec writeBatch = async (
974
1104
  ->Postgres.preparedUnsafe(
975
1105
  makeInsertDeleteUpdatesQuery(~entityConfig, ~pgSchema),
976
1106
  (
977
- batchDeleteEntityIds,
1107
+ entityConfig.table->Table.encodeIdsToJson(batchDeleteEntityIds),
978
1108
  batchDeleteCheckpointIds->Utils.BigInt.arrayToStringArray,
979
1109
  )->Obj.magic,
980
1110
  )
@@ -1230,10 +1360,25 @@ let makeGetRollbackRemovedIdsQuery = (~entityConfig: Internal.entityConfig, ~pgS
1230
1360
  )`
1231
1361
  }
1232
1362
 
1233
- let rollbackRowStateSchema = S.object(s => (
1234
- s.field(Table.idFieldName, S.string),
1235
- s.field(EntityHistory.changeFieldName, EntityHistory.RowAction.schema),
1236
- ))
1363
+ // Memoized per table so the id is parsed with that entity's id schema (a
1364
+ // numeric id comes back from Postgres as a number, not a string) and the
1365
+ // schema's operations compile once rather than per rollback row.
1366
+ let rollbackRowStateSchema: Table.table => S.t<(
1367
+ EntityId.t,
1368
+ EntityHistory.RowAction.t,
1369
+ )> = Utils.WeakMap.memoize(table =>
1370
+ S.object(s => (
1371
+ s.field(Table.idFieldName, table->Table.getIdSchema),
1372
+ s.field(EntityHistory.changeFieldName, EntityHistory.RowAction.schema),
1373
+ ))
1374
+ )
1375
+
1376
+ // Same reason as above for the id-only rows: both rollback queries must yield
1377
+ // ids in the entity's own representation, or the two halves of the diff would
1378
+ // disagree (Postgres hands back a NUMERIC id as a string, not a bigint).
1379
+ let rollbackRemovedIdsSchema: Table.table => S.t<array<EntityId.t>> = Utils.WeakMap.memoize(table =>
1380
+ S.array(S.object(s => s.field(Table.idFieldName, table->Table.getIdSchema)))
1381
+ )
1237
1382
 
1238
1383
  let make = (
1239
1384
  ~sql: Postgres.sql,
@@ -1260,6 +1405,37 @@ let make = (
1260
1405
  "cache",
1261
1406
  ])
1262
1407
 
1408
+ // The metric label, the `storage` field on this backend's logs, and the
1409
+ // storage record's own name are all the same string.
1410
+ let storageName = "postgres"
1411
+
1412
+ let indexRegistry = IndexRegistry.make()
1413
+
1414
+ // The only point where the catalog is consulted. Afterwards this process is
1415
+ // the sole writer of the schema, so the registry is kept in sync by adding
1416
+ // each index right after its DDL succeeds.
1417
+ let reloadIndexRegistry = async () => {
1418
+ let rows =
1419
+ (await sql->Postgres.unsafe(IndexRegistry.makeCatalogQuery(~pgSchema)))->S.parseOrThrow(
1420
+ IndexRegistry.catalogRowsSchema,
1421
+ )
1422
+ switch indexRegistry->IndexRegistry.reload(~rows) {
1423
+ | [] => ()
1424
+ | invalidIndexNames =>
1425
+ Logging.warn({
1426
+ "storage": storageName,
1427
+ "msg": `Ignoring invalid PostgreSQL indexes in schema "${pgSchema}". They are left untouched — drop and recreate them manually if you need them.`,
1428
+ "indexes": invalidIndexNames,
1429
+ })
1430
+ }
1431
+ Logging.info({
1432
+ "storage": storageName,
1433
+ "msg": `Found ${indexRegistry
1434
+ ->IndexRegistry.size
1435
+ ->Int.toString} existing indexes in the indexer schema.`,
1436
+ })
1437
+ }
1438
+
1263
1439
  let isInitialized = async () => {
1264
1440
  let envioTables = await sql->Postgres.unsafe(
1265
1441
  `SELECT table_schema FROM information_schema.tables WHERE table_schema = '${pgSchema}' AND (table_name = '${// This is for indexer before envio@2.28
@@ -1451,6 +1627,7 @@ let make = (
1451
1627
  ~chainConfigs,
1452
1628
  ~isEmptyPgSchema=schemaTableNames->Utils.Array.isEmpty,
1453
1629
  ~isHasuraEnabled,
1630
+ ~deferSchemaIndexes=true,
1454
1631
  )
1455
1632
  // Execute all queries within a single transaction for integrity.
1456
1633
  // The envio_info row is written in the same transaction so a successful
@@ -1487,6 +1664,8 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1487
1664
 
1488
1665
  let cache = await restoreEffectCache(~withUpload=true)
1489
1666
 
1667
+ await reloadIndexRegistry()
1668
+
1490
1669
  // Integration with other tools like Hasura
1491
1670
  switch onInitialize {
1492
1671
  | Some(onInitialize) => await onInitialize()
@@ -1543,6 +1722,177 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1543
1722
  }
1544
1723
  }
1545
1724
 
1725
+ // The physical columns a filter reads, deduped and in first-seen order.
1726
+ // Unknown field names are left to `loadOrThrow`, which reports them properly.
1727
+ let filterColumns = (~table: Table.table, ~filters: array<EntityFilter.t>) => {
1728
+ let queryFields = table->Table.queryFields
1729
+ let columns = []
1730
+ let seen = Utils.Set.make()
1731
+ let rec collect = (filter: EntityFilter.t) =>
1732
+ switch filter {
1733
+ | Eq({fieldName}) | Gt({fieldName}) | Lt({fieldName}) | In({fieldName}) =>
1734
+ switch queryFields->Utils.Dict.dangerouslyGetNonOption(fieldName) {
1735
+ | Some({pgDbFieldName}) =>
1736
+ if !(seen->Utils.Set.has(pgDbFieldName)) {
1737
+ seen->Utils.Set.add(pgDbFieldName)->ignore
1738
+ columns->Array.push(pgDbFieldName)->ignore
1739
+ }
1740
+ | None => ()
1741
+ }
1742
+ | And({filters}) => filters->Array.forEach(collect)
1743
+ }
1744
+ filters->Array.forEach(collect)
1745
+ columns
1746
+ }
1747
+
1748
+ // The DDL that leaves `schemaIndex` in place. Normally a create; a repair
1749
+ // when an invalid index already holds the name with the definition we want.
1750
+ let makeEnsureIndexQuery = schemaIndex => {
1751
+ let indexName = schemaIndex->schemaIndexName
1752
+ switch indexRegistry->IndexRegistry.getInvalid(indexName) {
1753
+ | None => makeCreateSchemaIndexQuery(schemaIndex, ~pgSchema)
1754
+ | Some({key, isRepairable: true}) if key === schemaIndex->schemaIndexKey =>
1755
+ makeReindexQuery(~pgSchema, ~indexName)
1756
+ | Some(_) => throw(nameCollisionError(~indexName, ~pgSchema))
1757
+ }
1758
+ }
1759
+
1760
+ let isRepair = schemaIndex =>
1761
+ indexRegistry->IndexRegistry.getInvalid(schemaIndex->schemaIndexName)->Option.isSome
1762
+
1763
+ let ensureQueryIndexes = async (~table: Table.table, ~filters: array<EntityFilter.t>) => {
1764
+ let missing = filterColumns(~table, ~filters)->Array.filterMap(column => {
1765
+ let schemaIndex = makeSingleColumnSchemaIndex(~tableName=table.tableName, ~column)
1766
+ indexRegistry->IndexRegistry.has(schemaIndex->schemaIndexKey) ? None : Some(schemaIndex)
1767
+ })
1768
+
1769
+ if missing->Utils.Array.notEmpty {
1770
+ let _ = await missing
1771
+ ->Array.map(schemaIndex => {
1772
+ let indexName = schemaIndex->schemaIndexName
1773
+ indexRegistry
1774
+ ->IndexRegistry.ensure(
1775
+ ~key=schemaIndex->schemaIndexKey,
1776
+ ~tableName=table.tableName,
1777
+ ~build=async () => {
1778
+ // Resolved before logging so a repair is reported as one, and an
1779
+ // unrelated index holding the name fails before any DDL runs.
1780
+ let query = makeEnsureIndexQuery(schemaIndex)
1781
+ let verb = schemaIndex->isRepair ? "Repairing invalid index" : "Creating index"
1782
+ // Logged from inside the build so it reports the one request that
1783
+ // actually creates the index, not the ones waiting on it.
1784
+ Logging.info({
1785
+ "storage": storageName,
1786
+ "msg": `${verb} "${indexName}" to serve a getWhere query on "${table.tableName}". Writes to the table are paused until it completes. ${slowOnLargeDatabaseNotice}`,
1787
+ })
1788
+ let timeRef = Performance.now()
1789
+ let _ = await sql->Postgres.unsafe(query)
1790
+ indexRegistry->IndexRegistry.clearInvalidName(indexName)
1791
+ Logging.info({
1792
+ "storage": storageName,
1793
+ "msg": `Index "${indexName}" is ready after ${timeRef->formatSeconds}s. Resuming indexing.`,
1794
+ })
1795
+ },
1796
+ )
1797
+ // A failed build leaves the registry untouched, so the next getWhere
1798
+ // retries. Meanwhile the query still runs — just without the index.
1799
+ ->Utils.Promise.catchResolve(exn => {
1800
+ Logging.warn({
1801
+ "storage": storageName,
1802
+ "msg": `Failed to create the index "${indexName}" for a getWhere query. The query runs without it.`,
1803
+ "err": exn->Utils.prettifyExn,
1804
+ })
1805
+ })
1806
+ })
1807
+ ->Promise.all
1808
+ }
1809
+ }
1810
+
1811
+ let finalizeBackfill = async (
1812
+ ~entities: array<Internal.entityConfig>,
1813
+ ~chainIds: array<int>,
1814
+ ~readyAt: Date.t,
1815
+ ) => {
1816
+ let schemaIndexes = getSchemaIndexes(
1817
+ ~entities=entities->Array.filter((e: Internal.entityConfig) => e.storage.postgres),
1818
+ )
1819
+ IndexRegistry.validateIndexNamesOrThrow(schemaIndexes->Array.map(schemaIndexDescription))
1820
+
1821
+ let missing =
1822
+ schemaIndexes->Array.filter(schemaIndex =>
1823
+ !(indexRegistry->IndexRegistry.has(schemaIndex->schemaIndexKey))
1824
+ )
1825
+
1826
+ // `IF NOT EXISTS` would quietly skip an index whose name is taken by an
1827
+ // invalid one, and the indexer would then report ready with an index the
1828
+ // planner can't use. Those are repaired in the same transaction instead.
1829
+ let repaired = missing->Array.filter(isRepair)
1830
+ if repaired->Utils.Array.notEmpty {
1831
+ Logging.warn({
1832
+ "storage": storageName,
1833
+ "msg": `PostgreSQL reports ${repaired
1834
+ ->Array.length
1835
+ ->Int.toString} existing indexes as invalid, so they can't serve queries. Rebuilding them.`,
1836
+ "indexes": repaired->Array.map(schemaIndexName),
1837
+ })
1838
+ }
1839
+
1840
+ switch missing {
1841
+ | [] =>
1842
+ Logging.info({
1843
+ "storage": storageName,
1844
+ "msg": `All ${schemaIndexes
1845
+ ->Array.length
1846
+ ->Int.toString} schema indexes are already in place. Marking the indexer ready.`,
1847
+ })
1848
+ | _ =>
1849
+ Logging.info({
1850
+ "storage": storageName,
1851
+ "msg": `Creating the ${missing
1852
+ ->Array.length
1853
+ ->Int.toString} remaining schema indexes before the indexer reports ready. Writes are paused until they are committed. ${slowOnLargeDatabaseNotice}`,
1854
+ "indexes": missing->Array.map(schemaIndexName),
1855
+ })
1856
+ }
1857
+ // Resolved up front so a name held by an unrelated invalid index fails
1858
+ // before the transaction opens, rather than rolling one back.
1859
+ let indexQueries = missing->Array.map(makeEnsureIndexQuery)
1860
+ let timeRef = Performance.now()
1861
+
1862
+ await sql->Postgres.beginSql(async sql => {
1863
+ // Sequential rather than Promise.all: they share one connection inside
1864
+ // the transaction, and several of them target the same table.
1865
+ for idx in 0 to indexQueries->Array.length - 1 {
1866
+ let _ = await sql->Postgres.unsafe(indexQueries->Array.getUnsafe(idx))
1867
+ }
1868
+ await sql
1869
+ ->Postgres.preparedUnsafe(
1870
+ InternalTable.Chains.makeSetReadyAtQuery(~pgSchema),
1871
+ [
1872
+ readyAt->(Utils.magic: Date.t => unknown),
1873
+ chainIds->(Utils.magic: array<int> => unknown),
1874
+ ]->(Utils.magic: array<unknown> => unknown),
1875
+ )
1876
+ ->Utils.Promise.ignoreValue
1877
+ })
1878
+
1879
+ // Only after the commit: a rollback takes both the indexes and ready_at
1880
+ // with it, and the registry must not claim what the schema doesn't have.
1881
+ missing->Array.forEach(schemaIndex =>
1882
+ indexRegistry->IndexRegistry.add(schemaIndex->schemaIndexKey)
1883
+ )
1884
+ repaired->Array.forEach(schemaIndex =>
1885
+ indexRegistry->IndexRegistry.clearInvalidName(schemaIndex->schemaIndexName)
1886
+ )
1887
+
1888
+ Logging.info({
1889
+ "storage": storageName,
1890
+ "msg": `Committed ${missing
1891
+ ->Array.length
1892
+ ->Int.toString} schema indexes and the ready timestamp in ${timeRef->formatSeconds}s.`,
1893
+ })
1894
+ }
1895
+
1546
1896
  let setOrThrow = (
1547
1897
  type item,
1548
1898
  ~items: array<item>,
@@ -1687,6 +2037,8 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1687
2037
  InternalTable.EnvioInfo.read(sql, ~pgSchema),
1688
2038
  ))
1689
2039
 
2040
+ await reloadIndexRegistry()
2041
+
1690
2042
  let checkpointId = (checkpointIdResult->Array.getUnsafe(0))["id"]->BigInt.fromStringOrThrow
1691
2043
 
1692
2044
  // Convert string checkpoint IDs from DB to bigint
@@ -1757,7 +2109,7 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1757
2109
  makeGetRollbackRemovedIdsQuery(~entityConfig, ~pgSchema),
1758
2110
  [rollbackTargetCheckpointId->BigInt.toString]->(Utils.magic: array<string> => unknown),
1759
2111
  )
1760
- ->(Utils.magic: promise<unknown> => promise<array<{"id": string}>>),
2112
+ ->(Utils.magic: promise<unknown> => promise<array<unknown>>),
1761
2113
  // Get the latest pre-target row, including its SET or DELETE action.
1762
2114
  sql
1763
2115
  ->Postgres.preparedUnsafe(
@@ -1767,10 +2119,10 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1767
2119
  ->(Utils.magic: promise<unknown> => promise<array<unknown>>),
1768
2120
  ))
1769
2121
 
1770
- let removedIds = removedIdRows->Array.map(row => row["id"])
2122
+ let removedIds = removedIdRows->S.parseOrThrow(rollbackRemovedIdsSchema(entityConfig.table))
1771
2123
  let restoredEntitiesResult = []
1772
2124
  rollbackRows->Array.forEach(row => {
1773
- let (entityId, action) = row->S.parseOrThrow(rollbackRowStateSchema)
2125
+ let (entityId, action) = row->S.parseOrThrow(rollbackRowStateSchema(entityConfig.table))
1774
2126
  switch action {
1775
2127
  | SET => restoredEntitiesResult->Array.push(row)->ignore
1776
2128
  | DELETE => removedIds->Array.push(entityId)->ignore
@@ -1836,17 +2188,19 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1836
2188
  ~sinkPromise,
1837
2189
  ~chainMetaData,
1838
2190
  )
1839
- onWrite(~storage="postgres", ~timeSeconds=primaryTimerRef->Performance.secondsSince)
2191
+ onWrite(~storage=storageName, ~timeSeconds=primaryTimerRef->Performance.secondsSince)
1840
2192
  }
1841
2193
 
1842
2194
  let close = () => sql->Postgres.endSql
1843
2195
 
1844
2196
  {
1845
- name: "postgres",
2197
+ name: storageName,
1846
2198
  isInitialized,
1847
2199
  initialize,
1848
2200
  resumeInitialState,
1849
2201
  loadOrThrow,
2202
+ ensureQueryIndexes,
2203
+ finalizeBackfill,
1850
2204
  dumpEffectCache,
1851
2205
  reset,
1852
2206
  setChainMeta,