@reventlessdev/reventless-local 3.0.0-alpha.233 → 3.0.0-alpha.235

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 (27) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +10 -10
  3. package/src/adapter/DomainGraphQL_Server.res +36 -0
  4. package/src/adapter/DomainGraphQL_Server.res.mjs +40 -1
  5. package/src/adapter/GraphQL_Server.res.mjs +6 -0
  6. package/src/adapter/PlatformGraphQL_Server.res +5 -0
  7. package/src/adapter/PlatformGraphQL_Server.res.mjs +4 -0
  8. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +171 -2
  9. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +118 -1
  10. package/tests/adapter/QueryDbListResolverTest.res +150 -0
  11. package/tests/adapter/QueryDbListResolverTest.res.mjs +133 -0
  12. package/tests/adapter/QueryDbResolvedFieldsTest.res +253 -0
  13. package/tests/adapter/QueryDbResolvedFieldsTest.res.mjs +291 -0
  14. package/tests/components/automationslice/AutomationSliceCallbackTest.res +64 -0
  15. package/tests/components/automationslice/AutomationSliceCallbackTest.res.mjs +55 -0
  16. package/tests/components/automationslice/AutomationSliceFixtures.res +2 -0
  17. package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +10 -0
  18. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +1 -0
  19. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +5 -0
  20. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res +1 -0
  21. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +5 -0
  22. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res +135 -1
  23. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +202 -0
  24. package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res +8 -0
  25. package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res.mjs +15 -0
  26. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res +2 -0
  27. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +5 -0
@@ -574,3 +574,153 @@ describe("QueryDb by-index resolver", () => {
574
574
  }
575
575
  })
576
576
  })
