@reventlessdev/trait-notification 1.0.0-alpha.1
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/LICENSE +202 -0
- package/README.md +110 -0
- package/package.json +40 -0
- package/rescript.json +27 -0
- package/src/Notification.res +105 -0
- package/src/Notification.res.mjs +19 -0
- package/src/Notification_Conformance.res +137 -0
- package/src/Notification_Conformance.res.mjs +51 -0
- package/src/Notification_Rules.res +173 -0
- package/src/Notification_Rules.res.mjs +187 -0
- package/src/Notification_Scaffold.res +894 -0
- package/src/Notification_Scaffold.res.mjs +814 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright 2019-2026 Martin Lorenz
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# @reventlessdev/trait-notification
|
|
2
|
+
|
|
3
|
+
A **domain trait**: telling somebody something happened. It holds a per-recipient
|
|
4
|
+
contact directory and a kind × channel subscription matrix, and decides — per
|
|
5
|
+
request — whether that becomes an addressed message, a suppression, or a record
|
|
6
|
+
that nobody could be reached. The rules are a module the host calls; the types are
|
|
7
|
+
the host's own:
|
|
8
|
+
|
|
9
|
+
| Part | Where |
|
|
10
|
+
|---|---|
|
|
11
|
+
| The competency's rules | `src/Notification_Rules.res` (`empty`, `op`, `fact`, `decide`, `evolve`, `addressFor`) |
|
|
12
|
+
| The host contract, as a type | `src/Notification.res` (`module type Binding`) |
|
|
13
|
+
| The conformance suite | `src/Notification_Conformance.res` (`Make(Binding).register()`) |
|
|
14
|
+
| The emitter | `src/Notification_Scaffold.res` (`emit(~config, ~into, ~tests)`) |
|
|
15
|
+
|
|
16
|
+
`Notification_Rules` is compiled code a host imports at runtime, so a change to it
|
|
17
|
+
is a behavior change for every host: version it `fix:`/`feat:` accordingly.
|
|
18
|
+
|
|
19
|
+
## What makes this one different
|
|
20
|
+
|
|
21
|
+
The first two traits add arms to something the host already had. This one **brings
|
|
22
|
+
its own components**: a StateChangeSlice, an outbound send, and two views, none of
|
|
23
|
+
which the host had before. So the emitter writes more than either of the others —
|
|
24
|
+
five files whole — and the host's part shrinks to two **relays**, which are printed
|
|
25
|
+
rather than written because what a host's events *mean* is the one thing a trait
|
|
26
|
+
cannot be told in names.
|
|
27
|
+
|
|
28
|
+
It also never writes to the host. Not one field, not one arm: the directory, the
|
|
29
|
+
matrix and the delivery log are all the trait's own state, so a host can graft this
|
|
30
|
+
onto components that have no opinion about mail at all.
|
|
31
|
+
|
|
32
|
+
## The rules it owns
|
|
33
|
+
|
|
34
|
+
- **The address is resolved when the message is composed, not when the occurrence
|
|
35
|
+
happened.** A request names a recipient and a kind; the address comes off the
|
|
36
|
+
directory at decision time. Putting it on the occurrence instead would freeze a
|
|
37
|
+
mutable fact into an append-only log, and a recipient who changed their address
|
|
38
|
+
would have old occurrences confirmed to the old one.
|
|
39
|
+
- **An absent choice falls back to a posture the host supplies.** The rule is the
|
|
40
|
+
trait's; the table is not — whether an unheard-from recipient should be notified
|
|
41
|
+
is per kind and per host, and a trait that hard-coded either answer would be
|
|
42
|
+
wrong for half of them.
|
|
43
|
+
- **Three ways to send nothing, and they stay three facts.** A recipient who
|
|
44
|
+
declined is `Suppressed`; one who enabled a channel nobody holds an address for
|
|
45
|
+
is `Undeliverable`; one nobody announced is `Undeliverable` too. Collapsing the
|
|
46
|
+
second into the first would hide every delivery gap behind a legitimate
|
|
47
|
+
preference, permanently.
|
|
48
|
+
- **Announcing an address already on file appends nothing.** The relay that feeds
|
|
49
|
+
it re-announces on every contact event a host publishes, and its row completes
|
|
50
|
+
on the publish rather than on an event coming back — so the no-op is safe, and
|
|
51
|
+
recording a change that did not happen would not be.
|
|
52
|
+
|
|
53
|
+
Delivery itself is the platform's: the send slice reaches
|
|
54
|
+
`Reventless.Capabilities.messaging`, declares the `Messaging` capability need, and
|
|
55
|
+
takes its retry split from `Reventless.Messaging.retriable`.
|
|
56
|
+
|
|
57
|
+
## Grafting a host
|
|
58
|
+
|
|
59
|
+
1. **Run the emitter.** Every `--key` is a field of its config, and it validates
|
|
60
|
+
them, so a misspelled one is refused before anything is written.
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
pnpm exec graft-trait @reventlessdev/trait-notification \
|
|
64
|
+
--into src/Notification --tests tests/Notification \
|
|
65
|
+
--chapter Notification --noun Recipient \
|
|
66
|
+
--categories OrderConfirmation,ShippingUpdate,Marketing \
|
|
67
|
+
--transactional OrderConfirmation,ShippingUpdate \
|
|
68
|
+
--contactSource Customer --contactEvents Registered,EmailUpdated \
|
|
69
|
+
--contactField email \
|
|
70
|
+
--occurrence OrderPlaced --occurrenceId orderId \
|
|
71
|
+
--occurrenceRecipient customerId --occurrenceCategory OrderConfirmation
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
2. **Write the two printed relays.** They arrive with their shape filled in and
|
|
75
|
+
their meaning marked `TODO(graft)`: which of your events announce a contact,
|
|
76
|
+
and what your notification actually says. The contact relay is an
|
|
77
|
+
`OutboundTranslationSlice` rather than an automation on purpose — an
|
|
78
|
+
automation's mapping is handed no source id, and an aggregate's event does not
|
|
79
|
+
repeat the id that addressed it.
|
|
80
|
+
|
|
81
|
+
3. **Fill the emitted `TODO(graft)` markers.** The posture table, and the wording.
|
|
82
|
+
Nothing else is left open.
|
|
83
|
+
|
|
84
|
+
4. **Provision a sender.** The send slice declares `Messaging`, so a deployment
|
|
85
|
+
that provisions none fails the deploy rather than queueing every message until
|
|
86
|
+
it is abandoned.
|
|
87
|
+
|
|
88
|
+
## What the suite checks, and what it does not
|
|
89
|
+
|
|
90
|
+
`Notification_Conformance` asserts the directory, the posture fallback, and the
|
|
91
|
+
three ways to send nothing. It says nothing about your wording, your
|
|
92
|
+
authorization, or what your views render — those are yours, and a rule the suite
|
|
93
|
+
covers must not also live in your own tests.
|
|
94
|
+
|
|
95
|
+
Two `Binding` members exist for the suite's sake and are worth reading before you
|
|
96
|
+
fill them: `transactional` and `optional` must be kinds whose posture actually
|
|
97
|
+
differs, and `unreachableChannel` is a channel your host announces no address for.
|
|
98
|
+
A host that announces every channel it offers passes `None` and two assertions are
|
|
99
|
+
skipped — which is the honest outcome, not a gap.
|
|
100
|
+
|
|
101
|
+
## Limits worth knowing
|
|
102
|
+
|
|
103
|
+
- **One address per channel.** An email is one address per person; a push token is
|
|
104
|
+
one per *install*, and a set that churns. The directory's shape allows several
|
|
105
|
+
channels but the announce path carries one address, so push is expressible and
|
|
106
|
+
not yet reachable — a recipient who enables it is recorded `Undeliverable`
|
|
107
|
+
rather than quietly skipped.
|
|
108
|
+
- **No dedupe on `reference`.** The relay upstream is a TODO list, which publishes
|
|
109
|
+
once per item and resolves on the outcome. Keeping every reference ever seen in
|
|
110
|
+
a snapshotted state to re-check that guarantee would cost more than it buys.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@reventlessdev/trait-notification",
|
|
3
|
+
"version": "1.0.0-alpha.1",
|
|
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
|
+
"license": "Apache-2.0",
|
|
6
|
+
"files": [
|
|
7
|
+
"src/**/*.res",
|
|
8
|
+
"src/**/*.resi",
|
|
9
|
+
"src/**/*.res.mjs",
|
|
10
|
+
"rescript.json",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"sury": "11.0.0-rc.2",
|
|
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"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"rescript": "12.3.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"rescript": "12.3.0"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"registry": "https://registry.npmjs.org"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/ReventlessDev/reventless-core.git",
|
|
31
|
+
"directory": "traits/notification"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "rescript build",
|
|
35
|
+
"start": "rescript watch",
|
|
36
|
+
"clean": "rescript clean",
|
|
37
|
+
"rebuild": "pnpm run clean && pnpm run build",
|
|
38
|
+
"test": "echo \"The conformance suite runs in its hosts; see README.md.\""
|
|
39
|
+
}
|
|
40
|
+
}
|
package/rescript.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@reventlessdev/trait-notification",
|
|
3
|
+
"package-specs": {
|
|
4
|
+
"module": "esmodule",
|
|
5
|
+
"in-source": true
|
|
6
|
+
},
|
|
7
|
+
"namespace": "TraitNotification",
|
|
8
|
+
"ppx-flags": [
|
|
9
|
+
"sury-ppx/bin"
|
|
10
|
+
],
|
|
11
|
+
"warnings": {
|
|
12
|
+
"error": "-44+101"
|
|
13
|
+
},
|
|
14
|
+
"sources": [
|
|
15
|
+
{
|
|
16
|
+
"dir": "src",
|
|
17
|
+
"subdirs": true
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"dependencies": [
|
|
21
|
+
"sury",
|
|
22
|
+
"@reventlessdev/reventless-spec",
|
|
23
|
+
"@reventlessdev/reventless-gwt"
|
|
24
|
+
],
|
|
25
|
+
"compiler-flags": [],
|
|
26
|
+
"suffix": ".res.mjs"
|
|
27
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
The host contract of the notification trait: a per-recipient contact directory, a
|
|
3
|
+
kind × channel subscription matrix, and the decision to send, suppress or record
|
|
4
|
+
a message as undeliverable.
|
|
5
|
+
|
|
6
|
+
Like the attachments trait and unlike the geocoding one, the graft *is* a
|
|
7
|
+
StateChangeSlice of the host, so the contract is over that slice. Unlike either,
|
|
8
|
+
this trait **brings its own components** rather than adding arms to something the
|
|
9
|
+
host already had — the slice, the send, and both views are its own, and only the
|
|
10
|
+
two relays that feed it are shaped by the host.
|
|
11
|
+
|
|
12
|
+
The rules live in `Notification_Rules` and are asserted through a host by
|
|
13
|
+
`Notification_Conformance`; the spec surface the host maps onto them is written by
|
|
14
|
+
`Notification_Scaffold`.
|
|
15
|
+
|
|
16
|
+
## What this trait refuses to know
|
|
17
|
+
|
|
18
|
+
What an occurrence *is*. It is told a recipient, a kind, and a reference; it is
|
|
19
|
+
never told there was an order. That is why the two relays are the only files a
|
|
20
|
+
graft has to be given, and everything else can be handed over whole.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
This trait's own account of itself — see `AddressGeocoding.declaration` for why the
|
|
25
|
+
version is read rather than written.
|
|
26
|
+
|
|
27
|
+
`SelfContained` is the whole reason this specimen was built third: it brings its own
|
|
28
|
+
components and grafts by reading host events alone, writing nothing back. The two
|
|
29
|
+
earlier traits share a posture; this one is the reason `posture` exists.
|
|
30
|
+
*/
|
|
31
|
+
let declaration: Reventless.Trait.t = {
|
|
32
|
+
trait: "@reventlessdev/trait-notification",
|
|
33
|
+
version: Reventless.PackageVersion.fromModuleUrl(%raw(`import.meta.url`)),
|
|
34
|
+
posture: SelfContained,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
The platform capabilities a host of this trait needs, for its send slice's
|
|
39
|
+
`capabilityNeeds` to name.
|
|
40
|
+
|
|
41
|
+
Exported as a value rather than written into the emitted text, for the reason the
|
|
42
|
+
geocoding trait exports its own: forgetting it is silent. An unprovisioned sender
|
|
43
|
+
answers `Unavailable`, every send retries its budget, and every message this host
|
|
44
|
+
was ever asked for is recorded as failed — a permanent wrong outcome with no error
|
|
45
|
+
anywhere. A host that spells this gets a deploy-time refusal instead.
|
|
46
|
+
|
|
47
|
+
It is also what lets a listing say what the trait needs without running the
|
|
48
|
+
emitter: a literal buried in generated text is not a fact anything can read.
|
|
49
|
+
*/
|
|
50
|
+
let capabilityNeeds: array<Reventless.CapabilityNeed.t> = [Messaging]
|
|
51
|
+
|
|
52
|
+
/** One host, bound.
|
|
53
|
+
|
|
54
|
+
`category` is abstract: a host's kinds are its own vocabulary, and the rules
|
|
55
|
+
hold for any of them. `posture` is the host's answer to the one question the
|
|
56
|
+
rules cannot settle — whether an unheard-from recipient should be notified —
|
|
57
|
+
and it is a member rather than a constant because it is per category. */
|
|
58
|
+
module type Binding = {
|
|
59
|
+
/** The host's own kind-of-notification type. */
|
|
60
|
+
type category
|
|
61
|
+
/** Two kinds that differ in posture: one an unheard-from recipient gets, and
|
|
62
|
+
one they do not. A host with only transactional kinds cannot satisfy this,
|
|
63
|
+
which is the suite refusing to certify a matrix with nothing to choose. */
|
|
64
|
+
let transactional: category
|
|
65
|
+
let optional: category
|
|
66
|
+
|
|
67
|
+
module Spec: ReventlessGwt.Behavior_GWT.BehaviorSpec
|
|
68
|
+
module Behavior: ReventlessGwt.Behavior_GWT.Behavior with module Spec = Spec
|
|
69
|
+
|
|
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
|
|
77
|
+
|
|
78
|
+
let announce: string => Spec.command
|
|
79
|
+
let subscribe: (category, Notification_Rules.channel) => Spec.command
|
|
80
|
+
let unsubscribe: (category, Notification_Rules.channel) => Spec.command
|
|
81
|
+
/** `reference` is the requester's key, echoed on whichever outcome follows. */
|
|
82
|
+
let request: (category, string) => Spec.command
|
|
83
|
+
|
|
84
|
+
/** The competency's facts, as the slice emits them. */
|
|
85
|
+
let announced: string => Spec.event
|
|
86
|
+
let subscribed: (category, Notification_Rules.channel) => Spec.event
|
|
87
|
+
let unsubscribed: (category, Notification_Rules.channel) => Spec.event
|
|
88
|
+
let requested: (category, string, Notification_Rules.channel, string) => Spec.event
|
|
89
|
+
let suppressed: (category, string) => Spec.event
|
|
90
|
+
let undeliverable: (category, string) => Spec.event
|
|
91
|
+
|
|
92
|
+
/** The refusal for managing preferences for somebody nobody has announced. */
|
|
93
|
+
let recipientUnknown: Spec.error
|
|
94
|
+
|
|
95
|
+
/** An address the host's announce command accepts, and the channel it lands on.
|
|
96
|
+
Email today for every host; named rather than assumed so the suite asserts
|
|
97
|
+
against the channel the host actually announces. */
|
|
98
|
+
let addressA: string
|
|
99
|
+
let addressB: string
|
|
100
|
+
let announcedChannel: Notification_Rules.channel
|
|
101
|
+
/** A channel the host announces no address for, so the suite can tell
|
|
102
|
+
"unreachable" apart from "not wanted". `None` for a host that announces
|
|
103
|
+
every channel it offers — the two assertions that need it are skipped. */
|
|
104
|
+
let unreachableChannel: option<Notification_Rules.channel>
|
|
105
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as PackageVersion$Reventless from "@reventlessdev/reventless-spec/src/PackageVersion.res.mjs";
|
|
4
|
+
|
|
5
|
+
let declaration_version = PackageVersion$Reventless.fromModuleUrl(import.meta.url);
|
|
6
|
+
|
|
7
|
+
let declaration = {
|
|
8
|
+
trait: "@reventlessdev/trait-notification",
|
|
9
|
+
version: declaration_version,
|
|
10
|
+
posture: "SelfContained"
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let capabilityNeeds = ["Messaging"];
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
declaration,
|
|
17
|
+
capabilityNeeds,
|
|
18
|
+
}
|
|
19
|
+
/* declaration Not a pure module */
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
The conformance suite, run by a host against its own graft. `Make(Binding).register()`
|
|
3
|
+
inside a Jest test file registers one `describe` block over the binding.
|
|
4
|
+
|
|
5
|
+
What it asserts is the competency, not the host: the directory, the fallback to
|
|
6
|
+
the host's posture, and — the part worth being strict about — that the three ways
|
|
7
|
+
to send nothing stay three different facts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The suite's title, composed once and read twice: the suite registers it,
|
|
11
|
+
and `certify-trait` computes the same string to find the run's assertions in
|
|
12
|
+
a test report. Exported rather than inlined so neither side parses the
|
|
13
|
+
other's prose. */
|
|
14
|
+
let suiteName = (host: string) => `${host} conforms to the notification trait`
|
|
15
|
+
|
|
16
|
+
module Make = (B: Notification.Binding) => {
|
|
17
|
+
module G = ReventlessGwt.Behavior_GWT.Make(B.Spec, B.Behavior)
|
|
18
|
+
module R = Notification_Rules
|
|
19
|
+
|
|
20
|
+
let announced = Array.concat(B.created, [B.announcedC(B.addressA)])
|
|
21
|
+
|
|
22
|
+
let register = () =>
|
|
23
|
+
G.describe(suiteName(B.Spec.name), () => {
|
|
24
|
+
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
|
+
)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// The relay re-announces on every contact event a host publishes, and its
|
|
31
|
+
// row completes on the publish rather than on an event coming back — so
|
|
32
|
+
// saying nothing is safe here, and recording a change that did not happen
|
|
33
|
+
// is not.
|
|
34
|
+
G.test("re-announcing the address already on file is a no-op", () =>
|
|
35
|
+
G.givenEvents(announced)->G.whenCmd(B.announce(B.addressA))->G.thenNoEvent
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
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
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// A person is at the other end of this one, so they are told rather than
|
|
45
|
+
// having a fact recorded about them.
|
|
46
|
+
G.test("managing preferences for an unannounced recipient is refused", () =>
|
|
47
|
+
G.givenEvents(B.created)
|
|
48
|
+
->G.whenCmd(B.subscribe(B.optional, B.announcedChannel))
|
|
49
|
+
->G.thenError(B.recipientUnknown)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
G.test("opting in to a kind that is off by default is recorded", () =>
|
|
53
|
+
G.givenEvents(announced)
|
|
54
|
+
->G.whenCmd(B.subscribe(B.optional, B.announcedChannel))
|
|
55
|
+
->G.thenEvent(B.subscribed(B.optional, B.announcedChannel))
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// Already on by the host's posture. Recording a subscription would say the
|
|
59
|
+
// recipient chose something they did not, and a later posture change would
|
|
60
|
+
// then not reach them.
|
|
61
|
+
G.test("subscribing to a kind already on by posture is a no-op", () =>
|
|
62
|
+
G.givenEvents(announced)
|
|
63
|
+
->G.whenCmd(B.subscribe(B.transactional, B.announcedChannel))
|
|
64
|
+
->G.thenNoEvent
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
G.test("opting out of a kind that is on is recorded", () =>
|
|
68
|
+
G.givenEvents(announced)
|
|
69
|
+
->G.whenCmd(B.unsubscribe(B.transactional, B.announcedChannel))
|
|
70
|
+
->G.thenEvent(B.unsubscribed(B.transactional, B.announcedChannel))
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
G.test("opting out of a kind already off is a no-op", () =>
|
|
74
|
+
G.givenEvents(announced)
|
|
75
|
+
->G.whenCmd(B.unsubscribe(B.optional, B.announcedChannel))
|
|
76
|
+
->G.thenNoEvent
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// The posture doing its job: somebody who has never seen a settings screen
|
|
80
|
+
// still gets what they asked for by acting.
|
|
81
|
+
G.test("a transactional request goes out with no explicit subscription", () =>
|
|
82
|
+
G.givenEvents(announced)
|
|
83
|
+
->G.whenCmd(B.request(B.transactional, "ref-1"))
|
|
84
|
+
->G.thenEvent(
|
|
85
|
+
B.requested(B.transactional, "ref-1", B.announcedChannel, B.addressA),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
G.test("the address on the request is the one currently on file", () =>
|
|
90
|
+
G.givenEvents(Array.concat(announced, [B.announcedC(B.addressB)]))
|
|
91
|
+
->G.whenCmd(B.request(B.transactional, "ref-1"))
|
|
92
|
+
->G.thenEvent(B.requested(B.transactional, "ref-1", B.announcedChannel, B.addressB))
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
G.test("an optional request is suppressed with no explicit subscription", () =>
|
|
96
|
+
G.givenEvents(announced)
|
|
97
|
+
->G.whenCmd(B.request(B.optional, "ref-2"))
|
|
98
|
+
->G.thenEvent(B.suppressed(B.optional, "ref-2"))
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
G.test("a recipient who opted out is suppressed", () =>
|
|
102
|
+
G.givenEvents(
|
|
103
|
+
Array.concat(announced, [B.unsubscribedC(B.transactional, B.announcedChannel)]),
|
|
104
|
+
)
|
|
105
|
+
->G.whenCmd(B.request(B.transactional, "ref-3"))
|
|
106
|
+
->G.thenEvent(B.suppressed(B.transactional, "ref-3"))
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// The case the directory exists for.
|
|
110
|
+
G.test("a request for a recipient nobody announced is undeliverable", () =>
|
|
111
|
+
G.givenEvents(B.created)
|
|
112
|
+
->G.whenCmd(B.request(B.transactional, "ref-4"))
|
|
113
|
+
->G.thenEvent(B.undeliverable(B.transactional, "ref-4"))
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// Wanted, and unreachable — the distinction the whole `Undeliverable` arm
|
|
117
|
+
// exists for. Skipped by a host that announces an address for every channel
|
|
118
|
+
// it offers, because there is then no way to be in this state.
|
|
119
|
+
switch B.unreachableChannel {
|
|
120
|
+
| None => ()
|
|
121
|
+
| Some(channel) =>
|
|
122
|
+
G.test("a channel with no address on file is undeliverable, not suppressed", () =>
|
|
123
|
+
G.givenEvents(
|
|
124
|
+
Array.concat(
|
|
125
|
+
announced,
|
|
126
|
+
[
|
|
127
|
+
B.subscribedC(B.transactional, channel),
|
|
128
|
+
B.unsubscribedC(B.transactional, B.announcedChannel),
|
|
129
|
+
],
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
->G.whenCmd(B.request(B.transactional, "ref-5"))
|
|
133
|
+
->G.thenEvent(B.undeliverable(B.transactional, "ref-5"))
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
}
|