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
@@ -70,6 +70,19 @@ let getIndexingAddressesByChain = (state: testIndexerState): dict<
70
70
  byChain
71
71
  }
72
72
 
73
+ // Rows of a per-chain entity are keyed per (chain, id): the same id exists
74
+ // independently on every chain.
75
+ let rowKey = (~scope: Internal.chainScope, ~entityId: EntityId.t) =>
76
+ switch scope {
77
+ | CrossChain => entityId->EntityId.toKey
78
+ | Chain(chainId) => `${chainId->ChainId.toString}|${entityId->EntityId.toKey}`
79
+ }
80
+
81
+ let readChainId = (entity: Internal.entity, ~field: Table.field): option<ChainId.t> =>
82
+ entity
83
+ ->(Utils.magic: Internal.entity => dict<ChainId.t>)
84
+ ->Utils.Dict.dangerouslyGetNonOption(field.fieldName)
85
+
73
86
  let handleLoad = (state: testIndexerState, ~tableName: string, ~filter: EntityFilter.t): array<
74
87
  Internal.entity,
75
88
  > => {
@@ -79,16 +92,28 @@ let handleLoad = (state: testIndexerState, ~tableName: string, ~filter: EntityFi
79
92
  // entityConfig.
80
93
  switch state.entityConfigs->Dict.get(tableName) {
81
94
  | None => []
82
- | Some(_) =>
95
+ | Some(entityConfig) =>
83
96
  let entityDict = state.entities->Dict.get(tableName)->Option.getOr(Dict.make())
84
- entityDict
85
- ->Dict.valuesToArray
86
- ->Array.filter(entity => {
87
- // The store holds decoded entities and the filter carries decoded values,
88
- // so compare directly (same approach as InMemoryTable) no JSON round-trip.
89
- let entityAsDict = entity->(Utils.magic: Internal.entity => dict<EntityFilter.FieldValue.t>)
90
- filter->EntityFilter.matches(~entity=entityAsDict)
91
- })
97
+ let matched =
98
+ entityDict
99
+ ->Dict.valuesToArray
100
+ ->Array.filter(entity => {
101
+ // The store holds decoded entities and the filter carries decoded values,
102
+ // so compare directly (same approach as InMemoryTable) — no JSON round-trip.
103
+ let entityAsDict = entity->(Utils.magic: Internal.entity => dict<EntityFilter.FieldValue.t>)
104
+ filter->EntityFilter.matches(~entity=entityAsDict)
105
+ })
106
+ // The chain is already fixed by the scope the load ran for, so the loaded
107
+ // entity is handed back in the shape the handlers see.
108
+ switch entityConfig.table->Table.getChainIdField {
109
+ | None => matched
110
+ | Some(field) =>
111
+ matched->Array.map(entity => {
112
+ let copy = entity->(Utils.magic: Internal.entity => dict<unknown>)->Utils.Dict.shallowCopy
113
+ copy->Utils.Dict.deleteInPlace(field.fieldName)
114
+ copy->(Utils.magic: dict<unknown> => Internal.entity)
115
+ })
116
+ }
92
117
  }
93
118
  }
94
119
 
@@ -104,8 +129,11 @@ let handleWriteBatch = (
104
129
  // checkpointId -> entityName -> entityChange
105
130
  let changesByCheckpoint: dict<dict<entityChange>> = Dict.make()
106
131
 
107
- updatedEntities->Array.forEach(({entityConfig, changes}: Persistence.updatedEntity) => {
132
+ updatedEntities->Array.forEach(({entityConfig, scope, changes}: Persistence.updatedEntity) => {
108
133
  let entityName = entityConfig.name
134
+ // The scope is what makes a per-chain row identifiable, so it's stamped
135
+ // onto the stored entity the same way the Postgres write path does.
136
+ let chainIdField = entityConfig.table->Table.getChainIdField
109
137
  let entityDict = switch state.entities->Dict.get(entityName) {
110
138
  | Some(dict) => dict
111
139
  | None =>
@@ -138,10 +166,17 @@ let handleWriteBatch = (
138
166
  // The store keeps decoded entities so load comparisons (bigint /
139
167
  // BigDecimal) work on real values. Ids are keyed by their string form
140
168
  // since they may be string/int/bigint.
141
- entityDict->Dict.set(entityId->EntityId.toKey, entity)
169
+ let storedEntity = switch (chainIdField, scope->Internal.chainScopeChainId) {
170
+ | (Some(field), Some(chainId)) =>
171
+ entity->Internal.stampChainId(~fieldName=field.fieldName, ~chainId)
172
+ | _ => entity
173
+ }
174
+ entityDict->Dict.set(rowKey(~scope, ~entityId), storedEntity)
175
+ // The change already carries the checkpoint's chainId, so the entity
176
+ // inside it stays unstamped.
142
177
  entityChangeFor(checkpointId).sets->Array.push(entity->Utils.magic)->ignore
143
178
  | Delete({entityId, checkpointId}) =>
144
- Dict.delete(entityDict->Obj.magic, entityId->EntityId.toKey)
179
+ Dict.delete(entityDict->Obj.magic, rowKey(~scope, ~entityId))
145
180
  entityChangeFor(checkpointId).deleted->Array.push(entityId)->ignore
146
181
  }
147
182
  }
@@ -379,7 +414,10 @@ let copyEntity = (entity: Internal.entity): Internal.entity =>
379
414
  ->Utils.Dict.shallowCopy
380
415
  ->(Utils.magic: dict<unknown> => Internal.entity)
381
416
 
382
- // Entity operations for direct manipulation outside of handlers
417
+ // Entity operations for direct manipulation outside of handlers. Unlike a
418
+ // handler, which always runs on a known chain, these are chain-agnostic — so a
419
+ // per-chain entity is looked up across every chain and an id present on more
420
+ // than one is an error rather than an arbitrary pick.
383
421
  let getEntityFromState = (
384
422
  ~state: testIndexerState,
385
423
  ~entityConfig: Internal.entityConfig,
@@ -392,7 +430,25 @@ let getEntityFromState = (
392
430
  )
393
431
  }
394
432
  let entityDict = state.entities->Dict.get(entityConfig.name)->Option.getOr(Dict.make())
395
- entityDict->Dict.get(entityId)->Option.map(copyEntity)
433
+ switch entityConfig.table->Table.getChainIdField {
434
+ | None => entityDict->Dict.get(entityId)->Option.map(copyEntity)
435
+ | Some(field) =>
436
+ let matches = entityDict->Dict.valuesToArray->Array.filter(entity => entity.id === entityId)
437
+ switch matches {
438
+ | [] => None
439
+ | [entity] => Some(copyEntity(entity))
440
+ | _ =>
441
+ let chains =
442
+ matches
443
+ ->Array.map(entity =>
444
+ entity->readChainId(~field)->Option.mapOr("unknown", ChainId.toString)
445
+ )
446
+ ->Array.join(", ")
447
+ JsError.throwWithMessage(
448
+ `Entity \`${entityConfig.name}\` with id \`${entityId}\` exists on multiple chains (${chains}) — use getWhere({${field.fieldName}: {_eq: ...}}) to pick one.`,
449
+ )
450
+ }
451
+ }
396
452
  }
397
453
 
398
454
  let makeEntityGet = (~state: testIndexerState, ~entityConfig: Internal.entityConfig): (
@@ -435,7 +491,23 @@ let makeEntitySet = (~state: testIndexerState, ~entityConfig: Internal.entityCon
435
491
  state.entities->Dict.set(entityConfig.name, dict)
436
492
  dict
437
493
  }
438
- entityDict->Dict.set(entity.id, copyEntity(entity))
494
+ // Outside a handler there's no chain in context, so a per-chain entity has
495
+ // to say which chain the row belongs to.
496
+ let scope = switch entityConfig.table->Table.getChainIdField {
497
+ | None => Internal.CrossChain
498
+ | Some(field) =>
499
+ switch entity->readChainId(~field) {
500
+ | Some(chainId) => Internal.Chain(chainId)
501
+ | None =>
502
+ JsError.throwWithMessage(
503
+ `${entityConfig.name}.set() requires a \`${field.fieldName}\` because the entity is per-chain. Pass it alongside the entity fields.`,
504
+ )
505
+ }
506
+ }
507
+ entityDict->Dict.set(
508
+ rowKey(~scope, ~entityId=entity.id->EntityId.unsafeOfString),
509
+ copyEntity(entity),
510
+ )
439
511
  }
440
512
  }
441
513
 
@@ -453,9 +525,43 @@ let makeEntityGetAll = (~state: testIndexerState, ~entityConfig: Internal.entity
453
525
  }
454
526
  }
455
527
 
528
+ // The same filter syntax as `context.X.getWhere` in a handler, matched against
529
+ // the store instead of a database. A per-chain entity's rows carry their chain
530
+ // id, so `{chainId: {_eq: 1}}` is what narrows an id that exists on several
531
+ // chains.
532
+ let makeEntityGetWhere = (~state: testIndexerState, ~entityConfig: Internal.entityConfig): (
533
+ dict<dict<unknown>> => promise<array<Internal.entity>>
534
+ ) => {
535
+ filter => {
536
+ if state.processInProgress {
537
+ JsError.throwWithMessage(
538
+ `Cannot call ${entityConfig.name}.getWhere() while indexer.process() is running. ` ++ "Wait for process() to complete before accessing entities directly.",
539
+ )
540
+ }
541
+ let filters =
542
+ filter->EntityFilter.parseGetWhereOrThrow(
543
+ ~entityName=entityConfig.name,
544
+ ~table=entityConfig.table,
545
+ )
546
+ let entityDict = state.entities->Dict.get(entityConfig.name)->Option.getOr(Dict.make())
547
+ // parseGetWhereOrThrow expands an operator group into alternatives whose
548
+ // matches are disjoint, so the union needs no dedup.
549
+ Promise.resolve(
550
+ entityDict
551
+ ->Dict.valuesToArray
552
+ ->Array.filter(entity => {
553
+ let entityAsDict = entity->(Utils.magic: Internal.entity => dict<EntityFilter.FieldValue.t>)
554
+ filters->Array.some(filter => filter->EntityFilter.matches(~entity=entityAsDict))
555
+ })
556
+ ->Array.map(copyEntity),
557
+ )
558
+ }
559
+ }
560
+
456
561
  type entityOperations = {
457
562
  get: string => promise<option<Internal.entity>>,
458
563
  getAll: unit => promise<array<Internal.entity>>,
564
+ getWhere: dict<dict<unknown>> => promise<array<Internal.entity>>,
459
565
  getOrThrow: (string, ~message: string=?) => promise<Internal.entity>,
460
566
  set: Internal.entity => unit,
461
567
  }
@@ -507,8 +613,12 @@ let makeInMemoryStorage = (~state: testIndexerState): Persistence.storage => {
507
613
  reset: async () => (),
508
614
  setChainMeta: async _ => Obj.magic(),
509
615
  pruneStaleCheckpoints: async (~safeCheckpointId as _) => (),
510
- pruneStaleEntityHistory: async (~entityName as _, ~entityIndex as _, ~safeCheckpointId as _) =>
511
- (),
616
+ pruneStaleEntityHistory: async (
617
+ ~entityName as _,
618
+ ~entityIndex as _,
619
+ ~chainIdColumn as _,
620
+ ~safeCheckpointId as _,
621
+ ) => (),
512
622
  getRollbackTargetCheckpoint: async (~reorgChainId as _, ~lastKnownValidBlockNumber as _) =>
513
623
  JsError.throwWithMessage(
514
624
  "TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable.",
@@ -625,6 +735,7 @@ let createTestIndexer = (): t<'processConfig> => {
625
735
  {
626
736
  get: makeEntityGet(~state, ~entityConfig),
627
737
  getAll: makeEntityGetAll(~state, ~entityConfig),
738
+ getWhere: makeEntityGetWhere(~state, ~entityConfig),
628
739
  getOrThrow: makeEntityGetOrThrow(~state, ~entityConfig),
629
740
  set: makeEntitySet(~state, ~entityConfig),
630
741
  },
@@ -1,12 +1,14 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Env from "./Env.res.mjs";
4
+ import * as Table from "./db/Table.res.mjs";
4
5
  import * as Utils from "./Utils.res.mjs";
5
6
  import * as Config from "./Config.res.mjs";
6
7
  import * as ChainId from "./ChainId.res.mjs";
7
8
  import * as Logging from "./Logging.res.mjs";
8
9
  import * as ChainMap from "./ChainMap.res.mjs";
9
10
  import * as EntityId from "./EntityId.res.mjs";
11
+ import * as Internal from "./Internal.res.mjs";
10
12
  import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
11
13
  import * as IndexerLoop from "./IndexerLoop.res.mjs";
12
14
  import * as Persistence from "./Persistence.res.mjs";
@@ -55,19 +57,44 @@ function getIndexingAddressesByChain(state) {
55
57
  return byChain;
56
58
  }
57
59
 
60
+ function rowKey(scope, entityId) {
61
+ if (scope === "crossChain") {
62
+ return EntityId.toKey(entityId);
63
+ } else {
64
+ return ChainId.toString(scope) + `|` + EntityId.toKey(entityId);
65
+ }
66
+ }
67
+
68
+ function readChainId(entity, field) {
69
+ return entity[field.fieldName];
70
+ }
71
+
58
72
  function handleLoad(state, tableName, filter) {
59
- let match = state.entityConfigs[tableName];
60
- if (match === undefined) {
73
+ let entityConfig = state.entityConfigs[tableName];
74
+ if (entityConfig === undefined) {
61
75
  return [];
62
76
  }
63
77
  let entityDict = Stdlib_Option.getOr(state.entities[tableName], {});
64
- return Object.values(entityDict).filter(entity => EntityFilter.matches(filter, entity));
78
+ let matched = Object.values(entityDict).filter(entity => EntityFilter.matches(filter, entity));
79
+ let field = Table.getChainIdField(entityConfig.table);
80
+ if (field !== undefined) {
81
+ return matched.map(entity => {
82
+ let copy = Utils.Dict.shallowCopy(entity);
83
+ Utils.Dict.deleteInPlace(copy, field.fieldName);
84
+ return copy;
85
+ });
86
+ } else {
87
+ return matched;
88
+ }
65
89
  }
66
90
 
67
91
  function handleWriteBatch(state, updatedEntities, checkpointIds, checkpointChainIds, checkpointBlockNumbers, checkpointEventsProcessed) {
68
92
  let changesByCheckpoint = {};
69
93
  updatedEntities.forEach(param => {
70
- let entityName = param.entityConfig.name;
94
+ let scope = param.scope;
95
+ let entityConfig = param.entityConfig;
96
+ let entityName = entityConfig.name;
97
+ let chainIdField = Table.getChainIdField(entityConfig.table);
71
98
  let dict = state.entities[entityName];
72
99
  let entityDict;
73
100
  if (dict !== undefined) {
@@ -104,12 +131,14 @@ function handleWriteBatch(state, updatedEntities, checkpointIds, checkpointChain
104
131
  let processChange = change => {
105
132
  if (change.type === "SET") {
106
133
  let entity = change.entity;
107
- entityDict[EntityId.toKey(change.entityId)] = entity;
134
+ let match = Internal.chainScopeChainId(scope);
135
+ let storedEntity = chainIdField !== undefined && match !== undefined ? Internal.stampChainId(entity, chainIdField.fieldName, Primitive_option.valFromOption(match)) : entity;
136
+ entityDict[rowKey(scope, change.entityId)] = storedEntity;
108
137
  entityChangeFor(change.checkpointId).sets.push(entity);
109
138
  return;
110
139
  }
111
140
  let entityId = change.entityId;
112
- Stdlib_Dict.$$delete(entityDict, EntityId.toKey(entityId));
141
+ Stdlib_Dict.$$delete(entityDict, rowKey(scope, entityId));
113
142
  entityChangeFor(change.checkpointId).deleted.push(entityId);
114
143
  };
115
144
  param.changes.forEach(processChange);
@@ -265,7 +294,21 @@ function getEntityFromState(state, entityConfig, entityId, methodName) {
265
294
  Stdlib_JsError.throwWithMessage(`Cannot call ` + entityConfig.name + `.` + methodName + `() while indexer.process() is running. ` + "Wait for process() to complete before accessing entities directly.");
266
295
  }
267
296
  let entityDict = Stdlib_Option.getOr(state.entities[entityConfig.name], {});
268
- return Stdlib_Option.map(entityDict[entityId], copyEntity);
297
+ let field = Table.getChainIdField(entityConfig.table);
298
+ if (field === undefined) {
299
+ return Stdlib_Option.map(entityDict[entityId], copyEntity);
300
+ }
301
+ let matches = Object.values(entityDict).filter(entity => entity.id === entityId);
302
+ let len = matches.length;
303
+ if (len !== 1) {
304
+ if (len === 0) {
305
+ return;
306
+ }
307
+ let chains = matches.map(entity => Stdlib_Option.mapOr(entity[field.fieldName], "unknown", ChainId.toString)).join(", ");
308
+ return Stdlib_JsError.throwWithMessage(`Entity \`` + entityConfig.name + `\` with id \`` + entityId + `\` exists on multiple chains (` + chains + `) — use getWhere({` + field.fieldName + `: {_eq: ...}}) to pick one.`);
309
+ }
310
+ let entity = matches[0];
311
+ return Utils.Dict.shallowCopy(entity);
269
312
  }
270
313
 
271
314
  function makeEntityGet(state, entityConfig) {
@@ -297,7 +340,15 @@ function makeEntitySet(state, entityConfig) {
297
340
  state.entities[entityConfig.name] = dict$1;
298
341
  entityDict = dict$1;
299
342
  }
300
- entityDict[entity.id] = Utils.Dict.shallowCopy(entity);
343
+ let field = Table.getChainIdField(entityConfig.table);
344
+ let scope;
345
+ if (field !== undefined) {
346
+ let chainId = entity[field.fieldName];
347
+ scope = chainId !== undefined ? Primitive_option.valFromOption(chainId) : Stdlib_JsError.throwWithMessage(entityConfig.name + `.set() requires a \`` + field.fieldName + `\` because the entity is per-chain. Pass it alongside the entity fields.`);
348
+ } else {
349
+ scope = "crossChain";
350
+ }
351
+ entityDict[rowKey(scope, entity.id)] = Utils.Dict.shallowCopy(entity);
301
352
  };
302
353
  }
303
354
 
@@ -311,6 +362,17 @@ function makeEntityGetAll(state, entityConfig) {
311
362
  };
312
363
  }
313
364
 
365
+ function makeEntityGetWhere(state, entityConfig) {
366
+ return filter => {
367
+ if (state.processInProgress) {
368
+ Stdlib_JsError.throwWithMessage(`Cannot call ` + entityConfig.name + `.getWhere() while indexer.process() is running. ` + "Wait for process() to complete before accessing entities directly.");
369
+ }
370
+ let filters = EntityFilter.parseGetWhereOrThrow(filter, entityConfig.name, entityConfig.table);
371
+ let entityDict = Stdlib_Option.getOr(state.entities[entityConfig.name], {});
372
+ return Promise.resolve(Object.values(entityDict).filter(entity => filters.some(filter => EntityFilter.matches(filter, entity))).map(copyEntity));
373
+ };
374
+ }
375
+
314
376
  function makeInMemoryStorage(state) {
315
377
  return {
316
378
  name: "test-inmemory",
@@ -325,7 +387,7 @@ function makeInMemoryStorage(state) {
325
387
  reset: async () => {},
326
388
  setChainMeta: async param => {},
327
389
  pruneStaleCheckpoints: async param => {},
328
- pruneStaleEntityHistory: async (param, param$1, param$2) => {},
390
+ pruneStaleEntityHistory: async (param, param$1, param$2, param$3) => {},
329
391
  getRollbackTargetCheckpoint: async (param, param$1) => Stdlib_JsError.throwWithMessage("TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable."),
330
392
  getRollbackProgressDiff: async param => Stdlib_JsError.throwWithMessage("TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable."),
331
393
  getRollbackData: async (param, param$1) => Stdlib_JsError.throwWithMessage("TestIndexer: Rollback is not supported. The runner forces rollbackOnReorg off, so this should be unreachable."),
@@ -407,6 +469,7 @@ function createTestIndexer() {
407
469
  entityOpsDict[entityConfig.name] = {
408
470
  get: makeEntityGet(state, entityConfig),
409
471
  getAll: makeEntityGetAll(state, entityConfig),
472
+ getWhere: makeEntityGetWhere(state, entityConfig),
410
473
  getOrThrow: makeEntityGetOrThrow(state, entityConfig),
411
474
  set: makeEntitySet(state, entityConfig)
412
475
  };
@@ -613,6 +676,8 @@ function createTestIndexer() {
613
676
  export {
614
677
  toIndexingAddress,
615
678
  getIndexingAddressesByChain,
679
+ rowKey,
680
+ readChainId,
616
681
  handleLoad,
617
682
  handleWriteBatch,
618
683
  makeInitialState,
@@ -626,6 +691,7 @@ export {
626
691
  makeEntityGetOrThrow,
627
692
  makeEntitySet,
628
693
  makeEntityGetAll,
694
+ makeEntityGetWhere,
629
695
  makeInMemoryStorage,
630
696
  cloneRegistrations,
631
697
  registrationsRef,
@@ -60,15 +60,19 @@ external makeEffectContext: (
60
60
 
61
61
  let initEffect = (params: contextParams) => {
62
62
  let handlerChainId = params.item->Internal.getItemChainId
63
+ // An effect that didn't state a scope follows the config: cross-chain by
64
+ // default, per-chain under `disable_default_cross_chain`.
65
+ let isCrossChain = (effect: Internal.effect) =>
66
+ effect.crossChain->Option.getOr(params.config.defaultCrossChain)
63
67
  // A chain-scoped effect always resolves against the chain of the handler that
64
68
  // triggered the call, even several effects deep, so the chain id is captured
65
69
  // once from the item and reused for the whole nested-call tree.
66
70
  let rec makeCaller = (~caller: option<Internal.effect>) => {
67
71
  (effect: Internal.effect, input: Internal.effectInput) => {
68
- let scope = effect.crossChain ? Internal.CrossChain : Internal.Chain(handlerChainId)
72
+ let scope = effect->isCrossChain ? Internal.CrossChain : Internal.Chain(handlerChainId)
69
73
 
70
74
  switch caller {
71
- | Some(callerEffect) if callerEffect.crossChain && !effect.crossChain =>
75
+ | Some(callerEffect) if callerEffect->isCrossChain && !(effect->isCrossChain) =>
72
76
  // A cross-chain effect isn't tied to a single chain, so it has no chain
73
77
  // to resolve a chain-scoped child against. Reject before any cache work.
74
78
  JsError.throwWithMessage(
@@ -114,6 +118,13 @@ type entityContextParams = {
114
118
  entityConfig: Internal.entityConfig,
115
119
  }
116
120
 
121
+ // The handler context is always chain-scoped, so a per-chain entity resolves to
122
+ // the chain the handler runs on and a cross-chain one to the shared partition.
123
+ let entityScope = (params: entityContextParams) =>
124
+ params.entityConfig->InMemoryStore.entityScope(
125
+ ~chainId=params.item->Internal.getItemChainId,
126
+ )
127
+
117
128
  let getWhereHandler = (params: entityContextParams, filter: dict<dict<unknown>>) => {
118
129
  let entityConfig = params.entityConfig
119
130
 
@@ -123,6 +134,7 @@ let getWhereHandler = (params: entityContextParams, filter: dict<dict<unknown>>)
123
134
  ~loadManager=params.loadManager,
124
135
  ~persistence=params.persistence,
125
136
  ~entityConfig,
137
+ ~scope=params->entityScope,
126
138
  ~indexerState=params.indexerState,
127
139
  ~shouldGroup=params.isPreload,
128
140
  ~item=params.item,
@@ -164,7 +176,7 @@ let entityTraps: Utils.Proxy.traps<entityContextParams> = {
164
176
  ? noopSet
165
177
  : (entity: Internal.entity) => {
166
178
  params.indexerState
167
- ->InMemoryStore.getInMemTable(~entityConfig=params.entityConfig)
179
+ ->InMemoryStore.getInMemTable(~entityConfig=params.entityConfig, ~scope=params->entityScope)
168
180
  ->InMemoryTable.Entity.set(
169
181
  ~committedCheckpointId=params.indexerState->IndexerState.committedCheckpointId,
170
182
  Set({
@@ -188,6 +200,7 @@ let entityTraps: Utils.Proxy.traps<entityContextParams> = {
188
200
  ~loadManager=params.loadManager,
189
201
  ~persistence=params.persistence,
190
202
  ~entityConfig=params.entityConfig,
203
+ ~scope=params->entityScope,
191
204
  ~indexerState=params.indexerState,
192
205
  ~shouldGroup=params.isPreload,
193
206
  ~item=params.item,
@@ -220,6 +233,7 @@ let entityTraps: Utils.Proxy.traps<entityContextParams> = {
220
233
  ~loadManager=params.loadManager,
221
234
  ~persistence=params.persistence,
222
235
  ~entityConfig=params.entityConfig,
236
+ ~scope=params->entityScope,
223
237
  ~indexerState=params.indexerState,
224
238
  ~shouldGroup=params.isPreload,
225
239
  ~item=params.item,
@@ -250,6 +264,7 @@ let entityTraps: Utils.Proxy.traps<entityContextParams> = {
250
264
  ~loadManager=params.loadManager,
251
265
  ~persistence=params.persistence,
252
266
  ~entityConfig=params.entityConfig,
267
+ ~scope=params->entityScope,
253
268
  ~indexerState=params.indexerState,
254
269
  ~shouldGroup=params.isPreload,
255
270
  ~item=params.item,
@@ -273,7 +288,7 @@ let entityTraps: Utils.Proxy.traps<entityContextParams> = {
273
288
  } else {
274
289
  entityId => {
275
290
  params.indexerState
276
- ->InMemoryStore.getInMemTable(~entityConfig=params.entityConfig)
291
+ ->InMemoryStore.getInMemTable(~entityConfig=params.entityConfig, ~scope=params->entityScope)
277
292
  ->InMemoryTable.Entity.set(
278
293
  ~committedCheckpointId=params.indexerState->IndexerState.committedCheckpointId,
279
294
  Delete({
@@ -49,9 +49,10 @@ EffectContext.prototype = effectContextPrototype;
49
49
 
50
50
  function initEffect(params) {
51
51
  let handlerChainId = Internal.getItemChainId(params.item);
52
+ let isCrossChain = effect => Stdlib_Option.getOr(effect.crossChain, params.config.defaultCrossChain);
52
53
  let makeCaller = caller => ((effect, input) => {
53
- let scope = effect.crossChain ? "crossChain" : handlerChainId;
54
- if (caller !== undefined && caller.crossChain && !effect.crossChain) {
54
+ let scope = isCrossChain(effect) ? "crossChain" : handlerChainId;
55
+ if (caller !== undefined && isCrossChain(caller) && !isCrossChain(effect)) {
55
56
  Stdlib_JsError.throwWithMessage(`The cross-chain effect "` + caller.name + `" cannot call the chain-scoped effect "` + effect.name + `", because a cross-chain effect isn't tied to a single chain. Make "` + effect.name + `" cross-chain (\`crossChain: true\`), or make "` + caller.name + `" chain-scoped (\`crossChain: false\`).`);
56
57
  }
57
58
  let tmp;
@@ -70,13 +71,17 @@ function initEffect(params) {
70
71
  return makeCaller(undefined);
71
72
  }
72
73
 
74
+ function entityScope(params) {
75
+ return InMemoryStore.entityScope(params.entityConfig, Internal.getItemChainId(params.item));
76
+ }
77
+
73
78
  function getWhereHandler(params, filter) {
74
79
  let entityConfig = params.entityConfig;
75
80
  let filters = EntityFilter.parseGetWhereOrThrow(filter, entityConfig.name, entityConfig.table);
76
81
  if (filters.length !== 1) {
77
- return Promise.all(filters.map(filter => LoadLayer.loadByFilter(params.loadManager, params.persistence, entityConfig, params.indexerState, params.isPreload, params.item, params.config.ecosystem, filter))).then(results => results.flat());
82
+ return Promise.all(filters.map(filter => LoadLayer.loadByFilter(params.loadManager, params.persistence, entityConfig, entityScope(params), params.indexerState, params.isPreload, params.item, params.config.ecosystem, filter))).then(results => results.flat());
78
83
  } else {
79
- return LoadLayer.loadByFilter(params.loadManager, params.persistence, entityConfig, params.indexerState, params.isPreload, params.item, params.config.ecosystem, filters[0]);
84
+ return LoadLayer.loadByFilter(params.loadManager, params.persistence, entityConfig, entityScope(params), params.indexerState, params.isPreload, params.item, params.config.ecosystem, filters[0]);
80
85
  }
81
86
  }
82
87
 
@@ -94,7 +99,7 @@ function throwClickHouseReadOnly(entityConfig, op) {
94
99
 
95
100
  let entityTraps_get = (params, prop) => {
96
101
  let isClickHouseOnly = !params.entityConfig.storage.postgres;
97
- let set = params.isPreload ? noopSet : entity => InMemoryTable.Entity.set(InMemoryStore.getInMemTable(params.indexerState, params.entityConfig), IndexerState.committedCheckpointId(params.indexerState), {
102
+ let set = params.isPreload ? noopSet : entity => InMemoryTable.Entity.set(InMemoryStore.getInMemTable(params.indexerState, params.entityConfig, entityScope(params)), IndexerState.committedCheckpointId(params.indexerState), {
98
103
  type: "SET",
99
104
  entityId: entity.id,
100
105
  entity: entity,
@@ -105,7 +110,7 @@ let entityTraps_get = (params, prop) => {
105
110
  if (params.isPreload) {
106
111
  return noopDeleteUnsafe;
107
112
  } else {
108
- return entityId => InMemoryTable.Entity.set(InMemoryStore.getInMemTable(params.indexerState, params.entityConfig), IndexerState.committedCheckpointId(params.indexerState), {
113
+ return entityId => InMemoryTable.Entity.set(InMemoryStore.getInMemTable(params.indexerState, params.entityConfig, entityScope(params)), IndexerState.committedCheckpointId(params.indexerState), {
109
114
  type: "DELETE",
110
115
  entityId: entityId,
111
116
  checkpointId: params.checkpointId
@@ -115,13 +120,13 @@ let entityTraps_get = (params, prop) => {
115
120
  if (isClickHouseOnly) {
116
121
  return _entityId => throwClickHouseReadOnly(params.entityConfig, "get");
117
122
  } else {
118
- return entityId => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, params.indexerState, params.isPreload, params.item, params.config.ecosystem, entityId);
123
+ return entityId => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, entityScope(params), params.indexerState, params.isPreload, params.item, params.config.ecosystem, entityId);
119
124
  }
120
125
  case "getOrCreate" :
121
126
  if (isClickHouseOnly) {
122
127
  return _entity => throwClickHouseReadOnly(params.entityConfig, "getOrCreate");
123
128
  } else {
124
- return entity => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, params.indexerState, params.isPreload, params.item, params.config.ecosystem, entity.id).then(storageEntity => {
129
+ return entity => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, entityScope(params), params.indexerState, params.isPreload, params.item, params.config.ecosystem, entity.id).then(storageEntity => {
125
130
  if (storageEntity !== undefined) {
126
131
  return storageEntity;
127
132
  } else {
@@ -134,7 +139,7 @@ let entityTraps_get = (params, prop) => {
134
139
  if (isClickHouseOnly) {
135
140
  return (_entityId, param) => throwClickHouseReadOnly(params.entityConfig, "getOrThrow");
136
141
  } else {
137
- return (entityId, message) => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, params.indexerState, params.isPreload, params.item, params.config.ecosystem, entityId).then(entity => {
142
+ return (entityId, message) => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, entityScope(params), params.indexerState, params.isPreload, params.item, params.config.ecosystem, entityId).then(entity => {
138
143
  if (entity !== undefined) {
139
144
  return entity;
140
145
  } else {
@@ -210,6 +215,7 @@ export {
210
215
  paramsByThis,
211
216
  effectContextPrototype,
212
217
  initEffect,
218
+ entityScope,
213
219
  getWhereHandler,
214
220
  noopSet,
215
221
  noopDeleteUnsafe,
package/src/Writing.res CHANGED
@@ -10,10 +10,8 @@ let keepLatestChangesLimit = Env.inMemoryObjectsTarget
10
10
 
11
11
  let getChangesCount = (state: IndexerState.t) => {
12
12
  let total = ref(0.)
13
- state
14
- ->IndexerState.allEntities
15
- ->Array.forEach(entityConfig => {
16
- total := total.contents +. (state->InMemoryStore.getInMemTable(~entityConfig)).changesCount
13
+ state->IndexerState.eachEntityTable((~entityConfig as _, ~scope as _, ~table) => {
14
+ total := total.contents +. table.changesCount
17
15
  })
18
16
  state
19
17
  ->IndexerState.effectState
@@ -113,14 +111,12 @@ let runOneWrite = async (state: IndexerState.t) => {
113
111
 
114
112
  let rollback = state->IndexerState.takeRollback
115
113
 
116
- let updatedEntities = persistence.allEntities->Array.filterMap(entityConfig => {
117
- let table = state->InMemoryStore.getInMemTable(~entityConfig)
114
+ let updatedEntities = []
115
+ state->IndexerState.eachEntityTable((~entityConfig, ~scope, ~table) => {
118
116
  let changes =
119
117
  table->InMemoryTable.Entity.snapshotChanges(~committedCheckpointId, ~upToCheckpointId)
120
- if changes->Utils.Array.isEmpty {
121
- None
122
- } else {
123
- Some(({entityConfig, changes}: Persistence.updatedEntity))
118
+ if changes->Utils.Array.notEmpty {
119
+ updatedEntities->Array.push(({entityConfig, scope, changes}: Persistence.updatedEntity))
124
120
  }
125
121
  })
126
122
  let updatedEffectsCache = snapshotEffects(state, ~cache)
@@ -221,12 +217,8 @@ let commitBatch = (state: IndexerState.t, ~batch: Batch.t) => {
221
217
  // keepLoadedFromDb, entries seeded from a db read are spared.
222
218
  let dropCommitted = (state: IndexerState.t, ~keepLoadedFromDb) => {
223
219
  let committedCheckpointId = state->IndexerState.committedCheckpointId
224
- state
225
- ->IndexerState.allEntities
226
- ->Array.forEach(entityConfig =>
227
- state
228
- ->InMemoryStore.getInMemTable(~entityConfig)
229
- ->InMemoryTable.Entity.dropCommittedChanges(~committedCheckpointId, ~keepLoadedFromDb)
220
+ state->IndexerState.eachEntityTable((~entityConfig as _, ~scope as _, ~table) =>
221
+ table->InMemoryTable.Entity.dropCommittedChanges(~committedCheckpointId, ~keepLoadedFromDb)
230
222
  )
231
223
  state
232
224
  ->IndexerState.effectState
@@ -19,8 +19,8 @@ function getChangesCount(state) {
19
19
  let total = {
20
20
  contents: 0
21
21
  };
22
- IndexerState.allEntities(state).forEach(entityConfig => {
23
- total.contents = total.contents + InMemoryStore.getInMemTable(state, entityConfig).changesCount;
22
+ IndexerState.eachEntityTable(state, (param, param$1, table) => {
23
+ total.contents = total.contents + table.changesCount;
24
24
  });
25
25
  EffectState.forEach(IndexerState.effectState(state), inMemTable => {
26
26
  total.contents = total.contents + inMemTable.changesCount;
@@ -105,16 +105,16 @@ async function runOneWrite(state) {
105
105
  let checkpointId = Utils.$$Array.last(batch.checkpointIds);
106
106
  let upToCheckpointId = checkpointId !== undefined ? checkpointId : committedCheckpointId;
107
107
  let rollback = IndexerState.takeRollback(state);
108
- let updatedEntities = Stdlib_Array.filterMap(persistence.allEntities, entityConfig => {
109
- let table = InMemoryStore.getInMemTable(state, entityConfig);
108
+ let updatedEntities = [];
109
+ IndexerState.eachEntityTable(state, (entityConfig, scope, table) => {
110
110
  let changes = InMemoryTable.Entity.snapshotChanges(table, committedCheckpointId, upToCheckpointId);
111
- if (Utils.$$Array.isEmpty(changes)) {
112
- return;
113
- } else {
114
- return {
111
+ if (Utils.$$Array.notEmpty(changes)) {
112
+ updatedEntities.push({
115
113
  entityConfig: entityConfig,
114
+ scope: scope,
116
115
  changes: changes
117
- };
116
+ });
117
+ return;
118
118
  }
119
119
  });
120
120
  let updatedEffectsCache = snapshotEffects(state, cache);
@@ -179,7 +179,7 @@ function commitBatch(state, batch) {
179
179
 
180
180
  function dropCommitted(state, keepLoadedFromDb) {
181
181
  let committedCheckpointId = IndexerState.committedCheckpointId(state);
182
- IndexerState.allEntities(state).forEach(entityConfig => InMemoryTable.Entity.dropCommittedChanges(InMemoryStore.getInMemTable(state, entityConfig), committedCheckpointId, keepLoadedFromDb));
182
+ IndexerState.eachEntityTable(state, (param, param$1, table) => InMemoryTable.Entity.dropCommittedChanges(table, committedCheckpointId, keepLoadedFromDb));
183
183
  EffectState.forEach(IndexerState.effectState(state), inMemTable => InMemoryStore.dropCommittedEffects(inMemTable, committedCheckpointId, keepLoadedFromDb));
184
184
  }
185
185