577
+
578
+ // ─────────────────────────────────────────────────────────────
579
+ // Sub-id view — the `{name}Items` door
580
+ // ─────────────────────────────────────────────────────────────
581
+
582
+ // The door only exists when a view declares a sub-id, and no fixture declared one
583
+ // alongside a union field, so it was the last door in the mechanism plan's table
584
+ // carrying a union nowhere.
585
+
586
+ @schema
587
+ type lineState = {
588
+ orderId: @s.matches(Reventless.DcbTag.string) string,
589
+ lineNo: string,
590
+ label: string,
591
+ geolocation?: geolocation,
592
+ }
593
+
594
+ let lineStateSchemaWithAnnotations =
595
+ lineStateSchema->S.Metadata.set(
596
+ ~id=Reventless.StateAnnotations.stateAnnotationsId,
597
+ {
598
+ ids: ["orderId"],
599
+ compositeIds: [],
600
+ subIds: ["lineNo"],
601
+ compositeSubIds: [],
602
+ indexes: [],
603
+ hidden: [],
604
+ summary: [],
605
+ drillTargets: [],
606
+ drillTargetKeys: [],
607
+ collapsed: [],
608
+ scan: [],
609
+ scanSort: [],
610
+ semantic: [],
611
+ metric: [],
612
+ lifecycle: None,
613
+ groupBy: None,
614
+ visibility: None,
615
+ live: None,
616
+ retired: None,
617
+ },
618
+ )
619
+
620
+ let buildSubIdFixture = async (~name: string) => {
621
+ module Bus = LocalBus.Make()
622
+ module Storage = LocalQueryDbStorage.Make(Bus)
623
+ module Resolvers = QueryDbResolvers_GraphQL.Make(Bus)
624
+
625
+ module Spec = {
626
+ module Id = Reventless.Id.StringPure
627
+ let name = name
628
+ let moduleUrl: string = %raw(`import.meta.url`)
629
+ @schema
630
+ type state = lineState
631
+ let config = Reventless.ReadModel.config()
632
+ let subIdConfig = Some({
633
+ Reventless.ReadModel.subIdField: "lineNo",
634
+ getSubId: (state: lineState) => state.lineNo,
635
+ })
636
+ let authorization: Reventless.Authorization.permission = AllowAuthenticated
637
+ let visibility: Reventless.Visibility.t = Public
638
+ }
639
+
640
+ module QDbResolversAdapter = ReventlessCore.QueryDb_Adapter.NoResolvers(Storage)
641
+ module Maker = ReventlessCore.QueryDb_Builder.Make(Spec, Storage, QDbResolversAdapter)
642
+
643
+ ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry->Dict.set(
644
+ name,
645
+ {
646
+ singleFieldName: name,
647
+ listFieldName: name ++ "s",
648
+ returnTypeName: name,
649
+ pluralTypeName: "[" ++ name ++ "]",
650
+ includeIdParam: true,
651
+ connectionSpec: true,
652
+ },
653
+ )
654
+ ReventlessCore.Plugin_Helpers.stateSchemaRegistry->Dict.set(
655
+ name,
656
+ lineStateSchemaWithAnnotations->S.castToUnknown,
657
+ )
658
+
659
+ let queryDb = Maker.make(~api=(), ~apiRole=())
660
+ let ops = await queryDb->ReventlessCore.Component.operations->TestRunner.resolve
661
+
662
+ let _: ReventlessCore.QueryDb_Adapter.resolvers = Resolvers.make(
663
+ ~name,
664
+ ~api=(),
665
+ ~apiRole=(),
666
+ ~dataSourceName=""->Pulumi.Output.make,
667
+ ~indexes=[],
668
+ ~subIdField=Some("lineNo"),
669
+ ~idResolverConfigs=[],
670
+ ~idsResolverConfigs=[],
671
+ ~authorization=Reventless.Authorization.AllowAuthenticated,
672
+ ~opts=({}: Pulumi.CustomResourceOptions.t),
673
+ )
674
+
675
+ // Two lines under one order: the first carries the union, the second leaves the
676
+ // optional field absent.
677
+ let _ = await ops.save(
678
+ "o-1",
679
+ {orderId: "o-1", lineNo: "l-1", label: "First", geolocation: Located({lat: 48.2082, lng: 16.3738})},
680
+ Init,
681
+ None,
682
+ )
683
+ let _ = await ops.save("o-1", {orderId: "o-1", lineNo: "l-2", label: "Second"}, Init, None)
684
+
685
+ switch DomainGraphQL_Server.getQueryResolver(name ++ "Items") {
686
+ | Some(r) => r
687
+ | None => JsError.throwWithMessage("items resolver not registered: " ++ name ++ "Items")
688
+ }
689
+ }
690
+
691
+ describe("QueryDb sub-id resolver — the Items door", () => {
692
+ beforeEach(() => {
693
+ DomainGraphQL_Server.reset()
694
+ })
695
+
696
+ testPromise("answers the sub-items of one id, as edges", async () => {
697
+ let resolver = await buildSubIdFixture(~name="ItemA")
698
+ let response = await resolver(JSON.Encode.null, argsOf([("id", strJson("o-1"))]), emptyCtx)
699
+ let edges = getEdges(response)
700
+ expect(edges->Array.length)->toBe(2)
701
+ expect(edgeNodeField(edges->Array.getUnsafe(0), "lineNo"))->toBe("l-1")
702
+ })
703
+
704
+ // The gap this fixture exists to close, mirroring the by-index case above: a
705
+ // union reaching a caller through the Items door with the member type the
706
+ // write-time stamp put on it. Without it the member resolves to null and takes
707
+ // its non-nullable parent, so the row leaves the connection rather than erroring.
708
+ testPromise("carries a union field's member type through", async () => {
709
+ let resolver = await buildSubIdFixture(~name="ItemB")
710
+ let response = await resolver(JSON.Encode.null, argsOf([("id", strJson("o-1"))]), emptyCtx)
711
+ let first = getEdges(response)->Array.getUnsafe(0)
712
+ let geo = edgeNodeJson(first, "geolocation")->Option.flatMap(JSON.Decode.object)
713
+ expect(geo->Option.flatMap(o => o->Dict.get("__typename"))->Option.flatMap(JSON.Decode.string))
714
+ ->toEqual(Some("GeolocationLocated"))
715
+ expect(geo->Option.flatMap(o => o->Dict.get("lat"))->Option.flatMap(JSON.Decode.float))
716
+ ->toEqual(Some(48.2082))
717
+ })
718
+
719
+ testPromise("leaves an absent union absent", async () => {
720
+ let resolver = await buildSubIdFixture(~name="ItemC")
721
+ let response = await resolver(JSON.Encode.null, argsOf([("id", strJson("o-1"))]), emptyCtx)
722
+ let second = getEdges(response)->Array.getUnsafe(1)
723
+ expect(edgeNodeField(second, "label"))->toBe("Second")
724
+ expect(edgeNodeJson(second, "geolocation")->Option.isNone)->toBe(true)
725
+ })
726
+ })
@@ -648,6 +648,136 @@ globalThis.describe("QueryDb by-index resolver", () => {
648
648
  });
649
649
  });
650
650
 
