@reventlessdev/reventless-core 3.0.0-alpha.193 → 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,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.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
+
13
+ # 3.0.0-alpha.194 (2026-07-30)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **spec,core:** record the storageRef annotation instead of inferring it ([06fb5d6](https://github.com/ReventlessDev/reventless-core/commit/06fb5d671db91fb536acefd0c2db69d98671da39))
18
+ * **spec:** read a semantic marker through an optional field's wrapper ([abebaa9](https://github.com/ReventlessDev/reventless-core/commit/abebaa9e6c73986765bb3de589ba0414eb0d85da))
19
+
20
+
6
21
  # 3.0.0-alpha.193 (2026-07-30)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.193",
3
+ "version": "3.0.0-alpha.195",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -28,17 +28,17 @@
28
28
  "dependencies": {
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.13",
32
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
32
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.13",
33
33
  "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.14",
34
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
35
35
  "@reventlessdev/rescript-node-streams": "1.1.0-alpha.13",
36
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
37
36
  "@reventlessdev/rescript-ssh2": "1.1.0-alpha.14",
38
37
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.111",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.86",
41
- "@reventlessdev/reventless-interop": "3.0.0-alpha.29"
38
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
39
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.29",
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
  }
@@ -498,6 +507,14 @@ let make = (
498
507
  Reventless.Plugin.store: target.plugin->Option.getOr(name) ++ "." ++ target.store,
499
508
  component,
500
509
  field,
510
+ // The annotation as its author wrote it. `target.plugin` is `None`
511
+ // exactly when the field left the store unqualified, so this is the
512
+ // source text rather than a guess at it — and this is the only place
513
+ // that distinction survives.
514
+ annotation: switch target.plugin {
515
+ | None => target.store
516
+ | Some(plugin) => plugin ++ "." ++ target.store
517
+ },
501
518
  })
502
519
  )
503
520
 
@@ -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
  };
@@ -447,11 +446,15 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
447
446
  let properties = v.properties;
448
447
  return Stdlib_Array.filterMap(Object.entries(properties), param => {
449
448
  let field = param[0];
450
- return Stdlib_Option.map(StorageRef$Reventless.getStore(param[1]), target => ({
451
- store: Stdlib_Option.getOr(target.plugin, name) + "." + target.store,
452
- component: component,
453
- field: field
454
- }));
449
+ return Stdlib_Option.map(StorageRef$Reventless.getStore(param[1]), target => {
450
+ let plugin = target.plugin;
451
+ return {
452
+ store: Stdlib_Option.getOr(target.plugin, name) + "." + target.store,
453
+ component: component,
454
+ field: field,
455
+ annotation: plugin !== undefined ? plugin + "." + target.store : target.store
456
+ };
457
+ });
455
458
  });
456
459
  } else {
457
460
  return [];
@@ -606,6 +606,22 @@ describe("SuryToJsonSchema:", () => {
606
606
  )->toEqual(Some(JSON.Encode.string("string")))
607
607
  })
608
608
 
