@reventlessdev/online-shop-hybrid-ordering 1.0.0-alpha.230 → 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 (32) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/package.json +10 -10
  3. package/src/Notification/AutomationSlice/NotificationIntake.res +11 -10
  4. package/src/Notification/AutomationSlice/NotificationIntake.res.mjs +8 -10
  5. package/src/Notification/AutomationSlice/NotificationIntake_Automation.res +107 -54
  6. package/src/Notification/AutomationSlice/NotificationIntake_Automation.res.mjs +90 -52
  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/Plugin.res +5 -3
  20. package/src/Plugin.res.mjs +33 -0
  21. package/tests/Flow/OrderingFlow_GWT.res +8 -0
  22. package/tests/Flow/OrderingFlow_GWT.res.mjs +10 -3
  23. package/tests/Notification/AutomationSlice/NotificationIntake_GWT.res +153 -0
  24. package/tests/Notification/AutomationSlice/NotificationIntake_GWT.res.mjs +248 -0
  25. package/tests/Notification/NotificationConformance_GWT.res +61 -5
  26. package/tests/Notification/NotificationConformance_GWT.res.mjs +86 -5
  27. package/tests/Notification/StateChangeSlice/NotificationPreferences_GWT.res +42 -5
  28. package/tests/Notification/StateChangeSlice/NotificationPreferences_GWT.res.mjs +30 -3
  29. package/tests/Notification/StateChangeSlice/NotificationSourceClaims_GWT.res +47 -0
  30. package/tests/Notification/StateChangeSlice/NotificationSourceClaims_GWT.res.mjs +106 -0
  31. package/tests/Notification/StateViewSliceStream/NotificationDeliveries_GWT.res +99 -0
  32. package/tests/Notification/StateViewSliceStream/NotificationDeliveries_GWT.res.mjs +148 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
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
+
6
16
  # 1.0.0-alpha.230 (2026-09-02)
7
17
 
8
18
  ### 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.230",
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-address-geocoding": "1.0.0-alpha.3",
31
- "@reventlessdev/trait-notification": "1.0.0-alpha.3",
32
- "@reventlessdev/reventless-local": "3.0.0-alpha.243"
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"
@@ -12,17 +12,14 @@
12
12
 
13
13
  @@reventless.spec
14
14
 
