@reventlessdev/reventless-spec 3.0.0-alpha.124 → 3.0.0-alpha.126

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/package.json +5 -2
  3. package/run-certify-trait.mjs +2 -0
  4. package/run-graft-trait.mjs +2 -0
  5. package/run-trait-manifest.mjs +2 -0
  6. package/schema/platform-api.graphql +17 -3
  7. package/src/components/Aggregate.res +23 -0
  8. package/src/components/AutomationSlice.res +29 -3
  9. package/src/components/CapabilityManifest.res +52 -24
  10. package/src/components/CapabilityManifest.res.mjs +35 -11
  11. package/src/components/InboundTranslationSlice.res +14 -0
  12. package/src/components/OutboundTranslationSlice.res +24 -0
  13. package/src/components/Plugin.res +55 -2
  14. package/src/components/Plugin.res.mjs +23 -1
  15. package/src/components/StateAnnotations.res +2 -2
  16. package/src/components/StateChangeSlice.res +22 -0
  17. package/src/components/TraitCertificate.res +105 -0
  18. package/src/components/TraitCertificate.res.mjs +65 -0
  19. package/src/components/TraitManifest.res +90 -0
  20. package/src/components/TraitManifest.res.mjs +48 -0
  21. package/src/generator/CertifyTrait.res +190 -0
  22. package/src/generator/CertifyTrait.res.mjs +154 -0
  23. package/src/generator/GraftTrait.res +230 -0
  24. package/src/generator/GraftTrait.res.mjs +193 -0
  25. package/src/generator/PlatformCodegen.res +44 -30
  26. package/src/generator/PlatformCodegen.res.mjs +36 -17
  27. package/src/generator/TraitManifestCli.res +138 -0
  28. package/src/generator/TraitManifestCli.res.mjs +105 -0
  29. package/src/semantic/Capabilities.res +19 -3
  30. package/src/semantic/Capabilities.res.mjs +18 -2
  31. package/src/semantic/CapabilityNeed.res +81 -0
  32. package/src/semantic/CapabilityNeed.res.mjs +46 -0
  33. package/src/semantic/Messaging.res +127 -0
  34. package/src/semantic/Messaging.res.mjs +57 -0
  35. package/src/types/Trait.res +62 -0
  36. package/src/types/Trait.res.mjs +18 -0
  37. package/src/types/Transition.res +70 -0
  38. package/src/types/Transition.res.mjs +36 -0
