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