@reventlessdev/reventless-core 3.0.0-alpha.228 → 3.0.0-alpha.229

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,14 @@
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.229 (2026-08-12)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **command:** drop null arguments, and let a caller read why a payload was refused ([bfee0dc](https://github.com/ReventlessDev/reventless-core/commit/bfee0dc37755596ea8bcce1fe2cccdace7759f10))
11
+ * **schema:** stop publishing a nested record's optional field as required ([44ed433](https://github.com/ReventlessDev/reventless-core/commit/44ed433fd40fc54cee41d7ab50aa7aafec851a2a))
12
+
13
+
6
14
  # 3.0.0-alpha.228 (2026-08-12)
7
15
 
8
16
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.228",
3
+ "version": "3.0.0-alpha.229",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -28,13 +28,13 @@
28
28
  "dependencies": {
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
31
32
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.4",
32
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
33
33
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
34
34
  "@reventlessdev/rescript-node": "2.0.0-alpha.4",
35
- "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
36
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
35
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
37
36
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.4",
37
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
38
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
39
39
  "@reventlessdev/reventless-infra": "3.0.0-alpha.137",
40
40
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
@@ -116,6 +116,19 @@ let mergeAnnotations = (
116
116
 
117
117
  // ── SchemaType → JSON Schema ─────────────────────────────────────────────
118
118
 
119
+ // Whether a field's own type says it may be absent. Read through a semantic,
120
+ // which wraps the shape rather than replacing it — an optional storage ref is
121
+ // `Semantic(storageRef, Nullable(string))`.
122
+ //
123
+ // This is the half of "is it required" that `optional` cannot answer, and vice
124
+ // versa. See `objectRefToJsonSchema`, which uses both.
125
+ let rec isNullableType = (st: SchemaType.schemaType): bool =>
126
+ switch st {
127
+ | Nullable(_) => true
128
+ | Semantic(_, inner) => isNullableType(inner)
129
+ | _ => false
130
+ }
131
+
119
132
  let rec fromSchemaType = (st: SchemaType.schemaType): JSON.t =>
120
133
  switch st {
121
134
  | ScalarString => jsonObject([("type", str("string"))])
@@ -222,7 +235,16 @@ and objectRefToJsonSchema = (
222
235
  withAnnotations
223
236
  }
224
237
  props->Dict.set(fieldName, withAnnotations)
225
- if !(optional->Array.includes(fieldName)) {
238
+ // Optional two ways, because neither source answers alone. `optional` is
239
+ // read off the sury schema and is the only thing that can speak for a
240
+ // reference or a tagged field, which classify as `EntityId` before their
241
+ // nullable wrapper is ever examined. But it is computed for the schema
242
+ // handed to `deriveObjectSchema` and there is no equivalent one level in,
243
+ // so a nested record's own optional field had nothing saying so and came
244
+ // out *required* — a generated form refusing to submit without a field the
245
+ // domain never asked for, which is the failure the note above describes,
246
+ // one level down. The field's own type answers there.
247
+ if !(optional->Array.includes(fieldName)) && !isNullableType(fieldType) {
226
248
  required->Array.push(fieldName)
227
249
  }
228
250
  })
@@ -106,6 +106,24 @@ function mergeAnnotations(fieldSchema, fieldName, spec) {
106
106
  return obj;
107
107
  }
108
108
 
109
+ function isNullableType(_st) {
110
+ while (true) {
111
+ let st = _st;
112
+ if (typeof st !== "object") {
113
+ return false;
114
+ }
115
+ switch (st.TAG) {
116
+ case "Nullable" :
117
+ return true;
118
+ case "Semantic" :
119
+ _st = st._1;
120
+ continue;
121
+ default:
122
+ return false;
123
+ }
124
+ };
125
+ }
126
+
109
127
  function fromSchemaType(st) {
110
128
  if (typeof st !== "object") {
111
129
  switch (st) {
@@ -229,8 +247,9 @@ function objectRefToJsonSchema(annotations, optionalOpt, ownersOpt, fields) {
229
247
  let props = {};
230
248
  let required = [];
231
249
  Object.entries(fields).forEach(param => {
250
+ let fieldType = param[1];
232
251
  let fieldName = param[0];
233
- let baseSchema = fromSchemaType(param[1]);
252
+ let baseSchema = fromSchemaType(fieldType);
234
253
  let withAnnotations = annotations !== undefined ? mergeAnnotations(baseSchema, fieldName, annotations) : baseSchema;
235
254
  let withAnnotations$1;
236
255
  if (owners.includes(fieldName)) {
@@ -245,7 +264,7 @@ function objectRefToJsonSchema(annotations, optionalOpt, ownersOpt, fields) {
245
264
  withAnnotations$1 = withAnnotations;
246
265
  }
247
266
  props[fieldName] = withAnnotations$1;
248
- if (!optional.includes(fieldName)) {
267
+ if (!optional.includes(fieldName) && !isNullableType(fieldType)) {
249
268
  required.push(fieldName);
250
269
  return;
251
270
  }
@@ -311,6 +330,7 @@ export {
311
330
  jsonObject,
312
331
  withOptionalPlugin,
313
332
  mergeAnnotations,
333
+ isNullableType,
314
334
  fromSchemaType,
315
335
  withSemantic,
316
336
  objectRefToJsonSchema,
@@ -66,12 +66,45 @@ let stampOwnerFields = (
66
66
  | Owned({userId}) =>
67
67
  ownerFields->Array.forEach(field => obj->Dict.set(field, JSON.Encode.string(userId)))
68
68
  | Unidentified(why) =>
69
- JsError.throwWithMessage(
69
+ Plugin_ResolverError.throwCallerFault(
70
70
  `Forbidden: ${serviceName}.${command} records an owner, but the caller could not be identified (${why})`,
71
71
  )
72
72
  }
73
73
  }
74
74
 
75
+ /**
76
+ Drop the arguments a caller explicitly sent as null.
77
+
78
+ A transport hands over exactly the arguments the caller supplied, and GraphQL
79
+ lets a caller supply null for any nullable argument. A command cannot represent
80
+ that: sury compiles both spellings of an optional field — `field?: t` and
81
+ `option<t>` — to `T | undefined`, so an explicit null fails to decode where
82
+ leaving the argument out succeeds. Two ways of saying the same thing, one of
83
+ them accepted.
84
+
85
+ Nothing is lost by collapsing them. No generated argument can *hold* a JSON
86
+ null either — `GraphQL_FragmentGenerator` maps every schema type to a scalar, a
87
+ generated enum, an input object or a list — so a null argument carries no
88
+ information beyond "not supplied", which is what its absence says.
89
+
90
+ Shallow on purpose. A null *inside* an input object is a malformed value rather
91
+ than an omission, and the one payload that can legitimately carry nulls is the
92
+ permissive `S.json` direct-invocation schema, where they are the caller's data
93
+ and not arguments at all.
94
+
95
+ Here rather than in a resolver: this is the one place every transport's payload
96
+ passes through, for the reason `stampOwnerFields` gives just above.
97
+ */
98
+ let dropNullArguments = (obj: dict<JSON.t>) =>
99
+ obj
100
+ ->Dict.keysToArray
101
+ ->Array.forEach(key =>
102
+ switch obj->Dict.get(key) {
103
+ | Some(JSON.Null) => obj->Dict.delete(key)
104
+ | _ => ()
105
+ }
106
+ )
107
+
75
108
  let makeGenerateCommand = (
76
109
  ~publishJsons: CommandGenerator.publishJsons,
77
110
  ~publishJsonsAndWait: option<CommandTopic.publishJsonsAndWait>=?,
@@ -100,6 +133,7 @@ let makeGenerateCommand = (
100
133
  if stripIdFromParams {
101
134
  obj->Dict.delete("id")
102
135
  }
136
+ obj->dropNullArguments
103
137
  obj->stampOwnerFields(
104
138
  ~commandSchema,
105
139
  ~command=payload.command,
@@ -216,10 +250,16 @@ let makeGenerateCommand = (
216
250
  })
217
251
  | None => ()
218
252
  }
219
- JsError.throwWithMessage(
220
- `Error: Couldn't decode ${commandJson->JSON.stringify}: ${err
221
- ->JSON.stringifyAny
222
- ->Option.getOrThrow}`,
253
+ // sury's own message says which field and why ("Failed parsing at
254
+ // [\"deliveryWindow\"]: Expected … received null"). Serialising the
255
+ // error object instead produced its internal representation, which is
256
+ // now the caller's to read and was never the more informative of the two.
257
+ let reason = switch err->JsExn.fromException->Option.flatMap(JsExn.message) {
258
+ | Some(message) => message
259
+ | None => err->JSON.stringifyAny->Option.getOr("unknown error")
260
+ }
261
+ Plugin_ResolverError.throwCallerFault(
262
+ `Error: Couldn't decode ${commandJson->JSON.stringify}: ${reason}`,
223
263
  )
224
264
  }
225
265
  })
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
4
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
7
  import * as Effect from "effect/Effect";
7
8
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
@@ -45,10 +46,19 @@ function stampOwnerFields(obj, commandSchema, command, identity, serviceName) {
45
46
  });
46
47
  return;
47
48
  case "Unidentified" :
48
- return Stdlib_JsError.throwWithMessage(`Forbidden: ` + serviceName + `.` + command + ` records an owner, but the caller could not be identified (` + why._0 + `)`);
49
+ return Plugin_ResolverError$ReventlessCore.throwCallerFault(`Forbidden: ` + serviceName + `.` + command + ` records an owner, but the caller could not be identified (` + why._0 + `)`);
49
50
  }
50
51
  }
51
52
 
53
+ function dropNullArguments(obj) {
54
+ Object.keys(obj).forEach(key => {
55
+ let match = obj[key];
56
+ if (match === null) {
57
+ return Stdlib_Dict.$$delete(obj, key);
58
+ }
59
+ });
60
+ }
61
+
52
62
  function makeGenerateCommand(publishJsons, publishJsonsAndWait, serviceName, commandSchema, componentKind, $staropt$star) {
53
63
  return payload => {
54
64
  let stripIdFromParams = $staropt$star !== undefined ? $staropt$star : true;
@@ -66,7 +76,7 @@ function makeGenerateCommand(publishJsons, publishJsonsAndWait, serviceName, com
66
76
  correlationId: msgId
67
77
  };
68
78
  let obj = Stdlib_Option.flatMap(JSON.stringify(payload.arguments), jsonString => Stdlib_JSON.Decode.object(JSON.parse(jsonString)));
69
- let params = obj !== undefined ? (stripIdFromParams ? Stdlib_Dict.$$delete(obj, "id") : undefined, stampOwnerFields(obj, commandSchema, payload.command, payload.identity, serviceName), Object.entries(obj)) : Stdlib_JsError.throwWithMessage("Couldn't decode:" + Stdlib_Option.getOr(JSON.stringify(payload.arguments), "<payload.arguments>"));
79
+ let params = obj !== undefined ? (stripIdFromParams ? Stdlib_Dict.$$delete(obj, "id") : undefined, dropNullArguments(obj), stampOwnerFields(obj, commandSchema, payload.command, payload.identity, serviceName), Object.entries(obj)) : Stdlib_JsError.throwWithMessage("Couldn't decode:" + Stdlib_Option.getOr(JSON.stringify(payload.arguments), "<payload.arguments>"));
70
80
  let commandStr = payload.command;
71
81
  let match = params.length;
72
82
  let commandJson = match !== 0 ? Object.fromEntries([[
@@ -123,7 +133,9 @@ function makeGenerateCommand(publishJsons, publishJsonsAndWait, serviceName, com
123
133
  timestamp: Message$ReventlessCore.nowAsISOString()
124
134
  });
125
135
  }
126
- return Stdlib_JsError.throwWithMessage(`Error: Couldn't decode ` + JSON.stringify(commandJson) + `: ` + Stdlib_Option.getOrThrow(JSON.stringify(err), undefined));
136
+ let message = Stdlib_Option.flatMap(Stdlib_JsExn.fromException(err), Stdlib_JsExn.message);
137
+ let reason = message !== undefined ? message : Stdlib_Option.getOr(JSON.stringify(err), "unknown error");
138
+ return Plugin_ResolverError$ReventlessCore.throwCallerFault(`Error: Couldn't decode ` + JSON.stringify(commandJson) + `: ` + reason);
127
139
  }
128
140
  let interceptor = commandInterceptorHook.contents;
129
141
  let interceptEffect = interceptor !== undefined ? Effect.promise(() => interceptor(payload.identity, serviceName, componentKind, payload.command, payload.arguments)) : Effect.succeed("Allow");
@@ -181,7 +193,8 @@ export {
181
193
  registerCommandInterceptor,
182
194
  clearCommandInterceptor,
183
195
  stampOwnerFields,
196
+ dropNullArguments,
184
197
  makeGenerateCommand,
185
198
  Make,
186
199
  }
187
- /* effect/Effect Not a pure module */
200
+ /* Stdlib_JsExn Not a pure module */
@@ -25,3 +25,44 @@ let registerOnResolverError = (hook: resolverErrorInfo => unit) => {
25
25
  let clearOnResolverError = () => {
26
26
  onResolverErrorHook.contents = None
27
27
  }
28
+
29
+ // ── Caller-fault errors ──────────────────────────────────────────────────────
30
+ //
31
+ // A failure a transport may report to the caller verbatim, because it describes
32
+ // the caller's own request: a payload that does not decode against the command
33
+ // schema, a caller who cannot be identified for a command that records an owner.
34
+ //
35
+ // It needs marking because the transports below deliberately hide everything
36
+ // else. graphql-yoga's `maskedErrors` answers `Unexpected error /
37
+ // INTERNAL_SERVER_ERROR` for any thrown value that is not a `GraphQLError`,
38
+ // which is right for a driver failure — an internal error is not the caller's
39
+ // business, and its message may not be theirs to read either. It is wrong for
40
+ // the two cases above: the server knows precisely what is wrong with the request
41
+ // and answers with the one thing the caller cannot act on.
42
+ //
43
+ // The mark rides on the error's `name` rather than a wrapper type, so it
44
+ // survives the `Effect` boundary, the `promise` boundary and the transports'
45
+ // `Obj.magic` interop unchanged, and an adapter that knows nothing about it
46
+ // keeps treating the error exactly as it does today.
47
+ let callerFaultName = "ReventlessCallerFault"
48
+
49
+ @set external setErrorName: (JsError.t, string) => unit = "name"
50
+
51
+ /** Throw a failure that describes the caller's own request. */
52
+ let throwCallerFault = (message: string): 'a => {
53
+ let error = JsError.make(message)
54
+ error->setErrorName(callerFaultName)
55
+ error->JsError.throw
56
+ }
57
+
58
+ /** Whether a caught failure is one a transport may report verbatim.
59
+
60
+ Matched as a substring because a command is generated inside an `Effect`, and
61
+ a failure crossing `runPromise` comes back re-wrapped with the original name
62
+ carried into the wrapper's — `(FiberFailure) ReventlessCallerFault`. The mark
63
+ is a name, not a message, so nothing else can put it there. */
64
+ let isCallerFault = (exn: exn): bool =>
65
+ exn
66
+ ->JsExn.fromException
67
+ ->Option.flatMap(JsExn.name)
68
+ ->Option.mapOr(false, name => name->String.includes(callerFaultName))
@@ -1,5 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
5
 
4
6
  let onResolverErrorHook = {
5
7
  contents: undefined
@@ -13,9 +15,24 @@ function clearOnResolverError() {
13
15
  onResolverErrorHook.contents = undefined;
14
16
  }
15
17
 
18
+ let callerFaultName = "ReventlessCallerFault";
19
+
20
+ function throwCallerFault(message) {
21
+ let error = new Error(message);
22
+ error.name = callerFaultName;
23
+ throw error;
24
+ }
25
+
26
+ function isCallerFault(exn) {
27
+ return Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.name), false, name => name.includes(callerFaultName));
28
+ }
29
+
16
30
  export {
17
31
  onResolverErrorHook,
18
32
  registerOnResolverError,
19
33
  clearOnResolverError,
34
+ callerFaultName,
35
+ throwCallerFault,
36
+ isCallerFault,
20
37
  }
21
- /* No side effect */
38
+ /* Stdlib_JsExn Not a pure module */
@@ -921,4 +921,40 @@ describe("SuryToJsonSchema:", () => {
921
921
  ))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("number"))))
922
922
  })
923
923
  })
924
+
925
+ // A nested record's own optional field. `optional` is computed for the schema
926
+ // handed to `deriveObjectSchema` and not threaded into `ObjectRef`, so a field
927
+ // one level in has only its nullable wrapper to say it may be absent — and the
928
+ // wrapper is exactly what a reader looking at `required` never sees.
929
+ describe("an optional field inside a nested record:", () => {
930
+ let inner = S.schema(s =>
931
+ {
932
+ "street": s.matches(S.string),
933
+ "unit": s.matches(S.option(S.string)),
934
+ }
935
+ )
936
+ let json = SuryToJsonSchema.deriveObjectSchema(
937
+ S.schema(s =>
938
+ {
939
+ "id": s.matches(S.string),
940
+ "address": s.matches(inner),
941
+ }
942
+ )->S.castToUnknown,
943
+ )
944
+ let nestedRequired =
945
+ getPropertyOf(json, "address")
946
+ ->Option.flatMap(a => getProperty(a, "required"))
947
+ ->Option.flatMap(JSON.Decode.array)
948
+ ->Option.getOr([])
949
+ ->Array.filterMap(JSON.Decode.string)
950
+
951
+ testSync("is not listed as required", () =>
952
+ expect(nestedRequired->Array.includes("unit"))->toBe(false)
953
+ )
954
+
955
+ testSync("while its non-optional sibling still is", () =>
956
+ expect(nestedRequired->Array.includes("street"))->toBe(true)
957
+ )
958
+ })
959
+
924
960
  })
@@ -1271,6 +1271,23 @@ globalThis.describe("SuryToJsonSchema:", () => {
1271
1271
  ]);
1272
1272
  });
