@reventlessdev/reventless-spec 3.0.0-alpha.132 → 3.0.0-alpha.134
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 +14 -0
- package/package.json +2 -2
- package/src/AnsiStyle.res +0 -1
- package/src/LogPrefix.res +4 -13
- package/src/components/AutomationSlice.res +1 -5
- package/src/components/CapabilityManifest.res +2 -3
- package/src/components/DcbDecode.res +6 -2
- package/src/components/DcbScopeInference.res +16 -12
- package/src/components/DcbTag.res +74 -46
- package/src/components/DcbValidation.res +114 -89
- package/src/components/DisplayName.res +4 -2
- package/src/components/ExtensionPoint.res +0 -1
- package/src/components/FieldDefault.res +52 -0
- package/src/components/FieldDefault.res.mjs +50 -0
- package/src/components/InboundTranslationSlice.res +0 -2
- package/src/components/OutboundTranslationSlice.res +10 -8
- package/src/components/Owner.res +4 -4
- package/src/components/Plugin.res +10 -4
- package/src/components/ReadModel.res +0 -1
- package/src/components/Reference.res +2 -8
- package/src/components/Sensitive.res +4 -4
- package/src/components/StateAnnotations.res +4 -2
- package/src/components/StateChangeSlice.res +0 -2
- package/src/components/StateViewSlice.res +0 -2
- package/src/components/TaggedUnion.res +1 -2
- package/src/components/Task.res +0 -1
- package/src/components/TraitCertificate.res +2 -2
- package/src/components/TraitManifest.res +1 -1
- package/src/generator/CertifyTrait.res +2 -4
- package/src/generator/Codegen.res +77 -68
- package/src/generator/Discovery.res +29 -12
- package/src/generator/GraftTrait.res +17 -9
- package/src/generator/Pairing.res +39 -13
- package/src/generator/PlatformCodegen.res +13 -11
- package/src/generator/PlatformManifests.res +1 -3
- package/src/generator/PluginGenerator.res +0 -1
- package/src/generator/TraitManifestCli.res +7 -7
- package/src/lifecycle/CheckLifecycleModel.res +173 -145
- package/src/lifecycle/CheckLifecycleModel.res.mjs +17 -2
- package/src/semantic/Bytes.res +0 -1
- package/src/semantic/CalendarDate.res +2 -2
- package/src/semantic/Capabilities.res +4 -6
- package/src/semantic/Capabilities.res.mjs +9 -11
- package/src/semantic/CapabilityNeed.res +4 -5
- package/src/semantic/CaptionedImage.res +0 -1
- package/src/semantic/Color.res +0 -1
- package/src/semantic/Currency.res +189 -170
- package/src/semantic/DateRange.res +3 -4
- package/src/semantic/DateTime.res +5 -5
- package/src/semantic/Duration.res +2 -2
- package/src/semantic/Email.res +0 -1
- package/src/semantic/FileRef.res +2 -2
- package/src/semantic/GeoPoint.res +17 -21
- package/src/semantic/Geocoding.res +8 -9
- package/src/semantic/Geolocation.res +6 -9
- package/src/semantic/ImageRef.res +2 -2
- package/src/semantic/MemberRef.res +0 -1
- package/src/semantic/Messaging.res +141 -21
- package/src/semantic/Messaging.res.mjs +51 -8
- package/src/semantic/Money.res +23 -20
- package/src/semantic/Offload.res +6 -4
- package/src/semantic/Percent.res +3 -3
- package/src/semantic/Phone.res +0 -1
- package/src/semantic/RowImage.res +4 -3
- package/src/semantic/Semantic.res +8 -11
- package/src/semantic/StorageRef.res +17 -17
- package/src/semantic/Template.res +5 -7
- package/src/semantic/UploadableFile.res +0 -1
- package/src/semantic/UploadableImage.res +0 -1
- package/src/semantic/Url.res +3 -3
- package/src/types/Authorization.res +1 -2
- package/src/types/Handler.res +0 -1
- package/src/types/Identity.res +1 -3
- package/src/types/Lifecycle.res +7 -6
- package/src/types/Message.res +6 -4
- package/src/types/OwnerScope.res +1 -3
- package/src/types/Projection.res +9 -2
- package/src/types/StoredEvent.res +2 -4
- package/src/types/Transition.res +8 -4
- package/src/util/Util_Sury.res +1 -4
|
@@ -30,7 +30,6 @@ a field that declares no store.
|
|
|
30
30
|
})
|
|
31
31
|
```
|
|
32
32
|
*/
|
|
33
|
-
|
|
34
33
|
/** Transparent `string`; see `Email.t`. */
|
|
35
34
|
type t = string
|
|
36
35
|
|
|
@@ -48,4 +47,5 @@ let fromString = (raw: string): result<t, string> => Media_Ref.check(~what="imag
|
|
|
48
47
|
|
|
49
48
|
/** The sury schema for an image-reference field.
|
|
50
49
|
Use with `@s.matches(Reventless.ImageRef.schema)`. */
|
|
51
|
-
let schema: S.t<t> =
|
|
50
|
+
let schema: S.t<t> =
|
|
51
|
+
S.string->Semantic.refined(~id=Semantic.Id.imageRef, ~check=fromString)
|
|
@@ -55,7 +55,6 @@ declares one there today: a view that carried a scalar *and* the set it was
|
|
|
55
55
|
drawn from needed a marker saying the two were one thing, and a view whose
|
|
56
56
|
primary is simply the first member has no second field to reconcile.
|
|
57
57
|
*/
|
|
58
|
-
|
|
59
58
|
/** Transparent `string`, as every ref-shaped semantic here is: the marker
|
|
60
59
|
refines an existing field rather than replacing it, so nothing stored
|
|
61
60
|
changes when a field adopts it. */
|
|
@@ -11,23 +11,75 @@ A `(channel, address)` pair can be built wrong: `Sms` beside an email address
|
|
|
11
11
|
compiles and fails at the provider. `recipient` fuses them, so the wrong pair
|
|
12
12
|
does not exist, and the channel is read back off the value that carries it.
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
A delivery route. The selector a recipient chooses per notification kind.
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
Three arms, and push stays one of them even though it is provisioned three ways.
|
|
18
|
+
Splitting it would offer a person a choice between notification services, which is
|
|
19
|
+
not a choice anyone has: an app knows its own token, and nobody prefers APNs. The
|
|
20
|
+
service-level discrimination lives on the address and on what a provider
|
|
21
|
+
publishes — see `pushService`.
|
|
22
|
+
*/
|
|
17
23
|
type channel =
|
|
18
24
|
| Email
|
|
19
25
|
| Sms
|
|
20
26
|
| Push
|
|
21
27
|
|
|
28
|
+
/**
|
|
29
|
+
How a push service names one install.
|
|
30
|
+
|
|
31
|
+
A variant rather than a string because the shapes differ: two services issue an
|
|
32
|
+
opaque token, and Web Push issues a subscription whose encryption keys are part of
|
|
33
|
+
the address. This applies the rule the file opens with one level down — the wrong
|
|
34
|
+
(service, address) pair stops being constructible for the same reason the wrong
|
|
35
|
+
(channel, address) pair already is.
|
|
36
|
+
*/
|
|
37
|
+
type pushAddress =
|
|
38
|
+
| Apns({deviceToken: string})
|
|
39
|
+
| Fcm({registrationToken: string})
|
|
40
|
+
/** The endpoint is where it goes; the keys are how it is sealed. Requiring them
|
|
41
|
+
here keeps an unsendable subscription unconstructible. */
|
|
42
|
+
| WebPush({endpoint: string, p256dh: string, auth: string})
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
Which service issued an address — the granularity push is provisioned at.
|
|
46
|
+
|
|
47
|
+
Email needs no such type: one sender provisioning reaches every mailbox, because
|
|
48
|
+
SMTP routes off the address. Push has no routing layer, so which service can reach
|
|
49
|
+
a device is fixed by which credential the deployment holds, and there is nothing
|
|
50
|
+
in the address to route on.
|
|
51
|
+
*/
|
|
52
|
+
type pushService =
|
|
53
|
+
/** Apple Push Notification service. Provisioned with a signing key, the team id
|
|
54
|
+
that issued it and the app's bundle id. */
|
|
55
|
+
| ApnsService
|
|
56
|
+
/** Firebase Cloud Messaging, Google's. Provisioned with one service-account
|
|
57
|
+
credential. */
|
|
58
|
+
| FcmService
|
|
59
|
+
/** Standardised rather than vendor-run: the endpoint the browser hands out is
|
|
60
|
+
itself the service, so one VAPID keypair provisions all of them. */
|
|
61
|
+
| WebPushService
|
|
62
|
+
|
|
22
63
|
/** An addressed recipient: the channel and the address it needs, inseparable.
|
|
23
|
-
|
|
24
|
-
refused where it is built rather than by the provider
|
|
64
|
+
Email and SMS carry the branded scalar for their channel, so an unparseable one
|
|
65
|
+
is refused where it is built rather than by the provider; push carries the
|
|
66
|
+
variant its services issue, and the same property holds by construction. */
|
|
25
67
|
type recipient =
|
|
26
68
|
| ToEmail(Email.t)
|
|
27
69
|
| ToSms(Phone.t)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
ToPush(
|
|
70
|
+
/** How one install is reached. Not a bare string: the two token services and
|
|
71
|
+
Web Push do not share a shape, and Web Push is not opaque. */
|
|
72
|
+
| ToPush(pushAddress)
|
|
73
|
+
|
|
74
|
+
/** The service that issued a push address. What a provider's published list is
|
|
75
|
+
checked against, so a token is never handed to the service that cannot use
|
|
76
|
+
it. */
|
|
77
|
+
let serviceOf = (address: pushAddress): pushService =>
|
|
78
|
+
switch address {
|
|
79
|
+
| Apns(_) => ApnsService
|
|
80
|
+
| Fcm(_) => FcmService
|
|
81
|
+
| WebPush(_) => WebPushService
|
|
82
|
+
}
|
|
31
83
|
|
|
32
84
|
/** The channel a recipient is addressed on. */
|
|
33
85
|
let channelOf = (recipient: recipient): channel =>
|
|
@@ -45,6 +97,15 @@ let channelToString = (channel: channel): string =>
|
|
|
45
97
|
| Push => "Push"
|
|
46
98
|
}
|
|
47
99
|
|
|
100
|
+
/** The service's name as its own documentation spells it, because that is the
|
|
101
|
+
word a support conversation about a missing notification is conducted with. */
|
|
102
|
+
let pushServiceToString = (service: pushService): string =>
|
|
103
|
+
switch service {
|
|
104
|
+
| ApnsService => "APNs"
|
|
105
|
+
| FcmService => "FCM"
|
|
106
|
+
| WebPushService => "Web Push"
|
|
107
|
+
}
|
|
108
|
+
|
|
48
109
|
/**
|
|
49
110
|
The `From:` header a deployment's email sender presents as: the bare address, or
|
|
50
111
|
a display name in front of it.
|
|
@@ -87,20 +148,28 @@ type receipt = {ref: string}
|
|
|
87
148
|
/**
|
|
88
149
|
Why a send produced no receipt.
|
|
89
150
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
151
|
+
The retry decision turns on the distinction, and getting it wrong is expensive in
|
|
152
|
+
both directions: retrying a refused address burns the budget on an outcome that
|
|
153
|
+
will not change, and abandoning a transient outage writes off a message that would
|
|
154
|
+
have gone.
|
|
155
|
+
|
|
156
|
+
Push needs its own refusal because it is provisioned per service. Once a
|
|
157
|
+
deployment can provision *some* push, `UnsupportedChannel(Push)` would have to
|
|
158
|
+
mean both "no push at all" and "not that service", and the two read the same to
|
|
159
|
+
someone debugging a half-provisioned deployment.
|
|
94
160
|
*/
|
|
95
161
|
type failure =
|
|
96
|
-
|
|
97
|
-
Unavailable(string)
|
|
98
|
-
|
|
162
|
+
/** The provider could not be reached, or refused the call. Retry. */
|
|
163
|
+
| Unavailable(string)
|
|
164
|
+
/** This deployment provisions nothing for this channel. Do not retry — no
|
|
99
165
|
number of attempts provisions one. */
|
|
100
|
-
UnsupportedChannel(channel)
|
|
101
|
-
|
|
166
|
+
| UnsupportedChannel(channel)
|
|
167
|
+
/** This deployment provisions push, but not the service that issued this
|
|
168
|
+
address. Do not retry — the device is reachable only through its own. */
|
|
169
|
+
| UnsupportedPushService(pushService)
|
|
170
|
+
/** The provider answered and will not take this message: an address it
|
|
102
171
|
rejects, a recipient it suppresses. Do not retry. */
|
|
103
|
-
Refused(string)
|
|
172
|
+
| Refused(string)
|
|
104
173
|
|
|
105
174
|
/** The retry rule, stated once. Everything that sweeps a failed send derives
|
|
106
175
|
from it rather than re-reading the constructors. */
|
|
@@ -108,6 +177,7 @@ let retriable = (failure: failure): bool =>
|
|
|
108
177
|
switch failure {
|
|
109
178
|
| Unavailable(_) => true
|
|
110
179
|
| UnsupportedChannel(_)
|
|
180
|
+
| UnsupportedPushService(_)
|
|
111
181
|
| Refused(_) => false
|
|
112
182
|
}
|
|
113
183
|
|
|
@@ -118,6 +188,8 @@ let failureReason = (failure: failure): string =>
|
|
|
118
188
|
| Unavailable(reason) => reason
|
|
119
189
|
| UnsupportedChannel(channel) =>
|
|
120
190
|
`this deployment provisions no ${channel->channelToString} channel`
|
|
191
|
+
| UnsupportedPushService(service) =>
|
|
192
|
+
`this deployment provisions push, but not ${service->pushServiceToString}`
|
|
121
193
|
| Refused(reason) => reason
|
|
122
194
|
}
|
|
123
195
|
|
|
@@ -138,14 +210,62 @@ because discovering a channel by failing on it costs a real message.
|
|
|
138
210
|
|
|
139
211
|
Empty means no channel at all — the shape `none` takes, and the one a deploy-time
|
|
140
212
|
gate exists to catch before it ships.
|
|
213
|
+
|
|
214
|
+
`pushServices` is the second answer, at the granularity push is actually
|
|
215
|
+
provisioned at. Both are published because the two granularities differ: a
|
|
216
|
+
preference centre reads `channels` because a person picks a channel, and `supports`
|
|
217
|
+
reads `pushServices` because a token is only reachable through its own service.
|
|
218
|
+
Build one through `makeProvider`, which derives the first from the second.
|
|
141
219
|
*/
|
|
142
220
|
type provider = {
|
|
143
221
|
channels: array<channel>,
|
|
222
|
+
pushServices: array<pushService>,
|
|
144
223
|
send: send,
|
|
145
224
|
}
|
|
146
225
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
226
|
+
/**
|
|
227
|
+
Build a provider whose two published answers cannot disagree.
|
|
228
|
+
|
|
229
|
+
`channels` is derived rather than given: `Push` appears exactly when a push service
|
|
230
|
+
is behind it, so no transport can publish a channel with nothing to reach it on.
|
|
231
|
+
The alternative is that invariant stated in a comment beside two independently
|
|
232
|
+
written fields, which is where invariants go to die.
|
|
233
|
+
|
|
234
|
+
`emailAndSms` carries the channels the channel itself settles reachability for. A
|
|
235
|
+
`Push` passed there is dropped — `pushServices` is the only thing that provisions
|
|
236
|
+
push — and the switch doing the dropping is exhaustive, so a fourth channel arrives
|
|
237
|
+
here as a compile error rather than as a silent omission.
|
|
238
|
+
*/
|
|
239
|
+
let makeProvider = (
|
|
240
|
+
~emailAndSms: array<channel>,
|
|
241
|
+
~pushServices: array<pushService>,
|
|
242
|
+
~send: send,
|
|
243
|
+
): provider => {
|
|
244
|
+
channels: emailAndSms
|
|
245
|
+
->Array.filter(channel =>
|
|
246
|
+
switch channel {
|
|
247
|
+
| Email
|
|
248
|
+
| Sms => true
|
|
249
|
+
| Push => false
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
->Array.concat(pushServices->Array.length == 0 ? [] : [Push]),
|
|
253
|
+
pushServices,
|
|
254
|
+
send,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
Whether this provider can attempt this recipient at all. The check a caller makes
|
|
259
|
+
before spending a send, and the same rule the provider applies internally, so the
|
|
260
|
+
two cannot disagree.
|
|
261
|
+
|
|
262
|
+
Push is checked against the issuing service, not against the channel. Answering off
|
|
263
|
+
`channels` would return `true` for an APNs token on a deployment that provisioned
|
|
264
|
+
FCM only — and inferring a channel by failing on it is exactly what publishing the
|
|
265
|
+
list exists to avoid, since a failed send costs a real message.
|
|
266
|
+
*/
|
|
150
267
|
let supports = (provider: provider, ~recipient: recipient): bool =>
|
|
151
|
-
|
|
268
|
+
switch recipient {
|
|
269
|
+
| ToPush(address) => provider.pushServices->Array.includes(address->serviceOf)
|
|
270
|
+
| recipient => provider.channels->Array.includes(recipient->channelOf)
|
|
271
|
+
}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
function serviceOf(address) {
|
|
5
|
+
switch (address.TAG) {
|
|
6
|
+
case "Apns" :
|
|
7
|
+
return "ApnsService";
|
|
8
|
+
case "Fcm" :
|
|
9
|
+
return "FcmService";
|
|
10
|
+
case "WebPush" :
|
|
11
|
+
return "WebPushService";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
4
15
|
function channelOf(recipient) {
|
|
5
16
|
switch (recipient.TAG) {
|
|
6
17
|
case "ToEmail" :
|
|
@@ -23,6 +34,17 @@ function channelToString(channel) {
|
|
|
23
34
|
}
|
|
24
35
|
}
|
|
25
36
|
|
|
37
|
+
function pushServiceToString(service) {
|
|
38
|
+
switch (service) {
|
|
39
|
+
case "ApnsService" :
|
|
40
|
+
return "APNs";
|
|
41
|
+
case "FcmService" :
|
|
42
|
+
return "FCM";
|
|
43
|
+
case "WebPushService" :
|
|
44
|
+
return "Web Push";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
function fromHeader(displayName, address) {
|
|
27
49
|
if (displayName === undefined) {
|
|
28
50
|
return address;
|
|
@@ -32,35 +54,56 @@ function fromHeader(displayName, address) {
|
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
function retriable(failure) {
|
|
35
|
-
|
|
36
|
-
case "Unavailable" :
|
|
37
|
-
return true;
|
|
38
|
-
case "UnsupportedChannel" :
|
|
39
|
-
case "Refused" :
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
57
|
+
return failure.TAG === "Unavailable";
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
function failureReason(failure) {
|
|
45
61
|
switch (failure.TAG) {
|
|
46
62
|
case "UnsupportedChannel" :
|
|
47
63
|
return `this deployment provisions no ` + channelToString(failure._0) + ` channel`;
|
|
64
|
+
case "UnsupportedPushService" :
|
|
65
|
+
return `this deployment provisions push, but not ` + pushServiceToString(failure._0);
|
|
48
66
|
case "Unavailable" :
|
|
49
67
|
case "Refused" :
|
|
50
68
|
return failure._0;
|
|
51
69
|
}
|
|
52
70
|
}
|
|
53
71
|
|
|
72
|
+
function makeProvider(emailAndSms, pushServices, send) {
|
|
73
|
+
return {
|
|
74
|
+
channels: emailAndSms.filter(channel => {
|
|
75
|
+
switch (channel) {
|
|
76
|
+
case "Email" :
|
|
77
|
+
case "Sms" :
|
|
78
|
+
return true;
|
|
79
|
+
case "Push" :
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}).concat(pushServices.length === 0 ? [] : ["Push"]),
|
|
83
|
+
pushServices: pushServices,
|
|
84
|
+
send: send
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
54
88
|
function supports(provider, recipient) {
|
|
55
|
-
|
|
89
|
+
switch (recipient.TAG) {
|
|
90
|
+
case "ToEmail" :
|
|
91
|
+
case "ToSms" :
|
|
92
|
+
return provider.channels.includes(channelOf(recipient));
|
|
93
|
+
case "ToPush" :
|
|
94
|
+
return provider.pushServices.includes(serviceOf(recipient._0));
|
|
95
|
+
}
|
|
56
96
|
}
|
|
57
97
|
|
|
58
98
|
export {
|
|
99
|
+
serviceOf,
|
|
59
100
|
channelOf,
|
|
60
101
|
channelToString,
|
|
102
|
+
pushServiceToString,
|
|
61
103
|
fromHeader,
|
|
62
104
|
retriable,
|
|
63
105
|
failureReason,
|
|
106
|
+
makeProvider,
|
|
64
107
|
supports,
|
|
65
108
|
}
|
|
66
109
|
/* No side effect */
|
package/src/semantic/Money.res
CHANGED
|
@@ -66,7 +66,6 @@ rewrites the wire shape, so stored events no longer decode and projections must
|
|
|
66
66
|
be rebuilt. Retyping a field in a log that has to survive needs an upcaster
|
|
67
67
|
first; a log that can be discarded can take it today.
|
|
68
68
|
*/
|
|
69
|
-
|
|
70
69
|
/**
|
|
71
70
|
Validate a minor-unit amount, saying why when it is not one.
|
|
72
71
|
|
|
@@ -79,8 +78,9 @@ let validateAmount = (amount: float): result<float, string> =>
|
|
|
79
78
|
} else if amount !== Math.trunc(amount) {
|
|
80
79
|
Error(
|
|
81
80
|
`an amount is a whole number of a currency's minor units, got ` ++
|
|
82
|
-
`${Float.toString(
|
|
83
|
-
|
|
81
|
+
`${Float.toString(
|
|
82
|
+
amount,
|
|
83
|
+
)}. There is no such thing as a fraction of the ` ++ `smallest unit — a major amount converts with Money.ofMajor.`,
|
|
84
84
|
)
|
|
85
85
|
} else {
|
|
86
86
|
Ok(amount)
|
|
@@ -96,14 +96,12 @@ let validateAmount = (amount: float): result<float, string> =>
|
|
|
96
96
|
threw `Cannot access 'v0' before initialization` — it is simply not what this
|
|
97
97
|
check wants.) */
|
|
98
98
|
let amountSchema: S.t<float> =
|
|
99
|
-
S.float->S.refine(
|
|
100
|
-
amount
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
~error="expected a monetary amount",
|
|
106
|
-
)
|
|
99
|
+
S.float->S.refine(amount =>
|
|
100
|
+
switch validateAmount(amount) {
|
|
101
|
+
| Ok(_) => true
|
|
102
|
+
| Error(_) => false
|
|
103
|
+
}
|
|
104
|
+
, ~error="expected a monetary amount")
|
|
107
105
|
|
|
108
106
|
@schema
|
|
109
107
|
type t = {
|
|
@@ -117,7 +115,8 @@ type t = {
|
|
|
117
115
|
|
|
118
116
|
Shadows the schema sury-ppx derived from the type above: the derived one is
|
|
119
117
|
the shape, and this adds the marker the shape cannot carry. */
|
|
120
|
-
let schema: S.t<t> =
|
|
118
|
+
let schema: S.t<t> =
|
|
119
|
+
schema->Semantic.mark(~id=Semantic.Id.money)
|
|
121
120
|
|
|
122
121
|
/** An amount already counted in minor units. */
|
|
123
122
|
let make = (~amount: float, ~currency: Currency.t): t => {amount, currency}
|
|
@@ -208,8 +207,9 @@ let add = (a: t, b: t): result<t, string> =>
|
|
|
208
207
|
a.currency == b.currency
|
|
209
208
|
? Ok({amount: a.amount +. b.amount, currency: a.currency})
|
|
210
209
|
: Error(
|
|
211
|
-
`cannot add ${format(b)} to ${format(
|
|
212
|
-
|
|
210
|
+
`cannot add ${format(b)} to ${format(
|
|
211
|
+
a,
|
|
212
|
+
)}: they are different currencies. ` ++ `Converting between them needs a rate, which is not something an amount carries.`,
|
|
213
213
|
)
|
|
214
214
|
|
|
215
215
|
/** The largest whole number a `float` holds exactly, 2^53 - 1. Bound here
|
|
@@ -266,8 +266,9 @@ A negative amount splits away from zero the same way: -€10.00 into three is
|
|
|
266
266
|
let allocate = (m: t, ~into: int): result<array<t>, string> =>
|
|
267
267
|
if into <= 0 {
|
|
268
268
|
Error(
|
|
269
|
-
`cannot split ${format(m)} into ${Int.toString(
|
|
270
|
-
|
|
269
|
+
`cannot split ${format(m)} into ${Int.toString(
|
|
270
|
+
into,
|
|
271
|
+
)} parts: ` ++ `a split is into at least one part.`,
|
|
271
272
|
)
|
|
272
273
|
} else {
|
|
273
274
|
let parts = Int.toFloat(into)
|
|
@@ -293,8 +294,10 @@ let allocate = (m: t, ~into: int): result<array<t>, string> =>
|
|
|
293
294
|
let sum = (amounts: array<t>): option<result<t, string>> =>
|
|
294
295
|
switch amounts {
|
|
295
296
|
| [] => None
|
|
296
|
-
| _ =>
|
|
297
|
-
|
|
298
|
-
m
|
|
299
|
-
|
|
297
|
+
| _ =>
|
|
298
|
+
Some(
|
|
299
|
+
amounts->Array.reduce(Ok(zero(~currency=(amounts->Array.getUnsafe(0)).currency)), (acc, m) =>
|
|
300
|
+
acc->Result.flatMap(total => add(total, m))
|
|
301
|
+
),
|
|
302
|
+
)
|
|
300
303
|
}
|
package/src/semantic/Offload.res
CHANGED
|
@@ -45,7 +45,6 @@ structure: @s.matches(Offload.optionSchema(~store="pluginStructures", pluginStru
|
|
|
45
45
|
option<Offload.payload<pluginStructure>>
|
|
46
46
|
```
|
|
47
47
|
*/
|
|
48
|
-
|
|
49
48
|
/** The reference an offloaded value carries: which store holds it, the
|
|
50
49
|
content-addressed key, the content hash (== the key's basis), and the byte
|
|
51
50
|
length. `hash` is redundant with `key` today (`key` is `sha256/<hash>`) but
|
|
@@ -75,7 +74,7 @@ Parameterised by the inner value's schema because the `Inline` arm round-trips
|
|
|
75
74
|
through it. The `Offloaded` arm round-trips through `offloadedRefSchema` under the
|
|
76
75
|
sentinel key. See the module doc for why this is untagged.
|
|
77
76
|
*/
|
|
78
|
-
// Both arms are *declared* (`S.object` / `S.shape`) rather than hand-written as
|
|
77
|
+
let // Both arms are *declared* (`S.object` / `S.shape`) rather than hand-written as
|
|
79
78
|
// `S.transform` pairs, and that is load-bearing, not a style choice. A transform
|
|
80
79
|
// arm is opaque: sury cannot see what it accepts, so it must offer every value to
|
|
81
80
|
// the arm's own serializer and let it reject. Two things follow, both of which bit
|
|
@@ -89,7 +88,7 @@ sentinel key. See the module doc for why this is untagged.
|
|
|
89
88
|
// A declared arm has neither problem: sury derives both directions from the shape,
|
|
90
89
|
// discriminates on it, and never runs user code to find out. One transform arm is
|
|
91
90
|
// enough to bring both failures back, so keep every arm declarative.
|
|
92
|
-
|
|
91
|
+
schema = (inner: S.t<'a>): S.t<payload<'a>> => {
|
|
93
92
|
// Offloaded arm: an object carrying the reserved sentinel key, tried first so an
|
|
94
93
|
// offloaded value is never mistaken for an inline one.
|
|
95
94
|
let offloadedArm = S.object(s => Offloaded(s.field(sentinelKey, offloadedRefSchema)))
|
|
@@ -115,7 +114,10 @@ let forStore = (
|
|
|
115
114
|
~threshold: option<int>=?,
|
|
116
115
|
inner: S.t<'a>,
|
|
117
116
|
): S.t<payload<'a>> =>
|
|
118
|
-
schema(inner)->Semantic.mark(
|
|
117
|
+
schema(inner)->Semantic.mark(
|
|
118
|
+
~id=Semantic.Id.offload,
|
|
119
|
+
~payload=StoredIn({plugin, store, threshold}),
|
|
120
|
+
)
|
|
119
121
|
|
|
120
122
|
/**
|
|
121
123
|
The codec wrapped for an **optional** field, plus the `StoredIn` marker. This is
|
package/src/semantic/Percent.res
CHANGED
|
@@ -13,7 +13,6 @@ fractions are allowed — `99.95` is a percentage.
|
|
|
13
13
|
}
|
|
14
14
|
```
|
|
15
15
|
*/
|
|
16
|
-
|
|
17
16
|
/** The percentage's representation. Transparent `float`: the marker refines an
|
|
18
17
|
existing numeric field, so nothing stored changes. */
|
|
19
18
|
type t = float
|
|
@@ -27,8 +26,9 @@ let fromFloat = (raw: float): result<t, string> =>
|
|
|
27
26
|
Error(`a percentage must be a finite number, got ${Float.toString(raw)}`)
|
|
28
27
|
} else if raw < 0.0 || raw > 100.0 {
|
|
29
28
|
Error(
|
|
30
|
-
`a percentage runs from 0 to 100, got ${Float.toString(
|
|
31
|
-
|
|
29
|
+
`a percentage runs from 0 to 100, got ${Float.toString(
|
|
30
|
+
raw,
|
|
31
|
+
)}. ` ++ `This scale is 0–100, not 0–1 — a fraction multiplies by 100 first.`,
|
|
32
32
|
)
|
|
33
33
|
} else {
|
|
34
34
|
Ok(raw)
|
package/src/semantic/Phone.res
CHANGED
|
@@ -23,11 +23,12 @@ Declaration order rather than a name rule: a view with two image fields has
|
|
|
23
23
|
already said which one comes first, and guessing from names would let a field
|
|
24
24
|
called `thumbnail` outrank the one the author put at the top.
|
|
25
25
|
*/
|
|
26
|
-
|
|
27
26
|
/** Where the ref string sits, relative to the field that carries it. */
|
|
28
27
|
type shape =
|
|
29
|
-
|
|
30
|
-
|
|
|
28
|
+
/** The field's own value is the ref. */
|
|
29
|
+
| Scalar
|
|
30
|
+
/** The ref is one member of the field's record. */
|
|
31
|
+
| Member(string)
|
|
31
32
|
|
|
32
33
|
type source = {
|
|
33
34
|
field: string,
|
|
@@ -9,7 +9,6 @@ semantic is a new value rather than a new branch.
|
|
|
9
9
|
The payload is a typed variant because the vocabulary is framework-owned and
|
|
10
10
|
closed — which also keeps `Reference.getTarget` total.
|
|
11
11
|
*/
|
|
12
|
-
|
|
13
12
|
/** Which entity a reference field points to. */
|
|
14
13
|
type referenceTarget = {entity: string, plugin: option<string>}
|
|
15
14
|
|
|
@@ -122,18 +121,16 @@ let mark = (schema: S.t<'a>, ~id: string, ~payload: payload=Plain): S.t<'a> =>
|
|
|
122
121
|
|
|
123
122
|
/** A schema that validates with `check` and carries the semantic `id`, derived
|
|
124
123
|
from the constructor so no second grammar can drift from it. */
|
|
125
|
-
// sury's refiner takes a fixed message, so `check`'s per-value reason is lost
|
|
124
|
+
let // sury's refiner takes a fixed message, so `check`'s per-value reason is lost
|
|
126
125
|
// here; call the scalar's own `fromString`/`fromFloat` to report which rule broke.
|
|
127
|
-
|
|
126
|
+
refined = (base: S.t<'a>, ~id: string, ~check: 'a => result<'a, string>): S.t<'a> =>
|
|
128
127
|
base
|
|
129
|
-
->S.refine(
|
|
130
|
-
value
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
~error=`expected a valid ${id}`,
|
|
136
|
-
)
|
|
128
|
+
->S.refine(value =>
|
|
129
|
+
switch check(value) {
|
|
130
|
+
| Ok(_) => true
|
|
131
|
+
| Error(_) => false
|
|
132
|
+
}
|
|
133
|
+
, ~error=`expected a valid ${id}`)
|
|
137
134
|
->mark(~id)
|
|
138
135
|
|
|
139
136
|
/** A value as it should read back to whoever typed it — rejection messages quote
|
|
@@ -50,7 +50,6 @@ wire format, or any stored value changing.
|
|
|
50
50
|
})
|
|
51
51
|
```
|
|
52
52
|
*/
|
|
53
|
-
|
|
54
53
|
/** The ref's representation. Transparent `string` on purpose: the marker refines
|
|
55
54
|
an existing `string` field rather than replacing it, so the field's runtime
|
|
56
55
|
representation — and therefore every stored event — is unchanged. A sealed
|
|
@@ -61,8 +60,7 @@ type t = string
|
|
|
61
60
|
external unsafe: string => t = "%identity"
|
|
62
61
|
external toString: t => string = "%identity"
|
|
63
62
|
|
|
64
|
-
let segmentIsSafe = (segment: string) =>
|
|
65
|
-
segment !== "" && segment !== "." && segment !== ".."
|
|
63
|
+
let segmentIsSafe = (segment: string) => segment !== "" && segment !== "." && segment !== ".."
|
|
66
64
|
|
|
67
65
|
/**
|
|
68
66
|
Validate a raw string as a storage ref, saying why when it is not one.
|
|
@@ -74,7 +72,9 @@ schema validation cannot drift apart.
|
|
|
74
72
|
let fromString = (raw: string): result<t, string> =>
|
|
75
73
|
if !String.startsWith(raw, "/") {
|
|
76
74
|
Error(
|
|
77
|
-
`expected an origin-relative storage ref starting with "/", got ${raw
|
|
75
|
+
`expected an origin-relative storage ref starting with "/", got ${raw
|
|
76
|
+
->JSON.Encode.string
|
|
77
|
+
->JSON.stringify}. External URLs and data: URIs are not storage refs.`,
|
|
78
78
|
)
|
|
79
79
|
} else if String.startsWith(raw, "//") {
|
|
80
80
|
Error(`protocol-relative refs are not storage refs: ${raw}`)
|
|
@@ -94,7 +94,7 @@ The refinement a ref-holding field validates against, carrying no semantic yet.
|
|
|
94
94
|
The `Uploadable*` types re-label this rather than restating the grammar, so a
|
|
95
95
|
re-labelling cannot validate differently from what it re-labels.
|
|
96
96
|
*/
|
|
97
|
-
// The empty string is admitted as the "no object" sentinel. The fields this
|
|
97
|
+
let // The empty string is admitted as the "no object" sentinel. The fields this
|
|
98
98
|
// marks are non-optional today, and a producer with nothing to reference —
|
|
99
99
|
// a supplier feed carrying no image, say — already writes `""` to mean
|
|
100
100
|
// absence. Rejecting it here would break a legitimate existing value and
|
|
@@ -107,16 +107,14 @@ re-labelling cannot validate differently from what it re-labels.
|
|
|
107
107
|
// sury's refiner is a predicate with a fixed message, so the per-value reason
|
|
108
108
|
// `fromString` returns is not threaded through; call `fromString` directly
|
|
109
109
|
// when the caller needs to report which rule the value broke.
|
|
110
|
-
|
|
111
|
-
S.string->S.refine(
|
|
112
|
-
value
|
|
113
|
-
value
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
~error=`expected an origin-relative storage ref: "/" followed by a prefix and an object path, or "" for no object`,
|
|
119
|
-
)
|
|
110
|
+
refinement: S.t<t> =
|
|
111
|
+
S.string->S.refine(value =>
|
|
112
|
+
value === "" ||
|
|
113
|
+
switch fromString(value) {
|
|
114
|
+
| Ok(_) => true
|
|
115
|
+
| Error(_) => false
|
|
116
|
+
}
|
|
117
|
+
, ~error=`expected an origin-relative storage ref: "/" followed by a prefix and an object path, or "" for no object`)
|
|
120
118
|
|
|
121
119
|
/**
|
|
122
120
|
The sury schema for a field holding a ref into a named store.
|
|
@@ -139,8 +137,10 @@ let getStore = (schema: S.t<'a>): option<Semantic.storeTarget> =>
|
|
|
139
137
|
|
|
140
138
|
/** How many refs one field holds. */
|
|
141
139
|
type arity =
|
|
142
|
-
|
|
143
|
-
|
|
|
140
|
+
/** A `string` field: one ref. */
|
|
141
|
+
| Single
|
|
142
|
+
/** An `array<string>` field: zero or more refs. */
|
|
143
|
+
| Multiple
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
146
|
The store a *field* declares, looking through an array wrapper, with the arity
|
|
@@ -6,7 +6,6 @@ Grammar: `{{ path }}`, `{{ path | formatter }}`, `{{# if path }}…{{/ if }}` an
|
|
|
6
6
|
evaluated, an unresolved path renders a visible placeholder rather than throwing,
|
|
7
7
|
and a field the schema marks `@sensitive` is withheld.
|
|
8
8
|
*/
|
|
9
|
-
|
|
10
9
|
/** A path into the payload, in **wire** field names. `text` is what was written,
|
|
11
10
|
so a placeholder can name it. */
|
|
12
11
|
type path = {text: string, relative: bool, segments: array<string>}
|
|
@@ -54,8 +53,7 @@ let parsePath = (raw: string, ~insideEach: bool): result<path, string> => {
|
|
|
54
53
|
segments->Array.every(segment => segmentGrammar->RegExp.test(segment))
|
|
55
54
|
? Ok({text, relative, segments})
|
|
56
55
|
: Error(
|
|
57
|
-
`"${text}" is not a path — segments are letters, digits and ` ++
|
|
58
|
-
`underscores, separated by dots`,
|
|
56
|
+
`"${text}" is not a path — segments are letters, digits and ` ++ `underscores, separated by dots`,
|
|
59
57
|
)
|
|
60
58
|
}
|
|
61
59
|
}
|
|
@@ -268,10 +266,10 @@ let isWithheld = (schema: option<S.t<unknown>>): bool =>
|
|
|
268
266
|
| None => false
|
|
269
267
|
| Some(field) =>
|
|
270
268
|
Sensitive.isFieldSensitive(field) ||
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
269
|
+
switch Semantic.getFrom(field) {
|
|
270
|
+
| Some({id}) => Sensitive.impliedBySemantic(id)
|
|
271
|
+
| None => false
|
|
272
|
+
}
|
|
275
273
|
}
|
|
276
274
|
|
|
277
275
|
let parseSafely = (json: JSON.t, schema: S.t<'a>): option<'a> =>
|