651
+ let lineStateSchema = Sury.$schema(s => ({
652
+ orderId: s.m(DcbTag$Reventless.string),
653
+ lineNo: s.m(Sury.string),
654
+ label: s.m(Sury.string),
655
+ geolocation: s.m(Sury.$option(geolocationSchema$1))
656
+ }));
657
+
658
+ let lineStateSchemaWithAnnotations = Sury.$Metadata_set(lineStateSchema, StateAnnotations$Reventless.stateAnnotationsId, {
659
+ ids: ["orderId"],
660
+ compositeIds: [],
661
+ subIds: ["lineNo"],
662
+ compositeSubIds: [],
663
+ indexes: [],
664
+ hidden: [],
665
+ summary: [],
666
+ drillTargets: [],
667
+ drillTargetKeys: [],
668
+ collapsed: [],
669
+ scan: [],
670
+ scanSort: [],
671
+ semantic: [],
672
+ metric: [],
673
+ lifecycle: undefined,
674
+ groupBy: undefined,
675
+ visibility: undefined,
676
+ live: undefined,
677
+ retired: undefined
678
+ });
679
+
680
+ async function buildSubIdFixture(name) {
681
+ let Bus = LocalBus$ReventlessLocal.Make({});
682
+ let Storage = LocalQueryDbStorage$ReventlessLocal.Make(Bus);
683
+ let Resolvers = QueryDbResolvers_GraphQL$ReventlessLocal.Make(Bus);
684
+ let moduleUrl = import.meta.url;
685
+ let config = ReadModel$Reventless.config(undefined, undefined, undefined);
686
+ let subIdConfig = {
687
+ subIdField: "lineNo",
688
+ getSubId: state => state.lineNo
689
+ };
690
+ let QDbResolversAdapter = QueryDb_Adapter$ReventlessCore.NoResolvers({
691
+ make: Storage.make
692
+ });
693
+ let Maker = QueryDb_Builder$ReventlessCore.Make({
694
+ Id: {
695
+ schema: Id$Reventless.StringPure.schema,
696
+ make: prim => prim,
697
+ makeFromString: prim => prim,
698
+ toString: prim => prim,
699
+ cmp: Id$Reventless.StringPure.cmp
700
+ },
701
+ name: name,
702
+ moduleUrl: moduleUrl,
703
+ stateSchema: lineStateSchema,
704
+ config: config,
705
+ subIdConfig: subIdConfig,
706
+ authorization: "AllowAuthenticated",
707
+ visibility: "Public"
708
+ })({
709
+ make: Storage.make
710
+ })(QDbResolversAdapter);
711
+ Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[name] = {
712
+ singleFieldName: name,
713
+ listFieldName: name + "s",
714
+ returnTypeName: name,
715
+ pluralTypeName: "[" + name + "]",
716
+ includeIdParam: true,
717
+ connectionSpec: true
718
+ };
719
+ Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name] = lineStateSchemaWithAnnotations;
720
+ let queryDb = Maker.make(undefined, undefined, undefined, undefined, undefined);
721
+ let ops = await TestRunner$ReventlessLocal.resolve(Component$ReventlessCore.operations(queryDb));
722
+ Resolvers.make(name, undefined, undefined, Pulumi.output(""), [], "lineNo", [], [], "AllowAuthenticated", {});
723
+ await ops.save("o-1", {
724
+ orderId: "o-1",
725
+ lineNo: "l-1",
726
+ label: "First",
727
+ geolocation: {
728
+ TAG: "Located",
729
+ lat: 48.2082,
730
+ lng: 16.3738
731
+ }
732
+ }, "Init", undefined);
733
+ await ops.save("o-1", {
734
+ orderId: "o-1",
735
+ lineNo: "l-2",
736
+ label: "Second"
737
+ }, "Init", undefined);
738
+ let r = DomainGraphQL_Server$ReventlessLocal.getQueryResolver(name + "Items");
739
+ if (r !== undefined) {
740
+ return r;
741
+ } else {
742
+ return Stdlib_JsError.throwWithMessage("items resolver not registered: " + name + "Items");
743
+ }
744
+ }
745
+
746
+ globalThis.describe("QueryDb sub-id resolver — the Items door", () => {
747
+ globalThis.beforeEach(() => DomainGraphQL_Server$ReventlessLocal.reset());
748
+ globalThis.test("answers the sub-items of one id, as edges", async () => {
749
+ let resolver = await buildSubIdFixture("ItemA");
750
+ let response = await resolver(null, Object.fromEntries([[
751
+ "id",
752
+ "o-1"
753
+ ]]), emptyCtx);
754
+ let edges = getEdges(response);
755
+ globalThis.expect(edges.length).toBe(2);
756
+ globalThis.expect(edgeNodeField(edges[0], "lineNo")).toBe("l-1");
757
+ });
758
+ globalThis.test("carries a union field's member type through", async () => {
759
+ let resolver = await buildSubIdFixture("ItemB");
760
+ let response = await resolver(null, Object.fromEntries([[
761
+ "id",
762
+ "o-1"
763
+ ]]), emptyCtx);
764
+ let first = getEdges(response)[0];
765
+ let geo = Stdlib_Option.flatMap(edgeNodeJson(first, "geolocation"), Stdlib_JSON.Decode.object);
766
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(geo, o => o["__typename"]), Stdlib_JSON.Decode.string)).toEqual("GeolocationLocated");
767
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(geo, o => o["lat"]), Stdlib_JSON.Decode.float)).toEqual(48.2082);
768
+ });
769
+ globalThis.test("leaves an absent union absent", async () => {
770
+ let resolver = await buildSubIdFixture("ItemC");
771
+ let response = await resolver(null, Object.fromEntries([[
772
+ "id",
773
+ "o-1"
774
+ ]]), emptyCtx);
775
+ let second = getEdges(response)[1];
776
+ globalThis.expect(edgeNodeField(second, "label")).toBe("Second");
777
+ globalThis.expect(Stdlib_Option.isNone(edgeNodeJson(second, "geolocation"))).toBe(true);
778
+ });
779
+ });
780
+
651
781
  export {
652
782
  geolocationSchema$1 as geolocationSchema,
653
783
  rowStateSchema,
@@ -667,5 +797,8 @@ export {
667
797
  pageInfoBool,
668
798
  pageInfoString,
669
799
  buildFixture,
800
+ lineStateSchema,
801
+ lineStateSchemaWithAnnotations,
802
+ buildSubIdFixture,
670
803
  }
671
804
  /* Not a pure module */
@@ -0,0 +1,253 @@
1
+ // Behavioural tests for the in-memory cross-table field resolvers
2
+ // (`@resolves` / `@resolvesMany`). An Orders view carries a foreign `productId`
3
+ // and a `productIds` array; both follow into a Products view's rows.
4
+ //
5
+ // The rows handed back are the TARGET's, so the target's `@retired` rule narrows
6
+ // them — the same reading the AppSync response bakes in. A nested field takes no
7
+ // `includeRetired` argument, so a retired row never travels through one.
8
+
9
+ @@warning("-44")
10
+
11
+ open JestGlobals
12
+
13
+ let _ = TestRunner.setup()
14
+
15
+ @schema
16
+ type productRow = {productId: string, name: string, archived: bool}
17
+
18
+ @schema
19
+ type orderRow = {orderId: string, productId: string, productIds: array<string>}
20
+
21
+ let annotations = (~retired: option<Reventless.StateAnnotations.retiredSpec>) => {
22
+ Reventless.StateAnnotations.ids: [],
23
+ compositeIds: [],
24
+ subIds: [],
25
+ compositeSubIds: [],
26
+ indexes: [],
27
+ hidden: [],
28
+ summary: [],
29
+ drillTargets: [],
30
+ drillTargetKeys: [],
31
+ collapsed: [],
32
+ scan: [],
33
+ scanSort: [],
34
+ semantic: [],
35
+ metric: [],
36
+ lifecycle: None,
37
+ groupBy: None,
38
+ visibility: None,
39
+ live: None,
40
+ retired,
41
+ }
42
+
43
+ let ctxFor = (~groups: array<string>): JSON.t =>
44
+ JSON.Encode.object(
45
+ Dict.fromArray([
46
+ (
47
+ "request",
48
+ JSON.Encode.object(Dict.fromArray([("headers", JSON.Encode.object(Dict.make()))])),
49
+ ),
50
+ (
51
+ "identity",
52
+ (
53
+ {
54
+ ...Reventless.Identity.anonymous,
55
+ userId: "test-user",
56
+ username: "test-user",
57
+ groups,
58
+ }: Reventless.Identity.t
59
+ )->Obj.magic,
60
+ ),
61
+ ]),
62
+ )
63
+
64
+ let plainCtx = ctxFor(~groups=["User"])
65
+
66
+ let noArgs: JSON.t = JSON.Encode.object(Dict.make())
67
+
68
+ let getString = (json: JSON.t, key: string): option<string> =>
69
+ json->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key))->Option.flatMap(JSON.Decode.string)
70
+
71
+ let names = (json: JSON.t): array<string> =>
72
+ json->JSON.Decode.array->Option.getOr([])->Array.filterMap(item => item->getString("name"))
73
+
74
+ // One Bus carrying both views, resolvers registered for the Orders view with the
75
+ // two configs the PPX writes from `@resolves` / `@resolvesMany`.
76
+ let buildFixture = async (~suffix: string, ~retired: option<Reventless.StateAnnotations.retiredSpec>) => {
77
+ module Bus = LocalBus.Make()
78
+ module Storage = LocalQueryDbStorage.Make(Bus)
79
+ module Resolvers = QueryDbResolvers_GraphQL.Make(Bus)
80
+
81
+ let productsName = "Products" ++ suffix
82
+ let ordersName = "Orders" ++ suffix
83
+ let orderTypeName = "Shop_Order" ++ suffix
84
+
85
+ module ProductsSpec = {
86
+ module Id = Reventless.Id.StringPure
87
+ let name = productsName
88
+ let moduleUrl: string = %raw(`import.meta.url`)
89
+ @schema
90
+ type state = productRow
91
+ let config = Reventless.ReadModel.config()
92
+ let subIdConfig = None
93
+ let authorization: Reventless.Authorization.permission = AllowAuthenticated
94
+ let visibility: Reventless.Visibility.t = Public
95
+ }
96
+
97
+ module OrdersSpec = {
98
+ module Id = Reventless.Id.StringPure
99
+ let name = ordersName
100
+ let moduleUrl: string = %raw(`import.meta.url`)
101
+ @schema
102
+ type state = orderRow
103
+ let config = Reventless.ReadModel.config(
104
+ ~idResolvers=[
105
+ {
106
+ source: {
107
+ Reventless.ReadModel.idField: "productId",
108
+ subId: NoSubId,
109
+ resolvedField: Single("product"),
110
+ },
111
+ target: {Reventless.ReadModel.tableName: productsName, idField: Id},
112
+ },
113
+ ],
114
+ ~idsResolvers=[
115
+ {
116
+ source: {Reventless.ReadModel.idsField: "productIds", resolvedField: "products"},
117
+ target: {Reventless.ReadModel.tableName: productsName},
118
+ },
119
+ ],
120
+ )
121
+ let subIdConfig = None
122
+ let authorization: Reventless.Authorization.permission = AllowAuthenticated
123
+ let visibility: Reventless.Visibility.t = Public
124
+ }
125
+
126
+ module NoResolvers = ReventlessCore.QueryDb_Adapter.NoResolvers(Storage)
127
+ module ProductsDb = ReventlessCore.QueryDb_Builder.Make(ProductsSpec, Storage, NoResolvers)
128
+ module OrdersDb = ReventlessCore.QueryDb_Builder.Make(OrdersSpec, Storage, NoResolvers)
129
+
130
+ let register = (~viewName, ~returnTypeName) =>
131
+ ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry->Dict.set(
132
+ viewName,
133
+ {
134
+ singleFieldName: returnTypeName,
135
+ listFieldName: returnTypeName ++ "s",
136
+ returnTypeName,
137
+ pluralTypeName: returnTypeName ++ "s",
138
+ includeIdParam: true,
139
+ connectionSpec: true,
140
+ },
141
+ )
142
+ register(~viewName=productsName, ~returnTypeName="Shop_Product" ++ suffix)
143
+ register(~viewName=ordersName, ~returnTypeName=orderTypeName)
144
+ ReventlessCore.Plugin_Helpers.stateSchemaRegistry->Dict.set(
145
+ productsName,
146
+ productRowSchema
147
+ ->S.Metadata.set(
148
+ ~id=Reventless.StateAnnotations.stateAnnotationsId,
149
+ annotations(~retired),
150
+ )
151
+ ->S.castToUnknown,
152
+ )
153
+
154
+ let productsDb = ProductsDb.make(~api=(), ~apiRole=())
155
+ let productOps = await productsDb->ReventlessCore.Component.operations->TestRunner.resolve
156
+ let ordersDb = OrdersDb.make(~api=(), ~apiRole=())
157
+ let orderOps = await ordersDb->ReventlessCore.Component.operations->TestRunner.resolve
158
+
159
+ let _: ReventlessCore.QueryDb_Adapter.resolvers = Resolvers.make(
160
+ ~name=ordersName,
161
+ ~api=(),
162
+ ~apiRole=(),
163
+ ~dataSourceName=""->Pulumi.Output.make,
164
+ ~indexes=[],
165
+ ~subIdField=None,
166
+ ~idResolverConfigs=OrdersSpec.config.idResolvers,
167
+ ~idsResolverConfigs=OrdersSpec.config.idsResolvers,
168
+ ~authorization=Reventless.Authorization.AllowAuthenticated,
169
+ ~opts=({}: Pulumi.CustomResourceOptions.t),
170
+ )
171
+
172
+ let _ = await productOps.save("p-1", {productId: "p-1", name: "Book", archived: false}, Init, None)
173
+ let _ = await productOps.save("p-2", {productId: "p-2", name: "Pen", archived: true}, Init, None)
174
+ let _ = await orderOps.save(
175
+ "o-1",
176
+ {orderId: "o-1", productId: "p-1", productIds: ["p-1", "gone", "p-2"]},
177
+ Init,
178
+ None,
179
+ )
180
+
181
+ let resolverFor = field =>
182
+ switch DomainGraphQL_Server.getFieldResolver(~typeName=orderTypeName, field) {
183
+ | Some(r) => r
184
+ | None => JsError.throwWithMessage("field resolver not registered: " ++ field)
185
+ }
186
+
187
+ let order = JSON.Encode.object(
188
+ Dict.fromArray([
189
+ ("orderId", JSON.Encode.string("o-1")),
190
+ ("productId", JSON.Encode.string("p-1")),
191
+ (
192
+ "productIds",
193
+ ["p-1", "gone", "p-2"]->Array.map(JSON.Encode.string)->JSON.Encode.array,
194
+ ),
195
+ ]),
196
+ )
197
+
198
+ (resolverFor, order)
199
+ }
200
+
201
+ describe("QueryDb cross-table field resolvers", () => {
202
+ beforeEach(() => {
203
+ DomainGraphQL_Server.reset()
204
+ })
205
+
206
+ testPromise("@resolves follows the foreign key into the target's row", async () => {
207
+ let (resolverFor, order) = await buildFixture(~suffix="A", ~retired=None)
208
+ let product = await resolverFor("product")(order, noArgs, plainCtx)
209
+ expect((product->getString("name"), product->getString("id")))->toEqual((
210
+ Some("Book"),
211
+ Some("p-1"),
212
+ ))
213
+ })
214
+
215
+ testPromise("@resolvesMany batch-follows the id array", async () => {
216
+ let (resolverFor, order) = await buildFixture(~suffix="B", ~retired=None)
217
+ let products = await resolverFor("products")(order, noArgs, plainCtx)
218
+ // "gone" names no row and drops out rather than becoming a null, matching
219
+ // BatchGetItem and the by-ids door built on it.
220
+ expect(products->names)->toEqual(["Book", "Pen"])
221
+ })
222
+
223
+ testPromise("a foreign key naming no row resolves to nothing", async () => {
224
+ let (resolverFor, _) = await buildFixture(~suffix="C", ~retired=None)
225
+ let orphan = JSON.Encode.object(
226
+ Dict.fromArray([("productId", JSON.Encode.string("nope"))]),
227
+ )
228
+ let product = await resolverFor("product")(orphan, noArgs, plainCtx)
229
+ expect(product)->toBe(JSON.Encode.null)
230
+ })
231
+
232
+ // The narrowing is the target's: Products declares the retirement, Orders
233
+ // declares the field, and the caller reading through Orders is answered by
234
+ // the Products rule.
235
+ testPromise("a retired target row is withheld from both forms", async () => {
236
+ let retired: Reventless.StateAnnotations.retiredSpec = {
237
+ field: "archived",
238
+ label: "",
239
+ showWhenFalse: false,
240
+ values: None,
241
+ namedWhenRetired: false,
242
+ }
243
+ let (resolverFor, order) = await buildFixture(~suffix="D", ~retired=Some(retired))
244
+ let products = await resolverFor("products")(order, noArgs, plainCtx)
245
+ expect(products->names)->toEqual(["Book"])
246
+
247
+ let archivedOnly = JSON.Encode.object(
248
+ Dict.fromArray([("productId", JSON.Encode.string("p-2"))]),
249
+ )
250
+ let product = await resolverFor("product")(archivedOnly, noArgs, plainCtx)
251
+ expect(product)->toBe(JSON.Encode.null)
252
+ })
253
+ })