envio 3.5.1 → 3.6.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 (48) hide show
  1. package/evm.schema.json +7 -0
  2. package/fuel.schema.json +7 -0
  3. package/index.d.ts +33 -10
  4. package/package.json +6 -6
  5. package/src/ChainState.res +12 -0
  6. package/src/ChainState.res.mjs +15 -2
  7. package/src/ChainState.resi +3 -0
  8. package/src/Config.res +60 -2
  9. package/src/Config.res.mjs +49 -13
  10. package/src/EffectState.res +2 -2
  11. package/src/EffectState.res.mjs +2 -2
  12. package/src/EntityTables.res +32 -0
  13. package/src/EntityTables.res.mjs +44 -0
  14. package/src/Envio.res +7 -7
  15. package/src/Envio.res.mjs +5 -6
  16. package/src/Hasura.res +37 -3
  17. package/src/Hasura.res.mjs +23 -5
  18. package/src/InMemoryStore.res +48 -8
  19. package/src/InMemoryStore.res.mjs +37 -7
  20. package/src/IndexerState.res +30 -24
  21. package/src/IndexerState.res.mjs +20 -35
  22. package/src/IndexerState.resi +8 -6
  23. package/src/Internal.res +29 -11
  24. package/src/Internal.res.mjs +27 -10
  25. package/src/LoadLayer.res +39 -8
  26. package/src/LoadLayer.res.mjs +36 -9
  27. package/src/LoadLayer.resi +2 -0
  28. package/src/Persistence.res +12 -1
  29. package/src/PgStorage.res +183 -37
  30. package/src/PgStorage.res.mjs +149 -34
  31. package/src/PruneStaleHistory.res +1 -0
  32. package/src/PruneStaleHistory.res.mjs +2 -1
  33. package/src/Sink.res +3 -3
  34. package/src/Sink.res.mjs +2 -2
  35. package/src/TestIndexer.res +128 -17
  36. package/src/TestIndexer.res.mjs +75 -9
  37. package/src/UserContext.res +19 -4
  38. package/src/UserContext.res.mjs +15 -9
  39. package/src/Writing.res +8 -16
  40. package/src/Writing.res.mjs +10 -10
  41. package/src/bindings/ClickHouse.res +40 -4
  42. package/src/bindings/ClickHouse.res.mjs +37 -5
  43. package/src/db/EntityHistory.res +64 -21
  44. package/src/db/EntityHistory.res.mjs +43 -22
  45. package/src/db/InternalTable.res.mjs +31 -31
  46. package/src/db/Table.res +24 -1
  47. package/src/db/Table.res.mjs +26 -4
  48. package/svm.schema.json +7 -0
