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.
- package/evm.schema.json +8 -1
- package/fuel.schema.json +7 -0
- package/index.d.ts +258 -5
- package/index.js +10 -0
- package/package.json +6 -6
- package/src/Bin.res +48 -0
- package/src/Bin.res.mjs +34 -1
- package/src/Config.res +12 -2
- package/src/Config.res.mjs +7 -0
- package/src/Env.res +141 -1
- package/src/Env.res.mjs +133 -1
- package/src/HandlerLoader.res +7 -13
- package/src/HandlerLoader.res.mjs +7 -14
- package/src/HandlerTypeCheck.res +207 -0
- package/src/HandlerTypeCheck.res.mjs +262 -0
- package/src/bindings/NodeJs.res +31 -0
- package/src/bindings/NodeJs.res.mjs +3 -0
- package/src/resolvers/ResolverProcess.res +805 -0
- package/src/resolvers/ResolverProcess.res.mjs +662 -0
- package/src/resolvers/ResolverQuery.res +313 -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 +259 -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 +333 -0
- package/src/resolvers/hasuraMetadata.js +137 -0
- package/src/resolvers/index.js +123 -0
- package/src/resolvers/manifest.js +376 -0
- package/src/resolvers/server.js +304 -0
- package/src/sources/EvmRpcClient.res +2 -0
- package/src/sources/RpcSource.res +17 -4
- package/src/sources/RpcSource.res.mjs +23 -5
- package/src/sources/SourceManager.res +63 -40
- package/src/sources/SourceManager.res.mjs +32 -20
- package/src/tui/Tui.res +1 -1
- package/src/tui/Tui.res.mjs +1 -1
- package/svm.schema.json +7 -0
- package/src/HandlerTypeCheck.mjs +0 -14
- 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
|
+
}
|