@reventlessdev/reventless-spec 3.0.0-alpha.79 → 3.0.0-alpha.81
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 +15 -0
- package/package.json +1 -1
- package/src/LogPrefix.res +25 -1
- package/src/LogPrefix.res.mjs +13 -2
- package/src/components/Plugin.res +10 -0
- package/src/components/Plugin.res.mjs +1 -0
- package/src/components/StateAnnotations.res +23 -0
- package/src/components/StateViewSlice.res +27 -3
- package/src/types/DateTime.res +33 -0
- package/src/types/DateTime.res.mjs +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
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.81 (2026-07-26)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **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)
|
|
11
|
+
* **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)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.80 (2026-07-22)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **core:** annotate event-collector logs with the element's comp ([eda413a](https://github.com/ReventlessDev/reventless-core/commit/eda413ab00eb8bb02b30e029af2d6221e3e9ba75)), closes [#1](https://github.com/ReventlessDev/reventless-core/issues/1)
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.79 (2026-07-17)
|
|
7
22
|
|
|
8
23
|
**Note:** Version bump only for package @reventlessdev/reventless-spec
|
package/package.json
CHANGED
package/src/LogPrefix.res
CHANGED
|
@@ -12,6 +12,8 @@ Resolution priority for a comp string like `"Kind(Name)"`:
|
|
|
12
12
|
- strip trailing `Plugin` (handles `CommandTopic(FooPlugin)`)
|
|
13
13
|
- last dot segment (handles `Extension(Catalog.Products.Ordering)`)
|
|
14
14
|
- first dot segment (handles `ExtensionPoint(Catalog.Products)`)
|
|
15
|
+
4. Longest registered component name that prefixes the inner name (handles
|
|
16
|
+
kind-suffixed resource names like `EventCollector(CustomersReadModel)`).
|
|
15
17
|
|
|
16
18
|
`Plugin_Builder` registers every component (and the plugin's self-name) when
|
|
17
19
|
constructing a plugin, so all transformation candidates resolve to a
|
|
@@ -100,6 +102,26 @@ let lastDotSegment = (s: string): option<string> => {
|
|
|
100
102
|
let lookup = (name: string): option<string> =>
|
|
101
103
|
componentPluginRegistry.contents->Dict.get(name)
|
|
102
104
|
|
|
105
|
+
// Last-resort candidate: the inner name is a registered component name carrying a
|
|
106
|
+
// component-kind suffix (`Customers` + `ReadModel` → `CustomersReadModel`), which is
|
|
107
|
+
// how event-collector resources are named. Longest prefix wins so `OrdersReadModel`
|
|
108
|
+
// resolves via `Orders` rather than a shorter `Order` registered by another plugin.
|
|
109
|
+
// LogPrefix sits below `ComponentType`, so this stays suffix-table-free.
|
|
110
|
+
let longestRegisteredPrefix = (name: string): option<string> =>
|
|
111
|
+
componentPluginRegistry.contents
|
|
112
|
+
->Dict.keysToArray
|
|
113
|
+
->Array.reduce(None, (acc, key) =>
|
|
114
|
+
if key->String.length < name->String.length && name->String.startsWith(key) {
|
|
115
|
+
switch acc {
|
|
116
|
+
| Some(best) if best->String.length >= key->String.length => acc
|
|
117
|
+
| _ => Some(key)
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
acc
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
->Option.flatMap(lookup)
|
|
124
|
+
|
|
103
125
|
let resolvePlugin = (~comp=?, ()) =>
|
|
104
126
|
switch comp {
|
|
105
127
|
| Some(c) =>
|
|
@@ -118,12 +140,14 @@ let resolvePlugin = (~comp=?, ()) =>
|
|
|
118
140
|
lastDotSegment(inner),
|
|
119
141
|
firstDotSegment(inner),
|
|
120
142
|
]
|
|
121
|
-
candidates
|
|
143
|
+
candidates
|
|
144
|
+
->Array.reduce(None, (acc, candidate) =>
|
|
122
145
|
switch acc {
|
|
123
146
|
| Some(_) => acc
|
|
124
147
|
| None => candidate->Option.flatMap(lookup)
|
|
125
148
|
}
|
|
126
149
|
)
|
|
150
|
+
->Option.orElse(longestRegisteredPrefix(inner))
|
|
127
151
|
}
|
|
128
152
|
}
|
|
129
153
|
| None => currentPluginName.contents
|
package/src/LogPrefix.res.mjs
CHANGED
|
@@ -80,6 +80,16 @@ function lookup(name) {
|
|
|
80
80
|
return componentPluginRegistry.contents[name];
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
function longestRegisteredPrefix(name) {
|
|
84
|
+
return Stdlib_Option.flatMap(Stdlib_Array.reduce(Object.keys(componentPluginRegistry.contents), undefined, (acc, key) => {
|
|
85
|
+
if (key.length < name.length && name.startsWith(key) && !(acc !== undefined && acc.length >= key.length)) {
|
|
86
|
+
return key;
|
|
87
|
+
} else {
|
|
88
|
+
return acc;
|
|
89
|
+
}
|
|
90
|
+
}), lookup);
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
function resolvePlugin(comp, param) {
|
|
84
94
|
if (comp === undefined) {
|
|
85
95
|
return currentPluginName.contents;
|
|
@@ -102,13 +112,13 @@ function resolvePlugin(comp, param) {
|
|
|
102
112
|
lastDotSegment(inner),
|
|
103
113
|
firstDotSegment(inner)
|
|
104
114
|
];
|
|
105
|
-
return Stdlib_Array.reduce(candidates, undefined, (acc, candidate) => {
|
|
115
|
+
return Stdlib_Option.orElse(Stdlib_Array.reduce(candidates, undefined, (acc, candidate) => {
|
|
106
116
|
if (acc !== undefined) {
|
|
107
117
|
return acc;
|
|
108
118
|
} else {
|
|
109
119
|
return Stdlib_Option.flatMap(candidate, lookup);
|
|
110
120
|
}
|
|
111
|
-
});
|
|
121
|
+
}), longestRegisteredPrefix(inner));
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
function fmtPlainPrefix(comp, param) {
|
|
@@ -141,6 +151,7 @@ export {
|
|
|
141
151
|
firstDotSegment,
|
|
142
152
|
lastDotSegment,
|
|
143
153
|
lookup,
|
|
154
|
+
longestRegisteredPrefix,
|
|
144
155
|
resolvePlugin,
|
|
145
156
|
fmtPlainPrefix,
|
|
146
157
|
fmtComp,
|
|
@@ -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
|
|
|
@@ -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,33 @@
|
|
|
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
|
+
/** Internal sury metadata ID used to mark a date-time string field. */
|
|
25
|
+
let dateTimeId: S.Metadata.Id.t<bool> = S.Metadata.Id.make(~namespace="reventless", ~name="dateTime")
|
|
26
|
+
|
|
27
|
+
/** A sury string schema annotated as an ISO-8601 date-time field.
|
|
28
|
+
Use with `@s.matches(Reventless.DateTime.string)`. */
|
|
29
|
+
let string: S.t<string> = S.string->S.Metadata.set(~id=dateTimeId, true)
|
|
30
|
+
|
|
31
|
+
/** Whether a field schema carries the date-time marker. */
|
|
32
|
+
let isDateTime = (fieldSchema: S.t<unknown>) =>
|
|
33
|
+
S.Metadata.get(fieldSchema, ~id=dateTimeId)->Option.isSome
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
|
|
6
|
+
let dateTimeId = S.Metadata.Id.make("reventless", "dateTime");
|
|
7
|
+
|
|
8
|
+
let string = S.Metadata.set(S.string, dateTimeId, true);
|
|
9
|
+
|
|
10
|
+
function isDateTime(fieldSchema) {
|
|
11
|
+
return Stdlib_Option.isSome(S.Metadata.get(fieldSchema, dateTimeId));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
dateTimeId,
|
|
16
|
+
string,
|
|
17
|
+
isDateTime,
|
|
18
|
+
}
|
|
19
|
+
/* dateTimeId Not a pure module */
|