@reventlessdev/reventless-spec 3.0.0-alpha.126 → 3.0.0-alpha.128
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 +29 -0
- package/package.json +1 -1
- package/schema/platform-api.graphql +1 -0
- package/src/components/DcbScopeInference.res +42 -1
- package/src/components/DcbScopeInference.res.mjs +25 -5
- package/src/components/DcbTag.res +19 -0
- package/src/components/DcbTag.res.mjs +3 -1
- package/src/components/Plugin.res +8 -0
- package/src/components/Plugin.res.mjs +2 -1
- package/src/components/Sensitive.res +137 -0
- package/src/components/Sensitive.res.mjs +74 -0
- package/src/generator/Codegen.res +21 -0
- package/src/generator/Codegen.res.mjs +8 -0
- package/src/semantic/Bytes.res +21 -21
- package/src/semantic/Bytes.res.mjs +35 -0
- package/src/semantic/CaptionedImage.res +100 -0
- package/src/semantic/CaptionedImage.res.mjs +35 -0
- package/src/semantic/Duration.res +22 -24
- package/src/semantic/Duration.res.mjs +43 -0
- package/src/semantic/MemberRef.res +119 -0
- package/src/semantic/MemberRef.res.mjs +57 -0
- package/src/semantic/Messaging.res +24 -0
- package/src/semantic/Messaging.res.mjs +9 -0
- package/src/semantic/Offload.res.mjs +2 -2
- package/src/semantic/Percent.res +9 -20
- package/src/semantic/Percent.res.mjs +5 -0
- package/src/semantic/Semantic.res +63 -0
- package/src/semantic/Semantic.res.mjs +39 -0
- package/src/semantic/StorageRef.res.mjs +1 -1
- package/src/semantic/Template.res +412 -0
- package/src/semantic/Template.res.mjs +545 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,35 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.128 (2026-09-04)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **notifications:** the wording is a table of values, not a switch ([bfcc939](https://github.com/ReventlessDev/reventless-core/commit/bfcc93940b81060d201fd231447fb14dcf48d80e))
|
|
11
|
+
* **plugin:** a slice publishes which topics it subscribes to ([c689695](https://github.com/ReventlessDev/reventless-core/commit/c6896957ecb636204678222ac5a26b30870439cb))
|
|
12
|
+
* **spec,traits:** an image carries the text that goes with it, and a set's first member is its primary ([e4e5845](https://github.com/ReventlessDev/reventless-core/commit/e4e58458aee7b3db5564727d358a3a9767362ca4))
|
|
13
|
+
* **spec:** a field can say it selects one of the values its row already holds ([2ae50c3](https://github.com/ReventlessDev/reventless-core/commit/2ae50c34deee48508a9f1f39e3eef6a5d2f5df00))
|
|
14
|
+
* **spec:** a field can say its value must not be rendered into a message ([3183f53](https://github.com/ReventlessDev/reventless-core/commit/3183f53a0ae667f794bbd1a3d77acf362dfa8e57))
|
|
15
|
+
* **spec:** a message template renders a payload through its own schema ([39e3f61](https://github.com/ReventlessDev/reventless-core/commit/39e3f6126bd831a316323b4663bc37c50cdfc704))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# 3.0.0-alpha.127 (2026-09-02)
|
|
19
|
+
|
|
20
|
+
* feat(aws)!: the messaging sender is configuration, and a stack can choose to only log ([23b8b4b](https://github.com/ReventlessDev/reventless-core/commit/23b8b4bfe9c70555de4d74266ca686cb427485ca))
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
* **dcb:** a boundary that cannot derive its scope says so ([db79969](https://github.com/ReventlessDev/reventless-core/commit/db79969df36bd90607425f6a22b4624227cfc4a0))
|
|
24
|
+
|
|
25
|
+
### BREAKING CHANGES
|
|
26
|
+
|
|
27
|
+
* `Capability_Messaging_Ses.make` is replaced by
|
|
28
|
+
`Capability_Messaging.make(~name)`, which reads the transport and the address
|
|
29
|
+
from config; the SES module keeps only `emailSender`. A deployment that named its
|
|
30
|
+
sender in code must move it to `platform:messagingEmailSender` or the deploy is
|
|
31
|
+
refused.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
6
35
|
# 3.0.0-alpha.126 (2026-09-01)
|
|
7
36
|
|
|
8
37
|
**Note:** Version bump only for package @reventlessdev/reventless-spec
|
package/package.json
CHANGED
|
@@ -130,6 +130,40 @@ let foreignConsumedKeys = (s: sliceShape): array<string> => {
|
|
|
130
130
|
)
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
Which consumed arms cost a slice its partition — the actionable half of the
|
|
135
|
+
"no own partition key" ambiguity.
|
|
136
|
+
|
|
137
|
+
Rule 1 subtracts `foreignConsumedKeys` from `producedKeys`, so a slice whose only
|
|
138
|
+
produced key is also declared on a consumed arm it does not itself produce is left
|
|
139
|
+
with nothing. In practice that is almost always one mistake: a *lifecycle* arm
|
|
140
|
+
naming the id it is already partitioned by (`ProductAdded({productId})` on a slice
|
|
141
|
+
whose every event carries `productId`), which reads as "this id comes from a
|
|
142
|
+
foreign producer" when it is in fact this slice's own partition.
|
|
143
|
+
|
|
144
|
+
The ambiguity message can only say a partition was not found. This says which arm
|
|
145
|
+
to delete, which is the whole difference between a diagnostic and a puzzle — so it
|
|
146
|
+
is here, beside the rule that produces the ambiguity, rather than duplicated by
|
|
147
|
+
each surface that reports one.
|
|
148
|
+
|
|
149
|
+
Returns one entry per produced key that a foreign arm claims, naming those arms.
|
|
150
|
+
Empty when the slice has a partition (nothing to explain) or when the produced
|
|
151
|
+
keys are simply too many (a different ambiguity, answered by `@partitionTag`).
|
|
152
|
+
*/
|
|
153
|
+
let partitionBlockers = (s: sliceShape): array<(string, array<string>)> => {
|
|
154
|
+
let foreign = foreignConsumedKeys(s)
|
|
155
|
+
let ownProduced = Set.make()
|
|
156
|
+
s.produced->Array.forEach(e => ownProduced->Set.add(e.eventType))
|
|
157
|
+
producedKeys(s)
|
|
158
|
+
->Array.filter(k => foreign->Array.includes(k))
|
|
159
|
+
->Array.map(k => (
|
|
160
|
+
k,
|
|
161
|
+
s.consumed
|
|
162
|
+
->Array.filter(e => !(ownProduced->Set.has(e.eventType)) && e->keysOfEvent->Array.includes(k))
|
|
163
|
+
->Array.map(e => e.eventType),
|
|
164
|
+
))
|
|
165
|
+
}
|
|
166
|
+
|
|
133
167
|
/**
|
|
134
168
|
Per-slice cross-partition keys for the test harness, which has no global owner
|
|
135
169
|
map: a foreign-read key that is not the slice's own partition is read across
|
|
@@ -169,9 +203,16 @@ let infer = (slices: array<sliceShape>): derived => {
|
|
|
169
203
|
switch produced->Array.filter(k => !(foreign->Array.includes(k))) {
|
|
170
204
|
| [single] => partitionBySlice->Dict.set(s.sliceName, single)
|
|
171
205
|
| [] =>
|
|
206
|
+
// Name the arms that took the key. Almost always a lifecycle arm
|
|
207
|
+
// declaring the id the slice is already partitioned by, where the fix is
|
|
208
|
+
// to drop the field rather than to annotate around it.
|
|
209
|
+
let blame =
|
|
210
|
+
partitionBlockers(s)
|
|
211
|
+
->Array.map(((key, arms)) => `${arms->Array.join("/")} declares ${key}`)
|
|
212
|
+
->Array.join("; ")
|
|
172
213
|
let _ = ambiguities->Array.push((
|
|
173
214
|
s.sliceName,
|
|
174
|
-
|
|
215
|
+
`no own partition key — every produced *Id is read from a foreign producer (${blame}). If that field is this slice's own partition, remove it from the consumed arm; if the slice really is a pure join, add an explicit @partitionTag`,
|
|
175
216
|
))
|
|
176
217
|
| many =>
|
|
177
218
|
let _ = ambiguities->Array.push((
|
|
@@ -50,6 +50,24 @@ function foreignConsumedKeys(s) {
|
|
|
50
50
|
}));
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function partitionBlockers(s) {
|
|
54
|
+
let foreign = foreignConsumedKeys(s);
|
|
55
|
+
let ownProduced = new Set();
|
|
56
|
+
s.produced.forEach(e => {
|
|
57
|
+
ownProduced.add(e.eventType);
|
|
58
|
+
});
|
|
59
|
+
return producedKeys(s).filter(k => foreign.includes(k)).map(k => [
|
|
60
|
+
k,
|
|
61
|
+
s.consumed.filter(e => {
|
|
62
|
+
if (ownProduced.has(e.eventType)) {
|
|
63
|
+
return false;
|
|
64
|
+
} else {
|
|
65
|
+
return e.idFields.map(tagKeyOf).includes(k);
|
|
66
|
+
}
|
|
67
|
+
}).map(e => e.eventType)
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
|
|
53
71
|
function crossPartitionForSlice(s) {
|
|
54
72
|
let foreign = foreignConsumedKeys(s);
|
|
55
73
|
let scalar = commandScalarKeys(s);
|
|
@@ -93,12 +111,13 @@ function infer(slices) {
|
|
|
93
111
|
s.sliceName,
|
|
94
112
|
`multiple candidate partition keys (` + many.join(", ") + `) — add an explicit @partitionTag`
|
|
95
113
|
]);
|
|
96
|
-
|
|
97
|
-
ambiguities.push([
|
|
98
|
-
s.sliceName,
|
|
99
|
-
"no own partition key — every produced *Id is read from a foreign producer (pure join?); add an explicit @partitionTag"
|
|
100
|
-
]);
|
|
114
|
+
return;
|
|
101
115
|
}
|
|
116
|
+
let blame = partitionBlockers(s).map(param => param[1].join("/") + ` declares ` + param[0]).join("; ");
|
|
117
|
+
ambiguities.push([
|
|
118
|
+
s.sliceName,
|
|
119
|
+
`no own partition key — every produced *Id is read from a foreign producer (` + blame + `). If that field is this slice's own partition, remove it from the consumed arm; if the slice really is a pure join, add an explicit @partitionTag`
|
|
120
|
+
]);
|
|
102
121
|
return;
|
|
103
122
|
}
|
|
104
123
|
let single = many[0];
|
|
@@ -171,6 +190,7 @@ export {
|
|
|
171
190
|
consumedKeys,
|
|
172
191
|
commandScalarKeys,
|
|
173
192
|
foreignConsumedKeys,
|
|
193
|
+
partitionBlockers,
|
|
174
194
|
crossPartitionForSlice,
|
|
175
195
|
infer,
|
|
176
196
|
}
|
|
@@ -1008,6 +1008,15 @@ The DCB decision-read scope threaded into every StateChangeSlice callback:
|
|
|
1008
1008
|
type effectiveScope = {
|
|
1009
1009
|
crossPartitionTagKeys: array<string>,
|
|
1010
1010
|
tagKeysByEventType: dict<array<string>>,
|
|
1011
|
+
/** Slices whose partition did not resolve, as `(slice, reason)` — non-empty
|
|
1012
|
+
means the two fields above are the *annotated* fallback, not the derivation. */
|
|
1013
|
+
ambiguities: array<(string, string)>,
|
|
1014
|
+
/** Cross-partition keys the inference found that the fallback does not carry.
|
|
1015
|
+
Non-empty is the harmful case and only that: a reference read silently
|
|
1016
|
+
narrows to its own partition, so a slice decides against events it cannot
|
|
1017
|
+
see and rejects a command whose facts are in the log. Empty (even with
|
|
1018
|
+
ambiguities) means the fallback happens to agree and nothing is lost. */
|
|
1019
|
+
droppedCrossPartitionTagKeys: array<string>,
|
|
1011
1020
|
}
|
|
1012
1021
|
|
|
1013
1022
|
/**
|
|
@@ -1023,6 +1032,12 @@ the runtime decision query cannot diverge from the storage/GSI scope. Before thi
|
|
|
1023
1032
|
existed the entry point re-derived scope from annotations alone, silently dropping
|
|
1024
1033
|
inferred cross-partition reference reads — see
|
|
1025
1034
|
`docs/analysis/dcb-runtime-scope-annotation-drift.md`.
|
|
1035
|
+
|
|
1036
|
+
The fallback reports itself. `ambiguities` says it happened and
|
|
1037
|
+
`droppedCrossPartitionTagKeys` says whether it cost anything, so a caller can
|
|
1038
|
+
distinguish a fallback that agrees with the derivation from one that quietly
|
|
1039
|
+
narrows a reference read — the second is a wrong answer waiting for the first
|
|
1040
|
+
command that needs the key, and callers are expected to raise it.
|
|
1026
1041
|
*/
|
|
1027
1042
|
let deriveEffectiveScope = (slices: array<sliceSchemas>): effectiveScope => {
|
|
1028
1043
|
let producedSchemas = slices->Array.map(s => s.eventSchema)
|
|
@@ -1055,6 +1070,10 @@ let deriveEffectiveScope = (slices: array<sliceSchemas>): effectiveScope => {
|
|
|
1055
1070
|
{
|
|
1056
1071
|
crossPartitionTagKeys: useInferred ? inferred.crossPartitionTagKeys : annotatedCross,
|
|
1057
1072
|
tagKeysByEventType: useInferred ? inferred.tagKeysByEventType : annotatedTagKeys,
|
|
1073
|
+
ambiguities: inferred.ambiguities,
|
|
1074
|
+
droppedCrossPartitionTagKeys: useInferred
|
|
1075
|
+
? []
|
|
1076
|
+
: inferred.crossPartitionTagKeys->Array.filter(k => !(annotatedCross->Array.includes(k))),
|
|
1058
1077
|
}
|
|
1059
1078
|
}
|
|
1060
1079
|
|
|
@@ -636,7 +636,9 @@ function deriveEffectiveScope(slices) {
|
|
|
636
636
|
let useInferred = inferred.ambiguities.length === 0;
|
|
637
637
|
return {
|
|
638
638
|
crossPartitionTagKeys: useInferred ? inferred.crossPartitionTagKeys : annotatedCross,
|
|
639
|
-
tagKeysByEventType: useInferred ? inferred.tagKeysByEventType : annotatedTagKeys
|
|
639
|
+
tagKeysByEventType: useInferred ? inferred.tagKeysByEventType : annotatedTagKeys,
|
|
640
|
+
ambiguities: inferred.ambiguities,
|
|
641
|
+
droppedCrossPartitionTagKeys: useInferred ? [] : inferred.crossPartitionTagKeys.filter(k => !annotatedCross.includes(k))
|
|
640
642
|
};
|
|
641
643
|
}
|
|
642
644
|
|
|
@@ -261,6 +261,14 @@ type outboundTranslationSliceDef = {
|
|
|
261
261
|
externalSystem: @s.matches(stringOptionSchema) option<string>,
|
|
262
262
|
/** Chapter grouping band — see `queryableDef.chapter`. */
|
|
263
263
|
chapter: @s.matches(stringOptionSchema) option<string>,
|
|
264
|
+
/** The topics this slice subscribes to, as `Spec.sourceNames` declares them —
|
|
265
|
+
an Aggregate's `Spec.name` or a DCB source name. `Some([])` is the declared
|
|
266
|
+
default and means this plugin's own DCB log; `None` is an older structure
|
|
267
|
+
that did not publish the field. A different fact from `consumedEventTypes`,
|
|
268
|
+
which names event types and not where they came from — two topics carrying
|
|
269
|
+
an event of the same name are indistinguishable there. Optional so an older
|
|
270
|
+
reader ignores it. */
|
|
271
|
+
consumedSources: @s.matches(stringArrayOptionSchema) option<array<string>>,
|
|
264
272
|
}
|
|
265
273
|
|
|
266
274
|
@schema
|
|
@@ -172,7 +172,8 @@ let outboundTranslationSliceDefSchema = Sury.$schema(s => ({
|
|
|
172
172
|
inboundCommandTypes: s.m(Sury.array(Sury.string)),
|
|
173
173
|
targetName: s.m(stringOptionSchema),
|
|
174
174
|
externalSystem: s.m(stringOptionSchema),
|
|
175
|
-
chapter: s.m(stringOptionSchema)
|
|
175
|
+
chapter: s.m(stringOptionSchema),
|
|
176
|
+
consumedSources: s.m(stringArrayOptionSchema)
|
|
176
177
|
}));
|
|
177
178
|
|
|
178
179
|
let inboundTranslationSliceDefSchema = Sury.$schema(s => ({
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Marks a field whose value must not be rendered into content a person receives.
|
|
3
|
+
|
|
4
|
+
`@sensitive` is a *position*, not a type: it says "whatever this field holds, do
|
|
5
|
+
not put it in an outbound message". What the field **is** — an email, a token, a
|
|
6
|
+
reference — is declared separately, exactly as owning and tagging are separate
|
|
7
|
+
facts about one field.
|
|
8
|
+
|
|
9
|
+
It is declared where the field is declared, and read by anything that composes
|
|
10
|
+
text somebody will read. That is the whole reason it lives here rather than
|
|
11
|
+
beside whichever consumer happens to need it first: a marking that exists in one
|
|
12
|
+
consumer is a marking the rest of the system cannot honour, and the domain model
|
|
13
|
+
is the only place that knows which values are sensitive.
|
|
14
|
+
|
|
15
|
+
## Absent means "not stated", not "safe"
|
|
16
|
+
|
|
17
|
+
A reader that misses the marker leaks the value, so the failure is *open* — the
|
|
18
|
+
same shape as `Owner`, and the reason `isFieldSensitive` follows the wrappers
|
|
19
|
+
rather than leaving each consumer to unwrap for itself.
|
|
20
|
+
|
|
21
|
+
## Some fields need no annotation
|
|
22
|
+
|
|
23
|
+
A field whose semantic already says it is a contact detail is sensitive without
|
|
24
|
+
anybody writing it down — see `impliedBySemantic`. The annotation is for the
|
|
25
|
+
values a type cannot betray: a token, a note, a reference somebody chose.
|
|
26
|
+
|
|
27
|
+
`@sensitive` is the authoring form and is sugar over the constructors below, the
|
|
28
|
+
way `@owner` is sugar over `Owner`'s. Write `@s.matches(Sensitive.mark(…))` by
|
|
29
|
+
hand where the ppx shorthand cannot reach — a file with no `@@reventless.spec`
|
|
30
|
+
annotation, or a field whose type is neither `string` nor `option<string>`.
|
|
31
|
+
|
|
32
|
+
@example
|
|
33
|
+
```rescript
|
|
34
|
+
@schema type event =
|
|
35
|
+
PasswordReset({
|
|
36
|
+
@owner customerId: string,
|
|
37
|
+
@sensitive resetToken: string,
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
*/
|
|
41
|
+
let sensitiveId: S.Metadata.Id.t<bool> = S.Metadata.Id.make(
|
|
42
|
+
~namespace="reventless",
|
|
43
|
+
~name="sensitive",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
Layers the marker onto a schema that already says something else.
|
|
48
|
+
|
|
49
|
+
Sensitivity is independent of everything else a field declares — it may equally
|
|
50
|
+
be a DCB tag, an owner, or a reference — but a field carries at most one
|
|
51
|
+
`@s.matches`, so this composes by *wrapping* what the field already resolved to
|
|
52
|
+
rather than replacing it. Replacing would silently drop the field's tag, and a
|
|
53
|
+
dropped tag is a decision read that quietly misses events.
|
|
54
|
+
*/
|
|
55
|
+
let mark = (schema: S.t<'a>): S.t<'a> => schema->S.Metadata.set(~id=sensitiveId, true)
|
|
56
|
+
|
|
57
|
+
/** A string field that must not be rendered outbound. */
|
|
58
|
+
let string: S.t<string> = S.string->mark
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
An `option<string>` field that must not be rendered outbound.
|
|
62
|
+
|
|
63
|
+
Needed because `@s.matches` on an explicitly-`option`-typed field must supply the
|
|
64
|
+
whole field schema, wrapper included. The `f?: string` form needs nothing extra:
|
|
65
|
+
sury wraps the annotated inner schema itself, and `isFieldSensitive` looks
|
|
66
|
+
through that wrapper either way.
|
|
67
|
+
*/
|
|
68
|
+
let optionString: S.t<option<string>> = S.option(string)
|
|
69
|
+
|
|
70
|
+
/** Whether this exact schema carries the marker. Does not look through wrappers. */
|
|
71
|
+
let isSensitive = (schema: S.t<unknown>): bool =>
|
|
72
|
+
S.Metadata.get(schema, ~id=sensitiveId)->Option.getOr(false)
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
Whether a *field* is sensitive, wherever inside the field's type the marker sits.
|
|
76
|
+
|
|
77
|
+
An optional field keeps its marker inside the union wrapper, so a walk reading
|
|
78
|
+
only the outer schema answers `false` for `token?: string` — and here that means
|
|
79
|
+
the value is rendered into a message rather than withheld. Object properties are
|
|
80
|
+
deliberately not followed, for the same reason `Owner` does not follow them: a
|
|
81
|
+
marker on a nested record's field belongs to that field, and attributing it to
|
|
82
|
+
the enclosing one would withhold a whole record because one leaf is private.
|
|
83
|
+
*/
|
|
84
|
+
let isFieldSensitive = (schema: S.t<unknown>): bool =>
|
|
85
|
+
isSensitive(schema) ||
|
|
86
|
+
switch schema->Semantic.unwrapOptional {
|
|
87
|
+
| Some(inner) => isSensitive(inner)
|
|
88
|
+
| None => false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
The sensitive fields declared on an object schema, in declaration order.
|
|
93
|
+
|
|
94
|
+
Threaded to the JSON-Schema walk the way `Owner.fieldNames` is, and for the same
|
|
95
|
+
reason: the IR is shape-driven and sensitivity is not a shape — the field stays a
|
|
96
|
+
plain string either way. Reading it off the sury schema here also keeps it
|
|
97
|
+
available on command and event variants, which carry no annotation spec.
|
|
98
|
+
*/
|
|
99
|
+
let fieldNamesOfProperties = (properties: dict<S.t<unknown>>): array<string> =>
|
|
100
|
+
properties
|
|
101
|
+
->Dict.toArray
|
|
102
|
+
->Array.filterMap(((propName, propSchema)) =>
|
|
103
|
+
isFieldSensitive(propSchema) ? Some(propName) : None
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
let fieldNames = (schema: S.t<unknown>): array<string> =>
|
|
107
|
+
switch schema {
|
|
108
|
+
| Object({properties}) => fieldNamesOfProperties(properties)
|
|
109
|
+
| _ => []
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
The sensitive fields of one constructor of a command or event union.
|
|
114
|
+
|
|
115
|
+
The form a renderer actually needs: it composes text about **one** occurrence, so
|
|
116
|
+
only that variant's fields have anything to say. `OrderPlaced` and
|
|
117
|
+
`PasswordReset` live in the same union and know nothing about each other's
|
|
118
|
+
fields — resolving by TAG here rather than at the call site is what stops a
|
|
119
|
+
template from consulting the wrong arm.
|
|
120
|
+
|
|
121
|
+
Answers `[]` for an unknown tag and for a payload-less variant, both of which
|
|
122
|
+
mean the same thing to a caller: nothing on this event is withheld.
|
|
123
|
+
*/
|
|
124
|
+
let variantFieldNames = (schema: S.t<unknown>, ~variant: string): array<string> =>
|
|
125
|
+
schema->Semantic.unionVariant(~variant)->Option.mapOr([], fieldNames)
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
The semantics that are sensitive without an annotation.
|
|
129
|
+
|
|
130
|
+
A contact detail is one whether or not anybody remembered to mark it, so the
|
|
131
|
+
rule is stated once here rather than left to each consumer to remember. Kept
|
|
132
|
+
deliberately short: it holds only the semantics whose *whole meaning* is "how to
|
|
133
|
+
reach a particular person". A postal address or a display name can be sensitive
|
|
134
|
+
in context, and context is exactly what an annotation is for.
|
|
135
|
+
*/
|
|
136
|
+
let impliedBySemantic = (semanticId: string): bool =>
|
|
137
|
+
semanticId === Semantic.Id.email || semanticId === Semantic.Id.phone
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Sury from "sury";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
|
|
7
|
+
|
|
8
|
+
let sensitiveId = Sury.$Metadata_Id_make("reventless", "sensitive");
|
|
9
|
+
|
|
10
|
+
function mark(schema) {
|
|
11
|
+
return Sury.$Metadata_set(schema, sensitiveId, true);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let string = Sury.$Metadata_set(Sury.string, sensitiveId, true);
|
|
15
|
+
|
|
16
|
+
let optionString = Sury.$option(string);
|
|
17
|
+
|
|
18
|
+
function isSensitive(schema) {
|
|
19
|
+
return Stdlib_Option.getOr(Sury.$Metadata_get(schema, sensitiveId), false);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isFieldSensitive(schema) {
|
|
23
|
+
if (isSensitive(schema)) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
let inner = Semantic$Reventless.unwrapOptional(schema);
|
|
27
|
+
if (inner !== undefined) {
|
|
28
|
+
return isSensitive(inner);
|
|
29
|
+
} else {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fieldNamesOfProperties(properties) {
|
|
35
|
+
return Stdlib_Array.filterMap(Object.entries(properties), param => {
|
|
36
|
+
if (isFieldSensitive(param[1])) {
|
|
37
|
+
return param[0];
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fieldNames(schema) {
|
|
43
|
+
if (schema.type === "object") {
|
|
44
|
+
return fieldNamesOfProperties(schema.properties);
|
|
45
|
+
} else {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function variantFieldNames(schema, variant) {
|
|
51
|
+
return Stdlib_Option.mapOr(Semantic$Reventless.unionVariant(schema, variant), [], fieldNames);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function impliedBySemantic(semanticId) {
|
|
55
|
+
if (semanticId === Semantic$Reventless.Id.email) {
|
|
56
|
+
return true;
|
|
57
|
+
} else {
|
|
58
|
+
return semanticId === Semantic$Reventless.Id.phone;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
sensitiveId,
|
|
64
|
+
mark,
|
|
65
|
+
string,
|
|
66
|
+
optionString,
|
|
67
|
+
isSensitive,
|
|
68
|
+
isFieldSensitive,
|
|
69
|
+
fieldNamesOfProperties,
|
|
70
|
+
fieldNames,
|
|
71
|
+
variantFieldNames,
|
|
72
|
+
impliedBySemantic,
|
|
73
|
+
}
|
|
74
|
+
/* sensitiveId Not a pure module */
|
|
@@ -608,6 +608,27 @@ let renderComposition = (
|
|
|
608
608
|
pluginNameToEnvBase(config.name) ++
|
|
609
609
|
"_UI_BUNDLE_URL\"",
|
|
610
610
|
)
|
|
611
|
+
// The DCB boundary's slices as schemas, outside `Make` because the scope they
|
|
612
|
+
// determine is a property of the specs and not of any platform. Emitted rather
|
|
613
|
+
// than hand-listed so a slice added tomorrow is in it: this is the only value
|
|
614
|
+
// from which the whole boundary's scope can be derived without applying the
|
|
615
|
+
// functor, which is what lets a test — and the repo's scope check — assert that
|
|
616
|
+
// it resolves. A per-slice test cannot: one slice never has a boundary.
|
|
617
|
+
if resolved.stateChangeSlices->Array.length > 0 {
|
|
618
|
+
lines->Array.push("")
|
|
619
|
+
lines->Array.push("let dcbSliceSchemas: array<Reventless.DcbTag.sliceSchemas> = [")
|
|
620
|
+
resolved.stateChangeSlices->Array.forEach(stem =>
|
|
621
|
+
lines->Array.push(
|
|
622
|
+
" {" ++
|
|
623
|
+
`name: ${stem}.name, ` ++
|
|
624
|
+
`commandSchema: ${stem}.commandSchema->S.castToUnknown, ` ++
|
|
625
|
+
`consumedEventSchema: ${stem}.consumedEventSchema->S.castToUnknown, ` ++
|
|
626
|
+
`eventSchema: ${stem}.eventSchema->S.castToUnknown` ++ "},",
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
lines->Array.push("]")
|
|
630
|
+
}
|
|
631
|
+
|
|
611
632
|
lines->Array.push("")
|
|
612
633
|
lines->Array.push("module Make = (Platform: ReventlessInfra.Platform.T) => {")
|
|
613
634
|
|
|
@@ -399,6 +399,14 @@ function renderComposition(config, resolved, componentChapters) {
|
|
|
399
399
|
lines.push("// AUTO-GENERATED — do not edit. Run `npm run generate` to update.");
|
|
400
400
|
lines.push("");
|
|
401
401
|
lines.push("@val external uiBundleUrl: option<string> = \"process.env." + pluginNameToEnvBase(config.name) + "_UI_BUNDLE_URL\"");
|
|
402
|
+
if (resolved.stateChangeSlices.length !== 0) {
|
|
403
|
+
lines.push("");
|
|
404
|
+
lines.push("let dcbSliceSchemas: array<Reventless.DcbTag.sliceSchemas> = [");
|
|
405
|
+
resolved.stateChangeSlices.forEach(stem => {
|
|
406
|
+
lines.push(" {" + (`name: ` + stem + `.name, `) + (`commandSchema: ` + stem + `.commandSchema->S.castToUnknown, `) + (`consumedEventSchema: ` + stem + `.consumedEventSchema->S.castToUnknown, `) + (`eventSchema: ` + stem + `.eventSchema->S.castToUnknown`) + "},");
|
|
407
|
+
});
|
|
408
|
+
lines.push("]");
|
|
409
|
+
}
|
|
402
410
|
lines.push("");
|
|
403
411
|
lines.push("module Make = (Platform: ReventlessInfra.Platform.T) => {");
|
|
404
412
|
let push = sectionLines => {
|
package/src/semantic/Bytes.res
CHANGED
|
@@ -1,26 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
Marks a numeric field as a count of bytes.
|
|
2
|
+
Marks a numeric field as a count of bytes: finite, whole, zero or greater.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
wants to be. It is the wrong one here anyway: ReScript's `int` is int32, and
|
|
8
|
-
sury enforces that, so an `int` byte count silently caps at 2,147,483,647 — just
|
|
9
|
-
under 2 GiB. A type whose stated job is file sizes cannot stop at 2 GB.
|
|
10
|
-
|
|
11
|
-
`float` is a JS number, exact for every integer up to 2^53 — nine petabytes,
|
|
12
|
-
which is enough. The discreteness `int` would have given for free is recovered
|
|
13
|
-
by checking it: the grammar below rejects a fractional byte count, so the type
|
|
14
|
-
still means what `int` meant, minus the ceiling.
|
|
15
|
-
|
|
16
|
-
The wire is unaffected either way — both are JSON numbers — so this is a source
|
|
17
|
-
choice, not a format one. A field genuinely bounded below 2 GiB can still be
|
|
18
|
-
declared `int` and left unmarked; this type is for the ones that are not.
|
|
19
|
-
|
|
20
|
-
## The grammar
|
|
21
|
-
|
|
22
|
-
A finite, whole number, zero or greater. Zero is a real byte count — an empty
|
|
23
|
-
object — so it is accepted.
|
|
4
|
+
`float` rather than `int` because ReScript's `int` is int32, which caps a byte
|
|
5
|
+
count just under 2 GiB; wholeness is recovered by the grammar below. The wire is
|
|
6
|
+
a JSON number either way.
|
|
24
7
|
|
|
25
8
|
@example
|
|
26
9
|
```rescript
|
|
@@ -52,3 +35,20 @@ let fromFloat = (raw: float): result<t, string> =>
|
|
|
52
35
|
|
|
53
36
|
/** The sury schema for a byte-count field. Use with `@s.matches(Reventless.Bytes.schema)`. */
|
|
54
37
|
let schema: S.t<t> = S.float->Semantic.refined(~id=Semantic.Id.bytes, ~check=fromFloat)
|
|
38
|
+
|
|
39
|
+
/** Binary, because a byte count is what a filesystem reports. */
|
|
40
|
+
let step = 1024.0
|
|
41
|
+
let units = ["B", "KB", "MB", "GB", "TB", "PB"]
|
|
42
|
+
|
|
43
|
+
/** The count as text — `"512 B"`, `"1.5 KB"`, `"2 MB"`. Locale-independent, the
|
|
44
|
+
way `Money.format` is, so one value reads the same everywhere. */
|
|
45
|
+
let format = (b: t): string => {
|
|
46
|
+
let last = Array.length(units) - 1
|
|
47
|
+
let rec reduce = (value: float, index: int): (float, string) =>
|
|
48
|
+
value < step || index >= last
|
|
49
|
+
? (value, units->Array.getUnsafe(index))
|
|
50
|
+
: reduce(value /. step, index + 1)
|
|
51
|
+
let (value, unit) = reduce(b, 0)
|
|
52
|
+
// One decimal, and only when it says something: 2097152 is "2 MB", not "2.0 MB".
|
|
53
|
+
Float.toString(Math.round(value *. 10.0) /. 10.0) ++ " " ++ unit
|
|
54
|
+
}
|
|
@@ -31,8 +31,43 @@ function fromFloat(raw) {
|
|
|
31
31
|
|
|
32
32
|
let schema = Semantic$Reventless.refined(Sury.float, Semantic$Reventless.Id.bytes, fromFloat);
|
|
33
33
|
|
|
34
|
+
let units = [
|
|
35
|
+
"B",
|
|
36
|
+
"KB",
|
|
37
|
+
"MB",
|
|
38
|
+
"GB",
|
|
39
|
+
"TB",
|
|
40
|
+
"PB"
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function format(b) {
|
|
44
|
+
let last = units.length - 1 | 0;
|
|
45
|
+
let reduce = (_value, _index) => {
|
|
46
|
+
while (true) {
|
|
47
|
+
let index = _index;
|
|
48
|
+
let value = _value;
|
|
49
|
+
if (value < 1024.0 || index >= last) {
|
|
50
|
+
return [
|
|
51
|
+
value,
|
|
52
|
+
units[index]
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
_index = index + 1 | 0;
|
|
56
|
+
_value = value / 1024.0;
|
|
57
|
+
continue;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
let match = reduce(b, 0);
|
|
61
|
+
return (Math.round(match[0] * 10.0) / 10.0).toString() + " " + match[1];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let step = 1024.0;
|
|
65
|
+
|
|
34
66
|
export {
|
|
35
67
|
fromFloat,
|
|
36
68
|
schema,
|
|
69
|
+
step,
|
|
70
|
+
units,
|
|
71
|
+
format,
|
|
37
72
|
}
|
|
38
73
|
/* schema Not a pure module */
|