@reventlessdev/reventless-core 3.0.0-alpha.258 → 3.0.0-alpha.260

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +9 -9
  3. package/src/Projection.res +158 -40
  4. package/src/Projection.res.mjs +107 -28
  5. package/src/admin/UiFragmentRegistry/{StateChangeSlice → StateChange}/UiFragmentRegistry.res.mjs +1 -1
  6. package/src/admin/UiFragmentRegistry/{StateChangeSlice → StateChange}/UiFragmentRegistry_Behavior.res.mjs +1 -1
  7. package/src/admin/UiFragmentRegistry/{StateViewSlice → StateView}/UiFragments.res.mjs +1 -1
  8. package/src/admin/UiFragmentRegistry/{StateViewSlice → StateView}/UiFragments_Projection.res.mjs +1 -1
  9. package/src/components/Api/GraphQL_FragmentGenerator.res +16 -2
  10. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +13 -5
  11. package/src/components/Api/SchemaType.res +81 -1
  12. package/src/components/Api/SchemaType.res.mjs +82 -3
  13. package/src/components/Api/SuryToJsonSchema.res +10 -0
  14. package/src/components/Api/SuryToJsonSchema.res.mjs +135 -110
  15. package/src/components/ReadModel/ReadModel_Callback.res +5 -2
  16. package/src/components/ReadModel/ReadModel_Callback.res.mjs +2 -5
  17. package/src/components/StateViewSlice/StateViewSlice_Builder.res +7 -6
  18. package/src/components/StateViewSlice/StateViewSlice_Builder.res.mjs +2 -5
  19. package/src/components/StateViewSlice/StateViewSlice_Callback.res +3 -1
  20. package/src/components/StateViewSlice/StateViewSlice_Callback.res.mjs +1 -1
  21. package/src/plugin/component/Plugin_Structure.res +9 -46
  22. package/src/plugin/component/Plugin_Structure.res.mjs +8 -40
  23. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res.mjs +1 -1
  24. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +1 -1
  25. package/tests/api/BrandedMarkerCompositionTest.res +74 -0
  26. package/tests/api/BrandedMarkerCompositionTest.res.mjs +74 -0
  27. package/tests/api/PsBrandedMarkersReadModel.res +24 -0
  28. package/tests/api/PsBrandedMarkersReadModel.res.mjs +87 -0
  29. package/tests/api/SuryToJsonSchemaTest.res +91 -3
  30. package/tests/api/SuryToJsonSchemaTest.res.mjs +66 -4
  31. package/tests/plugin/PluginStructureAccessTest.res.mjs +3 -3
  32. package/tests/plugin/PluginStructureTest.res +24 -5
  33. package/tests/plugin/PluginStructureTest.res.mjs +32 -26
  34. package/tests/plugin/{StateChangeSlice → StateChange}/PsAttachInvoice.res.mjs +1 -1
  35. package/tests/plugin/{StateChangeSlice → StateChange}/PsChangePhoto.res.mjs +1 -1
  36. package/tests/plugin/{StateChangeSlice → StateChange}/PsDispatchShipment.res.mjs +1 -1
  37. package/tests/plugin/{StateChangeSlice → StateChange}/PsGatedCommands.res.mjs +1 -1
  38. package/tests/plugin/{StateChangeSlice → StateChange}/PsPlaceOrder.res.mjs +1 -1
  39. package/tests/plugin/{StateChangeSlice → StateChange}/PsReserveStock.res.mjs +1 -1
  40. package/tests/plugin/{StateChangeSlice → StateChange}/PsShipOrder.res.mjs +1 -1
  41. package/tests/plugin/{StateChangeSlice → StateChange}/PsTransitionSwitch.res.mjs +1 -1
  42. package/tests/plugin/{StateChangeSlice → StateChange}/PsTypoStore.res.mjs +1 -1
  43. package/tests/plugin/{StateChangeSlice → StateChange}/PsUploadAvatar.res.mjs +1 -1
  44. package/tests/plugin/{StateChangeSlice → StateChange}/PsUploadImages.res.mjs +1 -1
  45. package/tests/plugin/{StateViewSlice → StateView}/PsAnnotatedView.res +4 -2
  46. package/tests/plugin/{StateViewSlice → StateView}/PsAnnotatedView.res.mjs +8 -4
  47. package/tests/plugin/{StateViewSlice → StateView}/PsAvailableProductsView.res.mjs +1 -1
  48. package/tests/plugin/{StateViewSlice → StateView}/PsCategoriesView.res.mjs +1 -1
  49. package/tests/plugin/{StateViewSlice → StateView}/PsCustomersView.res.mjs +1 -1
  50. package/tests/plugin/{StateViewSlice → StateView}/PsGatedView.res.mjs +1 -1
  51. package/tests/plugin/{StateViewSlice → StateView}/PsOrdersView.res.mjs +1 -1
  52. package/tests/plugin/{StateViewSlice → StateView}/PsShipmentsView.res.mjs +1 -1
  53. package/tests/projection/LifecycleTrailTest.res +139 -0
  54. package/tests/projection/LifecycleTrailTest.res.mjs +280 -0
  55. /package/src/admin/UiFragmentRegistry/{StateChangeSlice → StateChange}/UiFragmentRegistry.res +0 -0
  56. /package/src/admin/UiFragmentRegistry/{StateChangeSlice → StateChange}/UiFragmentRegistry_Behavior.res +0 -0
  57. /package/src/admin/UiFragmentRegistry/{StateViewSlice → StateView}/UiFragments.res +0 -0
  58. /package/src/admin/UiFragmentRegistry/{StateViewSlice → StateView}/UiFragments_Projection.res +0 -0
  59. /package/tests/plugin/{StateChangeSlice → StateChange}/PsAttachInvoice.res +0 -0
  60. /package/tests/plugin/{StateChangeSlice → StateChange}/PsChangePhoto.res +0 -0
  61. /package/tests/plugin/{StateChangeSlice → StateChange}/PsDispatchShipment.res +0 -0
  62. /package/tests/plugin/{StateChangeSlice → StateChange}/PsGatedCommands.res +0 -0
  63. /package/tests/plugin/{StateChangeSlice → StateChange}/PsPlaceOrder.res +0 -0
  64. /package/tests/plugin/{StateChangeSlice → StateChange}/PsReserveStock.res +0 -0
  65. /package/tests/plugin/{StateChangeSlice → StateChange}/PsShipOrder.res +0 -0
  66. /package/tests/plugin/{StateChangeSlice → StateChange}/PsTransitionSwitch.res +0 -0
  67. /package/tests/plugin/{StateChangeSlice → StateChange}/PsTypoStore.res +0 -0
  68. /package/tests/plugin/{StateChangeSlice → StateChange}/PsUploadAvatar.res +0 -0
  69. /package/tests/plugin/{StateChangeSlice → StateChange}/PsUploadImages.res +0 -0
  70. /package/tests/plugin/{StateViewSlice → StateView}/PsAvailableProductsView.res +0 -0
  71. /package/tests/plugin/{StateViewSlice → StateView}/PsCategoriesView.res +0 -0
  72. /package/tests/plugin/{StateViewSlice → StateView}/PsCustomersView.res +0 -0
  73. /package/tests/plugin/{StateViewSlice → StateView}/PsGatedView.res +0 -0
  74. /package/tests/plugin/{StateViewSlice → StateView}/PsOrdersView.res +0 -0
  75. /package/tests/plugin/{StateViewSlice → StateView}/PsShipmentsView.res +0 -0
