@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.
- package/LICENSE +202 -0
- package/README.md +110 -0
- package/package.json +40 -0
- package/rescript.json +27 -0
- package/src/Notification.res +105 -0
- package/src/Notification.res.mjs +19 -0
- package/src/Notification_Conformance.res +137 -0
- package/src/Notification_Conformance.res.mjs +51 -0
- package/src/Notification_Rules.res +173 -0
- package/src/Notification_Rules.res.mjs +187 -0
- package/src/Notification_Scaffold.res +894 -0
- package/src/Notification_Scaffold.res.mjs +814 -0
|
@@ -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 */
|