@reventlessdev/reventless-spec 3.0.0-alpha.86 → 3.0.0-alpha.87

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,14 @@
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.87 (2026-07-30)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **spec,core:** record the storageRef annotation instead of inferring it ([06fb5d6](https://github.com/ReventlessDev/reventless-core/commit/06fb5d671db91fb536acefd0c2db69d98671da39))
11
+ * **spec:** read a semantic marker through an optional field's wrapper ([abebaa9](https://github.com/ReventlessDev/reventless-core/commit/abebaa9e6c73986765bb3de589ba0414eb0d85da))
12
+
13
+
6
14
  # 3.0.0-alpha.86 (2026-07-30)
7
15
 
8
16
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.86",
3
+ "version": "3.0.0-alpha.87",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -17,9 +17,14 @@ emits the platform's capability list from them.
17
17
  type kind = ObjectStore
18
18
 
19
19
  /** The declaration site: the component's spec name and the field carrying the
20
- `@storageRef` annotation. */
20
+ `@storageRef` annotation, plus the store exactly as that field spells it.
21
+
22
+ `annotation` is optional only to keep reading a manifest emitted before it
23
+ existed: a plugin built earlier still parses, and a reader that cannot say
24
+ what the source says omits the claim rather than inventing one. Every
25
+ manifest emitted now carries it. */
21
26
  @schema
22
- type provenance = {component: string, field: string}
27
+ type provenance = {component: string, field: string, annotation?: string}
23
28
 
24
29
  @schema
25
30
  type entry = {
@@ -50,7 +55,9 @@ let fromStructure = (structure: Plugin.pluginStructure): t => {
50
55
  kind: ObjectStore,
51
56
  key,
52
57
  declaredBy: declarations->Array.filterMap(d =>
53
- d.store == key ? Some({component: d.component, field: d.field}) : None
58
+ d.store == key
59
+ ? Some({component: d.component, field: d.field, annotation: d.annotation})
60
+ : None
54
61
  ),
55
62
  }),
56
63
  }
@@ -8,7 +8,8 @@ let kindSchema = S.literal("ObjectStore");
8
8
 
9
9
  let provenanceSchema = S.schema(s => ({
10
10
  component: s.m(S.string),
11
- field: s.m(S.string)
11
+ field: s.m(S.string),
12
+ annotation: s.m(S.option(S.string))
12
13
  }));
13
14
 
14
15
  let entrySchema = S.schema(s => ({
@@ -31,7 +32,8 @@ function fromStructure(structure) {
31
32
  if (d.store === key) {
32
33
  return {
33
34
  component: d.component,
34
- field: d.field
35
+ field: d.field,
36
+ annotation: d.annotation
35
37
  };
36
38
  }
37
39
  })
@@ -386,12 +386,20 @@ One field's store requirement, with its provenance.
386
386
  carries; `component` and `field` name the declaration site. The site matters
387
387
  because a requirement only ever changes by editing a field — when a rename
388
388
  removes a store from the manifest, the diff has to say which field caused it.
389
+
390
+ `annotation` is the store exactly as the field spells it — bare for a store
391
+ the declaring plugin owns, qualified for a foreign one. It is recorded rather
392
+ than reconstructed: only here is the owning plugin unambiguous, so anything
393
+ downstream would have to infer it by comparing a registered plugin name with
394
+ whatever name a deploy manifest happened to use, and those were never required
395
+ to match.
389
396
  */
390
397
  @schema
391
398
  type requiredStoreDeclaration = {
392
399
  store: string,
393
400
  component: string,
394
401
  field: string,
402
+ annotation: string,
395
403
  }
396
404
 
397
405
  let requiredStoreDeclarationArrayOptionSchema = _jsNullable(
@@ -181,7 +181,8 @@ let extensionPointDefArrayOptionSchema = SuryResMjs.js_nullable(S.array(extensio
181
181
  let requiredStoreDeclarationSchema = S.schema(s => ({
182
182
  store: s.m(S.string),
183
183
  component: s.m(S.string),
184
- field: s.m(S.string)
184
+ field: s.m(S.string),
185
+ annotation: s.m(S.string)
185
186
  }));
186
187
 
187
188
  let requiredStoreDeclarationArrayOptionSchema = SuryResMjs.js_nullable(S.array(requiredStoreDeclarationSchema));
@@ -22,6 +22,7 @@ type provenance = {
22
22
  pluginName: string,
23
23
  component: string,
24
24
  field: string,
25
+ annotation: option<string>,
25
26
  }
26
27
 
27
28
  /** One capability the platform must provision: the qualified `{plugin}.{store}`
@@ -45,6 +46,7 @@ let union = (manifests: array<pluginManifest>): array<unionEntry> => {
45
46
  pluginName,
46
47
  component: site.component,
47
48
  field: site.field,
49
+ annotation: site.annotation,
48
50
  })
49
51
  switch byKey->Dict.get(entry.key) {
50
52
  | Some(existing) =>
@@ -71,17 +73,17 @@ let splitKey = (key: string): option<(string, string)> =>
71
73
  key->String.slice(~start=i + 1),
72
74
  ))
73
75
 
74
- // The provenance comment quotes the annotation as its author wrote it: a store
75
- // declared by its own plugin is unqualified, a foreign store keeps the
76
- // qualified form. The manifest carries only the deploy-manifest plugin name
77
- // (lowercase by convention) while the key carries the registered name, so the
78
- // comparison is case-insensitive — and it only shapes a comment.
79
- let annotationStore = (~pluginName, ~key): string =>
80
- switch splitKey(key) {
81
- | Some((keyPlugin, store)) if keyPlugin->String.toLowerCase == pluginName->String.toLowerCase => store
82
- | _ => key
83
- }
84
-
76
+ // The provenance comment quotes the annotation as its author wrote it — taken
77
+ // from the manifest, which recorded it at the one place the owning plugin was
78
+ // unambiguous. Inferring it here is what this replaced: the manifest carries a
79
+ // deploy-manifest entry name and the key carries the registered name, and no
80
+ // rule relates the two (`platform-inspector` against `PlatformInspector` is an
81
+ // ordinary pairing), so any comparison here eventually quotes a string that is
82
+ // in no source file.
83
+ //
84
+ // A site with no recorded annotation predates the recording. It still names the
85
+ // field, which is the comment's job; it just makes no claim about the source
86
+ // text it cannot see.
85
87
  let renderEntry = (entry: unionEntry): result<array<string>, string> =>
86
88
  switch splitKey(entry.key) {
87
89
  | None =>
@@ -90,11 +92,13 @@ let renderEntry = (entry: unionEntry): result<array<string>, string> =>
90
92
  )
91
93
  | Some((plugin, store)) => {
92
94
  let comments =
93
- entry.declaredBy->Array.map(site =>
94
- ` // ${site.pluginName}: ${site.component}.${site.field} @storageRef(${quote(
95
- annotationStore(~pluginName=site.pluginName, ~key=entry.key),
96
- )})`
97
- )
95
+ entry.declaredBy->Array.map(site => {
96
+ let site_ = ` // ${site.pluginName}: ${site.component}.${site.field}`
97
+ switch site.annotation {
98
+ | Some(annotation) => `${site_} @storageRef(${quote(annotation)})`
99
+ | None => site_
100
+ }
101
+ })
98
102
  let line = switch entry.kind {
99
103
  | ObjectStore => ` ObjectStore({plugin: ${quote(plugin)}, store: ${quote(store)}}),`
100
104
  }
@@ -14,7 +14,8 @@ function union(manifests) {
14
14
  let sites = entry.declaredBy.map(site => ({
15
15
  pluginName: pluginName,
16
16
  component: site.component,
17
- field: site.field
17
+ field: site.field,
18
+ annotation: site.annotation
18
19
  }));
19
20
  let existing = byKey[entry.key];
20
21
  if (existing !== undefined) {
@@ -46,15 +47,6 @@ function splitKey(key) {
46
47
  ]);
47
48
  }
48
49
 
49
- function annotationStore(pluginName, key) {
50
- let match = splitKey(key);
51
- if (match !== undefined && match[0].toLowerCase() === pluginName.toLowerCase()) {
52
- return match[1];
53
- } else {
54
- return key;
55
- }
56
- }
57
-
58
50
  function renderEntry(entry) {
59
51
  let match = splitKey(entry.key);
60
52
  if (match === undefined) {
@@ -63,7 +55,15 @@ function renderEntry(entry) {
63
55
  _0: `malformed capability key ` + JSON.stringify(entry.key) + ` — expected "{plugin}.{store}" (is the plugin's capabilities.json hand-edited?)`
64
56
  };
65
57
  }
66
- let comments = entry.declaredBy.map(site => ` // ` + site.pluginName + `: ` + site.component + `.` + site.field + ` @storageRef(` + JSON.stringify(annotationStore(site.pluginName, entry.key)) + `)`);
58
+ let comments = entry.declaredBy.map(site => {
59
+ let site_ = ` // ` + site.pluginName + `: ` + site.component + `.` + site.field;
60
+ let annotation = site.annotation;
61
+ if (annotation !== undefined) {
62
+ return site_ + ` @storageRef(` + JSON.stringify(annotation) + `)`;
63
+ } else {
64
+ return site_;
65
+ }
66
+ });
67
67
  let line = ` ObjectStore({plugin: ` + JSON.stringify(match[0]) + `, store: ` + JSON.stringify(match[1]) + `}),`;
68
68
  return {
69
69
  TAG: "Ok",
@@ -108,7 +108,6 @@ export {
108
108
  union,
109
109
  quote,
110
110
  splitKey,
111
- annotationStore,
112
111
  renderEntry,
113
112
  header,
114
113
  render,
@@ -90,8 +90,43 @@ let refined = (base: S.t<'a>, ~id: string, ~check: 'a => result<'a, string>): S.
90
90
  reach forms through `validateInput`, so they quote the offending value. */
91
91
  let showString = (raw: string): string => raw->JSON.Encode.string->JSON.stringify
92
92
 
93
- /** The semantic a field's schema carries, if any. */
94
- let get = (fieldSchema: S.t<'a>): option<t> => S.Metadata.get(fieldSchema, ~id=semanticId)
93
+ /**
94
+ The semantic a field's schema carries, if any.
95
+
96
+ An **optional** field keeps its marker one level down. The ppx annotates the
97
+ field's `string`, and sury-ppx then wraps that schema in a union with
98
+ `Undefined`/`Null`; the wrapper is a new schema and carries no metadata of its
99
+ own. So a walk that reads only the outer schema sees `imageUrl?: string` as
100
+ carrying no semantic at all — the store goes undeclared, the reference goes
101
+ uncollected, the branded scalar loses its brand. Every reader converges here, so
102
+ following the wrapper once here is what keeps "optional" a statement about
103
+ presence rather than a way to lose the field's type.
104
+
105
+ The outer schema is read first, so a marker set on the wrapper itself still wins.
106
+ Only a union with exactly one non-null variant is followed: that is the shape an
107
+ optional field has, and a genuine multi-variant union has no single inner schema
108
+ whose semantic could stand for the whole.
109
+ */
110
+ let rec getFrom = (schema: S.t<unknown>): option<t> =>
111
+ switch S.Metadata.get(schema, ~id=semanticId) {
112
+ | Some(_) as found => found
113
+ | None =>
114
+ switch schema {
115
+ | Union({anyOf}) =>
116
+ switch anyOf->Array.filter(v =>
117
+ switch v {
118
+ | Null(_) | Undefined(_) => false
119
+ | _ => true
120
+ }
121
+ ) {
122
+ | [inner] => getFrom(inner)
123
+ | _ => None
124
+ }
125
+ | _ => None
126
+ }
127
+ }
128
+
129
+ let get = (fieldSchema: S.t<'a>): option<t> => fieldSchema->S.castToUnknown->getFrom
95
130
 
96
131
  /** Whether a field's schema carries this specific semantic. */
97
132
  let has = (fieldSchema: S.t<'a>, ~id: string): bool =>
@@ -40,12 +40,37 @@ function showString(raw) {
40
40
  return JSON.stringify(raw);
41
41
  }
42
42
 
43
- function get(fieldSchema) {
44
- return S.Metadata.get(fieldSchema, semanticId);
43
+ function getFrom(_schema) {
44
+ while (true) {
45
+ let schema = _schema;
46
+ let found = S.Metadata.get(schema, semanticId);
47
+ if (found !== undefined) {
48
+ return found;
49
+ }
50
+ if (schema.type !== "union") {
51
+ return;
52
+ }
53
+ let match = schema.anyOf.filter(v => {
54
+ switch (v.type) {
55
+ case "null" :
56
+ case "undefined" :
57
+ return false;
58
+ default:
59
+ return true;
60
+ }
61
+ });
62
+ if (match.length !== 1) {
63
+ return;
64
+ }
65
+ _schema = match[0];
66
+ continue;
67
+ };
45
68
  }
46
69
 
70
+ let get = getFrom;
71
+
47
72
  function has(fieldSchema, id) {
48
- let s = S.Metadata.get(fieldSchema, semanticId);
73
+ let s = getFrom(fieldSchema);
49
74
  if (s !== undefined) {
50
75
  return s.id === id;
51
76
  } else {
@@ -59,6 +84,7 @@ export {
59
84
  mark,
60
85
  refined,
61
86
  showString,
87
+ getFrom,
62
88
  get,
63
89
  has,
64
90
  }