@reventlessdev/trait-notification 1.0.0-alpha.1

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.
@@ -0,0 +1,51 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Behavior_GWT$ReventlessGwt from "@reventlessdev/reventless-gwt/src/Behavior_GWT.res.mjs";
4
+
5
+ function suiteName(host) {
6
+ return host + ` conforms to the notification trait`;
7
+ }
8
+
9
+ function Make(B) {
10
+ let $$let = B.Behavior;
11
+ let G = Behavior_GWT$ReventlessGwt.Make(B.Spec)({
12
+ initialState: $$let.initialState,
13
+ evolve: $$let.evolve,
14
+ decide: $$let.decide
15
+ });
16
+ let announced = B.created.concat([B.announcedC(B.addressA)]);
17
+ let register = () => G.describe(suiteName(B.Spec.name), () => {
18
+ G.test("an announced contact is recorded", () => G.thenEvent(G.whenCmd(G.givenEvents(B.created), B.announce(B.addressA)), B.announced(B.addressA)));
19
+ G.test("re-announcing the address already on file is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(announced), B.announce(B.addressA))));
20
+ G.test("a changed address is recorded", () => G.thenEvent(G.whenCmd(G.givenEvents(announced), B.announce(B.addressB)), B.announced(B.addressB)));
21
+ G.test("managing preferences for an unannounced recipient is refused", () => G.thenError(G.whenCmd(G.givenEvents(B.created), B.subscribe(B.optional, B.announcedChannel)), B.recipientUnknown));
22
+ G.test("opting in to a kind that is off by default is recorded", () => G.thenEvent(G.whenCmd(G.givenEvents(announced), B.subscribe(B.optional, B.announcedChannel)), B.subscribed(B.optional, B.announcedChannel)));
23
+ G.test("subscribing to a kind already on by posture is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(announced), B.subscribe(B.transactional, B.announcedChannel))));
24
+ G.test("opting out of a kind that is on is recorded", () => G.thenEvent(G.whenCmd(G.givenEvents(announced), B.unsubscribe(B.transactional, B.announcedChannel)), B.unsubscribed(B.transactional, B.announcedChannel)));
25
+ G.test("opting out of a kind already off is a no-op", () => G.thenNoEvent(G.whenCmd(G.givenEvents(announced), B.unsubscribe(B.optional, B.announcedChannel))));
26
+ G.test("a transactional request goes out with no explicit subscription", () => G.thenEvent(G.whenCmd(G.givenEvents(announced), B.request(B.transactional, "ref-1")), B.requested(B.transactional, "ref-1", B.announcedChannel, B.addressA)));
27
+ G.test("the address on the request is the one currently on file", () => G.thenEvent(G.whenCmd(G.givenEvents(announced.concat([B.announcedC(B.addressB)])), B.request(B.transactional, "ref-1")), B.requested(B.transactional, "ref-1", B.announcedChannel, B.addressB)));
28
+ G.test("an optional request is suppressed with no explicit subscription", () => G.thenEvent(G.whenCmd(G.givenEvents(announced), B.request(B.optional, "ref-2")), B.suppressed(B.optional, "ref-2")));
29
+ G.test("a recipient who opted out is suppressed", () => G.thenEvent(G.whenCmd(G.givenEvents(announced.concat([B.unsubscribedC(B.transactional, B.announcedChannel)])), B.request(B.transactional, "ref-3")), B.suppressed(B.transactional, "ref-3")));
30
+ G.test("a request for a recipient nobody announced is undeliverable", () => G.thenEvent(G.whenCmd(G.givenEvents(B.created), B.request(B.transactional, "ref-4")), B.undeliverable(B.transactional, "ref-4")));
31
+ let channel = B.unreachableChannel;
32
+ if (channel !== undefined) {
33
+ return G.test("a channel with no address on file is undeliverable, not suppressed", () => G.thenEvent(G.whenCmd(G.givenEvents(announced.concat([
34
+ B.subscribedC(B.transactional, channel),
35
+ B.unsubscribedC(B.transactional, B.announcedChannel)
36
+ ])), B.request(B.transactional, "ref-5")), B.undeliverable(B.transactional, "ref-5")));
37
+ }
38
+ });
39
+ return {
40
+ G: G,
41
+ R: undefined,
42
+ announced: announced,
43
+ register: register
44
+ };
45
+ }
46
+
47
+ export {
48
+ suiteName,
49
+ Make,
50
+ }
51
+ /* Behavior_GWT-ReventlessGwt Not a pure module */
@@ -0,0 +1,173 @@
1
+ /**
2
+ The notification competency's rules, compiled once and called by every host: a
3
+ recipient is reachable at an address per channel, subscribes to a *kind* of
4
+ notification per channel, and a request to notify them resolves to an addressed
5
+ message, a suppression, or a record that nobody could be reached.
6
+
7
+ A host maps its own constructors onto `op` and `fact` and keeps the spec surface —
8
+ the variants, their annotations, its refusals. Nothing here knows what an order
9
+ is, or what a customer is. `Notification_Conformance` asserts these rules through
10
+ a host.
11
+
12
+ ## The one thing this module cannot know
13
+
14
+ Whether an unheard-from recipient should be notified is **per category and per
15
+ host**: a confirmation is one the recipient asked for by ordering, and marketing
16
+ is the opposite. So the fallback *rule* lives here — an absent choice defers to
17
+ the posture — and the *table* is passed in as `~posture`. A trait that hard-coded
18
+ either answer would be wrong for half its hosts.
19
+ */
20
+
21
+ /** How a recipient is reached. Mirrors `Reventless.Messaging.channel`, which is
22
+ the platform capability's vocabulary; this one is the domain's, so it carries
23
+ a host's schema and travels on the wire. `Notification_Send` maps the two. */
24
+ type channel =
25
+ | Email
26
+ | Sms
27
+ | Push
28
+
29
+ let channels = [Email, Sms, Push]
30
+
31
+ /** A selectable kind of notification, as its host spells it. A key rather than a
32
+ variant for the same reason the attachments trait's `ref` is a string: the
33
+ host's own `category` is a real variant with a schema, and wrapping it here
34
+ would only mean unwrapping it on every arm. What kinds exist is the host's
35
+ vocabulary — this module never enumerates them. */
36
+ type category = string
37
+
38
+ /** Refolded per decision, never stored — the host's slice state is. */
39
+ type t = {
40
+ /** One address per channel. An array rather than a single address because the
41
+ channels differ in kind: an email is one address per person, a device token
42
+ is one per *install*. A host that only ever announces an inbox holds a
43
+ one-element list, and the shape does not have to change when it does not. */
44
+ contacts: array<(channel, string)>,
45
+ /** Only the cells a recipient has an opinion about; the rest is `~posture`. */
46
+ choices: array<(category, channel, bool)>,
47
+ }
48
+
49
+ let empty = {contacts: [], choices: []}
50
+
51
+ /** What a host asks the competency to do. */
52
+ type op =
53
+ | /** Relayed from whatever the host publishes when it learns where somebody is.
54
+ Not a client's command: a caller who could announce another person's
55
+ address would be redirecting their mail. */
56
+ Announce({channel: channel, address: string})
57
+ | Subscribe({category: category, channel: channel})
58
+ | Unsubscribe({category: category, channel: channel})
59
+ | /** Something worth telling them about happened. `reference` is the caller's
60
+ own key for it, echoed back on whichever outcome follows, so the relay
61
+ that asked can tell its work is finished. Opaque here on purpose. */
62
+ Request({category: category, reference: string})
63
+
64
+ /** What the competency decided, for the host to name in its own events. */
65
+ type fact =
66
+ | Announced({channel: channel, address: string})
67
+ | Subscribed({category: category, channel: channel})
68
+ | Unsubscribed({category: category, channel: channel})
69
+ | /** The addressed message. `address` is the snapshot delivery uses, which is
70
+ why it is on the fact and not looked up again later. */
71
+ Requested({category: category, reference: string, channel: channel, address: string})
72
+ | /** They are reachable and said no. */
73
+ Suppressed({category: category, reference: string})
74
+ | /** They want it and nobody can be reached — no address on file for any
75
+ channel they left enabled. A different fact from `Suppressed` on purpose:
76
+ one is the system working and the other is the system falling short, and
77
+ reporting both the same way hides every delivery gap behind a legitimate
78
+ preference. */
79
+ Undeliverable({category: category, reference: string})
80
+
81
+ let addressFor = (t, channel) =>
82
+ t.contacts->Array.find(((c, _)) => c == channel)->Option.map(((_, address)) => address)
83
+
84
+ /** Whether this cell is on: the recipient's own choice if they made one, else the
85
+ host's posture for that kind. */
86
+ let enabled = (t, ~posture: (category, channel) => bool, category, channel) =>
87
+ switch t.choices->Array.find(((cat, ch, _)) => cat == category && ch == channel) {
88
+ | Some((_, _, isEnabled)) => isEnabled
89
+ | None => posture(category, channel)
90
+ }
91
+
92
+ let withChoice = (t, category, channel, isEnabled) => {
93
+ ...t,
94
+ choices: Array.concat(
95
+ t.choices->Array.filter(((cat, ch, _)) => !(cat == category && ch == channel)),
96
+ [(category, channel, isEnabled)],
97
+ ),
98
+ }
99
+
100
+ let evolve = (t, fact) =>
101
+ switch fact {
102
+ | Announced({channel, address}) => {
103
+ ...t,
104
+ contacts: Array.concat(
105
+ t.contacts->Array.filter(((c, _)) => c != channel),
106
+ [(channel, address)],
107
+ ),
108
+ }
109
+ | Subscribed({category, channel}) => withChoice(t, category, channel, true)
110
+ | Unsubscribed({category, channel}) => withChoice(t, category, channel, false)
111
+ // Neither the outcome of a request nor a delivery report changes who somebody
112
+ // is or what they want, so the fold ignores them. What a host must NOT do is
113
+ // read them back as consumed events to make this state bigger — the directory
114
+ // is snapshotted, and a set of every reference ever seen would grow forever.
115
+ | Requested(_)
116
+ | Suppressed(_)
117
+ | Undeliverable(_) => t
118
+ }
119
+
120
+ /** The whole competency, in one function.
121
+
122
+ `array<fact>` rather than `option<fact>`: a request can go out on more than
123
+ one channel, and each is its own message. An empty array is the no-op a
124
+ retried subscribe produces; a request never returns one, because the relay
125
+ upstream is waiting for an outcome and a silent path is a row that retries
126
+ until it is abandoned.
127
+
128
+ The one refusal is a client managing preferences for somebody the directory
129
+ has never heard of — a person is at the other end of that one and can be
130
+ told, unlike the relayed ops, which record a fact instead. */
131
+ let decide = (t, op, ~posture: (category, channel) => bool): result<
132
+ array<fact>,
133
+ [#RecipientUnknown],
134
+ > =>
135
+ switch op {
136
+ | Announce({channel, address}) =>
137
+ addressFor(t, channel) == Some(address) ? Ok([]) : Ok([Announced({channel, address})])
138
+
139
+ | Subscribe({category, channel}) =>
140
+ t.contacts->Array.length == 0
141
+ ? Error(#RecipientUnknown)
142
+ : enabled(t, ~posture, category, channel)
143
+ ? Ok([])
144
+ : Ok([Subscribed({category, channel})])
145
+
146
+ | Unsubscribe({category, channel}) =>
147
+ t.contacts->Array.length == 0
148
+ ? Error(#RecipientUnknown)
149
+ : enabled(t, ~posture, category, channel)
150
+ ? Ok([Unsubscribed({category, channel})])
151
+ : Ok([])
152
+
153
+ | Request({category, reference}) =>
154
+ let wanted = channels->Array.filter(channel => enabled(t, ~posture, category, channel))
155
+ let addressed =
156
+ wanted->Array.filterMap(channel =>
157
+ addressFor(t, channel)->Option.map(address => Requested({
158
+ category,
159
+ reference,
160
+ channel,
161
+ address,
162
+ }))
163
+ )
164
+ switch addressed {
165
+ | [] =>
166
+ // Wanted-but-unreachable and not-wanted are the two ways to send nothing,
167
+ // and telling them apart is the whole reason `Undeliverable` exists.
168
+ wanted->Array.length == 0
169
+ ? Ok([Suppressed({category, reference})])
170
+ : Ok([Undeliverable({category, reference})])
171
+ | facts => Ok(facts)
172
+ }
173
+ }
@@ -0,0 +1,187 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
6
+
7
+ let channels = [
8
+ "Email",
9
+ "Sms",
10
+ "Push"
11
+ ];
12
+
13
+ let empty_contacts = [];
14
+
15
+ let empty_choices = [];
16
+
17
+ let empty = {
18
+ contacts: empty_contacts,
19
+ choices: empty_choices
20
+ };
21
+
22
+ function addressFor(t, channel) {
23
+ return Stdlib_Option.map(t.contacts.find(param => param[0] === channel), param => param[1]);
24
+ }
25
+
26
+ function enabled(t, posture, category, channel) {
27
+ let match = t.choices.find(param => {
28
+ if (param[0] === category) {
29
+ return param[1] === channel;
30
+ } else {
31
+ return false;
32
+ }
33
+ });
34
+ if (match !== undefined) {
35
+ return match[2];
36
+ } else {
37
+ return posture(category, channel);
38
+ }
39
+ }
40
+
41
+ function withChoice(t, category, channel, isEnabled) {
42
+ return {
43
+ contacts: t.contacts,
44
+ choices: t.choices.filter(param => param[0] !== category || param[1] !== channel).concat([[
45
+ category,
46
+ channel,
47
+ isEnabled
48
+ ]])
49
+ };
50
+ }
51
+
52
+ function evolve(t, fact) {
53
+ switch (fact.TAG) {
54
+ case "Announced" :
55
+ let channel = fact.channel;
56
+ return {
57
+ contacts: t.contacts.filter(param => param[0] !== channel).concat([[
58
+ channel,
59
+ fact.address
60
+ ]]),
61
+ choices: t.choices
62
+ };
63
+ case "Subscribed" :
64
+ return withChoice(t, fact.category, fact.channel, true);
65
+ case "Unsubscribed" :
66
+ return withChoice(t, fact.category, fact.channel, false);
67
+ default:
68
+ return t;
69
+ }
70
+ }
71
+
72
+ function decide(t, op, posture) {
73
+ switch (op.TAG) {
74
+ case "Announce" :
75
+ let address = op.address;
76
+ let channel = op.channel;
77
+ if (Primitive_object.equal(addressFor(t, channel), address)) {
78
+ return {
79
+ TAG: "Ok",
80
+ _0: []
81
+ };
82
+ } else {
83
+ return {
84
+ TAG: "Ok",
85
+ _0: [{
86
+ TAG: "Announced",
87
+ channel: channel,
88
+ address: address
89
+ }]
90
+ };
91
+ }
92
+ case "Subscribe" :
93
+ if (t.contacts.length === 0) {
94
+ return {
95
+ TAG: "Error",
96
+ _0: "RecipientUnknown"
97
+ };
98
+ }
99
+ let channel$1 = op.channel;
100
+ let category = op.category;
101
+ if (enabled(t, posture, category, channel$1)) {
102
+ return {
103
+ TAG: "Ok",
104
+ _0: []
105
+ };
106
+ } else {
107
+ return {
108
+ TAG: "Ok",
109
+ _0: [{
110
+ TAG: "Subscribed",
111
+ category: category,
112
+ channel: channel$1
113
+ }]
114
+ };
115
+ }
116
+ case "Unsubscribe" :
117
+ if (t.contacts.length === 0) {
118
+ return {
119
+ TAG: "Error",
120
+ _0: "RecipientUnknown"
121
+ };
122
+ }
123
+ let channel$2 = op.channel;
124
+ let category$1 = op.category;
125
+ if (enabled(t, posture, category$1, channel$2)) {
126
+ return {
127
+ TAG: "Ok",
128
+ _0: [{
129
+ TAG: "Unsubscribed",
130
+ category: category$1,
131
+ channel: channel$2
132
+ }]
133
+ };
134
+ } else {
135
+ return {
136
+ TAG: "Ok",
137
+ _0: []
138
+ };
139
+ }
140
+ case "Request" :
141
+ let reference = op.reference;
142
+ let category$2 = op.category;
143
+ let wanted = channels.filter(channel => enabled(t, posture, category$2, channel));
144
+ let addressed = Stdlib_Array.filterMap(wanted, channel => Stdlib_Option.map(addressFor(t, channel), address => ({
145
+ TAG: "Requested",
146
+ category: category$2,
147
+ reference: reference,
148
+ channel: channel,
149
+ address: address
150
+ })));
151
+ if (addressed.length !== 0) {
152
+ return {
153
+ TAG: "Ok",
154
+ _0: addressed
155
+ };
156
+ } else if (wanted.length === 0) {
157
+ return {
158
+ TAG: "Ok",
159
+ _0: [{
160
+ TAG: "Suppressed",
161
+ category: category$2,
162
+ reference: reference
163
+ }]
164
+ };
165
+ } else {
166
+ return {
167
+ TAG: "Ok",
168
+ _0: [{
169
+ TAG: "Undeliverable",
170
+ category: category$2,
171
+ reference: reference
172
+ }]
173
+ };
174
+ }
175
+ }
176
+ }
177
+
178
+ export {
179
+ channels,
180
+ empty,
181
+ addressFor,
182
+ enabled,
183
+ withChoice,
184
+ evolve,
185
+ decide,
186
+ }
187
+ /* No side effect */