@reventlessdev/reventless-local 3.0.0-alpha.292 → 3.0.0-alpha.294

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 (26) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +11 -11
  3. package/src/Platform.res +6 -3
  4. package/src/Platform.res.mjs +11 -10
  5. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +19 -20
  6. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +15 -36
  7. package/src/adapter/CommandTopic/LocalCommandTopicChannel.res +1 -1
  8. package/src/adapter/Runtime/LocalRuntimeEnvironment.res +8 -3
  9. package/src/adapter/Task/LocalTaskBucket.res +1 -1
  10. package/src/reexport/CommandTopic.res.mjs +3 -0
  11. package/tests/adapter/CommandAuthorizationTest.res +6 -0
  12. package/tests/adapter/CommandAuthorizationTest.res.mjs +1 -0
  13. package/tests/adapter/GraphQL_SchemaInspectorTest.res +3 -3
  14. package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +1 -1
  15. package/tests/adapter/InboundTranslationMutationTest.res +26 -2
  16. package/tests/adapter/InboundTranslationMutationTest.res.mjs +30 -5
  17. package/tests/components/dcb/DcbRejectionCauseTest.res +271 -0
  18. package/tests/components/dcb/DcbRejectionCauseTest.res.mjs +440 -0
  19. package/tests/components/heartbeat/HeartbeatFixtures.res +1 -1
  20. package/tests/components/stateviewslice/StateViewSliceDisplayNameFixtures.res +1 -1
  21. package/tests/components/stateviewslice/StateViewSliceDisplayNameFixtures.res.mjs +2 -2
  22. package/tests/components/stateviewslice/StateViewSliceFixtures.res +1 -1
  23. package/tests/components/stateviewslice/StateViewSliceFixtures.res.mjs +2 -2
  24. package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res +1 -1
  25. package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res.mjs +2 -2
  26. package/tests/plugin/UiFragments_GWT.res.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,32 @@
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.294 (2026-10-21)
7
+
8
+ * feat!: a rejected command says whether the model, the access check or the platform said no ([6a8a6b0](https://github.com/ReventlessDev/reventless-core/commit/6a8a6b0f2f31e766ae26a944c23b0904e51a5865))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ * a type error variant with one of those names fails to
13
+ compile; rename it for what the model means, such as AlreadyBooked. On
14
+ AWS, rebuild every plugin against this release before a client selects
15
+ cause: a plugin built earlier answers it with a non-null error.
16
+
17
+
18
+
19
+ # 3.0.0-alpha.293 (2026-10-21)
20
+
21
+ * feat!: a state view's row key is module Id ([08c9726](https://github.com/ReventlessDev/reventless-core/commit/08c972659a3d689a39fd5e2f2026597155e92210))
22
+
23
+ ### BREAKING CHANGES
24
+
25
+ * a state view spec declares its row key as module Id. A
26
+ hand-written module Key in a spec under StateView/ or StateViewStream/
27
+ fails the build with "A state view's row key is `module Id`, not
28
+ `module Key`".
29
+
30
+
31
+
6
32
  # 3.0.0-alpha.292 (2026-10-21)
7
33
 
8
34
  * feat!: the write-side logic module is called the decision, and a codemod moves an app over ([cb573ff](https://github.com/ReventlessDev/reventless-core/commit/cb573ff5b928c7d7f3e75988dfaa65066f8c95eb))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.292",
3
+ "version": "3.0.0-alpha.294",
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": {
@@ -34,24 +34,24 @@
34
34
  "graphql-yoga": "^5.21.0",
35
35
  "sury": "11.0.0",
36
36
  "ws": "^8.18.0",
37
+ "@reventlessdev/rescript-effect": "1.0.0-alpha.1",
37
38
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.29",
38
39
  "@reventlessdev/rescript-jest": "1.0.0-alpha.13",
39
- "@reventlessdev/rescript-effect": "1.0.0-alpha.1",
40
- "@reventlessdev/rescript-node": "2.0.0-alpha.23",
41
40
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.23",
42
41
  "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
43
- "@reventlessdev/reventless-core": "3.0.0-alpha.301",
44
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.249",
45
- "@reventlessdev/reventless-infra": "3.0.0-alpha.198",
46
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.149",
47
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.165",
48
- "@reventlessdev/reventless-seed": "1.0.0-alpha.35",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.170"
42
+ "@reventlessdev/reventless-core": "3.0.0-alpha.303",
43
+ "@reventlessdev/rescript-node": "2.0.0-alpha.23",
44
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.151",
45
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.251",
46
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.167",
47
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.200",
48
+ "@reventlessdev/reventless-seed": "1.0.0-alpha.36",
49
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.172"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.1",
53
53
  "sury-ppx": "11.0.0",
54
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.103"
54
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.104"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "rescript": "^12.3.1"
package/src/Platform.res CHANGED
@@ -1862,9 +1862,12 @@ module MakeWithConfig = (
1862
1862
  }
1863
1863
  switch statusByName->Dict.get(pluginPrefix) {
1864
1864
  | Some(Connected) => None
1865
- | Some(Disconnected) => Some(("PluginUnavailable", "plugin is disconnected"))
1866
- | Some(Inactive) => Some(("PluginInactive", "plugin is inactive"))
1867
- | Some(Retired) => Some(("PluginRetired", "plugin version has been retired"))
1865
+ | Some(Disconnected) =>
1866
+ Some((Reventless.CommandOutcome.code(PluginUnavailable), "plugin is disconnected"))
1867
+ | Some(Inactive) =>
1868
+ Some((Reventless.CommandOutcome.code(PluginInactive), "plugin is inactive"))
1869
+ | Some(Retired) =>
1870
+ Some((Reventless.CommandOutcome.code(PluginRetired), "plugin version has been retired"))
1868
1871
  | None => None
1869
1872
  }
1870
1873
  }
@@ -26,6 +26,7 @@ import * as UiSlots$ReventlessLocal from "./UiSlots.res.mjs";
26
26
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
27
27
  import * as LocalBus$ReventlessLocal from "./adapter/LocalBus.res.mjs";
28
28
  import * as Api_Naming$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Naming.res.mjs";
29
+ import * as CommandOutcome$Reventless from "@reventlessdev/reventless-spec/src/components/CommandOutcome.res.mjs";
29
30
  import * as PluginSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginSpec.res.mjs";
30
31
  import * as UserStore$ReventlessLocal from "./adapter/Auth/UserStore.res.mjs";
31
32
  import * as TestRunner$ReventlessLocal from "./test/TestRunner.res.mjs";
@@ -672,7 +673,7 @@ function MakeWithConfig(Config) {
672
673
  isAsync: UiFragmentRegistrySlice_isAsync,
673
674
  make: UiFragmentRegistrySlice_make
674
675
  };
675
- let Spec_Key = {
676
+ let Spec_Id = {
676
677
  schema: Id$Reventless.StringPure.schema,
677
678
  make: prim => prim,
678
679
  makeFromString: prim => prim,
@@ -683,7 +684,7 @@ function MakeWithConfig(Config) {
683
684
  name: UiFragments$ReventlessCore.name,
684
685
  moduleUrl: UiFragments$ReventlessCore.moduleUrl,
685
686
  stateSchema: UiFragments$ReventlessCore.stateSchema,
686
- Key: Spec_Key,
687
+ Id: Spec_Id,
687
688
  consumedEventSchema: UiFragments$ReventlessCore.consumedEventSchema,
688
689
  config: UiFragments$ReventlessCore.config,
689
690
  subIdConfig: undefined,
@@ -1393,17 +1394,17 @@ function MakeWithConfig(Config) {
1393
1394
  return;
1394
1395
  case "Disconnected" :
1395
1396
  return [
1396
- "PluginUnavailable",
1397
+ CommandOutcome$Reventless.code("PluginUnavailable"),
1397
1398
  "plugin is disconnected"
1398
1399
  ];
1399
1400
  case "Inactive" :
1400
1401
  return [
1401
- "PluginInactive",
1402
+ CommandOutcome$Reventless.code("PluginInactive"),
1402
1403
  "plugin is inactive"
1403
1404
  ];
1404
1405
  case "Retired" :
1405
1406
  return [
1406
- "PluginRetired",
1407
+ CommandOutcome$Reventless.code("PluginRetired"),
1407
1408
  "plugin version has been retired"
1408
1409
  ];
1409
1410
  }
@@ -2433,7 +2434,7 @@ function Make($star) {
2433
2434
  isAsync: UiFragmentRegistrySlice_isAsync,
2434
2435
  make: UiFragmentRegistrySlice_make
2435
2436
  };
2436
- let Spec_Key = {
2437
+ let Spec_Id = {
2437
2438
  schema: Id$Reventless.StringPure.schema,
2438
2439
  make: prim => prim,
2439
2440
  makeFromString: prim => prim,
@@ -2444,7 +2445,7 @@ function Make($star) {
2444
2445
  name: UiFragments$ReventlessCore.name,
2445
2446
  moduleUrl: UiFragments$ReventlessCore.moduleUrl,
2446
2447
  stateSchema: UiFragments$ReventlessCore.stateSchema,
2447
- Key: Spec_Key,
2448
+ Id: Spec_Id,
2448
2449
  consumedEventSchema: UiFragments$ReventlessCore.consumedEventSchema,
2449
2450
  config: UiFragments$ReventlessCore.config,
2450
2451
  subIdConfig: undefined,
@@ -3149,17 +3150,17 @@ function Make($star) {
3149
3150
  return;
3150
3151
  case "Disconnected" :
3151
3152
  return [
3152
- "PluginUnavailable",
3153
+ CommandOutcome$Reventless.code("PluginUnavailable"),
3153
3154
  "plugin is disconnected"
3154
3155
  ];
3155
3156
  case "Inactive" :
3156
3157
  return [
3157
- "PluginInactive",
3158
+ CommandOutcome$Reventless.code("PluginInactive"),
3158
3159
  "plugin is inactive"
3159
3160
  ];
3160
3161
  case "Retired" :
3161
3162
  return [
3162
- "PluginRetired",
3163
+ CommandOutcome$Reventless.code("PluginRetired"),
3163
3164
  "plugin version has been retired"
3164
3165
  ];
3165
3166
  }
@@ -35,14 +35,15 @@ let extractIdentity = (ctx: JSON.t): Reventless.Identity.t => {
35
35
  // Returns a `CommandRejected` outcome with a fresh msgId so the GraphQL union
36
36
  // resolves consistently — matches the existing CommandResult contract.
37
37
 
38
- let rejectForbidden = (~field: string): JSON.t => JSON.Object(
39
- Dict.fromArray([
40
- ("__typename", JSON.String("CommandRejected")),
41
- ("msgId", JSON.String(ReventlessCore.Message.uuid())),
42
- ("errorCode", JSON.String("Forbidden")),
43
- ("errorDetail", JSON.String(`Mutation.${field}: identity is not authorized`)),
44
- ]),
45
- )
38
+ let rejectForbidden = (~field: string): JSON.t =>
39
+ CommandTopic.commandOutcomeToJson(
40
+ Rejected({
41
+ msgId: ReventlessCore.Message.uuid(),
42
+ errorCode: Reventless.CommandOutcome.code(Forbidden),
43
+ errorDetail: Some(`Mutation.${field}: identity is not authorized`),
44
+ cause: AccessRefusal,
45
+ }),
46
+ )
46
47
 
47
48
  // -- Plugin status gate -------------------------------------------------------
48
49
  // Set by the platform after Admin.construct to wire a per-mutation lookup against
@@ -60,18 +61,16 @@ let pluginStatusGate: ref<option<string => option<(string, string)>>> = ref(None
60
61
  let setPluginStatusGate = fn => pluginStatusGate := Some(fn)
61
62
  let resetPluginStatusGate = () => pluginStatusGate := None
62
63
 
63
- let rejectPluginStatus = (
64
- ~field: string,
65
- ~errorCode: string,
66
- ~detail: string,
67
- ): JSON.t => JSON.Object(
68
- Dict.fromArray([
69
- ("__typename", JSON.String("CommandRejected")),
70
- ("msgId", JSON.String(ReventlessCore.Message.uuid())),
71
- ("errorCode", JSON.String(errorCode)),
72
- ("errorDetail", JSON.String(`Mutation.${field}: ${detail}`)),
73
- ]),
74
- )
64
+ // The plugin, not the model, is what said no.
65
+ let rejectPluginStatus = (~field: string, ~errorCode: string, ~detail: string): JSON.t =>
66
+ CommandTopic.commandOutcomeToJson(
67
+ Rejected({
68
+ msgId: ReventlessCore.Message.uuid(),
69
+ errorCode,
70
+ errorDetail: Some(`Mutation.${field}: ${detail}`),
71
+ cause: InfrastructureFailure,
72
+ }),
73
+ )
75
74
 
76
75
  let checkPluginStatus = (~field: string): option<JSON.t> =>
77
76
  switch pluginStatusGate.contents {
@@ -10,6 +10,7 @@ import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/I
10
10
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
11
11
  import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
12
12
  import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
13
+ import * as CommandOutcome$Reventless from "@reventlessdev/reventless-spec/src/components/CommandOutcome.res.mjs";
13
14
  import * as CommandTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic.res.mjs";
14
15
  import * as GraphQL_CallerError$ReventlessLocal from "../GraphQL_CallerError.res.mjs";
15
16
  import * as Plugin_ResolverError$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_ResolverError.res.mjs";
@@ -29,24 +30,13 @@ function extractIdentity(ctx) {
29
30
  }
30
31
 
31
32
  function rejectForbidden(field) {
32
- return Object.fromEntries([
33
- [
34
- "__typename",
35
- "CommandRejected"
36
- ],
37
- [
38
- "msgId",
39
- Message$ReventlessCore.uuid()
40
- ],
41
- [
42
- "errorCode",
43
- "Forbidden"
44
- ],
45
- [
46
- "errorDetail",
47
- `Mutation.` + field + `: identity is not authorized`
48
- ]
49
- ]);
33
+ return CommandTopic$ReventlessCore.commandOutcomeToJson({
34
+ TAG: "Rejected",
35
+ msgId: Message$ReventlessCore.uuid(),
36
+ errorCode: CommandOutcome$Reventless.code("Forbidden"),
37
+ errorDetail: `Mutation.` + field + `: identity is not authorized`,
38
+ cause: "AccessRefusal"
39
+ });
50
40
  }
51
41
 
52
42
  let pluginStatusGate = {
@@ -62,24 +52,13 @@ function resetPluginStatusGate() {
62
52
  }
63
53
 
64
54
  function rejectPluginStatus(field, errorCode, detail) {
65
- return Object.fromEntries([
66
- [
67
- "__typename",
68
- "CommandRejected"
69
- ],
70
- [
71
- "msgId",
72
- Message$ReventlessCore.uuid()
73
- ],
74
- [
75
- "errorCode",
76
- errorCode
77
- ],
78
- [
79
- "errorDetail",
80
- `Mutation.` + field + `: ` + detail
81
- ]
82
- ]);
55
+ return CommandTopic$ReventlessCore.commandOutcomeToJson({
56
+ TAG: "Rejected",
57
+ msgId: Message$ReventlessCore.uuid(),
58
+ errorCode: errorCode,
59
+ errorDetail: `Mutation.` + field + `: ` + detail,
60
+ cause: "InfrastructureFailure"
61
+ });
83
62
  }
84
63
 
85
64
  function checkPluginStatus(field) {
@@ -62,7 +62,7 @@ module Make = (Bus: LocalBus.T) => {
62
62
  let handleChannelEvent = (
63
63
  handleCmds: ReventlessCore.CommandTopic.jsonCommandsHandler,
64
64
  ): Pulumi.Output.t<
65
- ReventlessCore.Runtime.effectHandler<callbackEvent, 'context, unit, string>,
65
+ ReventlessCore.Runtime.effectInvocationHandler<callbackEvent, 'context, unit, string>,
66
66
  > => {
67
67
  handleCmdsRef.contents = Some(handleCmds)
68
68
 
@@ -20,7 +20,7 @@ external asUnitHandler: ((JSON.t, unit) => promise<'r>) => (JSON.t, unit) => pro
20
20
 
21
21
  let make = (
22
22
  ~name as _: string,
23
- ~handler: Pulumi.Output.t<ReventlessCore.Runtime.eventHandler<event, context, 'result>>,
23
+ ~handler: Pulumi.Output.t<ReventlessCore.Runtime.invocationHandler<event, context, 'result>>,
24
24
  ~memorySize as _=1024,
25
25
  ~timeout as _=30,
26
26
  ~opts as _=?,
@@ -62,6 +62,11 @@ let extractSentTimestamp = (_event: event) => Some(Date.now())
62
62
  // No redelivery in-process — the bus calls each handler once.
63
63
  let extractRetryCount = (_event: event) => Some(1)
64
64
 
65
- external asEventHandler: 'a => ReventlessCore.Runtime.eventHandler<event, context, 'r> = "%identity"
66
- external asEffectHandler: 'a => ReventlessCore.Runtime.effectHandler<event, context, 'r, 'error> =
65
+ external asEventHandler: 'a => ReventlessCore.Runtime.invocationHandler<event, context, 'r> =
67
66
  "%identity"
67
+ external asEffectHandler: 'a => ReventlessCore.Runtime.effectInvocationHandler<
68
+ event,
69
+ context,
70
+ 'r,
71
+ 'error,
72
+ > = "%identity"
@@ -17,7 +17,7 @@ let connect: Task_Adapter.connect<bucketParts, runtimeParts> = (
17
17
  ~opts as _,
18
18
  ) => ()
19
19
 
20
- let makeHandler = (callback: Task.bucketCallback): Runtime.eventHandler<
20
+ let makeHandler = (callback: Task.bucketCallback): Runtime.invocationHandler<
21
21
  callbackEvent,
22
22
  context,
23
23
  array<Task.taskAction>,
@@ -6,6 +6,8 @@ let componentType = CommandTopic$ReventlessCore.componentType;
6
6
 
7
7
  let NotPublishedToChannel = CommandTopic$ReventlessCore.NotPublishedToChannel;
8
8
 
9
+ let refusalCauseToGraphQL = CommandTopic$ReventlessCore.refusalCauseToGraphQL;
10
+
9
11
  let commandOutcomeHook = CommandTopic$ReventlessCore.commandOutcomeHook;
10
12
 
11
13
  let registerCommandOutcome = CommandTopic$ReventlessCore.registerCommandOutcome;
@@ -47,6 +49,7 @@ let filter = CommandTopic$ReventlessCore.filter;
47
49
  export {
48
50
  componentType,
49
51
  NotPublishedToChannel,
52
+ refusalCauseToGraphQL,
50
53
  commandOutcomeHook,
51
54
  registerCommandOutcome,
52
55
  clearCommandOutcome,
@@ -139,6 +139,12 @@ describe("CommandGeneratorResolvers_GraphQL — per-constructor authorization",
139
139
  )
140
140
  expect(getTypename(response))->toEqual("CommandRejected")
141
141
  expect(getErrorCode(response))->toEqual("Forbidden")
142
+ expect(
143
+ response
144
+ ->JSON.Decode.object
145
+ ->Option.flatMap(o => o->Dict.get("cause"))
146
+ ->Option.flatMap(JSON.Decode.string),
147
+ )->toEqual(Some("ACCESS"))
142
148
  expect(calls.contents)->toEqual([])
143
149
  })
144
150
 
@@ -134,6 +134,7 @@ globalThis.describe("CommandGeneratorResolvers_GraphQL — per-constructor autho
134
134
  let response = await match[2](null, {}, ctxFor(userIdentity));
135
135
  globalThis.expect(getTypename(response)).toEqual("CommandRejected");
136
136
  globalThis.expect(getErrorCode(response)).toEqual("Forbidden");
137
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(response), o => o["cause"]), Stdlib_JSON.Decode.string)).toEqual("ACCESS");
137
138
  globalThis.expect(match[3].contents).toEqual([]);
138
139
  });
139
140
  globalThis.test("anonymous invoking Archive is rejected with Forbidden", async () => {
@@ -609,9 +609,9 @@ describe("GraphQL_SchemaInspector", () => {
609
609
  let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
610
610
  // TestState, TestStateEdge, TestStateConnection, TestStateFilter (connection filter)
611
611
  // + TestStateRef (the reference door's projection)
612
- // + CommandResult union + CommandAccepted + CommandRejected + CommandPending
613
- // (auto-injected whenever the fragment emits any mutation field).
614
- expect(inspection.types->Array.length)->toBe(9)
612
+ // + CommandResult union + CommandAccepted + CommandRejectionCause + CommandRejected
613
+ // + CommandPending (auto-injected whenever the fragment emits any mutation field).
614
+ expect(inspection.types->Array.length)->toBe(10)
615
615
  expect(inspection.mutations->Array.length)->toBe(1)
616
616
  // single Test_State(id), list Test_States(...), Test_StatesByIds(ids: [String!]!)
617
617
  // and Test_StatesRefs(ids: [ID!]!) — the by-ids read projected to a reference.
@@ -430,7 +430,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
430
430
  authorization: undefined
431
431
  }]);
432
432
  let inspection = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment);
433
- globalThis.expect(inspection.types.length).toBe(9);
433
+ globalThis.expect(inspection.types.length).toBe(10);
434
434
  globalThis.expect(inspection.mutations.length).toBe(1);
435
435
  globalThis.expect(inspection.queries.length).toBe(4);
436
436
  globalThis.expect(inspection.sdlPreview.includes("type TestState")).toBe(true);
@@ -44,6 +44,11 @@ let selection = `__typename
44
44
  ... on CommandAccepted { msgId entityId eventCount }
45
45
  ... on CommandRejected { msgId errorCode errorDetail }`
46
46
 
47
+ // The same selection with `cause`, which a client opts into.
48
+ let selectionWithCause = `__typename
49
+ ... on CommandAccepted { msgId entityId eventCount }
50
+ ... on CommandRejected { msgId errorCode errorDetail cause }`
51
+
47
52
  let signedIn = (groups): Reventless.Identity.t => {
48
53
  userId: "u-1",
49
54
  username: "alice",
@@ -67,7 +72,7 @@ let execute = async (~caller, source) =>
67
72
  "contextValue": contextOf(caller),
68
73
  })
69
74
 
70
- let runMutation = async (~status: string) =>
75
+ let runMutation = async (~status: string, ~selection=selection) =>
71
76
  await execute(
72
77
  ~caller=Some(signedIn([])),
73
78
  `mutation {
@@ -153,6 +158,23 @@ describe("InboundTranslationSlice mutation — response envelope", () => {
153
158
  expect(published.contents->Array.length)->toBe(0)
154
159
  })
155
160
 
161
+ testPromise("a client selecting cause learns the model said no", async () => {
162
+ let result = await runMutation(~status="pending", ~selection=selectionWithCause)
163
+ expect(errorMessages(result))->toEqual([])
164
+ let node = payload(result)
165
+ expect(node->str("errorCode"))->toEqual(Some("TranslationFailed"))
166
+ expect(node->str("cause"))->toEqual(Some("DOMAIN"))
167
+ })
168
+
169
+ testPromise("a client not selecting cause gets exactly the fields it asked for", async () => {
170
+ let node = payload(await runMutation(~status="pending"))
171
+ expect(
172
+ node
173
+ ->JSON.Decode.object
174
+ ->Option.map(d => d->Dict.keysToArray->Array.toSorted(String.compare)),
175
+ )->toEqual(Some(["__typename", "errorCode", "errorDetail", "msgId"]))
176
+ })
177
+
156
178
  testSync("the mutation field is typed CommandResult!", () => {
157
179
  let fieldLine =
158
180
  DomainGraphQL_Server.asInterface.buildSdl()
@@ -176,7 +198,7 @@ let feedField = "Catalog_CatalogFeed"
176
198
  let runFeed = async (~caller, ~kind) =>
177
199
  await execute(
178
200
  ~caller,
179
- `mutation { r: ${feedField}(kind: "${kind}", id: "x-1") { ${selection} } }`,
201
+ `mutation { r: ${feedField}(kind: "${kind}", id: "x-1") { ${selectionWithCause} } }`,
180
202
  )
181
203
 
182
204
  describe("InboundTranslationSlice mutation — the door", () => {
@@ -206,6 +228,7 @@ describe("InboundTranslationSlice mutation — the door", () => {
206
228
  testPromise("a caller holding none of the commands' roles is refused", async () => {
207
229
  let node = payload(await runFeed(~caller=Some(signedIn(["Ops"])), ~kind="product"))
208
230
  expect(node->str("errorCode"))->toEqual(Some("Forbidden"))
231
+ expect(node->str("cause"))->toEqual(Some("ACCESS"))
209
232
  expect(published.contents->Array.length)->toBe(0)
210
233
  })
211
234
 
@@ -225,6 +248,7 @@ describe("InboundTranslationSlice mutation — the door", () => {
225
248
  testPromise("the caller reaches the per-command check", async () => {
226
249
  let node = payload(await runFeed(~caller=Some(signedIn(["Merchandiser"])), ~kind="both"))
227
250
  expect(node->str("errorCode"))->toEqual(Some("Forbidden"))
251
+ expect(node->str("cause"))->toEqual(Some("ACCESS"))
228
252
  expect(published.contents->Array.length)->toBe(0)
229
253
  })
230
254
  })
@@ -5,6 +5,7 @@ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
5
5
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
6
6
  import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
7
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
8
9
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
9
10
  import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
10
11
  import * as Dcb_Builder$ReventlessCore from "@reventlessdev/reventless-core/src/components/Dcb/Dcb_Builder.res.mjs";
@@ -49,6 +50,10 @@ let selection = `__typename
49
50
  ... on CommandAccepted { msgId entityId eventCount }
50
51
  ... on CommandRejected { msgId errorCode errorDetail }`;
51
52
 
53
+ let selectionWithCause = `__typename
54
+ ... on CommandAccepted { msgId entityId eventCount }
55
+ ... on CommandRejected { msgId errorCode errorDetail cause }`;
56
+
52
57
  function signedIn(groups) {
53
58
  return {
54
59
  userId: "u-1",
@@ -77,9 +82,10 @@ async function execute(caller, source) {
77
82
  });
78
83
  }
79
84
 
80
- async function runMutation(status) {
85
+ async function runMutation(status, selectionOpt) {
86
+ let selection$1 = selectionOpt !== undefined ? selectionOpt : selection;
81
87
  return await execute(signedIn([]), `mutation {
82
- r: ` + fieldName + `(paymentId: "pay-1", orderId: "ord-1", status: "` + status + `") { ` + selection + ` }
88
+ r: ` + fieldName + `(paymentId: "pay-1", orderId: "ord-1", status: "` + status + `") { ` + selection$1 + ` }
83
89
  }`);
84
90
  }
85
91
 
@@ -110,7 +116,7 @@ globalThis.describe("InboundTranslationSlice mutation — response envelope", ()
110
116
  InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(fieldName, (inputJson, caller) => Callback.receive(publishJsons, inputJson, caller, undefined));
111
117
  });
112
118
  globalThis.test("a successful translation resolves as CommandAccepted, with no errors", async () => {
113
- let result = await runMutation("completed");
119
+ let result = await runMutation("completed", undefined);
114
120
  globalThis.expect(errorMessages(result)).toEqual([]);
115
121
  let node = payload(result);
116
122
  globalThis.expect(str(node, "__typename")).toEqual("CommandAccepted");
@@ -121,7 +127,7 @@ globalThis.describe("InboundTranslationSlice mutation — response envelope", ()
121
127
  globalThis.expect(published.contents.length).toBe(1);
122
128
  });
123
129
  globalThis.test("a rejected translation resolves as CommandRejected, with no errors", async () => {
124
- let result = await runMutation("pending");
130
+ let result = await runMutation("pending", undefined);
125
131
  globalThis.expect(errorMessages(result)).toEqual([]);
126
132
  let node = payload(result);
127
133
  globalThis.expect(str(node, "__typename")).toEqual("CommandRejected");
@@ -136,6 +142,22 @@ globalThis.describe("InboundTranslationSlice mutation — response envelope", ()
136
142
  }
137
143
  globalThis.expect(published.contents.length).toBe(0);
138
144
  });
145
+ globalThis.test("a client selecting cause learns the model said no", async () => {
146
+ let result = await runMutation("pending", selectionWithCause);
147
+ globalThis.expect(errorMessages(result)).toEqual([]);
148
+ let node = payload(result);
149
+ globalThis.expect(str(node, "errorCode")).toEqual("TranslationFailed");
150
+ globalThis.expect(str(node, "cause")).toEqual("DOMAIN");
151
+ });
152
+ globalThis.test("a client not selecting cause gets exactly the fields it asked for", async () => {
153
+ let node = payload(await runMutation("pending", undefined));
154
+ globalThis.expect(Stdlib_Option.map(Stdlib_JSON.Decode.object(node), d => Object.keys(d).toSorted(Primitive_string.compare))).toEqual([
155
+ "__typename",
156
+ "errorCode",
157
+ "errorDetail",
158
+ "msgId"
159
+ ]);
160
+ });
139
161
  globalThis.test("the mutation field is typed CommandResult!", () => {
140
162
  let fieldLine = DomainGraphQL_Server$ReventlessLocal.asInterface.buildSdl().split("\n").find(line => line.includes(fieldName));
141
163
  globalThis.expect(Stdlib_Option.map(fieldLine, l => l.endsWith(": CommandResult!"))).toEqual(true);
@@ -165,7 +187,7 @@ let CatalogFeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make({
165
187
  let feedField = "Catalog_CatalogFeed";
166
188
 
167
189
  async function runFeed(caller, kind) {
168
- return await execute(caller, `mutation { r: ` + feedField + `(kind: "` + kind + `", id: "x-1") { ` + selection + ` } }`);
190
+ return await execute(caller, `mutation { r: ` + feedField + `(kind: "` + kind + `", id: "x-1") { ` + selectionWithCause + ` } }`);
169
191
  }
170
192
 
171
193
  globalThis.describe("InboundTranslationSlice mutation — the door", () => {
@@ -180,6 +202,7 @@ globalThis.describe("InboundTranslationSlice mutation — the door", () => {
180
202
  globalThis.test("a caller holding none of the commands' roles is refused", async () => {
181
203
  let node = payload(await runFeed(signedIn(["Ops"]), "product"));
182
204
  globalThis.expect(str(node, "errorCode")).toEqual("Forbidden");
205
+ globalThis.expect(str(node, "cause")).toEqual("ACCESS");
183
206
  globalThis.expect(published.contents.length).toBe(0);
184
207
  });
185
208
  globalThis.test("an unauthenticated caller is refused", async () => {
@@ -195,6 +218,7 @@ globalThis.describe("InboundTranslationSlice mutation — the door", () => {
195
218
  globalThis.test("the caller reaches the per-command check", async () => {
196
219
  let node = payload(await runFeed(signedIn(["Merchandiser"]), "both"));
197
220
  globalThis.expect(str(node, "errorCode")).toEqual("Forbidden");
221
+ globalThis.expect(str(node, "cause")).toEqual("ACCESS");
198
222
  globalThis.expect(published.contents.length).toBe(0);
199
223
  });
200
224
  });
@@ -207,6 +231,7 @@ export {
207
231
  published,
208
232
  publishJsons,
209
233
  selection,
234
+ selectionWithCause,
210
235
  signedIn,
211
236
  contextOf,
212
237
  execute,