@reventlessdev/reventless-core 3.0.0-alpha.250 → 3.0.0-alpha.252

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 (70) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +7 -7
  3. package/src/admin/Platform_Admin_Structure.res +4 -1
  4. package/src/admin/Platform_Admin_Structure.res.mjs +4 -2
  5. package/src/admin/Platform_BakedManifest.res.mjs +3 -1
  6. package/src/admin/Platform_ComponentDefinitionsApi.res +31 -1
  7. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +29 -1
  8. package/src/admin/Platform_PluginStructuresApi.res +80 -8
  9. package/src/admin/Platform_PluginStructuresApi.res.mjs +76 -2
  10. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs +8 -0
  11. package/src/components/Api/ApiNoApiHelpers.res +37 -3
  12. package/src/components/Api/ApiNoApiHelpers.res.mjs +29 -2
  13. package/src/components/AutomationSlice/AutomationSlice_Callback.res +4 -4
  14. package/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs +3 -3
  15. package/src/plugin/api/PluginBaseFragment.res +3 -3
  16. package/src/plugin/component/Plugin_Structure.res +787 -384
  17. package/src/plugin/component/Plugin_Structure.res.mjs +430 -20
  18. package/src/plugin/connect/PluginConnectExtension_Mapping.res +3 -6
  19. package/src/plugin/connect/PluginConnectExtension_Mapping.res.mjs +14 -2
  20. package/src/plugin/connect/PluginExtensionPoint_Plugin.res +0 -1
  21. package/src/plugin/connect/PluginExtensionPoint_Plugin.res.mjs +63 -3
  22. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res.mjs +22 -3
  23. package/src/plugin/lifecycle/PluginBehavior.res +11 -11
  24. package/src/plugin/lifecycle/PluginBehavior.res.mjs +11 -11
  25. package/src/plugin/lifecycle/PluginSpec.res +11 -3
  26. package/src/plugin/lifecycle/PluginSpec.res.mjs +11 -3
  27. package/tests/admin/AdminApiSchemaDriftTest.res +486 -0
  28. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +619 -0
  29. package/tests/admin/Platform_Admin_StructureTest.res +8 -3
  30. package/tests/admin/Platform_Admin_StructureTest.res.mjs +8 -2
  31. package/tests/admin/Platform_BakedManifestTest.res +2 -0
  32. package/tests/admin/Platform_BakedManifestTest.res.mjs +3 -1
  33. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +10 -0
  34. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +30 -8
  35. package/tests/admin/Platform_PluginStructuresApiTest.res +28 -1
  36. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +27 -5
  37. package/tests/aggregate/AggregateCacheTest.res.mjs +15 -3
  38. package/tests/aggregate/AggregateConflictTest.res.mjs +15 -3
  39. package/tests/aggregate/AggregateFixtures.res.mjs +15 -3
  40. package/tests/aggregate/AggregateSnapshotTest.res.mjs +21 -5
  41. package/tests/api/ApiNoApiTest.res +46 -0
  42. package/tests/api/ApiNoApiTest.res.mjs +49 -1
  43. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +27 -3
  44. package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +12 -2
  45. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +2 -0
  46. package/tests/commandtopic/CommandTopicCallbackFixtures.res.mjs +12 -2
  47. package/tests/dcb/DcbFixtures.res.mjs +8 -0
  48. package/tests/dcb/DcbStateChangeSliceTest.res.mjs +18 -0
  49. package/tests/eventmapper/EventMapperFixtures.res.mjs +15 -3
  50. package/tests/extensionpoint/ExtensionPointFixtures.res.mjs +9 -1
  51. package/tests/extensionpoint/ExtensionPointOperationsTest.res.mjs +9 -1
  52. package/tests/message/MessageTest.res +2 -0
  53. package/tests/message/MessageTest.res.mjs +3 -1
  54. package/tests/plugin/PluginStructureAccessTest.res.mjs +2 -0
  55. package/tests/plugin/PluginStructureTest.res +294 -1
  56. package/tests/plugin/PluginStructureTest.res.mjs +373 -3
  57. package/tests/plugin/StateChangeSlice/PsAttachInvoice.res.mjs +8 -0
  58. package/tests/plugin/StateChangeSlice/PsChangePhoto.res.mjs +8 -0
  59. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +8 -0
  60. package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +8 -0
  61. package/tests/plugin/StateChangeSlice/PsPlaceOrder.res.mjs +8 -0
  62. package/tests/plugin/StateChangeSlice/PsReserveStock.res.mjs +8 -0
  63. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +8 -0
  64. package/tests/plugin/StateChangeSlice/PsTransitionSwitch.res +59 -0
  65. package/tests/plugin/StateChangeSlice/PsTransitionSwitch.res.mjs +115 -0
  66. package/tests/plugin/StateChangeSlice/PsTypoStore.res.mjs +8 -0
  67. package/tests/plugin/StateChangeSlice/PsUploadAvatar.res.mjs +8 -0
  68. package/tests/plugin/StateChangeSlice/PsUploadImages.res.mjs +8 -0
  69. package/tests/plugin/pluginDefinitionRequiredScalars.txt +26 -0
  70. package/tests/util/CommandPublisherTest.res.mjs +9 -1
