@reventlessdev/reventless-core 3.0.0-alpha.215 → 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 (30) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +6 -6
  3. package/src/admin/Platform_Admin_Structure.res +4 -0
  4. package/src/admin/Platform_Admin_Structure.res.mjs +5 -0
  5. package/src/admin/Platform_ComponentDefinitionsApi.res +16 -1
  6. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +24 -1
  7. package/src/components/Aggregate/Aggregate_Callback.res +14 -2
  8. package/src/components/Aggregate/Aggregate_Callback.res.mjs +3 -3
  9. package/src/components/CommandTopic/CommandTopic.res.mjs +12 -0
  10. package/src/components/CommandTopic/CommandTopic_Helpers.res +88 -2
  11. package/src/components/CommandTopic/CommandTopic_Helpers.res.mjs +48 -2
  12. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +13 -1
  13. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +4 -4
  14. package/src/plugin/component/Plugin_Builder.res +1 -0
  15. package/src/plugin/component/Plugin_Builder.res.mjs +1 -0
  16. package/src/plugin/component/Plugin_Structure.res +66 -51
  17. package/src/plugin/component/Plugin_Structure.res.mjs +61 -46
  18. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +21 -0
  19. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +21 -0
  20. package/tests/commandtopic/CommandOutcomeHookTest.res +258 -0
  21. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +372 -0
  22. package/tests/commandtopic/CommandTopicHelpersRejectionTest.res +9 -1
  23. package/tests/commandtopic/CommandTopicHelpersRejectionTest.res.mjs +3 -3
  24. package/tests/message/MessageTest.res +1 -0
  25. package/tests/message/MessageTest.res.mjs +1 -0
  26. package/tests/plugin/PluginStructureTest.res +27 -0
  27. package/tests/plugin/PluginStructureTest.res.mjs +27 -0
  28. package/tests/plugin/StateChangeSlice/PsShipOrder.res +3 -1
  29. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -1
  30. package/tests/plugin/pluginDefinitionRequiredScalars.txt +8 -0
@@ -157,6 +157,70 @@ let labelFieldsFromStateSchema = (
157
157
  }
158
158
  }
159
159
 
