@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.
@@ -0,0 +1,348 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
6
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
7
+ import * as Template$Reventless from "@reventlessdev/reventless-spec/src/semantic/Template.res.mjs";
8
+
9
+ let comparisonSchema = Sury.union([
10
+ Sury.literal("Eq"),
11
+ Sury.literal("Ne"),
12
+ Sury.literal("Lt"),
13
+ Sury.literal("Lte"),
14
+ Sury.literal("Gt"),
15
+ Sury.literal("Gte"),
16
+ Sury.literal("Contains")
17
+ ]);
18
+
19
+ let literalSchema = Sury.union([
20
+ Sury.$schema(s => ({
21
+ TAG: "Text",
22
+ _0: s.m(Sury.string)
23
+ })),
24
+ Sury.$schema(s => ({
25
+ TAG: "Number",
26
+ _0: s.m(Sury.float)
27
+ })),
28
+ Sury.$schema(s => ({
29
+ TAG: "Flag",
30
+ _0: s.m(Sury.bool)
31
+ }))
32
+ ]);
33
+
34
+ let predicateSchema = Sury.recursive("predicate", predicateSchema => Sury.union([
35
+ Sury.literal("Always"),
36
+ Sury.$schema(s => ({
37
+ TAG: "Compare",
38
+ path: s.m(Sury.string),
39
+ op: s.m(comparisonSchema),
40
+ value: s.m(literalSchema)
41
+ })),
42
+ Sury.$schema(s => ({
43
+ TAG: "All",
44
+ _0: s.m(Sury.array(predicateSchema))
45
+ })),
46
+ Sury.$schema(s => ({
47
+ TAG: "Any",
48
+ _0: s.m(Sury.array(predicateSchema))
49
+ })),
50
+ Sury.$schema(s => ({
51
+ TAG: "Not",
52
+ _0: s.m(predicateSchema)
53
+ }))
54
+ ]));
55
+
56
+ let contentSchema = Sury.$schema(s => ({
57
+ locale: s.m(Sury.string),
58
+ subject: s.m(Sury.string),
59
+ body: s.m(Sury.string)
60
+ }));
61
+
62
+ let sourceSchema = Sury.$schema(s => ({
63
+ log: s.m(Sury.string),
64
+ eventType: s.m(Sury.string)
65
+ }));
66
+
67
+ let deliverySchema = Sury.union([
68
+ Sury.literal("Immediate"),
69
+ Sury.$schema(s => ({
70
+ TAG: "Digest",
71
+ windowSeconds: s.m(Sury.int)
72
+ }))
73
+ ]);
74
+
75
+ let schema = Sury.$schema(s => ({
76
+ id: s.m(Sury.string),
77
+ version: s.m(Sury.string),
78
+ source: s.m(sourceSchema),
79
+ filter: s.m(predicateSchema),
80
+ category: s.m(Sury.string),
81
+ delivery: s.m(deliverySchema),
82
+ recipientPath: s.m(Sury.string),
83
+ subjectType: s.m(Sury.string),
84
+ subjectPath: s.m(Sury.string),
85
+ content: s.m(Sury.array(contentSchema))
86
+ }));
87
+
88
+ function sourceId(rule) {
89
+ return rule.source.log + `:` + rule.source.eventType;
90
+ }
91
+
92
+ function referenceFor(ruleId, subject) {
93
+ return ruleId + `:` + subject;
94
+ }
95
+
96
+ function reference(rule, subject) {
97
+ return referenceFor(rule.id, subject);
98
+ }
99
+
100
+ function byId(rules, id) {
101
+ return rules.find(rule => rule.id === id);
102
+ }
103
+
104
+ function isImmediate(rule) {
105
+ return rule.delivery === "Immediate";
106
+ }
107
+
108
+ function forEvent(rules, log, eventType) {
109
+ return rules.filter(rule => {
110
+ if (rule.source.log === log) {
111
+ return rule.source.eventType === eventType;
112
+ } else {
113
+ return false;
114
+ }
115
+ });
116
+ }
117
+
118
+ function compare(actual, op, expected) {
119
+ let ordered = (a, b) => {
120
+ switch (op) {
121
+ case "Eq" :
122
+ return Primitive_object.equal(a, b);
123
+ case "Ne" :
124
+ return Primitive_object.notequal(a, b);
125
+ case "Lt" :
126
+ return Primitive_object.lessthan(a, b);
127
+ case "Lte" :
128
+ return Primitive_object.lessequal(a, b);
129
+ case "Gt" :
130
+ return Primitive_object.greaterthan(a, b);
131
+ case "Gte" :
132
+ return Primitive_object.greaterequal(a, b);
133
+ case "Contains" :
134
+ return false;
135
+ }
136
+ };
137
+ if (actual === null) {
138
+ return false;
139
+ }
140
+ if (Array.isArray(actual)) {
141
+ switch (expected.TAG) {
142
+ case "Text" :
143
+ let b = expected._0;
144
+ if (op === "Contains") {
145
+ return actual.some(item => {
146
+ if (typeof item === "string") {
147
+ return item === b;
148
+ } else {
149
+ return false;
150
+ }
151
+ });
152
+ } else {
153
+ return false;
154
+ }
155
+ case "Number" :
156
+ case "Flag" :
157
+ return false;
158
+ }
159
+ } else {
160
+ switch (typeof actual) {
161
+ case "boolean" :
162
+ switch (expected.TAG) {
163
+ case "Text" :
164
+ case "Number" :
165
+ return false;
166
+ case "Flag" :
167
+ let b$1 = expected._0;
168
+ switch (op) {
169
+ case "Eq" :
170
+ return actual === b$1;
171
+ case "Ne" :
172
+ return actual !== b$1;
173
+ default:
174
+ return false;
175
+ }
176
+ }
177
+ case "string" :
178
+ switch (expected.TAG) {
179
+ case "Text" :
180
+ let b$2 = expected._0;
181
+ if (op === "Contains") {
182
+ return actual.includes(b$2);
183
+ } else {
184
+ return ordered(actual, b$2);
185
+ }
186
+ case "Number" :
187
+ case "Flag" :
188
+ return false;
189
+ }
190
+ case "number" :
191
+ switch (expected.TAG) {
192
+ case "Number" :
193
+ return ordered(actual, expected._0);
194
+ case "Text" :
195
+ case "Flag" :
196
+ return false;
197
+ }
198
+ case "object" :
199
+ return false;
200
+ }
201
+ }
202
+ }
203
+
204
+ function matches(predicate, payload) {
205
+ if (typeof predicate !== "object") {
206
+ return true;
207
+ }
208
+ switch (predicate.TAG) {
209
+ case "Compare" :
210
+ let actual = Template$Reventless.lookup(payload, predicate.path);
211
+ if (actual !== undefined) {
212
+ return compare(actual, predicate.op, predicate.value);
213
+ } else {
214
+ return false;
215
+ }
216
+ case "All" :
217
+ return predicate._0.every(part => matches(part, payload));
218
+ case "Any" :
219
+ return predicate._0.some(part => matches(part, payload));
220
+ case "Not" :
221
+ return !matches(predicate._0, payload);
222
+ }
223
+ }
224
+
225
+ function stringAt(payload, path) {
226
+ let match = Template$Reventless.lookup(payload, path);
227
+ if (typeof match === "string") {
228
+ return match;
229
+ }
230
+ }
231
+
232
+ function recipientOf(rule, payload) {
233
+ return stringAt(payload, rule.recipientPath);
234
+ }
235
+
236
+ function subjectOf(rule, payload) {
237
+ return Stdlib_Option.getOr(stringAt(payload, rule.subjectPath), "");
238
+ }
239
+
240
+ function contentFor(rule, locale) {
241
+ if (locale === undefined) {
242
+ return rule.content[0];
243
+ }
244
+ let found = rule.content.find(entry => entry.locale === locale);
245
+ if (found !== undefined) {
246
+ return found;
247
+ } else {
248
+ return rule.content[0];
249
+ }
250
+ }
251
+
252
+ function render(source, payload, schema) {
253
+ return Stdlib_Result.getOr(Template$Reventless.renderSource(source, payload, schema), source);
254
+ }
255
+
256
+ function compose(rule, payload, schema, locale) {
257
+ let match = contentFor(rule, locale);
258
+ if (match !== undefined) {
259
+ return [
260
+ render(match.subject, payload, schema),
261
+ render(match.body, payload, schema)
262
+ ];
263
+ } else {
264
+ return [
265
+ "",
266
+ ""
267
+ ];
268
+ }
269
+ }
270
+
271
+ function validate(rules, digestRoutedOpt, sample) {
272
+ let digestRouted = digestRoutedOpt !== undefined ? digestRoutedOpt : false;
273
+ return rules.flatMap(rule => {
274
+ let problems = [];
275
+ let note = message => {
276
+ problems.push(rule.id + `: ` + message);
277
+ };
278
+ if (rule.id === "") {
279
+ note("a rule needs an id — it is the namespace of every reference it writes");
280
+ }
281
+ if (rule.content.length === 0) {
282
+ note("no wording at all");
283
+ }
284
+ rule.content.forEach(param => {
285
+ let locale = param.locale;
286
+ [
287
+ [
288
+ "subject",
289
+ param.subject
290
+ ],
291
+ [
292
+ "body",
293
+ param.body
294
+ ]
295
+ ].forEach(param => {
296
+ let why = Template$Reventless.parse(param[1]);
297
+ if (why.TAG === "Ok") {
298
+ return;
299
+ } else {
300
+ return note(locale + ` ` + param[0] + ` does not parse — ` + why._0);
301
+ }
302
+ });
303
+ });
304
+ if (stringAt(sample, rule.recipientPath) === undefined) {
305
+ note(`recipientPath "` + rule.recipientPath + `" names nobody in the sample payload`);
306
+ }
307
+ if (stringAt(sample, rule.subjectPath) === undefined) {
308
+ note(`subjectPath "` + rule.subjectPath + `" resolves to nothing in the sample payload`);
309
+ }
310
+ let match = rule.delivery;
311
+ if (typeof match === "object") {
312
+ let windowSeconds = match.windowSeconds;
313
+ if (!digestRouted) {
314
+ note("delivered as a digest, and nothing in this deployment gathers one");
315
+ }
316
+ if (windowSeconds <= 0) {
317
+ note(`a digest window of ` + windowSeconds.toString() + `s gathers nothing`);
318
+ }
319
+ }
320
+ return problems;
321
+ });
322
+ }
323
+
324
+ export {
325
+ comparisonSchema,
326
+ literalSchema,
327
+ predicateSchema,
328
+ contentSchema,
329
+ sourceSchema,
330
+ deliverySchema,
331
+ schema,
332
+ sourceId,
333
+ referenceFor,
334
+ reference,
335
+ byId,
336
+ isImmediate,
337
+ forEvent,
338
+ compare,
339
+ matches,
340
+ stringAt,
341
+ recipientOf,
342
+ subjectOf,
343
+ contentFor,
344
+ render,
345
+ compose,
346
+ validate,
347
+ }
348
+ /* comparisonSchema Not a pure module */
@@ -17,7 +17,6 @@ is the opposite. So the fallback *rule* lives here — an absent choice defers t
17
17
  the posture — and the *table* is passed in as `~posture`. A trait that hard-coded
