@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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
The one marker every typed semantic marks itself with.
|
|
3
|
+
|
|
4
|
+
A semantic type says what a field's value *is* — a date-time, a reference to
|
|
5
|
+
another entity, a ref into an object store — as a property of the field's
|
|
6
|
+
**type**, not as a string stapled beside it. Every layer downstream then derives
|
|
7
|
+
from that single declaration: validation, the wire contract, the UI widget it
|
|
8
|
+
gets rendered with, and eventually the infrastructure provisioned for it.
|
|
9
|
+
|
|
10
|
+
Typed markers predate this module, and each was bespoke: `DateTime` carried its
|
|
11
|
+
own metadata id, `Reference` carried another, and the schema walk detected both
|
|
12
|
+
by hardcoded special case. That made every new typed marker new detection code.
|
|
13
|
+
One shared marker means the walk reads a semantic generically and a new semantic
|
|
14
|
+
type is a new *value*, not a new branch.
|
|
15
|
+
|
|
16
|
+
The payload is a real variant rather than free-form JSON. The semantic
|
|
17
|
+
vocabulary is framework-owned — an application declares a field *is* a storage
|
|
18
|
+
ref, it does not invent what a storage ref means — so the set is closed, and a
|
|
19
|
+
closed set typed here is one the compiler checks at every producer and consumer.
|
|
20
|
+
It also keeps `Reference.getTarget` a total typed function instead of a decode
|
|
21
|
+
that can fail at runtime.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Which entity a reference field points to. */
|
|
25
|
+
type referenceTarget = {entity: string, plugin: option<string>}
|
|
26
|
+
|
|
27
|
+
/** Which object store a storage-ref / offload field's value lives in. `plugin` is
|
|
28
|
+
absent when the store belongs to the declaring plugin, which is the common
|
|
29
|
+
case. `threshold` is the per-field inline-vs-offloaded byte cut an `@offload`
|
|
30
|
+
field may declare (`None` for `@storageRef`, which is always a ref, and for
|
|
31
|
+
`@offload` fields that leave it to the platform default). */
|
|
32
|
+
type storeTarget = {plugin: option<string>, store: string, threshold: option<int>}
|
|
33
|
+
|
|
34
|
+
/** Per-semantic detail, for the semantics that carry any. */
|
|
35
|
+
type payload =
|
|
36
|
+
| Plain
|
|
37
|
+
| ReferenceTo(referenceTarget)
|
|
38
|
+
| StoredIn(storeTarget)
|
|
39
|
+
|
|
40
|
+
/** A field's semantic: the vocabulary id, plus its detail. */
|
|
41
|
+
type t = {id: string, payload: payload}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
The semantic ids the framework itself defines.
|
|
45
|
+
|
|
46
|
+
These strings are the wire vocabulary — they are what `x-reventless-semantic`
|
|
47
|
+
carries, and the same vocabulary the string annotation path already uses, so the
|
|
48
|
+
type path and the annotation path converge on one wire format rather than two.
|
|
49
|
+
*/
|
|
50
|
+
module Id = {
|
|
51
|
+
let dateTime = "dateTime"
|
|
52
|
+
let reference = "reference"
|
|
53
|
+
let storageRef = "storageRef"
|
|
54
|
+
// A field whose large value the client stored in a content-addressed object
|
|
55
|
+
// store and carries by reference (inline below a size threshold). Sibling of
|
|
56
|
+
// `storageRef`: same `StoredIn` store declaration, but an inline-or-reference
|
|
57
|
+
// value rather than an always-a-ref path string.
|
|
58
|
+
let offload = "offload"
|
|
59
|
+
|
|
60
|
+
// The branded scalars. Each refines a `string` or a number without changing
|
|
61
|
+
// its shape, so a field gains one of these without anything stored changing.
|
|
62
|
+
let email = "email"
|
|
63
|
+
let phone = "phone"
|
|
64
|
+
let url = "url"
|
|
65
|
+
let percent = "percent"
|
|
66
|
+
let bytes = "bytes"
|
|
67
|
+
let duration = "duration"
|
|
68
|
+
let color = "color"
|
|
69
|
+
|
|
70
|
+
// The first composite that is not infrastructure. Unlike the seven above it
|
|
71
|
+
// this one changes a field's *shape* — a number becomes an object — so it is
|
|
72
|
+
// a wire-breaking declaration rather than a refinement of one.
|
|
73
|
+
let money = "money"
|
|
74
|
+
|
|
75
|
+
// The second composite. A pair of ISO-8601 instants as one value, replacing a
|
|
76
|
+
// span the UI used to guess from a `start*`/`end*` name pair. Like `money` it
|
|
77
|
+
// is an object on the wire; unlike it, adopting it as a *new* optional field
|
|
78
|
+
// is additive — an absent optional decodes to `None`.
|
|
79
|
+
let dateRange = "dateRange"
|
|
80
|
+
|
|
81
|
+
// The third composite, and the cheapest to adopt. A latitude/longitude pair as
|
|
82
|
+
// one value, replacing a point the UI used to guess from a `lat`/`lng` name
|
|
83
|
+
// pair. Most coordinate fields already store `{lat, lng}` as a hand-rolled
|
|
84
|
+
// record, so retyping one is shape-preserving: the wire is unchanged and
|
|
85
|
+
// nothing stored needs upcasting.
|
|
86
|
+
let geoPoint = "geoPoint"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless", ~name="semantic")
|
|
90
|
+
|
|
91
|
+
/** Mark a schema as carrying a semantic. */
|
|
92
|
+
let mark = (schema: S.t<'a>, ~id: string, ~payload: payload=Plain): S.t<'a> =>
|
|
93
|
+
schema->S.Metadata.set(~id=semanticId, {id, payload})
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
A schema that validates with `check` and carries the semantic `id`.
|
|
97
|
+
|
|
98
|
+
The branded scalars all have the same shape — one constructor function that
|
|
99
|
+
defines the grammar, and a schema that must agree with it — and `StorageRef`
|
|
100
|
+
established that the schema is *derived* from the constructor rather than
|
|
101
|
+
hand-rolling a second check beside it. Deriving it here makes that structural:
|
|
102
|
+
there is one place a grammar can be written, so there is nowhere for a second
|
|
103
|
+
one to drift.
|
|
104
|
+
*/
|
|
105
|
+
let refined = (base: S.t<'a>, ~id: string, ~check: 'a => result<'a, string>): S.t<'a> =>
|
|
106
|
+
base
|
|
107
|
+
->S.refine(s => value =>
|
|
108
|
+
switch check(value) {
|
|
109
|
+
| Ok(_) => ()
|
|
110
|
+
| Error(why) => s.fail(why)
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
->mark(~id)
|
|
114
|
+
|
|
115
|
+
/** A value as it should read back to the person who typed it. Rejection messages
|
|
116
|
+
reach forms through `validateInput`, so they quote the offending value. */
|
|
117
|
+
let showString = (raw: string): string => raw->JSON.Encode.string->JSON.stringify
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
The semantic a field's schema carries, if any.
|
|
121
|
+
|
|
122
|
+
An **optional** field keeps its marker one level down. The ppx annotates the
|
|
123
|
+
field's `string`, and sury-ppx then wraps that schema in a union with
|
|
124
|
+
`Undefined`/`Null`; the wrapper is a new schema and carries no metadata of its
|
|
125
|
+
own. So a walk that reads only the outer schema sees `imageUrl?: string` as
|
|
126
|
+
carrying no semantic at all — the store goes undeclared, the reference goes
|
|
127
|
+
uncollected, the branded scalar loses its brand. Every reader converges here, so
|
|
128
|
+
following the wrapper once here is what keeps "optional" a statement about
|
|
129
|
+
presence rather than a way to lose the field's type.
|
|
130
|
+
|
|
131
|
+
The outer schema is read first, so a marker set on the wrapper itself still wins.
|
|
132
|
+
Only a union with exactly one non-null variant is followed: that is the shape an
|
|
133
|
+
optional field has, and a genuine multi-variant union has no single inner schema
|
|
134
|
+
whose semantic could stand for the whole.
|
|
135
|
+
*/
|
|
136
|
+
let rec getFrom = (schema: S.t<unknown>): option<t> =>
|
|
137
|
+
switch S.Metadata.get(schema, ~id=semanticId) {
|
|
138
|
+
| Some(_) as found => found
|
|
139
|
+
| None =>
|
|
140
|
+
switch schema {
|
|
141
|
+
| Union({anyOf}) =>
|
|
142
|
+
switch anyOf->Array.filter(v =>
|
|
143
|
+
switch v {
|
|
144
|
+
| Null(_) | Undefined(_) => false
|
|
145
|
+
| _ => true
|
|
146
|
+
}
|
|
147
|
+
) {
|
|
148
|
+
| [inner] => getFrom(inner)
|
|
149
|
+
| _ => None
|
|
150
|
+
}
|
|
151
|
+
| _ => None
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let get = (fieldSchema: S.t<'a>): option<t> => fieldSchema->S.castToUnknown->getFrom
|
|
156
|
+
|
|
157
|
+
/** Whether a field's schema carries this specific semantic. */
|
|
158
|
+
let has = (fieldSchema: S.t<'a>, ~id: string): bool =>
|
|
159
|
+
switch get(fieldSchema) {
|
|
160
|
+
| Some(s) => s.id === id
|
|
161
|
+
| None => false
|
|
162
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
|
|
5
|
+
let Id = {
|
|
6
|
+
dateTime: "dateTime",
|
|
7
|
+
reference: "reference",
|
|
8
|
+
storageRef: "storageRef",
|
|
9
|
+
offload: "offload",
|
|
10
|
+
email: "email",
|
|
11
|
+
phone: "phone",
|
|
12
|
+
url: "url",
|
|
13
|
+
percent: "percent",
|
|
14
|
+
bytes: "bytes",
|
|
15
|
+
duration: "duration",
|
|
16
|
+
color: "color",
|
|
17
|
+
money: "money",
|
|
18
|
+
dateRange: "dateRange",
|
|
19
|
+
geoPoint: "geoPoint"
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let semanticId = S.Metadata.Id.make("reventless", "semantic");
|
|
23
|
+
|
|
24
|
+
function mark(schema, id, payloadOpt) {
|
|
25
|
+
let payload = payloadOpt !== undefined ? payloadOpt : "Plain";
|
|
26
|
+
return S.Metadata.set(schema, semanticId, {
|
|
27
|
+
id: id,
|
|
28
|
+
payload: payload
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function refined(base, id, check) {
|
|
33
|
+
return mark(S.refine(base, s => (value => {
|
|
34
|
+
let why = check(value);
|
|
35
|
+
if (why.TAG === "Ok") {
|
|
36
|
+
return;
|
|
37
|
+
} else {
|
|
38
|
+
return s.fail(why._0, undefined);
|
|
39
|
+
}
|
|
40
|
+
})), id, undefined);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function showString(raw) {
|
|
44
|
+
return JSON.stringify(raw);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getFrom(_schema) {
|
|
48
|
+
while (true) {
|
|
49
|
+
let schema = _schema;
|
|
50
|
+
let found = S.Metadata.get(schema, semanticId);
|
|
51
|
+
if (found !== undefined) {
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
if (schema.type !== "union") {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let match = schema.anyOf.filter(v => {
|
|
58
|
+
switch (v.type) {
|
|
59
|
+
case "null" :
|
|
60
|
+
case "undefined" :
|
|
61
|
+
return false;
|
|
62
|
+
default:
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
if (match.length !== 1) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
_schema = match[0];
|
|
70
|
+
continue;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let get = getFrom;
|
|
75
|
+
|
|
76
|
+
function has(fieldSchema, id) {
|
|
77
|
+
let s = getFrom(fieldSchema);
|
|
78
|
+
if (s !== undefined) {
|
|
79
|
+
return s.id === id;
|
|
80
|
+
} else {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export {
|
|
86
|
+
Id,
|
|
87
|
+
semanticId,
|
|
88
|
+
mark,
|
|
89
|
+
refined,
|
|
90
|
+
showString,
|
|
91
|
+
getFrom,
|
|
92
|
+
get,
|
|
93
|
+
has,
|
|
94
|
+
}
|
|
95
|
+
/* semanticId Not a pure module */
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
A reference to an object living in one of the platform's object stores.
|
|
3
|
+
|
|
4
|
+
The value is the ref string a store's presign service minted — an origin-relative
|
|
5
|
+
path rooted at the store's served prefix, which the UI renders directly because
|
|
6
|
+
the store is fronted read-only on the app's own origin.
|
|
7
|
+
|
|
8
|
+
## Why this is a type and not a convention
|
|
9
|
+
|
|
10
|
+
An event log is append-only, so whatever a command accepts into it is permanent.
|
|
11
|
+
Before this type, an `imageUrl: string` field accepted *anything* — including an
|
|
12
|
+
`https://` URL pointing at somebody else's server, or a multi-megabyte `data:`
|
|
13
|
+
URI inlined into the event itself. Both deploy green, both are unfixable after
|
|
14
|
+
the fact, and neither is what the field means. Declaring the field's type makes
|
|
15
|
+
the wrong values unrepresentable at the boundary, before `decide` ever runs.
|
|
16
|
+
|
|
17
|
+
The declaration also states a *requirement*: a field of this type says the
|
|
18
|
+
deployment needs a store called `store` to exist. Nothing provisions that store
|
|
19
|
+
yet, and that is a deliberate resting point — the validation hole is closed and
|
|
20
|
+
the requirement is written down, which is strictly better than the status quo
|
|
21
|
+
even if automatic provisioning never lands.
|
|
22
|
+
|
|
23
|
+
## The grammar
|
|
24
|
+
|
|
25
|
+
A ref is an absolute, origin-relative path of at least two non-empty segments:
|
|
26
|
+
|
|
27
|
+
/uploads/2f8c1e94-.../photo.jpg
|
|
28
|
+
/uploads/user-42/2f8c1e94-.../photo.jpg
|
|
29
|
+
|
|
30
|
+
Rejected: anything with a scheme (`https://…`, `data:…`), protocol-relative
|
|
31
|
+
`//host/path`, relative paths, empty segments, and `.`/`..` traversal.
|
|
32
|
+
|
|
33
|
+
The framework mints refs in exactly this form — the presign service builds the
|
|
34
|
+
object key as `{servedPrefix}/{identity}{uuid}/{fileName}` and returns `/{key}` —
|
|
35
|
+
so the grammar is the framework's to define, not an application's.
|
|
36
|
+
|
|
37
|
+
Note what is *not* checked: that the ref's prefix belongs to this specific store.
|
|
38
|
+
Today every store shares one served prefix, so there is nothing store-specific to
|
|
39
|
+
check against; the store identity is carried in the field's semantic payload,
|
|
40
|
+
where provisioning and the UI read it. When stores gain per-store prefixes, this
|
|
41
|
+
check tightens from a structural one to a per-store one without the type, the
|
|
42
|
+
wire format, or any stored value changing.
|
|
43
|
+
|
|
44
|
+
@example
|
|
45
|
+
```rescript
|
|
46
|
+
@schema type command =
|
|
47
|
+
| ChangeProductImage({
|
|
48
|
+
productId: @s.matches(DcbTag.string) string,
|
|
49
|
+
imageUrl: @storageRef("productImages") string,
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** The ref's representation. Transparent `string` on purpose: the marker refines
|
|
55
|
+
an existing `string` field rather than replacing it, so the field's runtime
|
|
56
|
+
representation — and therefore every stored event — is unchanged. A sealed
|
|
57
|
+
type here would defeat that, and would also be unattachable via `@s.matches`,
|
|
58
|
+
which requires the schema's type to match the field's. */
|
|
59
|
+
type t = string
|
|
60
|
+
|
|
61
|
+
external unsafe: string => t = "%identity"
|
|
62
|
+
external toString: t => string = "%identity"
|
|
63
|
+
|
|
64
|
+
let segmentIsSafe = (segment: string) =>
|
|
65
|
+
segment !== "" && segment !== "." && segment !== ".."
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
Validate a raw string as a storage ref, saying why when it is not one.
|
|
69
|
+
|
|
70
|
+
This is the single definition of the grammar. `forStore`'s sury schema is derived
|
|
71
|
+
from it rather than hand-rolling a second check, so the constructor and the
|
|
72
|
+
schema validation cannot drift apart.
|
|
73
|
+
*/
|
|
74
|
+
let fromString = (raw: string): result<t, string> =>
|
|
75
|
+
if !String.startsWith(raw, "/") {
|
|
76
|
+
Error(
|
|
77
|
+
`expected an origin-relative storage ref starting with "/", got ${raw->JSON.Encode.string->JSON.stringify}. External URLs and data: URIs are not storage refs.`,
|
|
78
|
+
)
|
|
79
|
+
} else if String.startsWith(raw, "//") {
|
|
80
|
+
Error(`protocol-relative refs are not storage refs: ${raw}`)
|
|
81
|
+
} else {
|
|
82
|
+
let segments = raw->String.slice(~start=1, ~end=String.length(raw))->String.split("/")
|
|
83
|
+
if segments->Array.length < 2 {
|
|
84
|
+
Error(`a storage ref needs a prefix and an object path, got ${raw}`)
|
|
85
|
+
} else if !(segments->Array.every(segmentIsSafe)) {
|
|
86
|
+
Error(`a storage ref may not contain empty or traversal segments, got ${raw}`)
|
|
87
|
+
} else {
|
|
88
|
+
Ok(raw)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
The sury schema for a field holding a ref into a named store.
|
|
94
|
+
|
|
95
|
+
Prefer the `@storageRef("<store>")` ppx shorthand over writing this by hand.
|
|
96
|
+
Qualify the store as `"<plugin>.<store>"` to point at another plugin's store.
|
|
97
|
+
*/
|
|
98
|
+
let forStore = (~plugin: option<string>=?, ~store: string): S.t<t> =>
|
|
99
|
+
S.string
|
|
100
|
+
->S.refine(s => value =>
|
|
101
|
+
// The empty string is admitted as the "no object" sentinel. The fields this
|
|
102
|
+
// marks are non-optional today, and a producer with nothing to reference —
|
|
103
|
+
// a supplier feed carrying no image, say — already writes `""` to mean
|
|
104
|
+
// absence. Rejecting it here would break a legitimate existing value and
|
|
105
|
+
// force an event-schema change, which this marker exists to avoid: it
|
|
106
|
+
// refines an existing `string` field without altering what is stored.
|
|
107
|
+
//
|
|
108
|
+
// Note this is a strictly weaker guarantee than `fromString`, which stays
|
|
109
|
+
// exact. Making these fields properly optional would let the sentinel go.
|
|
110
|
+
if value !== "" {
|
|
111
|
+
switch fromString(value) {
|
|
112
|
+
| Ok(_) => ()
|
|
113
|
+
| Error(why) => s.fail(why)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
->Semantic.mark(~id=Semantic.Id.storageRef, ~payload=StoredIn({plugin, store, threshold: None}))
|
|
118
|
+
|
|
119
|
+
/** The store a field's schema declares its refs live in, if any. */
|
|
120
|
+
let getStore = (schema: S.t<'a>): option<Semantic.storeTarget> =>
|
|
121
|
+
switch Semantic.get(schema) {
|
|
122
|
+
| Some({payload: StoredIn(target)}) => Some(target)
|
|
123
|
+
| _ => None
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** How many refs one field holds. */
|
|
127
|
+
type arity =
|
|
128
|
+
| /** A `string` field: one ref. */ Single
|
|
129
|
+
| /** An `array<string>` field: zero or more refs. */ Multiple
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
The store a *field* declares, looking through an array wrapper, with the arity
|
|
133
|
+
that tells a reader how to get at the value.
|
|
134
|
+
|
|
135
|
+
`getStore` asks about a schema; this asks about a field, and those differ for
|
|
136
|
+
`@storageRef("s") urls: array<string>` — the ppx attaches the marker to the
|
|
137
|
+
element type, because `@s.matches` requires the schema's type to match what it
|
|
138
|
+
is attached to, so the array itself carries no marker and `getStore` on it
|
|
139
|
+
answers `None`.
|
|
140
|
+
|
|
141
|
+
Every reader of a *field* wants this one. Reading `getStore` directly is what
|
|
142
|
+
made a multi-valued store declaration invisible: the store went unprovisioned,
|
|
143
|
+
which is the same silence as not having written the annotation at all.
|
|
144
|
+
*/
|
|
145
|
+
let getFieldStore = (schema: S.t<'a>): option<(Semantic.storeTarget, arity)> =>
|
|
146
|
+
switch getStore(schema) {
|
|
147
|
+
| Some(target) => Some(target, Single)
|
|
148
|
+
| None =>
|
|
149
|
+
switch schema->(Obj.magic: S.t<'a> => S.t<unknown>) {
|
|
150
|
+
| Array({items, additionalItems}) =>
|
|
151
|
+
// sury keeps a homogeneous `S.array` element schema in `additionalItems`
|
|
152
|
+
// and a tuple's positional schemas in `items`; read both so the marker is
|
|
153
|
+
// found wherever the element sits.
|
|
154
|
+
switch items->Array.get(0) {
|
|
155
|
+
| Some({schema: itemSchema}) => getStore(itemSchema)
|
|
156
|
+
| None =>
|
|
157
|
+
switch additionalItems {
|
|
158
|
+
| Schema(itemSchema) => getStore(itemSchema)
|
|
159
|
+
| _ => None
|
|
160
|
+
}
|
|
161
|
+
}->Option.map(target => (target, Multiple))
|
|
162
|
+
| _ => None
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
6
|
+
|
|
7
|
+
function segmentIsSafe(segment) {
|
|
8
|
+
if (segment !== "" && segment !== ".") {
|
|
9
|
+
return segment !== "..";
|
|
10
|
+
} else {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fromString(raw) {
|
|
16
|
+
if (!raw.startsWith("/")) {
|
|
17
|
+
return {
|
|
18
|
+
TAG: "Error",
|
|
19
|
+
_0: `expected an origin-relative storage ref starting with "/", got ` + JSON.stringify(raw) + `. External URLs and data: URIs are not storage refs.`
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (raw.startsWith("//")) {
|
|
23
|
+
return {
|
|
24
|
+
TAG: "Error",
|
|
25
|
+
_0: `protocol-relative refs are not storage refs: ` + raw
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
let segments = raw.slice(1, raw.length).split("/");
|
|
29
|
+
if (segments.length < 2) {
|
|
30
|
+
return {
|
|
31
|
+
TAG: "Error",
|
|
32
|
+
_0: `a storage ref needs a prefix and an object path, got ` + raw
|
|
33
|
+
};
|
|
34
|
+
} else if (segments.every(segmentIsSafe)) {
|
|
35
|
+
return {
|
|
36
|
+
TAG: "Ok",
|
|
37
|
+
_0: raw
|
|
38
|
+
};
|
|
39
|
+
} else {
|
|
40
|
+
return {
|
|
41
|
+
TAG: "Error",
|
|
42
|
+
_0: `a storage ref may not contain empty or traversal segments, got ` + raw
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function forStore(plugin, store) {
|
|
48
|
+
return Semantic$Reventless.mark(S.refine(S.string, s => (value => {
|
|
49
|
+
if (value === "") {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
let why = fromString(value);
|
|
53
|
+
if (why.TAG === "Ok") {
|
|
54
|
+
return;
|
|
55
|
+
} else {
|
|
56
|
+
return s.fail(why._0, undefined);
|
|
57
|
+
}
|
|
58
|
+
})), Semantic$Reventless.Id.storageRef, {
|
|
59
|
+
TAG: "StoredIn",
|
|
60
|
+
_0: {
|
|
61
|
+
plugin: plugin,
|
|
62
|
+
store: store,
|
|
63
|
+
threshold: undefined
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getStore(schema) {
|
|
69
|
+
let match = Semantic$Reventless.get(schema);
|
|
70
|
+
if (match === undefined) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let target = match.payload;
|
|
74
|
+
if (typeof target !== "object" || target.TAG === "ReferenceTo") {
|
|
75
|
+
return;
|
|
76
|
+
} else {
|
|
77
|
+
return target._0;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getFieldStore(schema) {
|
|
82
|
+
let target = getStore(schema);
|
|
83
|
+
if (target !== undefined) {
|
|
84
|
+
return [
|
|
85
|
+
target,
|
|
86
|
+
"Single"
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
if (schema.type !== "array") {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let additionalItems = schema.additionalItems;
|
|
93
|
+
let match = schema.items[0];
|
|
94
|
+
let tmp;
|
|
95
|
+
tmp = match !== undefined ? getStore(match.schema) : (
|
|
96
|
+
additionalItems === "strip" || additionalItems === "strict" ? undefined : getStore(additionalItems)
|
|
97
|
+
);
|
|
98
|
+
return Stdlib_Option.map(tmp, target => [
|
|
99
|
+
target,
|
|
100
|
+
"Multiple"
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export {
|
|
105
|
+
segmentIsSafe,
|
|
106
|
+
fromString,
|
|
107
|
+
forStore,
|
|
108
|
+
getStore,
|
|
109
|
+
getFieldStore,
|
|
110
|
+
}
|
|
111
|
+
/* S Not a pure module */
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Marks a `string` field as a web address.
|
|
3
|
+
|
|
4
|
+
## The grammar: parseable, **and** `http`/`https`
|
|
5
|
+
|
|
6
|
+
Sury's `S.url` is the parse — it is `new URL()`, so it settles host, port and
|
|
7
|
+
escaping without this module having an opinion. But `new URL()` accepts every
|
|
8
|
+
scheme, `javascript:alert(1)` included, and that is not an academic gap here: a
|
|
9
|
+
field carrying this semantic renders as an anchor whose `href` is the stored
|
|
10
|
+
value. An append-only log plus a scheme nobody checked is a stored XSS that
|
|
11
|
+
cannot be deleted afterwards — the same shape of hole `StorageRef` exists to
|
|
12
|
+
close, arriving through a different field.
|
|
13
|
+
|
|
14
|
+
So the grammar is sury's parse plus a two-scheme allowlist. `mailto:` and `tel:`
|
|
15
|
+
are deliberately outside it: those are `Email` and `Phone`, which validate what
|
|
16
|
+
they actually hold and render correctly on their own.
|
|
17
|
+
|
|
18
|
+
@example
|
|
19
|
+
```rescript
|
|
20
|
+
@schema type command =
|
|
21
|
+
| SetSupplierSite({
|
|
22
|
+
supplierId: @s.matches(DcbTag.string) string,
|
|
23
|
+
website: @s.matches(Reventless.Url.schema) string,
|
|
24
|
+
})
|
|
25
|
+
```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The address's representation. Transparent `string`; see `Email.t`. */
|
|
29
|
+
type t = string
|
|
30
|
+
|
|
31
|
+
external unsafe: string => t = "%identity"
|
|
32
|
+
external toString: t => string = "%identity"
|
|
33
|
+
|
|
34
|
+
// Sury's parse, held once — `fromString` runs it and adds the scheme check, and
|
|
35
|
+
// `schema` derives from `fromString`. One grammar, in one place.
|
|
36
|
+
let grammar: S.t<string> = S.string->S.url
|
|
37
|
+
|
|
38
|
+
// Schemes are case-insensitive per RFC 3986, and `HTTPS://x` parses fine, so the
|
|
39
|
+
// allowlist has to fold case or it rejects a valid address on a technicality.
|
|
40
|
+
let hasWebScheme = (raw: string): bool => {
|
|
41
|
+
let lower = String.toLowerCase(raw)
|
|
42
|
+
String.startsWith(lower, "http://") || String.startsWith(lower, "https://")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Validate a raw string as an `http`/`https` URL, saying why when it is not one. */
|
|
46
|
+
let fromString = (raw: string): result<t, string> =>
|
|
47
|
+
switch raw->S.parseOrThrow(grammar) {
|
|
48
|
+
| value =>
|
|
49
|
+
if hasWebScheme(value) {
|
|
50
|
+
Ok(value)
|
|
51
|
+
} else {
|
|
52
|
+
Error(
|
|
53
|
+
`a URL field takes an http:// or https:// address, got ${Semantic.showString(raw)}. ` ++
|
|
54
|
+
`Use Email or Phone for mailto:/tel:, and a storage ref for an uploaded object.`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
| exception _ =>
|
|
58
|
+
Error(
|
|
59
|
+
`expected an absolute URL, got ${Semantic.showString(
|
|
60
|
+
raw,
|
|
61
|
+
)}. A relative path is not a URL — include the scheme and host.`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The sury schema for a URL field. Use with `@s.matches(Reventless.Url.schema)`. */
|
|
66
|
+
let schema: S.t<t> = S.string->Semantic.refined(~id=Semantic.Id.url, ~check=fromString)
|
|
@@ -0,0 +1,48 @@
|
|
|
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 grammar = S.url(S.string, undefined);
|
|
7
|
+
|
|
8
|
+
function hasWebScheme(raw) {
|
|
9
|
+
let lower = raw.toLowerCase();
|
|
10
|
+
if (lower.startsWith("http://")) {
|
|
11
|
+
return true;
|
|
12
|
+
} else {
|
|
13
|
+
return lower.startsWith("https://");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fromString(raw) {
|
|
18
|
+
let value;
|
|
19
|
+
try {
|
|
20
|
+
value = S.parseOrThrow(raw, grammar);
|
|
21
|
+
} catch (exn) {
|
|
22
|
+
return {
|
|
23
|
+
TAG: "Error",
|
|
24
|
+
_0: `expected an absolute URL, got ` + Semantic$Reventless.showString(raw) + `. A relative path is not a URL — include the scheme and host.`
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (hasWebScheme(value)) {
|
|
28
|
+
return {
|
|
29
|
+
TAG: "Ok",
|
|
30
|
+
_0: value
|
|
31
|
+
};
|
|
32
|
+
} else {
|
|
33
|
+
return {
|
|
34
|
+
TAG: "Error",
|
|
35
|
+
_0: `a URL field takes an http:// or https:// address, got ` + Semantic$Reventless.showString(raw) + `. Use Email or Phone for mailto:/tel:, and a storage ref for an uploaded object.`
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let schema = Semantic$Reventless.refined(S.string, Semantic$Reventless.Id.url, fromString);
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
grammar,
|
|
44
|
+
hasWebScheme,
|
|
45
|
+
fromString,
|
|
46
|
+
schema,
|
|
47
|
+
}
|
|
48
|
+
/* grammar Not a pure module */
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Provider-agnostic authorization rules. Evaluated against the
|
|
2
|
+
// `Identity.t` resolved by an `Auth_Adapter.Provider.authenticate` call.
|
|
3
|
+
//
|
|
4
|
+
// `array<string>` for groups (not a parameterised variant) keeps the
|
|
5
|
+
// framework decoupled from any application's specific group set —
|
|
6
|
+
// applications can define their own typed `group` variant and convert via
|
|
7
|
+
// a thin helper, see docs/analysis/authentication-authorization.md §4.2.
|
|
8
|
+
|
|
9
|
+
@schema
|
|
10
|
+
type permission =
|
|
11
|
+
| AllowGroups(array<string>)
|
|
12
|
+
| AllowAuthenticated
|
|
13
|
+
| AllowAnonymous
|
|
14
|
+
| DenyAll
|
|
15
|
+
|
|
16
|
+
let isAllowed = (rule: permission, identity: Identity.t): bool =>
|
|
17
|
+
switch rule {
|
|
18
|
+
| DenyAll => false
|
|
19
|
+
| AllowAnonymous => true
|
|
20
|
+
| AllowAuthenticated => identity.userId !== "anonymous"
|
|
21
|
+
| AllowGroups(groups) =>
|
|
22
|
+
groups->Array.some(group => identity.groups->Array.includes(group))
|
|
23
|
+
}
|