@@ -0,0 +1,138 @@
1
+ // Derive a trait's listing metadata from the trait itself.
2
+ //
3
+ // Usage: trait-manifest <trait-package> --out <file>
4
+ //
5
+ // The old design had a hand-written `trait.yaml`. Everything in it is already a
6
+ // fact somewhere typed, so this reads those facts instead: identity from the
7
+ // package, needs from the value the trait exports, and the config surface from
8
+ // the emitter's own sury schema.
9
+ //
10
+ // Nothing here knows a trait's vocabulary. Both modules are resolved by the name
11
+ // the trait is given and dynamically imported, exactly as `graft-trait` reaches a
12
+ // scaffold — so `reventless-spec` still depends on no trait.
13
+
14
+ @val external dynImport: string => promise<'a> = "import"
15
+
16
+ type scaffoldExports = {configSchema: S.t<unknown>}
17
+ type traitExports = {capabilityNeeds: array<CapabilityNeed.t>}
18
+
19
+ let fail = (message: string) => {
20
+ Console.error("trait-manifest: " ++ message)
21
+ NodeProcess.exit(1)
22
+ }
23
+
24
+ let usage = `Usage: trait-manifest <trait-package> --out <file>
25
+
26
+ <trait-package> the installed trait, e.g. @reventlessdev/trait-attachments
27
+ --out where to write the manifest
28
+
29
+ Everything is read from the trait: identity from its package.json, capability
30
+ needs from the value it exports, the config surface from its emitter's schema.
31
+ There is nothing to hand-write and nothing to keep in step.`
32
+
33
+ /** `@scope/trait-address-geocoding` → `AddressGeocoding`. The same derivation
34
+ `graft-trait` and `certify-trait` do, for the same reason: a trait's module
35
+ names follow from its package name, so nothing has to be configured. */
36
+ let moduleBase = (traitPackage: string) =>
37
+ traitPackage
38
+ ->String.split("/")
39
+ ->Array.last
40
+ ->Option.getOr("")
41
+ ->String.replace("trait-", "")
42
+ ->String.split("-")
43
+ ->Array.map(part =>
44
+ part->String.charAt(0)->String.toUpperCase ++
45
+ part->String.slice(~start=1, ~end=part->String.length)
46
+ )
47
+ ->Array.join("")
48
+
49
+ let resolveFrom = (specifier: string) =>
50
+ try Some(
51
+ NodeModule.createRequire(NodeProcess.cwd() ++ "/index.js")->NodeModule.requireResolve(specifier),
52
+ ) catch {
53
+ | _ => None
54
+ }
55
+
56
+ let readPackageField = (packageJson: JSON.t, field: string, fallback: string) =>
57
+ packageJson
58
+ ->JSON.Decode.object
59
+ ->Option.flatMap(o => o->Dict.get(field))
60
+ ->Option.flatMap(JSON.Decode.string)
61
+ ->Option.getOr(fallback)
62
+
63
+ let main = async () => {
64
+ let argv = NodeProcess.argv->Array.slice(~start=2, ~end=NodeProcess.argv->Array.length)
65
+ let flag = key =>
66
+ switch argv->Array.indexOf("--" ++ key) {
67
+ | -1 => None
68
+ | i => argv->Array.get(i + 1)
69
+ }
70
+
71
+ switch (argv->Array.get(0), flag("out")) {
72
+ | (None, _) | (Some(""), _) | (Some("--help"), _) | (Some("-h"), _) => {
73
+ Console.log(usage)
74
+ NodeProcess.exit(argv->Array.length == 0 ? 1 : 0)
75
+ }
76
+ | (Some(_), None) => fail("--out is required.\n\n" ++ usage)
77
+ | (Some(traitPackage), Some(out)) => {
78
+ let base = moduleBase(traitPackage)
79
+
80
+ let packageJson = switch resolveFrom(`${traitPackage}/package.json`) {
81
+ | None =>
82
+ fail(
83
+ `${traitPackage} is not installed here. A manifest is derived from the trait, so ` ++
84
+ `the trait has to be resolvable.`,
85
+ )
86
+ JSON.Encode.null
87
+ | Some(path) => NodeFs.readFileSync(path)->JSON.parseOrThrow
88
+ }
89
+
90
+ // The trait's entry module, for what it needs. Absent is refused rather
91
+ // than defaulted to `[]`: "needs nothing" and "nobody said" are different
92
+ // claims, and a listing that could not tell them apart would quietly
93
+ // publish the second as the first.
94
+ let traitModule: traitExports = switch resolveFrom(`${traitPackage}/src/${base}.res.mjs`) {
95
+ | None =>
96
+ fail(
97
+ `${traitPackage} exports no ${base} module, so its capability needs cannot be read.\n` ++
98
+ ` A trait states them as a value — an empty array if it brokers nothing — because ` ++
99
+ `an unstated need fails silently at run time.`,
100
+ )
101
+ %raw(`undefined`)
102
+ | Some(path) => await dynImport(NodeUrl.pathToFileURL(path)["href"])
103
+ }
104
+
105
+ // The emitter is optional: a trait whose graft is all patches has nothing
106
+ // to write, and says so here rather than by failing when someone tries.
107
+ let scaffold: option<scaffoldExports> = switch resolveFrom(
108
+ `${traitPackage}/src/${base}_Scaffold.res.mjs`,
109
+ ) {
110
+ | None => None
111
+ | Some(path) => Some(await dynImport(NodeUrl.pathToFileURL(path)["href"]))
112
+ }
113
+
114
+ let manifest: TraitManifest.t = {
115
+ trait: readPackageField(packageJson, "name", traitPackage),
116
+ version: readPackageField(packageJson, "version", "0.0.0"),
117
+ description: readPackageField(packageJson, "description", ""),
118
+ license: readPackageField(packageJson, "license", ""),
119
+ capabilities: traitModule.capabilityNeeds->Array.map(CapabilityNeed.toString),
120
+ config: switch scaffold {
121
+ | Some({configSchema}) => TraitManifest.configFieldsOf(configSchema)
122
+ | None => []
123
+ },
124
+ scaffolded: scaffold->Option.isSome,
125
+ }
126
+
127
+ NodeFs.writeFileSync(out, manifest->TraitManifest.render)
128
+ Console.log(
129
+ `trait-manifest: ${manifest.trait}@${manifest.version} — ` ++
130
+ `${(manifest.capabilities->Array.length)->Int.toString} capabilit(ies), ` ++
131
+ `${(manifest.config->Array.length)->Int.toString} config field(s)`,
132
+ )
133
+ Console.log(`Wrote: ${out}`)
134
+ }
135
+ }
136
+ }
137
+
138
+ main()->Promise.ignore
@@ -0,0 +1,105 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodeurl from "node:url";
5
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
6
+ import * as Nodemodule from "node:module";
7
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
8
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
9
+ import * as TraitManifest$Reventless from "../components/TraitManifest.res.mjs";
10
+ import * as CapabilityNeed$Reventless from "../semantic/CapabilityNeed.res.mjs";
11
+
12
+ function fail(message) {
13
+ console.error("trait-manifest: " + message);
14
+ process.exit(1);
15
+ }
16
+
17
+ let usage = `Usage: trait-manifest <trait-package> --out <file>
18
+
19
+ <trait-package> the installed trait, e.g. @reventlessdev/trait-attachments
20
+ --out where to write the manifest
21
+
22
+ Everything is read from the trait: identity from its package.json, capability
23
+ needs from the value it exports, the config surface from its emitter's schema.
24
+ There is nothing to hand-write and nothing to keep in step.`;
25
+
26
+ function moduleBase(traitPackage) {
27
+ return Stdlib_Option.getOr(Stdlib_Array.last(traitPackage.split("/")), "").replace("trait-", "").split("-").map(part => part.charAt(0).toUpperCase() + part.slice(1, part.length)).join("");
28
+ }
29
+
30
+ function resolveFrom(specifier) {
31
+ try {
32
+ return Nodemodule.createRequire(process.cwd() + "/index.js").resolve(specifier);
33
+ } catch (exn) {
34
+ return;
35
+ }
36
+ }
37
+
38
+ function readPackageField(packageJson, field, fallback) {
39
+ return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(packageJson), o => o[field]), Stdlib_JSON.Decode.string), fallback);
40
+ }
41
+
42
+ async function main() {
43
+ let argv = process.argv.slice(2, process.argv.length);
44
+ let flag = key => {
45
+ let i = argv.indexOf("--" + key);
46
+ if (i !== -1) {
47
+ return argv[i + 1 | 0];
48
+ }
49
+ };
50
+ let match = argv[0];
51
+ let match$1 = flag("out");
52
+ if (match !== undefined) {
53
+ switch (match) {
54
+ case "" :
55
+ case "--help" :
56
+ case "-h" :
57
+ break;
58
+ default:
59
+ if (match$1 === undefined) {
60
+ return fail("--out is required.\n\n" + usage);
61
+ }
62
+ let base = moduleBase(match);
63
+ let path = resolveFrom(match + `/package.json`);
64
+ let packageJson = path !== undefined ? JSON.parse(Nodefs.readFileSync(path, "utf8")) : (fail(match + ` is not installed here. A manifest is derived from the trait, so the trait has to be resolvable.`), null);
65
+ let path$1 = resolveFrom(match + `/src/` + base + `.res.mjs`);
66
+ let traitModule = path$1 !== undefined ? await import(Nodeurl.pathToFileURL(path$1).href) : (fail(match + ` exports no ` + base + ` module, so its capability needs cannot be read.\n A trait states them as a value — an empty array if it brokers nothing — because an unstated need fails silently at run time.`), undefined);
67
+ let path$2 = resolveFrom(match + `/src/` + base + `_Scaffold.res.mjs`);
68
+ let scaffold = path$2 !== undefined ? await import(Nodeurl.pathToFileURL(path$2).href) : undefined;
69
+ let manifest_trait = readPackageField(packageJson, "name", match);
70
+ let manifest_version = readPackageField(packageJson, "version", "0.0.0");
71
+ let manifest_description = readPackageField(packageJson, "description", "");
72
+ let manifest_license = readPackageField(packageJson, "license", "");
73
+ let manifest_capabilities = traitModule.capabilityNeeds.map(CapabilityNeed$Reventless.toString);
74
+ let manifest_config = scaffold !== undefined ? TraitManifest$Reventless.configFieldsOf(scaffold.configSchema) : [];
75
+ let manifest_scaffolded = Stdlib_Option.isSome(scaffold);
76
+ let manifest = {
77
+ trait: manifest_trait,
78
+ version: manifest_version,
79
+ description: manifest_description,
80
+ license: manifest_license,
81
+ capabilities: manifest_capabilities,
82
+ config: manifest_config,
83
+ scaffolded: manifest_scaffolded
84
+ };
85
+ Nodefs.writeFileSync(match$1, TraitManifest$Reventless.render(manifest), "utf8");
86
+ console.log(`trait-manifest: ` + manifest_trait + `@` + manifest_version + ` — ` + (manifest_capabilities.length.toString() + ` capabilit(ies), `) + (manifest_config.length.toString() + ` config field(s)`));
87
+ console.log(`Wrote: ` + match$1);
88
+ return;
89
+ }
90
+ }
91
+ console.log(usage);
92
+ process.exit(argv.length === 0 ? 1 : 0);
93
+ }
94
+
95
+ main();
96
+
97
+ export {
98
+ fail,
99
+ usage,
100
+ moduleBase,
101
+ resolveFrom,
102
+ readPackageField,
103
+ main,
104
+ }
105
+ /* Not a pure module */
@@ -5,9 +5,9 @@ A plugin is provider-agnostic: it depends on `reventless-spec`, never on
5
5
  `reventless-aws`, so it cannot name Amazon Location — and it should not want to.
