envio 3.12.0 → 3.13.0-alpha.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 (84) hide show
  1. package/package.json +6 -6
  2. package/src/BatchProcessing.res +9 -9
  3. package/src/BatchProcessing.res.mjs +5 -5
  4. package/src/Bin.res +24 -17
  5. package/src/Bin.res.mjs +5 -0
  6. package/src/ChainFetching.res +31 -13
  7. package/src/ChainFetching.res.mjs +9 -4
  8. package/src/ChainState.res +55 -3
  9. package/src/ChainState.res.mjs +46 -3
  10. package/src/ChainState.resi +3 -1
  11. package/src/Config.res +35 -0
  12. package/src/Config.res.mjs +39 -0
  13. package/src/Core.res +4 -0
  14. package/src/Core.res.mjs +4 -0
  15. package/src/CrossChainState.res +61 -4
  16. package/src/CrossChainState.res.mjs +39 -5
  17. package/src/CrossChainState.resi +10 -1
  18. package/src/Env.res +4 -0
  19. package/src/FetchState.res +51 -46
  20. package/src/FetchState.res.mjs +54 -36
  21. package/src/InMemoryTable.res +155 -67
  22. package/src/InMemoryTable.res.mjs +151 -60
  23. package/src/IndexerLoop.res +2 -0
  24. package/src/IndexerLoop.res.mjs +1 -0
  25. package/src/IndexerState.res +41 -1
  26. package/src/IndexerState.res.mjs +43 -4
  27. package/src/IndexerState.resi +10 -0
  28. package/src/LoadLayer.res +57 -34
  29. package/src/LoadLayer.res.mjs +45 -62
  30. package/src/LoadLayer.resi +1 -1
  31. package/src/Logging.res +38 -6
  32. package/src/Logging.res.mjs +32 -5
  33. package/src/Main.res +131 -268
  34. package/src/Main.res.mjs +28 -152
  35. package/src/Metrics.res +263 -102
  36. package/src/Metrics.res.mjs +227 -48
  37. package/src/Persistence.res +27 -2
  38. package/src/Persistence.res.mjs +9 -2
  39. package/src/PgStorage.res +109 -71
  40. package/src/PgStorage.res.mjs +88 -58
  41. package/src/Server.res +181 -0
  42. package/src/Server.res.mjs +143 -0
  43. package/src/Supervisor.res +415 -0
  44. package/src/Supervisor.res.mjs +325 -0
  45. package/src/TestIndexer.res +9 -25
  46. package/src/TestIndexer.res.mjs +5 -4
  47. package/src/UserContext.res +13 -32
  48. package/src/UserContext.res.mjs +1 -7
  49. package/src/Utils.res +1 -4
  50. package/src/Utils.res.mjs +7 -16
  51. package/src/Worker.res +95 -0
  52. package/src/Worker.res.mjs +80 -0
  53. package/src/bindings/NodeJs.res +41 -0
  54. package/src/db/EntityFilter.res +487 -275
  55. package/src/db/EntityFilter.res.mjs +557 -309
  56. package/src/db/InternalTable.res +8 -1
  57. package/src/db/InternalTable.res.mjs +5 -1
  58. package/src/db/Table.res +21 -6
  59. package/src/db/Table.res.mjs +13 -4
  60. package/src/sources/BlockStore.res +7 -2
  61. package/src/sources/EvmHyperSyncSource.res +2 -0
  62. package/src/sources/EvmHyperSyncSource.res.mjs +2 -2
  63. package/src/sources/FuelHyperSyncSource.res +1 -0
  64. package/src/sources/FuelHyperSyncSource.res.mjs +1 -1
  65. package/src/sources/HyperSync.res +4 -0
  66. package/src/sources/HyperSync.res.mjs +4 -2
  67. package/src/sources/HyperSync.resi +1 -0
  68. package/src/sources/HyperSyncClient.res +3 -0
  69. package/src/sources/HyperSyncSSE.res +1 -1
  70. package/src/sources/HyperSyncSSE.res.mjs +4 -10
  71. package/src/sources/RpcSource.res +1 -0
  72. package/src/sources/RpcSource.res.mjs +1 -1
  73. package/src/sources/SimulateSource.res +1 -0
  74. package/src/sources/SimulateSource.res.mjs +1 -1
  75. package/src/sources/Source.res +7 -0
  76. package/src/sources/SourceManager.res +4 -3
  77. package/src/sources/SourceManager.res.mjs +2 -2
  78. package/src/sources/SvmHyperSyncClient.res +5 -0
  79. package/src/sources/SvmHyperSyncSource.res +3 -0
  80. package/src/sources/SvmHyperSyncSource.res.mjs +4 -2
  81. package/src/tui/Tui.res +24 -0
  82. package/src/tui/Tui.res.mjs +18 -0
  83. package/src/tui/components/SyncETA.res +12 -6
  84. package/src/tui/components/SyncETA.res.mjs +12 -8
