@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/trait-notification",
3
- "version": "1.0.0-alpha.1",
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.199",
17
- "@reventlessdev/reventless-spec": "3.0.0-alpha.125"
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"
@@ -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
- /** History that brings the recipient into existence with nothing on file. */
71
- let created: array<Spec.consumedEvent>
72
-
73
- /** The competency's own facts, as the slice consumes them. */
74
- let announcedC: string => Spec.consumedEvent
75
- let subscribedC: (category, Notification_Rules.channel) => Spec.consumedEvent
76
- let unsubscribedC: (category, Notification_Rules.channel) => Spec.consumedEvent
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.announcedC(B.addressA)])
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)->G.whenCmd(B.announce(B.addressA))->G.thenEvent(
26
- B.announced(B.addressA),
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)->G.whenCmd(B.announce(B.addressB))->G.thenEvent(
40
- B.announced(B.addressB),
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.announcedC(B.addressB)]))
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.unsubscribedC(B.transactional, B.announcedChannel)]),
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.subscribedC(B.transactional, channel),
128
- B.unsubscribedC(B.transactional, B.announcedChannel),
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.announcedC(B.addressA)]);
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.announcedC(B.addressB)])), B.request(B.transactional, "ref-1")), B.requested(B.transactional, "ref-1", B.announcedChannel, B.addressB)));
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.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")));
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.subscribedC(B.transactional, channel),
35
- B.unsubscribedC(B.transactional, B.announcedChannel)
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
+ })