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,805 @@
1
+ // What `envio resolvers` runs once the Rust CLI hands over: find the module
2
+ // named by `resolvers:` in config.yaml, import it so its declarations register,
3
+ // then either write the artefacts the image carries or serve them.
4
+ //
5
+ // Declaring is registering, so importing the module is the whole of "loading
6
+ // the resolvers" -- the same way importing a handler module registers its
7
+ // handlers.
8
+
9
+ type manifestBundle = {manifest: JSON.t, sdl: string}
10
+
11
+ @module("./index.js")
12
+ external buildRegisteredManifest: unit => manifestBundle = "buildRegisteredManifest"
13
+
14
+ @module("./index.js")
15
+ external getRegisteredResolvers: unit => array<unknown> = "getRegisteredResolvers"
16
+
17
+ type hasuraMetadataOptions = {handlerUrl: string, actionSecret?: string}
18
+
19
+ @module("./hasuraMetadata.js")
20
+ external buildHasuraMetadata: (JSON.t, hasuraMetadataOptions) => JSON.t = "buildHasuraMetadata"
21
+
22
+ type applyOptions = {endpoint: string, adminSecret: string, metadata: JSON.t}
23
+ type applyResult = {applied: bool, reasons: array<string>}
24
+
25
+ @module("./hasuraApply.js")
26
+ external applyResolverMetadata: applyOptions => promise<applyResult> = "applyResolverMetadata"
27
+
28
+ type reassertOptions = {
29
+ endpoint: string,
30
+ adminSecret: string,
31
+ metadata: JSON.t,
32
+ intervalMs: int,
33
+ onApplied: array<string> => unit,
34
+ onError: exn => unit,
35
+ }
36
+
37
+ @module("./hasuraApply.js")
38
+ external startMetadataReassert: reassertOptions => unit => unit = "startMetadataReassert"
39
+
40
+ type schemaNames = {entities: array<string>, enums: array<string>}
41
+
42
+ @module("./collisions.js")
43
+ external checkCollisions: (JSON.t, schemaNames) => unit = "checkCollisions"
44
+
45
+ // Only the entities and enums serve builds its schema from, which is what
46
+ // decides whether a custom name is already taken.
47
+ let schemaNamesOf = (config: Config.t) => {
48
+ entities: config->Config.getPgUserEntities->Array.map(entity => entity.name),
49
+ enums: config.allEnums->Array.map(enumConfig => enumConfig.name),
50
+ }
51
+
52
+ type pool
53
+
54
+ type poolOptions = {
55
+ entities: dict<Internal.entityConfig>,
56
+ pgSchema: string,
57
+ }
58
+
59
+ @module("./db.js")
60
+ external createResolverPoolFromEnv: poolOptions => pool = "createResolverPoolFromEnv"
61
+
62
+ @send external endPool: pool => promise<unit> = "end"
63
+
64
+ type server = {port: int, close: unit => promise<unit>}
65
+
66
+ type serverOptions = {
67
+ resolvers: array<unknown>,
68
+ pool: pool,
69
+ port?: int,
70
+ exposeErrors?: bool,
71
+ actionSecret?: string,
72
+ privateKeys?: array<string>,
73
+ // `null` means ready. A string is the reason it is not, and reaches the
74
+ // probe's body, so an operator reads it wherever the failed probe surfaces
75
+ // rather than by correlating logs.
76
+ checkCompatible?: unit => promise<Nullable.t<string>>,
77
+ }
78
+
79
+ @module("./server.js")
80
+ external startResolverServer: serverOptions => promise<server> = "startResolverServer"
81
+
82
+ @module("./server.js") external readyzProbeMs: int = "READYZ_PROBE_MS"
83
+
84
+ let toImportUrl = (~projectRoot, ~relativePath) =>
85
+ NodeJs.Url.pathToFileURL(
86
+ NodeJs.Path.resolve([projectRoot, relativePath])->NodeJs.Path.toString,
87
+ )->NodeJs.Url.toString
88
+
89
+ type stats
90
+ @module("node:fs") external statSync: string => stats = "statSync"
91
+ @send external isDirectory: stats => bool = "isDirectory"
92
+
93
+ type globOptions = {cwd: string}
94
+ @module("node:fs/promises")
95
+ external globIn: (string, globOptions) => Utils.asyncIterator<string> = "glob"
96
+
97
+ // `resolvers:` may name a directory as well as a file, because the reference
98
+ // implementation keeps its resolvers as `resolvers/<name>/index.ts` -- a
99
+ // parent of subdirectories -- and `handlers:` has auto-loaded exactly that
100
+ // shape since before this feature existed. Same glob, same exclusions, so the
101
+ // two config fields do not need separate explanations.
102
+ let sourceFilesIn = async (~absoluteDir, ~relativePath) => {
103
+ let files = try {
104
+ let iterator = globIn("**/*.{js,mjs,ts}", {cwd: absoluteDir})
105
+ await iterator->Utils.Array.fromAsyncIterator
106
+ } catch {
107
+ | exn =>
108
+ JsError.throwWithMessage(
109
+ `Failed to read the resolvers directory '${relativePath}'. Node 22 or newer is required for directory auto-loading. Cause: ${exn
110
+ ->Utils.prettifyExn
111
+ ->Obj.magic}`,
112
+ )
113
+ }
114
+ files
115
+ // Specs live beside the code they cover in the reference layout, and
116
+ // importing one runs `describe` outside a test runner. Same exclusions as
117
+ // `handlers:`, plus declaration files, which have nothing to execute.
118
+ ->Array.filter(file =>
119
+ !(
120
+ file->String.includes(".test.") ||
121
+ file->String.includes(".spec.") ||
122
+ file->String.includes("_test.") ||
123
+ file->String.endsWith(".d.ts")
124
+ )
125
+ )
126
+ // Sorted, so the manifest and the SDL derived from it are the same on every
127
+ // machine rather than following whatever order the filesystem returns.
128
+ ->Array.toSorted(String.compare)
129
+ }
130
+
131
+ let loadOrThrow = async (~projectRoot, ~relativePath) => {
132
+ let absolute = NodeJs.Path.resolve([projectRoot, relativePath])->NodeJs.Path.toString
133
+ // A missing path deliberately falls through to the single-module import, so
134
+ // the error still names the file and says what Node made of it.
135
+ let isDir = switch statSync(absolute) {
136
+ | stats => stats->isDirectory
137
+ | exception _ => false
138
+ }
139
+
140
+ let relativePaths = if isDir {
141
+ let files = await sourceFilesIn(~absoluteDir=absolute, ~relativePath)
142
+ if files->Utils.Array.isEmpty {
143
+ Logging.warn(
144
+ `'${relativePath}' is a directory with no .js, .mjs or .ts files in it, so no resolvers were loaded.`,
145
+ )
146
+ }
147
+ // Kept relative, so a load failure names the path the user wrote in
148
+ // config.yaml plus the file under it, not an absolute machine path.
149
+ files->Array.map(file => `${relativePath}/${file}`)
150
+ } else {
151
+ [relativePath]
152
+ }
153
+
154
+ // The resolver module is the user's TypeScript, loaded through the same
155
+ // hooks as the handlers.
156
+ HandlerLoader.registerTsHooks()
157
+ for index in 0 to relativePaths->Array.length - 1 {
158
+ let path = relativePaths->Array.getUnsafe(index)
159
+ switch await Utils.importPath(toImportUrl(~projectRoot, ~relativePath=path)) {
160
+ | _ => ()
161
+ | exception exn =>
162
+ // `anyToExnInternal`, not `prettifyExn`: the latter hands back the raw JS
163
+ // error cast to `exn`, so matching `JsExn(_)` on it never fires and every
164
+ // cause reads "unknown error".
165
+ let cause = switch exn->JsExn.anyToExnInternal {
166
+ | JsExn(e) => e->JsExn.message->Option.getOr("no message")
167
+ | _ => "unknown error"
168
+ }
169
+ JsError.throwWithMessage(
170
+ `Failed to load the resolvers module '${path}' named by \`resolvers:\` in config.yaml. Cause: ${cause}`,
171
+ )
172
+ }
173
+ }
174
+ }
175
+
176
+ // Emitted whether or not the project declares any, so nothing downstream needs
177
+ // a "file missing" branch. An empty manifest is still a manifest serve can
178
+ // parse -- it registers no custom fields rather than refusing to start.
179
+ let emptyManifest: JSON.t = %raw(`{ schemaVersion: 1, resolvers: [], types: [] }`)
180
+
181
+ let writeManifest = async (~config: Config.t, ~projectRoot) => {
182
+ let {manifest, sdl} = switch config.resolvers {
183
+ | None => {manifest: emptyManifest, sdl: ""}
184
+ | Some(relativePath) =>
185
+ await loadOrThrow(~projectRoot, ~relativePath)
186
+ let bundle = buildRegisteredManifest()
187
+ checkCollisions(bundle.manifest, schemaNamesOf(config))
188
+ bundle
189
+ }
190
+
191
+ let dir = NodeJs.Path.resolve([projectRoot, ".envio"])
192
+ await NodeJs.Fs.Promises.mkdir(~path=dir, ~options={recursive: true})
193
+ // Pretty-printed: `envio dev` diffs it, and so do the people reviewing what
194
+ // an image is about to publish.
195
+ await NodeJs.Fs.Promises.writeFile(
196
+ ~filepath=dir->NodeJs.Path.join("resolvers.json"),
197
+ ~content=JSON.stringify(manifest, ~space=2) ++ "\n",
198
+ )
199
+ await NodeJs.Fs.Promises.writeFile(
200
+ ~filepath=dir->NodeJs.Path.join("resolvers.graphql"),
201
+ ~content=sdl,
202
+ )
203
+ }
204
+
205
+ /// Where Hasura's metadata API lives and what may write to it, or a message
206
+ /// naming what is missing. Shared by `serve` and `migrate` so the two cannot
207
+ /// disagree about what "configured for Hasura" means.
208
+ let hasuraTarget = () =>
209
+ switch (Env.Resolvers.hasuraEndpoint(), Env.Resolvers.hasuraAdminSecret()) {
210
+ | (Some(endpoint), Some(adminSecret)) => Ok((endpoint, adminSecret))
211
+ | (None, _) =>
212
+ Error(
213
+ "HASURA_GRAPHQL_ENDPOINT is not set, so there is no Hasura to update. It is the full metadata URL, ending in /v1/metadata.",
214
+ )
215
+ | (_, None) =>
216
+ Error(
217
+ "HASURA_GRAPHQL_ADMIN_SECRET is not set. Writing metadata is an admin operation, so Hasura will refuse without it.",
218
+ )
219
+ }
220
+
221
+ let handlerUrlOrThrow = () =>
222
+ switch Env.Resolvers.publicUrl() {
223
+ | Some(url) => url
224
+ | None =>
225
+ JsError.throwWithMessage(
226
+ "ENVIO_RESOLVERS_PUBLIC_URL is not set. It is the URL Hasura posts to, which is not the address the resolver process binds -- Hasura has no other way to reach the resolvers, and it is baked into every action.",
227
+ )
228
+ }
229
+
230
+ /// The Hasura metadata for this project's resolvers, as `envio resolvers
231
+ /// metadata` prints it.
232
+ let metadataJson = async (~config: Config.t, ~projectRoot, ~handlerUrl) => {
233
+ let manifest = switch config.resolvers {
234
+ | None => emptyManifest
235
+ | Some(relativePath) =>
236
+ await loadOrThrow(~projectRoot, ~relativePath)
237
+ let bundle = buildRegisteredManifest()
238
+ checkCollisions(bundle.manifest, schemaNamesOf(config))
239
+ bundle.manifest
240
+ }
241
+ buildHasuraMetadata(manifest, {handlerUrl, actionSecret: ?Env.Resolvers.actionSecret()})
242
+ }
243
+
244
+ /// `envio resolvers migrate`: brings a running indexer's Hasura metadata in
245
+ /// line with these resolvers, then exits. The same shape as `db-migrate up` --
246
+ /// reconcile and stop -- for the metadata rather than the schema.
247
+ ///
248
+ /// Strict where `serve` is forgiving. Serving keeps answering when an apply
249
+ /// fails, because Hasura simply sends it nothing until the metadata lands; a
250
+ /// deploy step that cannot land it has to be a failure the pipeline sees.
251
+ let migrate = async (~config: Config.t, ~projectRoot) => {
252
+ let (endpoint, adminSecret) = switch hasuraTarget() {
253
+ | Ok(target) => target
254
+ | Error(message) => JsError.throwWithMessage(message)
255
+ }
256
+ let handlerUrl = handlerUrlOrThrow()
257
+ let metadata = await metadataJson(~config, ~projectRoot, ~handlerUrl)
258
+ await applyResolverMetadata({endpoint, adminSecret, metadata})
259
+ }
260
+
261
+ @val @scope("process") external onSignal: (string, unit => unit) => unit = "on"
262
+
263
+ // Node closes the server but leaves keep-alive connections to time out on
264
+ // their own, and serve holds those open by design. Bounded so a rolling
265
+ // update can't be held up by an idle socket.
266
+ let closeGracePeriodMs = 5000
267
+
268
+ @module("node:fs") external copyFileSync: (string, string) => unit = "copyFileSync"
269
+ @module("node:fs") external existsSync: string => bool = "existsSync"
270
+
271
+ type spawnOptions = {env: dict<string>, stdio: string, detached: bool, cwd?: string}
272
+ type child
273
+ @module("node:child_process")
274
+ external spawn: (string, array<string>, spawnOptions) => child = "spawn"
275
+ @send external unref: child => unit = "unref"
276
+ @get external childPid: child => option<int> = "pid"
277
+ @send external killChild: (child, string) => bool = "kill"
278
+ @send
279
+ external onChildExit: (child, string, (Nullable.t<int>, Nullable.t<string>) => unit) => unit = "on"
280
+ @val external processEnv: dict<string> = "process.env"
281
+
282
+ @module("node:path") external pathDirname: string => string = "dirname"
283
+
284
+ type httpResponse
285
+ type fetchInit = {method: string}
286
+ @val external fetchUrl: (string, fetchInit) => promise<httpResponse> = "fetch"
287
+ @get external responseOk: httpResponse => bool = "ok"
288
+
289
+ // The resolver process is started by re-invoking this package's own `bin.mjs`,
290
+ // resolved from this module rather than from `process.argv` -- dev is not
291
+ // always what argv names (a test runner isn't), and `node_modules/.bin/envio`
292
+ // is a shim into whichever version is published rather than this one.
293
+ let cliEntry = () =>
294
+ NodeJs.Path.resolve([
295
+ pathDirname(NodeJs.Url.fileURLToPath(NodeJs.ImportMeta.importMeta.url)),
296
+ "..",
297
+ "..",
298
+ "bin.mjs",
299
+ ])->NodeJs.Path.toString
300
+
301
+ @val @scope("process") external execPath: string = "execPath"
302
+
303
+ type dbHandle
304
+ type sqlFn
305
+ type resolverRef = {name: string, timeoutMs: int}
306
+ @send external forResolver: (pool, resolverRef) => dbHandle = "forResolver"
307
+ @get external sqlOf: dbHandle => sqlFn = "sql"
308
+ @send
309
+ external unsafe: (sqlFn, string, array<JSON.t>) => promise<array<{"n": int}>> = "unsafe"
310
+ @send
311
+ external readConfigRows: (sqlFn, string, array<JSON.t>) => promise<array<{"config": string}>> =
312
+ "unsafe"
313
+
314
+ // The compat check the indexer runs on resume, run instead against the database
315
+ // a resolver process was pointed at.
316
+ //
317
+ // It reads on every probe rather than once at startup because the mismatch it
318
+ // exists for arrives later: the indexer is redeployed with a new config while
319
+ // this process keeps serving, and readiness is the only thing that can still
320
+ // take it out of rotation.
321
+ let compatibilityOf = (~pool, ~pgSchema, ~envioInfo) =>
322
+ async () => {
323
+ // The probe's bound, not its own: this runs inside the same readiness check
324
+ // and shares its budget.
325
+ let sql = pool->forResolver({name: "readyz", timeoutMs: readyzProbeMs})->sqlOf
326
+ switch await readConfigRows(
327
+ sql,
328
+ `SELECT "config" FROM "${pgSchema}"."${InternalTable.EnvioInfo.table.tableName}" LIMIT 1;`,
329
+ [],
330
+ ) {
331
+ | rows =>
332
+ switch rows->Array.get(0) {
333
+ | None =>
334
+ Nullable.make(
335
+ "The database records no config, so it cannot be checked against this build. It was initialized by an envio version that predates the check.",
336
+ )
337
+ | Some(row) =>
338
+ switch Config.diffPaths(~stored=row["config"]->JSON.parseOrThrow, ~current=envioInfo) {
339
+ | [] => Nullable.null
340
+ | changedPaths =>
341
+ Nullable.make(
342
+ `The database was indexed with a different config: ${changedPaths->Array.join(
343
+ ", ",
344
+ )}. This build must not answer for it.`,
345
+ )
346
+ }
347
+ }
348
+ | exception exn =>
349
+ InternalTable.isUndefinedTable(exn)
350
+ ? Nullable.make(
351
+ "The database has no envio_info table, so it was not initialized by this indexer.",
352
+ )
353
+ : throw(exn)
354
+ }
355
+ }
356
+
357
+ type running = {server: server, pool: pool, shutdown: unit => promise<unit>}
358
+
359
+ let serve = async (
360
+ ~config: Config.t,
361
+ ~projectRoot,
362
+ ~port=?,
363
+ ~exposeErrors=?,
364
+ ~pgSchema=Env.Db.publicSchema,
365
+ ~envioInfo=Config.getPublicConfigJson()->Config.toEnvioInfo,
366
+ ) => {
367
+ let exposeErrors = exposeErrors->Option.getOr(Env.Resolvers.exposeErrors())
368
+ let relativePath = switch config.resolvers {
369
+ | Some(relativePath) => relativePath
370
+ | None =>
371
+ JsError.throwWithMessage(
372
+ "There is nothing to serve: config.yaml has no `resolvers:` entry. Add one naming the module that declares them, e.g. `resolvers: src/Resolvers.ts`.",
373
+ )
374
+ }
375
+ await loadOrThrow(~projectRoot, ~relativePath)
376
+
377
+ // Checked here too, not only at manifest time: `envio dev` never writes the
378
+ // artefacts, and a collision it doesn't catch becomes a schema serve refuses.
379
+ let manifest = buildRegisteredManifest().manifest
380
+ checkCollisions(manifest, schemaNamesOf(config))
381
+
382
+ let resolvers = getRegisteredResolvers()
383
+ if resolvers->Utils.Array.isEmpty {
384
+ Logging.warn(
385
+ `'${relativePath}' declared no resolvers, so this process will answer nothing. Every resolver has to be created with createResolver at module scope to register.`,
386
+ )
387
+ }
388
+
389
+ // Only the Postgres-backed entities: an entity kept elsewhere has no table
390
+ // for a loader to read, and leaving it out is what makes the unknown-entity
391
+ // error name what is actually queryable.
392
+ let entities = Dict.make()
393
+ config->Config.getPgUserEntities->Array.forEach(entity => entities->Dict.set(entity.name, entity))
394
+
395
+ // Read before the socket opens. It is a misconfiguration, not a runtime
396
+ // failure, so it should stop the process before anything can route to it.
397
+ // The interval belongs here for the same reason, and for one more: read
398
+ // after the bind, a malformed one throws from a `serve` that has already
399
+ // opened a port and a pool and returns neither, so nothing can close them.
400
+ let hasuraTargetOrNone = hasuraTarget()
401
+ switch hasuraTargetOrNone {
402
+ | Ok(_) =>
403
+ handlerUrlOrThrow()->ignore
404
+ Env.Resolvers.metadataIntervalMs()->ignore
405
+ | Error(_) => ()
406
+ }
407
+
408
+ let pool = createResolverPoolFromEnv({entities, pgSchema})
409
+ // The pool is opened before the socket, and a bind that fails -- the port is
410
+ // already taken -- returns nothing, so no caller can reach `shutdown` to
411
+ // release it later. It has to go back here or not at all.
412
+ let server = try await startResolverServer({
413
+ resolvers,
414
+ pool,
415
+ ?port,
416
+ exposeErrors,
417
+ actionSecret: ?Env.Resolvers.actionSecret(),
418
+ privateKeys: Env.Resolvers.privateKeys(),
419
+ checkCompatible: compatibilityOf(~pool, ~pgSchema, ~envioInfo),
420
+ }) catch {
421
+ | exn =>
422
+ try await pool->endPool catch {
423
+ | _ => ()
424
+ }
425
+ throw(exn)
426
+ }
427
+ Logging.info(
428
+ `Serving ${resolvers
429
+ ->Array.length
430
+ ->Int.toString} custom resolvers on port ${server.port->Int.toString}`,
431
+ )
432
+
433
+ // After the socket is listening, never before: an action Hasura knows about
434
+ // is one it will send traffic to.
435
+ let stopReassert = switch hasuraTargetOrNone {
436
+ | Ok((endpoint, adminSecret)) =>
437
+ let handlerUrl = handlerUrlOrThrow()
438
+ let metadata = buildHasuraMetadata(
439
+ manifest,
440
+ {handlerUrl, actionSecret: ?Env.Resolvers.actionSecret()},
441
+ )
442
+ let intervalMs = Env.Resolvers.metadataIntervalMs()
443
+
444
+ // Best effort, deliberately. Replicas rolling out together all find the
445
+ // same empty Hasura and all try to create the actions; the losers are told
446
+ // `already-exists`. That is a race to converge out of, not a reason to
447
+ // exit -- a process that dies here crash-loops through the rollout, while
448
+ // the metadata it wanted is already correct. Serving continues either way:
449
+ // until the metadata lands, Hasura simply sends nothing here.
450
+ switch await applyResolverMetadata({endpoint, adminSecret, metadata}) {
451
+ | {applied, reasons} =>
452
+ Logging.info(
453
+ applied
454
+ ? `Registered custom resolvers with Hasura: ${reasons->Array.join("; ")}`
455
+ : "Hasura already publishes these custom resolvers; nothing to register",
456
+ )
457
+ | exception exn =>
458
+ Logging.error(
459
+ `Failed to register the custom resolvers with Hasura, so Hasura will not route to this process yet: ${exn
460
+ ->Utils.prettifyExn
461
+ ->Obj.magic}. ${intervalMs === 0
462
+ ? "ENVIO_RESOLVERS_METADATA_INTERVAL_MS is 0, so this will not be retried."
463
+ : `Retrying every ${intervalMs->Int.toString}ms.`}`,
464
+ )
465
+ }
466
+
467
+ if intervalMs === 0 {
468
+ () => ()
469
+ } else {
470
+ startMetadataReassert({
471
+ endpoint,
472
+ adminSecret,
473
+ metadata,
474
+ intervalMs,
475
+ // Not defensive: `Hasura.trackDatabase` opens with a wholesale
476
+ // `clear_metadata`, so a re-initialised indexer deletes these actions
477
+ // while this process keeps running.
478
+ onApplied: reasons =>
479
+ Logging.warn(
480
+ `Hasura had lost the custom resolvers and they were restored: ${reasons->Array.join(
481
+ "; ",
482
+ )}`,
483
+ ),
484
+ onError: exn =>
485
+ Logging.error(
486
+ `Failed to re-assert the custom resolver metadata: ${exn
487
+ ->Utils.prettifyExn
488
+ ->Obj.magic}`,
489
+ ),
490
+ })
491
+ }
492
+ | Error(message) =>
493
+ Logging.info(
494
+ `${message} Nothing was registered with Hasura; this process still answers /hasura-action and /resolve.`,
495
+ )
496
+ () => ()
497
+ }
498
+
499
+ let stopped = ref(None)
500
+ let shutdown = () =>
501
+ switch stopped.contents {
502
+ | Some(promise) => promise
503
+ | None =>
504
+ let promise = (
505
+ async () => {
506
+ stopReassert()
507
+ // In-flight requests finish; the grace period only bounds how long
508
+ // an idle connection can delay the exit.
509
+ switch await Promise.race([server.close(), Utils.delay(closeGracePeriodMs)]) {
510
+ | () => ()
511
+ | exception _ => ()
512
+ }
513
+ switch await pool->endPool {
514
+ | () => ()
515
+ | exception _ => ()
516
+ }
517
+ }
518
+ )()
519
+ stopped := Some(promise)
520
+ promise
521
+ }
522
+
523
+ {server, pool, shutdown}
524
+ }
525
+
526
+ /// Wires SIGTERM/SIGINT to `shutdown`, so a rolling update drains rather than
527
+ /// severs. Separate from `serve` because a caller embedding it -- a test, or
528
+ /// `envio dev` -- owns the process's signals itself.
529
+ let handleSignals = (running: running) =>
530
+ ["SIGTERM", "SIGINT"]->Array.forEach(signal =>
531
+ onSignal(signal, () => {
532
+ Logging.info(`Received ${signal}, draining custom resolvers...`)
533
+ let _ = (
534
+ async () => {
535
+ await running.shutdown()
536
+ NodeJs.process->NodeJs.exitWithCode(Success)
537
+ }
538
+ )()
539
+ })
540
+ )
541
+
542
+ // `envio dev` only. `envio start` never calls it — a deployment runs the
543
+ // resolvers as their own service.
544
+ //
545
+ // Dev spawns `envio resolvers` as a child rather than serving in-process, so
546
+ // local and hosted run the same command over the same HTTP seam, with a pool
547
+ // and a lifetime of their own. It also makes a resolver edit a restart of that
548
+ // process alone: the indexer keeps its place, and a resolver that crashes
549
+ // takes nothing with it.
550
+ //
551
+ // `ENVIO_SERVE_BIN` points at a local envio-serve build and this starts it;
552
+ // without it, everything else still runs and the hint says what is missing.
553
+ type devResolvers = {port: int, pid: option<int>, stop: unit => promise<unit>}
554
+
555
+ // Bounded: a resolver module that throws on import never binds the port, and
556
+ // waiting forever would leave `envio dev` looking hung rather than saying so.
557
+ let healthzTimeoutMs = 20_000
558
+
559
+ let waitForHealthz = async (~port) => {
560
+ let deadline = Date.now() +. healthzTimeoutMs->Int.toFloat
561
+ let rec attempt = async () => {
562
+ let answered = switch await fetchUrl(
563
+ `http://127.0.0.1:${port->Int.toString}/healthz`,
564
+ {method: "GET"},
565
+ ) {
566
+ | response => response->responseOk
567
+ | exception _ => false
568
+ }
569
+ if answered {
570
+ true
571
+ } else if Date.now() > deadline {
572
+ false
573
+ } else {
574
+ await Utils.delay(200)
575
+ await attempt()
576
+ }
577
+ }
578
+ await attempt()
579
+ }
580
+
581
+ let stopChild = (child): promise<unit> =>
582
+ Promise.make((resolve, _) => {
583
+ switch child->childPid {
584
+ | None => resolve()
585
+ | Some(_) =>
586
+ child->onChildExit("exit", (_, _) => resolve())
587
+ let _ = child->killChild("SIGTERM")
588
+ // SIGTERM runs the child's drain; the race only bounds how long an idle
589
+ // connection can hold the exit up.
590
+ let _ = (
591
+ async () => {
592
+ await Utils.delay(closeGracePeriodMs)
593
+ resolve()
594
+ }
595
+ )()
596
+ }
597
+ })
598
+
599
+ let startForDev = async (~config: Config.t, ~projectRoot) => {
600
+ switch config.resolvers {
601
+ | None => None
602
+ // Set when you are running `envio resolvers` and envio-serve yourself —
603
+ // under a debugger, or with env of your own.
604
+ | Some(_) if processEnv->Dict.get("ENVIO_RESOLVERS_EXTERNAL") == Some("true") =>
605
+ Logging.info(
606
+ "ENVIO_RESOLVERS_EXTERNAL=true — not starting resolvers here. Run `envio resolvers` in another terminal, and envio-serve against .envio/serve-project.",
607
+ )
608
+ None
609
+ | Some(_) =>
610
+ let configFile = processEnv->Dict.get("ENVIO_CONFIG")->Option.getOr("config.yaml")
611
+ let port = Env.Resolvers.port()
612
+
613
+ let childEnv = Dict.copy(processEnv)
614
+ childEnv->Dict.set("ENVIO_RESOLVERS_PORT", port->Int.toString)
615
+ // Locally the caller is the person who wrote the resolver, so an
616
+ // unexpected error's own message is what they need to see.
617
+ childEnv->Dict.set("ENVIO_RESOLVERS_EXPOSE_ERRORS", "true")
618
+ childEnv->Dict.set("ENVIO_CONFIG", configFile)
619
+
620
+ // Point the child at the Hasura `envio dev` is already running, so a
621
+ // resolver shows up in the same endpoint as the generated entity fields.
622
+ // Without this, dev serves the resolvers on their own port and the schema
623
+ // a developer actually opens knows nothing about them.
624
+ //
625
+ // Read here rather than from `Env.Hasura`, whose values are fixed when that
626
+ // module loads -- dev sets its own port and secret and this has to follow
627
+ // them.
628
+ if Env.Hasura.enabled {
629
+ let endpoint = switch Env.Resolvers.hasuraEndpoint() {
630
+ | Some(endpoint) => endpoint
631
+ | None =>
632
+ let hasuraPort = processEnv->Dict.get("HASURA_EXTERNAL_PORT")->Option.getOr("8080")
633
+ `http://localhost:${hasuraPort}/v1/metadata`
634
+ }
635
+ childEnv->Dict.set("HASURA_GRAPHQL_ENDPOINT", endpoint)
636
+ childEnv->Dict.set(
637
+ "HASURA_GRAPHQL_ADMIN_SECRET",
638
+ Env.Resolvers.hasuraAdminSecret()->Option.getOr("testing"),
639
+ )
640
+ // Hasura is a container and this process is on the host, so the handler
641
+ // it registers is the host's address rather than the one bound here.
642
+ switch Env.Resolvers.publicUrl() {
643
+ | Some(_) => ()
644
+ | None =>
645
+ childEnv->Dict.set(
646
+ "ENVIO_RESOLVERS_PUBLIC_URL",
647
+ `http://host.docker.internal:${port->Int.toString}/hasura-action`,
648
+ )
649
+ }
650
+ }
651
+
652
+ let resolverChild = spawn(
653
+ execPath,
654
+ [cliEntry(), "resolvers"],
655
+ {env: childEnv, stdio: "inherit", detached: false, cwd: projectRoot},
656
+ )
657
+ // The indexer owns this process's lifetime; a resolver exiting is reported,
658
+ // never fatal.
659
+ resolverChild->onChildExit("exit", (code, signal) =>
660
+ switch (code->Nullable.toOption, signal->Nullable.toOption) {
661
+ | (Some(0), _) => ()
662
+ | (_, Some(s)) => Logging.info(`Custom resolvers stopped (${s}).`)
663
+ | (Some(c), _) =>
664
+ Logging.error(
665
+ `The custom resolvers process exited with code ${c->Int.toString}. The indexer keeps running; fix the resolver and run \`envio dev\` again.`,
666
+ )
667
+ | _ => Logging.info("Custom resolvers stopped.")
668
+ }
669
+ )
670
+
671
+ if !(await waitForHealthz(~port)) {
672
+ Logging.warn(
673
+ `The custom resolvers process did not answer /healthz on port ${port->Int.toString} within ${(healthzTimeoutMs / 1000)
674
+ ->Int.toString}s. Its own output above says why.`,
675
+ )
676
+ }
677
+
678
+ // Rewritten first: the copy below hands serve whatever `.envio/resolvers.json`
679
+ // holds, and that is whatever the last codegen left. A resolver edited since
680
+ // then would be served against a manifest describing the previous shape.
681
+ await writeManifest(~config, ~projectRoot)
682
+
683
+ let serveDir = NodeJs.Path.resolve([projectRoot, ".envio", "serve-project"])
684
+ await NodeJs.Fs.Promises.mkdir(~path=serveDir, ~options={recursive: true})
685
+ let envioDir = NodeJs.Path.resolve([projectRoot, ".envio"])->NodeJs.Path.toString
686
+ let root = NodeJs.Path.resolve([projectRoot])->NodeJs.Path.toString
687
+ [
688
+ (NodeJs.Path.resolve([root, configFile])->NodeJs.Path.toString, "config.yaml"),
689
+ (NodeJs.Path.resolve([root, "schema.graphql"])->NodeJs.Path.toString, "schema.graphql"),
690
+ (`${envioDir}/resolvers.json`, "resolvers.json"),
691
+ ]->Array.forEach(((from, to_)) =>
692
+ if existsSync(from) {
693
+ copyFileSync(from, `${serveDir->NodeJs.Path.toString}/${to_}`)
694
+ }
695
+ )
696
+
697
+ let serveChild = ref(None)
698
+ switch processEnv->Dict.get("ENVIO_SERVE_BIN") {
699
+ | Some(bin) if existsSync(bin) =>
700
+ let servePort = processEnv->Dict.get("ENVIO_SERVE_PORT")->Option.getOr("8080")
701
+ let startServe = () => {
702
+ let serveEnv = Dict.copy(processEnv)
703
+ serveEnv->Dict.set("ENVIO_SERVE_RESOLVERS_URL", `http://127.0.0.1:${port->Int.toString}`)
704
+ let child = spawn(
705
+ bin,
706
+ [
707
+ "--directory",
708
+ serveDir->NodeJs.Path.toString,
709
+ "--config",
710
+ "config.yaml",
711
+ "--port",
712
+ servePort,
713
+ ],
714
+ {env: serveEnv, stdio: "inherit", detached: false},
715
+ )
716
+ child->unref
717
+ serveChild := Some(child)
718
+ Logging.info(`Custom resolvers ready — GraphQL with custom fields on port ${servePort}`)
719
+ }
720
+
721
+ // Serve refuses to boot against a database with no tables, and the
722
+ // indexer is what creates them — but the indexer only starts once this
723
+ // function returns. So the wait runs detached: awaiting it here would
724
+ // block the very thing it is waiting for.
725
+ let pool = createResolverPoolFromEnv({
726
+ entities: Dict.make(),
727
+ pgSchema: Env.Db.publicSchema,
728
+ })
729
+ let sql = pool->forResolver({name: "dev-wait", timeoutMs: 5000})->sqlOf
730
+ let rec waitForTables = async attempt =>
731
+ if attempt >= 120 {
732
+ Logging.warn("Timed out waiting for the indexer's tables; starting envio-serve anyway.")
733
+ startServe()
734
+ await pool->endPool
735
+ } else {
736
+ let count = switch await sql->unsafe(
737
+ "SELECT count(*)::int AS n FROM information_schema.tables WHERE table_schema = $1;",
738
+ [JSON.Encode.string(Env.Db.publicSchema)],
739
+ ) {
740
+ | rows => rows->Array.get(0)->Option.mapOr(0, row => row["n"])
741
+ | exception _ => 0
742
+ }
743
+ if count > 0 {
744
+ startServe()
745
+ await pool->endPool
746
+ } else {
747
+ await Utils.delay(1000)
748
+ await waitForTables(attempt + 1)
749
+ }
750
+ }
751
+ // Detached on purpose -- it polls in the background while dev carries on
752
+ // -- so its rejection has to be caught here or it is unhandled.
753
+ waitForTables(0)
754
+ ->Promise.catch(exn => {
755
+ Logging.warn(
756
+ `Stopped waiting for the resolver tables: ${exn->Utils.prettifyExn->Obj.magic}`,
757
+ )
758
+ Promise.resolve()
759
+ })
760
+ ->ignore
761
+ | _ =>
762
+ // Not a warning when Hasura is running: it publishes the resolvers as
763
+ // actions, so they are already in the endpoint a developer opens.
764
+ if Env.Hasura.enabled {
765
+ Logging.info(`Custom resolvers are published by Hasura as actions. Set ENVIO_SERVE_BIN to serve them through envio-serve instead.`)
766
+ } else {
767
+ Logging.warn(
768
+ `Resolvers are declared, but ENVIO_HASURA=false and ENVIO_SERVE_BIN is unset, so nothing publishes them. ` ++
769
+ `Drop ENVIO_HASURA=false to have Hasura serve them as actions, or point ENVIO_SERVE_BIN at an envio-serve binary and run it against ${serveDir->NodeJs.Path.toString}.`,
770
+ )
771
+ }
772
+ }
773
+
774
+ let stop = async () => {
775
+ switch serveChild.contents {
776
+ | Some(child) => await stopChild(child)
777
+ | None => ()
778
+ }
779
+ await stopChild(resolverChild)
780
+ }
781
+ // Ctrl-C reaches the children through the process group, but a bare
782
+ // SIGTERM to `envio dev` does not — without this the resolver process
783
+ // outlives it and the next run cannot bind the port.
784
+ let killChildren = () => {
785
+ switch serveChild.contents {
786
+ | Some(child) => ignore(child->killChild("SIGTERM"))
787
+ | None => ()
788
+ }
789
+ ignore(resolverChild->killChild("SIGTERM"))
790
+ }
791
+ onSignal("exit", killChildren)
792
+ // Registering a signal listener replaces Node's default handler, which is
793
+ // what terminates the process — so having taken the signal, this has to
794
+ // finish the job the default would have done. Nothing else in the indexer
795
+ // handles these.
796
+ ["SIGTERM", "SIGINT"]->Array.forEach(signal =>
797
+ onSignal(signal, () => {
798
+ killChildren()
799
+ NodeJs.process->NodeJs.exitWithCode(Success)
800
+ })
801
+ )
802
+
803
+ Some({port, pid: resolverChild->childPid, stop})
804
+ }
805
+ }