envio 3.14.0 → 3.14.1-resolvers.2

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 (42) hide show
  1. package/evm.schema.json +8 -1
  2. package/fuel.schema.json +7 -0
  3. package/index.d.ts +258 -5
  4. package/index.js +10 -0
  5. package/package.json +6 -6
  6. package/src/Bin.res +48 -0
  7. package/src/Bin.res.mjs +34 -1
  8. package/src/Config.res +12 -2
  9. package/src/Config.res.mjs +7 -0
  10. package/src/Env.res +141 -1
  11. package/src/Env.res.mjs +133 -1
  12. package/src/HandlerLoader.res +7 -13
  13. package/src/HandlerLoader.res.mjs +7 -14
  14. package/src/HandlerTypeCheck.res +207 -0
  15. package/src/HandlerTypeCheck.res.mjs +262 -0
  16. package/src/bindings/NodeJs.res +31 -0
  17. package/src/bindings/NodeJs.res.mjs +3 -0
  18. package/src/resolvers/ResolverProcess.res +805 -0
  19. package/src/resolvers/ResolverProcess.res.mjs +662 -0
  20. package/src/resolvers/ResolverQuery.res +313 -0
  21. package/src/resolvers/ResolverQuery.res.mjs +220 -0
  22. package/src/resolvers/collisions.js +108 -0
  23. package/src/resolvers/db.js +363 -0
  24. package/src/resolvers/dispatch.js +259 -0
  25. package/src/resolvers/errors.js +30 -0
  26. package/src/resolvers/graphqlSelection.js +292 -0
  27. package/src/resolvers/hasuraAction.js +84 -0
  28. package/src/resolvers/hasuraApply.js +333 -0
  29. package/src/resolvers/hasuraMetadata.js +137 -0
  30. package/src/resolvers/index.js +123 -0
  31. package/src/resolvers/manifest.js +376 -0
  32. package/src/resolvers/server.js +304 -0
  33. package/src/sources/EvmRpcClient.res +2 -0
  34. package/src/sources/RpcSource.res +17 -4
  35. package/src/sources/RpcSource.res.mjs +23 -5
  36. package/src/sources/SourceManager.res +63 -40
  37. package/src/sources/SourceManager.res.mjs +32 -20
  38. package/src/tui/Tui.res +1 -1
  39. package/src/tui/Tui.res.mjs +1 -1
  40. package/svm.schema.json +7 -0
  41. package/src/HandlerTypeCheck.mjs +0 -14
  42. package/src/HandlerTypeCheckWorker.mjs +0 -68