609
+ // An optional field's marker sits inside the wrapper sury-ppx builds around
610
+ // it, so this used to emit a plain nullable string: the field was a storage
611
+ // ref in the source and an untyped string on the wire. The semantic now
612
+ // survives, and it rides *beside* the nullable shape rather than replacing
613
+ // it — a reader still learns the value may be absent.
614
+ testSync("an optional storage-ref field keeps both its semantic and its nullability", () => {
615
+ let schema = S.schema(s =>
616
+ {"imageUrl": s.matches(S.option(Reventless.StorageRef.forStore(~store="productImages")))}
617
+ )->S.castToUnknown
618
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
619
+ expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
620
+ expect(
621
+ getPropertyOf(json, "imageUrl")->Option.flatMap(s => getProperty(s, "oneOf"))->Option.isSome,
622
+ )->toBe(true)
623
+ })
624
+
609
625
  testSync("a type-carried semantic beats an annotation naming the same field", () => {
610
626
  let withSpec = (schema, spec) =>
611
627
  schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
@@ -909,6 +909,14 @@ globalThis.describe("SuryToJsonSchema:", () => {
909
909
  let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(schema);
910
910
  globalThis.expect(Stdlib_Option.flatMap(getPropertyOf(json, "imageUrl"), s => getProperty(s, "type"))).toEqual("string");
911
911
  });
912
+ globalThis.test("an optional storage-ref field keeps both its semantic and its nullability", () => {
913
+ let schema = S.schema(s => ({
914
+ imageUrl: s.m(S.option(StorageRef$Reventless.forStore(undefined, "productImages")))
915
+ }));
916
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(schema);
917
+ globalThis.expect(semanticOf(json, "imageUrl")).toEqual("storageRef");
918
+ globalThis.expect(Stdlib_Option.isSome(Stdlib_Option.flatMap(getPropertyOf(json, "imageUrl"), s => getProperty(s, "oneOf")))).toBe(true);
919
+ });
912
920
  globalThis.test("a type-carried semantic beats an annotation naming the same field", () => {
913
921
  let withSpec = (schema, spec) => S.Metadata.set(schema, StateAnnotations$Reventless.stateAnnotationsId, spec);
914
922
  let schema = withSpec(S.schema(s => ({
@@ -93,6 +93,20 @@ module PsAttachInvoiceSlice: ReventlessInfra.StateChangeSlice.T = {
93
93
  let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
94
94
  }
95
95
 
96
+ module PsUploadAvatarSlice: ReventlessInfra.StateChangeSlice.T = {
97
+ module Spec = PsUploadAvatar
98
+ module Behavior = {
99
+ type state = PsUploadAvatar.state
100
+ let initialState = PsUploadAvatar.initialState
101
+ let evolve = PsUploadAvatar.evolve
102
+ let decide = PsUploadAvatar.decide
103
+ let moduleUrl = PsUploadAvatar.moduleUrl
104
+ }
105
+ let isAsync = false
106
+ type component = scsComponent
107
+ let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
108
+ }
109
+
96
110
  module PsChangePhotoSlice: ReventlessInfra.StateChangeSlice.T = {
97
111
  module Spec = PsChangePhoto
98
112
  module Behavior = {
@@ -535,26 +549,28 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
535
549
  })
536
550
  })
537
551
 
538
- // Phase 4: queryableDef.schema must carry x-reventless-* extension keys for
539
- // annotated state types. Plugin_Structure now uses SuryToJsonSchema.deriveObjectSchema
540
- // (annotation-aware) instead of S.toJSONSchema (metadata-blind).
541
- describe("queryableDef.schema propagates x-reventless-* annotations", () => {
542
- let getProperty = (json: JSON.t, key: string): option<JSON.t> =>
543
- switch json->JSON.Decode.object {
544
- | Some(obj) => obj->Dict.get(key)
545
- | None => None
546
- }
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
+ }
547
559
 
548
- let getPropertyOf = (json: JSON.t, fieldName: string): option<JSON.t> =>
549
- switch getProperty(json, "properties") {
550
- | Some(props) =>
551
- switch props->JSON.Decode.object {
552
- | Some(obj) => obj->Dict.get(fieldName)
553
- | None => None
554
- }
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)
555
565
  | None => None
556
566
  }
567
+ | None => None
568
+ }
557
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", () => {
558
574
  let parseSchema = (svs: Reventless.Plugin.queryableDef): JSON.t =>
559
575
  svs.schema->JSON.parseOrThrow
560
576
 
@@ -694,9 +710,22 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
694
710
  // `documents` is declared on a command field AND an event field of the
695
711
  // same component under the same field name — one declaration site, so
696
712
  // the identical triples collapse to one entry.
713
+ // `annotation` is the source text, not the key: the own-plugin store is
714
+ // written bare and the foreign one qualified, which is exactly the
715
+ // distinction no downstream comparison can recover.
697
716
  let expected: array<Reventless.Plugin.requiredStoreDeclaration> = [
698
- {store: "TestPlugin.documents", component: "AttachInvoice", field: "documentUrl"},
699
- {store: "branding.logos", component: "AttachInvoice", field: "logoUrl"},
717
+ {
718
+ store: "TestPlugin.documents",
719
+ component: "AttachInvoice",
720
+ field: "documentUrl",
721
+ annotation: "documents",
722
+ },
723
+ {
724
+ store: "branding.logos",
725
+ component: "AttachInvoice",
726
+ field: "logoUrl",
727
+ annotation: "branding.logos",
728
+ },
700
729
  ]
701
730
  expect(withStores.requiredStoreDeclarations)->toEqual(Some(expected))
702
731
  })
@@ -718,7 +747,7 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
718
747
  let entry =
719
748
  manifest.capabilities->Array.find(c => c.key == "TestPlugin.documents")->Option.getOrThrow
720
749
  let expected: array<Reventless.CapabilityManifest.provenance> = [
721
- {component: "AttachInvoice", field: "documentUrl"},
750
+ {component: "AttachInvoice", field: "documentUrl", annotation: "documents"},
722
751
  ]
723
752
  expect(entry.declaredBy)->toEqual(expected)
724
753
  })
@@ -738,7 +767,8 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
738
767
  "declaredBy": [
739
768
  {
740
769
  "component": "AttachInvoice",
741
- "field": "documentUrl"
770
+ "field": "documentUrl",
771
+ "annotation": "documents"
742
772
  }
743
773
  ]
744
774
  },
