@reventlessdev/reventless-core 3.0.0-alpha.252 → 3.0.0-alpha.254
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 +25 -0
- package/package.json +9 -9
- package/src/adapter/Messaging/Messaging_Log_Backend.res +98 -0
- package/src/adapter/Messaging/Messaging_Log_Backend.res.mjs +86 -0
- package/src/components/Dcb/Dcb_Builder.res +21 -4
- package/src/components/Dcb/Dcb_Builder.res.mjs +6 -3
- package/src/plugin/component/Plugin_Structure.res +19 -39
- package/src/plugin/component/Plugin_Structure.res.mjs +5 -13
- package/src/plugin/lifecycle/PluginSpec.res +33 -18
- package/src/plugin/lifecycle/PluginSpec.res.mjs +48 -58
- package/tests/plugin/PluginStructureTest.res +20 -25
- package/tests/plugin/PluginStructureTest.res.mjs +3 -6
- package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +17 -7
- package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +9 -16
- package/tests/plugin/StateChangeSlice/PsShipOrder.res +17 -13
- package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +14 -16
- package/tests/plugin/StateChangeSlice/PsTransitionSwitch.res +8 -13
- package/tests/plugin/StateChangeSlice/PsTransitionSwitch.res.mjs +1 -12
- package/tests/plugin/StateViewSlice/PsShipmentsView.res +1 -1
- package/src/components/Api/ApiAllowedStatesHelpers.res +0 -18
- package/src/components/Api/ApiAllowedStatesHelpers.res.mjs +0 -16
- package/src/components/Api/ApiTargetStateHelpers.res +0 -21
- package/src/components/Api/ApiTargetStateHelpers.res.mjs +0 -16
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,31 @@
|
|
|
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.254 (2026-09-02)
|
|
7
|
+
|
|
8
|
+
* feat(aws)!: the messaging sender is configuration, and a stack can choose to only log ([23b8b4b](https://github.com/ReventlessDev/reventless-core/commit/23b8b4bfe9c70555de4d74266ca686cb427485ca))
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **dcb:** a boundary that cannot derive its scope says so ([db79969](https://github.com/ReventlessDev/reventless-core/commit/db79969df36bd90607425f6a22b4624227cfc4a0))
|
|
12
|
+
|
|
13
|
+
### BREAKING CHANGES
|
|
14
|
+
|
|
15
|
+
* `Capability_Messaging_Ses.make` is replaced by
|
|
16
|
+
`Capability_Messaging.make(~name)`, which reads the transport and the address
|
|
17
|
+
from config; the SES module keeps only `emailSender`. A deployment that named its
|
|
18
|
+
sender in code must move it to `platform:messagingEmailSender` or the deploy is
|
|
19
|
+
refused.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# 3.0.0-alpha.253 (2026-09-01)
|
|
24
|
+
|
|
25
|
+
**Note:** Version bump only for package @reventlessdev/reventless-core
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
6
31
|
# 3.0.0-alpha.252 (2026-09-01)
|
|
7
32
|
|
|
8
33
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.254",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -28,22 +28,22 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"sury": "11.0.0-rc.2",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
|
-
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
|
|
32
31
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
33
|
-
"@reventlessdev/rescript-
|
|
32
|
+
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
|
|
34
33
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
35
|
-
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
36
34
|
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
37
|
-
"@reventlessdev/rescript-
|
|
35
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
36
|
+
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
38
37
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
39
|
-
"@reventlessdev/
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
38
|
+
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
|
|
39
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.155",
|
|
40
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.127",
|
|
41
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.34"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
45
45
|
"sury-ppx": "11.0.0-rc.2",
|
|
46
|
-
"@reventlessdev/reventless-ppx": "1.0.0-alpha.
|
|
46
|
+
"@reventlessdev/reventless-ppx": "1.0.0-alpha.76"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"rescript": "12.3.0"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// A logging messaging transport: accepts every message, delivers none, prints
|
|
2
|
+
// each one to the log.
|
|
3
|
+
//
|
|
4
|
+
// Provider-neutral and therefore here rather than beside either platform's own
|
|
5
|
+
// transport: both use it, and two copies would drift into two different ideas of
|
|
6
|
+
// what a logged send looks like. It is the counterpart to
|
|
7
|
+
// `Messaging_Ses_Backend` and deliberately the same shape — `channels` derived
|
|
8
|
+
// from the sender, `send` answering the shared `Messaging` vocabulary, `provider`
|
|
9
|
+
// closing over the sender. A slice reaches it through the injected capability
|
|
10
|
+
// record and cannot tell which one it got, which is the property that makes
|
|
11
|
+
// running a shop against it worth anything.
|
|
12
|
+
//
|
|
13
|
+
// **Accepting is the honest answer here, not a shortcut.** The alternative is
|
|
14
|
+
// `Capabilities.none`, which reports `Unavailable` — a *retryable* outage, so
|
|
15
|
+
// every notification is retried three times and then recorded as failed. A
|
|
16
|
+
// delivery view that is all failures teaches the wrong thing about a competency
|
|
17
|
+
// that works. Nothing leaves the process either way; the difference is only
|
|
18
|
+
// whether the outcome tells the truth about the domain.
|
|
19
|
+
//
|
|
20
|
+
// It refuses nothing a real provider would refuse, and that is the tradeoff to
|
|
21
|
+
// know: SES rejects a message with no subject, this does not, so a bug of that
|
|
22
|
+
// shape is caught on the real transport and not here.
|
|
23
|
+
//
|
|
24
|
+
// Runtime-pure: no Pulumi import, so it can be bundled into a Lambda entry point
|
|
25
|
+
// without dragging deploy-time modules into the cold-start graph.
|
|
26
|
+
|
|
27
|
+
let log = Logger.fromEnv()
|
|
28
|
+
|
|
29
|
+
/** Logged messages are numbered rather than given a random id: someone reading two
|
|
30
|
+
log lines wants to know they are two messages, and a counter says so at a
|
|
31
|
+
glance where a uuid does not. Per process, like the transport itself. */
|
|
32
|
+
let sent = ref(0)
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
The channels this transport can attempt.
|
|
36
|
+
|
|
37
|
+
Derived from the sender exactly as SES derives it, so a platform that chose this
|
|
38
|
+
transport but named no address publishes the same empty list an unprovisioned
|
|
39
|
+
deployment does. SMS and push are absent for the same reason they are absent
|
|
40
|
+
there — no transport — and answer `UnsupportedChannel`, which is settled rather
|
|
41
|
+
than retried.
|
|
42
|
+
*/
|
|
43
|
+
let channels = (~sender: string): array<Reventless.Messaging.channel> =>
|
|
44
|
+
sender == "" ? [] : [Email]
|
|
45
|
+
|
|
46
|
+
/** The message as it would have gone out, headers and all.
|
|
47
|
+
|
|
48
|
+
The whole point of the transport: someone checking that a notification says the
|
|
49
|
+
right thing needs the words, not a line saying words were sent. Rendered as
|
|
50
|
+
one record rather than a line per header so two concurrent sends cannot
|
|
51
|
+
interleave into one unreadable message. */
|
|
52
|
+
let render = (
|
|
53
|
+
~ref_: string,
|
|
54
|
+
~sender: string,
|
|
55
|
+
~to_: string,
|
|
56
|
+
~message: Reventless.Messaging.message,
|
|
57
|
+
) =>
|
|
58
|
+
[
|
|
59
|
+
`${ref_} — logged, not sent`,
|
|
60
|
+
` From: ${sender}`,
|
|
61
|
+
` To: ${to_}`,
|
|
62
|
+
` Subject: ${message.subject->Option.getOr("(none)")}`,
|
|
63
|
+
"",
|
|
64
|
+
message.body,
|
|
65
|
+
]->Array.join("\n")
|
|
66
|
+
|
|
67
|
+
/** Accept the message, print it, deliver nothing. */
|
|
68
|
+
let send = async (
|
|
69
|
+
~sender: string,
|
|
70
|
+
~recipient: Reventless.Messaging.recipient,
|
|
71
|
+
~message: Reventless.Messaging.message,
|
|
72
|
+
): result<Reventless.Messaging.receipt, Reventless.Messaging.failure> =>
|
|
73
|
+
switch recipient {
|
|
74
|
+
| ToSms(_) => Error(UnsupportedChannel(Sms))
|
|
75
|
+
| ToPush(_) => Error(UnsupportedChannel(Push))
|
|
76
|
+
| ToEmail(address) =>
|
|
77
|
+
if sender == "" {
|
|
78
|
+
// The wording an unprovisioned platform gave before this transport existed.
|
|
79
|
+
// It is what lands in the delivery view's `detail`, and a platform that
|
|
80
|
+
// provisions nothing should still read the way it always did.
|
|
81
|
+
Error(Unavailable("no messaging provider is configured for this platform"))
|
|
82
|
+
} else {
|
|
83
|
+
sent := sent.contents + 1
|
|
84
|
+
let ref_ = `log:${sent.contents->Int.toString}`
|
|
85
|
+
log.info(
|
|
86
|
+
~comp="Messaging:Log",
|
|
87
|
+
render(~ref_, ~sender, ~to_=address->Reventless.Email.toString, ~message),
|
|
88
|
+
)
|
|
89
|
+
Ok({ref: ref_})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The port, closed over the sender this platform was given. What a platform's
|
|
94
|
+
capability record carries when logging is the chosen transport. */
|
|
95
|
+
let provider = (~sender: string): Reventless.Messaging.provider => {
|
|
96
|
+
channels: channels(~sender),
|
|
97
|
+
send: (~recipient, ~message) => send(~sender, ~recipient, ~message),
|
|
98
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
|
|
5
|
+
|
|
6
|
+
let log = Logger$ReventlessCore.fromEnv();
|
|
7
|
+
|
|
8
|
+
let sent = {
|
|
9
|
+
contents: 0
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function channels(sender) {
|
|
13
|
+
if (sender === "") {
|
|
14
|
+
return [];
|
|
15
|
+
} else {
|
|
16
|
+
return ["Email"];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function render(ref_, sender, to_, message) {
|
|
21
|
+
return [
|
|
22
|
+
ref_ + ` — logged, not sent`,
|
|
23
|
+
` From: ` + sender,
|
|
24
|
+
` To: ` + to_,
|
|
25
|
+
` Subject: ` + Stdlib_Option.getOr(message.subject, "(none)"),
|
|
26
|
+
"",
|
|
27
|
+
message.body
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function send(sender, recipient, message) {
|
|
32
|
+
switch (recipient.TAG) {
|
|
33
|
+
case "ToEmail" :
|
|
34
|
+
if (sender === "") {
|
|
35
|
+
return {
|
|
36
|
+
TAG: "Error",
|
|
37
|
+
_0: {
|
|
38
|
+
TAG: "Unavailable",
|
|
39
|
+
_0: "no messaging provider is configured for this platform"
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
sent.contents = sent.contents + 1 | 0;
|
|
44
|
+
let ref_ = `log:` + sent.contents.toString();
|
|
45
|
+
log.info("Messaging:Log", undefined, render(ref_, sender, recipient._0, message));
|
|
46
|
+
return {
|
|
47
|
+
TAG: "Ok",
|
|
48
|
+
_0: {
|
|
49
|
+
ref: ref_
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
case "ToSms" :
|
|
53
|
+
return {
|
|
54
|
+
TAG: "Error",
|
|
55
|
+
_0: {
|
|
56
|
+
TAG: "UnsupportedChannel",
|
|
57
|
+
_0: "Sms"
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
case "ToPush" :
|
|
61
|
+
return {
|
|
62
|
+
TAG: "Error",
|
|
63
|
+
_0: {
|
|
64
|
+
TAG: "UnsupportedChannel",
|
|
65
|
+
_0: "Push"
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function provider(sender) {
|
|
72
|
+
return {
|
|
73
|
+
channels: channels(sender),
|
|
74
|
+
send: (recipient, message) => send(sender, recipient, message)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export {
|
|
79
|
+
log,
|
|
80
|
+
sent,
|
|
81
|
+
channels,
|
|
82
|
+
render,
|
|
83
|
+
send,
|
|
84
|
+
provider,
|
|
85
|
+
}
|
|
86
|
+
/* log Not a pure module */
|
|
@@ -379,10 +379,7 @@ module Make = (
|
|
|
379
379
|
// (DcbCommandTopicEntryPoint.mjs) route through `DcbTag.deriveEffectiveScope`
|
|
380
380
|
// so the runtime decision query cannot drift from this scope — see
|
|
381
381
|
// docs/analysis/dcb-runtime-scope-annotation-drift.md.
|
|
382
|
-
let
|
|
383
|
-
crossPartitionTagKeys: effectiveCrossPartitionTagKeys,
|
|
384
|
-
tagKeysByEventType: effectiveTagKeysByEventType,
|
|
385
|
-
} = Reventless.DcbTag.deriveEffectiveScope(
|
|
382
|
+
let effective = Reventless.DcbTag.deriveEffectiveScope(
|
|
386
383
|
stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => {
|
|
387
384
|
Reventless.DcbTag.name: Sc.Spec.name,
|
|
388
385
|
commandSchema: Sc.Spec.commandSchema->S.castToUnknown,
|
|
@@ -390,6 +387,26 @@ module Make = (
|
|
|
390
387
|
eventSchema: Sc.Spec.eventSchema->S.castToUnknown,
|
|
391
388
|
}),
|
|
392
389
|
)
|
|
390
|
+
let effectiveCrossPartitionTagKeys = effective.crossPartitionTagKeys
|
|
391
|
+
let effectiveTagKeysByEventType = effective.tagKeysByEventType
|
|
392
|
+
|
|
393
|
+
// An ambiguity is a warning above because a boundary can fall back and
|
|
394
|
+
// lose nothing. Losing a cross-partition key is not that: the decision
|
|
395
|
+
// read for every slice that references another entity silently narrows to
|
|
396
|
+
// its own partition, so it decides against an empty history and rejects a
|
|
397
|
+
// command whose facts are sitting in the log. Nothing downstream fails —
|
|
398
|
+
// the wrong answer *is* the failure — so this is the only place it can be
|
|
399
|
+
// said, and it is said at error level naming the slice to fix.
|
|
400
|
+
if effective.droppedCrossPartitionTagKeys->Array.length > 0 {
|
|
401
|
+
log.error(
|
|
402
|
+
~comp="Dcb_Builder",
|
|
403
|
+
`DCB scope degraded (${name}): cross-partition reads of [${effective.droppedCrossPartitionTagKeys->Array.join(
|
|
404
|
+
", ",
|
|
405
|
+
)}] were derived but dropped — an ambiguous slice forced the boundary onto @crossPartition annotations that do not carry them. Slices referencing another entity by these keys will decide against an empty history and reject valid commands. Resolve: ${effective.ambiguities
|
|
406
|
+
->Array.map(((slice, reason)) => `${slice} (${reason})`)
|
|
407
|
+
->Array.join(" | ")}`,
|
|
408
|
+
)
|
|
409
|
+
}
|
|
393
410
|
|
|
394
411
|
module DcbEventLog = DcbEventLog_Builder.Make(
|
|
395
412
|
DcbEventLogStorage,
|
|
@@ -231,14 +231,17 @@ function Make(DcbEventLogStorage) {
|
|
|
231
231
|
let scopeIssues = DcbValidation$Reventless.validateScopeVsInference(scopeAnnotations, inferred);
|
|
232
232
|
scopeIssues.contradictions.forEach(e => log.warn("Dcb_Builder", undefined, `DCB scope contradiction (` + e.sliceName + `): ` + e.message));
|
|
233
233
|
scopeIssues.redundancies.forEach(e => log.info("Dcb_Builder", undefined, `DCB scope (` + e.sliceName + `): ` + e.message));
|
|
234
|
-
let
|
|
234
|
+
let effective = DcbTag$Reventless.deriveEffectiveScope(stateChangeSlices.map(Sc => ({
|
|
235
235
|
name: Sc.Spec.name,
|
|
236
236
|
commandSchema: Sc.Spec.commandSchema,
|
|
237
237
|
consumedEventSchema: Sc.Spec.consumedEventSchema,
|
|
238
238
|
eventSchema: Sc.Spec.eventSchema
|
|
239
239
|
})));
|
|
240
|
-
let
|
|
241
|
-
let
|
|
240
|
+
let effectiveCrossPartitionTagKeys = effective.crossPartitionTagKeys;
|
|
241
|
+
let effectiveTagKeysByEventType = effective.tagKeysByEventType;
|
|
242
|
+
if (effective.droppedCrossPartitionTagKeys.length !== 0) {
|
|
243
|
+
log.error("Dcb_Builder", undefined, `DCB scope degraded (` + name + `): cross-partition reads of [` + effective.droppedCrossPartitionTagKeys.join(", ") + `] were derived but dropped — an ambiguous slice forced the boundary onto @crossPartition annotations that do not carry them. Slices referencing another entity by these keys will decide against an empty history and reject valid commands. Resolve: ` + effective.ambiguities.map(param => param[0] + ` (` + param[1] + `)`).join(" | "));
|
|
244
|
+
}
|
|
242
245
|
let DcbEventLog = DcbEventLog_Builder$ReventlessCore.Make(DcbEventLogStorage)(DcbEventTopicPublisher);
|
|
243
246
|
let dcbEventLog = DcbEventLog.make(name, indexes$1, partitionTag, effectiveCrossPartitionTagKeys, opts);
|
|
244
247
|
Stdlib_Option.forEach(HooksConfig.hooks.onDcbEventLogCreated, hook => hook(dcbEventLog));
|
|
@@ -117,7 +117,7 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): retire
|
|
|
117
117
|
->Option.map(f => ` (that is "${f}")`)
|
|
118
118
|
->Option.getOr(
|
|
119
119
|
" (it declares none)",
|
|
120
|
-
)}. A retirement state no command's
|
|
120
|
+
)}. A retirement state no command's declared edge can name loses the command filtering the state form exists for.`,
|
|
121
121
|
)
|
|
122
122
|
}
|
|
123
123
|
let declared = switch stateSchema {
|
|
@@ -179,8 +179,9 @@ let reportRetiredStates = (
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
// The states a record's lifecycle field can hold — the field
|
|
183
|
-
// written in terms of. `None`: no lifecycle; `Some([])`: one
|
|
182
|
+
// The states a record's lifecycle field can hold — the field a command's
|
|
183
|
+
// declared edge is written in terms of. `None`: no lifecycle; `Some([])`: one
|
|
184
|
+
// with no cases.
|
|
184
185
|
let lifecycleStatesFromStateSchema = (
|
|
185
186
|
~entityName: string,
|
|
186
187
|
stateSchema: S.t<unknown>,
|
|
@@ -373,18 +374,17 @@ let reportTranslationTables = (
|
|
|
373
374
|
}
|
|
374
375
|
}
|
|
375
376
|
|
|
376
|
-
// A command's declared edge names states belonging to another component's enum
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
// here. A state the linked views do not declare raises (this runs at assembly,
|
|
377
|
+
// A command's declared edge names states belonging to another component's enum,
|
|
378
|
+
// and nothing forces a spec to pick the one its linked view declares. Both sides
|
|
379
|
+
// are in hand here. A state the linked views do not declare raises (this runs at assembly,
|
|
380
380
|
// never in a Lambda, so that is a failed deploy); no resolvable view only warns.
|
|
381
381
|
// Checked against the UNION of the linked views: a slice feeding two is not
|
|
382
382
|
// claiming which one.
|
|
383
383
|
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
384
|
+
// The compiler has already resolved the switch's constructors, so what survives
|
|
385
|
+
// to here is a component naming the wrong lifecycle enum rather than misspelling
|
|
386
|
+
// a state — the half of the check `lifecycleState` cannot make, since nothing
|
|
387
|
+
// forces a spec to pick its linked view's enum.
|
|
388
388
|
let checkDeclaredTransitions = (
|
|
389
389
|
~pluginName: string,
|
|
390
390
|
~writables: array<Reventless.Plugin.writableDef>,
|
|
@@ -714,9 +714,8 @@ let toCommandDef = (
|
|
|
714
714
|
// component-level shortcut would gate `AddProduct` and `PlaceOrder` alike.
|
|
715
715
|
~commandAuthorization: unknown => Reventless.Authorization.permission,
|
|
716
716
|
// The spec's `command => Transition.t<_>`, evaluated per variant the same way
|
|
717
|
-
// `commandAuthorization` is
|
|
718
|
-
//
|
|
719
|
-
// what the PPX injects for a spec that never wrote the switch.
|
|
717
|
+
// `commandAuthorization` is, and the only declaration of an edge there is —
|
|
718
|
+
// the PPX injects `Unrestricted` for a spec that writes no switch.
|
|
720
719
|
//
|
|
721
720
|
// Read at `t<string>` because this is the erasure boundary: the spec declares
|
|
722
721
|
// its edges over the linked view's own lifecycle enum, whose arms are
|
|
@@ -740,34 +739,15 @@ let toCommandDef = (
|
|
|
740
739
|
Reventless.DcbTag.isVariantPayloadBearing(parentSchema, variantName)
|
|
741
740
|
? {"TAG": variantName}->Obj.magic
|
|
742
741
|
: variantName->Obj.magic
|
|
743
|
-
// The spec's own switch, which is
|
|
744
|
-
// constructor the host did not declare
|
|
745
|
-
// the parent union, and a variant spread splices members, so a spliced
|
|
746
|
-
// command reaches the metadata below carrying nothing.
|
|
747
|
-
let declared = commandTransition(syntheticCommand)
|
|
748
|
-
// An edge is ONE declaration, so the two fields are chosen together. Taking
|
|
749
|
-
// them separately would let `Guards(…)` — which positively claims the
|
|
750
|
-
// command moves the row nowhere — inherit a target from an annotation it
|
|
751
|
-
// was written to replace.
|
|
752
|
-
//
|
|
753
|
-
// The `@transition` metadata is per-variant but attached by the PPX to the
|
|
754
|
-
// *parent* schema as one dict. It is consulted only where the switch
|
|
755
|
-
// declares no edge at all: a spec that never wrote one answers
|
|
756
|
-
// `Unrestricted` for every command, which is how the annotation stays in
|
|
757
|
-
// charge for the hosts that still use it.
|
|
742
|
+
// The spec's own switch, which is exhaustive — so it also speaks for a
|
|
743
|
+
// constructor the host did not declare but spliced from a trait.
|
|
758
744
|
//
|
|
745
|
+
// An edge is ONE declaration, so the two fields are read off it together.
|
|
759
746
|
// `targetState: None` ⇒ AutoUI's board resolver falls back to its name-stem
|
|
760
747
|
// heuristic.
|
|
761
|
-
let
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
ApiTargetStateHelpers.getTargetState(parentSchema, ~variantName),
|
|
765
|
-
)
|
|
766
|
-
| _ => (
|
|
767
|
-
Reventless.Transition.allowedStates(declared),
|
|
768
|
-
Reventless.Transition.targetState(declared),
|
|
769
|
-
)
|
|
770
|
-
}
|
|
748
|
+
let declared = commandTransition(syntheticCommand)
|
|
749
|
+
let allowedStates = Reventless.Transition.allowedStates(declared)
|
|
750
|
+
let targetState = Reventless.Transition.targetState(declared)
|
|
771
751
|
// API-exposed iff the whole command isn't @noApi and this variant
|
|
772
752
|
// isn't in its @noApi-variants set — mirrors the API-generation filter
|
|
773
753
|
// (Plugin_Helpers / PluginBaseFragment). Drives the event-graph API badge.
|
|
@@ -25,8 +25,6 @@ import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src
|
|
|
25
25
|
import * as ApiNoApiHelpers$ReventlessCore from "../../components/Api/ApiNoApiHelpers.res.mjs";
|
|
26
26
|
import * as SuryToJsonSchema$ReventlessCore from "../../components/Api/SuryToJsonSchema.res.mjs";
|
|
27
27
|
import * as Capability_Inference$ReventlessCore from "./Capability_Inference.res.mjs";
|
|
28
|
-
import * as ApiTargetStateHelpers$ReventlessCore from "../../components/Api/ApiTargetStateHelpers.res.mjs";
|
|
29
|
-
import * as ApiAllowedStatesHelpers$ReventlessCore from "../../components/Api/ApiAllowedStatesHelpers.res.mjs";
|
|
30
28
|
import * as GraphQL_FragmentGenerator$ReventlessCore from "../../components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
31
29
|
|
|
32
30
|
let log = Logger$ReventlessCore.fromEnv();
|
|
@@ -122,7 +120,7 @@ function checkRetiredValue(entityName, stateSchema) {
|
|
|
122
120
|
let named = values.join(", ");
|
|
123
121
|
let lifecycle = lifecycleFieldFromStateSchema(entityName, stateSchema);
|
|
124
122
|
if (Primitive_object.notequal(lifecycle, field)) {
|
|
125
|
-
log.warn("Plugin_Structure", undefined, entityName + `: @retired(` + named + `) is on "` + field + `", which is not this record's lifecycle field` + Stdlib_Option.getOr(Stdlib_Option.map(lifecycle, f => ` (that is "` + f + `")`), " (it declares none)") + `. A retirement state no command's
|
|
123
|
+
log.warn("Plugin_Structure", undefined, entityName + `: @retired(` + named + `) is on "` + field + `", which is not this record's lifecycle field` + Stdlib_Option.getOr(Stdlib_Option.map(lifecycle, f => ` (that is "` + f + `")`), " (it declares none)") + `. A retirement state no command's declared edge can name loses the command filtering the state form exists for.`);
|
|
126
124
|
}
|
|
127
125
|
let declared;
|
|
128
126
|
declared = stateSchema.type === "object" ? Stdlib_Option.getOr(Stdlib_Option.map(stateSchema.properties[field], schema => {
|
|
@@ -602,14 +600,8 @@ function toCommandDef(isAggregate, mutationFieldFor, parentSchema, commandAuthor
|
|
|
602
600
|
TAG: variantName
|
|
603
601
|
}) : variantName;
|
|
604
602
|
let declared = commandTransition(syntheticCommand);
|
|
605
|
-
let
|
|
606
|
-
|
|
607
|
-
ApiAllowedStatesHelpers$ReventlessCore.getAllowedStates(parentSchema, variantName),
|
|
608
|
-
ApiTargetStateHelpers$ReventlessCore.getTargetState(parentSchema, variantName)
|
|
609
|
-
] : [
|
|
610
|
-
Transition$Reventless.allowedStates(declared),
|
|
611
|
-
Transition$Reventless.targetState(declared)
|
|
612
|
-
];
|
|
603
|
+
let allowedStates = Transition$Reventless.allowedStates(declared);
|
|
604
|
+
let targetState = Transition$Reventless.targetState(declared);
|
|
613
605
|
let apiExposed = false;
|
|
614
606
|
if (!ApiNoApiHelpers$ReventlessCore.isNoApi(parentSchema)) {
|
|
615
607
|
let excluded = ApiNoApiHelpers$ReventlessCore.getExcludedVariants(parentSchema);
|
|
@@ -626,8 +618,8 @@ function toCommandDef(isAggregate, mutationFieldFor, parentSchema, commandAuthor
|
|
|
626
618
|
aggregateIdField: match[1],
|
|
627
619
|
mutationField: mutationField,
|
|
628
620
|
references: references,
|
|
629
|
-
allowedStates:
|
|
630
|
-
targetState:
|
|
621
|
+
allowedStates: allowedStates,
|
|
622
|
+
targetState: targetState,
|
|
631
623
|
apiExposed: apiExposed,
|
|
632
624
|
requiredAccess: requiredAccess,
|
|
633
625
|
ownerField: Owner$Reventless.fieldNamesOfProperties(properties)[0]
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
open Reventless.Plugin
|
|
4
4
|
|
|
5
|
-
// Alias, not `open`: the
|
|
6
|
-
//
|
|
5
|
+
// Alias, not `open`: the lifecycle states are named qualified in
|
|
6
|
+
// `commandTransition` below, and an `open` would pull the whole view's namespace
|
|
7
|
+
// in for four constructors.
|
|
7
8
|
module Plugins = PluginsReadModelSpec
|
|
8
9
|
|
|
9
10
|
// The Plugin aggregate is keyed by plugin **name** (not name@version). One
|
|
@@ -15,10 +16,7 @@ module Plugins = PluginsReadModelSpec
|
|
|
15
16
|
// more same-shaped ones, so the `pluginDefinition` pair must lead (DZakh/sury#392).
|
|
16
17
|
@schema
|
|
17
18
|
type command =
|
|
18
|
-
|
|
19
|
-
// — `decide` accepts it against an unknown version and against every status
|
|
20
|
-
// but `Connected`, where it is a definition refresh rather than a move.
|
|
21
|
-
| @noApi @transition(() => Plugins.Connected) Connect(pluginDefinition)
|
|
19
|
+
| @noApi Connect(pluginDefinition)
|
|
22
20
|
// Records a protocol-version incompatibility without changing connection state.
|
|
23
21
|
| @noApi ReportIncompatibility(pluginDefinition)
|
|
24
22
|
| @noApi Heartbeat(version)
|
|
@@ -26,28 +24,20 @@ type command =
|
|
|
26
24
|
// re-run for an already-connected version so its definition is refreshed on the
|
|
27
25
|
// row — unlike Heartbeat, which no-ops a connected keep-alive.
|
|
28
26
|
| @noApi Redetect(version)
|
|
29
|
-
|
|
30
|
-
// `decide` moves the row for exactly one status — anything else is a stray
|
|
31
|
-
// disconnect it tolerates without emitting.
|
|
32
|
-
| @noApi @transition(([Plugins.Connected]) => Plugins.Disconnected) Disconnect(version)
|
|
27
|
+
| @noApi Disconnect(version)
|
|
33
28
|
// Admin lifecycle commands — API-exposed (auto-derived admin mutations,
|
|
34
29
|
// Cognito group-gated). `version` selects which known version to act on.
|
|
35
|
-
// The `@transition` edges must agree with `decide` below; `Platform_Admin_Structure`
|
|
36
|
-
// reads them off this schema rather than restating them.
|
|
37
30
|
//
|
|
38
31
|
// Inline records, unlike the `@noApi` commands above, and the difference is the
|
|
39
32
|
// API. A positional payload publishes its argument as `_0`, which names nothing:
|
|
40
33
|
// a caller cannot match it against a field it holds, and a coercion failure says
|
|
41
34
|
// `Variable '_0'` rather than what was missing. These three are the ones a person
|
|
42
35
|
// calls, so their argument carries its own name.
|
|
43
|
-
|
|
|
44
|
-
|
|
45
|
-
| @transition(([Plugins.Connected, Plugins.Disconnected]) => Plugins.Inactive)
|
|
46
|
-
Deactivate({version: version})
|
|
36
|
+
| Activate({version: version})
|
|
37
|
+
| Deactivate({version: version})
|
|
47
38
|
// Manual admin retirement of a specific version (archive). Distinct from the
|
|
48
39
|
// automatic `Superseded` transition (which is decided, not commanded).
|
|
49
|
-
|
|
|
50
|
-
Retire({version: version})
|
|
40
|
+
| Retire({version: version})
|
|
51
41
|
|
|
52
42
|
// Carries both the superseded and the superseding version's full definition —
|
|
53
43
|
// a deterministic trigger for version-to-version schema/data migrations
|
|
@@ -91,3 +81,28 @@ type error =
|
|
|
91
81
|
| AlreadyConnected
|
|
92
82
|
| IsDisconnected
|
|
93
83
|
| IsInactive
|
|
84
|
+
|
|
85
|
+
// The edges must agree with `decide`; `Platform_Admin_Structure` reads them off
|
|
86
|
+
// the assembled structure rather than restating them.
|
|
87
|
+
type lifecycleState = Plugins.status
|
|
88
|
+
|
|
89
|
+
let commandTransition = (command: command): Reventless.Transition.t<lifecycleState> => {
|
|
90
|
+
open Reventless.Transition
|
|
91
|
+
switch command {
|
|
92
|
+
// The handshake brings the version's row into being, so it runs from no state
|
|
93
|
+
// — `decide` accepts it against an unknown version and against every status
|
|
94
|
+
// but `Connected`, where it is a definition refresh rather than a move.
|
|
95
|
+
| Connect(_) => Creates(Plugins.Connected)
|
|
96
|
+
// Neither records a move: an incompatibility is noted beside the row, and a
|
|
97
|
+
// heartbeat or a re-detect refreshes one that is already where it belongs.
|
|
98
|
+
| ReportIncompatibility(_) | Heartbeat(_) | Redetect(_) => Unrestricted
|
|
99
|
+
// A heartbeat timing out is the only way a row reaches `Disconnected`, and
|
|
100
|
+
// `decide` moves the row for exactly one status — anything else is a stray
|
|
101
|
+
// disconnect it tolerates without emitting.
|
|
102
|
+
| Disconnect(_) => Moves([Plugins.Connected], Plugins.Disconnected)
|
|
103
|
+
| Activate(_) => Moves([Plugins.Inactive, Plugins.Retired], Plugins.Connected)
|
|
104
|
+
| Deactivate(_) => Moves([Plugins.Connected, Plugins.Disconnected], Plugins.Inactive)
|
|
105
|
+
| Retire(_) =>
|
|
106
|
+
Moves([Plugins.Connected, Plugins.Disconnected, Plugins.Inactive], Plugins.Retired)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -92,6 +92,52 @@ let errorSchema = Sury.union([
|
|
|
92
92
|
Sury.literal("IsInactive")
|
|
93
93
|
]);
|
|
94
94
|
|
|
95
|
+
function commandTransition(command) {
|
|
96
|
+
switch (command.TAG) {
|
|
97
|
+
case "Connect" :
|
|
98
|
+
return {
|
|
99
|
+
TAG: "Creates",
|
|
100
|
+
_0: "Connected"
|
|
101
|
+
};
|
|
102
|
+
case "Disconnect" :
|
|
103
|
+
return {
|
|
104
|
+
TAG: "Moves",
|
|
105
|
+
_0: ["Connected"],
|
|
106
|
+
_1: "Disconnected"
|
|
107
|
+
};
|
|
108
|
+
case "Activate" :
|
|
109
|
+
return {
|
|
110
|
+
TAG: "Moves",
|
|
111
|
+
_0: [
|
|
112
|
+
"Inactive",
|
|
113
|
+
"Retired"
|
|
114
|
+
],
|
|
115
|
+
_1: "Connected"
|
|
116
|
+
};
|
|
117
|
+
case "Deactivate" :
|
|
118
|
+
return {
|
|
119
|
+
TAG: "Moves",
|
|
120
|
+
_0: [
|
|
121
|
+
"Connected",
|
|
122
|
+
"Disconnected"
|
|
123
|
+
],
|
|
124
|
+
_1: "Inactive"
|
|
125
|
+
};
|
|
126
|
+
case "Retire" :
|
|
127
|
+
return {
|
|
128
|
+
TAG: "Moves",
|
|
129
|
+
_0: [
|
|
130
|
+
"Connected",
|
|
131
|
+
"Disconnected",
|
|
132
|
+
"Inactive"
|
|
133
|
+
],
|
|
134
|
+
_1: "Retired"
|
|
135
|
+
};
|
|
136
|
+
default:
|
|
137
|
+
return "Unrestricted";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
95
141
|
let commandSchema$1 = Api$ReventlessInfra.markNoApiVariants(commandSchema, [
|
|
96
142
|
"Connect",
|
|
97
143
|
"ReportIncompatibility",
|
|
@@ -100,66 +146,10 @@ let commandSchema$1 = Api$ReventlessInfra.markNoApiVariants(commandSchema, [
|
|
|
100
146
|
"Disconnect"
|
|
101
147
|
]);
|
|
102
148
|
|
|
103
|
-
let commandSchema$2 = Api$ReventlessInfra.markAllowedStates(commandSchema$1, [
|
|
104
|
-
[
|
|
105
|
-
"Disconnect",
|
|
106
|
-
["Connected"]
|
|
107
|
-
],
|
|
108
|
-
[
|
|
109
|
-
"Activate",
|
|
110
|
-
[
|
|
111
|
-
"Inactive",
|
|
112
|
-
"Retired"
|
|
113
|
-
]
|
|
114
|
-
],
|
|
115
|
-
[
|
|
116
|
-
"Deactivate",
|
|
117
|
-
[
|
|
118
|
-
"Connected",
|
|
119
|
-
"Disconnected"
|
|
120
|
-
]
|
|
121
|
-
],
|
|
122
|
-
[
|
|
123
|
-
"Retire",
|
|
124
|
-
[
|
|
125
|
-
"Connected",
|
|
126
|
-
"Disconnected",
|
|
127
|
-
"Inactive"
|
|
128
|
-
]
|
|
129
|
-
]
|
|
130
|
-
]);
|
|
131
|
-
|
|
132
|
-
let commandSchema$3 = Api$ReventlessInfra.markTargetState(commandSchema$2, [
|
|
133
|
-
[
|
|
134
|
-
"Connect",
|
|
135
|
-
"Connected"
|
|
136
|
-
],
|
|
137
|
-
[
|
|
138
|
-
"Disconnect",
|
|
139
|
-
"Disconnected"
|
|
140
|
-
],
|
|
141
|
-
[
|
|
142
|
-
"Activate",
|
|
143
|
-
"Connected"
|
|
144
|
-
],
|
|
145
|
-
[
|
|
146
|
-
"Deactivate",
|
|
147
|
-
"Inactive"
|
|
148
|
-
],
|
|
149
|
-
[
|
|
150
|
-
"Retire",
|
|
151
|
-
"Retired"
|
|
152
|
-
]
|
|
153
|
-
]);
|
|
154
|
-
|
|
155
149
|
function commandAuthorization(param) {
|
|
156
150
|
return "AllowAuthenticated";
|
|
157
151
|
}
|
|
158
152
|
|
|
159
|
-
function commandTransition(param) {
|
|
160
|
-
return "Unrestricted";
|
|
161
|
-
}
|
|
162
|
-
|
|
163
153
|
let traits = [];
|
|
164
154
|
|
|
165
155
|
let name = "Plugin";
|
|
@@ -177,10 +167,10 @@ export {
|
|
|
177
167
|
versionSupersededDataSchema,
|
|
178
168
|
eventSchema,
|
|
179
169
|
errorSchema,
|
|
180
|
-
|
|
170
|
+
commandTransition,
|
|
171
|
+
commandSchema$1 as commandSchema,
|
|
181
172
|
moduleUrl,
|
|
182
173
|
commandAuthorization,
|
|
183
|
-
commandTransition,
|
|
184
174
|
traits,
|
|
185
175
|
}
|
|
186
176
|
/* commandSchema Not a pure module */
|
|
@@ -304,11 +304,11 @@ describe("lifecycle topology", () => {
|
|
|
304
304
|
})
|
|
305
305
|
|
|
306
306
|
describe("commandTransition — the edge as a switch", () => {
|
|
307
|
-
// The seam a variant spread needs.
|
|
308
|
-
// parent union keyed by variant name, and a spread splices members, so a
|
|
309
|
-
// command a host did not declare
|
|
310
|
-
// command reaches every constructor, spliced or not, and the compiler
|
|
311
|
-
// it exhaustive.
|
|
307
|
+
// The seam a variant spread needs. The removed attribute lowered to a dict on
|
|
308
|
+
// the parent union keyed by variant name, and a spread splices members, so a
|
|
309
|
+
// command a host did not declare could not be annotated at all. A switch over
|
|
310
|
+
// the command reaches every constructor, spliced or not, and the compiler
|
|
311
|
+
// makes it exhaustive.
|
|
312
312
|
let structure = Plugin_Structure.make(
|
|
313
313
|
~name="TransitionSwitchPlugin",
|
|
314
314
|
~stateChangeSlices=[module(PsTransitionSwitchSlice)],
|
|
@@ -325,21 +325,15 @@ describe("commandTransition — the edge as a switch", () => {
|
|
|
325
325
|
})
|
|
326
326
|
|
|
327
327
|
testSync("a guard declares its from-set and no target", () => {
|
|
328
|
-
//
|
|
329
|
-
//
|
|
328
|
+
// A positive claim rather than an omission: legal in these states, and it
|
|
329
|
+
// moves the row nowhere. Distinguishable on the wire from both fields being
|
|
330
|
+
// None, and consumers are expected to honour it.
|
|
330
331
|
expect((
|
|
331
332
|
byName("Rebook")->Option.flatMap(c => c.allowedStates),
|
|
332
333
|
byName("Rebook")->Option.flatMap(c => c.targetState),
|
|
333
334
|
))->toEqual((Some(["Booked"]), None))
|
|
334
335
|
})
|
|
335
336
|
|
|
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
337
|
testSync("Unrestricted declares nothing, leaving the command unconstrained", () => {
|
|
344
338
|
// Distinct from a from-set naming every state, which says the same thing to
|
|
345
339
|
// a menu and something different to a reader.
|
|
@@ -384,12 +378,14 @@ describe("traitDeclarations — a graft leaves a trace", () => {
|
|
|
384
378
|
})
|
|
385
379
|
})
|
|
386
380
|
|
|
387
|
-
describe("
|
|
381
|
+
describe("declared-edge cross-check", () => {
|
|
388
382
|
// The exit criterion: a state no linked view declares stops the build, and the
|
|
389
383
|
// message says which command, which state, and what it was checked against.
|
|
390
384
|
// `DispatchShipment` produces `ShipmentDispatched`, which the `Shipments` view
|
|
391
385
|
// consumes — so the two are linked, the view's lifecycle is in hand, and
|
|
392
|
-
// "Dispatchd" has somewhere to be wrong.
|
|
386
|
+
// "Dispatchd" has somewhere to be wrong. It compiles because the switch names
|
|
387
|
+
// the slice's OWN enum: the typechecker cannot see that the wrong lifecycle
|
|
388
|
+
// was picked, and this is the check that can.
|
|
393
389
|
let buildBad = () =>
|
|
394
390
|
try {
|
|
395
391
|
Plugin_Structure.make(
|
|
@@ -504,11 +500,10 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
504
500
|
))->toEqual((Some(true), Some(false)))
|
|
505
501
|
})
|
|
506
502
|
|
|
507
|
-
testSync("ShipOrder:
|
|
508
|
-
// End-to-end: the
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
// name-stem).
|
|
503
|
+
testSync("ShipOrder: the switch's target flows through to commandDef.targetState", () => {
|
|
504
|
+
// End-to-end: the spec's `commandTransition`, evaluated per constructor
|
|
505
|
+
// against a synthetic command → commandDef. `CancelShipment` answers
|
|
506
|
+
// `Unrestricted` and carries None (resolver falls back to name-stem).
|
|
512
507
|
let shipOrder = structure.stateChangeSlices->Array.getUnsafe(1)
|
|
513
508
|
let byName = name => shipOrder.commands->Array.find(c => c.name == name)
|
|
514
509
|
expect((
|
|
@@ -517,10 +512,10 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
517
512
|
))->toEqual((Some("Shipped"), None))
|
|
518
513
|
})
|
|
519
514
|
|
|
520
|
-
testSync("ShipOrder:
|
|
521
|
-
// The other half of the same
|
|
522
|
-
//
|
|
523
|
-
//
|
|
515
|
+
testSync("ShipOrder: the switch's from-set flows through to commandDef.allowedStates", () => {
|
|
516
|
+
// The other half of the same declaration. An edge is one value, so a
|
|
517
|
+
// command that declares a target cannot end up without the states it may
|
|
518
|
+
// run from — the two fields are read off it together.
|
|
524
519
|
let shipOrder = structure.stateChangeSlices->Array.getUnsafe(1)
|
|
525
520
|
let byName = name => shipOrder.commands->Array.find(c => c.name == name)
|
|
526
521
|
expect((
|
|
@@ -602,9 +602,6 @@ globalThis.describe("commandTransition — the edge as a switch", () => {
|
|
|
602
602
|
undefined
|
|
603
603
|
]);
|
|
604
604
|
});
|
|
605
|
-
globalThis.test("the switch wins over an @transition on the same command", () => {
|
|
606
|
-
globalThis.expect(Stdlib_Option.flatMap(byName("Rebook"), c => c.allowedStates)).toEqual(["Booked"]);
|
|
607
|
-
});
|
|
608
605
|
globalThis.test("Unrestricted declares nothing, leaving the command unconstrained", () => {
|
|
609
606
|
globalThis.expect([
|
|
610
607
|
Stdlib_Option.flatMap(byName("Abandon"), c => c.allowedStates),
|
|
@@ -646,7 +643,7 @@ globalThis.describe("traitDeclarations — a graft leaves a trace", () => {
|
|
|
646
643
|
});
|
|
647
644
|
});
|
|
648
645
|
|
|
649
|
-
globalThis.describe("
|
|
646
|
+
globalThis.describe("declared-edge cross-check", () => {
|
|
650
647
|
let buildBad = () => {
|
|
651
648
|
try {
|
|
652
649
|
Plugin_Structure$ReventlessCore.make("TransitionPlugin", undefined, undefined, [PsShipmentsViewSlice], [PsDispatchShipmentSlice], undefined, undefined, undefined, undefined, undefined, undefined);
|
|
@@ -759,7 +756,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
759
756
|
false
|
|
760
757
|
]);
|
|
761
758
|
});
|
|
762
|
-
globalThis.test("ShipOrder:
|
|
759
|
+
globalThis.test("ShipOrder: the switch's target flows through to commandDef.targetState", () => {
|
|
763
760
|
let shipOrder = structure.stateChangeSlices[1];
|
|
764
761
|
let byName = name => shipOrder.commands.find(c => c.name === name);
|
|
765
762
|
globalThis.expect([
|
|
@@ -770,7 +767,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
770
767
|
undefined
|
|
771
768
|
]);
|
|
772
769
|
});
|
|
773
|
-
globalThis.test("ShipOrder:
|
|
770
|
+
globalThis.test("ShipOrder: the switch's from-set flows through to commandDef.allowedStates", () => {
|
|
774
771
|
let shipOrder = structure.stateChangeSlices[1];
|
|
775
772
|
let byName = name => shipOrder.commands.find(c => c.name === name);
|
|
776
773
|
globalThis.expect([
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
// Test fixture spec for the
|
|
1
|
+
// Test fixture spec for the declared-edge cross-check — the failing case.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// The switch names constructors of THIS file's own enum, and `Dispatchd` is a
|
|
4
|
+
// real case of it, so the compiler is satisfied. The linked `Shipments` view
|
|
5
|
+
// declares `Booked | Dispatched`, and nothing forces a spec to pick its linked
|
|
6
|
+
// view's enum — which is the hole the structure-side check closes, and the half
|
|
7
|
+
// the typechecker cannot reach.
|
|
7
8
|
|
|
8
9
|
@@reventless.spec("DispatchShipment")
|
|
9
10
|
|
|
10
11
|
type shipmentStatus =
|
|
11
12
|
| Booked
|
|
12
|
-
|
|
|
13
|
+
| Dispatchd
|
|
13
14
|
|
|
14
15
|
@schema
|
|
15
16
|
type consumedEvent = ShipmentBooked({shipmentId: string})
|
|
@@ -20,7 +21,7 @@ type state = bool
|
|
|
20
21
|
let initialState = false
|
|
21
22
|
|
|
22
23
|
@schema
|
|
23
|
-
type command =
|
|
24
|
+
type command = DispatchShipment({shipmentId: string})
|
|
24
25
|
|
|
25
26
|
@schema
|
|
26
27
|
type error = ShipmentNotFound
|
|
@@ -29,3 +30,12 @@ type error = ShipmentNotFound
|
|
|
29
30
|
type event = ShipmentDispatched({shipmentId: string})
|
|
30
31
|
|
|
31
32
|
let decide = (_state, _command): result<array<event>, error> => Ok([])
|
|
33
|
+
|
|
34
|
+
type lifecycleState = shipmentStatus
|
|
35
|
+
|
|
36
|
+
let commandTransition = (command: command): Reventless.Transition.t<lifecycleState> => {
|
|
37
|
+
open Reventless.Transition
|
|
38
|
+
switch command {
|
|
39
|
+
| DispatchShipment(_) => Moves([Booked], Dispatchd)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import * as Sury from "sury";
|
|
4
4
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
5
|
-
import * as Api$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/Api.res.mjs";
|
|
6
5
|
|
|
7
6
|
let consumedEventSchema = Sury.$schema(s => ({
|
|
8
7
|
TAG: "ShipmentBooked",
|
|
@@ -32,24 +31,18 @@ function decide(_state, _command) {
|
|
|
32
31
|
};
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"Dispatchd"
|
|
43
|
-
]]);
|
|
34
|
+
function commandTransition(command) {
|
|
35
|
+
return {
|
|
36
|
+
TAG: "Moves",
|
|
37
|
+
_0: ["Booked"],
|
|
38
|
+
_1: "Dispatchd"
|
|
39
|
+
};
|
|
40
|
+
}
|
|
44
41
|
|
|
45
42
|
function commandAuthorization(param) {
|
|
46
43
|
return "AllowAuthenticated";
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
function commandTransition(param) {
|
|
50
|
-
return "Unrestricted";
|
|
51
|
-
}
|
|
52
|
-
|
|
53
46
|
let traits = [];
|
|
54
47
|
|
|
55
48
|
let name = "DispatchShipment";
|
|
@@ -68,14 +61,14 @@ export {
|
|
|
68
61
|
consumedEventSchema,
|
|
69
62
|
evolve,
|
|
70
63
|
initialState,
|
|
64
|
+
commandSchema,
|
|
71
65
|
errorSchema,
|
|
72
66
|
eventSchema,
|
|
73
67
|
decide,
|
|
74
|
-
|
|
68
|
+
commandTransition,
|
|
75
69
|
moduleUrl,
|
|
76
70
|
commandAuthorization,
|
|
77
71
|
readConsistency,
|
|
78
|
-
commandTransition,
|
|
79
72
|
traits,
|
|
80
73
|
}
|
|
81
74
|
/* consumedEventSchema Not a pure module */
|
|
@@ -13,27 +13,21 @@ type consumedEvent = OrderPlaced | OrderShipped | OrderCancelled
|
|
|
13
13
|
|
|
14
14
|
let evolve = (_state, _event) => true
|
|
15
15
|
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// command's slice mutation field onto the non-exposed variant.
|
|
21
|
-
// The lifecycle this slice's commands move a shipment through. The PPX strips
|
|
22
|
-
// @transition before the typechecker sees it, so this enum is not what makes the
|
|
23
|
-
// reference below valid — the structure-side check against the linked view is.
|
|
24
|
-
// It is here because a state is a constructor, and naming one in a string is a
|
|
25
|
-
// reference nothing can check at any layer.
|
|
16
|
+
// The lifecycle this slice's commands move a shipment through. Declared here
|
|
17
|
+
// rather than on a linked view because this fixture's plugin has none: the
|
|
18
|
+
// structure-side check warns instead of failing when a component's views declare
|
|
19
|
+
// no lifecycle, and that is the path this exercises.
|
|
26
20
|
type shipmentStatus = Placed | Shipped | Cancelled
|
|
27
21
|
|
|
28
22
|
// CancelShipment is payload-less — it compiles to a bare string literal, so it
|
|
29
23
|
// exercises toCommandDef's payload-less branch (still surfaced in `commands`).
|
|
30
24
|
// It is also `@noApi` (internal/admin only): a two-variant slice with exactly
|
|
31
25
|
// one API-exposed command, which is the case that used to leak the exposed
|
|
32
|
-
// command's slice mutation field onto the non-exposed variant. It
|
|
33
|
-
//
|
|
26
|
+
// command's slice mutation field onto the non-exposed variant. It declares no
|
|
27
|
+
// edge, which is the case the board resolver still guesses for.
|
|
34
28
|
@schema
|
|
35
29
|
type command =
|
|
36
|
-
|
|
|
30
|
+
| ShipOrder({orderId: string})
|
|
37
31
|
| @noApi CancelShipment
|
|
38
32
|
|
|
39
33
|
// Two variants, one payload-less and one carrying a field — the same two branches
|
|
@@ -47,3 +41,13 @@ type error = OrderNotFound | NotShippable({reason: string})
|
|
|
47
41
|
type event = OrderShipped({orderId: string}) | ShipmentVoided
|
|
48
42
|
|
|
49
43
|
let decide = (_state, _command): result<array<event>, error> => Ok([])
|
|
44
|
+
|
|
45
|
+
type lifecycleState = shipmentStatus
|
|
46
|
+
|
|
47
|
+
let commandTransition = (command: command): Reventless.Transition.t<lifecycleState> => {
|
|
48
|
+
open Reventless.Transition
|
|
49
|
+
switch command {
|
|
50
|
+
| ShipOrder(_) => Moves([Placed], Shipped)
|
|
51
|
+
| CancelShipment => Unrestricted
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -45,26 +45,24 @@ function decide(_state, _command) {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
function commandTransition(command) {
|
|
49
|
+
if (typeof command !== "object") {
|
|
50
|
+
return "Unrestricted";
|
|
51
|
+
} else {
|
|
52
|
+
return {
|
|
53
|
+
TAG: "Moves",
|
|
54
|
+
_0: ["Placed"],
|
|
55
|
+
_1: "Shipped"
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
54
59
|
|
|
55
|
-
let commandSchema$
|
|
56
|
-
"ShipOrder",
|
|
57
|
-
"Shipped"
|
|
58
|
-
]]);
|
|
60
|
+
let commandSchema$1 = Api$ReventlessInfra.markNoApiVariants(commandSchema, ["CancelShipment"]);
|
|
59
61
|
|
|
60
62
|
function commandAuthorization(param) {
|
|
61
63
|
return "AllowAuthenticated";
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
function commandTransition(param) {
|
|
65
|
-
return "Unrestricted";
|
|
66
|
-
}
|
|
67
|
-
|
|
68
66
|
let traits = [];
|
|
69
67
|
|
|
70
68
|
let name = "ShipOrder";
|
|
@@ -86,11 +84,11 @@ export {
|
|
|
86
84
|
errorSchema,
|
|
87
85
|
eventSchema,
|
|
88
86
|
decide,
|
|
89
|
-
|
|
87
|
+
commandTransition,
|
|
88
|
+
commandSchema$1 as commandSchema,
|
|
90
89
|
moduleUrl,
|
|
91
90
|
commandAuthorization,
|
|
92
91
|
readConsistency,
|
|
93
|
-
commandTransition,
|
|
94
92
|
traits,
|
|
95
93
|
}
|
|
96
94
|
/* consumedEventSchema Not a pure module */
|
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
// Test fixture spec for `commandTransition` — the
|
|
2
|
-
//
|
|
1
|
+
// Test fixture spec for `commandTransition` — the three shapes of a declared
|
|
2
|
+
// edge, in one command type:
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// `Rebook` carries BOTH. The switch says `Booked`, the attribute says
|
|
9
|
-
// `Draft`; the switch is what must reach the commandDef, because
|
|
10
|
-
// two sources of truth need a stated winner.
|
|
11
|
-
// `Abandon` declares nothing in either, and must stay unconstrained.
|
|
4
|
+
// `Book` moves the row: a from-set and a target.
|
|
5
|
+
// `Rebook` guards only: a from-set and no target, which is the positive
|
|
6
|
+
// claim that the command moves the row nowhere.
|
|
7
|
+
// `Abandon` declares nothing, and must stay unconstrained.
|
|
12
8
|
//
|
|
13
9
|
// The states go in as constructors of this file's own enum, so the compiler
|
|
14
|
-
// resolves them.
|
|
15
|
-
// typechecker runs, which is why `PsDispatchShipment` can carry a typo at all.
|
|
10
|
+
// resolves them.
|
|
16
11
|
|
|
17
12
|
@@reventless.spec("TransitionSwitch")
|
|
18
13
|
|
|
@@ -29,7 +24,7 @@ let initialState = false
|
|
|
29
24
|
@schema
|
|
30
25
|
type command =
|
|
31
26
|
| Book({bookingId: string})
|
|
32
|
-
|
|
|
27
|
+
| Rebook({bookingId: string})
|
|
33
28
|
| Abandon({bookingId: string})
|
|
34
29
|
|
|
35
30
|
@schema
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import * as Sury from "sury";
|
|
4
4
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
5
|
-
import * as Api$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/Api.res.mjs";
|
|
6
5
|
|
|
7
6
|
let consumedEventSchema = Sury.$schema(s => ({
|
|
8
7
|
TAG: "BookingDrafted",
|
|
@@ -72,16 +71,6 @@ let traits = [{
|
|
|
72
71
|
posture: "SelfContained"
|
|
73
72
|
}];
|
|
74
73
|
|
|
75
|
-
let commandSchema$1 = Api$ReventlessInfra.markAllowedStates(commandSchema, [[
|
|
76
|
-
"Rebook",
|
|
77
|
-
["Draft"]
|
|
78
|
-
]]);
|
|
79
|
-
|
|
80
|
-
let commandSchema$2 = Api$ReventlessInfra.markTargetState(commandSchema$1, [[
|
|
81
|
-
"Rebook",
|
|
82
|
-
"Draft"
|
|
83
|
-
]]);
|
|
84
|
-
|
|
85
74
|
function commandAuthorization(param) {
|
|
86
75
|
return "AllowAuthenticated";
|
|
87
76
|
}
|
|
@@ -102,12 +91,12 @@ export {
|
|
|
102
91
|
consumedEventSchema,
|
|
103
92
|
evolve,
|
|
104
93
|
initialState,
|
|
94
|
+
commandSchema,
|
|
105
95
|
errorSchema,
|
|
106
96
|
eventSchema,
|
|
107
97
|
decide,
|
|
108
98
|
commandTransition,
|
|
109
99
|
traits,
|
|
110
|
-
commandSchema$2 as commandSchema,
|
|
111
100
|
moduleUrl,
|
|
112
101
|
commandAuthorization,
|
|
113
102
|
readConsistency,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Test fixture spec for the
|
|
1
|
+
// Test fixture spec for the declared-edge cross-check.
|
|
2
2
|
// A view that declares a lifecycle, so a command's declared states have
|
|
3
3
|
// something to be checked against. `PsOrdersView` deliberately declares none —
|
|
4
4
|
// the two together cover both the checked and the unvalidated path.
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
// Read per-variant `allowedStates` metadata that the reventless-ppx
|
|
2
|
-
// an `@transition` from-set attaches to a command schema. Consumed by
|
|
3
|
-
// `Plugin_Structure.toCommandDef` when building the `commandDef` records that
|
|
4
|
-
// land in `Platform_ComponentDefinitions` so AutoUI can filter the per-row command
|
|
5
|
-
// menu by the row's status.
|
|
6
|
-
|
|
7
|
-
open ReventlessInfra.Api
|
|
8
|
-
|
|
9
|
-
/** Returns the allowed-state list for a given command-variant name, or None
|
|
10
|
-
when the variant declares no `@transition` (the command shows on every row). */
|
|
11
|
-
let getAllowedStates = (
|
|
12
|
-
commandSchema: S.t<unknown>,
|
|
13
|
-
~variantName: string,
|
|
14
|
-
): option<array<string>> =>
|
|
15
|
-
switch commandSchema->S.Metadata.get(~id=allowedStatesId) {
|
|
16
|
-
| None => None
|
|
17
|
-
| Some(entries) => entries->Dict.get(variantName)
|
|
18
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
-
|
|
3
|
-
import * as Sury from "sury";
|
|
4
|
-
import * as Api$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/Api.res.mjs";
|
|
5
|
-
|
|
6
|
-
function getAllowedStates(commandSchema, variantName) {
|
|
7
|
-
let entries = Sury.$Metadata_get(commandSchema, Api$ReventlessInfra.allowedStatesId);
|
|
8
|
-
if (entries !== undefined) {
|
|
9
|
-
return entries[variantName];
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export {
|
|
14
|
-
getAllowedStates,
|
|
15
|
-
}
|
|
16
|
-
/* sury Not a pure module */
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
// Read per-variant `targetState` metadata that the reventless-ppx
|
|
2
|
-
// an `@transition` target attaches to a command schema. Consumed by
|
|
3
|
-
// `Plugin_Structure.toCommandDef` when building the `commandDef` records that
|
|
4
|
-
// land in `Platform_ComponentDefinitions`, so AutoUI's board drag resolver can
|
|
5
|
-
// move a row by a *declared* transition instead of a name-stem guess. Mirrors
|
|
6
|
-
// `ApiAllowedStatesHelpers.getAllowedStates`.
|
|
7
|
-
|
|
8
|
-
open ReventlessInfra.Api
|
|
9
|
-
|
|
10
|
-
/** Returns the declared target state for a given command-variant name, or None
|
|
11
|
-
when the variant declares no target — either because it carries no
|
|
12
|
-
`@transition` at all, or because it declares a from-set only, which is a
|
|
13
|
-
command saying it does not move the row. */
|
|
14
|
-
let getTargetState = (
|
|
15
|
-
commandSchema: S.t<unknown>,
|
|
16
|
-
~variantName: string,
|
|
17
|
-
): option<string> =>
|
|
18
|
-
switch commandSchema->S.Metadata.get(~id=targetStateId) {
|
|
19
|
-
| None => None
|
|
20
|
-
| Some(entries) => entries->Dict.get(variantName)
|
|
21
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
-
|
|
3
|
-
import * as Sury from "sury";
|
|
4
|
-
import * as Api$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/Api.res.mjs";
|
|
5
|
-
|
|
6
|
-
function getTargetState(commandSchema, variantName) {
|
|
7
|
-
let entries = Sury.$Metadata_get(commandSchema, Api$ReventlessInfra.targetStateId);
|
|
8
|
-
if (entries !== undefined) {
|
|
9
|
-
return entries[variantName];
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export {
|
|
14
|
-
getTargetState,
|
|
15
|
-
}
|
|
16
|
-
/* sury Not a pure module */
|