@reventlessdev/reventless-core 3.0.0-alpha.214 → 3.0.0-alpha.216
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 +7 -7
- package/src/adapter/RuntimeExtension/RuntimeExtension.res +194 -0
- package/src/adapter/RuntimeExtension/RuntimeExtension.res.mjs +60 -0
- package/src/admin/Platform_Admin_Structure.res +4 -0
- package/src/admin/Platform_Admin_Structure.res.mjs +5 -0
- package/src/admin/Platform_ComponentDefinitionsApi.res +16 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +24 -1
- package/src/components/Aggregate/Aggregate_Callback.res +14 -2
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +3 -3
- package/src/components/CommandTopic/CommandTopic.res.mjs +12 -0
- package/src/components/CommandTopic/CommandTopic_Helpers.res +88 -2
- package/src/components/CommandTopic/CommandTopic_Helpers.res.mjs +48 -2
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +13 -1
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +4 -4
- package/src/plugin/component/Plugin_Builder.res +1 -0
- package/src/plugin/component/Plugin_Builder.res.mjs +1 -0
- package/src/plugin/component/Plugin_Structure.res +66 -51
- package/src/plugin/component/Plugin_Structure.res.mjs +61 -46
- package/tests/adapter/RuntimeExtensionHookReachTest.res +84 -0
- package/tests/adapter/RuntimeExtensionHookReachTest.res.mjs +95 -0
- package/tests/adapter/RuntimeExtensionTest.res +127 -0
- package/tests/adapter/RuntimeExtensionTest.res.mjs +111 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +21 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +21 -0
- package/tests/commandtopic/CommandOutcomeHookTest.res +258 -0
- package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +372 -0
- package/tests/commandtopic/CommandTopicHelpersRejectionTest.res +9 -1
- package/tests/commandtopic/CommandTopicHelpersRejectionTest.res.mjs +3 -3
- package/tests/message/MessageTest.res +1 -0
- package/tests/message/MessageTest.res.mjs +1 -0
- package/tests/plugin/PluginStructureTest.res +27 -0
- package/tests/plugin/PluginStructureTest.res.mjs +27 -0
- package/tests/plugin/StateChangeSlice/PsShipOrder.res +3 -1
- package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -1
- package/tests/plugin/pluginDefinitionRequiredScalars.txt +8 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
type call = {
|
|
4
|
+
runtimeKind: ComponentType.t,
|
|
5
|
+
component: string,
|
|
6
|
+
plugin: option<string>,
|
|
7
|
+
platform: option<string>,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let recorderNamed = (label: string, into: array<(string, call)>): module(
|
|
11
|
+
RuntimeExtension.Extension
|
|
12
|
+
) => {
|
|
13
|
+
module R = {
|
|
14
|
+
let moduleUrl = `file:///pkg/${label}.res.mjs`
|
|
15
|
+
let onColdStart = (~runtimeKind, ~component, ~plugin, ~platform) =>
|
|
16
|
+
into->Array.push((label, {runtimeKind, component, plugin, platform}))
|
|
17
|
+
}
|
|
18
|
+
module(R: RuntimeExtension.Extension)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let fireAggregate = () =>
|
|
22
|
+
RuntimeExtension.notifyColdStart(
|
|
23
|
+
~runtimeKind=ComponentType.Aggregate,
|
|
24
|
+
~component="AllAggregatesCmdHandler",
|
|
25
|
+
~plugin=Some("Catalog"),
|
|
26
|
+
~platform=Some("Shop"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
describe("RuntimeExtension", () => {
|
|
30
|
+
beforeEach(() => RuntimeExtension.reset())
|
|
31
|
+
|
|
32
|
+
testSync("empty registry is the default and notifyColdStart is silent", () => {
|
|
33
|
+
expect(RuntimeExtension.isEmpty())->toBe(true)
|
|
34
|
+
expect(RuntimeExtension.moduleUrls())->toEqual([])
|
|
35
|
+
fireAggregate()
|
|
36
|
+
expect(true)->toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
testSync("a registered extension receives the runtime's full identity", () => {
|
|
40
|
+
let calls: array<(string, call)> = []
|
|
41
|
+
RuntimeExtension.use(recorderNamed("tracing", calls))
|
|
42
|
+
|
|
43
|
+
fireAggregate()
|
|
44
|
+
|
|
45
|
+
expect(calls)->toEqual([
|
|
46
|
+
(
|
|
47
|
+
"tracing",
|
|
48
|
+
{
|
|
49
|
+
runtimeKind: ComponentType.Aggregate,
|
|
50
|
+
component: "AllAggregatesCmdHandler",
|
|
51
|
+
plugin: Some("Catalog"),
|
|
52
|
+
platform: Some("Shop"),
|
|
53
|
+
},
|
|
54
|
+
),
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
testSync("several extensions compose, in registration order", () => {
|
|
59
|
+
let calls: array<(string, call)> = []
|
|
60
|
+
RuntimeExtension.use(recorderNamed("tracing", calls))
|
|
61
|
+
RuntimeExtension.use(recorderNamed("accounting", calls))
|
|
62
|
+
|
|
63
|
+
fireAggregate()
|
|
64
|
+
|
|
65
|
+
expect(calls->Array.map(((label, _)) => label))->toEqual(["tracing", "accounting"])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
testSync("moduleUrls reports every registration, in order", () => {
|
|
69
|
+
let calls: array<(string, call)> = []
|
|
70
|
+
RuntimeExtension.use(recorderNamed("tracing", calls))
|
|
71
|
+
RuntimeExtension.use(recorderNamed("accounting", calls))
|
|
72
|
+
|
|
73
|
+
expect(RuntimeExtension.isEmpty())->toBe(false)
|
|
74
|
+
expect(RuntimeExtension.moduleUrls())->toEqual([
|
|
75
|
+
"file:///pkg/tracing.res.mjs",
|
|
76
|
+
"file:///pkg/accounting.res.mjs",
|
|
77
|
+
])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
testSync("a throwing extension is skipped; the runtime and its siblings survive", () => {
|
|
81
|
+
let calls: array<(string, call)> = []
|
|
82
|
+
module Broken: RuntimeExtension.Extension = {
|
|
83
|
+
let moduleUrl = "file:///pkg/broken.res.mjs"
|
|
84
|
+
let onColdStart = (~runtimeKind as _, ~component as _, ~plugin as _, ~platform as _) =>
|
|
85
|
+
JsError.throwWithMessage("extension blew up")
|
|
86
|
+
}
|
|
87
|
+
RuntimeExtension.use(module(Broken: RuntimeExtension.Extension))
|
|
88
|
+
RuntimeExtension.use(recorderNamed("accounting", calls))
|
|
89
|
+
|
|
90
|
+
// Fails the test if the seam propagates: the acceptance criterion is that a
|
|
91
|
+
// broken extension does not take the runtime down.
|
|
92
|
+
fireAggregate()
|
|
93
|
+
|
|
94
|
+
expect(calls->Array.map(((label, _)) => label))->toEqual(["accounting"])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
testSync("notifyColdStartHooks fires loose hooks — the deployed runtime's path", () => {
|
|
98
|
+
// A deployed entry point holds hooks lifted off dynamically imported
|
|
99
|
+
// modules, not first-class modules, so it never populates this process's
|
|
100
|
+
// registry. Same isolation and ordering apply.
|
|
101
|
+
let seen: array<string> = []
|
|
102
|
+
let hook = (label): RuntimeExtension.coldStartHook =>
|
|
103
|
+
(~runtimeKind as _, ~component, ~plugin as _, ~platform as _) =>
|
|
104
|
+
seen->Array.push(`${label}:${component}`)
|
|
105
|
+
|
|
106
|
+
RuntimeExtension.notifyColdStartHooks(
|
|
107
|
+
~hooks=[hook("a"), hook("b")],
|
|
108
|
+
~runtimeKind=ComponentType.ReadModel,
|
|
109
|
+
~component="AllReadModels",
|
|
110
|
+
~plugin=None,
|
|
111
|
+
~platform=None,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
expect(seen)->toEqual(["a:AllReadModels", "b:AllReadModels"])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
testSync("reset clears the registry", () => {
|
|
118
|
+
let calls: array<(string, call)> = []
|
|
119
|
+
RuntimeExtension.use(recorderNamed("tracing", calls))
|
|
120
|
+
RuntimeExtension.reset()
|
|
121
|
+
|
|
122
|
+
fireAggregate()
|
|
123
|
+
|
|
124
|
+
expect(RuntimeExtension.isEmpty())->toBe(true)
|
|
125
|
+
expect(calls)->toEqual([])
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
4
|
+
import * as RuntimeExtension$ReventlessCore from "../../src/adapter/RuntimeExtension/RuntimeExtension.res.mjs";
|
|
5
|
+
|
|
6
|
+
function recorderNamed(label, into) {
|
|
7
|
+
let moduleUrl = `file:///pkg/` + label + `.res.mjs`;
|
|
8
|
+
let onColdStart = (runtimeKind, component, plugin, platform) => {
|
|
9
|
+
into.push([
|
|
10
|
+
label,
|
|
11
|
+
{
|
|
12
|
+
runtimeKind: runtimeKind,
|
|
13
|
+
component: component,
|
|
14
|
+
plugin: plugin,
|
|
15
|
+
platform: platform
|
|
16
|
+
}
|
|
17
|
+
]);
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
moduleUrl: moduleUrl,
|
|
21
|
+
onColdStart: onColdStart
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fireAggregate() {
|
|
26
|
+
RuntimeExtension$ReventlessCore.notifyColdStart("Aggregate", "AllAggregatesCmdHandler", "Catalog", "Shop");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
globalThis.describe("RuntimeExtension", () => {
|
|
30
|
+
globalThis.beforeEach(() => RuntimeExtension$ReventlessCore.reset());
|
|
31
|
+
globalThis.test("empty registry is the default and notifyColdStart is silent", () => {
|
|
32
|
+
globalThis.expect(RuntimeExtension$ReventlessCore.isEmpty()).toBe(true);
|
|
33
|
+
globalThis.expect(RuntimeExtension$ReventlessCore.moduleUrls()).toEqual([]);
|
|
34
|
+
fireAggregate();
|
|
35
|
+
globalThis.expect(true).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
globalThis.test("a registered extension receives the runtime's full identity", () => {
|
|
38
|
+
let calls = [];
|
|
39
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("tracing", calls));
|
|
40
|
+
fireAggregate();
|
|
41
|
+
globalThis.expect(calls).toEqual([[
|
|
42
|
+
"tracing",
|
|
43
|
+
{
|
|
44
|
+
runtimeKind: "Aggregate",
|
|
45
|
+
component: "AllAggregatesCmdHandler",
|
|
46
|
+
plugin: "Catalog",
|
|
47
|
+
platform: "Shop"
|
|
48
|
+
}
|
|
49
|
+
]]);
|
|
50
|
+
});
|
|
51
|
+
globalThis.test("several extensions compose, in registration order", () => {
|
|
52
|
+
let calls = [];
|
|
53
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("tracing", calls));
|
|
54
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("accounting", calls));
|
|
55
|
+
fireAggregate();
|
|
56
|
+
globalThis.expect(calls.map(param => param[0])).toEqual([
|
|
57
|
+
"tracing",
|
|
58
|
+
"accounting"
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
globalThis.test("moduleUrls reports every registration, in order", () => {
|
|
62
|
+
let calls = [];
|
|
63
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("tracing", calls));
|
|
64
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("accounting", calls));
|
|
65
|
+
globalThis.expect(RuntimeExtension$ReventlessCore.isEmpty()).toBe(false);
|
|
66
|
+
globalThis.expect(RuntimeExtension$ReventlessCore.moduleUrls()).toEqual([
|
|
67
|
+
"file:///pkg/tracing.res.mjs",
|
|
68
|
+
"file:///pkg/accounting.res.mjs"
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
globalThis.test("a throwing extension is skipped; the runtime and its siblings survive", () => {
|
|
72
|
+
let calls = [];
|
|
73
|
+
let onColdStart = (param, param$1, param$2, param$3) => Stdlib_JsError.throwWithMessage("extension blew up");
|
|
74
|
+
let Broken = {
|
|
75
|
+
moduleUrl: "file:///pkg/broken.res.mjs",
|
|
76
|
+
onColdStart: onColdStart
|
|
77
|
+
};
|
|
78
|
+
RuntimeExtension$ReventlessCore.use(Broken);
|
|
79
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("accounting", calls));
|
|
80
|
+
fireAggregate();
|
|
81
|
+
globalThis.expect(calls.map(param => param[0])).toEqual(["accounting"]);
|
|
82
|
+
});
|
|
83
|
+
globalThis.test("notifyColdStartHooks fires loose hooks — the deployed runtime's path", () => {
|
|
84
|
+
let seen = [];
|
|
85
|
+
let hook = label => ((param, component, param$1, param$2) => {
|
|
86
|
+
seen.push(label + `:` + component);
|
|
87
|
+
});
|
|
88
|
+
RuntimeExtension$ReventlessCore.notifyColdStartHooks([
|
|
89
|
+
hook("a"),
|
|
90
|
+
hook("b")
|
|
91
|
+
], "ReadModel", "AllReadModels", undefined, undefined);
|
|
92
|
+
globalThis.expect(seen).toEqual([
|
|
93
|
+
"a:AllReadModels",
|
|
94
|
+
"b:AllReadModels"
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
globalThis.test("reset clears the registry", () => {
|
|
98
|
+
let calls = [];
|
|
99
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("tracing", calls));
|
|
100
|
+
RuntimeExtension$ReventlessCore.reset();
|
|
101
|
+
fireAggregate();
|
|
102
|
+
globalThis.expect(RuntimeExtension$ReventlessCore.isEmpty()).toBe(true);
|
|
103
|
+
globalThis.expect(calls).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export {
|
|
108
|
+
recorderNamed,
|
|
109
|
+
fireAggregate,
|
|
110
|
+
}
|
|
111
|
+
/* Not a pure module */
|
|
@@ -41,6 +41,7 @@ let wbl: writableDef = {
|
|
|
41
41
|
producedEventTypes: ["ProductAdded"],
|
|
42
42
|
consumedEventTypes: [],
|
|
43
43
|
events: [{name: "ProductAdded", schema: "{}", references: []}],
|
|
44
|
+
errors: [{name: "ProductAlreadyExists", schema: "{}", references: []}],
|
|
44
45
|
chapter: None,
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -162,6 +163,17 @@ describe("encodePluginStructureEntry", () => {
|
|
|
162
163
|
expect(json->String.includes("\"events\":[{\"name\":\"ProductAdded\""))->toEqual(true)
|
|
163
164
|
)
|
|
164
165
|
|
|
166
|
+
// The refusals a caller has to handle travel beside the facts it can observe.
|
|
167
|
+
testSync("encodes writableDef errors", () =>
|
|
168
|
+
expect(json->String.includes("\"errors\":[{\"name\":\"ProductAlreadyExists\""))->toEqual(true)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
testSync("declares Platform_ErrorDef and the errors field in the shared write-side SDL", () => {
|
|
172
|
+
let sdl = Platform_ComponentDefinitionsApi.sdlTypes->Array.join("\n")
|
|
173
|
+
expect(sdl->String.includes("type Platform_ErrorDef"))->toEqual(true)
|
|
174
|
+
expect(sdl->String.includes("errors: [Platform_ErrorDef!]!"))->toEqual(true)
|
|
175
|
+
})
|
|
176
|
+
|
|
165
177
|
// Translation-slice externalSystem: absent on the fixture above (both None), so it
|
|
166
178
|
// must serialize as null — the "no external box" case. The leading field keeps the
|
|
167
179
|
// two directions apart; `targetName` sits between them and is pinned here too.
|
|
@@ -292,6 +304,7 @@ describe("allowedStates + statusField populated", () => {
|
|
|
292
304
|
producedEventTypes: [],
|
|
293
305
|
consumedEventTypes: [],
|
|
294
306
|
events: [],
|
|
307
|
+
errors: [],
|
|
295
308
|
chapter: None,
|
|
296
309
|
}
|
|
297
310
|
|
|
@@ -325,4 +338,12 @@ describe("allowedStates + statusField populated", () => {
|
|
|
325
338
|
testSync("encodes populated statusField as the field name string", () =>
|
|
326
339
|
expect(json->String.includes("\"statusField\":\"status\""))->toEqual(true)
|
|
327
340
|
)
|
|
341
|
+
|
|
342
|
+
// "declares no errors" is an empty list, never null — the SDL types `errors` as
|
|
343
|
+
// non-null, and a structure is re-derived on every build, so there is no third
|
|
344
|
+
// "cannot say" state to encode.
|
|
345
|
+
testSync("encodes a component with no declared errors as an empty array, not null", () => {
|
|
346
|
+
expect(json->String.includes("\"errors\":[]"))->toEqual(true)
|
|
347
|
+
expect(json->String.includes("\"errors\":null"))->toEqual(false)
|
|
348
|
+
})
|
|
328
349
|
})
|
|
@@ -61,6 +61,12 @@ let wbl_events = [{
|
|
|
61
61
|
references: []
|
|
62
62
|
}];
|
|
63
63
|
|
|
64
|
+
let wbl_errors = [{
|
|
65
|
+
name: "ProductAlreadyExists",
|
|
66
|
+
schema: "{}",
|
|
67
|
+
references: []
|
|
68
|
+
}];
|
|
69
|
+
|
|
64
70
|
let wbl = {
|
|
65
71
|
name: "Product",
|
|
66
72
|
commands: wbl_commands,
|
|
@@ -69,6 +75,7 @@ let wbl = {
|
|
|
69
75
|
linkedViews: wbl_linkedViews,
|
|
70
76
|
consistencyRead: undefined,
|
|
71
77
|
events: wbl_events,
|
|
78
|
+
errors: wbl_errors,
|
|
72
79
|
chapter: undefined
|
|
73
80
|
};
|
|
74
81
|
|
|
@@ -182,6 +189,14 @@ globalThis.describe("encodePluginStructureEntry", () => {
|
|
|
182
189
|
globalThis.test("encodes writableDef events", () => {
|
|
183
190
|
globalThis.expect(json.includes("\"events\":[{\"name\":\"ProductAdded\"")).toEqual(true);
|
|
184
191
|
});
|
|
192
|
+
globalThis.test("encodes writableDef errors", () => {
|
|
193
|
+
globalThis.expect(json.includes("\"errors\":[{\"name\":\"ProductAlreadyExists\"")).toEqual(true);
|
|
194
|
+
});
|
|
195
|
+
globalThis.test("declares Platform_ErrorDef and the errors field in the shared write-side SDL", () => {
|
|
196
|
+
let sdl = Platform_ComponentDefinitionsApi$ReventlessCore.sdlTypes.join("\n");
|
|
197
|
+
globalThis.expect(sdl.includes("type Platform_ErrorDef")).toEqual(true);
|
|
198
|
+
globalThis.expect(sdl.includes("errors: [Platform_ErrorDef!]!")).toEqual(true);
|
|
199
|
+
});
|
|
185
200
|
globalThis.test("encodes None outbound externalSystem as null", () => {
|
|
186
201
|
globalThis.expect(json.includes("\"inboundCommandTypes\":[\"Ship\"],\"targetName\":null,\"externalSystem\":null")).toEqual(true);
|
|
187
202
|
});
|
|
@@ -326,6 +341,7 @@ globalThis.describe("allowedStates + statusField populated", () => {
|
|
|
326
341
|
let wblWithStates_consumedEventTypes = [];
|
|
327
342
|
let wblWithStates_linkedViews = ["Plugin"];
|
|
328
343
|
let wblWithStates_events = [];
|
|
344
|
+
let wblWithStates_errors = [];
|
|
329
345
|
let wblWithStates = {
|
|
330
346
|
name: "Plugin",
|
|
331
347
|
commands: wblWithStates_commands,
|
|
@@ -334,6 +350,7 @@ globalThis.describe("allowedStates + statusField populated", () => {
|
|
|
334
350
|
linkedViews: wblWithStates_linkedViews,
|
|
335
351
|
consistencyRead: undefined,
|
|
336
352
|
events: wblWithStates_events,
|
|
353
|
+
errors: wblWithStates_errors,
|
|
337
354
|
chapter: undefined
|
|
338
355
|
};
|
|
339
356
|
let structureWithStates_readModels = [qblWithStatus];
|
|
@@ -367,6 +384,10 @@ globalThis.describe("allowedStates + statusField populated", () => {
|
|
|
367
384
|
globalThis.test("encodes populated statusField as the field name string", () => {
|
|
368
385
|
globalThis.expect(json.includes("\"statusField\":\"status\"")).toEqual(true);
|
|
369
386
|
});
|
|
387
|
+
globalThis.test("encodes a component with no declared errors as an empty array, not null", () => {
|
|
388
|
+
globalThis.expect(json.includes("\"errors\":[]")).toEqual(true);
|
|
389
|
+
globalThis.expect(json.includes("\"errors\":null")).toEqual(false);
|
|
390
|
+
});
|
|
370
391
|
});
|
|
371
392
|
|
|
372
393
|
export {
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// The command-outcome hook, at the property the side-channels don't have: it fires on the
|
|
2
|
+
// fire-and-forget dispatch path — no acceptedResultChannel/rejectedResultChannel is installed
|
|
3
|
+
// anywhere in this file — for both write-side component kinds, carrying the owning component
|
|
4
|
+
// and telling a domain rejection apart from an infrastructure failure.
|
|
5
|
+
|
|
6
|
+
open JestGlobals
|
|
7
|
+
|
|
8
|
+
// ── StateChangeSlice arm (the aggregate arm reuses AggregateFixtures as-is) ──
|
|
9
|
+
|
|
10
|
+
let dcbMock = DcbFixtures.makeMockStorage()
|
|
11
|
+
|
|
12
|
+
module TestDcbOps: DcbEventLog_Operations.Ops = {
|
|
13
|
+
let name = "TestDcbEventLog"
|
|
14
|
+
let serviceName = "TestDcbEventLog"
|
|
15
|
+
let storage = dcbMock.operations
|
|
16
|
+
let publishJson = dcbMock.mockPublishJson
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module EventLogOps = DcbEventLog_Operations.Make(TestDcbOps)
|
|
20
|
+
|
|
21
|
+
let dcbEventLog: DcbEventLog.operations = {
|
|
22
|
+
read: EventLogOps.read,
|
|
23
|
+
append: EventLogOps.append,
|
|
24
|
+
readStream: EventLogOps.readStream,
|
|
25
|
+
appendStream: EventLogOps.appendStream,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module SliceBehavior = {
|
|
29
|
+
type state = DcbFixtures.TestCommandSpec.state
|
|
30
|
+
let initialState = DcbFixtures.TestCommandSpec.initialState
|
|
31
|
+
let evolve = DcbFixtures.TestCommandSpec.evolve
|
|
32
|
+
let decide = DcbFixtures.TestCommandSpec.decide
|
|
33
|
+
let moduleUrl = DcbFixtures.TestCommandSpec.moduleUrl
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module SliceHandler = StateChangeSlice_Callback.Make(DcbFixtures.TestCommandSpec, SliceBehavior)
|
|
37
|
+
|
|
38
|
+
let sliceItem = (reference, command): CommandTopic.topicItem<
|
|
39
|
+
Message.command'<Reventless.Id.String.t, DcbFixtures.TestCommandSpec.command>,
|
|
40
|
+
> => {
|
|
41
|
+
command: {
|
|
42
|
+
id: Reventless.Id.String.makeFromString("cmd-" ++ reference),
|
|
43
|
+
meta: DcbFixtures.testMeta,
|
|
44
|
+
command,
|
|
45
|
+
},
|
|
46
|
+
reference,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let runSlice = (reference, command) =>
|
|
50
|
+
SliceHandler.handleCommands(dcbEventLog, Stream.fromIterable([sliceItem(reference, command)]))
|
|
51
|
+
->Effect.runPromise
|
|
52
|
+
|
|
53
|
+
let runAgg = (~aggId="agg-1", reference, command) =>
|
|
54
|
+
Stream.fromIterable([AggregateFixtures.makeTopicItem(~aggId, reference, command)])
|
|
55
|
+
->AggregateFixtures.TestHandler.handleCommands
|
|
56
|
+
->Effect.runPromise
|
|
57
|
+
|
|
58
|
+
// ── Capture ──
|
|
59
|
+
|
|
60
|
+
let observed: ref<array<CommandTopic_Helpers.commandOutcomeReport>> = ref([])
|
|
61
|
+
|
|
62
|
+
// Flattened for readable assertions: (component, reference, disposition, errorCode, eventCount).
|
|
63
|
+
let seen = () =>
|
|
64
|
+
observed.contents->Array.map(r =>
|
|
65
|
+
switch r.outcome {
|
|
66
|
+
| OutcomeAccepted({eventCount}) => (r.component, r.reference, "accepted", "", eventCount)
|
|
67
|
+
| OutcomeRejected({errorCode, cause}) => (
|
|
68
|
+
r.component,
|
|
69
|
+
r.reference,
|
|
70
|
+
switch cause {
|
|
71
|
+
| DomainRejection => "rejected/domain"
|
|
72
|
+
| InfrastructureFailure => "rejected/infrastructure"
|
|
73
|
+
},
|
|
74
|
+
errorCode,
|
|
75
|
+
0,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
let record = () =>
|
|
81
|
+
CommandTopic_Helpers.registerCommandOutcome(r =>
|
|
82
|
+
observed := observed.contents->Array.concat([r])
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
let _ = beforeEach(() => {
|
|
86
|
+
AggregateFixtures.mock.reset()
|
|
87
|
+
AggregateFixtures.TestHandler.resetCache()
|
|
88
|
+
dcbMock.reset()
|
|
89
|
+
SliceHandler.resetCache()
|
|
90
|
+
observed := []
|
|
91
|
+
CommandTopic_Helpers.clearCommandOutcome()
|
|
92
|
+
RuntimeExtension.reset()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("CommandTopic command-outcome hook:", () => {
|
|
96
|
+
describe("aggregate", () => {
|
|
97
|
+
testPromise("observes an accepted command with no side-channel installed", async () => {
|
|
98
|
+
record()
|
|
99
|
+
let results = await runAgg("ref-1", AggregateFixtures.AggSpec.Create({name: "Widget"}))
|
|
100
|
+
|
|
101
|
+
expect(results)->toEqual([Ok("ref-1")])
|
|
102
|
+
expect(seen())->toEqual([("TestAggregate", "ref-1", "accepted", "", 1)])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
testPromise("carries the entity id of an accepted command", async () => {
|
|
106
|
+
record()
|
|
107
|
+
let _ = await runAgg(~aggId="agg-7", "ref-1", AggregateFixtures.AggSpec.Create({name: "W"}))
|
|
108
|
+
|
|
109
|
+
let entityId = switch (observed.contents->Array.getUnsafe(0)).outcome {
|
|
110
|
+
| OutcomeAccepted({entityId}) => entityId
|
|
111
|
+
| OutcomeRejected(_) => None
|
|
112
|
+
}
|
|
113
|
+
expect(entityId)->toEqual(Some("agg-7"))
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
testPromise("reports a decide rejection as domain, with the declared error code", async () => {
|
|
117
|
+
// Seed before registering, so only the second (rejected) Create is observed.
|
|
118
|
+
let _ = await runAgg("ref-seed", AggregateFixtures.AggSpec.Create({name: "Widget"}))
|
|
119
|
+
record()
|
|
120
|
+
let results = await runAgg("ref-1", AggregateFixtures.AggSpec.Create({name: "Widget"}))
|
|
121
|
+
|
|
122
|
+
expect(results)->toEqual([Ok("ref-1")])
|
|
123
|
+
expect(seen())->toEqual([
|
|
124
|
+
("TestAggregate", "ref-1", "rejected/domain", "AlreadyExists", 0),
|
|
125
|
+
])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
testPromise("reports a failed append as infrastructure, not a domain rejection", async () => {
|
|
129
|
+
record()
|
|
130
|
+
AggregateFixtures.mock.failNextAppend := true
|
|
131
|
+
let _ = await runAgg("ref-1", AggregateFixtures.AggSpec.Create({name: "Widget"}))
|
|
132
|
+
|
|
133
|
+
// `decide` said Ok here — the store is what failed, and the hook has to say so.
|
|
134
|
+
expect(seen())->toEqual([
|
|
135
|
+
("TestAggregate", "ref-1", "rejected/infrastructure", "AppendFailed", 0),
|
|
136
|
+
])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
testPromise("observes every command of a mixed batch", async () => {
|
|
140
|
+
let _ = await runAgg("ref-seed", AggregateFixtures.AggSpec.Create({name: "Widget"}))
|
|
141
|
+
record()
|
|
142
|
+
let _ =
|
|
143
|
+
await Stream.fromIterable([
|
|
144
|
+
AggregateFixtures.makeTopicItem("ref-1", AggregateFixtures.AggSpec.Rename({newName: "A"})),
|
|
145
|
+
AggregateFixtures.makeTopicItem("ref-2", AggregateFixtures.AggSpec.Create({name: "B"})),
|
|
146
|
+
AggregateFixtures.makeTopicItem("ref-3", AggregateFixtures.AggSpec.Rename({newName: "C"})),
|
|
147
|
+
])
|
|
148
|
+
->AggregateFixtures.TestHandler.handleCommands
|
|
149
|
+
->Effect.runPromise
|
|
150
|
+
|
|
151
|
+
expect(seen())->toEqual([
|
|
152
|
+
("TestAggregate", "ref-1", "accepted", "", 2),
|
|
153
|
+
("TestAggregate", "ref-2", "rejected/domain", "AlreadyExists", 0),
|
|
154
|
+
("TestAggregate", "ref-3", "accepted", "", 2),
|
|
155
|
+
])
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe("state-change slice", () => {
|
|
160
|
+
testPromise("observes an accepted command with no side-channel installed", async () => {
|
|
161
|
+
record()
|
|
162
|
+
let _ = await runSlice(
|
|
163
|
+
"ref-1",
|
|
164
|
+
DcbFixtures.TestCommandSpec.CreateItem({itemId: "item-1", name: "Test"}),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
expect(seen())->toEqual([
|
|
168
|
+
("TestStateChangeSlice", DcbFixtures.testMeta.msgId, "accepted", "", 1),
|
|
169
|
+
])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
testPromise("reports a decide rejection as domain, with the declared error code", async () => {
|
|
173
|
+
let _ = await runSlice(
|
|
174
|
+
"ref-seed",
|
|
175
|
+
DcbFixtures.TestCommandSpec.CreateItem({itemId: "item-1", name: "Test"}),
|
|
176
|
+
)
|
|
177
|
+
record()
|
|
178
|
+
let _ = await runSlice(
|
|
179
|
+
"ref-1",
|
|
180
|
+
DcbFixtures.TestCommandSpec.CreateItem({itemId: "item-1", name: "Again"}),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
expect(seen())->toEqual([
|
|
184
|
+
(
|
|
185
|
+
"TestStateChangeSlice",
|
|
186
|
+
DcbFixtures.testMeta.msgId,
|
|
187
|
+
"rejected/domain",
|
|
188
|
+
"ItemAlreadyExists",
|
|
189
|
+
0,
|
|
190
|
+
),
|
|
191
|
+
])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
testPromise("reports exhausted append conflicts as infrastructure", async () => {
|
|
195
|
+
record()
|
|
196
|
+
// One more failure than the retry loop can absorb.
|
|
197
|
+
dcbMock.failNextAppends := 4
|
|
198
|
+
let _ = await runSlice(
|
|
199
|
+
"ref-1",
|
|
200
|
+
DcbFixtures.TestCommandSpec.CreateItem({itemId: "item-1", name: "Test"}),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
expect(seen())->toEqual([
|
|
204
|
+
("TestStateChangeSlice", DcbFixtures.testMeta.msgId, "rejected/infrastructure", "Conflict", 0),
|
|
205
|
+
])
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe("registration", () => {
|
|
210
|
+
testPromise("with nothing registered nothing is observed and the command is unaffected", async () => {
|
|
211
|
+
let results = await runAgg("ref-1", AggregateFixtures.AggSpec.Create({name: "Widget"}))
|
|
212
|
+
|
|
213
|
+
expect(results)->toEqual([Ok("ref-1")])
|
|
214
|
+
expect(AggregateFixtures.mock.getAll()->Array.length)->toBe(1)
|
|
215
|
+
expect(observed.contents->Array.length)->toBe(0)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
testPromise("clearCommandOutcome stops observation", async () => {
|
|
219
|
+
record()
|
|
220
|
+
CommandTopic_Helpers.clearCommandOutcome()
|
|
221
|
+
let _ = await runAgg("ref-1", AggregateFixtures.AggSpec.Create({name: "Widget"}))
|
|
222
|
+
|
|
223
|
+
expect(observed.contents->Array.length)->toBe(0)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
testPromise("an out-of-tree extension can register it from onColdStart", async () => {
|
|
227
|
+
// The hook needs no registration path of its own: it has the shape the cold-start
|
|
228
|
+
// seam already reaches, so an extension picks it up alongside the other four.
|
|
229
|
+
module Accountant: RuntimeExtension.Extension = {
|
|
230
|
+
let moduleUrl = "file:///pkg/accountant.res.mjs"
|
|
231
|
+
let onColdStart = (~runtimeKind as _, ~component as _, ~plugin as _, ~platform as _) =>
|
|
232
|
+
record()
|
|
233
|
+
}
|
|
234
|
+
RuntimeExtension.use(module(Accountant: RuntimeExtension.Extension))
|
|
235
|
+
RuntimeExtension.notifyColdStart(
|
|
236
|
+
~runtimeKind=ComponentType.Aggregate,
|
|
237
|
+
~component="AllAggregates",
|
|
238
|
+
~plugin=Some("Catalog"),
|
|
239
|
+
~platform=Some("Shop"),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
let _ = await runAgg("ref-1", AggregateFixtures.AggSpec.Create({name: "Widget"}))
|
|
243
|
+
|
|
244
|
+
expect(seen())->toEqual([("TestAggregate", "ref-1", "accepted", "", 1)])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
testPromise("a throwing hook is swallowed and the command still succeeds", async () => {
|
|
248
|
+
CommandTopic_Helpers.registerCommandOutcome(_ =>
|
|
249
|
+
JsError.throwWithMessage("hook exploded")
|
|
250
|
+
)
|
|
251
|
+
let results = await runAgg("ref-1", AggregateFixtures.AggSpec.Create({name: "Widget"}))
|
|
252
|
+
|
|
253
|
+
// The outcome the hook observes has already happened — an observer must not undo it.
|
|
254
|
+
expect(results)->toEqual([Ok("ref-1")])
|
|
255
|
+
expect(AggregateFixtures.mock.getAll()->Array.length)->toBe(1)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
})
|