6
6
  What it can do is receive a function that geocodes, and let whoever assembled the
7
7
  runtime decide what is on the other end. The same capability a client reaches
8
- through a GraphQL field, plugin code reaches through here; the object store
9
- already works this way, with `Upload_Presign` on one side and an injected
10
- `Offload.resolve(~fetch)` on the other.
8
+ through a GraphQL field, plugin code reaches through here. (The object store has
9
+ only the client half so far — `Upload_Presign`; `Offload.resolve(~fetch)` has no
10
+ injected caller yet, and the accessor for it belongs in this record.)
11
11
 
12
12
  Injected rather than looked up, and required rather than optional, because the
13
13
  alternative is a slot filled at cold start that nothing enforces: ES modules
@@ -24,6 +24,9 @@ leaves every `translate` reading `capabilities.geocode` untouched.
24
24
  type t = {
25
25
  /** Turn an address into ranked candidates. See `Geocoding.search`. */
26
26
  geocode: Geocoding.search,
27
+ /** Send a message to a person, and say which channels this deployment can
28
+ attempt at all. See `Messaging.provider`. */
29
+ messaging: Messaging.provider,
27
30
  }
28
31
 
29
32
  /**
@@ -40,4 +43,17 @@ a blank.
40
43
  */
41
44
  let none: t = {
42
45
  geocode: async (~text as _) => Error(Unavailable("no geocoder is configured for this platform")),
46
+ // `channels: []` and a retryable `send` say two different true things, and both
47
+ // are needed. The empty list is what a deploy gate reads and what a preference
48
+ // centre renders — offering a channel nothing can deliver on would collect a
49
+ // subscription that never arrives. The send stays `Unavailable` rather than
50
+ // `UnsupportedChannel` because a caller that got this far is looking at a
51
+ // deployment gap, not at a fact about the recipient, and abandoning the message
52
+ // would record the second.
53
+ messaging: {
54
+ channels: [],
55
+ send: async (~recipient as _, ~message as _) => Error(
56
+ Unavailable("no messaging provider is configured for this platform"),
57
+ ),
58
+ },
43
59
  }
