@reventlessdev/reventless-core 3.0.0-alpha.253 → 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
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
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
|
+
|
|
6
23
|
# 3.0.0-alpha.253 (2026-09-01)
|
|
7
24
|
|
|
8
25
|
**Note:** Version bump only for package @reventlessdev/reventless-core
|
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,17 +28,17 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"sury": "11.0.0-rc.2",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
31
32
|
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
|
|
33
|
+
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
32
34
|
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
35
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
33
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
34
|
-
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
35
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
36
|
-
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
|
|
37
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.154",
|
|
38
37
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
39
|
-
"@reventlessdev/
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/
|
|
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",
|
|
@@ -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));
|