@reventlessdev/reventless-local 3.0.0-alpha.152 → 3.0.0-alpha.154

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.
@@ -1,9 +1,8 @@
1
1
  // LocalGraphQL_Adapter — implements ReventlessInfra.Api_Adapter.Provider for
2
2
  // the in-memory graphql-yoga server.
3
3
  //
4
- // makeApiResource: no-op (server lifecycle is managed by DomainGraphQL_Server.start/rebuildSchema)
4
+ // makeApiResource: no-op (server lifecycle is managed by DomainGraphQL_Server.start)
5
5
  // generateFragment: delegates to ReventlessCore.GraphQL_FragmentGenerator
6
- // updateSchema: rebuilds the stitched SDL via DomainGraphQL_Server.rebuildSchema
7
6
 
8
7
  type api = unit
9
8
  type role = unit
@@ -18,12 +17,3 @@ let generateFragment = (
18
17
  ~queryEntries: array<ReventlessInfra.Api.querySchemaEntry>,
19
18
  ): Reventless.Plugin.apiSchemaFragment =>
20
19
  ReventlessCore.GraphQL_FragmentGenerator.generate(~mutationEntries, ~queryEntries)
21
-
22
- let updateSchema = (
23
- ~api as _: Pulumi.Output.t<api>,
24
- ~baseFragment: Reventless.Plugin.apiSchemaFragment,
25
- ~pluginFragments: array<Reventless.Plugin.apiSchemaFragment>,
26
- ): promise<unit> => {
27
- DomainGraphQL_Server.rebuildSchema(~baseFragment, ~pluginFragments)
28
- Promise.resolve()
29
- }
@@ -1,7 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Pulumi from "@pulumi/pulumi";
4
- import * as DomainGraphQL_Server$ReventlessLocal from "../DomainGraphQL_Server.res.mjs";
5
4
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
6
5
 
