@reventlessdev/reventless-core 3.0.0-alpha.262 → 3.0.0-alpha.264

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 (34) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +9 -9
  3. package/src/admin/Platform_Admin_Structure.res.mjs +1 -1
  4. package/src/components/Aggregate/Aggregate_Builder.res.mjs +1 -1
  5. package/src/components/CommandGenerator/CommandGenerator.res +4 -4
  6. package/src/components/CommandGenerator/CommandGenerator_Callback.res +13 -20
  7. package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +3 -24
  8. package/src/components/Dcb/Dcb_Builder.res +45 -49
  9. package/src/components/Dcb/Dcb_Builder.res.mjs +31 -40
  10. package/src/components/StateChangeSlice/StateChangeSlice.res +2 -0
  11. package/src/components/StateChangeSlice/StateChangeSlice_Builder.res +6 -0
  12. package/src/components/StateChangeSlice/StateChangeSlice_Builder.res.mjs +4 -4
  13. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +51 -22
  14. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +47 -25
  15. package/src/plugin/component/Plugin_Structure.res +101 -4
  16. package/src/plugin/component/Plugin_Structure.res.mjs +82 -9
  17. package/tests/adapter/RuntimeExtensionHookReachTest.res.mjs +1 -1
  18. package/tests/commandgenerator/CommandGeneratorCallbackTest.res +1 -1
  19. package/tests/commandgenerator/CommandGeneratorCallbackTest.res.mjs +1 -1
  20. package/tests/commandgenerator/CommandGeneratorFixtures.res +2 -0
  21. package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +17 -3
  22. package/tests/commandgenerator/OwnerStampingTest.res.mjs +2 -2
  23. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +1 -1
  24. package/tests/dcb/DcbFixtures.res +18 -1
  25. package/tests/dcb/DcbFixtures.res.mjs +14 -0
  26. package/tests/dcb/DcbScopeInferenceTest.res +234 -0
  27. package/tests/dcb/DcbScopeInferenceTest.res.mjs +425 -13
  28. package/tests/dcb/DcbStateChangeSliceTest.res.mjs +22 -22
  29. package/tests/dcb/DcbTagTest.res +81 -68
  30. package/tests/dcb/DcbTagTest.res.mjs +88 -89
  31. package/tests/plugin/PluginStructureAccessTest.res +1 -0
  32. package/tests/plugin/PluginStructureAccessTest.res.mjs +1 -1
  33. package/tests/plugin/PluginStructureTest.res +47 -0
  34. package/tests/plugin/PluginStructureTest.res.mjs +50 -10