18
18
  either answer would be wrong for half its hosts.
19
19
  */
20
-
21
20
  /** How a recipient is reached. Mirrors `Reventless.Messaging.channel`, which is
22
21
  the platform capability's vocabulary; this one is the domain's, so it carries
23
22
  a host's schema and travels on the wire. `Notification_Send` maps the two. */
@@ -35,6 +34,21 @@ let channels = [Email, Sms, Push]
35
34
  vocabulary — this module never enumerates them. */
36
35
  type category = string
37
36
 
37
+ /** Which stream of occurrences a request came from, as `"<log>:<eventType>"`.
38
+ Opaque here: the competency never parses it, it only compares it against what
39
+ has been claimed. */
40
+ type source = string
41
+
42
+ /** Why a request exists. The compiled table that ships with a host, or a rule
43
+ somebody configured on top of it.
44
+
45
+ No payload on `Configured`: the trait needs exactly one thing from this — which
46
+ of the two yields when a source has been taken over — and *which* rule it was
47
+ is the host's fact to record on its own event. */
48
+ type origin =
49
+ | Default
50
+ | Configured
51
+
38
52
  /** Refolded per decision, never stored — the host's slice state is. */
39
53
  type t = {
40
54
  /** One address per channel. An array rather than a single address because the
@@ -44,39 +58,66 @@ type t = {
44
58
  contacts: array<(channel, string)>,
45
59
  /** Only the cells a recipient has an opinion about; the rest is `~posture`. */
46
60
  choices: array<(category, channel, bool)>,
61
+ /** Sources a second producer has taken over, and who took each one. Empty for
62
+ every host with one producer, which is what makes the deferral below inert
63
+ until something actually claims a source. */
64
+ claims: array<(source, string)>,
47
65
  }
