envio 3.5.0-alpha.1 → 3.5.0-alpha.3

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 (99) hide show
  1. package/index.d.ts +10 -6
  2. package/package.json +6 -6
  3. package/src/BatchProcessing.res +27 -0
  4. package/src/BatchProcessing.res.mjs +12 -0
  5. package/src/ChainFetching.res +5 -5
  6. package/src/ChainFetching.res.mjs +3 -4
  7. package/src/ChainState.res +48 -25
  8. package/src/ChainState.res.mjs +23 -29
  9. package/src/ChainState.resi +2 -6
  10. package/src/Change.res +3 -3
  11. package/src/Config.res +2 -6
  12. package/src/Config.res.mjs +2 -12
  13. package/src/Core.res +3 -0
  14. package/src/CrossChainState.res +33 -10
  15. package/src/CrossChainState.res.mjs +21 -6
  16. package/src/CrossChainState.resi +3 -0
  17. package/src/EntityId.res +15 -0
  18. package/src/EntityId.res.mjs +9 -0
  19. package/src/EventConfigBuilder.res +1 -58
  20. package/src/EventConfigBuilder.res.mjs +1 -33
  21. package/src/FetchState.res +510 -460
  22. package/src/FetchState.res.mjs +413 -428
  23. package/src/FinalizeBackfill.res +34 -0
  24. package/src/FinalizeBackfill.res.mjs +26 -0
  25. package/src/HandlerRegister.res +45 -0
  26. package/src/HandlerRegister.res.mjs +43 -0
  27. package/src/InMemoryStore.res +11 -4
  28. package/src/InMemoryTable.res +26 -24
  29. package/src/InMemoryTable.res.mjs +19 -18
  30. package/src/IndexerState.res +71 -2
  31. package/src/IndexerState.res.mjs +67 -3
  32. package/src/IndexerState.resi +5 -0
  33. package/src/Internal.res +17 -8
  34. package/src/LoadLayer.res +9 -1
  35. package/src/LoadLayer.res.mjs +1 -0
  36. package/src/LogSelection.res +2 -1
  37. package/src/Metrics.res +37 -6
  38. package/src/Metrics.res.mjs +5 -1
  39. package/src/Persistence.res +12 -1
  40. package/src/PgStorage.res +411 -57
  41. package/src/PgStorage.res.mjs +262 -41
  42. package/src/SimulateItems.res +2 -2
  43. package/src/TestIndexer.res +9 -5
  44. package/src/TestIndexer.res.mjs +5 -2
  45. package/src/UserContext.res +3 -3
  46. package/src/Writing.res +12 -2
  47. package/src/Writing.res.mjs +13 -2
  48. package/src/bindings/ClickHouse.res +5 -3
  49. package/src/bindings/ClickHouse.res.mjs +2 -5
  50. package/src/bindings/Vitest.res +22 -1
  51. package/src/bindings/Vitest.res.mjs +15 -0
  52. package/src/db/EntityFilter.res +0 -4
  53. package/src/db/EntityFilter.res.mjs +1 -8
  54. package/src/db/EntityHistory.res +16 -7
  55. package/src/db/EntityHistory.res.mjs +7 -6
  56. package/src/db/IndexRegistry.res +219 -0
  57. package/src/db/IndexRegistry.res.mjs +195 -0
  58. package/src/db/InternalTable.res +7 -0
  59. package/src/db/InternalTable.res.mjs +7 -0
  60. package/src/db/Table.res +53 -22
  61. package/src/db/Table.res.mjs +56 -18
  62. package/src/sources/AddressSet.res +48 -0
  63. package/src/sources/AddressSet.res.mjs +11 -0
  64. package/src/sources/AddressStore.res +152 -0
  65. package/src/sources/AddressStore.res.mjs +97 -0
  66. package/src/sources/EvmChain.res +3 -0
  67. package/src/sources/EvmChain.res.mjs +4 -2
  68. package/src/sources/EvmHyperSyncSource.res +6 -3
  69. package/src/sources/EvmHyperSyncSource.res.mjs +3 -3
  70. package/src/sources/EvmRpcClient.res +6 -3
  71. package/src/sources/EvmRpcClient.res.mjs +3 -3
  72. package/src/sources/FuelHyperSync.res +4 -3
  73. package/src/sources/FuelHyperSync.res.mjs +3 -3
  74. package/src/sources/FuelHyperSync.resi +2 -1
  75. package/src/sources/FuelHyperSyncClient.res +12 -6
  76. package/src/sources/FuelHyperSyncClient.res.mjs +2 -2
  77. package/src/sources/FuelHyperSyncSource.res +7 -4
  78. package/src/sources/FuelHyperSyncSource.res.mjs +3 -3
  79. package/src/sources/HyperSync.res +6 -4
  80. package/src/sources/HyperSync.res.mjs +2 -3
  81. package/src/sources/HyperSync.resi +1 -1
  82. package/src/sources/HyperSyncClient.res +18 -9
  83. package/src/sources/HyperSyncClient.res.mjs +4 -4
  84. package/src/sources/RpcSource.res +15 -10
  85. package/src/sources/RpcSource.res.mjs +3 -4
  86. package/src/sources/SimulateSource.res +26 -11
  87. package/src/sources/SimulateSource.res.mjs +13 -5
  88. package/src/sources/Source.res +4 -2
  89. package/src/sources/SourceManager.res +2 -3
  90. package/src/sources/SourceManager.res.mjs +2 -3
  91. package/src/sources/Svm.res +1 -2
  92. package/src/sources/Svm.res.mjs +1 -1
  93. package/src/sources/SvmHyperSyncClient.res +12 -3
  94. package/src/sources/SvmHyperSyncClient.res.mjs +2 -2
  95. package/src/sources/SvmHyperSyncSource.res +10 -5
  96. package/src/sources/SvmHyperSyncSource.res.mjs +5 -4
  97. package/src/IndexingAddresses.res +0 -151
  98. package/src/IndexingAddresses.res.mjs +0 -130
  99. package/src/IndexingAddresses.resi +0 -39