1273
1273
  });
1274
+ globalThis.describe("an optional field inside a nested record:", () => {
1275
+ let inner = S.schema(s => ({
1276
+ street: s.m(S.string),
1277
+ unit: s.m(S.option(S.string))
1278
+ }));
1279
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(S.schema(s => ({
1280
+ id: s.m(S.string),
1281
+ address: s.m(inner)
1282
+ })));
1283
+ let nestedRequired = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(getPropertyOf(json, "address"), a => getProperty(a, "required")), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.string);
1284
+ globalThis.test("is not listed as required", () => {
1285
+ globalThis.expect(nestedRequired.includes("unit")).toBe(false);
1286
+ });
1287
+ globalThis.test("while its non-optional sibling still is", () => {
1288
+ globalThis.expect(nestedRequired.includes("street")).toBe(true);
1289
+ });
1290
+ });
1274
1291
  });
1275
1292
 
1276
1293
  export {
@@ -125,4 +125,108 @@ describe("CommandGenerator_Callback.generateCommand:", () => {
125
125
  expect(publishedCmd.id)->toBe("agg-7")
126
126
  })
127
127
  })
128
+
129
+ // A transport hands over exactly what the caller supplied, and GraphQL lets a
130
+ // caller supply null for any nullable argument. sury compiles an optional
131
+ // field to `T | undefined`, so an explicit null used to fail to decode where
132
+ // leaving the argument out succeeded — two ways of saying the same thing, one
133
+ // of them accepted.
134
+ describe("an argument sent as null", () => {
135
+ let storeItem = (~args) => makeSlicePayload(~command="StoreItem", ~args)
136
+ let data = JSON.Object(Dict.fromArray([("kind", JSON.String("blob"))]))
137
+ let fieldOf = (cmd: Message.commandJson, name) =>
138
+ cmd.commandJson->JSON.Decode.object->Option.flatMap(o => o->Dict.get(name))
139
+
140
+ testPromise("is dropped, and the command decodes and publishes", async () => {
141
+ let payload = storeItem(
142
+ ~args=Dict.fromArray([
143
+ ("itemId", JSON.Encode.string("item-1")),
144
+ ("data", data),
145
+ ("note", JSON.Null),
146
+ ]),
147
+ )
148
+ let _outcome = await optionalFieldSliceGen(payload)->Effect.runPromise
149
+ let publishedCmd = capturedCmds.contents->Array.getUnsafe(0)
150
+ expect((capturedCmds.contents->Array.length, publishedCmd->fieldOf("note")))
151
+ ->toEqual((1, None))
152
+ })
153
+
154
+ testPromise("carrying a value is left exactly as sent", async () => {
155
+ let payload = storeItem(
156
+ ~args=Dict.fromArray([
157
+ ("itemId", JSON.Encode.string("item-2")),
158
+ ("data", data),
159
+ ("note", JSON.Encode.string("gift wrap")),
160
+ ]),
161
+ )
162
+ let _outcome = await optionalFieldSliceGen(payload)->Effect.runPromise
163
+ expect(capturedCmds.contents->Array.getUnsafe(0)->fieldOf("note"))
164
+ ->toEqual(Some(JSON.Encode.string("gift wrap")))
165
+ })
166
+
167
+ // The control the drop is modelled on: absent and explicitly-null must
168
+ // reach the domain as the same command.
169
+ testPromise("and one simply omitted produce the same command", async () => {
170
+ let withNull = storeItem(
171
+ ~args=Dict.fromArray([
172
+ ("itemId", JSON.Encode.string("item-3")),
173
+ ("data", data),
174
+ ("note", JSON.Null),
175
+ ]),
176
+ )
177
+ let withoutKey = storeItem(
178
+ ~args=Dict.fromArray([("itemId", JSON.Encode.string("item-3")), ("data", data)]),
179
+ )
180
+ let _ = await optionalFieldSliceGen(withNull)->Effect.runPromise
181
+ let _ = await optionalFieldSliceGen(withoutKey)->Effect.runPromise
182
+ let published = capturedCmds.contents
183
+ expect((published->Array.getUnsafe(0)).commandJson)
184
+ ->toEqual((published->Array.getUnsafe(1)).commandJson)
185
+ })
186
+
187
+ // Shallow on purpose. A null one level in is part of a value the caller
188
+ // sent, not an argument they left out — `data` is opaque JSON and its own
189
+ // nulls are data.
190
+ testPromise("nested inside a value is left alone", async () => {
191
+ let nested = JSON.Object(Dict.fromArray([("kind", JSON.Null)]))
192
+ let payload = storeItem(
193
+ ~args=Dict.fromArray([("itemId", JSON.Encode.string("item-4")), ("data", nested)]),
194
+ )
195
+ let _outcome = await optionalFieldSliceGen(payload)->Effect.runPromise
196
+ expect(capturedCmds.contents->Array.getUnsafe(0)->fieldOf("data"))->toEqual(Some(nested))
197
+ })
198
+ })
199
+
200
+ // A payload that does not decode describes the caller's own request, so a
201
+ // transport is allowed to report it. Unmarked, it arrives as "Unexpected
202
+ // error" and is indistinguishable from a database outage.
203
+ describe("a payload that cannot be decoded", () => {
204
+ let undecodable = () =>
205
+ makeSlicePayload(
206
+ ~command="StoreItem",
207
+ ~args=Dict.fromArray([("itemId", JSON.Encode.string("item-9"))]),
208
+ )
209
+
210
+ testPromise("is refused rather than published", async () => {
211
+ let refused = switch await optionalFieldSliceGen(undecodable())->Effect.runPromise {
212
+ | _ => false
213
+ | exception _ => true
214
+ }
215
+ expect((refused, capturedCmds.contents->Array.length))->toEqual((true, 0))
216
+ })
217
+
218
+ testPromise("is marked as the caller's fault, naming the field", async () => {
219
+ let failure = switch await optionalFieldSliceGen(undecodable())->Effect.runPromise {
220
+ | _ => None
221
+ | exception e => Some(e)
222
+ }
223
+ let marked = failure->Option.mapOr(false, Plugin_ResolverError.isCallerFault)
224
+ let saysWhy =
225
+ failure
226
+ ->Option.flatMap(JsExn.fromException)
227
+ ->Option.flatMap(JsExn.message)
228
+ ->Option.mapOr(false, m => m->String.includes("data"))
229
+ expect((marked, saysWhy))->toEqual((true, true))
230
+ })
231
+ })
128
232
  })
