@reventlessdev/reventless-core 3.0.0-alpha.194 → 3.0.0-alpha.195

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,13 @@
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.195 (2026-07-30)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core:** a command's field markers reach the wire, and its optional fields stay optional ([f1c1112](https://github.com/ReventlessDev/reventless-core/commit/f1c1112e9baa6b06e50097a5a618f49c9301cd0a))
11
+
12
+
6
13
  # 3.0.0-alpha.194 (2026-07-30)
7
14
 
8
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.194",
3
+ "version": "3.0.0-alpha.195",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,16 +29,16 @@
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
32
- "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.14",
33
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.13",
34
- "@reventlessdev/rescript-node-streams": "1.1.0-alpha.13",
33
+ "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.14",
35
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
36
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
37
- "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.112",
35
+ "@reventlessdev/rescript-node-streams": "1.1.0-alpha.13",
39
36
  "@reventlessdev/rescript-ssh2": "1.1.0-alpha.14",
37
+ "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
38
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
40
39
  "@reventlessdev/reventless-interop": "3.0.0-alpha.29",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.87"
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.88",
41
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.113"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -141,6 +141,42 @@ and shapeOf = (~parentName: string, ~fieldName: string, schema: S.t<unknown>): s
141
141
  }
142
142
  }
143
143
 
144
+ /**
145
+ The names of an object's optional properties, read from sury rather than from
146
+ the IR.
147
+
148
+ The IR cannot answer this for every field. `shapeOf` classifies a DCB-tagged or
149
+ `@ref` field as `EntityId` before it ever looks at the union sury wraps an
150
+ `option<…>` in — correctly, since those markers now describe the field through
151
+ that wrapper — so an optional reference reaches the IR as a plain `EntityId`
152
+ with its optionality spent. Asking the source schema keeps the two questions
153
+ apart: what a field *is*, and whether it has to be there.
154
+
155
+ Top-level properties only. A nested object's own fields are emitted by the
156
+ `ObjectRef` branch, which has no `required` of its own today.
157
+ */
158
+ let optionalFieldNames = (schema: S.t<unknown>): array<string> =>
159
+ switch schema {
160
+ | Object({properties}) =>
161
+ properties
162
+ ->Dict.toArray
163
+ ->Array.filterMap(((propName, propSchema)) =>
164
+ switch propSchema {
165
+ | Union({anyOf}) =>
166
+ anyOf->Array.some(v =>
167
+ switch v {
168
+ | Null(_) | Undefined(_) => true
169
+ | _ => false
170
+ }
171
+ )
172
+ ? Some(propName)
173
+ : None
174
+ | _ => None
175
+ }
176
+ )
177
+ | _ => []
178
+ }
179
+
144
180
  let fromSuryObject = (~typeName: string, schema: S.t<unknown>): option<dict<schemaType>> =>
145
181
  switch schema {
146
182
  | Object({properties}) =>
@@ -150,6 +150,27 @@ function shapeOf(parentName, fieldName, schema) {
150
150
  }
151
151
  }
152
152
 
153
+ function optionalFieldNames(schema) {
154
+ if (schema.type === "object") {
155
+ return Stdlib_Array.filterMap(Object.entries(schema.properties), param => {
156
+ let propSchema = param[1];
157
+ if (propSchema.type === "union" && propSchema.anyOf.some(v => {
158
+ switch (v.type) {
159
+ case "null" :
160
+ case "undefined" :
161
+ return true;
162
+ default:
163
+ return false;
164
+ }
165
+ })) {
166
+ return param[0];
167
+ }
168
+ });
169
+ } else {
170
+ return [];
171
+ }
172
+ }
173
+
153
174
  function fromSuryObject(typeName, schema) {
154
175
  if (schema.type !== "object") {
155
176
  return;
@@ -176,6 +197,7 @@ export {
176
197
  isIdsFieldName,
177
198
  fromSury,
178
199
  shapeOf,
200
+ optionalFieldNames,
179
201
  fromSuryObject,
180
202
  }
181
203
  /* DcbTag-Reventless Not a pure module */
@@ -179,8 +179,18 @@ and withSemantic = (fieldSchema: JSON.t, sem: Reventless.Semantic.t): JSON.t =>
179
179
  JSON.Encode.object(obj)
180
180
  }
181
181
 
182
+ // `optional` names the fields that may be absent; everything else is required.
183
+ // It comes from the sury schema (`SchemaType.optionalFieldNames`) rather than
184
+ // from the IR, which cannot answer for a reference or a DCB-tagged field — both
185
+ // classify as `EntityId` before the nullable wrapper is ever examined.
186
+ //
187
+ // Defaulting to "all required" mattered nowhere while only read-model state came
188
+ // through here: nothing validates a read schema. A command schema is the input
189
+ // of a form, and a field listed as required is one the form refuses to submit
190
+ // without — which turned `imageUrl?` into a picture every product had to have.
182
191
  and objectRefToJsonSchema = (
183
192
  ~annotations: option<Reventless.StateAnnotations.stateAnnotationSpec>=?,
193
+ ~optional: array<string>=[],
184
194
  fields: dict<SchemaType.schemaType>,
185
195
  ): JSON.t => {
186
196
  let props = Dict.make()
@@ -194,7 +204,9 @@ and objectRefToJsonSchema = (
194
204
  | None => baseSchema
195
205
  }
196
206
  props->Dict.set(fieldName, withAnnotations)
197
- required->Array.push(fieldName)
207
+ if !(optional->Array.includes(fieldName)) {
208
+ required->Array.push(fieldName)
209
+ }
198
210
  })
