@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.
- package/CHANGELOG.md +8 -0
- package/package.json +6 -6
- 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/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
|
@@ -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
|
+
})
|