@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
@@ -1,11 +1,42 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
4
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
6
  import * as Effect from "effect/Effect";
6
7
  import * as Stream from "effect/Stream";
7
8
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
8
9
  import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
10
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
11
+ import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
12
+
13
+ let commandOutcomeHook = {
14
+ contents: undefined
15
+ };
16
+
17
+ function registerCommandOutcome(hook) {
18
+ commandOutcomeHook.contents = hook;
19
+ }
20
+
21
+ function clearCommandOutcome() {
22
+ commandOutcomeHook.contents = undefined;
23
+ }
24
+
25
+ function fireCommandOutcome(component, reference, outcome) {
26
+ Stdlib_Option.forEach(commandOutcomeHook.contents, hook => {
27
+ try {
28
+ hook({
29
+ component: component,
30
+ reference: reference,
31
+ outcome: outcome
32
+ });
33
+ return;
34
+ } catch (raw_err) {
35
+ let err = Primitive_exceptions.internalToException(raw_err);
36
+ return Effect.runSync(EffectLogger$ReventlessCore.logError(`CommandTopic(` + component + `)`, undefined, `command-outcome hook threw (ignored): ` + Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(err), Stdlib_JsExn.message), "unknown")));
37
+ }
38
+ });
39
+ }
9
40
 
10
41
  let acceptedResultChannel = {
11
42
  contents: undefined
@@ -15,12 +46,23 @@ let rejectedResultChannel = {
15
46
  contents: undefined
16
47
  };
17
48
 
18
- function reportAccepted(reference, result) {
49
+ function reportAccepted(component, reference, result) {
19
50
  Stdlib_Option.forEach(acceptedResultChannel.contents, cb => cb(reference, result));
51
+ fireCommandOutcome(component, reference, {
52
+ TAG: "OutcomeAccepted",
53
+ entityId: result.entityId,
54
+ eventCount: result.eventCount
55
+ });
20
56
  }
21
57
 
22
- function reportRejected(reference, result) {
58
+ function reportRejected(component, cause, reference, result) {
23
59
  Stdlib_Option.forEach(rejectedResultChannel.contents, cb => cb(reference, result));
60
+ fireCommandOutcome(component, reference, {
61
+ TAG: "OutcomeRejected",
62
+ errorCode: result.errorCode,
63
+ errorDetail: result.errorDetail,
64
+ cause: cause
65
+ });
24
66
  }
25
67
 
26
68
  function commandOutcomeToJson(outcome) {
@@ -179,6 +221,10 @@ function getHandlers(typeName) {
179
221
  }
180
222
 
181
223
  export {
224
+ commandOutcomeHook,
225
+ registerCommandOutcome,
226
+ clearCommandOutcome,
227
+ fireCommandOutcome,
182
228
  acceptedResultChannel,
183
229
  rejectedResultChannel,
184
230
  reportAccepted,
@@ -322,6 +322,7 @@ module Make = (
322
322
  // No append, but the read snapshot is valid — cache it for the next command.
323
323
  cachePut((state, headPosition))
324
324
  CommandTopic_Helpers.reportAccepted(
325
+ ~component=Spec.name,
325
326
  cmdJson.meta.msgId,
326
327
  switch entityId {
327
328
  | Some(eid) => {entityId: eid, eventCount: 0}
@@ -373,6 +374,7 @@ module Make = (
373
374
  // next command's delta read picks our events up from `headPosition`.
374
375
  cachePut((state, headPosition))
375
376
  CommandTopic_Helpers.reportAccepted(
377
+ ~component=Spec.name,
376
378
  cmdJson.meta.msgId,
377
379
  switch entityId {
378
380
  | Some(eid) => {entityId: eid, eventCount: rawEvents->Array.length}
@@ -412,7 +414,12 @@ module Make = (
412
414
  if errorCode == "Conflict" {
413
415
  Metrics.emitCount(~metric="AppendConflict", ~slice=Spec.name)
414
416
  }
417
+ // Both arms here are storage, not the model: a surfaced Conflict is
418
+ // contention the retry loop couldn't absorb, and AppendFailed is the
419
+ // store failing. `decide` had already said Ok.
415
420
  CommandTopic_Helpers.reportRejected(
421
+ ~component=Spec.name,
422
+ ~cause=InfrastructureFailure,
416
423
  cmdJson.meta.msgId,
417
424
  {errorCode, errorDetail: errDetail},
418
425
  )
@@ -433,7 +440,12 @@ module Make = (
433
440
  payloadDict->Dict.toArray->Array.length == 0
434
441
  ? ""
435
442
  : payloadDict->JSON.Encode.object->JSON.stringify
436
- CommandTopic_Helpers.reportRejected(cmdJson.meta.msgId, {errorCode, errorDetail})
443
+ CommandTopic_Helpers.reportRejected(
444
+ ~component=Spec.name,
445
+ ~cause=DomainRejection,
446
+ cmdJson.meta.msgId,
447
+ {errorCode, errorDetail},
448
+ )
437
449
  EffectLogger.logError(
438
450
  ~comp,
439
451
  `decide rejected: ${errorCode} ${errorDetail}`,
@@ -173,7 +173,7 @@ function Make(Spec) {
173
173
  state,
174
174
  headPosition
175
175
  ]);
176
- CommandTopic_Helpers$ReventlessCore.reportAccepted(cmdJson.meta.msgId, entityId !== undefined ? ({
176
+ CommandTopic_Helpers$ReventlessCore.reportAccepted(Spec.name, cmdJson.meta.msgId, entityId !== undefined ? ({
177
177
  entityId: entityId,
178
178
  eventCount: 0
179
179
  }) : ({
@@ -223,7 +223,7 @@ function Make(Spec) {
223
223
  state,
224
224
  headPosition
225
225
  ]);
226
- CommandTopic_Helpers$ReventlessCore.reportAccepted(cmdJson.meta.msgId, entityId !== undefined ? ({
226
+ CommandTopic_Helpers$ReventlessCore.reportAccepted(Spec.name, cmdJson.meta.msgId, entityId !== undefined ? ({
227
227
  entityId: entityId,
228
228
  eventCount: rawEvents.length
229
229
  }) : ({
@@ -257,7 +257,7 @@ function Make(Spec) {
257
257
  if (errorCode === "Conflict") {
258
258
  Metrics$ReventlessCore.emitCount("AppendConflict", Spec.name, undefined);
259
259
  }
260
- CommandTopic_Helpers$ReventlessCore.reportRejected(cmdJson.meta.msgId, {
260
+ CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "InfrastructureFailure", cmdJson.meta.msgId, {
261
261
  errorCode: errorCode,
262
262
  errorDetail: errDetail
263
263
  });
@@ -277,7 +277,7 @@ function Make(Spec) {
277
277
  let match = Message$ReventlessCore.splitMessage(errorJson);
278
278
  let payloadDict = match[1];
279
279
  let errorDetail = Object.entries(payloadDict).length === 0 ? "" : JSON.stringify(payloadDict);
280
- CommandTopic_Helpers$ReventlessCore.reportRejected(cmdJson.meta.msgId, {
280
+ CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "DomainRejection", cmdJson.meta.msgId, {
281
281
  errorCode: errorCode,
282
282
  errorDetail: errorDetail
283
283
  });
@@ -529,6 +529,7 @@ module Make = (
529
529
  {
530
530
  commandTypes: extractTypes(Scs.Spec.commandSchema),
531
531
  eventTypes: extractEventTypes(Scs.Spec.eventSchema->S.castToUnknown),
532
+ errorTypes: extractTypes(Scs.Spec.errorSchema),
532
533
  consumedEventTypes: extractEventTypes(Scs.Spec.consumedEventSchema->S.castToUnknown),
533
534
  chapter: ?chapterFor(Scs.Spec.name),
534
535
  },
@@ -373,6 +373,7 @@ function Make(Spec) {
373
373
  stateSchemas[Scs.Spec.name] = {
374
374
  commandTypes: DcbTag$Reventless.extractAllVariantNames(Scs.Spec.commandSchema),
375
375
  eventTypes: DcbTag$Reventless.extractVariantNames(Scs.Spec.eventSchema),
376
+ errorTypes: DcbTag$Reventless.extractAllVariantNames(Scs.Spec.errorSchema),
376
377
  consumedEventTypes: DcbTag$Reventless.extractVariantNames(Scs.Spec.consumedEventSchema),
377
378
  chapter: chapterByName[Scs.Spec.name]
378
379
  };
@@ -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 */
@@ -0,0 +1,84 @@
1
+ open JestGlobals
2
+
3
+ S.enableJson()
4
+
5
+ // The point of the seam, end to end at the hook level: an extension that only
6
+ // ever gets `onColdStart` can reach the framework's runtime callback hooks from
7
+ // there, and they take effect on the requests that follow.
8
+ //
9
+ // `CommandGenerator_Callback.registerCommandInterceptor` stands in for all four
10
+ // registrars — they share the shape (a module-level ref consulted on the hot
11
+ // path), so what makes one reachable makes the others reachable too. What is
12
+ // being proved is the ordering: registration happens inside the cold-start hook,
13
+ // the command runs after it, and the interceptor is consulted.
14
+
15
+ @schema
16
+ type command = Add({@partitionTag productId: string, name: string})
17
+
18
+ let published: ref<array<Message.commandJson>> = ref([])
19
+ let publishJsons: CommandGenerator.publishJsons = async cmds =>
20
+ published := published.contents->Array.concat(cmds)
21
+
22
+ let generateCommand = CommandGenerator_Callback.makeGenerateCommand(
23
+ ~publishJsons,
24
+ ~serviceName="Product",
25
+ ~commandSchema=commandSchema->S.castToUnknown,
26
+ ~componentKind=CommandGenerator_Callback.StateChangeSlice,
27
+ ~stripIdFromParams=false,
28
+ )
29
+
30
+ let addChair = (): CommandGenerator.payload =>
31
+ Obj.magic({
32
+ "command": "Add",
33
+ "arguments": {"productId": "p1", "name": "Chair"},
34
+ "meta": {"ip": ["127.0.0.1"], "user": "test-user", "info": ""},
35
+ })
36
+
37
+ let runAdd = async () => await generateCommand(addChair())->Effect.runPromise
38
+
39
+ describe("RuntimeExtension reaches the runtime callback hooks", () => {
40
+ beforeEach(() => {
41
+ RuntimeExtension.reset()
42
+ CommandGenerator_Callback.clearCommandInterceptor()
43
+ published := []
44
+ })
45
+
46
+ testPromise("an interceptor registered at cold start denies a later command", async () => {
47
+ let seenAtColdStart: array<string> = []
48
+ module Gatekeeper: RuntimeExtension.Extension = {
49
+ let moduleUrl = "file:///pkg/gatekeeper.res.mjs"
50
+ let onColdStart = (~runtimeKind as _, ~component, ~plugin as _, ~platform as _) => {
51
+ seenAtColdStart->Array.push(component)
52
+ CommandGenerator_Callback.registerCommandInterceptor(async (
53
+ ~identity as _,
54
+ ~componentName,
55
+ ~componentKind as _,
56
+ ~tag,
57
+ ~args as _,
58
+ ) => CommandGenerator_Callback.Deny(`${componentName}.${tag} refused`))
59
+ }
60
+ }
61
+ RuntimeExtension.use(module(Gatekeeper: RuntimeExtension.Extension))
62
+
63
+ RuntimeExtension.notifyColdStart(
64
+ ~runtimeKind=ComponentType.StateChangeSlice,
65
+ ~component="CatalogStateChanges",
66
+ ~plugin=Some("Catalog"),
67
+ ~platform=Some("Shop"),
68
+ )
69
+ expect(seenAtColdStart)->toEqual(["CatalogStateChanges"])
70
+
71
+ let denied = switch await runAdd() {
72
+ | _ => None
73
+ | exception exn => exn->JsExn.fromException->Option.flatMap(JsExn.message)
74
+ }
75
+
76
+ expect(denied)->toEqual(Some("Product.Add refused"))
77
+ expect(published.contents->Array.length)->toBe(0)
78
+ })
79
+
80
+ testPromise("with nothing registered the same command publishes untouched", async () => {
81
+ let _ = await runAdd()
82
+ expect(published.contents->Array.length)->toBe(1)
83
+ })
84
+ })
@@ -0,0 +1,95 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
8
+ import * as RuntimeExtension$ReventlessCore from "../../src/adapter/RuntimeExtension/RuntimeExtension.res.mjs";
9
+ import * as CommandGenerator_Callback$ReventlessCore from "../../src/components/CommandGenerator/CommandGenerator_Callback.res.mjs";
10
+
11
+ S.enableJson();
12
+
13
+ let commandSchema = S.schema(s => ({
14
+ TAG: "Add",
15
+ productId: s.m(S.string),
16
+ name: s.m(S.string)
17
+ }));
18
+
19
+ let published = {
20
+ contents: []
21
+ };
22
+
23
+ async function publishJsons(cmds) {
24
+ published.contents = published.contents.concat(cmds);
25
+ }
26
+
27
+ let generateCommand = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "Product", commandSchema, "StateChangeSlice", false);
28
+
29
+ function addChair() {
30
+ return {
31
+ command: "Add",
32
+ arguments: {
33
+ productId: "p1",
34
+ name: "Chair"
35
+ },
36
+ meta: {
37
+ ip: ["127.0.0.1"],
38
+ user: "test-user",
39
+ info: ""
40
+ }
41
+ };
42
+ }
43
+
44
+ async function runAdd() {
45
+ return await Effect.runPromise(generateCommand(addChair()));
46
+ }
47
+
48
+ globalThis.describe("RuntimeExtension reaches the runtime callback hooks", () => {
49
+ globalThis.beforeEach(() => {
50
+ RuntimeExtension$ReventlessCore.reset();
51
+ CommandGenerator_Callback$ReventlessCore.clearCommandInterceptor();
52
+ published.contents = [];
53
+ });
54
+ globalThis.test("an interceptor registered at cold start denies a later command", async () => {
55
+ let seenAtColdStart = [];
56
+ let onColdStart = (param, component, param$1, param$2) => {
57
+ seenAtColdStart.push(component);
58
+ CommandGenerator_Callback$ReventlessCore.registerCommandInterceptor(async (param, componentName, param$1, tag, param$2) => ({
59
+ TAG: "Deny",
60
+ _0: componentName + `.` + tag + ` refused`
61
+ }));
62
+ };
63
+ let Gatekeeper = {
64
+ moduleUrl: "file:///pkg/gatekeeper.res.mjs",
65
+ onColdStart: onColdStart
66
+ };
67
+ RuntimeExtension$ReventlessCore.use(Gatekeeper);
68
+ RuntimeExtension$ReventlessCore.notifyColdStart("StateChangeSlice", "CatalogStateChanges", "Catalog", "Shop");
69
+ globalThis.expect(seenAtColdStart).toEqual(["CatalogStateChanges"]);
70
+ let denied;
71
+ try {
72
+ await runAdd();
73
+ denied = undefined;
74
+ } catch (raw_exn) {
75
+ let exn = Primitive_exceptions.internalToException(raw_exn);
76
+ denied = Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message);
77
+ }
78
+ globalThis.expect(denied).toEqual("Product.Add refused");
79
+ globalThis.expect(published.contents.length).toBe(0);
80
+ });
81
+ globalThis.test("with nothing registered the same command publishes untouched", async () => {
82
+ await runAdd();
83
+ globalThis.expect(published.contents.length).toBe(1);
84
+ });
85
+ });
86
+
87
+ export {
88
+ commandSchema,
89
+ published,
90
+ publishJsons,
91
+ generateCommand,
92
+ addChair,
93
+ runAdd,
94
+ }
95
+ /* Not a pure module */