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.
- package/index.d.ts +10 -6
- package/package.json +6 -6
- package/src/BatchProcessing.res +27 -0
- package/src/BatchProcessing.res.mjs +12 -0
- package/src/ChainFetching.res +5 -5
- package/src/ChainFetching.res.mjs +3 -4
- package/src/ChainState.res +48 -25
- package/src/ChainState.res.mjs +23 -29
- package/src/ChainState.resi +2 -6
- package/src/Change.res +3 -3
- package/src/Config.res +2 -6
- package/src/Config.res.mjs +2 -12
- package/src/Core.res +3 -0
- package/src/CrossChainState.res +33 -10
- package/src/CrossChainState.res.mjs +21 -6
- package/src/CrossChainState.resi +3 -0
- package/src/EntityId.res +15 -0
- package/src/EntityId.res.mjs +9 -0
- package/src/EventConfigBuilder.res +1 -58
- package/src/EventConfigBuilder.res.mjs +1 -33
- package/src/FetchState.res +510 -460
- package/src/FetchState.res.mjs +413 -428
- package/src/FinalizeBackfill.res +34 -0
- package/src/FinalizeBackfill.res.mjs +26 -0
- package/src/HandlerRegister.res +45 -0
- package/src/HandlerRegister.res.mjs +43 -0
- package/src/InMemoryStore.res +11 -4
- package/src/InMemoryTable.res +26 -24
- package/src/InMemoryTable.res.mjs +19 -18
- package/src/IndexerState.res +71 -2
- package/src/IndexerState.res.mjs +67 -3
- package/src/IndexerState.resi +5 -0
- package/src/Internal.res +17 -8
- package/src/LoadLayer.res +9 -1
- package/src/LoadLayer.res.mjs +1 -0
- package/src/LogSelection.res +2 -1
- package/src/Metrics.res +37 -6
- package/src/Metrics.res.mjs +5 -1
- package/src/Persistence.res +12 -1
- package/src/PgStorage.res +411 -57
- package/src/PgStorage.res.mjs +262 -41
- package/src/SimulateItems.res +2 -2
- package/src/TestIndexer.res +9 -5
- package/src/TestIndexer.res.mjs +5 -2
- package/src/UserContext.res +3 -3
- package/src/Writing.res +12 -2
- package/src/Writing.res.mjs +13 -2
- package/src/bindings/ClickHouse.res +5 -3
- package/src/bindings/ClickHouse.res.mjs +2 -5
- package/src/bindings/Vitest.res +22 -1
- package/src/bindings/Vitest.res.mjs +15 -0
- package/src/db/EntityFilter.res +0 -4
- package/src/db/EntityFilter.res.mjs +1 -8
- package/src/db/EntityHistory.res +16 -7
- package/src/db/EntityHistory.res.mjs +7 -6
- package/src/db/IndexRegistry.res +219 -0
- package/src/db/IndexRegistry.res.mjs +195 -0
- package/src/db/InternalTable.res +7 -0
- package/src/db/InternalTable.res.mjs +7 -0
- package/src/db/Table.res +53 -22
- package/src/db/Table.res.mjs +56 -18
- package/src/sources/AddressSet.res +48 -0
- package/src/sources/AddressSet.res.mjs +11 -0
- package/src/sources/AddressStore.res +152 -0
- package/src/sources/AddressStore.res.mjs +97 -0
- package/src/sources/EvmChain.res +3 -0
- package/src/sources/EvmChain.res.mjs +4 -2
- package/src/sources/EvmHyperSyncSource.res +6 -3
- package/src/sources/EvmHyperSyncSource.res.mjs +3 -3
- package/src/sources/EvmRpcClient.res +6 -3
- package/src/sources/EvmRpcClient.res.mjs +3 -3
- package/src/sources/FuelHyperSync.res +4 -3
- package/src/sources/FuelHyperSync.res.mjs +3 -3
- package/src/sources/FuelHyperSync.resi +2 -1
- package/src/sources/FuelHyperSyncClient.res +12 -6
- package/src/sources/FuelHyperSyncClient.res.mjs +2 -2
- package/src/sources/FuelHyperSyncSource.res +7 -4
- package/src/sources/FuelHyperSyncSource.res.mjs +3 -3
- package/src/sources/HyperSync.res +6 -4
- package/src/sources/HyperSync.res.mjs +2 -3
- package/src/sources/HyperSync.resi +1 -1
- package/src/sources/HyperSyncClient.res +18 -9
- package/src/sources/HyperSyncClient.res.mjs +4 -4
- package/src/sources/RpcSource.res +15 -10
- package/src/sources/RpcSource.res.mjs +3 -4
- package/src/sources/SimulateSource.res +26 -11
- package/src/sources/SimulateSource.res.mjs +13 -5
- package/src/sources/Source.res +4 -2
- package/src/sources/SourceManager.res +2 -3
- package/src/sources/SourceManager.res.mjs +2 -3
- package/src/sources/Svm.res +1 -2
- package/src/sources/Svm.res.mjs +1 -1
- package/src/sources/SvmHyperSyncClient.res +12 -3
- package/src/sources/SvmHyperSyncClient.res.mjs +2 -2
- package/src/sources/SvmHyperSyncSource.res +10 -5
- package/src/sources/SvmHyperSyncSource.res.mjs +5 -4
- package/src/IndexingAddresses.res +0 -151
- package/src/IndexingAddresses.res.mjs +0 -130
- package/src/IndexingAddresses.resi +0 -39
package/src/Writing.res.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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,
|
|
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,
|
|
208
|
+
entityId: s.f(Table.idFieldName, Table.getIdSchema(entityConfig.table)),
|
|
212
209
|
checkpointId: s.f(EntityHistory.checkpointIdFieldName, EntityHistory.unsafeCheckpointIdSchema)
|
|
213
210
|
};
|
|
214
211
|
})
|
package/src/bindings/Vitest.res
CHANGED
|
@@ -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 */
|
package/src/db/EntityFilter.res
CHANGED
|
@@ -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
|
|
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 {
|
package/src/db/EntityHistory.res
CHANGED
|
@@ -30,12 +30,14 @@ let unsafeCheckpointIdSchema =
|
|
|
30
30
|
serializer: bigint => bigint->BigInt.toString,
|
|
31
31
|
})
|
|
32
32
|
|
|
33
|
-
let makeSetUpdateSchema: S.t<
|
|
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,
|
|
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::${
|
|
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 = (
|
|
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]->
|
|
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,
|
|
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::` +
|
|
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,
|
|
118
|
-
|
|
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
|
+
}
|