199
211
  let entries: array<(string, JSON.t)> = [
200
212
  ("type", str("object")),
@@ -212,7 +224,11 @@ let deriveObjectSchema = (schema: S.t<unknown>): JSON.t =>
212
224
  switch SchemaType.fromSuryObject(~typeName="", schema) {
213
225
  | Some(fields) =>
214
226
  let annotations = Reventless.StateAnnotations.getSpec(schema)
215
- let objSchema = objectRefToJsonSchema(~annotations?, fields)
227
+ let objSchema = objectRefToJsonSchema(
228
+ ~annotations?,
229
+ ~optional=SchemaType.optionalFieldNames(schema),
230
+ fields,
231
+ )
216
232
  // Surface component-level visibility on the top-level object schema.
217
233
  // Omitted entirely for the default `Public` (None) so schemas stay compact.
218
234
  switch annotations {
@@ -177,7 +177,7 @@ function fromSchemaType(st) {
177
177
  ]
178
178
  ]);
179
179
  case "ObjectRef" :
180
- return objectRefToJsonSchema(undefined, st._1);
180
+ return objectRefToJsonSchema(undefined, undefined, st._1);
181
181
  case "Enum" :
182
182
  return Object.fromEntries([
183
183
  [
@@ -221,7 +221,8 @@ function withSemantic(fieldSchema, sem) {
221
221
  return obj;
222
222
  }
223
223
 
224
- function objectRefToJsonSchema(annotations, fields) {
224
+ function objectRefToJsonSchema(annotations, optionalOpt, fields) {
225
+ let optional = optionalOpt !== undefined ? optionalOpt : [];
225
226
  let props = {};
226
227
  let required = [];
227
228
  Object.entries(fields).forEach(param => {
@@ -229,7 +230,10 @@ function objectRefToJsonSchema(annotations, fields) {
229
230
  let baseSchema = fromSchemaType(param[1]);
230
231
  let withAnnotations = annotations !== undefined ? mergeAnnotations(baseSchema, fieldName, annotations) : baseSchema;
231
232
  props[fieldName] = withAnnotations;
232
- required.push(fieldName);
233
+ if (!optional.includes(fieldName)) {
234
+ required.push(fieldName);
235
+ return;
236
+ }
233
237
  });
234
238
  let entries = [
235
239
  [
@@ -259,7 +263,7 @@ function deriveObjectSchema(schema) {
259
263
  ]]);
260
264
  }
261
265
  let annotations = StateAnnotations$Reventless.getSpec(schema);
262
- let objSchema = objectRefToJsonSchema(annotations, fields);
266
+ let objSchema = objectRefToJsonSchema(annotations, SchemaType$ReventlessCore.optionalFieldNames(schema), fields);
263
267
  if (annotations === undefined) {
264
268
  return objSchema;
265
269
  }
@@ -269,7 +269,14 @@ let make = (
269
269
  }
270
270
  ({
271
271
  Reventless.Plugin.name: variantName,
272
- schema: (v->S.toJSONSchema->Obj.magic: JSON.t)->JSON.stringify,
272
+ // The derived schema, not sury's raw one: `S.toJSONSchema` carries the
273
+ // shape and drops every `x-reventless-*` marker the PPX put on the
274
+ // fields, so a command's `@storageRef`/`@semantic`/`@ref` reached the
275
+ // wire on the read side and nowhere on the write side. A reader that
276
+ // matches a field against its setter — or picks the upload endpoint of
277
+ // the store a command argument declares — then has nothing to match on.
278
+ // `MCP_SchemaGenerator` already derives these same variant schemas.
279
+ schema: v->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
273
280
  level,
274
281
  aggregateIdField,
275
282
  // A non-exposed (`@noApi`) variant has no callable mutation field. For a
@@ -343,7 +350,9 @@ let make = (
343
350
  )
344
351
  ({
345
352
  Reventless.Plugin.name: variantName,
346
- schema: (v->S.toJSONSchema->Obj.magic: JSON.t)->JSON.stringify,
353
+ // Derived for the same reason as `commandDef.schema` above: an event's
354
+ // field markers are the write side's half of the same vocabulary.
355
+ schema: v->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
347
356
  references,
348
357
  }: Reventless.Plugin.eventDef)
349
358
  }
@@ -1,6 +1,5 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
- import * as S from "sury/src/S.res.mjs";
4
3
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
4
  import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
6
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
@@ -229,7 +228,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
229
228
  }
230
229
  return {
231
230
  name: variantName,
232
- schema: JSON.stringify(S.toJSONSchema(v)),
231
+ schema: JSON.stringify(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(v)),
233
232
  level: match[0],
234
233
  aggregateIdField: match[1],
235
234
  mutationField: apiExposed ? mutationFieldFor(variantName) : "",
@@ -281,7 +280,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
281
280
  });
282
281
  return {
283
282
  name: variantName,
284
- schema: JSON.stringify(S.toJSONSchema(v)),
283
+ schema: JSON.stringify(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(v)),
285
284
  references: references
286
285
  };
287
286
  };
@@ -549,26 +549,28 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
549
549
  })
550
550
  })