package/src/PgStorage.res CHANGED
@@ -118,6 +118,36 @@ let makeCreateTableQuery = (
118
118
  : ""});`
119
119
  }
120
120
 
121
+ // The entity as it's stored: the handler-visible schema plus the chain-id
122
+ // column a per-chain entity's table carries. The value for that column is
123
+ // stamped from the flush group's scope right before serialization, so it never
124
+ // has to be re-derived from a checkpoint.
125
+ let rowSchemaCache = Utils.WeakMap.make()
126
+ let getRowSchema = (entityConfig: Internal.entityConfig): S.t<Internal.entity> =>
127
+ switch rowSchemaCache->Utils.WeakMap.get(entityConfig) {
128
+ | Some(cached) => cached
129
+ | None =>
130
+ let schema = switch entityConfig.table->Table.getChainIdField {
131
+ | None => entityConfig.schema
132
+ | Some(chainIdField) =>
133
+ S.schema(s => {
134
+ let dict = Dict.make()
135
+ switch entityConfig.schema->S.classify {
136
+ | Object({items}) =>
137
+ items->Array.forEach(({location, schema}) => dict->Dict.set(location, s.matches(schema)))
138
+ | _ =>
139
+ JsError.throwWithMessage(
140
+ `Unexpected non-object schema for entity "${entityConfig.name}".`,
141
+ )
142
+ }
143
+ dict->Dict.set(chainIdField.fieldName, s.matches(ChainId.schema->S.toUnknown))
144
+ dict
145
+ })->(Utils.magic: S.t<dict<unknown>> => S.t<Internal.entity>)
146
+ }
147
+ rowSchemaCache->Utils.WeakMap.set(entityConfig, schema)->ignore
148
+ schema
149
+ }
150
+
121
151
  let entityHistoryCache = Utils.WeakMap.make()
122
152
  let getEntityHistory = (~entityConfig: Internal.entityConfig): EntityHistory.pgEntityHistory<
123
153
  'entity,
@@ -134,6 +164,9 @@ let getEntityHistory = (~entityConfig: Internal.entityConfig): EntityHistory.pgE
134
164
  switch field.fieldName {
135
165
  //id is not nullable and should be part of the pk
136
166
  | "id" => {...field, fieldName: id, isPrimaryKey: true}->Table.Field->Some
167
+ // Same for the chain id of a per-chain entity: it completes the row's
168
+ // identity, so it can neither be nulled out nor left out of the pk.
169
+ | _ if field.isChainId => field->Table.Field->Some
137
170
  | _ =>
138
171
  {
139
172
  ...field,
@@ -174,7 +207,7 @@ let getEntityHistory = (~entityConfig: Internal.entityConfig): EntityHistory.pgE
174
207
 
175
208
  let setChangeSchema = EntityHistory.makeSetUpdateSchema(
176
209
  ~idSchema=entityConfig.table->Table.getIdSchema,
177
- entityConfig.schema,
210
+ entityConfig->getRowSchema,
178
211
  )
179
212
 
180
213
  {
@@ -353,12 +386,22 @@ let rec makeFilterCondition = (
353
386
  }
354
387
  }
355
388
 
356
- let makeDeleteByIdQuery = (~pgSchema, ~tableName) => {
357
- `DELETE FROM "${pgSchema}"."${tableName}" WHERE id = $1;`
389
+ // The chain-id predicate a per-chain entity's row-level SQL needs, already
390
+ // including the leading AND. The chain id is bound as $2 — after the id
391
+ // params at $1 — so one prepared statement serves every chain. Empty for
392
+ // cross-chain entities and for internal tables, which have no such column.
393
+ let makeChainIdCondition = (~table: Table.table, ~chainId: option<ChainId.t>) =>
394
+ switch (table->Table.getChainIdField, chainId) {
395
+ | (Some(field), Some(_)) => ` AND "${field->Table.getPgDbFieldName}" = $2`
396
+ | _ => ""
397
+ }
398
+
399
+ let makeDeleteByIdQuery = (~pgSchema, ~tableName, ~chainIdCondition) => {
400
+ `DELETE FROM "${pgSchema}"."${tableName}" WHERE id = $1${chainIdCondition};`
358
401
  }
359
402
 
360
- let makeDeleteByIdsQuery = (~pgSchema, ~tableName, ~idPgType) => {
361
- `DELETE FROM "${pgSchema}"."${tableName}" WHERE id = ANY($1::${idPgType}[]);`
403
+ let makeDeleteByIdsQuery = (~pgSchema, ~tableName, ~idPgType, ~chainIdCondition) => {
404
+ `DELETE FROM "${pgSchema}"."${tableName}" WHERE id = ANY($1::${idPgType}[])${chainIdCondition};`
362
405
  }
363
406
 
364
407
  let makeLoadAllQuery = (~pgSchema, ~tableName) => {
@@ -779,17 +822,32 @@ let getConnectedPsqlExec = {
779
822
  }
780
823
  }
781
824
 
782
- let deleteByIdsOrThrow = async (sql, ~pgSchema, ~ids: array<EntityId.t>, ~table: Table.table) => {
825
+ let deleteByIdsOrThrow = async (
826
+ sql,
827
+ ~pgSchema,
828
+ ~ids: array<EntityId.t>,
829
+ ~table: Table.table,
830
+ ~chainId: option<ChainId.t>=None,
831
+ ) => {
832
+ let chainIdCondition = makeChainIdCondition(~table, ~chainId)
833
+ let chainIdParams = switch chainId {
834
+ | Some(chainId) if chainIdCondition !== "" => [chainId->(Utils.magic: ChainId.t => unknown)]
835
+ | _ => []
836
+ }
783
837
  // A JSON array of the serialized ids. For a single id the query binds it as
784
838
  // `$1` directly (the array is the positional-params array); for many it binds
785
- // the whole array to `$1` behind an `ANY(...)`.
839
+ // the whole array to `$1` behind an `ANY(...)`. The chain id, when the
840
+ // condition needs it, rides along as $2.
786
841
  let idsJson = table->Table.encodeIdsToJson(ids)
787
842
  switch await (
788
843
  switch ids {
789
844
  | [_] =>
790
845
  sql->Postgres.preparedUnsafe(
791
- makeDeleteByIdQuery(~pgSchema, ~tableName=table.tableName),
792
- idsJson->Obj.magic,
846
+ makeDeleteByIdQuery(~pgSchema, ~tableName=table.tableName, ~chainIdCondition),
847
+ idsJson
848
+ ->(Utils.magic: JSON.t => array<unknown>)
849
+ ->Array.concat(chainIdParams)
850
+ ->Obj.magic,
793
851
  )
794
852
  | _ =>
795
853
  sql->Postgres.preparedUnsafe(
@@ -797,8 +855,9 @@ let deleteByIdsOrThrow = async (sql, ~pgSchema, ~ids: array<EntityId.t>, ~table:
797
855
  ~pgSchema,
798
856
  ~tableName=table.tableName,
799
857
  ~idPgType=table->Table.getIdPgFieldType(~pgSchema),
858
+ ~chainIdCondition,
800
859
  ),
801
- [idsJson]->Obj.magic,
860
+ [idsJson->(Utils.magic: JSON.t => unknown)]->Array.concat(chainIdParams)->Obj.magic,
802
861
  )
803
862
  }
804
863
  ) {
@@ -813,7 +872,11 @@ let deleteByIdsOrThrow = async (sql, ~pgSchema, ~ids: array<EntityId.t>, ~table:
813
872
  }
814
873
  }
815
874
 
816
- let makeInsertDeleteUpdatesQuery = (~entityConfig: Internal.entityConfig, ~pgSchema) => {
875
+ let makeInsertDeleteUpdatesQuery = (
876
+ ~entityConfig: Internal.entityConfig,
877
+ ~pgSchema,
878
+ ~chainId: option<ChainId.t>,
879
+ ) => {
817
880
  let historyTableName = EntityHistory.historyTableName(
818
881
  ~entityName=entityConfig.name,
819
882
  ~entityIndex=entityConfig.index,
@@ -833,6 +896,13 @@ let makeInsertDeleteUpdatesQuery = (~entityConfig: Internal.entityConfig, ~pgSch
833
896
  allHistoryFieldNames->Array.map(name => `"${name}"`)->Array.joinUnsafe(", ")
834
897
 
835
898
  // Build the SELECT part: id from unnest, envio_checkpoint_id from unnest, 'DELETE' for action, NULL for all other fields
899
+ // The chain-id column is part of the history primary key, so a DELETE row
900
+ // carries the scope's chain — bound once as $3 — rather than the NULL every
901
+ // other data field gets.
902
+ let chainIdColumn = switch (entityConfig.table->Table.getChainIdField, chainId) {
903
+ | (Some(field), Some(_)) => field->Table.getPgDbFieldName
904
+ | _ => ""
905
+ }
836
906
  let selectParts = allHistoryFieldNames->Array.map(fieldName => {
837
907
  switch fieldName {
838
908
  | field if field == Table.idFieldName => `u.${Table.idFieldName}`
@@ -840,6 +910,7 @@ let makeInsertDeleteUpdatesQuery = (~entityConfig: Internal.entityConfig, ~pgSch
840
910
  `u.${EntityHistory.checkpointIdFieldName}`
841
911
  | field if field == EntityHistory.changeFieldName =>
842
912
  `'${(EntityHistory.RowAction.DELETE :> string)}'`
913
+ | field if chainIdColumn !== "" && field == chainIdColumn => "$3"
843
914
  | _ => "NULL"
844
915
  }
845
916
  })
@@ -942,10 +1013,38 @@ let rec writeBatch = async (
942
1013
  }
943
1014
  }
944
1015
 
945
- let setEntities = updatedEntities->Array.map(({entityConfig, changes}) => {
1016
+ let setEntities = updatedEntities->Array.map(({entityConfig, scope, changes}) => {
946
1017
  let entitiesToSet = []
947
1018
  let idsToDelete = []
948
1019
 
1020
+ // Every row in this group belongs to the group's scope, so the chain id
1021
+ // is stamped once here instead of being looked up per row downstream.
1022
+ let scopeChainId = switch scope {
1023
+ | Internal.CrossChain => None
1024
+ | Chain(chainId) => Some(chainId)
1025
+ }
1026
+ let changes = switch (entityConfig.table->Table.getChainIdField, scopeChainId) {
1027
+ | (Some(field), Some(chainId)) =>
1028
+ changes->Array.map(change =>
1029
+ switch change {
1030
+ | Change.Set(set) =>
1031
+ Change.Set({
1032
+ ...set,
1033
+ entity: set.entity->Internal.stampChainId(~fieldName=field.fieldName, ~chainId),
1034
+ })
1035
+ | Delete(_) => change
1036
+ }
1037
+ )
1038
+ | _ => changes
1039
+ }
1040
+
1041
+ // Bound as $3 by the history-delete query, after its two unnest arrays.
1042
+ // Empty for cross-chain entities, whose SQL has no such param.
1043
+ let chainIdParams = switch (entityConfig.table->Table.getChainIdField, scopeChainId) {
1044
+ | (Some(_), Some(chainId)) => [chainId->(Utils.magic: ChainId.t => unknown)]
1045
+ | _ => []
1046
+ }
1047
+
949
1048
  // The rollback-diff change is written to the entity table only, never the
950
1049
  // history table; when present it is an id's oldest change.
951
1050
  let diffCheckpointId = rollback->Option.map(r => r.diffCheckpointId)
@@ -1016,6 +1115,7 @@ let rec writeBatch = async (
1016
1115
  ~pgSchema,
1017
1116
  ~table=entityConfig.table,
1018
1117
  ~entityIndex=entityConfig.index,
1118
+ ~chainId=scopeChainId,
1019
1119
  ~ids=backfillHistoryIds->Utils.Set.toArray,
1020
1120
  )
1021
1121
  }
@@ -1024,11 +1124,17 @@ let rec writeBatch = async (
1024
1124
  promises->Array.push(
1025
1125
  sql
1026
1126
  ->Postgres.preparedUnsafe(
1027
- makeInsertDeleteUpdatesQuery(~entityConfig, ~pgSchema),
1028
- (
1029
- entityConfig.table->Table.encodeIdsToJson(batchDeleteEntityIds),
1030
- batchDeleteCheckpointIds->Utils.BigInt.arrayToStringArray,
1031
- )->Obj.magic,
1127
+ makeInsertDeleteUpdatesQuery(~entityConfig, ~pgSchema, ~chainId=scopeChainId),
1128
+ [
1129
+ entityConfig.table
1130
+ ->Table.encodeIdsToJson(batchDeleteEntityIds)
1131
+ ->(Utils.magic: JSON.t => unknown),
1132
+ batchDeleteCheckpointIds
1133
+ ->Utils.BigInt.arrayToStringArray
1134
+ ->(Utils.magic: array<string> => unknown),
1135
+ ]
1136
+ ->Array.concat(chainIdParams)
1137
+ ->Obj.magic,
1032
1138
  )
1033
1139
  ->Utils.Promise.ignoreValue,
1034
1140
  )
@@ -1069,7 +1175,7 @@ let rec writeBatch = async (
1069
1175
  sql->setOrThrow(
1070
1176
  ~items=entitiesToSet,
1071
1177
  ~table=entityConfig.table,
1072
- ~itemSchema=entityConfig.schema,
1178
+ ~itemSchema=entityConfig->getRowSchema,
1073
1179
  ~pgSchema,
1074
1180
  ~chainIdMode,
1075
1181
  ),
@@ -1077,7 +1183,12 @@ let rec writeBatch = async (
1077
1183
  }
1078
1184
  if idsToDelete->Utils.Array.notEmpty {
1079
1185
  promises->Array.push(
1080
- sql->deleteByIdsOrThrow(~pgSchema, ~ids=idsToDelete, ~table=entityConfig.table),
1186
+ sql->deleteByIdsOrThrow(
1187
+ ~pgSchema,
1188
+ ~ids=idsToDelete,
1189
+ ~table=entityConfig.table,
1190
+ ~chainId=scopeChainId,
1191
+ ),
1081
1192
  )
1082
1193
  }
1083
1194
 
@@ -1244,6 +1355,14 @@ let rec writeBatch = async (
1244
1355
 
1245
1356
  // Returns the most recent history row at or before the rollback target for IDs changed after it.
1246
1357
  // envio_change is included so ReScript can turn SET rows into restores and DELETE rows into removals.
1358
+ // The columns that identify a history row: the id, plus the chain id for a
1359
+ // per-chain entity.
1360
+ let rollbackKeyColumns = (entityConfig: Internal.entityConfig) =>
1361
+ switch entityConfig.table->Table.getChainIdField {
1362
+ | Some(field) => [Table.idFieldName, field->Table.getPgDbFieldName]
1363
+ | None => [Table.idFieldName]
1364
+ }
1365
+
1247
1366
  let makeGetRollbackPreTargetRowsQuery = (~entityConfig: Internal.entityConfig, ~pgSchema) => {
1248
1367
  let dataFieldNames = entityConfig.table.fields->Array.filterMap(fieldOrDerived =>
1249
1368
  switch fieldOrDerived {
@@ -1260,16 +1379,25 @@ let makeGetRollbackPreTargetRowsQuery = (~entityConfig: Internal.entityConfig, ~
1260
1379
  ~entityIndex=entityConfig.index,
1261
1380
  )
1262
1381
 
1263
- `SELECT DISTINCT ON ("${Table.idFieldName}") ${dataFieldsCommaSeparated}, "${EntityHistory.changeFieldName}"
1382
+ // A per-chain entity's rows are only comparable within a chain, so the row's
1383
+ // identity here is (id, chain id) rather than the id alone.
1384
+ let keyColumns = rollbackKeyColumns(entityConfig)
1385
+ let keyColumnsCommaSeparated = keyColumns->Array.map(c => `"${c}"`)->Array.joinUnsafe(", ")
1386
+ let keyMatch =
1387
+ keyColumns
1388
+ ->Array.map(c => `h."${c}" = "${historyTableName}"."${c}"`)
1389
+ ->Array.joinUnsafe(" AND ")
1390
+
1391
+ `SELECT DISTINCT ON (${keyColumnsCommaSeparated}) ${dataFieldsCommaSeparated}, "${EntityHistory.changeFieldName}"
1264
1392
  FROM "${pgSchema}"."${historyTableName}"
1265
1393
  WHERE "${EntityHistory.checkpointIdFieldName}" <= $1
1266
1394
  AND EXISTS (
1267
1395
  SELECT 1
1268
1396
  FROM "${pgSchema}"."${historyTableName}" h
1269
- WHERE h."${Table.idFieldName}" = "${historyTableName}"."${Table.idFieldName}"
1397
+ WHERE ${keyMatch}
1270
1398
  AND h."${EntityHistory.checkpointIdFieldName}" > $1
1271
1399
  )
1272
- ORDER BY "${Table.idFieldName}", "${EntityHistory.checkpointIdFieldName}" DESC`
1400
+ ORDER BY ${keyColumnsCommaSeparated}, "${EntityHistory.checkpointIdFieldName}" DESC`
1273
1401
  }
1274
1402
 
1275
1403
  // Returns entity IDs that were created after the rollback target and have no history before it.
@@ -1279,13 +1407,19 @@ let makeGetRollbackRemovedIdsQuery = (~entityConfig: Internal.entityConfig, ~pgS
1279
1407
  ~entityName=entityConfig.name,
1280
1408
  ~entityIndex=entityConfig.index,
1281
1409
  )
1282
- `SELECT DISTINCT "${Table.idFieldName}"
1410
+ let keyColumns = rollbackKeyColumns(entityConfig)
1411
+ let keyMatch =
1412
+ keyColumns
1413
+ ->Array.map(c => `h."${c}" = "${historyTableName}"."${c}"`)
1414
+ ->Array.joinUnsafe(" AND ")
1415
+
1416
+ `SELECT DISTINCT ${keyColumns->Array.map(c => `"${c}"`)->Array.joinUnsafe(", ")}
1283
1417
  FROM "${pgSchema}"."${historyTableName}"
1284
1418
  WHERE "${EntityHistory.checkpointIdFieldName}" > $1
1285
1419
  AND NOT EXISTS (
1286
1420
  SELECT 1
1287
1421
  FROM "${pgSchema}"."${historyTableName}" h
1288
- WHERE h."${Table.idFieldName}" = "${historyTableName}"."${Table.idFieldName}"
1422
+ WHERE ${keyMatch}
1289
1423
  AND h."${EntityHistory.checkpointIdFieldName}" <= $1
1290
1424
  )`
