envio 3.1.1 → 3.2.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 (134) hide show
  1. package/evm.schema.json +83 -11
  2. package/fuel.schema.json +83 -11
  3. package/index.d.ts +184 -3
  4. package/package.json +6 -6
  5. package/src/Batch.res +2 -2
  6. package/src/ChainFetcher.res +27 -3
  7. package/src/ChainFetcher.res.mjs +17 -3
  8. package/src/ChainManager.res +163 -0
  9. package/src/ChainManager.res.mjs +136 -0
  10. package/src/Config.res +213 -30
  11. package/src/Config.res.mjs +102 -41
  12. package/src/Core.res +16 -10
  13. package/src/Ecosystem.res +0 -3
  14. package/src/Env.res +2 -2
  15. package/src/Env.res.mjs +2 -2
  16. package/src/Envio.res +101 -2
  17. package/src/Envio.res.mjs +2 -3
  18. package/src/EventConfigBuilder.res +52 -0
  19. package/src/EventConfigBuilder.res.mjs +32 -0
  20. package/src/EventUtils.res +2 -2
  21. package/src/FetchState.res +126 -71
  22. package/src/FetchState.res.mjs +73 -51
  23. package/src/GlobalState.res +219 -363
  24. package/src/GlobalState.res.mjs +314 -491
  25. package/src/GlobalStateManager.res +49 -59
  26. package/src/GlobalStateManager.res.mjs +5 -4
  27. package/src/GlobalStateManager.resi +1 -1
  28. package/src/HandlerLoader.res +12 -1
  29. package/src/HandlerLoader.res.mjs +6 -1
  30. package/src/HandlerRegister.res +9 -9
  31. package/src/HandlerRegister.res.mjs +9 -9
  32. package/src/Hasura.res +102 -32
  33. package/src/Hasura.res.mjs +88 -34
  34. package/src/InMemoryStore.res +10 -1
  35. package/src/InMemoryStore.res.mjs +4 -1
  36. package/src/InMemoryTable.res +83 -136
  37. package/src/InMemoryTable.res.mjs +57 -86
  38. package/src/Internal.res +54 -5
  39. package/src/Internal.res.mjs +2 -8
  40. package/src/LazyLoader.res +2 -2
  41. package/src/LazyLoader.res.mjs +3 -3
  42. package/src/LoadLayer.res +47 -60
  43. package/src/LoadLayer.res.mjs +28 -50
  44. package/src/LoadLayer.resi +2 -5
  45. package/src/LogSelection.res +4 -4
  46. package/src/LogSelection.res.mjs +5 -7
  47. package/src/Logging.res +1 -1
  48. package/src/Main.res +61 -2
  49. package/src/Main.res.mjs +37 -1
  50. package/src/Persistence.res +3 -16
  51. package/src/PgStorage.res +125 -114
  52. package/src/PgStorage.res.mjs +112 -95
  53. package/src/Ports.res +5 -0
  54. package/src/Ports.res.mjs +9 -0
  55. package/src/Prometheus.res +3 -3
  56. package/src/Prometheus.res.mjs +4 -4
  57. package/src/ReorgDetection.res +4 -4
  58. package/src/ReorgDetection.res.mjs +4 -5
  59. package/src/SafeCheckpointTracking.res +16 -16
  60. package/src/SafeCheckpointTracking.res.mjs +2 -2
  61. package/src/SimulateItems.res +10 -14
  62. package/src/SimulateItems.res.mjs +5 -2
  63. package/src/Sink.res +1 -1
  64. package/src/Sink.res.mjs +1 -2
  65. package/src/SvmTypes.res +9 -0
  66. package/src/SvmTypes.res.mjs +14 -0
  67. package/src/TestIndexer.res +17 -57
  68. package/src/TestIndexer.res.mjs +14 -48
  69. package/src/TestIndexerProxyStorage.res +23 -23
  70. package/src/TestIndexerProxyStorage.res.mjs +12 -15
  71. package/src/Throttler.res +2 -2
  72. package/src/Time.res +2 -2
  73. package/src/Time.res.mjs +2 -2
  74. package/src/UserContext.res +19 -118
  75. package/src/UserContext.res.mjs +10 -66
  76. package/src/Utils.res +15 -15
  77. package/src/Utils.res.mjs +7 -8
  78. package/src/adapters/MarkBatchProcessedAdapter.res +5 -0
  79. package/src/adapters/MarkBatchProcessedAdapter.res.mjs +14 -0
  80. package/src/bindings/BigDecimal.res +1 -1
  81. package/src/bindings/BigDecimal.res.mjs +2 -2
  82. package/src/bindings/ClickHouse.res +8 -6
  83. package/src/bindings/ClickHouse.res.mjs +5 -5
  84. package/src/bindings/Hrtime.res +1 -1
  85. package/src/bindings/Pino.res +2 -2
  86. package/src/bindings/Pino.res.mjs +3 -4
  87. package/src/db/EntityFilter.res +410 -0
  88. package/src/db/EntityFilter.res.mjs +424 -0
  89. package/src/db/EntityHistory.res +1 -1
  90. package/src/db/EntityHistory.res.mjs +1 -1
  91. package/src/db/InternalTable.res +10 -10
  92. package/src/db/InternalTable.res.mjs +41 -45
  93. package/src/db/Schema.res +2 -2
  94. package/src/db/Schema.res.mjs +3 -3
  95. package/src/db/Table.res +106 -22
  96. package/src/db/Table.res.mjs +84 -35
  97. package/src/sources/EventRouter.res +67 -2
  98. package/src/sources/EventRouter.res.mjs +45 -3
  99. package/src/sources/Evm.res +0 -7
  100. package/src/sources/Evm.res.mjs +0 -15
  101. package/src/sources/EvmChain.res +1 -1
  102. package/src/sources/EvmChain.res.mjs +1 -2
  103. package/src/sources/EvmRpcClient.res +42 -0
  104. package/src/sources/EvmRpcClient.res.mjs +64 -0
  105. package/src/sources/Fuel.res +0 -7
  106. package/src/sources/Fuel.res.mjs +0 -15
  107. package/src/sources/HyperFuelSource.res +5 -4
  108. package/src/sources/HyperFuelSource.res.mjs +2 -2
  109. package/src/sources/HyperSyncClient.res +9 -5
  110. package/src/sources/HyperSyncClient.res.mjs +2 -2
  111. package/src/sources/HyperSyncHeightStream.res +2 -2
  112. package/src/sources/HyperSyncHeightStream.res.mjs +2 -2
  113. package/src/sources/HyperSyncSource.res +10 -9
  114. package/src/sources/HyperSyncSource.res.mjs +4 -4
  115. package/src/sources/Rpc.res +1 -5
  116. package/src/sources/Rpc.res.mjs +1 -9
  117. package/src/sources/RpcSource.res +57 -21
  118. package/src/sources/RpcSource.res.mjs +47 -20
  119. package/src/sources/RpcWebSocketHeightStream.res +1 -1
  120. package/src/sources/SourceManager.res +3 -2
  121. package/src/sources/SourceManager.res.mjs +1 -1
  122. package/src/sources/Svm.res +3 -10
  123. package/src/sources/Svm.res.mjs +4 -18
  124. package/src/sources/SvmHyperSyncClient.res +265 -0
  125. package/src/sources/SvmHyperSyncClient.res.mjs +28 -0
  126. package/src/sources/SvmHyperSyncSource.res +638 -0
  127. package/src/sources/SvmHyperSyncSource.res.mjs +557 -0
  128. package/src/tui/Tui.res +9 -2
  129. package/src/tui/Tui.res.mjs +18 -3
  130. package/src/tui/components/BufferedProgressBar.res +2 -2
  131. package/src/tui/components/TuiData.res +3 -0
  132. package/svm.schema.json +523 -14
  133. package/src/TableIndices.res +0 -115
  134. package/src/TableIndices.res.mjs +0 -144
