@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.
@@ -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 */
@@ -35,6 +35,21 @@ let channels = [Email, Sms, Push]
35
35
  vocabulary — this module never enumerates them. */
36
36
  type category = string
37
37
 
38
+ /** Which stream of occurrences a request came from, as `"<log>:<eventType>"`.
39
+ Opaque here: the competency never parses it, it only compares it against what
40
+ has been claimed. */
41
+ type source = string
42
+
43
+ /** Why a request exists. The compiled table that ships with a host, or a rule
44
+ somebody configured on top of it.
45
+
46
+ No payload on `Configured`: the trait needs exactly one thing from this — which
47
+ of the two yields when a source has been taken over — and *which* rule it was
48
+ is the host's fact to record on its own event. */
49
+ type origin =
50
+ | Default
51
+ | Configured
52
+
38
53
  /** Refolded per decision, never stored — the host's slice state is. */
39
54
  type t = {
40
55
  /** One address per channel. An array rather than a single address because the
@@ -44,9 +59,16 @@ type t = {
44
59
  contacts: array<(channel, string)>,
45
60
  /** Only the cells a recipient has an opinion about; the rest is `~posture`. */
46
61
  choices: array<(category, channel, bool)>,
62
+ /** Sources a second producer has taken over, and who took each one. Empty for
63
+ every host with one producer, which is what makes the deferral below inert
64
+ until something actually claims a source. */
65
+ claims: array<(source, string)>,
47
66
  }
48
67
 
49
- let empty = {contacts: [], choices: []}
68
+ let empty = {contacts: [], choices: [], claims: []}
69
+
70
+ let claimedBy = (t, source) =>
71
+ t.claims->Array.find(((s, _)) => s == source)->Option.map(((_, by)) => by)
50
72
 
51
73
  /** What a host asks the competency to do. */
52
74
  type op =
@@ -58,8 +80,15 @@ type op =
58
80
  | Unsubscribe({category: category, channel: channel})
59
81
  | /** Something worth telling them about happened. `reference` is the caller's
60
82
  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})
83
+ that asked can tell its work is finished. Opaque here on purpose.
84
+
85
+ `source` says which stream it came from and `origin` says who is asking;
86
+ together they are the whole of the handover — see `decide`. */
87
+ Request({category: category, reference: string, source: source, origin: origin})
88
+ | /** A second producer takes over one source. Relayed like `Announce`: a
89
+ client that could claim a source would be silencing somebody else's mail. */
90
+ Claim({source: source, by: string})
91
+ | Release({source: source})
63
92
 
64
93
  /** What the competency decided, for the host to name in its own events. */
65
94
  type fact =
@@ -77,6 +106,19 @@ type fact =
77
106
  reporting both the same way hides every delivery gap behind a legitimate
78
107
  preference. */
79
108
  Undeliverable({category: category, reference: string})
109
+ | /** Not acted on because another producer owns this source. A third way of
110
+ not sending, and its own fact for the same reason `Suppressed` and
111
+ `Undeliverable` are two rather than one: "they declined", "nobody was
112
+ reachable" and "somebody else has this" are three different things, and a
113
+ shared fact would hide two of them behind whichever name it took.
114
+
115
+ It is also load-bearing rather than tidy. The relay upstream resolves its
116
+ TODO row on an outcome; without a fourth one a deferred request retries
117
+ its whole budget and lands in `onExhausted`, which is a slow silent
118
+ failure with nothing in the log to explain it. */
119
+ Deferred({reference: string, source: source})
120
+ | Claimed({source: source, by: string})
121
+ | Released({source: source})
80
122
 
81
123
  let addressFor = (t, channel) =>
82
124
  t.contacts->Array.find(((c, _)) => c == channel)->Option.map(((_, address)) => address)
@@ -108,13 +150,22 @@ let evolve = (t, fact) =>
108
150
  }
109
151
  | Subscribed({category, channel}) => withChoice(t, category, channel, true)
110
152
  | Unsubscribed({category, channel}) => withChoice(t, category, channel, false)
153
+ // Claims are per source and not per recipient, so they arrive from outside this
154
+ // recipient's own history — the host reads them across partitions, which is why
155
+ // the request carries the source it came from.
156
+ | Claimed({source, by}) => {
157
+ ...t,
158
+ claims: Array.concat(t.claims->Array.filter(((s, _)) => s != source), [(source, by)]),
159
+ }
160
+ | Released({source}) => {...t, claims: t.claims->Array.filter(((s, _)) => s != source)}
111
161
  // Neither the outcome of a request nor a delivery report changes who somebody
112
162
  // is or what they want, so the fold ignores them. What a host must NOT do is
113
163
  // read them back as consumed events to make this state bigger — the directory
114
164
  // is snapshotted, and a set of every reference ever seen would grow forever.
115
165
  | Requested(_)
116
166
  | Suppressed(_)
117
- | Undeliverable(_) => t
167
+ | Undeliverable(_)
168
+ | Deferred(_) => t
118
169
  }
119
170
 
120
171
  /** The whole competency, in one function.
@@ -150,6 +201,26 @@ let decide = (t, op, ~posture: (category, channel) => bool): result<
150
201
  ? Ok([Unsubscribed({category, channel})])
151
202
  : Ok([])
152
203
 
204
+ | Claim({source: src, by}) =>
205
+ claimedBy(t, src) == Some(by) ? Ok([]) : Ok([Claimed({source: src, by})])
206
+
207
+ | Release({source: src}) =>
208
+ switch claimedBy(t, src) {
209
+ | None => Ok([])
210
+ | Some(_) => Ok([Released({source: src})])
211
+ }
212
+
213
+ // The handover, and the whole of it. A `Configured` request is the second
214
+ // producer's own and always goes through; a `Default` one is the compiled
215
+ // table still firing for a source somebody else has taken over, and it yields.
216
+ //
217
+ // Precedence has to be per source rather than per deployment, which is why this
218
+ // is folded state and not a swapped-in implementation: a second producer that
219
+ // takes over some of the table and leaves the rest compiled cannot be expressed
220
+ // by choosing one of two implementations at build time.
221
+ | Request({source, origin: Default, reference}) if claimedBy(t, source) != None =>
222
+ Ok([Deferred({reference, source})])
223
+
153
224
  | Request({category, reference}) =>
154
225
  let wanted = channels->Array.filter(channel => enabled(t, ~posture, category, channel))
155
226
  let addressed =
@@ -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,