@reventlessdev/reventless-spec 3.0.0-alpha.100
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 +931 -0
- package/LICENSE +202 -0
- package/README.md +109 -0
- package/package.json +49 -0
- package/rescript.json +32 -0
- package/run-generator.mjs +2 -0
- package/run-platform-generator.mjs +2 -0
- package/scripts/generate-currency.mjs +215 -0
- package/scripts/iso-4217-list-one.xml +1956 -0
- package/src/AnsiStyle.res +40 -0
- package/src/AnsiStyle.res.mjs +54 -0
- package/src/LogPrefix.res +192 -0
- package/src/LogPrefix.res.mjs +159 -0
- package/src/PackageVersion.res +67 -0
- package/src/PackageVersion.res.mjs +81 -0
- package/src/components/Aggregate.res +64 -0
- package/src/components/Aggregate.res.mjs +2 -0
- package/src/components/AutomationSlice.res +279 -0
- package/src/components/AutomationSlice.res.mjs +30 -0
- package/src/components/CapabilityManifest.res +74 -0
- package/src/components/CapabilityManifest.res.mjs +61 -0
- package/src/components/ComponentKind.res +99 -0
- package/src/components/ComponentKind.res.mjs +125 -0
- package/src/components/Counter.res +24 -0
- package/src/components/Counter.res.mjs +2 -0
- package/src/components/DcbDecode.res +118 -0
- package/src/components/DcbDecode.res.mjs +100 -0
- package/src/components/DcbScopeInference.res +244 -0
- package/src/components/DcbScopeInference.res.mjs +177 -0
- package/src/components/DcbTag.res +1335 -0
- package/src/components/DcbTag.res.mjs +898 -0
- package/src/components/DcbValidation.res +427 -0
- package/src/components/DcbValidation.res.mjs +423 -0
- package/src/components/DisplayName.res +40 -0
- package/src/components/DisplayName.res.mjs +26 -0
- package/src/components/ExtensionPoint.res +27 -0
- package/src/components/ExtensionPoint.res.mjs +2 -0
- package/src/components/InboundTranslationSlice.res +85 -0
- package/src/components/InboundTranslationSlice.res.mjs +2 -0
- package/src/components/OutboundTranslationSlice.res +153 -0
- package/src/components/OutboundTranslationSlice.res.mjs +2 -0
- package/src/components/Plugin.res +538 -0
- package/src/components/Plugin.res.mjs +264 -0
- package/src/components/PluginName.res +39 -0
- package/src/components/PluginName.res.mjs +45 -0
- package/src/components/ReadModel.res +199 -0
- package/src/components/ReadModel.res.mjs +18 -0
- package/src/components/Reference.res +55 -0
- package/src/components/Reference.res.mjs +50 -0
- package/src/components/Snapshot.res +26 -0
- package/src/components/Snapshot.res.mjs +2 -0
- package/src/components/StateAnnotations.res +97 -0
- package/src/components/StateAnnotations.res.mjs +15 -0
- package/src/components/StateChangeSlice.res +131 -0
- package/src/components/StateChangeSlice.res.mjs +2 -0
- package/src/components/StateViewSlice.res +123 -0
- package/src/components/StateViewSlice.res.mjs +2 -0
- package/src/components/Task.res +62 -0
- package/src/components/Task.res.mjs +2 -0
- package/src/generator/Codegen.res +842 -0
- package/src/generator/Codegen.res.mjs +565 -0
- package/src/generator/Config.res +106 -0
- package/src/generator/Config.res.mjs +69 -0
- package/src/generator/Discovery.res +230 -0
- package/src/generator/Discovery.res.mjs +198 -0
- package/src/generator/Generator_Node.res +14 -0
- package/src/generator/Generator_Node.res.mjs +18 -0
- package/src/generator/Pairing.res +460 -0
- package/src/generator/Pairing.res.mjs +415 -0
- package/src/generator/PlatformCodegen.res +207 -0
- package/src/generator/PlatformCodegen.res.mjs +154 -0
- package/src/generator/PlatformGenerator.res +126 -0
- package/src/generator/PlatformGenerator.res.mjs +114 -0
- package/src/generator/PlatformManifests.res +203 -0
- package/src/generator/PlatformManifests.res.mjs +212 -0
- package/src/generator/PluginGenerator.res +57 -0
- package/src/generator/PluginGenerator.res.mjs +73 -0
- package/src/semantic/Bytes.res +54 -0
- package/src/semantic/Bytes.res.mjs +38 -0
- package/src/semantic/Capabilities.res +43 -0
- package/src/semantic/Capabilities.res.mjs +17 -0
- package/src/semantic/Color.res +51 -0
- package/src/semantic/Color.res.mjs +29 -0
- package/src/semantic/Currency.res +598 -0
- package/src/semantic/Currency.res.mjs +743 -0
- package/src/semantic/DateRange.res +148 -0
- package/src/semantic/DateRange.res.mjs +74 -0
- package/src/semantic/Duration.res +53 -0
- package/src/semantic/Duration.res.mjs +26 -0
- package/src/semantic/Email.res +51 -0
- package/src/semantic/Email.res.mjs +31 -0
- package/src/semantic/GeoPoint.res +226 -0
- package/src/semantic/GeoPoint.res.mjs +190 -0
- package/src/semantic/Geocoding.res +127 -0
- package/src/semantic/Geocoding.res.mjs +36 -0
- package/src/semantic/Money.res +196 -0
- package/src/semantic/Money.res.mjs +138 -0
- package/src/semantic/Offload.res +294 -0
- package/src/semantic/Offload.res.mjs +191 -0
- package/src/semantic/Percent.res +53 -0
- package/src/semantic/Percent.res.mjs +33 -0
- package/src/semantic/Phone.res +55 -0
- package/src/semantic/Phone.res.mjs +29 -0
- package/src/semantic/Semantic.res +162 -0
- package/src/semantic/Semantic.res.mjs +95 -0
- package/src/semantic/StorageRef.res +164 -0
- package/src/semantic/StorageRef.res.mjs +111 -0
- package/src/semantic/Url.res +66 -0
- package/src/semantic/Url.res.mjs +48 -0
- package/src/types/Authorization.res +23 -0
- package/src/types/Authorization.res.mjs +33 -0
- package/src/types/Behavior.res +86 -0
- package/src/types/Behavior.res.mjs +2 -0
- package/src/types/DateTime.res +29 -0
- package/src/types/DateTime.res.mjs +16 -0
- package/src/types/EventMapping.res +100 -0
- package/src/types/EventMapping.res.mjs +15 -0
- package/src/types/Handler.res +30 -0
- package/src/types/Handler.res.mjs +2 -0
- package/src/types/Id.res +75 -0
- package/src/types/Id.res.mjs +37 -0
- package/src/types/Identity.res +46 -0
- package/src/types/Identity.res.mjs +51 -0
- package/src/types/Message.res +326 -0
- package/src/types/Message.res.mjs +186 -0
- package/src/types/Projection.res +220 -0
- package/src/types/Projection.res.mjs +44 -0
- package/src/types/QueryEngine.res +123 -0
- package/src/types/QueryEngine.res.mjs +12 -0
- package/src/types/ReadConsistency.res +38 -0
- package/src/types/ReadConsistency.res.mjs +29 -0
- package/src/types/Schedule.res +65 -0
- package/src/types/Schedule.res.mjs +68 -0
- package/src/types/SideEffect.res +45 -0
- package/src/types/SideEffect.res.mjs +2 -0
- package/src/types/StoredEvent.res +46 -0
- package/src/types/StoredEvent.res.mjs +32 -0
- package/src/types/Visibility.res +24 -0
- package/src/types/Visibility.res.mjs +25 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
A field whose large value lives in a content-addressed object store, carried by
|
|
3
|
+
reference — or inline when it is small enough not to be worth a round trip.
|
|
4
|
+
|
|
5
|
+
## Sibling of `StorageRef`, not the same thing
|
|
6
|
+
|
|
7
|
+
`StorageRef` and `Offload` are two members of one family: both say "this field's
|
|
8
|
+
value lives in an object store", both declare that store through the shared
|
|
9
|
+
`Semantic.StoredIn` marker, and both are produced by a **client** that uploads
|
|
10
|
+
the bytes before the command is issued — never by the framework inside `decide`.
|
|
11
|
+
They differ in what the field carries:
|
|
12
|
+
|
|
13
|
+
- `@storageRef` is always a reference (an origin-relative path a store minted),
|
|
14
|
+
and the reference *is* the value the reader sees (a URL it renders).
|
|
15
|
+
- `@offload` is an **inline-or-reference** value. Below a size threshold the
|
|
16
|
+
value stays embedded; above it the client stores the bytes under a
|
|
17
|
+
content-addressed key and the field carries `Offloaded{store, key, hash, bytes}`.
|
|
18
|
+
A reader resolves either arm back to the value.
|
|
19
|
+
|
|
20
|
+
Content addressing (the key is the SHA-256 of the bytes) makes the store write
|
|
21
|
+
idempotent and deduplicating: the same value stored twice lands on the same key,
|
|
22
|
+
so identical payloads across versions or tenants hold one object, not many.
|
|
23
|
+
|
|
24
|
+
## The backward-compatible wire form
|
|
25
|
+
|
|
26
|
+
Every event already in history stored the value **inline and unwrapped** — a
|
|
27
|
+
plain record, with no variant tag. So the codec here must decode those bytes
|
|
28
|
+
unchanged, which rules out sury's default tagged-union encoding (`{TAG, _0}`).
|
|
29
|
+
|
|
30
|
+
Instead the codec is *untagged* and sniffs a reserved sentinel key:
|
|
31
|
+
|
|
32
|
+
- an `Offloaded` value encodes as `{"$offload": {store, key, hash, bytes}}`;
|
|
33
|
+
- an `Inline` value encodes as the raw value, exactly as before.
|
|
34
|
+
|
|
35
|
+
On decode, a JSON object carrying the `$offload` key is an `Offloaded`; anything
|
|
36
|
+
else is decoded as the inner value into `Inline`. A record field name can never
|
|
37
|
+
be `$offload` (identifiers cannot start with `$`), so a legacy inline payload can
|
|
38
|
+
never be mistaken for a reference, and vice versa — no migration, and a
|
|
39
|
+
pre-change fixture decodes as `Inline` untouched.
|
|
40
|
+
|
|
41
|
+
@example
|
|
42
|
+
```rescript
|
|
43
|
+
// an event/command field, optional and offloadable to the "pluginStructures" store
|
|
44
|
+
structure: @s.matches(Offload.optionSchema(~store="pluginStructures", pluginStructureSchema))
|
|
45
|
+
option<Offload.payload<pluginStructure>>
|
|
46
|
+
```
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** The reference an offloaded value carries: which store holds it, the
|
|
50
|
+
content-addressed key, the content hash (== the key's basis), and the byte
|
|
51
|
+
length. `hash` is redundant with `key` today (`key` is `sha256/<hash>`) but
|
|
52
|
+
named so a reader can verify integrity without parsing the key. */
|
|
53
|
+
@schema
|
|
54
|
+
type offloadedRef = {
|
|
55
|
+
store: string,
|
|
56
|
+
key: string,
|
|
57
|
+
hash: string,
|
|
58
|
+
bytes: int,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A field's value: embedded, or a reference to bytes the client stored. */
|
|
62
|
+
type payload<'a> =
|
|
63
|
+
| Inline('a)
|
|
64
|
+
| Offloaded(offloadedRef)
|
|
65
|
+
|
|
66
|
+
/** `nullableAsOption` emits `T | undefined | null`, which fails
|
|
67
|
+
`jsonableValidation` inside an event union; `js_nullable` emits `T | null`,
|
|
68
|
+
which is JSON-safe there. Same reason `Plugin.res` reaches for it. */
|
|
69
|
+
@module("sury/src/Sury.res.mjs")
|
|
70
|
+
external _jsNullable: (S.t<'a>, unit) => S.t<option<'a>> = "js_nullable"
|
|
71
|
+
|
|
72
|
+
/** The codec builds on `S.json`, which sury 11 gates behind an explicit enable.
|
|
73
|
+
Doing it here (at module load, before any `schema` call) makes the primitive
|
|
74
|
+
self-contained: importing `Offload` is enough, no consumer has to remember. */
|
|
75
|
+
S.enableJson()
|
|
76
|
+
|
|
77
|
+
/** The object key under which an `Offloaded` value hides. Reserved: no ReScript
|
|
78
|
+
record field encodes to a key starting with `$`, so it cannot collide with an
|
|
79
|
+
inline payload's own fields. */
|
|
80
|
+
let sentinelKey = "$offload"
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
The untagged inline-or-reference codec for a field of inner type `'a`.
|
|
84
|
+
|
|
85
|
+
Parameterised by the inner value's schema because the `Inline` arm round-trips
|
|
86
|
+
through it. The `Offloaded` arm round-trips through `offloadedRefSchema` under the
|
|
87
|
+
sentinel key. See the module doc for why this is untagged.
|
|
88
|
+
*/
|
|
89
|
+
let schema = (inner: S.t<'a>): S.t<payload<'a>> => {
|
|
90
|
+
// Offloaded arm: recognised by the reserved sentinel key, strict, and tried
|
|
91
|
+
// first so an offloaded value is never mistaken for an inline one. The ref is
|
|
92
|
+
// a fixed new shape that needs no healing, so a direct json-transform is fine.
|
|
93
|
+
let offloadedArm = S.json->S.transform(s => {
|
|
94
|
+
parser: json =>
|
|
95
|
+
switch json->JSON.Decode.object->Option.flatMap(dict => dict->Dict.get(sentinelKey)) {
|
|
96
|
+
| Some(refJson) => Offloaded(refJson->S.parseJsonOrThrow(offloadedRefSchema))
|
|
97
|
+
| None => s.fail("not an offloaded reference")
|
|
98
|
+
},
|
|
99
|
+
serializer: payload =>
|
|
100
|
+
switch payload {
|
|
101
|
+
| Offloaded(ref) =>
|
|
102
|
+
Dict.fromArray([(sentinelKey, ref->S.reverseConvertToJsonOrThrow(offloadedRefSchema))])
|
|
103
|
+
->JSON.Encode.object
|
|
104
|
+
| Inline(_) => s.fail("not an offloaded reference")
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
// Inline arm: the inner schema applied through sury's own pipeline, so it
|
|
108
|
+
// inherits whatever tolerance the surrounding decode uses — the lifecycle
|
|
109
|
+
// Message decoder heals older payloads with missing fields. This is why the
|
|
110
|
+
// codec is a union rather than one json-transform with a nested
|
|
111
|
+
// parseJsonOrThrow: a nested parse runs strict and breaks the frozen corpus.
|
|
112
|
+
let inlineArm = inner->S.transform(s => {
|
|
113
|
+
parser: value => Inline(value),
|
|
114
|
+
serializer: payload =>
|
|
115
|
+
switch payload {
|
|
116
|
+
| Inline(value) => value
|
|
117
|
+
| Offloaded(_) => s.fail("not an inline value")
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
S.union([offloadedArm, inlineArm])
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
The codec plus the `StoredIn` marker declaring which store the field's references
|
|
125
|
+
live in, for a **non-optional** field. `plugin` is absent for the declaring
|
|
126
|
+
plugin's own store; qualify as `"<plugin>.<store>"` to point at another's.
|
|
127
|
+
|
|
128
|
+
Prefer the `@offload("<store>")` ppx shorthand over calling this by hand.
|
|
129
|
+
*/
|
|
130
|
+
let forStore = (
|
|
131
|
+
~plugin: option<string>=?,
|
|
132
|
+
~store: string,
|
|
133
|
+
~threshold: option<int>=?,
|
|
134
|
+
inner: S.t<'a>,
|
|
135
|
+
): S.t<payload<'a>> =>
|
|
136
|
+
schema(inner)->Semantic.mark(~id=Semantic.Id.offload, ~payload=StoredIn({plugin, store, threshold}))
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
The codec wrapped for an **optional** field (`js_nullable`), plus the `StoredIn`
|
|
140
|
+
marker. This is the common case: offloadable fields are usually optional (absent
|
|
141
|
+
for older protocol versions, say). The marker sits on the outer schema, where
|
|
142
|
+
`Semantic.get` reads it first.
|
|
143
|
+
*/
|
|
144
|
+
let optionSchema = (
|
|
145
|
+
~plugin: option<string>=?,
|
|
146
|
+
~store: string,
|
|
147
|
+
~threshold: option<int>=?,
|
|
148
|
+
inner: S.t<'a>,
|
|
149
|
+
): S.t<option<payload<'a>>> =>
|
|
150
|
+
_jsNullable(schema(inner), ())->Semantic.mark(
|
|
151
|
+
~id=Semantic.Id.offload,
|
|
152
|
+
~payload=StoredIn({plugin, store, threshold}),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
/** Serialize a payload to its **untagged** wire JSON: `Inline` becomes the bare
|
|
156
|
+
inner value's JSON, `Offloaded` becomes `{"$offload": {...}}`. Used where a
|
|
157
|
+
payload must be stored as a plain JSON blob rather than the ReScript variant —
|
|
158
|
+
e.g. the plugin read model, whose DynamoDB write path marshals the raw value
|
|
159
|
+
and would otherwise persist the variant's runtime `{TAG, _0}` shape. */
|
|
160
|
+
let toJson = (inner: S.t<'a>, payload: payload<'a>): JSON.t =>
|
|
161
|
+
payload->S.reverseConvertToJsonOrThrow(schema(inner))
|
|
162
|
+
|
|
163
|
+
/** The inline value, if this payload is `Inline`. `None` for `Offloaded` — a
|
|
164
|
+
caller that must handle both arms uses `resolve` (async, fetches the ref); a
|
|
165
|
+
caller that only ever sees inline values (a test, or a path where offloading
|
|
166
|
+
is not yet wired) uses this. */
|
|
167
|
+
let getInline = (payload: payload<'a>): option<'a> =>
|
|
168
|
+
switch payload {
|
|
169
|
+
| Inline(value) => Some(value)
|
|
170
|
+
| Offloaded(_) => None
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Client helpers ─────────────────────────────────────────────────────────
|
|
174
|
+
//
|
|
175
|
+
// The producer/reader surface that makes offloading easy for a client to drive.
|
|
176
|
+
// Both take their store I/O as injected transports so this module stays pure
|
|
177
|
+
// and provider-agnostic: a browser passes a presigned PUT / fetch, the Node seed
|
|
178
|
+
// passes a direct SDK call, a test passes an in-memory map. The plugin's own
|
|
179
|
+
// deploy-time producer is the exception — it offloads Pulumi-natively (declares a
|
|
180
|
+
// content-addressed object resource) rather than through `prepare`, because its
|
|
181
|
+
// value is a `Pulumi.Output` and a resource cannot be created inside `.apply`.
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
Decide inline-or-offloaded for one value and, when it is large, upload it.
|
|
185
|
+
|
|
186
|
+
Serializes `value` with `schema`; if the JSON is below `threshold` it stays
|
|
187
|
+
`Inline` (no round trip). Otherwise it is hashed, uploaded under the
|
|
188
|
+
content-addressed key `sha256/<hash>`, and returned as `Offloaded`. Because the
|
|
189
|
+
key is the content hash, re-uploading identical bytes writes the same object —
|
|
190
|
+
idempotent, and the source of cross-version/cross-client dedupe.
|
|
191
|
+
|
|
192
|
+
`~hash` and `~upload` are injected so this stays provider-agnostic; `~hash` must
|
|
193
|
+
be a stable content hash (the same bytes must always hash the same). Size is the
|
|
194
|
+
JSON string's length in characters — a close proxy for byte length on the
|
|
195
|
+
mostly-ASCII JSON these payloads are, and only ever used for the threshold cut.
|
|
196
|
+
*/
|
|
197
|
+
let prepare = (
|
|
198
|
+
value: 'a,
|
|
199
|
+
~schema: S.t<'a>,
|
|
200
|
+
~store: string,
|
|
201
|
+
~threshold: int,
|
|
202
|
+
~hash: string => string,
|
|
203
|
+
~upload: (~key: string, ~bytes: string) => promise<unit>,
|
|
204
|
+
): promise<payload<'a>> => {
|
|
205
|
+
let bytes = value->S.reverseConvertToJsonStringOrThrow(schema)
|
|
206
|
+
if bytes->String.length < threshold {
|
|
207
|
+
Promise.resolve(Inline(value))
|
|
208
|
+
} else {
|
|
209
|
+
let digest = hash(bytes)
|
|
210
|
+
let key = "sha256/" ++ digest
|
|
211
|
+
upload(~key, ~bytes)->Promise.then(() =>
|
|
212
|
+
Promise.resolve(Offloaded({store, key, hash: digest, bytes: bytes->String.length}))
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
Resolve a payload back to its value: `Inline` directly, `Offloaded` by fetching
|
|
219
|
+
the object's bytes (via the injected `~fetch`) and decoding with `schema`.
|
|
220
|
+
|
|
221
|
+
Offloaded objects are written by current code, so their bytes decode strictly —
|
|
222
|
+
unlike the inline arm of the wire codec, which heals older event payloads.
|
|
223
|
+
*/
|
|
224
|
+
let resolve = (payload: payload<'a>, ~schema: S.t<'a>, ~fetch: string => promise<string>): promise<
|
|
225
|
+
'a,
|
|
226
|
+
> =>
|
|
227
|
+
switch payload {
|
|
228
|
+
| Inline(value) => Promise.resolve(value)
|
|
229
|
+
| Offloaded({key}) =>
|
|
230
|
+
fetch(key)->Promise.then(bytes => Promise.resolve(bytes->S.parseJsonStringOrThrow(schema)))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
Wrap a `~fetch` so each content-addressed key is fetched at most once per process.
|
|
235
|
+
|
|
236
|
+
Keys are immutable (they are content hashes), so a fetched object can be cached
|
|
237
|
+
forever — every replay/projection that references the same hash reuses it, and
|
|
238
|
+
concurrent resolves of the same key share the one in-flight promise.
|
|
239
|
+
*/
|
|
240
|
+
let cachedFetch = (fetch: string => promise<string>): (string => promise<string>) => {
|
|
241
|
+
let cache: dict<promise<string>> = Dict.make()
|
|
242
|
+
key =>
|
|
243
|
+
switch cache->Dict.get(key) {
|
|
244
|
+
| Some(inflight) => inflight
|
|
245
|
+
| None =>
|
|
246
|
+
let inflight = fetch(key)
|
|
247
|
+
cache->Dict.set(key, inflight)
|
|
248
|
+
inflight
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The store an `@offload` field declares, if any — the read side of the marker,
|
|
253
|
+
used by provisioning to know the field requires this store to exist. Distinct
|
|
254
|
+
from `StorageRef.getStore` by the semantic id, so the two families stay
|
|
255
|
+
separable (offload objects are content-addressed and durable up front; they
|
|
256
|
+
must not be swept by the pending-upload claimer). */
|
|
257
|
+
let getStore = (schema: S.t<'a>): option<Semantic.storeTarget> =>
|
|
258
|
+
switch Semantic.get(schema) {
|
|
259
|
+
| Some({id, payload: StoredIn(target)}) if id == Semantic.Id.offload => Some(target)
|
|
260
|
+
| _ => None
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The framework-default inline-vs-offloaded byte cut, used when neither the field
|
|
264
|
+
marker nor the platform declares one. Retuning any level is safe: both arms of
|
|
265
|
+
the codec read back to identical bytes, so the threshold only decides how
|
|
266
|
+
*future* values are split — no wire change, no re-encoding, existing events
|
|
267
|
+
stay valid. */
|
|
268
|
+
let defaultThreshold = 8192
|
|
269
|
+
|
|
270
|
+
/** The per-field threshold an `@offload` field declares, if any — the top of the
|
|
271
|
+
precedence chain (`@offload({..., threshold})`). `None` when the field left it
|
|
272
|
+
unset, which defers to the platform default and then {!defaultThreshold}. */
|
|
273
|
+
let getThreshold = (schema: S.t<'a>): option<int> =>
|
|
274
|
+
switch Semantic.get(schema) {
|
|
275
|
+
| Some({id, payload: StoredIn({threshold})}) if id == Semantic.Id.offload => threshold
|
|
276
|
+
| _ => None
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
The effective threshold for a field, resolving the precedence chain most-specific
|
|
281
|
+
first: the per-field `@offload({threshold})` (read from the field's schema), then
|
|
282
|
+
the platform config default (`~platformDefault`, e.g. `MakeWithConfig`'s
|
|
283
|
+
`offloadThreshold`), then {!defaultThreshold}.
|
|
284
|
+
|
|
285
|
+
A client drives an offloadable field by reading its field schema, calling this to
|
|
286
|
+
get the cut, and passing the result as {!prepare}'s `~threshold`. `prepare` stays
|
|
287
|
+
threshold-explicit (it holds the *value* schema, not the field schema that carries
|
|
288
|
+
the marker), so this is the seam that turns the declaration into a number.
|
|
289
|
+
*/
|
|
290
|
+
let effectiveThreshold = (schema: S.t<'a>, ~platformDefault: option<int>=?, ()): int =>
|
|
291
|
+
switch getThreshold(schema) {
|
|
292
|
+
| Some(t) => t
|
|
293
|
+
| None => platformDefault->Option.getOr(defaultThreshold)
|
|
294
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
7
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
8
|
+
import * as SuryResMjs from "sury/src/Sury.res.mjs";
|
|
9
|
+
|
|
10
|
+
let offloadedRefSchema = S.schema(s => ({
|
|
11
|
+
store: s.m(S.string),
|
|
12
|
+
key: s.m(S.string),
|
|
13
|
+
hash: s.m(S.string),
|
|
14
|
+
bytes: s.m(S.int)
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
S.enableJson();
|
|
18
|
+
|
|
19
|
+
let sentinelKey = "$offload";
|
|
20
|
+
|
|
21
|
+
function schema(inner) {
|
|
22
|
+
let offloadedArm = S.transform(S.json, s => ({
|
|
23
|
+
p: json => {
|
|
24
|
+
let refJson = Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), dict => dict[sentinelKey]);
|
|
25
|
+
if (refJson !== undefined) {
|
|
26
|
+
return {
|
|
27
|
+
TAG: "Offloaded",
|
|
28
|
+
_0: S.parseJsonOrThrow(refJson, offloadedRefSchema)
|
|
29
|
+
};
|
|
30
|
+
} else {
|
|
31
|
+
return s.fail("not an offloaded reference", undefined);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
s: payload => {
|
|
35
|
+
if (payload.TAG === "Inline") {
|
|
36
|
+
return s.fail("not an offloaded reference", undefined);
|
|
37
|
+
} else {
|
|
38
|
+
return Object.fromEntries([[
|
|
39
|
+
sentinelKey,
|
|
40
|
+
S.reverseConvertToJsonOrThrow(payload._0, offloadedRefSchema)
|
|
41
|
+
]]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}));
|
|
45
|
+
let inlineArm = S.transform(inner, s => ({
|
|
46
|
+
p: value => ({
|
|
47
|
+
TAG: "Inline",
|
|
48
|
+
_0: value
|
|
49
|
+
}),
|
|
50
|
+
s: payload => {
|
|
51
|
+
if (payload.TAG === "Inline") {
|
|
52
|
+
return payload._0;
|
|
53
|
+
} else {
|
|
54
|
+
return s.fail("not an inline value", undefined);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}));
|
|
58
|
+
return S.union([
|
|
59
|
+
offloadedArm,
|
|
60
|
+
inlineArm
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function forStore(plugin, store, threshold, inner) {
|
|
65
|
+
return Semantic$Reventless.mark(schema(inner), Semantic$Reventless.Id.offload, {
|
|
66
|
+
TAG: "StoredIn",
|
|
67
|
+
_0: {
|
|
68
|
+
plugin: plugin,
|
|
69
|
+
store: store,
|
|
70
|
+
threshold: threshold
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function optionSchema(plugin, store, threshold, inner) {
|
|
76
|
+
return Semantic$Reventless.mark(SuryResMjs.js_nullable(schema(inner)), Semantic$Reventless.Id.offload, {
|
|
77
|
+
TAG: "StoredIn",
|
|
78
|
+
_0: {
|
|
79
|
+
plugin: plugin,
|
|
80
|
+
store: store,
|
|
81
|
+
threshold: threshold
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toJson(inner, payload) {
|
|
87
|
+
return S.reverseConvertToJsonOrThrow(payload, schema(inner));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getInline(payload) {
|
|
91
|
+
if (payload.TAG === "Inline") {
|
|
92
|
+
return Primitive_option.some(payload._0);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function prepare(value, schema, store, threshold, hash, upload) {
|
|
97
|
+
let bytes = S.reverseConvertToJsonStringOrThrow(value, schema, undefined);
|
|
98
|
+
if (bytes.length < threshold) {
|
|
99
|
+
return Promise.resolve({
|
|
100
|
+
TAG: "Inline",
|
|
101
|
+
_0: value
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
let digest = hash(bytes);
|
|
105
|
+
let key = "sha256/" + digest;
|
|
106
|
+
return upload(key, bytes).then(() => Promise.resolve({
|
|
107
|
+
TAG: "Offloaded",
|
|
108
|
+
_0: {
|
|
109
|
+
store: store,
|
|
110
|
+
key: key,
|
|
111
|
+
hash: digest,
|
|
112
|
+
bytes: bytes.length
|
|
113
|
+
}
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolve(payload, schema, fetch) {
|
|
118
|
+
if (payload.TAG === "Inline") {
|
|
119
|
+
return Promise.resolve(payload._0);
|
|
120
|
+
} else {
|
|
121
|
+
return fetch(payload._0.key).then(bytes => Promise.resolve(S.parseJsonStringOrThrow(bytes, schema)));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function cachedFetch(fetch) {
|
|
126
|
+
let cache = {};
|
|
127
|
+
return key => {
|
|
128
|
+
let inflight = cache[key];
|
|
129
|
+
if (inflight !== undefined) {
|
|
130
|
+
return inflight;
|
|
131
|
+
}
|
|
132
|
+
let inflight$1 = fetch(key);
|
|
133
|
+
cache[key] = inflight$1;
|
|
134
|
+
return inflight$1;
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getStore(schema) {
|
|
139
|
+
let match = Semantic$Reventless.get(schema);
|
|
140
|
+
if (match === undefined) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
let target = match.payload;
|
|
144
|
+
if (typeof target !== "object" || target.TAG === "ReferenceTo" || match.id !== Semantic$Reventless.Id.offload) {
|
|
145
|
+
return;
|
|
146
|
+
} else {
|
|
147
|
+
return target._0;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getThreshold(schema) {
|
|
152
|
+
let match = Semantic$Reventless.get(schema);
|
|
153
|
+
if (match === undefined) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let match$1 = match.payload;
|
|
157
|
+
if (typeof match$1 !== "object" || match$1.TAG === "ReferenceTo" || match.id !== Semantic$Reventless.Id.offload) {
|
|
158
|
+
return;
|
|
159
|
+
} else {
|
|
160
|
+
return match$1._0.threshold;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function effectiveThreshold(schema, platformDefault, param) {
|
|
165
|
+
let t = getThreshold(schema);
|
|
166
|
+
if (t !== undefined) {
|
|
167
|
+
return t;
|
|
168
|
+
} else {
|
|
169
|
+
return Stdlib_Option.getOr(platformDefault, 8192);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let defaultThreshold = 8192;
|
|
174
|
+
|
|
175
|
+
export {
|
|
176
|
+
offloadedRefSchema,
|
|
177
|
+
sentinelKey,
|
|
178
|
+
schema,
|
|
179
|
+
forStore,
|
|
180
|
+
optionSchema,
|
|
181
|
+
toJson,
|
|
182
|
+
getInline,
|
|
183
|
+
prepare,
|
|
184
|
+
resolve,
|
|
185
|
+
cachedFetch,
|
|
186
|
+
getStore,
|
|
187
|
+
defaultThreshold,
|
|
188
|
+
getThreshold,
|
|
189
|
+
effectiveThreshold,
|
|
190
|
+
}
|
|
191
|
+
/* offloadedRefSchema Not a pure module */
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Marks a `float` field as a percentage, expressed **0–100**.
|
|
3
|
+
|
|
4
|
+
## Why 0–100 and not 0–1
|
|
5
|
+
|
|
6
|
+
Both conventions are defensible in the abstract, so the tie is broken by the
|
|
7
|
+
consumer that already exists: the dashboard gauges a field with this semantic
|
|
8
|
+
against fixed bounds of 0 and 100, and formats `42.0` as `"42%"`. Under a 0–1
|
|
9
|
+
convention every value would render as a rounding error near zero — a gauge
|
|
10
|
+
pinned at empty and a label reading `"0.42%"`.
|
|
11
|
+
|
|
12
|
+
That failure is quiet, and it is quiet in the worst way: the numbers are
|
|
13
|
+
*present* and *wrong*, and the layer at fault is not the one showing the symptom.
|
|
14
|
+
Agreeing with the renderer costs nothing; disagreeing costs an afternoon.
|
|
15
|
+
|
|
16
|
+
A fraction is still perfectly good arithmetic — it just multiplies by 100 before
|
|
17
|
+
it becomes this type.
|
|
18
|
+
|
|
19
|
+
## The grammar
|
|
20
|
+
|
|
21
|
+
A finite number in `[0, 100]`. Fractions are allowed: `99.95` is a percentage.
|
|
22
|
+
|
|
23
|
+
@example
|
|
24
|
+
```rescript
|
|
25
|
+
@schema type state = {
|
|
26
|
+
productId: string,
|
|
27
|
+
taxRate: @s.matches(Reventless.Percent.schema) float,
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The percentage's representation. Transparent `float`: the marker refines an
|
|
33
|
+
existing numeric field rather than replacing it, so nothing stored changes. */
|
|
34
|
+
type t = float
|
|
35
|
+
|
|
36
|
+
external unsafe: float => t = "%identity"
|
|
37
|
+
external toFloat: t => float = "%identity"
|
|
38
|
+
|
|
39
|
+
/** Validate a number as a percentage, saying why when it is not one. */
|
|
40
|
+
let fromFloat = (raw: float): result<t, string> =>
|
|
41
|
+
if !Float.isFinite(raw) {
|
|
42
|
+
Error(`a percentage must be a finite number, got ${Float.toString(raw)}`)
|
|
43
|
+
} else if raw < 0.0 || raw > 100.0 {
|
|
44
|
+
Error(
|
|
45
|
+
`a percentage runs from 0 to 100, got ${Float.toString(raw)}. ` ++
|
|
46
|
+
`This scale is 0–100, not 0–1 — a fraction multiplies by 100 first.`,
|
|
47
|
+
)
|
|
48
|
+
} else {
|
|
49
|
+
Ok(raw)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The sury schema for a percentage field. Use with `@s.matches(Reventless.Percent.schema)`. */
|
|
53
|
+
let schema: S.t<t> = S.float->Semantic.refined(~id=Semantic.Id.percent, ~check=fromFloat)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
5
|
+
|
|
6
|
+
function fromFloat(raw) {
|
|
7
|
+
if (isFinite(raw)) {
|
|
8
|
+
if (raw < 0.0 || raw > 100.0) {
|
|
9
|
+
return {
|
|
10
|
+
TAG: "Error",
|
|
11
|
+
_0: `a percentage runs from 0 to 100, got ` + raw.toString() + `. This scale is 0–100, not 0–1 — a fraction multiplies by 100 first.`
|
|
12
|
+
};
|
|
13
|
+
} else {
|
|
14
|
+
return {
|
|
15
|
+
TAG: "Ok",
|
|
16
|
+
_0: raw
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
} else {
|
|
20
|
+
return {
|
|
21
|
+
TAG: "Error",
|
|
22
|
+
_0: `a percentage must be a finite number, got ` + raw.toString()
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let schema = Semantic$Reventless.refined(S.float, Semantic$Reventless.Id.percent, fromFloat);
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
fromFloat,
|
|
31
|
+
schema,
|
|
32
|
+
}
|
|
33
|
+
/* schema Not a pure module */
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Marks a `string` field as a phone number in E.164 form.
|
|
3
|
+
|
|
4
|
+
## Why E.164, and only E.164
|
|
5
|
+
|
|
6
|
+
A phone number written the way a person says it out loud — `(030) 12 34 56`,
|
|
7
|
+
`+49 30 123456`, `0049-30-123456` — is three different strings for one number,
|
|
8
|
+
so a log full of them cannot be searched, deduplicated or dialled reliably. E.164
|
|
9
|
+
is the one form that is unambiguous internationally, and it is the form the
|
|
10
|
+
`tel:` link this field renders as actually wants.
|
|
11
|
+
|
|
12
|
+
That makes this the one branded scalar that is likely to *reject* input a form
|
|
13
|
+
would otherwise have accepted, and it should: the alternative is storing an
|
|
14
|
+
un-dialable string permanently. Normalising a local number into E.164 needs a
|
|
15
|
+
default country the framework does not know, so that belongs to the caller,
|
|
16
|
+
before the command.
|
|
17
|
+
|
|
18
|
+
## The grammar
|
|
19
|
+
|
|
20
|
+
`+`, then a country digit 1–9, then up to 14 more digits — at most 15 in total,
|
|
21
|
+
which is the E.164 limit. No spaces, no punctuation, no leading zero after the
|
|
22
|
+
`+`.
|
|
23
|
+
|
|
24
|
+
@example
|
|
25
|
+
```rescript
|
|
26
|
+
@schema type command =
|
|
27
|
+
| SetContactPhone({
|
|
28
|
+
customerId: @s.matches(DcbTag.string) string,
|
|
29
|
+
phone: @s.matches(Reventless.Phone.schema) string,
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The number's representation. Transparent `string`; see `Email.t`. */
|
|
35
|
+
type t = string
|
|
36
|
+
|
|
37
|
+
external unsafe: string => t = "%identity"
|
|
38
|
+
external toString: t => string = "%identity"
|
|
39
|
+
|
|
40
|
+
let e164 = /^\+[1-9]\d{0,14}$/
|
|
41
|
+
|
|
42
|
+
/** Validate a raw string as an E.164 number, saying why when it is not one. */
|
|
43
|
+
let fromString = (raw: string): result<t, string> =>
|
|
44
|
+
if e164->RegExp.test(raw) {
|
|
45
|
+
Ok(raw)
|
|
46
|
+
} else {
|
|
47
|
+
Error(
|
|
48
|
+
`expected a phone number in E.164 form — "+" then up to 15 digits, as in "+4930123456" — got ${Semantic.showString(
|
|
49
|
+
raw,
|
|
50
|
+
)}. Spaces, dashes and brackets are not part of the stored form.`,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The sury schema for a phone field. Use with `@s.matches(Reventless.Phone.schema)`. */
|
|
55
|
+
let schema: S.t<t> = S.string->Semantic.refined(~id=Semantic.Id.phone, ~check=fromString)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
5
|
+
|
|
6
|
+
let e164 = /^\+[1-9]\d{0,14}$/;
|
|
7
|
+
|
|
8
|
+
function fromString(raw) {
|
|
9
|
+
if (e164.test(raw)) {
|
|
10
|
+
return {
|
|
11
|
+
TAG: "Ok",
|
|
12
|
+
_0: raw
|
|
13
|
+
};
|
|
14
|
+
} else {
|
|
15
|
+
return {
|
|
16
|
+
TAG: "Error",
|
|
17
|
+
_0: `expected a phone number in E.164 form — "+" then up to 15 digits, as in "+4930123456" — got ` + Semantic$Reventless.showString(raw) + `. Spaces, dashes and brackets are not part of the stored form.`
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let schema = Semantic$Reventless.refined(S.string, Semantic$Reventless.Id.phone, fromString);
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
e164,
|
|
26
|
+
fromString,
|
|
27
|
+
schema,
|
|
28
|
+
}
|
|
29
|
+
/* schema Not a pure module */
|