@reventlessdev/reventless-local 3.0.0-alpha.226 → 3.0.0-alpha.227

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/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.227 (2026-08-18)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **api:** declare the reference door in the SDL every backend is built from ([5c1857e](https://github.com/ReventlessDev/reventless-core/commit/5c1857ea90ff40305a1c44a9e57043528e6a93aa))
11
+ * **api:** make the by-index door answer, and let an elevated caller widen it ([0fe0c6f](https://github.com/ReventlessDev/reventless-core/commit/0fe0c6f8dec6228ecaba39577e28d780b4f79c83))
12
+ ### Features
13
+
14
+ * **core:** let a reference name a retired row, and let an elevated caller open one ([9e2623a](https://github.com/ReventlessDev/reventless-core/commit/9e2623a4b22487561607fcc0ca19d51726069ee4))
15
+
16
+
6
17
  # 3.0.0-alpha.226 (2026-08-16)
7
18
 
8
19
  **Note:** Version bump only for package @reventlessdev/reventless-local
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.226",
3
+ "version": "3.0.0-alpha.227",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -35,23 +35,23 @@
35
35
  "sury": "11.0.0-alpha.4",
36
36
  "ws": "^8.18.0",
37
37
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
38
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
39
38
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
39
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
40
40
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
41
- "@reventlessdev/rescript-node": "2.0.0-alpha.7",
41
+ "@reventlessdev/rescript-node": "2.0.0-alpha.8",
42
+ "@reventlessdev/reventless-core": "3.0.0-alpha.239",
42
43
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
43
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.86",
44
- "@reventlessdev/reventless-infra": "3.0.0-alpha.144",
45
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.185",
46
- "@reventlessdev/reventless-core": "3.0.0-alpha.238",
47
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.102",
48
- "@reventlessdev/reventless-seed": "1.0.0-alpha.15",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.116"
44
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.186",
45
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.87",
46
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.145",
47
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.103",
48
+ "@reventlessdev/reventless-seed": "1.0.0-alpha.16",
49
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.117"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.0",
53
53
  "sury-ppx": "11.0.0-alpha.2",
54
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.68"
54
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.69"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "rescript": "12.3.0"
package/src/Platform.res CHANGED
@@ -1428,11 +1428,10 @@ module MakeWithConfig = (
1428
1428
  switch entry.indexQueries {
1429
1429
  | Some(indexes) =>
1430
1430
  indexes->Array.forEach((ic: Reventless.ReadModel.indexConfig) => {
1431
- let stripped =
1432
- ic.index->String.startsWith("by") && ic.index->String.length > 2
1433
- ? ic.index->String.slice(~start=2, ~end=ic.index->String.length)
1434
- : ic.index
1435
- let fieldName = entry.singleFieldName ++ "By" ++ stripped->String.capitalize
1431
+ let fieldName = ReventlessCore.GraphQL_FragmentGenerator.indexQueryFieldName(
1432
+ ~singleFieldName=entry.singleFieldName,
1433
+ ~index=ic.index,
1434
+ )
1436
1435
  queryResolvers->Dict.set(fieldName, async (_root, _args, _ctx): JSON.t =>
1437
1436
  connectionResponse([])
1438
1437
  )
@@ -9,7 +9,6 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
9
9
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
10
10
  import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
11
11
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
12
- import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
13
12
  import * as Effect from "effect/Effect";
14
13
  import * as Pulumi from "@pulumi/pulumi";
15
14
  import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
@@ -81,6 +80,7 @@ import * as LocalDcbEventLogStorage$ReventlessLocal from "./adapter/DcbEventLog/
81
80
  import * as LocalRuntimeEnvironment$ReventlessLocal from "./adapter/Runtime/LocalRuntimeEnvironment.res.mjs";
82
81
  import * as LocalScheduledPublisher$ReventlessLocal from "./adapter/Scheduler/LocalScheduledPublisher.res.mjs";
83
82
  import * as Platform_Admin_Structure$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_Admin_Structure.res.mjs";
83
+ import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
84
84
  import * as LocalCommandTopicChannel$ReventlessLocal from "./adapter/CommandTopic/LocalCommandTopicChannel.res.mjs";
85
85
  import * as LocalEventTopicPublisher$ReventlessLocal from "./adapter/EventTopic/LocalEventTopicPublisher.res.mjs";
86
86
  import * as StateChangeSlice_Builder$ReventlessLocal from "./components/StateChangeSlice_Builder.res.mjs";
@@ -1206,8 +1206,7 @@ function MakeWithConfig(Config) {
1206
1206
  let indexes = entry.indexQueries;
1207
1207
  if (indexes !== undefined) {
1208
1208
  indexes.forEach(ic => {
1209
- let stripped = ic.index.startsWith("by") && ic.index.length > 2 ? ic.index.slice(2, ic.index.length) : ic.index;
1210
- let fieldName = entry.singleFieldName + "By" + Stdlib_String.capitalize(stripped);
1209
+ let fieldName = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(entry.singleFieldName, ic.index);
1211
1210
  queryResolvers[fieldName] = async (_root, _args, _ctx) => connectionResponse([]);
1212
1211
  });
1213
1212
  return;
@@ -2933,8 +2932,7 @@ function Make($star) {
2933
2932
  let indexes = entry.indexQueries;
2934
2933
  if (indexes !== undefined) {
2935
2934
  indexes.forEach(ic => {
2936
- let stripped = ic.index.startsWith("by") && ic.index.length > 2 ? ic.index.slice(2, ic.index.length) : ic.index;
2937
- let fieldName = entry.singleFieldName + "By" + Stdlib_String.capitalize(stripped);
2935
+ let fieldName = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(entry.singleFieldName, ic.index);
2938
2936
  queryResolvers[fieldName] = async (_root, _args, _ctx) => connectionResponse([]);
2939
2937
  });
2940
2938
  return;
@@ -228,8 +228,6 @@ module Make = (Bus: LocalBus.T) => {
228
228
  )
229
229
  }
230
230
 
231
- let cap = s => s->String.charAt(0)->String.toUpperCase ++ s->String.slice(~start=1)
232
-
233
231
  // Resolve query field names: check registry first, fall back to safe defaults.
234
232
  // Fallbacks use simple GraphQL built-in types to avoid referencing non-existent custom types.
235
233
  let registryEntry = Plugin_Helpers.queryFieldNamesRegistry->Dict.get(name)
@@ -271,10 +269,16 @@ module Make = (Bus: LocalBus.T) => {
271
269
  }
272
270
 
273
271
  // -- Main query: getById ---------------------------------------------------
272
+ // `includeRetired` here matches the connection field and the AppSync SDL the
273
+ // FragmentGenerator emits. The resolver below already reads it — `retiredAllows`
274
+ // consults `askedForRetired(~args)` on every door — so this argument was the
275
+ // only missing half, and without it an elevated caller could see an archived
276
+ // row in a list and not open it.
274
277
  let byIdSdl = if includeIdParam {
275
278
  switch subIdField {
276
- | Some(sf) => ` ${singleQueryName}(id: ID!, ${sf}: String): ${returnTypeName}`
277
- | None => ` ${singleQueryName}(id: ID!): ${returnTypeName}`
279
+ | Some(sf) =>
280
+ ` ${singleQueryName}(id: ID!, ${sf}: String, includeRetired: Boolean): ${returnTypeName}`
281
+ | None => ` ${singleQueryName}(id: ID!, includeRetired: Boolean): ${returnTypeName}`
278
282
  }
279
283
  } else {
280
284
  ` ${singleQueryName}: ${returnTypeName}`
@@ -402,6 +406,124 @@ module Make = (Bus: LocalBus.T) => {
402
406
  | None => "id"
403
407
  }
404
408
 
409
+ // -- Reference door: {listFieldName}Refs -----------------------------------
410
+ // Names the rows a caller already holds pointers to, and nothing more. Three
411
+ // fields, fixed by the type, so this cannot be widened by what a caller
412
+ // selects.
413
+ //
414
+ // Retirement is lifted here only where the view declared `@namedWhenRetired`.
415
+ // Ownership is not lifted at all, and the asymmetry is the point: an archived
416
+ // product is a thing the shop sold and every order naming one may say what it
417
+ // was, while a deactivated customer is somebody's row before it is anything
418
+ // else. One predicate answers "is this row still on offer", the other "is this
419
+ // row yours", and only the first is what an archive withdraws.
420
+ let refsSdl = if includeIdParam && subIdField === None {
421
+ [GraphQL_FragmentGenerator.deriveRefsQueryField(~listFieldName=listQueryName, ~returnTypeName)]
422
+ } else {
423
+ []
424
+ }
425
+ let refsResolverEntry: option<(string, ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn)> =
426
+ if includeIdParam && subIdField === None {
427
+ server.registerTypes(~sdlTypes=[GraphQL_FragmentGenerator.deriveRefTypeSdl(~returnTypeName)])
428
+ let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (
429
+ _root,
430
+ args,
431
+ ctx,
432
+ ) => {
433
+ switch await runInterceptor(~ctx, ~args) {
434
+ | Deny(_) => []->JSON.Encode.array
435
+ | Allow =>
436
+ let ids =
437
+ args
438
+ ->JSON.Decode.object
439
+ ->Option.flatMap(d => d->Dict.get("ids"))
440
+ ->Option.flatMap(JSON.Decode.array)
441
+ ->Option.getOr([])
442
+ ->Array.filterMap(JSON.Decode.string)
443
+ let spec = retiredSpecOf()
444
+ let namesRetired = spec->Option.mapOr(false, r => r.namedWhenRetired)
445
+ // The row's own retirement, read with the same `isRetiredValue` every
446
+ // other door narrows by — so "retired" here and "withheld" there are
447
+ // one answer, not two that drift.
448
+ let retirementOf = (item: JSON.t) =>
449
+ switch spec {
450
+ | None => (false, None)
451
+ | Some(r) =>
452
+ let cell = item->JSON.Decode.object->Option.flatMap(d => d->Dict.get(r.field))
453
+ let scope: Reventless.OwnerScope.retiredScope = {field: r.field, values: r.values}
454
+ let retired = scope->Reventless.OwnerScope.isRetiredValue(cell)
455
+ (
456
+ retired,
457
+ // The state that retired it, and only that. A live row reports
458
+ // none — this door names rows, it does not publish a lifecycle
459
+ // column to callers the list withholds. Nor does the boolean
460
+ // form, where the field is the state and `retired` said it.
461
+ retired && r.values->Option.isSome
462
+ ? cell->Option.flatMap(JSON.Decode.string)
463
+ : None,
464
+ )
465
+ }
466
+ switch Bus.getQueryDb(name) {
467
+ | Some(ops) =>
468
+ let load = key =>
469
+ ops.loadStream(key)
470
+ ->Stream.runCollect
471
+ ->Effect.catchAll(_ => Effect.succeed([]))
472
+ ->Effect.runPromise
473
+ let loaded = await ids->Array.map(async id =>
474
+ switch (await load(id))->Array.get(0) {
475
+ | Some(item) => (id, Some(item))
476
+ | None =>
477
+ switch Api_Ids.alternateKey(id) {
478
+ | Some(localId) => (localId, (await load(localId))->Array.get(0))
479
+ | None => (id, None)
480
+ }
481
+ }
482
+ )->Promise.all
483
+ loaded
484
+ ->Array.filterMap(((id, opt)) =>
485
+ opt
486
+ ->Option.filter(item => ownerAllows(~ctx, item))
487
+ ->Option.flatMap(item => {
488
+ let (retired, state) = retirementOf(item)
489
+ // A retired row leaves through this door only where the view
490
+ // said it may. Where it did not, the door answers exactly as
491
+ // every other one does — with nothing.
492
+ if retired && !namesRetired {
493
+ None
494
+ } else {
495
+ let label =
496
+ item
497
+ ->JSON.Decode.object
498
+ ->Option.flatMap(d => d->Dict.get(labelField))
499
+ ->Option.flatMap(JSON.Decode.string)
500
+ // A view with no label field resolves to its id, which is
501
+ // what `labelField`'s own fallback already decided.
502
+ ->Option.getOr(id)
503
+ Some(
504
+ Dict.fromArray([
505
+ ("id", JSON.Encode.string(id)),
506
+ ("label", JSON.Encode.string(label)),
507
+ ("retired", JSON.Encode.bool(retired)),
508
+ (
509
+ "retiredState",
510
+ state->Option.mapOr(JSON.Encode.null, JSON.Encode.string),
511
+ ),
512
+ ])->JSON.Encode.object,
513
+ )
514
+ }
515
+ })
516
+ )
517
+ ->JSON.Encode.array
518
+ | None => []->JSON.Encode.array
519
+ }
520
+ }
521
+ }
522
+ Some((listQueryName ++ "Refs", resolver))
523
+ } else {
524
+ None
525
+ }
526
+
405
527
  // -- List query -------------------------------------------------------------
406
528
  // Look up the registered state schema (populated alongside queryFieldNamesRegistry)
407
529
  // so the resolver derives the same serverCapability the FragmentGenerator emitted.
@@ -666,20 +788,95 @@ module Make = (Bus: LocalBus.T) => {
666
788
  }
667
789
 
668
790
  // -- Index queries: {name}By{Index} ---------------------------------------
791
+ // Name, argument and return type all come from `GraphQL_FragmentGenerator`,
792
+ // which is also where the AppSync SDL gets them. They used to be spelled out
793
+ // here independently and disagreed with that emitter on all three, so the
794
+ // same door had a different signature per backend and worked on neither.
669
795
  let indexSdlFields = indexes->Array.map((ic: Reventless.ReadModel.indexConfig) =>
670
- ` ${singleQueryName}By${cap(ic.index)}(${ic.index}: String!): [String]`
796
+ GraphQL_FragmentGenerator.deriveIndexQueryField(
797
+ ~singleFieldName=singleQueryName,
798
+ ~indexConfig=ic,
799
+ ~connectionTypeName=returnTypeName ++ "Connection",
800
+ )
671
801
  )
802
+ // Backward paging is refused rather than ignored, and refused on both
803
+ // backends rather than on the one that cannot do it. `listAllItemsConnection`
804
+ // already sets the rule for a door whose read cannot walk backwards — "fail
805
+ // loud rather than silently returning the forward page" — and its reasoning
806
+ // is about the answer, not about DynamoDB: `last: 2` handed the first two
807
+ // rows is a different question answered without saying so.
808
+ //
809
+ // The arguments stay in the SDL. One that appeared and disappeared with the
810
+ // backend would make adding a sort key a breaking schema change and force
811
+ // every client to feature-detect, which is the same argument `includeRetired`
812
+ // and the reference door are emitted unconditionally for.
813
+ let rejectBackwardPaging = (~args) => {
814
+ let given = key =>
815
+ switch args->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key)) {
816
+ | None | Some(JSON.Null) => false
817
+ | Some(_) => true
818
+ }
819
+ if given("last") || given("before") {
820
+ throw(
821
+ GraphQL_CallerError.badUserInput(
822
+ "Backward pagination (last/before) is not supported on by-index connections; use first/after.",
823
+ ),
824
+ )
825
+ }
826
+ }
827
+ // A positional connection. This door answers an equality lookup on one index
828
+ // value and has no ordering key to cut a cursor from — the list door borrows
829
+ // its sort key because it orders by one. An offset is stable for as long as
830
+ // a page-forward takes, which is what the field advertises and no more.
831
+ let indexConnection = (~args, items: array<JSON.t>) => {
832
+ let arg = key => args->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key))
833
+ let after =
834
+ arg("after")->Option.flatMap(JSON.Decode.string)->Option.flatMap(s => Int.fromString(s))->Option.getOr(-1)
835
+ let first =
836
+ arg("first")->Option.flatMap(JSON.Decode.float)->Option.map(Float.toInt)->Option.getOr(50)
837
+ let start = after + 1
838
+ let page = items->Array.slice(~start, ~end=start + first)
839
+ let taken = page->Array.length
840
+ let edges =
841
+ page->Array.mapWithIndex((item, i) =>
842
+ Dict.fromArray([
843
+ ("node", item),
844
+ ("cursor", (start + i)->Int.toString->JSON.Encode.string),
845
+ ])->JSON.Encode.object
846
+ )
847
+ let cursorAt = i => i->Int.toString->JSON.Encode.string
848
+ Dict.fromArray([
849
+ ("edges", edges->JSON.Encode.array),
850
+ (
851
+ "pageInfo",
852
+ Dict.fromArray([
853
+ ("hasNextPage", JSON.Encode.bool(start + taken < items->Array.length)),
854
+ ("hasPreviousPage", JSON.Encode.bool(start > 0)),
855
+ ("startCursor", taken > 0 ? cursorAt(start) : JSON.Encode.null),
856
+ ("endCursor", taken > 0 ? cursorAt(start + taken - 1) : JSON.Encode.null),
857
+ ])->JSON.Encode.object,
858
+ ),
859
+ ])->JSON.Encode.object
860
+ }
672
861
  let indexResolvers: array<(string, ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn)> = indexes->Array.map(
673
862
  (ic: Reventless.ReadModel.indexConfig) => {
674
- let index = ic.index
675
- let resolverName = singleQueryName ++ "By" ++ cap(index)
676
- let filterField = ic.idField->Option.getOr(index)
863
+ let resolverName = GraphQL_FragmentGenerator.indexQueryFieldName(
864
+ ~singleFieldName=singleQueryName,
865
+ ~index=ic.index,
866
+ )
867
+ // The argument the caller passes and the row field this filters on are
868
+ // one and the same — the door reads the index's key, not its name.
869
+ let filterField = GraphQL_FragmentGenerator.indexKeyField(ic)
677
870
  let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (_root, args, ctx) => {
678
871
  switch await runInterceptor(~ctx, ~args) {
679
- | Deny(_) => []->JSON.Encode.array
872
+ | Deny(_) => indexConnection(~args, [])
680
873
  | Allow =>
874
+ // After the interceptor, matching the AppSync pipeline, where the
875
+ // interceptor leads the chain and the query function's `request`
876
+ // raises this second.
877
+ rejectBackwardPaging(~args)
681
878
  let value =
682
- args->JSON.Decode.object->Option.flatMap(d => d->Dict.get(index))->Option.flatMap(JSON.Decode.string)->Option.getOr("")
879
+ args->JSON.Decode.object->Option.flatMap(d => d->Dict.get(filterField))->Option.flatMap(JSON.Decode.string)->Option.getOr("")
683
880
  // Applied to whichever arm answers, rather than inside one of them: the
684
881
  // push-down and the scan are two ways to reach the same rows, and a
685
882
  // narrowing that lives in only one is a hole that appears when a
@@ -690,22 +887,24 @@ module Make = (Bus: LocalBus.T) => {
690
887
  // in-memory reuses its lazy snapshot). Fall back to scan+filter only
691
888
  // if no lookup is registered for this QueryDb.
692
889
  switch Bus.getQueryDbIndexLookup(name) {
693
- | Some(lookup) => lookup(filterField, value)->scoped->JSON.Encode.array
890
+ | Some(lookup) => indexConnection(~args, lookup(filterField, value)->scoped)
694
891
  | None =>
695
892
  switch Bus.getQueryDbScan(name) {
696
893
  | Some(scanAll) =>
697
- scanAll()
698
- ->Array.filter(item =>
699
- item
700
- ->JSON.Decode.object
701
- ->Option.flatMap(d => d->Dict.get(filterField))
702
- ->Option.flatMap(JSON.Decode.string)
703
- ->Option.map(v => v == value)
704
- ->Option.getOr(false)
894
+ indexConnection(
895
+ ~args,
896
+ scanAll()
897
+ ->Array.filter(item =>
898
+ item
899
+ ->JSON.Decode.object
900
+ ->Option.flatMap(d => d->Dict.get(filterField))
901
+ ->Option.flatMap(JSON.Decode.string)
902
+ ->Option.map(v => v == value)
903
+ ->Option.getOr(false)
904
+ )
905
+ ->scoped,
705
906
  )
706
- ->scoped
707
- ->JSON.Encode.array
708
- | None => []->JSON.Encode.array
907
+ | None => indexConnection(~args, [])
709
908
  }
710
909
  }
711
910
  }
@@ -718,6 +917,7 @@ module Make = (Bus: LocalBus.T) => {
718
917
  let allSdl =
719
918
  [byIdSdl]
720
919
  ->Array.concat(byIdsSdl)
920
+ ->Array.concat(refsSdl)
721
921
  ->Array.concat(listSdl)
722
922
  ->Array.concat(itemsSdl)
723
923
  ->Array.concat(indexSdlFields)
@@ -725,6 +925,7 @@ module Make = (Bus: LocalBus.T) => {
725
925
  let resolvers = Dict.make()
726
926
  resolvers->Dict.set(singleQueryName, byIdResolver)
727
927
  byIdsResolverEntry->Option.forEach(((k, v)) => resolvers->Dict.set(k, v))
928
+ refsResolverEntry->Option.forEach(((k, v)) => resolvers->Dict.set(k, v))
728
929
  listResolvers->Array.forEach(((k, v)) => resolvers->Dict.set(k, v))
729
930
  itemsResolvers->Array.forEach(((k, v)) => resolvers->Dict.set(k, v))
730
931
  indexResolvers->Array.forEach(((k, v)) => resolvers->Dict.set(k, v))
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
4
+ import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
4
5
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
6
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
@@ -16,6 +17,7 @@ import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/s
16
17
  import * as SortKey_Filter$ReventlessLocal from "./SortKey_Filter.res.mjs";
17
18
  import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
18
19
  import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
20
+ import * as GraphQL_CallerError$ReventlessLocal from "../GraphQL_CallerError.res.mjs";
19
21
  import * as DomainGraphQL_Server$ReventlessLocal from "../DomainGraphQL_Server.res.mjs";
20
22
  import * as OwnerScopeDiagnostics$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/OwnerScopeDiagnostics.res.mjs";
21
23
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
@@ -112,7 +114,6 @@ function Make(Bus) {
112
114
  return true;
113
115
  }
114
116
  };
115
- let cap = s => s.charAt(0).toUpperCase() + s.slice(1);
116
117
  let registryEntry = Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[name];
117
118
  let singleQueryName = registryEntry !== undefined ? registryEntry.singleFieldName : name.charAt(0).toLowerCase() + name.slice(1);
118
119
  let listQueryName = registryEntry !== undefined ? registryEntry.listFieldName : name + "s";
@@ -124,7 +125,7 @@ function Make(Bus) {
124
125
  relay.registerNodeType(returnTypeName, name);
125
126
  }
126
127
  let byIdSdl = includeIdParam ? (
127
- subIdField !== undefined ? ` ` + singleQueryName + `(id: ID!, ` + subIdField + `: String): ` + returnTypeName : ` ` + singleQueryName + `(id: ID!): ` + returnTypeName
128
+ subIdField !== undefined ? ` ` + singleQueryName + `(id: ID!, ` + subIdField + `: String, includeRetired: Boolean): ` + returnTypeName : ` ` + singleQueryName + `(id: ID!, includeRetired: Boolean): ` + returnTypeName
128
129
  ) : ` ` + singleQueryName + `: ` + returnTypeName;
129
130
  let byIdResolver = async (_root, args, ctx) => {
130
131
  let match = await runInterceptor(ctx, args);
@@ -219,6 +220,101 @@ function Make(Bus) {
219
220
  byIdsResolverEntry = undefined;
220
221
  }
221
222
  let labelField = registryEntry !== undefined ? Stdlib_Option.getOr(registryEntry.labelField, "id") : "id";
223
+ let refsSdl = includeIdParam && subIdField === undefined ? [GraphQL_FragmentGenerator$ReventlessCore.deriveRefsQueryField(listQueryName, returnTypeName)] : [];
224
+ let refsResolverEntry;
225
+ if (includeIdParam && subIdField === undefined) {
226
+ server.registerTypes([GraphQL_FragmentGenerator$ReventlessCore.deriveRefTypeSdl(returnTypeName)]);
227
+ let resolver$1 = async (_root, args, ctx) => {
228
+ let match = await runInterceptor(ctx, args);
229
+ if (typeof match === "object") {
230
+ return [];
231
+ }
232
+ let ids = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d["ids"]), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.string);
233
+ let spec = retiredSpecOf();
234
+ let namesRetired = Stdlib_Option.mapOr(spec, false, r => r.namedWhenRetired);
235
+ let retirementOf = item => {
236
+ if (spec === undefined) {
237
+ return [
238
+ false,
239
+ undefined
240
+ ];
241
+ }
242
+ let cell = Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[spec.field]);
243
+ let scope_field = spec.field;
244
+ let scope_values = spec.values;
245
+ let scope = {
246
+ field: scope_field,
247
+ values: scope_values
248
+ };
249
+ let retired = OwnerScope$Reventless.isRetiredValue(scope, cell);
250
+ return [
251
+ retired,
252
+ retired && Stdlib_Option.isSome(spec.values) ? Stdlib_Option.flatMap(cell, Stdlib_JSON.Decode.string) : undefined
253
+ ];
254
+ };
255
+ let ops = Bus.getQueryDb(name);
256
+ if (ops === undefined) {
257
+ return [];
258
+ }
259
+ let load = key => Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(key)), param => Effect.succeed([])));
260
+ let loaded = await Promise.all(ids.map(async id => {
261
+ let item = (await load(id))[0];
262
+ if (item !== undefined) {
263
+ return [
264
+ id,
265
+ item
266
+ ];
267
+ }
268
+ let localId = Api_Ids$ReventlessCore.alternateKey(id);
269
+ if (localId !== undefined) {
270
+ return [
271
+ localId,
272
+ (await load(localId))[0]
273
+ ];
274
+ } else {
275
+ return [
276
+ id,
277
+ undefined
278
+ ];
279
+ }
280
+ }));
281
+ return Stdlib_Array.filterMap(loaded, param => {
282
+ let id = param[0];
283
+ return Stdlib_Option.flatMap(Stdlib_Option.filter(param[1], item => ownerAllows(ctx, item)), item => {
284
+ let match = retirementOf(item);
285
+ let retired = match[0];
286
+ if (retired && !namesRetired) {
287
+ return;
288
+ }
289
+ let label = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[labelField]), Stdlib_JSON.Decode.string), id);
290
+ return Object.fromEntries([
291
+ [
292
+ "id",
293
+ id
294
+ ],
295
+ [
296
+ "label",
297
+ label
298
+ ],
299
+ [
300
+ "retired",
301
+ retired
302
+ ],
303
+ [
304
+ "retiredState",
305
+ Stdlib_Option.mapOr(match[1], null, prim => prim)
306
+ ]
307
+ ]);
308
+ });
309
+ });
310
+ };
311
+ refsResolverEntry = [
312
+ listQueryName + "Refs",
313
+ resolver$1
314
+ ];
315
+ } else {
316
+ refsResolverEntry = undefined;
317
+ }
222
318
  let stateSchemaOpt = Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name];
223
319
  let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(name, stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
224
320
  let fetchAllItems = async () => {
@@ -250,7 +346,7 @@ function Make(Bus) {
250
346
  endCursor: null
251
347
  }
252
348
  };
253
- let resolver$1 = async (_root, args, ctx) => {
349
+ let resolver$2 = async (_root, args, ctx) => {
254
350
  let match = await runInterceptor(ctx, args);
255
351
  if (typeof match === "object") {
256
352
  return emptyConnection;
@@ -277,11 +373,11 @@ function Make(Bus) {
277
373
  };
278
374
  match = [
279
375
  sdl,
280
- resolver$1
376
+ resolver$2
281
377
  ];
282
378
  } else {
283
379
  let sdl$1 = [` ` + listQueryName + `(nextToken: String, limit: Int): ` + pluralTypeName + `!`];
284
- let resolver$2 = async (_root, args, ctx) => {
380
+ let resolver$3 = async (_root, args, ctx) => {
285
381
  let match = await runInterceptor(ctx, args);
286
382
  if (typeof match === "object") {
287
383
  return {
@@ -306,7 +402,7 @@ function Make(Bus) {
306
402
  };
307
403
  match = [
308
404
  sdl$1,
309
- resolver$2
405
+ resolver$3
310
406
  ];
311
407
  }
312
408
  let listResolvers = [[
@@ -324,7 +420,7 @@ function Make(Bus) {
324
420
  }
325
421
  let itemsResolvers;
326
422
  if (subIdField !== undefined) {
327
- let resolver$3 = async (_root, args, ctx) => {
423
+ let resolver$4 = async (_root, args, ctx) => {
328
424
  let emptyConn = {
329
425
  edges: [],
330
426
  pageInfo: {
@@ -423,22 +519,81 @@ function Make(Bus) {
423
519
  };
424
520
  itemsResolvers = [[
425
521
  singleQueryName + "Items",
426
- resolver$3
522
+ resolver$4
427
523
  ]];
428
524
  } else {
429
525
  itemsResolvers = [];
430
526
  }
431
- let indexSdlFields = indexes.map(ic => ` ` + singleQueryName + `By` + cap(ic.index) + `(` + ic.index + `: String!): [String]`);
527
+ let indexSdlFields = indexes.map(ic => GraphQL_FragmentGenerator$ReventlessCore.deriveIndexQueryField(singleQueryName, ic, returnTypeName + "Connection"));
528
+ let rejectBackwardPaging = args => {
529
+ let given = key => {
530
+ let match = Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[key]);
531
+ if (match !== undefined) {
532
+ return match !== null;
533
+ } else {
534
+ return false;
535
+ }
536
+ };
537
+ if (!(given("last") || given("before"))) {
538
+ return;
539
+ }
540
+ throw GraphQL_CallerError$ReventlessLocal.badUserInput("Backward pagination (last/before) is not supported on by-index connections; use first/after.");
541
+ };
542
+ let indexConnection = (args, items) => {
543
+ let arg = key => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[key]);
544
+ let after = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(arg("after"), Stdlib_JSON.Decode.string), s => Stdlib_Int.fromString(s, undefined)), -1);
545
+ let first = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(arg("first"), Stdlib_JSON.Decode.float), prim => prim | 0), 50);
546
+ let start = after + 1 | 0;
547
+ let page = items.slice(start, start + first | 0);
548
+ let taken = page.length;
549
+ let edges = page.map((item, i) => Object.fromEntries([
550
+ [
551
+ "node",
552
+ item
553
+ ],
554
+ [
555
+ "cursor",
556
+ (start + i | 0).toString()
557
+ ]
558
+ ]));
559
+ return Object.fromEntries([
560
+ [
561
+ "edges",
562
+ edges
563
+ ],
564
+ [
565
+ "pageInfo",
566
+ Object.fromEntries([
567
+ [
568
+ "hasNextPage",
569
+ (start + taken | 0) < items.length
570
+ ],
571
+ [
572
+ "hasPreviousPage",
573
+ start > 0
574
+ ],
575
+ [
576
+ "startCursor",
577
+ taken > 0 ? start.toString() : null
578
+ ],
579
+ [
580
+ "endCursor",
581
+ taken > 0 ? ((start + taken | 0) - 1 | 0).toString() : null
582
+ ]
583
+ ])
584
+ ]
585
+ ]);
586
+ };
432
587
  let indexResolvers = indexes.map(ic => {
433
- let index = ic.index;
434
- let resolverName = singleQueryName + "By" + cap(index);
435
- let filterField = Stdlib_Option.getOr(ic.idField, index);
588
+ let resolverName = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(singleQueryName, ic.index);
589
+ let filterField = GraphQL_FragmentGenerator$ReventlessCore.indexKeyField(ic);
436
590
  let resolver = async (_root, args, ctx) => {
437
591
  let match = await runInterceptor(ctx, args);
438
592
  if (typeof match === "object") {
439
- return [];
593
+ return indexConnection(args, []);
440
594
  }
441
- let value = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[index]), Stdlib_JSON.Decode.string), "");
595
+ rejectBackwardPaging(args);
596
+ let value = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[filterField]), Stdlib_JSON.Decode.string), "");
442
597
  let scoped = rows => rows.filter(item => {
443
598
  if (ownerAllows(ctx, item)) {
444
599
  return retiredAllows(ctx, args, item);
@@ -448,13 +603,13 @@ function Make(Bus) {
448
603
  });
449
604
  let lookup = Bus.getQueryDbIndexLookup(name);
450
605
  if (lookup !== undefined) {
451
- return scoped(lookup(filterField, value));
606
+ return indexConnection(args, scoped(lookup(filterField, value)));
452
607
  }
453
608
  let scanAll = Bus.getQueryDbScan(name);
454
609
  if (scanAll !== undefined) {
455
- return scoped(scanAll().filter(item => Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[filterField]), Stdlib_JSON.Decode.string), v => v === value), false)));
610
+ return indexConnection(args, scoped(scanAll().filter(item => Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[filterField]), Stdlib_JSON.Decode.string), v => v === value), false))));
456
611
  } else {
457
- return [];
612
+ return indexConnection(args, []);
458
613
  }
459
614
  };
460
615
  return [
@@ -462,12 +617,15 @@ function Make(Bus) {
462
617
  resolver
463
618
  ];
464
619
  });
465
- let allSdl = [byIdSdl].concat(byIdsSdl).concat(match[0]).concat(itemsSdl).concat(indexSdlFields);
620
+ let allSdl = [byIdSdl].concat(byIdsSdl).concat(refsSdl).concat(match[0]).concat(itemsSdl).concat(indexSdlFields);
466
621
  let resolvers = {};
467
622
  resolvers[singleQueryName] = byIdResolver;
468
623
  Stdlib_Option.forEach(byIdsResolverEntry, param => {
469
624
  resolvers[param[0]] = param[1];
470
625
  });
626
+ Stdlib_Option.forEach(refsResolverEntry, param => {
627
+ resolvers[param[0]] = param[1];
628
+ });
471
629
  listResolvers.forEach(param => {
472
630
  resolvers[param[0]] = param[1];
473
631
  });
@@ -103,30 +103,54 @@ describe("UiHints.emit", () => {
103
103
  // point, while mid-session it is almost always an editor mid-save, and killing a
104
104
  // running dev server over a keystroke is worse than the restart this replaces.
105
105
 
106
- // Both exits close the watcher AND clear the deadline. A watcher is `unref`ed
106
+ // The edit is repeated, and that is not belt-and-braces.
107
+ //
108
+ // `fs.watch` does not promise that a write landing near the watcher's creation
109
+ // is reported. The FSEvents stream behind it on macOS is armed asynchronously,
110
+ // and a write that gets there first is not reported late — it is not reported at
111
+ // all. Measured against this very module: once the stream is up the edit is seen
112
+ // in about 10ms, and when it is not, the same edit produces no event in four
113
+ // seconds, with which of the two happens varying by how many watchers the
114
+ // process has already opened rather than by anything the test does.
115
+ //
116
+ // So a single edit tests the arming, not the reload. Repeating it on an interval
117
+ // far longer than a reload takes — 50ms of debounce plus a file copy — leaves the
118
+ // case where the first write lands identical to what it always was, and lets the
119
+ // case where it is dropped recover on the next one instead of reporting that hot
120
+ // reload is broken. Re-writing the same bytes is what a developer saving twice
121
+ // does, so nothing here asserts less than it did.
122
+ let retryEvery = 1000
123
+
124
+ // All three exits close the watcher AND clear both timers. A watcher is `unref`ed
107
125
  // and would not hold the run open on its own, but a live 4-second timer would —
108
126
  // and "Jest did not exit" on a suite that passed is exactly the noise that
109
127
  // teaches a reader to ignore it.
110
- let waitForReload = (~timeoutMs: int=4000, ~afterWatching: unit => unit, ~uiHintsFile, ~dir) =>
128
+ let waitForReload = (~timeoutMs: int=4000, ~edit: unit => unit, ~uiHintsFile, ~dir) =>
111
129
  Promise.make((resolve, _) => {
112
130
  let fired = ref(0)
113
131
  let watcher = ref(None)
114
132
  let deadline = ref(None)
133
+ let retry = ref(None)
115
134
  // Resolves with a count rather than rejecting on the deadline: "nothing was
116
135
  // re-served" is the expected answer in half these cases, and a rejection
117
136
  // would make the assertion read as an infrastructure failure.
118
137
  let finish = () => {
119
138
  watcher.contents->Option.forEach(NodeFs.watcherClose)
120
139
  deadline.contents->Option.forEach(clearTimeout)
140
+ retry.contents->Option.forEach(clearTimeout)
121
141
  resolve(fired.contents)
122
142
  }
143
+ let rec editUntilSeen = () => {
144
+ edit()
145
+ retry := Some(setTimeout(editUntilSeen, retryEvery))
146
+ }
123
147
  watcher :=
124
148
  UiHints.watch(~uiHintsFile, ~dir, ~onReload=() => {
125
149
  fired := fired.contents + 1
126
150
  finish()
127
151
  })
128
152
  deadline := Some(setTimeout(finish, timeoutMs))
129
- afterWatching()
153
+ editUntilSeen()
130
154
  })
131
155
 
132
156
  describe("UiHints.watch", () => {
@@ -147,7 +171,7 @@ describe("UiHints.watch", () => {
147
171
  let fired = await waitForReload(
148
172
  ~uiHintsFile=Some(path),
149
173
  ~dir,
150
- ~afterWatching=() => NodeFs.writeFileSync(path, edited),
174
+ ~edit=() => NodeFs.writeFileSync(path, edited),
151
175
  )
152
176
  expect((fired > 0, served(dir)))->toEqual((true, edited))
153
177
  })
@@ -163,7 +187,7 @@ describe("UiHints.watch", () => {
163
187
  ~timeoutMs=1500,
164
188
  ~uiHintsFile=Some(path),
165
189
  ~dir,
166
- ~afterWatching=() => NodeFs.writeFileSync(path, `{"Catalog": {"views":`),
190
+ ~edit=() => NodeFs.writeFileSync(path, `{"Catalog": {"views":`),
167
191
  )
168
192
  expect((fired, served(dir)))->toEqual((0, declared))
169
193
  })
@@ -178,7 +202,10 @@ describe("UiHints.watch", () => {
178
202
  let fired = await waitForReload(
179
203
  ~uiHintsFile=Some(path),
180
204
  ~dir,
181
- ~afterWatching=() => {
205
+ // Both halves of the save, so a repeat of this edit is the same story told
206
+ // again and still ends on the whole file — never a truncated write left
207
+ // standing after a complete one.
208
+ ~edit=() => {
182
209
  NodeFs.writeFileSync(path, `{"Catalog": {"views":`)
183
210
  let _ = setTimeout(() => NodeFs.writeFileSync(path, whole), 200)
184
211
  },
@@ -88,7 +88,7 @@ globalThis.describe("UiHints.emit", () => {
88
88
  });
89
89
  });
90
90
 
91
- function waitForReload(timeoutMsOpt, afterWatching, uiHintsFile, dir) {
91
+ function waitForReload(timeoutMsOpt, edit, uiHintsFile, dir) {
92
92
  let timeoutMs = timeoutMsOpt !== undefined ? timeoutMsOpt : 4000;
93
93
  return new Promise((resolve, param) => {
94
94
  let fired = {
@@ -100,6 +100,9 @@ function waitForReload(timeoutMsOpt, afterWatching, uiHintsFile, dir) {
100
100
  let deadline = {
101
101
  contents: undefined
102
102
  };
103
+ let retry = {
104
+ contents: undefined
105
+ };
103
106
  let finish = () => {
104
107
  Stdlib_Option.forEach(watcher.contents, prim => {
105
108
  prim.close();
@@ -107,14 +110,21 @@ function waitForReload(timeoutMsOpt, afterWatching, uiHintsFile, dir) {
107
110
  Stdlib_Option.forEach(deadline.contents, prim => {
108
111
  clearTimeout(prim);
109
112
  });
113
+ Stdlib_Option.forEach(retry.contents, prim => {
114
+ clearTimeout(prim);
115
+ });
110
116
  resolve(fired.contents);
111
117
  };
118
+ let editUntilSeen = () => {
119
+ edit();
120
+ retry.contents = Primitive_option.some(setTimeout(editUntilSeen, 1000));
121
+ };
112
122
  watcher.contents = UiHints$ReventlessLocal.watch(uiHintsFile, dir, () => {
113
123
  fired.contents = fired.contents + 1 | 0;
114
124
  finish();
115
125
  });
116
126
  deadline.contents = Primitive_option.some(setTimeout(finish, timeoutMs));
117
- afterWatching();
127
+ editUntilSeen();
118
128
  });
119
129
  }
120
130
 
@@ -178,6 +188,8 @@ globalThis.describe("UiHints.watch", () => {
178
188
  });
179
189
  });
180
190
 
191
+ let retryEvery = 1000;
192
+
181
193
  export {
182
194
  shipped,
183
195
  declared,
@@ -187,6 +199,7 @@ export {
187
199
  served,
188
200
  baselinePath,
189
201
  threw,
202
+ retryEvery,
190
203
  waitForReload,
191
204
  }
192
205
  /* Not a pure module */
@@ -271,7 +271,7 @@ describe("GraphQL_SchemaInspector", () => {
271
271
  expect(result.typeDef->Option.isSome)->toBe(true)
272
272
  let typeDef = result.typeDef->Option.getOrThrow
273
273
  expect(typeDef->String.includes("type CatalogProduct"))->toBe(true)
274
- expect(result.singleQuery->String.includes("Catalog_Product(id: ID!)"))->toBe(true)
274
+ expect(result.singleQuery->String.includes("Catalog_Product(id: ID!, includeRetired: Boolean)"))->toBe(true)
275
275
  expect(result.singleQuery->String.includes("CatalogProduct"))->toBe(true)
276
276
  expect(result.listQuery->Option.isSome)->toBe(true)
277
277
  let listQ = result.listQuery->Option.getOrThrow
@@ -298,7 +298,7 @@ describe("GraphQL_SchemaInspector", () => {
298
298
  )
299
299
  let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
300
300
  let sdl = inspection.sdlPreview
301
- expect(sdl->String.includes("RM_Product(id: ID!): RMProduct"))->toBe(true)
301
+ expect(sdl->String.includes("RM_Product(id: ID!, includeRetired: Boolean): RMProduct"))->toBe(true)
302
302
  expect(sdl->String.includes("type RMProduct"))->toBe(true)
303
303
  // The type should have an injected id: ID! field (first field in the type)
304
304
  let typeLines = sdl->String.split("\n")
@@ -357,7 +357,7 @@ describe("GraphQL_SchemaInspector", () => {
357
357
  )
358
358
  let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
359
359
  let sdl = inspection.sdlPreview
360
- expect(sdl->String.includes("Default_Thing(id: ID!): DefaultThing"))->toBe(true)
360
+ expect(sdl->String.includes("Default_Thing(id: ID!, includeRetired: Boolean): DefaultThing"))->toBe(true)
361
361
  })
362
362
  })
363
363
 
@@ -542,13 +542,15 @@ describe("GraphQL_SchemaInspector", () => {
542
542
  ],
543
543
  )
544
544
  let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
545
- // TestState, TestStateEdge, TestStateConnection, TestStateFilter (Phase 4 connection filter)
545
+ // TestState, TestStateEdge, TestStateConnection, TestStateFilter (connection filter)
546
+ // + TestStateRef (the reference door's projection)
546
547
  // + CommandResult union + CommandAccepted + CommandRejected + CommandPending
547
548
  // (auto-injected whenever the fragment emits any mutation field).
548
- expect(inspection.types->Array.length)->toBe(8)
549
+ expect(inspection.types->Array.length)->toBe(9)
549
550
  expect(inspection.mutations->Array.length)->toBe(1)
550
- // single Test_State(id), list Test_States(...), and Test_StatesByIds(ids: [String!]!)
551
- expect(inspection.queries->Array.length)->toBe(3)
551
+ // single Test_State(id), list Test_States(...), Test_StatesByIds(ids: [String!]!)
552
+ // and Test_StatesRefs(ids: [ID!]!) — the by-ids read projected to a reference.
553
+ expect(inspection.queries->Array.length)->toBe(4)
552
554
  expect(inspection.sdlPreview->String.includes("type TestState"))->toBe(true)
553
555
  expect(inspection.sdlPreview->String.includes("type TestStateEdge"))->toBe(true)
554
556
  expect(inspection.sdlPreview->String.includes("type TestStateConnection"))->toBe(true)
@@ -239,7 +239,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
239
239
  globalThis.expect(Stdlib_Option.isSome(result.typeDef)).toBe(true);
240
240
  let typeDef = Stdlib_Option.getOrThrow(result.typeDef, undefined);
241
241
  globalThis.expect(typeDef.includes("type CatalogProduct")).toBe(true);
242
- globalThis.expect(result.singleQuery.includes("Catalog_Product(id: ID!)")).toBe(true);
242
+ globalThis.expect(result.singleQuery.includes("Catalog_Product(id: ID!, includeRetired: Boolean)")).toBe(true);
243
243
  globalThis.expect(result.singleQuery.includes("CatalogProduct")).toBe(true);
244
244
  globalThis.expect(Stdlib_Option.isSome(result.listQuery)).toBe(true);
245
245
  let listQ = Stdlib_Option.getOrThrow(result.listQuery, undefined);
@@ -258,7 +258,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
258
258
  }]);
259
259
  let inspection = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment);
260
260
  let sdl = inspection.sdlPreview;
261
- globalThis.expect(sdl.includes("RM_Product(id: ID!): RMProduct")).toBe(true);
261
+ globalThis.expect(sdl.includes("RM_Product(id: ID!, includeRetired: Boolean): RMProduct")).toBe(true);
262
262
  globalThis.expect(sdl.includes("type RMProduct")).toBe(true);
263
263
  let typeLines = sdl.split("\n");
264
264
  let idFieldInType = typeLines.some(line => {
@@ -302,7 +302,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
302
302
  }]);
303
303
  let inspection = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment);
304
304
  let sdl = inspection.sdlPreview;
305
- globalThis.expect(sdl.includes("Default_Thing(id: ID!): DefaultThing")).toBe(true);
305
+ globalThis.expect(sdl.includes("Default_Thing(id: ID!, includeRetired: Boolean): DefaultThing")).toBe(true);
306
306
  });
307
307
  });
308
308
  globalThis.describe("Relay compliance", () => {
@@ -424,9 +424,9 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
424
424
  authorization: undefined
425
425
  }]);
426
426
  let inspection = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment);
427
- globalThis.expect(inspection.types.length).toBe(8);
427
+ globalThis.expect(inspection.types.length).toBe(9);
428
428
  globalThis.expect(inspection.mutations.length).toBe(1);
429
- globalThis.expect(inspection.queries.length).toBe(3);
429
+ globalThis.expect(inspection.queries.length).toBe(4);
430
430
  globalThis.expect(inspection.sdlPreview.includes("type TestState")).toBe(true);
431
431
  globalThis.expect(inspection.sdlPreview.includes("type TestStateEdge")).toBe(true);
432
432
  globalThis.expect(inspection.sdlPreview.includes("type TestStateConnection")).toBe(true);
@@ -102,7 +102,7 @@ let pageInfoString = (response: JSON.t, key: string): option<string> =>
102
102
  // Per-test fixture — fresh Bus + storage + registry + resolver
103
103
  // ─────────────────────────────────────────────────────────────
104
104
 
105
- let buildFixture = async (~name: string) => {
105
+ let buildFixture = async (~name: string, ~indexes: array<Reventless.ReadModel.indexConfig>=[]) => {
106
106
  module Bus = LocalBus.Make()
107
107
  module Storage = LocalQueryDbStorage.Make(Bus)
108
108
  module Resolvers = QueryDbResolvers_GraphQL.Make(Bus)
@@ -151,7 +151,7 @@ let buildFixture = async (~name: string) => {
151
151
  ~api=(),
152
152
  ~apiRole=(),
153
153
  ~dataSourceName=""->Pulumi.Output.make,
154
- ~indexes=[],
154
+ ~indexes,
155
155
  ~subIdField=None,
156
156
  ~idResolverConfigs=[],
157
157
  ~idsResolverConfigs=[],
@@ -440,3 +440,83 @@ describe("QueryDb list resolver — SQLite push-down path", () => {
440
440
  })
441
441
  )
442
442
  })
443
+
444
+ // The by-index door answers a Relay connection, keyed on the index's column.
445
+ // Both halves used to be wrong here and in the opposite direction on AppSync:
446
+ // this backend promised `[String]` and returned whole rows, so every call failed
447
+ // to serialise before a caller could reach the rows at all.
448
+ describe("QueryDb by-index resolver", () => {
449
+ let statusIndex: array<Reventless.ReadModel.indexConfig> = [
450
+ {index: "status", type_: "S", projectionType: ALL},
451
+ ]
452
+
453
+ let indexResolver = async (~name) => {
454
+ let _ = await buildFixture(~name, ~indexes=statusIndex)
455
+ switch DomainGraphQL_Server.getQueryResolver(name ++ "ByStatus") {
456
+ | Some(r) => r
457
+ | None => JsError.throwWithMessage("index resolver not registered: " ++ name ++ "ByStatus")
458
+ }
459
+ }
460
+
461
+ beforeEach(() => {
462
+ DomainGraphQL_Server.reset()
463
+ })
464
+
465
+ testPromise("answers the rows carrying the index value, as edges", async () => {
466
+ let resolver = await indexResolver(~name="IdxA")
467
+ let response = await resolver(JSON.Encode.null, argsOf([("status", strJson("active"))]), emptyCtx)
468
+ let edges = getEdges(response)
469
+ expect(edges->Array.length)->toBe(3)
470
+ expect(edgeNodeField(edges->Array.getUnsafe(0), "productId"))->toBe("p-1")
471
+ })
472
+
473
+ testPromise("pages forward on first/after like every other connection", async () => {
474
+ let resolver = await indexResolver(~name="IdxB")
475
+ let page1 =
476
+ await resolver(
477
+ JSON.Encode.null,
478
+ argsOf([("status", strJson("active")), ("first", numJson(2))]),
479
+ emptyCtx,
480
+ )
481
+ expect(getEdges(page1)->Array.length)->toBe(2)
482
+ expect(pageInfoBool(page1, "hasNextPage"))->toBe(true)
483
+
484
+ let page2 =
485
+ await resolver(
486
+ JSON.Encode.null,
487
+ argsOf([
488
+ ("status", strJson("active")),
489
+ ("first", numJson(2)),
490
+ ("after", strJson(pageInfoString(page1, "endCursor")->Option.getOr(""))),
491
+ ]),
492
+ emptyCtx,
493
+ )
494
+ let edges2 = getEdges(page2)
495
+ expect(edges2->Array.length)->toBe(1)
496
+ expect(edgeNodeField(edges2->Array.getUnsafe(0), "productId"))->toBe("p-4")
497
+ expect(pageInfoBool(page2, "hasNextPage"))->toBe(false)
498
+ })
499
+
500
+ // Refused rather than ignored, and refused here because AppSync cannot do it:
501
+ // a door that paged backward on one backend and quietly returned the forward
502
+ // page on the other is the divergence this whole field was rebuilt to remove.
503
+ testPromise("refuses backward paging instead of answering forward", async () => {
504
+ let resolver = await indexResolver(~name="IdxC")
505
+ let outcome = try {
506
+ let _ =
507
+ await resolver(
508
+ JSON.Encode.null,
509
+ argsOf([("status", strJson("active")), ("last", numJson(2))]),
510
+ emptyCtx,
511
+ )
512
+ Ok()
513
+ } catch {
514
+ | e => Error(e->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr(""))
515
+ }
516
+ switch outcome {
517
+ | Ok() => expect("no error")->toBe("a BAD_USER_INPUT refusal")
518
+ | Error(message) =>
519
+ expect(message->String.includes("Backward pagination (last/before) is not supported"))->toBe(true)
520
+ }
521
+ })
522
+ })
@@ -2,12 +2,14 @@
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
6
  import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
6
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
8
  import * as Pulumi from "@pulumi/pulumi";
8
9
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
9
10
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
10
11
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
12
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
11
13
  import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
12
14
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
13
15
  import * as LocalBus$ReventlessLocal from "../../src/adapter/LocalBus.res.mjs";
@@ -112,7 +114,8 @@ function pageInfoString(response, key) {
112
114
  return getString(pageInfo(response), key);
113
115
  }
114
116
 
115
- async function buildFixture(name) {
117
+ async function buildFixture(name, indexesOpt) {
118
+ let indexes = indexesOpt !== undefined ? indexesOpt : [];
116
119
  let Bus = LocalBus$ReventlessLocal.Make({});
117
120
  let Storage = LocalQueryDbStorage$ReventlessLocal.Make(Bus);
118
121
  let Resolvers = QueryDbResolvers_GraphQL$ReventlessLocal.Make(Bus);
@@ -151,7 +154,7 @@ async function buildFixture(name) {
151
154
  Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name] = rowStateSchemaWithAnnotations;
152
155
  let queryDb = Maker.make(undefined, undefined, undefined, undefined, undefined);
153
156
  let ops = await TestRunner$ReventlessLocal.resolve(Component$ReventlessCore.operations(queryDb));
154
- Resolvers.make(name, undefined, undefined, Pulumi.output(""), [], undefined, [], [], "AllowAuthenticated", {});
157
+ Resolvers.make(name, undefined, undefined, Pulumi.output(""), indexes, undefined, [], [], "AllowAuthenticated", {});
155
158
  let r = DomainGraphQL_Server$ReventlessLocal.getQueryResolver(listFieldName);
156
159
  let resolver = r !== undefined ? r : Stdlib_JsError.throwWithMessage("resolver not registered: " + listFieldName);
157
160
  let rows = [
@@ -199,7 +202,7 @@ async function buildFixture(name) {
199
202
  globalThis.describe("QueryDb list resolver — keyset pagination", () => {
200
203
  globalThis.beforeEach(() => DomainGraphQL_Server$ReventlessLocal.reset());
201
204
  globalThis.test("first: 2 over a 5-row fixture returns 2 edges and hasNextPage = true", async () => {
202
- let match = await buildFixture("PageA");
205
+ let match = await buildFixture("PageA", undefined);
203
206
  let response = await match[0](null, Object.fromEntries([[
204
207
  "first",
205
208
  2
@@ -212,7 +215,7 @@ globalThis.describe("QueryDb list resolver — keyset pagination", () => {
212
215
  globalThis.expect(edgeNodeField(edges[1], "productId")).toBe("p-2");
213
216
  });
214
217
  globalThis.test("after: <endCursor> walks forward without overlap", async () => {
215
- let match = await buildFixture("PageB");
218
+ let match = await buildFixture("PageB", undefined);
216
219
  let resolver = match[0];
217
220
  let page1 = await resolver(null, Object.fromEntries([[
218
221
  "first",
@@ -251,7 +254,7 @@ globalThis.describe("QueryDb list resolver — keyset pagination", () => {
251
254
  globalThis.expect(pageInfoBool(page3, "hasNextPage")).toBe(false);
252
255
  });
253
256
  globalThis.test("last + before walks backward and flips hasPreviousPage", async () => {
254
- let match = await buildFixture("PageC");
257
+ let match = await buildFixture("PageC", undefined);
255
258
  let resolver = match[0];
256
259
  let page1 = await resolver(null, Object.fromEntries([[
257
260
  "first",
@@ -314,7 +317,7 @@ globalThis.describe("QueryDb list resolver — keyset pagination", () => {
314
317
  globalThis.expect(pageInfoBool(firstPage, "hasPreviousPage")).toBe(false);
315
318
  });
316
319
  globalThis.test("filter.<field>Eq + first / after paginates only the narrowed subset", async () => {
317
- let match = await buildFixture("PageD");
320
+ let match = await buildFixture("PageD", undefined);
318
321
  let resolver = match[0];
319
322
  let activeFilter = Object.fromEntries([[
320
323
  "statusEq",
@@ -356,7 +359,7 @@ globalThis.describe("QueryDb list resolver — keyset pagination", () => {
356
359
  globalThis.expect(pageInfoBool(page2, "hasNextPage")).toBe(false);
357
360
  });
358
361
  globalThis.test("orderBy DESC + first / after paginates the reverse-sorted view", async () => {
359
- let match = await buildFixture("PageE");
362
+ let match = await buildFixture("PageE", undefined);
360
363
  let resolver = match[0];
361
364
  let orderBy = Object.fromEntries([
362
365
  [
@@ -404,7 +407,7 @@ globalThis.describe("QueryDb list resolver — keyset pagination", () => {
404
407
  globalThis.expect(edgeNodeField(edges2[1], "name")).toBe("Bravo");
405
408
  });
406
409
  globalThis.test("bare request (no first / after) returns a single bounded page", async () => {
407
- let match = await buildFixture("PageF");
410
+ let match = await buildFixture("PageF", undefined);
408
411
  let response = await match[0](null, Object.fromEntries([]), emptyCtx);
409
412
  let edges = getEdges(response);
410
413
  globalThis.expect(edges.length).toBe(5);
@@ -424,7 +427,7 @@ globalThis.describe("QueryDb list resolver — SQLite push-down path", () => {
424
427
  return r;
425
428
  };
426
429
  globalThis.test("first:2 then after walks forward without overlap", () => withSqlite(async () => {
427
- let match = await buildFixture("SqlPageA");
430
+ let match = await buildFixture("SqlPageA", undefined);
428
431
  let resolver = match[0];
429
432
  let page1 = await resolver(null, Object.fromEntries([[
430
433
  "first",
@@ -452,7 +455,7 @@ globalThis.describe("QueryDb list resolver — SQLite push-down path", () => {
452
455
  globalThis.expect(edgeNodeField(edges2[1], "productId")).toBe("p-4");
453
456
  }));
454
457
  globalThis.test("orderBy name DESC + first:2 returns the reverse-sorted head", () => withSqlite(async () => {
455
- let match = await buildFixture("SqlPageB");
458
+ let match = await buildFixture("SqlPageB", undefined);
456
459
  let orderBy = Object.fromEntries([
457
460
  [
458
461
  "field",
@@ -479,7 +482,7 @@ globalThis.describe("QueryDb list resolver — SQLite push-down path", () => {
479
482
  globalThis.expect(edgeNodeField(edges1[1], "name")).toBe("Delta");
480
483
  }));
481
484
  globalThis.test("statusEq active narrows the pushed page", () => withSqlite(async () => {
482
- let match = await buildFixture("SqlPageC");
485
+ let match = await buildFixture("SqlPageC", undefined);
483
486
  let activeFilter = Object.fromEntries([[
484
487
  "statusEq",
485
488
  "active"
@@ -495,6 +498,98 @@ globalThis.describe("QueryDb list resolver — SQLite push-down path", () => {
495
498
  }));
496
499
  });
497
500
 
501
+ globalThis.describe("QueryDb by-index resolver", () => {
502
+ let statusIndex = [{
503
+ index: "status",
504
+ type_: "S",
505
+ projectionType: "ALL"
506
+ }];
507
+ let indexResolver = async name => {
508
+ await buildFixture(name, statusIndex);
509
+ let r = DomainGraphQL_Server$ReventlessLocal.getQueryResolver(name + "ByStatus");
510
+ if (r !== undefined) {
511
+ return r;
512
+ } else {
513
+ return Stdlib_JsError.throwWithMessage("index resolver not registered: " + name + "ByStatus");
514
+ }
515
+ };
516
+ globalThis.beforeEach(() => DomainGraphQL_Server$ReventlessLocal.reset());
517
+ globalThis.test("answers the rows carrying the index value, as edges", async () => {
518
+ let resolver = await indexResolver("IdxA");
519
+ let response = await resolver(null, Object.fromEntries([[
520
+ "status",
521
+ "active"
522
+ ]]), emptyCtx);
523
+ let edges = getEdges(response);
524
+ globalThis.expect(edges.length).toBe(3);
525
+ globalThis.expect(edgeNodeField(edges[0], "productId")).toBe("p-1");
526
+ });
527
+ globalThis.test("pages forward on first/after like every other connection", async () => {
528
+ let resolver = await indexResolver("IdxB");
529
+ let page1 = await resolver(null, Object.fromEntries([
530
+ [
531
+ "status",
532
+ "active"
533
+ ],
534
+ [
535
+ "first",
536
+ 2
537
+ ]
538
+ ]), emptyCtx);
539
+ globalThis.expect(getEdges(page1).length).toBe(2);
540
+ globalThis.expect(pageInfoBool(page1, "hasNextPage")).toBe(true);
541
+ let page2 = await resolver(null, Object.fromEntries([
542
+ [
543
+ "status",
544
+ "active"
545
+ ],
546
+ [
547
+ "first",
548
+ 2
549
+ ],
550
+ [
551
+ "after",
552
+ Stdlib_Option.getOr(getString(pageInfo(page1), "endCursor"), "")
553
+ ]
554
+ ]), emptyCtx);
555
+ let edges2 = getEdges(page2);
556
+ globalThis.expect(edges2.length).toBe(1);
557
+ globalThis.expect(edgeNodeField(edges2[0], "productId")).toBe("p-4");
558
+ globalThis.expect(pageInfoBool(page2, "hasNextPage")).toBe(false);
559
+ });
560
+ globalThis.test("refuses backward paging instead of answering forward", async () => {
561
+ let resolver = await indexResolver("IdxC");
562
+ let outcome;
563
+ try {
564
+ await resolver(null, Object.fromEntries([
565
+ [
566
+ "status",
567
+ "active"
568
+ ],
569
+ [
570
+ "last",
571
+ 2
572
+ ]
573
+ ]), emptyCtx);
574
+ outcome = {
575
+ TAG: "Ok",
576
+ _0: undefined
577
+ };
578
+ } catch (raw_e) {
579
+ let e = Primitive_exceptions.internalToException(raw_e);
580
+ outcome = {
581
+ TAG: "Error",
582
+ _0: Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(e), Stdlib_JsExn.message), "")
583
+ };
584
+ }
585
+ if (outcome.TAG === "Ok") {
586
+ globalThis.expect("no error").toBe("a BAD_USER_INPUT refusal");
587
+ return;
588
+ }
589
+ globalThis.expect(outcome._0.includes("Backward pagination (last/before) is not supported")).toBe(true);
590
+ });
591
+ });
592
+
498
593
  export {
499
594
  rowStateSchema,
500
595
  rowStateSchemaWithAnnotations,