@reventlessdev/reventless-core 3.0.0-alpha.227 → 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 +15 -0
- package/package.json +5 -5
- package/src/components/Api/GraphQL_FragmentGenerator.res +31 -0
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +11 -0
- package/src/components/Api/SuryToJsonSchema.res +23 -1
- package/src/components/Api/SuryToJsonSchema.res.mjs +22 -2
- package/src/components/CommandGenerator/CommandGenerator_Callback.res +45 -5
- package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +17 -4
- package/src/plugin/component/Plugin_ResolverError.res +41 -0
- package/src/plugin/component/Plugin_ResolverError.res.mjs +18 -1
- package/src/plugin/component/Plugin_Structure.res +45 -2
- package/src/plugin/component/Plugin_Structure.res.mjs +18 -2
- package/tests/api/GraphQL_FragmentGeneratorTest.res +84 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +63 -0
- package/tests/api/SuryToJsonSchemaTest.res +36 -0
- package/tests/api/SuryToJsonSchemaTest.res.mjs +17 -0
- package/tests/commandgenerator/CommandGeneratorCallbackTest.res +104 -0
- package/tests/commandgenerator/CommandGeneratorCallbackTest.res.mjs +141 -0
- package/tests/commandgenerator/CommandGeneratorFixtures.res +19 -0
- package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +11 -0
- package/tests/commandgenerator/OwnerStampingTest.res +13 -0
- package/tests/commandgenerator/OwnerStampingTest.res.mjs +12 -0
- package/tests/plugin/PluginStructureTest.res +20 -0
- package/tests/plugin/PluginStructureTest.res.mjs +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
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
|
+
|
|
14
|
+
# 3.0.0-alpha.228 (2026-08-12)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **api:** publish each mutation argument's GraphQL type on the command schema ([9ea1a5b](https://github.com/ReventlessDev/reventless-core/commit/9ea1a5bdf1957a726f6452e122c8fcda73ae824f))
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.227 (2026-08-12)
|
|
7
22
|
|
|
8
23
|
**Note:** Version bump only for package @reventlessdev/reventless-core
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.229",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -30,15 +30,15 @@
|
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
31
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
32
32
|
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.4",
|
|
33
|
-
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
34
33
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
35
34
|
"@reventlessdev/rescript-node": "2.0.0-alpha.4",
|
|
36
|
-
"@reventlessdev/rescript-
|
|
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
|
+
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
38
39
|
"@reventlessdev/reventless-infra": "3.0.0-alpha.137",
|
|
39
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.111",
|
|
40
40
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.30",
|
|
41
|
-
"@reventlessdev/
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.111"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
@@ -475,6 +475,37 @@ let deriveMutationFieldFromObject = (
|
|
|
475
475
|
| None => None
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
// The rendered GraphQL type of every argument one mutation field declares,
|
|
479
|
+
// keyed by argument name — `"Ordering_PlaceOrderShippingMethod!"`,
|
|
480
|
+
// `"DateRangeInput"`, `"[Ordering_PlaceOrderLineItems!]!"`.
|
|
481
|
+
//
|
|
482
|
+
// A client that assembles its own mutation document must declare a variable per
|
|
483
|
+
// argument, and it cannot derive these from JSON Schema: an enum's type name is
|
|
484
|
+
// composed from the mutation field and the property, an object's comes from a
|
|
485
|
+
// semantic or the field path, and the nullability comes from whether the sury
|
|
486
|
+
// schema wrapped it in an option. None of that survives the JSON-Schema
|
|
487
|
+
// projection, so the name is published rather than left to be re-derived from a
|
|
488
|
+
// convention that would then live in two repos shipping on different cycles.
|
|
489
|
+
//
|
|
490
|
+
// Deliberately the same `fromSchemaType` call `deriveMutationFieldFromObject`
|
|
491
|
+
// makes: one producer, so the published string and the SDL cannot disagree.
|
|
492
|
+
// The collected type *definitions* are discarded — a caller wants the reference
|
|
493
|
+
// each argument renders as, and the definitions are already in the schema this
|
|
494
|
+
// generator emits.
|
|
495
|
+
let mutationArgTypes = (~fieldName: string, variantSchema: S.t<unknown>): option<dict<string>> =>
|
|
496
|
+
SchemaType.fromSuryObject(~typeName=fieldName, variantSchema)->Option.map(fields => {
|
|
497
|
+
let out = Dict.make()
|
|
498
|
+
fields
|
|
499
|
+
->Dict.toArray
|
|
500
|
+
->Array.forEach(((argName, argType)) =>
|
|
501
|
+
out->Dict.set(
|
|
502
|
+
argName,
|
|
503
|
+
fromSchemaType(~required=true, ~asInput=true, argType, [], Set.make()),
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
out
|
|
507
|
+
})
|
|
508
|
+
|
|
478
509
|
// ── Main generate function ─────────────────────────────────────────────────
|
|
479
510
|
|
|
480
511
|
let generate = (
|
|
@@ -390,6 +390,16 @@ function deriveMutationFieldFromObject(fieldName, collectedTypes, seenTypes, var
|
|
|
390
390
|
return ` ` + fieldName + argsPart + `: CommandResult!`;
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
+
function mutationArgTypes(fieldName, variantSchema) {
|
|
394
|
+
return Stdlib_Option.map(SchemaType$ReventlessCore.fromSuryObject(fieldName, variantSchema), fields => {
|
|
395
|
+
let out = {};
|
|
396
|
+
Object.entries(fields).forEach(param => {
|
|
397
|
+
out[param[0]] = fromSchemaType(true, true, param[1], [], new Set());
|
|
398
|
+
});
|
|
399
|
+
return out;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
393
403
|
function generate(mutationEntries, queryEntries) {
|
|
394
404
|
let types = [];
|
|
395
405
|
let mutations = [];
|
|
@@ -571,6 +581,7 @@ export {
|
|
|
571
581
|
deriveByIdsQueryField,
|
|
572
582
|
deriveConnectionQueryField,
|
|
573
583
|
deriveMutationFieldFromObject,
|
|
584
|
+
mutationArgTypes,
|
|
574
585
|
generate,
|
|
575
586
|
}
|
|
576
587
|
/* Api_Naming-ReventlessCore Not a pure module */
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
-
/*
|
|
38
|
+
/* Stdlib_JsExn Not a pure module */
|
|
@@ -318,6 +318,32 @@ let make = (
|
|
|
318
318
|
| AllowGroups(_) | AllowAuthenticated | AllowAnonymous | DenyAll => None
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
// Write each mutation argument's rendered GraphQL type onto the property it
|
|
322
|
+
// belongs to, so a consumer assembling its own mutation document declares the
|
|
323
|
+
// variable the server actually expects instead of guessing `String!`.
|
|
324
|
+
//
|
|
325
|
+
// Mutates the freshly derived schema in place — `deriveObjectSchema` has just
|
|
326
|
+
// built it and nothing else holds it yet. A property with no matching
|
|
327
|
+
// argument is left alone rather than annotated with a guess.
|
|
328
|
+
let annotateArgTypes = (schema: JSON.t, argTypes: dict<string>): JSON.t => {
|
|
329
|
+
schema
|
|
330
|
+
->JSON.Decode.object
|
|
331
|
+
->Option.flatMap(o => o->Dict.get("properties"))
|
|
332
|
+
->Option.flatMap(JSON.Decode.object)
|
|
333
|
+
->Option.forEach(props =>
|
|
334
|
+
props
|
|
335
|
+
->Dict.toArray
|
|
336
|
+
->Array.forEach(((key, prop)) =>
|
|
337
|
+
switch (argTypes->Dict.get(key), prop->JSON.Decode.object) {
|
|
338
|
+
| (Some(gqlType), Some(p)) =>
|
|
339
|
+
p->Dict.set("x-reventless-graphql-type", JSON.Encode.string(gqlType))
|
|
340
|
+
| _ => ()
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
schema
|
|
345
|
+
}
|
|
346
|
+
|
|
321
347
|
let toCommandDef = (
|
|
322
348
|
~isAggregate,
|
|
323
349
|
~mutationFieldFor: string => string,
|
|
@@ -360,6 +386,20 @@ let make = (
|
|
|
360
386
|
? {"TAG": variantName}->Obj.magic
|
|
361
387
|
: variantName->Obj.magic
|
|
362
388
|
let requiredAccess = accessKeysFor(commandAuthorization(syntheticCommand))
|
|
389
|
+
// See the note on the record's `mutationField` for why a non-exposed
|
|
390
|
+
// variant gets the empty sentinel. It has no callable field, and the
|
|
391
|
+
// argument type names are composed *from* that field name, so there is
|
|
392
|
+
// nothing to publish for it either.
|
|
393
|
+
let mutationField = apiExposed ? mutationFieldFor(variantName) : ""
|
|
394
|
+
let jsonSchema = v->SuryToJsonSchema.deriveObjectSchema
|
|
395
|
+
let annotatedSchema = if apiExposed {
|
|
396
|
+
GraphQL_FragmentGenerator.mutationArgTypes(~fieldName=mutationField, v)->Option.mapOr(
|
|
397
|
+
jsonSchema,
|
|
398
|
+
annotateArgTypes(jsonSchema, _),
|
|
399
|
+
)
|
|
400
|
+
} else {
|
|
401
|
+
jsonSchema
|
|
402
|
+
}
|
|
363
403
|
({
|
|
364
404
|
Reventless.Plugin.name: variantName,
|
|
365
405
|
// The derived schema, not sury's raw one: `S.toJSONSchema` carries the
|
|
@@ -369,7 +409,10 @@ let make = (
|
|
|
369
409
|
// matches a field against its setter — or picks the upload endpoint of
|
|
370
410
|
// the store a command argument declares — then has nothing to match on.
|
|
371
411
|
// `MCP_SchemaGenerator` already derives these same variant schemas.
|
|
372
|
-
|
|
412
|
+
//
|
|
413
|
+
// Carries `x-reventless-graphql-type` per property — see
|
|
414
|
+
// `annotateArgTypes`.
|
|
415
|
+
schema: annotatedSchema->JSON.stringify,
|
|
373
416
|
level,
|
|
374
417
|
aggregateIdField,
|
|
375
418
|
// A non-exposed (`@noApi`) variant has no callable mutation field. For a
|
|
@@ -381,7 +424,7 @@ let make = (
|
|
|
381
424
|
// stays listed with `apiExposed: false` for the event-graph badge, but
|
|
382
425
|
// no consumer can mistake it for a callable field. Exposed variants are
|
|
383
426
|
// byte-identical.
|
|
384
|
-
mutationField
|
|
427
|
+
mutationField,
|
|
385
428
|
references,
|
|
386
429
|
allowedStates,
|
|
387
430
|
targetState,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
3
4
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
5
|
import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
|
|
5
6
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
@@ -294,12 +295,27 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
|
|
|
294
295
|
TAG: variantName
|
|
295
296
|
}) : variantName;
|
|
296
297
|
let requiredAccess = accessKeysFor(commandAuthorization(syntheticCommand));
|
|
298
|
+
let mutationField = apiExposed ? mutationFieldFor(variantName) : "";
|
|
299
|
+
let jsonSchema = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(v);
|
|
300
|
+
let annotatedSchema = apiExposed ? Stdlib_Option.mapOr(GraphQL_FragmentGenerator$ReventlessCore.mutationArgTypes(mutationField, v), jsonSchema, __x => {
|
|
301
|
+
Stdlib_Option.forEach(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(jsonSchema), o => o["properties"]), Stdlib_JSON.Decode.object), props => {
|
|
302
|
+
Object.entries(props).forEach(param => {
|
|
303
|
+
let match = __x[param[0]];
|
|
304
|
+
let match$1 = Stdlib_JSON.Decode.object(param[1]);
|
|
305
|
+
if (match !== undefined && match$1 !== undefined) {
|
|
306
|
+
match$1["x-reventless-graphql-type"] = match;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
return jsonSchema;
|
|
312
|
+
}) : jsonSchema;
|
|
297
313
|
return {
|
|
298
314
|
name: variantName,
|
|
299
|
-
schema: JSON.stringify(
|
|
315
|
+
schema: JSON.stringify(annotatedSchema),
|
|
300
316
|
level: match[0],
|
|
301
317
|
aggregateIdField: match[1],
|
|
302
|
-
mutationField:
|
|
318
|
+
mutationField: mutationField,
|
|
303
319
|
references: references,
|
|
304
320
|
allowedStates: allowedStates,
|
|
305
321
|
targetState: targetState,
|
|
@@ -256,3 +256,87 @@ describe("semantic composites are named once, not once per field", () => {
|
|
|
256
256
|
))->toEqual((1, 1, true))
|
|
257
257
|
})
|
|
258
258
|
})
|
|
259
|
+
|
|
260
|
+
// `mutationArgTypes` is what a client that assembles its own mutation document
|
|
261
|
+
// reads instead of guessing the variable types. Its whole value is that it
|
|
262
|
+
// agrees with the SDL, so the last case here checks every published string
|
|
263
|
+
// against the argument the generator emitted for the same command.
|
|
264
|
+
module PlaceOrder = {
|
|
265
|
+
@schema
|
|
266
|
+
type shippingMethod = Standard | Express
|
|
267
|
+
|
|
268
|
+
// A single-payload command reaches the generator as a plain object schema —
|
|
269
|
+
// the `Object(_)` branch of `generate` — which is also the shape one variant
|
|
270
|
+
// of a union presents to `mutationArgTypes`.
|
|
271
|
+
let schema =
|
|
272
|
+
S.schema(s =>
|
|
273
|
+
{
|
|
274
|
+
"orderId": s.matches(S.string),
|
|
275
|
+
"shippingMethod": s.matches(shippingMethodSchema),
|
|
276
|
+
"total": s.matches(Reventless.Money.schema),
|
|
277
|
+
"tip": s.matches(S.option(Reventless.Money.schema)),
|
|
278
|
+
"itemCount": s.matches(S.int),
|
|
279
|
+
}
|
|
280
|
+
)->S.castToUnknown
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
describe("GraphQL_FragmentGenerator.mutationArgTypes", () => {
|
|
284
|
+
let argTypes =
|
|
285
|
+
GraphQL_FragmentGenerator.mutationArgTypes(
|
|
286
|
+
~fieldName="Ordering_PlaceOrder",
|
|
287
|
+
PlaceOrder.schema,
|
|
288
|
+
)->Option.getOr(Dict.make())
|
|
289
|
+
|
|
290
|
+
let typeOf = (name: string) => argTypes->Dict.get(name)
|
|
291
|
+
|
|
292
|
+
// The defect this exists for: a client that fell back to `String!` here got
|
|
293
|
+
// `Variable "$shippingMethod" of type "String!" used in position expecting
|
|
294
|
+
// type "Ordering_PlaceOrderShippingMethod!"` — a 200 carrying no data. The
|
|
295
|
+
// name is composed from the mutation field, so nothing downstream of the
|
|
296
|
+
// JSON Schema can reconstruct it.
|
|
297
|
+
testSync("names an enum by the type the mutation field composes", () =>
|
|
298
|
+
expect(typeOf("shippingMethod"))->toEqual(Some("Ordering_PlaceOrderShippingMethod!"))
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
// A semantic composite is named after the semantic, and takes the `Input`
|
|
302
|
+
// suffix in argument position.
|
|
303
|
+
testSync("names a semantic composite by its input type", () =>
|
|
304
|
+
expect(typeOf("total"))->toEqual(Some("MoneyInput!"))
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
// Nullability is the other half of the answer: with the right name and an
|
|
308
|
+
// unconditional `!` appended, the declaration still would not match.
|
|
309
|
+
testSync("distinguishes an optional argument from a required one", () =>
|
|
310
|
+
expect((typeOf("tip"), typeOf("total")))->toEqual((Some("MoneyInput"), Some("MoneyInput!")))
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
// Both of these differ from what the JSON-Schema type alone suggests —
|
|
314
|
+
// `orderId` is a plain string there and `itemCount` an integer — which is the
|
|
315
|
+
// narrower reason a client cannot derive even the scalars itself.
|
|
316
|
+
testSync("reports the scalar the server chose, not the JSON-Schema one", () =>
|
|
317
|
+
expect((typeOf("orderId"), typeOf("itemCount")))->toEqual((Some("ID!"), Some("Float!")))
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
// The property that makes publishing worth more than re-deriving downstream:
|
|
321
|
+
// one producer, so what a client declares and what the server declares cannot
|
|
322
|
+
// drift apart.
|
|
323
|
+
testSync("agrees with every argument the SDL declares", () => {
|
|
324
|
+
let fragment = GraphQL_FragmentGenerator.generate(
|
|
325
|
+
~mutationEntries=[
|
|
326
|
+
{
|
|
327
|
+
ReventlessInfra.Api.fieldNames: ["Ordering_PlaceOrder"],
|
|
328
|
+
commandSchema: PlaceOrder.schema,
|
|
329
|
+
injectIdArg: false,
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
~queryEntries=[],
|
|
333
|
+
)
|
|
334
|
+
let field = mutationFor(fragment, "Ordering_PlaceOrder")->Option.getOr("")
|
|
335
|
+
let mismatched =
|
|
336
|
+
argTypes
|
|
337
|
+
->Dict.toArray
|
|
338
|
+
->Array.filter(((arg, gqlType)) => !(field->String.includes(`${arg}: ${gqlType}`)))
|
|
339
|
+
->Array.map(((arg, gqlType)) => `${arg}: ${gqlType}`)
|
|
340
|
+
expect((mismatched, field->String.length > 0))->toEqual(([], true))
|
|
341
|
+
})
|
|
342
|
+
})
|
|
@@ -260,6 +260,68 @@ globalThis.describe("semantic composites are named once, not once per field", ()
|
|
|
260
260
|
});
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
+
let shippingMethodSchema = S.union([
|
|
264
|
+
S.literal("Standard"),
|
|
265
|
+
S.literal("Express")
|
|
266
|
+
]);
|
|
267
|
+
|
|
268
|
+
let schema = S.schema(s => ({
|
|
269
|
+
orderId: s.m(S.string),
|
|
270
|
+
shippingMethod: s.m(shippingMethodSchema),
|
|
271
|
+
total: s.m(Money$Reventless.schema),
|
|
272
|
+
tip: s.m(S.option(Money$Reventless.schema)),
|
|
273
|
+
itemCount: s.m(S.int)
|
|
274
|
+
}));
|
|
275
|
+
|
|
276
|
+
let PlaceOrder = {
|
|
277
|
+
shippingMethodSchema: shippingMethodSchema,
|
|
278
|
+
schema: schema
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
globalThis.describe("GraphQL_FragmentGenerator.mutationArgTypes", () => {
|
|
282
|
+
let argTypes = Stdlib_Option.getOr(GraphQL_FragmentGenerator$ReventlessCore.mutationArgTypes("Ordering_PlaceOrder", schema), {});
|
|
283
|
+
globalThis.test("names an enum by the type the mutation field composes", () => {
|
|
284
|
+
globalThis.expect(argTypes["shippingMethod"]).toEqual("Ordering_PlaceOrderShippingMethod!");
|
|
285
|
+
});
|
|
286
|
+
globalThis.test("names a semantic composite by its input type", () => {
|
|
287
|
+
globalThis.expect(argTypes["total"]).toEqual("MoneyInput!");
|
|
288
|
+
});
|
|
289
|
+
globalThis.test("distinguishes an optional argument from a required one", () => {
|
|
290
|
+
globalThis.expect([
|
|
291
|
+
argTypes["tip"],
|
|
292
|
+
argTypes["total"]
|
|
293
|
+
]).toEqual([
|
|
294
|
+
"MoneyInput",
|
|
295
|
+
"MoneyInput!"
|
|
296
|
+
]);
|
|
297
|
+
});
|
|
298
|
+
globalThis.test("reports the scalar the server chose, not the JSON-Schema one", () => {
|
|
299
|
+
globalThis.expect([
|
|
300
|
+
argTypes["orderId"],
|
|
301
|
+
argTypes["itemCount"]
|
|
302
|
+
]).toEqual([
|
|
303
|
+
"ID!",
|
|
304
|
+
"Float!"
|
|
305
|
+
]);
|
|
306
|
+
});
|
|
307
|
+
globalThis.test("agrees with every argument the SDL declares", () => {
|
|
308
|
+
let fragment = GraphQL_FragmentGenerator$ReventlessCore.generate([{
|
|
309
|
+
fieldNames: ["Ordering_PlaceOrder"],
|
|
310
|
+
commandSchema: schema,
|
|
311
|
+
injectIdArg: false
|
|
312
|
+
}], []);
|
|
313
|
+
let field = Stdlib_Option.getOr(mutationFor(fragment, "Ordering_PlaceOrder"), "");
|
|
314
|
+
let mismatched = Object.entries(argTypes).filter(param => !field.includes(param[0] + `: ` + param[1])).map(param => param[0] + `: ` + param[1]);
|
|
315
|
+
globalThis.expect([
|
|
316
|
+
mismatched,
|
|
317
|
+
field.length > 0
|
|
318
|
+
]).toEqual([
|
|
319
|
+
[],
|
|
320
|
+
true
|
|
321
|
+
]);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
263
325
|
export {
|
|
264
326
|
OrderSliceCmd,
|
|
265
327
|
mutationFor,
|
|
@@ -267,5 +329,6 @@ export {
|
|
|
267
329
|
typeDefFor,
|
|
268
330
|
AddProductCmd,
|
|
269
331
|
ChangePriceCmd,
|
|
332
|
+
PlaceOrder,
|
|
270
333
|
}
|
|
271
334
|
/* commandSchema 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);
|
|
@@ -259,6 +259,26 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
259
259
|
expect((shipField->String.length > 0, cancelField))->toEqual((true, ""))
|
|
260
260
|
})
|
|
261
261
|
|
|
262
|
+
// A consumer that builds its own mutation document declares one variable
|
|
263
|
+
// per argument, and JSON Schema does not carry the GraphQL type those
|
|
264
|
+
// variables need — `orderId` reads as a plain string there while the server
|
|
265
|
+
// declares `ID!`. So the rendered type rides along on the property.
|
|
266
|
+
testSync("ShipOrder: the command schema publishes each argument's GraphQL type", () => {
|
|
267
|
+
let shipOrder = structure.stateChangeSlices->Array.getUnsafe(1)
|
|
268
|
+
let ship = shipOrder.commands->Array.find(c => c.name == "ShipOrder")->Option.getOrThrow
|
|
269
|
+
expect(ship.schema->String.includes(`"x-reventless-graphql-type":"ID!"`))->toBe(true)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
// The names are composed from the mutation field, and a `@noApi` variant
|
|
273
|
+
// has none — publishing a type derived from the empty sentinel would name
|
|
274
|
+
// types no schema declares.
|
|
275
|
+
testSync("CancelShipment: a @noApi variant publishes no argument types", () => {
|
|
276
|
+
let shipOrder = structure.stateChangeSlices->Array.getUnsafe(1)
|
|
277
|
+
let cancel =
|
|
278
|
+
shipOrder.commands->Array.find(c => c.name == "CancelShipment")->Option.getOrThrow
|
|
279
|
+
expect(cancel.schema->String.includes("x-reventless-graphql-type"))->toBe(false)
|
|
280
|
+
})
|
|
281
|
+
|
|
262
282
|
testSync("ShipOrder: payload-less event ShipmentVoided is surfaced in events", () => {
|
|
263
283
|
let shipOrder = structure.stateChangeSlices->Array.getUnsafe(1)
|
|
264
284
|
expect(shipOrder.events->Array.map(e => e.name))->toEqual(["OrderShipped", "ShipmentVoided"])
|
|
@@ -447,6 +447,16 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
447
447
|
""
|
|
448
448
|
]);
|
|
449
449
|
});
|
|
450
|
+
globalThis.test("ShipOrder: the command schema publishes each argument's GraphQL type", () => {
|
|
451
|
+
let shipOrder = structure.stateChangeSlices[1];
|
|
452
|
+
let ship = Stdlib_Option.getOrThrow(shipOrder.commands.find(c => c.name === "ShipOrder"), undefined);
|
|
453
|
+
globalThis.expect(ship.schema.includes(`"x-reventless-graphql-type":"ID!"`)).toBe(true);
|
|
454
|
+
});
|
|
455
|
+
globalThis.test("CancelShipment: a @noApi variant publishes no argument types", () => {
|
|
456
|
+
let shipOrder = structure.stateChangeSlices[1];
|
|
457
|
+
let cancel = Stdlib_Option.getOrThrow(shipOrder.commands.find(c => c.name === "CancelShipment"), undefined);
|
|
458
|
+
globalThis.expect(cancel.schema.includes("x-reventless-graphql-type")).toBe(false);
|
|
459
|
+
});
|
|
450
460
|
globalThis.test("ShipOrder: payload-less event ShipmentVoided is surfaced in events", () => {
|
|
451
461
|
let shipOrder = structure.stateChangeSlices[1];
|
|
452
462
|
globalThis.expect(shipOrder.events.map(e => e.name)).toEqual([
|