@@ -65,6 +65,8 @@ let SliceHandler = StateChangeSlice_Callback$ReventlessCore.Make({
65
65
  eventSchema: DcbFixtures$ReventlessCore.TestCommandSpec.eventSchema,
66
66
  commandSchema: DcbFixtures$ReventlessCore.TestCommandSpec.commandSchema,
67
67
  commandAuthorization: DcbFixtures$ReventlessCore.TestCommandSpec.commandAuthorization,
68
+ commandTransition: DcbFixtures$ReventlessCore.TestCommandSpec.commandTransition,
69
+ traits: DcbFixtures$ReventlessCore.TestCommandSpec.traits,
68
70
  readConsistency: DcbFixtures$ReventlessCore.TestCommandSpec.readConsistency
69
71
  })(SliceBehavior);
70
72
 
@@ -25,11 +25,19 @@ function commandAuthorization(param) {
25
25
  return "AllowAuthenticated";
26
26
  }
27
27
 
28
+ function commandTransition(param) {
29
+ return "Unrestricted";
30
+ }
31
+
32
+ let traits = [];
33
+
28
34
  let TestSpec = {
29
35
  Id: undefined,
30
36
  name: name,
31
37
  commandSchema: commandSchema,
32
- commandAuthorization: commandAuthorization
38
+ commandAuthorization: commandAuthorization,
39
+ commandTransition: commandTransition,
40
+ traits: traits
33
41
  };
34
42
 