160
+ // ── Per-variant event / error field extraction (Phase 6.3) ───────────────────
161
+ // Mirrors toCommandDef/extractCommandDefs for emitted events: name (TAG const),
162
+ // payload JSON Schema, and cross-entity references. Module-level rather than
163
+ // inside `make` because the synthetic Platform_Admin structure — hand-written,
164
+ // because its components never pass through `make` — has to derive its defs the
165
+ // same way, and a second copy of this walk would be free to drift from this one.
166
+
167
+ let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
168
+ let mkDef = (~variantName, ~properties) => {
169
+ let references =
170
+ properties
171
+ ->Dict.toArray
172
+ ->Array.filterMap(((fieldName, fieldSchema)) =>
173
+ Reventless.Reference.getTarget(fieldSchema)->Option.map(target => (
174
+ {
175
+ Reventless.Plugin.fieldName,
176
+ entity: target.entity,
177
+ plugin: target.plugin,
178
+ }: Reventless.Plugin.fieldReference
179
+ ))
180
+ )
181
+ ({
182
+ Reventless.Plugin.name: variantName,
183
+ // Derived for the same reason as `commandDef.schema`: an event's field
184
+ // markers are the write side's half of the same vocabulary.
185
+ schema: v->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
186
+ references,
187
+ }: Reventless.Plugin.eventDef)
188
+ }
189
+ switch v {
190
+ | Object({properties}) =>
191
+ properties
192
+ ->Dict.get("TAG")
193
+ ->Option.flatMap(tagSchema =>
194
+ switch tagSchema {
195
+ | String({const: ?Some(variantName)}) => Some(mkDef(~variantName, ~properties))
196
+ | _ => None
197
+ }
198
+ )
199
+ // Payload-less event variants (`| Archived`) compile to a bare string literal.
200
+ // DCB-projection lookups can't WHERE-clause on them, so they stay out of
201
+ // producedEventTypes/consumedEventTypes; but the full `events` list carries them
202
+ // so the event graph can still draw the emitted (orphan) event node.
203
+ | String({const: ?Some(variantName)}) => Some(mkDef(~variantName, ~properties=Dict.make()))
204
+ | _ => None
205
+ }
206
+ }
207
+
208
+ let extractEventDefs = (eventSchema: S.t<unknown>): array<Reventless.Plugin.eventDef> =>
209
+ switch eventSchema {
210
+ | Union({anyOf}) => anyOf->Array.filterMap(toEventDef)
211
+ | _ => toEventDef(eventSchema)->Option.mapOr([], def => [def])
212
+ }
213
+
214
+ // Declared errors walk identically to emitted events — an error variant is a
215
+ // variant, and the payload-less form (`| CategoryNotFound`) is the common case
216
+ // the bare-string branch above already handles. Reusing the walk rather than
217
+ // copying it keeps the two from drifting; only the def type differs, so that a
218
+ // consumer (and the SDL) can tell a refusal from a fact.
219
+ let extractErrorDefs = (errorSchema: S.t<unknown>): array<Reventless.Plugin.errorDef> =>
220
+ extractEventDefs(errorSchema)->Array.map(({name, schema, references}) => (
221
+ {name, schema, references}: Reventless.Plugin.errorDef
222
+ ))
223
+
160
224
  let make = (
161
225
  type api role,
162
226
  ~name: string,
@@ -330,57 +394,6 @@ let make = (
330
394
  ->Option.mapOr([], def => [def])
331
395
  }
332
396
 
333
- // ── Per-variant event field extraction (Phase 6.3) ─────────────────────────
334
- // Mirrors toCommandDef/extractCommandDefs for emitted events: name (TAG const),
335
- // payload JSON Schema, and cross-entity references.
336
-
337
- let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
338
- let mkDef = (~variantName, ~properties) => {
339
- let references =
340
- properties
341
- ->Dict.toArray
342
- ->Array.filterMap(((fieldName, fieldSchema)) =>
343
- Reventless.Reference.getTarget(fieldSchema)->Option.map(target => (
344
- {
345
- Reventless.Plugin.fieldName,
346
- entity: target.entity,
347
- plugin: target.plugin,
348
- }: Reventless.Plugin.fieldReference
349
- ))
350
- )
351
- ({
352
- Reventless.Plugin.name: variantName,
353
- // Derived for the same reason as `commandDef.schema` above: an event's
354
- // field markers are the write side's half of the same vocabulary.
355
- schema: v->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
356
- references,
357
- }: Reventless.Plugin.eventDef)
358
- }
359
- switch v {
360
- | Object({properties}) =>
361
- properties
362
- ->Dict.get("TAG")
363
- ->Option.flatMap(tagSchema =>
364
- switch tagSchema {
365
- | String({const: ?Some(variantName)}) => Some(mkDef(~variantName, ~properties))
366
- | _ => None
367
- }
368
- )
369
- // Payload-less event variants (`| Archived`) compile to a bare string literal.
370
- // DCB-projection lookups can't WHERE-clause on them, so they stay out of
371
- // producedEventTypes/consumedEventTypes; but the full `events` list carries them
372
- // so the event graph can still draw the emitted (orphan) event node.
373
- | String({const: ?Some(variantName)}) => Some(mkDef(~variantName, ~properties=Dict.make()))
374
- | _ => None
375
- }
376
- }
377
-
378
- let extractEventDefs = (eventSchema: S.t<unknown>): array<Reventless.Plugin.eventDef> =>
379
- switch eventSchema {
380
- | Union({anyOf}) => anyOf->Array.filterMap(toEventDef)
381
- | _ => toEventDef(eventSchema)->Option.mapOr([], def => [def])
382
- }
383
-
384
397
  // ── Per-component event type extraction ────────────────────────────────────
385
398
 
386
399
  let scsProduced =
@@ -704,6 +717,7 @@ let make = (
704
717
  linkedViews: linkedSvsFor(produced),
705
718
  consistencyRead: consistencyReadFor(consumed),
706
719
  events: extractEventDefs(SCS.Spec.eventSchema->S.castToUnknown),
720
+ errors: extractErrorDefs(SCS.Spec.errorSchema->S.castToUnknown),
707
721
  chapter: chapterOf(SCS.Spec.name),
708
722
  }: Reventless.Plugin.writableDef)
709
723
  })
@@ -726,6 +740,7 @@ let make = (
726
740
  linkedViews: Array.concat(linkedSvsFor(produced), linkedReadModelsFor(A.Spec.name)),
727
741
  consistencyRead: None,
728
742
  events: extractEventDefs(A.Spec.eventSchema->S.castToUnknown),
743
+ errors: extractErrorDefs(A.Spec.errorSchema->S.castToUnknown),
729
744
  chapter: chapterOf(A.Spec.name),
730
745
  }: Reventless.Plugin.writableDef)
731
746
  })
@@ -140,6 +140,62 @@ function labelFieldsFromStateSchema(entityName, stateSchema) {
140
140
  };
141
141
  }
142
142
 
