@reventlessdev/reventless-core 3.0.0-alpha.261 → 3.0.0-alpha.263

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 (40) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/package.json +9 -9
  3. package/src/adapter/Secrets/Secrets_Node_Backend.res +60 -0
  4. package/src/adapter/Secrets/Secrets_Node_Backend.res.mjs +69 -0
  5. package/src/admin/Platform_Admin_Structure.res.mjs +1 -1
  6. package/src/components/Aggregate/Aggregate_Builder.res.mjs +1 -1
  7. package/src/components/CommandGenerator/CommandGenerator.res +4 -4
  8. package/src/components/CommandGenerator/CommandGenerator_Callback.res +13 -20
  9. package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +3 -24
  10. package/src/components/Dcb/Dcb_Builder.res +45 -49
  11. package/src/components/Dcb/Dcb_Builder.res.mjs +31 -40
  12. package/src/components/StateChangeSlice/StateChangeSlice.res +2 -0
  13. package/src/components/StateChangeSlice/StateChangeSlice_Builder.res +6 -0
  14. package/src/components/StateChangeSlice/StateChangeSlice_Builder.res.mjs +4 -4
  15. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +51 -22
  16. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +47 -25
  17. package/src/plugin/component/Plugin_Structure.res +101 -4
  18. package/src/plugin/component/Plugin_Structure.res.mjs +82 -9
  19. package/tests/Secrets_Node_BackendTest.res +141 -0
  20. package/tests/Secrets_Node_BackendTest.res.mjs +152 -0
  21. package/tests/adapter/RuntimeExtensionHookReachTest.res.mjs +1 -1
  22. package/tests/admin/Platform_Admin_StructureTest.res +12 -3
  23. package/tests/admin/Platform_Admin_StructureTest.res.mjs +4 -3
  24. package/tests/commandgenerator/CommandGeneratorCallbackTest.res +1 -1
  25. package/tests/commandgenerator/CommandGeneratorCallbackTest.res.mjs +1 -1
  26. package/tests/commandgenerator/CommandGeneratorFixtures.res +2 -0
  27. package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +17 -3
  28. package/tests/commandgenerator/OwnerStampingTest.res.mjs +2 -2
  29. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +1 -1
  30. package/tests/dcb/DcbFixtures.res +18 -1
  31. package/tests/dcb/DcbFixtures.res.mjs +14 -0
  32. package/tests/dcb/DcbScopeInferenceTest.res +234 -0
  33. package/tests/dcb/DcbScopeInferenceTest.res.mjs +425 -13
  34. package/tests/dcb/DcbStateChangeSliceTest.res.mjs +22 -22
  35. package/tests/dcb/DcbTagTest.res +81 -68
  36. package/tests/dcb/DcbTagTest.res.mjs +88 -89
  37. package/tests/plugin/PluginStructureAccessTest.res +1 -0
  38. package/tests/plugin/PluginStructureAccessTest.res.mjs +1 -1
  39. package/tests/plugin/PluginStructureTest.res +47 -0
  40. package/tests/plugin/PluginStructureTest.res.mjs +50 -10