@@ -4,6 +4,7 @@ import * as Env from "./Env.res.mjs";
4
4
  import * as Utils from "./Utils.res.mjs";
5
5
  import * as Throttler from "./Throttler.res.mjs";
6
6
  import * as EffectState from "./EffectState.res.mjs";
7
+ import * as Performance from "./bindings/Performance.res.mjs";
7
8
  import * as IndexerState from "./IndexerState.res.mjs";
8
9
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
9
10
  import * as InMemoryStore from "./InMemoryStore.res.mjs";
@@ -182,7 +183,7 @@ function dropCommitted(state, keepLoadedFromDb) {
182
183
  EffectState.forEach(IndexerState.effectState(state), inMemTable => InMemoryStore.dropCommittedEffects(inMemTable, committedCheckpointId, keepLoadedFromDb));
183
184
  }
184
185
 
185
- async function awaitCapacity(state) {
186
+ async function awaitCapacityLoop(state) {
186
187
  if (!IndexerState.hasFailedWrite(state) && getChangesCount(state) >= Env.inMemoryObjectsTarget) {
187
188
  dropCommitted(state, true);
188
189
  if (getChangesCount(state) >= Env.inMemoryObjectsTarget) {
@@ -191,13 +192,22 @@ async function awaitCapacity(state) {
191
192
  if (getChangesCount(state) >= Env.inMemoryObjectsTarget && Utils.$$Array.notEmpty(IndexerState.processedBatches(state))) {
192
193
  schedule(state);
193
194
  await new Promise((resolve, param) => IndexerState.addCommitWaiter(state, resolve));
194
- return await awaitCapacity(state);
195
+ return await awaitCapacityLoop(state);
195
196
  } else {
196
197
  return;
197
198
  }
198
199
  }
199
200
  }
200
201
 
202
+ async function awaitCapacity(state) {
203
+ if (getChangesCount(state) < Env.inMemoryObjectsTarget) {
204
+ return;
205
+ }
206
+ let timeRef = Performance.now();
207
+ await awaitCapacityLoop(state);
208
+ return IndexerState.recordStalledOnStorageWrite(state, Performance.secondsSince(timeRef));
209
+ }
210
+
201
211
  async function flush(state) {
202
212
  if (IndexerState.hasFailedWrite(state)) {
203
213
  return;
@@ -224,6 +234,7 @@ export {
224
234
  setChainMeta,
225
235
  commitBatch,
226
236
  dropCommitted,
237
+ awaitCapacityLoop,
227
238
  awaitCapacity,
228
239
  flush,
229
240
  }
@@ -91,7 +91,6 @@ let getClickHouseFieldType = (
91
91
  ->Array.joinUnsafe(", ")
92
92
  `${enumType}(${enumValues})`
93
93
  }
94
- | Entity(_) => "String"
95
94
  }
96
95
 
97
96
  let baseType = if isArray {
@@ -261,11 +260,14 @@ let setUpdatesOrThrow = async (
261
260
  convertOrThrow: S.compile(
262
261
  S.array(
263
262
  S.union([
264
- EntityHistory.makeSetUpdateSchema(makeClickHouseEntitySchema(entityConfig.table)),
263
+ EntityHistory.makeSetUpdateSchema(
264
+ ~idSchema=entityConfig.table->Table.getIdSchema,
265
+ makeClickHouseEntitySchema(entityConfig.table),
266
+ ),
265
267
  S.object(s => {
266
268
  s.tag(EntityHistory.changeFieldName, EntityHistory.RowAction.DELETE)
267
269
  Change.Delete({
268
- entityId: s.field(Table.idFieldName, S.string),
270
+ entityId: s.field(Table.idFieldName, entityConfig.table->Table.getIdSchema),
269
271
  checkpointId: s.field(
270
272
  EntityHistory.checkpointIdFieldName,
271
273
  EntityHistory.unsafeCheckpointIdSchema,
@@ -68,9 +68,6 @@ function getClickHouseFieldType(fieldType, isNullable, isArray) {
68
68
  let enumValues = config$1.variants.map(variant => `'` + variant + `'`).join(", ");
69
69
  baseType = enumType + `(` + enumValues + `)`;
70
70
  break;
71
- case "Entity" :
72
- baseType = "String";
73
- break;
74
71
  }
75
72
  }
76
73
  let baseType$1 = isArray ? `Array(` + baseType + `)` : baseType;
@@ -203,12 +200,12 @@ async function setUpdatesOrThrow(client, cache, changes, entityConfig, database)
203
200
  } else {
204
201
  let cached_tableName = database + `.\`` + EntityHistory.historyTableName(entityConfig.name, entityConfig.index) + `\``;
205
202
  let cached_convertOrThrow = S$RescriptSchema.compile(S$RescriptSchema.array(S$RescriptSchema.union([
206
- EntityHistory.makeSetUpdateSchema(makeClickHouseEntitySchema(entityConfig.table)),
203
+ EntityHistory.makeSetUpdateSchema(Table.getIdSchema(entityConfig.table), makeClickHouseEntitySchema(entityConfig.table)),
207
204
  S$RescriptSchema.object(s => {
208
205
  s.tag(EntityHistory.changeFieldName, "DELETE");
209
206
  return {
210
207
  type: "DELETE",
211
- entityId: s.f(Table.idFieldName, S$RescriptSchema.string),
208
+ entityId: s.f(Table.idFieldName, Table.getIdSchema(entityConfig.table)),
212
209
  checkpointId: s.f(EntityHistory.checkpointIdFieldName, EntityHistory.unsafeCheckpointIdSchema)
213
210
  };
214
211
  })
@@ -34,7 +34,6 @@ type rec expectation<'a> = {
34
34
  toHavePropertyValue: 'b. (string, 'b) => unit,
35
35
  // Exception matchers
36
36
  toThrow: unit => unit,
37
- toThrowError: string => unit,
38
37
  // Snapshot matchers
39
38
  toMatchSnapshot: unit => unit,
40
39
  // Negation
@@ -151,3 +150,25 @@ module Async = {
151
150
 
152
151
  @module("vitest")
153
152
  external expect: ('a, ~message: string=?) => expectation<'a> = "expect"
153
+
154
+ // Runs `fn` and returns the thrown error's message — mirroring JS's
155
+ // `e instanceof Error ? e.message : String(e)` — or `None` when nothing threw.
156
+ let messageOfThrown: (unit => 'a) => option<string> = %raw(`function (fn) {
157
+ try {
158
+ fn();
159
+ return undefined;
160
+ } catch (e) {
161
+ return e instanceof Error ? e.message : String(e);
162
+ }
163
+ }`)
164
+
165
+ // Strict counterpart to the built-in `toThrow`, which only checks that the
166
+ // thrown message *contains* the expected string. `toThrowErrorEqual` requires
167
+ // the whole message to match, so a test can pin the complete error text.
168
+ // A plain ReScript function (not a custom `expect.extend` matcher), so it needs
169
+ // no per-package vitest setup.
170
+ // Compared as options rather than through a "didn't throw" placeholder string,
171
+ // so a function that throws nothing can never match — not even when `expected`
172
+ // happens to equal the placeholder.
173
+ let toThrowErrorEqual = (t: testContext, fn: unit => 'a, ~message=?, expected: string) =>
174
+ t.expect(fn->messageOfThrown, ~message?).toEqual(Some(expected))
@@ -13,7 +13,22 @@ let Async = {
13
13
  itSkipInClaudeCloud: itSkipInClaudeCloud
14
14
  };
15
15
 
16
+ let messageOfThrown = (function (fn) {
17
+ try {
18
+ fn();
19
+ return undefined;
20
+ } catch (e) {
21
+ return e instanceof Error ? e.message : String(e);
22
+ }
23
+ });
24
+
25
+ function toThrowErrorEqual(t, fn, message, expected) {
26
+ t.expect(messageOfThrown(fn), message).toEqual(expected);
27
+ }
28
+
16
29
  export {
17
30
  Async,
31
+ messageOfThrown,
32
+ toThrowErrorEqual,
18
33
  }
19
34
  /* isClaudeCloud Not a pure module */
@@ -176,10 +176,6 @@ let parseGetWhereOrThrow = (filter: dict<dict<unknown>>, ~entityName, ~table: Ta
176
176
  JsError.throwWithMessage(
177
177
  `The field "${apiFieldName}" on entity "${entityName}" is a derived field and cannot be used in getWhere(). Use the source entity's indexed field instead.`,
178
178
  )
179
- | Some(Field({isPrimaryKey: false, isIndex: false, linkedEntity: None})) =>
180
- JsError.throwWithMessage(
181
- `The field "${apiFieldName}" on entity "${entityName}" does not have an index. To use it in getWhere(), add the @index directive in your schema.graphql:\n\n ${apiFieldName}: ... @index\n\nThen run 'pnpm envio codegen' to regenerate.`,
182
- )
183
179
  | Some(Field(_)) => ()
184
180
  }
185
181
 
@@ -142,14 +142,7 @@ function parseGetWhereOrThrow(filter, entityName, table) {
142
142
  });
143
143
  let match = Table.getFieldByApiName(table, apiFieldName);
144
144
  if (match !== undefined) {
145
- if (match.TAG === "Field") {
146
- let match$1 = match._0;
147
- if (match$1.isPrimaryKey || match$1.isIndex || match$1.linkedEntity !== undefined) {
148
-
149
- } else {
150
- Stdlib_JsError.throwWithMessage(`The field "` + apiFieldName + `" on entity "` + entityName + `" does not have an index. To use it in getWhere(), add the @index directive in your schema.graphql:\n\n ` + apiFieldName + `: ... @index\n\nThen run 'pnpm envio codegen' to regenerate.`);
151
- }
152
- } else {
145
+ if (match.TAG !== "Field") {
153
146
  Stdlib_JsError.throwWithMessage(`The field "` + apiFieldName + `" on entity "` + entityName + `" is a derived field and cannot be used in getWhere(). Use the source entity's indexed field instead.`);
154
147
  }
155
148
  } else {
@@ -30,12 +30,14 @@ let unsafeCheckpointIdSchema =
30
30
  serializer: bigint => bigint->BigInt.toString,
31
31
  })
32
32
 
33
- let makeSetUpdateSchema: S.t<'entity> => S.t<Change.t<'entity>> = entitySchema => {
33
+ let makeSetUpdateSchema = (~idSchema: S.t<EntityId.t>, entitySchema: S.t<'entity>): S.t<
34
+ Change.t<'entity>,
35
+ > => {
34
36
  S.object(s => {
35
37
  s.tag(changeFieldName, RowAction.SET)
36
38
  Change.Set({
37
39
  checkpointId: s.field(checkpointIdFieldName, unsafeCheckpointIdSchema),
38
- entityId: s.field(Table.idFieldName, S.string),
40
+ entityId: s.field(Table.idFieldName, idSchema),
39
41
  entity: s.flatten(entitySchema),
40
42
  })
41
43
  })
@@ -118,10 +120,10 @@ let pruneStaleEntityHistory = (
118
120
 
119
121
  // If an entity doesn't have a history before the update
120
122
  // we create it automatically with envio_checkpoint_id 0
121
- let makeBackfillHistoryQuery = (~pgSchema, ~entityName, ~entityIndex) => {
123
+ let makeBackfillHistoryQuery = (~pgSchema, ~entityName, ~entityIndex, ~idPgType) => {
122
124
  let historyTableRef = `"${pgSchema}"."${historyTableName(~entityName, ~entityIndex)}"`
123
125
  `WITH target_ids AS (
124
- SELECT UNNEST($1::${(Text: Postgres.columnType :> string)}[]) AS id
126
+ SELECT UNNEST($1::${idPgType}[]) AS id
125
127
  ),
126
128
  missing_history AS (
127
129
  SELECT e.*
@@ -135,11 +137,18 @@ SELECT *, 0 AS ${checkpointIdFieldName}, '${(RowAction.SET :> string)}' as ${cha
135
137
  FROM missing_history;`
136
138
  }
137
139
 
138
- let backfillHistory = (sql, ~pgSchema, ~entityName, ~entityIndex, ~ids: array<string>) => {
140
+ let backfillHistory = (
141
+ sql,
142
+ ~pgSchema,
143
+ ~table: Table.table,
144
+ ~entityIndex,
145
+ ~ids: array<EntityId.t>,
146
+ ) => {
147
+ let idPgType = table->Table.getIdPgFieldType(~pgSchema)
139
148
  sql
140
149
  ->Postgres.preparedUnsafe(
141
- makeBackfillHistoryQuery(~entityName, ~entityIndex, ~pgSchema),
142
- [ids]->Obj.magic,
150
+ makeBackfillHistoryQuery(~entityName=table.tableName, ~entityIndex, ~pgSchema, ~idPgType),
151
+ [table->Table.encodeIdsToJson(ids)]->(Utils.magic: array<JSON.t> => unknown),
143
152
  )
144
153
  ->Utils.Promise.ignoreValue
145
154
  }
@@ -48,12 +48,12 @@ let unsafeCheckpointIdSchema = S$RescriptSchema.transform(S$RescriptSchema.setNa
48
48
  s: bigint => bigint.toString()
49
49
  }));
50
50
 
51
- function makeSetUpdateSchema(entitySchema) {
51
+ function makeSetUpdateSchema(idSchema, entitySchema) {
52
52
  return S$RescriptSchema.object(s => {
53
53
  s.tag(changeFieldName, "SET");
54
54
  return {
55
55
  type: "SET",
56
- entityId: s.f(Table.idFieldName, S$RescriptSchema.string),
56
+ entityId: s.f(Table.idFieldName, idSchema),
57
57
  entity: s.flatten(entitySchema),
58
58
  checkpointId: s.f(checkpointIdFieldName, unsafeCheckpointIdSchema)
59
59
  };
@@ -97,10 +97,10 @@ function pruneStaleEntityHistory(sql, entityName, entityIndex, pgSchema, safeChe
97
97
  return sql.unsafe(makePruneStaleEntityHistoryQuery(entityName, entityIndex, pgSchema), [safeCheckpointId.toString()], {prepare: true});
98
98
  }
99
99
 
100
- function makeBackfillHistoryQuery(pgSchema, entityName, entityIndex) {
100
+ function makeBackfillHistoryQuery(pgSchema, entityName, entityIndex, idPgType) {
101
101
  let historyTableRef = `"` + pgSchema + `"."` + historyTableName(entityName, entityIndex) + `"`;
102
102
  return `WITH target_ids AS (
103
- SELECT UNNEST($1::` + "TEXT" + `[]) AS id
103
+ SELECT UNNEST($1::` + idPgType + `[]) AS id
104
104
  ),
105
105
  missing_history AS (
106
106
  SELECT e.*
@@ -114,8 +114,9 @@ SELECT *, 0 AS ` + checkpointIdFieldName + `, '` + "SET" + `' as ` + changeField
114
114
  FROM missing_history;`;
115
115
  }
116
116
 
117
- function backfillHistory(sql, pgSchema, entityName, entityIndex, ids) {
118
- return sql.unsafe(makeBackfillHistoryQuery(pgSchema, entityName, entityIndex), [ids], {prepare: true});
117
+ function backfillHistory(sql, pgSchema, table, entityIndex, ids) {
118
+ let idPgType = Table.getIdPgFieldType(table, pgSchema);
119
+ return sql.unsafe(makeBackfillHistoryQuery(pgSchema, table.tableName, entityIndex, idPgType), [Table.encodeIdsToJson(table, ids)], {prepare: true});
119
120
  }
120
121
 
121
122
  function rollback(sql, pgSchema, entityName, entityIndex, rollbackTargetCheckpointId) {
@@ -0,0 +1,219 @@
1
+ // An in-memory mirror of the indexes PostgreSQL holds for the indexer schema.
2
+ // Postgres stays authoritative: the registry is loaded from pg_catalog at
3
+ // startup/resume and afterwards only grows — optimistically, right after a
4
+ // CREATE INDEX we issued succeeds. Only one Indexer instance writes to the
5
+ // schema, so no catalog refresh is needed between those two points.
6
+
7
+ let btree = "btree"
8
+
9
+ type column = {
10
+ name: string,
11
+ direction: Table.indexFieldDirection,
12
+ }
13
+
14
+ let fromIndexFields = (indexFields: array<Table.compositeIndexField>) =>
15
+ indexFields->Array.map(({fieldName, direction}) => {name: fieldName, direction})
16
+
17
+ // Identity of an index is table + ordered columns + direction + method. Names
18
+ // are deliberately not part of it: the catalog is matched on what the index
19
+ // actually covers, not on how it was spelled.
20
+ let makeKey = (~tableName, ~columns: array<column>, ~method) =>
21
+ `${tableName}|${method}|${columns
22
+ ->Array.map(({name, direction}) =>
23
+ switch direction {
24
+ | Table.Asc => name
25
+ | Desc => name ++ " DESC"
26
+ }
27
+ )
28
+ ->Array.joinUnsafe(",")}`
29
+
30
+ type catalogRow = {
31
+ tableName: string,
32
+ indexName: string,
33
+ method: string,
34
+ // 1/0 rather than a bool: postgres.js hands booleans back inconsistently
35
+ // depending on the driver's type resolution, so the query casts it.
36
+ isValid: int,
37
+ // 1 only for an index the indexer could have created: plain btree over plain
38
+ // columns. A unique or partial index carries a definition our key doesn't
39
+ // capture, so it can never be one of ours to rebuild.
40
+ isPlain: int,
41
+ columns: array<string>,
42
+ // "ASC"/"DESC" per column, in the same order as `columns`.
43
+ directions: array<string>,
44
+ }
45
+
46
+ let catalogRowsSchema = S.array(
47
+ S.object((s): catalogRow => {
48
+ tableName: s.field("tableName", S.string),
49
+ indexName: s.field("indexName", S.string),
50
+ method: s.field("method", S.string),
51
+ isValid: s.field("isValid", S.int),
52
+ isPlain: s.field("isPlain", S.int),
53
+ columns: s.field("columns", S.array(S.string)),
54
+ directions: s.field("directions", S.array(S.string)),
55
+ }),
56
+ )
57
+
58
+ // One row per index, with its key columns aggregated in ordinal order.
59
+ // INCLUDE columns are excluded (they don't affect what the index can serve),
60
+ // and expression columns come back as their printed definition so an
61
+ // expression index can never be mistaken for a plain-column one.
62
+ let makeCatalogQuery = (~pgSchema) =>
63
+ `SELECT
64
+ t.relname AS "tableName",
65
+ i.relname AS "indexName",
66
+ am.amname AS "method",
67
+ CASE WHEN ix.indisvalid AND ix.indisready THEN 1 ELSE 0 END AS "isValid",
68
+ CASE
69
+ WHEN ix.indisunique OR ix.indpred IS NOT NULL OR ix.indexprs IS NOT NULL THEN 0
70
+ ELSE 1
71
+ END AS "isPlain",
72
+ array_agg(
73
+ CASE WHEN k.attnum = 0
74
+ THEN pg_get_indexdef(ix.indexrelid, k.ord::int, true)
75
+ ELSE a.attname
76
+ END ORDER BY k.ord
77
+ ) AS "columns",
78
+ array_agg(
79
+ CASE WHEN (ix.indoption[k.ord - 1] & 1) = 1 THEN 'DESC' ELSE 'ASC' END ORDER BY k.ord
80
+ ) AS "directions"
81
+ FROM pg_index ix
82
+ JOIN pg_class i ON i.oid = ix.indexrelid
83
+ JOIN pg_class t ON t.oid = ix.indrelid
84
+ JOIN pg_namespace n ON n.oid = t.relnamespace
85
+ JOIN pg_am am ON am.oid = i.relam
86
+ JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) ON k.ord <= ix.indnkeyatts
87
+ LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
88
+ WHERE n.nspname = '${pgSchema}'
89
+ GROUP BY t.relname, i.relname, am.amname, ix.indisvalid, ix.indisready,
90
+ ix.indisunique, ix.indpred, ix.indexprs;`
91
+
92
+ // What we know about an index that came back invalid, enough to decide whether
93
+ // rebuilding it would produce the index we actually wanted.
94
+ type invalidIndex = {
95
+ key: string,
96
+ isRepairable: bool,
97
+ }
98
+
99
+ type t = {
100
+ keys: Utils.Set.t<string>,
101
+ // Every index Postgres reports as invalid, by name. Kept because
102
+ // `CREATE INDEX IF NOT EXISTS` matches on name: with an invalid index already
103
+ // under the name we want, the DDL is a silent no-op and we'd register a key
104
+ // for something the planner refuses to use.
105
+ invalidByName: dict<invalidIndex>,
106
+ // Keyed by index key so identical concurrent requests await one build.
107
+ inflight: dict<promise<unit>>,
108
+ // Tail of the build chain per table, so two different indexes on the same
109
+ // table are built one after the other rather than at once.
110
+ tableQueue: dict<promise<unit>>,
111
+ }
112
+
113
+ let make = () => {
114
+ keys: Utils.Set.make(),
115
+ invalidByName: Dict.make(),
116
+ inflight: Dict.make(),
117
+ tableQueue: Dict.make(),
118
+ }
119
+
120
+ let has = (registry, key) => registry.keys->Utils.Set.has(key)
121
+
122
+ let add = (registry, key) => registry.keys->Utils.Set.add(key)->ignore
123
+
124
+ let size = registry => registry.keys->Utils.Set.size
125
+
126
+ let toArray = registry => registry.keys->Utils.Set.toArray->Array.toSorted(String.compare)
127
+
128
+ // A plain CREATE INDEX either commits or leaves nothing behind, so an index can
129
+ // only be invalid because something before us left it that way — the startup
130
+ // snapshot stays accurate until the next restart.
131
+ let getInvalid = (registry, name) => registry.invalidByName->Utils.Dict.dangerouslyGetNonOption(name)
132
+
133
+ let clearInvalidName = (registry, name) => registry.invalidByName->Utils.Dict.deleteInPlace(name)
134
+
135
+ // Replaces the whole registry with what the catalog reports. Returns the names
136
+ // of indexes Postgres flagged invalid (eg a CREATE INDEX CONCURRENTLY that died
137
+ // midway). They stay out of the registry, so the next request that needs one
138
+ // repairs it.
139
+ let reload = (registry, ~rows: array<catalogRow>) => {
140
+ registry.keys->Utils.Set.clear
141
+ registry.invalidByName
142
+ ->Dict.keysToArray
143
+ ->Array.forEach(name => registry.invalidByName->Utils.Dict.deleteInPlace(name))
144
+ let invalidIndexNames = []
145
+ rows->Array.forEach(row => {
146
+ let key = makeKey(
147
+ ~tableName=row.tableName,
148
+ ~columns=row.columns->Array.mapWithIndex((name, idx) => {
149
+ name,
150
+ direction: switch row.directions->Array.get(idx) {
151
+ | Some("DESC") => Table.Desc
152
+ | _ => Asc
153
+ },
154
+ }),
155
+ ~method=row.method,
156
+ )
157
+ if row.isValid === 0 {
158
+ invalidIndexNames->Array.push(row.indexName)->ignore
159
+ registry.invalidByName->Dict.set(row.indexName, {key, isRepairable: row.isPlain === 1})
160
+ } else {
161
+ registry->add(key)
162
+ }
163
+ })
164
+ invalidIndexNames
165
+ }
166
+
167
+ // Runs `build` unless the index is already registered. The key is added only
168
+ // after `build` resolves, so a failed build leaves the registry untouched and
169
+ // the next request retries.
170
+ let ensure = (registry, ~key, ~tableName, ~build) =>
171
+ if registry->has(key) {
172
+ Promise.resolve()
173
+ } else {
174
+ switch registry.inflight->Utils.Dict.dangerouslyGetNonOption(key) {
175
+ | Some(promise) => promise
176
+ | None =>
177
+ let tail = switch registry.tableQueue->Utils.Dict.dangerouslyGetNonOption(tableName) {
178
+ | Some(tail) => tail
179
+ | None => Promise.resolve()
180
+ }
181
+ let promise =
182
+ tail
183
+ // The queued build may have been satisfied while waiting behind another
184
+ // one on the same table (eg a finalize pass created it).
185
+ ->Promise.then(() => registry->has(key) ? Promise.resolve() : build())
186
+ ->Promise.thenResolve(() => registry->add(key))
187
+ registry.inflight->Dict.set(key, promise)
188
+ registry.tableQueue->Dict.set(tableName, promise->Utils.Promise.silentCatch)
189
+ promise->Promise.finally(() => registry.inflight->Utils.Dict.deleteInPlace(key))
190
+ }
191
+ }
192
+
193
+ let pgMaxIdentifierLength = 63
194
+
195
+ // Postgres truncates identifiers past 63 characters on its own. Doing it here
196
+ // instead keeps the name we emit, log and read back from the catalog identical
197
+ // to the one it stores. Descriptions are ASCII (GraphQL names are), so
198
+ // characters and Postgres' byte limit line up.
199
+ let truncateIndexName = description =>
200
+ description->String.length > pgMaxIdentifierLength
201
+ ? description->String.slice(~start=0, ~end=pgMaxIdentifierLength)
202
+ : description
203
+
204
+ // Two distinct descriptions can collapse onto one truncated name — and the
205
+ // second `CREATE INDEX IF NOT EXISTS` would then be skipped without a trace,
206
+ // leaving the registry claiming an index that doesn't exist.
207
+ let validateIndexNamesOrThrow = (descriptions: array<string>) => {
208
+ let byName = Dict.make()
209
+ descriptions->Array.forEach(description => {
210
+ let name = description->truncateIndexName
211
+ switch byName->Utils.Dict.dangerouslyGetNonOption(name) {
212
+ | Some(existing) if existing !== description =>
213
+ JsError.throwWithMessage(
214
+ `Index names "${existing}" and "${description}" both truncate to "${name}" at PostgreSQL's ${pgMaxIdentifierLength->Int.toString}-character identifier limit. Rename a field or an entity so the generated index names stay distinct.`,
215
+ )
216
+ | _ => byName->Dict.set(name, description)
217
+ }
218
+ })
219
+ }