@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 +18 -0
- package/package.json +2 -2
- package/src/components/InboundTranslationSlice.res +13 -65
- package/src/lifecycle/CheckLifecycleModel.res +17 -10
- package/src/lifecycle/CheckLifecycleModel.res.mjs +11 -1
- package/src/types/Authorization.res +15 -0
- package/src/types/Authorization.res.mjs +22 -0
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.
|
|
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.
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
/**
|
|
54
|
-
|
|
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
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
862
|
-
`
|
|
863
|
-
verb
|
|
864
|
-
|
|
865
|
-
|
|
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(
|
|
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(
|
|
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 */
|