1291
1425
  }
@@ -1303,11 +1437,19 @@ let rollbackRowStateSchema: Table.table => S.t<(
1303
1437
  ))
1304
1438
  )
1305
1439
 
1440
+ // The chain a rollback row belongs to, read from the chain-id column both
1441
+ // rollback queries select. None for a cross-chain entity, which has no column.
1442
+ let rollbackChainIdSchema: Table.table => option<S.t<ChainId.t>> = Utils.WeakMap.memoize(table =>
1443
+ table
1444
+ ->Table.getChainIdField
1445
+ ->Option.map(field => S.object(s => s.field(field->Table.getPgDbFieldName, ChainId.schema)))
1446
+ )
1447
+
1306
1448
  // Same reason as above for the id-only rows: both rollback queries must yield
1307
1449
  // ids in the entity's own representation, or the two halves of the diff would
1308
1450
  // disagree (Postgres hands back a NUMERIC id as a string, not a bigint).
1309
- let rollbackRemovedIdsSchema: Table.table => S.t<array<EntityId.t>> = Utils.WeakMap.memoize(table =>
1310
- S.array(S.object(s => s.field(Table.idFieldName, table->Table.getIdSchema)))
1451
+ let rollbackRemovedIdSchema: Table.table => S.t<EntityId.t> = Utils.WeakMap.memoize(table =>
1452
+ S.object(s => s.field(Table.idFieldName, table->Table.getIdSchema))
1311
1453
  )
1312
1454
 
1313
1455
  let make = (
@@ -1366,12 +1508,6 @@ let make = (
1366
1508
  "indexes": invalidIndexNames,
1367
1509
  })
1368
1510
  }