@@ -0,0 +1,333 @@
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
+ // Compared, so turning forwarding on for an action that already exists is
35
+ // seen as drift and actually applied. Without it the flag would only ever
36
+ // be right on a freshly created action.
37
+ forward_client_headers: definition.forward_client_headers === true,
38
+ // The value Hasura presents is what the service checks, so a rotated secret
39
+ // that is not written through leaves Hasura vouching with the old one while
40
+ // every resolver refuses every request.
41
+ headers: (definition.headers ?? [])
42
+ .map((header) => ({ name: header.name.toLowerCase(), value: header.value ?? null }))
43
+ .sort((a, b) => a.name.localeCompare(b.name)),
44
+ arguments: (definition.arguments ?? []).map((argument) => ({
45
+ name: argument.name,
46
+ type: argument.type,
47
+ })),
48
+ };
49
+ }
50
+
51
+ const normaliseFields = (types = []) =>
52
+ types
53
+ .map((type) => ({
54
+ name: type.name,
55
+ fields: (type.fields ?? []).map((field) => ({ name: field.name, type: field.type })),
56
+ }))
57
+ .sort(byName);
58
+
59
+ function normaliseCustomTypes(customTypes = {}) {
60
+ return {
61
+ scalars: (customTypes.scalars ?? []).map(({ name }) => ({ name })).sort(byName),
62
+ enums: (customTypes.enums ?? [])
63
+ .map((type) => ({
64
+ name: type.name,
65
+ // Hasura hands enum values back with a null description and
66
+ // is_deprecated it was never given.
67
+ values: (type.values ?? []).map(({ value }) => value),
68
+ }))
69
+ .sort(byName),
70
+ input_objects: normaliseFields(customTypes.input_objects),
71
+ objects: normaliseFields(customTypes.objects),
72
+ };
73
+ }
74
+
75
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
76
+
77
+ const actionArgs = (action) => ({
78
+ name: action.name,
79
+ ...(action.comment === undefined ? {} : { comment: action.comment }),
80
+ definition: action.definition,
81
+ });
82
+
83
+ /**
84
+ * The single metadata call that moves Hasura from `exported` to `metadata`,
85
+ * and the reasons it is needed. `bulk` is null when nothing differs.
86
+ *
87
+ * `healOnly` restricts it to restoring what has been *deleted*, leaving
88
+ * anything that merely differs alone. A rolling update runs two versions at
89
+ * once: the new one updates the actions to its definitions, and a periodic
90
+ * re-assert that called that drift would write the old ones straight back,
91
+ * flapping the published schema until the rollout finished. Reconciling
92
+ * definitions is startup's job, where only one version is speaking.
93
+ */
94
+ export function planApply(metadata, exported, { healOnly = false } = {}) {
95
+ const current = new Map((exported?.actions ?? []).map((action) => [action.name, action]));
96
+
97
+ if (healOnly) {
98
+ const wanted = metadata.actions;
99
+ // Only a Hasura holding *none* of ours is the `clear_metadata` this heals.
100
+ // Some present and some not is what a newer deployment looks like mid-roll:
101
+ // it renamed one, and restoring our whole set over it would trade the
102
+ // published schema back and forth for the length of the rollout. A single
103
+ // action dropped by hand is therefore not healed until the next start,
104
+ // which is the price of never fighting a deployment that is ahead of us.
105
+ if (wanted.length === 0 || wanted.some((action) => current.has(action.name))) {
106
+ return { bulk: null, reasons: [] };
107
+ }
108
+ }
109
+
110
+ const desired = new Map(metadata.actions.map((action) => [action.name, action]));
111
+ const desiredRoles = new Map(metadata.actions.map((action) => [action.name, new Set()]));
112
+ for (const permission of metadata.permissions) {
113
+ desiredRoles.get(permission.action)?.add(permission.role);
114
+ }
115
+
116
+ const reasons = [];
117
+ const drops = [];
118
+ const writes = [];
119
+ const permissionDrops = [];
120
+ const permissionCreates = [];
121
+
122
+ for (const [name, action] of current) {
123
+ if (!desired.has(name)) {
124
+ // Left in place, Hasura keeps publishing a field whose handler no longer
125
+ // knows the name -- every call to it fails at the resolver instead of
126
+ // being absent from the schema. This service is the only thing that
127
+ // creates actions on an indexer's Hasura, so an action it does not
128
+ // declare is one it removed.
129
+ reasons.push(`action '${name}' is no longer declared`);
130
+ drops.push({ type: "drop_action", args: { name } });
131
+ }
132
+ }
133
+
134
+ for (const [name, action] of desired) {
135
+ const existing = current.get(name);
136
+ if (existing === undefined) {
137
+ reasons.push(`action '${name}' is missing`);
138
+ writes.push({ type: "create_action", args: actionArgs(action) });
139
+ } else if (!same(normaliseAction(existing), normaliseAction(action))) {
140
+ reasons.push(`action '${name}' differs`);
141
+ writes.push({ type: "update_action", args: actionArgs(action) });
142
+ }
143
+
144
+ const wanted = desiredRoles.get(name) ?? new Set();
145
+ const held = new Set((existing?.permissions ?? []).map((permission) => permission.role));
146
+ for (const role of held) {
147
+ if (!wanted.has(role)) {
148
+ reasons.push(`action '${name}' should not be readable by '${role}'`);
149
+ // Asymmetric with create_action_permission, which calls it `action`.
150
+ permissionDrops.push({ type: "drop_action_permission", args: { name, role } });
151
+ }
152
+ }
153
+ for (const role of wanted) {
154
+ if (!held.has(role)) {
155
+ reasons.push(`action '${name}' is not readable by '${role}'`);
156
+ permissionCreates.push({
157
+ type: "create_action_permission",
158
+ args: { action: name, role },
159
+ });
160
+ }
161
+ }
162
+ }
163
+
164
+ const desiredTypes = metadata.customTypes;
165
+ const currentTypes = exported?.custom_types ?? {};
166
+ const typesDiffer = !same(
167
+ normaliseCustomTypes(currentTypes),
168
+ normaliseCustomTypes(desiredTypes)
169
+ );
170
+ if (typesDiffer) {
171
+ reasons.push("custom types differ");
172
+ }
173
+
174
+ if (reasons.length === 0) {
175
+ return { bulk: null, reasons };
176
+ }
177
+
178
+ // `set_custom_types` replaces the whole block and is validated the moment it
179
+ // runs, not at the end of the bulk. A type being added has to exist before
180
+ // the action that names it, and a type being removed has to outlive the
181
+ // action that named it -- opposite orders. The union satisfies both: it is
182
+ // asserted first, so it adds everything new while keeping everything old,
183
+ // and the desired set follows the actions only when something is dropped.
184
+ const union = unionCustomTypes(currentTypes, desiredTypes);
185
+ const args = [];
186
+ if (typesDiffer) {
187
+ args.push({ type: "set_custom_types", args: union });
188
+ }
189
+ args.push(...drops, ...writes, ...permissionDrops, ...permissionCreates);
190
+ if (typesDiffer && !same(normaliseCustomTypes(union), normaliseCustomTypes(desiredTypes))) {
191
+ args.push({ type: "set_custom_types", args: desiredTypes });
192
+ }
193
+
194
+ return { bulk: { type: "bulk", args }, reasons };
195
+ }
196
+
197
+ function unionCustomTypes(current, desired) {
198
+ const merge = (key) => {
199
+ const wanted = desired[key] ?? [];
200
+ const names = new Set(wanted.map((type) => type.name));
201
+ const kept = (current[key] ?? []).filter((type) => !names.has(type.name));
202
+ return [...wanted, ...kept];
203
+ };
204
+ return {
205
+ scalars: merge("scalars"),
206
+ enums: merge("enums"),
207
+ input_objects: merge("input_objects"),
208
+ objects: merge("objects"),
209
+ };
210
+ }
211
+
212
+ async function metadataCall({ endpoint, adminSecret, role = "admin", signal }, body) {
213
+ try {
214
+ const response = await fetch(endpoint, {
215
+ method: "POST",
216
+ headers: {
217
+ "content-type": "application/json",
218
+ "x-hasura-role": role,
219
+ "x-hasura-admin-secret": adminSecret,
220
+ },
221
+ body: JSON.stringify(body),
222
+ signal,
223
+ });
224
+ let answer = null;
225
+ let unreadable = null;
226
+ try {
227
+ answer = await response.json();
228
+ } catch (error) {
229
+ unreadable = error;
230
+ }
231
+ if (!response.ok) {
232
+ throw new Error(
233
+ `Hasura metadata call '${body.type}' failed with ${response.status}: ${JSON.stringify(answer)}`
234
+ );
235
+ }
236
+ // A 2xx whose body could not be read is not an empty Hasura. Answering
237
+ // `null` here would have `planApply` see metadata holding none of our
238
+ // actions and recreate the lot against a Hasura that already has them.
239
+ if (unreadable !== null) {
240
+ throw new Error(
241
+ `Hasura metadata call '${body.type}' answered ${response.status} with a body that could not be read: ${unreadable.message}`
242
+ );
243
+ }
244
+ return answer;
245
+ } catch (error) {
246
+ if (error?.name === "AbortError") {
247
+ throw new Error(`Hasura metadata call '${body.type}' gave up on an unfinished response`);
248
+ }
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Brings Hasura's metadata in line with the manifest.
255
+ *
256
+ * Idempotent by construction: what to write is decided from what Hasura reports
257
+ * it already has, so applying twice makes one call the second time and changes
258
+ * nothing.
259
+ */
260
+ export async function applyResolverMetadata({
261
+ endpoint,
262
+ adminSecret,
263
+ role,
264
+ metadata,
265
+ healOnly = false,
266
+ timeoutMs = DEFAULT_METADATA_TIMEOUT_MS,
267
+ }) {
268
+ // One deadline for the apply, not one per call: an apply is an export and
269
+ // then a bulk, so a per-call bound lets a slow Hasura hold this for twice
270
+ // the number the caller passed. The re-assert loop skips every tick while it
271
+ // runs, so the guard against overlapping applies would stop them happening.
272
+ // The signal covers reading each body too, not just its headers -- a Hasura
273
+ // that writes a 200 and then stops leaves `response.json()` pending for good.
274
+ const controller = new AbortController();
275
+ const expiry = setTimeout(() => controller.abort(), timeoutMs);
276
+ const connection = { endpoint, adminSecret, role, signal: controller.signal };
277
+ try {
278
+ const exported = await metadataCall(connection, { type: "export_metadata", args: {} });
279
+ const { bulk, reasons } = planApply(metadata, exported, { healOnly });
280
+ if (bulk === null) {
281
+ return { applied: false, reasons };
282
+ }
283
+ await metadataCall(connection, bulk);
284
+ return { applied: true, reasons };
285
+ } finally {
286
+ clearTimeout(expiry);
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Re-asserts the metadata on an interval, returning a function that stops it.
292
+ *
293
+ * `intervalMs: 0` disables it, for a deployment where something else owns the
294
+ * metadata.
295
+ */
296
+ export function startMetadataReassert({
297
+ endpoint,
298
+ adminSecret,
299
+ role,
300
+ metadata,
301
+ intervalMs,
302
+ timeoutMs,
303
+ onApplied,
304
+ onError,
305
+ }) {
306
+ if (!intervalMs || intervalMs <= 0) {
307
+ return () => {};
308
+ }
309
+ let running = false;
310
+ const timer = setInterval(async () => {
311
+ // A slow Hasura must not stack up overlapping applies.
312
+ if (running) return;
313
+ running = true;
314
+ try {
315
+ const { applied, reasons } = await applyResolverMetadata({
316
+ endpoint,
317
+ adminSecret,
318
+ role,
319
+ metadata,
320
+ healOnly: true,
321
+ timeoutMs,
322
+ });
323
+ if (applied) onApplied?.(reasons);
324
+ } catch (error) {
325
+ onError?.(error);
326
+ } finally {
327
+ running = false;
328
+ }
329
+ }, intervalMs);
330
+ // The loop must never be the reason the process stays alive.
331
+ timer.unref?.();
332
+ return () => clearInterval(timer);
333
+ }
@@ -0,0 +1,137 @@
1
+ // The manifest, expressed as the Hasura metadata that makes each resolver a
2
+ // queryable root field.
3
+ //
4
+ // Hasura cannot execute user code, so an action is a declaration: the field's
5
+ // name, its arguments, its result type, and a URL to POST to. Everything here
6
+ // is derived from the manifest `envio codegen` already writes, so the schema
7
+ // Hasura publishes and the code that answers it come from one source.
8
+ //
9
+ // Pure on purpose -- manifest in, JSON out, no network. Applying it is
10
+ // `hasuraApply.js`; keeping the two apart is what makes the shape testable
11
+ // without a Hasura to point at.
12
+
13
+ // Hasura's own; anything else a resolver names has to be declared.
14
+ const BUILTIN_SCALARS = new Set(["String", "Int", "Float", "Boolean", "ID"]);
15
+
16
+ /** The header Hasura presents so the handler can tell it apart from anyone else
17
+ * who can reach the socket.
18
+ *
19
+ * `x-hasura-` prefixed on purpose. A private action forwards the caller's
20
+ * headers, and a forwarded header replaces the action's static one of the same
21
+ * name rather than merging with it -- so under any other name a caller could
22
+ * set this and the handler would refuse every request. Hasura takes a client
23
+ * `x-hasura-*` header as a session variable and strips it before forwarding,
24
+ * so under this one the caller's copy never arrives as a header at all. It
25
+ * does arrive in `session_variables`, which is why the secret is only ever
26
+ * read from the header. */
27
+ export const RESOLVER_SECRET_HEADER = "x-hasura-envio-resolver-secret";
28
+
29
+ /** Where a caller presents the key that unlocks a `private` resolver. */
30
+ export const PRIVATE_KEY_HEADER = "x-envio-private-key";
31
+
32
+ /**
33
+ * @param manifest the parsed `.envio/resolvers.json`
34
+ * @param handlerUrl the URL *Hasura* posts to -- reachable from Hasura, which
35
+ * is not necessarily the address the service binds
36
+ * @param publicRole the role a non-admin caller runs as (Hasura's
37
+ * `unauthorized_role`)
38
+ */
39
+ export function buildHasuraMetadata(manifest, { handlerUrl, publicRole = "public", actionSecret }) {
40
+ if (!handlerUrl) {
41
+ throw new Error(
42
+ "buildHasuraMetadata requires a handlerUrl: it is baked into every action, and Hasura has no other way to reach the resolvers."
43
+ );
44
+ }
45
+
46
+ const scalars = [];
47
+ const enums = [];
48
+ const objects = [];
49
+ const inputObjects = [];
50
+
51
+ for (const type of manifest.types ?? []) {
52
+ switch (type.kind) {
53
+ case "scalar":
54
+ // A resolver declaring `String` as a custom scalar would shadow
55
+ // Hasura's own and make the metadata inconsistent.
56
+ if (BUILTIN_SCALARS.has(type.name)) {
57
+ throw new Error(
58
+ `Custom scalar '${type.name}' collides with a GraphQL built-in scalar.`
59
+ );
60
+ }
61
+ scalars.push({ name: type.name });
62
+ break;
63
+ case "enum":
64
+ enums.push({
65
+ name: type.name,
66
+ // The manifest names an enum value `name`; Hasura calls it `value`.
67
+ values: type.values.map(({ name }) => ({ value: name })),
68
+ });
69
+ break;
70
+ case "object":
71
+ objects.push({ name: type.name, fields: type.fields });
72
+ break;
73
+ case "input_object":
74
+ inputObjects.push({ name: type.name, fields: type.fields });
75
+ break;
76
+ default:
77
+ // Emitting a partial set would leave Hasura with an action whose types
78
+ // are missing, which fails at apply time with a worse message than this.
79
+ throw new Error(
80
+ `Cannot express manifest type '${type.name}' of kind '${type.kind}' as a Hasura custom type.`
81
+ );
82
+ }
83
+ }
84
+
85
+ const actions = [];
86
+ const permissions = [];
87
+
88
+ for (const resolver of manifest.resolvers ?? []) {
89
+ const definition = {
90
+ // An action is a mutation unless it says otherwise, and a resolver is a
91
+ // read: the manifest's own SDL says `extend type Query`.
92
+ type: "query",
93
+ kind: "synchronous",
94
+ handler: handlerUrl,
95
+ arguments: resolver.args ?? [],
96
+ output_type: resolver.type,
97
+ // Hasura's timeout is whole seconds and bounds the whole HTTP call; the
98
+ // resolver's own `timeoutMs` bounds only the queries inside it. Acquiring
99
+ // a connection, parsing arguments and serializing the result spend
100
+ // Hasura's budget without spending the resolver's, so the extra second is
101
+ // what keeps Hasura from being the first to give up on a request the
102
+ // resolver still considers live -- which the client would see as an
103
+ // unreachable webhook rather than as a timeout.
104
+ timeout: Math.ceil(resolver.timeoutMs / 1000) + 1,
105
+ // The role reaches the handler inside the request body, so on its own it
106
+ // is the caller's claim rather than a fact. This header is the only thing
107
+ // that makes it one: Hasura sends it because the action declares it, and
108
+ // the value is literal, so Hasura itself needs no configuration.
109
+ ...(actionSecret
110
+ ? { headers: [{ name: RESOLVER_SECRET_HEADER, value: actionSecret }] }
111
+ : {}),
112
+ // A private resolver authenticates its caller here, not at Hasura, so the
113
+ // caller's own headers have to survive the hop. Hasura's own default
114
+ // ignore list is left alone: naming any header here would replace that
115
+ // default wholesale and pin it to whatever this version happens to ignore.
116
+ ...(resolver.private ? { forward_client_headers: true } : {}),
117
+ };
118
+ const action = { name: resolver.name, definition };
119
+ if (resolver.description) {
120
+ action.comment = resolver.description;
121
+ }
122
+ actions.push(action);
123
+
124
+ // Every resolver is granted to the public role, private ones included:
125
+ // without the permission Hasura refuses to route the call at all, and then
126
+ // there is nothing for the key check to run against. Being routable is not
127
+ // being reachable -- a private resolver still refuses anyone who cannot
128
+ // present a key.
129
+ permissions.push({ action: resolver.name, role: publicRole });
130
+ }
131
+
132
+ return {
133
+ customTypes: { scalars, enums, input_objects: inputObjects, objects },
134
+ actions,
135
+ permissions,
136
+ };
137
+ }
@@ -0,0 +1,123 @@
1
+ // The user-facing resolver API.
2
+ //
3
+ // Declaring a resolver registers it, the same way importing a handler module
4
+ // registers its handlers. `envio resolvers manifest` imports the project's
5
+ // resolver module and reads the registry back out; the runtime reads the same
6
+ // registry to dispatch. One registration, two consumers, no separate export
7
+ // list for the user to keep in sync.
8
+
9
+ import { buildManifest, toSDL } from "./manifest.js";
10
+
11
+ export { ResolverError } from "./errors.js";
12
+
13
+ export {
14
+ buildManifest,
15
+ defineEnum,
16
+ defineInput,
17
+ defineScalar,
18
+ defineType,
19
+ toSDL,
20
+ SCHEMA_VERSION,
21
+ } from "./manifest.js";
22
+
23
+ const registry = [];
24
+
25
+ /**
26
+ * Declares a custom GraphQL resolver.
27
+ *
28
+ * Validation happens here rather than at manifest time so the error points at
29
+ * the declaration that caused it, while the stack still says which file it
30
+ * came from.
31
+ */
32
+ export function createResolver(options) {
33
+ if (!options || typeof options !== "object") {
34
+ throw new Error("createResolver expects an options object");
35
+ }
36
+ const { name, output, handler, timeoutMs } = options;
37
+
38
+ if (typeof name !== "string" || name.length === 0) {
39
+ throw new Error("createResolver requires a non-empty `name`");
40
+ }
41
+ if (!output || typeof output.t === "undefined") {
42
+ throw new Error(
43
+ `Resolver '${name}' requires an \`output\` schema, e.g. output: S.array(MyType)`
44
+ );
45
+ }
46
+ if (typeof handler !== "function") {
47
+ throw new Error(`Resolver '${name}' requires a \`handler\` function`);
48
+ }
49
+ if (options.maxBlocksBehind !== undefined) {
50
+ const whole = (value) =>
51
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
52
+ const limit = options.maxBlocksBehind;
53
+ const ok =
54
+ whole(limit) ||
55
+ (typeof limit === "object" &&
56
+ limit !== null &&
57
+ !Array.isArray(limit) &&
58
+ Object.keys(limit).length > 0 &&
59
+ // Looked up as `limit[chain.chainId]`, so only the canonical decimal
60
+ // spelling ever matches: " 1" and "1e3" are numbers to `Number` but
61
+ // name no chain, and would leave the resolver silently ungated.
62
+ Object.entries(limit).every(
63
+ ([chainId, value]) =>
64
+ whole(Number(chainId)) && String(Number(chainId)) === chainId && whole(value)
65
+ ));
66
+ if (!ok) {
67
+ throw new Error(
68
+ `Resolver '${name}' has an invalid \`maxBlocksBehind\`. It is how far behind head this resolver will still answer, so it is either a whole number of blocks applying to every chain, or an object of chainId to blocks naming the chains it cares about.`
69
+ );
70
+ }
71
+ }
72
+ if (typeof timeoutMs !== "number" || timeoutMs <= 0) {
73
+ throw new Error(
74
+ `Resolver '${name}' requires a positive \`timeoutMs\`. It becomes the statement_timeout on every query the resolver makes, which is the only thing bounding a runaway one.`
75
+ );
76
+ }
77
+ if (registry.some((r) => r.name === name)) {
78
+ throw new Error(`Resolver '${name}' is declared more than once`);
79
+ }
80
+
81
+ const resolver = {
82
+ name,
83
+ // Passed through so `buildManifest` below refuses it with a message that
84
+ // names the resolver, rather than it being silently dropped here.
85
+ cacheTtlMs: options.cacheTtlMs,
86
+ description: options.description,
87
+ args: options.args ?? {},
88
+ output,
89
+ // `admin` is the old spelling. It named a Hasura role that is no longer how
90
+ // these are reached, so `private` is the name now; both mean the same
91
+ // thing -- published so Hasura will route to it, and refused at the service
92
+ // unless the caller presents a key.
93
+ private: options.private === true || options.admin === true,
94
+ maxBlocksBehind: options.maxBlocksBehind,
95
+ timeoutMs,
96
+ handler,
97
+ };
98
+ // Built before registering, not after: a declaration whose schemas cannot
99
+ // be represented in GraphQL must leave the registry untouched, or the
100
+ // failure follows every later build. Doing it here at all is what surfaces
101
+ // the problem at the declaration rather than at the end of codegen, where
102
+ // the stack no longer says which resolver was at fault.
103
+ buildManifest([resolver]);
104
+
105
+ registry.push(resolver);
106
+ return resolver;
107
+ }
108
+
109
+ /** Every resolver declared so far, in declaration order. */
110
+ export function getRegisteredResolvers() {
111
+ return [...registry];
112
+ }
113
+
114
+ /** Test-only: forgets every declaration. */
115
+ export function resetResolvers() {
116
+ registry.length = 0;
117
+ }
118
+
119
+ /** The manifest and SDL for everything declared so far. */
120
+ export function buildRegisteredManifest() {
121
+ const manifest = buildManifest(registry);
122
+ return { manifest, sdl: toSDL(manifest) };
123
+ }