@@ -1,8 +1,11 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
4
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
6
  import * as Effect from "effect/Effect";
7
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
8
+ import * as Plugin_ResolverError$ReventlessCore from "../../src/plugin/component/Plugin_ResolverError.res.mjs";
6
9
  import * as CommandGeneratorFixtures$ReventlessCore from "./CommandGeneratorFixtures.res.mjs";
7
10
 
8
11
  globalThis.beforeEach(() => CommandGeneratorFixtures$ReventlessCore.reset());
@@ -155,6 +158,144 @@ globalThis.describe("CommandGenerator_Callback.generateCommand:", () => {
155
158
  globalThis.expect(publishedCmd.id).toBe("agg-7");
156
159
  });
157
160
  });
161
+ globalThis.describe("an argument sent as null", () => {
162
+ let data = Object.fromEntries([[
163
+ "kind",
164
+ "blob"
165
+ ]]);
166
+ let fieldOf = (cmd, name) => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(cmd.commandJson), o => o[name]);
167
+ globalThis.test("is dropped, and the command decodes and publishes", async () => {
168
+ let payload = CommandGeneratorFixtures$ReventlessCore.makeSlicePayload("StoreItem", Object.fromEntries([
169
+ [
170
+ "itemId",
171
+ "item-1"
172
+ ],
173
+ [
174
+ "data",
175
+ data
176
+ ],
177
+ [
178
+ "note",
179
+ null
180
+ ]
181
+ ]));
182
+ await Effect.runPromise(CommandGeneratorFixtures$ReventlessCore.optionalFieldSliceGen(payload));
183
+ let publishedCmd = CommandGeneratorFixtures$ReventlessCore.capturedCmds.contents[0];
184
+ globalThis.expect([
185
+ CommandGeneratorFixtures$ReventlessCore.capturedCmds.contents.length,
186
+ fieldOf(publishedCmd, "note")
187
+ ]).toEqual([
188
+ 1,
189
+ undefined
190
+ ]);
191
+ });
192
+ globalThis.test("carrying a value is left exactly as sent", async () => {
193
+ let payload = CommandGeneratorFixtures$ReventlessCore.makeSlicePayload("StoreItem", Object.fromEntries([
194
+ [
195
+ "itemId",
196
+ "item-2"
197
+ ],
198
+ [
199
+ "data",
200
+ data
201
+ ],
202
+ [
203
+ "note",
204
+ "gift wrap"
205
+ ]
206
+ ]));
207
+ await Effect.runPromise(CommandGeneratorFixtures$ReventlessCore.optionalFieldSliceGen(payload));
208
+ globalThis.expect(fieldOf(CommandGeneratorFixtures$ReventlessCore.capturedCmds.contents[0], "note")).toEqual("gift wrap");
209
+ });
210
+ globalThis.test("and one simply omitted produce the same command", async () => {
211
+ let withNull = CommandGeneratorFixtures$ReventlessCore.makeSlicePayload("StoreItem", Object.fromEntries([
212
+ [
213
+ "itemId",
214
+ "item-3"
215
+ ],
216
+ [
217
+ "data",
218
+ data
219
+ ],
220
+ [
221
+ "note",
222
+ null
223
+ ]
224
+ ]));
225
+ let withoutKey = CommandGeneratorFixtures$ReventlessCore.makeSlicePayload("StoreItem", Object.fromEntries([
226
+ [
227
+ "itemId",
228
+ "item-3"
229
+ ],
230
+ [
231
+ "data",
232
+ data
233
+ ]
234
+ ]));
235
+ await Effect.runPromise(CommandGeneratorFixtures$ReventlessCore.optionalFieldSliceGen(withNull));
236
+ await Effect.runPromise(CommandGeneratorFixtures$ReventlessCore.optionalFieldSliceGen(withoutKey));
237
+ let published = CommandGeneratorFixtures$ReventlessCore.capturedCmds.contents;
238
+ globalThis.expect(published[0].commandJson).toEqual(published[1].commandJson);
239
+ });
240
+ globalThis.test("nested inside a value is left alone", async () => {
241
+ let nested = Object.fromEntries([[
242
+ "kind",
243
+ null
244
+ ]]);
245
+ let payload = CommandGeneratorFixtures$ReventlessCore.makeSlicePayload("StoreItem", Object.fromEntries([
246
+ [
247
+ "itemId",
248
+ "item-4"
249
+ ],
250
+ [
251
+ "data",
252
+ nested
253
+ ]
254
+ ]));
255
+ await Effect.runPromise(CommandGeneratorFixtures$ReventlessCore.optionalFieldSliceGen(payload));
256
+ globalThis.expect(fieldOf(CommandGeneratorFixtures$ReventlessCore.capturedCmds.contents[0], "data")).toEqual(nested);
257
+ });
258
+ });
259
+ globalThis.describe("a payload that cannot be decoded", () => {
260
+ let undecodable = () => CommandGeneratorFixtures$ReventlessCore.makeSlicePayload("StoreItem", Object.fromEntries([[
261
+ "itemId",
262
+ "item-9"
263
+ ]]));
264
+ globalThis.test("is refused rather than published", async () => {
265
+ let refused;
266
+ try {
267
+ await Effect.runPromise(CommandGeneratorFixtures$ReventlessCore.optionalFieldSliceGen(undecodable()));
268
+ refused = false;
269
+ } catch (exn) {
270
+ refused = true;
271
+ }
272
+ globalThis.expect([
273
+ refused,
274
+ CommandGeneratorFixtures$ReventlessCore.capturedCmds.contents.length
275
+ ]).toEqual([
276
+ true,
277
+ 0
278
+ ]);
279
+ });
280
+ globalThis.test("is marked as the caller's fault, naming the field", async () => {
281
+ let failure;
282
+ try {
283
+ await Effect.runPromise(CommandGeneratorFixtures$ReventlessCore.optionalFieldSliceGen(undecodable()));
284
+ failure = undefined;
285
+ } catch (raw_e) {
286
+ failure = Primitive_exceptions.internalToException(raw_e);
287
+ }
288
+ let marked = Stdlib_Option.mapOr(failure, false, Plugin_ResolverError$ReventlessCore.isCallerFault);
289
+ let saysWhy = Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(failure, Stdlib_JsExn.fromException), Stdlib_JsExn.message), false, m => m.includes("data"));
290
+ globalThis.expect([
291
+ marked,
292
+ saysWhy
293
+ ]).toEqual([
294
+ true,
295
+ true
296
+ ]);
297
+ });
298
+ });
158
299
  });
