envio 3.0.0-rc.0 → 3.0.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.
package/index.d.ts CHANGED
@@ -1167,6 +1167,22 @@ type SvmEcosystem<Config extends IndexerConfigTypes = GlobalConfig> =
1167
1167
  : never
1168
1168
  : never;
1169
1169
 
1170
+ // Surfaced when the indexer has no configured ecosystem — typically because
1171
+ // `envio codegen` hasn't been run yet, so `Global` isn't augmented and
1172
+ // `GlobalConfig` resolves to `{}`. The handler methods are still present so
1173
+ // IDE autocomplete shows them; their rest parameter is typed as a
1174
+ // string-literal hint so any call site fails with an error message that
1175
+ // names codegen as the fix. A rest parameter (rather than a single one)
1176
+ // avoids "Expected N arguments" errors masking the hint.
1177
+ type CodegenRequiredHint =
1178
+ "Run 'envio codegen' to generate handler types from config.yaml. Without codegen, the indexer has no contracts, chains, or events to register handlers for.";
1179
+ type CodegenRequiredFallback = {
1180
+ readonly onEvent: (...hint: CodegenRequiredHint[]) => void;
1181
+ readonly onBlock: (...hint: CodegenRequiredHint[]) => void;
1182
+ readonly onSlot: (...hint: CodegenRequiredHint[]) => void;
1183
+ readonly contractRegister: (...hint: CodegenRequiredHint[]) => void;
1184
+ };
1185
+
1170
1186
  // Single-ecosystem chains live at the root of the indexer object alongside
1171
1187
  // the handler-registration methods. Multi-ecosystem indexers aren't
1172
1188
  // supported by the runtime, so there's no nested `evm` / `fuel` / `svm`
@@ -1178,7 +1194,7 @@ type SingleEcosystemChains<Config extends IndexerConfigTypes = GlobalConfig> =
1178
1194
  ? FuelEcosystem<Config>
1179
1195
  : HasSvm<Config> extends true
1180
1196
  ? SvmEcosystem<Config>
1181
- : {};
1197
+ : CodegenRequiredFallback;
1182
1198
 
1183
1199
  /** Indexer type resolved from config. */
1184
1200
  export type IndexerFromConfig<Config extends IndexerConfigTypes = GlobalConfig> = Prettify<
@@ -1457,7 +1473,7 @@ type SingleEcosystemTestChains<Config extends IndexerConfigTypes = GlobalConfig>
1457
1473
  ? FuelTestEcosystem<Config>
1458
1474
  : HasSvm<Config> extends true
1459
1475
  ? SvmTestEcosystem<Config>
1460
- : {};
1476
+ : CodegenRequiredFallback;
1461
1477
 
