@reventlessdev/trait-notification 1.0.0-alpha.3 → 1.0.0-alpha.5

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.3",
3
+ "version": "1.0.0-alpha.5",
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.201",
17
- "@reventlessdev/reventless-spec": "3.0.0-alpha.127"
16
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.203",
17
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.129"
18
18
  },
19
19
  "devDependencies": {
20
20
  "rescript": "12.3.0"
@@ -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
+ })