@@ -748,7 +778,8 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
748
778
  "declaredBy": [
749
779
  {
750
780
  "component": "AttachInvoice",
751
- "field": "logoUrl"
781
+ "field": "logoUrl",
782
+ "annotation": "branding.logos"
752
783
  }
753
784
  ]
754
785
  }
@@ -760,6 +791,76 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
760
791
  })
761
792
  })
762
793
 
794
+ // Making a field optional says the value may be absent. It does not say the
795
+ // field stopped being a storage ref or a reference — but both walks read the
796
+ // marker through `Semantic.get`, and an optional field's marker sits on the
797
+ // schema inside the wrapper sury-ppx builds. Until that unwrap existed, adding
798
+ // a `?` to a field silently un-declared its store: nothing failed, an S3
799
+ // bucket just stopped being provisioned.
800
+ describe("optional carriers keep their declarations", () => {
801
+ let withOptional = Plugin_Structure.make(
802
+ ~name="TestPlugin",
803
+ ~stateChangeSlices=[module(PsUploadAvatarSlice)],
804
+ )
805
+
806
+ testSync("an optional storage ref still requires its store", () =>
807
+ expect(withOptional.requiredStores)->toEqual(Some(["TestPlugin.avatars"]))
808
+ )
809
+
810
+ // Declared on the command and on the event under one field name — the same
811
+ // collapse the required case makes, so optionality does not change arity.
812
+ testSync("and still carries one declaring site", () => {
813
+ let expected: array<Reventless.Plugin.requiredStoreDeclaration> = [
814
+ {
815
+ store: "TestPlugin.avatars",
816
+ component: "UploadAvatar",
817
+ field: "avatarUrl",
818
+ annotation: "avatars",
819
+ },
820
+ ]
821
+ expect(withOptional.requiredStoreDeclarations)->toEqual(Some(expected))
822
+ })
823
+
824
+ testSync("an optional reference is still collected", () => {
825
+ let cmd = (withOptional.stateChangeSlices->Array.getUnsafe(0)).commands->Array.getUnsafe(0)
826
+ let expected: array<Reventless.Plugin.fieldReference> = [
827
+ {fieldName: "referredBy", entity: "Customers", plugin: None},
828
+ ]
829
+ expect(cmd.references)->toEqual(expected)
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
+ })
862
+ })
863
+
763
864
  // Declarations are authoritative; the name heuristics are a lint. A
764
865
  // heuristic-only match warns and never reaches the manifest, so nothing is
765
866
  // provisioned from a guess — `imageUrl` is genuinely ambiguous between an