7
6
  function makeApiResource(param, param$1) {
@@ -13,14 +12,8 @@ function makeApiResource(param, param$1) {
13
12
 
14
13
  let generateFragment = GraphQL_FragmentGenerator$ReventlessCore.generate;
15
14
 
16
- function updateSchema(param, baseFragment, pluginFragments) {
17
- DomainGraphQL_Server$ReventlessLocal.rebuildSchema(baseFragment, pluginFragments);
18
- return Promise.resolve();
19
- }
20
-
21
15
  export {
22
16
  makeApiResource,
23
17
  generateFragment,
24
- updateSchema,
25
18
  }
26
19
  /* @pulumi/pulumi Not a pure module */
@@ -100,15 +100,15 @@ let capitalize = s =>
100
100
  // the AWS stitched schema and the in-memory graphql-yoga server agree on the
101
101
  // CommandResult shape. We join the 4 declarations into a single SDL block for
102
102
  // the in-memory server's registerTypes API.
103
- // Registered once per server (guards against duplicate registration after reset).
103
+ // Registered unconditionally: registerTypes dedupes identical blocks, so on
104
+ // the scoped domain server every plugin scope gets its own copy (keeping each
105
+ // subgraph standalone-valid, mirroring the AWS source-API SDL) while repeat
106
+ // registrations within one scope/instance collapse to a single definition.
104
107
 
105
108
  let commandResultSdl = GraphQL_FragmentGenerator.commandResultSdlTypes->Array.join("\n\n")
106
109
 
107
- let ensureCommandResultTypes = (server: ReventlessGraphqlServer.GraphQL_ServerInstance.t) => {
108
- if !(server.buildSdl()->String.includes("CommandAccepted")) {
109
- server.registerTypes(~sdlTypes=[commandResultSdl])
110
- }
111
- }
110
+ let ensureCommandResultTypes = (server: ReventlessGraphqlServer.GraphQL_ServerInstance.t) =>
111
+ server.registerTypes(~sdlTypes=[commandResultSdl])
112
112
 
113
113
  // Re-export the shared encoder. Source of truth is
114
114
  // CommandTopic_Helpers.commandOutcomeToJson — same encoder is also reused by
@@ -106,9 +106,7 @@ function capitalize(s) {
106
106
  let commandResultSdl = GraphQL_FragmentGenerator$ReventlessCore.commandResultSdlTypes.join("\n\n");
107
107
 
108
108
  function ensureCommandResultTypes(server) {
109
- if (!server.buildSdl().includes("CommandAccepted")) {
110
- return server.registerTypes([commandResultSdl]);
111
- }
109
+ server.registerTypes([commandResultSdl]);
112
110
  }
113
111
 
114
112
  function extractCommandName(fieldName) {
@@ -143,7 +141,7 @@ function deriveSdlField(fieldName, variantSchema) {
143
141
  let handlerRefs = {};
144
142
 
145
143
  function register(fields, commandSchema, commandAuthorization, server) {
146
- ensureCommandResultTypes(server);
144
+ server.registerTypes([commandResultSdl]);
147
145
  let sdlFields = fields.map(field => {
148
146
  let variantIndex = variantIndexForField(commandSchema, field);
149
147
  let sdl = deriveSdlField(field, extractVariantSchema(commandSchema, variantIndex));
@@ -197,7 +195,7 @@ function register(fields, commandSchema, commandAuthorization, server) {
197
195
  }
198
196
 
199
197
  function registerDcb(fieldName, commandSchema, commandAuthorization, server) {
200
- ensureCommandResultTypes(server);
198
+ server.registerTypes([commandResultSdl]);
201
199
  let constructorNames = DcbTag$Reventless.extractAllVariantNames(commandSchema);
202
200
  let variantIndex = variantIndexForField(commandSchema, fieldName);
203
201
  let variantSchema = extractVariantSchema(commandSchema, variantIndex >= 0 ? variantIndex : 0);
@@ -8,7 +8,6 @@
8
8
  // - Relay Global ID encoding/decoding (encodeGlobalId / decodeGlobalId)
9
9
  // - Node type registry (registerNodeType / registerNodeResolverCallback)
10
10
  // - /sdl HTTP endpoint for Relay compiler
11
- // - rebuildSchema for schema stitching
12
11
  //
13
12
  // Note: the old setRegistrationTarget redirect hack has been removed. Routing
14
13
  // to the platform server is now done explicitly via resolveTargetGraphQL() in
@@ -119,7 +118,7 @@ let _isInvalidBearer = (req: nodeRequest): bool =>
119
118
  | _ => false
120
119
  }
121
120
 
122
- // Shared dispatch for start() and rebuildSchema(). Order matters: built-in
121
+ // Shared dispatch for start(). Order matters: built-in
123
122
  // endpoints win over the yoga catch-all so they aren't shadowed by graphql.
124
123
  let _dispatch = (req: nodeRequest, res: nodeResponse, yoga: YG.yoga, getSdl: unit => string): unit => {
125
124
  // Strip query string before path matching.
@@ -185,44 +184,155 @@ let decodeGlobalId = (globalId: string): option<(string, string)> =>
185
184
  | _ => None
186
185
  }
187
186
 
188
- // -- Registry --------------------------------------------------------------
187
+ // -- Scoped registry ---------------------------------------------------------
188
+ // Mirrors the AWS plugin=source-API, platform=merged-API model: every
189
+ // registration lands in the bucket of the *current scope*. The default
190
+ // "platform" scope holds admin/base registrations; Platform.res wraps each
191
+ // plugin's construction in its own scope (setScope/relabelScope/resetScope)
192
+ // so a plugin's SDL fragment + resolvers form a standalone subgraph that is
193
+ // validated in isolation and composed at start().
194
+
195
+ let platformScope = "platform"
196
+
197
+ type bucket = {
198
+ mutable mutationFields: array<string>,
199
+ mutable queryFields: array<string>,
200
+ mutable subscriptionFields: array<string>,
201
+ mutable typeDefinitions: array<string>,
202
+ mutationResolvers: dict<resolverFn>,
203
+ queryResolvers: dict<resolverFn>,
204
+ subscriptionResolvers: dict<resolverFn>,
205
+ }
206
+
207
+ let makeBucket = (~scope: string): bucket => {
208
+ mutationFields: [],
209
+ queryFields: [],
210
+ subscriptionFields: [],
211
+ // Non-platform (plugin) buckets are seeded with the Relay base types so
212
+ // their standalone documents are self-contained — mirrors the AWS path
213
+ // where stitchStandalone embeds the shared types in every subgraph SDL.
214
+ // Identical copies dedupe in the final merge.
215
+ typeDefinitions: scope == platformScope
216
+ ? []
217
+ : ReventlessCore.GraphQL_Stitcher.relayBaseTypes->Array.copy,
218
+ mutationResolvers: Dict.make(),
219
+ queryResolvers: Dict.make(),
220
+ subscriptionResolvers: Dict.make(),
221
+ }
189
222
 
190
- let mutationResolvers: ref<dict<resolverFn>> = ref(Dict.make())
191
- let queryResolvers: ref<dict<resolverFn>> = ref(Dict.make())
192
- let subscriptionResolvers: ref<dict<resolverFn>> = ref(Dict.make())
223
+ // Insertion order = composition order (platform bucket is forced first in
224
+ // orderedBuckets regardless of creation order).
225
+ let buckets: ref<dict<bucket>> = ref(Dict.make())
226
+ let currentScope: ref<string> = ref(platformScope)
227
+
228
+ let bucketFor = (scope: string): bucket =>
229
+ switch buckets.contents->Dict.get(scope) {
230
+ | Some(b) => b
231
+ | None =>
232
+ let b = makeBucket(~scope)
233
+ buckets.contents->Dict.set(scope, b)
234
+ b
235
+ }
236
+
237
+ let currentBucket = () => bucketFor(currentScope.contents)
193
238
 
194
- let mutationFields: ref<array<string>> = ref([])
195
- let queryFields: ref<array<string>> = ref([])
196
- let subscriptionFields: ref<array<string>> = ref([])
197
- let typeDefinitions: ref<array<string>> = ref([])
239
+ /** Route subsequent registrations into the named scope's bucket. */
240
+ let setScope = (name: string) => {
241
+ currentScope.contents = name
242
+ let _ = bucketFor(name)
243
+ }
244
+
245
+ /** Route subsequent registrations back into the default "platform" bucket. */
246
+ let resetScope = () => currentScope.contents = platformScope
247
+
248
+ /** Rename a scope bucket. Platform.res scopes a plugin's construction with a
249
+ token (the plugin name is only known after construction) and relabels the
250
+ bucket to the plugin name afterwards. Merges into `to_` if it exists. */
251
+ let relabelScope = (~from: string, ~to_: string) =>
252
+ if from != to_ {
253
+ switch buckets.contents->Dict.get(from) {
254
+ | None => ()
255
+ | Some(b) =>
256
+ buckets.contents->Dict.delete(from)
257
+ switch buckets.contents->Dict.get(to_) {
258
+ | None => buckets.contents->Dict.set(to_, b)
259
+ | Some(existing) =>
260
+ existing.mutationFields = existing.mutationFields->Array.concat(b.mutationFields)
261
+ existing.queryFields = existing.queryFields->Array.concat(b.queryFields)
262
+ existing.subscriptionFields =
263
+ existing.subscriptionFields->Array.concat(b.subscriptionFields)
264
+ b.typeDefinitions->Array.forEach(t =>
265
+ if !(existing.typeDefinitions->Array.includes(t)) {
266
+ existing.typeDefinitions->Array.push(t)
267
+ }
268
+ )
269
+ b.mutationResolvers
270
+ ->Dict.toArray
271
+ ->Array.forEach(((k, v)) => existing.mutationResolvers->Dict.set(k, v))
272
+ b.queryResolvers
273
+ ->Dict.toArray
274
+ ->Array.forEach(((k, v)) => existing.queryResolvers->Dict.set(k, v))
275
+ b.subscriptionResolvers
276
+ ->Dict.toArray
277
+ ->Array.forEach(((k, v)) => existing.subscriptionResolvers->Dict.set(k, v))
278
+ }
279
+ if currentScope.contents == from {
280
+ currentScope.contents = to_
281
+ }
282
+ }
283
+ }
284
+
285
+ // Platform bucket first, then the plugin buckets in creation order.
286
+ let orderedBuckets = (): array<(string, bucket)> => {
287
+ let entries = buckets.contents->Dict.toArray
288
+ entries
289
+ ->Array.filter(((scope, _)) => scope == platformScope)
290
+ ->Array.concat(entries->Array.filter(((scope, _)) => scope != platformScope))
291
+ }
198
292
 
199
293
  let registerMutations = (~sdlFields: array<string>, ~resolvers: dict<resolverFn>) => {
200
- mutationFields.contents = mutationFields.contents->Array.concat(sdlFields)
201
- resolvers->Dict.toArray->Array.forEach(((k, v)) => mutationResolvers.contents->Dict.set(k, v))
294
+ let b = currentBucket()
295
+ b.mutationFields = b.mutationFields->Array.concat(sdlFields)
296
+ resolvers->Dict.toArray->Array.forEach(((k, v)) => b.mutationResolvers->Dict.set(k, v))
202
297
  }
203
298
 
204
299
  let registerQueries = (~sdlFields: array<string>, ~resolvers: dict<resolverFn>) => {
205
- queryFields.contents = queryFields.contents->Array.concat(sdlFields)
206
- resolvers->Dict.toArray->Array.forEach(((k, v)) => queryResolvers.contents->Dict.set(k, v))
300
+ let b = currentBucket()
301
+ b.queryFields = b.queryFields->Array.concat(sdlFields)
302
+ resolvers->Dict.toArray->Array.forEach(((k, v)) => b.queryResolvers->Dict.set(k, v))
207
303
  }
208
304
 
209
305
  let registerSubscriptions = (~sdlFields: array<string>, ~resolvers: dict<resolverFn>) => {
210
- subscriptionFields.contents = subscriptionFields.contents->Array.concat(sdlFields)
211
- resolvers->Dict.toArray->Array.forEach(((k, v)) =>
212
- subscriptionResolvers.contents->Dict.set(k, v)
213
- )
306
+ let b = currentBucket()
307
+ b.subscriptionFields = b.subscriptionFields->Array.concat(sdlFields)
308
+ resolvers->Dict.toArray->Array.forEach(((k, v)) => b.subscriptionResolvers->Dict.set(k, v))
214
309
  }
215
310
 
216
- /** Look up a registered mutation resolver by field name (used by MCP_Server). */
311
+ /** Look up a registered mutation resolver by field name (used by MCP_Server).
312
+ Searches all scope buckets. */
217
313
  let getMutationResolver = (fieldName: string): option<resolverFn> =>
218
- mutationResolvers.contents->Dict.get(fieldName)
314
+ buckets.contents
315
+ ->Dict.valuesToArray
316
+ ->Array.findMap(b => b.mutationResolvers->Dict.get(fieldName))
219
317
 
220
- /** Look up a registered query resolver by field name (used by MCP_Server). */
318
+ /** Look up a registered query resolver by field name (used by MCP_Server).
319
+ Searches all scope buckets. */
221
320
  let getQueryResolver = (fieldName: string): option<resolverFn> =>
222
- queryResolvers.contents->Dict.get(fieldName)
223
-
224
- let registerTypes = (~sdlTypes: array<string>) =>
225
- typeDefinitions.contents = typeDefinitions.contents->Array.concat(sdlTypes)
321
+ buckets.contents
322
+ ->Dict.valuesToArray
323
+ ->Array.findMap(b => b.queryResolvers->Dict.get(fieldName))
324
+
325
+ // Identical type blocks dedupe within a bucket, so shared-type registrations
326
+ // (CommandResult union family, Relay base types) can be repeated per scope —
327
+ // each plugin bucket carries its own copy and stays standalone-valid.
328
+ let registerTypes = (~sdlTypes: array<string>) => {
329
+ let b = currentBucket()
330
+ sdlTypes->Array.forEach(t =>
331
+ if !(b.typeDefinitions->Array.includes(t)) {
332
+ b.typeDefinitions->Array.push(t)
333
+ }
334
+ )
335
+ }
226
336
 
227
337
  // -- Relay Node type registry -----------------------------------------------
228
338
  // Maps GraphQL type names to QueryDb component names for node(id: ID!) resolution.
@@ -272,59 +382,191 @@ let activeServer: ref<option<YG.httpServer>> = ref(None)
272
382
  let activeSchema: ref<option<YG.schema>> = ref(None)
273
383
  let lastFullSdl: ref<option<string>> = ref(None)
274
384
 
275
- let buildSdl = () => {
276
- let typesSdl = typeDefinitions.contents->Array.length > 0
277
- ? typeDefinitions.contents->Array.join("\n\n")
278
- : ""
279
- let mutations =
280
- mutationFields.contents->Array.length > 0
281
- ? mutationFields.contents->Array.join("\n")
282
- : " _noop: String"
283
- let queries =
284
- queryFields.contents->Array.length > 0
285
- ? queryFields.contents->Array.join("\n")
286
- : " _noop: String"
287
- let queriesMutationsSdl = `type Query {
288
- ${queries}
385
+ // Shared SDL assembly for one bucket. `withRootDefaults` keeps the historical
386
+ // behavior of always emitting Query/Mutation (with a `_noop` placeholder when
387
+ // empty) — required for the platform bucket so the composed schema always has
388
+ // a Query type. Plugin buckets omit empty root types (their standalone
389
+ // documents mirror an AWS source-API schema).
390
+ let assembleBucketSdl = (
391
+ ~typeDefinitions: array<string>,
392
+ ~queryFields: array<string>,
393
+ ~mutationFields: array<string>,
394
+ ~subscriptionFields: array<string>,
395
+ ~withRootDefaults: bool,
396
+ ): string => {
397
+ let sections = []
398
+ if typeDefinitions->Array.length > 0 {
399
+ sections->Array.push(typeDefinitions->Array.join("\n\n"))
400
+ }
401
+ if withRootDefaults || queryFields->Array.length > 0 {
402
+ let queries =
403
+ queryFields->Array.length > 0 ? queryFields->Array.join("\n") : " _noop: String"
404
+ sections->Array.push(`type Query {\n${queries}\n}`)
405
+ }
406
+ if withRootDefaults || mutationFields->Array.length > 0 {
407
+ let mutations =
408
+ mutationFields->Array.length > 0 ? mutationFields->Array.join("\n") : " _noop: String"
409
+ sections->Array.push(`type Mutation {\n${mutations}\n}`)
410
+ }
411
+ if subscriptionFields->Array.length > 0 {
412
+ sections->Array.push(`type Subscription {\n${subscriptionFields->Array.join("\n")}\n}`)
413
+ }
414
+ sections->Array.join("\n\n")
289
415
  }
290
- type Mutation {
291
- ${mutations}
292
- }`
293
- let subscriptionsSdl =
294
- subscriptionFields.contents->Array.length > 0
295
- ? `\n\ntype Subscription {\n${subscriptionFields.contents->Array.join("\n")}\n}`
296
- : ""
297
- let base =
298
- typesSdl->String.length > 0
299
- ? typesSdl ++ "\n\n" ++ queriesMutationsSdl
300
- : queriesMutationsSdl
301
- base ++ subscriptionsSdl
416
+
417
+ // Standalone subgraph document for a plugin bucket (empty root types omitted).
418
+ let buildScopeSdl = (b: bucket): string =>
419
+ assembleBucketSdl(
420
+ ~typeDefinitions=b.typeDefinitions,
421
+ ~queryFields=b.queryFields,
422
+ ~mutationFields=b.mutationFields,
423
+ ~subscriptionFields=b.subscriptionFields,
424
+ ~withRootDefaults=false,
425
+ )
426
+
427
+ // Aggregated SDL across all buckets (diagnostics / registered-SDL view).
428
+ // Identical type blocks registered in multiple buckets appear once.
429
+ let buildSdl = () => {
430
+ let seenTypes = Set.make()
431
+ let allTypes: array<string> = []
432
+ let allQueries: array<string> = []
433
+ let allMutations: array<string> = []
434
+ let allSubscriptions: array<string> = []
435
+ orderedBuckets()->Array.forEach(((_, b)) => {
436
+ b.typeDefinitions->Array.forEach(t =>
437
+ if !(seenTypes->Set.has(t)) {
438
+ seenTypes->Set.add(t)
439
+ allTypes->Array.push(t)
440
+ }
441
+ )
442
+ allQueries->Array.pushMany(b.queryFields)
443
+ allMutations->Array.pushMany(b.mutationFields)
444
+ allSubscriptions->Array.pushMany(b.subscriptionFields)
445
+ })
446
+ assembleBucketSdl(
447
+ ~typeDefinitions=allTypes,
448
+ ~queryFields=allQueries,
449
+ ~mutationFields=allMutations,
450
+ ~subscriptionFields=allSubscriptions,
451
+ ~withRootDefaults=true,
452
+ )
302
453
  }
303
454
 
304
455
  // `~contextFactory` is accepted to satisfy the `ReventlessGraphqlServer.GraphQL_ServerInstance.t`
305
456
  // interface but ignored — the data server always wires `buildAuthContext`
306
457
  // internally so the bearer-token rules + /__inmemory/login + /sdl dispatch
307
458
  // stay consistent across callers.
459
+ let exnMessage = (exn: exn): string =>
460
+ exn
461
+ ->JsExn.fromException
462
+ ->Option.flatMap(JsExn.message)
463
+ ->Option.getOr("unknown error")
464
+
465
+ // Per-bucket root resolver map ({Query, Mutation, Subscription} — empty roots
466
+ // omitted except for the platform bucket which always carries Query/Mutation,
467
+ // matching its `_noop` root defaults).
468
+ let scopeResolverMap = (b: bucket, ~withRootDefaults: bool): dict<dict<resolverFn>> => {
469
+ let resolvers = Dict.make()
470
+ if withRootDefaults || b.queryResolvers->Dict.keysToArray->Array.length > 0 {
471
+ resolvers->Dict.set("Query", b.queryResolvers)
472
+ }
473
+ if withRootDefaults || b.mutationResolvers->Dict.keysToArray->Array.length > 0 {
474
+ resolvers->Dict.set("Mutation", b.mutationResolvers)
475
+ }
476
+ if b.subscriptionResolvers->Dict.keysToArray->Array.length > 0 {
477
+ resolvers->Dict.set("Subscription", b.subscriptionResolvers)
478
+ }
479
+ resolvers
480
+ }
481
+
482
+ let scopeHasFields = (b: bucket): bool =>
483
+ b.mutationFields->Array.length > 0 ||
484
+ b.queryFields->Array.length > 0 ||
485
+ b.subscriptionFields->Array.length > 0
486
+
487
+ /** Validate every plugin bucket's standalone document, then compose the final
488
+ schema from one document + one resolver map per bucket (platform first) via
489
+ graphql-tools merge semantics — mirrors AWS source-API validation followed
490
+ by the merged-API build. Exposed for start() and tests. */
491
+ let composeSchema = (): YG.schema => {
492
+ // Standalone validation per plugin bucket — a plugin whose document
493
+ // references types it does not define itself fails HERE, with attribution,
494
+ // before the cross-plugin merge (mirrors AWS: each source API schema must
495
+ // be valid standalone).
496
+ orderedBuckets()->Array.forEach(((scope, b)) =>
497
+ if scope != platformScope && scopeHasFields(b) {
498
+ let doc = buildScopeSdl(b)
499
+ try {
500
+ let _ = YG.createSchema({
501
+ "typeDefs": doc,
502
+ "resolvers": scopeResolverMap(b, ~withRootDefaults=false),
503
+ })
504
+ } catch {
505
+ | e =>
506
+ JsError.throwWithMessage(
507
+ `Plugin "${scope}" subgraph document is not valid standalone: ${exnMessage(e)}`,
508
+ )
509
+ }
510
+ }
511
+ )
512
+
513
+ // Composition — graphql-tools mergeTypeDefs/mergeResolvers semantics:
514
+ // identical duplicate type definitions dedupe; conflicting same-named
515
+ // definitions throw naming the type (the local equivalent of MERGE_FAILED).
516
+ let platformBucket = bucketFor(platformScope)
517
+ let docs = [
518
+ assembleBucketSdl(
519
+ ~typeDefinitions=platformBucket.typeDefinitions,
520
+ ~queryFields=platformBucket.queryFields,
521
+ ~mutationFields=platformBucket.mutationFields,
522
+ ~subscriptionFields=platformBucket.subscriptionFields,
523
+ ~withRootDefaults=true,
524
+ ),
525
+ ]
526
+ let resolverMaps = [scopeResolverMap(platformBucket, ~withRootDefaults=true)]
527
+ orderedBuckets()->Array.forEach(((scope, b)) =>
528
+ if scope != platformScope {
529
+ let doc = buildScopeSdl(b)
530
+ if doc->String.length > 0 {
531
+ docs->Array.push(doc)
532
+ resolverMaps->Array.push(scopeResolverMap(b, ~withRootDefaults=false))
533
+ }
534
+ }
535
+ )
536
+ try {
537
+ YG.createSchemaMulti({"typeDefs": docs, "resolvers": resolverMaps})
538
+ } catch {
539
+ | e =>
540
+ JsError.throwWithMessage(
541
+ `Cross-plugin schema merge failed (mirrors AWS MERGE_FAILED): ${exnMessage(e)}`,
542
+ )
543
+ }
544
+ }
545
+
308
546
  let start = (~port: int=4000, ~contextFactory as _: option<YG.contextFactory>=?, ()) => {
309
- // Register node resolver if callback is set
547
+ // Register node resolver if callback is set — the node()/Node registry is
548
+ // platform-owned (local keeps its working in-process node()).
310
549
  switch buildNodeResolver() {
311
- | Some((name, resolver)) => queryResolvers.contents->Dict.set(name, resolver)
550
+ | Some((name, resolver)) => bucketFor(platformScope).queryResolvers->Dict.set(name, resolver)
312
551
  | None => ()
313
552
  }
314
- let resolvers = Dict.make()
315
- resolvers->Dict.set("Query", queryResolvers.contents)
316
- resolvers->Dict.set("Mutation", mutationResolvers.contents)
317
- if subscriptionResolvers.contents->Dict.keysToArray->Array.length > 0 {
318
- resolvers->Dict.set("Subscription", subscriptionResolvers.contents)
319
- }
320
- let sdl = buildSdl()
321
- lastFullSdl.contents = Some(sdl)
322
- let schema = YG.createSchema({"typeDefs": sdl, "resolvers": resolvers})
553
+ lastFullSdl.contents = Some(buildSdl())
554
+ let schema = composeSchema()
323
555
  activeSchema.contents = Some(schema)
556
+ // Route yoga's own logging into the framework logger. error/warn ALWAYS surface
557
+ // (so a masked resolver failure — which yoga reports via logger.error with the
558
+ // original Error even when the client response is masked — is never swallowed
559
+ // again), while verbose debug/info stay gated on GRAPHQL_DEBUG.
560
+ let yogaLogging: YG.yogaLogger = {
561
+ debug: a => if debug { log.debug(~comp="GraphQL:Domain", YG.logArgToString(a)) },
562
+ info: a => if debug { log.info(~comp="GraphQL:Domain", YG.logArgToString(a)) },
563
+ warn: a => log.warn(~comp="GraphQL:Domain", YG.logArgToString(a)),
564
+ error: a => log.error(~comp="GraphQL:Domain", YG.logArgToString(a)),
565
+ }
324
566
  let yoga = YG.createYogaWithContext({
325
567
  "schema": schema,
326
568
  "graphiql": true,
327
- "logging": debug,
569
+ "logging": yogaLogging,
328
570
  "maskedErrors": !debug,
329
571
  "context": buildAuthContext,
330
572
  })
@@ -350,69 +592,11 @@ let stop = () =>
350
592
  | None => ()
351
593
  }
352
594
 
353
- // Rebuild the server schema by stitching plugin fragments with registered resolvers.
354
- // Merges any additional type definitions from fragments into the type registry,
355
- // then uses buildSdl() which includes types + Query + Mutation.
356
- // Stops the existing server and restarts with the new SDL.
357
- let rebuildSchema = (
358
- ~baseFragment: Reventless.Plugin.apiSchemaFragment,
359
- ~pluginFragments: array<Reventless.Plugin.apiSchemaFragment>,
360
- ) => {
361
- stop()
362
- // Merge any fragment types not already registered via the hook
363
- let allFragments = Array.concat([baseFragment], pluginFragments)
364
- let fragmentTypes = allFragments->Array.flatMap(frag => ReventlessCore.GraphQL_Stitcher.decode(frag).types)
365
- let existingNames = Set.fromArray(typeDefinitions.contents->Array.map(ReventlessCore.GraphQL_Stitcher.extractLeadingName))
366
- fragmentTypes->Array.forEach(typeDef => {
367
- let name = ReventlessCore.GraphQL_Stitcher.extractLeadingName(typeDef)
368
- if !(existingNames->Set.has(name)) {
369
- typeDefinitions.contents->Array.push(typeDef)
370
- existingNames->Set.add(name)
371
- }
372
- })
373
- // Register node resolver if callback is set
374
- switch buildNodeResolver() {
375
- | Some((name, resolver)) => queryResolvers.contents->Dict.set(name, resolver)
376
- | None => ()
377
- }
378
- let fullSdl = buildSdl()
379
- lastFullSdl.contents = Some(fullSdl)
380
- let resolvers = Dict.make()
381
- resolvers->Dict.set("Query", queryResolvers.contents)
382
- resolvers->Dict.set("Mutation", mutationResolvers.contents)
383
- if subscriptionResolvers.contents->Dict.keysToArray->Array.length > 0 {
384
- resolvers->Dict.set("Subscription", subscriptionResolvers.contents)
385
- }
386
- let schema = YG.createSchema({"typeDefs": fullSdl, "resolvers": resolvers})
387
- activeSchema.contents = Some(schema)
388
- let yoga = YG.createYogaWithContext({
389
- "schema": schema,
390
- "graphiql": true,
391
- "logging": debug,
392
- "maskedErrors": !debug,
393
- "context": buildAuthContext,
394
- })
395
- let getSdl = () =>
396
- switch activeSchema.contents {
397
- | Some(s) => YG.printSchema(s)
398
- | None => lastFullSdl.contents->Option.getOr("")
399
- }
400
- let server = createServerWithHandler((req, res) => _dispatch(req, res, yoga, getSdl))
401
- server->YG.listen(4000, () =>
402
- log.info(~comp="GraphQL:Domain", "rebuilt schema - http://localhost:4000/graphql (SDL: /sdl)")
403
- )
404
- activeServer.contents = Some(server)
405
- }
406
-
407
- // Reset registry state (call between isolated test suites).
595
+ // Reset registry state (call between isolated test suites). Clears all scope
596
+ // buckets and restores the default "platform" scope.
408
597
  let reset = () => {
409
- mutationResolvers.contents = Dict.make()
410
- queryResolvers.contents = Dict.make()
411
- subscriptionResolvers.contents = Dict.make()
412
- mutationFields.contents = []
413
- queryFields.contents = []
414
- subscriptionFields.contents = []
415
- typeDefinitions.contents = []
598
+ buckets.contents = Dict.make()
599
+ currentScope.contents = platformScope
416
600
  nodeTypeRegistry.contents = Dict.make()
417
601
  nodeResolverCallback.contents = None
418
602
  activeSchema.contents = None
@@ -426,8 +610,8 @@ let getRegisteredSdl = () => buildSdl()
426
610
  type resolverNames = {mutations: array<string>, queries: array<string>}
427
611
 
428
612
  let getRegisteredResolverNames = (): resolverNames => {
429
- mutations: mutationResolvers.contents->Dict.keysToArray,
430
- queries: queryResolvers.contents->Dict.keysToArray,
613
+ mutations: orderedBuckets()->Array.flatMap(((_, b)) => b.mutationResolvers->Dict.keysToArray),
614
+ queries: orderedBuckets()->Array.flatMap(((_, b)) => b.queryResolvers->Dict.keysToArray),
431
615
  }
432
616
 
433
617
  let getFullSdl = () => lastFullSdl.contents
@@ -460,12 +644,19 @@ let extractFieldName = (sdlField: string): string => {
460
644
  }
461
645
 
462
646
  let diagnostics = (): diagnostics => {
647
+ let allBuckets = orderedBuckets()->Array.map(((_, b)) => b)
463
648
  let mutFieldNames =
464
- mutationFields.contents->Array.map(extractFieldName)->Array.filter(n => n != "_noop")
649
+ allBuckets
650
+ ->Array.flatMap(b => b.mutationFields)
651
+ ->Array.map(extractFieldName)
652
+ ->Array.filter(n => n != "_noop")
465
653
  let queryFieldNames =
466
- queryFields.contents->Array.map(extractFieldName)->Array.filter(n => n != "_noop")
467
- let mutResolverNames = mutationResolvers.contents->Dict.keysToArray
468
- let queryResolverNames = queryResolvers.contents->Dict.keysToArray
654
+ allBuckets
655
+ ->Array.flatMap(b => b.queryFields)
656
+ ->Array.map(extractFieldName)
657
+ ->Array.filter(n => n != "_noop")
658
+ let mutResolverNames = allBuckets->Array.flatMap(b => b.mutationResolvers->Dict.keysToArray)
659
+ let queryResolverNames = allBuckets->Array.flatMap(b => b.queryResolvers->Dict.keysToArray)
469
660
 
470
661
  let mismatches: array<string> = []
471
662
 
@@ -497,7 +688,18 @@ let diagnostics = (): diagnostics => {
497
688
  }
498
689
  )
499
690
 
500
- let typeNames = typeDefinitions.contents->Array.map(ReventlessCore.GraphQL_Stitcher.extractLeadingName)
691
+ // Identical type blocks registered in multiple scopes count once (they
692
+ // dedupe in the composed schema).
693
+ let seenTypeBlocks = Set.make()
694
+ let typeNames: array<string> = []
695
+ allBuckets->Array.forEach(b =>
696
+ b.typeDefinitions->Array.forEach(t =>
697
+ if !(seenTypeBlocks->Set.has(t)) {
698
+ seenTypeBlocks->Set.add(t)
699
+ typeNames->Array.push(ReventlessCore.GraphQL_Stitcher.extractLeadingName(t))
700
+ }
701
+ )
702
+ )
501
703
 
502
704
  {
503
705
  registeredTypeDefinitions: typeNames,