@reventlessdev/reventless-spec 3.0.0-alpha.106 → 3.0.0-alpha.108

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.108 (2026-08-11)
7
+
8
+ ### Features
9
+
10
+ * **api:** infer a queryable's key field and publish its provenance ([c835a42](https://github.com/ReventlessDev/reventless-core/commit/c835a42a0da07cdc4a3f010212e1f340a4a0ca27))
11
+ * **plugin:** publish singleQueryField on queryableDef ([a724ab5](https://github.com/ReventlessDev/reventless-core/commit/a724ab573614792c0615d68b6486b94da14f9f82))
12
+
13
+
14
+ # 3.0.0-alpha.107 (2026-08-10)
15
+
16
+ ### Bug Fixes
17
+
18
+ * **core:** collect [@ref](https://github.com/ref) declared on an array field ([6f9e2fe](https://github.com/ReventlessDev/reventless-core/commit/6f9e2fef2568ad57a8bf2efbaa9cd4830a947f26))
19
+
20
+
6
21
  # 3.0.0-alpha.106 (2026-08-09)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.106",
3
+ "version": "3.0.0-alpha.108",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "sury": "11.0.0-alpha.4",
22
22
  "sury-ppx": "11.0.0-alpha.2",
23
23
  "yaml": "^2.8.3",
24
- "@reventlessdev/rescript-node": "2.0.0-alpha.3"
24
+ "@reventlessdev/rescript-node": "2.0.0-alpha.4"
25
25
  },
26
26
  "devDependencies": {
27
27
  "rescript": "12.3.0",
@@ -269,6 +269,51 @@ type queryableDef = {
269
269
  before this field existed must be reset/re-emitted. See [[deployed-chapter-grouping]].
270
270
  */
271
271
  chapter: @s.matches(stringOptionSchema) option<string>,
272
+ /**
273
+ The singular counterpart of `queryField`: the generated single-entity query
274
+ (`Plugin_Order(id: ID!)` beside the list field `Plugin_Orders`), and — because
275
+ `Api_Naming` returns the same string for both — the prefix of the queryable's
276
+ generated input types (`Plugin_OrderFilter`, `Plugin_OrderOrderBy`). One field
277
+ rather than two, so the two uses cannot drift apart.
278
+
279
+ Published because it is not derivable from `queryField` without re-implementing
280
+ `Api_Naming.singularize`: a consumer that strips a trailing `s` turns
281
+ `Plugin_Categories` into `Plugin_Categorie`, a name the schema does not serve,
282
+ and fails at query time against that one view. Sourced from the naming module
283
+ itself, never re-derived.
284
+
285
+ `None` means not stated — defs persisted before this field existed, and
286
+ hand-rolled defs that decline to say; a consumer falls back to its own
287
+ derivation there. js_nullable for the same JSON-safety reason as `statusField`.
288
+ */
289
+ singleQueryField: @s.matches(stringOptionSchema) option<string>,
290
+ /**
291
+ The state field that identifies a row — the queryable's own key, as opposed to
292
+ a reference to some other entity. `Products` carries `productId` and
293
+ `categoryId`; this says which of the two the row is about.
294
+
295
+ `None` means unresolved: a state with several `*Id` fields and no name match,
296
+ or with none at all. Such a component gets no key-derived filter or sort until
297
+ its spec declares `@id`. Also `None` on defs persisted before this field
298
+ existed. js_nullable for the same JSON-safety reason as `statusField`.
299
+ */
300
+ idField: @s.matches(stringOptionSchema) option<string>,
301
+ /**
302
+ Which rung produced `idField`, so a consumer can tell a declaration from a
303
+ guess — the same reason `labelFieldSource` exists:
304
+
305
+ - `"annotation"` — the state declares `@id`. The author said which field keys
306
+ the row; nothing inferred outranks it.
307
+ - `"convention"` — a field named `<singular component name>Id` exists
308
+ (`Products` → `productId`). A guess, and the one guess a client can make for
309
+ itself.
310
+ - `"sole"` — the state has exactly one `*Id` field, so there is nothing else
311
+ the key could be (`AvailableProducts` → `productId`). A guess, and one that
312
+ needs the state's full field list to make.
313
+
314
+ `None` whenever `idField` is `None`, and on defs that predate the field.
315
+ */
316
+ idFieldSource: @s.matches(stringOptionSchema) option<string>,
272
317
  }
273
318
 
274
319
  /**
@@ -118,7 +118,10 @@ let queryableDefSchema = S.schema(s => ({
118
118
  labelFieldSource: s.m(stringOptionSchema),
119
119
  statusField: s.m(stringOptionSchema),
120
120
  visibility: s.m(stringOptionSchema),
121
- chapter: s.m(stringOptionSchema)
121
+ chapter: s.m(stringOptionSchema),
122
+ singleQueryField: s.m(stringOptionSchema),
123
+ idField: s.m(stringOptionSchema),
124
+ idFieldSource: s.m(stringOptionSchema)
122
125
  }));
123
126
 
124
127
  let eventDefSchema = S.schema(s => ({
@@ -46,6 +46,35 @@ let getTarget = (schema: S.t<unknown>): option<target> =>
46
46
  | _ => None
47
47
  }
48
48
 
49
+ /**
50
+ The entity a *field* references, wherever inside the field's type the marker sits.
51
+
52
+ `to_` returns an element schema — a `S.t<string>` — so on `@ref("E") ids:
53
+ array<string>` the ppx annotates the `string` *inside* the array and the field's
54
+ own schema carries nothing. `getTarget` answers `None` there, which is not the
55
+ same as the field declaring no reference: a consumer that finds no declared
56
+ reference falls back to a naming heuristic and resolves the field to whatever
57
+ entity the name suggests, so a dropped `@ref` is a *different* resolution rather
58
+ than a missing one. Any walk collecting a command's or event's references must
59
+ ask this question, not `getTarget`.
60
+
61
+ Only wrappers around the field's own value are followed — the optional union and
62
+ the array element, to any depth. Object properties are not: a reference declared
63
+ on a nested record's field belongs to that field, and attributing it to the
64
+ enclosing one would name the wrong field.
65
+ */
66
+ let rec getFieldTarget = (schema: S.t<unknown>): option<target> =>
67
+ switch getTarget(schema) {
68
+ | Some(_) as found => found
69
+ | None =>
70
+ // `getTarget` already reads through the optional wrapper; the *shape* inside
71
+ // it still has to be unwrapped here to reach an optional array's element.
72
+ switch schema->Semantic.unwrapOptional->Option.getOr(schema) {
73
+ | Array({additionalItems: Schema(item)}) => getFieldTarget(item)
74
+ | _ => None
75
+ }
76
+ }
77
+
49
78
  /**
50
79
  Like `to_` but does not imply DCB tag semantics.
51
80
  Use with `@ref("Entity") @noDcbTag` when the field references another entity
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
5
  import * as DcbTag$Reventless from "./DcbTag.res.mjs";
5
6
  import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
6
7
 
@@ -32,6 +33,26 @@ function getTarget(schema) {
32
33
  }
33
34
  }
34
35
 
36
+ function getFieldTarget(_schema) {
37
+ while (true) {
38
+ let schema = _schema;
39
+ let found = getTarget(schema);
40
+ if (found !== undefined) {
41
+ return found;
42
+ }
43
+ let match = Stdlib_Option.getOr(Semantic$Reventless.unwrapOptional(schema), schema);
44
+ if (match.type !== "array") {
45
+ return;
46
+ }
47
+ let item = match.additionalItems;
48
+ if (item === "strip" || item === "strict") {
49
+ return;
50
+ }
51
+ _schema = item;
52
+ continue;
53
+ };
54
+ }
55
+
35
56
  function toWithoutDcbTag(plugin, entity) {
36
57
  return Semantic$Reventless.mark(S.string, Semantic$Reventless.Id.reference, {
37
58
  TAG: "ReferenceTo",
@@ -45,6 +66,7 @@ function toWithoutDcbTag(plugin, entity) {
45
66
  export {
46
67
  to_,
47
68
  getTarget,
69
+ getFieldTarget,
48
70
  toWithoutDcbTag,
49
71
  }
50
72
  /* S Not a pure module */
@@ -117,39 +117,49 @@ let refined = (base: S.t<'a>, ~id: string, ~check: 'a => result<'a, string>): S.
117
117
  let showString = (raw: string): string => raw->JSON.Encode.string->JSON.stringify
118
118
 
119
119
  /**
120
- The semantic a field's schema carries, if any.
120
+ The schema an optional field's wrapper stands for, if it is one.
121
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.
122
+ sury-ppx compiles `f?: X` to a union of `X`'s schema with `Undefined`/`Null`, and
123
+ that wrapper is a new schema carrying no metadata of its own. Every reader that
124
+ looks *through* a field — for its semantic, or for the element type inside it —
125
+ needs the same one-level unwrap, so it lives here once rather than once per
126
+ reader.
130
127
 
131
- The outer schema is read first, so a marker set on the wrapper itself still wins.
132
128
  Only a union with exactly one non-null variant is followed: that is the shape an
133
129
  optional field has, and a genuine multi-variant union has no single inner schema
134
- whose semantic could stand for the whole.
130
+ that could stand for the whole.
135
131
  */
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
132
+ let unwrapOptional = (schema: S.t<unknown>): option<S.t<unknown>> =>
133
+ switch schema {
134
+ | Union({anyOf}) =>
135
+ switch anyOf->Array.filter(v =>
136
+ switch v {
137
+ | Null(_) | Undefined(_) => false
138
+ | _ => true
150
139
  }
140
+ ) {
141
+ | [inner] => Some(inner)
151
142
  | _ => None
152
143
  }
144
+ | _ => None
145
+ }
146
+
147
+ /**
148
+ The semantic a field's schema carries, if any.
149
+
150
+ An **optional** field keeps its marker one level down, inside the wrapper
151
+ `unwrapOptional` describes. So a walk that reads only the outer schema sees
152
+ `imageUrl?: string` as carrying no semantic at all — the store goes undeclared,
153
+ the reference goes uncollected, the branded scalar loses its brand. Every reader
154
+ converges here, so following the wrapper once here is what keeps "optional" a
155
+ statement about presence rather than a way to lose the field's type.
156
+
157
+ The outer schema is read first, so a marker set on the wrapper itself still wins.
158
+ */
159
+ let rec getFrom = (schema: S.t<unknown>): option<t> =>
160
+ switch S.Metadata.get(schema, ~id=semanticId) {
161
+ | Some(_) as found => found
162
+ | None => schema->unwrapOptional->Option.flatMap(getFrom)
153
163
  }
154
164
 
155
165
  let get = (fieldSchema: S.t<'a>): option<t> => fieldSchema->S.castToUnknown->getFrom
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
5
 
5
6
  let Id = {
6
7
  dateTime: "dateTime",
@@ -44,31 +45,33 @@ function showString(raw) {
44
45
  return JSON.stringify(raw);
45
46
  }
46
47
 
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;
48
+ function unwrapOptional(schema) {
49
+ if (schema.type !== "union") {
50
+ return;
51
+ }
52
+ let match = schema.anyOf.filter(v => {
53
+ switch (v.type) {
54
+ case "null" :
55
+ case "undefined" :
56
+ return false;
57
+ default:
58
+ return true;
68
59
  }
69
- _schema = match[0];
70
- continue;
71
- };
60
+ });
61
+ if (match.length !== 1) {
62
+ return;
63
+ } else {
64
+ return match[0];
65
+ }
66
+ }
67
+
68
+ function getFrom(schema) {
69
+ let found = S.Metadata.get(schema, semanticId);
70
+ if (found !== undefined) {
71
+ return found;
72
+ } else {
73
+ return Stdlib_Option.flatMap(unwrapOptional(schema), getFrom);
74
+ }
72
75
  }
73
76
 
74
77
  let get = getFrom;
@@ -88,6 +91,7 @@ export {
88
91
  mark,
89
92
  refined,
90
93
  showString,
94
+ unwrapOptional,
91
95
  getFrom,
92
96
  get,
93
97
  has,