@@ -1,16 +1,32 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
 
4
- let none = {
5
- geocode: async param => ({
4
+ async function none_geocode(param) {
5
+ return {
6
6
  TAG: "Error",
7
7
  _0: {
8
8
  TAG: "Unavailable",
9
9
  _0: "no geocoder is configured for this platform"
10
10
  }
11
+ };
12
+ }
13
+
14
+ let none_messaging = {
15
+ channels: [],
16
+ send: async (param, param$1) => ({
17
+ TAG: "Error",
18
+ _0: {
19
+ TAG: "Unavailable",
20
+ _0: "no messaging provider is configured for this platform"
21
+ }
11
22
  })
12
23
  };
13
24
 
25
+ let none = {
26
+ geocode: none_geocode,
27
+ messaging: none_messaging
28
+ };
29
+
14
30
  export {
15
31
  none,
16
32
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ A platform capability a component declares it needs, in the plugin's own
3
+ vocabulary.
4
+
5
+ `Capabilities.t` is what a slice is *handed*; this is what it says it will
6
+ reach for. The two are separate because they are read at different times: the
7
+ record is injected at run time, the declaration is read at build time by
8
+ `emit-capabilities` and at deploy time by the coverage gate, neither of which
9
+ can run a `translate`.
10
+
11
+ **Object stores are not here.** A store need is already declared by the field
12
+ that carries `@storageRef`, travels as `pluginStructure.requiredStores`, and is
13
+ identified by `(plugin, store)` rather than by a capability alone. This type
14
+ carries the needs that no field can express.
15
+
16
+ A real variant, so a trait exports its need as a value the compiler checks
17
+ rather than a string a README repeats.
18
+ */
19
+ type t =
20
+ | Geocoding
21
+ | /** Sending a message to a person, reached through `Capabilities.messaging`.
22
+ One need however many channels the platform provisions — which channels
23
+ those are is a runtime answer the provider publishes, not a second
24
+ declaration, because a plugin that named `Sms` would fail a deploy it
25
+ could have run on email. */
26
+ Messaging
27
+
28
+ /** The spelling carried in `pluginStructure` and in `capabilities.json`.
29
+ Persisted structures hold strings, not enum members, so a plugin built
30
+ against a newer framework still decodes in an older reader. */
31
+ let toString = (need: t): string =>
32
+ switch need {
33
+ | Geocoding => "Geocoding"
34
+ | Messaging => "Messaging"
35
+ }
36
+
37
+ /** The inverse, for readers of a persisted structure or a committed manifest.
38
+ `None` for a capability this build does not know — a newer plugin's need is
39
+ reported as unrecognised, never silently dropped into a known arm. */
40
+ let fromString = (name: string): option<t> =>
41
+ switch name {
42
+ | "Geocoding" => Some(Geocoding)
43
+ | "Messaging" => Some(Messaging)
44
+ | _ => None
45
+ }
46
+
47
+ /** One declared need the deployment does not provision, with the component that
48
+ declared it — a deploy refusal has to name what to go and look at. */
49
+ type unmet = {need: t, component: string}
50
+
51
+ /**
52
+ Declared capability needs a platform does not provision.
53
+
54
+ `declared` is `pluginStructure.requiredCapabilities` as persisted: `(capability,
55
+ component)` pairs whose capability is a string, because a structure is replayed
56
+ from an event log. A name this build does not recognise is **skipped** — it
57
+ belongs to a newer framework, and refusing a deploy over a capability this code
58
+ cannot evaluate would be a guess dressed as a check.
59
+
60
+ Only what was declared. A plugin that declares nothing is unaffected: an
61
+ unprovisioned capability it never named degrades exactly as it always has, which
62
+ is the modelled outcome for a deployment that simply does not have one.
63
+ */
64
+ let unmet = (~declared: array<(string, string)>, ~provisioned: array<t>): array<unmet> =>
65
+ declared->Array.filterMap((((capability, component))) =>
66
+ switch fromString(capability) {
67
+ | Some(need) if !(provisioned->Array.includes(need)) => Some({need, component})
68
+ | _ => None
69
+ }
70
+ )
71
+
72
+ /** The refusal text: what is missing, who asked for it, and what silently happens
73
+ if it is not provisioned — the symptom is a wrong verdict, never an error. */
74
+ let unmetMessage = (unmet: array<unmet>): string =>
75
+ `Plugin declares capabilit(ies) the platform does not provision: ` ++
76
+ `${unmet
77
+ ->Array.map(u => `${toString(u.need)} (${u.component})`)
78
+ ->Array.join(", ")}.\n` ++
79
+ ` Provision them in the platform stack and redeploy the platform first.\n` ++
80
+ ` Without it every call answers Unavailable, the slice exhausts its retries, and a ` ++
81
+ `permanent verdict is recorded against data that is fine — no error anywhere.`
@@ -0,0 +1,46 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
+
5
+ function toString(need) {
6
+ if (need === "Geocoding") {
7
+ return "Geocoding";
8
+ } else {
9
+ return "Messaging";
10
+ }
11
+ }
12
+
13
+ function fromString(name) {
14
+ switch (name) {
15
+ case "Geocoding" :
16
+ return "Geocoding";
17
+ case "Messaging" :
18
+ return "Messaging";
19
+ default:
20
+ return;
21
+ }
22
+ }
23
+
24
+ function unmet(declared, provisioned) {
25
+ return Stdlib_Array.filterMap(declared, param => {
26
+ let need = fromString(param[0]);
27
+ if (need !== undefined && !provisioned.includes(need)) {
28
+ return {
29
+ need: need,
30
+ component: param[1]
31
+ };
32
+ }
33
+ });
34
+ }
35
+
36
+ function unmetMessage(unmet) {
37
+ return `Plugin declares capabilit(ies) the platform does not provision: ` + (unmet.map(u => toString(u.need) + ` (` + u.component + `)`).join(", ") + `.\n`) + ` Provision them in the platform stack and redeploy the platform first.\n Without it every call answers Unavailable, the slice exhausts its retries, and a permanent verdict is recorded against data that is fine — no error anywhere.`;
38
+ }
39
+
40
+ export {
41
+ toString,
42
+ fromString,
43
+ unmet,
44
+ unmetMessage,
45
+ }
46
+ /* No side effect */
@@ -0,0 +1,127 @@
1
+ /**
2
+ Sending a message to a person, and deciding whether to try again.
3
+
4
+ The transport is provider-specific and lives with its provider. What is here is
5
+ provider-neutral: who a message can be addressed to, what a send can answer, the
6
+ retry rule — decided once, so no transport invents its own.
7
+
8
+ ## One value carries the channel and the address
9
+
10
+ A `(channel, address)` pair can be built wrong: `Sms` beside an email address
11
+ compiles and fails at the provider. `recipient` fuses them, so the wrong pair
12
+ does not exist, and the channel is read back off the value that carries it.
13
+ */
14
+
15
+ /** A delivery route. The selector a recipient chooses per notification kind, and
16
+ the granularity a platform provisions at. */
17
+ type channel =
18
+ | Email
19
+ | Sms
20
+ | Push
21
+
22
+ /** An addressed recipient: the channel and the address it needs, inseparable.
23
+ Each address is the branded scalar for its channel, so an unparseable one is
24
+ refused where it is built rather than by the provider. */
25
+ type recipient =
26
+ | ToEmail(Email.t)
27
+ | ToSms(Phone.t)
28
+ | /** The token the device registered with the push service. Opaque and
29
+ provider-shaped — unlike an address, nobody else has a grammar for it. */
30
+ ToPush({deviceToken: string})
31
+
32
+ /** The channel a recipient is addressed on. */
33
+ let channelOf = (recipient: recipient): channel =>
34
+ switch recipient {
35
+ | ToEmail(_) => Email
36
+ | ToSms(_) => Sms
37
+ | ToPush(_) => Push
38
+ }
39
+
40
+ /** The channel's name, for a message a human reads and for a preference key. */
41
+ let channelToString = (channel: channel): string =>
42
+ switch channel {
43
+ | Email => "Email"
44
+ | Sms => "Sms"
45
+ | Push => "Push"
46
+ }
47
+
48
+ /**
49
+ What to say.
50
+
51
+ `subject` is carried for the channels that have one — an email header, a push
52
+ notification's title — and ignored by those that do not. Optional rather than an
53
+ empty string, so "this message has no subject" and "its subject is blank" stay
54
+ distinguishable.
55
+ */
56
+ type message = {subject?: string, body: string}
57
+
58
+ /** The provider accepted the message. `ref` is its own id for it — what a
59
+ support conversation about a missing message is conducted with, and the only
60
+ thing a caller can record that the provider will recognise. */
61
+ type receipt = {ref: string}
62
+
63
+ /**
64
+ Why a send produced no receipt.
65
+
66
+ Three constructors because the retry decision turns on the distinction, and
67
+ getting it wrong is expensive in both directions: retrying a refused address
68
+ burns the budget on an outcome that will not change, and abandoning a transient
69
+ outage writes off a message that would have gone.
70
+ */
71
+ type failure =
72
+ | /** The provider could not be reached, or refused the call. Retry. */
73
+ Unavailable(string)
74
+ | /** This deployment provisions nothing for this channel. Do not retry — no
75
+ number of attempts provisions one. */
76
+ UnsupportedChannel(channel)
77
+ | /** The provider answered and will not take this message: an address it
78
+ rejects, a recipient it suppresses. Do not retry. */
79
+ Refused(string)
80
+
81
+ /** The retry rule, stated once. Everything that sweeps a failed send derives
82
+ from it rather than re-reading the constructors. */
83
+ let retriable = (failure: failure): bool =>
84
+ switch failure {
85
+ | Unavailable(_) => true
86
+ | UnsupportedChannel(_)
87
+ | Refused(_) => false
88
+ }
89
+
90
+ /** A reason for a human, for a caller that records the outcome rather than
91
+ acting on it. */
92
+ let failureReason = (failure: failure): string =>
93
+ switch failure {
94
+ | Unavailable(reason) => reason
95
+ | UnsupportedChannel(channel) =>
96
+ `this deployment provisions no ${channel->channelToString} channel`
97
+ | Refused(reason) => reason
98
+ }
99
+
100
+ /**
101
+ The port a caller reaches a messaging provider through, so swapping the
102
+ implementation is a change of supplier rather than of call site.
103
+ */
104
+ type send = (~recipient: recipient, ~message: message) => promise<result<receipt, failure>>
105
+
106
+ /**
107
+ The capability as a slice receives it: what can be attempted, and how.
108
+
109
+ `channels` is here because a recipient cannot be offered a choice the deployment
110
+ cannot honour. A platform provisions email only, or email and SMS; a preference
111
+ centre that listed all three would collect a subscription every send then answers
112
+ `UnsupportedChannel` for. Published rather than inferred from a failed send,
113
+ because discovering a channel by failing on it costs a real message.
114
+
115
+ Empty means no channel at all — the shape `none` takes, and the one a deploy-time
116
+ gate exists to catch before it ships.
117
+ */
118
+ type provider = {
119
+ channels: array<channel>,
120
+ send: send,
121
+ }
122
+
123
+ /** Whether this provider can attempt a recipient's channel at all. The check a
124
+ caller makes before spending a send, and the same rule the provider applies
125
+ internally, so the two cannot disagree. */
126
+ let supports = (provider: provider, ~recipient: recipient): bool =>
127
+ provider.channels->Array.includes(recipient->channelOf)
@@ -0,0 +1,57 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ function channelOf(recipient) {
5
+ switch (recipient.TAG) {
6
+ case "ToEmail" :
7
+ return "Email";
8
+ case "ToSms" :
9
+ return "Sms";
10
+ case "ToPush" :
11
+ return "Push";
12
+ }
13
+ }
14
+
15
+ function channelToString(channel) {
16
+ switch (channel) {
17
+ case "Email" :
18
+ return "Email";
19
+ case "Sms" :
20
+ return "Sms";
21
+ case "Push" :
22
+ return "Push";
23
+ }
24
+ }
25
+
26
+ function retriable(failure) {
27
+ switch (failure.TAG) {
28
+ case "Unavailable" :
29
+ return true;
30
+ case "UnsupportedChannel" :
31
+ case "Refused" :
32
+ return false;
33
+ }
34
+ }
35
+
36
+ function failureReason(failure) {
37
+ switch (failure.TAG) {
38
+ case "UnsupportedChannel" :
39
+ return `this deployment provisions no ` + channelToString(failure._0) + ` channel`;
40
+ case "Unavailable" :
41
+ case "Refused" :
42
+ return failure._0;
43
+ }
44
+ }
45
+
46
+ function supports(provider, recipient) {
47
+ return provider.channels.includes(channelOf(recipient));
48
+ }
49
+
50
+ export {
51
+ channelOf,
52
+ channelToString,
53
+ retriable,
54
+ failureReason,
55
+ supports,
56
+ }
57
+ /* No side effect */