15
- // Which of this host's occurrences a row stands for. The todo carries the
16
- // occurrence, not the finished sentence: the category and the wording are chosen
17
- // from it in one place, so a second notifiable event cannot quietly reuse the
18
- // first one's category.
19
- @schema
20
- type occurrence =
21
- | Placed
22
- | Shipped
23
-
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 }}`.
24
21
  @schema
25
- type todoItem = {recipientId: string, orderId: string, occurrence: occurrence}
22
+ type todoItem = {ruleId: string, recipientId: string, orderId: string}
26
23
 
27
24
  @schema
28
25
  type command =
@@ -30,8 +27,12 @@ type command =
30
27
  recipientId: string,
31
28
  category: NotificationPreferences.category,
32
29
  reference: string,
30
+ subjectType: string,
31
+ subjectRef: string,
33
32
  subject: string,
34
33
  body: string,
34
+ sourceId: string,
35
+ origin: NotificationPreferences.origin,
35
36
  })
36
37
 
37
38
  let maxRetries = 3
@@ -4,15 +4,10 @@ import * as Sury from "sury";
4
4
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
5
5
  import * as NotificationPreferences$OrderingPlugin from "../StateChangeSlice/NotificationPreferences.res.mjs";
6
6
 
7
- let occurrenceSchema = Sury.union([
8
- Sury.literal("Placed"),
9
- Sury.literal("Shipped")
10
- ]);
11
-
12
7
  let todoItemSchema = Sury.$schema(s => ({
8
+ ruleId: s.m(Sury.string),
13
9
  recipientId: s.m(Sury.string),
14
- orderId: s.m(Sury.string),
15
- occurrence: s.m(occurrenceSchema)
10
+ orderId: s.m(Sury.string)
16
11
  }));
17
12
 
18
13
  let commandSchema = Sury.$schema(s => ({
@@ -20,8 +15,12 @@ let commandSchema = Sury.$schema(s => ({
20
15
  recipientId: s.m(DcbTag$Reventless.string),
21
16
  category: s.m(NotificationPreferences$OrderingPlugin.categorySchema),
22
17
  reference: s.m(Sury.string),
18
+ subjectType: s.m(Sury.string),
19
+ subjectRef: s.m(Sury.string),
23
20
  subject: s.m(Sury.string),
24
- 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)
25
24
  }));
26
25
 
27
26
  function commandAuthorization(param) {
@@ -49,7 +48,6 @@ let moduleUrl = "@reventlessdev/online-shop-hybrid-ordering/src/Notification/Aut
49
48
  export {
50
49
  name,
51
50
  Id,
52
- occurrenceSchema,
53
51
  todoItemSchema,
54
52
  commandSchema,
55
53
  maxRetries,
@@ -60,4 +58,4 @@ export {
60
58
  commandTransition,
61
59
  traits,
62
60
  }
63
- /* occurrenceSchema Not a pure module */
61
+ /* todoItemSchema Not a pure module */
@@ -1,11 +1,6 @@
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. One key per occurrence, not per
5
- // order: an order that is placed and then ships is two notifications, and a
6
- // shared key would let the first one's outcome close the second one's row.
7
- let confirmationKey = orderId => `confirm:${orderId}`
8
- let shippingKey = orderId => `ship:${orderId}`
3
+ module Rule = TraitNotification.Notification_Rule
9
4
 
10
5
  // One source: this plugin's DCB log, which is where the occurrence is and where
11
6
  // every outcome lands. Both order events name their own customer, which is what
@@ -18,14 +13,79 @@ module OrderingDcbSource = {
18
13
  type event =
19
14
  | OrderPlaced({orderId: string, customerId: string})
20
15
  | OrderShipped({orderId: string, customerId: string})
21
- // Every way a request can end. All three resolve the row, because all three
16
+ // Every way a request can end. All four resolve the row, because all four
22
17
  // mean the decision was made — a suppressed notification is a finished piece
23
- // 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.
24
23
  | NotificationRequested({reference: string})
25
24
  | NotificationSuppressed({reference: string})
26
25
  | NotificationUndeliverable({reference: string})
26
+ | NotificationDeferred({reference: string})
27
27
  }
28
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
+
29
89
  module FromOrderingDcb = Mapping.Make(
30
90
  OrderingDcbSource,
31
91
  NotificationIntake,
@@ -34,28 +94,22 @@ module FromOrderingDcb = Mapping.Make(
34
94
 
35
95
  let collect = (event, ~sourceId as _, _ctx) =>
36
96
  switch event {
37
- | OrderPlaced({orderId, customerId}) => [
38
- (
39
- confirmationKey(orderId),
40
- ({recipientId: customerId, orderId, occurrence: Placed}: NotificationIntake.todoItem),
41
- ),
42
- ]
43
- | OrderShipped({orderId, customerId}) => [
44
- (
45
- shippingKey(orderId),
46
- ({recipientId: customerId, orderId, occurrence: Shipped}: NotificationIntake.todoItem),
47
- ),
48
- ]
97
+ | OrderPlaced({orderId, customerId}) =>
98
+ todosFor(~eventType="OrderPlaced", ~recipientId=customerId, ~orderId)
99
+ | OrderShipped({orderId, customerId}) =>
100
+ todosFor(~eventType="OrderShipped", ~recipientId=customerId, ~orderId)
49
101
  | NotificationRequested(_)
50
102
  | NotificationSuppressed(_)
51
- | NotificationUndeliverable(_) => []
103
+ | NotificationUndeliverable(_)
104
+ | NotificationDeferred(_) => []
52
105
  }
53
106
 
54
107
  let resolve = event =>
55
108
  switch event {
56
109
  | NotificationRequested({reference})
57
110
  | NotificationSuppressed({reference})
58
- | NotificationUndeliverable({reference}) =>
111
+ | NotificationUndeliverable({reference})
112
+ | NotificationDeferred({reference}) =>
59
113
  Some(reference)
60
114
  | OrderPlaced(_)
61
115
  | OrderShipped(_) => None
@@ -65,40 +119,39 @@ module FromOrderingDcb = Mapping.Make(
65
119
 
66
120
  let mappings: array<module(Mapping)> = [module(FromOrderingDcb)]
67
121
 
68
- // The one place this host's occurrences are given a kind and a wording. A trait
69
- // cannot write this: the categories are ones the trait declares, but which of the
70
- // host's events earns which, and what the sentence says, is the host's.
71
- //
72
- // One switch over the occurrence, so adding a notifiable event means adding an
73
- // arm here rather than remembering to change a category further down.
74
- let compose = (item: NotificationIntake.todoItem) =>
75
- switch item.occurrence {
76
- | Placed => (
77
- NotificationPreferences.OrderConfirmation,
78
- `Your order ${item.orderId} is confirmed`,
79
- `Thanks — we have your order ${item.orderId} and will let you know when it ships.`,
80
- )
81
- | Shipped => (
82
- NotificationPreferences.ShippingUpdate,
83
- `Your order ${item.orderId} is on its way`,
84
- `Good news — order ${item.orderId} has shipped.`,
85
- )
122
+ let process = (id, item: NotificationIntake.todoItem) =>
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
+ }
86
153
  }
87
154
 
88
- let process = (id, item: NotificationIntake.todoItem) => {
89
- let (category, subject, body) = compose(item)
90
- Some((
91
- item.recipientId,
92
- NotificationIntake.RequestNotification({
93
- recipientId: item.recipientId,
94
- category,
95
- reference: id,
96
- subject,
97
- body,
98
- }),
99
- ))
100
- }
101
-
102
155
  // Nothing to say. A relay that gave up published no command, so the notification
103
156
  // competency never heard of the occurrence — and a delivery-failed fact for a
104
157
  // notification nobody requested would put a row in the log for something that
@@ -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,14 +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
- function shippingKey(orderId) {
24
- return `ship:` + orderId;
25
- }
26
-
27
23
  let name = "OrderingDcbEventLog";
28
24
 
29
25
  let eventSchema = Sury.union([
@@ -48,6 +44,10 @@ let eventSchema = Sury.union([
48
44
  Sury.$schema(s => ({
49
45
  TAG: "NotificationUndeliverable",
50
46
  reference: s.m(Sury.string)
47
+ })),
48
+ Sury.$schema(s => ({
49
+ TAG: "NotificationDeferred",
50
+ reference: s.m(Sury.string)
51
51
  }))
52
52
  ]);
53
53
 
@@ -57,28 +57,64 @@ let OrderingDcbSource = {
57
57
  eventSchema: eventSchema
58
58
  };
59
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
+
60
112
  function collect(event, param, _ctx) {
61
113
  switch (event.TAG) {
62
114
  case "OrderPlaced" :
63
- let orderId = event.orderId;
64
- return [[
65
- confirmationKey(orderId),
66
- {
67
- recipientId: event.customerId,
68
- orderId: orderId,
69
- occurrence: "Placed"
70
- }
71
- ]];
115
+ return todosFor("OrderPlaced", event.customerId, event.orderId);
72
116
  case "OrderShipped" :
73
- let orderId$1 = event.orderId;
74
- return [[
75
- shippingKey(orderId$1),
76
- {
77
- recipientId: event.customerId,
78
- orderId: orderId$1,
79
- occurrence: "Shipped"
80
- }
81
- ]];
117
+ return todosFor("OrderShipped", event.customerId, event.orderId);
82
118
  default:
83
119
  return [];
84
120
  }
@@ -113,34 +149,34 @@ let FromOrderingDcb = AutomationSlice$Reventless.Mapping.Make({
113
149
 
114
150
  let mappings = [FromOrderingDcb];
115
151
 
116
- function compose(item) {
117
- let match = item.occurrence;
118
- if (match === "Placed") {
119
- return [
120
- "OrderConfirmation",
121
- `Your order ` + item.orderId + ` is confirmed`,
122
- `Thanks — we have your order ` + item.orderId + ` and will let you know when it ships.`
123
- ];
124
- } else {
125
- return [
126
- "ShippingUpdate",
127
- `Your order ` + item.orderId + ` is on its way`,
128
- `Good news — order ` + item.orderId + ` has shipped.`
129
- ];
130
- }
131
- }
132
-
133
152
  function process(id, item) {
134
- let match = compose(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);
135
167
  return [
136
- item.recipientId,
168
+ match$1,
137
169
  {
138
170
  TAG: "RequestNotification",
139
- recipientId: item.recipientId,
140
- category: match[0],
171
+ recipientId: match$1,
172
+ category: NotificationPreferences_Behavior$OrderingPlugin.categoryOf(rule.category),
141
173
  reference: id,
142
- subject: match[1],
143
- body: match[2]
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"
144
180
  }
145
181
  ];
146
182
  }
@@ -151,17 +187,19 @@ function onExhausted(_id, _item) {
151
187
 
152
188
  let Spec;
153
189
 
190
+ let Rule;
191
+
154
192
  let moduleUrl = "@reventlessdev/online-shop-hybrid-ordering/src/Notification/AutomationSlice/NotificationIntake_Automation.res.mjs";
155
193
 
156
194
  export {
157
195
  Spec,
158
196
  M,
159
- confirmationKey,
160
- shippingKey,
197
+ Rule,
161
198
  OrderingDcbSource,
199
+ defaultRules,
200
+ todosFor,
162
201
  FromOrderingDcb,
163
202
  mappings,
164
- compose,
165
203
  process,
166
204
  onExhausted,
167
205
  moduleUrl,
@@ -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