143
+ function toEventDef(v) {
144
+ let mkDef = (variantName, properties) => {
145
+ let references = Stdlib_Array.filterMap(Object.entries(properties), param => {
146
+ let fieldName = param[0];
147
+ return Stdlib_Option.map(Reference$Reventless.getTarget(param[1]), target => ({
148
+ fieldName: fieldName,
149
+ entity: target.entity,
150
+ plugin: target.plugin
151
+ }));
152
+ });
153
+ return {
154
+ name: variantName,
155
+ schema: JSON.stringify(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(v)),
156
+ references: references
157
+ };
158
+ };
159
+ switch (v.type) {
160
+ case "string" :
161
+ let variantName = v.const;
162
+ if (variantName !== undefined) {
163
+ return mkDef(variantName, {});
164
+ } else {
165
+ return;
166
+ }
167
+ case "object" :
168
+ let properties = v.properties;
169
+ return Stdlib_Option.flatMap(properties["TAG"], tagSchema => {
170
+ if (tagSchema.type !== "string") {
171
+ return;
172
+ }
173
+ let variantName = tagSchema.const;
174
+ if (variantName !== undefined) {
175
+ return mkDef(variantName, properties);
176
+ }
177
+ });
178
+ default:
179
+ return;
180
+ }
181
+ }
182
+
183
+ function extractEventDefs(eventSchema) {
184
+ if (eventSchema.type === "union") {
185
+ return Stdlib_Array.filterMap(eventSchema.anyOf, toEventDef);
186
+ } else {
187
+ return Stdlib_Option.mapOr(toEventDef(eventSchema), [], def => [def]);
188
+ }
189
+ }
190
+
191
+ function extractErrorDefs(errorSchema) {
192
+ return extractEventDefs(errorSchema).map(param => ({
193
+ name: param.name,
194
+ schema: param.schema,
195
+ references: param.references
196
+ }));
197
+ }
198
+
143
199
  function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChangeSlicesOpt, automationSlicesOpt, outboundTranslationSlicesOpt, inboundTranslationSlicesOpt, extensionsOpt, extensionPointsOpt, componentChaptersOpt) {
144
200
  let aggregates = aggregatesOpt !== undefined ? aggregatesOpt : [];
145
201
  let readModels = readModelsOpt !== undefined ? readModelsOpt : [];
@@ -268,52 +324,6 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
268
324
  return Stdlib_Option.mapOr(toCommandDef(isAggregate, mutationFieldFor, commandSchema, commandSchema), [], def => [def]);
269
325
  }
270
326
  };
271
- let toEventDef = v => {
272
- let mkDef = (variantName, properties) => {
273
- let references = Stdlib_Array.filterMap(Object.entries(properties), param => {
274
- let fieldName = param[0];
275
- return Stdlib_Option.map(Reference$Reventless.getTarget(param[1]), target => ({
276
- fieldName: fieldName,
277
- entity: target.entity,
278
- plugin: target.plugin
279
- }));
280
- });
281
- return {
282
- name: variantName,
283
- schema: JSON.stringify(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(v)),
284
- references: references
285
- };
286
- };
287
- switch (v.type) {
288
- case "string" :
289
- let variantName = v.const;
290
- if (variantName !== undefined) {
291
- return mkDef(variantName, {});
292
- } else {
293
- return;
294
- }
295
- case "object" :
296
- let properties = v.properties;
297
- return Stdlib_Option.flatMap(properties["TAG"], tagSchema => {
298
- if (tagSchema.type !== "string") {
299
- return;
300
- }
301
- let variantName = tagSchema.const;
302
- if (variantName !== undefined) {
303
- return mkDef(variantName, properties);
304
- }
305
- });
306
- default:
307
- return;
308
- }
309
- };
310
- let extractEventDefs = eventSchema => {
311
- if (eventSchema.type === "union") {
312
- return Stdlib_Array.filterMap(eventSchema.anyOf, toEventDef);
313
- } else {
314
- return Stdlib_Option.mapOr(toEventDef(eventSchema), [], def => [def]);
315
- }
316
- };
317
327
  let scsProduced = stateChangeSlices.map(SCS => [
318
328
  SCS.Spec.name,
319
329
  qualify(name, DcbTag$Reventless.extractVariantNames(SCS.Spec.eventSchema))
@@ -541,6 +551,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
541
551
  linkedViews: linkedSvsFor(produced),
542
552
  consistencyRead: consistencyReadFor(consumed),
543
553
  events: extractEventDefs(SCS.Spec.eventSchema),
554
+ errors: extractErrorDefs(SCS.Spec.errorSchema),
544
555
  chapter: componentChapters[SCS.Spec.name]
545
556
  };
546
557
  });
@@ -555,6 +566,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
555
566
  linkedViews: linkedSvsFor(produced).concat(linkedReadModelsFor(A.Spec.name)),
556
567
  consistencyRead: undefined,
557
568
  events: extractEventDefs(A.Spec.eventSchema),
569
+ errors: extractErrorDefs(A.Spec.errorSchema),
558
570
  chapter: componentChapters[A.Spec.name]
559
571
  };
560
572
  });
@@ -641,6 +653,9 @@ export {
641
653
  statusFieldFromStateSchema,
642
654
  labelFieldSourceToString,
643
655
  labelFieldsFromStateSchema,
656
+ toEventDef,
657
+ extractEventDefs,
658
+ extractErrorDefs,
644
659
  make,
645
660
  }
646
661
  /* log 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
+ })