@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.
- package/CHANGELOG.md +33 -0
- package/package.json +5 -2
- package/run-certify-trait.mjs +2 -0
- package/run-graft-trait.mjs +2 -0
- package/run-trait-manifest.mjs +2 -0
- package/schema/platform-api.graphql +17 -3
- package/src/components/Aggregate.res +23 -0
- package/src/components/AutomationSlice.res +29 -3
- package/src/components/CapabilityManifest.res +52 -24
- package/src/components/CapabilityManifest.res.mjs +35 -11
- package/src/components/InboundTranslationSlice.res +14 -0
- package/src/components/OutboundTranslationSlice.res +24 -0
- package/src/components/Plugin.res +55 -2
- package/src/components/Plugin.res.mjs +23 -1
- package/src/components/StateAnnotations.res +2 -2
- package/src/components/StateChangeSlice.res +22 -0
- package/src/components/TraitCertificate.res +105 -0
- package/src/components/TraitCertificate.res.mjs +65 -0
- package/src/components/TraitManifest.res +90 -0
- package/src/components/TraitManifest.res.mjs +48 -0
- package/src/generator/CertifyTrait.res +190 -0
- package/src/generator/CertifyTrait.res.mjs +154 -0
- package/src/generator/GraftTrait.res +230 -0
- package/src/generator/GraftTrait.res.mjs +193 -0
- package/src/generator/PlatformCodegen.res +44 -30
- package/src/generator/PlatformCodegen.res.mjs +36 -17
- package/src/generator/TraitManifestCli.res +138 -0
- package/src/generator/TraitManifestCli.res.mjs +105 -0
- package/src/semantic/Capabilities.res +19 -3
- package/src/semantic/Capabilities.res.mjs +18 -2
- package/src/semantic/CapabilityNeed.res +81 -0
- package/src/semantic/CapabilityNeed.res.mjs +46 -0
- package/src/semantic/Messaging.res +127 -0
- package/src/semantic/Messaging.res.mjs +57 -0
- package/src/types/Trait.res +62 -0
- package/src/types/Trait.res.mjs +18 -0
- package/src/types/Transition.res +70 -0
- 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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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 */
|