@@ -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";
@@ -13,6 +14,7 @@ import * as PsOrdersView$ReventlessCore from "./StateViewSlice/PsOrdersView.res.
13
14
  import * as PsPlaceOrder$ReventlessCore from "./StateChangeSlice/PsPlaceOrder.res.mjs";
14
15
  import * as PsChangePhoto$ReventlessCore from "./StateChangeSlice/PsChangePhoto.res.mjs";
15
16
  import * as CapabilityManifest$Reventless from "@reventlessdev/reventless-spec/src/components/CapabilityManifest.res.mjs";
17
+ import * as PsUploadAvatar$ReventlessCore from "./StateChangeSlice/PsUploadAvatar.res.mjs";
16
18
  import * as PsAnnotatedView$ReventlessCore from "./StateViewSlice/PsAnnotatedView.res.mjs";
17
19
  import * as PsAttachInvoice$ReventlessCore from "./StateChangeSlice/PsAttachInvoice.res.mjs";
18
20
  import * as PsCustomersView$ReventlessCore from "./StateViewSlice/PsCustomersView.res.mjs";
@@ -215,13 +217,43 @@ let PsAttachInvoiceSlice = {
215
217
  };
216
218
 
217
219
  let Behavior$3 = {
220
+ initialState: false,
221
+ evolve: PsUploadAvatar$ReventlessCore.evolve,
222
+ decide: PsUploadAvatar$ReventlessCore.decide,
223
+ moduleUrl: PsUploadAvatar$ReventlessCore.moduleUrl
224
+ };
225
+
226
+ function make$7(param, param$1, param$2, param$3, param$4, param$5) {
227
+ return 0;
228
+ }
229
+
230
+ let PsUploadAvatarSlice_Spec = {
231
+ name: PsUploadAvatar$ReventlessCore.name,
232
+ moduleUrl: PsUploadAvatar$ReventlessCore.moduleUrl,
233
+ Id: Id$Reventless.$$String,
234
+ consumedEventSchema: PsUploadAvatar$ReventlessCore.consumedEventSchema,
235
+ errorSchema: PsUploadAvatar$ReventlessCore.errorSchema,
236
+ eventSchema: PsUploadAvatar$ReventlessCore.eventSchema,
237
+ commandSchema: PsUploadAvatar$ReventlessCore.commandSchema,
238
+ commandAuthorization: PsUploadAvatar$ReventlessCore.commandAuthorization,
239
+ readConsistency: PsUploadAvatar$ReventlessCore.readConsistency
240
+ };
241
+
242
+ let PsUploadAvatarSlice = {
243
+ Spec: PsUploadAvatarSlice_Spec,
244
+ Behavior: Behavior$3,
245
+ isAsync: false,
246
+ make: make$7
247
+ };
248
+
249
+ let Behavior$4 = {
218
250
  initialState: false,
219
251
  evolve: PsChangePhoto$ReventlessCore.evolve,
220
252
  decide: PsChangePhoto$ReventlessCore.decide,
221
253
  moduleUrl: PsChangePhoto$ReventlessCore.moduleUrl
222
254
  };
223
255
 
