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,363 @@
|
|
|
1
|
+
// The `db` handle a resolver's handler is given.
|
|
2
|
+
//
|
|
3
|
+
// Three things it owns that a bare postgres client does not:
|
|
4
|
+
//
|
|
5
|
+
// 1. A `statement_timeout` on every query, taken from the resolver's own
|
|
6
|
+
// `timeoutMs`. It is the only thing that actually bounds a runaway query
|
|
7
|
+
// -- a pooler's `query_timeout`, where there is one, drops the client
|
|
8
|
+
// without cancelling the backend -- so a handle cannot be made without
|
|
9
|
+
// one.
|
|
10
|
+
// 2. A bounded pool with a bounded *wait*, sized as concurrent heavy
|
|
11
|
+
// requests x per-request fan-out rather than by CPU count: a single
|
|
12
|
+
// resolver holding four connections at once is the shape this exists for.
|
|
13
|
+
// 3. Entity loaders that go through the indexer's own table definitions, so
|
|
14
|
+
// what a resolver reads matches what a handler wrote.
|
|
15
|
+
//
|
|
16
|
+
// SQL construction and row decoding live in `ResolverQuery.res` because both
|
|
17
|
+
// are driven by those table definitions.
|
|
18
|
+
|
|
19
|
+
import postgres from "postgres";
|
|
20
|
+
import { Db as DbEnv, Resolvers as ResolversEnv } from "../Env.res.mjs";
|
|
21
|
+
import { ResolverError } from "./errors.js";
|
|
22
|
+
import { warn as logWarn } from "../Logging.res.mjs";
|
|
23
|
+
import {
|
|
24
|
+
decodeChainHeights,
|
|
25
|
+
decodeRows,
|
|
26
|
+
makeChainHeightsQuery,
|
|
27
|
+
makeFindQuery,
|
|
28
|
+
makeGetQuery,
|
|
29
|
+
} from "./ResolverQuery.res.mjs";
|
|
30
|
+
|
|
31
|
+
// Design §7.7: concurrent heavy requests x per-request fan-out, not serve's
|
|
32
|
+
// `min(cpus*2, 10)`. The fan-out arithmetic is sound; the tail latency it
|
|
33
|
+
// assumes is not measured, so the first real workload should re-derive it.
|
|
34
|
+
const DEFAULT_POOL_SIZE = 25;
|
|
35
|
+
|
|
36
|
+
const DEFAULT_POOL_WAIT_TIMEOUT_MS = 10_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A failure of the resolver's own database access — the pool being saturated,
|
|
40
|
+
* a query hitting its statement_timeout, an entity that isn't in the schema.
|
|
41
|
+
*
|
|
42
|
+
* A `ResolverError`, so its code reaches the client: §7.4 asks for pool
|
|
43
|
+
* exhaustion to surface as "a clean per-field error", and a caller that cannot
|
|
44
|
+
* tell saturation from a bug cannot back off and retry. None of these carry
|
|
45
|
+
* driver internals; a raw Postgres error still has its message withheld.
|
|
46
|
+
*/
|
|
47
|
+
export class ResolverDbError extends ResolverError {
|
|
48
|
+
constructor(message, code, options = {}) {
|
|
49
|
+
super(message, { code, httpStatus: options.httpStatus ?? 503 });
|
|
50
|
+
this.name = "ResolverDbError";
|
|
51
|
+
// The driver's own error, kept so a handler that wants the SQLSTATE can
|
|
52
|
+
// still reach it. It is not put on the wire.
|
|
53
|
+
if (options.cause !== undefined) this.cause = options.cause;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Bounds concurrency in front of the driver rather than inside it. postgres.js
|
|
58
|
+
// queues indefinitely once its pool is full; a resolver answering a request
|
|
59
|
+
// needs the wait itself bounded, so a saturated pool fails that field cleanly
|
|
60
|
+
// instead of holding the whole operation open.
|
|
61
|
+
class Gate {
|
|
62
|
+
constructor(size) {
|
|
63
|
+
this.size = size;
|
|
64
|
+
this.inUse = 0;
|
|
65
|
+
this.peakInUse = 0;
|
|
66
|
+
this.queue = [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
take() {
|
|
70
|
+
this.inUse++;
|
|
71
|
+
if (this.inUse > this.peakInUse) {
|
|
72
|
+
this.peakInUse = this.inUse;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
acquire(waitMs) {
|
|
77
|
+
if (this.inUse < this.size) {
|
|
78
|
+
this.take();
|
|
79
|
+
return Promise.resolve();
|
|
80
|
+
}
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const entry = { resolve, timer: null };
|
|
83
|
+
entry.timer = setTimeout(() => {
|
|
84
|
+
const index = this.queue.indexOf(entry);
|
|
85
|
+
if (index !== -1) {
|
|
86
|
+
this.queue.splice(index, 1);
|
|
87
|
+
}
|
|
88
|
+
reject(
|
|
89
|
+
new ResolverDbError(
|
|
90
|
+
`Timed out after ${waitMs}ms waiting for one of the resolver pool's ${this.size} connections.`,
|
|
91
|
+
"POOL_WAIT_TIMEOUT"
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
}, waitMs);
|
|
95
|
+
this.queue.push(entry);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
release() {
|
|
100
|
+
this.inUse--;
|
|
101
|
+
const next = this.queue.shift();
|
|
102
|
+
if (next) {
|
|
103
|
+
clearTimeout(next.timer);
|
|
104
|
+
this.take();
|
|
105
|
+
next.resolve();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function requirePositiveInt(value, describe) {
|
|
111
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
112
|
+
throw new ResolverDbError(describe, "INVALID_OPTION");
|
|
113
|
+
}
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Opens the resolver process's database pool. One per process: the handles
|
|
119
|
+
* `forResolver` hands out share its connections and its bound on them.
|
|
120
|
+
*/
|
|
121
|
+
export function createResolverPool(options) {
|
|
122
|
+
if (!options || typeof options !== "object") {
|
|
123
|
+
throw new ResolverDbError(
|
|
124
|
+
"createResolverPool expects an options object",
|
|
125
|
+
"INVALID_OPTION"
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const { connection, entities, pgSchema, poolerBacked, onWarn } = options;
|
|
129
|
+
|
|
130
|
+
if (!connection || typeof connection !== "object") {
|
|
131
|
+
throw new ResolverDbError(
|
|
132
|
+
"createResolverPool requires a `connection` with host, port, username, password and database",
|
|
133
|
+
"INVALID_OPTION"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (!entities || typeof entities !== "object") {
|
|
137
|
+
throw new ResolverDbError(
|
|
138
|
+
"createResolverPool requires `entities`, the project's entity configs keyed by name",
|
|
139
|
+
"INVALID_OPTION"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (typeof pgSchema !== "string" || pgSchema.length === 0) {
|
|
143
|
+
throw new ResolverDbError(
|
|
144
|
+
"createResolverPool requires a `pgSchema`",
|
|
145
|
+
"INVALID_OPTION"
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const poolSize = requirePositiveInt(
|
|
150
|
+
options.poolSize ?? DEFAULT_POOL_SIZE,
|
|
151
|
+
"createResolverPool `poolSize` must be a positive whole number"
|
|
152
|
+
);
|
|
153
|
+
const poolWaitTimeoutMs = requirePositiveInt(
|
|
154
|
+
options.poolWaitTimeoutMs ?? DEFAULT_POOL_WAIT_TIMEOUT_MS,
|
|
155
|
+
"createResolverPool `poolWaitTimeoutMs` must be a positive whole number"
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// A transaction-mode pooler (PgBouncer and friends) rejects named prepared
|
|
159
|
+
// statements, so that path gives up plan reuse; connecting straight to
|
|
160
|
+
// Postgres keeps it. The per-query timeout is `SET LOCAL` on *both* paths: a
|
|
161
|
+
// single pool serves every resolver, and each brings its own `timeoutMs`.
|
|
162
|
+
const prepare = poolerBacked !== true;
|
|
163
|
+
|
|
164
|
+
const sql = postgres({
|
|
165
|
+
host: connection.host,
|
|
166
|
+
port: connection.port,
|
|
167
|
+
username: connection.username,
|
|
168
|
+
password: connection.password,
|
|
169
|
+
database: connection.database,
|
|
170
|
+
ssl: connection.ssl,
|
|
171
|
+
max: poolSize,
|
|
172
|
+
// `statement_timeout` bounds a query, not the TCP connect and handshake in
|
|
173
|
+
// front of it, so an unreachable database would otherwise hold a request
|
|
174
|
+
// open far longer than the resolver's own `timeoutMs`. Seconds, and matched
|
|
175
|
+
// to the pool wait so a request fails at roughly the same point either way.
|
|
176
|
+
connect_timeout: Math.ceil(poolWaitTimeoutMs / 1000),
|
|
177
|
+
prepare,
|
|
178
|
+
transform: { undefined: null },
|
|
179
|
+
onnotice: () => {},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const warn = typeof onWarn === "function" ? onWarn : logWarn;
|
|
183
|
+
const gate = new Gate(poolSize);
|
|
184
|
+
// Kept on the pool, not the handle: a handle is per request, so warning
|
|
185
|
+
// there would repeat the same line for every request a wide resolver serves.
|
|
186
|
+
const warnedResolvers = new Set();
|
|
187
|
+
// One resolver holding a quarter of the pool is the fan-out that breaks
|
|
188
|
+
// naive sizing, and it is silent otherwise: the queries all succeed, right
|
|
189
|
+
// up until concurrent requests turn it into pool-wait timeouts.
|
|
190
|
+
const fanOutWarnAt = Math.max(1, Math.floor(poolSize / 4));
|
|
191
|
+
|
|
192
|
+
const entityOrThrow = (entityName) => {
|
|
193
|
+
if (typeof entityName !== "string" || !Object.hasOwn(entities, entityName)) {
|
|
194
|
+
throw new ResolverDbError(
|
|
195
|
+
`Unknown entity '${entityName}'. This project has ${Object.keys(entities)
|
|
196
|
+
.sort()
|
|
197
|
+
.join(", ")}.`,
|
|
198
|
+
"UNKNOWN_ENTITY"
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return entities[entityName];
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const forResolver = (resolver) => {
|
|
205
|
+
const name = resolver?.name;
|
|
206
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
207
|
+
throw new ResolverDbError(
|
|
208
|
+
"forResolver requires the resolver's `name`",
|
|
209
|
+
"INVALID_OPTION"
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const { timeoutMs } = resolver;
|
|
213
|
+
if (
|
|
214
|
+
typeof timeoutMs !== "number" ||
|
|
215
|
+
!Number.isSafeInteger(timeoutMs) ||
|
|
216
|
+
timeoutMs <= 0
|
|
217
|
+
) {
|
|
218
|
+
throw new ResolverDbError(
|
|
219
|
+
`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.`,
|
|
220
|
+
"MISSING_TIMEOUT"
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// No point waiting longer for a connection than the resolver has left to
|
|
225
|
+
// spend on the query it would run.
|
|
226
|
+
const waitMs = Math.min(poolWaitTimeoutMs, timeoutMs);
|
|
227
|
+
let inFlight = 0;
|
|
228
|
+
|
|
229
|
+
const run = async (work) => {
|
|
230
|
+
await gate.acquire(waitMs);
|
|
231
|
+
inFlight++;
|
|
232
|
+
if (inFlight > fanOutWarnAt && !warnedResolvers.has(name)) {
|
|
233
|
+
warnedResolvers.add(name);
|
|
234
|
+
warn(
|
|
235
|
+
`Resolver '${name}' held ${inFlight} of the pool's ${poolSize} connections at once. The pool is sized as concurrent heavy requests x per-request fan-out, so a fan-out this wide needs a larger pool or fewer concurrent queries.`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
return await runInTransaction();
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error?.code === "57014") {
|
|
242
|
+
throw new ResolverDbError(
|
|
243
|
+
`Query exceeded resolver '${name}'s ${timeoutMs}ms statement_timeout`,
|
|
244
|
+
"STATEMENT_TIMEOUT",
|
|
245
|
+
{ cause: error }
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
throw error;
|
|
249
|
+
} finally {
|
|
250
|
+
inFlight--;
|
|
251
|
+
gate.release();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function runInTransaction() {
|
|
255
|
+
return await sql.begin(async (tx) => {
|
|
256
|
+
// `SET LOCAL` rather than a startup option so the bound is this
|
|
257
|
+
// resolver's own, and so it cannot outlive the transaction onto the
|
|
258
|
+
// next query that checks the connection out.
|
|
259
|
+
await tx.unsafe(`SET LOCAL statement_timeout = ${timeoutMs}`);
|
|
260
|
+
return await work(tx);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const tagged = (strings, ...values) => run((tx) => tx(strings, ...values));
|
|
266
|
+
tagged.unsafe = (text, params = [], queryOptions) =>
|
|
267
|
+
run((tx) => tx.unsafe(text, params, queryOptions));
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
/** Entities matching `where`, decoded as the handlers see them. */
|
|
271
|
+
find: async (entityName, findOptions) => {
|
|
272
|
+
const entityConfig = entityOrThrow(entityName);
|
|
273
|
+
const query = makeFindQuery({
|
|
274
|
+
entityConfig,
|
|
275
|
+
pgSchema,
|
|
276
|
+
where: findOptions?.where,
|
|
277
|
+
orderBy: findOptions?.orderBy,
|
|
278
|
+
limit: findOptions?.limit,
|
|
279
|
+
offset: findOptions?.offset,
|
|
280
|
+
});
|
|
281
|
+
const rows = await run((tx) =>
|
|
282
|
+
tx.unsafe(query.text, query.params, { prepare })
|
|
283
|
+
);
|
|
284
|
+
return decodeRows(entityConfig, rows);
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
/** One entity by id, or null. */
|
|
288
|
+
get: async (entityName, id) => {
|
|
289
|
+
const entityConfig = entityOrThrow(entityName);
|
|
290
|
+
const query = makeGetQuery(entityConfig, pgSchema, id);
|
|
291
|
+
const rows = await run((tx) =>
|
|
292
|
+
tx.unsafe(query.text, query.params, { prepare })
|
|
293
|
+
);
|
|
294
|
+
const decoded = decodeRows(entityConfig, rows);
|
|
295
|
+
return decoded.length === 0 ? null : decoded[0];
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The per-chain indexed height, for resolvers that refuse to answer from
|
|
300
|
+
* a stale index. `progressBlock` is what has been processed;
|
|
301
|
+
* `sourceBlock` is where the chain was last seen.
|
|
302
|
+
*/
|
|
303
|
+
chainHeights: async () => {
|
|
304
|
+
const query = makeChainHeightsQuery(pgSchema);
|
|
305
|
+
const rows = await run((tx) =>
|
|
306
|
+
tx.unsafe(query.text, query.params, { prepare })
|
|
307
|
+
);
|
|
308
|
+
return decodeChainHeights(rows);
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
/** Raw SQL: ``db.sql`select ...` `` or `db.sql.unsafe(text, params)`. */
|
|
312
|
+
sql: tagged,
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The Postgres schema the indexer's tables live in. Raw SQL has to
|
|
316
|
+
* qualify table names with it, and it is not always "public".
|
|
317
|
+
*/
|
|
318
|
+
pgSchema,
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Several queries on one connection, under one timeout. The `sql` handed
|
|
322
|
+
* to the callback is postgres.js's own, so a fragment built from it can
|
|
323
|
+
* be used in a later query of the same block.
|
|
324
|
+
*/
|
|
325
|
+
transaction: (work) => run(work),
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
forResolver,
|
|
331
|
+
stats: () => ({
|
|
332
|
+
poolSize,
|
|
333
|
+
inUse: gate.inUse,
|
|
334
|
+
peakInUse: gate.peakInUse,
|
|
335
|
+
waiting: gate.queue.length,
|
|
336
|
+
}),
|
|
337
|
+
end: () => sql.end(),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The pool for the running resolver process: the indexer's ENVIO_PG_* target,
|
|
343
|
+
* with sizing of its own.
|
|
344
|
+
*/
|
|
345
|
+
export function createResolverPoolFromEnv(options) {
|
|
346
|
+
return createResolverPool({
|
|
347
|
+
connection: {
|
|
348
|
+
host: DbEnv.host,
|
|
349
|
+
port: DbEnv.port,
|
|
350
|
+
username: DbEnv.user,
|
|
351
|
+
password: DbEnv.password,
|
|
352
|
+
database: DbEnv.database,
|
|
353
|
+
ssl: DbEnv.ssl,
|
|
354
|
+
},
|
|
355
|
+
pgSchema: options?.pgSchema ?? DbEnv.publicSchema,
|
|
356
|
+
entities: options?.entities ?? {},
|
|
357
|
+
poolSize: options?.poolSize ?? ResolversEnv.poolSize(),
|
|
358
|
+
poolWaitTimeoutMs:
|
|
359
|
+
options?.poolWaitTimeoutMs ?? ResolversEnv.poolWaitTimeoutMs(),
|
|
360
|
+
poolerBacked: options?.poolerBacked ?? ResolversEnv.poolerBacked(),
|
|
361
|
+
onWarn: options?.onWarn,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Turns one `/resolve` request into one response body.
|
|
2
|
+
//
|
|
3
|
+
// The wire contract is envio-serve's, which already implements and tests it:
|
|
4
|
+
// a request names a field, its already-coerced arguments, the selection the
|
|
5
|
+
// caller asked for, and the role the operation runs under; the answer is
|
|
6
|
+
// `{ data }` or `{ errors }`, and `errors` wins when both could apply.
|
|
7
|
+
//
|
|
8
|
+
// Two things happen here that look like validation but are not optional.
|
|
9
|
+
// Arguments are parsed through their declared schemas, because that is what
|
|
10
|
+
// turns serve's JSON into the values the handler declared -- a BigInt argument
|
|
11
|
+
// arrives as a string -- and what runs the user's own refinements, which serve
|
|
12
|
+
// cannot. Results are converted back through the output schema for the mirror
|
|
13
|
+
// reason: a handler returning a bigint would otherwise throw inside
|
|
14
|
+
// JSON.stringify, and the conversion drops anything not declared, so an
|
|
15
|
+
// over-fetching resolver cannot leak through even before serve projects.
|
|
16
|
+
|
|
17
|
+
import * as S from "rescript-schema";
|
|
18
|
+
import { unwrapNullableOutput } from "./manifest.js";
|
|
19
|
+
import { ResolverError } from "./errors.js";
|
|
20
|
+
import { PRIVATE_KEY_HEADER } from "./hasuraMetadata.js";
|
|
21
|
+
|
|
22
|
+
// Built once per resolver, from a copy: `S.schema` replaces the schema values
|
|
23
|
+
// in the object it is handed, which would gut the declaration the manifest is
|
|
24
|
+
// derived from.
|
|
25
|
+
const argsSchemas = new WeakMap();
|
|
26
|
+
const outputSchemas = new WeakMap();
|
|
27
|
+
|
|
28
|
+
function argsSchemaOf(resolver) {
|
|
29
|
+
let schema = argsSchemas.get(resolver);
|
|
30
|
+
if (schema === undefined) {
|
|
31
|
+
schema = S.schema({ ...resolver.args });
|
|
32
|
+
argsSchemas.set(resolver, schema);
|
|
33
|
+
}
|
|
34
|
+
return schema;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function outputSchemaOf(resolver) {
|
|
38
|
+
let unwrapped = outputSchemas.get(resolver);
|
|
39
|
+
if (unwrapped === undefined) {
|
|
40
|
+
unwrapped = unwrapNullableOutput(resolver.output);
|
|
41
|
+
outputSchemas.set(resolver, unwrapped);
|
|
42
|
+
}
|
|
43
|
+
return unwrapped;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function errorBody(message, code, extra) {
|
|
47
|
+
return {
|
|
48
|
+
errors: [{ message, extensions: { code, ...extra } }],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isPlainObject = (value) =>
|
|
53
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validates a decoded request body against the wire contract.
|
|
57
|
+
* Returns null when it is well formed.
|
|
58
|
+
*/
|
|
59
|
+
export function badRequest(request) {
|
|
60
|
+
if (!isPlainObject(request)) {
|
|
61
|
+
return "expected a JSON object";
|
|
62
|
+
}
|
|
63
|
+
if (typeof request.field !== "string" || request.field.length === 0) {
|
|
64
|
+
return "`field` must be a non-empty string";
|
|
65
|
+
}
|
|
66
|
+
if (request.args !== undefined && !isPlainObject(request.args)) {
|
|
67
|
+
return "`args` must be an object";
|
|
68
|
+
}
|
|
69
|
+
if (request.selection !== undefined && !isPlainObject(request.selection)) {
|
|
70
|
+
return "`selection` must be an object";
|
|
71
|
+
}
|
|
72
|
+
if (request.role !== "public" && request.role !== "admin") {
|
|
73
|
+
return '`role` must be "public" or "admin"';
|
|
74
|
+
}
|
|
75
|
+
if (typeof request.requestId !== "string") {
|
|
76
|
+
return "`requestId` must be a string";
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Builds the dispatcher the HTTP server calls per request.
|
|
83
|
+
*
|
|
84
|
+
* `exposeErrors` puts an unexpected error's own message on the wire. Off by
|
|
85
|
+
* default: a driver error can carry a connection string, and the caller is the
|
|
86
|
+
* public internet. `envio dev` turns it on.
|
|
87
|
+
*/
|
|
88
|
+
// How long a chain-height read is reused for. Staleness is measured in blocks
|
|
89
|
+
// and moves at block time, so re-reading it per request would cost a query to
|
|
90
|
+
// learn the same answer.
|
|
91
|
+
const STALENESS_CACHE_MS = 2_000;
|
|
92
|
+
|
|
93
|
+
export function createDispatcher({ resolvers, pool, exposeErrors = false, onError }) {
|
|
94
|
+
const byName = new Map();
|
|
95
|
+
for (const resolver of resolvers) {
|
|
96
|
+
byName.set(resolver.name, resolver);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let cachedAt = 0;
|
|
100
|
+
let cachedChains = null;
|
|
101
|
+
let inFlight = null;
|
|
102
|
+
|
|
103
|
+
const readChainsBehindHead = async () => {
|
|
104
|
+
try {
|
|
105
|
+
const heights = await pool
|
|
106
|
+
.forResolver({ name: "staleness", timeoutMs: 2_000 })
|
|
107
|
+
.chainHeights();
|
|
108
|
+
return Object.values(heights).map((chain) => ({
|
|
109
|
+
chainId: chain.chainId,
|
|
110
|
+
behind: Math.max(0, chain.sourceBlock - chain.progressBlock),
|
|
111
|
+
}));
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Every chain's distance from head, or null when that cannot be read. Null
|
|
119
|
+
* means "do not know", and a gate that cannot read the answer lets the
|
|
120
|
+
* request through: refusing everything because the freshness probe itself
|
|
121
|
+
* failed would turn one broken query into a total outage.
|
|
122
|
+
*
|
|
123
|
+
* A burst that arrives on a cold cache shares one read rather than each
|
|
124
|
+
* taking a connection to learn the same answer -- the probes alone can
|
|
125
|
+
* saturate the pool, and then every request pays the pool wait and the gate
|
|
126
|
+
* fails open, under exactly the load that makes staleness worth checking.
|
|
127
|
+
* A failed read is remembered for the same window and for the same reason:
|
|
128
|
+
* repeating it per request costs each one the probe's full timeout while the
|
|
129
|
+
* gate is open regardless.
|
|
130
|
+
*/
|
|
131
|
+
const chainsBehindHead = () => {
|
|
132
|
+
if (cachedAt !== 0 && Date.now() - cachedAt < STALENESS_CACHE_MS) {
|
|
133
|
+
return Promise.resolve(cachedChains);
|
|
134
|
+
}
|
|
135
|
+
if (inFlight !== null) return inFlight;
|
|
136
|
+
inFlight = readChainsBehindHead().then((chains) => {
|
|
137
|
+
cachedAt = Date.now();
|
|
138
|
+
cachedChains = chains;
|
|
139
|
+
inFlight = null;
|
|
140
|
+
return chains;
|
|
141
|
+
});
|
|
142
|
+
return inFlight;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The first chain past its limit, or null when all of them are inside it.
|
|
147
|
+
*
|
|
148
|
+
* A bare number applies to every chain; an object gives a limit per chain
|
|
149
|
+
* and covers only the chains it names. That distinction matters on a
|
|
150
|
+
* multichain indexer, because "blocks behind" is not comparable between
|
|
151
|
+
* chains — a few hundred blocks is seconds on Arbitrum and hours on
|
|
152
|
+
* Ethereum — and because a resolver reading one chain's tables should not be
|
|
153
|
+
* refused for another chain's lag.
|
|
154
|
+
*/
|
|
155
|
+
const chainPastLimit = (chains, limit) => {
|
|
156
|
+
for (const chain of chains) {
|
|
157
|
+
const cap = typeof limit === "number" ? limit : limit[chain.chainId];
|
|
158
|
+
if (cap === undefined) continue;
|
|
159
|
+
if (chain.behind > cap) return { ...chain, cap };
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return async function dispatch(request, { privateKeyOk = false } = {}) {
|
|
165
|
+
const invalid = badRequest(request);
|
|
166
|
+
if (invalid !== null) {
|
|
167
|
+
return errorBody(`Malformed resolve request: ${invalid}`, "BAD_REQUEST");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { field, role } = request;
|
|
171
|
+
const resolver = byName.get(field);
|
|
172
|
+
if (resolver === undefined) {
|
|
173
|
+
return errorBody(
|
|
174
|
+
`No resolver named '${field}' is registered`,
|
|
175
|
+
"RESOLVER_NOT_FOUND"
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
// A private resolver is on the public schema so Hasura will route to it,
|
|
179
|
+
// which makes this check the only thing standing in front of it. An admin
|
|
180
|
+
// caller still passes: reaching here as `admin` means the shared secret was
|
|
181
|
+
// presented, which an unauthenticated caller cannot do.
|
|
182
|
+
if (resolver.private && !privateKeyOk && role !== "admin") {
|
|
183
|
+
return errorBody(
|
|
184
|
+
`Resolver '${field}' is private. Present its key in the ${PRIVATE_KEY_HEADER} header.`,
|
|
185
|
+
"FORBIDDEN"
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let args;
|
|
190
|
+
try {
|
|
191
|
+
args = S.parseOrThrow(request.args ?? {}, argsSchemaOf(resolver));
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return errorBody(
|
|
194
|
+
`Invalid arguments for '${field}': ${error.message}`,
|
|
195
|
+
"BAD_USER_INPUT"
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// After the arguments parse and before any handler runs: answering from an
|
|
200
|
+
// index that is behind head is worse than not answering, because the
|
|
201
|
+
// numbers look real.
|
|
202
|
+
if (resolver.maxBlocksBehind !== undefined) {
|
|
203
|
+
const chains = await chainsBehindHead();
|
|
204
|
+
const stale = chains === null ? null : chainPastLimit(chains, resolver.maxBlocksBehind);
|
|
205
|
+
if (stale !== null) {
|
|
206
|
+
return errorBody(
|
|
207
|
+
`Chain ${stale.chainId} is ${stale.behind} blocks behind head, past this resolver's limit of ${stale.cap}; refusing to answer from a stale index`,
|
|
208
|
+
"SERVICE_UNAVAILABLE",
|
|
209
|
+
// Without this the action path answers 400, which a client cannot
|
|
210
|
+
// tell from a malformed request -- and backing off and retrying is
|
|
211
|
+
// the only useful response to an index that is merely behind.
|
|
212
|
+
{ http: { status: 503 } }
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let result;
|
|
218
|
+
try {
|
|
219
|
+
result = await resolver.handler({
|
|
220
|
+
args,
|
|
221
|
+
db: pool.forResolver({ name: field, timeoutMs: resolver.timeoutMs }),
|
|
222
|
+
selection: request.selection ?? {},
|
|
223
|
+
ctx: {
|
|
224
|
+
role,
|
|
225
|
+
requestId: request.requestId,
|
|
226
|
+
traceparent: request.traceparent,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error instanceof ResolverError) {
|
|
231
|
+
return { errors: [{ message: error.message, extensions: error.toExtensions() }] };
|
|
232
|
+
}
|
|
233
|
+
onError?.(error, { field, requestId: request.requestId });
|
|
234
|
+
return errorBody(
|
|
235
|
+
exposeErrors
|
|
236
|
+
? `Resolver '${field}' failed: ${error?.message ?? String(error)}`
|
|
237
|
+
: `Resolver '${field}' failed`,
|
|
238
|
+
"INTERNAL_SERVER_ERROR"
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const { inner, nullable } = outputSchemaOf(resolver);
|
|
244
|
+
if (nullable && (result === undefined || result === null)) {
|
|
245
|
+
return { data: null };
|
|
246
|
+
}
|
|
247
|
+
return { data: S.reverseConvertToJsonOrThrow(result, inner) };
|
|
248
|
+
} catch (error) {
|
|
249
|
+
// Reported rather than nulled: serve's projection would make a missing
|
|
250
|
+
// field read as absent, which in a dashboard is a wrong number with no
|
|
251
|
+
// explanation anywhere.
|
|
252
|
+
onError?.(error, { field, requestId: request.requestId });
|
|
253
|
+
return errorBody(
|
|
254
|
+
`Resolver '${field}' returned a result that doesn't match its declared output: ${error.message}`,
|
|
255
|
+
"INVALID_RESULT"
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The error a resolver throws when it wants the client to see something
|
|
2
|
+
// specific. Anything else a handler throws is reported as an internal failure
|
|
3
|
+
// with its message withheld, so this is the way to say more than that.
|
|
4
|
+
//
|
|
5
|
+
// envio-serve carries these extensions through to the operation's `errors`
|
|
6
|
+
// array unchanged, and a resolver's own `code` wins over serve's.
|
|
7
|
+
|
|
8
|
+
export class ResolverError extends Error {
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} message
|
|
11
|
+
* @param {{ code?: string, httpStatus?: number, extensions?: Record<string, unknown> }} [options]
|
|
12
|
+
*/
|
|
13
|
+
constructor(message, options = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ResolverError";
|
|
16
|
+
this.code = options.code ?? "INTERNAL_SERVER_ERROR";
|
|
17
|
+
this.httpStatus = options.httpStatus;
|
|
18
|
+
this.extensions = options.extensions;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
toExtensions() {
|
|
22
|
+
return {
|
|
23
|
+
code: this.code,
|
|
24
|
+
...(this.httpStatus === undefined
|
|
25
|
+
? {}
|
|
26
|
+
: { http: { status: this.httpStatus } }),
|
|
27
|
+
...this.extensions,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|