@reventlessdev/reventless-local 3.0.0-alpha.293 → 3.0.0-alpha.294
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/package.json +10 -10
- package/src/Platform.res +6 -3
- package/src/Platform.res.mjs +7 -6
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +19 -20
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +15 -36
- package/src/reexport/CommandTopic.res.mjs +3 -0
- package/tests/adapter/CommandAuthorizationTest.res +6 -0
- package/tests/adapter/CommandAuthorizationTest.res.mjs +1 -0
- package/tests/adapter/GraphQL_SchemaInspectorTest.res +3 -3
- package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +1 -1
- package/tests/adapter/InboundTranslationMutationTest.res +26 -2
- package/tests/adapter/InboundTranslationMutationTest.res.mjs +30 -5
- package/tests/components/dcb/DcbRejectionCauseTest.res +271 -0
- package/tests/components/dcb/DcbRejectionCauseTest.res.mjs +440 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.294 (2026-10-21)
|
|
7
|
+
|
|
8
|
+
* feat!: a rejected command says whether the model, the access check or the platform said no ([6a8a6b0](https://github.com/ReventlessDev/reventless-core/commit/6a8a6b0f2f31e766ae26a944c23b0904e51a5865))
|
|
9
|
+
|
|
10
|
+
### BREAKING CHANGES
|
|
11
|
+
|
|
12
|
+
* a type error variant with one of those names fails to
|
|
13
|
+
compile; rename it for what the model means, such as AlreadyBooked. On
|
|
14
|
+
AWS, rebuild every plugin against this release before a client selects
|
|
15
|
+
cause: a plugin built earlier answers it with a non-null error.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
6
19
|
# 3.0.0-alpha.293 (2026-10-21)
|
|
7
20
|
|
|
8
21
|
* feat!: a state view's row key is module Id ([08c9726](https://github.com/ReventlessDev/reventless-core/commit/08c972659a3d689a39fd5e2f2026597155e92210))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-local",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.294",
|
|
4
4
|
"description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -35,18 +35,18 @@
|
|
|
35
35
|
"sury": "11.0.0",
|
|
36
36
|
"ws": "^8.18.0",
|
|
37
37
|
"@reventlessdev/rescript-effect": "1.0.0-alpha.1",
|
|
38
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.13",
|
|
39
38
|
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.29",
|
|
39
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.13",
|
|
40
40
|
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.23",
|
|
41
|
-
"@reventlessdev/rescript-node": "2.0.0-alpha.23",
|
|
42
41
|
"@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
|
|
43
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.
|
|
44
|
-
"@reventlessdev/
|
|
45
|
-
"@reventlessdev/reventless-
|
|
46
|
-
"@reventlessdev/reventless-
|
|
47
|
-
"@reventlessdev/reventless-postgres": "3.0.0-alpha.
|
|
48
|
-
"@reventlessdev/reventless-
|
|
49
|
-
"@reventlessdev/reventless-seed": "1.0.0-alpha.
|
|
42
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.303",
|
|
43
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.23",
|
|
44
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.151",
|
|
45
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.251",
|
|
46
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.167",
|
|
47
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.200",
|
|
48
|
+
"@reventlessdev/reventless-seed": "1.0.0-alpha.36",
|
|
49
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.172"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"rescript": "12.3.1",
|
package/src/Platform.res
CHANGED
|
@@ -1862,9 +1862,12 @@ module MakeWithConfig = (
|
|
|
1862
1862
|
}
|
|
1863
1863
|
switch statusByName->Dict.get(pluginPrefix) {
|
|
1864
1864
|
| Some(Connected) => None
|
|
1865
|
-
| Some(Disconnected) =>
|
|
1866
|
-
|
|
1867
|
-
| Some(
|
|
1865
|
+
| Some(Disconnected) =>
|
|
1866
|
+
Some((Reventless.CommandOutcome.code(PluginUnavailable), "plugin is disconnected"))
|
|
1867
|
+
| Some(Inactive) =>
|
|
1868
|
+
Some((Reventless.CommandOutcome.code(PluginInactive), "plugin is inactive"))
|
|
1869
|
+
| Some(Retired) =>
|
|
1870
|
+
Some((Reventless.CommandOutcome.code(PluginRetired), "plugin version has been retired"))
|
|
1868
1871
|
| None => None
|
|
1869
1872
|
}
|
|
1870
1873
|
}
|
package/src/Platform.res.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import * as UiSlots$ReventlessLocal from "./UiSlots.res.mjs";
|
|
|
26
26
|
import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
|
|
27
27
|
import * as LocalBus$ReventlessLocal from "./adapter/LocalBus.res.mjs";
|
|
28
28
|
import * as Api_Naming$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Naming.res.mjs";
|
|
29
|
+
import * as CommandOutcome$Reventless from "@reventlessdev/reventless-spec/src/components/CommandOutcome.res.mjs";
|
|
29
30
|
import * as PluginSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginSpec.res.mjs";
|
|
30
31
|
import * as UserStore$ReventlessLocal from "./adapter/Auth/UserStore.res.mjs";
|
|
31
32
|
import * as TestRunner$ReventlessLocal from "./test/TestRunner.res.mjs";
|
|
@@ -1393,17 +1394,17 @@ function MakeWithConfig(Config) {
|
|
|
1393
1394
|
return;
|
|
1394
1395
|
case "Disconnected" :
|
|
1395
1396
|
return [
|
|
1396
|
-
"PluginUnavailable",
|
|
1397
|
+
CommandOutcome$Reventless.code("PluginUnavailable"),
|
|
1397
1398
|
"plugin is disconnected"
|
|
1398
1399
|
];
|
|
1399
1400
|
case "Inactive" :
|
|
1400
1401
|
return [
|
|
1401
|
-
"PluginInactive",
|
|
1402
|
+
CommandOutcome$Reventless.code("PluginInactive"),
|
|
1402
1403
|
"plugin is inactive"
|
|
1403
1404
|
];
|
|
1404
1405
|
case "Retired" :
|
|
1405
1406
|
return [
|
|
1406
|
-
"PluginRetired",
|
|
1407
|
+
CommandOutcome$Reventless.code("PluginRetired"),
|
|
1407
1408
|
"plugin version has been retired"
|
|
1408
1409
|
];
|
|
1409
1410
|
}
|
|
@@ -3149,17 +3150,17 @@ function Make($star) {
|
|
|
3149
3150
|
return;
|
|
3150
3151
|
case "Disconnected" :
|
|
3151
3152
|
return [
|
|
3152
|
-
"PluginUnavailable",
|
|
3153
|
+
CommandOutcome$Reventless.code("PluginUnavailable"),
|
|
3153
3154
|
"plugin is disconnected"
|
|
3154
3155
|
];
|
|
3155
3156
|
case "Inactive" :
|
|
3156
3157
|
return [
|
|
3157
|
-
"PluginInactive",
|
|
3158
|
+
CommandOutcome$Reventless.code("PluginInactive"),
|
|
3158
3159
|
"plugin is inactive"
|
|
3159
3160
|
];
|
|
3160
3161
|
case "Retired" :
|
|
3161
3162
|
return [
|
|
3162
|
-
"PluginRetired",
|
|
3163
|
+
CommandOutcome$Reventless.code("PluginRetired"),
|
|
3163
3164
|
"plugin version has been retired"
|
|
3164
3165
|
];
|
|
3165
3166
|
}
|
|
@@ -35,14 +35,15 @@ let extractIdentity = (ctx: JSON.t): Reventless.Identity.t => {
|
|
|
35
35
|
// Returns a `CommandRejected` outcome with a fresh msgId so the GraphQL union
|
|
36
36
|
// resolves consistently — matches the existing CommandResult contract.
|
|
37
37
|
|
|
38
|
-
let rejectForbidden = (~field: string): JSON.t =>
|
|
39
|
-
|
|
40
|
-
(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
)
|
|
38
|
+
let rejectForbidden = (~field: string): JSON.t =>
|
|
39
|
+
CommandTopic.commandOutcomeToJson(
|
|
40
|
+
Rejected({
|
|
41
|
+
msgId: ReventlessCore.Message.uuid(),
|
|
42
|
+
errorCode: Reventless.CommandOutcome.code(Forbidden),
|
|
43
|
+
errorDetail: Some(`Mutation.${field}: identity is not authorized`),
|
|
44
|
+
cause: AccessRefusal,
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
46
47
|
|
|
47
48
|
// -- Plugin status gate -------------------------------------------------------
|
|
48
49
|
// Set by the platform after Admin.construct to wire a per-mutation lookup against
|
|
@@ -60,18 +61,16 @@ let pluginStatusGate: ref<option<string => option<(string, string)>>> = ref(None
|
|
|
60
61
|
let setPluginStatusGate = fn => pluginStatusGate := Some(fn)
|
|
61
62
|
let resetPluginStatusGate = () => pluginStatusGate := None
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
]),
|
|
74
|
-
)
|
|
64
|
+
// The plugin, not the model, is what said no.
|
|
65
|
+
let rejectPluginStatus = (~field: string, ~errorCode: string, ~detail: string): JSON.t =>
|
|
66
|
+
CommandTopic.commandOutcomeToJson(
|
|
67
|
+
Rejected({
|
|
68
|
+
msgId: ReventlessCore.Message.uuid(),
|
|
69
|
+
errorCode,
|
|
70
|
+
errorDetail: Some(`Mutation.${field}: ${detail}`),
|
|
71
|
+
cause: InfrastructureFailure,
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
75
74
|
|
|
76
75
|
let checkPluginStatus = (~field: string): option<JSON.t> =>
|
|
77
76
|
switch pluginStatusGate.contents {
|
|
@@ -10,6 +10,7 @@ import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/I
|
|
|
10
10
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
11
11
|
import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
|
|
12
12
|
import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
|
|
13
|
+
import * as CommandOutcome$Reventless from "@reventlessdev/reventless-spec/src/components/CommandOutcome.res.mjs";
|
|
13
14
|
import * as CommandTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic.res.mjs";
|
|
14
15
|
import * as GraphQL_CallerError$ReventlessLocal from "../GraphQL_CallerError.res.mjs";
|
|
15
16
|
import * as Plugin_ResolverError$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_ResolverError.res.mjs";
|
|
@@ -29,24 +30,13 @@ function extractIdentity(ctx) {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function rejectForbidden(field) {
|
|
32
|
-
return
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
Message$ReventlessCore.uuid()
|
|
40
|
-
],
|
|
41
|
-
[
|
|
42
|
-
"errorCode",
|
|
43
|
-
"Forbidden"
|
|
44
|
-
],
|
|
45
|
-
[
|
|
46
|
-
"errorDetail",
|
|
47
|
-
`Mutation.` + field + `: identity is not authorized`
|
|
48
|
-
]
|
|
49
|
-
]);
|
|
33
|
+
return CommandTopic$ReventlessCore.commandOutcomeToJson({
|
|
34
|
+
TAG: "Rejected",
|
|
35
|
+
msgId: Message$ReventlessCore.uuid(),
|
|
36
|
+
errorCode: CommandOutcome$Reventless.code("Forbidden"),
|
|
37
|
+
errorDetail: `Mutation.` + field + `: identity is not authorized`,
|
|
38
|
+
cause: "AccessRefusal"
|
|
39
|
+
});
|
|
50
40
|
}
|
|
51
41
|
|
|
52
42
|
let pluginStatusGate = {
|
|
@@ -62,24 +52,13 @@ function resetPluginStatusGate() {
|
|
|
62
52
|
}
|
|
63
53
|
|
|
64
54
|
function rejectPluginStatus(field, errorCode, detail) {
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
Message$ReventlessCore.uuid()
|
|
73
|
-
],
|
|
74
|
-
[
|
|
75
|
-
"errorCode",
|
|
76
|
-
errorCode
|
|
77
|
-
],
|
|
78
|
-
[
|
|
79
|
-
"errorDetail",
|
|
80
|
-
`Mutation.` + field + `: ` + detail
|
|
81
|
-
]
|
|
82
|
-
]);
|
|
55
|
+
return CommandTopic$ReventlessCore.commandOutcomeToJson({
|
|
56
|
+
TAG: "Rejected",
|
|
57
|
+
msgId: Message$ReventlessCore.uuid(),
|
|
58
|
+
errorCode: errorCode,
|
|
59
|
+
errorDetail: `Mutation.` + field + `: ` + detail,
|
|
60
|
+
cause: "InfrastructureFailure"
|
|
61
|
+
});
|
|
83
62
|
}
|
|
84
63
|
|
|
85
64
|
function checkPluginStatus(field) {
|
|
@@ -6,6 +6,8 @@ let componentType = CommandTopic$ReventlessCore.componentType;
|
|
|
6
6
|
|
|
7
7
|
let NotPublishedToChannel = CommandTopic$ReventlessCore.NotPublishedToChannel;
|
|
8
8
|
|
|
9
|
+
let refusalCauseToGraphQL = CommandTopic$ReventlessCore.refusalCauseToGraphQL;
|
|
10
|
+
|
|
9
11
|
let commandOutcomeHook = CommandTopic$ReventlessCore.commandOutcomeHook;
|
|
10
12
|
|
|
11
13
|
let registerCommandOutcome = CommandTopic$ReventlessCore.registerCommandOutcome;
|
|
@@ -47,6 +49,7 @@ let filter = CommandTopic$ReventlessCore.filter;
|
|
|
47
49
|
export {
|
|
48
50
|
componentType,
|
|
49
51
|
NotPublishedToChannel,
|
|
52
|
+
refusalCauseToGraphQL,
|
|
50
53
|
commandOutcomeHook,
|
|
51
54
|
registerCommandOutcome,
|
|
52
55
|
clearCommandOutcome,
|
|
@@ -139,6 +139,12 @@ describe("CommandGeneratorResolvers_GraphQL — per-constructor authorization",
|
|
|
139
139
|
)
|
|
140
140
|
expect(getTypename(response))->toEqual("CommandRejected")
|
|
141
141
|
expect(getErrorCode(response))->toEqual("Forbidden")
|
|
142
|
+
expect(
|
|
143
|
+
response
|
|
144
|
+
->JSON.Decode.object
|
|
145
|
+
->Option.flatMap(o => o->Dict.get("cause"))
|
|
146
|
+
->Option.flatMap(JSON.Decode.string),
|
|
147
|
+
)->toEqual(Some("ACCESS"))
|
|
142
148
|
expect(calls.contents)->toEqual([])
|
|
143
149
|
})
|
|
144
150
|
|
|
@@ -134,6 +134,7 @@ globalThis.describe("CommandGeneratorResolvers_GraphQL — per-constructor autho
|
|
|
134
134
|
let response = await match[2](null, {}, ctxFor(userIdentity));
|
|
135
135
|
globalThis.expect(getTypename(response)).toEqual("CommandRejected");
|
|
136
136
|
globalThis.expect(getErrorCode(response)).toEqual("Forbidden");
|
|
137
|
+
globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(response), o => o["cause"]), Stdlib_JSON.Decode.string)).toEqual("ACCESS");
|
|
137
138
|
globalThis.expect(match[3].contents).toEqual([]);
|
|
138
139
|
});
|
|
139
140
|
globalThis.test("anonymous invoking Archive is rejected with Forbidden", async () => {
|
|
@@ -609,9 +609,9 @@ describe("GraphQL_SchemaInspector", () => {
|
|
|
609
609
|
let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
|
|
610
610
|
// TestState, TestStateEdge, TestStateConnection, TestStateFilter (connection filter)
|
|
611
611
|
// + TestStateRef (the reference door's projection)
|
|
612
|
-
// + CommandResult union + CommandAccepted +
|
|
613
|
-
// (auto-injected whenever the fragment emits any mutation field).
|
|
614
|
-
expect(inspection.types->Array.length)->toBe(
|
|
612
|
+
// + CommandResult union + CommandAccepted + CommandRejectionCause + CommandRejected
|
|
613
|
+
// + CommandPending (auto-injected whenever the fragment emits any mutation field).
|
|
614
|
+
expect(inspection.types->Array.length)->toBe(10)
|
|
615
615
|
expect(inspection.mutations->Array.length)->toBe(1)
|
|
616
616
|
// single Test_State(id), list Test_States(...), Test_StatesByIds(ids: [String!]!)
|
|
617
617
|
// and Test_StatesRefs(ids: [ID!]!) — the by-ids read projected to a reference.
|
|
@@ -430,7 +430,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
|
|
|
430
430
|
authorization: undefined
|
|
431
431
|
}]);
|
|
432
432
|
let inspection = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment);
|
|
433
|
-
globalThis.expect(inspection.types.length).toBe(
|
|
433
|
+
globalThis.expect(inspection.types.length).toBe(10);
|
|
434
434
|
globalThis.expect(inspection.mutations.length).toBe(1);
|
|
435
435
|
globalThis.expect(inspection.queries.length).toBe(4);
|
|
436
436
|
globalThis.expect(inspection.sdlPreview.includes("type TestState")).toBe(true);
|
|
@@ -44,6 +44,11 @@ let selection = `__typename
|
|
|
44
44
|
... on CommandAccepted { msgId entityId eventCount }
|
|
45
45
|
... on CommandRejected { msgId errorCode errorDetail }`
|
|
46
46
|
|
|
47
|
+
// The same selection with `cause`, which a client opts into.
|
|
48
|
+
let selectionWithCause = `__typename
|
|
49
|
+
... on CommandAccepted { msgId entityId eventCount }
|
|
50
|
+
... on CommandRejected { msgId errorCode errorDetail cause }`
|
|
51
|
+
|
|
47
52
|
let signedIn = (groups): Reventless.Identity.t => {
|
|
48
53
|
userId: "u-1",
|
|
49
54
|
username: "alice",
|
|
@@ -67,7 +72,7 @@ let execute = async (~caller, source) =>
|
|
|
67
72
|
"contextValue": contextOf(caller),
|
|
68
73
|
})
|
|
69
74
|
|
|
70
|
-
let runMutation = async (~status: string) =>
|
|
75
|
+
let runMutation = async (~status: string, ~selection=selection) =>
|
|
71
76
|
await execute(
|
|
72
77
|
~caller=Some(signedIn([])),
|
|
73
78
|
`mutation {
|
|
@@ -153,6 +158,23 @@ describe("InboundTranslationSlice mutation — response envelope", () => {
|
|
|
153
158
|
expect(published.contents->Array.length)->toBe(0)
|
|
154
159
|
})
|
|
155
160
|
|
|
161
|
+
testPromise("a client selecting cause learns the model said no", async () => {
|
|
162
|
+
let result = await runMutation(~status="pending", ~selection=selectionWithCause)
|
|
163
|
+
expect(errorMessages(result))->toEqual([])
|
|
164
|
+
let node = payload(result)
|
|
165
|
+
expect(node->str("errorCode"))->toEqual(Some("TranslationFailed"))
|
|
166
|
+
expect(node->str("cause"))->toEqual(Some("DOMAIN"))
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
testPromise("a client not selecting cause gets exactly the fields it asked for", async () => {
|
|
170
|
+
let node = payload(await runMutation(~status="pending"))
|
|
171
|
+
expect(
|
|
172
|
+
node
|
|
173
|
+
->JSON.Decode.object
|
|
174
|
+
->Option.map(d => d->Dict.keysToArray->Array.toSorted(String.compare)),
|
|
175
|
+
)->toEqual(Some(["__typename", "errorCode", "errorDetail", "msgId"]))
|
|
176
|
+
})
|
|
177
|
+
|
|
156
178
|
testSync("the mutation field is typed CommandResult!", () => {
|
|
157
179
|
let fieldLine =
|
|
158
180
|
DomainGraphQL_Server.asInterface.buildSdl()
|
|
@@ -176,7 +198,7 @@ let feedField = "Catalog_CatalogFeed"
|
|
|
176
198
|
let runFeed = async (~caller, ~kind) =>
|
|
177
199
|
await execute(
|
|
178
200
|
~caller,
|
|
179
|
-
`mutation { r: ${feedField}(kind: "${kind}", id: "x-1") { ${
|
|
201
|
+
`mutation { r: ${feedField}(kind: "${kind}", id: "x-1") { ${selectionWithCause} } }`,
|
|
180
202
|
)
|
|
181
203
|
|
|
182
204
|
describe("InboundTranslationSlice mutation — the door", () => {
|
|
@@ -206,6 +228,7 @@ describe("InboundTranslationSlice mutation — the door", () => {
|
|
|
206
228
|
testPromise("a caller holding none of the commands' roles is refused", async () => {
|
|
207
229
|
let node = payload(await runFeed(~caller=Some(signedIn(["Ops"])), ~kind="product"))
|
|
208
230
|
expect(node->str("errorCode"))->toEqual(Some("Forbidden"))
|
|
231
|
+
expect(node->str("cause"))->toEqual(Some("ACCESS"))
|
|
209
232
|
expect(published.contents->Array.length)->toBe(0)
|
|
210
233
|
})
|
|
211
234
|
|
|
@@ -225,6 +248,7 @@ describe("InboundTranslationSlice mutation — the door", () => {
|
|
|
225
248
|
testPromise("the caller reaches the per-command check", async () => {
|
|
226
249
|
let node = payload(await runFeed(~caller=Some(signedIn(["Merchandiser"])), ~kind="both"))
|
|
227
250
|
expect(node->str("errorCode"))->toEqual(Some("Forbidden"))
|
|
251
|
+
expect(node->str("cause"))->toEqual(Some("ACCESS"))
|
|
228
252
|
expect(published.contents->Array.length)->toBe(0)
|
|
229
253
|
})
|
|
230
254
|
})
|
|
@@ -5,6 +5,7 @@ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
|
5
5
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
6
6
|
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
7
7
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
8
|
+
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
8
9
|
import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
|
|
9
10
|
import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
|
|
10
11
|
import * as Dcb_Builder$ReventlessCore from "@reventlessdev/reventless-core/src/components/Dcb/Dcb_Builder.res.mjs";
|
|
@@ -49,6 +50,10 @@ let selection = `__typename
|
|
|
49
50
|
... on CommandAccepted { msgId entityId eventCount }
|
|
50
51
|
... on CommandRejected { msgId errorCode errorDetail }`;
|
|
51
52
|
|
|
53
|
+
let selectionWithCause = `__typename
|
|
54
|
+
... on CommandAccepted { msgId entityId eventCount }
|
|
55
|
+
... on CommandRejected { msgId errorCode errorDetail cause }`;
|
|
56
|
+
|
|
52
57
|
function signedIn(groups) {
|
|
53
58
|
return {
|
|
54
59
|
userId: "u-1",
|
|
@@ -77,9 +82,10 @@ async function execute(caller, source) {
|
|
|
77
82
|
});
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
async function runMutation(status) {
|
|
85
|
+
async function runMutation(status, selectionOpt) {
|
|
86
|
+
let selection$1 = selectionOpt !== undefined ? selectionOpt : selection;
|
|
81
87
|
return await execute(signedIn([]), `mutation {
|
|
82
|
-
r: ` + fieldName + `(paymentId: "pay-1", orderId: "ord-1", status: "` + status + `") { ` + selection + ` }
|
|
88
|
+
r: ` + fieldName + `(paymentId: "pay-1", orderId: "ord-1", status: "` + status + `") { ` + selection$1 + ` }
|
|
83
89
|
}`);
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -110,7 +116,7 @@ globalThis.describe("InboundTranslationSlice mutation — response envelope", ()
|
|
|
110
116
|
InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(fieldName, (inputJson, caller) => Callback.receive(publishJsons, inputJson, caller, undefined));
|
|
111
117
|
});
|
|
112
118
|
globalThis.test("a successful translation resolves as CommandAccepted, with no errors", async () => {
|
|
113
|
-
let result = await runMutation("completed");
|
|
119
|
+
let result = await runMutation("completed", undefined);
|
|
114
120
|
globalThis.expect(errorMessages(result)).toEqual([]);
|
|
115
121
|
let node = payload(result);
|
|
116
122
|
globalThis.expect(str(node, "__typename")).toEqual("CommandAccepted");
|
|
@@ -121,7 +127,7 @@ globalThis.describe("InboundTranslationSlice mutation — response envelope", ()
|
|
|
121
127
|
globalThis.expect(published.contents.length).toBe(1);
|
|
122
128
|
});
|
|
123
129
|
globalThis.test("a rejected translation resolves as CommandRejected, with no errors", async () => {
|
|
124
|
-
let result = await runMutation("pending");
|
|
130
|
+
let result = await runMutation("pending", undefined);
|
|
125
131
|
globalThis.expect(errorMessages(result)).toEqual([]);
|
|
126
132
|
let node = payload(result);
|
|
127
133
|
globalThis.expect(str(node, "__typename")).toEqual("CommandRejected");
|
|
@@ -136,6 +142,22 @@ globalThis.describe("InboundTranslationSlice mutation — response envelope", ()
|
|
|
136
142
|
}
|
|
137
143
|
globalThis.expect(published.contents.length).toBe(0);
|
|
138
144
|
});
|
|
145
|
+
globalThis.test("a client selecting cause learns the model said no", async () => {
|
|
146
|
+
let result = await runMutation("pending", selectionWithCause);
|
|
147
|
+
globalThis.expect(errorMessages(result)).toEqual([]);
|
|
148
|
+
let node = payload(result);
|
|
149
|
+
globalThis.expect(str(node, "errorCode")).toEqual("TranslationFailed");
|
|
150
|
+
globalThis.expect(str(node, "cause")).toEqual("DOMAIN");
|
|
151
|
+
});
|
|
152
|
+
globalThis.test("a client not selecting cause gets exactly the fields it asked for", async () => {
|
|
153
|
+
let node = payload(await runMutation("pending", undefined));
|
|
154
|
+
globalThis.expect(Stdlib_Option.map(Stdlib_JSON.Decode.object(node), d => Object.keys(d).toSorted(Primitive_string.compare))).toEqual([
|
|
155
|
+
"__typename",
|
|
156
|
+
"errorCode",
|
|
157
|
+
"errorDetail",
|
|
158
|
+
"msgId"
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
139
161
|
globalThis.test("the mutation field is typed CommandResult!", () => {
|
|
140
162
|
let fieldLine = DomainGraphQL_Server$ReventlessLocal.asInterface.buildSdl().split("\n").find(line => line.includes(fieldName));
|
|
141
163
|
globalThis.expect(Stdlib_Option.map(fieldLine, l => l.endsWith(": CommandResult!"))).toEqual(true);
|
|
@@ -165,7 +187,7 @@ let CatalogFeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make({
|
|
|
165
187
|
let feedField = "Catalog_CatalogFeed";
|
|
166
188
|
|
|
167
189
|
async function runFeed(caller, kind) {
|
|
168
|
-
return await execute(caller, `mutation { r: ` + feedField + `(kind: "` + kind + `", id: "x-1") { ` +
|
|
190
|
+
return await execute(caller, `mutation { r: ` + feedField + `(kind: "` + kind + `", id: "x-1") { ` + selectionWithCause + ` } }`);
|
|
169
191
|
}
|
|
170
192
|
|
|
171
193
|
globalThis.describe("InboundTranslationSlice mutation — the door", () => {
|
|
@@ -180,6 +202,7 @@ globalThis.describe("InboundTranslationSlice mutation — the door", () => {
|
|
|
180
202
|
globalThis.test("a caller holding none of the commands' roles is refused", async () => {
|
|
181
203
|
let node = payload(await runFeed(signedIn(["Ops"]), "product"));
|
|
182
204
|
globalThis.expect(str(node, "errorCode")).toEqual("Forbidden");
|
|
205
|
+
globalThis.expect(str(node, "cause")).toEqual("ACCESS");
|
|
183
206
|
globalThis.expect(published.contents.length).toBe(0);
|
|
184
207
|
});
|
|
185
208
|
globalThis.test("an unauthenticated caller is refused", async () => {
|
|
@@ -195,6 +218,7 @@ globalThis.describe("InboundTranslationSlice mutation — the door", () => {
|
|
|
195
218
|
globalThis.test("the caller reaches the per-command check", async () => {
|
|
196
219
|
let node = payload(await runFeed(signedIn(["Merchandiser"]), "both"));
|
|
197
220
|
globalThis.expect(str(node, "errorCode")).toEqual("Forbidden");
|
|
221
|
+
globalThis.expect(str(node, "cause")).toEqual("ACCESS");
|
|
198
222
|
globalThis.expect(published.contents.length).toBe(0);
|
|
199
223
|
});
|
|
200
224
|
});
|
|
@@ -207,6 +231,7 @@ export {
|
|
|
207
231
|
published,
|
|
208
232
|
publishJsons,
|
|
209
233
|
selection,
|
|
234
|
+
selectionWithCause,
|
|
210
235
|
signedIn,
|
|
211
236
|
contextOf,
|
|
212
237
|
execute,
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// A rejected command says who said no, on the wire a client reads. Driven through
|
|
2
|
+
// the producer path (`runInlineAndCollect` → `commandOutcomeToJson`) over the real
|
|
3
|
+
// in-memory and SQLite DCB stores: the model (`decide`), the caller (ownership),
|
|
4
|
+
// and the platform (exhausted retries, a failing store).
|
|
5
|
+
|
|
6
|
+
open JestGlobals
|
|
7
|
+
|
|
8
|
+
let _ = TestRunner.setup()
|
|
9
|
+
let opts: Pulumi.CustomResourceOptions.t = {}
|
|
10
|
+
|
|
11
|
+
module Message = ReventlessCore.Message
|
|
12
|
+
module CommandTopic_Helpers = ReventlessCore.CommandTopic_Helpers
|
|
13
|
+
|
|
14
|
+
module OrderSlice = {
|
|
15
|
+
let name = "CauseOrders"
|
|
16
|
+
module Id = Reventless.Id.String
|
|
17
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
18
|
+
|
|
19
|
+
@schema
|
|
20
|
+
type event =
|
|
21
|
+
| OrderPlaced({orderId: @s.matches(Reventless.DcbTag.string) string, customerId: string})
|
|
22
|
+
| OrderCancelled({orderId: @s.matches(Reventless.DcbTag.string) string})
|
|
23
|
+
|
|
24
|
+
@schema
|
|
25
|
+
type consumedEvent =
|
|
26
|
+
| OrderPlaced({
|
|
27
|
+
orderId: @s.matches(Reventless.DcbTag.string) string,
|
|
28
|
+
customerId: @s.matches(Reventless.Owner.string) string,
|
|
29
|
+
})
|
|
30
|
+
| OrderCancelled({orderId: @s.matches(Reventless.DcbTag.string) string})
|
|
31
|
+
|
|
32
|
+
@schema
|
|
33
|
+
type command =
|
|
34
|
+
| PlaceOrder({
|
|
35
|
+
orderId: @s.matches(Reventless.DcbTag.string) string,
|
|
36
|
+
customerId: @s.matches(Reventless.Owner.string) string,
|
|
37
|
+
})
|
|
38
|
+
| CancelOrder({orderId: @s.matches(Reventless.DcbTag.string) string})
|
|
39
|
+
|
|
40
|
+
@schema
|
|
41
|
+
type error = OrderAlreadyPlaced | OrderNotFound
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module OrderDecision = {
|
|
45
|
+
type state = {placed: bool, cancelled: bool}
|
|
46
|
+
let initialState = {placed: false, cancelled: false}
|
|
47
|
+
let moduleUrl = OrderSlice.moduleUrl
|
|
48
|
+
|
|
49
|
+
let evolve = (state, event: OrderSlice.consumedEvent) =>
|
|
50
|
+
switch event {
|
|
51
|
+
| OrderPlaced(_) => {...state, placed: true}
|
|
52
|
+
| OrderCancelled(_) => {...state, cancelled: true}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let decide = (state, command: OrderSlice.command): result<
|
|
56
|
+
array<OrderSlice.event>,
|
|
57
|
+
OrderSlice.error,
|
|
58
|
+
> =>
|
|
59
|
+
switch command {
|
|
60
|
+
| PlaceOrder({orderId, customerId}) =>
|
|
61
|
+
state.placed ? Error(OrderAlreadyPlaced) : Ok([OrderPlaced({orderId, customerId})])
|
|
62
|
+
| CancelOrder({orderId}) =>
|
|
63
|
+
state.placed ? Ok([OrderCancelled({orderId: orderId})]) : Error(OrderNotFound)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module OrderHandler = ReventlessCore.StateChangeSlice_Callback.Make(OrderSlice, OrderDecision)
|
|
68
|
+
|
|
69
|
+
let partitionTag = Reventless.DcbTag.Simple({key: "orderId"})
|
|
70
|
+
|
|
71
|
+
// ─── Callers ────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
external asIdentity: 'a => Reventless.Identity.t = "%identity"
|
|
74
|
+
|
|
75
|
+
let cognito = (~userId): Reventless.Identity.t =>
|
|
76
|
+
asIdentity({"userId": userId, "username": userId, "groups": ["User"], "provider": "Cognito"})
|
|
77
|
+
|
|
78
|
+
let owner = cognito(~userId="cust-A")
|
|
79
|
+
let stranger = cognito(~userId="cust-B")
|
|
80
|
+
|
|
81
|
+
// ─── Generator → producer path ──────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
let generated: ref<array<Message.commandJson>> = ref([])
|
|
84
|
+
|
|
85
|
+
let generate = ReventlessCore.CommandGenerator_Callback.makeGenerateCommand(
|
|
86
|
+
~publishJsons=async cmds => generated := generated.contents->Array.concat(cmds),
|
|
87
|
+
~serviceName=OrderSlice.name,
|
|
88
|
+
~commandSchema=OrderSlice.commandSchema->S.castToUnknown,
|
|
89
|
+
~componentKind=ReventlessCore.CommandGenerator_Callback.StateChangeSlice,
|
|
90
|
+
~stripIdFromParams=false,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
let envelope = async (~command, ~args, ~identity) => {
|
|
94
|
+
generated := []
|
|
95
|
+
let p: ReventlessCore.CommandGenerator.payload = Obj.magic({
|
|
96
|
+
"command": command,
|
|
97
|
+
"arguments": Obj.magic(Dict.fromArray(args)),
|
|
98
|
+
"meta": {"ip": [], "user": "test", "info": ""},
|
|
99
|
+
"identity": identity,
|
|
100
|
+
})
|
|
101
|
+
let _ = await generate(p)->Effect.runPromise
|
|
102
|
+
generated.contents->Array.getUnsafe(0)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let handlerOver = (eventLog): CommandTopic_Helpers.jsonCommandsHandler =>
|
|
106
|
+
stream =>
|
|
107
|
+
OrderHandler.handleCommands(
|
|
108
|
+
~partitionTag,
|
|
109
|
+
eventLog,
|
|
110
|
+
stream->Stream.map((item: CommandTopic_Helpers.topicItem<JSON.t>) => {
|
|
111
|
+
ReventlessInfra.CommandTopic.command: item.command->Message.decodeCommand'(
|
|
112
|
+
Reventless.Id.String.schema,
|
|
113
|
+
OrderSlice.commandSchema,
|
|
114
|
+
),
|
|
115
|
+
reference: item.reference,
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// The wire answer a client gets for one command: (`__typename`, `errorCode`, `cause`).
|
|
120
|
+
let send = async (eventLog, cmd: Message.commandJson) => {
|
|
121
|
+
let outcomes = await CommandTopic_Helpers.runInlineAndCollect([cmd], handlerOver(eventLog))
|
|
122
|
+
let json = outcomes->Array.getUnsafe(0)->CommandTopic_Helpers.commandOutcomeToJson
|
|
123
|
+
let str = key =>
|
|
124
|
+
json
|
|
125
|
+
->JSON.Decode.object
|
|
126
|
+
->Option.flatMap(o => o->Dict.get(key))
|
|
127
|
+
->Option.flatMap(JSON.Decode.string)
|
|
128
|
+
(str("__typename"), str("errorCode"), str("cause"))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let str = JSON.Encode.string
|
|
132
|
+
let place = (~identity) =>
|
|
133
|
+
envelope(
|
|
134
|
+
~command="PlaceOrder",
|
|
135
|
+
~args=[("orderId", str("o-1")), ("customerId", str("cust-A"))],
|
|
136
|
+
~identity,
|
|
137
|
+
)
|
|
138
|
+
let cancel = (~identity) =>
|
|
139
|
+
envelope(~command="CancelOrder", ~args=[("orderId", str("o-1"))], ~identity)
|
|
140
|
+
|
|
141
|
+
// ─── Backends ───────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
type appendMode = Real | AlwaysConflict | StoreFails
|
|
144
|
+
|
|
145
|
+
// One fresh event log per scenario; `mode` replaces the store's append to fail
|
|
146
|
+
// the way the platform can.
|
|
147
|
+
let eventLogOn = async (~name, ~mode) => {
|
|
148
|
+
module Bus = LocalBus.Make()
|
|
149
|
+
module Storage = LocalDcbEventLogStorage.Make(Bus)
|
|
150
|
+
let s = Storage.make(
|
|
151
|
+
~name,
|
|
152
|
+
~indexes=[],
|
|
153
|
+
~partitionTag=Reventless.DcbTag.Simple({key: "orderId"}),
|
|
154
|
+
~opts,
|
|
155
|
+
)
|
|
156
|
+
let real = await s.operations->TestRunner.resolve
|
|
157
|
+
let storage: ReventlessCore.DcbEventLog_Adapter.operations = switch mode {
|
|
158
|
+
| Real => real
|
|
159
|
+
| AlwaysConflict => {...real, append: async (_, ~condition as _=?) => Error(Conflict)}
|
|
160
|
+
| StoreFails => {
|
|
161
|
+
...real,
|
|
162
|
+
append: async (_, ~condition as _=?) => Error(StorageFailure("disk unavailable")),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
module Log = ReventlessCore.DcbEventLog_Operations.Make({
|
|
166
|
+
let name = name
|
|
167
|
+
let serviceName = name
|
|
168
|
+
let storage = storage
|
|
169
|
+
let publishJson = async (_, _, _) => ()
|
|
170
|
+
})
|
|
171
|
+
let eventLog: ReventlessCore.DcbEventLog.operations = {
|
|
172
|
+
read: Log.read,
|
|
173
|
+
append: Log.append,
|
|
174
|
+
readStream: Log.readStream,
|
|
175
|
+
appendStream: Log.appendStream,
|
|
176
|
+
}
|
|
177
|
+
eventLog
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let runUnderMemory = async fn => {
|
|
181
|
+
BackendState.setMemory()
|
|
182
|
+
await fn()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let runUnderSqlite = async fn => {
|
|
186
|
+
let db = SqliteDriver.openDb(~path=":memory:")
|
|
187
|
+
BackendState.setSqlite(~db, ~path=":memory:")
|
|
188
|
+
await fn()
|
|
189
|
+
BackendState.setMemory()
|
|
190
|
+
db->SqliteDriver.close
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let _ = beforeEach(() => {
|
|
194
|
+
Reventless.OwnerScope.setElevatedGroups(["Admin"])
|
|
195
|
+
OrderHandler.resetCache()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
let counter = ref(0)
|
|
199
|
+
let fresh = prefix => {
|
|
200
|
+
counter := counter.contents + 1
|
|
201
|
+
`${prefix}-${counter.contents->Int.toString}`
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let backends = [("in-memory", runUnderMemory), ("SQLite", runUnderSqlite)]
|
|
205
|
+
|
|
206
|
+
backends->Array.forEach(((label, runUnder)) =>
|
|
207
|
+
describe(`a rejection's cause (${label} backend)`, () => {
|
|
208
|
+
testPromise(
|
|
209
|
+
"the model's no: a decide error arrives with DOMAIN",
|
|
210
|
+
async () =>
|
|
211
|
+
await runUnder(
|
|
212
|
+
async () => {
|
|
213
|
+
let log = await eventLogOn(~name=fresh("CauseDomain"), ~mode=Real)
|
|
214
|
+
let first = await send(log, await place(~identity=owner))
|
|
215
|
+
expect(first)->toEqual((Some("CommandAccepted"), None, None))
|
|
216
|
+
let again = await send(log, await place(~identity=owner))
|
|
217
|
+
expect(again)->toEqual((
|
|
218
|
+
Some("CommandRejected"),
|
|
219
|
+
Some("OrderAlreadyPlaced"),
|
|
220
|
+
Some("DOMAIN"),
|
|
221
|
+
))
|
|
222
|
+
},
|
|
223
|
+
),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
testPromise(
|
|
227
|
+
"the caller's no: an ownership refusal arrives with ACCESS",
|
|
228
|
+
async () =>
|
|
229
|
+
await runUnder(
|
|
230
|
+
async () => {
|
|
231
|
+
let log = await eventLogOn(~name=fresh("CauseAccess"), ~mode=Real)
|
|
232
|
+
let _ = await send(log, await place(~identity=owner))
|
|
233
|
+
let refused = await send(log, await cancel(~identity=stranger))
|
|
234
|
+
expect(refused)->toEqual((Some("CommandRejected"), Some("Forbidden"), Some("ACCESS")))
|
|
235
|
+
},
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
testPromise(
|
|
240
|
+
"the platform's no: an exhausted retry loop arrives with INFRASTRUCTURE",
|
|
241
|
+
async () =>
|
|
242
|
+
await runUnder(
|
|
243
|
+
async () => {
|
|
244
|
+
let log = await eventLogOn(~name=fresh("CauseConflict"), ~mode=AlwaysConflict)
|
|
245
|
+
let failed = await send(log, await place(~identity=owner))
|
|
246
|
+
expect(failed)->toEqual((
|
|
247
|
+
Some("CommandRejected"),
|
|
248
|
+
Some("Conflict"),
|
|
249
|
+
Some("INFRASTRUCTURE"),
|
|
250
|
+
))
|
|
251
|
+
},
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
testPromise(
|
|
256
|
+
"the platform's no: a failing store arrives with INFRASTRUCTURE",
|
|
257
|
+
async () =>
|
|
258
|
+
await runUnder(
|
|
259
|
+
async () => {
|
|
260
|
+
let log = await eventLogOn(~name=fresh("CauseStore"), ~mode=StoreFails)
|
|
261
|
+
let failed = await send(log, await place(~identity=owner))
|
|
262
|
+
expect(failed)->toEqual((
|
|
263
|
+
Some("CommandRejected"),
|
|
264
|
+
Some("AppendFailed"),
|
|
265
|
+
Some("INFRASTRUCTURE"),
|
|
266
|
+
))
|
|
267
|
+
},
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
})
|
|
271
|
+
)
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Sury from "sury";
|
|
4
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
|
+
import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
6
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import * as Stream from "effect/Stream";
|
|
9
|
+
import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
|
|
10
|
+
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
11
|
+
import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
|
|
12
|
+
import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
|
|
13
|
+
import * as LocalBus$ReventlessLocal from "../../../src/adapter/LocalBus.res.mjs";
|
|
14
|
+
import * as TestRunner$ReventlessLocal from "../../../src/test/TestRunner.res.mjs";
|
|
15
|
+
import * as BackendState$ReventlessLocal from "../../../src/adapter/BackendState.res.mjs";
|
|
16
|
+
import * as SqliteDriver$ReventlessLocal from "../../../src/adapter/SqliteDriver.res.mjs";
|
|
17
|
+
import * as CommandTopic_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic_Helpers.res.mjs";
|
|
18
|
+
import * as DcbEventLog_Operations$ReventlessCore from "@reventlessdev/reventless-core/src/components/DcbEventLog/DcbEventLog_Operations.res.mjs";
|
|
19
|
+
import * as LocalDcbEventLogStorage$ReventlessLocal from "../../../src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs";
|
|
20
|
+
import * as CommandGenerator_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs";
|
|
21
|
+
import * as StateChangeSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs";
|
|
22
|
+
|
|
23
|
+
TestRunner$ReventlessLocal.setup();
|
|
24
|
+
|
|
25
|
+
let opts = {};
|
|
26
|
+
|
|
27
|
+
let name = "CauseOrders";
|
|
28
|
+
|
|
29
|
+
let moduleUrl = import.meta.url;
|
|
30
|
+
|
|
31
|
+
let eventSchema = Sury.union([
|
|
32
|
+
Sury.$schema(s => ({
|
|
33
|
+
TAG: "OrderPlaced",
|
|
34
|
+
orderId: s.m(DcbTag$Reventless.string),
|
|
35
|
+
customerId: s.m(Sury.string)
|
|
36
|
+
})),
|
|
37
|
+
Sury.$schema(s => ({
|
|
38
|
+
TAG: "OrderCancelled",
|
|
39
|
+
orderId: s.m(DcbTag$Reventless.string)
|
|
40
|
+
}))
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
let consumedEventSchema = Sury.union([
|
|
44
|
+
Sury.$schema(s => ({
|
|
45
|
+
TAG: "OrderPlaced",
|
|
46
|
+
orderId: s.m(DcbTag$Reventless.string),
|
|
47
|
+
customerId: s.m(Owner$Reventless.string)
|
|
48
|
+
})),
|
|
49
|
+
Sury.$schema(s => ({
|
|
50
|
+
TAG: "OrderCancelled",
|
|
51
|
+
orderId: s.m(DcbTag$Reventless.string)
|
|
52
|
+
}))
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
let commandSchema = Sury.union([
|
|
56
|
+
Sury.$schema(s => ({
|
|
57
|
+
TAG: "PlaceOrder",
|
|
58
|
+
orderId: s.m(DcbTag$Reventless.string),
|
|
59
|
+
customerId: s.m(Owner$Reventless.string)
|
|
60
|
+
})),
|
|
61
|
+
Sury.$schema(s => ({
|
|
62
|
+
TAG: "CancelOrder",
|
|
63
|
+
orderId: s.m(DcbTag$Reventless.string)
|
|
64
|
+
}))
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
let errorSchema = Sury.union([
|
|
68
|
+
Sury.literal("OrderAlreadyPlaced"),
|
|
69
|
+
Sury.literal("OrderNotFound")
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
function authorizationOf(param) {
|
|
73
|
+
return "AllowAuthenticated";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function commandTransition(param) {
|
|
77
|
+
return "Undeclared";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let traits = [];
|
|
81
|
+
|
|
82
|
+
let OrderSlice = {
|
|
83
|
+
name: name,
|
|
84
|
+
Id: undefined,
|
|
85
|
+
moduleUrl: moduleUrl,
|
|
86
|
+
eventSchema: eventSchema,
|
|
87
|
+
consumedEventSchema: consumedEventSchema,
|
|
88
|
+
commandSchema: commandSchema,
|
|
89
|
+
errorSchema: errorSchema,
|
|
90
|
+
authorizationOf: authorizationOf,
|
|
91
|
+
commandTransition: commandTransition,
|
|
92
|
+
traits: traits,
|
|
93
|
+
readConsistency: "EscalateOnRetry"
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
let initialState = {
|
|
97
|
+
placed: false,
|
|
98
|
+
cancelled: false
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function evolve(state, event) {
|
|
102
|
+
if (event.TAG === "OrderPlaced") {
|
|
103
|
+
return {
|
|
104
|
+
placed: true,
|
|
105
|
+
cancelled: state.cancelled
|
|
106
|
+
};
|
|
107
|
+
} else {
|
|
108
|
+
return {
|
|
109
|
+
placed: state.placed,
|
|
110
|
+
cancelled: true
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function decide(state, command) {
|
|
116
|
+
if (command.TAG === "PlaceOrder") {
|
|
117
|
+
if (state.placed) {
|
|
118
|
+
return {
|
|
119
|
+
TAG: "Error",
|
|
120
|
+
_0: "OrderAlreadyPlaced"
|
|
121
|
+
};
|
|
122
|
+
} else {
|
|
123
|
+
return {
|
|
124
|
+
TAG: "Ok",
|
|
125
|
+
_0: [{
|
|
126
|
+
TAG: "OrderPlaced",
|
|
127
|
+
orderId: command.orderId,
|
|
128
|
+
customerId: command.customerId
|
|
129
|
+
}]
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
} else if (state.placed) {
|
|
133
|
+
return {
|
|
134
|
+
TAG: "Ok",
|
|
135
|
+
_0: [{
|
|
136
|
+
TAG: "OrderCancelled",
|
|
137
|
+
orderId: command.orderId
|
|
138
|
+
}]
|
|
139
|
+
};
|
|
140
|
+
} else {
|
|
141
|
+
return {
|
|
142
|
+
TAG: "Error",
|
|
143
|
+
_0: "OrderNotFound"
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let OrderDecision = {
|
|
149
|
+
initialState: initialState,
|
|
150
|
+
moduleUrl: moduleUrl,
|
|
151
|
+
evolve: evolve,
|
|
152
|
+
decide: decide
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
let OrderHandler = StateChangeSlice_Callback$ReventlessCore.Make({
|
|
156
|
+
name: name,
|
|
157
|
+
moduleUrl: moduleUrl,
|
|
158
|
+
consumedEventSchema: consumedEventSchema,
|
|
159
|
+
errorSchema: errorSchema,
|
|
160
|
+
eventSchema: eventSchema,
|
|
161
|
+
commandSchema: commandSchema,
|
|
162
|
+
authorizationOf: authorizationOf,
|
|
163
|
+
commandTransition: commandTransition,
|
|
164
|
+
traits: traits,
|
|
165
|
+
readConsistency: "EscalateOnRetry"
|
|
166
|
+
})({
|
|
167
|
+
initialState: initialState,
|
|
168
|
+
evolve: evolve,
|
|
169
|
+
decide: decide,
|
|
170
|
+
moduleUrl: moduleUrl
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
let partitionTag = {
|
|
174
|
+
TAG: "Simple",
|
|
175
|
+
_0: {
|
|
176
|
+
key: "orderId"
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
function cognito(userId) {
|
|
181
|
+
return {
|
|
182
|
+
userId: userId,
|
|
183
|
+
username: userId,
|
|
184
|
+
groups: ["User"],
|
|
185
|
+
provider: "Cognito"
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let owner = {
|
|
190
|
+
userId: "cust-A",
|
|
191
|
+
username: "cust-A",
|
|
192
|
+
groups: ["User"],
|
|
193
|
+
provider: "Cognito"
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
let stranger = {
|
|
197
|
+
userId: "cust-B",
|
|
198
|
+
username: "cust-B",
|
|
199
|
+
groups: ["User"],
|
|
200
|
+
provider: "Cognito"
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
let generated = {
|
|
204
|
+
contents: []
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
let generate = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(async cmds => {
|
|
208
|
+
generated.contents = generated.contents.concat(cmds);
|
|
209
|
+
}, undefined, name, commandSchema, "StateChangeSlice", false, undefined);
|
|
210
|
+
|
|
211
|
+
async function envelope(command, args, identity) {
|
|
212
|
+
generated.contents = [];
|
|
213
|
+
let p = {
|
|
214
|
+
command: command,
|
|
215
|
+
arguments: Object.fromEntries(args),
|
|
216
|
+
meta: {
|
|
217
|
+
ip: [],
|
|
218
|
+
user: "test",
|
|
219
|
+
info: ""
|
|
220
|
+
},
|
|
221
|
+
identity: identity
|
|
222
|
+
};
|
|
223
|
+
await Effect.runPromise(generate(p));
|
|
224
|
+
return generated.contents[0];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function handlerOver(eventLog) {
|
|
228
|
+
return stream => OrderHandler.handleCommands(undefined, undefined, partitionTag, eventLog, Stream.map(stream, item => ({
|
|
229
|
+
command: Message$ReventlessCore.decodeCommand$p(item.command, Id$Reventless.$$String.schema, commandSchema),
|
|
230
|
+
reference: item.reference
|
|
231
|
+
})));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function send(eventLog, cmd) {
|
|
235
|
+
let outcomes = await CommandTopic_Helpers$ReventlessCore.runInlineAndCollect([cmd], handlerOver(eventLog));
|
|
236
|
+
let json = CommandTopic_Helpers$ReventlessCore.commandOutcomeToJson(outcomes[0]);
|
|
237
|
+
let str = key => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => o[key]), Stdlib_JSON.Decode.string);
|
|
238
|
+
return [
|
|
239
|
+
str("__typename"),
|
|
240
|
+
str("errorCode"),
|
|
241
|
+
str("cause")
|
|
242
|
+
];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function str(prim) {
|
|
246
|
+
return prim;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function place(identity) {
|
|
250
|
+
return envelope("PlaceOrder", [
|
|
251
|
+
[
|
|
252
|
+
"orderId",
|
|
253
|
+
"o-1"
|
|
254
|
+
],
|
|
255
|
+
[
|
|
256
|
+
"customerId",
|
|
257
|
+
"cust-A"
|
|
258
|
+
]
|
|
259
|
+
], identity);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cancel(identity) {
|
|
263
|
+
return envelope("CancelOrder", [[
|
|
264
|
+
"orderId",
|
|
265
|
+
"o-1"
|
|
266
|
+
]], identity);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function eventLogOn(name, mode) {
|
|
270
|
+
let Bus = LocalBus$ReventlessLocal.Make({});
|
|
271
|
+
let Storage = LocalDcbEventLogStorage$ReventlessLocal.Make(Bus);
|
|
272
|
+
let s = Storage.make(name, [], {
|
|
273
|
+
TAG: "Simple",
|
|
274
|
+
_0: {
|
|
275
|
+
key: "orderId"
|
|
276
|
+
}
|
|
277
|
+
}, undefined, opts);
|
|
278
|
+
let real = await TestRunner$ReventlessLocal.resolve(s.operations);
|
|
279
|
+
let storage;
|
|
280
|
+
switch (mode) {
|
|
281
|
+
case "Real" :
|
|
282
|
+
storage = real;
|
|
283
|
+
break;
|
|
284
|
+
case "AlwaysConflict" :
|
|
285
|
+
storage = {
|
|
286
|
+
read: real.read,
|
|
287
|
+
append: async (param, param$1) => ({
|
|
288
|
+
TAG: "Error",
|
|
289
|
+
_0: "Conflict"
|
|
290
|
+
}),
|
|
291
|
+
readStream: real.readStream
|
|
292
|
+
};
|
|
293
|
+
break;
|
|
294
|
+
case "StoreFails" :
|
|
295
|
+
storage = {
|
|
296
|
+
read: real.read,
|
|
297
|
+
append: async (param, param$1) => ({
|
|
298
|
+
TAG: "Error",
|
|
299
|
+
_0: {
|
|
300
|
+
TAG: "StorageFailure",
|
|
301
|
+
_0: "disk unavailable"
|
|
302
|
+
}
|
|
303
|
+
}),
|
|
304
|
+
readStream: real.readStream
|
|
305
|
+
};
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
let publishJson = async (param, param$1, param$2) => {};
|
|
309
|
+
let Log = DcbEventLog_Operations$ReventlessCore.Make({
|
|
310
|
+
name: name,
|
|
311
|
+
serviceName: name,
|
|
312
|
+
storage: storage,
|
|
313
|
+
publishJson: publishJson
|
|
314
|
+
});
|
|
315
|
+
return {
|
|
316
|
+
read: Log.read,
|
|
317
|
+
append: Log.append,
|
|
318
|
+
readStream: Log.readStream,
|
|
319
|
+
appendStream: Log.appendStream
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function runUnderMemory(fn) {
|
|
324
|
+
BackendState$ReventlessLocal.setMemory();
|
|
325
|
+
return await fn();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function runUnderSqlite(fn) {
|
|
329
|
+
let db = SqliteDriver$ReventlessLocal.openDb(":memory:");
|
|
330
|
+
BackendState$ReventlessLocal.setSqlite(db, ":memory:");
|
|
331
|
+
await fn();
|
|
332
|
+
BackendState$ReventlessLocal.setMemory();
|
|
333
|
+
return SqliteDriver$ReventlessLocal.close(db);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
globalThis.beforeEach(() => {
|
|
337
|
+
OwnerScope$Reventless.setElevatedGroups(["Admin"]);
|
|
338
|
+
OrderHandler.resetCache();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
let counter = {
|
|
342
|
+
contents: 0
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
function fresh(prefix) {
|
|
346
|
+
counter.contents = counter.contents + 1 | 0;
|
|
347
|
+
return prefix + `-` + counter.contents.toString();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let backends = [
|
|
351
|
+
[
|
|
352
|
+
"in-memory",
|
|
353
|
+
runUnderMemory
|
|
354
|
+
],
|
|
355
|
+
[
|
|
356
|
+
"SQLite",
|
|
357
|
+
runUnderSqlite
|
|
358
|
+
]
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
backends.forEach(param => {
|
|
362
|
+
let runUnder = param[1];
|
|
363
|
+
globalThis.describe(`a rejection's cause (` + param[0] + ` backend)`, () => {
|
|
364
|
+
globalThis.test("the model's no: a decide error arrives with DOMAIN", async () => await runUnder(async () => {
|
|
365
|
+
let log = await eventLogOn(fresh("CauseDomain"), "Real");
|
|
366
|
+
let first = await send(log, await place(owner));
|
|
367
|
+
globalThis.expect(first).toEqual([
|
|
368
|
+
"CommandAccepted",
|
|
369
|
+
undefined,
|
|
370
|
+
undefined
|
|
371
|
+
]);
|
|
372
|
+
let again = await send(log, await place(owner));
|
|
373
|
+
globalThis.expect(again).toEqual([
|
|
374
|
+
"CommandRejected",
|
|
375
|
+
"OrderAlreadyPlaced",
|
|
376
|
+
"DOMAIN"
|
|
377
|
+
]);
|
|
378
|
+
}));
|
|
379
|
+
globalThis.test("the caller's no: an ownership refusal arrives with ACCESS", async () => await runUnder(async () => {
|
|
380
|
+
let log = await eventLogOn(fresh("CauseAccess"), "Real");
|
|
381
|
+
await send(log, await place(owner));
|
|
382
|
+
let refused = await send(log, await cancel(stranger));
|
|
383
|
+
globalThis.expect(refused).toEqual([
|
|
384
|
+
"CommandRejected",
|
|
385
|
+
"Forbidden",
|
|
386
|
+
"ACCESS"
|
|
387
|
+
]);
|
|
388
|
+
}));
|
|
389
|
+
globalThis.test("the platform's no: an exhausted retry loop arrives with INFRASTRUCTURE", async () => await runUnder(async () => {
|
|
390
|
+
let log = await eventLogOn(fresh("CauseConflict"), "AlwaysConflict");
|
|
391
|
+
let failed = await send(log, await place(owner));
|
|
392
|
+
globalThis.expect(failed).toEqual([
|
|
393
|
+
"CommandRejected",
|
|
394
|
+
"Conflict",
|
|
395
|
+
"INFRASTRUCTURE"
|
|
396
|
+
]);
|
|
397
|
+
}));
|
|
398
|
+
globalThis.test("the platform's no: a failing store arrives with INFRASTRUCTURE", async () => await runUnder(async () => {
|
|
399
|
+
let log = await eventLogOn(fresh("CauseStore"), "StoreFails");
|
|
400
|
+
let failed = await send(log, await place(owner));
|
|
401
|
+
globalThis.expect(failed).toEqual([
|
|
402
|
+
"CommandRejected",
|
|
403
|
+
"AppendFailed",
|
|
404
|
+
"INFRASTRUCTURE"
|
|
405
|
+
]);
|
|
406
|
+
}));
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
let Message;
|
|
411
|
+
|
|
412
|
+
let CommandTopic_Helpers;
|
|
413
|
+
|
|
414
|
+
export {
|
|
415
|
+
opts,
|
|
416
|
+
Message,
|
|
417
|
+
CommandTopic_Helpers,
|
|
418
|
+
OrderSlice,
|
|
419
|
+
OrderDecision,
|
|
420
|
+
OrderHandler,
|
|
421
|
+
partitionTag,
|
|
422
|
+
cognito,
|
|
423
|
+
owner,
|
|
424
|
+
stranger,
|
|
425
|
+
generated,
|
|
426
|
+
generate,
|
|
427
|
+
envelope,
|
|
428
|
+
handlerOver,
|
|
429
|
+
send,
|
|
430
|
+
str,
|
|
431
|
+
place,
|
|
432
|
+
cancel,
|
|
433
|
+
eventLogOn,
|
|
434
|
+
runUnderMemory,
|
|
435
|
+
runUnderSqlite,
|
|
436
|
+
counter,
|
|
437
|
+
fresh,
|
|
438
|
+
backends,
|
|
439
|
+
}
|
|
440
|
+
/* Not a pure module */
|