224
- function make$7(param, param$1, param$2, param$3, param$4, param$5) {
256
+ function make$8(param, param$1, param$2, param$3, param$4, param$5) {
225
257
  return 0;
226
258
  }
227
259
 
@@ -239,9 +271,9 @@ let PsChangePhotoSlice_Spec = {
239
271
 
240
272
  let PsChangePhotoSlice = {
241
273
  Spec: PsChangePhotoSlice_Spec,
242
- Behavior: Behavior$3,
274
+ Behavior: Behavior$4,
243
275
  isAsync: false,
244
- make: make$7
276
+ make: make$8
245
277
  };
246
278
 
247
279
  let structure = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, [
@@ -645,23 +677,23 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
645
677
  globalThis.expect(Plugin_Structure$ReventlessCore.statusFieldFromStateSchema("Test", schema)).toEqual(undefined);
646
678
  });
647
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
+ };
648
696
  globalThis.describe("queryableDef.schema propagates x-reventless-* annotations", () => {
649
- let getProperty = (json, key) => {
650
- let obj = Stdlib_JSON.Decode.object(json);
651
- if (obj !== undefined) {
652
- return obj[key];
653
- }
654
- };
655
- let getPropertyOf = (json, fieldName) => {
656
- let props = getProperty(json, "properties");
657
- if (props === undefined) {
658
- return;
659
- }
660
- let obj = Stdlib_JSON.Decode.object(props);
661
- if (obj !== undefined) {
662
- return obj[fieldName];
663
- }
664
- };
665
697
  let annotatedSchema = JSON.parse(structure.stateViewSlices[3].schema);
666
698
  globalThis.test("itemId carries x-reventless-id", () => {
667
699
  globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(getPropertyOf(annotatedSchema, "itemId"), s => getProperty(s, "x-reventless-id")), Stdlib_JSON.Decode.bool)).toBe(true);
@@ -757,12 +789,14 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
757
789
  {
758
790
  store: "TestPlugin.documents",
759
791
  component: "AttachInvoice",
760
- field: "documentUrl"
792
+ field: "documentUrl",
793
+ annotation: "documents"
761
794
  },
762
795
  {
763
796
  store: "branding.logos",
764
797
  component: "AttachInvoice",
765
- field: "logoUrl"
798
+ field: "logoUrl",
799
+ annotation: "branding.logos"
766
800
  }
767
801
  ];
768
802
  globalThis.expect(withStores.requiredStoreDeclarations).toEqual(expected);
@@ -776,7 +810,8 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
776
810
  let entry = Stdlib_Option.getOrThrow(manifest.capabilities.find(c => c.key === "TestPlugin.documents"), undefined);
777
811
  let expected = [{
778
812
  component: "AttachInvoice",
779
- field: "documentUrl"
813
+ field: "documentUrl",
814
+ annotation: "documents"
780
815
  }];
781
816
  globalThis.expect(entry.declaredBy).toEqual(expected);
782
817
  });
@@ -793,7 +828,8 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
793
828
  "declaredBy": [
794
829
  {
795
830
  "component": "AttachInvoice",
796
- "field": "documentUrl"
831
+ "field": "documentUrl",
832
+ "annotation": "documents"
797
833
  }
798
834
  ]
799
835
  },
@@ -803,7 +839,8 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
803
839
  "declaredBy": [
804
840
  {
805
841
  "component": "AttachInvoice",
806
- "field": "logoUrl"
842
+ "field": "logoUrl",
843
+ "annotation": "branding.logos"
807
844
  }
808
845
  ]
809
846
  }
@@ -813,6 +850,40 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
813
850
  });
814
851
  });
815
852
  });
