@reventlessdev/reventless-spec 3.0.0-alpha.159 → 3.0.0-alpha.161

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.161 (2026-10-10)
7
+
8
+ ### Bug Fixes
9
+
10
+ * an inbound translation is gated alike on every door, and encodes its commands by schema ([6ec3ce6](https://github.com/ReventlessDev/reventless-core/commit/6ec3ce672e53e2c162c64a90d103be691391be5e))
11
+ ### Features
12
+
13
+ * **gwt:** automation and translation tests take the slice as written, and the sidecars read them ([93a0919](https://github.com/ReventlessDev/reventless-core/commit/93a0919a0d66730cf9bf89144ad8fba3cb025332))
14
+
15
+
16
+ # 3.0.0-alpha.160 (2026-10-08)
17
+
18
+ **Note:** Version bump only for package @reventlessdev/reventless-spec
19
+
20
+
21
+
22
+
23
+
6
24
  # 3.0.0-alpha.159 (2026-10-08)
7
25
 
8
26
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.159",
3
+ "version": "3.0.0-alpha.161",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "sury": "11.0.0",
27
27
  "sury-ppx": "11.0.0",
28
28
  "yaml": "^2.8.3",
29
- "@reventlessdev/rescript-node": "2.0.0-alpha.21"
29
+ "@reventlessdev/rescript-node": "2.0.0-alpha.22"
30
30
  },
31
31
  "devDependencies": {
32
32
  "rescript": "12.3.1",
@@ -1,38 +1,10 @@
1
1
  /**
2
- Module types for a DCB inbound translation slice.
2
+ Module types for a DCB inbound translation slice: external input (a webhook, an
3
+ API call) translated into domain commands by an anti-corruption layer, triggered
4
+ through `operations.receive` rather than by events.
3
5
 
4
- An `InboundTranslationSlice` receives external input (webhooks, API calls) and
5
- translates it into domain commands via an anti-corruption layer.
6
-
7
- Unlike outbound slices, inbound slices are triggered externally via
8
- `operations.receive` rather than by subscribing to domain events.
9
-
10
- ```
11
- External Input -> Anti-Corruption Layer (translate) -> Command
12
- ```
13
-
14
- Plan 02 splits the merged spec into two module types:
15
-
16
- - `Spec` — types, identity, schemas, target name. (No state — translation is
17
- a pure function of external input.)
18
- - `Translation` — the single `translate` function.
19
-
20
- @example
21
- ```rescript
22
- // PaymentWebhook.res
23
- let name = "PaymentWebhook"
24
-
25
- @schema type externalInput = {paymentId: string, orderId: string, status: string}
26
- @schema type command = ConfirmPayment({orderId: @s.matches(DcbTag.string) string, paymentId: string})
27
-
28
- let translate = input => switch input.status {
29
- | "completed" => Ok([(input.orderId, ConfirmPayment({orderId: input.orderId, paymentId: input.paymentId}))])
30
- | _ => Error("Unknown payment status: " ++ input.status)
31
- }
32
- ```
33
- */
34
- /**
35
- The lean Spec for an InboundTranslationSlice — types, identity, schemas.
6
+ `Spec` holds the types, schemas and target; `Translation` the `translate` function.
7
+ The slice moves no lifecycle of its own — its target owns that.
36
8
  */
37
9
  module type Spec = {
38
10
  /** Logical name of this inbound translation slice (used as a component prefix). */
@@ -50,49 +22,25 @@ module type Spec = {
50
22
  /** Name of the aggregate or StateChangeSlice that receives the produced command. */
51
23
  let targetName: string
52
24
 
53
- /** Optional display name of the foreign system this anti-corruption slice receives
54
- from (e.g. `"SupplierFeed"`). Drives the **external box** drawn outside the plugin
55
- in the Event Graph / Context Map.
56
- Auto-injected by `@@reventless.spec` defaulting to `None` — set it to name the box. */
25
+ /** The foreign system this slice receives from (e.g. `"SupplierFeed"`), drawn as a
26
+ box outside the plugin. Injected as `None` by `@@reventless.spec`. */
57
27
  let externalSystem: option<string>
58
28
 
59
29
  /** The roles this spec's rules name: its plugin's `Roles.t`, which the PPX
60
30
  supplies; `Role.name` where the plugin declares none. */
61
31
  type role
62
- /** Authorization rule evaluated at the GraphQL resolver entry before any
63
- external input is translated. Auto-injected by `@@reventless.spec` and
64
- on structurally-detected inline spec modules — defaults to
65
- `AllowAuthenticated`. */
32
+ /** Each command's rule. The door admits a caller satisfying any command's
33
+ rule; each command the input translates into is then checked against its
34
+ own, and one refused refuses the whole input. Defaults to `AllowAuthenticated`. */
66
35
  let commandAuthorization: command => Authorization.rule<role>
67
-
68
- /** The lifecycle enum this component's commands move a row through — the
69
- linked view's own, e.g. `type lifecycleState = Customers.accountStatus`.
70
- Auto-injected as `unit` alongside the default below; a host that declares
71
- `commandTransition` declares this too, and the pair is what makes every
72
- edge name one lifecycle. */
73
- type lifecycleState
74
-
75
- /** The lifecycle edge each command owns, read while the plugin structure is
76
- assembled. Auto-injected as `_ => Unrestricted` by `@@reventless.spec`,
77
- so a component whose commands guard nothing needs no line; a host that
78
- writes the switch by hand gets an exhaustive one over typed states.
79
- See `Transition`. */
80
- let commandTransition: command => Transition.t<lifecycleState>
81
36
  }
82
37
 
83
- /**
84
- The Translation — the synchronous translate function.
85
- */
38
+ /** The synchronous translate function. */
86
39
  module type Translation = {
87
40
  module Spec: Spec
88
41
 
89
- /**
90
- Translate: convert external input into domain commands.
91
- Returns:
92
- - `Ok([(targetId, cmd), ...])` to publish one or more commands
93
- - `Ok([])` for idempotent no-ops (nothing to publish)
94
- - `Error(msg)` to reject the input
95
- */
42
+ /** External input into `(targetId, command)` pairs; `Ok([])` is an idempotent
43
+ no-op, `Error(msg)` rejects the input. */
96
44
  let translate: Spec.externalInput => result<array<(string, Spec.command)>, string>
97
45
 
98
46
  /** File URL of this Translation module (`import.meta.url`). */
@@ -233,6 +233,10 @@ let elementsOf = (steps: array<dict<JSON.t>>): array<element> =>
233
233
 
234
234
  let isOpaque = (e: element) => e.opaque == Some(true)
235
235
 
236
+ /** A step of a kind the sidecar knows. A `when` of `process`, `sweep` or
237
+ `exhausted` names no element, and is readable all the same. */
238
+ let readableStep = (e: element) => !isOpaque(e)
239
+
236
240
  let kindOf = (steps: array<dict<JSON.t>>): string =>
237
241
  steps->Array.get(0)->Option.flatMap(s => s->getStr("kind"))->Option.getOr("")
238
242
 
@@ -263,6 +267,9 @@ let scenarioOf = (j: JSON.t): option<scenario> =>
263
267
  }
264
268
  })
265
269
 
270
+ /** No `when` step of a known kind: the walk has nothing to exercise. */
271
+ let isUnreadable = (s: scenario) => !(s.whenElements->Array.some(readableStep))
272
+
266
273
  let readCorpus = (path: string): option<corpus> =>
267
274
  switch path->NodeFs.readFileSync->JSON.parseOrThrow->asObj {
268
275
  | None => None
@@ -759,10 +766,10 @@ let deriveCommands = (
759
766
  than guessed from names. It needs no lifecycle labelling, so every corpus a
760
767
  plugin has is read, though only its command scenarios count.
761
768
 
762
- An empty `then` is skipped rather than read as "accepted, nothing happened".
763
- The PPX currently writes `then: []` for `thenNoEvent`, so an empty `then` is
764
- indistinguishable from one the sidecar could not read, and an outcome shown
765
- here has to be one a scenario actually states. */
769
+ An empty `then` is skipped rather than read as "accepted, nothing happened":
770
+ `thenNoEvent` is written as `noEvent`, so an empty `then` is one the sidecar
771
+ could not read (or an older sidecar's `noEvent`), and an outcome shown here
772
+ has to be one a scenario actually states. */
766
773
  type shownOutcome = {
767
774
  kind: string, // "event" | "error" | "noEvent"
768
775
  /** The whole `then`, in order: `thenEvents([A, B])` is one outcome of two
@@ -858,11 +865,11 @@ type finding = {
858
865
 
859
866
  /** A corpus the walk can only partly read.
860
867
 
861
- Several DSL verbs record their `given` and nothing else — `thenIssuesCommand`,
862
- `whenReacts`, `whenPublishedThrough` and the rest — and so does a readable
863
- verb handed a let-bound value rather than a literal, since the sidecar records
864
- the constructor application it can see. Either way the scenario reaches here
865
- with an empty `when`, and the walk has nothing to exercise.
868
+ A readable verb handed a let-bound value rather than a literal records it as
869
+ `opaque`, since the sidecar records the constructor application it can see,
870
+ and a verb the sidecar has no reading for records nothing. Either way the
871
+ scenario reaches here with no `when` step of a known kind, and the walk has
872
+ nothing to exercise.
866
873
 
867
874
  Counted per component and published, because the distinction a consumer MUST
868
875
  keep is "not covered" against "not analysed": a slice with twenty thorough
@@ -1118,7 +1125,7 @@ let runPlugin = async (
1118
1125
  // slices — are exactly the ones that sit outside the view/writable folders
1119
1126
  // below, and they are the ones a coverage UI would otherwise slander.
1120
1127
  corpora->Array.forEach(c => {
1121
- let unreadable = c.scenarios->Array.filter(s => Array.length(s.whenElements) == 0)
1128
+ let unreadable = c.scenarios->Array.filter(isUnreadable)
1122
1129
  if Array.length(unreadable) > 0 {
1123
1130
  opaque
1124
1131
  ->Array.push({
@@ -132,6 +132,10 @@ function isOpaque(e) {
132
132
  return Primitive_object.equal(e.opaque, true);
133
133
  }
134
134
 
135
+ function readableStep(e) {
136
+ return !Primitive_object.equal(e.opaque, true);
137
+ }
138
+
135
139
  function kindOf(steps) {
136
140
  return Stdlib_Option.getOr(Stdlib_Option.flatMap(steps[0], s => getStr(s, "kind")), "");
137
141
  }
@@ -162,6 +166,10 @@ function scenarioOf(j) {
162
166
  });
163
167
  }
164
168
 
169
+ function isUnreadable(s) {
170
+ return !s.whenElements.some(readableStep);
171
+ }
172
+
165
173
  function readCorpus(path) {
166
174
  let d;
167
175
  try {
@@ -853,7 +861,7 @@ async function runPlugin(plugin, pluginDir, findings, opaque, outcomes) {
853
861
  let corpora = Stdlib_Array.filterMap(filesUnder(Nodepath.join(pluginDir, "tests"), ".gwt.json"), readCorpus);
854
862
  outcomes.push(...commandOutcomes(plugin, corpora));
855
863
  corpora.forEach(c => {
856
- let unreadable = c.scenarios.filter(s => s.whenElements.length === 0);
864
+ let unreadable = c.scenarios.filter(isUnreadable);
857
865
  if (unreadable.length !== 0) {
858
866
  opaque.push({
859
867
  plugin: plugin,
@@ -1337,9 +1345,11 @@ export {
1337
1345
  valuesOf,
1338
1346
  elementsOf,
1339
1347
  isOpaque,
1348
+ readableStep,
1340
1349
  kindOf,
1341
1350
  thenKindOf,
1342
1351
  scenarioOf,
1352
+ isUnreadable,
1343
1353
  readCorpus,
1344
1354
  filesUnder,
1345
1355
  gwtSources,
@@ -43,6 +43,21 @@ let admits = (rule: permission, ~signedIn: bool, ~holds: Role.name => bool): boo
43
43
  | AllowRoles(roles) => roles->Array.some(holds)
44
44
  }
45
45
 
46
+ /** The rule a caller satisfies when they satisfy any of `rules`; `None` for none. */
47
+ let anyOf = (rules: array<permission>): option<permission> =>
48
+ if rules->Array.length == 0 {
49
+ None
50
+ } else if rules->Array.some(r => r == AllowAnonymous) {
51
+ Some(AllowAnonymous)
52
+ } else if rules->Array.some(r => r == AllowAuthenticated) {
53
+ Some(AllowAuthenticated)
54
+ } else {
55
+ switch rules->Array.flatMap(rolesOf)->Set.fromArray->Set.values->Array.fromIterator {
56
+ | [] => Some(DenyAll)
57
+ | roles => Some(AllowRoles(roles))
58
+ }
59
+ }
60
+
46
61
  let isAllowed = (rule: permission, identity: Identity.t): bool =>
47
62
  admits(rule, ~signedIn=identity.userId !== "anonymous", ~holds=role =>
48
63
  identity.groups->Array.includes(Role.groupOf(role))
@@ -33,6 +33,27 @@ function admits(rule, signedIn, holds) {
33
33
  }
34
34
  }
35
35
 
36
+ function anyOf(rules) {
37
+ if (rules.length === 0) {
38
+ return;
39
+ }
40
+ if (rules.some(r => r === "AllowAnonymous")) {
41
+ return "AllowAnonymous";
42
+ }
43
+ if (rules.some(r => r === "AllowAuthenticated")) {
44
+ return "AllowAuthenticated";
45
+ }
46
+ let roles = Array.from(new Set(rules.flatMap(rolesOf)).values());
47
+ if (roles.length !== 0) {
48
+ return {
49
+ TAG: "AllowRoles",
50
+ _0: roles
51
+ };
52
+ } else {
53
+ return "DenyAll";
54
+ }
55
+ }
56
+
36
57
  function isAllowed(rule, identity) {
37
58
  return admits(rule, identity.userId !== "anonymous", role => identity.groups.includes(Role$Reventless.groupOf(role)));
38
59
  }
@@ -42,6 +63,7 @@ export {
42
63
  rolesOf,
43
64
  groupsOf,
44
65
  admits,
66
+ anyOf,
45
67
  isAllowed,
46
68
  }
47
69
  /* Role-Reventless Not a pure module */