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.
Files changed (118) hide show
  1. package/README.md +2 -2
  2. package/evm.schema.json +7 -0
  3. package/fuel.schema.json +7 -0
  4. package/index.d.ts +674 -161
  5. package/index.js +10 -0
  6. package/package.json +6 -7
  7. package/src/AddressRows.res +121 -0
  8. package/src/AddressRows.res.mjs +143 -0
  9. package/src/Batch.res +11 -0
  10. package/src/Batch.res.mjs +2 -1
  11. package/src/Bin.res +47 -0
  12. package/src/Bin.res.mjs +34 -1
  13. package/src/ChainState.res +108 -98
  14. package/src/ChainState.res.mjs +86 -67
  15. package/src/ChainState.resi +28 -15
  16. package/src/Config.res +61 -131
  17. package/src/Config.res.mjs +47 -79
  18. package/src/ContractMapping.res +66 -0
  19. package/src/ContractMapping.res.mjs +73 -0
  20. package/src/Core.res +24 -0
  21. package/src/Core.res.mjs +4 -0
  22. package/src/Ecosystem.res +7 -2
  23. package/src/Env.res +128 -1
  24. package/src/Env.res.mjs +123 -1
  25. package/src/Envio.res +54 -76
  26. package/src/EventConfigBuilder.res +316 -46
  27. package/src/EventConfigBuilder.res.mjs +231 -28
  28. package/src/FetchState.res +59 -67
  29. package/src/FetchState.res.mjs +34 -44
  30. package/src/HandlerRegister.res +26 -15
  31. package/src/HandlerRegister.res.mjs +27 -7
  32. package/src/Hasura.res +30 -22
  33. package/src/Hasura.res.mjs +11 -5
  34. package/src/InMemoryStore.res +23 -38
  35. package/src/InMemoryStore.res.mjs +13 -27
  36. package/src/IndexerState.res +40 -3
  37. package/src/IndexerState.res.mjs +30 -4
  38. package/src/IndexerState.resi +4 -1
  39. package/src/Internal.res +49 -30
  40. package/src/Internal.res.mjs +12 -16
  41. package/src/Main.res +27 -20
  42. package/src/Main.res.mjs +19 -14
  43. package/src/Metrics.res +15 -1
  44. package/src/Metrics.res.mjs +5 -1
  45. package/src/Persistence.res +46 -21
  46. package/src/Persistence.res.mjs +8 -5
  47. package/src/PgStorage.res +238 -79
  48. package/src/PgStorage.res.mjs +115 -73
  49. package/src/PruneStaleHistory.res +1 -1
  50. package/src/PruneStaleHistory.res.mjs +1 -1
  51. package/src/Rollback.res +62 -41
  52. package/src/Rollback.res.mjs +61 -19
  53. package/src/RollbackCommit.res +7 -9
  54. package/src/RollbackCommit.res.mjs +5 -6
  55. package/src/SimulateItems.res +254 -9
  56. package/src/SimulateItems.res.mjs +214 -47
  57. package/src/Sink.res +35 -26
  58. package/src/Sink.res.mjs +20 -16
  59. package/src/TestIndexer.res +121 -123
  60. package/src/TestIndexer.res.mjs +97 -102
  61. package/src/Utils.res +38 -0
  62. package/src/Utils.res.mjs +39 -2
  63. package/src/Writing.res +4 -2
  64. package/src/Writing.res.mjs +3 -2
  65. package/src/bindings/ClickHouse.res +311 -656
  66. package/src/bindings/ClickHouse.res.mjs +294 -395
  67. package/src/bindings/ClickHouseSink.res +303 -0
  68. package/src/bindings/ClickHouseSink.res.mjs +216 -0
  69. package/src/bindings/NodeJs.res +9 -0
  70. package/src/bindings/NodeJs.res.mjs +8 -1
  71. package/src/bindings/Postgres.res +9 -0
  72. package/src/bindings/Postgres.res.mjs +3 -0
  73. package/src/db/EntityHistory.res +29 -24
  74. package/src/db/EntityHistory.res.mjs +11 -19
  75. package/src/db/InternalTable.res +258 -83
  76. package/src/db/InternalTable.res.mjs +223 -85
  77. package/src/db/RollbackScope.res +29 -0
  78. package/src/db/RollbackScope.res.mjs +31 -0
  79. package/src/db/Table.res +28 -1
  80. package/src/db/Table.res.mjs +26 -2
  81. package/src/resolvers/ResolverProcess.res +809 -0
  82. package/src/resolvers/ResolverProcess.res.mjs +667 -0
  83. package/src/resolvers/ResolverQuery.res +317 -0
  84. package/src/resolvers/ResolverQuery.res.mjs +220 -0
  85. package/src/resolvers/collisions.js +108 -0
  86. package/src/resolvers/db.js +363 -0
  87. package/src/resolvers/dispatch.js +165 -0
  88. package/src/resolvers/errors.js +30 -0
  89. package/src/resolvers/graphqlSelection.js +292 -0
  90. package/src/resolvers/hasuraAction.js +84 -0
  91. package/src/resolvers/hasuraApply.js +323 -0
  92. package/src/resolvers/hasuraMetadata.js +119 -0
  93. package/src/resolvers/index.js +95 -0
  94. package/src/resolvers/manifest.js +370 -0
  95. package/src/resolvers/server.js +270 -0
  96. package/src/sources/AddressSet.res +0 -22
  97. package/src/sources/AddressStore.res +48 -88
  98. package/src/sources/AddressStore.res.mjs +11 -22
  99. package/src/sources/Evm.res +2 -0
  100. package/src/sources/Evm.res.mjs +2 -0
  101. package/src/sources/EvmHyperSyncSource.res +3 -2
  102. package/src/sources/Fuel.res +2 -0
  103. package/src/sources/Fuel.res.mjs +2 -0
  104. package/src/sources/SimulateSource.res +8 -4
  105. package/src/sources/SimulateSource.res.mjs +7 -3
  106. package/src/sources/Svm.res +36 -7
  107. package/src/sources/Svm.res.mjs +34 -4
  108. package/src/sources/SvmHyperSyncClient.res +20 -116
  109. package/src/sources/SvmHyperSyncClient.res.mjs +5 -7
  110. package/src/sources/SvmHyperSyncSource.res +89 -37
  111. package/src/sources/SvmHyperSyncSource.res.mjs +71 -39
  112. package/src/sources/TransactionStore.res +38 -0
  113. package/src/sources/TransactionStore.res.mjs +5 -0
  114. package/src/tui/Tui.res +1 -1
  115. package/src/tui/Tui.res.mjs +1 -1
  116. package/svm.schema.json +44 -156
  117. package/src/MemoryStorage.res +0 -724
  118. 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
+ }