@@ -21,20 +21,6 @@ let rec isLabelShape = (t: SchemaType.schemaType): bool =>
21
21
  | _ => false
22
22
  }
23
23
 
24
- // Whether a field can hold a lifecycle: a closed set of values, or an optional
25
- // one. A free-text `lifecycle: string` is not a lifecycle — filtering a command
26
- // menu against `allowedStates` needs states to compare with.
27
- let rec isLifecycleShape = (t: SchemaType.schemaType): bool =>
28
- switch t {
29
- | Enum(_, _) => true
30
- | Nullable(inner) => isLifecycleShape(inner)
31
- // A union is a closed set of *shapes*, not of values, and `allowedStates`
32
- // compares values. The same answer the ppx gives `@lifecycle` on a union
33
- // field, which is a compile error — this is the convention rung agreeing.
34
- | TaggedUnion(_, _) => false
35
- | _ => false
36
- }
37
-
38
24
  // Field names that say "this one is the record's name" in the only way
39
25
  // available short of the `@displayName` annotation. Matched case-insensitively
40
26
  // and *exactly*: `customerName` holds a customer's name, not this record's.
@@ -43,33 +29,10 @@ let conventionalLabelNames = ["name", "title", "label", "displayname"]
43
29
  let shapeOfField = (~entityName: string, ~name: string, schema: S.t<unknown>): SchemaType.schemaType =>
44
30
  SchemaType.fromSury(~parentName=entityName, ~fieldName=name, schema)
45
31
 
