@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
package/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
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.235 (2026-08-22)
7
+
8
+ * feat(core)!: a slice that has given up says so ([f5559ab](https://github.com/ReventlessDev/reventless-core/commit/f5559abede3a9cf2c9a48aadc69a666c46cc75bf))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ * `onExhausted` is required on the Translation and Automation
13
+ module types. ReScript has no optional module-type fields, so the alternative
14
+ was a PPX-injected default — and defaulting this one to silence is the bug.
15
+ A slice now states what abandonment means for its domain, `None` included.
16
+ GeocodeCustomerAddress answers with MarkAddressUnresolvable; the others say
17
+ nothing, on purpose.
18
+
19
+
20
+
21
+ # 3.0.0-alpha.234 (2026-08-21)
22
+
23
+ ### Bug Fixes
24
+
25
+ * **core:** make @resolves/[@resolves](https://github.com/resolves)Many work end to end ([4c52957](https://github.com/ReventlessDev/reventless-core/commit/4c5295759dcb4b3eda4f0f4f2c1bc387fed88fcb))
26
+ * **deps:** update sury to 11.0.0-rc.2 to fix unreachable union constructors ([fa5744f](https://github.com/ReventlessDev/reventless-core/commit/fa5744fed8de975e2f14725c856c6e5ce7d04a74))
27
+
28
+
6
29
  # 3.0.0-alpha.233 (2026-08-20)
7
30
 
8
31
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.233",
3
+ "version": "3.0.0-alpha.235",
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": {
@@ -32,25 +32,25 @@
32
32
  "graphql": "^16.13.2",
33
33
  "graphql-ws": "^5.16.0",
34
34
  "graphql-yoga": "^5.21.0",
35
- "sury": "11.0.0-rc.1",
35
+ "sury": "11.0.0-rc.2",
36
36
  "ws": "^8.18.0",
37
37
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
38
38
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
39
39
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
40
- "@reventlessdev/rescript-node": "2.0.0-alpha.8",
41
40
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
41
+ "@reventlessdev/rescript-node": "2.0.0-alpha.8",
42
42
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
43
- "@reventlessdev/reventless-core": "3.0.0-alpha.245",
44
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.192",
43
+ "@reventlessdev/reventless-core": "3.0.0-alpha.247",
44
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.95",
45
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.194",
46
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.111",
47
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.150",
45
48
  "@reventlessdev/reventless-seed": "1.0.0-alpha.17",
46
- "@reventlessdev/reventless-spec": "3.0.0-alpha.120",
47
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.109",
48
- "@reventlessdev/reventless-infra": "3.0.0-alpha.148",
49
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.93"
49
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.122"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.0",
53
- "sury-ppx": "11.0.0-rc.1",
53
+ "sury-ppx": "11.0.0-rc.2",
54
54
  "@reventlessdev/reventless-ppx": "1.0.0-alpha.72"
55
55
  },
56
56
  "peerDependencies": {
@@ -347,6 +347,10 @@ type bucket = {
347
347
  mutationResolvers: dict<resolverFn>,
348
348
  queryResolvers: dict<resolverFn>,
349
349
  subscriptionResolvers: dict<resolverFn>,
350
+ // Object-type field resolvers, keyed type name → field name. Carries the
351
+ // cross-table fields `@resolves` / `@resolvesMany` add to a queryable; their
352
+ // SDL rides the plugin's schema fragment, so only the map lives here.
353
+ fieldResolvers: dict<dict<resolverFn>>,
350
354
  }
351
355
 
352
356
  let makeBucket = (~scope: string): bucket => {
@@ -363,6 +367,7 @@ let makeBucket = (~scope: string): bucket => {
363
367
  mutationResolvers: Dict.make(),
364
368
  queryResolvers: Dict.make(),
365
369
  subscriptionResolvers: Dict.make(),
370
+ fieldResolvers: Dict.make(),
366
371
  }
367
372
 
368
373
  // Insertion order = composition order (platform bucket is forced first in
@@ -420,6 +425,15 @@ let relabelScope = (~from: string, ~to_: string) =>
420
425
  b.subscriptionResolvers
421
426
  ->Dict.toArray
422
427
  ->Array.forEach(((k, v)) => existing.subscriptionResolvers->Dict.set(k, v))
428
+ b.fieldResolvers
429
+ ->Dict.toArray
430
+ ->Array.forEach(((typeName, byField)) =>
431
+ switch existing.fieldResolvers->Dict.get(typeName) {
432
+ | Some(target) =>
433
+ byField->Dict.toArray->Array.forEach(((k, v)) => target->Dict.set(k, v))
434
+ | None => existing.fieldResolvers->Dict.set(typeName, byField)
435
+ }
436
+ )
423
437
  }
424
438
  if currentScope.contents == from {
425
439
  currentScope.contents = to_
@@ -453,6 +467,24 @@ let registerSubscriptions = (~sdlFields: array<string>, ~resolvers: dict<resolve
453
467
  resolvers->Dict.toArray->Array.forEach(((k, v)) => b.subscriptionResolvers->Dict.set(k, v))
454
468
  }
455
469
 
470
+ let registerFieldResolvers = (~typeName: string, ~resolvers: dict<resolverFn>) => {
471
+ let b = currentBucket()
472
+ let forType = switch b.fieldResolvers->Dict.get(typeName) {
473
+ | Some(d) => d
474
+ | None =>
475
+ let d = Dict.make()
476
+ b.fieldResolvers->Dict.set(typeName, d)
477
+ d
478
+ }
479
+ resolvers->Dict.toArray->Array.forEach(((k, v)) => forType->Dict.set(k, v))
480
+ }
481
+
482
+ /** Look up a registered object-type field resolver. Searches all scope buckets. */
483
+ let getFieldResolver = (~typeName: string, fieldName: string): option<resolverFn> =>
484
+ buckets.contents
485
+ ->Dict.valuesToArray
486
+ ->Array.findMap(b => b.fieldResolvers->Dict.get(typeName)->Option.flatMap(d => d->Dict.get(fieldName)))
487
+
456
488
  /** Look up a registered mutation resolver by field name (used by MCP_Server).
457
489
  Searches all scope buckets. */
458
490
  let getMutationResolver = (fieldName: string): option<resolverFn> =>
@@ -620,6 +652,9 @@ let scopeResolverMap = (b: bucket, ~withRootDefaults: bool): dict<dict<resolverF
620
652
  if b.subscriptionResolvers->Dict.keysToArray->Array.length > 0 {
621
653
  resolvers->Dict.set("Subscription", b.subscriptionResolvers)
622
654
  }
655
+ b.fieldResolvers
656
+ ->Dict.toArray
657
+ ->Array.forEach(((typeName, byField)) => resolvers->Dict.set(typeName, byField))
623
658
  resolvers
624
659
  }
625
660
 
@@ -913,6 +948,7 @@ let asInterface: ReventlessGraphqlServer.GraphQL_ServerInstance.t = {
913
948
  registerQueries,
914
949
  registerSubscriptions,
915
950
  registerTypes,
951
+ registerFieldResolvers,
916
952
  getMutationResolver,
917
953
  getQueryResolver,
918
954
  start,
@@ -252,7 +252,8 @@ function makeBucket(scope) {
252
252
  typeDefinitions: scope === platformScope ? [] : GraphQL_Stitcher$ReventlessCore.relayBaseTypes.slice(),
253
253
  mutationResolvers: {},
254
254
  queryResolvers: {},
255
- subscriptionResolvers: {}
255
+ subscriptionResolvers: {},
256
+ fieldResolvers: {}
256
257
  };
257
258
  }
258
259
 
@@ -316,6 +317,18 @@ function relabelScope(from, to_) {
316
317
  Object.entries(b.subscriptionResolvers).forEach(param => {
317
318
  existing.subscriptionResolvers[param[0]] = param[1];
318
319
  });
320
+ Object.entries(b.fieldResolvers).forEach(param => {
321
+ let byField = param[1];
322
+ let typeName = param[0];
323
+ let target = existing.fieldResolvers[typeName];
324
+ if (target !== undefined) {
325
+ Object.entries(byField).forEach(param => {
326
+ target[param[0]] = param[1];
327
+ });
328
+ } else {
329
+ existing.fieldResolvers[typeName] = byField;
330
+ }
331
+ });
319
332
  } else {
320
333
  buckets.contents[to_] = b;
321
334
  }
@@ -354,6 +367,26 @@ function registerSubscriptions(sdlFields, resolvers) {
354
367
  });
355
368
  }
356
369
 
370
+ function registerFieldResolvers(typeName, resolvers) {
371
+ let b = bucketFor(currentScope.contents);
372
+ let d = b.fieldResolvers[typeName];
373
+ let forType;
374
+ if (d !== undefined) {
375
+ forType = d;
376
+ } else {
377
+ let d$1 = {};
378
+ b.fieldResolvers[typeName] = d$1;
379
+ forType = d$1;
380
+ }
381
+ Object.entries(resolvers).forEach(param => {
382
+ forType[param[0]] = param[1];
383
+ });
384
+ }
385
+
386
+ function getFieldResolver(typeName, fieldName) {
387
+ return Stdlib_Array.findMap(Object.values(buckets.contents), b => Stdlib_Option.flatMap(b.fieldResolvers[typeName], d => d[fieldName]));
388
+ }
389
+
357
390
  function getMutationResolver(fieldName) {
358
391
  return Stdlib_Array.findMap(Object.values(buckets.contents), b => b.mutationResolvers[fieldName]);
359
392
  }
@@ -486,6 +519,9 @@ function scopeResolverMap(b, withRootDefaults) {
486
519
  if (Object.keys(b.subscriptionResolvers).length !== 0) {
487
520
  resolvers["Subscription"] = b.subscriptionResolvers;
488
521
  }
522
+ Object.entries(b.fieldResolvers).forEach(param => {
523
+ resolvers[param[0]] = param[1];
524
+ });
489
525
  return resolvers;
490
526
  }
491
527
 
@@ -749,6 +785,7 @@ let asInterface = {
749
785
  registerQueries: registerQueries,
750
786
  registerSubscriptions: registerSubscriptions,
751
787
  registerTypes: registerTypes,
788
+ registerFieldResolvers: registerFieldResolvers,
752
789
  getMutationResolver: getMutationResolver,
753
790
  getQueryResolver: getQueryResolver,
754
791
  start: start,
@@ -798,6 +835,8 @@ export {
798
835
  registerMutations,
799
836
  registerQueries,
800
837
  registerSubscriptions,
838
+ registerFieldResolvers,
839
+ getFieldResolver,
801
840
  getMutationResolver,
802
841
  getQueryResolver,
803
842
  registerTypes,
@@ -62,6 +62,10 @@ let registerQueries = DomainGraphQL_Server$ReventlessLocal.registerQueries;
62
62
 
63
63
  let registerSubscriptions = DomainGraphQL_Server$ReventlessLocal.registerSubscriptions;
64
64
 
65
+ let registerFieldResolvers = DomainGraphQL_Server$ReventlessLocal.registerFieldResolvers;
66
+
67
+ let getFieldResolver = DomainGraphQL_Server$ReventlessLocal.getFieldResolver;
68
+
65
69
  let getMutationResolver = DomainGraphQL_Server$ReventlessLocal.getMutationResolver;
66
70
 
67
71
  let getQueryResolver = DomainGraphQL_Server$ReventlessLocal.getQueryResolver;
@@ -157,6 +161,8 @@ export {
157
161
  registerMutations,
158
162
  registerQueries,
159
163
  registerSubscriptions,
164
+ registerFieldResolvers,
165
+ getFieldResolver,
160
166
  getMutationResolver,
161
167
  getQueryResolver,
162
168
  registerTypes,
@@ -48,6 +48,10 @@ let registerQueries = (~sdlFields, ~resolvers) =>
48
48
  instance.registerQueries(~sdlFields, ~resolvers=wrapAdmin(resolvers))
49
49
  let registerSubscriptions = instance.registerSubscriptions
50
50
  let registerTypes = instance.registerTypes
51
+ // Not admin-wrapped: a field resolver is only reached through a parent field
52
+ // that already met the group check, so wrapping would re-refuse a caller the
53
+ // root already admitted.
54
+ let registerFieldResolvers = instance.registerFieldResolvers
51
55
  let getMutationResolver = instance.getMutationResolver
52
56
  let getQueryResolver = instance.getQueryResolver
53
57
  let start = instance.start
@@ -67,6 +71,7 @@ let asInterface: ReventlessGraphqlServer.GraphQL_ServerInstance.t = {
67
71
  registerQueries,
68
72
  registerSubscriptions,
69
73
  registerTypes,
74
+ registerFieldResolvers,
70
75
  getMutationResolver,
71
76
  getQueryResolver,
72
77
  start,
@@ -25,6 +25,8 @@ let registerSubscriptions = instance.registerSubscriptions;
25
25
 
26
26
  let registerTypes = instance.registerTypes;
27
27
 
28
+ let registerFieldResolvers = instance.registerFieldResolvers;
29
+
28
30
  let getMutationResolver = instance.getMutationResolver;
29
31
 
30
32
  let getQueryResolver = instance.getQueryResolver;
@@ -50,6 +52,7 @@ let asInterface = {
50
52
  registerQueries: registerQueries,
51
53
  registerSubscriptions: registerSubscriptions,
52
54
  registerTypes: registerTypes,
55
+ registerFieldResolvers: registerFieldResolvers,
53
56
  getMutationResolver: getMutationResolver,
54
57
  getQueryResolver: getQueryResolver,
55
58
  start: start,
@@ -69,6 +72,7 @@ export {
69
72
  registerQueries,
70
73
  registerSubscriptions,
71
74
  registerTypes,
75
+ registerFieldResolvers,
72
76
  getMutationResolver,
73
77
  getQueryResolver,
74
78
  start,
@@ -72,8 +72,8 @@ module Make = (Bus: LocalBus.T) => {
72
72
  ~dataSourceName as _,
73
73
  ~indexes,
74
74
  ~subIdField,
75
- ~idResolverConfigs as _,
76
- ~idsResolverConfigs as _,
75
+ ~idResolverConfigs,
76
+ ~idsResolverConfigs,
77
77
  ~authorization,
78
78
  ~opts as _,
79
79
  ) => {
@@ -913,6 +913,175 @@ module Make = (Bus: LocalBus.T) => {
913
913
  },
914
914
  )
915
915
 
916
+ // -- Cross-table fields: @resolves / @resolvesMany -------------------------
917
+ // Field resolvers on this view's object type, following a foreign key into
918
+ // another queryable's rows. Their SDL rides the plugin's schema fragment
919
+ // (`GraphQL_FragmentGenerator` emits it from the same config), so only the
920
+ // resolvers are registered here.
921
+ //
922
+ // The rows are the TARGET's, so the target's `@owner` / `@retired` rules
923
+ // narrow them — the same reading the AppSync resolver bakes into its
924
+ // response. A nested field takes no `includeRetired` argument, so a retired
925
+ // row never travels through one; `{list}Refs` is the door that names those.
926
+ let targetAllows = (~ctx, ~target: string, item: JSON.t): bool => {
927
+ let schema = Plugin_Helpers.stateSchemaRegistry->Dict.get(target)
928
+ let cell = field => item->JSON.Decode.object->Option.flatMap(d => d->Dict.get(field))
929
+ let identity = extractIdentity(ctx)
930
+ let ownerOk = switch identity->Reventless.OwnerScope.decide(
931
+ ~ownerField=schema->Option.flatMap(s => Reventless.Owner.fieldNames(s)->Array.get(0)),
932
+ ) {
933
+ | Unscoped => true
934
+ | RefuseOwned => false
935
+ | ScopeTo(field, required) =>
936
+ cell(field)->Option.flatMap(JSON.Decode.string)->Option.mapOr(false, v => v == required)
937
+ }
938
+ let retiredSpec =
939
+ schema
940
+ ->Option.flatMap(Reventless.StateAnnotations.getSpec)
941
+ ->Option.flatMap(spec => spec.retired)
942
+ let retiredOk = switch identity
943
+ ->Reventless.OwnerScope.decideRetired(
944
+ ~retiredField=retiredSpec->Option.map(r => r.field),
945
+ ~retiredValues=?retiredSpec->Option.flatMap(r => r.values),
946
+ ~asked=false,
947
+ )
948
+ ->Reventless.OwnerScope.retiredScopeOf {
949
+ | None => true
950
+ | Some(scope) => !(scope->Reventless.OwnerScope.isRetiredValue(cell(scope.field)))
951
+ }
952
+ ownerOk && retiredOk
953
+ }
954
+
955
+ // The storage key the row was loaded by, reported as `id` — the nested type
956
+ // is a `Node`, so the field is non-null, and `loadStream` hands back the
957
+ // stored value without one.
958
+ let withId = (~key: string, item: JSON.t): JSON.t => {
959
+ let obj = item->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
960
+ obj->Dict.set("id", JSON.Encode.string(key))
961
+ JSON.Encode.object(obj)
962
+ }
963
+ let loadFrom = async (~target: string, key: string): array<JSON.t> =>
964
+ switch Bus.getQueryDb(target) {
965
+ | Some(ops) =>
966
+ await ops.loadStream(key)
967
+ ->Stream.runCollect
968
+ ->Effect.catchAll(_ => Effect.succeed([]))
969
+ ->Effect.runPromise
970
+ | None => []
971
+ }
972
+
973
+ let lookupByIndexIn = (~target: string, ~field: string, value: string): array<JSON.t> =>
974
+ switch Bus.getQueryDbIndexLookup(target) {
975
+ | Some(lookup) => lookup(field, value)
976
+ | None =>
977
+ switch Bus.getQueryDbScan(target) {
978
+ | Some(scanAll) =>
979
+ scanAll()->Array.filter(item =>
980
+ item
981
+ ->JSON.Decode.object
982
+ ->Option.flatMap(d => d->Dict.get(field))
983
+ ->Option.flatMap(JSON.Decode.string)
984
+ ->Option.mapOr(false, v => v == value)
985
+ )
986
+ | None => []
987
+ }
988
+ }
989
+
990
+ let sourceField = (root: JSON.t, field: string): option<JSON.t> =>
991
+ root->JSON.Decode.object->Option.flatMap(d => d->Dict.get(field))
992
+
993
+ let fieldResolvers = Dict.make()
994
+
995
+ idResolverConfigs->Array.forEach((config: Reventless.ReadModel.idResolverConfig) => {
996
+ let {source: {idField, subId, resolvedField}, target} = config
997
+ let (fieldName, multi) = switch resolvedField {
998
+ | Single(f) => (f, false)
999
+ | Multi(f) => (f, true)
1000
+ }
1001
+ let targetName = target.tableName
1002
+ let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (
1003
+ root,
1004
+ args,
1005
+ ctx,
1006
+ ) => {
1007
+ let key =
1008
+ root->sourceField(idField)->Option.flatMap(JSON.Decode.string)->Option.getOr("")
1009
+ let rows = if key == "" {
1010
+ []
1011
+ } else {
1012
+ switch target.idField {
1013
+ | Id => (await loadFrom(~target=targetName, key))->Array.map(withId(~key, ...))
1014
+ | Index(index) => lookupByIndexIn(~target=targetName, ~field=index, key)
1015
+ | IndexWithId(_, targetIdField) =>
1016
+ lookupByIndexIn(~target=targetName, ~field=targetIdField, key)
1017
+ }
1018
+ }
1019
+ // The target's sort key, taken from the parent row or from an argument on
1020
+ // the field — the same two sources the AppSync template reads.
1021
+ let narrowed = switch (subId, target.subIdField) {
1022
+ | (Field(sourceSortField), Some(targetSortField)) =>
1023
+ let want = root->sourceField(sourceSortField)->Option.flatMap(JSON.Decode.string)
1024
+ rows->Array.filter(item =>
1025
+ item
1026
+ ->JSON.Decode.object
1027
+ ->Option.flatMap(d => d->Dict.get(targetSortField))
1028
+ ->Option.flatMap(JSON.Decode.string) == want
1029
+ )
1030
+ | (Argument(sortArgument), Some(targetSortField)) =>
1031
+ switch args->JSON.Decode.object->Option.flatMap(d => d->Dict.get(sortArgument)) {
1032
+ | None => rows
1033
+ | Some(want) =>
1034
+ rows->Array.filter(item =>
1035
+ item
1036
+ ->JSON.Decode.object
1037
+ ->Option.flatMap(d => d->Dict.get(targetSortField))
1038
+ ->Option.flatMap(JSON.Decode.string) == want->JSON.Decode.string
1039
+ )
1040
+ }
1041
+ | _ => rows
1042
+ }
1043
+ let allowed = narrowed->Array.filter(item => targetAllows(~ctx, ~target=targetName, item))
1044
+ if multi {
1045
+ allowed->JSON.Encode.array
1046
+ } else {
1047
+ allowed->Array.get(0)->Option.getOr(JSON.Encode.null)
1048
+ }
1049
+ }
1050
+ fieldResolvers->Dict.set(fieldName, resolver)
1051
+ })
1052
+
1053
+ idsResolverConfigs->Array.forEach((config: Reventless.ReadModel.idsResolverConfig) => {
1054
+ let {source: {idsField, resolvedField}, target} = config
1055
+ let targetName = target.tableName
1056
+ let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (
1057
+ root,
1058
+ _args,
1059
+ ctx,
1060
+ ) => {
1061
+ let ids =
1062
+ root
1063
+ ->sourceField(idsField)
1064
+ ->Option.flatMap(JSON.Decode.array)
1065
+ ->Option.getOr([])
1066
+ ->Array.filterMap(JSON.Decode.string)
1067
+ // Missing ids drop out rather than becoming nulls, matching BatchGetItem
1068
+ // and the by-ids door built on it.
1069
+ let rows = await ids->Array.map(async key =>
1070
+ (await loadFrom(~target=targetName, key))->Array.get(0)->Option.map(withId(~key, ...))
1071
+ )->Promise.all
1072
+ rows
1073
+ ->Array.filterMap(row =>
1074
+ row->Option.filter(item => targetAllows(~ctx, ~target=targetName, item))
1075
+ )
1076
+ ->JSON.Encode.array
1077
+ }
1078
+ fieldResolvers->Dict.set(resolvedField, resolver)
1079
+ })
1080
+
1081
+ if fieldResolvers->Dict.keysToArray->Array.length > 0 {
1082
+ server.registerFieldResolvers(~typeName=returnTypeName, ~resolvers=fieldResolvers)
1083
+ }
1084
+
916
1085
  // -- Register all fields --------------------------------------------------
917
1086
  let allSdl =
918
1087
  [byIdSdl]
@@ -8,6 +8,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
8
  import * as Effect from "effect/Effect";
9
9
  import * as Stdlib_Nullable from "@rescript/runtime/lib/es6/Stdlib_Nullable.js";
10
10
  import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
11
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
11
12
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
12
13
  import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
13
14
  import * as Api_Ids$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Ids.res.mjs";
@@ -54,7 +55,7 @@ function Make(Bus) {
54
55
  nodeTypeRegistry: DomainGraphQL_Server$ReventlessLocal.nodeTypeRegistry
55
56
  }
56
57
  };
57
- let make = (name, param, param$1, param$2, indexes, subIdField, param$3, param$4, authorization, param$5) => {
58
+ let make = (name, param, param$1, param$2, indexes, subIdField, idResolverConfigs, idsResolverConfigs, authorization, param$3) => {
58
59
  let server = serverRef.contents;
59
60
  let relay = relayRef.contents;
60
61
  if (relay !== undefined) {
@@ -617,6 +618,122 @@ function Make(Bus) {
617
618
  resolver
618
619
  ];
619
620
  });
621
+ let targetAllows = (ctx, target, item) => {
622
+ let schema = Plugin_Helpers$ReventlessCore.stateSchemaRegistry[target];
623
+ let cell = field => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[field]);
624
+ let identity = extractIdentity(ctx);
625
+ let match = OwnerScope$Reventless.decide(identity, Stdlib_Option.flatMap(schema, s => Owner$Reventless.fieldNames(s)[0]), undefined);
626
+ let ownerOk;
627
+ if (typeof match !== "object") {
628
+ ownerOk = match === "Unscoped";
629
+ } else {
630
+ let required = match._1;
631
+ ownerOk = Stdlib_Option.mapOr(Stdlib_Option.flatMap(cell(match._0), Stdlib_JSON.Decode.string), false, v => v === required);
632
+ }
633
+ let retiredSpec = Stdlib_Option.flatMap(Stdlib_Option.flatMap(schema, StateAnnotations$Reventless.getSpec), spec => spec.retired);
634
+ let scope = OwnerScope$Reventless.retiredScopeOf(OwnerScope$Reventless.decideRetired(identity, Stdlib_Option.map(retiredSpec, r => r.field), Stdlib_Option.flatMap(retiredSpec, r => r.values), false, undefined));
635
+ let retiredOk = scope !== undefined ? !OwnerScope$Reventless.isRetiredValue(scope, cell(scope.field)) : true;
636
+ if (ownerOk) {
637
+ return retiredOk;
638
+ } else {
639
+ return false;
640
+ }
641
+ };
642
+ let withId = (key, item) => {
643
+ let obj = Stdlib_Option.mapOr(Stdlib_JSON.Decode.object(item), {}, prim => Object.assign({}, prim));
644
+ obj["id"] = key;
645
+ return obj;
646
+ };
647
+ let loadFrom = async (target, key) => {
648
+ let ops = Bus.getQueryDb(target);
649
+ if (ops !== undefined) {
650
+ return await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(key)), param => Effect.succeed([])));
651
+ } else {
652
+ return [];
653
+ }
654
+ };
655
+ let lookupByIndexIn = (target, field, value) => {
656
+ let lookup = Bus.getQueryDbIndexLookup(target);
657
+ if (lookup !== undefined) {
658
+ return lookup(field, value);
659
+ }
660
+ let scanAll = Bus.getQueryDbScan(target);
661
+ if (scanAll !== undefined) {
662
+ return scanAll().filter(item => Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[field]), Stdlib_JSON.Decode.string), false, v => v === value));
663
+ } else {
664
+ return [];
665
+ }
666
+ };
667
+ let sourceField = (root, field) => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(root), d => d[field]);
668
+ let fieldResolvers = {};
669
+ idResolverConfigs.forEach(config => {
670
+ let target = config.target;
671
+ let match = config.source;
672
+ let resolvedField = match.resolvedField;
673
+ let subId = match.subId;
674
+ let idField = match.idField;
675
+ let match$1;
676
+ match$1 = resolvedField.TAG === "Single" ? [
677
+ resolvedField._0,
678
+ false
679
+ ] : [
680
+ resolvedField._0,
681
+ true
682
+ ];
683
+ let multi = match$1[1];
684
+ let targetName = target.tableName;
685
+ let resolver = async (root, args, ctx) => {
686
+ let key = Stdlib_Option.getOr(Stdlib_Option.flatMap(sourceField(root, idField), Stdlib_JSON.Decode.string), "");
687
+ let rows;
688
+ if (key === "") {
689
+ rows = [];
690
+ } else {
691
+ let index = target.idField;
692
+ rows = typeof index !== "object" ? (await loadFrom(targetName, key)).map(extra => withId(key, extra)) : (
693
+ index.TAG === "Index" ? lookupByIndexIn(targetName, index._0, key) : lookupByIndexIn(targetName, index._1, key)
694
+ );
695
+ }
696
+ let match = target.subIdField;
697
+ let narrowed;
698
+ if (typeof subId !== "object") {
699
+ narrowed = rows;
700
+ } else if (subId.TAG === "Field") {
701
+ if (match !== undefined) {
702
+ let want = Stdlib_Option.flatMap(sourceField(root, subId._0), Stdlib_JSON.Decode.string);
703
+ narrowed = rows.filter(item => Primitive_object.equal(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[match]), Stdlib_JSON.Decode.string), want));
704
+ } else {
705
+ narrowed = rows;
706
+ }
707
+ } else if (match !== undefined) {
708
+ let sortArgument = subId._0;
709
+ let want$1 = Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[sortArgument]);
710
+ narrowed = want$1 !== undefined ? rows.filter(item => Primitive_object.equal(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[match]), Stdlib_JSON.Decode.string), Stdlib_JSON.Decode.string(want$1))) : rows;
711
+ } else {
712
+ narrowed = rows;
713
+ }
714
+ let allowed = narrowed.filter(item => targetAllows(ctx, targetName, item));
715
+ if (multi) {
716
+ return allowed;
717
+ } else {
718
+ return Stdlib_Option.getOr(allowed[0], null);
719
+ }
720
+ };
721
+ fieldResolvers[match$1[0]] = resolver;
722
+ });
723
+ idsResolverConfigs.forEach(config => {
724
+ let match = config.source;
725
+ let idsField = match.idsField;
726
+ let targetName = config.target.tableName;
727
+ let resolver = async (root, _args, ctx) => {
728
+ let ids = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(sourceField(root, idsField), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.string);
729
+ let rows = await Promise.all(ids.map(async key => Stdlib_Option.map((await loadFrom(targetName, key))[0], extra => withId(key, extra))));
730
+ return Stdlib_Array.filterMap(rows, row => Stdlib_Option.filter(row, item => targetAllows(ctx, targetName, item)));
731
+ };
732
+ fieldResolvers[match.resolvedField] = resolver;
733
+ });
734
+ if (Object.keys(fieldResolvers).length !== 0) {
735
+ server.registerFieldResolvers(returnTypeName, fieldResolvers);
736
+ }
620
737
  let allSdl = [byIdSdl].concat(byIdsSdl).concat(refsSdl).concat(match[0]).concat(itemsSdl).concat(indexSdlFields);
621
738
  let resolvers = {};
622
739
  resolvers[singleQueryName] = byIdResolver;