envio 3.5.0-alpha.2 → 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.
@@ -18,6 +18,7 @@ import * as Performance from "./bindings/Performance.res.mjs";
18
18
  import * as Persistence from "./Persistence.res.mjs";
19
19
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
20
20
  import * as EntityHistory from "./db/EntityHistory.res.mjs";
21
+ import * as IndexRegistry from "./db/IndexRegistry.res.mjs";
21
22
  import * as InternalTable from "./db/InternalTable.res.mjs";
22
23
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
23
24
  import * as Child_process from "child_process";
@@ -72,13 +73,87 @@ function makeCreateCompositeIndexQuery(tableName, indexFields, pgSchema) {
72
73
  return `CREATE INDEX IF NOT EXISTS "` + indexName + `" ON "` + pgSchema + `"."` + tableName + `"(` + index + `);`;
73
74
  }
74
75
 
75
- function makeCreateTableIndicesQuery(table, pgSchema) {
76
+ function makeCreateTableIndexesQuery(table, pgSchema) {
76
77
  let tableName = table.tableName;
77
78
  let createIndex = indexField => makeCreateIndexQuery(tableName, [indexField], pgSchema);
78
79
  let createCompositeIndex = indexFields => makeCreateCompositeIndexQuery(tableName, indexFields, pgSchema);
79
- let singleIndices = Table.getSingleIndices(table);
80
- let compositeIndices = Table.getCompositeIndices(table);
81
- return singleIndices.map(createIndex).join("\n") + compositeIndices.map(createCompositeIndex).join("\n");
80
+ let singleIndexes = Table.getSingleIndexes(table);
81
+ let compositeIndexes = Table.getCompositeIndexes(table);
82
+ return singleIndexes.map(createIndex).join("\n") + compositeIndexes.map(createCompositeIndex).join("\n");
83
+ }
84
+
85
+ function schemaIndexDescription(param) {
86
+ return param.tableName + "_" + param.indexFields.map(f => f.fieldName + directionToIndexName(f.direction)).join("_");
87
+ }
88
+
89
+ function schemaIndexName(schemaIndex) {
90
+ return IndexRegistry.truncateIndexName(schemaIndexDescription(schemaIndex));
91
+ }
92
+
93
+ function schemaIndexKey(param) {
94
+ return IndexRegistry.makeKey(param.tableName, IndexRegistry.fromIndexFields(param.indexFields), IndexRegistry.btree);
95
+ }
96
+
97
+ function makeCreateSchemaIndexQuery(schemaIndex, pgSchema) {
98
+ let columns = schemaIndex.indexFields.map(f => `"` + f.fieldName + `"` + directionToSql(f.direction)).join(", ");
99
+ return `CREATE INDEX IF NOT EXISTS "` + IndexRegistry.truncateIndexName(schemaIndexDescription(schemaIndex)) + `" ON "` + pgSchema + `"."` + schemaIndex.tableName + `"(` + columns + `);`;
100
+ }
101
+
102
+ function formatSeconds(timeRef) {
103
+ return (Math.round(Performance.secondsSince(timeRef) * 100) / 100).toString();
104
+ }
105
+
106
+ let slowOnLargeDatabaseNotice = "This can take a long time on a large database.";
107
+
108
+ function makeReindexQuery(pgSchema, indexName) {
109
+ return `REINDEX INDEX "` + pgSchema + `"."` + indexName + `";`;
110
+ }
111
+
112
+ function nameCollisionError(indexName, pgSchema) {
113
+ return new Error(`Cannot create the index "` + indexName + `" in schema "` + pgSchema + `". An index of that name already exists and PostgreSQL reports it as invalid, but it isn't one the indexer created — it covers different columns, or it is a unique or partial index — so it is left untouched. Drop or repair it by hand, then restart the indexer.`);
114
+ }
115
+
116
+ function makeSingleColumnSchemaIndex(tableName, column) {
117
+ return {
118
+ tableName: tableName,
119
+ indexFields: [{
120
+ fieldName: column,
121
+ direction: "Asc"
122
+ }]
123
+ };
124
+ }
125
+
126
+ function getSchemaIndexes(entities) {
127
+ let derivedSchema = Schema.make(entities.map(e => e.table));
128
+ let all = [];
129
+ entities.forEach(param => {
130
+ let table = param.table;
131
+ Table.getSingleIndexes(table).forEach(column => {
132
+ all.push(makeSingleColumnSchemaIndex(table.tableName, column));
133
+ });
134
+ Table.getCompositeIndexes(table).forEach(indexFields => {
135
+ all.push({
136
+ tableName: table.tableName,
137
+ indexFields: indexFields
138
+ });
139
+ });
140
+ });
141
+ entities.forEach(param => {
142
+ Table.getDerivedFromFields(param.table).forEach(derivedFromField => {
143
+ let column = Utils.unwrapResultExn(Schema.getDerivedFromPgFieldName(derivedSchema, derivedFromField));
144
+ all.push(makeSingleColumnSchemaIndex(derivedFromField.derivedFromEntity, column));
145
+ });
146
+ });
147
+ let seen = new Set();
148
+ return all.filter(schemaIndex => {
149
+ let key = schemaIndexKey(schemaIndex);
150
+ if (seen.has(key)) {
151
+ return false;
152
+ } else {
153
+ seen.add(key);
154
+ return true;
155
+ }
156
+ });
82
157
  }
83
158
 
84
159
  function makeCreateTableQuery(table, pgSchema, isNumericArrayAsText) {
@@ -169,11 +244,12 @@ function getEntityHistory(entityConfig) {
169
244
  return cache$1;
170
245
  }
171
246
 
172
- function makeInitializeTransaction(pgSchema, pgUser, isHasuraEnabled, chainConfigsOpt, entitiesOpt, enumsOpt, isEmptyPgSchemaOpt) {
247
+ function makeInitializeTransaction(pgSchema, pgUser, isHasuraEnabled, chainConfigsOpt, entitiesOpt, enumsOpt, isEmptyPgSchemaOpt, deferSchemaIndexesOpt) {
173
248
  let chainConfigs = chainConfigsOpt !== undefined ? chainConfigsOpt : [];
174
249
  let entities = entitiesOpt !== undefined ? entitiesOpt : [];
175
250
  let enums = enumsOpt !== undefined ? enumsOpt : [];
176
251
  let isEmptyPgSchema = isEmptyPgSchemaOpt !== undefined ? isEmptyPgSchemaOpt : false;
252
+ let deferSchemaIndexes = deferSchemaIndexesOpt !== undefined ? deferSchemaIndexesOpt : false;
177
253
  let generalTables = [
178
254
  InternalTable.Chains.table,
179
255
  InternalTable.EnvioInfo.table,
@@ -181,13 +257,12 @@ function makeInitializeTransaction(pgSchema, pgUser, isHasuraEnabled, chainConfi
181
257
  InternalTable.RawEvents.table
182
258
  ];
183
259
  let allTables = generalTables.slice();
184
- let allEntityTables = [];
185
260
  entities.forEach(entityConfig => {
186
- allEntityTables.push(entityConfig.table);
187
261
  allTables.push(entityConfig.table);
188
262
  allTables.push(getEntityHistory(entityConfig).table);
189
263
  });
190
- let derivedSchema = Schema.make(allEntityTables);
264
+ let schemaIndexes = getSchemaIndexes(entities);
265
+ IndexRegistry.validateIndexNamesOrThrow(schemaIndexes.map(schemaIndexDescription));
191
266
  let query = {
192
267
  contents: (
193
268
  isEmptyPgSchema && pgSchema === "public" ? `CREATE SCHEMA IF NOT EXISTS "` + pgSchema + `";\n` : `DROP SCHEMA IF EXISTS "` + pgSchema + `" CASCADE;
@@ -202,19 +277,11 @@ GRANT ALL ON SCHEMA "` + pgSchema + `" TO public;`)
202
277
  allTables.forEach(table => {
203
278
  query.contents = query.contents + "\n" + makeCreateTableQuery(table, pgSchema, isHasuraEnabled);
204
279
  });
205
- allTables.forEach(table => {
206
- let indices = makeCreateTableIndicesQuery(table, pgSchema);
207
- if (indices !== "") {
208
- query.contents = query.contents + "\n" + indices;
209
- return;
210
- }
211
- });
212
- entities.forEach(entity => {
213
- Table.getDerivedFromFields(entity.table).forEach(derivedFromField => {
214
- let indexField = Utils.unwrapResultExn(Schema.getDerivedFromPgFieldName(derivedSchema, derivedFromField));
215
- query.contents = query.contents + "\n" + makeCreateIndexQuery(derivedFromField.derivedFromEntity, [indexField], pgSchema);
280
+ if (!deferSchemaIndexes) {
281
+ schemaIndexes.forEach(schemaIndex => {
282
+ query.contents = query.contents + "\n" + makeCreateSchemaIndexQuery(schemaIndex, pgSchema);
216
283
  });
217
- });
284
+ }
218
285
  query.contents = query.contents + "\n" + InternalTable.Views.makeMetaViewQuery(pgSchema);
219
286
  query.contents = query.contents + "\n" + InternalTable.Views.makeChainMetadataViewQuery(pgSchema);
220
287
  let initialChainsValuesQuery = InternalTable.Chains.makeInitialValuesQuery(pgSchema, chainConfigs);
@@ -850,6 +917,23 @@ function make(sql, pgHost, pgSchema, pgPort, pgUser, pgDatabase, pgPassword, isH
850
917
  env: psqlExecOptions_env
851
918
  };
852
919
  let cacheDirPath = Path.resolve(".envio", "cache");
920
+ let storageName = "postgres";
921
+ let indexRegistry = IndexRegistry.make();
922
+ let reloadIndexRegistry = async () => {
923
+ let rows = S$RescriptSchema.parseOrThrow(await sql.unsafe(IndexRegistry.makeCatalogQuery(pgSchema)), IndexRegistry.catalogRowsSchema);
924
+ let invalidIndexNames = IndexRegistry.reload(indexRegistry, rows);
925
+ if (invalidIndexNames.length !== 0) {
926
+ Logging.warn({
927
+ storage: storageName,
928
+ msg: `Ignoring invalid PostgreSQL indexes in schema "` + pgSchema + `". They are left untouched — drop and recreate them manually if you need them.`,
929
+ indexes: invalidIndexNames
930
+ });
931
+ }
932
+ return Logging.info({
933
+ storage: storageName,
934
+ msg: `Found ` + IndexRegistry.size(indexRegistry).toString() + ` existing indexes in the indexer schema.`
935
+ });
936
+ };
853
937
  let isInitialized = async () => Utils.$$Array.notEmpty(await sql.unsafe(`SELECT table_schema FROM information_schema.tables WHERE table_schema = '` + pgSchema + `' AND (table_name = '` + "event_sync_state" + `' OR table_name = '` + InternalTable.Chains.table.tableName + `');`));
854
938
  let scanCacheDir = async () => {
855
939
  let topEntries;
@@ -969,7 +1053,7 @@ function make(sql, pgHost, pgSchema, pgPort, pgUser, pgDatabase, pgPassword, isH
969
1053
  if (sink !== undefined) {
970
1054
  await sink.initialize(chainConfigs, chEntities, enums);
971
1055
  }
972
- let queries = makeInitializeTransaction(pgSchema, pgUser, isHasuraEnabled, chainConfigs, pgEntities, enums, Utils.$$Array.isEmpty(schemaTableNames));
1056
+ let queries = makeInitializeTransaction(pgSchema, pgUser, isHasuraEnabled, chainConfigs, pgEntities, enums, Utils.$$Array.isEmpty(schemaTableNames), true);
973
1057
  await sql.begin(async sql => {
974
1058
  await Promise.all(queries.map(query => sql.unsafe(query)));
975
1059
  return await InternalTable.EnvioInfo.write(sql, pgSchema, envioInfo);
@@ -995,6 +1079,7 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
995
1079
  ]);
996
1080
  }
997
1081
  let cache = await restoreEffectCache(true);
1082
+ await reloadIndexRegistry();
998
1083
  if (onInitialize !== undefined) {
999
1084
  await onInitialize();
1000
1085
  }
@@ -1045,6 +1130,121 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1045
1130
  };
1046
1131
  }
1047
1132
  };
1133
+ let filterColumns = (table, filters) => {
1134
+ let queryFields = Table.queryFields(table);
1135
+ let columns = [];
1136
+ let seen = new Set();
1137
+ let collect = filter => {
1138
+ if (filter.operator === "and") {
1139
+ filter.filters.forEach(collect);
1140
+ return;
1141
+ }
1142
+ let match = queryFields[filter.fieldName];
1143
+ if (match === undefined) {
1144
+ return;
1145
+ }
1146
+ let pgDbFieldName = match.pgDbFieldName;
1147
+ if (!seen.has(pgDbFieldName)) {
1148
+ seen.add(pgDbFieldName);
1149
+ columns.push(pgDbFieldName);
1150
+ return;
1151
+ }
1152
+ };
1153
+ filters.forEach(collect);
1154
+ return columns;
1155
+ };
1156
+ let makeEnsureIndexQuery = schemaIndex => {
1157
+ let indexName = IndexRegistry.truncateIndexName(schemaIndexDescription(schemaIndex));
1158
+ let match = IndexRegistry.getInvalid(indexRegistry, indexName);
1159
+ if (match === undefined) {
1160
+ return makeCreateSchemaIndexQuery(schemaIndex, pgSchema);
1161
+ }
1162
+ if (match.isRepairable) {
1163
+ if (match.key === schemaIndexKey(schemaIndex)) {
1164
+ return makeReindexQuery(pgSchema, indexName);
1165
+ }
1166
+ throw nameCollisionError(indexName, pgSchema);
1167
+ }
1168
+ throw nameCollisionError(indexName, pgSchema);
1169
+ };
1170
+ let isRepair = schemaIndex => Stdlib_Option.isSome(IndexRegistry.getInvalid(indexRegistry, IndexRegistry.truncateIndexName(schemaIndexDescription(schemaIndex))));
1171
+ let ensureQueryIndexes = async (table, filters) => {
1172
+ let missing = Stdlib_Array.filterMap(filterColumns(table, filters), column => {
1173
+ let schemaIndex = makeSingleColumnSchemaIndex(table.tableName, column);
1174
+ if (IndexRegistry.has(indexRegistry, schemaIndexKey(schemaIndex))) {
1175
+ return;
1176
+ } else {
1177
+ return schemaIndex;
1178
+ }
1179
+ });
1180
+ if (Utils.$$Array.notEmpty(missing)) {
1181
+ await Promise.all(missing.map(schemaIndex => {
1182
+ let indexName = IndexRegistry.truncateIndexName(schemaIndexDescription(schemaIndex));
1183
+ return IndexRegistry.ensure(indexRegistry, schemaIndexKey(schemaIndex), table.tableName, async () => {
1184
+ let query = makeEnsureIndexQuery(schemaIndex);
1185
+ let verb = isRepair(schemaIndex) ? "Repairing invalid index" : "Creating index";
1186
+ Logging.info({
1187
+ storage: storageName,
1188
+ msg: verb + ` "` + indexName + `" to serve a getWhere query on "` + table.tableName + `". Writes to the table are paused until it completes. ` + slowOnLargeDatabaseNotice
1189
+ });
1190
+ let timeRef = Performance.now();
1191
+ await sql.unsafe(query);
1192
+ IndexRegistry.clearInvalidName(indexRegistry, indexName);
1193
+ return Logging.info({
1194
+ storage: storageName,
1195
+ msg: `Index "` + indexName + `" is ready after ` + formatSeconds(timeRef) + `s. Resuming indexing.`
1196
+ });
1197
+ }).catch(exn => Logging.warn({
1198
+ storage: storageName,
1199
+ msg: `Failed to create the index "` + indexName + `" for a getWhere query. The query runs without it.`,
1200
+ err: Utils.prettifyExn(exn)
1201
+ }));
1202
+ }));
1203
+ return;
1204
+ }
1205
+ };
1206
+ let finalizeBackfill = async (entities, chainIds, readyAt) => {
1207
+ let schemaIndexes = getSchemaIndexes(entities.filter(e => e.storage.postgres));
1208
+ IndexRegistry.validateIndexNamesOrThrow(schemaIndexes.map(schemaIndexDescription));
1209
+ let missing = schemaIndexes.filter(schemaIndex => !IndexRegistry.has(indexRegistry, schemaIndexKey(schemaIndex)));
1210
+ let repaired = missing.filter(isRepair);
1211
+ if (Utils.$$Array.notEmpty(repaired)) {
1212
+ Logging.warn({
1213
+ storage: storageName,
1214
+ msg: `PostgreSQL reports ` + repaired.length.toString() + ` existing indexes as invalid, so they can't serve queries. Rebuilding them.`,
1215
+ indexes: repaired.map(schemaIndexName)
1216
+ });
1217
+ }
1218
+ if (missing.length !== 0) {
1219
+ Logging.info({
1220
+ storage: storageName,
1221
+ msg: `Creating the ` + missing.length.toString() + ` remaining schema indexes before the indexer reports ready. Writes are paused until they are committed. ` + slowOnLargeDatabaseNotice,
1222
+ indexes: missing.map(schemaIndexName)
1223
+ });
1224
+ } else {
1225
+ Logging.info({
1226
+ storage: storageName,
1227
+ msg: `All ` + schemaIndexes.length.toString() + ` schema indexes are already in place. Marking the indexer ready.`
1228
+ });
1229
+ }
1230
+ let indexQueries = missing.map(makeEnsureIndexQuery);
1231
+ let timeRef = Performance.now();
1232
+ await sql.begin(async sql => {
1233
+ for (let idx = 0, idx_finish = indexQueries.length; idx < idx_finish; ++idx) {
1234
+ await sql.unsafe(indexQueries[idx]);
1235
+ }
1236
+ return await sql.unsafe(InternalTable.Chains.makeSetReadyAtQuery(pgSchema), [
1237
+ readyAt,
1238
+ chainIds
1239
+ ], {prepare: true});
1240
+ });
1241
+ missing.forEach(schemaIndex => IndexRegistry.add(indexRegistry, schemaIndexKey(schemaIndex)));
1242
+ repaired.forEach(schemaIndex => IndexRegistry.clearInvalidName(indexRegistry, IndexRegistry.truncateIndexName(schemaIndexDescription(schemaIndex))));
1243
+ return Logging.info({
1244
+ storage: storageName,
1245
+ msg: `Committed ` + missing.length.toString() + ` schema indexes and the ready timestamp in ` + formatSeconds(timeRef) + `s.`
1246
+ });
1247
+ };
1048
1248
  let setOrThrow$1 = (items, table, itemSchema) => setOrThrow(sql, items, table, itemSchema, pgSchema);
1049
1249
  let setEffectCacheOrThrow = async (table, itemSchema, items, initialize) => {
1050
1250
  if (initialize) {
@@ -1121,6 +1321,7 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1121
1321
  sql.unsafe(InternalTable.Checkpoints.makeGetReorgCheckpointsQuery(pgSchema)),
1122
1322
  InternalTable.EnvioInfo.read(sql, pgSchema)
1123
1323
  ]);
1324
+ await reloadIndexRegistry();
1124
1325
  let checkpointId = BigInt(match[2][0].id);
1125
1326
  let reorgCheckpoints = match[3].map(raw => ({
1126
1327
  id: BigInt(raw.id),
@@ -1193,15 +1394,17 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1193
1394
  }
1194
1395
  let primaryTimerRef = Performance.now();
1195
1396
  await writeBatch(sql, batch, pgSchema, rollback, isInReorgThreshold, config, allEntities, setEffectCacheOrThrow, updatedEffectsCache, pgUpdates, sinkPromise, chainMetaData, undefined);
1196
- return onWrite("postgres", Performance.secondsSince(primaryTimerRef));
1397
+ return onWrite(storageName, Performance.secondsSince(primaryTimerRef));
1197
1398
  };
1198
1399
  let close = () => sql.end();
1199
1400
  return {
1200
- name: "postgres",
1401
+ name: storageName,
1201
1402
  isInitialized: isInitialized,
1202
1403
  initialize: initialize,
1203
1404
  resumeInitialState: resumeInitialState,
1204
1405
  loadOrThrow: loadOrThrow,
1406
+ ensureQueryIndexes: ensureQueryIndexes,
1407
+ finalizeBackfill: finalizeBackfill,
1205
1408
  dumpEffectCache: dumpEffectCache,
1206
1409
  reset: reset,
1207
1410
  setChainMeta: setChainMeta,
@@ -1271,7 +1474,17 @@ export {
1271
1474
  directionToSql,
1272
1475
  directionToIndexName,
1273
1476
  makeCreateCompositeIndexQuery,
1274
- makeCreateTableIndicesQuery,
1477
+ makeCreateTableIndexesQuery,
1478
+ schemaIndexDescription,
1479
+ schemaIndexName,
1480
+ schemaIndexKey,
1481
+ makeCreateSchemaIndexQuery,
1482
+ formatSeconds,
1483
+ slowOnLargeDatabaseNotice,
1484
+ makeReindexQuery,
1485
+ nameCollisionError,
1486
+ makeSingleColumnSchemaIndex,
1487
+ getSchemaIndexes,
1275
1488
  makeCreateTableQuery,
1276
1489
  entityHistoryCache,
1277
1490
  getEntityHistory,
@@ -485,6 +485,9 @@ let makeInMemoryStorage = (~state: testIndexerState): Persistence.storage => {
485
485
  state
486
486
  ->handleLoad(~tableName=table.tableName, ~filter)
487
487
  ->(Utils.magic: array<Internal.entity> => array<unknown>),
488
+ // The in-memory storage has no indexes to build, and it's always ready.
489
+ ensureQueryIndexes: async (~table as _, ~filters as _) => (),
490
+ finalizeBackfill: async (~entities as _, ~chainIds as _, ~readyAt as _) => (),
488
491
  writeBatch: async (
489
492
  ~batch,
490
493
  ~rollback as _,
@@ -315,6 +315,8 @@ function makeInMemoryStorage(state) {
315
315
  initialize: async (param, param$1, param$2, param$3) => Stdlib_JsError.throwWithMessage("TestIndexer: initialize should not be called; the initial state is derived from config."),
316
316
  resumeInitialState: async () => Stdlib_JsError.throwWithMessage("TestIndexer: resumeInitialState should not be called; the initial state is derived from config."),
317
317
  loadOrThrow: async (filter, table) => handleLoad(state, table.tableName, filter),
318
+ ensureQueryIndexes: async (param, param$1) => {},
319
+ finalizeBackfill: async (param, param$1, param$2) => {},
318
320
  dumpEffectCache: async () => {},
319
321
  reset: async () => {},
320
322
  setChainMeta: async param => {},
@@ -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 {
@@ -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
+ }