46
- // The field holding the entity's lifecycle, for filtering a per-row command menu:
47
- // `@lifecycle`, else an enum field literally named `lifecycle`, else None. Not
48
- // keyed on `status` — a promiscuous name that would guess, and guess often.
49
- let lifecycleFieldFromStateSchema = (
50
- ~entityName: string,
51
- stateSchema: S.t<unknown>,
52
- ): option<string> => {
53
- let annotated = switch Reventless.StateAnnotations.getSpec(stateSchema) {
54
- | Some(spec) => spec.lifecycle
55
- | None => None
56
- }
57
- switch annotated {
58
- | Some(_) as some => some
59
- | None =>
60
- switch stateSchema {
61
- | Object({properties}) =>
62
- properties
63
- ->Dict.get("lifecycle")
64
- ->Option.flatMap(schema =>
65
- isLifecycleShape(shapeOfField(~entityName, ~name="lifecycle", schema))
66
- ? Some("lifecycle")
67
- : None
68
- )
69
- | _ => None
70
- }
71
- }
72
- }
32
+ // The field holding the entity's lifecycle, for filtering a per-row command
33
+ // menu. One rule, stated in `Reventless.Lifecycle` — the projection machinery
34
+ // resolves the field a trail entry records against from the same place.
35
+ let lifecycleFieldFromStateSchema = Reventless.Lifecycle.fieldName
73
36
 
74
37
  // The field whose truth withdraws a row from ordinary reads. Annotation-only —
75
38
  // no convention rung, since guessing wrong here makes rows vanish.
@@ -113,7 +76,7 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): retire
113
76
  | None | Some({values: None}) => NotDeclared
114
77
  | Some({field, values: Some(values)}) =>
115
78
  let named = values->Array.join(", ")