48
66
 
49
- let empty = {contacts: [], choices: []}
67
+ let empty = {contacts: [], choices: [], claims: []}
68
+
69
+ let claimedBy = (t, source) =>
70
+ t.claims->Array.find(((s, _)) => s == source)->Option.map(((_, by)) => by)
50
71
 
51
72
  /** What a host asks the competency to do. */
52
73
  type op =
53
- | /** Relayed from whatever the host publishes when it learns where somebody is.
74
+ /** Relayed from whatever the host publishes when it learns where somebody is.
54
75
  Not a client's command: a caller who could announce another person's
55
76
  address would be redirecting their mail. */
56
- Announce({channel: channel, address: string})
77
+ | Announce({channel: channel, address: string})
57
78
  | Subscribe({category: category, channel: channel})
58
79
  | Unsubscribe({category: category, channel: channel})
59
- | /** Something worth telling them about happened. `reference` is the caller's
80
+ /** Something worth telling them about happened. `reference` is the caller's
60
81
  own key for it, echoed back on whichever outcome follows, so the relay
61
- that asked can tell its work is finished. Opaque here on purpose. */
62
- Request({category: category, reference: string})
82
+ that asked can tell its work is finished. Opaque here on purpose.
83
+
84
+ `source` says which stream it came from and `origin` says who is asking;
85
+ together they are the whole of the handover — see `decide`. */
86
+ | Request({category: category, reference: string, source: source, origin: origin})
87
+ /** A second producer takes over one source. Relayed like `Announce`: a
88
+ client that could claim a source would be silencing somebody else's mail. */
89
+ | Claim({source: source, by: string})
90
+ | Release({source: source})
63
91
 