@@ -1,111 +1,61 @@
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
- // A Uint8Array is an object like a BigDecimal, so the unboxed variant can't
27
- // tell them apart on its own.
28
- let asBytes = (value: t) => value->(Utils.magic: t => unknown)->Utils.Bytes.asUint8Array
29
-
30
- let toString = (value: t) =>
31
- switch (value, value->asBytes) {
32
- | (_, Some(bytes)) => bytes->Utils.Bytes.toHex
33
- | (Some(v), None) => v->toString
34
- | (None, None) => "undefined"
35
- }
36
-
37
- external castFrom: 'a => t = "%identity"
38
-
39
- let compareWith = (a, b, ~bigDecimal, ~bytes, ~fallback) =>
40
- switch (a, b, a->asBytes, b->asBytes) {
41
- | (_, _, Some(bytesA), Some(bytesB)) => bytes(Utils.Bytes.compare(bytesA, bytesB))
42
- //For big decimal use custom equals operator otherwise let Caml_obj.equal do its magic
43
- | (Some(BigDecimal(bdA)), Some(BigDecimal(bdB)), _, _) => bigDecimal(bdA, bdB)
44
- | (a, b, _, _) => fallback(a, b)
45
- }
46
-
47
- let eq = (a, b) =>
48
- compareWith(
49
- a,
50
- b,
51
- ~bigDecimal=BigDecimal.equals,
52
- ~bytes=order => order === 0.,
53
- ~fallback=(a, b) => a == b,
54
- )
55
-
56
- let gt = (a, b) =>
57
- compareWith(
58
- a,
59
- b,
60
- ~bigDecimal=BigDecimal.gt,
61
- ~bytes=order => order > 0.,
62
- ~fallback=(a, b) => a > b,
63
- )
64
-
65
- let lt = (a, b) =>
66
- compareWith(
67
- a,
68
- b,
69
- ~bigDecimal=BigDecimal.lt,
70
- ~bytes=order => order < 0.,
71
- ~fallback=(a, b) => a < b,
1
+ @scope("JSON") @val external jsonStringify: unknown => string = "stringify"
2
+
3
+ let nullish: unknown => bool = %raw(`v => v === undefined || v === null`)
4
+
5
+ let asArray = (v: unknown) => v->(Utils.magic: unknown => array<unknown>)
6
+
7
+ // Renders one already-projected value into a cache key: a type tag, then the
8
+ // length, then the value. The tag keeps "5" apart from 5, and the length means
9
+ // a delimiter inside a value can't imitate the delimiter, so no escaping pass
10
+ // is needed. Both together make the encoding injective, which is the only
11
+ // property a key needs.
12
+ //
13
+ // Every branch but the last is reached because the column's projection already
14
+ // collapsed its values to a primitive — a Date to its epoch millis, a Bytea to
15
+ // hex — so nothing here has to guess a value's type at runtime. JSON is the
16
+ // last resort, for a column whose values are objects in the first place.
17
+ let encodeValueKey: unknown => string = %raw(`v => {
18
+ if (v === null) return "z"
19
+ switch (typeof v) {
20
+ case "string": return "s" + v.length + ":" + v
21
+ case "number": { const s = "" + v; return "n" + s.length + ":" + s }
22
+ case "bigint": { const s = "" + v; return "g" + s.length + ":" + s }
23
+ case "boolean": return v ? "t" : "f"
24
+ case "undefined": return "u"
25
+ default: { const s = JSON.stringify(v); return "o" + s.length + ":" + s }
26
+ }
27
+ }`)
28
+
29
+ // JSON is the one step above that can fail, and only on an object: one holding
30
+ // a bigint, or one that refers to itself. An array is walked rather than
31
+ // stringified, because its key is built from its elements' keys.
32
+ let isKeyable: unknown => bool = %raw(`function isKeyable(v) {
33
+ if (typeof v !== "object" || v === null) return true
34
+ if (Array.isArray(v)) return v.every(isKeyable)
35
+ try { JSON.stringify(v); return true } catch { return false }
36
+ }`)
37
+
38
+ // The filter as the handler wrote it: field -> operator -> value. Private, so
39
+ // a value of this type is one parseOrThrow accepted or the indexer built
40
+ // itself, and nothing downstream has to check the filter again.
41
+ type t = private dict<dict<unknown>>
42
+
43
+ external unsafeFromDict: dict<dict<unknown>> => t = "%identity"
44
+
45
+ let entries = (filter: t) => (filter :> dict<dict<unknown>>)
46
+
47
+ let valuesCount = (filter: t) => {
48
+ let count = ref(0)
49
+ filter
50
+ ->entries
51
+ ->Utils.Dict.forEachWithKey((operators, _) =>
52
+ operators->Utils.Dict.forEachWithKey((fieldValue, operator) =>
53
+ count := count.contents + (operator === "_in" ? fieldValue->asArray->Array.length : 1)
72
54
  )
55
+ )
56
+ count.contents
73
57
  }
74
58
 
75
- // The And case requires at least one nested filter (storage throws otherwise),
76
- // while In with an empty array matches nothing.
77
- @tag("operator")
78
- type rec t =
79
- | @as("=") Eq({fieldName: string, fieldValue: unknown})
80
- | @as(">") Gt({fieldName: string, fieldValue: unknown})
81
- | @as("<") Lt({fieldName: string, fieldValue: unknown})
82
- | @as("in") In({fieldName: string, fieldValue: array<unknown>})
83
- | @as("and") And({filters: array<t>})
84
-
85
- // Used as a stable in-memory cache key, so it must be unambiguous
86
- // for any two different filters.
87
- let rec toString = (filter: t) =>
88
- switch filter {
89
- | Eq({fieldName, fieldValue}) =>
90
- `${fieldName}:Eq:${fieldValue->FieldValue.castFrom->FieldValue.toString}`
91
- | Gt({fieldName, fieldValue}) =>
92
- `${fieldName}:Gt:${fieldValue->FieldValue.castFrom->FieldValue.toString}`
93
- | Lt({fieldName, fieldValue}) =>
94
- `${fieldName}:Lt:${fieldValue->FieldValue.castFrom->FieldValue.toString}`
95
- | In({fieldName, fieldValue}) =>
96
- `${fieldName}:In:[${fieldValue
97
- ->Array.map(v => v->FieldValue.castFrom->FieldValue.toString)
98
- ->Array.join(",")}]`
99
- | And({filters}) => `And(${filters->Array.map(toString)->Array.join(",")})`
100
- }
101
-
102
- let rec valuesCount = (filter: t) =>
103
- switch filter {
104
- | Eq(_) | Gt(_) | Lt(_) => 1
105
- | In({fieldValue}) => fieldValue->Array.length
106
- | And({filters}) => filters->Array.reduce(0, (acc, filter) => acc + filter->valuesCount)
107
- }
108
-
109
59
  let codegenHelpMessage = `Rerun 'pnpm dev' to update generated code after schema.graphql changes.`
110
60
 
111
61
  let getUndefinedOrNullName = (value: 'a) =>
@@ -124,15 +74,42 @@ let throwUnsupportedGetWhereValue = (~valueName, ~entityName, ~filterDisplay, ~h
124
74
  `Invalid ${valueName} value passed to context.${entityName}.getWhere(${filterDisplay}). Filtering by null or undefined values is not supported in getWhere.${hint}`,
125
75
  )
126
76
 
127
- // Each returned filter should be loaded separately and the results flattened:
128
- // _in maps to one Eq per value so loads memoize on the per-value level,
129
- // and _gte/_lte are composed from Eq + Gt/Lt. Each field+operator pair
130
- // expands into a group of such alternatives, and multiple pairs combine
131
- // as a cross product of And filters — the groups stay disjoint, so the
132
- // flattened results contain no duplicates.
133
- let parseGetWhereOrThrow = (filter: dict<dict<unknown>>, ~entityName, ~table: Table.table): array<
134
- t,
135
- > => {
77
+ let isDate: unknown => bool = %raw(`v => v instanceof Date`)
78
+
79
+ // Columns whose comparison needs a specific object shape. The rest compare
80
+ // natively, so whatever they're handed is already safe.
81
+ let expectedValueType = (field: Table.field) =>
82
+ switch field.fieldType {
83
+ | Date => Some(field.isArray ? "an array of Date" : "a Date")
84
+ | Bytea => Some(field.isArray ? "an array of Uint8Array" : "a Uint8Array")
85
+ | _ => None
86
+ }
87
+
88
+ // Whether this column's values can reach JSON when a key is built. Date, Bytea
89
+ // and BigDecimal collapse to a primitive first, and an array's elements follow
90
+ // the same rule as the scalar they hold.
91
+ let keyUsesSerializer = (field: Table.field) =>
92
+ switch field.fieldType {
93
+ | Date | Bytea | BigDecimal(_) => false
94
+ | _ => true
95
+ }
96
+
97
+ let matchesFieldType = (value: unknown, ~field: Table.field) => {
98
+ let matchesScalar = value =>
99
+ switch field.fieldType {
100
+ | Date => value->isDate
101
+ | Bytea => value->Utils.Bytes.asUint8Array->Option.isSome
102
+ | _ => true
103
+ }
104
+ field.isArray
105
+ ? value->Array.isArray && value->asArray->Array.every(matchesScalar)
106
+ : value->matchesScalar
107
+ }
108
+
109
+ // The one way in for a handler's filter. A bad field, operator or value is
110
+ // reported against the call the handler made rather than surfacing later as a
111
+ // comparator or SQL failure.
112
+ let parseOrThrow = (filter: dict<dict<unknown>>, ~entityName, ~table: Table.table): t => {
136
113
  let filterKeys = filter->Dict.keysToArray
137
114
 
138
115
  if filterKeys->Array.length === 0 {
@@ -141,7 +118,7 @@ let parseGetWhereOrThrow = (filter: dict<dict<unknown>>, ~entityName, ~table: Ta
141
118
  )
142
119
  }
143
120
 
144
- let filterGroups = filterKeys->Array.flatMap(apiFieldName => {
121
+ filterKeys->Array.forEach(apiFieldName => {
145
122
  let operatorObj = filter->Dict.getUnsafe(apiFieldName)
146
123
 
147
124
  switch operatorObj->getUndefinedOrNullName {
@@ -186,7 +163,7 @@ let parseGetWhereOrThrow = (filter: dict<dict<unknown>>, ~entityName, ~table: Ta
186
163
  }
187
164
  )
188
165
 
189
- switch table->Table.getFieldByApiName(apiFieldName) {
166
+ let field = switch table->Table.getFieldByApiName(apiFieldName) {
190
167
  | None =>
191
168
  JsError.throwWithMessage(
192
169
  `Invalid field "${apiFieldName}" in context.${entityName}.getWhere(). The field doesn't exist. ${codegenHelpMessage}`,
@@ -195,10 +172,16 @@ let parseGetWhereOrThrow = (filter: dict<dict<unknown>>, ~entityName, ~table: Ta
195
172
  JsError.throwWithMessage(
196
173
  `The field "${apiFieldName}" on entity "${entityName}" is a derived field and cannot be used in getWhere(). Use the source entity's indexed field instead.`,
197
174
  )
198
- | Some(Field(_)) => ()
175
+ | Some(Field(field)) => field
199
176
  }
200
177
 
201
- operatorKeys->Array.map(operatorKey => {
178
+ // Constant per field, and None for every column that accepts any runtime
179
+ // shape — so the per-value check below is a single comparison on the path
180
+ // that matters, an _in of many values that are all about to pass.
181
+ let expectedType = field->expectedValueType
182
+ let checkKeyable = field->keyUsesSerializer
183
+
184
+ operatorKeys->Array.forEach(operatorKey => {
202
185
  let fieldValue = operatorObj->Dict.getUnsafe(operatorKey)
203
186
  switch fieldValue->getUndefinedOrNullName {
204
187
  | Some(valueName) =>
@@ -209,6 +192,14 @@ let parseGetWhereOrThrow = (filter: dict<dict<unknown>>, ~entityName, ~table: Ta
209
192
  )
210
193
  | None => ()
211
194
  }
195
+ let throwUnexpectedType = (~typeName, ~hint) =>
196
+ JsError.throwWithMessage(
197
+ `Invalid value passed to context.${entityName}.getWhere({ ${apiFieldName}: { ${operatorKey}: ... } }). The field "${apiFieldName}" expects ${typeName}.${hint}`,
198
+ )
199
+ let throwUnkeyable = (~hint) =>
200
+ JsError.throwWithMessage(
201
+ `Invalid value passed to context.${entityName}.getWhere({ ${apiFieldName}: { ${operatorKey}: ... } }). The value can't be serialized, so it can't be used as a filter. An object holding a bigint, or a circular reference, is not supported.${hint}`,
202
+ )
212
203
 
213
204
  switch operatorKey {
214
205
  | "_in" => {
@@ -217,9 +208,9 @@ let parseGetWhereOrThrow = (filter: dict<dict<unknown>>, ~entityName, ~table: Ta
217
208
  `Invalid value passed to context.${entityName}.getWhere({ ${apiFieldName}: { _in: ... } }). The _in operator expects an array of values.`,
218
209
  )
219
210
  }
220
- let fieldValues = fieldValue->(Utils.magic: unknown => array<unknown>)
211
+ let fieldValues = fieldValue->asArray
221
212
 
222
- fieldValues->Array.mapWithIndex(
213
+ fieldValues->Array.forEachWithIndex(
223
214
  (fieldValue, index) => {
224
215
  switch fieldValue->getUndefinedOrNullName {
225
216
  | Some(valueName) =>
@@ -231,195 +222,416 @@ let parseGetWhereOrThrow = (filter: dict<dict<unknown>>, ~entityName, ~table: Ta
231
222
  )
232
223
  | None => ()
233
224
  }
234
- Eq({fieldName: apiFieldName, fieldValue})
225
+ switch expectedType {
226
+ | Some(typeName) if !(fieldValue->matchesFieldType(~field)) =>
227
+ throwUnexpectedType(
228
+ ~typeName,
229
+ ~hint=` The value is at index ${index->Int.toString} of the _in array.`,
230
+ )
231
+ | _ => ()
232
+ }
233
+ if checkKeyable && !(fieldValue->isKeyable) {
234
+ throwUnkeyable(
235
+ ~hint=` The value is at index ${index->Int.toString} of the _in array.`,
236
+ )
237
+ }
235
238
  },
236
239
  )
237
240
  }
238
- | "_gte" => [
239
- Eq({fieldName: apiFieldName, fieldValue}),
240
- Gt({fieldName: apiFieldName, fieldValue}),
241
- ]
242
- | "_lte" => [
243
- Eq({fieldName: apiFieldName, fieldValue}),
244
- Lt({fieldName: apiFieldName, fieldValue}),
245
- ]
246
- | "_eq" => [Eq({fieldName: apiFieldName, fieldValue})]
247
- | "_gt" => [Gt({fieldName: apiFieldName, fieldValue})]
248
- | "_lt" => [Lt({fieldName: apiFieldName, fieldValue})]
249
- | _ => throwInvalidOperator(operatorKey)
241
+ | _ =>
242
+ switch expectedType {
243
+ | Some(typeName) if !(fieldValue->matchesFieldType(~field)) =>
244
+ throwUnexpectedType(~typeName, ~hint="")
245
+ | _ => ()
246
+ }
247
+ if checkKeyable && !(fieldValue->isKeyable) {
248
+ throwUnkeyable(~hint="")
249
+ }
250
250
  }
251
251
  })
252
252
  })
253
253
 
254
- filterGroups
255
- ->Array.reduce([[]], (combinations, group) =>
256
- combinations->Array.flatMap(combination =>
257
- group->Array.map(filter => combination->Array.concat([filter]))
258
- )
259
- )
260
- ->Array.map(filters =>
261
- switch filters {
262
- | [filter] => filter
263
- | _ => And({filters: filters})
264
- }
265
- )
254
+ filter->unsafeFromDict
266
255
  }
267
256
 
268
- let rec printOperationFilter = (filter: t, ~paramsCount: ref<int>) =>
269
- switch filter {
270
- | Eq({fieldName}) => {
271
- paramsCount := paramsCount.contents + 1
272
- `${fieldName}: $${paramsCount.contents->Int.toString}`
273
- }
274
- | Gt({fieldName}) => {
275
- paramsCount := paramsCount.contents + 1
276
- `${fieldName}: {_gt: $${paramsCount.contents->Int.toString}}`
277
- }
278
- | Lt({fieldName}) => {
279
- paramsCount := paramsCount.contents + 1
280
- `${fieldName}: {_lt: $${paramsCount.contents->Int.toString}}`
281
- }
282
- | In({fieldName}) => {
283
- paramsCount := paramsCount.contents + 1
284
- `${fieldName}: {_in: $${paramsCount.contents->Int.toString}}`
285
- }
286
- | And({filters}) => {
287
- let acc = ref("")
288
- for idx in 0 to filters->Array.length - 1 {
289
- let part = filters->Array.getUnsafe(idx)->printOperationFilter(~paramsCount)
290
- acc := (acc.contents === "" ? part : `${acc.contents}, ${part}`)
291
- }
292
- acc.contents
293
- }
257
+ let byIds = (ids: array<string>): t =>
258
+ Dict.fromArray([
259
+ (Table.idFieldName, dict{"_in": ids->(Utils.magic: array<string> => unknown)}),
260
+ ])->unsafeFromDict
261
+
262
+ // Narrows a filter to the scope's chain. Cross-chain entities have no chain-id
263
+ // column, so their filter is left untouched.
264
+ let scoped = (filter: t, ~table: Table.table, ~scope: Internal.chainScope): t =>
265
+ switch (scope, table->Table.getChainIdField) {
266
+ | (Chain(chainId), Some(field)) =>
267
+ // Copied, because the filter the handler passed in must not gain a column
268
+ // it never asked for.
269
+ let scoped = filter->entries->Utils.Dict.shallowCopy
270
+ scoped->Dict.set(field.fieldName, dict{"_eq": chainId->(Utils.magic: ChainId.t => unknown)})
271
+ scoped->unsafeFromDict
272
+ | _ => filter
294
273
  }
295
274
 
296
- // Filters that may be batched into a single storage query must produce
297
- // the same key, so concrete values are replaced with $N placeholders.
298
- // The flat cases duplicate printOperationFilter to keep this hot path
299
- // allocation-free.
300
- let toOperationKey = (filter: t, ~entityName) =>
301
- switch filter {
302
- | Eq({fieldName}) => `${entityName}.getWhere({${fieldName}: $1})`
303
- | Gt({fieldName}) => `${entityName}.getWhere({${fieldName}: {_gt: $1}})`
304
- | Lt({fieldName}) => `${entityName}.getWhere({${fieldName}: {_lt: $1}})`
305
- | In({fieldName}) => `${entityName}.getWhere({${fieldName}: {_in: $1}})`
306
- | And(_) => `${entityName}.getWhere({${filter->printOperationFilter(~paramsCount=ref(0))}})`
307
- }
275
+ // Values bound to the query's $N placeholders, in the order it binds them.
276
+ let getParams = (filter: t) => {
277
+ let params = []
278
+ filter
279
+ ->entries
280
+ ->Utils.Dict.forEachWithKey((operators, _) =>
281
+ operators->Utils.Dict.forEachWithKey((fieldValue, _) => params->Array.push(fieldValue)->ignore)
282
+ )
283
+ params
284
+ }
308
285
 
309
- // Values bound to the operation key's $N placeholders, in placeholder
310
- // order. A top-level In is reported flat, since a merged query holds one
311
- // value per batched call there, while an In nested in And binds its whole
312
- // array to a single placeholder, mirroring the one paramsCount increment
313
- // per flat filter in printOperationFilter.
314
- let getParams = (filter: t) =>
315
- switch filter {
316
- | Eq({fieldValue}) => [fieldValue]
317
- | Gt({fieldValue}) => [fieldValue]
318
- | Lt({fieldValue}) => [fieldValue]
319
- | In({fieldValue}) => fieldValue
320
- | And(_) => {
321
- let acc = []
322
- let rec collect = (filter: t) =>
323
- switch filter {
324
- | Eq({fieldValue}) => acc->Array.push(fieldValue)->ignore
325
- | Gt({fieldValue}) => acc->Array.push(fieldValue)->ignore
326
- | Lt({fieldValue}) => acc->Array.push(fieldValue)->ignore
327
- | In({fieldValue}) =>
328
- acc->Array.push(fieldValue->(Utils.magic: array<unknown> => unknown))->ignore
329
- | And({filters}) => filters->Array.forEach(collect)
330
- }
331
- collect(filter)
332
- acc
286
+ // The one shape a value can key an index or a merged query by: a single field
287
+ // under a single operator.
288
+ let asSingleOperator = (filter: t) =>
289
+ switch filter->entries->Dict.keysToArray {
290
+ | [fieldName] =>
291
+ let operators = filter->entries->Dict.getUnsafe(fieldName)
292
+ switch operators->Dict.keysToArray {
293
+ | [operator] => Some((fieldName, operator, operators->Dict.getUnsafe(operator)))
294
+ | _ => None
333
295
  }
296
+ | _ => None
334
297
  }
335
298
 
336
299
  // Collapses filters sharing an operation key into fewer storage queries:
337
- // Eq and In batches merge into a single In on the field. Gt/Lt/And have
338
- // no lossless single-query form without an Or operator, so they stay as is.
300
+ // _eq and _in batches merge into a single _in on the field. The rest have no
301
+ // lossless single-query form without an Or operator, so they stay as is.
339
302
  // Expects a homogeneous batch — filters with the same operation key.
340
303
  // A mismatched filter throws: dropping it would leave its already
341
304
  // registered index without the matching db rows, silently losing data.
342
305
  let throwUnmergeable = (filter: t) =>
343
306
  JsError.throwWithMessage(
344
- `Unexpected filter ${filter->toString} in a merged batch. Filters batched into a single query must use the same operator and field.`,
307
+ `Unexpected ${switch filter->asSingleOperator {
308
+ | Some((_, operator, _)) => operator
309
+ | None => "composite"
310
+ }} filter in a merged batch. Filters batched into a single query must use the same operator and field.`,
345
311
  )
346
312
 
347
313
  let merge = (filters: array<t>) =>
348
314
  switch filters {
349
315
  | [] | [_] => filters
350
316
  | _ =>
351
- switch filters->Array.getUnsafe(0) {
352
- | Eq({fieldName}) => [
353
- In({
354
- fieldName,
355
- fieldValue: filters->Array.map(filter =>
356
- switch filter {
357
- | Eq({fieldValue}) => fieldValue
358
- | _ => throwUnmergeable(filter)
359
- }
360
- ),
361
- }),
362
- ]
363
- | In({fieldName}) => [
364
- In({
365
- fieldName,
366
- fieldValue: filters
367
- ->Array.map(filter =>
368
- switch filter {
369
- | In({fieldValue}) => fieldValue
370
- | _ => throwUnmergeable(filter)
371
- }
372
- )
373
- ->Array.flat,
374
- }),
317
+ switch filters->Array.getUnsafe(0)->asSingleOperator {
318
+ | Some((fieldName, ("_eq" | "_in") as operator, _)) =>
319
+ let values = []
320
+ filters->Array.forEach(filter =>
321
+ switch filter->asSingleOperator {
322
+ | Some((candidateField, candidateOperator, fieldValue))
323
+ if candidateField === fieldName && candidateOperator === operator =>
324
+ if operator === "_in" {
325
+ fieldValue->asArray->Array.forEach(value => values->Array.push(value)->ignore)
326
+ } else {
327
+ values->Array.push(fieldValue)->ignore
328
+ }
329
+ | _ => throwUnmergeable(filter)
330
+ }
331
+ )
332
+ [
333
+ Dict.fromArray([
334
+ (fieldName, dict{"_in": values->(Utils.magic: array<unknown> => unknown)}),
335
+ ])->unsafeFromDict,
375
336
  ]
376
- | Gt(_) | Lt(_) | And(_) => filters
337
+ | _ => filters
377
338
  }
378
339
  }
379
340
 
380
- // A field missing on the entity reads as `undefined`, which matches the `None`
381
- // arm of `FieldValue.t` (`option<...>`), so nullable columns omitted on the
382
- // entity object are compared as null rather than crashing.
383
- let rec matches = (filter: t, ~entity: dict<FieldValue.t>) =>
384
- switch filter {
385
- | Eq({fieldName, fieldValue}) =>
386
- entity->Dict.getUnsafe(fieldName)->FieldValue.eq(fieldValue->FieldValue.castFrom)
387
- | Gt({fieldName, fieldValue}) =>
388
- entity->Dict.getUnsafe(fieldName)->FieldValue.gt(fieldValue->FieldValue.castFrom)
389
- | Lt({fieldName, fieldValue}) =>
390
- entity->Dict.getUnsafe(fieldName)->FieldValue.lt(fieldValue->FieldValue.castFrom)
391
- | In({fieldName, fieldValue}) => {
392
- let entityFieldValue = entity->Dict.getUnsafe(fieldName)
393
- fieldValue->Array.some(fieldValue =>
394
- entityFieldValue->FieldValue.eq(fieldValue->FieldValue.castFrom)
341
+ // A predicate specialized to a single filter. The field's comparison is
342
+ // resolved once from the table config, so per-entity matching avoids both the
343
+ // operator dispatch and the polymorphic compare.
344
+ type matcher = Internal.entity => bool
345
+
346
+ // Reads a field off an entity by its API name. Indexed/queryable fields hold
347
+ // raw runtime values, so the result is compared directly.
348
+ @get_index external getField: (Internal.entity, string) => unknown = ""
349
+
350
+ // Compares (entityValue, filterValue) raw runtime values for one field. A
351
+ // nullish entity value (a missing or null column) matches nothing, mirroring
352
+ // SQL NULL semantics and the Postgres-side filter.
353
+ type valueCompare = {
354
+ eq: (unknown, unknown) => bool,
355
+ gt: (unknown, unknown) => bool,
356
+ lt: (unknown, unknown) => bool,
357
+ // Projects a value onto something a Map can key by. Primitives key by
358
+ // themselves; the object-shaped types have to collapse to a primitive or
359
+ // equal values would miss each other on identity.
360
+ key: unknown => unknown,
361
+ }
362
+
363
+ // `>`/`<` on `unknown` would compile to the polymorphic Primitive_object path; the raw
364
+ // operators give native JS comparison for primitive (string/number/bigint)
365
+ // fields. `===` is already physical equality and false for a nullish column,
366
+ // but `<` and `>` coerce a `null` column to 0 (a JS handler can write one for
367
+ // a nullable field), so the ordering operators check for it first.
368
+ let nativeEq = (a: unknown, b: unknown) => a === b
369
+ let nativeGt: (unknown, unknown) => bool = %raw(`(a, b) => a != null && a > b`)
370
+ let nativeLt: (unknown, unknown) => bool = %raw(`(a, b) => a != null && a < b`)
371
+ let identityKey = (v: unknown) => v
372
+ let native = {eq: nativeEq, gt: nativeGt, lt: nativeLt, key: identityKey}
373
+
374
+ // A nullable column holds null where a row has no value, and a comparison that
375
+ // calls a method on it would throw. Matching nothing is what SQL does, and
376
+ // saying so once here keeps every comparison below a plain two-value compare.
377
+ // The filter's own value needs no such guard — parseOrThrow rejects a nullish one.
378
+ let nullSafe = (compare: valueCompare): valueCompare => {
379
+ eq: (a, b) => !(a->nullish) && compare.eq(a, b),
380
+ gt: (a, b) => !(a->nullish) && compare.gt(a, b),
381
+ lt: (a, b) => !(a->nullish) && compare.lt(a, b),
382
+ key: compare.key,
383
+ }
384
+
385
+ let asBigDecimal = (v: unknown) => v->(Utils.magic: unknown => BigDecimal.t)
386
+ let bigDecimal = nullSafe({
387
+ eq: (a, b) => BigDecimal.equals(a->asBigDecimal, b->asBigDecimal),
388
+ gt: (a, b) => BigDecimal.gt(a->asBigDecimal, b->asBigDecimal),
389
+ lt: (a, b) => BigDecimal.lt(a->asBigDecimal, b->asBigDecimal),
390
+ key: v => v->asBigDecimal->BigDecimal.toString->(Utils.magic: string => unknown),
391
+ })
392
+
393
+ let getTime = (v: unknown) => v->(Utils.magic: unknown => Date.t)->Date.getTime
394
+ let date = nullSafe({
395
+ eq: (a, b) => getTime(a) === getTime(b),
396
+ gt: (a, b) => getTime(a) > getTime(b),
397
+ lt: (a, b) => getTime(a) < getTime(b),
398
+ key: v => v->getTime->(Utils.magic: float => unknown),
399
+ })
400
+
401
+ // Json has no meaningful ordering, so reuse the structural compare for every
402
+ // operator. Polymorphic `==` is intentional here.
403
+ let json = nullSafe({
404
+ eq: (a: unknown, b: unknown) => a == b,
405
+ gt: (a, b) => a > b,
406
+ lt: (a, b) => a < b,
407
+ key: v => v->jsonStringify->(Utils.magic: string => unknown),
408
+ })
409
+
410
+ let asBytes = (v: unknown) => v->(Utils.magic: unknown => Uint8Array.t)
411
+ let bytes = nullSafe({
412
+ eq: (a, b) => Utils.Bytes.compare(a->asBytes, b->asBytes) === 0.,
413
+ gt: (a, b) => Utils.Bytes.compare(a->asBytes, b->asBytes) > 0.,
414
+ lt: (a, b) => Utils.Bytes.compare(a->asBytes, b->asBytes) < 0.,
415
+ key: v => v->asBytes->Utils.Bytes.toHex->(Utils.magic: string => unknown),
416
+ })
417
+
418
+ // Postgres orders an enum by the position its members were declared in, not
419
+ // by their names. Equality stays native so an _in keeps the Set fast path.
420
+ let enum_ = (config: Table.enumConfig<Table.enum>): valueCompare => {
421
+ let ranks = Dict.make()
422
+ config.variants->Array.forEachWithIndex((variant, rank) =>
423
+ ranks->Dict.set(variant->(Utils.magic: Table.enum => string), rank)
424
+ )
425
+ let rank = (v: unknown) => ranks->Dict.getUnsafe(v->(Utils.magic: unknown => string))
426
+ {
427
+ eq: nativeEq,
428
+ gt: (a, b) => !(a->nullish) && rank(a) > rank(b),
429
+ lt: (a, b) => !(a->nullish) && rank(a) < rank(b),
430
+ key: identityKey,
431
+ }
432
+ }
433
+
434
+ let scalarCompare = (fieldType: Table.fieldType): valueCompare =>
435
+ switch fieldType {
436
+ | BigDecimal(_) => bigDecimal
437
+ | Date => date
438
+ | Json => json
439
+ | Bytea => bytes
440
+ | Enum({config}) => enum_(config)
441
+ | String
442
+ | Boolean
443
+ | Uint32
444
+ | UInt52
445
+ | SmallInt
446
+ | UInt64
447
+ | Int32
448
+ | ChainId
449
+ | Number
450
+ | BigInt(_)
451
+ | Serial
452
+ | BigSerial => native
453
+ }
454
+
455
+ // Array-valued fields compare element-wise with the element type's comparator:
456
+ // equality is length + pairwise eq, ordering is lexicographic where the first
457
+ // differing element decides and a proper prefix is the smaller array. The key
458
+ // follows the same rule, concatenating each element's own key, so a Date[]
459
+ // keys by epoch millis and a Bytea[] by hex without inspecting a value.
460
+ let arrayCompare = (element: valueCompare): valueCompare => {
461
+ let eq = (a, b) => {
462
+ let a = a->asArray
463
+ let b = b->asArray
464
+ let len = a->Array.length
465
+ len === b->Array.length && {
466
+ let rec go = i =>
467
+ i >= len || (element.eq(a->Array.getUnsafe(i), b->Array.getUnsafe(i)) && go(i + 1))
468
+ go(0)
469
+ }
470
+ }
471
+ let order = (~gt) =>
472
+ (a, b) => {
473
+ let a = a->asArray
474
+ let b = b->asArray
475
+ let la = a->Array.length
476
+ let lb = b->Array.length
477
+ let len = la < lb ? la : lb
478
+ let rec go = i =>
479
+ if i >= len {
480
+ gt ? la > lb : la < lb
481
+ } else {
482
+ let x = a->Array.getUnsafe(i)
483
+ let y = b->Array.getUnsafe(i)
484
+ if element.eq(x, y) {
485
+ go(i + 1)
486
+ } else if gt {
487
+ element.gt(x, y)
488
+ } else {
489
+ element.lt(x, y)
490
+ }
491
+ }
492
+ go(0)
493
+ }
494
+ nullSafe({
495
+ eq,
496
+ gt: order(~gt=true),
497
+ lt: order(~gt=false),
498
+ key: v => {
499
+ let key = ref("")
500
+ v->asArray->Array.forEach(item => key := key.contents ++ encodeValueKey(element.key(item)))
501
+ key.contents->(Utils.magic: string => unknown)
502
+ },
503
+ })
504
+ }
505
+
506
+ // Built once per table: an array field's comparator closes over its element's,
507
+ // so resolving it per lookup would allocate that chain on every getWhere.
508
+ let comparesByField: Table.table => dict<
509
+ valueCompare,
510
+ > = Utils.WeakMap.memoize((table: Table.table) => {
511
+ let compares = Dict.make()
512
+ table.fields->Array.forEach(field =>
513
+ switch field {
514
+ | Field(field) =>
515
+ let element = scalarCompare(field.fieldType)
516
+ compares->Dict.set(
517
+ field->Table.getApiFieldName,
518
+ field.isArray ? arrayCompare(element) : element,
395
519
  )
520
+ | DerivedFrom(_) => ()
396
521
  }
397
- | And({filters: []}) =>
398
- JsError.throwWithMessage(`The "and" filter must contain at least one nested filter.`)
399
- | And({filters}) => filters->Array.every(filter => filter->matches(~entity))
522
+ )
523
+ compares
524
+ })
525
+
526
+ let fieldCompare = (~table: Table.table, fieldName) =>
527
+ switch table->comparesByField->Utils.Dict.dangerouslyGetNonOption(fieldName) {
528
+ | Some(compare) => compare
529
+ | None =>
530
+ JsError.throwWithMessage(
531
+ `Internal error: no comparator for the field "${fieldName}" on the table "${table.tableName}". A filter is parsed against its table before it reaches the in-memory matcher.`,
532
+ )
400
533
  }
401
534
 
402
- // In values are mapped as one array (isArray=true), so they can be
403
- // converted with the table's cached array schema in a single pass.
404
- let rec mapValues = (
405
- filter: t,
406
- ~mapValue: (~fieldName: string, ~fieldValue: unknown, ~isArray: bool) => unknown,
407
- ) =>
408
- switch filter {
409
- | Eq({fieldName, fieldValue}) =>
410
- Eq({fieldName, fieldValue: mapValue(~fieldName, ~fieldValue, ~isArray=false)})
411
- | Gt({fieldName, fieldValue}) =>
412
- Gt({fieldName, fieldValue: mapValue(~fieldName, ~fieldValue, ~isArray=false)})
413
- | Lt({fieldName, fieldValue}) =>
414
- Lt({fieldName, fieldValue: mapValue(~fieldName, ~fieldValue, ~isArray=false)})
415
- | In({fieldName, fieldValue}) =>
416
- In({
417
- fieldName,
418
- fieldValue: mapValue(
419
- ~fieldName,
420
- ~fieldValue=fieldValue->(Utils.magic: array<unknown> => unknown),
421
- ~isArray=true,
422
- )->(Utils.magic: unknown => array<unknown>),
535
+ // Projects a field's values onto Map keys, so an index can be found by value
536
+ // instead of by a serialized filter.
537
+ let makeValueKey = (~table: Table.table, ~fieldName) => (fieldName->fieldCompare(~table)).key
538
+
539
+ // The cache key stands in for structural equality between filters. Values go
540
+ // through the same per-field projection the equality buckets key by, so the
541
+ // two agree on which values are distinct.
542
+ //
543
+ // Each field/operator entry ends with ";", which no field name or value key
544
+ // starts with, so an _in's value list can't run into the next field name: a
545
+ // one-letter key like "t" for true followed by field "b" would otherwise read
546
+ // the same as an empty list followed by field "tb".
547
+ let toString = (filter: t, ~table: Table.table) => {
548
+ let key = ref("")
549
+ filter
550
+ ->entries
551
+ ->Utils.Dict.forEachWithKey((operators, fieldName) => {
552
+ let keyOf = makeValueKey(~table, ~fieldName)
553
+ operators->Utils.Dict.forEachWithKey((fieldValue, operator) => {
554
+ key := key.contents ++ fieldName ++ operator
555
+ if operator === "_in" {
556
+ fieldValue
557
+ ->asArray
558
+ ->Array.forEach(value => key := key.contents ++ encodeValueKey(keyOf(value)))
559
+ } else {
560
+ key := key.contents ++ encodeValueKey(keyOf(fieldValue))
561
+ }
562
+ key := key.contents ++ ";"
563
+ })
564
+ })
565
+ key.contents
566
+ }
567
+
568
+ // Values are replaced by placeholders so calls that differ only in what they
569
+ // filter for batch together.
570
+ let toOperationKey = (filter: t, ~entityName) => {
571
+ let params = ref(0)
572
+ let printed = ref("")
573
+ filter
574
+ ->entries
575
+ ->Utils.Dict.forEachWithKey((operators, fieldName) => {
576
+ let ops = ref("")
577
+ // An _eq on its own is written without the operator, the way the user does.
578
+ let loneEq = ref(false)
579
+ operators->Utils.Dict.forEachWithKey((_, operator) => {
580
+ params := params.contents + 1
581
+ let part = `${operator}: $${params.contents->Int.toString}`
582
+ loneEq := ops.contents === "" && operator === "_eq"
583
+ ops := (ops.contents === "" ? part : ops.contents ++ ", " ++ part)
423
584
  })
424
- | And({filters}) => And({filters: filters->Array.map(filter => filter->mapValues(~mapValue))})
585
+ let part = loneEq.contents
586
+ ? `${fieldName}: $${params.contents->Int.toString}`
587
+ : `${fieldName}: {${ops.contents}}`
588
+ printed := (printed.contents === "" ? part : printed.contents ++ ", " ++ part)
589
+ })
590
+ `${entityName}.getWhere({${printed.contents}})`
591
+ }
592
+
593
+ let makeMatcher = (filter: t, ~table: Table.table): matcher => {
594
+ let checks = []
595
+ filter
596
+ ->entries
597
+ ->Utils.Dict.forEachWithKey((operators, fieldName) => {
598
+ let compare = fieldName->fieldCompare(~table)
599
+ operators->Utils.Dict.forEachWithKey((fieldValue, operator) => {
600
+ let check = switch operator {
601
+ | "_eq" => entity => compare.eq(entity->getField(fieldName), fieldValue)
602
+ | "_gt" => entity => compare.gt(entity->getField(fieldName), fieldValue)
603
+ | "_lt" => entity => compare.lt(entity->getField(fieldName), fieldValue)
604
+ | "_gte" =>
605
+ entity => {
606
+ let entityFieldValue = entity->getField(fieldName)
607
+ compare.eq(entityFieldValue, fieldValue) || compare.gt(entityFieldValue, fieldValue)
608
+ }
609
+ | "_lte" =>
610
+ entity => {
611
+ let entityFieldValue = entity->getField(fieldName)
612
+ compare.eq(entityFieldValue, fieldValue) || compare.lt(entityFieldValue, fieldValue)
613
+ }
614
+ | "_in" =>
615
+ let fieldValues = fieldValue->asArray
616
+ if compare.eq === nativeEq {
617
+ let set = fieldValues->Utils.Set.fromArray
618
+ entity => set->Utils.Set.has(entity->getField(fieldName))
619
+ } else {
620
+ entity => {
621
+ let entityFieldValue = entity->getField(fieldName)
622
+ fieldValues->Array.some(candidate => compare.eq(entityFieldValue, candidate))
623
+ }
624
+ }
625
+ | _ =>
626
+ JsError.throwWithMessage(
627
+ `Invalid operator "${operator}" in a getWhere filter. Valid operators are _eq, _gt, _lt, _gte, _lte, _in.`,
628
+ )
629
+ }
630
+ checks->Array.push(check)->ignore
631
+ })
632
+ })
633
+ switch checks {
634
+ | [check] => check
635
+ | _ => entity => checks->Array.every(check => check(entity))
425
636
  }
637
+ }