116
- let lifecycle = lifecycleFieldFromStateSchema(~entityName, stateSchema)
79
+ let lifecycle = lifecycleFieldFromStateSchema(stateSchema)
117
80
  if lifecycle != Some(field) {
118
81
  log.warn(
119
82
  ~comp="Plugin_Structure",
@@ -190,7 +153,7 @@ let lifecycleStatesFromStateSchema = (
190
153
  ~entityName: string,
191
154
  stateSchema: S.t<unknown>,
192
155
  ): option<array<string>> =>
193
- lifecycleFieldFromStateSchema(~entityName, stateSchema)->Option.flatMap(field =>
156
+ lifecycleFieldFromStateSchema(stateSchema)->Option.flatMap(field =>
194
157
  switch stateSchema {
195
158
  | Object({properties}) =>
196
159
  properties
@@ -965,7 +928,7 @@ let queryableDefFromSpec = (
965
928
  labelField: label.field,
966
929
  searchableFields: label.searchableFields,
967
930
  labelFieldSource: Some(labelFieldSourceToString(label.source)),
968
- lifecycleField: lifecycleFieldFromStateSchema(~entityName=name, stateSchema),
931
+ lifecycleField: lifecycleFieldFromStateSchema(stateSchema),
969
932
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
970
933
  retiredField: retiredFieldFromStateSchema(stateSchema),
971
934
  retiredValues: retiredValuesFromStateSchema(stateSchema),
@@ -1410,7 +1373,7 @@ let make = (
1410
1373
  labelField: label.field,
1411
1374
  searchableFields: label.searchableFields,
1412
1375
  labelFieldSource: Some(labelFieldSourceToString(label.source)),
1413
- lifecycleField: lifecycleFieldFromStateSchema(~entityName=R.Spec.name, stateSchema),
1376
+ lifecycleField: lifecycleFieldFromStateSchema(stateSchema),
1414
1377
  // Same schema `lifecycleField` reads, so the two cannot disagree about which
1415
1378
  // fields this view has.
1416
1379
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
@@ -1451,7 +1414,7 @@ let make = (
1451
1414
  labelField: label.field,
1452
1415
  searchableFields: label.searchableFields,
1453
1416
  labelFieldSource: Some(labelFieldSourceToString(label.source)),
1454
- lifecycleField: lifecycleFieldFromStateSchema(~entityName=SVS.Spec.name, stateSchema),
1417
+ lifecycleField: lifecycleFieldFromStateSchema(stateSchema),
1455
1418
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
1456
1419
  retiredField: retiredFieldFromStateSchema(stateSchema),
1457
1420
  retiredValues: retiredValuesFromStateSchema(stateSchema),
@@ -12,6 +12,7 @@ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js
12
12
  import * as Trait$Reventless from "@reventlessdev/reventless-spec/src/types/Trait.res.mjs";
13
13
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
14
14
  import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
15
+ import * as Lifecycle$Reventless from "@reventlessdev/reventless-spec/src/types/Lifecycle.res.mjs";
15
16
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
16
17
  import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
17
18
  import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
@@ -47,24 +48,6 @@ function isLabelShape(_t) {
47
48
  };
48
49
  }
49
50
 
50
- function isLifecycleShape(_t) {
51
- while (true) {
52
- let t = _t;
53
- if (typeof t !== "object") {
54
- return false;
55
- }
56
- switch (t.TAG) {
57
- case "Nullable" :
58
- _t = t._0;
59
- continue;
60
- case "Enum" :
61
- return true;
62
- default:
63
- return false;
64
- }
65
- };
66
- }
67
-
68
51
  let conventionalLabelNames = [
69
52
  "name",
70
53
  "title",
@@ -74,22 +57,6 @@ let conventionalLabelNames = [
74
57
 
75
58
  let shapeOfField = SchemaType$ReventlessCore.fromSury;
76
59
 
77
- function lifecycleFieldFromStateSchema(entityName, stateSchema) {
78
- let spec = StateAnnotations$Reventless.getSpec(stateSchema);
79
- let annotated = spec !== undefined ? spec.lifecycle : undefined;
80
- if (annotated !== undefined) {
81
- return annotated;
82
- } else if (stateSchema.type === "object") {
83
- return Stdlib_Option.flatMap(stateSchema.properties["lifecycle"], schema => {
84
- if (isLifecycleShape(SchemaType$ReventlessCore.fromSury(entityName, "lifecycle", schema))) {
85
- return "lifecycle";
86
- }
87
- });
88
- } else {
89
- return;
90
- }
91
- }
92
-
93
60
  function retiredFromStateSchema(stateSchema) {
94
61
  let spec = StateAnnotations$Reventless.getSpec(stateSchema);
95
62
  if (spec !== undefined) {
@@ -120,7 +87,7 @@ function checkRetiredValue(entityName, stateSchema) {
120
87
  }
121
88
  let field = match.field;
122
89
  let named = values.join(", ");
123
- let lifecycle = lifecycleFieldFromStateSchema(entityName, stateSchema);
90
+ let lifecycle = Lifecycle$Reventless.fieldName(stateSchema);
124
91
  if (Primitive_object.notequal(lifecycle, field)) {
125
92
  log.warn("Plugin_Structure", undefined, entityName + `: @retired(` + named + `) is on "` + field + `", which is not this record's lifecycle field` + Stdlib_Option.getOr(Stdlib_Option.map(lifecycle, f => ` (that is "` + f + `")`), " (it declares none)") + `. A retirement state no command's declared edge can name loses the command filtering the state form exists for.`);
126
93
  }
@@ -169,7 +136,7 @@ function reportRetiredStates(pluginName, failures, unchecked) {
169
136
  }
170
137
 
171
138
  function lifecycleStatesFromStateSchema(entityName, stateSchema) {
172
- return Stdlib_Option.flatMap(lifecycleFieldFromStateSchema(entityName, stateSchema), field => {
139
+ return Stdlib_Option.flatMap(Lifecycle$Reventless.fieldName(stateSchema), field => {
173
140
  if (stateSchema.type === "object") {
174
141
  return Stdlib_Option.map(stateSchema.properties[field], schema => {
175
142
  let match = SchemaType$ReventlessCore.fromSury(entityName, field, schema);
@@ -715,7 +682,7 @@ function queryableDefFromSpec(plugin, name, stateSchema, authorization, visibili
715
682
  labelField: label.field,
716
683
  searchableFields: label.searchableFields,
717
684
  labelFieldSource: labelFieldSourceToString(label.source),
718
- lifecycleField: lifecycleFieldFromStateSchema(name, stateSchema),
685
+ lifecycleField: Lifecycle$Reventless.fieldName(stateSchema),
719
686
  ownerField: Owner$Reventless.fieldNames(stateSchema)[0],
720
687
  retiredField: retiredFieldFromStateSchema(stateSchema),
721
688
  retiredValues: retiredValuesFromStateSchema(stateSchema),
@@ -1024,7 +991,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1024
991
  labelField: label.field,
1025
992
  searchableFields: label.searchableFields,
1026
993
  labelFieldSource: labelFieldSourceToString(label.source),
1027
- lifecycleField: lifecycleFieldFromStateSchema(R.Spec.name, stateSchema),
994
+ lifecycleField: Lifecycle$Reventless.fieldName(stateSchema),
1028
995
  ownerField: Owner$Reventless.fieldNames(stateSchema)[0],
1029
996
  retiredField: retiredFieldFromStateSchema(stateSchema),
1030
997
  retiredValues: retiredValuesFromStateSchema(stateSchema),
@@ -1056,7 +1023,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1056
1023
  labelField: label.field,
1057
1024
  searchableFields: label.searchableFields,
1058
1025
  labelFieldSource: labelFieldSourceToString(label.source),
1059
- lifecycleField: lifecycleFieldFromStateSchema(SVS.Spec.name, stateSchema),
1026
+ lifecycleField: Lifecycle$Reventless.fieldName(stateSchema),
1060
1027
  ownerField: Owner$Reventless.fieldNames(stateSchema)[0],
1061
1028
  retiredField: retiredFieldFromStateSchema(stateSchema),
1062
1029
  retiredValues: retiredValuesFromStateSchema(stateSchema),
@@ -1383,11 +1350,12 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1383
1350
  };
1384
1351
  }
1385
1352
 
1353
+ let lifecycleFieldFromStateSchema = Lifecycle$Reventless.fieldName;
1354
+
1386
1355
  export {
1387
1356
  log,
1388
1357
  declaredTransitionsOnly,
1389
1358
  isLabelShape,
1390
- isLifecycleShape,
1391
1359
  conventionalLabelNames,
1392
1360
  shapeOfField,
1393
1361
  lifecycleFieldFromStateSchema,
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
4
4
  import * as Plugin$ReventlessCore from "../component/Plugin.res.mjs";
5
- import * as UiFragmentRegistry$ReventlessCore from "../../admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs";
5
+ import * as UiFragmentRegistry$ReventlessCore from "../../admin/UiFragmentRegistry/StateChange/UiFragmentRegistry.res.mjs";
6
6
  import * as ExtensionPointMapping$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/ExtensionPointMapping.res.mjs";
7
7
  import * as PluginExtensionPointSpec$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/PluginExtensionPointSpec.res.mjs";
8
8
 
@@ -4,7 +4,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
6
6
  import * as SchemaType$ReventlessCore from "../../src/components/Api/SchemaType.res.mjs";
7
- import * as UiFragments$ReventlessCore from "../../src/admin/UiFragmentRegistry/StateViewSlice/UiFragments.res.mjs";
7
+ import * as UiFragments$ReventlessCore from "../../src/admin/UiFragmentRegistry/StateView/UiFragments.res.mjs";
8
8
  import * as Platform_UIFragmentsApi$ReventlessCore from "../../src/admin/Platform_UIFragmentsApi.res.mjs";
9
9
  import * as Platform_PluginStructuresApi$ReventlessCore from "../../src/admin/Platform_PluginStructuresApi.res.mjs";
10
10
  import * as Platform_ComponentDefinitionsApi$ReventlessCore from "../../src/admin/Platform_ComponentDefinitionsApi.res.mjs";
@@ -0,0 +1,74 @@
1
+ open JestGlobals
2
+
3
+ // A field marker and a branded semantic type on the same field. Both markers
4
+ // used to test for the literal `string` keyword, so both saw the brand instead
5
+ // of the string and refused a field they should have taken — which would have
6
+ // made declaring what a field *is* cost it whatever the marker gave.
7
+ //
8
+ // The composition assertion is the one that matters. `@owner` cannot see the
9
+ // schema sury-ppx derives from `Email.t`, so the tempting fix is to inject
10
+ // `Owner.string` over it: that type-checks, passes an "is it marked" test, and
11
+ // silently leaves the field a plain string with the address grammar gone.
12
+ // Asserting the marker is there proves nothing on its own — the rejection below
13
+ // is what says the brand survived.
14
+ describe("a field marker on a branded semantic:", () => {
15
+ let stateSchema = PsBrandedMarkersReadModel.stateSchema->S.castToUnknown
16
+
17
+ let fieldOf = name =>
18
+ switch stateSchema {
19
+ | Object({properties}) => properties->Dict.get(name)
20
+ | _ => None
21
+ }
22
+
23
+ describe("@owner on an Email field:", () => {
24
+ testSync("marks the field as the owner", () =>
25
+ expect(fieldOf("email")->Option.mapOr(false, Reventless.Owner.isFieldOwner))->toBe(true)
26
+ )
27
+
28
+ testSync("keeps the email semantic", () =>
29
+ expect(
30
+ fieldOf("email")
31
+ ->Option.flatMap(Reventless.Semantic.getFrom)
32
+ ->Option.map(s => s.id),
33
+ )->toEqual(Some("email"))
34
+ )
35
+
36
+ // The regression a substituting fix would land, and it lands in silence:
37
+ // the row still scopes to its owner, and the field accepts anything.
38
+ testSync("still rejects what is not an address", () => {
39
+ let parses = raw =>
40
+ switch fieldOf("email") {
41
+ | Some(schema) =>
42
+ switch raw->JSON.Encode.string->S.parseOrThrow(~to=schema) {
43
+ | _ => true
44
+ | exception _ => false
45
+ }
46
+ | None => false
47
+ }
48
+ expect((parses("buyer@example.com"), parses("buyer")))->toEqual((true, false))
49
+ })
50
+ })
51
+
52
+ describe("@displayName on a DateTime field:", () => {
53
+ testSync("names the row by it", () =>
54
+ expect(
55
+ S.Metadata.get(stateSchema, ~id=Reventless.DisplayName.displayNameId)->Option.map(d =>
56
+ d.fields
57
+ ),
58
+ )->toEqual(Some(["openedAt"]))
59
+ )
60
+
61
+ testSync("and the instant grammar survives", () => {
62
+ let parses = raw =>
63
+ switch fieldOf("openedAt") {
64
+ | Some(schema) =>
65
+ switch raw->JSON.Encode.string->S.parseOrThrow(~to=schema) {
66
+ | _ => true
67
+ | exception _ => false
68
+ }
69
+ | None => false
70
+ }
71
+ expect((parses("2026-03-02T09:00:00Z"), parses("tomorrow")))->toEqual((true, false))
72
+ })
73
+ })
74
+ })
@@ -0,0 +1,74 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as Sury from "sury";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
7
+ import * as Semantic$Reventless from "@reventlessdev/reventless-spec/src/semantic/Semantic.res.mjs";
8
+ import * as DisplayName$Reventless from "@reventlessdev/reventless-spec/src/components/DisplayName.res.mjs";
9
+ import * as PsBrandedMarkersReadModel$ReventlessCore from "./PsBrandedMarkersReadModel.res.mjs";
10
+
11
+ globalThis.describe("a field marker on a branded semantic:", () => {
12
+ let fieldOf = name => {
13
+ if (PsBrandedMarkersReadModel$ReventlessCore.stateSchema.type === "object") {
14
+ return PsBrandedMarkersReadModel$ReventlessCore.stateSchema.properties[name];
15
+ }
16
+ };
17
+ globalThis.describe("@owner on an Email field:", () => {
18
+ globalThis.test("marks the field as the owner", () => {
19
+ globalThis.expect(Stdlib_Option.mapOr(fieldOf("email"), false, Owner$Reventless.isFieldOwner)).toBe(true);
20
+ });
21
+ globalThis.test("keeps the email semantic", () => {
22
+ globalThis.expect(Stdlib_Option.map(Stdlib_Option.flatMap(fieldOf("email"), Semantic$Reventless.getFrom), s => s.id)).toEqual("email");
23
+ });
24
+ globalThis.test("still rejects what is not an address", () => {
25
+ let parses = raw => {
26
+ let schema = fieldOf("email");
27
+ if (schema === undefined) {
28
+ return false;
29
+ }
30
+ try {
31
+ S.parseOrThrow(raw, schema);
32
+ return true;
33
+ } catch (exn) {
34
+ return false;
35
+ }
36
+ };
37
+ globalThis.expect([
38
+ parses("buyer@example.com"),
39
+ parses("buyer")
40
+ ]).toEqual([
41
+ true,
42
+ false
43
+ ]);
44
+ });
45
+ });
46
+ globalThis.describe("@displayName on a DateTime field:", () => {
47
+ globalThis.test("names the row by it", () => {
48
+ globalThis.expect(Stdlib_Option.map(Sury.$Metadata_get(PsBrandedMarkersReadModel$ReventlessCore.stateSchema, DisplayName$Reventless.displayNameId), d => d.fields)).toEqual(["openedAt"]);
49
+ });
50
+ globalThis.test("and the instant grammar survives", () => {
51
+ let parses = raw => {
52
+ let schema = fieldOf("openedAt");
53
+ if (schema === undefined) {
54
+ return false;
55
+ }
56
+ try {
57
+ S.parseOrThrow(raw, schema);
58
+ return true;
59
+ } catch (exn) {
60
+ return false;
61
+ }
62
+ };
63
+ globalThis.expect([
64
+ parses("2026-03-02T09:00:00Z"),
65
+ parses("tomorrow")
66
+ ]).toEqual([
67
+ true,
68
+ false
69
+ ]);
70
+ });
71
+ });
72
+ });
73
+
74
+ /* Not a pure module */
@@ -0,0 +1,24 @@
1
+ // Fixture for the field markers that meet a branded semantic type. A spec file
2
+ // and named `*ReadModel*` deliberately: the annotation machinery runs only under
3
+ // `@@reventless.spec` and only for a file the PPX reads as a read model or state
4
+ // view, so a module inside the test would exercise nothing.
5
+ //
6
+ // The two markers here are the two that used to read the literal `string`
7
+ // keyword and so refuse a field whose type is a string wearing a brand.
8
+
9
+ @@reventless.spec("BrandedMarkersReadModel")
10
+
11
+ @schema
12
+ type consumedEvent = SubscriptionOpened({subscriptionId: string, email: string})
13
+
14
+ @schema
15
+ type state = {
16
+ @id subscriptionId: string,
17
+ // The owner named by an address rather than by a bare id. `@owner` has to
18
+ // compose onto the schema sury-ppx derives from `Email.t` — replacing it would
19
+ // type-check and leave the field a plain marked string.
20
+ @owner email: Reventless.Email.t,
21
+ // `@displayName` never touches the field's schema; it only joins values, so a
22
+ // branded string is one it can read.
23
+ @displayName @summary openedAt: Reventless.DateTime.t,
24
+ }
@@ -0,0 +1,87 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as Email$Reventless from "@reventlessdev/reventless-spec/src/semantic/Email.res.mjs";
5
+ import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
6
+ import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/semantic/DateTime.res.mjs";
7
+ import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
8
+ import * as DisplayName$Reventless from "@reventlessdev/reventless-spec/src/components/DisplayName.res.mjs";
9
+ import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
10
+
11
+ let consumedEventSchema = Sury.$schema(s => ({
12
+ TAG: "SubscriptionOpened",
13
+ subscriptionId: s.m(Sury.string),
14
+ email: s.m(Sury.string)
15
+ }));
16
+
17
+ let stateSchema = Sury.$schema(s => ({
18
+ subscriptionId: s.m(Sury.string),
19
+ email: s.m(Owner$Reventless.mark(Email$Reventless.schema)),
20
+ openedAt: s.m(DateTime$Reventless.schema),
21
+ displayName: s.m(Sury.$option(Sury.string))
22
+ }));
23
+
24
+ let stateSchema$1 = Sury.$Metadata_set(stateSchema, DisplayName$Reventless.displayNameId, {
25
+ fields: ["openedAt"],
26
+ separator: " "
27
+ });
28
+
29
+ let config = ReadModel$Reventless.config(undefined, undefined, [{
30
+ index: "_owner",
31
+ type_: "S",
32
+ idField: "email",
33
+ subIdField: "id",
34
+ projectionType: "ALL",
35
+ derived: true
36
+ }]);
37
+
38
+ let makeId = (state => state.subscriptionId);
39
+
40
+ let stateSchema$2 = Sury.$Metadata_set(stateSchema$1, StateAnnotations$Reventless.stateAnnotationsId, {
41
+ ids: ["subscriptionId"],
42
+ compositeIds: [],
43
+ subIds: [],
44
+ compositeSubIds: [],
45
+ indexes: [],
46
+ hidden: [],
47
+ summary: ["openedAt"],
48
+ internal: [],
49
+ drillTargets: [],
50
+ drillTargetKeys: [],
51
+ collapsed: [],
52
+ scan: [],
53
+ scanSort: [],
54
+ semantic: [],
55
+ metric: [],
56
+ lifecycle: undefined,
57
+ groupBy: undefined,
58
+ visibility: undefined,
59
+ live: undefined,
60
+ retired: undefined
61
+ });
62
+
63
+ let name = "BrandedMarkersReadModel";
64
+
65
+ let Id;
66
+
67
+ let subIdConfig;
68
+
69
+ let moduleUrl = "@reventlessdev/reventless-core/tests/api/PsBrandedMarkersReadModel.res.mjs";
70
+
71
+ let authorization = "AllowAuthenticated";
72
+
73
+ let visibility = "Public";
74
+
75
+ export {
76
+ name,
77
+ Id,
78
+ consumedEventSchema,
79
+ config,
80
+ subIdConfig,
81
+ makeId,
82
+ stateSchema$2 as stateSchema,
83
+ moduleUrl,
84
+ authorization,
85
+ visibility,
86
+ }
87
+ /* consumedEventSchema Not a pure module */
@@ -57,7 +57,7 @@ describe("SuryToJsonSchema:", () => {
57
57
  testSync("emits format:\"date-time\" for a DateTime-marked string field", () => {
58
58
  let schema = S.schema(s =>
59
59
  {
60
- "placedAt": s.matches(Reventless.DateTime.string),
60
+ "placedAt": s.matches(Reventless.DateTime.schema),
61
61
  "name": s.matches(S.string),
62
62
  }
63
63
  )->S.castToUnknown
@@ -72,6 +72,38 @@ describe("SuryToJsonSchema:", () => {
72
72
  getPropertyOf(json, "name")->Option.flatMap(s => getProperty(s, "format")),
73
73
  )->toBe(None)
74
74
  })
75
+
76
+ // The no-drift claim behind writing `placedAt: Reventless.DateTime.t` in
77
+ // place of `@s.matches(DateTime.string) string`: the type emits exactly what
78
+ // the annotation emitted, semantic key still absent. This is the one test
79
+ // that would catch that output changing by accident.
80
+ testSync("a DateTime field emits format and no semantic key", () => {
81
+ let json = SuryToJsonSchema.deriveObjectSchema(
82
+ S.schema(s => {"placedAt": s.matches(Reventless.DateTime.schema)})->S.castToUnknown,
83
+ )
84
+ let keyOf = key => getPropertyOf(json, "placedAt")->Option.flatMap(s => getProperty(s, key))
85
+ expect((keyOf("type"), keyOf("format"), keyOf("x-reventless-semantic")))->toEqual((
86
+ Some(JSON.Encode.string("string")),
87
+ Some(JSON.Encode.string("date-time")),
88
+ None,
89
+ ))
90
+ })
91
+
92
+ // A calendar date speaks both vocabularies, and that asymmetry with
93
+ // `DateTime` is deliberate: `DateTime`'s bare-`format` output predates the
94
+ // generic marker and is a published contract, so it keeps it; a semantic
95
+ // arriving now has no such history and should say what it is as well.
96
+ testSync("a CalendarDate field emits format:\"date\" AND the semantic key", () => {
97
+ let json = SuryToJsonSchema.deriveObjectSchema(
98
+ S.schema(s => {"issuedOn": s.matches(Reventless.CalendarDate.schema)})->S.castToUnknown,
99
+ )
100
+ let keyOf = key => getPropertyOf(json, "issuedOn")->Option.flatMap(s => getProperty(s, key))
101
+ expect((keyOf("type"), keyOf("format"), keyOf("x-reventless-semantic")))->toEqual((
102
+ Some(JSON.Encode.string("string")),
103
+ Some(JSON.Encode.string("date")),
104
+ Some(JSON.Encode.string("date")),
105
+ ))
106
+ })
75
107
  })
76
108
 
77
109
  describe("deriveObjectSchema with an owner field:", () => {
@@ -898,7 +930,7 @@ describe("SuryToJsonSchema:", () => {
898
930
  let schema = S.schema(s =>
899
931
  {
900
932
  "customerId": s.matches(Reventless.Reference.to_("Customer")),
901
- "placedAt": s.matches(Reventless.DateTime.string),
933
+ "placedAt": s.matches(Reventless.DateTime.schema),
902
934
  }
903
935
  )->S.castToUnknown
904
936
  let json = SuryToJsonSchema.deriveObjectSchema(schema)
@@ -1203,7 +1235,7 @@ describe("SuryToJsonSchema:", () => {
1203
1235
  S.schema(s =>
1204
1236
  {
1205
1237
  "deliveryWindow": s.matches(Reventless.DateRange.schema),
1206
- "orderedAt": s.matches(Reventless.DateTime.string),
1238
+ "orderedAt": s.matches(Reventless.DateTime.schema),
1207
1239
  }
1208
1240
  )->S.castToUnknown,
1209
1241
  )
@@ -1324,4 +1356,60 @@ describe("SuryToJsonSchema:", () => {
1324
1356
  )
1325
1357
  })
1326
1358
 
1359
+ // The value a field opens with is JSON Schema's own keyword, not an
1360
+ // `x-reventless-` extension — and it rides on the field's schema, so it
1361
+ // survives the one place a name-keyed annotation spec cannot reach: a record
1362
+ // nested inside a command.
1363
+ describe("a field declaring a default:", () => {
1364
+ let defaultOf = (json, ~field) =>
1365
+ getPropertyOf(json, field)->Option.flatMap(s => getProperty(s, "default"))
1366
+
1367
+ let json = SuryToJsonSchema.deriveObjectSchema(
1368
+ S.schema(s =>
1369
+ {
1370
+ "quantity": s.matches(S.int->Reventless.FieldDefault.int(1)),
1371
+ "shippingMethod": s.matches(S.string->Reventless.FieldDefault.string("Standard")),
1372
+ "plain": s.matches(S.int),
1373
+ }
1374
+ )->S.castToUnknown,
1375
+ )
1376
+
1377
+ testSync("carries it as `default`", () =>
1378
+ expect(defaultOf(json, ~field="quantity"))->toEqual(Some(JSON.Encode.int(1)))
1379
+ )
1380
+
1381
+ testSync("keeps the shape the value actually has", () =>
1382
+ expect(
1383
+ getPropertyOf(json, "quantity")->Option.flatMap(s => getProperty(s, "type")),
1384
+ )->toEqual(Some(JSON.Encode.string("integer")))
1385
+ )
1386
+
1387
+ testSync("carries a string default the same way", () =>
1388
+ expect(defaultOf(json, ~field="shippingMethod"))
1389
+ ->toEqual(Some(JSON.Encode.string("Standard")))
1390
+ )
1391
+
1392
+ testSync("while a field declaring none says nothing", () =>
1393
+ expect(defaultOf(json, ~field="plain"))->toBe(None)
1394
+ )
1395
+
1396
+ // The case the whole marker exists for: a name-keyed annotation spec speaks
1397
+ // for a record's own fields, and an order's line items are one level in.
1398
+ testSync("and reaches a field of a nested record", () => {
1399
+ let lineItem = S.schema(s =>
1400
+ {
1401
+ "productId": s.matches(S.string),
1402
+ "quantity": s.matches(S.int->Reventless.FieldDefault.int(1)),
1403
+ }
1404
+ )
1405
+ let nested = SuryToJsonSchema.deriveObjectSchema(
1406
+ S.schema(s => {"lineItems": s.matches(S.array(lineItem))})->S.castToUnknown,
1407
+ )
1408
+ expect(
1409
+ getPropertyOf(nested, "lineItems")
1410
+ ->Option.flatMap(a => getProperty(a, "items"))
1411
+ ->Option.flatMap(i => defaultOf(i, ~field="quantity")),
1412
+ )->toEqual(Some(JSON.Encode.int(1)))
1413
+ })
1414
+ })
1327
1415
  })