envio 3.7.0 → 3.9.0-resolvers.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/README.md +2 -2
- package/evm.schema.json +7 -0
- package/fuel.schema.json +7 -0
- package/index.d.ts +674 -161
- package/index.js +10 -0
- package/package.json +6 -7
- package/src/AddressRows.res +121 -0
- package/src/AddressRows.res.mjs +143 -0
- package/src/Batch.res +11 -0
- package/src/Batch.res.mjs +2 -1
- package/src/Bin.res +47 -0
- package/src/Bin.res.mjs +34 -1
- package/src/ChainState.res +108 -98
- package/src/ChainState.res.mjs +86 -67
- package/src/ChainState.resi +28 -15
- package/src/Config.res +61 -131
- package/src/Config.res.mjs +47 -79
- package/src/ContractMapping.res +66 -0
- package/src/ContractMapping.res.mjs +73 -0
- package/src/Core.res +24 -0
- package/src/Core.res.mjs +4 -0
- package/src/Ecosystem.res +7 -2
- package/src/Env.res +128 -1
- package/src/Env.res.mjs +123 -1
- package/src/Envio.res +54 -76
- package/src/EventConfigBuilder.res +316 -46
- package/src/EventConfigBuilder.res.mjs +231 -28
- package/src/FetchState.res +59 -67
- package/src/FetchState.res.mjs +34 -44
- package/src/HandlerRegister.res +26 -15
- package/src/HandlerRegister.res.mjs +27 -7
- package/src/Hasura.res +30 -22
- package/src/Hasura.res.mjs +11 -5
- package/src/InMemoryStore.res +23 -38
- package/src/InMemoryStore.res.mjs +13 -27
- package/src/IndexerState.res +40 -3
- package/src/IndexerState.res.mjs +30 -4
- package/src/IndexerState.resi +4 -1
- package/src/Internal.res +49 -30
- package/src/Internal.res.mjs +12 -16
- package/src/Main.res +27 -20
- package/src/Main.res.mjs +19 -14
- package/src/Metrics.res +15 -1
- package/src/Metrics.res.mjs +5 -1
- package/src/Persistence.res +46 -21
- package/src/Persistence.res.mjs +8 -5
- package/src/PgStorage.res +238 -79
- package/src/PgStorage.res.mjs +115 -73
- package/src/PruneStaleHistory.res +1 -1
- package/src/PruneStaleHistory.res.mjs +1 -1
- package/src/Rollback.res +62 -41
- package/src/Rollback.res.mjs +61 -19
- package/src/RollbackCommit.res +7 -9
- package/src/RollbackCommit.res.mjs +5 -6
- package/src/SimulateItems.res +254 -9
- package/src/SimulateItems.res.mjs +214 -47
- package/src/Sink.res +35 -26
- package/src/Sink.res.mjs +20 -16
- package/src/TestIndexer.res +121 -123
- package/src/TestIndexer.res.mjs +97 -102
- package/src/Utils.res +38 -0
- package/src/Utils.res.mjs +39 -2
- package/src/Writing.res +4 -2
- package/src/Writing.res.mjs +3 -2
- package/src/bindings/ClickHouse.res +311 -656
- package/src/bindings/ClickHouse.res.mjs +294 -395
- package/src/bindings/ClickHouseSink.res +303 -0
- package/src/bindings/ClickHouseSink.res.mjs +216 -0
- package/src/bindings/NodeJs.res +9 -0
- package/src/bindings/NodeJs.res.mjs +8 -1
- package/src/bindings/Postgres.res +9 -0
- package/src/bindings/Postgres.res.mjs +3 -0
- package/src/db/EntityHistory.res +29 -24
- package/src/db/EntityHistory.res.mjs +11 -19
- package/src/db/InternalTable.res +258 -83
- package/src/db/InternalTable.res.mjs +223 -85
- package/src/db/RollbackScope.res +29 -0
- package/src/db/RollbackScope.res.mjs +31 -0
- package/src/db/Table.res +28 -1
- package/src/db/Table.res.mjs +26 -2
- package/src/resolvers/ResolverProcess.res +809 -0
- package/src/resolvers/ResolverProcess.res.mjs +667 -0
- package/src/resolvers/ResolverQuery.res +317 -0
- package/src/resolvers/ResolverQuery.res.mjs +220 -0
- package/src/resolvers/collisions.js +108 -0
- package/src/resolvers/db.js +363 -0
- package/src/resolvers/dispatch.js +165 -0
- package/src/resolvers/errors.js +30 -0
- package/src/resolvers/graphqlSelection.js +292 -0
- package/src/resolvers/hasuraAction.js +84 -0
- package/src/resolvers/hasuraApply.js +323 -0
- package/src/resolvers/hasuraMetadata.js +119 -0
- package/src/resolvers/index.js +95 -0
- package/src/resolvers/manifest.js +370 -0
- package/src/resolvers/server.js +270 -0
- package/src/sources/AddressSet.res +0 -22
- package/src/sources/AddressStore.res +48 -88
- package/src/sources/AddressStore.res.mjs +11 -22
- package/src/sources/Evm.res +2 -0
- package/src/sources/Evm.res.mjs +2 -0
- package/src/sources/EvmHyperSyncSource.res +3 -2
- package/src/sources/Fuel.res +2 -0
- package/src/sources/Fuel.res.mjs +2 -0
- package/src/sources/SimulateSource.res +8 -4
- package/src/sources/SimulateSource.res.mjs +7 -3
- package/src/sources/Svm.res +36 -7
- package/src/sources/Svm.res.mjs +34 -4
- package/src/sources/SvmHyperSyncClient.res +20 -116
- package/src/sources/SvmHyperSyncClient.res.mjs +5 -7
- package/src/sources/SvmHyperSyncSource.res +89 -37
- package/src/sources/SvmHyperSyncSource.res.mjs +71 -39
- package/src/sources/TransactionStore.res +38 -0
- package/src/sources/TransactionStore.res.mjs +5 -0
- package/src/tui/Tui.res +1 -1
- package/src/tui/Tui.res.mjs +1 -1
- package/svm.schema.json +44 -156
- package/src/MemoryStorage.res +0 -724
- package/src/MemoryStorage.res.mjs +0 -601
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Recovers a resolver's selection tree from the operation text Hasura sends.
|
|
2
|
+
//
|
|
3
|
+
// Hasura hands an action handler `request_query`, the raw GraphQL document,
|
|
4
|
+
// and nothing else about what the caller asked for -- there is no parsed
|
|
5
|
+
// selection in the payload. A resolver that skips expensive work when a field
|
|
6
|
+
// is not requested therefore needs the document read back, which is what this
|
|
7
|
+
// does: names and nesting, aliases resolved to the declared name, fragments
|
|
8
|
+
// inlined, which is the same shape envio-serve resolves against the output
|
|
9
|
+
// type.
|
|
10
|
+
//
|
|
11
|
+
// The selection is an optimisation and never correctness -- a resolver that
|
|
12
|
+
// ignores it is still right, only slower. So every failure here answers with an
|
|
13
|
+
// empty tree, which means "assume everything was asked for", and no request is
|
|
14
|
+
// ever refused because its query could not be read.
|
|
15
|
+
|
|
16
|
+
// A document this size is not a client query. Bailing keeps a pathological
|
|
17
|
+
// body from being lexed token by token before the request is answered.
|
|
18
|
+
const MAX_QUERY_BYTES = 128 * 1024;
|
|
19
|
+
|
|
20
|
+
const NAME_START = /[_A-Za-z]/;
|
|
21
|
+
const NAME_CHAR = /[_0-9A-Za-z]/;
|
|
22
|
+
const DIGIT = /[0-9]/;
|
|
23
|
+
|
|
24
|
+
const PUNCTUATORS = new Set(["!", "$", "&", "(", ")", ":", "=", "@", "[", "]", "{", "|", "}"]);
|
|
25
|
+
|
|
26
|
+
// Lexed, so a brace inside a string argument is a character and not structure.
|
|
27
|
+
function lex(source) {
|
|
28
|
+
const tokens = [];
|
|
29
|
+
let i = 0;
|
|
30
|
+
const length = source.length;
|
|
31
|
+
|
|
32
|
+
while (i < length) {
|
|
33
|
+
const char = source[i];
|
|
34
|
+
|
|
35
|
+
if (char === "" || char === " " || char === "\t" || char === "\n" || char === "\r" || char === ",") {
|
|
36
|
+
i += 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (char === "#") {
|
|
41
|
+
while (i < length && source[i] !== "\n" && source[i] !== "\r") i += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (char === "." && source.startsWith("...", i)) {
|
|
46
|
+
tokens.push({ kind: "...", value: "..." });
|
|
47
|
+
i += 3;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (PUNCTUATORS.has(char)) {
|
|
52
|
+
tokens.push({ kind: char, value: char });
|
|
53
|
+
i += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (NAME_START.test(char)) {
|
|
58
|
+
let end = i + 1;
|
|
59
|
+
while (end < length && NAME_CHAR.test(source[end])) end += 1;
|
|
60
|
+
tokens.push({ kind: "name", value: source.slice(i, end) });
|
|
61
|
+
i = end;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (char === '"') {
|
|
66
|
+
if (source.startsWith('"""', i)) {
|
|
67
|
+
let end = i + 3;
|
|
68
|
+
while (end < length) {
|
|
69
|
+
if (source[end] === "\\" && source.startsWith('\\"""', end)) {
|
|
70
|
+
end += 4;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (source.startsWith('"""', end)) break;
|
|
74
|
+
end += 1;
|
|
75
|
+
}
|
|
76
|
+
if (end >= length) throw new Error("Unterminated block string");
|
|
77
|
+
tokens.push({ kind: "value", value: source.slice(i, end + 3) });
|
|
78
|
+
i = end + 3;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
let end = i + 1;
|
|
82
|
+
while (end < length && source[end] !== '"') {
|
|
83
|
+
end += source[end] === "\\" ? 2 : 1;
|
|
84
|
+
}
|
|
85
|
+
if (end >= length) throw new Error("Unterminated string");
|
|
86
|
+
tokens.push({ kind: "value", value: source.slice(i, end + 1) });
|
|
87
|
+
i = end + 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (DIGIT.test(char) || (char === "-" && DIGIT.test(source[i + 1] ?? ""))) {
|
|
92
|
+
let end = i + 1;
|
|
93
|
+
while (end < length && /[0-9eE.+-]/.test(source[end])) end += 1;
|
|
94
|
+
tokens.push({ kind: "value", value: source.slice(i, end) });
|
|
95
|
+
i = end;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(`Unexpected character '${char}'`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return tokens;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const OPERATIONS = new Set(["query", "mutation", "subscription"]);
|
|
106
|
+
|
|
107
|
+
class Parser {
|
|
108
|
+
constructor(tokens) {
|
|
109
|
+
this.tokens = tokens;
|
|
110
|
+
this.at = 0;
|
|
111
|
+
this.fragments = new Map();
|
|
112
|
+
this.operations = [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
peek(offset = 0) {
|
|
116
|
+
return this.tokens[this.at + offset];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
next() {
|
|
120
|
+
const token = this.tokens[this.at];
|
|
121
|
+
if (token === undefined) throw new Error("Unexpected end of document");
|
|
122
|
+
this.at += 1;
|
|
123
|
+
return token;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
expect(kind) {
|
|
127
|
+
const token = this.next();
|
|
128
|
+
if (token.kind !== kind) throw new Error(`Expected ${kind}, found ${token.kind}`);
|
|
129
|
+
return token;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
parseDocument() {
|
|
133
|
+
while (this.at < this.tokens.length) {
|
|
134
|
+
const token = this.peek();
|
|
135
|
+
if (token.kind === "{") {
|
|
136
|
+
this.operations.push(this.parseSelectionSet());
|
|
137
|
+
} else if (token.kind === "name" && OPERATIONS.has(token.value)) {
|
|
138
|
+
this.next();
|
|
139
|
+
if (this.peek()?.kind === "name") this.next();
|
|
140
|
+
this.skipParens();
|
|
141
|
+
this.skipDirectives();
|
|
142
|
+
this.operations.push(this.parseSelectionSet());
|
|
143
|
+
} else if (token.kind === "name" && token.value === "fragment") {
|
|
144
|
+
this.next();
|
|
145
|
+
const name = this.expect("name").value;
|
|
146
|
+
this.expect("name"); // `on`
|
|
147
|
+
this.parseTypeCondition();
|
|
148
|
+
this.skipDirectives();
|
|
149
|
+
this.fragments.set(name, this.parseSelectionSet());
|
|
150
|
+
} else {
|
|
151
|
+
throw new Error(`Unexpected ${token.kind} at top level`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A type condition is a single name, but `[T!]!`-shaped tokens appear in
|
|
158
|
+
// variable definitions handled by skipParens, so only the name form is here.
|
|
159
|
+
parseTypeCondition() {
|
|
160
|
+
this.expect("name");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
skipParens() {
|
|
164
|
+
if (this.peek()?.kind !== "(") return;
|
|
165
|
+
let depth = 0;
|
|
166
|
+
do {
|
|
167
|
+
const token = this.next();
|
|
168
|
+
if (token.kind === "(") depth += 1;
|
|
169
|
+
else if (token.kind === ")") depth -= 1;
|
|
170
|
+
else if (token.kind === "{") depth += 1;
|
|
171
|
+
else if (token.kind === "}") depth -= 1;
|
|
172
|
+
} while (depth > 0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
skipDirectives() {
|
|
176
|
+
while (this.peek()?.kind === "@") {
|
|
177
|
+
this.next();
|
|
178
|
+
this.expect("name");
|
|
179
|
+
this.skipParens();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
parseSelectionSet() {
|
|
184
|
+
this.expect("{");
|
|
185
|
+
const selections = [];
|
|
186
|
+
while (this.peek()?.kind !== "}") {
|
|
187
|
+
selections.push(this.parseSelection());
|
|
188
|
+
}
|
|
189
|
+
this.expect("}");
|
|
190
|
+
return selections;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
parseSelection() {
|
|
194
|
+
if (this.peek().kind === "...") {
|
|
195
|
+
this.next();
|
|
196
|
+
const token = this.peek();
|
|
197
|
+
if (token?.kind === "name" && token.value !== "on") {
|
|
198
|
+
this.next();
|
|
199
|
+
this.skipDirectives();
|
|
200
|
+
return { kind: "spread", name: token.value };
|
|
201
|
+
}
|
|
202
|
+
if (token?.kind === "name") {
|
|
203
|
+
this.next();
|
|
204
|
+
this.parseTypeCondition();
|
|
205
|
+
}
|
|
206
|
+
this.skipDirectives();
|
|
207
|
+
return { kind: "inline", selections: this.parseSelectionSet() };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let name = this.expect("name").value;
|
|
211
|
+
if (this.peek()?.kind === ":") {
|
|
212
|
+
// An alias renames the field for the caller; the resolver only knows the
|
|
213
|
+
// name it declared, so the alias is dropped rather than reported.
|
|
214
|
+
this.next();
|
|
215
|
+
name = this.expect("name").value;
|
|
216
|
+
}
|
|
217
|
+
this.skipParens();
|
|
218
|
+
this.skipDirectives();
|
|
219
|
+
const selections = this.peek()?.kind === "{" ? this.parseSelectionSet() : null;
|
|
220
|
+
return { kind: "field", name, selections };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function collectFields(selections, fragments, seenFragments, into) {
|
|
225
|
+
for (const selection of selections) {
|
|
226
|
+
if (selection.kind === "field") {
|
|
227
|
+
if (selection.name === "__typename") continue;
|
|
228
|
+
const existing = into[selection.name] ?? {};
|
|
229
|
+
into[selection.name] = existing;
|
|
230
|
+
if (selection.selections !== null) {
|
|
231
|
+
collectFields(selection.selections, fragments, seenFragments, existing);
|
|
232
|
+
}
|
|
233
|
+
} else if (selection.kind === "inline") {
|
|
234
|
+
collectFields(selection.selections, fragments, seenFragments, into);
|
|
235
|
+
} else if (!seenFragments.has(selection.name)) {
|
|
236
|
+
const fragment = fragments.get(selection.name);
|
|
237
|
+
if (fragment !== undefined) {
|
|
238
|
+
seenFragments.add(selection.name);
|
|
239
|
+
collectFields(fragment, fragments, seenFragments, into);
|
|
240
|
+
seenFragments.delete(selection.name);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return into;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function findField(selections, fragments, fieldName, seenFragments, into) {
|
|
248
|
+
for (const selection of selections) {
|
|
249
|
+
if (selection.kind === "field") {
|
|
250
|
+
if (selection.name === fieldName && selection.selections !== null) {
|
|
251
|
+
collectFields(selection.selections, fragments, new Set(), into);
|
|
252
|
+
} else if (selection.selections !== null) {
|
|
253
|
+
findField(selection.selections, fragments, fieldName, seenFragments, into);
|
|
254
|
+
}
|
|
255
|
+
} else if (selection.kind === "inline") {
|
|
256
|
+
findField(selection.selections, fragments, fieldName, seenFragments, into);
|
|
257
|
+
} else if (!seenFragments.has(selection.name)) {
|
|
258
|
+
const fragment = fragments.get(selection.name);
|
|
259
|
+
if (fragment !== undefined) {
|
|
260
|
+
seenFragments.add(selection.name);
|
|
261
|
+
findField(fragment, fragments, fieldName, seenFragments, into);
|
|
262
|
+
seenFragments.delete(selection.name);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return into;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The selection tree for `fieldName`, as names and nesting.
|
|
271
|
+
*
|
|
272
|
+
* Hasura sends no operation name, so a document with more than one operation
|
|
273
|
+
* cannot be narrowed to the one that ran. Every field of that name is merged
|
|
274
|
+
* instead: a superset means a resolver does work it could have skipped, where
|
|
275
|
+
* a subset would have it skip work that was asked for.
|
|
276
|
+
*/
|
|
277
|
+
export function selectionFromRequestQuery(requestQuery, fieldName) {
|
|
278
|
+
if (typeof requestQuery !== "string" || requestQuery.length === 0) return {};
|
|
279
|
+
if (requestQuery.length > MAX_QUERY_BYTES) return {};
|
|
280
|
+
try {
|
|
281
|
+
const document = new Parser(lex(requestQuery)).parseDocument();
|
|
282
|
+
return findField(
|
|
283
|
+
document.operations.flat(),
|
|
284
|
+
document.fragments,
|
|
285
|
+
fieldName,
|
|
286
|
+
new Set(),
|
|
287
|
+
{}
|
|
288
|
+
);
|
|
289
|
+
} catch {
|
|
290
|
+
return {};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Hasura's action contract, translated to and from the dispatcher's.
|
|
2
|
+
//
|
|
3
|
+
// The two differ in the ways that matter to a client. Hasura splices the
|
|
4
|
+
// response body straight in as the field's value, so a result is returned bare
|
|
5
|
+
// rather than under `data`, and a failure is a status code rather than an
|
|
6
|
+
// `errors` array -- a 200 is taken as success whatever the body says.
|
|
7
|
+
//
|
|
8
|
+
// Pure, so the mapping is testable without a socket; `server.js` does the I/O.
|
|
9
|
+
|
|
10
|
+
// Hasura documents 4xx as *the* way a handler reports failure. A 5xx risks
|
|
11
|
+
// being reported as an unreachable webhook instead of the message below, so a
|
|
12
|
+
// resolver's intended status travels in `extensions.http.status` and every
|
|
13
|
+
// error leaves here as a 4xx.
|
|
14
|
+
const STATUS_BY_CODE = {
|
|
15
|
+
BAD_REQUEST: 400,
|
|
16
|
+
BAD_USER_INPUT: 400,
|
|
17
|
+
FORBIDDEN: 403,
|
|
18
|
+
RESOLVER_NOT_FOUND: 404,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const DEFAULT_ERROR_STATUS = 400;
|
|
22
|
+
|
|
23
|
+
import { selectionFromRequestQuery } from "./graphqlSelection.js";
|
|
24
|
+
|
|
25
|
+
const isPlainObject = (value) =>
|
|
26
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validates a decoded action payload.
|
|
30
|
+
* Returns null when it is well formed.
|
|
31
|
+
*/
|
|
32
|
+
export function badActionRequest(payload) {
|
|
33
|
+
if (!isPlainObject(payload)) {
|
|
34
|
+
return "expected a JSON object";
|
|
35
|
+
}
|
|
36
|
+
if (!isPlainObject(payload.action) || typeof payload.action.name !== "string" || payload.action.name.length === 0) {
|
|
37
|
+
return "`action.name` must be a non-empty string";
|
|
38
|
+
}
|
|
39
|
+
if (payload.input !== undefined && !isPlainObject(payload.input)) {
|
|
40
|
+
return "`input` must be an object";
|
|
41
|
+
}
|
|
42
|
+
if (payload.session_variables !== undefined && !isPlainObject(payload.session_variables)) {
|
|
43
|
+
return "`session_variables` must be an object";
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Turns a validated action payload into the dispatcher's request.
|
|
50
|
+
*
|
|
51
|
+
* `requestId` is minted by the caller: Hasura sends none, and without one a
|
|
52
|
+
* failure in the logs cannot be tied back to the request that caused it.
|
|
53
|
+
*/
|
|
54
|
+
export function toResolveRequest(payload, requestId) {
|
|
55
|
+
const role = payload.session_variables?.["x-hasura-role"];
|
|
56
|
+
return {
|
|
57
|
+
field: payload.action.name,
|
|
58
|
+
args: payload.input ?? {},
|
|
59
|
+
selection: selectionFromRequestQuery(payload.request_query, payload.action.name),
|
|
60
|
+
// Hasura roles are arbitrary strings, and the dispatcher's are not. Only
|
|
61
|
+
// `admin` grants admin, so an unrecognised role is treated as public
|
|
62
|
+
// rather than rejected -- the same answer an anonymous caller would get.
|
|
63
|
+
role: role === "admin" ? "admin" : "public",
|
|
64
|
+
requestId,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Turns a dispatcher answer into the status and body Hasura expects. */
|
|
69
|
+
export function toActionResponse(answer) {
|
|
70
|
+
const failure = answer.errors?.[0];
|
|
71
|
+
if (failure !== undefined) {
|
|
72
|
+
const extensions = failure.extensions ?? {};
|
|
73
|
+
return {
|
|
74
|
+
status: STATUS_BY_CODE[extensions.code] ?? DEFAULT_ERROR_STATUS,
|
|
75
|
+
body: { message: failure.message, extensions },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { status: 200, body: answer.data ?? null };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The error body for a request that never reached the dispatcher. */
|
|
82
|
+
export function actionErrorBody(message, code) {
|
|
83
|
+
return { message, extensions: { code } };
|
|
84
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// Puts the resolver metadata into Hasura, and keeps it there.
|
|
2
|
+
//
|
|
3
|
+
// Applying is a read then, only if something differs, one write. The write
|
|
4
|
+
// reloads Hasura's schema cache, so a re-assert that fired every minute
|
|
5
|
+
// regardless would disrupt a busy Hasura sixty times an hour to change nothing.
|
|
6
|
+
//
|
|
7
|
+
// Re-asserting at all is not defensive. `Hasura.trackDatabase` opens with a
|
|
8
|
+
// wholesale `clear_metadata`, so any re-initialised indexer silently deletes
|
|
9
|
+
// these actions while this service keeps running and Hasura keeps answering
|
|
10
|
+
// that the field does not exist.
|
|
11
|
+
|
|
12
|
+
// Hasura's own defaults, applied when reading its metadata back: `export_metadata`
|
|
13
|
+
// omits whatever matches them, so a comparison against what we sent has to put
|
|
14
|
+
// them back or every read reports drift.
|
|
15
|
+
const DEFAULT_TIMEOUT_SECONDS = 30;
|
|
16
|
+
|
|
17
|
+
// Well inside the 60s re-assert interval: an apply that outlived that would
|
|
18
|
+
// have the loop skip ticks it can never catch up on.
|
|
19
|
+
const DEFAULT_METADATA_TIMEOUT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
22
|
+
|
|
23
|
+
function normaliseAction(action) {
|
|
24
|
+
const definition = action.definition ?? {};
|
|
25
|
+
return {
|
|
26
|
+
comment: action.comment ?? null,
|
|
27
|
+
// Absent means mutation, which is never what a resolver wants -- so an
|
|
28
|
+
// action missing it is drift, not a match.
|
|
29
|
+
type: definition.type ?? "mutation",
|
|
30
|
+
kind: definition.kind ?? "synchronous",
|
|
31
|
+
handler: definition.handler ?? null,
|
|
32
|
+
output_type: definition.output_type ?? null,
|
|
33
|
+
timeout: definition.timeout ?? DEFAULT_TIMEOUT_SECONDS,
|
|
34
|
+
arguments: (definition.arguments ?? []).map((argument) => ({
|
|
35
|
+
name: argument.name,
|
|
36
|
+
type: argument.type,
|
|
37
|
+
})),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const normaliseFields = (types = []) =>
|
|
42
|
+
types
|
|
43
|
+
.map((type) => ({
|
|
44
|
+
name: type.name,
|
|
45
|
+
fields: (type.fields ?? []).map((field) => ({ name: field.name, type: field.type })),
|
|
46
|
+
}))
|
|
47
|
+
.sort(byName);
|
|
48
|
+
|
|
49
|
+
function normaliseCustomTypes(customTypes = {}) {
|
|
50
|
+
return {
|
|
51
|
+
scalars: (customTypes.scalars ?? []).map(({ name }) => ({ name })).sort(byName),
|
|
52
|
+
enums: (customTypes.enums ?? [])
|
|
53
|
+
.map((type) => ({
|
|
54
|
+
name: type.name,
|
|
55
|
+
// Hasura hands enum values back with a null description and
|
|
56
|
+
// is_deprecated it was never given.
|
|
57
|
+
values: (type.values ?? []).map(({ value }) => value),
|
|
58
|
+
}))
|
|
59
|
+
.sort(byName),
|
|
60
|
+
input_objects: normaliseFields(customTypes.input_objects),
|
|
61
|
+
objects: normaliseFields(customTypes.objects),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
66
|
+
|
|
67
|
+
const actionArgs = (action) => ({
|
|
68
|
+
name: action.name,
|
|
69
|
+
...(action.comment === undefined ? {} : { comment: action.comment }),
|
|
70
|
+
definition: action.definition,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The single metadata call that moves Hasura from `exported` to `metadata`,
|
|
75
|
+
* and the reasons it is needed. `bulk` is null when nothing differs.
|
|
76
|
+
*
|
|
77
|
+
* `healOnly` restricts it to restoring what has been *deleted*, leaving
|
|
78
|
+
* anything that merely differs alone. A rolling update runs two versions at
|
|
79
|
+
* once: the new one updates the actions to its definitions, and a periodic
|
|
80
|
+
* re-assert that called that drift would write the old ones straight back,
|
|
81
|
+
* flapping the published schema until the rollout finished. Reconciling
|
|
82
|
+
* definitions is startup's job, where only one version is speaking.
|
|
83
|
+
*/
|
|
84
|
+
export function planApply(metadata, exported, { healOnly = false } = {}) {
|
|
85
|
+
const current = new Map((exported?.actions ?? []).map((action) => [action.name, action]));
|
|
86
|
+
|
|
87
|
+
if (healOnly) {
|
|
88
|
+
const wanted = metadata.actions;
|
|
89
|
+
// Only a Hasura holding *none* of ours is the `clear_metadata` this heals.
|
|
90
|
+
// Some present and some not is what a newer deployment looks like mid-roll:
|
|
91
|
+
// it renamed one, and restoring our whole set over it would trade the
|
|
92
|
+
// published schema back and forth for the length of the rollout. A single
|
|
93
|
+
// action dropped by hand is therefore not healed until the next start,
|
|
94
|
+
// which is the price of never fighting a deployment that is ahead of us.
|
|
95
|
+
if (wanted.length === 0 || wanted.some((action) => current.has(action.name))) {
|
|
96
|
+
return { bulk: null, reasons: [] };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const desired = new Map(metadata.actions.map((action) => [action.name, action]));
|
|
101
|
+
const desiredRoles = new Map(metadata.actions.map((action) => [action.name, new Set()]));
|
|
102
|
+
for (const permission of metadata.permissions) {
|
|
103
|
+
desiredRoles.get(permission.action)?.add(permission.role);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const reasons = [];
|
|
107
|
+
const drops = [];
|
|
108
|
+
const writes = [];
|
|
109
|
+
const permissionDrops = [];
|
|
110
|
+
const permissionCreates = [];
|
|
111
|
+
|
|
112
|
+
for (const [name, action] of current) {
|
|
113
|
+
if (!desired.has(name)) {
|
|
114
|
+
// Left in place, Hasura keeps publishing a field whose handler no longer
|
|
115
|
+
// knows the name -- every call to it fails at the resolver instead of
|
|
116
|
+
// being absent from the schema. This service is the only thing that
|
|
117
|
+
// creates actions on an indexer's Hasura, so an action it does not
|
|
118
|
+
// declare is one it removed.
|
|
119
|
+
reasons.push(`action '${name}' is no longer declared`);
|
|
120
|
+
drops.push({ type: "drop_action", args: { name } });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const [name, action] of desired) {
|
|
125
|
+
const existing = current.get(name);
|
|
126
|
+
if (existing === undefined) {
|
|
127
|
+
reasons.push(`action '${name}' is missing`);
|
|
128
|
+
writes.push({ type: "create_action", args: actionArgs(action) });
|
|
129
|
+
} else if (!same(normaliseAction(existing), normaliseAction(action))) {
|
|
130
|
+
reasons.push(`action '${name}' differs`);
|
|
131
|
+
writes.push({ type: "update_action", args: actionArgs(action) });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const wanted = desiredRoles.get(name) ?? new Set();
|
|
135
|
+
const held = new Set((existing?.permissions ?? []).map((permission) => permission.role));
|
|
136
|
+
for (const role of held) {
|
|
137
|
+
if (!wanted.has(role)) {
|
|
138
|
+
reasons.push(`action '${name}' should not be readable by '${role}'`);
|
|
139
|
+
// Asymmetric with create_action_permission, which calls it `action`.
|
|
140
|
+
permissionDrops.push({ type: "drop_action_permission", args: { name, role } });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const role of wanted) {
|
|
144
|
+
if (!held.has(role)) {
|
|
145
|
+
reasons.push(`action '${name}' is not readable by '${role}'`);
|
|
146
|
+
permissionCreates.push({
|
|
147
|
+
type: "create_action_permission",
|
|
148
|
+
args: { action: name, role },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const desiredTypes = metadata.customTypes;
|
|
155
|
+
const currentTypes = exported?.custom_types ?? {};
|
|
156
|
+
const typesDiffer = !same(
|
|
157
|
+
normaliseCustomTypes(currentTypes),
|
|
158
|
+
normaliseCustomTypes(desiredTypes)
|
|
159
|
+
);
|
|
160
|
+
if (typesDiffer) {
|
|
161
|
+
reasons.push("custom types differ");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (reasons.length === 0) {
|
|
165
|
+
return { bulk: null, reasons };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// `set_custom_types` replaces the whole block and is validated the moment it
|
|
169
|
+
// runs, not at the end of the bulk. A type being added has to exist before
|
|
170
|
+
// the action that names it, and a type being removed has to outlive the
|
|
171
|
+
// action that named it -- opposite orders. The union satisfies both: it is
|
|
172
|
+
// asserted first, so it adds everything new while keeping everything old,
|
|
173
|
+
// and the desired set follows the actions only when something is dropped.
|
|
174
|
+
const union = unionCustomTypes(currentTypes, desiredTypes);
|
|
175
|
+
const args = [];
|
|
176
|
+
if (typesDiffer) {
|
|
177
|
+
args.push({ type: "set_custom_types", args: union });
|
|
178
|
+
}
|
|
179
|
+
args.push(...drops, ...writes, ...permissionDrops, ...permissionCreates);
|
|
180
|
+
if (typesDiffer && !same(normaliseCustomTypes(union), normaliseCustomTypes(desiredTypes))) {
|
|
181
|
+
args.push({ type: "set_custom_types", args: desiredTypes });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { bulk: { type: "bulk", args }, reasons };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function unionCustomTypes(current, desired) {
|
|
188
|
+
const merge = (key) => {
|
|
189
|
+
const wanted = desired[key] ?? [];
|
|
190
|
+
const names = new Set(wanted.map((type) => type.name));
|
|
191
|
+
const kept = (current[key] ?? []).filter((type) => !names.has(type.name));
|
|
192
|
+
return [...wanted, ...kept];
|
|
193
|
+
};
|
|
194
|
+
return {
|
|
195
|
+
scalars: merge("scalars"),
|
|
196
|
+
enums: merge("enums"),
|
|
197
|
+
input_objects: merge("input_objects"),
|
|
198
|
+
objects: merge("objects"),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function metadataCall({ endpoint, adminSecret, role = "admin", signal }, body) {
|
|
203
|
+
try {
|
|
204
|
+
const response = await fetch(endpoint, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
"content-type": "application/json",
|
|
208
|
+
"x-hasura-role": role,
|
|
209
|
+
"x-hasura-admin-secret": adminSecret,
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify(body),
|
|
212
|
+
signal,
|
|
213
|
+
});
|
|
214
|
+
let answer = null;
|
|
215
|
+
let unreadable = null;
|
|
216
|
+
try {
|
|
217
|
+
answer = await response.json();
|
|
218
|
+
} catch (error) {
|
|
219
|
+
unreadable = error;
|
|
220
|
+
}
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Hasura metadata call '${body.type}' failed with ${response.status}: ${JSON.stringify(answer)}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
// A 2xx whose body could not be read is not an empty Hasura. Answering
|
|
227
|
+
// `null` here would have `planApply` see metadata holding none of our
|
|
228
|
+
// actions and recreate the lot against a Hasura that already has them.
|
|
229
|
+
if (unreadable !== null) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Hasura metadata call '${body.type}' answered ${response.status} with a body that could not be read: ${unreadable.message}`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return answer;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (error?.name === "AbortError") {
|
|
237
|
+
throw new Error(`Hasura metadata call '${body.type}' gave up on an unfinished response`);
|
|
238
|
+
}
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Brings Hasura's metadata in line with the manifest.
|
|
245
|
+
*
|
|
246
|
+
* Idempotent by construction: what to write is decided from what Hasura reports
|
|
247
|
+
* it already has, so applying twice makes one call the second time and changes
|
|
248
|
+
* nothing.
|
|
249
|
+
*/
|
|
250
|
+
export async function applyResolverMetadata({
|
|
251
|
+
endpoint,
|
|
252
|
+
adminSecret,
|
|
253
|
+
role,
|
|
254
|
+
metadata,
|
|
255
|
+
healOnly = false,
|
|
256
|
+
timeoutMs = DEFAULT_METADATA_TIMEOUT_MS,
|
|
257
|
+
}) {
|
|
258
|
+
// One deadline for the apply, not one per call: an apply is an export and
|
|
259
|
+
// then a bulk, so a per-call bound lets a slow Hasura hold this for twice
|
|
260
|
+
// the number the caller passed. The re-assert loop skips every tick while it
|
|
261
|
+
// runs, so the guard against overlapping applies would stop them happening.
|
|
262
|
+
// The signal covers reading each body too, not just its headers -- a Hasura
|
|
263
|
+
// that writes a 200 and then stops leaves `response.json()` pending for good.
|
|
264
|
+
const controller = new AbortController();
|
|
265
|
+
const expiry = setTimeout(() => controller.abort(), timeoutMs);
|
|
266
|
+
const connection = { endpoint, adminSecret, role, signal: controller.signal };
|
|
267
|
+
try {
|
|
268
|
+
const exported = await metadataCall(connection, { type: "export_metadata", args: {} });
|
|
269
|
+
const { bulk, reasons } = planApply(metadata, exported, { healOnly });
|
|
270
|
+
if (bulk === null) {
|
|
271
|
+
return { applied: false, reasons };
|
|
272
|
+
}
|
|
273
|
+
await metadataCall(connection, bulk);
|
|
274
|
+
return { applied: true, reasons };
|
|
275
|
+
} finally {
|
|
276
|
+
clearTimeout(expiry);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Re-asserts the metadata on an interval, returning a function that stops it.
|
|
282
|
+
*
|
|
283
|
+
* `intervalMs: 0` disables it, for a deployment where something else owns the
|
|
284
|
+
* metadata.
|
|
285
|
+
*/
|
|
286
|
+
export function startMetadataReassert({
|
|
287
|
+
endpoint,
|
|
288
|
+
adminSecret,
|
|
289
|
+
role,
|
|
290
|
+
metadata,
|
|
291
|
+
intervalMs,
|
|
292
|
+
timeoutMs,
|
|
293
|
+
onApplied,
|
|
294
|
+
onError,
|
|
295
|
+
}) {
|
|
296
|
+
if (!intervalMs || intervalMs <= 0) {
|
|
297
|
+
return () => {};
|
|
298
|
+
}
|
|
299
|
+
let running = false;
|
|
300
|
+
const timer = setInterval(async () => {
|
|
301
|
+
// A slow Hasura must not stack up overlapping applies.
|
|
302
|
+
if (running) return;
|
|
303
|
+
running = true;
|
|
304
|
+
try {
|
|
305
|
+
const { applied, reasons } = await applyResolverMetadata({
|
|
306
|
+
endpoint,
|
|
307
|
+
adminSecret,
|
|
308
|
+
role,
|
|
309
|
+
metadata,
|
|
310
|
+
healOnly: true,
|
|
311
|
+
timeoutMs,
|
|
312
|
+
});
|
|
313
|
+
if (applied) onApplied?.(reasons);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
onError?.(error);
|
|
316
|
+
} finally {
|
|
317
|
+
running = false;
|
|
318
|
+
}
|
|
319
|
+
}, intervalMs);
|
|
320
|
+
// The loop must never be the reason the process stays alive.
|
|
321
|
+
timer.unref?.();
|
|
322
|
+
return () => clearInterval(timer);
|
|
323
|
+
}
|