@@ -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,
@@ -0,0 +1,141 @@
1
+ open JestGlobals
2
+
3
+ // The secret source. What matters here is not that it produces characters, but
4
+ // that it produces them uniformly — a token drawn from a skewed distribution is
5
+ // guessable in a way that no other test would notice.
6
+
7
+ module Secrets = Reventless.Secrets
8
+ module Backend = Secrets_Node_Backend
9
+
10
+ let token = (~length, ~alphabet) =>
11
+ Backend.provider.randomToken(~length, ~alphabet)->Result.getOr("")
12
+
13
+ describe("randomToken", () => {
14
+ testSync("draws the requested length", () =>
15
+ expect(
16
+ [1, 6, 32, 64]->Array.map(length => token(~length, ~alphabet=Digits)->String.length),
17
+ )->toEqual([1, 6, 32, 64])
18
+ )
19
+
20
+ testSync("draws only from the alphabet it was given", () => {
21
+ let allowed = Secrets.characters(UrlSafe)
22
+ expect(
23
+ token(~length=512, ~alphabet=UrlSafe)
24
+ ->String.split("")
25
+ ->Array.every(c => allowed->String.includes(c)),
26
+ )->toBe(true)
27
+ })
28
+
29
+ testSync("digits are digits", () =>
30
+ expect(
31
+ token(~length=512, ~alphabet=Digits)
32
+ ->String.split("")
33
+ ->Array.every(c => "0123456789"->String.includes(c)),
34
+ )->toBe(true)
35
+ )
36
+
37
+ // Not proof of randomness — it is proof the source is not a constant, which is
38
+ // the failure a fixed or broken binding would produce.
39
+ testSync("two draws differ", () =>
40
+ expect(token(~length=32, ~alphabet=UrlSafe) == token(~length=32, ~alphabet=UrlSafe))->toBe(
41
+ false,
42
+ )
43
+ )
44
+
45
+ // A zero-length token would compare equal to nothing and settle every
46
+ // challenge presented against it, so it is refused rather than clamped.
47
+ testSync("a non-positive length is refused, not clamped", () =>
48
+ expect(
49
+ [0, -1]->Array.map(
50
+ length => Backend.provider.randomToken(~length, ~alphabet=Digits)->Result.isError,
51
+ ),
52
+ )->toEqual([true, true])
53
+ )
54
+ })
55
+
56
+ // 🚨 Checked over every byte value rather than by sampling the output, and the
57
+ // first attempt here got that wrong. `byte % 10` skews the low six digits by
58
+ // about four percent each — roughly 0.609 of draws landing in the low half
59
+ // against 0.600 for a fair source. At any sample size a test can afford that
60
+ // gap is a couple of standard errors, so a sampled assertion either flakes or,
61
+ // as the first version did, passes happily with the defence deleted.
62
+ describe("accepts — the rejection rule", () => {
63
+ let rejected = size =>
64
+ Array.fromInitializer(~length=256, i => i)->Array.filter(byte => !Backend.accepts(~byte, ~size))
65
+
66
+ testSync("rejects exactly the bytes that would skew a 10-character alphabet", () =>
67
+ expect(rejected(10))->toEqual([250, 251, 252, 253, 254, 255])
68
+ )
69
+
70
+ // 64 divides 256 exactly, so nothing needs discarding and a url-safe token
71
+ // costs no extra draws.
72
+ testSync("rejects nothing when the alphabet divides evenly", () =>
73
+ expect(rejected(64))->toEqual([])
74
+ )
75
+
76
+ // What survives always folds onto a whole number of cycles, which is the
77
+ // property that makes every character equally likely.
78
+ testSync("what it accepts is a whole multiple of the alphabet", () =>
79
+ expect(
80
+ [2, 3, 7, 10, 16, 26, 62, 64]->Array.map(
81
+ size => mod(256 - rejected(size)->Array.length, size),
82
+ ),
83
+ )->toEqual([0, 0, 0, 0, 0, 0, 0, 0])
84
+ )
85
+
86
+ testSync("never discards more than one cycle's worth", () =>
87
+ expect([3, 7, 10, 26, 62]->Array.map(size => rejected(size)->Array.length < size))->toEqual([
88
+ true,
89
+ true,
90
+ true,
91
+ true,
92
+ true,
93
+ ])
94
+ )
95
+ })
96
+
97
+ describe("hash", () => {
98
+ testSync("is deterministic, which is what lets a proof be compared", () =>
99
+ expect(Backend.provider.hash("a-secret") == Backend.provider.hash("a-secret"))->toBe(true)
100
+ )
101
+
102
+ testSync("separates different inputs", () =>
103
+ expect(Backend.provider.hash("a-secret") == Backend.provider.hash("a-secret "))->toBe(false)
104
+ )
105
+
106
+ // One-way: the digest must not carry the secret it was made from.
107
+ testSync("does not contain its input", () =>
108
+ expect(
109
+ Backend.provider.hash("a-secret")
110
+ ->Result.getOr("")
111
+ ->String.includes("a-secret"),
112
+ )->toBe(false)
113
+ )
114
+ })
115
+
116
+ describe("Capabilities.none.secrets", () => {
117
+ // Refusing rather than degrading: there is no weaker token that would be safe
118
+ // to hand back, so an unwired platform must fail rather than produce one.
119
+ testSync("refuses both operations", () =>
120
+ expect((
121
+ Reventless.Capabilities.none.secrets.randomToken(
122
+ ~length=32,
123
+ ~alphabet=UrlSafe,
124
+ )->Result.isError,
125
+ Reventless.Capabilities.none.secrets.hash("x")->Result.isError,
126
+ ))->toEqual((true, true))
127
+ )
128
+ })
129
+
130
+ describe("Secrets.fixed", () => {
131
+ // What a consumer's suite uses to know the secret before presenting it.
132
+ let provider = Secrets.fixed(~token="known", ~hash=input => `hashed:${input}`)
133
+
134
+ testSync("hands back the token it was built with", () =>
135
+ expect(provider.randomToken(~length=32, ~alphabet=UrlSafe))->toEqual(Ok("known"))
136
+ )
137
+
138
+ testSync("hashes through the function it was given", () =>
139
+ expect(provider.hash("known"))->toEqual(Ok("hashed:known"))
140
+ )
141
+ })
@@ -0,0 +1,152 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
+ import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
5
+ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
6
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
7
+ import * as Secrets$Reventless from "@reventlessdev/reventless-spec/src/semantic/Secrets.res.mjs";
8
+ import * as Capabilities$Reventless from "@reventlessdev/reventless-spec/src/semantic/Capabilities.res.mjs";
9
+ import * as Secrets_Node_Backend$ReventlessCore from "../src/adapter/Secrets/Secrets_Node_Backend.res.mjs";
10
+
11
+ function token(length, alphabet) {
12
+ return Stdlib_Result.getOr(Secrets_Node_Backend$ReventlessCore.provider.randomToken(length, alphabet), "");
13
+ }
14
+
15
+ globalThis.describe("randomToken", () => {
16
+ globalThis.test("draws the requested length", () => {
17
+ globalThis.expect([
18
+ 1,
19
+ 6,
20
+ 32,
21
+ 64
22
+ ].map(length => token(length, "Digits").length)).toEqual([
23
+ 1,
24
+ 6,
25
+ 32,
26
+ 64
27
+ ]);
28
+ });
29
+ globalThis.test("draws only from the alphabet it was given", () => {
30
+ let allowed = Secrets$Reventless.characters("UrlSafe");
31
+ globalThis.expect(token(512, "UrlSafe").split("").every(c => allowed.includes(c))).toBe(true);
32
+ });
33
+ globalThis.test("digits are digits", () => {
34
+ globalThis.expect(token(512, "Digits").split("").every(c => "0123456789".includes(c))).toBe(true);
35
+ });
36
+ globalThis.test("two draws differ", () => {
37
+ globalThis.expect(token(32, "UrlSafe") === token(32, "UrlSafe")).toBe(false);
38
+ });
39
+ globalThis.test("a non-positive length is refused, not clamped", () => {
40
+ globalThis.expect([
41
+ 0,
42
+ -1
43
+ ].map(length => Stdlib_Result.isError(Secrets_Node_Backend$ReventlessCore.provider.randomToken(length, "Digits")))).toEqual([
44
+ true,
45
+ true
46
+ ]);
47
+ });
48
+ });
49
+
50
+ globalThis.describe("accepts — the rejection rule", () => {
51
+ let rejected = size => Stdlib_Array.fromInitializer(256, i => i).filter(byte => !Secrets_Node_Backend$ReventlessCore.accepts(byte, size));
52
+ globalThis.test("rejects exactly the bytes that would skew a 10-character alphabet", () => {
53
+ globalThis.expect(rejected(10)).toEqual([
54
+ 250,
55
+ 251,
56
+ 252,
57
+ 253,
58
+ 254,
59
+ 255
60
+ ]);
61
+ });
62
+ globalThis.test("rejects nothing when the alphabet divides evenly", () => {
63
+ globalThis.expect(rejected(64)).toEqual([]);
64
+ });
65
+ globalThis.test("what it accepts is a whole multiple of the alphabet", () => {
66
+ globalThis.expect([
67
+ 2,
68
+ 3,
69
+ 7,
70
+ 10,
71
+ 16,
72
+ 26,
73
+ 62,
74
+ 64
75
+ ].map(size => Primitive_int.mod_(256 - rejected(size).length | 0, size))).toEqual([
76
+ 0,
77
+ 0,
78
+ 0,
79
+ 0,
80
+ 0,
81
+ 0,
82
+ 0,
83
+ 0
84
+ ]);
85
+ });
86
+ globalThis.test("never discards more than one cycle's worth", () => {
87
+ globalThis.expect([
88
+ 3,
89
+ 7,
90
+ 10,
91
+ 26,
92
+ 62
93
+ ].map(size => rejected(size).length < size)).toEqual([
94
+ true,
95
+ true,
96
+ true,
97
+ true,
98
+ true
99
+ ]);
100
+ });
101
+ });
102
+
103
+ globalThis.describe("hash", () => {
104
+ globalThis.test("is deterministic, which is what lets a proof be compared", () => {
105
+ globalThis.expect(Primitive_object.equal(Secrets_Node_Backend$ReventlessCore.provider.hash("a-secret"), Secrets_Node_Backend$ReventlessCore.provider.hash("a-secret"))).toBe(true);
106
+ });
107
+ globalThis.test("separates different inputs", () => {
108
+ globalThis.expect(Primitive_object.equal(Secrets_Node_Backend$ReventlessCore.provider.hash("a-secret"), Secrets_Node_Backend$ReventlessCore.provider.hash("a-secret "))).toBe(false);
109
+ });
110
+ globalThis.test("does not contain its input", () => {
111
+ globalThis.expect(Stdlib_Result.getOr(Secrets_Node_Backend$ReventlessCore.provider.hash("a-secret"), "").includes("a-secret")).toBe(false);
112
+ });
113
+ });
114
+
115
+ globalThis.describe("Capabilities.none.secrets", () => {
116
+ globalThis.test("refuses both operations", () => {
117
+ globalThis.expect([
118
+ Stdlib_Result.isError(Capabilities$Reventless.none.secrets.randomToken(32, "UrlSafe")),
119
+ Stdlib_Result.isError(Capabilities$Reventless.none.secrets.hash("x"))
120
+ ]).toEqual([
121
+ true,
122
+ true
123
+ ]);
124
+ });
125
+ });
126
+
127
+ globalThis.describe("Secrets.fixed", () => {
128
+ let provider = Secrets$Reventless.fixed("known", input => `hashed:` + input);
129
+ globalThis.test("hands back the token it was built with", () => {
130
+ globalThis.expect(provider.randomToken(32, "UrlSafe")).toEqual({
131
+ TAG: "Ok",
132
+ _0: "known"
133
+ });
134
+ });
135
+ globalThis.test("hashes through the function it was given", () => {
136
+ globalThis.expect(provider.hash("known")).toEqual({
137
+ TAG: "Ok",
138
+ _0: "hashed:known"
139
+ });
140
+ });
141
+ });
142
+
143
+ let Secrets;
144
+
145
+ let Backend;
146
+
147
+ export {
148
+ Secrets,
149
+ Backend,
150
+ token,
151
+ }
152
+ /* Not a pure module */
@@ -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 {