@reventlessdev/online-shop-hybrid-ordering 1.0.0-alpha.229 → 1.0.0-alpha.231

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +10 -10
  3. package/src/Notification/AutomationSlice/NotificationIntake.res +17 -7
  4. package/src/Notification/AutomationSlice/NotificationIntake.res.mjs +6 -1
  5. package/src/Notification/AutomationSlice/NotificationIntake_Automation.res +113 -30
  6. package/src/Notification/AutomationSlice/NotificationIntake_Automation.res.mjs +106 -24
  7. package/src/Notification/StateChangeSlice/NotificationPreferences.res +74 -5
  8. package/src/Notification/StateChangeSlice/NotificationPreferences.res.mjs +42 -4
  9. package/src/Notification/StateChangeSlice/NotificationPreferences_Behavior.res +111 -39
  10. package/src/Notification/StateChangeSlice/NotificationPreferences_Behavior.res.mjs +80 -18
  11. package/src/Notification/StateChangeSlice/NotificationSourceClaims.res +48 -0
  12. package/src/Notification/StateChangeSlice/NotificationSourceClaims.res.mjs +82 -0
  13. package/src/Notification/StateChangeSlice/NotificationSourceClaims_Behavior.res +43 -0
  14. package/src/Notification/StateChangeSlice/NotificationSourceClaims_Behavior.res.mjs +92 -0
  15. package/src/Notification/StateViewSliceStream/NotificationDeliveries.res +40 -13
  16. package/src/Notification/StateViewSliceStream/NotificationDeliveries.res.mjs +16 -6
  17. package/src/Notification/StateViewSliceStream/NotificationDeliveries_Projection.res +20 -6
  18. package/src/Notification/StateViewSliceStream/NotificationDeliveries_Projection.res.mjs +24 -5
  19. package/src/Order/StateChangeSlice/ShipOrder.res +8 -2
  20. package/src/Order/StateChangeSlice/ShipOrder.res.mjs +4 -2
  21. package/src/Order/StateChangeSlice/ShipOrder_Behavior.res +17 -10
  22. package/src/Order/StateChangeSlice/ShipOrder_Behavior.res.mjs +25 -8
  23. package/src/Plugin.res +5 -3
  24. package/src/Plugin.res.mjs +33 -0
  25. package/tests/Flow/OrderingFlow_GWT.res +28 -7
  26. package/tests/Flow/OrderingFlow_GWT.res.mjs +29 -10
  27. package/tests/Notification/AutomationSlice/NotificationIntake_GWT.res +153 -0
  28. package/tests/Notification/AutomationSlice/NotificationIntake_GWT.res.mjs +248 -0
  29. package/tests/Notification/NotificationConformance_GWT.res +61 -5
  30. package/tests/Notification/NotificationConformance_GWT.res.mjs +86 -5
  31. package/tests/Notification/StateChangeSlice/NotificationPreferences_GWT.res +42 -5
  32. package/tests/Notification/StateChangeSlice/NotificationPreferences_GWT.res.mjs +30 -3
  33. package/tests/Notification/StateChangeSlice/NotificationSourceClaims_GWT.res +47 -0
  34. package/tests/Notification/StateChangeSlice/NotificationSourceClaims_GWT.res.mjs +106 -0
  35. package/tests/Notification/StateViewSliceStream/NotificationDeliveries_GWT.res +99 -0
  36. package/tests/Notification/StateViewSliceStream/NotificationDeliveries_GWT.res.mjs +148 -0
  37. package/tests/Order/StateChangeSlice/ShipOrder_GWT.res +10 -6
  38. package/tests/Order/StateChangeSlice/ShipOrder_GWT.res.mjs +12 -6
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
+ # 1.0.0-alpha.231 (2026-09-04)
7
+
8
+ ### Features
9
+
10
+ * **notifications:** a delivery row names what it was about, not where it was sent ([65c01c6](https://github.com/ReventlessDev/reventless-core/commit/65c01c6b1b982880364c952266eab4294a824108))
11
+ * **notifications:** a rule says how it goes out, and the relay sends only its half ([cbcb992](https://github.com/ReventlessDev/reventless-core/commit/cbcb99207597830aa955154a0833aac2205a8e2f))
12
+ * **notifications:** a second producer can take over one stream of occurrences ([3050637](https://github.com/ReventlessDev/reventless-core/commit/3050637c8af69083c528030f2c59b04065ab8c2f))
13
+ * **notifications:** the wording is a table of values, not a switch ([bfcc939](https://github.com/ReventlessDev/reventless-core/commit/bfcc93940b81060d201fd231447fb14dcf48d80e))
14
+
15
+
16
+ # 1.0.0-alpha.230 (2026-09-02)
17
+
18
+ ### Features
19
+
20
+ * **notifications:** a shipped order notifies the customer who placed it ([4280274](https://github.com/ReventlessDev/reventless-core/commit/428027471fc98cc60c1ab12dbcd5c70b9f33e545))
21
+
22
+
6
23
  # 1.0.0-alpha.229 (2026-09-02)
7
24
 
8
25
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/online-shop-hybrid-ordering",
3
- "version": "1.0.0-alpha.229",
3
+ "version": "1.0.0-alpha.231",
4
4
  "description": "Online Shop Hybrid example — Ordering plugin (Customer aggregate + Order/CatalogProduct DCB)",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -21,20 +21,20 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "sury": "11.0.0-rc.2",
24
- "@reventlessdev/online-shop-hybrid-catalog-spec": "1.0.0-alpha.123",
25
- "@reventlessdev/online-shop-hybrid-ordering-spec": "1.0.0-alpha.123",
26
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.201",
27
- "@reventlessdev/reventless-infra": "3.0.0-alpha.155",
24
+ "@reventlessdev/online-shop-hybrid-catalog-spec": "1.0.0-alpha.124",
25
+ "@reventlessdev/online-shop-hybrid-ordering-spec": "1.0.0-alpha.124",
28
26
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
29
- "@reventlessdev/reventless-spec": "3.0.0-alpha.127",
30
- "@reventlessdev/trait-notification": "1.0.0-alpha.3",
31
- "@reventlessdev/reventless-local": "3.0.0-alpha.243",
32
- "@reventlessdev/trait-address-geocoding": "1.0.0-alpha.3"
27
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.202",
28
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.156",
29
+ "@reventlessdev/trait-address-geocoding": "1.0.0-alpha.4",
30
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.128",
31
+ "@reventlessdev/trait-notification": "1.0.0-alpha.4",
32
+ "@reventlessdev/reventless-local": "3.0.0-alpha.244"
33
33
  },
34
34
  "devDependencies": {
35
35
  "rescript": "12.3.0",
36
36
  "sury-ppx": "11.0.0-rc.2",
37
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.76"
37
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.77"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "rescript": "12.3.0"
@@ -1,19 +1,25 @@
1
1
  // NotificationIntake AutomationSlice.
2
2
  //
3
- // Half of the graft point: it turns one of this host's occurrences into a
4
- // request to notify somebody about it. The other half is
5
- // `AnnounceRecipientContact`, which relays who that somebody is reachable at.
3
+ // Half of the graft point: it turns this host's occurrences into requests to
4
+ // notify somebody about them. The other half is `AnnounceRecipientContact`,
5
+ // which relays who that somebody is reachable at.
6
6
  //
7
7
  // Everything downstream is host-free — `NotificationPreferences` decides and
8
8
  // `SendNotification` delivers, neither knowing what an order is. This file is
9
- // where "a placed order is an OrderConfirmation, and here is what it says" is
10
- // written down, which is why it is a file a graft has to be given rather than
11
- // handed whole.
9
+ // where "a placed order is an OrderConfirmation, a shipped one is a
10
+ // ShippingUpdate, and here is what each says" is written down, which is why it
11
+ // is a file a graft has to be given rather than handed whole.
12
12
 
13
13
  @@reventless.spec
14
14
 
15
+ // The row a rule composes from. It carries the rule's id rather than a kind of
16
+ // occurrence: the rule holds the category, the subject and the wording, so a
17
+ // second notifiable event is a second entry in the table next door.
18
+ //
19
+ // It is also the payload the wording is rendered against, so every field here is
20
+ // a path a template may name — `{{ orderId }}`.
15
21
  @schema
16
- type todoItem = {recipientId: string, orderId: string}
22
+ type todoItem = {ruleId: string, recipientId: string, orderId: string}
17
23
 
18
24
  @schema
19
25
  type command =
@@ -21,8 +27,12 @@ type command =
21
27
  recipientId: string,
22
28
  category: NotificationPreferences.category,
23
29
  reference: string,
30
+ subjectType: string,
31
+ subjectRef: string,
24
32
  subject: string,
25
33
  body: string,
34
+ sourceId: string,
35
+ origin: NotificationPreferences.origin,
26
36
  })
27
37
 
28
38
  let maxRetries = 3
@@ -5,6 +5,7 @@ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/component
5
5
  import * as NotificationPreferences$OrderingPlugin from "../StateChangeSlice/NotificationPreferences.res.mjs";
6
6
 
7
7
  let todoItemSchema = Sury.$schema(s => ({
8
+ ruleId: s.m(Sury.string),
8
9
  recipientId: s.m(Sury.string),
9
10
  orderId: s.m(Sury.string)
10
11
  }));
@@ -14,8 +15,12 @@ let commandSchema = Sury.$schema(s => ({
14
15
  recipientId: s.m(DcbTag$Reventless.string),
15
16
  category: s.m(NotificationPreferences$OrderingPlugin.categorySchema),
16
17
  reference: s.m(Sury.string),
18
+ subjectType: s.m(Sury.string),
19
+ subjectRef: s.m(Sury.string),
17
20
  subject: s.m(Sury.string),
18
- body: s.m(Sury.string)
21
+ body: s.m(Sury.string),
22
+ sourceId: s.m(DcbTag$Reventless.string),
23
+ origin: s.m(NotificationPreferences$OrderingPlugin.originSchema)
19
24
  }));
20
25
 
21
26
  function commandAuthorization(param) {
@@ -1,27 +1,91 @@
1
1
  @@reventless.automation
2
2
 
3
- // A TODO id is also the `reference` the request carries, so the outcome event
4
- // echoes back exactly what resolves the row.
5
- let confirmationKey = orderId => `confirm:${orderId}`
3
+ module Rule = TraitNotification.Notification_Rule
6
4
 
7
5
  // One source: this plugin's DCB log, which is where the occurrence is and where
8
- // every outcome lands. `OrderPlaced` names its own customer, which is what lets
9
- // an automation read it at all — see the sibling contact relay for the events
10
- // that do not.
6
+ // every outcome lands. Both order events name their own customer, which is what
7
+ // lets an automation read them at all — see the sibling contact relay for the
8
+ // events that do not.
11
9
  module OrderingDcbSource = {
12
10
  let name = "OrderingDcbEventLog"
13
11
 
14
12
  @schema
15
13
  type event =
16
14
  | OrderPlaced({orderId: string, customerId: string})
17
- // Every way a request can end. All three resolve the row, because all three
15
+ | OrderShipped({orderId: string, customerId: string})
16
+ // Every way a request can end. All four resolve the row, because all four
18
17
  // mean the decision was made — a suppressed notification is a finished piece
19
- // of work, not a failed one.
18
+ // of work, not a failed one, and a deferred one is somebody else's work.
19
+ //
20
+ // Missing the fourth is not a missing nicety: an unresolved row retries its
21
+ // whole budget and lands in `onExhausted`, so a handover would look like a
22
+ // slow failure with nothing in the log to explain it.
20
23
  | NotificationRequested({reference: string})
21
24
  | NotificationSuppressed({reference: string})
22
25
  | NotificationUndeliverable({reference: string})
26
+ | NotificationDeferred({reference: string})
23
27
  }
24
28
 
29
+ // The one place this host's occurrences are given a kind and a wording, and it
30
+ // is a table of values rather than a switch. A trait cannot write this: the
31
+ // categories are ones the trait declares, but which of the host's events earns
32
+ // which — and what the sentence says — is the host's.
33
+ //
34
+ // A rule's id is also the namespace of the references it writes, so an order
35
+ // that is placed and then ships is two notifications and two delivery rows.
36
+ let defaultRules: array<Rule.t> = [
37
+ {
38
+ id: "confirm",
39
+ version: "1",
40
+ source: {log: OrderingDcbSource.name, eventType: "OrderPlaced"},
41
+ filter: Always,
42
+ category: "OrderConfirmation",
43
+ delivery: Immediate,
44
+ recipientPath: "recipientId",
45
+ subjectType: "Order",
46
+ subjectPath: "orderId",
47
+ content: [
48
+ {
49
+ locale: "en",
50
+ subject: "Your order {{ orderId }} is confirmed",
51
+ body: "Thanks — we have your order {{ orderId }} and will let you know when it ships.",
52
+ },
53
+ ],
54
+ },
55
+ {
56
+ id: "ship",
57
+ version: "1",
58
+ source: {log: OrderingDcbSource.name, eventType: "OrderShipped"},
59
+ filter: Always,
60
+ category: "ShippingUpdate",
61
+ delivery: Immediate,
62
+ recipientPath: "recipientId",
63
+ subjectType: "Order",
64
+ subjectPath: "orderId",
65
+ content: [
66
+ {
67
+ locale: "en",
68
+ subject: "Your order {{ orderId }} is on its way",
69
+ body: "Good news — order {{ orderId }} has shipped.",
70
+ },
71
+ ],
72
+ },
73
+ ]
74
+
75
+ // The dispatch is the table's, so the switch below only takes an event apart.
76
+ // Two rules on one event type are two notifications, with no arm to add.
77
+ //
78
+ // Only the immediate half of the table: a digest rule's occurrences are gathered
79
+ // by the component that sends the digest, so no row is opened for one here.
80
+ let todosFor = (~eventType, ~recipientId, ~orderId) =>
81
+ defaultRules
82
+ ->Rule.forEvent(~log=OrderingDcbSource.name, ~eventType)
83
+ ->Array.filter(Rule.isImmediate)
84
+ ->Array.map(rule => (
85
+ Rule.reference(rule, ~subject=orderId),
86
+ ({ruleId: rule.id, recipientId, orderId}: NotificationIntake.todoItem),
87
+ ))
88
+
25
89
  module FromOrderingDcb = Mapping.Make(
26
90
  OrderingDcbSource,
27
91
  NotificationIntake,
@@ -30,44 +94,63 @@ module FromOrderingDcb = Mapping.Make(
30
94
 
31
95
  let collect = (event, ~sourceId as _, _ctx) =>
32
96
  switch event {
33
- | OrderPlaced({orderId, customerId}) => [
34
- (
35
- confirmationKey(orderId),
36
- ({recipientId: customerId, orderId}: NotificationIntake.todoItem),
37
- ),
38
- ]
97
+ | OrderPlaced({orderId, customerId}) =>
98
+ todosFor(~eventType="OrderPlaced", ~recipientId=customerId, ~orderId)
99
+ | OrderShipped({orderId, customerId}) =>
100
+ todosFor(~eventType="OrderShipped", ~recipientId=customerId, ~orderId)
39
101
  | NotificationRequested(_)
40
102
  | NotificationSuppressed(_)
41
- | NotificationUndeliverable(_) => []
103
+ | NotificationUndeliverable(_)
104
+ | NotificationDeferred(_) => []
42
105
  }
43
106
 
44
107
  let resolve = event =>
45
108
  switch event {
46
109
  | NotificationRequested({reference})
47
110
  | NotificationSuppressed({reference})
48
- | NotificationUndeliverable({reference}) =>
111
+ | NotificationUndeliverable({reference})
112
+ | NotificationDeferred({reference}) =>
49
113
  Some(reference)
50
- | OrderPlaced(_) => None
114
+ | OrderPlaced(_)
115
+ | OrderShipped(_) => None
51
116
  }
52
117
  },
53
118
  )
54
119
 
55
120
  let mappings: array<module(Mapping)> = [module(FromOrderingDcb)]
56
121
 
57
- // The one place this host's occurrences are given a kind and a wording. A trait
58
- // cannot write this: `OrderConfirmation` is a category the trait declares, but
59
- // which of the host's events earns it, and what the sentence says, is the host's.
60
122
  let process = (id, item: NotificationIntake.todoItem) =>
61
- Some((
62
- item.recipientId,
63
- NotificationIntake.RequestNotification({
64
- recipientId: item.recipientId,
65
- category: OrderConfirmation,
66
- reference: id,
67
- subject: `Your order ${item.orderId} is confirmed`,
68
- body: `Thanks — we have your order ${item.orderId} and will let you know when it ships.`,
69
- }),
70
- ))
123
+ // Two ways the table can have moved under a row that was already open: the rule
124
+ // is gone, or it has become a digest's and is no longer this relay's to send.
125
+ // Neither publishes anything, and either leaves the row Pending — a `None` from
126
+ // `process` spends no retry budget, so it is re-swept on every batch and never
127
+ // reaches `onExhausted`. Inert, but not self-clearing.
128
+ switch defaultRules->Rule.byId(item.ruleId)->Option.filter(Rule.isImmediate) {
129
+ | None => None
130
+ | Some(rule) =>
131
+ let payload = item->Reventless.Util_Sury.toJson(NotificationIntake.todoItemSchema)
132
+ switch (Rule.matches(rule.filter, ~payload), Rule.recipientOf(rule, ~payload)) {
133
+ | (false, _) | (_, None) => None
134
+ | (true, Some(recipientId)) =>
135
+ let (subject, body) = Rule.compose(rule, ~payload, ~schema=NotificationIntake.todoItemSchema)
136
+ Some((
137
+ recipientId,
138
+ NotificationIntake.RequestNotification({
139
+ recipientId,
140
+ category: NotificationPreferences_Behavior.categoryOf(rule.category),
141
+ reference: id,
142
+ subjectType: rule.subjectType,
143
+ subjectRef: Rule.subjectOf(rule, ~payload),
144
+ subject,
145
+ body,
146
+ sourceId: Rule.sourceId(rule),
147
+ // This relay IS the compiled table, so every request it makes is the
148
+ // default one — the arm that yields when somebody else claims the source.
149
+ origin: Default,
150
+ }),
151
+ ))
152
+ }
153
+ }
71
154
 
72
155
  // Nothing to say. A relay that gave up published no command, so the notification
73
156
  // competency never heard of the occurrence — and a delivery-failed fact for a
@@ -2,9 +2,13 @@
2
2
 
3
3
  import * as Sury from "sury";
4
4
  import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
6
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
7
+ import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
6
8
  import * as AutomationSlice$Reventless from "@reventlessdev/reventless-spec/src/components/AutomationSlice.res.mjs";
7
9
  import * as NotificationIntake$OrderingPlugin from "./NotificationIntake.res.mjs";
10
+ import * as Notification_Rule$TraitNotification from "@reventlessdev/trait-notification/src/Notification_Rule.res.mjs";
11
+ import * as NotificationPreferences_Behavior$OrderingPlugin from "../StateChangeSlice/NotificationPreferences_Behavior.res.mjs";
8
12
 
9
13
  let M = AutomationSlice$Reventless.Mappings.Make({
10
14
  name: NotificationIntake$OrderingPlugin.name,
@@ -16,10 +20,6 @@ let M = AutomationSlice$Reventless.Mappings.Make({
16
20
  targetName: NotificationIntake$OrderingPlugin.targetName
17
21
  });
18
22
 
19
- function confirmationKey(orderId) {
20
- return `confirm:` + orderId;
21
- }
22
-
23
23
  let name = "OrderingDcbEventLog";
24
24
 
25
25
  let eventSchema = Sury.union([
@@ -28,6 +28,11 @@ let eventSchema = Sury.union([
28
28
  orderId: s.m(DcbTag$Reventless.string),
29
29
  customerId: s.m(DcbTag$Reventless.string)
30
30
  })),
31
+ Sury.$schema(s => ({
32
+ TAG: "OrderShipped",
33
+ orderId: s.m(DcbTag$Reventless.string),
34
+ customerId: s.m(DcbTag$Reventless.string)
35
+ })),
31
36
  Sury.$schema(s => ({
32
37
  TAG: "NotificationRequested",
33
38
  reference: s.m(Sury.string)
@@ -39,6 +44,10 @@ let eventSchema = Sury.union([
39
44
  Sury.$schema(s => ({
40
45
  TAG: "NotificationUndeliverable",
41
46
  reference: s.m(Sury.string)
47
+ })),
48
+ Sury.$schema(s => ({
49
+ TAG: "NotificationDeferred",
50
+ reference: s.m(Sury.string)
42
51
  }))
43
52
  ]);
44
53
 
@@ -48,25 +57,76 @@ let OrderingDcbSource = {
48
57
  eventSchema: eventSchema
49
58
  };
50
59
 
60
+ let defaultRules = [
61
+ {
62
+ id: "confirm",
63
+ version: "1",
64
+ source: {
65
+ log: name,
66
+ eventType: "OrderPlaced"
67
+ },
68
+ filter: "Always",
69
+ category: "OrderConfirmation",
70
+ delivery: "Immediate",
71
+ recipientPath: "recipientId",
72
+ subjectType: "Order",
73
+ subjectPath: "orderId",
74
+ content: [{
75
+ locale: "en",
76
+ subject: "Your order {{ orderId }} is confirmed",
77
+ body: "Thanks — we have your order {{ orderId }} and will let you know when it ships."
78
+ }]
79
+ },
80
+ {
81
+ id: "ship",
82
+ version: "1",
83
+ source: {
84
+ log: name,
85
+ eventType: "OrderShipped"
86
+ },
87
+ filter: "Always",
88
+ category: "ShippingUpdate",
89
+ delivery: "Immediate",
90
+ recipientPath: "recipientId",
91
+ subjectType: "Order",
92
+ subjectPath: "orderId",
93
+ content: [{
94
+ locale: "en",
95
+ subject: "Your order {{ orderId }} is on its way",
96
+ body: "Good news — order {{ orderId }} has shipped."
97
+ }]
98
+ }
99
+ ];
100
+
101
+ function todosFor(eventType, recipientId, orderId) {
102
+ return Notification_Rule$TraitNotification.forEvent(defaultRules, name, eventType).filter(Notification_Rule$TraitNotification.isImmediate).map(rule => [
103
+ Notification_Rule$TraitNotification.reference(rule, orderId),
104
+ {
105
+ ruleId: rule.id,
106
+ recipientId: recipientId,
107
+ orderId: orderId
108
+ }
109
+ ]);
110
+ }
111
+
51
112
  function collect(event, param, _ctx) {
52
- if (event.TAG !== "OrderPlaced") {
53
- return [];
113
+ switch (event.TAG) {
114
+ case "OrderPlaced" :
115
+ return todosFor("OrderPlaced", event.customerId, event.orderId);
116
+ case "OrderShipped" :
117
+ return todosFor("OrderShipped", event.customerId, event.orderId);
118
+ default:
119
+ return [];
54
120
  }
55
- let orderId = event.orderId;
56
- return [[
57
- confirmationKey(orderId),
58
- {
59
- recipientId: event.customerId,
60
- orderId: orderId
61
- }
62
- ]];
63
121
  }
64
122
 
65
123
  function resolve(event) {
66
- if (event.TAG === "OrderPlaced") {
67
- return;
68
- } else {
69
- return event.reference;
124
+ switch (event.TAG) {
125
+ case "OrderPlaced" :
126
+ case "OrderShipped" :
127
+ return;
128
+ default:
129
+ return event.reference;
70
130
  }
71
131
  }
72
132
 
@@ -90,15 +150,33 @@ let FromOrderingDcb = AutomationSlice$Reventless.Mapping.Make({
90
150
  let mappings = [FromOrderingDcb];
91
151
 
92
152
  function process(id, item) {
153
+ let rule = Stdlib_Option.filter(Notification_Rule$TraitNotification.byId(defaultRules, item.ruleId), Notification_Rule$TraitNotification.isImmediate);
154
+ if (rule === undefined) {
155
+ return;
156
+ }
157
+ let payload = Util_Sury$Reventless.toJson(item, NotificationIntake$OrderingPlugin.todoItemSchema);
158
+ let match = Notification_Rule$TraitNotification.matches(rule.filter, payload);
159
+ let match$1 = Notification_Rule$TraitNotification.recipientOf(rule, payload);
160
+ if (!match) {
161
+ return;
162
+ }
163
+ if (match$1 === undefined) {
164
+ return;
165
+ }
166
+ let match$2 = Notification_Rule$TraitNotification.compose(rule, payload, NotificationIntake$OrderingPlugin.todoItemSchema, undefined);
93
167
  return [
94
- item.recipientId,
168
+ match$1,
95
169
  {
96
170
  TAG: "RequestNotification",
97
- recipientId: item.recipientId,
98
- category: "OrderConfirmation",
171
+ recipientId: match$1,
172
+ category: NotificationPreferences_Behavior$OrderingPlugin.categoryOf(rule.category),
99
173
  reference: id,
100
- subject: `Your order ` + item.orderId + ` is confirmed`,
101
- body: `Thanks — we have your order ` + item.orderId + ` and will let you know when it ships.`
174
+ subjectType: rule.subjectType,
175
+ subjectRef: Notification_Rule$TraitNotification.subjectOf(rule, payload),
176
+ subject: match$2[0],
177
+ body: match$2[1],
178
+ sourceId: Notification_Rule$TraitNotification.sourceId(rule),
179
+ origin: "Default"
102
180
  }
103
181
  ];
104
182
  }
@@ -109,13 +187,17 @@ function onExhausted(_id, _item) {
109
187
 
110
188
  let Spec;
111
189
 
190
+ let Rule;
191
+
112
192
  let moduleUrl = "@reventlessdev/online-shop-hybrid-ordering/src/Notification/AutomationSlice/NotificationIntake_Automation.res.mjs";
113
193
 
114
194
  export {
115
195
  Spec,
116
196
  M,
117
- confirmationKey,
197
+ Rule,
118
198
  OrderingDcbSource,
199
+ defaultRules,
200
+ todosFor,
119
201
  FromOrderingDcb,
120
202
  mappings,
121
203
  process,
@@ -37,14 +37,24 @@ type channel =
37
37
  | Sms
38
38
  | Push
39
39
 
40
- // Its own past facts, and nothing else. What lets one component hold the
41
- // directory and make the dispatch decision without either half reaching for the
42
- // other's state.
40
+ // Why a request exists: the compiled table in `NotificationIntake`, or a rule
41
+ // somebody configured on top of it. Recorded on the request that goes out, so
42
+ // "which wording was in force when this was sent" has an answer.
43
+ @schema
44
+ type origin =
45
+ | Default
46
+ | Configured({ruleId: string, ruleVersion: string})
47
+
48
+ // Its own past facts, plus the claim set from `NotificationSourceClaims`. The
49
+ // claims are per source and this slice is per recipient, so they come from
50
+ // another partition — see `RequestNotification` for what makes that read happen.
43
51
  @schema
44
52
  type consumedEvent =
45
53
  | RecipientAnnounced({recipientId: string, email: string})
46
54
  | NotificationSubscribed({recipientId: string, category: category, channel: channel})
47
55
  | NotificationUnsubscribed({recipientId: string, category: category, channel: channel})
56
+ | NotificationSourceClaimed({sourceId: string, by: string})
57
+ | NotificationSourceReleased({sourceId: string})
48
58
 
49
59
  @schema
50
60
  type command =
@@ -67,8 +77,29 @@ type command =
67
77
  recipientId: string,
68
78
  category: category,
69
79
  reference: string,
80
+ // What the notification is about. `reference` is a correlation key whose
81
+ // format is the requester's own business; these two name the thing plainly,
82
+ // so a row can be read back without decoding somebody else's string.
83
+ // `subjectType` is the referenced component's name as the deployment
84
+ // registers it, `subjectRef` that row's own id. Empty is legal — a
85
+ // notification about nothing in particular is a real case, and a fabricated
86
+ // subject is worse than an absent one.
87
+ subjectType: string,
88
+ subjectRef: string,
70
89
  subject: string,
71
90
  body: string,
91
+ // Which stream this came from, `"<log>:<eventType>"`, and who is asking.
92
+ // Together they are the handover: a `Default` request for a source another
93
+ // producer has claimed yields, a `Configured` one always goes through.
94
+ //
95
+ // `sourceId` is a DCB tag, and it has to be — the decision must read the
96
+ // claim facts, which live in another partition (they are per source, this
97
+ // slice is per recipient). Because the slice consumes `sourceId` without
98
+ // producing it, the framework infers the cross-partition read from the
99
+ // slice graph, and the claim write fences in-flight requests for that
100
+ // source. Renaming it off the `*Id` convention would silently drop both.
101
+ sourceId: string,
102
+ origin: origin,
72
103
  })
73
104
  // Reported by the send slice once the provider has settled. Trait-owned state,
74
105
  // so this is not a host side effect — the host is never written to at all.
@@ -94,16 +125,54 @@ type event =
94
125
  category: category,
95
126
  reference: string,
96
127
  channel: channel,
128
+ // Kept on the event and deliberately not on the delivery view: the send
129
+ // slice cannot deliver without it, and this is the record of the address a
130
+ // message actually went to, as opposed to the address of record the
131
+ // directory holds. Answering "where was this confirmation sent" therefore
132
+ // means reading the log, which is the right reach for personal data —
133
+ // available to an investigation, not to every reader of a view.
97
134
  address: string,
135
+ subjectType: string,
136
+ subjectRef: string,
98
137
  subject: string,
99
138
  body: string,
139
+ // What was in force when this went out. A message should record why it was
140
+ // sent, and until this field existed nothing did.
141
+ origin: origin,
100
142
  })
101
143
  // Both of the ways not to send, kept apart because they mean opposite things.
102
144
  // A recipient who declined is the system working; a recipient with no address
103
145
  // is the system missing something, and one fact for both would hide every
104
146
  // delivery gap behind a legitimate preference.
105
- | NotificationSuppressed({recipientId: string, category: category, reference: string})
106
- | NotificationUndeliverable({recipientId: string, category: category, reference: string})
147
+ | NotificationSuppressed({
148
+ recipientId: string,
149
+ category: category,
150
+ reference: string,
151
+ subjectType: string,
152
+ subjectRef: string,
153
+ origin: origin,
154
+ })
155
+ | NotificationUndeliverable({
156
+ recipientId: string,
157
+ category: category,
158
+ reference: string,
159
+ subjectType: string,
160
+ subjectRef: string,
161
+ origin: origin,
162
+ })
163
+ // The third way of not sending: another producer owns this source. Its own
164
+ // fact for the same reason the two above are two — and load-bearing besides,
165
+ // because the relay resolves its TODO row on an outcome, and without this one a
166
+ // deferred request retries its whole budget and is abandoned with nothing in
167
+ // the log to say why.
168
+ //
169
+ // `sourceKey` and not `sourceId`, deliberately, and this is the one place the
170
+ // two names differ: the slice's partition is derived from the `*Id` fields its
171
+ // own events declare, so a produced `sourceId` here would give it two candidate
172
+ // partitions, resolve to neither, and — worse — stop `sourceId` counting as a
173
+ // consumed-but-not-produced key, which is what makes the claim read cross
174
+ // partitions at all. The value is the same; only the fact is descriptive.
175
+ | NotificationDeferred({recipientId: string, reference: string, sourceKey: string})
107
176
  | NotificationDelivered({recipientId: string, reference: string, providerRef: string})
108
177
  | NotificationFailed({recipientId: string, reference: string, reason: string})
109
178