853
+ globalThis.describe("optional carriers keep their declarations", () => {
854
+ let withOptional = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsUploadAvatarSlice], undefined, undefined, undefined, undefined, undefined, undefined);
855
+ globalThis.test("an optional storage ref still requires its store", () => {
856
+ globalThis.expect(withOptional.requiredStores).toEqual(["TestPlugin.avatars"]);
857
+ });
858
+ globalThis.test("and still carries one declaring site", () => {
859
+ let expected = [{
860
+ store: "TestPlugin.avatars",
861
+ component: "UploadAvatar",
862
+ field: "avatarUrl",
863
+ annotation: "avatars"
864
+ }];
865
+ globalThis.expect(withOptional.requiredStoreDeclarations).toEqual(expected);
866
+ });
867
+ globalThis.test("an optional reference is still collected", () => {
868
+ let cmd = withOptional.stateChangeSlices[0].commands[0];
869
+ let expected = [{
870
+ fieldName: "referredBy",
871
+ entity: "Customers",
872
+ plugin: undefined
873
+ }];
874
+ globalThis.expect(cmd.references).toEqual(expected);
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
+ });
886
+ });
816
887
  globalThis.describe("capability inference", () => {
817
888
  let commandWarnings = Capability_Inference$ReventlessCore.scanSchema("ChangePhoto", PsChangePhoto$ReventlessCore.commandSchema);
818
889
  let eventWarnings = Capability_Inference$ReventlessCore.scanSchema("ChangePhoto", PsChangePhoto$ReventlessCore.eventSchema);
@@ -867,6 +938,7 @@ export {
867
938
  PsCustomersViewSlice,
868
939
  PsAnnotatedViewSlice,
869
940
  PsAttachInvoiceSlice,
941
+ PsUploadAvatarSlice,
870
942
  PsChangePhotoSlice,
871
943
  structure,
872
944
  }
@@ -0,0 +1,37 @@
1
+ // Test fixture spec where every semantic carrier is OPTIONAL.
2
+ //
3
+ // The marker of an optional field sits on the schema *inside* the
4
+ // undefined-union sury-ppx wraps it in, so a walk that reads only the outer
5
+ // schema finds nothing. That failure is silent in the worst way: the plugin
6
+ // compiles, deploys green, and provisions no object store — so `avatarUrl` is
7
+ // the only declarer of `avatars` here on purpose. If the unwrap regresses, the
8
+ // store requirement disappears entirely rather than merely losing a site.
9
+ //
10
+ // `referredBy` is the same trap for `@ref`: an optional reference that goes
11
+ // uncollected drops out of `commandDef.references`.
12
+
13
+ @@reventless.spec("UploadAvatar")
14
+
15
+ type state = bool
16
+ let initialState = false
17
+
18
+ @schema
19
+ type consumedEvent = OrderPlaced
20
+
21
+ let evolve = (_state, _event) => true
22
+
23
+ @schema
24
+ type command =
25
+ | UploadAvatar({
26
+ customerId: string,
27
+ @storageRef("avatars") avatarUrl?: string,
28
+ @ref("Customers") referredBy?: string,
29
+ })
30
+
31
+ @schema
32
+ type error = CustomerNotFound
33
+
34
+ @schema
35
+ type event = AvatarUploaded({customerId: string, @storageRef("avatars") avatarUrl?: string})
36
+
37
+ let decide = (_state, _command): result<array<event>, error> => Ok([])
@@ -0,0 +1,64 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
5
+ import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
6
+ import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
7
+
8
+ let consumedEventSchema = S.literal("OrderPlaced");
9
+
10
+ function evolve(_state, _event) {
11
+ return true;
12
+ }
13
+
14
+ let commandSchema = S.schema(s => ({
15
+ TAG: "UploadAvatar",
16
+ customerId: s.m(DcbTag$Reventless.string),
17
+ avatarUrl: s.m(S.option(StorageRef$Reventless.forStore(undefined, "avatars"))),
18
+ referredBy: s.m(S.option(Reference$Reventless.to_(undefined, undefined, "Customers")))
19
+ }));
20
+
21
+ let errorSchema = S.literal("CustomerNotFound");
22
+
23
+ let eventSchema = S.schema(s => ({
24
+ TAG: "AvatarUploaded",
25
+ customerId: s.m(DcbTag$Reventless.string),
26
+ avatarUrl: s.m(S.option(StorageRef$Reventless.forStore(undefined, "avatars")))
27
+ }));
28
+
29
+ function decide(_state, _command) {
30
+ return {
31
+ TAG: "Ok",
32
+ _0: []
33
+ };
34
+ }
35
+
36
+ function commandAuthorization(param) {
37
+ return "AllowAuthenticated";
38
+ }
39
+
40
+ let name = "UploadAvatar";
41
+
42
+ let Id;
43
+
44
+ let initialState = false;
45
+
46
+ let moduleUrl = "@reventlessdev/reventless-core/tests/plugin/StateChangeSlice/PsUploadAvatar.res.mjs";
47
+
48
+ let readConsistency = "EscalateOnRetry";
49
+
50
+ export {
51
+ name,
52
+ Id,
53
+ initialState,
54
+ consumedEventSchema,
55
+ evolve,
56
+ commandSchema,
57
+ errorSchema,
58
+ eventSchema,
59
+ decide,
60
+ moduleUrl,
61
+ commandAuthorization,
62
+ readConsistency,
63
+ }
64
+ /* consumedEventSchema Not a pure module */