64
92
  /** What the competency decided, for the host to name in its own events. */
65
93
  type fact =
66
94
  | Announced({channel: channel, address: string})
67
95
  | Subscribed({category: category, channel: channel})
68
96
  | Unsubscribed({category: category, channel: channel})
69
- | /** The addressed message. `address` is the snapshot delivery uses, which is
97
+ /** The addressed message. `address` is the snapshot delivery uses, which is
70
98
  why it is on the fact and not looked up again later. */
71
- Requested({category: category, reference: string, channel: channel, address: string})
72
- | /** They are reachable and said no. */
73
- Suppressed({category: category, reference: string})
74
- | /** They want it and nobody can be reached — no address on file for any
99
+ | Requested({category: category, reference: string, channel: channel, address: string})
100
+ /** They are reachable and said no. */
101
+ | Suppressed({category: category, reference: string})
102
+ /** They want it and nobody can be reached — no address on file for any
75
103
  channel they left enabled. A different fact from `Suppressed` on purpose:
76
104
  one is the system working and the other is the system falling short, and
77
105
  reporting both the same way hides every delivery gap behind a legitimate
78
106
  preference. */
79
- Undeliverable({category: category, reference: string})
107
+ | Undeliverable({category: category, reference: string})
108
+ /** Not acted on because another producer owns this source. A third way of
109
+ not sending, and its own fact for the same reason `Suppressed` and
110
+ `Undeliverable` are two rather than one: "they declined", "nobody was
111
+ reachable" and "somebody else has this" are three different things, and a
112
+ shared fact would hide two of them behind whichever name it took.
113
+
114
+ It is also load-bearing rather than tidy. The relay upstream resolves its
115
+ TODO row on an outcome; without a fourth one a deferred request retries
116
+ its whole budget and lands in `onExhausted`, which is a slow silent
117
+ failure with nothing in the log to explain it. */
118
+ | Deferred({reference: string, source: source})
119
+ | Claimed({source: source, by: string})
120
+ | Released({source: source})
80
121
 