551
551
 
552
- // Phase 4: queryableDef.schema must carry x-reventless-* extension keys for
553
- // annotated state types. Plugin_Structure now uses SuryToJsonSchema.deriveObjectSchema
554
- // (annotation-aware) instead of S.toJSONSchema (metadata-blind).
555
- describe("queryableDef.schema propagates x-reventless-* annotations", () => {
556
- let getProperty = (json: JSON.t, key: string): option<JSON.t> =>
557
- switch json->JSON.Decode.object {
558
- | Some(obj) => obj->Dict.get(key)
559
- | None => None
560
- }
552
+ // Shared by the read-side and write-side schema cases below, which ask the
553
+ // same question of the same emitter.
554
+ let getProperty = (json: JSON.t, key: string): option<JSON.t> =>
555
+ switch json->JSON.Decode.object {
556
+ | Some(obj) => obj->Dict.get(key)
557
+ | None => None
558
+ }
561
559
 
562
- let getPropertyOf = (json: JSON.t, fieldName: string): option<JSON.t> =>
563
- switch getProperty(json, "properties") {
564
- | Some(props) =>
565
- switch props->JSON.Decode.object {
566
- | Some(obj) => obj->Dict.get(fieldName)
567
- | None => None
568
- }
560
+ let getPropertyOf = (json: JSON.t, fieldName: string): option<JSON.t> =>
561
+ switch getProperty(json, "properties") {
562
+ | Some(props) =>
563
+ switch props->JSON.Decode.object {
564
+ | Some(obj) => obj->Dict.get(fieldName)
569
565
  | None => None
570
566
  }
567
+ | None => None
568
+ }
571
569
 
570
+ // Phase 4: queryableDef.schema must carry x-reventless-* extension keys for
571
+ // annotated state types. Plugin_Structure now uses SuryToJsonSchema.deriveObjectSchema
572
+ // (annotation-aware) instead of S.toJSONSchema (metadata-blind).
573
+ describe("queryableDef.schema propagates x-reventless-* annotations", () => {
572
574
  let parseSchema = (svs: Reventless.Plugin.queryableDef): JSON.t =>
573
575
  svs.schema->JSON.parseOrThrow
574
576
 
@@ -826,6 +828,37 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
826
828
  ]
827
829
  expect(cmd.references)->toEqual(expected)
828
830
  })
831
+
832
+ // What `requiredStores` knows, the wire has to carry. A reader deciding
833
+ // which command fills a storage-ref field, or which store's endpoint an
834
+ // upload goes to, has only the command schema to go on.
835
+ testSync("the command schema carries the field's storage-ref marker", () => {
836
+ let cmd = (withOptional.stateChangeSlices->Array.getUnsafe(0)).commands->Array.getUnsafe(0)
837
+ let target =
838
+ cmd.schema
839
+ ->JSON.parseOrThrow
840
+ ->getPropertyOf("avatarUrl")
841
+ ->Option.flatMap(s => getProperty(s, "x-reventless-semantic-target"))
842
+ ->Option.flatMap(t => getProperty(t, "store"))
843
+ ->Option.flatMap(JSON.Decode.string)
844
+ expect(target)->toBe(Some("avatars"))
845
+ })
846
+
847
+ // A form submits what a schema says is required. `avatarUrl?` is optional in
848
+ // the spec, so listing it would make the picture mandatory in every UI that
849
+ // renders the command — the failure this pairs with, since carrying the
850
+ // marker is worth nothing if the field cannot be left empty.
851
+ testSync("an optional command argument is not required", () => {
852
+ let cmd = (withOptional.stateChangeSlices->Array.getUnsafe(0)).commands->Array.getUnsafe(0)
853
+ let required =
854
+ cmd.schema
855
+ ->JSON.parseOrThrow
856
+ ->getProperty("required")
857
+ ->Option.flatMap(JSON.Decode.array)
858
+ ->Option.getOr([])
859
+ ->Array.filterMap(JSON.Decode.string)
860
+ expect(required)->toEqual(["customerId"])
861
+ })
829
862
  })