1462
1478
  /**
1463
1479
  * Test indexer type resolved from config.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envio",
3
- "version": "3.0.0-rc.0",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "description": "A latency and sync speed optimized, developer friendly blockchain data indexer.",
6
6
  "bin": "./bin.mjs",
@@ -71,10 +71,10 @@
71
71
  "tsx": "4.21.0"
72
72
  },
73
73
  "optionalDependencies": {
74
- "envio-linux-x64": "3.0.0-rc.0",
75
- "envio-linux-x64-musl": "3.0.0-rc.0",
76
- "envio-linux-arm64": "3.0.0-rc.0",
77
- "envio-darwin-x64": "3.0.0-rc.0",
78
- "envio-darwin-arm64": "3.0.0-rc.0"
74
+ "envio-linux-x64": "3.0.0",
75
+ "envio-linux-x64-musl": "3.0.0",
76
+ "envio-linux-arm64": "3.0.0",
77
+ "envio-darwin-x64": "3.0.0",
78
+ "envio-darwin-arm64": "3.0.0"
79
79
  }
80
80
  }
package/src/Config.res CHANGED
@@ -145,11 +145,15 @@ module EnvioAddresses = {
145
145
  external castToInternal: t => Internal.entity = "%identity"
146
146
 
147
147
  let entityConfig = {
148
- name,
148
+ Internal.name,
149
149
  index,
150
150
  schema,
151
151
  rowsSchema,
152
152
  table,
153
+ // Internal address tracking is Postgres-only; the global config is
154
+ // always required to have Postgres enabled (Storage::resolve forbids
155
+ // a Postgres-disabled global), so this is safe regardless of mode.
156
+ storage: {postgres: true, clickhouse: false},
153
157
  }->Internal.fromGenericEntityConfig
154
158
  }
155
159
 
@@ -269,9 +273,17 @@ let propertySchema = S.schema(s =>
269
273
  }
270
274
  )
271
275
 
276
+ let entityStorageSchema = S.schema(s =>
277
+ {
278
+ "postgres": s.matches(S.option(S.bool)),
279
+ "clickhouse": s.matches(S.option(S.bool)),
280
+ }
281
+ )
282
+
272
283
  let entityJsonSchema = S.schema(s =>
273
284
  {
274
285
  "name": s.matches(S.string),
286
+ "storage": s.matches(S.option(entityStorageSchema)),
275
287
  "properties": s.matches(S.array(propertySchema)),
276
288
  "derivedFields": s.matches(S.option(S.array(derivedFieldSchema))),
277
289
  "compositeIndices": s.matches(S.option(S.array(S.array(compositeIndexFieldSchema)))),
@@ -348,6 +360,7 @@ let parseEnumsFromJson = (enumsJson: dict<array<string>>): array<Table.enumConfi
348
360
  let parseEntitiesFromJson = (
349
361
  entitiesJson: array<'entityJson>,
350
362
  ~enumConfigsByName: dict<Table.enumConfig<Table.enum>>,
363
+ ~globalStorage: storage,
351
364
  ): array<Internal.entityConfig> => {
352
365
  entitiesJson->Array.mapWithIndex((entityJson, index) => {
353
366
  let entityName = entityJson["name"]
@@ -416,6 +429,21 @@ let parseEntitiesFromJson = (
416
429
  dict
417
430
  })
418
431
 
432
+ // Resolve per-entity storage against the global config. The CLI
433
+ // validates that an entity never opts into a backend the global
434
+ // config didn't enable, and that at least one backend stays true
435
+ // for an annotated entity — so `getOr(false)` is safe here.
436
+ let storage: Internal.entityStorage = switch entityJson["storage"] {
437
+ | Some(s) => {
438
+ postgres: s["postgres"]->Option.getOr(false),
439
+ clickhouse: s["clickhouse"]->Option.getOr(false),
440
+ }
441
+ | None => {
442
+ postgres: globalStorage.postgres,
443
+ clickhouse: globalStorage.clickhouse,
444
+ }
445
+ }
446
+
419
447
  {
420
448
  Internal.name: entityName,
421
449
  index,
@@ -424,6 +452,7 @@ let parseEntitiesFromJson = (
424
452
  Utils.magic: S.t<array<dict<unknown>>> => S.t<array<Internal.entity>>
425
453
  ),
426
454
  table,
455
+ storage,
427
456
  }->Internal.fromGenericEntityConfig
428
457
  })
429
458
  }
@@ -756,10 +785,15 @@ let fromPublic = (publicConfigJson: JSON.t) => {
756
785
  let enumConfigsByName =
757
786
  allEnums->Array.map(enumConfig => (enumConfig.name, enumConfig))->Dict.fromArray
758
787
 
788
+ let globalStorage: storage = {
789
+ postgres: publicConfig["storage"]["postgres"],
790
+ clickhouse: publicConfig["storage"]["clickhouse"]->Option.getOr(false),
791
+ }
792
+
759
793
  let userEntities =
760
794
  publicConfig["entities"]
761
795
  ->Option.getOr([])
762
- ->parseEntitiesFromJson(~enumConfigsByName)
796
+ ->parseEntitiesFromJson(~enumConfigsByName, ~globalStorage)
763
797
 
764
798
  let allEntities = userEntities->Array.concat([EnvioAddresses.entityConfig])
765
799
 
@@ -791,10 +825,7 @@ let fromPublic = (publicConfigJson: JSON.t) => {
791
825
  contractHandlers,
792
826
  shouldRollbackOnReorg: publicConfig["rollbackOnReorg"]->Option.getOr(true),
793
827
  shouldSaveFullHistory: publicConfig["saveFullHistory"]->Option.getOr(false),
794
- storage: {
795
- postgres: publicConfig["storage"]["postgres"],
796
- clickhouse: publicConfig["storage"]["clickhouse"]->Option.getOr(false),
797
- },
828
+ storage: globalStorage,
798
829
  multichain: publicConfig["multichain"]->Option.getOr(Unordered),
799
830
  chainMap,
800
831
  defaultChain: chains->Array.get(0),
@@ -992,3 +1023,5 @@ let loadWithoutRegistrations = () =>
992
1023
  c
993
1024
  }
994
1025
  }
1026
+
1027
+ let getPgUserEntities = (config: t) => config.userEntities->Array.filter(e => e.storage.postgres)
@@ -51,12 +51,18 @@ let table = Table.mkTable(name, undefined, [
51
51
  Table.mkField("contract_name", "String", S$RescriptSchema.string, undefined, undefined, undefined, undefined, undefined, undefined)
52
52
  ]);
53
53
 
54
+ let entityConfig_storage = {
55
+ postgres: true,
56
+ clickhouse: false
57
+ };
58
+
54
59
  let entityConfig = {
55
60
  name: name,
56
61
  index: -1,
57
62
  schema: schema,
58
63
  rowsSchema: rowsSchema,
59
- table: table
64
+ table: table,
65
+ storage: entityConfig_storage
60
66
  };
61
67
 
62
68
  let EnvioAddresses = {
@@ -168,8 +174,14 @@ let propertySchema = S$RescriptSchema.schema(s => ({
168
174
  scale: s.m(S$RescriptSchema.option(S$RescriptSchema.int))
169
175
  }));
170
176
 
177
+ let entityStorageSchema = S$RescriptSchema.schema(s => ({
178
+ postgres: s.m(S$RescriptSchema.option(S$RescriptSchema.bool)),
179
+ clickhouse: s.m(S$RescriptSchema.option(S$RescriptSchema.bool))
180
+ }));
181
+
171
182
  let entityJsonSchema = S$RescriptSchema.schema(s => ({
172
183
  name: s.m(S$RescriptSchema.string),
184
+ storage: s.m(S$RescriptSchema.option(entityStorageSchema)),
173
185
  properties: s.m(S$RescriptSchema.array(propertySchema)),
174
186
  derivedFields: s.m(S$RescriptSchema.option(S$RescriptSchema.array(derivedFieldSchema))),
175
187
  compositeIndices: s.m(S$RescriptSchema.option(S$RescriptSchema.array(S$RescriptSchema.array(compositeIndexFieldSchema))))
@@ -285,7 +297,7 @@ function parseEnumsFromJson(enumsJson) {
285
297
  return Object.entries(enumsJson).map(param => Table.makeEnumConfig(param[0], param[1]));
286
298
  }
287
299
 
288
- function parseEntitiesFromJson(entitiesJson, enumConfigsByName) {
300
+ function parseEntitiesFromJson(entitiesJson, enumConfigsByName, globalStorage) {
289
301
  return entitiesJson.map((entityJson, index) => {
290
302
  let entityName = entityJson.name;
291
303
  let fields = entityJson.properties.map(prop => {
@@ -308,12 +320,27 @@ function parseEntitiesFromJson(entitiesJson, enumConfigsByName) {
308
320
  });
309
321
  return dict;
310
322
  });
323
+ let s = entityJson.storage;
324
+ let storage;
325
+ if (s !== undefined) {
326
+ let s$1 = Primitive_option.valFromOption(s);
327
+ storage = {
328
+ postgres: Stdlib_Option.getOr(s$1.postgres, false),
329
+ clickhouse: Stdlib_Option.getOr(s$1.clickhouse, false)
330
+ };
331
+ } else {
332
+ storage = {
333
+ postgres: globalStorage.postgres,
334
+ clickhouse: globalStorage.clickhouse
335
+ };
336
+ }
311
337
  return {
312
338
  name: entityName,
313
339
  index: index,
314
340
  schema: schema,
315
341
  rowsSchema: S$RescriptSchema.array(schema),
316
- table: table
342
+ table: table,
343
+ storage: storage
317
344
  };
318
345
  });
319
346
  }
@@ -583,7 +610,13 @@ function fromPublic(publicConfigJson) {
583
610
  enumConfig.name,
584
611
  enumConfig
585
612
  ]));
586
- let userEntities = parseEntitiesFromJson(Stdlib_Option.getOr(publicConfig.entities, []), enumConfigsByName);
613
+ let globalStorage_postgres = publicConfig.storage.postgres;
614
+ let globalStorage_clickhouse = Stdlib_Option.getOr(publicConfig.storage.clickhouse, false);
615
+ let globalStorage = {
616
+ postgres: globalStorage_postgres,
617
+ clickhouse: globalStorage_clickhouse
618
+ };
619
+ let userEntities = parseEntitiesFromJson(Stdlib_Option.getOr(publicConfig.entities, []), enumConfigsByName, globalStorage);
587
620
  let allEntities = userEntities.concat([entityConfig]);
588
621
  let userEntitiesByName = Object.fromEntries(userEntities.map(entityConfig => [
589
622
  entityConfig.name,
@@ -600,10 +633,7 @@ function fromPublic(publicConfigJson) {
600
633
  contractHandlers: contractHandlers,
601
634
  shouldRollbackOnReorg: Stdlib_Option.getOr(publicConfig.rollbackOnReorg, true),
602
635
  shouldSaveFullHistory: Stdlib_Option.getOr(publicConfig.saveFullHistory, false),
603
- storage: {
604
- postgres: publicConfig.storage.postgres,
605
- clickhouse: Stdlib_Option.getOr(publicConfig.storage.clickhouse, false)
606
- },
636
+ storage: globalStorage,
607
637
  multichain: Stdlib_Option.getOr(publicConfig.multichain, "unordered"),
608
638
  chainMap: chainMap,
609
639
  defaultChain: chains[0],
@@ -822,6 +852,10 @@ function loadWithoutRegistrations() {
822
852
  return c$1;
823
853
  }
824
854
 
855
+ function getPgUserEntities(config) {
856
+ return config.userEntities.filter(e => e.storage.postgres);
857
+ }
858
+
825
859
  export {
826
860
  EnvioAddresses,
827
861
  rpcSourceForSchema,
@@ -836,6 +870,7 @@ export {
836
870
  compositeIndexFieldSchema,
837
871
  derivedFieldSchema,
838
872
  propertySchema,
873
+ entityStorageSchema,
839
874
  entityJsonSchema,
840
875
  getFieldTypeAndSchema,
841
876
  parseEnumsFromJson,
@@ -854,5 +889,6 @@ export {
854
889
  diffPaths,
855
890
  throwIfIncompatible,
856
891
  loadWithoutRegistrations,
892
+ getPgUserEntities,
857
893
  }
858
894
  /* schema Not a pure module */