@@ -2,9 +2,8 @@
2
2
 
3
3
  import * as Pino from "pino";
4
4
  import * as Utils from "../Utils.res.mjs";
5
- import * as Belt_Array from "@rescript/runtime/lib/es6/Belt_Array.js";
6
- import * as Belt_Option from "@rescript/runtime/lib/es6/Belt_Option.js";
7
5
  import * as PinoPretty from "pino-pretty";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
7
  import EcsPinoFormat from "@elastic/ecs-pino-format";
9
8
 
10
9
  function createPinoMessage(message) {
@@ -61,7 +60,7 @@ function makeStreams(userLogLevel, formatter, logFile, defaultFileLogLevel) {
61
60
  stream: stream_stream,
62
61
  level: userLogLevel
63
62
  };
64
- let maybeFileStream = Belt_Option.mapWithDefault(logFile, [], dest => [{
63
+ let maybeFileStream = Stdlib_Option.mapOr(logFile, [], dest => [{
65
64
  stream: Pino.destination({
66
65
  dest: dest,
67
66
  sync: false,
@@ -69,7 +68,7 @@ function makeStreams(userLogLevel, formatter, logFile, defaultFileLogLevel) {
69
68
  }),
70
69
  level: defaultFileLogLevel
71
70
  }]);
72
- return Belt_Array.concat([stream], maybeFileStream);
71
+ return [stream].concat(maybeFileStream);
73
72
  }
74
73
 
75
74
  function make$1(userLogLevel, customLevels, logFile, options, defaultFileLogLevel) {
@@ -0,0 +1,410 @@
1
+ module FieldValue = {
2
+ @unboxed
3
+ type rec tNonOptional =
4
+ | String(string)
5
+ | BigInt(bigint)
6
+ | Int(int)
7
+ | BigDecimal(BigDecimal.t)
8
+ | Bool(bool)
9
+ | Array(array<tNonOptional>)
10
+
11
+ let rec toString = tNonOptional =>
12
+ switch tNonOptional {
13
+ | String(v) => v
14
+ | BigInt(v) => v->BigInt.toString
15
+ | Int(v) => v->Int.toString
16
+ | BigDecimal(v) => v->BigDecimal.toString
17
+ | Bool(v) => v ? "true" : "false"
18
+ | Array(v) => `[${v->Array.map(toString)->Array.join(",")}]`
19
+ }
20
+
21
+ //This needs to be a castable type from any type that we
22
+ //support in entities so that we can create evaluations
23
+ //and serialize the types without parsing/wrapping them
24
+ type t = option<tNonOptional>
25
+
26
+ let toString = (value: t) =>
27
+ switch value {
28
+ | Some(v) => v->toString
29
+ | None => "undefined"
30
+ }
31
+
32
+ external castFrom: 'a => t = "%identity"
33
+
34
+ let eq = (a, b) =>
35
+ switch (a, b) {
36
+ //For big decimal use custom equals operator otherwise let Caml_obj.equal do its magic
37
+ | (Some(BigDecimal(bdA)), Some(BigDecimal(bdB))) => BigDecimal.equals(bdA, bdB)
38
+ | (a, b) => a == b
39
+ }
40
+
41
+ let gt = (a, b) =>
42
+ switch (a, b) {
43
+ //For big decimal use custom equals operator otherwise let Caml_obj.equal do its magic
44
+ | (Some(BigDecimal(bdA)), Some(BigDecimal(bdB))) => BigDecimal.gt(bdA, bdB)
45
+ | (a, b) => a > b
46
+ }
47
+
48
+ let lt = (a, b) =>
49
+ switch (a, b) {
50
+ //For big decimal use custom equals operator otherwise let Caml_obj.equal do its magic
51
+ | (Some(BigDecimal(bdA)), Some(BigDecimal(bdB))) => BigDecimal.lt(bdA, bdB)
52
+ | (a, b) => a < b
53
+ }
54
+ }
55
+
56
+ // The And case requires at least one nested filter (storage throws otherwise),
57
+ // while In with an empty array matches nothing.
58
+ @tag("operator")
59
+ type rec t =
60
+ | @as("=") Eq({fieldName: string, fieldValue: unknown})
61
+ | @as(">") Gt({fieldName: string, fieldValue: unknown})
62
+ | @as("<") Lt({fieldName: string, fieldValue: unknown})
63
+ | @as("in") In({fieldName: string, fieldValue: array<unknown>})
64
+ | @as("and") And({filters: array<t>})
65
+
66
+ // Used as a stable in-memory cache key, so it must be unambiguous
67
+ // for any two different filters.
68
+ let rec toString = (filter: t) =>
69
+ switch filter {
70
+ | Eq({fieldName, fieldValue}) =>
71
+ `${fieldName}:Eq:${fieldValue->FieldValue.castFrom->FieldValue.toString}`
72
+ | Gt({fieldName, fieldValue}) =>
73
+ `${fieldName}:Gt:${fieldValue->FieldValue.castFrom->FieldValue.toString}`
74
+ | Lt({fieldName, fieldValue}) =>
75
+ `${fieldName}:Lt:${fieldValue->FieldValue.castFrom->FieldValue.toString}`
76
+ | In({fieldName, fieldValue}) =>
77
+ `${fieldName}:In:[${fieldValue
78
+ ->Array.map(v => v->FieldValue.castFrom->FieldValue.toString)
79
+ ->Array.join(",")}]`
80
+ | And({filters}) => `And(${filters->Array.map(toString)->Array.join(",")})`
81
+ }
82
+
83
+ let rec valuesCount = (filter: t) =>
84
+ switch filter {
85
+ | Eq(_) | Gt(_) | Lt(_) => 1
86
+ | In({fieldValue}) => fieldValue->Array.length
87
+ | And({filters}) => filters->Array.reduce(0, (acc, filter) => acc + filter->valuesCount)
88
+ }
89
+
90
+ let codegenHelpMessage = `Rerun 'pnpm dev' to update generated code after schema.graphql changes.`
91
+
92
+ let getUndefinedOrNullName = (value: 'a) =>
93
+ if value === %raw(`undefined`) {
94
+ Some("undefined")
95
+ } else if value === %raw(`null`) {
96
+ Some("null")
97
+ } else {
98
+ None
99
+ }
100
+
101
+ // Nullish values would otherwise turn into a "= NULL" query
102
+ // silently matching nothing.
103
+ let throwUnsupportedGetWhereValue = (~valueName, ~entityName, ~filterDisplay, ~hint="") =>
104
+ JsError.throwWithMessage(
105
+ `Invalid ${valueName} value passed to context.${entityName}.getWhere(${filterDisplay}). Filtering by null or undefined values is not supported in getWhere.${hint}`,
106
+ )
107
+
108
+ // Each returned filter should be loaded separately and the results flattened:
109
+ // _in maps to one Eq per value so loads memoize on the per-value level,
110
+ // and _gte/_lte are composed from Eq + Gt/Lt. Each field+operator pair
111
+ // expands into a group of such alternatives, and multiple pairs combine
112
+ // as a cross product of And filters — the groups stay disjoint, so the
113
+ // flattened results contain no duplicates.
114
+ let parseGetWhereOrThrow = (filter: dict<dict<unknown>>, ~entityName, ~table: Table.table): array<
115
+ t,
116
+ > => {
117
+ let filterKeys = filter->Dict.keysToArray
118
+
119
+ if filterKeys->Array.length === 0 {
120
+ JsError.throwWithMessage(
121
+ `Empty filter passed to context.${entityName}.getWhere(). Please provide a filter like { fieldName: { _eq: value } }.`,
122
+ )
123
+ }
124
+
125
+ let filterGroups = filterKeys->Array.flatMap(apiFieldName => {
126
+ let operatorObj = filter->Dict.getUnsafe(apiFieldName)
127
+
128
+ switch operatorObj->getUndefinedOrNullName {
129
+ | Some(valueName) =>
130
+ throwUnsupportedGetWhereValue(
131
+ ~valueName,
132
+ ~entityName,
133
+ ~filterDisplay=`{ ${apiFieldName}: ${valueName} }`,
134
+ ~hint=` Please provide an operator like { _eq: value }.`,
135
+ )
136
+ | None => ()
137
+ }
138
+
139
+ // A primitive operator value wouldn't throw on Dict.keysToArray, but report
140
+ // string indices or no keys as operators, so catch it with a real hint instead
141
+ if operatorObj->typeof !== #object || operatorObj->Array.isArray {
142
+ JsError.throwWithMessage(
143
+ `Invalid value passed to context.${entityName}.getWhere({ ${apiFieldName}: ... }). Please provide an operator like { _eq: value }.`,
144
+ )
145
+ }
146
+
147
+ let operatorKeys = operatorObj->Dict.keysToArray
148
+
149
+ if operatorKeys->Array.length === 0 {
150
+ JsError.throwWithMessage(
151
+ `Empty operator passed to context.${entityName}.getWhere({ ${apiFieldName}: {} }). Please provide an operator like { _eq: value }, { _gt: value }, { _lt: value }, { _gte: value }, { _lte: value }, or { _in: [values] }.`,
152
+ )
153
+ }
154
+
155
+ let throwInvalidOperator = operatorKey =>
156
+ JsError.throwWithMessage(
157
+ `Invalid operator "${operatorKey}" in context.${entityName}.getWhere({ ${apiFieldName}: { ${operatorKey}: ... } }). Valid operators are _eq, _gt, _lt, _gte, _lte, _in.`,
158
+ )
159
+
160
+ // Validate the operators and the field before the values, so a typoed
161
+ // operator or field gets the more specific error even when the value
162
+ // is also nullish
163
+ operatorKeys->Array.forEach(operatorKey =>
164
+ switch operatorKey {
165
+ | "_eq" | "_gt" | "_lt" | "_gte" | "_lte" | "_in" => ()
166
+ | _ => throwInvalidOperator(operatorKey)
167
+ }
168
+ )
169
+
170
+ switch table->Table.getFieldByApiName(apiFieldName) {
171
+ | None =>
172
+ JsError.throwWithMessage(
173
+ `Invalid field "${apiFieldName}" in context.${entityName}.getWhere(). The field doesn't exist. ${codegenHelpMessage}`,
174
+ )
175
+ | Some(DerivedFrom(_)) =>
176
+ JsError.throwWithMessage(
177
+ `The field "${apiFieldName}" on entity "${entityName}" is a derived field and cannot be used in getWhere(). Use the source entity's indexed field instead.`,
178
+ )
179
+ | Some(Field({isPrimaryKey: false, isIndex: false, linkedEntity: None})) =>
180
+ JsError.throwWithMessage(
181
+ `The field "${apiFieldName}" on entity "${entityName}" does not have an index. To use it in getWhere(), add the @index directive in your schema.graphql:\n\n ${apiFieldName}: ... @index\n\nThen run 'pnpm envio codegen' to regenerate.`,
182
+ )
183
+ | Some(Field(_)) => ()
184
+ }
185
+
186
+ operatorKeys->Array.map(operatorKey => {
187
+ let fieldValue = operatorObj->Dict.getUnsafe(operatorKey)
188
+ switch fieldValue->getUndefinedOrNullName {
189
+ | Some(valueName) =>
190
+ throwUnsupportedGetWhereValue(
191
+ ~valueName,
192
+ ~entityName,
193
+ ~filterDisplay=`{ ${apiFieldName}: { ${operatorKey}: ${valueName} } }`,
194
+ )
195
+ | None => ()
196
+ }
197
+
198
+ switch operatorKey {
199
+ | "_in" => {
200
+ if !(fieldValue->Array.isArray) {
201
+ JsError.throwWithMessage(
202
+ `Invalid value passed to context.${entityName}.getWhere({ ${apiFieldName}: { _in: ... } }). The _in operator expects an array of values.`,
203
+ )
204
+ }
205
+ let fieldValues = fieldValue->(Utils.magic: unknown => array<unknown>)
206
+
207
+ fieldValues->Array.mapWithIndex(
208
+ (fieldValue, index) => {
209
+ switch fieldValue->getUndefinedOrNullName {
210
+ | Some(valueName) =>
211
+ throwUnsupportedGetWhereValue(
212
+ ~valueName,
213
+ ~entityName,
214
+ ~filterDisplay=`{ ${apiFieldName}: { _in: [...] } }`,
215
+ ~hint=` The ${valueName} value is at index ${index->Int.toString} of the _in array.`,
216
+ )
217
+ | None => ()
218
+ }
219
+ Eq({fieldName: apiFieldName, fieldValue})
220
+ },
221
+ )
222
+ }
223
+ | "_gte" => [
224
+ Eq({fieldName: apiFieldName, fieldValue}),
225
+ Gt({fieldName: apiFieldName, fieldValue}),
226
+ ]
227
+ | "_lte" => [
228
+ Eq({fieldName: apiFieldName, fieldValue}),
229
+ Lt({fieldName: apiFieldName, fieldValue}),
230
+ ]
231
+ | "_eq" => [Eq({fieldName: apiFieldName, fieldValue})]
232
+ | "_gt" => [Gt({fieldName: apiFieldName, fieldValue})]
233
+ | "_lt" => [Lt({fieldName: apiFieldName, fieldValue})]
234
+ | _ => throwInvalidOperator(operatorKey)
235
+ }
236
+ })
237
+ })
238
+
239
+ filterGroups
240
+ ->Array.reduce([[]], (combinations, group) =>
241
+ combinations->Array.flatMap(combination =>
242
+ group->Array.map(filter => combination->Array.concat([filter]))
243
+ )
244
+ )
245
+ ->Array.map(filters =>
246
+ switch filters {
247
+ | [filter] => filter
248
+ | _ => And({filters: filters})
249
+ }
250
+ )
251
+ }
252
+
253
+ let rec printOperationFilter = (filter: t, ~paramsCount: ref<int>) =>
254
+ switch filter {
255
+ | Eq({fieldName}) => {
256
+ paramsCount := paramsCount.contents + 1
257
+ `${fieldName}: $${paramsCount.contents->Int.toString}`
258
+ }
259
+ | Gt({fieldName}) => {
260
+ paramsCount := paramsCount.contents + 1
261
+ `${fieldName}: {_gt: $${paramsCount.contents->Int.toString}}`
262
+ }
263
+ | Lt({fieldName}) => {
264
+ paramsCount := paramsCount.contents + 1
265
+ `${fieldName}: {_lt: $${paramsCount.contents->Int.toString}}`
266
+ }
267
+ | In({fieldName}) => {
268
+ paramsCount := paramsCount.contents + 1
269
+ `${fieldName}: {_in: $${paramsCount.contents->Int.toString}}`
270
+ }
271
+ | And({filters}) => {
272
+ let acc = ref("")
273
+ for idx in 0 to filters->Array.length - 1 {
274
+ let part = filters->Array.getUnsafe(idx)->printOperationFilter(~paramsCount)
275
+ acc := (acc.contents === "" ? part : `${acc.contents}, ${part}`)
276
+ }
277
+ acc.contents
278
+ }
279
+ }
280
+
281
+ // Filters that may be batched into a single storage query must produce
282
+ // the same key, so concrete values are replaced with $N placeholders.
283
+ // The flat cases duplicate printOperationFilter to keep this hot path
284
+ // allocation-free.
285
+ let toOperationKey = (filter: t, ~entityName) =>
286
+ switch filter {
287
+ | Eq({fieldName}) => `${entityName}.getWhere({${fieldName}: $1})`
288
+ | Gt({fieldName}) => `${entityName}.getWhere({${fieldName}: {_gt: $1}})`
289
+ | Lt({fieldName}) => `${entityName}.getWhere({${fieldName}: {_lt: $1}})`
290
+ | In({fieldName}) => `${entityName}.getWhere({${fieldName}: {_in: $1}})`
291
+ | And(_) => `${entityName}.getWhere({${filter->printOperationFilter(~paramsCount=ref(0))}})`
292
+ }
293
+
294
+ // Values bound to the operation key's $N placeholders, in placeholder
295
+ // order. A top-level In is reported flat, since a merged query holds one
296
+ // value per batched call there, while an In nested in And binds its whole
297
+ // array to a single placeholder, mirroring the one paramsCount increment
298
+ // per flat filter in printOperationFilter.
299
+ let getParams = (filter: t) =>
300
+ switch filter {
301
+ | Eq({fieldValue}) => [fieldValue]
302
+ | Gt({fieldValue}) => [fieldValue]
303
+ | Lt({fieldValue}) => [fieldValue]
304
+ | In({fieldValue}) => fieldValue
305
+ | And(_) => {
306
+ let acc = []
307
+ let rec collect = (filter: t) =>
308
+ switch filter {
309
+ | Eq({fieldValue}) => acc->Array.push(fieldValue)->ignore
310
+ | Gt({fieldValue}) => acc->Array.push(fieldValue)->ignore
311
+ | Lt({fieldValue}) => acc->Array.push(fieldValue)->ignore
312
+ | In({fieldValue}) =>
313
+ acc->Array.push(fieldValue->(Utils.magic: array<unknown> => unknown))->ignore
314
+ | And({filters}) => filters->Array.forEach(collect)
315
+ }
316
+ collect(filter)
317
+ acc
318
+ }
319
+ }
320
+
321
+ // Collapses filters sharing an operation key into fewer storage queries:
322
+ // Eq and In batches merge into a single In on the field. Gt/Lt/And have
323
+ // no lossless single-query form without an Or operator, so they stay as is.
324
+ // Expects a homogeneous batch — filters with the same operation key.
325
+ // A mismatched filter throws: dropping it would leave its already
326
+ // registered index without the matching db rows, silently losing data.
327
+ let throwUnmergeable = (filter: t) =>
328
+ JsError.throwWithMessage(
329
+ `Unexpected filter ${filter->toString} in a merged batch. Filters batched into a single query must use the same operator and field.`,
330
+ )
331
+
332
+ let merge = (filters: array<t>) =>
333
+ switch filters {
334
+ | [] | [_] => filters
335
+ | _ =>
336
+ switch filters->Array.getUnsafe(0) {
337
+ | Eq({fieldName}) => [
338
+ In({
339
+ fieldName,
340
+ fieldValue: filters->Array.map(filter =>
341
+ switch filter {
342
+ | Eq({fieldValue}) => fieldValue
343
+ | _ => throwUnmergeable(filter)
344
+ }
345
+ ),
346
+ }),
347
+ ]
348
+ | In({fieldName}) => [
349
+ In({
350
+ fieldName,
351
+ fieldValue: filters
352
+ ->Array.map(filter =>
353
+ switch filter {
354
+ | In({fieldValue}) => fieldValue
355
+ | _ => throwUnmergeable(filter)
356
+ }
357
+ )
358
+ ->Array.flat,
359
+ }),
360
+ ]
361
+ | Gt(_) | Lt(_) | And(_) => filters
362
+ }
363
+ }
364
+
365
+ // A field missing on the entity reads as `undefined`, which matches the `None`
366
+ // arm of `FieldValue.t` (`option<...>`), so nullable columns omitted on the
367
+ // entity object are compared as null rather than crashing.
368
+ let rec matches = (filter: t, ~entity: dict<FieldValue.t>) =>
369
+ switch filter {
370
+ | Eq({fieldName, fieldValue}) =>
371
+ entity->Dict.getUnsafe(fieldName)->FieldValue.eq(fieldValue->FieldValue.castFrom)
372
+ | Gt({fieldName, fieldValue}) =>
373
+ entity->Dict.getUnsafe(fieldName)->FieldValue.gt(fieldValue->FieldValue.castFrom)
374
+ | Lt({fieldName, fieldValue}) =>
375
+ entity->Dict.getUnsafe(fieldName)->FieldValue.lt(fieldValue->FieldValue.castFrom)
376
+ | In({fieldName, fieldValue}) => {
377
+ let entityFieldValue = entity->Dict.getUnsafe(fieldName)
378
+ fieldValue->Array.some(fieldValue =>
379
+ entityFieldValue->FieldValue.eq(fieldValue->FieldValue.castFrom)
380
+ )
381
+ }
382
+ | And({filters: []}) =>
383
+ JsError.throwWithMessage(`The "and" filter must contain at least one nested filter.`)
384
+ | And({filters}) => filters->Array.every(filter => filter->matches(~entity))
385
+ }
386
+
387
+ // In values are mapped as one array (isArray=true), so they can be
388
+ // converted with the table's cached array schema in a single pass.
389
+ let rec mapValues = (
390
+ filter: t,
391
+ ~mapValue: (~fieldName: string, ~fieldValue: unknown, ~isArray: bool) => unknown,
392
+ ) =>
393
+ switch filter {
394
+ | Eq({fieldName, fieldValue}) =>
395
+ Eq({fieldName, fieldValue: mapValue(~fieldName, ~fieldValue, ~isArray=false)})
396
+ | Gt({fieldName, fieldValue}) =>
397
+ Gt({fieldName, fieldValue: mapValue(~fieldName, ~fieldValue, ~isArray=false)})
398
+ | Lt({fieldName, fieldValue}) =>
399
+ Lt({fieldName, fieldValue: mapValue(~fieldName, ~fieldValue, ~isArray=false)})
400
+ | In({fieldName, fieldValue}) =>
401
+ In({
402
+ fieldName,
403
+ fieldValue: mapValue(
404
+ ~fieldName,
405
+ ~fieldValue=fieldValue->(Utils.magic: array<unknown> => unknown),
406
+ ~isArray=true,
407
+ )->(Utils.magic: unknown => array<unknown>),
408
+ })
409
+ | And({filters}) => And({filters: filters->Array.map(filter => filter->mapValues(~mapValue))})
410
+ }