@reventlessdev/reventless-spec 3.0.0-alpha.80 → 3.0.0-alpha.82
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 +16 -0
- package/package.json +1 -1
- package/src/components/Plugin.res +10 -0
- package/src/components/Plugin.res.mjs +1 -0
- package/src/components/Reference.res +11 -7
- package/src/components/Reference.res.mjs +24 -11
- package/src/components/StateAnnotations.res +23 -0
- package/src/components/StateViewSlice.res +27 -3
- package/src/semantic/Semantic.res +67 -0
- package/src/semantic/Semantic.res.mjs +41 -0
- package/src/semantic/StorageRef.res +124 -0
- package/src/semantic/StorageRef.res.mjs +85 -0
- package/src/types/DateTime.res +29 -0
- package/src/types/DateTime.res.mjs +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
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.82 (2026-07-28)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **spec:** one semantic marker every typed semantic marks itself with ([aa18afc](https://github.com/ReventlessDev/reventless-core/commit/aa18afcf04c8edad9afe27e6fa4261d01e184da7))
|
|
11
|
+
* **spec:** StorageRef — the first semantic type, declared on the field's type ([44f15c3](https://github.com/ReventlessDev/reventless-core/commit/44f15c37de71261d701d18a9f1ada6f481c4a8dc))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.81 (2026-07-26)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **auto-ui:** declare command target state via [@target](https://github.com/target)State ([5fc0374](https://github.com/ReventlessDev/reventless-core/commit/5fc03741a8816c57085b86a4ad7d595e3b690193)), closes [#5](https://github.com/ReventlessDev/reventless-core/issues/5)
|
|
19
|
+
* **auto-ui:** declare field semantics + dashboard metrics ([@semantic](https://github.com/semantic), [@metric](https://github.com/metric)) ([d74ff77](https://github.com/ReventlessDev/reventless-core/commit/d74ff7721e18e8638a82931a370a549b304dac94)), closes [#4](https://github.com/ReventlessDev/reventless-core/issues/4)
|
|
20
|
+
|
|
21
|
+
|
|
6
22
|
# 3.0.0-alpha.80 (2026-07-22)
|
|
7
23
|
|
|
8
24
|
### Features
|
package/package.json
CHANGED
|
@@ -177,6 +177,16 @@ type commandDef = {
|
|
|
177
177
|
*/
|
|
178
178
|
allowedStates: @s.matches(stringArrayOptionSchema) option<array<string>>,
|
|
179
179
|
/**
|
|
180
|
+
The single status value this command's handler writes — the command's *to*
|
|
181
|
+
state, sibling of `allowedStates`' *from* set. Source: the
|
|
182
|
+
`@targetState("Shipped")` command-variant annotation. `None` (absent
|
|
183
|
+
annotation) is the back-compat default: AutoUI's board resolver then falls
|
|
184
|
+
back to its name-stem heuristic. `Some("Shipped")` lets the resolver move a
|
|
185
|
+
row by a declared transition instead of a guess. js_nullable for JSON safety,
|
|
186
|
+
same as `allowedStates`.
|
|
187
|
+
*/
|
|
188
|
+
targetState: @s.matches(stringOptionSchema) option<string>,
|
|
189
|
+
/**
|
|
180
190
|
Whether this command variant is exposed in the generated API (a non-`@noApi`
|
|
181
191
|
variant of a non-`@noApi` command). Dev tooling badges API-exposed commands in
|
|
182
192
|
the event graph. js_nullable (T | null) so it stays JSON-safe inside the
|
|
@@ -102,6 +102,7 @@ let commandDefSchema = S.schema(s => ({
|
|
|
102
102
|
mutationField: s.m(S.string),
|
|
103
103
|
references: s.m(S.array(fieldReferenceSchema)),
|
|
104
104
|
allowedStates: s.m(stringArrayOptionSchema),
|
|
105
|
+
targetState: s.m(stringOptionSchema),
|
|
105
106
|
apiExposed: s.m(boolOptionSchema)
|
|
106
107
|
}));
|
|
107
108
|
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
/** Identifies which entity a reference field points to. */
|
|
2
|
-
type target =
|
|
3
|
-
|
|
4
|
-
/** Sury metadata ID for entity reference annotation. */
|
|
5
|
-
let referenceId: S.Metadata.Id.t<target> = S.Metadata.Id.make(~namespace="reventless", ~name="reference")
|
|
2
|
+
type target = Semantic.referenceTarget
|
|
6
3
|
|
|
7
4
|
/**
|
|
8
5
|
A sury string schema annotated as an entity reference field.
|
|
@@ -28,10 +25,14 @@ Prefer the `@ref("EntityName")` ppx shorthand over writing `@s.matches(...)` by
|
|
|
28
25
|
```
|
|
29
26
|
*/
|
|
30
27
|
let to_ = (~plugin=?, ~key=?, entity: string): S.t<string> => {
|
|
28
|
+
// Reference-ness and DCB-tagged-ness are two independent facts that happen to
|
|
29
|
+
// co-occur here: this constructor declares both. `toWithoutDcbTag` declares
|
|
30
|
+
// only the first, and plain `DcbTag.string` only the second — so no consumer
|
|
31
|
+
// may infer either one from the other.
|
|
31
32
|
let base =
|
|
32
33
|
S.string
|
|
33
34
|
->S.Metadata.set(~id=DcbTag.dcbTagId, true)
|
|
34
|
-
->
|
|
35
|
+
->Semantic.mark(~id=Semantic.Id.reference, ~payload=ReferenceTo({entity, plugin}))
|
|
35
36
|
switch key {
|
|
36
37
|
| Some(k) => base->S.Metadata.set(~id=DcbTag.dcbTagKeyOverrideId, k)
|
|
37
38
|
| None => base
|
|
@@ -40,7 +41,10 @@ let to_ = (~plugin=?, ~key=?, entity: string): S.t<string> => {
|
|
|
40
41
|
|
|
41
42
|
/** Returns the reference target if the schema carries `Reference.to_(...)` metadata. */
|
|
42
43
|
let getTarget = (schema: S.t<unknown>): option<target> =>
|
|
43
|
-
|
|
44
|
+
switch Semantic.get(schema) {
|
|
45
|
+
| Some({payload: ReferenceTo(target)}) => Some(target)
|
|
46
|
+
| _ => None
|
|
47
|
+
}
|
|
44
48
|
|
|
45
49
|
/**
|
|
46
50
|
Like `to_` but does not imply DCB tag semantics.
|
|
@@ -48,4 +52,4 @@ Use with `@ref("Entity") @noDcbTag` when the field references another entity
|
|
|
48
52
|
but should not participate in content-based event routing.
|
|
49
53
|
*/
|
|
50
54
|
let toWithoutDcbTag = (~plugin=?, entity: string): S.t<string> =>
|
|
51
|
-
S.string->
|
|
55
|
+
S.string->Semantic.mark(~id=Semantic.Id.reference, ~payload=ReferenceTo({entity, plugin}))
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import * as S from "sury/src/S.res.mjs";
|
|
4
4
|
import * as DcbTag$Reventless from "./DcbTag.res.mjs";
|
|
5
|
-
|
|
6
|
-
let referenceId = S.Metadata.Id.make("reventless", "reference");
|
|
5
|
+
import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
|
|
7
6
|
|
|
8
7
|
function to_(plugin, key, entity) {
|
|
9
|
-
let base =
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
let base = Semantic$Reventless.mark(S.Metadata.set(S.string, DcbTag$Reventless.dcbTagId, true), Semantic$Reventless.Id.reference, {
|
|
9
|
+
TAG: "ReferenceTo",
|
|
10
|
+
_0: {
|
|
11
|
+
entity: entity,
|
|
12
|
+
plugin: plugin
|
|
13
|
+
}
|
|
12
14
|
});
|
|
13
15
|
if (key !== undefined) {
|
|
14
16
|
return S.Metadata.set(base, DcbTag$Reventless.dcbTagKeyOverrideId, key);
|
|
@@ -18,20 +20,31 @@ function to_(plugin, key, entity) {
|
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
function getTarget(schema) {
|
|
21
|
-
|
|
23
|
+
let match = Semantic$Reventless.get(schema);
|
|
24
|
+
if (match === undefined) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
let target = match.payload;
|
|
28
|
+
if (typeof target !== "object" || target.TAG !== "ReferenceTo") {
|
|
29
|
+
return;
|
|
30
|
+
} else {
|
|
31
|
+
return target._0;
|
|
32
|
+
}
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
function toWithoutDcbTag(plugin, entity) {
|
|
25
|
-
return
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
return Semantic$Reventless.mark(S.string, Semantic$Reventless.Id.reference, {
|
|
37
|
+
TAG: "ReferenceTo",
|
|
38
|
+
_0: {
|
|
39
|
+
entity: entity,
|
|
40
|
+
plugin: plugin
|
|
41
|
+
}
|
|
28
42
|
});
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
export {
|
|
32
|
-
referenceId,
|
|
33
46
|
to_,
|
|
34
47
|
getTarget,
|
|
35
48
|
toWithoutDcbTag,
|
|
36
49
|
}
|
|
37
|
-
/*
|
|
50
|
+
/* S Not a pure module */
|
|
@@ -26,6 +26,13 @@ free on the in-memory adapter but is `O(n)` Scan + FilterExpression on
|
|
|
26
26
|
DynamoDB-backed adapters — the annotation is the explicit signal that the
|
|
27
27
|
read model is small enough or the cost is acceptable.
|
|
28
28
|
*/
|
|
29
|
+
/**
|
|
30
|
+
Declared aggregation for a `@metric`-annotated numeric field. `aggregate` is one
|
|
31
|
+
of `"count"`, `"sum"`, `"avg"` (the UI's dashboard vocabulary); `label` is the
|
|
32
|
+
KPI label, or `""` to let the UI derive one from the field name.
|
|
33
|
+
*/
|
|
34
|
+
type metricSpec = {aggregate: string, label: string}
|
|
35
|
+
|
|
29
36
|
type stateAnnotationSpec = {
|
|
30
37
|
ids: array<string>,
|
|
31
38
|
compositeIds: array<string>,
|
|
@@ -40,6 +47,22 @@ type stateAnnotationSpec = {
|
|
|
40
47
|
scan: array<string>,
|
|
41
48
|
scanSort: array<string>,
|
|
42
49
|
/**
|
|
50
|
+
Fields annotated `@semantic("<id>")` — `(fieldName, semanticId)` pairs. The
|
|
51
|
+
semantic id is the UI's `AutoSemantics` vocabulary (e.g. `"currency"`,
|
|
52
|
+
`"geo-lat"`); the PPX can't validate it (the UI owns that vocabulary).
|
|
53
|
+
`SuryToJsonSchema` emits `x-reventless-semantic` on the named field, which
|
|
54
|
+
AutoUI reads at `#Annotation` provenance — above its heuristic, below a
|
|
55
|
+
`ui-hints.json` `fields:` override.
|
|
56
|
+
*/
|
|
57
|
+
semantic: array<(string, string)>,
|
|
58
|
+
/**
|
|
59
|
+
Fields annotated `@metric(...)` — `(fieldName, metricSpec)` pairs.
|
|
60
|
+
`SuryToJsonSchema` emits `x-reventless-metric: {aggregate, label}` on the named
|
|
61
|
+
field, which AutoUI folds into the dashboard metric list it already builds from
|
|
62
|
+
hints — so a plugin gets a Dashboard page from its schema alone.
|
|
63
|
+
*/
|
|
64
|
+
metric: array<(string, metricSpec)>,
|
|
65
|
+
/**
|
|
43
66
|
Field annotated `@status` on the state record (PPX-emitted). `Some(name)`
|
|
44
67
|
when one such annotation exists; the PPX errors on duplicate `@status`
|
|
45
68
|
annotations within the same record. Codegen consumes this to populate
|
|
@@ -24,7 +24,7 @@ let name = "CategoriesView"
|
|
|
24
24
|
| CategoryRenamed({categoryId: string, name: string})
|
|
25
25
|
| CategoryArchived({categoryId: string})
|
|
26
26
|
|
|
27
|
-
let project = event => switch event {
|
|
27
|
+
let project = ({event}) => switch event {
|
|
28
28
|
| CategoryAdded({categoryId, name}) =>
|
|
29
29
|
[Set(categoryId, {categoryId, name, archived: false})]
|
|
30
30
|
| CategoryRenamed({categoryId, name}) =>
|
|
@@ -81,6 +81,29 @@ module type Spec = {
|
|
|
81
81
|
let visibility: Visibility.t
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
The envelope a projection receives for each consumed event.
|
|
86
|
+
|
|
87
|
+
Carries the decoded event alongside the metadata the storage layer already
|
|
88
|
+
holds, so a projection can persist producer time or the acting user without the
|
|
89
|
+
command author having to duplicate that framework state into the event payload.
|
|
90
|
+
|
|
91
|
+
`meta.time` and `recordedAt` are **different clocks** — pick deliberately:
|
|
92
|
+
|
|
93
|
+
- `meta.time` — *producer* time, stamped when the command handler created the
|
|
94
|
+
event. This is the domain-meaningful timestamp (when the order was placed,
|
|
95
|
+
when it shipped); it is identical across every path.
|
|
96
|
+
- `recordedAt` — *storage* time, when the event was appended to the DCB log. On
|
|
97
|
+
the AWS (DynamoDB-stream) path this is the authoritative stored column; on the
|
|
98
|
+
local (topic) path it is stamped at publish, so it may sit a few milliseconds
|
|
99
|
+
after the stored value. Use it for storage-lag diagnostics, not domain dates.
|
|
100
|
+
*/
|
|
101
|
+
type consumed<'e> = {
|
|
102
|
+
event: 'e,
|
|
103
|
+
meta: Message.meta,
|
|
104
|
+
recordedAt: string,
|
|
105
|
+
}
|
|
106
|
+
|
|
84
107
|
/**
|
|
85
108
|
The Projection — the pure projection function from consumed events to actions.
|
|
86
109
|
*/
|
|
@@ -89,9 +112,10 @@ module type Projection = {
|
|
|
89
112
|
|
|
90
113
|
/**
|
|
91
114
|
Projects one consumed event into read model actions.
|
|
92
|
-
Receives
|
|
115
|
+
Receives the event wrapped in a `consumed` envelope (event + `meta` +
|
|
116
|
+
`recordedAt`); only events declared in `Spec.consumedEvent` — no wildcard needed.
|
|
93
117
|
*/
|
|
94
|
-
let project: Spec.consumedEvent => array<Projection.action<string, Spec.state>>
|
|
118
|
+
let project: consumed<Spec.consumedEvent> => array<Projection.action<string, Spec.state>>
|
|
95
119
|
|
|
96
120
|
/** File URL of this Projection module (`import.meta.url`). */
|
|
97
121
|
let moduleUrl: string
|
|
@@ -0,0 +1,67 @@
|
|
|
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 field's value lives in. `plugin` is absent
|
|
28
|
+
when the store belongs to the declaring plugin, which is the common case. */
|
|
29
|
+
type storeTarget = {plugin: option<string>, store: string}
|
|
30
|
+
|
|
31
|
+
/** Per-semantic detail, for the semantics that carry any. */
|
|
32
|
+
type payload =
|
|
33
|
+
| Plain
|
|
34
|
+
| ReferenceTo(referenceTarget)
|
|
35
|
+
| StoredIn(storeTarget)
|
|
36
|
+
|
|
37
|
+
/** A field's semantic: the vocabulary id, plus its detail. */
|
|
38
|
+
type t = {id: string, payload: payload}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
The semantic ids the framework itself defines.
|
|
42
|
+
|
|
43
|
+
These strings are the wire vocabulary — they are what `x-reventless-semantic`
|
|
44
|
+
carries, and the same vocabulary the string annotation path already uses, so the
|
|
45
|
+
type path and the annotation path converge on one wire format rather than two.
|
|
46
|
+
*/
|
|
47
|
+
module Id = {
|
|
48
|
+
let dateTime = "dateTime"
|
|
49
|
+
let reference = "reference"
|
|
50
|
+
let storageRef = "storageRef"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless", ~name="semantic")
|
|
54
|
+
|
|
55
|
+
/** Mark a schema as carrying a semantic. */
|
|
56
|
+
let mark = (schema: S.t<'a>, ~id: string, ~payload: payload=Plain): S.t<'a> =>
|
|
57
|
+
schema->S.Metadata.set(~id=semanticId, {id, payload})
|
|
58
|
+
|
|
59
|
+
/** The semantic a field's schema carries, if any. */
|
|
60
|
+
let get = (fieldSchema: S.t<'a>): option<t> => S.Metadata.get(fieldSchema, ~id=semanticId)
|
|
61
|
+
|
|
62
|
+
/** Whether a field's schema carries this specific semantic. */
|
|
63
|
+
let has = (fieldSchema: S.t<'a>, ~id: string): bool =>
|
|
64
|
+
switch get(fieldSchema) {
|
|
65
|
+
| Some(s) => s.id === id
|
|
66
|
+
| None => false
|
|
67
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
};
|
|
10
|
+
|
|
11
|
+
let semanticId = S.Metadata.Id.make("reventless", "semantic");
|
|
12
|
+
|
|
13
|
+
function mark(schema, id, payloadOpt) {
|
|
14
|
+
let payload = payloadOpt !== undefined ? payloadOpt : "Plain";
|
|
15
|
+
return S.Metadata.set(schema, semanticId, {
|
|
16
|
+
id: id,
|
|
17
|
+
payload: payload
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function get(fieldSchema) {
|
|
22
|
+
return S.Metadata.get(fieldSchema, semanticId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function has(fieldSchema, id) {
|
|
26
|
+
let s = S.Metadata.get(fieldSchema, semanticId);
|
|
27
|
+
if (s !== undefined) {
|
|
28
|
+
return s.id === id;
|
|
29
|
+
} else {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
Id,
|
|
36
|
+
semanticId,
|
|
37
|
+
mark,
|
|
38
|
+
get,
|
|
39
|
+
has,
|
|
40
|
+
}
|
|
41
|
+
/* semanticId Not a pure module */
|
|
@@ -0,0 +1,124 @@
|
|
|
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}))
|
|
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
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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 segmentIsSafe(segment) {
|
|
7
|
+
if (segment !== "" && segment !== ".") {
|
|
8
|
+
return segment !== "..";
|
|
9
|
+
} else {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fromString(raw) {
|
|
15
|
+
if (!raw.startsWith("/")) {
|
|
16
|
+
return {
|
|
17
|
+
TAG: "Error",
|
|
18
|
+
_0: `expected an origin-relative storage ref starting with "/", got ` + JSON.stringify(raw) + `. External URLs and data: URIs are not storage refs.`
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (raw.startsWith("//")) {
|
|
22
|
+
return {
|
|
23
|
+
TAG: "Error",
|
|
24
|
+
_0: `protocol-relative refs are not storage refs: ` + raw
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
let segments = raw.slice(1, raw.length).split("/");
|
|
28
|
+
if (segments.length < 2) {
|
|
29
|
+
return {
|
|
30
|
+
TAG: "Error",
|
|
31
|
+
_0: `a storage ref needs a prefix and an object path, got ` + raw
|
|
32
|
+
};
|
|
33
|
+
} else if (segments.every(segmentIsSafe)) {
|
|
34
|
+
return {
|
|
35
|
+
TAG: "Ok",
|
|
36
|
+
_0: raw
|
|
37
|
+
};
|
|
38
|
+
} else {
|
|
39
|
+
return {
|
|
40
|
+
TAG: "Error",
|
|
41
|
+
_0: `a storage ref may not contain empty or traversal segments, got ` + raw
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function forStore(plugin, store) {
|
|
47
|
+
return Semantic$Reventless.mark(S.refine(S.string, s => (value => {
|
|
48
|
+
if (value === "") {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let why = fromString(value);
|
|
52
|
+
if (why.TAG === "Ok") {
|
|
53
|
+
return;
|
|
54
|
+
} else {
|
|
55
|
+
return s.fail(why._0, undefined);
|
|
56
|
+
}
|
|
57
|
+
})), Semantic$Reventless.Id.storageRef, {
|
|
58
|
+
TAG: "StoredIn",
|
|
59
|
+
_0: {
|
|
60
|
+
plugin: plugin,
|
|
61
|
+
store: store
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getStore(schema) {
|
|
67
|
+
let match = Semantic$Reventless.get(schema);
|
|
68
|
+
if (match === undefined) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let target = match.payload;
|
|
72
|
+
if (typeof target !== "object" || target.TAG === "ReferenceTo") {
|
|
73
|
+
return;
|
|
74
|
+
} else {
|
|
75
|
+
return target._0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export {
|
|
80
|
+
segmentIsSafe,
|
|
81
|
+
fromString,
|
|
82
|
+
forStore,
|
|
83
|
+
getStore,
|
|
84
|
+
}
|
|
85
|
+
/* S Not a pure module */
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Marks a `string` state field as an ISO-8601 date-time.
|
|
3
|
+
|
|
4
|
+
Sury has no string-typed datetime format (`S.datetime` transforms to `Js.Date.t`,
|
|
5
|
+
changing the field's runtime type), so this mirrors the `DcbTag.string`
|
|
6
|
+
precedent: a `S.t<string>` carrying sury metadata that downstream schema walkers
|
|
7
|
+
detect. `SchemaType`/`SuryToJsonSchema` surface it as `format: "date-time"` on
|
|
8
|
+
the field's JSON Schema, which the AutoUI date heuristics key off (CalendarView,
|
|
9
|
+
TimelineView, date-axis charts).
|
|
10
|
+
|
|
11
|
+
Use on a producer/storage timestamp a projection writes into its state — most
|
|
12
|
+
commonly a `meta.time`-derived field such as `placedAt` / `shippedAt`:
|
|
13
|
+
|
|
14
|
+
@example
|
|
15
|
+
```rescript
|
|
16
|
+
@schema
|
|
17
|
+
type state = {
|
|
18
|
+
orderId: string,
|
|
19
|
+
placedAt: @s.matches(Reventless.DateTime.string) string,
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** A sury string schema annotated as an ISO-8601 date-time field.
|
|
25
|
+
Use with `@s.matches(Reventless.DateTime.string)`. */
|
|
26
|
+
let string: S.t<string> = S.string->Semantic.mark(~id=Semantic.Id.dateTime)
|
|
27
|
+
|
|
28
|
+
/** Whether a field schema carries the date-time marker. */
|
|
29
|
+
let isDateTime = (fieldSchema: S.t<unknown>) => fieldSchema->Semantic.has(~id=Semantic.Id.dateTime)
|
|
@@ -0,0 +1,16 @@
|
|
|
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/Semantic.res.mjs";
|
|
5
|
+
|
|
6
|
+
let string = Semantic$Reventless.mark(S.string, Semantic$Reventless.Id.dateTime, undefined);
|
|
7
|
+
|
|
8
|
+
function isDateTime(fieldSchema) {
|
|
9
|
+
return Semantic$Reventless.has(fieldSchema, Semantic$Reventless.Id.dateTime);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
string,
|
|
14
|
+
isDateTime,
|
|
15
|
+
}
|
|
16
|
+
/* string Not a pure module */
|