@reventlessdev/reventless-core 3.0.0-alpha.302 → 3.0.0-alpha.303

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,19 @@
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.303 (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
+
6
19
  # 3.0.0-alpha.302 (2026-10-21)
7
20
 
8
21
  * feat!: a state view's row key is module Id ([08c9726](https://github.com/ReventlessDev/reventless-core/commit/08c972659a3d689a39fd5e2f2026597155e92210))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.302",
3
+ "version": "3.0.0-alpha.303",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -28,17 +28,17 @@
28
28
  "dependencies": {
29
29
  "sury": "11.0.0",
30
30
  "uuid": "^13.0.0",
31
- "@reventlessdev/rescript-effect": "1.0.0-alpha.1",
32
31
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.15",
33
- "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.23",
32
+ "@reventlessdev/rescript-effect": "1.0.0-alpha.1",
34
33
  "@reventlessdev/rescript-jest": "1.0.0-alpha.13",
34
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.23",
35
35
  "@reventlessdev/rescript-node": "2.0.0-alpha.23",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
37
- "@reventlessdev/rescript-ssh2": "2.0.0-alpha.24",
38
37
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.199",
38
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.200",
39
+ "@reventlessdev/rescript-ssh2": "2.0.0-alpha.24",
40
40
  "@reventlessdev/reventless-interop": "3.0.0-alpha.42",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.171"
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.172"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.1",
@@ -392,7 +392,7 @@ module Make = (
392
392
  ~component=Spec.name,
393
393
  ~cause=InfrastructureFailure,
394
394
  reference,
395
- {errorCode: "AppendFailed", errorDetail: appendErrorDetail},
395
+ {errorCode: Reventless.CommandOutcome.code(AppendFailed), errorDetail: appendErrorDetail},
396
396
  )
397
397
  Error(reference)
398
398
  }
@@ -15,6 +15,7 @@ import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/U
15
15
  import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
16
16
  import * as Message$ReventlessCore from "../../Message.res.mjs";
17
17
  import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
18
+ import * as CommandOutcome$Reventless from "@reventlessdev/reventless-spec/src/components/CommandOutcome.res.mjs";
18
19
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
19
20
  import * as SchemaWalker$ReventlessCore from "../../plugin/component/SchemaWalker.res.mjs";
20
21
  import * as CommandTopic_Helpers$ReventlessCore from "../CommandTopic/CommandTopic_Helpers.res.mjs";
@@ -254,7 +255,7 @@ function Make(Spec) {
254
255
  };
255
256
  } else {
256
257
  CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "InfrastructureFailure", reference, {
257
- errorCode: "AppendFailed",
258
+ errorCode: CommandOutcome$Reventless.code("AppendFailed"),
258
259
  errorDetail: appendErrorDetail
259
260
  });