package/src/Env.res CHANGED
@@ -134,11 +134,24 @@ module ClickHouse = {
134
134
  const v = process.env[k];
135
135
  return v === undefined || v === "" ? undefined : v;
136
136
  }`)
137
+ // Empty password is a valid, passwordless ClickHouse user — distinguish
138
+ // "unset" (None) from "set but empty" (Some("")).
139
+ let readAllowEmpty: string => option<string> = %raw(`(k) => process.env[k]`)
137
140
  )
138
141
  let host = () => read("ENVIO_CLICKHOUSE_HOST")
139
142
  let database = () => read("ENVIO_CLICKHOUSE_DATABASE")
140
143
  let username = () => read("ENVIO_CLICKHOUSE_USERNAME")
141
- let password = () => read("ENVIO_CLICKHOUSE_PASSWORD")
144
+ let password = () => readAllowEmpty("ENVIO_CLICKHOUSE_PASSWORD")
145
+ let replicated = () =>
146
+ switch read("ENVIO_CLICKHOUSE_REPLICATED") {
147
+ | None => false
148
+ | Some("true") => true
149
+ | Some(other) =>
150
+ JsError.throwWithMessage(
151
+ `Invalid ENVIO_CLICKHOUSE_REPLICATED value: "${other}". Only "true" is accepted.`,
152
+ )
153
+ }
154
+ let databaseEngine = () => read("ENVIO_CLICKHOUSE_DATABASE_ENGINE")
142
155
  }
143
156
 
144
157
  module Hasura = {
package/src/Env.res.mjs CHANGED
@@ -4,6 +4,7 @@ import * as EnvSafe from "./EnvSafe.res.mjs";
4
4
  import * as Logging from "./Logging.res.mjs";
5
5
  import * as Postgres from "./bindings/Postgres.res.mjs";
6
6
  import * as Belt_Option from "@rescript/runtime/lib/es6/Belt_Option.js";
7
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
8
  import * as HyperSyncClient from "./sources/HyperSyncClient.res.mjs";
8
9
  import * as S$RescriptSchema from "rescript-schema/src/S.res.mjs";
9
10
  import * as HypersyncClient from "@envio-dev/hypersync-client";
@@ -107,6 +108,8 @@ let read = ((k) => {
107
108
  return v === undefined || v === "" ? undefined : v;
108
109
  });
109
110
 
111
+ let readAllowEmpty = ((k) => process.env[k]);
112
+
110
113
  function host$1() {
111
114
  return read("ENVIO_CLICKHOUSE_HOST");
112
115
  }
@@ -120,14 +123,33 @@ function username() {
120
123
  }
121
124
 
122
125
  function password$1() {
123
- return read("ENVIO_CLICKHOUSE_PASSWORD");
126
+ return readAllowEmpty("ENVIO_CLICKHOUSE_PASSWORD");
127
+ }
128
+
129
+ function replicated() {
130
+ let other = read("ENVIO_CLICKHOUSE_REPLICATED");
131
+ if (other !== undefined) {
132
+ if (other === "true") {
133
+ return true;
134
+ } else {
135
+ return Stdlib_JsError.throwWithMessage(`Invalid ENVIO_CLICKHOUSE_REPLICATED value: "` + other + `". Only "true" is accepted.`);
136
+ }
137
+ } else {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ function databaseEngine() {
143
+ return read("ENVIO_CLICKHOUSE_DATABASE_ENGINE");
124
144
  }
125
145
 
126
146
  let ClickHouse = {
127
147
  host: host$1,
128
148
  database: database$1,
129
149
  username: username,
130
- password: password$1
150
+ password: password$1,
151
+ replicated: replicated,
152
+ databaseEngine: databaseEngine
131
153
  };
132
154
 
133
155
  let enabled = EnvSafe.get(envSafe, "ENVIO_HASURA", S$RescriptSchema.bool, undefined, true, undefined, undefined);
package/src/Internal.res CHANGED
@@ -523,12 +523,21 @@ let fuelTransferParamsSchema = S.schema(s => {
523
523
  type multichain = | @as("ordered") Ordered | @as("unordered") Unordered
524
524
 
525
525
  type entity = private {id: string}
526
+
527
+ // Per-entity storage resolved at parse time against the global storage
528
+ // config. Downstream PG/CH consumers just check the matching boolean.
529
+ type entityStorage = {
530
+ postgres: bool,
531
+ clickhouse: bool,
532
+ }
533
+
526
534
  type genericEntityConfig<'entity> = {
527
535
  name: string,
528
536
  index: int,
529
537
  schema: S.t<'entity>,
530
538
  rowsSchema: S.t<array<'entity>>,
531
539
  table: Table.table,
540
+ storage: entityStorage,
532
541
  }
533
542
  type entityConfig = genericEntityConfig<entity>
534
543
  external fromGenericEntityConfig: genericEntityConfig<'entity> => entityConfig = "%identity"
package/src/PgStorage.res CHANGED
@@ -1208,6 +1208,12 @@ let make = (
1208
1208
  ~enums=[],
1209
1209
  ~envioInfo,
1210
1210
  ): Persistence.initialState => {
1211
+ // Per-entity storage routing: PG owns tables only for entities that
1212
+ // opted into Postgres; the sink mirrors only those that opted into
1213
+ // ClickHouse.
1214
+ let pgEntities = entities->Array.filter((e: Internal.entityConfig) => e.storage.postgres)
1215
+ let chEntities = entities->Array.filter((e: Internal.entityConfig) => e.storage.clickhouse)
1216
+
1211
1217
  let schemaTableNames: array<schemaTableName> = await sql->Postgres.unsafe(
1212
1218
  makeSchemaTableNamesQuery(~pgSchema),
1213
1219
  )
@@ -1235,14 +1241,14 @@ let make = (
1235
1241
 
1236
1242
  // Call sink.initialize before executing PG queries
1237
1243
  switch sink {
1238
- | Some(sink) => await sink.initialize(~chainConfigs, ~entities, ~enums)
1244
+ | Some(sink) => await sink.initialize(~chainConfigs, ~entities=chEntities, ~enums)
1239
1245
  | None => ()
1240
1246
  }
1241
1247
 
1242
1248
  let queries = makeInitializeTransaction(
1243
1249
  ~pgSchema,
1244
1250
  ~pgUser,
1245
- ~entities,
1251
+ ~entities=pgEntities,
1246
1252
  ~enums,
1247
1253
  ~chainConfigs,
1248
1254
  ~isEmptyPgSchema=schemaTableNames->Utils.Array.isEmpty,
@@ -1620,12 +1626,25 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1620
1626
  ~updatedEffectsCache,
1621
1627
  ~updatedEntities,
1622
1628
  ) => {
1629
+ let pgUpdates = []
1630
+ let chUpdates = []
1631
+ for i in 0 to updatedEntities->Array.length - 1 {
1632
+ let update = updatedEntities->Array.getUnsafe(i)
1633
+ let {entityConfig}: Persistence.updatedEntity = update
1634
+ if entityConfig.storage.postgres {
1635
+ pgUpdates->Array.push(update)
1636
+ }
1637
+ if entityConfig.storage.clickhouse {
1638
+ chUpdates->Array.push(update)
1639
+ }
1640
+ }
1641
+
1623
1642
  // Initialize sink if configured
1624
1643
  let sinkPromise = switch sink {
1625
1644
  | Some(sink) => {
1626
1645
  let timerRef = Hrtime.makeTimer()
1627
1646
  Some(
1628
- sink.writeBatch(~batch, ~updatedEntities)
1647
+ sink.writeBatch(~batch, ~updatedEntities=chUpdates)
1629
1648
  ->Promise.thenResolve(_ => {
1630
1649
  Prometheus.StorageWrite.increment(
1631
1650
  ~storage=sink.name,
@@ -1652,7 +1671,7 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1652
1671
  ~allEntities,
1653
1672
  ~setEffectCacheOrThrow,
1654
1673
  ~updatedEffectsCache,
1655
- ~updatedEntities,
1674
+ ~updatedEntities=pgUpdates,
1656
1675
  ~sinkPromise,
1657
1676
  )
1658
1677
  Prometheus.StorageWrite.increment(
@@ -1705,6 +1724,7 @@ let makeStorageFromEnv = (
1705
1724
  let host = Env.ClickHouse.host()
1706
1725
  let username = Env.ClickHouse.username()
1707
1726
  let password = Env.ClickHouse.password()
1727
+ let database = Env.ClickHouse.database()
1708
1728
  let missing = []
1709
1729
  let checkEnv = (opt, name) =>
1710
1730
  switch opt {
@@ -1714,6 +1734,7 @@ let makeStorageFromEnv = (
1714
1734
  host->checkEnv("ENVIO_CLICKHOUSE_HOST")
1715
1735
  username->checkEnv("ENVIO_CLICKHOUSE_USERNAME")
1716
1736
  password->checkEnv("ENVIO_CLICKHOUSE_PASSWORD")
1737
+ database->checkEnv("ENVIO_CLICKHOUSE_DATABASE")
1717
1738
  if missing->Array.length > 0 {
1718
1739
  JsError.throwWithMessage(
1719
1740
  `ClickHouse storage is enabled but required env vars are not set: ${missing->Array.joinUnsafe(
@@ -1724,7 +1745,7 @@ let makeStorageFromEnv = (
1724
1745
  Some(
1725
1746
  Sink.makeClickHouse(
1726
1747
  ~host=host->Option.getUnsafe,
1727
- ~database=Env.ClickHouse.database(),
1748
+ ~database=database->Option.getUnsafe,
1728
1749
  ~username=username->Option.getUnsafe,
1729
1750
  ~password=password->Option.getUnsafe,
1730
1751
  ),
@@ -1744,7 +1765,7 @@ let makeStorageFromEnv = (
1744
1765
  secret: Env.Hasura.secret,
1745
1766
  },
1746
1767
  ~pgSchema,
1747
- ~userEntities=config.userEntities,
1768
+ ~userEntities=config->Config.getPgUserEntities,
1748
1769
  ~responseLimit=Env.Hasura.responseLimit,
1749
1770
  ~schema=Schema.make(config.allEntities->Belt.Array.map(e => e.table)),
1750
1771
  ~aggregateEntities=Env.Hasura.aggregateEntities,
@@ -794,14 +794,16 @@ function make(sql, pgHost, pgSchema, pgPort, pgUser, pgDatabase, pgPassword, isH
794
794
  let chainConfigs = chainConfigsOpt !== undefined ? chainConfigsOpt : [];
795
795
  let entities = entitiesOpt !== undefined ? entitiesOpt : [];
796
796
  let enums = enumsOpt !== undefined ? enumsOpt : [];
797
+ let pgEntities = entities.filter(e => e.storage.postgres);
798
+ let chEntities = entities.filter(e => e.storage.clickhouse);
797
799
  let schemaTableNames = await sql.unsafe(makeSchemaTableNamesQuery(pgSchema));
798
800
  if (Utils.$$Array.notEmpty(schemaTableNames) && !schemaTableNames.some(table => table.table_name === InternalTable.Chains.table.tableName ? true : table.table_name === "event_sync_state")) {
799
801
  Stdlib_JsError.throwWithMessage(`Cannot run Envio migrations on PostgreSQL schema "` + pgSchema + `" because it contains non-Envio tables. Running migrations would delete all data in this schema.\n\nTo resolve this:\n1. If you want to use this schema, first backup any important data, then drop it with: "pnpm envio local db-migrate down"\n2. Or specify a different schema name by setting the "ENVIO_PG_SCHEMA" environment variable\n3. Or manually drop the schema in your database if you're certain the data is not needed.`);
800
802
  }
801
803
  if (sink !== undefined) {
802
- await sink.initialize(chainConfigs, entities, enums);
804
+ await sink.initialize(chainConfigs, chEntities, enums);
803
805
  }
804
- let queries = makeInitializeTransaction(pgSchema, pgUser, isHasuraEnabled, chainConfigs, entities, enums, Utils.$$Array.isEmpty(schemaTableNames));
806
+ let queries = makeInitializeTransaction(pgSchema, pgUser, isHasuraEnabled, chainConfigs, pgEntities, enums, Utils.$$Array.isEmpty(schemaTableNames));
805
807
  await sql.begin(async sql => {
806
808
  await Promise.all(queries.map(query => sql.unsafe(query)));
807
809
  return await InternalTable.EnvioInfo.write(sql, pgSchema, envioInfo);
@@ -1019,17 +1021,29 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1019
1021
  sql.unsafe(makeGetRollbackRestoredEntitiesQuery(entityConfig, pgSchema), [rollbackTargetCheckpointId.toString()], {prepare: true})
1020
1022
  ]);
1021
1023
  let writeBatchMethod = async (batch, rawEvents, rollbackTargetCheckpointId, isInReorgThreshold, config, allEntities, updatedEffectsCache, updatedEntities) => {
1024
+ let pgUpdates = [];
1025
+ let chUpdates = [];
1026
+ for (let i = 0, i_finish = updatedEntities.length; i < i_finish; ++i) {
1027
+ let update = updatedEntities[i];
1028
+ let entityConfig = update.entityConfig;
1029
+ if (entityConfig.storage.postgres) {
1030
+ pgUpdates.push(update);
1031
+ }
1032
+ if (entityConfig.storage.clickhouse) {
1033
+ chUpdates.push(update);
1034
+ }
1035
+ }
1022
1036
  let sinkPromise;
1023
1037
  if (sink !== undefined) {
1024
1038
  let timerRef = Hrtime.makeTimer();
1025
- sinkPromise = sink.writeBatch(batch, updatedEntities).then(() => {
1039
+ sinkPromise = sink.writeBatch(batch, chUpdates).then(() => {
1026
1040
  Prometheus.StorageWrite.increment(sink.name, Hrtime.toSecondsFloat(Hrtime.timeSince(timerRef)));
1027
1041
  }).catch(exn => exn);
1028
1042
  } else {
1029
1043
  sinkPromise = undefined;
1030
1044
  }
1031
1045
  let primaryTimerRef = Hrtime.makeTimer();
1032
- await writeBatch(sql, batch, rawEvents, pgSchema, rollbackTargetCheckpointId, isInReorgThreshold, config, allEntities, setEffectCacheOrThrow, updatedEffectsCache, updatedEntities, sinkPromise, undefined);
1046
+ await writeBatch(sql, batch, rawEvents, pgSchema, rollbackTargetCheckpointId, isInReorgThreshold, config, allEntities, setEffectCacheOrThrow, updatedEffectsCache, pgUpdates, sinkPromise, undefined);
1033
1047
  return Prometheus.StorageWrite.increment("postgres", Hrtime.toSecondsFloat(Hrtime.timeSince(primaryTimerRef)));
1034
1048
  };
1035
1049
  let close = () => sql.end();
@@ -1062,6 +1076,7 @@ function makeStorageFromEnv(config, sqlOpt, pgSchemaOpt, isHasuraEnabledOpt) {
1062
1076
  let host = Env.ClickHouse.host();
1063
1077
  let username = Env.ClickHouse.username();
1064
1078
  let password = Env.ClickHouse.password();
1079
+ let database = Env.ClickHouse.database();
1065
1080
  let missing = [];
1066
1081
  let checkEnv = (opt, name) => {
1067
1082
  if (opt !== undefined) {
@@ -1074,17 +1089,18 @@ function makeStorageFromEnv(config, sqlOpt, pgSchemaOpt, isHasuraEnabledOpt) {
1074
1089
  checkEnv(host, "ENVIO_CLICKHOUSE_HOST");
1075
1090
  checkEnv(username, "ENVIO_CLICKHOUSE_USERNAME");
1076
1091
  checkEnv(password, "ENVIO_CLICKHOUSE_PASSWORD");
1092
+ checkEnv(database, "ENVIO_CLICKHOUSE_DATABASE");
1077
1093
  if (missing.length !== 0) {
1078
1094
  Stdlib_JsError.throwWithMessage(`ClickHouse storage is enabled but required env vars are not set: ` + missing.join(", ") + `. Please set them, disable clickhouse in the \`storage\` config, or run \`envio dev\` for a pre-configured local ClickHouse.`);
1079
1095
  }
1080
- tmp = Sink.makeClickHouse(host, Env.ClickHouse.database(), username, password);
1096
+ tmp = Sink.makeClickHouse(host, database, username, password);
1081
1097
  } else {
1082
1098
  tmp = undefined;
1083
1099
  }
1084
1100
  return make(sql, Env.Db.host, pgSchema, Env.Db.port, Env.Db.user, Env.Db.database, Env.Db.password, isHasuraEnabled, tmp, isHasuraEnabled ? () => Stdlib_Promise.$$catch(Hasura.trackDatabase(Env.Hasura.graphqlEndpoint, {
1085
1101
  role: Env.Hasura.role,
1086
1102
  secret: Env.Hasura.secret
1087
- }, pgSchema, config.userEntities, Env.Hasura.aggregateEntities, Env.Hasura.responseLimit, Schema.make(Belt_Array.map(config.allEntities, e => e.table))), err => Promise.resolve(Logging.errorWithExn(Utils.prettifyExn(err), `Error tracking tables`))) : undefined, isHasuraEnabled ? tableNames => Stdlib_Promise.$$catch(Hasura.trackTables(Env.Hasura.graphqlEndpoint, {
1103
+ }, pgSchema, Config.getPgUserEntities(config), Env.Hasura.aggregateEntities, Env.Hasura.responseLimit, Schema.make(Belt_Array.map(config.allEntities, e => e.table))), err => Promise.resolve(Logging.errorWithExn(Utils.prettifyExn(err), `Error tracking tables`))) : undefined, isHasuraEnabled ? tableNames => Stdlib_Promise.$$catch(Hasura.trackTables(Env.Hasura.graphqlEndpoint, {
1088
1104
  role: Env.Hasura.role,
1089
1105
  secret: Env.Hasura.secret
1090
1106
  }, pgSchema, tableNames), err => Promise.resolve(Logging.errorWithExn(Utils.prettifyExn(err), `Error tracking new tables`))) : undefined);
package/src/Sink.res CHANGED
@@ -19,13 +19,8 @@ let makeClickHouse = (~host, ~database, ~username, ~password): t => {
19
19
  password,
20
20
  })
21
21
 
22
- // Don't assign it to client immediately,
23
- // since it will fail if the database doesn't exist
24
- // Call USE database instead
25
- let database = switch database {
26
- | Some(database) => database
27
- | None => "envio_sink"
28
- }
22
+ // Don't pass database to the client; it would fail if the database doesn't
23
+ // exist yet. Each query qualifies the name explicitly or runs USE first.
29
24
 
30
25
  let cache = Utils.WeakMap.make()
31
26
 
package/src/Sink.res.mjs CHANGED
@@ -10,7 +10,6 @@ function makeClickHouse(host, database, username, password) {
10
10
  username: username,
11
11
  password: password
12
12
  });
13
- let database$1 = database !== undefined ? database : "envio_sink";
14
13
  let cache = new WeakMap();
15
14
  return {
16
15
  name: "clickhouse",
@@ -18,12 +17,12 @@ function makeClickHouse(host, database, username, password) {
18
17
  $staropt$star !== undefined;
19
18
  let entities = $staropt$star$1 !== undefined ? $staropt$star$1 : [];
20
19
  let enums = $staropt$star$2 !== undefined ? $staropt$star$2 : [];
21
- return ClickHouse.initialize(client, database$1, entities, enums);
20
+ return ClickHouse.initialize(client, database, entities, enums);
22
21
  },
23
- resume: checkpointId => ClickHouse.resume(client, database$1, checkpointId),
22
+ resume: checkpointId => ClickHouse.resume(client, database, checkpointId),
24
23
  writeBatch: async (batch, updatedEntities) => {
25
- await Promise.all(Belt_Array.map(updatedEntities, param => ClickHouse.setUpdatesOrThrow(client, cache, param.updates, param.entityConfig, database$1)));
26
- return await ClickHouse.setCheckpointsOrThrow(client, batch, database$1);
24
+ await Promise.all(Belt_Array.map(updatedEntities, param => ClickHouse.setUpdatesOrThrow(client, cache, param.updates, param.entityConfig, database)));
25
+ return await ClickHouse.setCheckpointsOrThrow(client, batch, database);
27
26
  }
28
27
  };
29
28
  }
@@ -75,7 +75,7 @@ let handleLoadByField = (
75
75
  let results = []
76
76
 
77
77
  // Get the field schema from the entity's table to properly parse the JSON field value
78
- let fieldSchema = switch entityConfig.table->Table.getFieldByName(fieldName) {
78
+ let fieldSchema = switch entityConfig.table->Table.getFieldByDbName(fieldName) {
79
79
  | Some(Table.Field({fieldSchema})) => fieldSchema
80
80
  | _ => JsError.throwWithMessage(`Field ${fieldName} not found in entity ${tableName}`)
81
81
  }
@@ -51,7 +51,7 @@ function handleLoadByField(state, tableName, fieldName, fieldValue, operator) {
51
51
  let entityDict = Stdlib_Option.getOr(state.entities[tableName], {});
52
52
  let entityConfig = state.entityConfigs[tableName];
53
53
  let results = [];
54
- let match = Table.getFieldByName(entityConfig.table, fieldName);
54
+ let match = Table.getFieldByDbName(entityConfig.table, fieldName);
55
55
  let fieldSchema;
56
56
  let exit = 0;
57
57
  if (match !== undefined && match.TAG === "Field") {
@@ -203,10 +203,20 @@ let getWhereHandler = (params: entityContextParams, filter: dict<dict<unknown>>)
203
203
  let noopSet = (_entity: Internal.entity) => ()
204
204
  let noopDeleteUnsafe = (_entityId: string) => ()
205
205
 
206
+ // Reads against ClickHouse-only entities have no Postgres table to hit;
207
+ // surface a friendly error instead of letting the SQL layer fail with
208
+ // "relation does not exist".
209
+ let throwClickHouseReadOnly = (entityConfig: Internal.entityConfig, op: string) =>
210
+ JsError.throwWithMessage(
211
+ `context.${entityConfig.name}.${op}() is unavailable: ClickHouse storage is currently write-only. Follow Envio releases to be notified when ClickHouse supports both reads and writes from handlers.`,
212
+ )
213
+
206
214
  let entityTraps: Utils.Proxy.traps<entityContextParams> = {
207
215
  get: (~target as params, ~prop: unknown) => {
208
216
  let prop = prop->(Utils.magic: unknown => string)
209
217
 
218
+ let isClickHouseOnly = !params.entityConfig.storage.postgres
219
+
210
220
  let set = params.isPreload
211
221
  ? noopSet
212
222
  : (entity: Internal.entity) => {
@@ -224,67 +234,92 @@ let entityTraps: Utils.Proxy.traps<entityContextParams> = {
224
234
 
225
235
  switch prop {
226
236
  | "get" =>
227
- (
228
- entityId =>
229
- LoadLayer.loadById(
230
- ~loadManager=params.loadManager,
231
- ~persistence=params.persistence,
232
- ~entityConfig=params.entityConfig,
233
- ~inMemoryStore=params.inMemoryStore,
234
- ~shouldGroup=params.isPreload,
235
- ~item=params.item,
236
- ~entityId,
237
- )
238
- )->(Utils.magic: (string => promise<option<Internal.entity>>) => unknown)
237
+ if isClickHouseOnly {
238
+ ((_entityId: string) => throwClickHouseReadOnly(params.entityConfig, "get"))->(
239
+ Utils.magic: (string => promise<option<Internal.entity>>) => unknown
240
+ )
241
+ } else {
242
+ (
243
+ entityId =>
244
+ LoadLayer.loadById(
245
+ ~loadManager=params.loadManager,
246
+ ~persistence=params.persistence,
247
+ ~entityConfig=params.entityConfig,
248
+ ~inMemoryStore=params.inMemoryStore,
249
+ ~shouldGroup=params.isPreload,
250
+ ~item=params.item,
251
+ ~entityId,
252
+ )
253
+ )->(Utils.magic: (string => promise<option<Internal.entity>>) => unknown)
254
+ }
239
255
  | "getWhere" =>
240
- (filter => getWhereHandler(params, filter->(Utils.magic: unknown => dict<dict<unknown>>)))->(
241
- Utils.magic: (unknown => promise<array<Internal.entity>>) => unknown
242
- )
256
+ if isClickHouseOnly {
257
+ ((_filter: unknown) => throwClickHouseReadOnly(params.entityConfig, "getWhere"))->(
258
+ Utils.magic: (unknown => promise<array<Internal.entity>>) => unknown
259
+ )
260
+ } else {
261
+ (
262
+ filter => getWhereHandler(params, filter->(Utils.magic: unknown => dict<dict<unknown>>))
263
+ )->(Utils.magic: (unknown => promise<array<Internal.entity>>) => unknown)
264
+ }
243
265
 
244
266
  | "getOrThrow" =>
245
- (
246
- (entityId, ~message=?) =>
247
- LoadLayer.loadById(
248
- ~loadManager=params.loadManager,
249
- ~persistence=params.persistence,
250
- ~entityConfig=params.entityConfig,
251
- ~inMemoryStore=params.inMemoryStore,
252
- ~shouldGroup=params.isPreload,
253
- ~item=params.item,
254
- ~entityId,
255
- )->Promise.thenResolve(entity => {
256
- switch entity {
257
- | Some(entity) => entity
258
- | None =>
259
- JsError.throwWithMessage(
260
- message->Belt.Option.getWithDefault(
261
- `Entity '${params.entityConfig.name}' with ID '${entityId}' is expected to exist.`,
262
- ),
263
- )
264
- }
265
- })
266
- )->(Utils.magic: ((string, ~message: string=?) => promise<Internal.entity>) => unknown)
267
+ if isClickHouseOnly {
268
+ (
269
+ (_entityId: string, ~message as _=?) =>
270
+ throwClickHouseReadOnly(params.entityConfig, "getOrThrow")
271
+ )->(Utils.magic: ((string, ~message: string=?) => promise<Internal.entity>) => unknown)
272
+ } else {
273
+ (
274
+ (entityId, ~message=?) =>
275
+ LoadLayer.loadById(
276
+ ~loadManager=params.loadManager,
277
+ ~persistence=params.persistence,
278
+ ~entityConfig=params.entityConfig,
279
+ ~inMemoryStore=params.inMemoryStore,
280
+ ~shouldGroup=params.isPreload,
281
+ ~item=params.item,
282
+ ~entityId,
283
+ )->Promise.thenResolve(entity => {
284
+ switch entity {
285
+ | Some(entity) => entity
286
+ | None =>
287
+ JsError.throwWithMessage(
288
+ message->Belt.Option.getWithDefault(
289
+ `Entity '${params.entityConfig.name}' with ID '${entityId}' is expected to exist.`,
290
+ ),
291
+ )
292
+ }
293
+ })
294
+ )->(Utils.magic: ((string, ~message: string=?) => promise<Internal.entity>) => unknown)
295
+ }
267
296
  | "getOrCreate" =>
268
- (
269
- (entity: Internal.entity) =>
270
- LoadLayer.loadById(
271
- ~loadManager=params.loadManager,
272
- ~persistence=params.persistence,
273
- ~entityConfig=params.entityConfig,
274
- ~inMemoryStore=params.inMemoryStore,
275
- ~shouldGroup=params.isPreload,
276
- ~item=params.item,
277
- ~entityId=entity.id,
278
- )->Promise.thenResolve(storageEntity => {
279
- switch storageEntity {
280
- | Some(entity) => entity
281
- | None => {
282
- set(entity)
283
- entity
297
+ if isClickHouseOnly {
298
+ (
299
+ (_entity: Internal.entity) => throwClickHouseReadOnly(params.entityConfig, "getOrCreate")
300
+ )->(Utils.magic: (Internal.entity => promise<Internal.entity>) => unknown)
301
+ } else {
302
+ (
303
+ (entity: Internal.entity) =>
304
+ LoadLayer.loadById(
305
+ ~loadManager=params.loadManager,
306
+ ~persistence=params.persistence,
307
+ ~entityConfig=params.entityConfig,
308
+ ~inMemoryStore=params.inMemoryStore,
309
+ ~shouldGroup=params.isPreload,
310
+ ~item=params.item,
311
+ ~entityId=entity.id,
312
+ )->Promise.thenResolve(storageEntity => {
313
+ switch storageEntity {
314
+ | Some(entity) => entity
315
+ | None => {
316
+ set(entity)
317
+ entity
318
+ }
284
319
  }
285
- }
286
- })
287
- )->(Utils.magic: (Internal.entity => promise<Internal.entity>) => unknown)
320
+ })
321
+ )->(Utils.magic: (Internal.entity => promise<Internal.entity>) => unknown)
322
+ }
288
323
  | "set" => set->(Utils.magic: (Internal.entity => unit) => unknown)
289
324
  | "deleteUnsafe" =>
290
325
  if params.isPreload {
@@ -119,7 +119,12 @@ function noopDeleteUnsafe(_entityId) {
119
119
 
120
120
  }
121
121
 
122
+ function throwClickHouseReadOnly(entityConfig, op) {
123
+ return Stdlib_JsError.throwWithMessage(`context.` + entityConfig.name + `.` + op + `() is unavailable: ClickHouse storage is currently write-only. Follow Envio releases to be notified when ClickHouse supports both reads and writes from handlers.`);
124
+ }
125
+
122
126
  let entityTraps_get = (params, prop) => {
127
+ let isClickHouseOnly = !params.entityConfig.storage.postgres;
123
128
  let set = params.isPreload ? noopSet : entity => InMemoryTable.Entity.set(InMemoryStore.getInMemTable(params.inMemoryStore, params.entityConfig), {
124
129
  type: "SET",
125
130
  entityId: entity.id,
@@ -138,26 +143,42 @@ let entityTraps_get = (params, prop) => {
138
143
  }, params.shouldSaveHistory, undefined);
139
144
  }
140
145
  case "get" :
141
- return entityId => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, params.inMemoryStore, params.isPreload, params.item, entityId);
146
+ if (isClickHouseOnly) {
147
+ return _entityId => throwClickHouseReadOnly(params.entityConfig, "get");
148
+ } else {
149
+ return entityId => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, params.inMemoryStore, params.isPreload, params.item, entityId);
150
+ }
142
151
  case "getOrCreate" :
143
- return entity => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, params.inMemoryStore, params.isPreload, params.item, entity.id).then(storageEntity => {
144
- if (storageEntity !== undefined) {
145
- return storageEntity;
146
- } else {
147
- set(entity);
148
- return entity;
149
- }
150
- });
152
+ if (isClickHouseOnly) {
153
+ return _entity => throwClickHouseReadOnly(params.entityConfig, "getOrCreate");
154
+ } else {
155
+ return entity => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, params.inMemoryStore, params.isPreload, params.item, entity.id).then(storageEntity => {
156
+ if (storageEntity !== undefined) {
157
+ return storageEntity;
158
+ } else {
159
+ set(entity);
160
+ return entity;
161
+ }
162
+ });
163
+ }
151
164
  case "getOrThrow" :
152
- return (entityId, message) => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, params.inMemoryStore, params.isPreload, params.item, entityId).then(entity => {
153
- if (entity !== undefined) {
154
- return entity;
155
- } else {
156
- return Stdlib_JsError.throwWithMessage(Belt_Option.getWithDefault(message, `Entity '` + params.entityConfig.name + `' with ID '` + entityId + `' is expected to exist.`));
157
- }
158
- });
165
+ if (isClickHouseOnly) {
166
+ return (_entityId, param) => throwClickHouseReadOnly(params.entityConfig, "getOrThrow");
167
+ } else {
168
+ return (entityId, message) => LoadLayer.loadById(params.loadManager, params.persistence, params.entityConfig, params.inMemoryStore, params.isPreload, params.item, entityId).then(entity => {
169
+ if (entity !== undefined) {
170
+ return entity;
171
+ } else {
172
+ return Stdlib_JsError.throwWithMessage(Belt_Option.getWithDefault(message, `Entity '` + params.entityConfig.name + `' with ID '` + entityId + `' is expected to exist.`));
173
+ }
174
+ });
175
+ }
159
176
  case "getWhere" :
160
- return filter => getWhereHandler(params, filter);
177
+ if (isClickHouseOnly) {
178
+ return _filter => throwClickHouseReadOnly(params.entityConfig, "getWhere");
179
+ } else {
180
+ return filter => getWhereHandler(params, filter);
181
+ }
161
182
  case "set" :
162
183
  return set;
163
184
  default:
@@ -284,6 +305,7 @@ export {
284
305
  getWhereHandler,
285
306
  noopSet,
286
307
  noopDeleteUnsafe,
308
+ throwClickHouseReadOnly,
287
309
  entityTraps,
288
310
  handlerTraps,
289
311
  getHandlerContext,
@@ -299,7 +299,12 @@ let setUpdatesOrThrow = async (
299
299
  }
300
300
 
301
301
  // Generate CREATE TABLE query for entity history table
302
- let makeCreateHistoryTableQuery = (~entityConfig: Internal.entityConfig, ~database: string) => {
302
+ let makeCreateHistoryTableQuery = (
303
+ ~entityConfig: Internal.entityConfig,
304
+ ~database: string,
305
+ ~replicated: bool=false,
306
+ ) => {
307
+ let tableEngine = replicated ? "ReplicatedMergeTree" : "MergeTree()"
303
308
  let fieldDefinitions = entityConfig.table.fields->Belt.Array.keepMap(field => {
304
309
  switch field {
305
310
  | Field(field) =>
@@ -332,12 +337,13 @@ let makeCreateHistoryTableQuery = (~entityConfig: Internal.entityConfig, ~databa
332
337
  ~isArray=false,
333
338
  )}
334
339
  )
335
- ENGINE = MergeTree()
340
+ ENGINE = ${tableEngine}
336
341
  ORDER BY (${Table.idFieldName}, ${EntityHistory.checkpointIdFieldName})`
337
342
  }
338
343
 
339
344
  // Generate CREATE TABLE query for checkpoints
340
- let makeCreateCheckpointsTableQuery = (~database: string) => {
345
+ let makeCreateCheckpointsTableQuery = (~database: string, ~replicated: bool=false) => {
346
+ let tableEngine = replicated ? "ReplicatedMergeTree" : "MergeTree()"
341
347
  let idField = (#id: InternalTable.Checkpoints.field :> string)
342
348
  let chainIdField = (#chain_id: InternalTable.Checkpoints.field :> string)
343
349
  let blockNumberField = (#block_number: InternalTable.Checkpoints.field :> string)
@@ -367,7 +373,7 @@ let makeCreateCheckpointsTableQuery = (~database: string) => {
367
373
  ~isArray=false,
368
374
  )}
369
375
  )
370
- ENGINE = MergeTree()
376
+ ENGINE = ${tableEngine}
371
377
  ORDER BY (${idField})`
372
378
  }
373
379
 
@@ -414,16 +420,43 @@ let initialize = async (
414
420
  ~enums as _: array<Table.enumConfig<Table.enum>>,
415
421
  ) => {
416
422
  try {
423
+ let replicated = Env.ClickHouse.replicated()
424
+ let databaseEngine = Env.ClickHouse.databaseEngine()
425
+ let databaseEngineClause = switch databaseEngine {
426
+ | Some(engine) => ` ENGINE = ${engine}`
427
+ | None => ""
428
+ }
429
+
430
+ switch databaseEngine {
431
+ | Some(engineSpec) => {
432
+ let expectedEngineName = engineSpec->String.split("(")->Belt.Array.getUnsafe(0)->String.trim
433
+ let existingResult = await client->query({
434
+ query: `SELECT engine FROM system.databases WHERE name = '${database}'`,
435
+ })
436
+ let rows: array<{"engine": string}> = await existingResult->json
437
+ switch rows->Belt.Array.get(0) {
438
+ | Some(row) if row["engine"] !== expectedEngineName =>
439
+ JsError.throwWithMessage(
440
+ `ClickHouse database "${database}" exists with engine "${row["engine"]}" but ENVIO_CLICKHOUSE_DATABASE_ENGINE specifies "${expectedEngineName}". Drop the database manually to change its engine.`,
441
+ )
442
+ | _ => ()
443
+ }
444
+ }
445
+ | None => ()
446
+ }
447
+
417
448
  await client->exec({query: `TRUNCATE DATABASE IF EXISTS ${database}`})
418
- await client->exec({query: `CREATE DATABASE IF NOT EXISTS ${database}`})
449
+ await client->exec({
450
+ query: `CREATE DATABASE IF NOT EXISTS ${database}${databaseEngineClause}`,
451
+ })
419
452
  await client->exec({query: `USE ${database}`})
420
453
 
421
454
  await Promise.all(
422
455
  entities->Belt.Array.map(entityConfig =>
423
- client->exec({query: makeCreateHistoryTableQuery(~entityConfig, ~database)})
456
+ client->exec({query: makeCreateHistoryTableQuery(~entityConfig, ~database, ~replicated)})
424
457
  ),
425
458
  )->Utils.Promise.ignoreValue
426
- await client->exec({query: makeCreateCheckpointsTableQuery(~database)})
459
+ await client->exec({query: makeCreateCheckpointsTableQuery(~database, ~replicated)})
427
460
 
428
461
  await Promise.all(
429
462
  entities->Belt.Array.map(entityConfig =>
@@ -431,10 +464,10 @@ let initialize = async (
431
464
  ),
432
465
  )->Utils.Promise.ignoreValue
433
466
 
434
- Logging.trace("ClickHouse sink initialization completed successfully")
467
+ Logging.trace("ClickHouse storage initialization completed successfully")
435
468
  } catch {
436
469
  | exn => {
437
- Logging.errorWithExn(exn, "Failed to initialize ClickHouse sink")
470
+ Logging.errorWithExn(exn, "Failed to initialize ClickHouse storage")
438
471
  JsError.throwWithMessage("ClickHouse initialization failed")
439
472
  }
440
473
  }
@@ -450,7 +483,7 @@ let resume = async (client, ~database: string, ~checkpointId: Internal.checkpoin
450
483
  | exn =>
451
484
  Logging.errorWithExn(
452
485
  exn,
453
- `ClickHouse sink database "${database}" not found. Please run 'envio start -r' to reinitialize the indexer (it'll also drop Postgres database).`,
486
+ `ClickHouse storage database "${database}" not found. Please run 'envio start -r' to reinitialize the indexer (it'll also drop Postgres database).`,
454
487
  )
455
488
  JsError.throwWithMessage("ClickHouse resume failed")
456
489
  }
@@ -478,7 +511,7 @@ let resume = async (client, ~database: string, ~checkpointId: Internal.checkpoin
478
511
  } catch {
479
512
  | Persistence.StorageError(_) as exn => throw(exn)
480
513
  | exn => {
481
- Logging.errorWithExn(exn, "Failed to resume ClickHouse sink")
514
+ Logging.errorWithExn(exn, "Failed to resume ClickHouse storage")
482
515
  JsError.throwWithMessage("ClickHouse resume failed")
483
516
  }
484
517
  }
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Env from "../Env.res.mjs";
3
4
  import * as Table from "../db/Table.res.mjs";
4
5
  import * as Utils from "../Utils.res.mjs";
5
6
  import * as Logging from "../Logging.res.mjs";
@@ -8,6 +9,7 @@ import * as Persistence from "../Persistence.res.mjs";
8
9
  import * as EntityHistory from "../db/EntityHistory.res.mjs";
9
10
  import * as InternalTable from "../db/InternalTable.res.mjs";
10
11
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
12
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
11
13
  import * as S$RescriptSchema from "rescript-schema/src/S.res.mjs";
12
14
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
13
15
 
@@ -234,7 +236,9 @@ async function setUpdatesOrThrow(client, cache, updates, entityConfig, database)
234
236
  }
235
237
  }
236
238
 
237
- function makeCreateHistoryTableQuery(entityConfig, database) {
239
+ function makeCreateHistoryTableQuery(entityConfig, database, replicatedOpt) {
240
+ let replicated = replicatedOpt !== undefined ? replicatedOpt : false;
241
+ let tableEngine = replicated ? "ReplicatedMergeTree" : "MergeTree()";
238
242
  let fieldDefinitions = Belt_Array.keepMap(entityConfig.table.fields, field => {
239
243
  if (field.TAG !== "Field") {
240
244
  return;
@@ -252,11 +256,13 @@ function makeCreateHistoryTableQuery(entityConfig, database) {
252
256
  config: EntityHistory.RowAction.config
253
257
  }, false, false) + `
254
258
  )
255
- ENGINE = MergeTree()
259
+ ENGINE = ` + tableEngine + `
256
260
  ORDER BY (` + Table.idFieldName + `, ` + EntityHistory.checkpointIdFieldName + `)`;
257
261
  }
258
262
 
259
- function makeCreateCheckpointsTableQuery(database) {
263
+ function makeCreateCheckpointsTableQuery(database, replicatedOpt) {
264
+ let replicated = replicatedOpt !== undefined ? replicatedOpt : false;
265
+ let tableEngine = replicated ? "ReplicatedMergeTree" : "MergeTree()";
260
266
  return `CREATE TABLE IF NOT EXISTS ` + database + `.\`` + InternalTable.Checkpoints.table.tableName + `\` (
261
267
  \`` + "id" + `\` ` + getClickHouseFieldType("UInt64", false, false) + `,
262
268
  \`` + "chain_id" + `\` ` + getClickHouseFieldType("Int32", false, false) + `,
@@ -264,7 +270,7 @@ function makeCreateCheckpointsTableQuery(database) {
264
270
  \`` + "block_hash" + `\` ` + getClickHouseFieldType("String", true, false) + `,
265
271
  \`` + "events_processed" + `\` ` + getClickHouseFieldType("UInt64", false, false) + `
266
272
  )
267
- ENGINE = MergeTree()
273
+ ENGINE = ` + tableEngine + `
268
274
  ORDER BY (` + "id" + `)`;
269
275
  }
270
276
 
@@ -292,28 +298,45 @@ WHERE \`` + EntityHistory.changeFieldName + `\` = '` + "SET" + `'`;
292
298
 
293
299
  async function initialize(client, database, entities, param) {
294
300
  try {
301
+ let replicated = Env.ClickHouse.replicated();
302
+ let databaseEngine = Env.ClickHouse.databaseEngine();
303
+ let databaseEngineClause = databaseEngine !== undefined ? ` ENGINE = ` + databaseEngine : "";
304
+ if (databaseEngine !== undefined) {
305
+ let expectedEngineName = databaseEngine.split("(")[0].trim();
306
+ let existingResult = await client.query({
307
+ query: `SELECT engine FROM system.databases WHERE name = '` + database + `'`
308
+ });
309
+ let rows = await existingResult.json();
310
+ let row = Belt_Array.get(rows, 0);
311
+ if (row !== undefined) {
312
+ let row$1 = Primitive_option.valFromOption(row);
313
+ if (row$1.engine !== expectedEngineName) {
314
+ Stdlib_JsError.throwWithMessage(`ClickHouse database "` + database + `" exists with engine "` + row$1.engine + `" but ENVIO_CLICKHOUSE_DATABASE_ENGINE specifies "` + expectedEngineName + `". Drop the database manually to change its engine.`);
315
+ }
316
+ }
317
+ }
295
318
  await client.exec({
296
319
  query: `TRUNCATE DATABASE IF EXISTS ` + database
297
320
  });
298
321
  await client.exec({
299
- query: `CREATE DATABASE IF NOT EXISTS ` + database
322
+ query: `CREATE DATABASE IF NOT EXISTS ` + database + databaseEngineClause
300
323
  });
301
324
  await client.exec({
302
325
  query: `USE ` + database
303
326
  });
304
327
  await Promise.all(Belt_Array.map(entities, entityConfig => client.exec({
305
- query: makeCreateHistoryTableQuery(entityConfig, database)
328
+ query: makeCreateHistoryTableQuery(entityConfig, database, replicated)
306
329
  })));
307
330
  await client.exec({
308
- query: makeCreateCheckpointsTableQuery(database)
331
+ query: makeCreateCheckpointsTableQuery(database, replicated)
309
332
  });
310
333
  await Promise.all(Belt_Array.map(entities, entityConfig => client.exec({
311
334
  query: makeCreateViewQuery(entityConfig, database)
312
335
  })));
313
- return Logging.trace("ClickHouse sink initialization completed successfully");
336
+ return Logging.trace("ClickHouse storage initialization completed successfully");
314
337
  } catch (raw_exn) {
315
338
  let exn = Primitive_exceptions.internalToException(raw_exn);
316
- Logging.errorWithExn(exn, "Failed to initialize ClickHouse sink");
339
+ Logging.errorWithExn(exn, "Failed to initialize ClickHouse storage");
317
340
  return Stdlib_JsError.throwWithMessage("ClickHouse initialization failed");
318
341
  }
319
342
  }
@@ -326,7 +349,7 @@ async function resume(client, database, checkpointId) {
326
349
  });
327
350
  } catch (raw_exn) {
328
351
  let exn = Primitive_exceptions.internalToException(raw_exn);
329
- Logging.errorWithExn(exn, `ClickHouse sink database "` + database + `" not found. Please run 'envio start -r' to reinitialize the indexer (it'll also drop Postgres database).`);
352
+ Logging.errorWithExn(exn, `ClickHouse storage database "` + database + `" not found. Please run 'envio start -r' to reinitialize the indexer (it'll also drop Postgres database).`);
330
353
  Stdlib_JsError.throwWithMessage("ClickHouse resume failed");
331
354
  }
332
355
  let tablesResult = await client.query({
@@ -347,7 +370,7 @@ async function resume(client, database, checkpointId) {
347
370
  if (exn$1.RE_EXN_ID === Persistence.StorageError) {
348
371
  throw exn$1;
349
372
  }
350
- Logging.errorWithExn(exn$1, "Failed to resume ClickHouse sink");
373
+ Logging.errorWithExn(exn$1, "Failed to resume ClickHouse storage");
351
374
  return Stdlib_JsError.throwWithMessage("ClickHouse resume failed");
352
375
  }
353
376
  }
package/src/tui/Tui.res CHANGED
@@ -78,15 +78,52 @@ module ChainLine = {
78
78
  }
79
79
  }
80
80
 
81
+ module EventsPerSecond = {
82
+ type sample = {time: float, events: float}
83
+
84
+ let windowMs = 60_000.
85
+
86
+ let computeEps = (samples: array<sample>) => {
87
+ let len = samples->Array.length
88
+ switch (samples->Array.get(0), samples->Array.get(len - 1)) {
89
+ | (Some(first), Some(last)) if last.time > first.time =>
90
+ Some((last.events -. first.events) /. ((last.time -. first.time) /. 1000.))
91
+ | _ => None
92
+ }
93
+ }
94
+
95
+ let use = (~totalEventsProcessed: float) => {
96
+ let (samples, setSamples) = React.useState((): array<sample> => [])
97
+
98
+ React.useEffect1(() => {
99
+ let now = Date.now()
100
+ let cutoff = now -. windowMs
101
+ setSamples(prev => {
102
+ let kept = prev->Array.filter(s => s.time >= cutoff)
103
+ kept->Array.concat([{time: now, events: totalEventsProcessed}])
104
+ })
105
+ None
106
+ }, [totalEventsProcessed])
107
+
108
+ computeEps(samples)
109
+ }
110
+ }
111
+
81
112
  module TotalEventsProcessed = {
82
113
  @react.component
83
- let make = (~totalEventsProcessed) => {
84
- let label = "Total Events: "
114
+ let make = (~totalEventsProcessed, ~eventsPerSecond: option<float>) => {
85
115
  <Text>
86
- <Text bold=true> {label->React.string} </Text>
116
+ <Text bold=true> {"Total Events: "->React.string} </Text>
87
117
  <Text color={Secondary}>
88
118
  {`${totalEventsProcessed->TuiData.formatFloatLocaleString}`->React.string}
89
119
  </Text>
120
+ {switch eventsPerSecond {
121
+ | Some(eps) =>
122
+ <Text color={Gray}>
123
+ {` (${Math.round(eps)->TuiData.formatFloatLocaleString} events/sec)`->React.string}
124
+ </Text>
125
+ | None => React.null
126
+ }}
90
127
  </Text>
91
128
  }
92
129
  }
@@ -188,6 +225,7 @@ module App = {
188
225
  acc
189
226
  }
190
227
  })
228
+ let eventsPerSecond = EventsPerSecond.use(~totalEventsProcessed)
191
229
 
192
230
  <Box flexDirection={Column}>
193
231
  <BigText
@@ -214,7 +252,10 @@ module App = {
214
252
  />
215
253
  })
216
254
  ->React.array}
217
- <TotalEventsProcessed totalEventsProcessed />
255
+ <TotalEventsProcessed
256
+ totalEventsProcessed
257
+ eventsPerSecond={SyncETA.isIndexerFullySynced(chains) ? None : eventsPerSecond}
258
+ />
218
259
  <SyncETA chains indexerStartTime=state.indexerStartTime />
219
260
  <Newline />
220
261
  <Box flexDirection={Row}>
@@ -136,7 +136,40 @@ let ChainLine = {
136
136
  make: Tui$ChainLine
137
137
  };
138
138
 
139
+ function computeEps(samples) {
140
+ let len = samples.length;
141
+ let match = samples[0];
142
+ let match$1 = samples[len - 1 | 0];
143
+ if (match !== undefined && match$1 !== undefined && match$1.time > match.time) {
144
+ return (match$1.events - match.events) / ((match$1.time - match.time) / 1000);
145
+ }
146
+ }
147
+
148
+ function use(totalEventsProcessed) {
149
+ let match = React.useState(() => []);
150
+ let setSamples = match[1];
151
+ React.useEffect(() => {
152
+ let now = Date.now();
153
+ let cutoff = now - 60000;
154
+ setSamples(prev => {
155
+ let kept = prev.filter(s => s.time >= cutoff);
156
+ return kept.concat([{
157
+ time: now,
158
+ events: totalEventsProcessed
159
+ }]);
160
+ });
161
+ }, [totalEventsProcessed]);
162
+ return computeEps(match[0]);
163
+ }
164
+
165
+ let EventsPerSecond = {
166
+ windowMs: 60000,
167
+ computeEps: computeEps,
168
+ use: use
169
+ };
170
+
139
171
  function Tui$TotalEventsProcessed(props) {
172
+ let eventsPerSecond = props.eventsPerSecond;
140
173
  return JsxRuntime.jsxs(Ink$1.Text, {
141
174
  children: [
142
175
  JsxRuntime.jsx(Ink$1.Text, {
@@ -146,7 +179,11 @@ function Tui$TotalEventsProcessed(props) {
146
179
  JsxRuntime.jsx(Ink$1.Text, {
147
180
  children: TuiData.formatFloatLocaleString(props.totalEventsProcessed),
148
181
  color: "#FFBB2F"
149
- })
182
+ }),
183
+ eventsPerSecond !== undefined ? JsxRuntime.jsx(Ink$1.Text, {
184
+ children: ` (` + TuiData.formatFloatLocaleString(Math.round(eventsPerSecond)) + ` events/sec)`,
185
+ color: "gray"
186
+ }) : null
150
187
  ]
151
188
  });
152
189
  }
@@ -229,6 +266,7 @@ function Tui$App(props) {
229
266
  return acc;
230
267
  }
231
268
  });
269
+ let eventsPerSecond = use(totalEventsProcessed);
232
270
  let defaultPassword = "testing";
233
271
  let match$1 = state.ctx.config.storage.clickhouse;
234
272
  let match$2 = Env.ClickHouse.host();
@@ -257,7 +295,8 @@ function Tui$App(props) {
257
295
  eventsProcessed: chainData.eventsProcessed
258
296
  }, i.toString())),
259
297
  JsxRuntime.jsx(Tui$TotalEventsProcessed, {
260
- totalEventsProcessed: totalEventsProcessed
298
+ totalEventsProcessed: totalEventsProcessed,
299
+ eventsPerSecond: SyncETA.isIndexerFullySynced(chains) ? undefined : eventsPerSecond
261
300
  }),
262
301
  JsxRuntime.jsx(SyncETA.make, {
263
302
  chains: chains,
@@ -331,6 +370,7 @@ function start(getState) {
331
370
 
332
371
  export {
333
372
  ChainLine,
373
+ EventsPerSecond,
334
374
  TotalEventsProcessed,
335
375
  App,
336
376
  start,