@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.79",
3
+ "version": "3.0.0-alpha.81",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
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->Array.reduce(None, (acc, candidate) =>
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
@@ -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 only events declared in `Spec.consumedEvent` — no wildcard needed.
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 */