1369
- Logging.info({
1370
- "storage": storageName,
1371
- "msg": `Found ${catalog
1372
- ->IndexCatalog.size
1373
- ->Int.toString} existing indexes in the indexer schema.`,
1374
- })
1375
1511
  }
1376
1512
 
1377
1513
  let isInitialized = async () => {
@@ -1657,7 +1793,7 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::${addrChai
1657
1793
  }),
1658
1794
  )
1659
1795
  | rows =>
1660
- try rows->S.parseOrThrow(table->Table.pgRowsSchema) catch {
1796
+ try rows->S.parseOrThrow(table->Table.pgEntityRowsSchema) catch {
1661
1797
  | exn =>
1662
1798
  throw(
1663
1799
  Persistence.StorageError({
@@ -2101,12 +2237,13 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::${addrChai
2101
2237
  let pruneStaleCheckpoints = (~safeCheckpointId) =>
2102
2238
  InternalTable.Checkpoints.pruneStaleCheckpoints(sql, ~pgSchema, ~safeCheckpointId)
2103
2239
 
2104
- let pruneStaleEntityHistory = (~entityName, ~entityIndex, ~safeCheckpointId) =>
2240
+ let pruneStaleEntityHistory = (~entityName, ~entityIndex, ~chainIdColumn, ~safeCheckpointId) =>
2105
2241
  EntityHistory.pruneStaleEntityHistory(
2106
2242
  sql,
2107
2243
  ~pgSchema,
2108
2244
  ~entityName,
2109
2245
  ~entityIndex,
2246
+ ~chainIdColumn,
2110
2247
  ~safeCheckpointId,
2111
2248
  )
2112
2249
 
@@ -2142,17 +2279,26 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::${addrChai
2142
2279
  ->(Utils.magic: promise<unknown> => promise<array<unknown>>),
2143
2280
  ))
2144
2281
 
2145
- let removedIds = removedIdRows->S.parseOrThrow(rollbackRemovedIdsSchema(entityConfig.table))
2282
+ let chainIdSchema = rollbackChainIdSchema(entityConfig.table)
2283
+ let scopeOf = row =>
2284
+ switch chainIdSchema {
2285
+ | None => Internal.CrossChain
2286
+ | Some(schema) => Internal.Chain(row->S.parseOrThrow(schema))
2287
+ }
2288
+ let removals = removedIdRows->Array.map((row): Persistence.rollbackRemoval => {
2289
+ entityId: row->S.parseOrThrow(rollbackRemovedIdSchema(entityConfig.table)),
2290
+ scope: scopeOf(row),
2291
+ })
2146
2292
  let restoredEntitiesResult = []
2147
2293
  rollbackRows->Array.forEach(row => {
2148
2294
  let (entityId, action) = row->S.parseOrThrow(rollbackRowStateSchema(entityConfig.table))
2149
2295
  switch action {
2150
2296
  | SET => restoredEntitiesResult->Array.push(row)->ignore
2151
- | DELETE => removedIds->Array.push(entityId)->ignore
2297
+ | DELETE => removals->Array.push({entityId, scope: scopeOf(row)})->ignore
2152
2298
  }
2153
2299
  })
2154
2300
 
2155
- (removedIds, restoredEntitiesResult)
2301
+ (removals, restoredEntitiesResult)
2156
2302
  }
2157
2303
 
2158
2304
  let writeBatchMethod = async (