@reventlessdev/trait-notification 1.0.0-alpha.1 → 1.0.0-alpha.11
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/package.json +3 -3
- package/src/Notification.res +41 -9
- package/src/Notification_Conformance.res +82 -17
- package/src/Notification_Conformance.res.mjs +16 -8
- package/src/Notification_Rule.res +244 -0
- package/src/Notification_Rule.res.mjs +348 -0
- package/src/Notification_Rules.res +92 -23
- package/src/Notification_Rules.res.mjs +76 -3
- package/src/Notification_Scaffold.res +491 -103
- package/src/Notification_Scaffold.res.mjs +465 -85
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/trait-notification",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.11",
|
|
4
4
|
"description": "Notification domain trait: rules, host contract, conformance suite and emitter for a per-recipient contact directory, a kind x channel subscription matrix, and the decision to send, suppress or record a message as undeliverable",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"files": [
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"sury": "11.0.0-rc.2",
|
|
15
15
|
"sury-ppx": "11.0.0-rc.2",
|
|
16
|
-
"@reventlessdev/reventless-gwt": "1.0.0-alpha.
|
|
17
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
16
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.209",
|
|
17
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.135"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"rescript": "12.3.0"
|
package/src/Notification.res
CHANGED
|
@@ -19,7 +19,6 @@ What an occurrence *is*. It is told a recipient, a kind, and a reference; it is
|
|
|
19
19
|
never told there was an order. That is why the two relays are the only files a
|
|
20
20
|
graft has to be given, and everything else can be handed over whole.
|
|
21
21
|
*/
|
|
22
|
-
|
|
23
22
|
/**
|
|
24
23
|
This trait's own account of itself — see `AddressGeocoding.declaration` for why the
|
|
25
24
|
version is read rather than written.
|
|
@@ -67,19 +66,46 @@ module type Binding = {
|
|
|
67
66
|
module Spec: ReventlessGwt.Behavior_GWT.BehaviorSpec
|
|
68
67
|
module Behavior: ReventlessGwt.Behavior_GWT.Behavior with module Spec = Spec
|
|
69
68
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
/** The same facts as the slice **consumes** them, for building history.
|
|
70
|
+
|
|
71
|
+
A slice declares `consumedEvent` and `event` separately and they differ —
|
|
72
|
+
the consumed form carries no recipient id, because the partition already
|
|
73
|
+
says whose it is. So a suite needs both spellings of the same fact: one to
|
|
74
|
+
lay down history, one to assert what was emitted.
|
|
75
|
+
|
|
76
|
+
Grouped rather than suffixed. The pairs used to read `announcedC` against
|
|
77
|
+
`announced`, which said "consumed" only to someone who already knew — and
|
|
78
|
+
in a codebase where every binding also carries commands, a bare `C` reads
|
|
79
|
+
just as naturally as one. */
|
|
80
|
+
module Consumed: {
|
|
81
|
+
/** History that brings the recipient into existence with nothing on file. */
|
|
82
|
+
let created: array<Spec.consumedEvent>
|
|
83
|
+
let announced: string => Spec.consumedEvent
|
|
84
|
+
let subscribed: (category, Notification_Rules.channel) => Spec.consumedEvent
|
|
85
|
+
let unsubscribed: (category, Notification_Rules.channel) => Spec.consumedEvent
|
|
86
|
+
/** A source taken over, as the slice reads it back. Claims are per source
|
|
87
|
+
and not per recipient, so a host produces these from wherever it keeps
|
|
88
|
+
them — what this contract requires is only that the decision can see
|
|
89
|
+
them. */
|
|
90
|
+
let claimed: (string, string) => Spec.consumedEvent
|
|
91
|
+
let released: string => Spec.consumedEvent
|
|
92
|
+
}
|
|
77
93
|
|
|
78
94
|
let announce: string => Spec.command
|
|
79
95
|
let subscribe: (category, Notification_Rules.channel) => Spec.command
|
|
80
96
|
let unsubscribe: (category, Notification_Rules.channel) => Spec.command
|
|
81
|
-
/** `reference` is the requester's key, echoed on whichever outcome follows.
|
|
97
|
+
/** `reference` is the requester's key, echoed on whichever outcome follows.
|
|
98
|
+
The host's own relay's request: `Default` origin, from `defaultSource`. */
|
|
82
99
|
let request: (category, string) => Spec.command
|
|
100
|
+
/** The same, with the two fields the handover turns on spelled out. */
|
|
101
|
+
let requestFrom: (
|
|
102
|
+
category,
|
|
103
|
+
string,
|
|
104
|
+
~source: string,
|
|
105
|
+
~origin: Notification_Rules.origin,
|
|
106
|
+
) => Spec.command
|
|
107
|
+
/** The source `request` above comes from, and one nothing ever claims. */
|
|
108
|
+
let defaultSource: string
|
|
83
109
|
|
|
84
110
|
/** The competency's facts, as the slice emits them. */
|
|
85
111
|
let announced: string => Spec.event
|
|
@@ -88,6 +114,12 @@ module type Binding = {
|
|
|
88
114
|
let requested: (category, string, Notification_Rules.channel, string) => Spec.event
|
|
89
115
|
let suppressed: (category, string) => Spec.event
|
|
90
116
|
let undeliverable: (category, string) => Spec.event
|
|
117
|
+
/** `(reference, source)` — the request another producer owns. */
|
|
118
|
+
let deferred: (string, string) => Spec.event
|
|
119
|
+
/** `requested` as it comes out for a `Configured` request. A separate member
|
|
120
|
+
because *which* rule asked is the host's own shape — the trait's fact
|
|
121
|
+
carries no origin at all, so it cannot construct this one. */
|
|
122
|
+
let requestedConfigured: (category, string, Notification_Rules.channel, string) => Spec.event
|
|
91
123
|
|
|
92
124
|
/** The refusal for managing preferences for somebody nobody has announced. */
|
|
93
125
|
let recipientUnknown: Spec.error
|
|
@@ -6,7 +6,6 @@ What it asserts is the competency, not the host: the directory, the fallback to
|
|
|
6
6
|
the host's posture, and — the part worth being strict about — that the three ways
|
|
7
7
|
to send nothing stay three different facts.
|
|
8
8
|
*/
|
|
9
|
-
|
|
10
9
|
/** The suite's title, composed once and read twice: the suite registers it,
|
|
11
10
|
and `certify-trait` computes the same string to find the run's assertions in
|
|
12
11
|
a test report. Exported rather than inlined so neither side parses the
|
|
@@ -17,14 +16,14 @@ module Make = (B: Notification.Binding) => {
|
|
|
17
16
|
module G = ReventlessGwt.Behavior_GWT.Make(B.Spec, B.Behavior)
|
|
18
17
|
module R = Notification_Rules
|
|
19
18
|
|
|
20
|
-
let announced = Array.concat(B.created, [B.
|
|
19
|
+
let announced = Array.concat(B.Consumed.created, [B.Consumed.announced(B.addressA)])
|
|
21
20
|
|
|
22
21
|
let register = () =>
|
|
23
22
|
G.describe(suiteName(B.Spec.name), () => {
|
|
24
23
|
G.test("an announced contact is recorded", () =>
|
|
25
|
-
G.givenEvents(B.created)
|
|
26
|
-
|
|
27
|
-
)
|
|
24
|
+
G.givenEvents(B.Consumed.created)
|
|
25
|
+
->G.whenCmd(B.announce(B.addressA))
|
|
26
|
+
->G.thenEvent(B.announced(B.addressA))
|
|
28
27
|
)
|
|
29
28
|
|
|
30
29
|
// The relay re-announces on every contact event a host publishes, and its
|
|
@@ -36,15 +35,15 @@ module Make = (B: Notification.Binding) => {
|
|
|
36
35
|
)
|
|
37
36
|
|
|
38
37
|
G.test("a changed address is recorded", () =>
|
|
39
|
-
G.givenEvents(announced)
|
|
40
|
-
|
|
41
|
-
)
|
|
38
|
+
G.givenEvents(announced)
|
|
39
|
+
->G.whenCmd(B.announce(B.addressB))
|
|
40
|
+
->G.thenEvent(B.announced(B.addressB))
|
|
42
41
|
)
|
|
43
42
|
|
|
44
43
|
// A person is at the other end of this one, so they are told rather than
|
|
45
44
|
// having a fact recorded about them.
|
|
46
45
|
G.test("managing preferences for an unannounced recipient is refused", () =>
|
|
47
|
-
G.givenEvents(B.created)
|
|
46
|
+
G.givenEvents(B.Consumed.created)
|
|
48
47
|
->G.whenCmd(B.subscribe(B.optional, B.announcedChannel))
|
|
49
48
|
->G.thenError(B.recipientUnknown)
|
|
50
49
|
)
|
|
@@ -81,13 +80,11 @@ module Make = (B: Notification.Binding) => {
|
|
|
81
80
|
G.test("a transactional request goes out with no explicit subscription", () =>
|
|
82
81
|
G.givenEvents(announced)
|
|
83
82
|
->G.whenCmd(B.request(B.transactional, "ref-1"))
|
|
84
|
-
->G.thenEvent(
|
|
85
|
-
B.requested(B.transactional, "ref-1", B.announcedChannel, B.addressA),
|
|
86
|
-
)
|
|
83
|
+
->G.thenEvent(B.requested(B.transactional, "ref-1", B.announcedChannel, B.addressA))
|
|
87
84
|
)
|
|
88
85
|
|
|
89
86
|
G.test("the address on the request is the one currently on file", () =>
|
|
90
|
-
G.givenEvents(Array.concat(announced, [B.
|
|
87
|
+
G.givenEvents(Array.concat(announced, [B.Consumed.announced(B.addressB)]))
|
|
91
88
|
->G.whenCmd(B.request(B.transactional, "ref-1"))
|
|
92
89
|
->G.thenEvent(B.requested(B.transactional, "ref-1", B.announcedChannel, B.addressB))
|
|
93
90
|
)
|
|
@@ -100,7 +97,7 @@ module Make = (B: Notification.Binding) => {
|
|
|
100
97
|
|
|
101
98
|
G.test("a recipient who opted out is suppressed", () =>
|
|
102
99
|
G.givenEvents(
|
|
103
|
-
Array.concat(announced, [B.
|
|
100
|
+
Array.concat(announced, [B.Consumed.unsubscribed(B.transactional, B.announcedChannel)]),
|
|
104
101
|
)
|
|
105
102
|
->G.whenCmd(B.request(B.transactional, "ref-3"))
|
|
106
103
|
->G.thenEvent(B.suppressed(B.transactional, "ref-3"))
|
|
@@ -108,11 +105,79 @@ module Make = (B: Notification.Binding) => {
|
|
|
108
105
|
|
|
109
106
|
// The case the directory exists for.
|
|
110
107
|
G.test("a request for a recipient nobody announced is undeliverable", () =>
|
|
111
|
-
G.givenEvents(B.created)
|
|
108
|
+
G.givenEvents(B.Consumed.created)
|
|
112
109
|
->G.whenCmd(B.request(B.transactional, "ref-4"))
|
|
113
110
|
->G.thenEvent(B.undeliverable(B.transactional, "ref-4"))
|
|
114
111
|
)
|
|
115
112
|
|
|
113
|
+
// ── The handover ────────────────────────────────────────────────────────
|
|
114
|
+
//
|
|
115
|
+
// A second producer takes over one source and the compiled table yields on
|
|
116
|
+
// that source alone. The first of these is the one that matters most: it is
|
|
117
|
+
// the assertion that a host with nobody claiming anything behaves exactly as
|
|
118
|
+
// it did before any of this existed.
|
|
119
|
+
|
|
120
|
+
G.test("with nothing claimed, a default request is decided as usual", () =>
|
|
121
|
+
G.givenEvents(announced)
|
|
122
|
+
->G.whenCmd(
|
|
123
|
+
B.requestFrom(B.transactional, "ref-6", ~source=B.defaultSource, ~origin=Default),
|
|
124
|
+
)
|
|
125
|
+
->G.thenEvent(B.requested(B.transactional, "ref-6", B.announcedChannel, B.addressA))
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
G.test("a default request for a claimed source is deferred", () =>
|
|
129
|
+
G.givenEvents(
|
|
130
|
+
Array.concat(announced, [B.Consumed.claimed("other:Source", "second-producer")]),
|
|
131
|
+
)
|
|
132
|
+
->G.whenCmd(
|
|
133
|
+
B.requestFrom(B.transactional, "ref-7", ~source="other:Source", ~origin=Default),
|
|
134
|
+
)
|
|
135
|
+
->G.thenEvent(B.deferred("ref-7", "other:Source"))
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
// The claim is per source, not per recipient or per deployment: the table
|
|
139
|
+
// keeps firing everywhere it was not taken over. Without this, a single
|
|
140
|
+
// claim would silence the whole competency.
|
|
141
|
+
G.test("a claim on one source leaves every other source alone", () =>
|
|
142
|
+
G.givenEvents(
|
|
143
|
+
Array.concat(announced, [B.Consumed.claimed("other:Source", "second-producer")]),
|
|
144
|
+
)
|
|
145
|
+
->G.whenCmd(
|
|
146
|
+
B.requestFrom(B.transactional, "ref-8", ~source=B.defaultSource, ~origin=Default),
|
|
147
|
+
)
|
|
148
|
+
->G.thenEvent(B.requested(B.transactional, "ref-8", B.announcedChannel, B.addressA))
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
// The producer that owns the source is the one that must get through, or
|
|
152
|
+
// the handover would silence the entry rather than move it.
|
|
153
|
+
G.test("a configured request for a claimed source goes through", () =>
|
|
154
|
+
G.givenEvents(
|
|
155
|
+
Array.concat(announced, [B.Consumed.claimed("other:Source", "second-producer")]),
|
|
156
|
+
)
|
|
157
|
+
->G.whenCmd(
|
|
158
|
+
B.requestFrom(B.transactional, "ref-9", ~source="other:Source", ~origin=Configured),
|
|
159
|
+
)
|
|
160
|
+
->G.thenEvent(
|
|
161
|
+
B.requestedConfigured(B.transactional, "ref-9", B.announcedChannel, B.addressA),
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
G.test("a released source stops deferring", () =>
|
|
166
|
+
G.givenEvents(
|
|
167
|
+
Array.concat(
|
|
168
|
+
announced,
|
|
169
|
+
[
|
|
170
|
+
B.Consumed.claimed("other:Source", "second-producer"),
|
|
171
|
+
B.Consumed.released("other:Source"),
|
|
172
|
+
],
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
->G.whenCmd(
|
|
176
|
+
B.requestFrom(B.transactional, "ref-10", ~source="other:Source", ~origin=Default),
|
|
177
|
+
)
|
|
178
|
+
->G.thenEvent(B.requested(B.transactional, "ref-10", B.announcedChannel, B.addressA))
|
|
179
|
+
)
|
|
180
|
+
|
|
116
181
|
// Wanted, and unreachable — the distinction the whole `Undeliverable` arm
|
|
117
182
|
// exists for. Skipped by a host that announces an address for every channel
|
|
118
183
|
// it offers, because there is then no way to be in this state.
|
|
@@ -124,8 +189,8 @@ module Make = (B: Notification.Binding) => {
|
|
|
124
189
|
Array.concat(
|
|
125
190
|
announced,
|
|
126
191
|
[
|
|
127
|
-
B.
|
|
128
|
-
B.
|
|
192
|
+
B.Consumed.subscribed(B.transactional, channel),
|
|
193
|
+
B.Consumed.unsubscribed(B.transactional, B.announcedChannel),
|
|
129
194
|
],
|
|
130
195
|
),
|
|
131
196
|
)
|
|
@@ -13,26 +13,34 @@ function Make(B) {
|
|
|
13
13
|
evolve: $$let.evolve,
|
|
14
14
|
decide: $$let.decide
|
|
15
15
|
});
|
|
16
|
-
let announced = B.created.concat([B.
|
|
16
|
+
let announced = B.Consumed.created.concat([B.Consumed.announced(B.addressA)]);
|
|
17
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)));
|
|
18
|
+
G.test("an announced contact is recorded", () => G.thenEvent(G.whenCmd(G.givenEvents(B.Consumed.created), B.announce(B.addressA)), B.announced(B.addressA)));
|
|
19
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
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));
|
|
21
|
+
G.test("managing preferences for an unannounced recipient is refused", () => G.thenError(G.whenCmd(G.givenEvents(B.Consumed.created), B.subscribe(B.optional, B.announcedChannel)), B.recipientUnknown));
|
|
22
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
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
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
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
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.
|
|
27
|
+
G.test("the address on the request is the one currently on file", () => G.thenEvent(G.whenCmd(G.givenEvents(announced.concat([B.Consumed.announced(B.addressB)])), B.request(B.transactional, "ref-1")), B.requested(B.transactional, "ref-1", B.announcedChannel, B.addressB)));
|
|
28
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.
|
|
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")));
|
|
29
|
+
G.test("a recipient who opted out is suppressed", () => G.thenEvent(G.whenCmd(G.givenEvents(announced.concat([B.Consumed.unsubscribed(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.Consumed.created), B.request(B.transactional, "ref-4")), B.undeliverable(B.transactional, "ref-4")));
|
|
31
|
+
G.test("with nothing claimed, a default request is decided as usual", () => G.thenEvent(G.whenCmd(G.givenEvents(announced), B.requestFrom(B.transactional, "ref-6", B.defaultSource, "Default")), B.requested(B.transactional, "ref-6", B.announcedChannel, B.addressA)));
|
|
32
|
+
G.test("a default request for a claimed source is deferred", () => G.thenEvent(G.whenCmd(G.givenEvents(announced.concat([B.Consumed.claimed("other:Source", "second-producer")])), B.requestFrom(B.transactional, "ref-7", "other:Source", "Default")), B.deferred("ref-7", "other:Source")));
|
|
33
|
+
G.test("a claim on one source leaves every other source alone", () => G.thenEvent(G.whenCmd(G.givenEvents(announced.concat([B.Consumed.claimed("other:Source", "second-producer")])), B.requestFrom(B.transactional, "ref-8", B.defaultSource, "Default")), B.requested(B.transactional, "ref-8", B.announcedChannel, B.addressA)));
|
|
34
|
+
G.test("a configured request for a claimed source goes through", () => G.thenEvent(G.whenCmd(G.givenEvents(announced.concat([B.Consumed.claimed("other:Source", "second-producer")])), B.requestFrom(B.transactional, "ref-9", "other:Source", "Configured")), B.requestedConfigured(B.transactional, "ref-9", B.announcedChannel, B.addressA)));
|
|
35
|
+
G.test("a released source stops deferring", () => G.thenEvent(G.whenCmd(G.givenEvents(announced.concat([
|
|
36
|
+
B.Consumed.claimed("other:Source", "second-producer"),
|
|
37
|
+
B.Consumed.released("other:Source")
|
|
38
|
+
])), B.requestFrom(B.transactional, "ref-10", "other:Source", "Default")), B.requested(B.transactional, "ref-10", B.announcedChannel, B.addressA)));
|
|
31
39
|
let channel = B.unreachableChannel;
|
|
32
40
|
if (channel !== undefined) {
|
|
33
41
|
return G.test("a channel with no address on file is undeliverable, not suppressed", () => G.thenEvent(G.whenCmd(G.givenEvents(announced.concat([
|
|
34
|
-
B.
|
|
35
|
-
B.
|
|
42
|
+
B.Consumed.subscribed(B.transactional, channel),
|
|
43
|
+
B.Consumed.unsubscribed(B.transactional, B.announcedChannel)
|
|
36
44
|
])), B.request(B.transactional, "ref-5")), B.undeliverable(B.transactional, "ref-5")));
|
|
37
45
|
}
|
|
38
46
|
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
One notification rule as a value: which stream of occurrences it answers to, who
|
|
3
|
+
it is for, which kind it is, and what it says.
|
|
4
|
+
|
|
5
|
+
The shape a compiled table and a configured one both hold. That is why the filter
|
|
6
|
+
is a typed tree and not an expression string — a string needs a parser at
|
|
7
|
+
evaluation time, and a parser that runs on data from outside is an injection
|
|
8
|
+
surface. It is also why the wording is `Reventless.Template` source: readable
|
|
9
|
+
text rather than ReScript interpolation, rendered against the payload's schema so
|
|
10
|
+
a semantic formats itself and a `@sensitive` field is withheld.
|
|
11
|
+
*/
|
|
12
|
+
/** How a field is compared. */
|
|
13
|
+
@schema
|
|
14
|
+
type comparison =
|
|
15
|
+
| Eq
|
|
16
|
+
| Ne
|
|
17
|
+
| Lt
|
|
18
|
+
| Lte
|
|
19
|
+
| Gt
|
|
20
|
+
| Gte
|
|
21
|
+
| Contains
|
|
22
|
+
|
|
23
|
+
/** What it is compared against. */
|
|
24
|
+
@schema
|
|
25
|
+
type literal =
|
|
26
|
+
| Text(string)
|
|
27
|
+
| Number(float)
|
|
28
|
+
| Flag(bool)
|
|
29
|
+
|
|
30
|
+
/** Whether a rule answers to this occurrence. */
|
|
31
|
+
@schema
|
|
32
|
+
type rec predicate =
|
|
33
|
+
| Always
|
|
34
|
+
| Compare({path: string, op: comparison, value: literal})
|
|
35
|
+
| All(array<predicate>)
|
|
36
|
+
| Any(array<predicate>)
|
|
37
|
+
| Not(predicate)
|
|
38
|
+
|
|
39
|
+
/** One wording, in one locale. Both fields are `Reventless.Template` sources. */
|
|
40
|
+
@schema
|
|
41
|
+
type content = {locale: string, subject: string, body: string}
|
|
42
|
+
|
|
43
|
+
/** The stream of occurrences a rule answers to, as the two halves of the
|
|
44
|
+
`"<log>:<eventType>"` a claim is made against. */
|
|
45
|
+
@schema
|
|
46
|
+
type source = {log: string, eventType: string}
|
|
47
|
+
|
|
48
|
+
/** How a rule's occurrences reach the recipient: one message each, or gathered
|
|
49
|
+
into one message per window.
|
|
50
|
+
|
|
51
|
+
Routing is a field on the rule and not a negotiation between producers. A
|
|
52
|
+
digest is scheduler-driven and therefore its own component, but *which*
|
|
53
|
+
occurrences are its to gather is a table entry — so a relay serves the half it
|
|
54
|
+
can deliver and leaves the rest, with nothing to arbitrate.
|
|
55
|
+
|
|
56
|
+
`windowSeconds` is the one thing a digest cannot read off the rule. Where a
|
|
57
|
+
window starts and ends is the gathering component's own, deliberately: aligning
|
|
58
|
+
to a local midnight is a decision this table has no input for. */
|
|
59
|
+
@schema
|
|
60
|
+
type delivery =
|
|
61
|
+
| Immediate
|
|
62
|
+
| Digest({windowSeconds: int})
|
|
63
|
+
|
|
64
|
+
@schema
|
|
65
|
+
type t = {
|
|
66
|
+
/** Stable, because it is also the namespace of every reference this rule's
|
|
67
|
+
requests carry — renaming it re-keys their delivery rows. */
|
|
68
|
+
id: string,
|
|
69
|
+
/** Which version of this rule wrote a message, for the request to record. */
|
|
70
|
+
version: string,
|
|
71
|
+
source: source,
|
|
72
|
+
filter: predicate,
|
|
73
|
+
/** The host's kind of notification, as a key — see `Notification_Rules.category`. */
|
|
74
|
+
category: string,
|
|
75
|
+
delivery: delivery,
|
|
76
|
+
/** Where in the payload the person to notify is named. */
|
|
77
|
+
recipientPath: string,
|
|
78
|
+
/** What the notification is about: the component's name as the deployment
|
|
79
|
+
registers it, and where that row's own id sits in the payload. */
|
|
80
|
+
subjectType: string,
|
|
81
|
+
subjectPath: string,
|
|
82
|
+
content: array<content>,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The stream this rule answers to, in the form a claim is made against. */
|
|
86
|
+
let sourceId = (rule: t) => `${rule.source.log}:${rule.source.eventType}`
|
|
87
|
+
|
|
88
|
+
/** The correlation key a request carries. One per rule per subject, so an order
|
|
89
|
+
that is placed and then ships is two notifications and two delivery rows. */
|
|
90
|
+
let referenceFor = (~ruleId: string, ~subject: string) => `${ruleId}:${subject}`
|
|
91
|
+
|
|
92
|
+
let reference = (rule: t, ~subject: string) => referenceFor(~ruleId=rule.id, ~subject)
|
|
93
|
+
|
|
94
|
+
let byId = (rules: array<t>, id: string) => rules->Array.find(rule => rule.id == id)
|
|
95
|
+
|
|
96
|
+
/** Whether the per-event relay is the one that delivers this rule. A digest's
|
|
97
|
+
occurrences are gathered by its own component, so the relay passes over them
|
|
98
|
+
rather than sending one message each. */
|
|
99
|
+
let isImmediate = (rule: t) => rule.delivery == Immediate
|
|
100
|
+
|
|
101
|
+
/** Every rule answering to one occurrence. Two rules on one event type are two
|
|
102
|
+
notifications, which is what makes adding one a table change and not a code
|
|
103
|
+
change. */
|
|
104
|
+
let forEvent = (rules: array<t>, ~log: string, ~eventType: string) =>
|
|
105
|
+
rules->Array.filter(rule => rule.source.log == log && rule.source.eventType == eventType)
|
|
106
|
+
|
|
107
|
+
/** A mismatch of kinds is `false`, `Ne` included: a filter that cannot be read
|
|
108
|
+
against this payload does not fire. */
|
|
109
|
+
let compare = (actual: JSON.t, op: comparison, expected: literal): bool => {
|
|
110
|
+
let ordered = (a, b) =>
|
|
111
|
+
switch op {
|
|
112
|
+
| Eq => a == b
|
|
113
|
+
| Ne => a != b
|
|
114
|
+
| Lt => a < b
|
|
115
|
+
| Lte => a <= b
|
|
116
|
+
| Gt => a > b
|
|
117
|
+
| Gte => a >= b
|
|
118
|
+
| Contains => false
|
|
119
|
+
}
|
|
120
|
+
switch (actual, expected) {
|
|
121
|
+
| (String(a), Text(b)) => op == Contains ? a->String.includes(b) : ordered(a, b)
|
|
122
|
+
| (Number(a), Number(b)) => ordered(a, b)
|
|
123
|
+
| (Boolean(a), Flag(b)) =>
|
|
124
|
+
switch op {
|
|
125
|
+
| Eq => a == b
|
|
126
|
+
| Ne => a != b
|
|
127
|
+
| _ => false
|
|
128
|
+
}
|
|
129
|
+
| (Array(items), Text(b)) =>
|
|
130
|
+
op == Contains &&
|
|
131
|
+
items->Array.some(item =>
|
|
132
|
+
switch item {
|
|
133
|
+
| String(text) => text == b
|
|
134
|
+
| _ => false
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
| _ => false
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let rec matches = (predicate: predicate, ~payload: JSON.t): bool =>
|
|
142
|
+
switch predicate {
|
|
143
|
+
| Always => true
|
|
144
|
+
| Compare({path, op, value}) =>
|
|
145
|
+
switch Reventless.Template.lookup(payload, path) {
|
|
146
|
+
| Some(actual) => compare(actual, op, value)
|
|
147
|
+
| None => false
|
|
148
|
+
}
|
|
149
|
+
| All(parts) => parts->Array.every(part => matches(part, ~payload))
|
|
150
|
+
| Any(parts) => parts->Array.some(part => matches(part, ~payload))
|
|
151
|
+
| Not(part) => !matches(part, ~payload)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let stringAt = (payload: JSON.t, path: string) =>
|
|
155
|
+
switch Reventless.Template.lookup(payload, path) {
|
|
156
|
+
| Some(String(text)) => Some(text)
|
|
157
|
+
| _ => None
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Who to notify. `None` is a rule whose path does not name a person in this
|
|
161
|
+
payload, which is a rule that cannot be acted on rather than one that sends
|
|
162
|
+
to nobody. */
|
|
163
|
+
let recipientOf = (rule: t, ~payload: JSON.t) => stringAt(payload, rule.recipientPath)
|
|
164
|
+
|
|
165
|
+
/** What it is about. Empty is legal — a notification about nothing in particular
|
|
166
|
+
is a real case, and a fabricated subject is worse than an absent one. */
|
|
167
|
+
let subjectOf = (rule: t, ~payload: JSON.t) => stringAt(payload, rule.subjectPath)->Option.getOr("")
|
|
168
|
+
|
|
169
|
+
/** The wording to use, the asked-for locale if the rule has it and the first
|
|
170
|
+
otherwise. Which locale to ask for is the caller's — nothing here knows who
|
|
171
|
+
is being written to. */
|
|
172
|
+
let contentFor = (rule: t, ~locale: option<string>=?) =>
|
|
173
|
+
switch locale {
|
|
174
|
+
| Some(wanted) =>
|
|
175
|
+
switch rule.content->Array.find(entry => entry.locale == wanted) {
|
|
176
|
+
| Some(_) as found => found
|
|
177
|
+
| None => rule.content->Array.get(0)
|
|
178
|
+
}
|
|
179
|
+
| None => rule.content->Array.get(0)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// A template that does not parse falls back to its own source: the renderer's
|
|
183
|
+
// posture is that a fault is visible rather than silent, and `validate` is what
|
|
184
|
+
// keeps a compiled table from reaching this.
|
|
185
|
+
let render = (source: string, ~payload: JSON.t, ~schema: S.t<'a>) =>
|
|
186
|
+
Reventless.Template.renderSource(source, ~payload, ~schema)->Result.getOr(source)
|
|
187
|
+
|
|
188
|
+
/** The rendered `(subject, body)`. */
|
|
189
|
+
let compose = (rule: t, ~payload: JSON.t, ~schema: S.t<'a>, ~locale: option<string>=?) =>
|
|
190
|
+
switch contentFor(rule, ~locale?) {
|
|
191
|
+
| None => ("", "")
|
|
192
|
+
| Some({subject, body}) => (render(subject, ~payload, ~schema), render(body, ~payload, ~schema))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
The problems in a table, empty when it is sound.
|
|
197
|
+
|
|
198
|
+
What a compiled table's own test asserts, so `compose`'s fallbacks and
|
|
199
|
+
`recipientOf`'s `None` stay unreachable in a build rather than merely unlikely.
|
|
200
|
+
`sample` is one payload of the shape the table's rules read.
|
|
201
|
+
|
|
202
|
+
`~digestRouted` says whether this deployment has a component that gathers
|
|
203
|
+
digests. It defaults to `false` because most do not, and there a `Digest` rule is
|
|
204
|
+
a rule whose occurrences the per-event relay passes over and nobody else picks
|
|
205
|
+
up — silence, which is the failure this competency is careful about everywhere
|
|
206
|
+
else.
|
|
207
|
+
*/
|
|
208
|
+
let validate = (rules: array<t>, ~digestRouted: bool=false, ~sample: JSON.t): array<string> =>
|
|
209
|
+
rules->Array.flatMap(rule => {
|
|
210
|
+
let problems = []
|
|
211
|
+
let note = message => problems->Array.push(`${rule.id}: ${message}`)
|
|
212
|
+
if rule.id == "" {
|
|
213
|
+
note("a rule needs an id — it is the namespace of every reference it writes")
|
|
214
|
+
}
|
|
215
|
+
if Array.length(rule.content) == 0 {
|
|
216
|
+
note("no wording at all")
|
|
217
|
+
}
|
|
218
|
+
rule.content->Array.forEach(({locale, subject, body}) =>
|
|
219
|
+
[("subject", subject), ("body", body)]->Array.forEach(
|
|
220
|
+
((which, template)) =>
|
|
221
|
+
switch Reventless.Template.parse(template) {
|
|
222
|
+
| Ok(_) => ()
|
|
223
|
+
| Error(why) => note(`${locale} ${which} does not parse — ${why}`)
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
if recipientOf(rule, ~payload=sample) == None {
|
|
228
|
+
note(`recipientPath "${rule.recipientPath}" names nobody in the sample payload`)
|
|
229
|
+
}
|
|
230
|
+
if stringAt(sample, rule.subjectPath) == None {
|
|
231
|
+
note(`subjectPath "${rule.subjectPath}" resolves to nothing in the sample payload`)
|
|
232
|
+
}
|
|
233
|
+
switch rule.delivery {
|
|
234
|
+
| Immediate => ()
|
|
235
|
+
| Digest({windowSeconds}) =>
|
|
236
|
+
if !digestRouted {
|
|
237
|
+
note("delivered as a digest, and nothing in this deployment gathers one")
|
|
238
|
+
}
|
|
239
|
+
if windowSeconds <= 0 {
|
|
240
|
+
note(`a digest window of ${windowSeconds->Int.toString}s gathers nothing`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
problems
|
|
244
|
+
})
|