81
122
  let addressFor = (t, channel) =>
82
123
  t.contacts->Array.find(((c, _)) => c == channel)->Option.map(((_, address)) => address)
@@ -108,13 +149,22 @@ let evolve = (t, fact) =>
108
149
  }
109
150
  | Subscribed({category, channel}) => withChoice(t, category, channel, true)
110
151
  | Unsubscribed({category, channel}) => withChoice(t, category, channel, false)
152
+ // Claims are per source and not per recipient, so they arrive from outside this
153
+ // recipient's own history — the host reads them across partitions, which is why
154
+ // the request carries the source it came from.
155
+ | Claimed({source, by}) => {
156
+ ...t,
157
+ claims: Array.concat(t.claims->Array.filter(((s, _)) => s != source), [(source, by)]),
158
+ }
159
+ | Released({source}) => {...t, claims: t.claims->Array.filter(((s, _)) => s != source)}
111
160
  // Neither the outcome of a request nor a delivery report changes who somebody
112
161
  // is or what they want, so the fold ignores them. What a host must NOT do is
113
162
  // read them back as consumed events to make this state bigger — the directory
114
163
  // is snapshotted, and a set of every reference ever seen would grow forever.
115
164
  | Requested(_)
116
165
  | Suppressed(_)
117
- | Undeliverable(_) => t
166
+ | Undeliverable(_)
167
+ | Deferred(_) => t
118
168
  }
119
169
 