159
300
 
160
301
  /* Not a pure module */
@@ -85,6 +85,25 @@ type compositeTagCommand =
85
85
  version: string,
86
86
  })
87
87
 
88
+ // A command carrying an optional field beside an opaque-JSON one. The optional
89
+ // field is what a caller can send as null; the JSON field is a value whose own
90
+ // nulls are data and must survive.
91
+ @schema
92
+ type optionalFieldCommand =
93
+ StoreItem({
94
+ itemId: @s.matches(Reventless.DcbTag.string) string,
95
+ data: JSON.t,
96
+ note?: string,
97
+ })
98
+
99
+ let optionalFieldSliceGen = CommandGenerator_Callback.makeGenerateCommand(
100
+ ~publishJsons=MockPublishSpec.publishJsons,
101
+ ~serviceName="OptionalFieldSlice",
102
+ ~commandSchema=optionalFieldCommandSchema->S.castToUnknown,
103
+ ~componentKind=CommandGenerator_Callback.StateChangeSlice,
104
+ ~stripIdFromParams=false,
105
+ )
106
+
88
107
  let singleTagSliceGen = CommandGenerator_Callback.makeGenerateCommand(
89
108
  ~publishJsons=MockPublishSpec.publishJsons,
90
109
  ~serviceName="SingleTagSlice",
@@ -124,6 +124,15 @@ let compositeTagCommandSchema = S.schema(s => ({
124
124
  version: s.m(S.string)
125
125
  }));
126
126
 
127
+ let optionalFieldCommandSchema = S.schema(s => ({
128
+ TAG: "StoreItem",
129
+ itemId: s.m(DcbTag$Reventless.string),
130
+ data: s.m(S.json),
131
+ note: s.m(S.option(S.string))
132
+ }));
133
+
134
+ let optionalFieldSliceGen = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "OptionalFieldSlice", optionalFieldCommandSchema, "StateChangeSlice", false);
135
+
127
136
  let singleTagSliceGen = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "SingleTagSlice", singleTagCommandSchema, "StateChangeSlice", false);