830
863
 
831
864
  // Declarations are authoritative; the name heuristics are a lint. A
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
6
  import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
6
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
8
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
@@ -676,23 +677,23 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
676
677
  globalThis.expect(Plugin_Structure$ReventlessCore.statusFieldFromStateSchema("Test", schema)).toEqual(undefined);
677
678
  });
678
679
  });
680
+ let getProperty = (json, key) => {
681
+ let obj = Stdlib_JSON.Decode.object(json);
682
+ if (obj !== undefined) {
683
+ return obj[key];
684
+ }
685
+ };
686
+ let getPropertyOf = (json, fieldName) => {
687
+ let props = getProperty(json, "properties");
688
+ if (props === undefined) {
689
+ return;
690
+ }
691
+ let obj = Stdlib_JSON.Decode.object(props);
692
+ if (obj !== undefined) {
693
+ return obj[fieldName];
694
+ }
695
+ };
679
696
  globalThis.describe("queryableDef.schema propagates x-reventless-* annotations", () => {
680
- let getProperty = (json, key) => {
681
- let obj = Stdlib_JSON.Decode.object(json);
682
- if (obj !== undefined) {
683
- return obj[key];
684
- }
685
- };
686
- let getPropertyOf = (json, fieldName) => {
687
- let props = getProperty(json, "properties");
688
- if (props === undefined) {
689
- return;
690
- }
691
- let obj = Stdlib_JSON.Decode.object(props);
692
- if (obj !== undefined) {
693
- return obj[fieldName];
694
- }
695
- };
696
697
  let annotatedSchema = JSON.parse(structure.stateViewSlices[3].schema);
697
698
  globalThis.test("itemId carries x-reventless-id", () => {
698
699
  globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(getPropertyOf(annotatedSchema, "itemId"), s => getProperty(s, "x-reventless-id")), Stdlib_JSON.Decode.bool)).toBe(true);
@@ -872,6 +873,16 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
872
873
  }];
873
874
  globalThis.expect(cmd.references).toEqual(expected);
874
875
  });
876
+ globalThis.test("the command schema carries the field's storage-ref marker", () => {
877
+ let cmd = withOptional.stateChangeSlices[0].commands[0];
878
+ let target = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(getPropertyOf(JSON.parse(cmd.schema), "avatarUrl"), s => getProperty(s, "x-reventless-semantic-target")), t => getProperty(t, "store")), Stdlib_JSON.Decode.string);
879
+ globalThis.expect(target).toBe("avatars");
880
+ });
881
+ globalThis.test("an optional command argument is not required", () => {
882
+ let cmd = withOptional.stateChangeSlices[0].commands[0];
883
+ let required = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(getProperty(JSON.parse(cmd.schema), "required"), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.string);
884
+ globalThis.expect(required).toEqual(["customerId"]);
885
+ });
875
886
  });
876
887
  globalThis.describe("capability inference", () => {
877
888
  let commandWarnings = Capability_Inference$ReventlessCore.scanSchema("ChangePhoto", PsChangePhoto$ReventlessCore.commandSchema);