120
170
  /** The whole competency, in one function.
@@ -150,17 +200,36 @@ let decide = (t, op, ~posture: (category, channel) => bool): result<
150
200
  ? Ok([Unsubscribed({category, channel})])
151
201
  : Ok([])
152
202
 
203
+ | Claim({source: src, by}) =>
204
+ claimedBy(t, src) == Some(by) ? Ok([]) : Ok([Claimed({source: src, by})])
205
+
206
+ | Release({source: src}) =>
207
+ switch claimedBy(t, src) {
208
+ | None => Ok([])
209
+ | Some(_) => Ok([Released({source: src})])
210
+ }
211
+
212
+ // The handover, and the whole of it. A `Configured` request is the second
213
+ // producer's own and always goes through; a `Default` one is the compiled
214
+ // table still firing for a source somebody else has taken over, and it yields.
215
+ //
216
+ // Precedence has to be per source rather than per deployment, which is why this
217
+ // is folded state and not a swapped-in implementation: a second producer that
218
+ // takes over some of the table and leaves the rest compiled cannot be expressed
219
+ // by choosing one of two implementations at build time.
220
+ | Request({source, origin: Default, reference}) if claimedBy(t, source) != None =>
221
+ Ok([Deferred({reference, source})])
222
+
153
223
  | Request({category, reference}) =>
154
224
  let wanted = channels->Array.filter(channel => enabled(t, ~posture, category, channel))
155
- let addressed =
156
- wanted->Array.filterMap(channel =>
157
- addressFor(t, channel)->Option.map(address => Requested({
158
- category,
159
- reference,
160
- channel,
161
- address,
162
- }))
163
- )
225
+ let addressed = wanted->Array.filterMap(channel =>
226
+ addressFor(t, channel)->Option.map(address => Requested({
227
+ category,
228
+ reference,
229
+ channel,
230
+ address,
231
+ }))
232
+ )
164
233
  switch addressed {
165
234
  | [] =>
166
235
  // Wanted-but-unreachable and not-wanted are the two ways to send nothing,
@@ -14,11 +14,18 @@ let empty_contacts = [];
14
14
 
15
15
  let empty_choices = [];
16
16
 
17
+ let empty_claims = [];
18
+
17
19
  let empty = {
18
20
  contacts: empty_contacts,
19
- choices: empty_choices
21
+ choices: empty_choices,
22
+ claims: empty_claims
20
23
  };
21
24
 
25
+ function claimedBy(t, source) {
26
+ return Stdlib_Option.map(t.claims.find(param => param[0] === source), param => param[1]);
27
+ }
28
+
22
29
  function addressFor(t, channel) {
23
30
  return Stdlib_Option.map(t.contacts.find(param => param[0] === channel), param => param[1]);
24
31
  }
@@ -45,7 +52,8 @@ function withChoice(t, category, channel, isEnabled) {
45
52
  category,
46
53
  channel,
47
54
  isEnabled
48
- ]])
55
+ ]]),
56
+ claims: t.claims
49
57
  };
50
58
  }
51
59
 
@@ -58,12 +66,30 @@ function evolve(t, fact) {
58
66
  channel,
59
67
  fact.address
60
68
  ]]),
61
- choices: t.choices
69
+ choices: t.choices,
70
+ claims: t.claims
62
71
  };
63
72
  case "Subscribed" :
64
73
  return withChoice(t, fact.category, fact.channel, true);
65
74
  case "Unsubscribed" :
66
75
  return withChoice(t, fact.category, fact.channel, false);
76
+ case "Claimed" :
77
+ let source = fact.source;
78
+ return {
79
+ contacts: t.contacts,
80
+ choices: t.choices,
81
+ claims: t.claims.filter(param => param[0] !== source).concat([[
82
+ source,
83
+ fact.by
84
+ ]])
85
+ };
86
+ case "Released" :
87
+ let source$1 = fact.source;
88
+ return {
89
+ contacts: t.contacts,
90
+ choices: t.choices,
91
+ claims: t.claims.filter(param => param[0] !== source$1)
92
+ };
67
93
  default:
68
94
  return t;
69
95
  }
@@ -138,8 +164,19 @@ function decide(t, op, posture) {
138
164
  };
139
165
  }
140
166
  case "Request" :
167
+ let source = op.source;
141
168
  let reference = op.reference;
142
169
  let category$2 = op.category;
170
+ if (op.origin === "Default" && claimedBy(t, source) !== undefined) {
171
+ return {
172
+ TAG: "Ok",
173
+ _0: [{
174
+ TAG: "Deferred",
175
+ reference: reference,
176
+ source: source
177
+ }]
178
+ };
179
+ }
143
180
  let wanted = channels.filter(channel => enabled(t, posture, category$2, channel));
144
181
  let addressed = Stdlib_Array.filterMap(wanted, channel => Stdlib_Option.map(addressFor(t, channel), address => ({
145
182
  TAG: "Requested",
@@ -172,12 +209,48 @@ function decide(t, op, posture) {
172
209
  }]
173
210
  };
174
211
  }
212
+ case "Claim" :
213
+ let by = op.by;
214
+ let src = op.source;
215
+ if (Primitive_object.equal(claimedBy(t, src), by)) {
216
+ return {
217
+ TAG: "Ok",
218
+ _0: []
219
+ };
220
+ } else {
221
+ return {
222
+ TAG: "Ok",
223
+ _0: [{
224
+ TAG: "Claimed",
225
+ source: src,
226
+ by: by
227
+ }]
228
+ };
229
+ }
230
+ case "Release" :
231
+ let src$1 = op.source;
232
+ let match = claimedBy(t, src$1);
233
+ if (match !== undefined) {
234
+ return {
235
+ TAG: "Ok",
236
+ _0: [{
237
+ TAG: "Released",
238
+ source: src$1
239
+ }]
240
+ };
241
+ } else {
242
+ return {
243
+ TAG: "Ok",
244
+ _0: []
245
+ };
246
+ }
175
247
  }
176
248
  }
177
249
 
178
250
  export {
179
251
  channels,
180
252
  empty,
253
+ claimedBy,
181
254
  addressFor,
182
255
  enabled,
183
256
  withChoice,