@@ -4,6 +4,8 @@ module type T = {
4
4
  let handleCommands: (
5
5
  ~tagKeysByEventType: Dict.t<array<string>>=?,
6
6
  ~crossPartitionTagKeys: array<string>=?,
7
+ /** This slice's partition within its boundary; inferred from the slice alone when absent. */
8
+ ~partitionTag: Reventless.DcbTag.derivedPartitionTag=?,
7
9
  DcbEventLog.operations,
8
10
  Stream.t<
9
11
  CommandTopic.topicItem<Message.command'<Reventless.Id.String.t, Spec.command>>,
@@ -74,18 +76,32 @@ module Make = (
74
76
  {eventType, data: JSON.Object(data), tags, meta}
75
77
  }
76
78
 
77
- // Computed once at functor init — used to extract entityId for publishJsonsAndWait outcomes.
78
- let derivedPartitionTag = Reventless.DcbTag.derivePartitionTag([
79
- (Spec.name, Behavior.moduleUrl, Spec.eventSchema->S.castToUnknown),
80
- ])
79
+ // The partition inferred from this slice alone, for callers that do not thread
80
+ // the boundary's. Lazy and non-throwing: it only names ids in logs and outcomes,
81
+ // and a slice that needs its siblings to resolve must not fail to load for that.
82
+ let slicePartitionTag = Lazy.make(() =>
83
+ try Some(
84
+ Reventless.DcbTag.deriveSlicePartition({
85
+ name: Spec.name,
86
+ commandSchema: Spec.commandSchema->S.castToUnknown,
87
+ consumedEventSchema: Spec.consumedEventSchema->S.castToUnknown,
88
+ eventSchema: Spec.eventSchema->S.castToUnknown,
89
+ moduleUrl: Spec.moduleUrl,
90
+ }),
91
+ ) catch {
92
+ | _ => None
93
+ }
94
+ )
81
95
 
82
96
  // Extracts the entity id of a read event from its own tags, for logging
83
97
  // which events the decision model was built from. Prefers this slice's
84
- // partition key (derived from the produced event), but consumed events from
85
- // other sources are tagged by their own key (e.g. CatalogProductSynced →
86
- // productId, not this slice's orderId), so fall back to the read event's own
87
- // tag value(s) instead of logging no id.
88
- let readEventId = (tags: array<Reventless.DcbTag.tag>): option<string> => {
98
+ // partition key, but consumed events from other sources are tagged by their
99
+ // own key (e.g. CatalogProductSynced → productId, not this slice's orderId),
100
+ // so fall back to the read event's own tag value(s) instead of logging no id.
101
+ let readEventId = (
102
+ ~partitionTag: option<Reventless.DcbTag.derivedPartitionTag>,
103
+ tags: array<Reventless.DcbTag.tag>,
104
+ ): option<string> => {
89
105
  let ownTagValues = () => {
90
106
  let vals =
91
107
  tags->Array.reduce([], (acc, t: Reventless.DcbTag.tag) =>
@@ -93,15 +109,14 @@ module Make = (
93
109
  )
94
110
  vals->Array.length == 0 ? None : Some(vals->Array.join(","))
95
111
  }
96
- switch derivedPartitionTag {
97
- | Simple(pt) =>
98
- switch tags->Array.findMap((t: Reventless.DcbTag.tag) =>
99
- t.key == pt.key ? Some(t.value) : None
100
- ) {
101
- | Some(v) => Some(v)
102
- | None => ownTagValues()
112
+ switch partitionTag {
113
+ | Some(Composite(spec)) => Some(Reventless.DcbTag.getCompositePartitionKeyValue(tags, spec))
114
+ | Some(pt) =>
115
+ switch tags->Reventless.DcbTag.partitionValueOfTags(pt) {
116
+ | "" => ownTagValues()
117
+ | v => Some(v)
103
118
  }
104
- | Composite(spec) => Some(Reventless.DcbTag.getCompositePartitionKeyValue(tags, spec))
119
+ | None => ownTagValues()
105
120
  }
106
121
  }
107
122
 
@@ -147,6 +162,7 @@ module Make = (
147
162
  let handleSingleCommand = (
148
163
  ~tagKeysByEventType,
149
164
  ~crossPartitionTagKeys,
165
+ ~partitionTag,
150
166
  dcbEventLog: DcbEventLog.operations,
151
167
  command': Message.command'<Reventless.Id.String.t, Spec.command>,
152
168
  ) => {
@@ -192,11 +208,12 @@ module Make = (
192
208
  EffectLogger.logInfo(~comp, `query: ${queryDetail}`)->Effect.runSync
193
209
 
194
210
  // Extract the entity ID from the command for use in Accepted outcomes.
195
- let entityId = switch derivedPartitionTag {
196
- | Simple(pt) => Reventless.DcbTag.getPartitionTagValue(query, pt)
197
- | Composite(spec) =>
211
+ let entityId = switch partitionTag {
212
+ | Some(Reventless.DcbTag.Composite(spec)) =>
198
213
  let tags = Reventless.DcbTag.extractTags(Spec.commandSchema, command'.command)
199
214
  Some(Reventless.DcbTag.getCompositePartitionKeyValue(tags, spec))
215
+ | Some(Simple(pt)) => Reventless.DcbTag.getPartitionTagValue(query, pt)
216
+ | Some(ByEventType(_)) | None => None
200
217
  }
201
218
 
202
219
  // Fail closed: a query that can't be serialized yields `None`, not the empty
@@ -267,7 +284,12 @@ module Make = (
267
284
  ~eventType=raw.eventType,
268
285
  ~data=raw.data->JSON.Decode.object->Option.getOr(Dict.make()),
269
286
  )
270
- decoded->Option.map(event => (event, raw.position, raw.eventType, readEventId(raw.tags)))
287
+ decoded->Option.map(event => (
288
+ event,
289
+ raw.position,
290
+ raw.eventType,
291
+ readEventId(~partitionTag, raw.tags),
292
+ ))
271
293
  })
272
294
  ->Stream.flatMap(opt =>
273
295
  switch opt {
@@ -474,14 +496,20 @@ module Make = (
474
496
  let handleCommands = (
475
497
  ~tagKeysByEventType=Dict.make(),
476
498
  ~crossPartitionTagKeys=[],
499
+ ~partitionTag=?,
477
500
  dcbEventLog,
478
501
  stream,
479
- ) =>
502
+ ) => {
503
+ let partitionTag = switch partitionTag {
504
+ | Some(_) => partitionTag
505
+ | None => slicePartitionTag->Lazy.get
506
+ }
480
507
  stream
481
508
  ->Stream.mapEffect(({ReventlessInfra.CommandTopic.reference: reference, command}) =>
482
509
  handleSingleCommand(
483
510
  ~tagKeysByEventType,
484
511
  ~crossPartitionTagKeys,
512
+ ~partitionTag,
485
513
  dcbEventLog,
486
514
  command,
487
515
  )->Effect.map(result =>
@@ -492,4 +520,5 @@ module Make = (
492
520
  )
493
521
  )
494
522
  ->Stream.runCollect
523
+ }
495
524
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
4
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Lazy from "@rescript/runtime/lib/es6/Stdlib_Lazy.js";
5
6
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
7
  import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
7
8
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
@@ -22,12 +23,20 @@ function Make(Spec) {
22
23
  let comp = `StateChangeSlice(` + Spec.name + `)`;
23
24
  let decoder = DcbDecode$Reventless.makeDecoder(Spec.consumedEventSchema);
24
25
  let queryEventTypes = decoder.eventTypes;
25
- let derivedPartitionTag = DcbTag$Reventless.derivePartitionTag([[
26
- Spec.name,
27
- Behavior.moduleUrl,
28
- Spec.eventSchema
29
- ]]);
30
- let readEventId = tags => {
26
+ let slicePartitionTag = Stdlib_Lazy.make(() => {
27
+ try {
28
+ return DcbTag$Reventless.deriveSlicePartition({
29
+ name: Spec.name,
30
+ commandSchema: Spec.commandSchema,
31
+ consumedEventSchema: Spec.consumedEventSchema,
32
+ eventSchema: Spec.eventSchema,
33
+ moduleUrl: Spec.moduleUrl
34
+ });
35
+ } catch (exn) {
36
+ return;
37
+ }
38
+ });
39
+ let readEventId = (partitionTag, tags) => {
31
40
  let ownTagValues = () => {
32
41
  let vals = Stdlib_Array.reduce(tags, [], (acc, t) => {
33
42
  if (acc.includes(t.value)) {
@@ -42,24 +51,26 @@ function Make(Spec) {
42
51
  return vals.join(",");
43
52
  }
44
53
  };
45
- if (derivedPartitionTag.TAG !== "Simple") {
46
- return DcbTag$Reventless.getCompositePartitionKeyValue(tags, derivedPartitionTag._0);
54
+ if (partitionTag === undefined) {
55
+ return ownTagValues();
47
56
  }
48
- let pt = derivedPartitionTag._0;
49
- let v = Stdlib_Array.findMap(tags, t => {
50
- if (t.key === pt.key) {
51
- return t.value;
52
- }
53
- });
54
- if (v !== undefined) {
55
- return v;
56
- } else {
57
+ switch (partitionTag.TAG) {
58
+ case "Composite" :
59
+ return DcbTag$Reventless.getCompositePartitionKeyValue(tags, partitionTag._0);
60
+ case "Simple" :
61
+ case "ByEventType" :
62
+ break;
63
+ }
64
+ let v = DcbTag$Reventless.partitionValueOfTags(tags, partitionTag);
65
+ if (v === "") {
57
66
  return ownTagValues();
67
+ } else {
68
+ return v;
58
69
  }
59
70
  };
60
71
  let projectionCache = Lru$ReventlessCore.make(100);
61
72
  let resetCache = () => Lru$ReventlessCore.clear(projectionCache);
62
- let handleSingleCommand = (tagKeysByEventType, crossPartitionTagKeys, dcbEventLog, command$p) => {
73
+ let handleSingleCommand = (tagKeysByEventType, crossPartitionTagKeys, partitionTag, dcbEventLog, command$p) => {
63
74
  let cmdJson = Message$ReventlessCore.commandJsonOfCommand$p(Id$Reventless.$$String.toString, Spec.commandSchema, command$p);
64
75
  Effect.runSync(EffectLogger$ReventlessCore.logInfo(comp, cmdJson.commandJson, `handling command: ` + LogFormat$ReventlessCore.cmdDetailNoId(cmdJson)));
65
76
  let query = DcbTag$Reventless.buildQueryFromCommand(queryEventTypes, Spec.commandSchema, command$p.command, tagKeysByEventType, crossPartitionTagKeys);
@@ -76,11 +87,21 @@ function Make(Spec) {
76
87
  }).join(" OR ");
77
88
  Effect.runSync(EffectLogger$ReventlessCore.logInfo(comp, undefined, `query: ` + queryDetail));
78
89
  let entityId;
79
- if (derivedPartitionTag.TAG === "Simple") {
80
- entityId = DcbTag$Reventless.getPartitionTagValue(query, derivedPartitionTag._0);
90
+ if (partitionTag !== undefined) {
91
+ switch (partitionTag.TAG) {
92
+ case "Simple" :
93
+ entityId = DcbTag$Reventless.getPartitionTagValue(query, partitionTag._0);
94
+ break;
95
+ case "Composite" :
96
+ let tags = DcbTag$Reventless.extractTags(Spec.commandSchema, command$p.command);
97
+ entityId = DcbTag$Reventless.getCompositePartitionKeyValue(tags, partitionTag._0);
98
+ break;
99
+ case "ByEventType" :
100
+ entityId = undefined;
101
+ break;
102
+ }
81
103
  } else {
82
- let tags = DcbTag$Reventless.extractTags(Spec.commandSchema, command$p.command);
83
- entityId = DcbTag$Reventless.getCompositePartitionKeyValue(tags, derivedPartitionTag._0);
104
+ entityId = undefined;
84
105
  }
85
106
  let cacheKey = JSON.stringify(query);
86
107
  let cacheGet = () => {
@@ -131,7 +152,7 @@ function Make(Spec) {
131
152
  event,
132
153
  raw.position,
133
154
  raw.eventType,
134
- readEventId(raw.tags)
155
+ readEventId(partitionTag, raw.tags)
135
156
  ]);
136
157
  }), opt => {
137
158
  if (opt !== undefined) {
@@ -290,12 +311,13 @@ function Make(Spec) {
290
311
  };
291
312
  return attempt(3);
292
313
  };
293
- let handleCommands = (tagKeysByEventTypeOpt, crossPartitionTagKeysOpt, dcbEventLog, stream) => {
314
+ let handleCommands = (tagKeysByEventTypeOpt, crossPartitionTagKeysOpt, partitionTag, dcbEventLog, stream) => {
294
315
  let tagKeysByEventType = tagKeysByEventTypeOpt !== undefined ? tagKeysByEventTypeOpt : ({});
295
316
  let crossPartitionTagKeys = crossPartitionTagKeysOpt !== undefined ? crossPartitionTagKeysOpt : [];
317
+ let partitionTag$1 = partitionTag !== undefined ? partitionTag : Stdlib_Lazy.get(slicePartitionTag);
296
318
  return Stream.runCollect(Stream$1.mapEffect(stream, param => {
297
319
  let reference = param.reference;
298
- return Effect.map(handleSingleCommand(tagKeysByEventType, crossPartitionTagKeys, dcbEventLog, param.command), result => {
320
+ return Effect.map(handleSingleCommand(tagKeysByEventType, crossPartitionTagKeys, partitionTag$1, dcbEventLog, param.command), result => {
299
321
  if (result.TAG === "Ok") {
300
322
  return {
301
323
  TAG: "Ok",
@@ -345,6 +345,40 @@ let reportTranslationTables = (
345
345
  }
346
346
  }
347
347
 
348
+ let describePartition = (pt: Reventless.DcbTag.derivedPartitionTag) =>
349
+ switch pt {
350
+ | Simple({key}) => key
351
+ | Composite({keys}) => keys->Array.join(" + ")
352
+ | ByEventType(_) => "a key per event type"
353
+ }
354
+
355
+ // An extension groups the commands it sends a DCB slice by the slice's partition
356
+ // key, but it infers that key from the slice alone: the command and the events,
357
+ // not what the slice reads. Where the plugin needs those reads — or the chapter —
358
+ // to decide, the two can differ, and the extension would fail or group commands
359
+ // by the wrong id at run time. `expected` is `None` when the plugin itself cannot
360
+ // derive a partition; that failure is the builder's to report.
361
+ let extensionPartitionFailure = (
362
+ ~label: string,
363
+ ~delegate: string,
364
+ ~expected: option<Reventless.DcbTag.derivedPartitionTag>,
365
+ ~actual: result<Reventless.DcbTag.derivedPartitionTag, string>,
366
+ ): option<string> =>
367
+ switch (expected, actual) {
368
+ | (None, _) => None
369
+ | (Some(e), Ok(a)) if e == a => None
370
+ | (Some(e), outcome) =>
371
+ let seen = switch outcome {
372
+ | Ok(a) => `groups its commands by ${a->describePartition}`
373
+ | Error(reason) => `cannot tell which key to group its commands by (${reason})`
374
+ }
375
+ Some(
376
+ `${label}: the extension ${seen}, but the plugin partitions ${delegate} by ${e->describePartition}. ` ++
377
+ `An extension sees only the slice's command and events, not what it reads or its chapter — ` ++
378
+ `add @partitionTag ${e->describePartition} to ${delegate}'s event.`,
379
+ )
380
+ }
381
+
348
382
  // A command's declared edge names states belonging to another component's enum,
349
383
  // and nothing forces a spec to pick the one its linked view declares. Both sides
350
384
  // are in hand here. A state the linked views do not declare raises (this runs at assembly,
@@ -636,7 +670,15 @@ let isCreateCommandName = name =>
636
670
  name->String.startsWith(p)
637
671
  )
638
672
 
639
- let commandLevelAndId = (~isAggregate, ~variantName, properties: dict<S.t<unknown>>) =>
673
+ // A slice command's id field is the one carrying its slice's partition key, as the
674
+ // boundary infers it; a `@partitionTag` or the first tagged field stands in only
675
+ // where no key was inferred.
676
+ let commandLevelAndId = (
677
+ ~isAggregate,
678
+ ~partitionKey: option<string>=?,
679
+ ~variantName,
680
+ properties: dict<S.t<unknown>>,
681
+ ) =>
640
682
  if isAggregate {
641
683
  if isCreateCommandName(variantName) {
642
684
  (Reventless.Plugin.Collection, None)
@@ -651,9 +693,18 @@ let commandLevelAndId = (~isAggregate, ~variantName, properties: dict<S.t<unknow
651
693
  fieldName != "TAG" &&
652
694
  (Reventless.DcbTag.isTagged(fieldSchema) || Reventless.DcbTag.isTaggedArray(fieldSchema))
653
695
  )
696
+ let keyOf = ((fieldName, fieldSchema)) =>
697
+ Reventless.DcbTag.isTaggedArray(fieldSchema)
698
+ ? Reventless.DcbTag.resolveArrayTagKey(fieldName, fieldSchema)
699
+ : Reventless.DcbTag.resolveTagKey(fieldName, fieldSchema)
654
700
  let taggedField =
655
- taggedFields
656
- ->Array.find(((_, fieldSchema)) => Reventless.DcbTag.isPartitionTag(fieldSchema))
701
+ partitionKey
702
+ ->Option.flatMap(key => taggedFields->Array.find(field => keyOf(field) == key))
703
+ ->Option.orElse(
704
+ taggedFields->Array.find(((_, fieldSchema)) =>
705
+ Reventless.DcbTag.isPartitionTag(fieldSchema)
706
+ ),
707
+ )
657
708
  ->Option.orElse(taggedFields->Array.get(0))
658
709
  switch taggedField {
659
710
  | Some((fieldName, _)) =>
@@ -700,6 +751,7 @@ let annotateArgTypes = (schema: JSON.t, argTypes: dict<string>): JSON.t => {
700
751
 
701
752
  let toCommandDef = (
702
753
  ~isAggregate,
754
+ ~partitionKey: option<string>=?,
703
755
  ~mutationFieldFor: string => string,
704
756
  ~parentSchema: S.t<unknown>,
705
757
  // The PPX-generated `command => permission`. Per VARIANT, not per component:
@@ -727,7 +779,12 @@ let toCommandDef = (
727
779
  // empty for a payload-less variant (e.g. `| Archive`), which compiles to a bare
728
780
  // `S.literal("Archive")` string rather than an `{TAG, ...}` object.
729
781
  let mkDef = (~variantName, ~properties) => {
730
- let (guessedLevel, aggregateIdField) = commandLevelAndId(~isAggregate, ~variantName, properties)
782
+ let (guessedLevel, aggregateIdField) = commandLevelAndId(
783
+ ~isAggregate,
784
+ ~partitionKey?,
785
+ ~variantName,
786
+ properties,
787
+ )
731
788
  let references = extractReferences(properties)
732
789
  // Evaluated against a synthetic value per constructor, the same shape the
733
790
  // resolver builds at call time: a payload-bearing variant compiles to
@@ -867,6 +924,7 @@ let toCommandDef = (
867
924
 
868
925
  let extractCommandDefs = (
869
926
  ~isAggregate,
927
+ ~partitionKey: option<string>=?,
870
928
  ~mutationFieldFor: string => string,
871
929
  ~commandAuthorization: unknown => Reventless.Authorization.permission,
872
930
  ~commandTransition: unknown => Reventless.Transition.t<string>,
@@ -878,6 +936,7 @@ let extractCommandDefs = (
878
936
  anyOf->Array.filterMap(v =>
879
937
  toCommandDef(
880
938
  ~isAggregate,
939
+ ~partitionKey?,
881
940
  ~mutationFieldFor,
882
941
  ~parentSchema=commandSchema,
883
942
  ~commandAuthorization,
@@ -890,6 +949,7 @@ let extractCommandDefs = (
890
949
  // Single-variant command types compile to a bare Object schema, not a Union.
891
950
  toCommandDef(
892
951
  ~isAggregate,
952
+ ~partitionKey?,
893
953
  ~mutationFieldFor,
894
954
  ~parentSchema=commandSchema,
895
955
  ~commandAuthorization,
@@ -1445,6 +1505,30 @@ let make = (
1445
1505
 
1446
1506
  // ── Build writable defs ────────────────────────────────────────────────────
1447
1507
 
1508
+ // Non-throwing: this is a description of the plugin, and the builder is what
1509
+ // refuses a boundary whose partitions do not resolve.
1510
+ let dcbSliceSchemas =
1511
+ stateChangeSlices->Array.map((module(SCS: ReventlessInfra.StateChangeSlice.T)) => {
1512
+ Reventless.DcbTag.name: SCS.Spec.name,
1513
+ commandSchema: SCS.Spec.commandSchema->S.castToUnknown,
1514
+ consumedEventSchema: SCS.Spec.consumedEventSchema->S.castToUnknown,
1515
+ eventSchema: SCS.Spec.eventSchema->S.castToUnknown,
1516
+ moduleUrl: SCS.Spec.moduleUrl,
1517
+ })
1518
+ let {partitionBySlice}: Reventless.DcbScopeInference.partitionResolution =
1519
+ dcbSliceSchemas
1520
+ ->Array.map(Reventless.DcbTag.sliceShape)
1521
+ ->Reventless.DcbScopeInference.resolvePartitions
1522
+ let slicePartitionOf = switch Reventless.DcbTag.deriveBoundaryPartition(dcbSliceSchemas) {
1523
+ | boundary =>
1524
+ delegate =>
1525
+ dcbSliceSchemas->Array.some(s => s.name == delegate)
1526
+ ? boundary->Reventless.DcbTag.slicePartitionTag(delegate)
1527
+ : None
1528
+ | exception JsExn(_) => _ => None
1529
+ }
1530
+ let partitionFailures = []
1531
+
1448
1532
  let stateChangeDefs =
1449
1533
  stateChangeSlices->Array.mapWithIndex((module(SCS: ReventlessInfra.StateChangeSlice.T), i) => {
1450
1534
  let (_, produced) = scsProduced->Array.getUnsafe(i)
@@ -1455,6 +1539,7 @@ let make = (
1455
1539
  Reventless.Plugin.name: SCS.Spec.name,
1456
1540
  commands: extractCommandDefs(
1457
1541
  ~isAggregate=false,
1542
+ ~partitionKey=?partitionBySlice->Dict.get(SCS.Spec.name),
1458
1543
  ~mutationFieldFor=variantName =>
1459
1544
  Api_Naming.sliceMutationFieldFor(
1460
1545
  ~plugin=name,
@@ -1592,6 +1677,12 @@ let make = (
1592
1677
  let eventsByCommand: Dict.t<array<string>> = Dict.make()
1593
1678
  E.mappings->Array.forEach((module(M: E.Mapping)) => {
1594
1679
  let label = `${E.Spec.name} → ${M.delegateName}`
1680
+ extensionPartitionFailure(
1681
+ ~label,
1682
+ ~delegate=M.delegateName,
1683
+ ~expected=slicePartitionOf(M.delegateName),
1684
+ ~actual=M.delegatePartition(),
1685
+ )->Option.forEach(f => partitionFailures->Array.push(f))
1595
1686
  pushAll(
1596
1687
  tableFailures,
1597
1688
  handledTableFailures(
@@ -1965,6 +2056,12 @@ let make = (
1965
2056
  })
1966
2057
 
1967
2058
  reportTranslationTables(~pluginName=name, ~failures=tableFailures, ~warnings=tableWarnings)
2059
+ if partitionFailures->Array.length > 0 {
2060
+ JsError.throwWithMessage(
2061
+ `${name}: an extension cannot group its commands by the target slice's partition key.\n` ++
2062
+ partitionFailures->Array.join("\n"),
2063
+ )
2064
+ }
1968
2065
 
1969
2066
  // Second pass, on purpose: commands are built well before `linkedViews` is
1970
2067
  // assembled, so the check cannot run inline where the defs are made.
@@ -13,6 +13,7 @@ import * as Trait$Reventless from "@reventlessdev/reventless-spec/src/types/Trai
13
13
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
14
14
  import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
15
15
  import * as Lifecycle$Reventless from "@reventlessdev/reventless-spec/src/types/Lifecycle.res.mjs";
16
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
16
17
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
17
18
  import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
18
19
  import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
@@ -23,6 +24,7 @@ import * as Api_Naming$ReventlessCore from "../../components/Api/Api_Naming.res.
23
24
  import * as CapabilityNeed$Reventless from "@reventlessdev/reventless-spec/src/semantic/CapabilityNeed.res.mjs";
24
25
  import * as SchemaType$ReventlessCore from "../../components/Api/SchemaType.res.mjs";
25
26
  import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
27
+ import * as DcbScopeInference$Reventless from "@reventlessdev/reventless-spec/src/components/DcbScopeInference.res.mjs";
26
28
  import * as ApiNoApiHelpers$ReventlessCore from "../../components/Api/ApiNoApiHelpers.res.mjs";
27
29
  import * as SuryToJsonSchema$ReventlessCore from "../../components/Api/SuryToJsonSchema.res.mjs";
28
30
  import * as Capability_Inference$ReventlessCore from "./Capability_Inference.res.mjs";
@@ -289,6 +291,29 @@ function reportTranslationTables(pluginName, failures, warnings) {
289
291
  }
290
292
  }
291
293
 
294
+ function describePartition(pt) {
295
+ switch (pt.TAG) {
296
+ case "Simple" :
297
+ return pt._0.key;
298
+ case "Composite" :
299
+ return pt._0.keys.join(" + ");
300
+ case "ByEventType" :
301
+ return "a key per event type";
302
+ }
303
+ }
304
+
305
+ function extensionPartitionFailure(label, delegate, expected, actual) {
306
+ if (expected === undefined) {
307
+ return;
308
+ }
309
+ if (actual.TAG === "Ok" && Primitive_object.equal(expected, actual._0)) {
310
+ return;
311
+ }
312
+ let seen;
313
+ seen = actual.TAG === "Ok" ? `groups its commands by ` + describePartition(actual._0) : `cannot tell which key to group its commands by (` + actual._0 + `)`;
314
+ return label + `: the extension ` + seen + `, but the plugin partitions ` + delegate + ` by ` + describePartition(expected) + `. An extension sees only the slice's command and events, not what it reads or its chapter — ` + (`add @partitionTag ` + describePartition(expected) + ` to ` + delegate + `'s event.`);
315
+ }
316
+
292
317
  function checkDeclaredTransitions(pluginName, writables, lifecycleStatesByView) {
293
318
  let unvalidated = {
294
319
  contents: 0
@@ -492,7 +517,7 @@ function isCreateCommandName(name) {
492
517
  ].some(p => name.startsWith(p));
493
518
  }
494
519
 
495
- function commandLevelAndId(isAggregate, variantName, properties) {
520
+ function commandLevelAndId(isAggregate, partitionKey, variantName, properties) {
496
521
  if (isAggregate) {
497
522
  if (isCreateCommandName(variantName)) {
498
523
  return [
@@ -518,7 +543,16 @@ function commandLevelAndId(isAggregate, variantName, properties) {
518
543
  return false;
519
544
  }
520
545
  });
521
- let taggedField = Stdlib_Option.orElse(taggedFields.find(param => DcbTag$Reventless.isPartitionTag(param[1])), taggedFields[0]);
546
+ let keyOf = param => {
547
+ let fieldSchema = param[1];
548
+ let fieldName = param[0];
549
+ if (DcbTag$Reventless.isTaggedArray(fieldSchema)) {
550
+ return DcbTag$Reventless.resolveArrayTagKey(fieldName, fieldSchema);
551
+ } else {
552
+ return DcbTag$Reventless.resolveTagKey(fieldName, fieldSchema);
553
+ }
554
+ };
555
+ let taggedField = Stdlib_Option.orElse(Stdlib_Option.orElse(Stdlib_Option.flatMap(partitionKey, key => taggedFields.find(field => keyOf(field) === key)), taggedFields.find(param => DcbTag$Reventless.isPartitionTag(param[1]))), taggedFields[0]);
522
556
  if (taggedField === undefined) {
523
557
  return [
524
558
  "Collection",
@@ -563,9 +597,9 @@ function annotateArgTypes(schema, argTypes) {
563
597
  return schema;
564
598
  }
565
599
 
566
- function toCommandDef(isAggregate, mutationFieldFor, parentSchema, commandAuthorization, commandTransition, derivedEdgeFor, v) {
600
+ function toCommandDef(isAggregate, partitionKey, mutationFieldFor, parentSchema, commandAuthorization, commandTransition, derivedEdgeFor, v) {
567
601
  let mkDef = (variantName, properties) => {
568
- let match = commandLevelAndId(isAggregate, variantName, properties);
602
+ let match = commandLevelAndId(isAggregate, partitionKey, variantName, properties);
569
603
  let references = extractReferences(properties);
570
604
  let syntheticCommand = DcbTag$Reventless.isVariantPayloadBearing(parentSchema, variantName) ? ({
571
605
  TAG: variantName
@@ -649,12 +683,12 @@ function toCommandDef(isAggregate, mutationFieldFor, parentSchema, commandAuthor
649
683
  }
650
684
  }
651
685
 
652
- function extractCommandDefs(isAggregate, mutationFieldFor, commandAuthorization, commandTransition, derivedEdgeForOpt, commandSchema) {
686
+ function extractCommandDefs(isAggregate, partitionKey, mutationFieldFor, commandAuthorization, commandTransition, derivedEdgeForOpt, commandSchema) {
653
687
  let derivedEdgeFor = derivedEdgeForOpt !== undefined ? derivedEdgeForOpt : param => {};
654
688
  if (commandSchema.type === "anyOf") {
655
- return Stdlib_Array.filterMap(commandSchema.anyOf, v => toCommandDef(isAggregate, mutationFieldFor, commandSchema, commandAuthorization, commandTransition, derivedEdgeFor, v));
689
+ return Stdlib_Array.filterMap(commandSchema.anyOf, v => toCommandDef(isAggregate, partitionKey, mutationFieldFor, commandSchema, commandAuthorization, commandTransition, derivedEdgeFor, v));
656
690
  } else {
657
- return Stdlib_Option.mapOr(toCommandDef(isAggregate, mutationFieldFor, commandSchema, commandAuthorization, commandTransition, derivedEdgeFor, commandSchema), [], def => [def]);
691
+ return Stdlib_Option.mapOr(toCommandDef(isAggregate, partitionKey, mutationFieldFor, commandSchema, commandAuthorization, commandTransition, derivedEdgeFor, commandSchema), [], def => [def]);
658
692
  }
659
693
  }
660
694
 
@@ -1036,6 +1070,37 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1036
1070
  requiredAccess: accessKeysFor(SVS.Spec.authorization)
1037
1071
  };
1038
1072
  });
1073
+ let dcbSliceSchemas = stateChangeSlices.map(SCS => ({
1074
+ name: SCS.Spec.name,
1075
+ commandSchema: SCS.Spec.commandSchema,
1076
+ consumedEventSchema: SCS.Spec.consumedEventSchema,
1077
+ eventSchema: SCS.Spec.eventSchema,
1078
+ moduleUrl: SCS.Spec.moduleUrl
1079
+ }));
1080
+ let match = DcbScopeInference$Reventless.resolvePartitions(dcbSliceSchemas.map(DcbTag$Reventless.sliceShape));
1081
+ let partitionBySlice = match.partitionBySlice;
1082
+ let slicePartitionOf;
1083
+ let exit = 0;
1084
+ let boundary;
1085
+ try {
1086
+ boundary = DcbTag$Reventless.deriveBoundaryPartition(dcbSliceSchemas);
1087
+ exit = 1;
1088
+ } catch (raw_exn) {
1089
+ let exn = Primitive_exceptions.internalToException(raw_exn);
1090
+ if (exn.RE_EXN_ID === "JsExn") {
1091
+ slicePartitionOf = param => {};
1092
+ } else {
1093
+ throw exn;
1094
+ }
1095
+ }
1096
+ if (exit === 1) {
1097
+ slicePartitionOf = delegate => {
1098
+ if (dcbSliceSchemas.some(s => s.name === delegate)) {
1099
+ return DcbTag$Reventless.slicePartitionTag(boundary, delegate);
1100
+ }
1101
+ };
1102
+ }
1103
+ let partitionFailures = [];
1039
1104
  let stateChangeDefs = stateChangeSlices.map((SCS, i) => {
1040
1105
  let match = scsProduced[i];
1041
1106
  let produced = match[1];
@@ -1043,7 +1108,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1043
1108
  let consumed = match$1[1];
1044
1109
  return {
1045
1110
  name: SCS.Spec.name,
1046
- commands: extractCommandDefs(false, variantName => Api_Naming$ReventlessCore.sliceMutationFieldFor(name, SCS.Spec.name, SCS.Spec.commandSchema, variantName), SCS.Spec.commandAuthorization, SCS.Spec.commandTransition, extra => derivedEdgeFor(SCS.Spec.name, extra), SCS.Spec.commandSchema),
1111
+ commands: extractCommandDefs(false, partitionBySlice[SCS.Spec.name], variantName => Api_Naming$ReventlessCore.sliceMutationFieldFor(name, SCS.Spec.name, SCS.Spec.commandSchema, variantName), SCS.Spec.commandAuthorization, SCS.Spec.commandTransition, extra => derivedEdgeFor(SCS.Spec.name, extra), SCS.Spec.commandSchema),
1047
1112
  producedEventTypes: produced,
1048
1113
  consumedEventTypes: consumed,
1049
1114
  linkedViews: linkedSvsFor(produced),
@@ -1058,7 +1123,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1058
1123
  let produced = match[1];
1059
1124
  return {
1060
1125
  name: A.Spec.name,
1061
- commands: extractCommandDefs(true, variantName => Api_Naming$ReventlessCore.aggregateMutationField(name, A.Spec.name, variantName), A.Spec.commandAuthorization, A.Spec.commandTransition, extra => derivedEdgeFor(A.Spec.name, extra), A.Spec.commandSchema),
1126
+ commands: extractCommandDefs(true, undefined, variantName => Api_Naming$ReventlessCore.aggregateMutationField(name, A.Spec.name, variantName), A.Spec.commandAuthorization, A.Spec.commandTransition, extra => derivedEdgeFor(A.Spec.name, extra), A.Spec.commandSchema),
1062
1127
  producedEventTypes: produced,
1063
1128
  consumedEventTypes: [],
1064
1129
  linkedViews: linkedSvsFor(produced).concat(linkedReadModelsFor(A.Spec.name)),
@@ -1109,6 +1174,9 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1109
1174
  let eventsByCommand = {};
1110
1175
  E.mappings.forEach(M => {
1111
1176
  let label = E.Spec.name + ` → ` + M.delegateName;
1177
+ Stdlib_Option.forEach(extensionPartitionFailure(label, M.delegateName, slicePartitionOf(M.delegateName), M.delegatePartition()), f => {
1178
+ partitionFailures.push(f);
1179
+ });
1112
1180
  pushAll(tableFailures, handledTableFailures(label, M.handledEvents, epEventNames, M.delegateCommandNames.concat(epCommandNames)));
1113
1181
  pushAll(tableFailures, commandTableFailures(label, "issuedCommands", "comes from", M.issuedCommands.map(param => [
1114
1182
  param.name,
@@ -1330,6 +1398,9 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1330
1398
  });
1331
1399
  });
1332
1400
  reportTranslationTables(name, tableFailures, tableWarnings);
1401
+ if (partitionFailures.length !== 0) {
1402
+ Stdlib_JsError.throwWithMessage(name + `: an extension cannot group its commands by the target slice's partition key.\n` + partitionFailures.join("\n"));
1403
+ }
1333
1404
  checkDeclaredTransitions(name, stateChangeDefs.concat(aggregateDefs), lifecycleStatesByView);
1334
1405
  checkLifecycleTopology(name, stateChangeDefs.concat(aggregateDefs), lifecycleStatesByView);
1335
1406
  reportRetiredStates(name, retiredFailures, retiredUnchecked);
@@ -1374,6 +1445,8 @@ export {
1374
1445
  probeMeta,
1375
1446
  probeQueryEngine,
1376
1447
  reportTranslationTables,
1448
+ describePartition,
1449
+ extensionPartitionFailure,
1377
1450
  checkDeclaredTransitions,
1378
1451
  lifecycleTopologyFindings,
1379
1452
  checkLifecycleTopology,
@@ -22,7 +22,7 @@ async function publishJsons(cmds) {
22
22
  published.contents = published.contents.concat(cmds);
23
23
  }
24
24
 
25
- let generateCommand = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "Product", commandSchema, "StateChangeSlice", false);
25
+ let generateCommand = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "Product", commandSchema, "StateChangeSlice", false, undefined);
26
26
 
27
27
  function addChair() {
28
28
  return {
@@ -91,7 +91,7 @@ describe("CommandGenerator_Callback.generateCommand:", () => {
91
91
 
92
92
  describe("StateChangeSlice envelope id derivation", () => {
93
93
  testPromise(
94
- "single @partitionTag: id derived from tagged field when args have no id",
94
+ "the slice's partition key: id derived from its field when args have no id",
95
95
  async () => {
96
96
  let payload = makeSlicePayload(
97
97
  ~command="CreateItem",
@@ -85,7 +85,7 @@ globalThis.describe("CommandGenerator_Callback.generateCommand:", () => {
85
85
  });
86
86
  });
87
87
  globalThis.describe("StateChangeSlice envelope id derivation", () => {
88
- globalThis.test("single @partitionTag: id derived from tagged field when args have no id", async () => {
88
+ globalThis.test("the slice's partition key: id derived from its field when args have no id", async () => {
89
89
  let payload = CommandGeneratorFixtures$ReventlessCore.makeSlicePayload("CreateItem", Object.fromEntries([
90
90
  [
91
91
  "itemId",