@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.253",
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/reventless-interop": "3.0.0-alpha.34",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.126",
41
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10"
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 match = DcbTag$Reventless.deriveEffectiveScope(stateChangeSlices.map(Sc => ({
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 effectiveTagKeysByEventType = match.tagKeysByEventType;
241
- let effectiveCrossPartitionTagKeys = match.crossPartitionTagKeys;
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));