128
137
 
129
138
  let compositeTagSliceGen = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "CompositeTagSlice", compositeTagCommandSchema, "StateChangeSlice", false);
@@ -187,6 +196,8 @@ export {
187
196
  TestGenerator,
188
197
  singleTagCommandSchema,
189
198
  compositeTagCommandSchema,
199
+ optionalFieldCommandSchema,
200
+ optionalFieldSliceGen,
190
201
  singleTagSliceGen,
191
202
  compositeTagSliceGen,
192
203
  makeSlicePayload,
@@ -150,6 +150,19 @@ describe("@owner stamping on the command path:", () => {
150
150
  )->toBe(true)
151
151
  )
152
152
 
153
+ // The refusal describes the caller's own request, so a transport is allowed
154
+ // to report it. Unmarked it arrives as "Unexpected error", which tells a
155
+ // caller who needs to authenticate nothing at all.
156
+ testPromise("the refusal is marked as the caller's fault", async () => {
157
+ let failure = switch await generate(
158
+ placeOrder(~customerId="cust-B", ~identity=Reventless.Identity.anonymous),
159
+ )->Effect.runPromise {
160
+ | _ => None
161
+ | exception e => Some(e)
162
+ }
163
+ expect(failure->Option.mapOr(false, Plugin_ResolverError.isCallerFault))->toBe(true)
164
+ })
165
+
153
166
  testPromise("nothing is published when the caller is refused", async () => {
154
167
  let _ = await refused(
155
168
  placeOrder(~customerId="cust-B", ~identity=Reventless.Identity.anonymous),
@@ -7,7 +7,9 @@ import * as Effect from "effect/Effect";
7
7
  import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
8
8
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
9
9
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
10
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
10
11
  import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
12
+ import * as Plugin_ResolverError$ReventlessCore from "../../src/plugin/component/Plugin_ResolverError.res.mjs";
11
13
  import * as CommandGenerator_Callback$ReventlessCore from "../../src/components/CommandGenerator/CommandGenerator_Callback.res.mjs";
12
14
 
13
15
  S.enableJson();
@@ -160,6 +162,16 @@ globalThis.describe("@owner stamping on the command path:", () => {
160
162
  globalThis.test("an anonymous caller cannot place an owned command", async () => {
161
163
  globalThis.expect(await refused(placeOrder("cust-B", Identity$Reventless.anonymous))).toBe(true);
162
164
  });
165
+ globalThis.test("the refusal is marked as the caller's fault", async () => {
166
+ let failure;
167
+ try {
168
+ await Effect.runPromise(generate(placeOrder("cust-B", Identity$Reventless.anonymous)));
169
+ failure = undefined;
170
+ } catch (raw_e) {
171
+ failure = Primitive_exceptions.internalToException(raw_e);
172
+ }
173
+ globalThis.expect(Stdlib_Option.mapOr(failure, false, Plugin_ResolverError$ReventlessCore.isCallerFault)).toBe(true);
174
+ });
163
175
  globalThis.test("nothing is published when the caller is refused", async () => {
164
176
  await refused(placeOrder("cust-B", Identity$Reventless.anonymous));
165
177
  globalThis.expect(published.contents.length).toBe(0);