@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +7 -7
  3. package/src/adapter/RuntimeExtension/RuntimeExtension.res +194 -0
  4. package/src/adapter/RuntimeExtension/RuntimeExtension.res.mjs +60 -0
  5. package/src/admin/Platform_Admin_Structure.res +4 -0
  6. package/src/admin/Platform_Admin_Structure.res.mjs +5 -0
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +16 -1
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +24 -1
  9. package/src/components/Aggregate/Aggregate_Callback.res +14 -2
  10. package/src/components/Aggregate/Aggregate_Callback.res.mjs +3 -3
  11. package/src/components/CommandTopic/CommandTopic.res.mjs +12 -0
  12. package/src/components/CommandTopic/CommandTopic_Helpers.res +88 -2
  13. package/src/components/CommandTopic/CommandTopic_Helpers.res.mjs +48 -2
  14. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +13 -1
  15. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +4 -4
  16. package/src/plugin/component/Plugin_Builder.res +1 -0
  17. package/src/plugin/component/Plugin_Builder.res.mjs +1 -0
  18. package/src/plugin/component/Plugin_Structure.res +66 -51
  19. package/src/plugin/component/Plugin_Structure.res.mjs +61 -46
  20. package/tests/adapter/RuntimeExtensionHookReachTest.res +84 -0
  21. package/tests/adapter/RuntimeExtensionHookReachTest.res.mjs +95 -0
  22. package/tests/adapter/RuntimeExtensionTest.res +127 -0
  23. package/tests/adapter/RuntimeExtensionTest.res.mjs +111 -0
  24. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +21 -0
  25. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +21 -0
  26. package/tests/commandtopic/CommandOutcomeHookTest.res +258 -0
  27. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +372 -0
  28. package/tests/commandtopic/CommandTopicHelpersRejectionTest.res +9 -1
  29. package/tests/commandtopic/CommandTopicHelpersRejectionTest.res.mjs +3 -3
  30. package/tests/message/MessageTest.res +1 -0
  31. package/tests/message/MessageTest.res.mjs +1 -0
  32. package/tests/plugin/PluginStructureTest.res +27 -0
  33. package/tests/plugin/PluginStructureTest.res.mjs +27 -0
  34. package/tests/plugin/StateChangeSlice/PsShipOrder.res +3 -1
  35. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -1
  36. 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
+ })