35
43
  let capturedItems = {
@@ -64,7 +72,9 @@ let TestOps_Spec = {
64
72
  Id: Id$Reventless.StringPure,
65
73
  name: name,
66
74
  commandSchema: commandSchema,
67
- commandAuthorization: commandAuthorization
75
+ commandAuthorization: commandAuthorization,
76
+ commandTransition: commandTransition,
77
+ traits: traits
68
78
  };
69
79
 
70
80
  let TestOps = {
@@ -156,6 +156,12 @@ function commandAuthorization(param) {
156
156
  return "AllowAuthenticated";
157
157
  }
158
158
 
159
+ function commandTransition(param) {
160
+ return "Unrestricted";
161
+ }
162
+
163
+ let traits = [];
164
+
159
165
  let TestCommandSpec_initialState = {
160
166
  exists: false,
161
167
  currentName: undefined
@@ -173,6 +179,8 @@ let TestCommandSpec = {
173
179
  evolve: evolve,
174
180
  decide: decide,
175
181
  commandAuthorization: commandAuthorization,
182
+ commandTransition: commandTransition,
183
+ traits: traits,
176
184
  readConsistency: "EscalateOnRetry"
177
185
  };
178
186
 
@@ -61,6 +61,8 @@ let TestHandler = StateChangeSlice_Callback$ReventlessCore.Make({
61
61
  eventSchema: DcbFixtures$ReventlessCore.TestCommandSpec.eventSchema,
62
62
  commandSchema: DcbFixtures$ReventlessCore.TestCommandSpec.commandSchema,
63
63
  commandAuthorization: DcbFixtures$ReventlessCore.TestCommandSpec.commandAuthorization,
64
+ commandTransition: DcbFixtures$ReventlessCore.TestCommandSpec.commandTransition,
65
+ traits: DcbFixtures$ReventlessCore.TestCommandSpec.traits,
64
66
  readConsistency: DcbFixtures$ReventlessCore.TestCommandSpec.readConsistency
65
67
  })(TestCommandBehavior);
66
68
 
@@ -78,6 +80,10 @@ let errorSchema = DcbFixtures$ReventlessCore.TestCommandSpec.errorSchema;
78
80
 
79
81
  let commandAuthorization = DcbFixtures$ReventlessCore.TestCommandSpec.commandAuthorization;
80
82
 
83
+ let commandTransition = DcbFixtures$ReventlessCore.TestCommandSpec.commandTransition;
84
+
85
+ let traits = DcbFixtures$ReventlessCore.TestCommandSpec.traits;
86
+
81
87
  let StrongSpec_Id = DcbFixtures$ReventlessCore.TestCommandSpec.Id;
82
88
 
83
89
  let StrongSpec_initialState = DcbFixtures$ReventlessCore.TestCommandSpec.initialState;
@@ -98,6 +104,8 @@ let StrongSpec = {
98
104
  evolve: StrongSpec_evolve,
99
105
  decide: StrongSpec_decide,
100
106
  commandAuthorization: commandAuthorization,
107
+ commandTransition: commandTransition,
108
+ traits: traits,
101
109
  readConsistency: "AlwaysStrong"
102
110
  };
103
111
 
@@ -115,6 +123,10 @@ let errorSchema$1 = DcbFixtures$ReventlessCore.TestCommandSpec.errorSchema;
115
123
 
116
124
  let commandAuthorization$1 = DcbFixtures$ReventlessCore.TestCommandSpec.commandAuthorization;
117
125
 
126
+ let commandTransition$1 = DcbFixtures$ReventlessCore.TestCommandSpec.commandTransition;
127
+
128
+ let traits$1 = DcbFixtures$ReventlessCore.TestCommandSpec.traits;
129
+
118
130
  let EventualSpec_Id = DcbFixtures$ReventlessCore.TestCommandSpec.Id;
119
131
 
120
132
  let EventualSpec_initialState = DcbFixtures$ReventlessCore.TestCommandSpec.initialState;
@@ -135,6 +147,8 @@ let EventualSpec = {
135
147
  evolve: EventualSpec_evolve,
136
148
  decide: EventualSpec_decide,
137
149
  commandAuthorization: commandAuthorization$1,
150
+ commandTransition: commandTransition$1,
151
+ traits: traits$1,
138
152
  readConsistency: "AlwaysEventual"
139
153
  };
140
154
 
@@ -147,6 +161,8 @@ let StrongHandler = StateChangeSlice_Callback$ReventlessCore.Make({
147
161
  eventSchema: eventSchema,
148
162
  commandSchema: commandSchema,
149
163
  commandAuthorization: commandAuthorization,
164
+ commandTransition: commandTransition,
165
+ traits: traits,
150
166
  readConsistency: "AlwaysStrong"
151
167
  })(TestCommandBehavior);
152
168
 
@@ -159,6 +175,8 @@ let EventualHandler = StateChangeSlice_Callback$ReventlessCore.Make({
159
175
  eventSchema: eventSchema$1,
160
176
  commandSchema: commandSchema$1,
161
177
  commandAuthorization: commandAuthorization$1,
178
+ commandTransition: commandTransition$1,
179
+ traits: traits$1,
162
180
  readConsistency: "AlwaysEventual"
163
181
  })(TestCommandBehavior);
164
182
 
@@ -153,12 +153,20 @@ function commandAuthorization(param) {
153
153
  return "AllowAuthenticated";
154
154
  }
155
155
 
156
+ function commandTransition(param) {
157
+ return "Unrestricted";
158
+ }
159
+
160
+ let traits = [];
161
+
156
162
  let CmdTargetSpec = {
157
163
  Id: undefined,
158
164
  name: name$3,
159
165
  commandSchema: commandSchema,
160
166
  moduleUrl: moduleUrl$2,
161
- commandAuthorization: commandAuthorization
167
+ commandAuthorization: commandAuthorization,
168
+ commandTransition: commandTransition,
169
+ traits: traits
162
170
  };
163
171
 
164
172
  function map(id, event, _queryEngine) {
@@ -233,7 +241,9 @@ let OrderMappings_Target = {
233
241
  name: name$3,
234
242
  commandSchema: commandSchema,
235
243
  moduleUrl: moduleUrl$2,
236
- commandAuthorization: commandAuthorization
244
+ commandAuthorization: commandAuthorization,
245
+ commandTransition: commandTransition,
246
+ traits: traits
237
247
  };
238
248
 
239
249
  let OrderMappings = {
@@ -262,7 +272,9 @@ let CountOrderMappings_Target = {
262
272
  name: name$3,
263
273
  commandSchema: commandSchema,
264
274
  moduleUrl: moduleUrl$2,
265
- commandAuthorization: commandAuthorization
275
+ commandAuthorization: commandAuthorization,
276
+ commandTransition: commandTransition,
277
+ traits: traits
266
278
  };
267
279
 
268
280
  let CountOrderMappings = {
@@ -30,13 +30,21 @@ function commandAuthorization(param) {
30
30
  return "AllowAuthenticated";
31
31
  }
32
32
 
33
+ function commandTransition(param) {
34
+ return "Unrestricted";
35
+ }
36
+
37
+ let traits = [];
38
+
33
39
  let TestEPSpec = {
34
40
  name: name,
35
41
  commandSchema: commandSchema,
36
42
  eventSchema: eventSchema,
37
43
  directiveSchema: directiveSchema,
38
44
  moduleUrl: moduleUrl,
39
- commandAuthorization: commandAuthorization
45
+ commandAuthorization: commandAuthorization,
46
+ commandTransition: commandTransition,
47
+ traits: traits
40
48
  };
41
49
 
42
50
  let capturedPublishedCmds = {
@@ -25,13 +25,21 @@ function commandAuthorization(param) {
25
25
  return "AllowAuthenticated";
26
26
  }
27
27
 
28
+ function commandTransition(param) {
29
+ return "Unrestricted";
30
+ }
31
+
32
+ let traits = [];
33
+
28
34
  let OpsEPSpec = {
29
35
  name: name,
30
36
  commandSchema: commandSchema,
31
37
  eventSchema: eventSchema,
32
38
  directiveSchema: directiveSchema,
33
39
  moduleUrl: moduleUrl,
34
- commandAuthorization: commandAuthorization
40
+ commandAuthorization: commandAuthorization,
41
+ commandTransition: commandTransition,
42
+ traits: traits
35
43
  };
36
44
 
37
45
  let capturedPublished = {
@@ -133,6 +133,8 @@ describe("Message should", () => {
133
133
  extensionPoints: None,
134
134
  requiredStores: None,
135
135
  requiredStoreDeclarations: None,
136
+ requiredCapabilities: None,
137
+ traitDeclarations: None,
136
138
  })),
137
139
  dcbEventLog: None,
138
140
  kind: Domain,
@@ -124,7 +124,9 @@ globalThis.describe("Message should", () => {
124
124
  extensions: [],
125
125
  extensionPoints: undefined,
126
126
  requiredStores: undefined,
127
- requiredStoreDeclarations: undefined
127
+ requiredStoreDeclarations: undefined,
128
+ requiredCapabilities: undefined,
129
+ traitDeclarations: undefined
128
130
  }
129
131
  },
130
132
  dcbEventLog: undefined,
@@ -28,6 +28,8 @@ let PsGatedCommandsSlice_Spec = {
28
28
  eventSchema: PsGatedCommands$ReventlessCore.eventSchema,
29
29
  commandSchema: PsGatedCommands$ReventlessCore.commandSchema,
30
30
  commandAuthorization: PsGatedCommands$ReventlessCore.commandAuthorization,
31
+ commandTransition: PsGatedCommands$ReventlessCore.commandTransition,
32
+ traits: PsGatedCommands$ReventlessCore.traits,
31
33
  readConsistency: PsGatedCommands$ReventlessCore.readConsistency
32
34
  };
33
35
 
@@ -185,6 +185,19 @@ module PsDispatchShipmentSlice: ReventlessInfra.StateChangeSlice.T = {
185
185
  type component = scsComponent
186
186
  let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
187
187
  }
188
+ module PsTransitionSwitchSlice: ReventlessInfra.StateChangeSlice.T = {
189
+ module Spec = PsTransitionSwitch
190
+ module Behavior = {
191
+ type state = PsTransitionSwitch.state
192
+ let initialState = PsTransitionSwitch.initialState
193
+ let evolve = PsTransitionSwitch.evolve
194
+ let decide = PsTransitionSwitch.decide
195
+ let moduleUrl = PsTransitionSwitch.moduleUrl
196
+ }
197
+ let isAsync = false
198
+ type component = scsComponent
199
+ let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
200
+ }
188
201
  module PsShipmentsViewSlice: ReventlessInfra.StateViewSlice.T = {
189
202
  module Spec = PsShipmentsView
190
203
  module Projection = {
@@ -290,6 +303,87 @@ describe("lifecycle topology", () => {
290
303
  })
291
304
  })
292
305
 
306
+ describe("commandTransition — the edge as a switch", () => {
307
+ // The seam a variant spread needs. `@transition` lowers to a dict on the
308
+ // parent union keyed by variant name, and a spread splices members, so a
309
+ // command a host did not declare cannot be annotated at all. A switch over the
310
+ // command reaches every constructor, spliced or not, and the compiler makes
311
+ // it exhaustive.
312
+ let structure = Plugin_Structure.make(
313
+ ~name="TransitionSwitchPlugin",
314
+ ~stateChangeSlices=[module(PsTransitionSwitchSlice)],
315
+ ~stateViewSlices=[],
316
+ )
317
+ let slice = structure.stateChangeSlices->Array.getUnsafe(0)
318
+ let byName = name => slice.commands->Array.find(c => c.name == name)
319
+
320
+ testSync("the switch fills allowedStates and targetState", () => {
321
+ expect((
322
+ byName("Book")->Option.flatMap(c => c.allowedStates),
323
+ byName("Book")->Option.flatMap(c => c.targetState),
324
+ ))->toEqual((Some(["Draft"]), Some("Booked")))
325
+ })
326
+
327
+ testSync("a guard declares its from-set and no target", () => {
328
+ // The same positive claim the one-sided `@transition` makes: legal in these
329
+ // states, and it moves the row nowhere.
330
+ expect((
331
+ byName("Rebook")->Option.flatMap(c => c.allowedStates),
332
+ byName("Rebook")->Option.flatMap(c => c.targetState),
333
+ ))->toEqual((Some(["Booked"]), None))
334
+ })
335
+
336
+ testSync("the switch wins over an @transition on the same command", () => {
337
+ // `Rebook` carries both, and they disagree on purpose: the attribute says
338
+ // Draft. Two sources of truth need a stated winner, and it is the one the
339
+ // compiler checked.
340
+ expect(byName("Rebook")->Option.flatMap(c => c.allowedStates))->toEqual(Some(["Booked"]))
341
+ })
342
+
343
+ testSync("Unrestricted declares nothing, leaving the command unconstrained", () => {
344
+ // Distinct from a from-set naming every state, which says the same thing to
345
+ // a menu and something different to a reader.
346
+ expect((
347
+ byName("Abandon")->Option.flatMap(c => c.allowedStates),
348
+ byName("Abandon")->Option.flatMap(c => c.targetState),
349
+ ))->toEqual((None, None))
350
+ })
351
+ })
352
+
353
+ describe("traitDeclarations — a graft leaves a trace", () => {
354
+ // The one signal a graft leaves that survives into a deployed plugin. Every
355
+ // other one — the package dependency, the variant spread, the rules alias, the
356
+ // conformance binding — is source-side and invisible to a running estate.
357
+ let structure = Plugin_Structure.make(
358
+ ~name="TraitPlugin",
359
+ ~stateChangeSlices=[module(PsTransitionSwitchSlice), module(PsShipOrderSlice)],
360
+ ~stateViewSlices=[],
361
+ )
362
+
363
+ testSync("a declaring component's trait reaches the structure", () => {
364
+ expect(
365
+ structure.traitDeclarations->Option.getOr([])->Array.map(d => (d.trait, d.component)),
366
+ )->toEqual([("@reventlessdev/trait-booking", "TransitionSwitch")])
367
+ })
368
+
369
+ testSync("the component name is filled in by the structure, not declared", () => {
370
+ // The trait exports its identity and posture; it cannot know which component a
371
+ // host grafted it onto, and a host writing that name down would be the one
372
+ // hand-typed string this mechanism exists to avoid.
373
+ expect(
374
+ structure.traitDeclarations
375
+ ->Option.getOr([])
376
+ ->Array.map(d => (d.version, d.posture, d.component)),
377
+ )->toEqual([("9.9.9", "SelfContained", "TransitionSwitch")])
378
+ })
379
+
380
+ testSync("a component that is nobody's graft contributes nothing", () => {
381
+ // `PsShipOrder` declares no traits, and the injected default is empty — so the
382
+ // absence of a graft is silence rather than a row.
383
+ expect(structure.traitDeclarations->Option.getOr([])->Array.length)->toBe(1)
384
+ })
385
+ })
386
+
293
387
  describe("@transition cross-check", () => {
294
388
  // The exit criterion: a state no linked view declares stops the build, and the
295
389
  // message says which command, which state, and what it was checked against.
@@ -1366,6 +1460,45 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1366
1460
  expect(Reventless.CapabilityManifest.fromStructure(structure).capabilities)->toEqual([])
1367
1461
  )
1368
1462
 
1463
+ // A capability a slice declares rides the same manifest as the stores, so
1464
+ // the platform generator reads one file. It carries no `field`: nothing
1465
+ // annotated it, and naming one would be a fiction.
1466
+ describe("declared capabilities", () => {
1467
+ let withCapability = {
1468
+ ...structure,
1469
+ requiredCapabilities: Some([
1470
+ ({capability: "Geocoding", component: "GeocodeAddress"}: Reventless.Plugin.requiredCapabilityDeclaration),
1471
+ {capability: "Geocoding", component: "GeocodeDropOff"},
1472
+ ]),
1473
+ traitDeclarations: None,
1474
+ }
1475
+ let entries = Reventless.CapabilityManifest.fromStructure(withCapability).capabilities
1476
+
1477
+ testSync("two components needing one capability yield one entry", () =>
1478
+ expect(entries->Array.map(e => e.key))->toEqual(["Geocoding"])
1479
+ )
1480
+
1481
+ testSync("both declaring components are kept, with no field", () =>
1482
+ expect((entries->Array.getUnsafe(0)).declaredBy)->toEqual([
1483
+ ({component: "GeocodeAddress"}: Reventless.CapabilityManifest.provenance),
1484
+ {component: "GeocodeDropOff"},
1485
+ ])
1486
+ )
1487
+
1488
+ // The generator downstream renders a real `Platform.capability` arm, and
1489
+ // a name this build cannot map has no arm to render — so it is dropped
1490
+ // here rather than passed through as an unrenderable key.
1491
+ testSync("a capability this build does not know is dropped", () =>
1492
+ expect(
1493
+ Reventless.CapabilityManifest.fromStructure({
1494
+ ...structure,
1495
+ requiredCapabilities: Some([{capability: "Telepathy", component: "Guess"}]),
1496
+ traitDeclarations: None,
1497
+ }).capabilities,
1498
+ )->toEqual([])
1499
+ )
1500
+ })
1501
+
1369
1502
  testSync("rendering is deterministic and newline-terminated", () => {
1370
1503
  let rendered = Reventless.CapabilityManifest.renderForStructure(withStores)
1371
1504
  expect(rendered)->toBe(
@@ -1671,7 +1804,7 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1671
1804
  let manifest = Reventless.CapabilityManifest.fromStructure(withHeuristicField)
1672
1805
  expect(
1673
1806
  manifest.capabilities->Array.every(entry =>
1674
- entry.declaredBy->Array.every(site => site.field != "photoUrl")
1807
+ entry.declaredBy->Array.every(site => site.field != Some("photoUrl"))
1675
1808
  ),
1676
1809
  )->toBe(true)
1677
1810
  })
@@ -1747,3 +1880,163 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1747
1880
  })
1748
1881
  })
1749
1882
  })
1883
+
1884
+ // The port's translation table — the pure halves of the check. The probe itself
1885
+ // needs a mapping module and is exercised by the examples' own build.
1886
+ describe("the port's translation table", () => {
1887
+ let published = (name, fromEventTypes): ReventlessInfra.ExtensionPointMapping.publishedEvent => {
1888
+ name,
1889
+ fromEventTypes,
1890
+ }
1891
+ let failures = (~declared) =>
1892
+ Plugin_Structure.translationTableFailures(
1893
+ ~label="Catalog.Products ← Product",
1894
+ ~declared,
1895
+ ~publishedNames=["ProductBecameAvailable", "ProductWithdrawn"],
1896
+ ~sourceNames=["Added", "Archived", "Discontinued", "Renamed"],
1897
+ )
1898
+
1899
+ testSync("accepts a many-to-one declaration", () =>
1900
+ expect(
1901
+ failures(~declared=[published("ProductWithdrawn", ["Archived", "Discontinued"])])->Array.length,
1902
+ )->toBe(0)
1903
+ )
1904
+
1905
+ testSync("rejects a published name the extension point does not declare", () =>
1906
+ expect(
1907
+ failures(~declared=[published("ProductRetired", ["Archived"])])->Array.length,
1908
+ )->toBe(1)
1909
+ )
1910
+
1911
+ testSync("rejects a source name the delegate does not declare", () =>
1912
+ expect(
1913
+ failures(~declared=[published("ProductWithdrawn", ["Archivd"])])->Array.length,
1914
+ )->toBe(1)
1915
+ )
1916
+
1917
+ })
1918
+
1919
+ describe("the translation table against the probe", () => {
1920
+ let declared = [
1921
+ ({
1922
+ ReventlessInfra.ExtensionPointMapping.name: "ProductWithdrawn",
1923
+ fromEventTypes: ["Archived", "Discontinued"],
1924
+ }: ReventlessInfra.ExtensionPointMapping.publishedEvent),
1925
+ ]
1926
+ let drift = (~observed, ~followed) =>
1927
+ Plugin_Structure.translationTableDrift(~label="EP", ~declared, ~observed, ~followed)
1928
+
1929
+ testSync("says nothing when the arms produce what was declared", () => {
1930
+ let (failures, warnings) = drift(
1931
+ ~observed=[("Archived", "ProductWithdrawn"), ("Discontinued", "ProductWithdrawn")],
1932
+ ~followed=["Archived", "Discontinued"],
1933
+ )
1934
+ expect((failures->Array.length, warnings->Array.length))->toEqual((0, 0))
1935
+ })
1936
+
1937
+ // The probe watched the arm publish it, so the declaration is stale.
1938
+ testSync("fails on an edge the probe saw and the declaration omits", () => {
1939
+ let (failures, _) = drift(
1940
+ ~observed=[("Archived", "ProductRelisted")],
1941
+ ~followed=["Archived"],
1942
+ )
1943
+ expect(failures->Array.length)->toBe(1)
1944
+ })
1945
+
1946
+ // The arm may branch on a payload the probe cannot synthesise.
1947
+ testSync("only warns on a declared edge the probe did not see", () => {
1948
+ let (failures, warnings) = drift(
1949
+ ~observed=[("Archived", "ProductWithdrawn")],
1950
+ ~followed=["Archived", "Discontinued"],
1951
+ )
1952
+ expect((failures->Array.length, warnings->Array.length))->toEqual((0, 1))
1953
+ })
1954
+
1955
+ // "Did not look" must not read as "no edge".
1956
+ testSync("judges nothing about a source the probe could not follow", () => {
1957
+ let (failures, warnings) = drift(~observed=[], ~followed=[])
1958
+ expect((failures->Array.length, warnings->Array.length))->toEqual((0, 0))
1959
+ })
1960
+ })
1961
+
1962
+ describe("the subscriber's half of the table", () => {
1963
+ let handled = (name, toCommandTypes): ReventlessInfra.ExtensionMapping.handledEvent => {
1964
+ name,
1965
+ toCommandTypes,
1966
+ }
1967
+ let failures = declared =>
1968
+ Plugin_Structure.handledTableFailures(
1969
+ ~label="Catalog.Products → SyncCatalogProduct",
1970
+ ~declared,
1971
+ ~eventNames=["ProductBecameAvailable"],
1972
+ // The delegate's commands and the extension point's own, resolved as one set.
1973
+ ~commandNames=["SyncNewProduct", "AckProduct"],
1974
+ )
1975
+
1976
+ testSync("accepts a delegate command and an extension point command alike", () =>
1977
+ expect(
1978
+ failures([handled("ProductBecameAvailable", ["SyncNewProduct", "AckProduct"])])->Array.length,
1979
+ )->toBe(0)
1980
+ )
1981
+
1982
+ testSync("rejects a handled event the extension point does not publish", () =>
1983
+ expect(failures([handled("ProductAdded", ["SyncNewProduct"])])->Array.length)->toBe(1)
1984
+ )
1985
+
1986
+ testSync("rejects a command neither side declares", () =>
1987
+ expect(
1988
+ failures([handled("ProductBecameAvailable", ["SyncNewProdct"])])->Array.length,
1989
+ )->toBe(1)
1990
+ )
1991
+ })
1992
+
1993
+ // ── One command name, one handler ────────────────────────────────────────────
1994
+ //
1995
+ // A DCB plugin routes a command by its bare type name, so two slices declaring
1996
+ // one name have one handler between them. The runtime notices only when it goes
1997
+ // to stamp owner fields — by which point the deploy has succeeded and the losing
1998
+ // slice is silently unreachable — so the structure refuses it instead.
1999
+
2000
+ describe("duplicate DCB command names", () => {
2001
+ // The same slice twice is the smallest possible collision, and it needs no
2002
+ // fixture that exists only to collide.
2003
+ testSync("two slices declaring one command are refused", () =>
2004
+ expect(
2005
+ switch Plugin_Structure.make(
2006
+ ~name="TestPlugin",
2007
+ ~stateChangeSlices=[module(PsShipOrderSlice), module(PsShipOrderSlice)],
2008
+ ) {
2009
+ | _ => false
2010
+ | exception _ => true
2011
+ },
2012
+ )->toBe(true)
2013
+ )
2014
+
2015
+ testSync("the refusal names the command and both slices", () =>
2016
+ switch Plugin_Structure.make(
2017
+ ~name="TestPlugin",
2018
+ ~stateChangeSlices=[module(PsShipOrderSlice), module(PsShipOrderSlice)],
2019
+ ) {
2020
+ | _ => expect("no refusal")->toBe("a refusal naming the command")
2021
+ | exception JsExn(e) =>
2022
+ let message = e->JsExn.message->Option.getOr("")
2023
+ expect((
2024
+ message->String.includes("ShipOrder"),
2025
+ message->String.includes("routes a command by its bare name"),
2026
+ ))->toEqual((true, true))
2027
+ }
2028
+ )
2029
+
2030
+ // Distinct names are the ordinary case and must stay silent.
2031
+ testSync("distinct command names are fine", () =>
2032
+ expect(
2033
+ switch Plugin_Structure.make(
2034
+ ~name="TestPlugin",
2035
+ ~stateChangeSlices=[module(PsPlaceOrderSlice), module(PsShipOrderSlice)],
2036
+ ) {
2037
+ | _ => true
2038
+ | exception _ => false
2039
+ },
2040
+ )->toBe(true)
2041
+ )
2042
+ })