260
261
  return {
@@ -12,17 +12,26 @@ let log = Logger.fromEnv()
12
12
  // fragments. These SDL declarations are injected into any fragment that emits
13
13
  // at least one mutation field; the stitcher dedupes by type name so
14
14
  // concatenating across plugin + admin fragments stays safe.
15
+ //
16
+ // `cause` says which no a rejection is: the model's (DOMAIN, `errorCode` is the
17
+ // declared error), the caller's (ACCESS) or the platform's (INFRASTRUCTURE).
15
18
  let commandResultSdlTypes: array<string> = [
16
19
  `union CommandResult = CommandAccepted | CommandRejected | CommandPending`,
17
20
  `type CommandAccepted {
18
21
  msgId: ID!
19
22
  entityId: ID
20
23
  eventCount: Int!
24
+ }`,
25
+ `enum CommandRejectionCause {
26
+ DOMAIN
27
+ ACCESS
28
+ INFRASTRUCTURE
21
29
  }`,
22
30
  `type CommandRejected {
23
31
  msgId: ID!
24
32
  errorCode: String!
25
33
  errorDetail: String
34
+ cause: CommandRejectionCause!
26
35
  }`,
27
36
  `type CommandPending {
28
37
  msgId: ID!
@@ -22,11 +22,17 @@ let commandResultSdlTypes = [
22
22
  msgId: ID!
23
23
  entityId: ID
24
24
  eventCount: Int!
25
+ }`,
26
+ `enum CommandRejectionCause {
27
+ DOMAIN
28
+ ACCESS
29
+ INFRASTRUCTURE
25
30
  }`,
26
31
  `type CommandRejected {
27
32
  msgId: ID!
28
33
  errorCode: String!
29
34
  errorDetail: String
35
+ cause: CommandRejectionCause!
30
36
  }`,
31
37
  `type CommandPending {
32
38
  msgId: ID!
@@ -23,6 +23,8 @@ function filter(allCommandTopics, names) {
23
23
 
24
24
  let componentType = "CommandTopic";
25
25
 
26
+ let refusalCauseToGraphQL = CommandTopic_Helpers$ReventlessCore.refusalCauseToGraphQL;
27
+
26
28
  let commandOutcomeHook = CommandTopic_Helpers$ReventlessCore.commandOutcomeHook;
27
29
 
28
30
  let registerCommandOutcome = CommandTopic_Helpers$ReventlessCore.registerCommandOutcome;
@@ -60,6 +62,7 @@ let getHandlers = CommandTopic_Helpers$ReventlessCore.getHandlers;
60
62
  export {
61
63
  componentType,
62
64
  NotPublishedToChannel,
65
+ refusalCauseToGraphQL,
63
66
  commandOutcomeHook,
64
67
  registerCommandOutcome,
65
68
  clearCommandOutcome,
@@ -31,6 +31,14 @@ own what the command acts on (`OwnerScope.decideActing`).
31
31
  */
32
32
  type refusalCause = DomainRejection | InfrastructureFailure | AccessRefusal
33
33
 
34
+ /** The `CommandRejectionCause` enum value a client sees for a cause. */
35
+ let refusalCauseToGraphQL = (cause: refusalCause): string =>
36
+ switch cause {
37
+ | DomainRejection => "DOMAIN"
38
+ | AccessRefusal => "ACCESS"
39
+ | InfrastructureFailure => "INFRASTRUCTURE"
40
+ }
41
+
34
42
  /**
35
43
  What became of one command. Constructors are prefixed to stay clear of `commandOutcome`'s
36
44
  `Accepted`/`Rejected` below — that type is the producer-facing result shape (and the GraphQL
@@ -91,10 +99,9 @@ let fireCommandOutcome = (~component: string, ~reference: string, outcome: obser
91
99
  // Callback implementations call reportAccepted during inline dispatch.
92
100
  let acceptedResultChannel: ref<option<(string, acceptedResult) => unit>> = ref(None)
93
101
 
94
- // Sibling side-channel for domain rejections — populated by callbacks when
95
- // `Decision.decide` returns `Error`. Read by runInlineAndCollect to build
96
- // `Rejected` outcomes that carry the real error code and detail.
97
- let rejectedResultChannel: ref<option<(string, rejectedResult) => unit>> = ref(None)
102
+ // Sibling side-channel for rejections, with their cause. Read by runInlineAndCollect
103
+ // to build `Rejected` outcomes that carry the real error code, detail and cause.
104
+ let rejectedResultChannel: ref<option<(string, rejectedResult, refusalCause) => unit>> = ref(None)
98
105
 
99
106
  /**
100
107
  Turn an `acting` verdict into the refusal a command handler reports, or `None` to let
@@ -112,7 +119,10 @@ let ownershipRefusal = (
112
119
  ~command: string,
113
120
  ): option<ownershipRefusal> => {
114
121
  let refuse = (~isDefect=false, why) => Some({
115
- rejected: {errorCode: "Forbidden", errorDetail: `${component}.${command}: ${why}`},
122
+ rejected: {
123
+ errorCode: Reventless.CommandOutcome.code(Forbidden),
124
+ errorDetail: `${component}.${command}: ${why}`,
125
+ },
116
126
  isDefect,
117
127
  })
118
128
  switch verdict {
@@ -148,7 +158,7 @@ let reportRejected = (
148
158
  reference: string,
149
159
  result: rejectedResult,
150
160
  ) => {
151
- rejectedResultChannel.contents->Option.forEach(cb => cb(reference, result))
161
+ rejectedResultChannel.contents->Option.forEach(cb => cb(reference, result, cause))
152
162
  fireCommandOutcome(
153
163
  ~component,
154
164
  ~reference,
@@ -160,7 +170,7 @@ let reportRejected = (
160
170
  // without importing Adapter.res → @pulumi/pulumi. Re-exported by CommandTopic via include.
161
171
  type commandOutcome =
162
172
  | Accepted({msgId: string, entityId?: string, eventCount: int})
163
- | Rejected({msgId: string, errorCode: string, errorDetail: option<string>})
173
+ | Rejected({msgId: string, errorCode: string, errorDetail: option<string>, cause: refusalCause})
164
174
  | Pending({msgId: string})
165
175
 
166
176
  // Serialise a commandOutcome to the JSON shape the CommandResult GraphQL union
@@ -179,13 +189,14 @@ let commandOutcomeToJson = (outcome: commandOutcome): JSON.t =>
179
189
  ("eventCount", JSON.Number(eventCount->Int.toFloat)),
180
190
  ]),
181
191
  )
182
- | Rejected({msgId, errorCode, errorDetail}) =>
192
+ | Rejected({msgId, errorCode, errorDetail, cause}) =>
183
193
  JSON.Object(
184
194
  Dict.fromArray([
185
195
  ("__typename", JSON.String("CommandRejected")),
186
196
  ("msgId", JSON.String(msgId)),
187
197
  ("errorCode", JSON.String(errorCode)),
188
198
  ("errorDetail", errorDetail->Option.map(s => JSON.String(s))->Option.getOr(JSON.Null)),
199
+ ("cause", JSON.String(refusalCauseToGraphQL(cause))),
189
200
  ]),
190
201
  )
191
202
  | Pending({msgId}) =>
@@ -222,27 +233,34 @@ let runInlineAndCollect = async (
222
233
  item
223
234
  })
224
235
  let acceptedResults: dict<acceptedResult> = Dict.make()
225
- let rejectedResults: dict<rejectedResult> = Dict.make()
236
+ let rejectedResults: dict<(rejectedResult, refusalCause)> = Dict.make()
226
237
  acceptedResultChannel.contents = Some(
227
238
  (reference, result) => acceptedResults->Dict.set(reference, result),
228
239
  )
229
240
  rejectedResultChannel.contents = Some(
230
- (reference, result) => rejectedResults->Dict.set(reference, result),
241
+ (reference, result, cause) => rejectedResults->Dict.set(reference, (result, cause)),
231
242
  )
232
243
  let results = await handleCmds(Stream.fromIterable(items))->Effect.runPromise
233
244
  acceptedResultChannel.contents = None
234
245
  rejectedResultChannel.contents = None
235
246
  jsons->Array.mapWithIndex((cmdJson, i) => {
236
247
  let msgId = cmdJson.meta.msgId
237
- // Domain rejection from Decision.decide takes precedence over both Accepted
238
- // and the synthesized "Conflict" — it carries the most informative error.
248
+ // A reported rejection takes precedence over both Accepted and the
249
+ // synthesized "Conflict" — it carries the most informative error.
239
250
  switch rejectedResults->Dict.get(msgId) {
240
- | Some({errorCode, errorDetail}) =>
251
+ | Some(({errorCode, errorDetail}, cause)) =>
241
252
  let detail = errorDetail == "" ? None : Some(errorDetail)
242
- Rejected({msgId, errorCode, errorDetail: detail})
253
+ Rejected({msgId, errorCode, errorDetail: detail, cause})
243
254
  | None =>
244
255
  switch results->Array.get(i) {
245
- | Some(Error(msg)) => Rejected({msgId, errorCode: "Conflict", errorDetail: Some(msg)})
256
+ // The handler failed outright: the platform, not the model.
257
+ | Some(Error(msg)) =>
258
+ Rejected({
259
+ msgId,
260
+ errorCode: Reventless.CommandOutcome.code(Conflict),
261
+ errorDetail: Some(msg),
262
+ cause: InfrastructureFailure,
263
+ })
246
264
  | _ =>
247
265
  let ar = acceptedResults->Dict.get(msgId)->Option.getOr({eventCount: 0})
248
266
  switch ar.entityId {
@@ -8,8 +8,20 @@ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/component
8
8
  import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
9
9
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
10
10
  import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
11
+ import * as CommandOutcome$Reventless from "@reventlessdev/reventless-spec/src/components/CommandOutcome.res.mjs";
11
12
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
12
13
 
14
+ function refusalCauseToGraphQL(cause) {
15
+ switch (cause) {
16
+ case "DomainRejection" :
17
+ return "DOMAIN";
18
+ case "InfrastructureFailure" :
19
+ return "INFRASTRUCTURE";
20
+ case "AccessRefusal" :
21
+ return "ACCESS";
22
+ }
23
+ }
24
+
13
25
  let commandOutcomeHook = {
14
26
  contents: undefined
15
27
  };
@@ -51,7 +63,7 @@ function ownershipRefusal(verdict, component, command) {
51
63
  let isDefect = isDefectOpt !== undefined ? isDefectOpt : false;
52
64
  return {
53
65
  rejected: {
54
- errorCode: "Forbidden",
66
+ errorCode: CommandOutcome$Reventless.code("Forbidden"),
55
67
  errorDetail: component + `.` + command + `: ` + why
56
68
  },
57
69
  isDefect: isDefect
@@ -84,7 +96,7 @@ function reportAccepted(component, reference, result) {
84
96
  }
85
97
 
86
98
  function reportRejected(component, cause, reference, result) {
87
- Stdlib_Option.forEach(rejectedResultChannel.contents, cb => cb(reference, result));
99
+ Stdlib_Option.forEach(rejectedResultChannel.contents, cb => cb(reference, result, cause));
88
100
  fireCommandOutcome(component, reference, {
89
101
  TAG: "OutcomeRejected",
90
102
  errorCode: result.errorCode,
@@ -131,6 +143,10 @@ function commandOutcomeToJson(outcome) {
131
143
  [
132
144
  "errorDetail",
133
145
  Stdlib_Option.getOr(Stdlib_Option.map(outcome.errorDetail, s => (s)), null)
146
+ ],
147
+ [
148
+ "cause",
149
+ refusalCauseToGraphQL(outcome.cause)
134
150
  ]
135
151
  ]);
136
152
  case "Pending" :
@@ -177,8 +193,11 @@ async function runInlineAndCollect(jsons, handleCmds) {
177
193
  acceptedResultChannel.contents = (reference, result) => {
178
194
  acceptedResults[reference] = result;
179
195
  };
180
- rejectedResultChannel.contents = (reference, result) => {
181
- rejectedResults[reference] = result;
196
+ rejectedResultChannel.contents = (reference, result, cause) => {
197
+ rejectedResults[reference] = [
198
+ result,
199
+ cause
200
+ ];
182
201
  };
183
202
  let results = await Effect.runPromise(handleCmds(Stream.fromIterable(items)));
184
203
  acceptedResultChannel.contents = undefined;
@@ -187,22 +206,25 @@ async function runInlineAndCollect(jsons, handleCmds) {
187
206
  let msgId = cmdJson.meta.msgId;
188
207
  let match = rejectedResults[msgId];
189
208
  if (match !== undefined) {
190
- let errorDetail = match.errorDetail;
209
+ let match$1 = match[0];
210
+ let errorDetail = match$1.errorDetail;
191
211
  let detail = errorDetail === "" ? undefined : errorDetail;
192
212
  return {
193
213
  TAG: "Rejected",
194
214
  msgId: msgId,
195
- errorCode: match.errorCode,
196
- errorDetail: detail
215
+ errorCode: match$1.errorCode,
216
+ errorDetail: detail,
217
+ cause: match[1]
197
218
  };
198
219
  }
199
- let match$1 = results[i];
200
- if (match$1 !== undefined && match$1.TAG !== "Ok") {
220
+ let match$2 = results[i];
221
+ if (match$2 !== undefined && match$2.TAG !== "Ok") {
201
222
  return {
202
223
  TAG: "Rejected",
203
224
  msgId: msgId,
204
- errorCode: "Conflict",
205
- errorDetail: match$1._0
225
+ errorCode: CommandOutcome$Reventless.code("Conflict"),
226
+ errorDetail: match$2._0,
227
+ cause: "InfrastructureFailure"
206
228
  };
207
229
  }
208
230
  let ar = Stdlib_Option.getOr(acceptedResults[msgId], {
@@ -249,6 +271,7 @@ function getHandlers(typeName) {
249
271
  }
250
272
 
251
273
  export {
274
+ refusalCauseToGraphQL,
252
275
  commandOutcomeHook,
253
276
  registerCommandOutcome,
254
277
  clearCommandOutcome,
@@ -19,6 +19,17 @@ type auditRow = {
19
19
  door?: ReventlessInfra.InboundTranslationSlice.door,
20
20
  }
21
21
 
22
+ /** A refused caller is access, a failed publish the platform; anything else is
23
+ the translation refusing its input. */
24
+ let rejectionCauseOf = (errorCode: string): CommandTopic_Helpers.refusalCause =>
25
+ if errorCode == Reventless.CommandOutcome.code(Forbidden) {
26
+ AccessRefusal
27
+ } else if errorCode == Reventless.CommandOutcome.code(PublishFailed) {
28
+ InfrastructureFailure
29
+ } else {
30
+ DomainRejection
31
+ }
32
+
22
33
  /** The `CommandResult` a `receive` outcome answers with: the first target as
23
34
  `entityId` (omitted when nothing was produced), the fan-out as `eventCount`. */
24
35
  let receiveResultToOutcome = (
@@ -31,10 +42,12 @@ let receiveResultToOutcome = (
31
42
  | None => Accepted({msgId: requestId, eventCount: commandCount})
32
43
  }
33
44
  | Error({requestId, error, ?errorCode}) =>
45
+ let errorCode = errorCode->Option.getOr(Reventless.CommandOutcome.code(TranslationFailed))
34
46
  Rejected({
35
47
  msgId: requestId,
36
- errorCode: errorCode->Option.getOr("TranslationFailed"),
48
+ errorCode,
37
49
  errorDetail: Some(error),
50
+ cause: rejectionCauseOf(errorCode),
38
51
  })
39
52
  }
40
53
 
@@ -216,7 +229,7 @@ module Make = (
216
229
  )
217
230
  ) =>
218
231
  fail(
219
- ~errorCode="Forbidden",
232
+ ~errorCode=Reventless.CommandOutcome.code(Forbidden),
220
233
  `${Spec.name}: the caller is not authorized for every command this input translates into`,
221
234
  )
222
235
  | Ok([]) => succeed([])
@@ -225,7 +238,11 @@ module Make = (
225
238
  await publishJsons(msgs)
226
239
  succeed(pairs->Array.map(((targetId, _)) => targetId))
227
240
  } catch {
228
- | exn => fail(~errorCode="PublishFailed", messageOf(exn, "publish failed"))
241
+ | exn =>
242
+ fail(
243
+ ~errorCode=Reventless.CommandOutcome.code(PublishFailed),
244
+ messageOf(exn, "publish failed"),
245
+ )
229
246
  }
230
247
  }
231
248
  }
@@ -16,6 +16,7 @@ import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/U
16
16
  import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
17
17
  import * as Message$ReventlessCore from "../../Message.res.mjs";
18
18
  import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
19
+ import * as CommandOutcome$Reventless from "@reventlessdev/reventless-spec/src/components/CommandOutcome.res.mjs";
19
20
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
20
21
  import * as ApiNoApiHelpers$ReventlessCore from "../Api/ApiNoApiHelpers.res.mjs";
21
22
  import * as InboundTranslationSlice$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/InboundTranslationSlice.res.mjs";
@@ -36,6 +37,16 @@ let auditRowSchema = Sury.$schema(s => ({
36
37
  door: s.m(Sury.$option(InboundTranslationSlice$ReventlessInfra.doorSchema))
37
38
  }));
38
39
 
40
+ function rejectionCauseOf(errorCode) {
41
+ if (errorCode === CommandOutcome$Reventless.code("Forbidden")) {
42
+ return "AccessRefusal";
43
+ } else if (errorCode === CommandOutcome$Reventless.code("PublishFailed")) {
44
+ return "InfrastructureFailure";
45
+ } else {
46
+ return "DomainRejection";
47
+ }
48
+ }
49
+
39
50
  function receiveResultToOutcome(result) {
40
51
  if (result.TAG === "Ok") {
41
52
  let match = result._0;
@@ -58,11 +69,13 @@ function receiveResultToOutcome(result) {
58
69
  }
59
70
  }
60
71
  let match$1 = result._0;
72
+ let errorCode = Stdlib_Option.getOr(match$1.errorCode, CommandOutcome$Reventless.code("TranslationFailed"));
61
73
  return {
62
74
  TAG: "Rejected",
63
75
  msgId: match$1.requestId,
64
- errorCode: Stdlib_Option.getOr(match$1.errorCode, "TranslationFailed"),
65
- errorDetail: match$1.error
76
+ errorCode: errorCode,
77
+ errorDetail: match$1.error,
78
+ cause: rejectionCauseOf(errorCode)
66
79
  };
67
80
  }
68
81
 
@@ -213,7 +226,7 @@ function Make(Spec) {
213
226
  }
214
227
  let msgs = msg$1._0;
215
228
  if (msgs.some(param => !callerAdmits(Authorization$Reventless.named(Spec.authorizationOf(Message$Reventless.variantNameOfJson(param.commandJson))), caller))) {
216
- return fail("Forbidden", Spec.name + `: the caller is not authorized for every command this input translates into`);
229
+ return fail(CommandOutcome$Reventless.code("Forbidden"), Spec.name + `: the caller is not authorized for every command this input translates into`);
217
230
  }
218
231
  if (msgs.length === 0) {
219
232
  return succeed([]);
@@ -223,7 +236,7 @@ function Make(Spec) {
223
236
  return succeed(pairs.map(param => param[0]));
224
237
  } catch (raw_exn$1) {
225
238
  let exn$1 = Primitive_exceptions.internalToException(raw_exn$1);
226
- return fail("PublishFailed", messageOf(exn$1, "publish failed"));
239
+ return fail(CommandOutcome$Reventless.code("PublishFailed"), messageOf(exn$1, "publish failed"));
227
240
  }
228
241
  };
229
242
  let refuse = (body, errorCode, error) => failWith(Uuid.v4(), body, "Webhook", errorCode, error);
@@ -241,6 +254,7 @@ function Make(Spec) {
241
254
  export {
242
255
  auditStatusSchema,
243
256
  auditRowSchema,
257
+ rejectionCauseOf,
244
258
  receiveResultToOutcome,
245
259
  requestIdOf,
246
260
  takeAuditRow,
@@ -478,10 +478,10 @@ module Make = (
478
478
  // which disagreed with the backends' casing).
479
479
  let (errorCode, errDetail) = switch err {
480
480
  | ReventlessInfra.DcbEventLog.Conflict => (
481
- "Conflict",
481
+ Reventless.CommandOutcome.code(Conflict),
482
482
  "conflict: condition check failed",
483
483
  )
484
- | StorageFailure(msg) => ("AppendFailed", msg)
484
+ | StorageFailure(msg) => (Reventless.CommandOutcome.code(AppendFailed), msg)
485
485
  }
486
486
  if retries > 0 {
487
487
  // Re-seed from the just-read snapshot so the retry reads only
@@ -504,7 +504,7 @@ module Make = (
504
504
  // A surfaced Conflict means the 3-retry loop could not absorb
505
505
  // the contention — the operator signal for "this slice is hot"
506
506
  // (consider sharding / async — Issue 10, §4 of the analysis).
507
- if errorCode == "Conflict" {
507
+ if errorCode == Reventless.CommandOutcome.code(Conflict) {
508
508
  Metrics.emitCount(~metric="AppendConflict", ~slice=Spec.name)
509
509
  }
510
510
  // Both arms here are storage, not the model: a surfaced Conflict is
@@ -17,6 +17,7 @@ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types
17
17
  import * as Message$ReventlessCore from "../../Message.res.mjs";
18
18
  import * as Metrics$ReventlessCore from "../../util/Metrics.res.mjs";
19
19
  import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
20
+ import * as CommandOutcome$Reventless from "@reventlessdev/reventless-spec/src/components/CommandOutcome.res.mjs";
20
21
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
21
22
  import * as CommandTopic_Helpers$ReventlessCore from "../CommandTopic/CommandTopic_Helpers.res.mjs";
22
23
 
@@ -311,10 +312,10 @@ function Make(Spec) {
311
312
  let err = appendResult._0;
312
313
  let match;
313
314
  match = typeof err !== "object" ? [
314
- "Conflict",
315
+ CommandOutcome$Reventless.code("Conflict"),
315
316
  "conflict: condition check failed"
316
317
  ] : [
317
- "AppendFailed",
318
+ CommandOutcome$Reventless.code("AppendFailed"),
318
319
  err._0
319
320
  ];
320
321
  let errDetail = match[1];
@@ -329,7 +330,7 @@ function Make(Spec) {
329
330
  return Effect.flatMap(EffectLogger$ReventlessCore.logWarn(comp, undefined, `append failed (retrying ` + ((3 - retries | 0) + 1 | 0).toString() + `/` + (3).toString() + `): ` + errDetail), () => attempt(retries - 1 | 0));
330
331
  } else {
331
332
  cacheInvalidate();
332
- if (errorCode === "Conflict") {
333
+ if (errorCode === CommandOutcome$Reventless.code("Conflict")) {
333
334
  Metrics$ReventlessCore.emitCount("AppendConflict", Spec.name, undefined);
334
335
  }
335
336
  CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "InfrastructureFailure", cmdJson.meta.msgId, {
@@ -14,13 +14,18 @@ let _ = beforeEach(() => {
14
14
 
15
15
  // Captures whatever flows through CommandTopic_Helpers.reportRejected during a test.
16
16
  let capturedRejections: ref<array<(string, CommandTopic_Helpers.rejectedResult)>> = ref([])
17
+ let capturedCauses: ref<array<CommandTopic_Helpers.refusalCause>> = ref([])
17
18
  let capturedAccepts: ref<array<(string, CommandTopic_Helpers.acceptedResult)>> = ref([])
18
19
 
19
20
  let installChannels = () => {
20
21
  capturedRejections := []
22
+ capturedCauses := []
21
23
  capturedAccepts := []
22
24
  CommandTopic_Helpers.rejectedResultChannel.contents = Some(
23
- (ref, info) => capturedRejections := capturedRejections.contents->Array.concat([(ref, info)]),
25
+ (ref, info, cause) => {
26
+ capturedRejections := capturedRejections.contents->Array.concat([(ref, info)])
27
+ capturedCauses := capturedCauses.contents->Array.concat([cause])
28
+ },
24
29
  )
25
30
  CommandTopic_Helpers.acceptedResultChannel.contents = Some(
26
31
  (ref, info) => capturedAccepts := capturedAccepts.contents->Array.concat([(ref, info)]),
@@ -58,6 +63,8 @@ describe("Aggregate decide-rejection propagation:", () => {
58
63
  let (ref, info) = capturedRejections.contents->Array.getUnsafe(0)
59
64
  expect(ref)->toEqual("ref-1")
60
65
  expect(info.errorCode)->toEqual("AlreadyExists")
66
+ // `decide` said no: the model, not the platform.
67
+ expect(capturedCauses.contents)->toEqual([CommandTopic_Helpers.DomainRejection])
61
68
  // No event was appended.
62
69
  expect(mock.getAll()->Array.length)->toBe(1)
63
70
  },
@@ -14,18 +14,24 @@ let capturedRejections = {
14
14
  contents: []
15
15
  };
16
16
 
17
+ let capturedCauses = {
18
+ contents: []
19
+ };
20
+
17
21
  let capturedAccepts = {
18
22
  contents: []
19
23
  };
20
24
 
21
25
  function installChannels() {
22
26
  capturedRejections.contents = [];
27
+ capturedCauses.contents = [];
23
28
  capturedAccepts.contents = [];
24
- CommandTopic_Helpers$ReventlessCore.rejectedResultChannel.contents = (ref, info) => {
29
+ CommandTopic_Helpers$ReventlessCore.rejectedResultChannel.contents = (ref, info, cause) => {
25
30
  capturedRejections.contents = capturedRejections.contents.concat([[
26
31
  ref,
27
32
  info
28
33
  ]]);
34
+ capturedCauses.contents = capturedCauses.contents.concat([cause]);
29
35
  };
30
36
  CommandTopic_Helpers$ReventlessCore.acceptedResultChannel.contents = (ref, info) => {
31
37
  capturedAccepts.contents = capturedAccepts.contents.concat([[
@@ -61,6 +67,7 @@ globalThis.describe("Aggregate decide-rejection propagation:", () => {
61
67
  let match = capturedRejections.contents[0];
62
68
  globalThis.expect(match[0]).toEqual("ref-1");
63
69
  globalThis.expect(match[1].errorCode).toEqual("AlreadyExists");
70
+ globalThis.expect(capturedCauses.contents).toEqual(["DomainRejection"]);
64
71
  globalThis.expect(AggregateFixtures$ReventlessCore.mock.getAll().length).toBe(1);
65
72
  });
66
73
  });
@@ -159,6 +166,7 @@ globalThis.describe("Aggregate decide-rejection propagation:", () => {
159
166
 
160
167
  export {
161
168
  capturedRejections,
169
+ capturedCauses,
162
170
  capturedAccepts,
163
171
  installChannels,
164
172
  uninstallChannels,
@@ -50,10 +50,11 @@ describe("CommandTopic_Helpers.runInlineAndCollect:", () => {
50
50
  let outcomes = await CommandTopic_Helpers.runInlineAndCollect([cmdJson("msg-1")], handler)
51
51
  expect(outcomes->Array.length)->toBe(1)
52
52
  switch outcomes->Array.getUnsafe(0) {
53
- | Rejected({msgId, errorCode, errorDetail}) =>
53
+ | Rejected({msgId, errorCode, errorDetail, cause}) =>
54
54
  expect(msgId)->toEqual("msg-1")
55
55
  expect(errorCode)->toEqual("AlreadyExists")
56
56
  expect(errorDetail)->toEqual(None)
57
+ expect(cause)->toEqual(CommandTopic_Helpers.DomainRejection)
57
58
  | Accepted(_) | Pending(_) => expect("expected Rejected")->toEqual("got other")
58
59
  }
59
60
  },
@@ -99,8 +100,59 @@ describe("CommandTopic_Helpers.runInlineAndCollect:", () => {
99
100
 
100
101
  let outcomes = await CommandTopic_Helpers.runInlineAndCollect([cmdJson("msg-1")], handler)
101
102
  switch outcomes->Array.getUnsafe(0) {
102
- | Rejected({errorCode}) => expect(errorCode)->toEqual("Conflict")
103
+ | Rejected({errorCode, cause}) =>
104
+ expect(errorCode)->toEqual("Conflict")
105
+ expect(cause)->toEqual(CommandTopic_Helpers.InfrastructureFailure)
103
106
  | _ => expect("expected Rejected(Conflict)")->toEqual("got other")
104
107
  }
105
108
  })
109
+
110
+ testPromise("a reported access refusal keeps its cause", async () => {
111
+ let handler: CommandTopic_Helpers.jsonCommandsHandler = stream =>
112
+ stream
113
+ ->Stream.runCollect
114
+ ->Effect.map(
115
+ items => {
116
+ items->Array.forEach(
117
+ item =>
118
+ CommandTopic_Helpers.reportRejected(
119
+ ~component="TestService",
120
+ ~cause=AccessRefusal,
121
+ item.reference,
122
+ {errorCode: "Forbidden", errorDetail: "not the owner"},
123
+ ),
124
+ )
125
+ items->Array.map(item => Ok(item.reference))
126
+ },
127
+ )
128
+
129
+ let outcomes = await CommandTopic_Helpers.runInlineAndCollect([cmdJson("msg-1")], handler)
130
+ switch outcomes->Array.getUnsafe(0) {
131
+ | Rejected({errorCode, cause}) =>
132
+ expect(errorCode)->toEqual("Forbidden")
133
+ expect(cause)->toEqual(CommandTopic_Helpers.AccessRefusal)
134
+ | _ => expect("expected Rejected(Forbidden)")->toEqual("got other")
135
+ }
136
+ })
137
+ })
138
+
139
+ describe("CommandTopic_Helpers.commandOutcomeToJson:", () => {
140
+ let causeOf = (cause: CommandTopic_Helpers.refusalCause) =>
141
+ CommandTopic_Helpers.commandOutcomeToJson(
142
+ Rejected({msgId: "m", errorCode: "X", errorDetail: None, cause}),
143
+ )
144
+ ->JSON.Decode.object
145
+ ->Option.flatMap(o => o->Dict.get("cause"))
146
+ ->Option.flatMap(JSON.Decode.string)
147
+
148
+ testSync("a rejection carries its cause as the GraphQL enum value", () => {
149
+ expect(causeOf(DomainRejection))->toEqual(Some("DOMAIN"))
150
+ expect(causeOf(AccessRefusal))->toEqual(Some("ACCESS"))
151
+ expect(causeOf(InfrastructureFailure))->toEqual(Some("INFRASTRUCTURE"))
152
+ })
153
+
154
+ testSync("an accepted outcome carries no cause", () => {
155
+ let json = CommandTopic_Helpers.commandOutcomeToJson(Accepted({msgId: "m", eventCount: 1}))
156
+ expect(json->JSON.Decode.object->Option.flatMap(o => o->Dict.get("cause")))->toEqual(None)
157
+ })
106
158
  })
@@ -1,6 +1,8 @@
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_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
6
  import * as Effect from "effect/Effect";
5
7
  import * as CommandTopic_Helpers$ReventlessCore from "../../src/components/CommandTopic/CommandTopic_Helpers.res.mjs";
6
8
 
@@ -52,6 +54,7 @@ globalThis.describe("CommandTopic_Helpers.runInlineAndCollect:", () => {
52
54
  globalThis.expect(match.msgId).toEqual("msg-1");
53
55
  globalThis.expect(match.errorCode).toEqual("AlreadyExists");
54
56
  globalThis.expect(match.errorDetail).toEqual(undefined);
57
+ globalThis.expect(match.cause).toEqual("DomainRejection");
55
58
  return;
56
59
  case "Accepted" :
57
60
  case "Pending" :
@@ -93,6 +96,7 @@ globalThis.describe("CommandTopic_Helpers.runInlineAndCollect:", () => {
93
96
  switch (match.TAG) {
94
97
  case "Rejected" :
95
98
  globalThis.expect(match.errorCode).toEqual("Conflict");
99
+ globalThis.expect(match.cause).toEqual("InfrastructureFailure");
96
100
  return;
97
101
  case "Accepted" :
98
102
  case "Pending" :
@@ -100,6 +104,53 @@ globalThis.describe("CommandTopic_Helpers.runInlineAndCollect:", () => {
100
104
  return;
101
105
  }
102
106
  });
107
+ globalThis.test("a reported access refusal keeps its cause", async () => {
108
+ let handler = stream => Effect.map(Stream.runCollect(stream), items => {
109
+ items.forEach(item => CommandTopic_Helpers$ReventlessCore.reportRejected("TestService", "AccessRefusal", item.reference, {
110
+ errorCode: "Forbidden",
111
+ errorDetail: "not the owner"
112
+ }));
113
+ return items.map(item => ({
114
+ TAG: "Ok",
115
+ _0: item.reference
116
+ }));
117
+ });
118
+ let outcomes = await CommandTopic_Helpers$ReventlessCore.runInlineAndCollect([cmdJson("msg-1")], handler);
119
+ let match = outcomes[0];
120
+ switch (match.TAG) {
121
+ case "Rejected" :
122
+ globalThis.expect(match.errorCode).toEqual("Forbidden");
123
+ globalThis.expect(match.cause).toEqual("AccessRefusal");
124
+ return;
125
+ case "Accepted" :
126
+ case "Pending" :
127
+ globalThis.expect("expected Rejected(Forbidden)").toEqual("got other");
128
+ return;
129
+ }
130
+ });
131
+ });
132
+
133
+ globalThis.describe("CommandTopic_Helpers.commandOutcomeToJson:", () => {
134
+ let causeOf = cause => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(CommandTopic_Helpers$ReventlessCore.commandOutcomeToJson({
135
+ TAG: "Rejected",
136
+ msgId: "m",
137
+ errorCode: "X",
138
+ errorDetail: undefined,
139
+ cause: cause
140
+ })), o => o["cause"]), Stdlib_JSON.Decode.string);
141
+ globalThis.test("a rejection carries its cause as the GraphQL enum value", () => {
142
+ globalThis.expect(causeOf("DomainRejection")).toEqual("DOMAIN");
143
+ globalThis.expect(causeOf("AccessRefusal")).toEqual("ACCESS");
144
+ globalThis.expect(causeOf("InfrastructureFailure")).toEqual("INFRASTRUCTURE");
145
+ });
146
+ globalThis.test("an accepted outcome carries no cause", () => {
147
+ let json = CommandTopic_Helpers$ReventlessCore.commandOutcomeToJson({
148
+ TAG: "Accepted",
149
+ msgId: "m",
150
+ eventCount: 1
151
+ });
152
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => o["cause"])).toEqual(undefined);
153
+ });
103
154
  });
104
155
 
105
156
  export {