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
package/src/PgStorage.res CHANGED
@@ -1,4 +1,4 @@
1
- let makeClient = () => {
1
+ let makeClient = (~maxConnections=Env.Db.maxConnections) => {
2
2
  Postgres.makeSql(
3
3
  ~config={
4
4
  host: Env.Db.host,
@@ -14,7 +14,7 @@ let makeClient = () => {
14
14
  : Some(_str => ())
15
15
  ),
16
16
  transform: {undefined: Null},
17
- max: Env.Db.maxConnections,
17
+ max: maxConnections,
18
18
  // debug: (~connection, ~query, ~params as _, ~types as _) => Js.log2(connection, query),
19
19
  },
20
20
  )
@@ -409,9 +409,10 @@ let makeLoadQuery = (~pgSchema, ~tableName, ~condition) => {
409
409
  // Field names are spliced as quoted identifiers only after the queryFields
410
410
  // lookup proves they exist on the table (and they originate from
411
411
  // codegen-validated schemas), so the interpolation can't be abused.
412
- let rec makeFilterCondition = (
412
+ let makeFilterCondition = (
413
413
  ~filter: EntityFilter.t,
414
414
  ~table: Table.table,
415
+ ~pgSchema,
415
416
  ~params: array<unknown>,
416
417
  ) => {
417
418
  // Filters reference fields by API name, while the SQL references columns
@@ -447,60 +448,89 @@ let rec makeFilterCondition = (
447
448
  params->Array.push(param)->ignore
448
449
  `$${params->Array.length->Int.toString}`
449
450
  }
450
- let scalarCondition = (~fieldName, ~fieldValue, ~op) => {
451
+
452
+ let condition = ref("")
453
+ filter
454
+ ->EntityFilter.entries
455
+ ->Utils.Dict.forEachWithKey((operators, fieldName) => {
451
456
  let queryField = getQueryFieldOrThrow(fieldName)
452
- `"${queryField.pgDbFieldName}" ${op} ${serializeParamOrThrow(
453
- ~queryField,
454
- ~fieldName,
455
- ~fieldValue,
456
- ~isArray=false,
457
- )}`
458
- }
459
- switch filter {
460
- // A per-chain entity's table is partitioned by its chain-id column, and
461
- // Postgres can only prune a plan it caches when that column is a constant in
462
- // the SQL. Bound, the cached plan has to keep every partition, and the
463
- // planner ends up throwing it away and re-planning on every execution
464
- // instead — measured at 315us per load against 218us with the id written in,
465
- // on 30 chains.
466
- //
467
- // The cost is that each chain gets its own query text, so Postgres caches a
468
- // prepared statement per (entity, chain, filter shape) rather than per
469
- // (entity, filter shape). Measured at ~8KB of plan cache each, which is ~10MB
470
- // per connection for 40 entities across 30 chains — accepted, since the
471
- // alternative is a cached plan that can't prune.
472
- //
473
- // `LoadLayer.scopeFilter` is what puts this filter here, and the value is
474
- // range-checked to a non-negative safe integer, so it can carry nothing but
475
- // digits.
476
- | Eq({fieldName, fieldValue}) if getQueryFieldOrThrow(fieldName).isChainId =>
477
- `"${getQueryFieldOrThrow(fieldName).pgDbFieldName}" = ${fieldValue
478
- ->ChainId.normalizeOrThrow
479
- ->ChainId.toString}`
480
- | Eq({fieldName, fieldValue}) => scalarCondition(~fieldName, ~fieldValue, ~op="=")
481
- | Gt({fieldName, fieldValue}) => scalarCondition(~fieldName, ~fieldValue, ~op=">")
482
- | Lt({fieldName, fieldValue}) => scalarCondition(~fieldName, ~fieldValue, ~op="<")
483
- | In({fieldName, fieldValue}) => {
484
- let queryField = getQueryFieldOrThrow(fieldName)
485
- `"${queryField.pgDbFieldName}" = ANY(${serializeParamOrThrow(
486
- ~queryField,
487
- ~fieldName,
488
- ~fieldValue=fieldValue->(Utils.magic: array<unknown> => unknown),
489
- ~isArray=true,
490
- )})`
491
- }
492
- | And({filters: []}) =>
493
- throw(
494
- Persistence.StorageError({
495
- message: `Failed loading "${table.tableName}" from storage. The "and" filter must contain at least one nested filter.`,
496
- reason: Utils.Error.make(`Empty "and" filter`),
497
- }),
498
- )
499
- | And({filters}) =>
500
- `(${filters
501
- ->Array.map(filter => makeFilterCondition(~filter, ~table, ~params))
502
- ->Array.join(" AND ")})`
503
- }
457
+ operators->Utils.Dict.forEachWithKey((fieldValue, operator) => {
458
+ let column = `"${queryField.pgDbFieldName}"`
459
+ let part = switch operator {
460
+ // A per-chain entity's table is partitioned by its chain-id column, and
461
+ // Postgres can only prune a plan it caches when that column is a constant
462
+ // in the SQL. Bound, the cached plan has to keep every partition, and the
463
+ // planner ends up throwing it away and re-planning on every execution
464
+ // instead — measured at 315us per load against 218us with the id written
465
+ // in, on 30 chains.
466
+ //
467
+ // The cost is that each chain gets its own query text, so Postgres caches
468
+ // a prepared statement per (entity, chain, filter shape) rather than per
469
+ // (entity, filter shape). Measured at ~8KB of plan cache each, which is
470
+ // ~10MB per connection for 40 entities across 30 chains — accepted, since
471
+ // the alternative is a cached plan that can't prune.
472
+ //
473
+ // `EntityFilter.scoped` is what puts this filter here, and the value is
474
+ // range-checked to a non-negative safe integer, so it can carry nothing
475
+ // but digits.
476
+ | "_eq" if queryField.isChainId =>
477
+ `${column} = ${fieldValue->ChainId.normalizeOrThrow->ChainId.toString}`
478
+ // Postgres arrays are rectangular, so candidates for a list column can't
479
+ // be bound as one array unless they all have the same length, and
480
+ // postgres.js can't bind a boolean array at all
481
+ // (https://github.com/porsager/postgres/issues/471). One equality per
482
+ // candidate has neither problem.
483
+ | "_in" if queryField.isArray || queryField.fieldType === Boolean =>
484
+ switch fieldValue->EntityFilter.asArray {
485
+ | [] => "FALSE"
486
+ | candidates =>
487
+ `(${candidates
488
+ ->Array.map(
489
+ candidate =>
490
+ `${column} = ${serializeParamOrThrow(
491
+ ~queryField,
492
+ ~fieldName,
493
+ ~fieldValue=candidate,
494
+ ~isArray=false,
495
+ )}`,
496
+ )
497
+ ->Array.join(" OR ")})`
498
+ }
499
+ | "_in" =>
500
+ let param = serializeParamOrThrow(~queryField, ~fieldName, ~fieldValue, ~isArray=true)
501
+ switch queryField.fieldType {
502
+ // A bound array of strings is text[], which has no equality with an
503
+ // enum. The insert casts the same way.
504
+ | Enum({config}) => `${column} = ANY(${param}::TEXT[]::"${pgSchema}".${config.name}[])`
505
+ | _ => `${column} = ANY(${param})`
506
+ }
507
+ | _ =>
508
+ let sqlOperator = switch operator {
509
+ | "_eq" => "="
510
+ | "_gt" => ">"
511
+ | "_lt" => "<"
512
+ | "_gte" => ">="
513
+ | "_lte" => "<="
514
+ | _ =>
515
+ throw(
516
+ Persistence.StorageError({
517
+ message: `Failed loading "${table.tableName}" from storage. Unknown filter operator "${operator}".`,
518
+ reason: Utils.Error.make(`Unknown filter operator "${operator}"`),
519
+ }),
520
+ )
521
+ }
522
+ `${column} ${sqlOperator} ${serializeParamOrThrow(
523
+ ~queryField,
524
+ ~fieldName,
525
+ ~fieldValue,
526
+ ~isArray=false,
527
+ )}`
528
+ }
529
+ condition := (condition.contents === "" ? part : condition.contents ++ " AND " ++ part)
530
+ })
531
+ })
532
+
533
+ condition.contents
504
534
  }
505
535
 
506
536
  // The chain-id predicate a per-chain entity's row-level SQL needs, already
@@ -1709,7 +1739,7 @@ let make = (
1709
1739
  // Must match PG_CONTAINER in packages/cli/src/docker_env.rs
1710
1740
  let containerName = "envio-postgres"
1711
1741
  let psqlExecOptions: NodeJs.ChildProcess.execOptions = {
1712
- env: Dict.fromArray([("PGPASSWORD", pgPassword), ("PATH", %raw(`process.env.PATH`))]),
1742
+ env: dict{"PGPASSWORD": pgPassword, "PATH": %raw(`process.env.PATH`)},
1713
1743
  }
1714
1744
 
1715
1745
  let cacheDirPath = NodeJs.Path.resolve([
@@ -2002,7 +2032,7 @@ let make = (
2002
2032
 
2003
2033
  let loadOrThrow = async (~filter: EntityFilter.t, ~table: Table.table) => {
2004
2034
  let params = []
2005
- let condition = makeFilterCondition(~filter, ~table, ~params)
2035
+ let condition = makeFilterCondition(~filter, ~table, ~pgSchema, ~params)
2006
2036
  switch await sql->Postgres.preparedUnsafe(
2007
2037
  makeLoadQuery(~pgSchema, ~tableName=table.tableName, ~condition),
2008
2038
  params->Obj.magic,
@@ -2033,9 +2063,10 @@ let make = (
2033
2063
  let queryFields = table->Table.queryFields
2034
2064
  let columns = []
2035
2065
  let seen = Utils.Set.make()
2036
- let rec collect = (filter: EntityFilter.t) =>
2037
- switch filter {
2038
- | Eq({fieldName}) | Gt({fieldName}) | Lt({fieldName}) | In({fieldName}) =>
2066
+ filters->Array.forEach(filter =>
2067
+ filter
2068
+ ->EntityFilter.entries
2069
+ ->Utils.Dict.forEachWithKey((_, fieldName) =>
2039
2070
  switch queryFields->Utils.Dict.dangerouslyGetNonOption(fieldName) {
2040
2071
  | Some({pgDbFieldName}) =>
2041
2072
  if !(seen->Utils.Set.has(pgDbFieldName)) {
@@ -2044,9 +2075,8 @@ let make = (
2044
2075
  }
2045
2076
  | None => ()
2046
2077
  }
2047
- | And({filters}) => filters->Array.forEach(collect)
2048
- }
2049
- filters->Array.forEach(collect)
2078
+ )
2079
+ )
2050
2080
  columns
2051
2081
  }
2052
2082
 
@@ -2234,13 +2264,17 @@ let make = (
2234
2264
  }
2235
2265
 
2236
2266
  switch missing {
2237
- | [] =>
2267
+ // A schema that declares no indexes has nothing to say about them, and one
2268
+ // whose indexes are all in place says it once. Either way the line that
2269
+ // matters is the indexer reporting itself ready, which finalization logs.
2270
+ | [] if schemaIndexes->Utils.Array.notEmpty =>
2238
2271
  Logging.info({
2239
2272
  "storage": storageName,
2240
2273
  "msg": `All ${schemaIndexes
2241
2274
  ->Array.length
2242
2275
  ->Int.toString} schema indexes are already in place. Marking the indexer ready.`,
2243
2276
  })
2277
+ | [] => ()
2244
2278
  | _ =>
2245
2279
  Logging.info({
2246
2280
  "storage": storageName,
@@ -2288,12 +2322,16 @@ let make = (
2288
2322
  }
2289
2323
  })
2290
2324
 
2291
- Logging.info({
2292
- "storage": storageName,
2293
- "msg": `Committed ${missing
2294
- ->Array.length
2295
- ->Int.toString} schema indexes and the ready timestamp in ${timeRef->formatSeconds}s.`,
2296
- })
2325
+ // Only when something was built: the wait this closes is the index build,
2326
+ // and the stamp on its own is not one anybody waited through.
2327
+ if missing->Utils.Array.notEmpty {
2328
+ Logging.info({
2329
+ "storage": storageName,
2330
+ "msg": `Committed ${missing
2331
+ ->Array.length
2332
+ ->Int.toString} schema indexes and the ready timestamp in ${timeRef->formatSeconds}s.`,
2333
+ })
2334
+ }
2297
2335
  }
2298
2336
 
2299
2337
  let setOrThrow = (
@@ -19,6 +19,7 @@ import * as ChainState from "./ChainState.res.mjs";
19
19
  import * as AddressRows from "./AddressRows.res.mjs";
20
20
  import * as Performance from "./bindings/Performance.res.mjs";
21
21
  import * as Persistence from "./Persistence.res.mjs";
22
+ import * as EntityFilter from "./db/EntityFilter.res.mjs";
22
23
  import * as IndexCatalog from "./db/IndexCatalog.res.mjs";
23
24
  import * as IndexManager from "./db/IndexManager.res.mjs";
24
25
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
@@ -37,7 +38,8 @@ import * as S$RescriptSchema from "rescript-schema/src/S.res.mjs";
37
38
  import * as CheckpointSequence from "./db/CheckpointSequence.res.mjs";
38
39
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
39
40
 
40
- function makeClient() {
41
+ function makeClient(maxConnectionsOpt) {
42
+ let maxConnections = maxConnectionsOpt !== undefined ? maxConnectionsOpt : Env.Db.maxConnections;
41
43
  return Postgres({
42
44
  host: Env.Db.host,
43
45
  port: Env.Db.port,
@@ -45,7 +47,7 @@ function makeClient() {
45
47
  username: Env.Db.user,
46
48
  password: Env.Db.password,
47
49
  ssl: Env.Db.ssl,
48
- max: Env.Db.maxConnections,
50
+ max: maxConnections,
49
51
  onnotice: Primitive_object.equal(Env.userLogLevel, "warn") || Primitive_object.equal(Env.userLogLevel, "error") ? undefined : _str => {},
50
52
  transform: {
51
53
  undefined: null
@@ -301,7 +303,7 @@ function makeLoadQuery(pgSchema, tableName, condition) {
301
303
  return `SELECT * FROM "` + pgSchema + `"."` + tableName + `" WHERE ` + condition + `;`;
302
304
  }
303
305
 
304
- function makeFilterCondition(filter, table, params) {
306
+ function makeFilterCondition(filter, table, pgSchema, params) {
305
307
  let getQueryFieldOrThrow = fieldName => {
306
308
  let queryField = Table.queryFields(table)[fieldName];
307
309
  if (queryField !== undefined) {
@@ -333,39 +335,76 @@ function makeFilterCondition(filter, table, params) {
333
335
  params.push(param);
334
336
  return `$` + params.length.toString();
335
337
  };
336
- let scalarCondition = (fieldName, fieldValue, op) => {
337
- let queryField = getQueryFieldOrThrow(fieldName);
338
- return `"` + queryField.pgDbFieldName + `" ` + op + ` ` + serializeParamOrThrow(queryField, fieldName, fieldValue, false);
338
+ let condition = {
339
+ contents: ""
339
340
  };
340
- switch (filter.operator) {
341
- case "=" :
342
- let fieldValue = filter.fieldValue;
343
- let fieldName = filter.fieldName;
344
- if (getQueryFieldOrThrow(fieldName).isChainId) {
345
- return `"` + getQueryFieldOrThrow(fieldName).pgDbFieldName + `" = ` + ChainId.toString(ChainId.normalizeOrThrow(fieldValue));
346
- } else {
347
- return scalarCondition(fieldName, fieldValue, "=");
341
+ Utils.Dict.forEachWithKey(EntityFilter.entries(filter), (operators, fieldName) => {
342
+ let queryField = getQueryFieldOrThrow(fieldName);
343
+ Utils.Dict.forEachWithKey(operators, (fieldValue, operator) => {
344
+ let column = `"` + queryField.pgDbFieldName + `"`;
345
+ let part;
346
+ let exit = 0;
347
+ switch (operator) {
348
+ case "_eq" :
349
+ if (queryField.isChainId) {
350
+ part = column + ` = ` + ChainId.toString(ChainId.normalizeOrThrow(fieldValue));
351
+ } else {
352
+ exit = 1;
353
+ }
354
+ break;
355
+ case "_in" :
356
+ if (queryField.isArray || queryField.fieldType === "Boolean") {
357
+ let candidates = EntityFilter.asArray(fieldValue);
358
+ part = candidates.length !== 0 ? `(` + candidates.map(candidate => column + ` = ` + serializeParamOrThrow(queryField, fieldName, candidate, false)).join(" OR ") + `)` : "FALSE";
359
+ } else {
360
+ let param = serializeParamOrThrow(queryField, fieldName, fieldValue, true);
361
+ let match = queryField.fieldType;
362
+ let exit$1 = 0;
363
+ if (typeof match !== "object" || match.type !== "Enum") {
364
+ exit$1 = 2;
365
+ } else {
366
+ part = column + ` = ANY(` + param + `::TEXT[]::"` + pgSchema + `".` + match.config.name + `[])`;
367
+ }
368
+ if (exit$1 === 2) {
369
+ part = column + ` = ANY(` + param + `)`;
370
+ }
371
+ }
372
+ break;
373
+ default:
374
+ exit = 1;
348
375
  }
349
- case ">" :
350
- return scalarCondition(filter.fieldName, filter.fieldValue, ">");
351
- case "<" :
352
- return scalarCondition(filter.fieldName, filter.fieldValue, "<");
353
- case "in" :
354
- let fieldName$1 = filter.fieldName;
355
- let queryField = getQueryFieldOrThrow(fieldName$1);
356
- return `"` + queryField.pgDbFieldName + `" = ANY(` + serializeParamOrThrow(queryField, fieldName$1, filter.fieldValue, true) + `)`;
357
- case "and" :
358
- let filters = filter.filters;
359
- if (filters.length !== 0) {
360
- return `(` + filters.map(filter => makeFilterCondition(filter, table, params)).join(" AND ") + `)`;
376
+ if (exit === 1) {
377
+ let sqlOperator;
378
+ switch (operator) {
379
+ case "_eq" :
380
+ sqlOperator = "=";
381
+ break;
382
+ case "_gt" :
383
+ sqlOperator = ">";
384
+ break;
385
+ case "_gte" :
386
+ sqlOperator = ">=";
387
+ break;
388
+ case "_lt" :
389
+ sqlOperator = "<";
390
+ break;
391
+ case "_lte" :
392
+ sqlOperator = "<=";
393
+ break;
394
+ default:
395
+ throw {
396
+ RE_EXN_ID: Persistence.StorageError,
397
+ message: `Failed loading "` + table.tableName + `" from storage. Unknown filter operator "` + operator + `".`,
398
+ reason: new Error(`Unknown filter operator "` + operator + `"`),
399
+ Error: new Error()
400
+ };
401
+ }
402
+ part = column + ` ` + sqlOperator + ` ` + serializeParamOrThrow(queryField, fieldName, fieldValue, false);
361
403
  }
362
- throw {
363
- RE_EXN_ID: Persistence.StorageError,
364
- message: `Failed loading "` + table.tableName + `" from storage. The "and" filter must contain at least one nested filter.`,
365
- reason: new Error(`Empty "and" filter`),
366
- Error: new Error()
367
- };
368
- }
404
+ condition.contents = condition.contents === "" ? part : condition.contents + " AND " + part;
405
+ });
406
+ });
407
+ return condition.contents;
369
408
  }
370
409
 
371
410
  function makeChainIdCondition(table, chainId) {
@@ -1032,16 +1071,10 @@ function make(sql, pgHost, pgSchema, pgPort, pgUser, pgDatabase, pgPassword, isH
1032
1071
  let chainIdMode = chainIdModeOpt !== undefined ? chainIdModeOpt : "int32";
1033
1072
  let isolated = isolatedOpt !== undefined ? isolatedOpt : false;
1034
1073
  let containerName = "envio-postgres";
1035
- let psqlExecOptions_env = Object.fromEntries([
1036
- [
1037
- "PGPASSWORD",
1038
- pgPassword
1039
- ],
1040
- [
1041
- "PATH",
1042
- process.env.PATH
1043
- ]
1044
- ]);
1074
+ let psqlExecOptions_env = {
1075
+ PGPASSWORD: pgPassword,
1076
+ PATH: process.env.PATH
1077
+ };
1045
1078
  let psqlExecOptions = {
1046
1079
  env: psqlExecOptions_env
1047
1080
  };
@@ -1222,7 +1255,7 @@ function make(sql, pgHost, pgSchema, pgPort, pgUser, pgDatabase, pgPassword, isH
1222
1255
  };
1223
1256
  let loadOrThrow = async (filter, table) => {
1224
1257
  let params = [];
1225
- let condition = makeFilterCondition(filter, table, params);
1258
+ let condition = makeFilterCondition(filter, table, pgSchema, params);
1226
1259
  let rows;
1227
1260
  try {
1228
1261
  rows = await sql.unsafe(makeLoadQuery(pgSchema, table.tableName, condition), params, {prepare: true});
@@ -1251,12 +1284,8 @@ function make(sql, pgHost, pgSchema, pgPort, pgUser, pgDatabase, pgPassword, isH
1251
1284
  let queryFields = Table.queryFields(table);
1252
1285
  let columns = [];
1253
1286
  let seen = new Set();
1254
- let collect = filter => {
1255
- if (filter.operator === "and") {
1256
- filter.filters.forEach(collect);
1257
- return;
1258
- }
1259
- let match = queryFields[filter.fieldName];
1287
+ filters.forEach(filter => Utils.Dict.forEachWithKey(EntityFilter.entries(filter), (param, fieldName) => {
1288
+ let match = queryFields[fieldName];
1260
1289
  if (match === undefined) {
1261
1290
  return;
1262
1291
  }
@@ -1266,8 +1295,7 @@ function make(sql, pgHost, pgSchema, pgPort, pgUser, pgDatabase, pgPassword, isH
1266
1295
  columns.push(pgDbFieldName);
1267
1296
  return;
1268
1297
  }
1269
- };
1270
- filters.forEach(collect);
1298
+ }));
1271
1299
  return columns;
1272
1300
  };
1273
1301
  let runAndVerify = async (sql, prepared) => {
@@ -1380,7 +1408,7 @@ function make(sql, pgHost, pgSchema, pgPort, pgUser, pgDatabase, pgPassword, isH
1380
1408
  msg: `Creating the ` + missing.length.toString() + ` remaining schema indexes before the indexer reports ready. Writes are paused until they are committed. ` + slowOnLargeDatabaseNotice,
1381
1409
  indexes: missing.map(prepared => prepared.name)
1382
1410
  });
1383
- } else {
1411
+ } else if (Utils.$$Array.notEmpty(schemaIndexes)) {
1384
1412
  Logging.info({
1385
1413
  storage: storageName,
1386
1414
  msg: `All ` + schemaIndexes.length.toString() + ` schema indexes are already in place. Marking the indexer ready.`
@@ -1411,10 +1439,12 @@ function make(sql, pgHost, pgSchema, pgPort, pgUser, pgDatabase, pgPassword, isH
1411
1439
  ], {prepare: true});
1412
1440
  }
1413
1441
  });
1414
- return Logging.info({
1415
- storage: storageName,
1416
- msg: `Committed ` + missing.length.toString() + ` schema indexes and the ready timestamp in ` + formatSeconds(timeRef) + `s.`
1417
- });
1442
+ if (Utils.$$Array.notEmpty(missing)) {
1443
+ return Logging.info({
1444
+ storage: storageName,
1445
+ msg: `Committed ` + missing.length.toString() + ` schema indexes and the ready timestamp in ` + formatSeconds(timeRef) + `s.`
1446
+ });
1447
+ }
1418
1448
  };
1419
1449
  let setOrThrow$1 = (items, table, itemSchema) => setOrThrow(sql, items, table, itemSchema, pgSchema, setQueryCache, chainIdMode);
1420
1450
  let setEffectCacheOrThrow = async (table, itemSchema, items, initialize) => {
@@ -1636,7 +1666,7 @@ function make(sql, pgHost, pgSchema, pgPort, pgUser, pgDatabase, pgPassword, isH
1636
1666
  }
1637
1667
 
1638
1668
  function makeStorageFromEnv(config, sqlOpt, pgSchemaOpt, isHasuraEnabledOpt) {
1639
- let sql = sqlOpt !== undefined ? Primitive_option.valFromOption(sqlOpt) : makeClient();
1669
+ let sql = sqlOpt !== undefined ? Primitive_option.valFromOption(sqlOpt) : makeClient(undefined);
1640
1670
  let pgSchema = pgSchemaOpt !== undefined ? pgSchemaOpt : Env.Db.publicSchema;
1641
1671
  let isHasuraEnabled = isHasuraEnabledOpt !== undefined ? isHasuraEnabledOpt : Env.Hasura.enabled;
1642
1672
  let tmp;
package/src/Server.res ADDED
@@ -0,0 +1,181 @@
1
+ // The indexer's own HTTP surface: metrics for a scraper, health for an
2
+ // orchestrator, and the console's view of the run. What it serves is handed to
3
+ // it, so one process's readings and a supervised group's merged ones render the
4
+ // same way.
5
+
6
+ // The public console/state chain shape. Kept to exactly this field set for
7
+ // backward compatibility with consumers like RACE — new metric fields stay off
8
+ // the HTTP response.
9
+ type chainData = {
10
+ chainId: ChainId.t,
11
+ poweredByHyperSync: bool,
12
+ firstEventBlockNumber: option<int>,
13
+ latestProcessedBlock: option<int>,
14
+ timestampCaughtUpToHeadOrEndblock: option<Date.t>,
15
+ numEventsProcessed: float,
16
+ latestFetchedBlockNumber: int,
17
+ // Need this for API backwards compatibility
18
+ @as("currentBlockHeight")
19
+ knownHeight: int,
20
+ numBatchesFetched: int,
21
+ startBlock: int,
22
+ endBlock: option<int>,
23
+ numAddresses: int,
24
+ }
25
+ @tag("status")
26
+ type state =
27
+ | @as("disabled") Disabled({})
28
+ | @as("initializing") Initializing({})
29
+ | @as("active")
30
+ Active({
31
+ envioVersion: string,
32
+ chains: array<chainData>,
33
+ indexerStartTime: Date.t,
34
+ isPreRegisteringDynamicContracts: bool,
35
+ rollbackOnReorg: bool,
36
+ })
37
+
38
+ let toChainData = (m: Metrics.chainMetrics): chainData => {
39
+ chainId: m.chainId,
40
+ poweredByHyperSync: m.poweredByHyperSync,
41
+ firstEventBlockNumber: m.firstEventBlockNumber,
42
+ latestProcessedBlock: m.latestProcessedBlock,
43
+ timestampCaughtUpToHeadOrEndblock: m.timestampCaughtUpToHeadOrEndblock,
44
+ numEventsProcessed: m.numEventsProcessed,
45
+ latestFetchedBlockNumber: m.latestFetchedBlockNumber,
46
+ knownHeight: m.knownHeight,
47
+ numBatchesFetched: m.numBatchesFetched,
48
+ startBlock: m.startBlock,
49
+ endBlock: m.endBlock,
50
+ numAddresses: m.numAddresses,
51
+ }
52
+
53
+ let chainDataSchema = S.schema((s): chainData => {
54
+ chainId: s.matches(ChainId.schema),
55
+ poweredByHyperSync: s.matches(S.bool),
56
+ firstEventBlockNumber: s.matches(S.option(S.int)),
57
+ latestProcessedBlock: s.matches(S.option(S.int)),
58
+ timestampCaughtUpToHeadOrEndblock: s.matches(S.option(S.datetime(S.string))),
59
+ numEventsProcessed: s.matches(S.float),
60
+ latestFetchedBlockNumber: s.matches(S.int),
61
+ knownHeight: s.matches(S.int),
62
+ numBatchesFetched: s.matches(S.int),
63
+ startBlock: s.matches(S.int),
64
+ endBlock: s.matches(S.option(S.int)),
65
+ numAddresses: s.matches(S.int),
66
+ })
67
+ let stateSchema = S.union([
68
+ S.literal(Disabled({})),
69
+ S.literal(Initializing({})),
70
+ S.schema(s => Active({
71
+ envioVersion: s.matches(S.string),
72
+ chains: s.matches(S.array(chainDataSchema)),
73
+ indexerStartTime: s.matches(S.datetime(S.string)),
74
+ // Keep the field, since Dev Console expects it to be present
75
+ isPreRegisteringDynamicContracts: false,
76
+ rollbackOnReorg: s.matches(S.bool),
77
+ })),
78
+ ])
79
+
80
+ // Runtime state lives in the process-wide `EnvioGlobal` record (shared
81
+ // across duplicate envio module instances); the slots are opaque there, so
82
+ // cast them to the real types here.
83
+ let startServer = (
84
+ ~getMetrics: unit => option<Metrics.t>,
85
+ ~envioVersion: string,
86
+ ~onSyncCache: unit => promise<unit>,
87
+ ~collectRuntime: unit => string,
88
+ ~isDevelopmentMode: bool,
89
+ ) => {
90
+ open Express
91
+
92
+ let app = make()
93
+
94
+ let consoleCorsMiddleware = (req, res, next) => {
95
+ switch req.headers->Dict.get("origin") {
96
+ | Some(origin) if origin === Env.prodEnvioAppUrl || origin === Env.envioAppUrl =>
97
+ res->setHeader("Access-Control-Allow-Origin", origin)
98
+ | _ => ()
99
+ }
100
+
101
+ res->setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
102
+ res->setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept")
103
+
104
+ if req.method === Rest.Options {
105
+ res->sendStatus(200)
106
+ } else {
107
+ next()
108
+ }
109
+ }
110
+ app->useFor("/console", consoleCorsMiddleware)
111
+ app->useFor("/metrics", consoleCorsMiddleware)
112
+ app->useFor("/metrics/runtime", consoleCorsMiddleware)
113
+
114
+ app->get("/healthz", (_req, res) => {
115
+ // this is the machine readable port used in kubernetes to check the health of this service.
116
+ // aditional health information could be added in the future (info about errors, back-offs, etc).
117
+ res->sendStatus(200)
118
+ })
119
+
120
+ app->get("/console/state", (_req, res) => {
121
+ let state = if !isDevelopmentMode {
122
+ Disabled({})
123
+ } else {
124
+ switch getMetrics() {
125
+ | None => Initializing({})
126
+ | Some(metrics) =>
127
+ Active({
128
+ envioVersion,
129
+ chains: metrics.chains->Array.map(toChainData),
130
+ indexerStartTime: metrics.startTime,
131
+ isPreRegisteringDynamicContracts: false,
132
+ rollbackOnReorg: metrics.rollbackEnabled,
133
+ })
134
+ }
135
+ }
136
+
137
+ res->json(state->S.reverseConvertToJsonOrThrow(stateSchema))
138
+ })
139
+
140
+ app->post("/console/syncCache", (_req, res) => {
141
+ if isDevelopmentMode {
142
+ onSyncCache()
143
+ ->Promise.thenResolve(() => res->json(Boolean(true)))
144
+ // A dump that couldn't be made, or couldn't be confirmed, answers the
145
+ // same `false` a disabled console does. Leaving it unanswered would hold
146
+ // the request open for as long as the indexer runs.
147
+ ->Promise.catch(exn => {
148
+ Logging.errorWithExn(exn, "Failed to sync the effect cache")
149
+ res->json(Boolean(false))
150
+ Promise.resolve()
151
+ })
152
+ ->Promise.ignore
153
+ } else {
154
+ res->json(Boolean(false))
155
+ }
156
+ })
157
+
158
+ app->get("/metrics", (_req, res) => {
159
+ res->set("Content-Type", Metrics.contentType)
160
+ let _ = res->endWithData(Metrics.collect(~metrics=getMetrics()))
161
+ })
162
+
163
+ app->get("/metrics/runtime", (_req, res) => {
164
+ res->set("Content-Type", Metrics.contentType)
165
+ let _ = res->endWithData(collectRuntime())
166
+ })
167
+
168
+ let server = app->listen(Env.serverPort)
169
+ server->Express.onError(err => {
170
+ let code = (err->(Utils.magic: JsExn.t => {..}))["code"]
171
+ if code === "EADDRINUSE" {
172
+ Logging.error(
173
+ `Port ${Env.serverPort->Int.toString} is already in use. To fix this either:` ++
174
+ `\n 1. Kill the process using the port: lsof -ti :${Env.serverPort->Int.toString} | xargs kill -9` ++ `\n 2. Use a different port by setting the ENVIO_INDEXER_PORT environment variable: ENVIO_INDEXER_PORT=9899 envio start`,
175
+ )
176
+ } else {
177
+ Logging.errorWithExn(err, "Failed to start indexer server")
178
+ }
179
+ NodeJs.process->NodeJs.exitWithCode(Failure)
180
+ })
181
+ }