@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.
- package/CHANGELOG.md +18 -0
- package/package.json +9 -9
- package/src/Projection.res +158 -40
- package/src/Projection.res.mjs +107 -28
- package/src/admin/UiFragmentRegistry/{StateChangeSlice → StateChange}/UiFragmentRegistry.res.mjs +1 -1
- package/src/admin/UiFragmentRegistry/{StateChangeSlice → StateChange}/UiFragmentRegistry_Behavior.res.mjs +1 -1
- package/src/admin/UiFragmentRegistry/{StateViewSlice → StateView}/UiFragments.res.mjs +1 -1
- package/src/admin/UiFragmentRegistry/{StateViewSlice → StateView}/UiFragments_Projection.res.mjs +1 -1
- package/src/components/Api/GraphQL_FragmentGenerator.res +16 -2
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +13 -5
- package/src/components/Api/SchemaType.res +81 -1
- package/src/components/Api/SchemaType.res.mjs +82 -3
- package/src/components/Api/SuryToJsonSchema.res +10 -0
- package/src/components/Api/SuryToJsonSchema.res.mjs +135 -110
- package/src/components/ReadModel/ReadModel_Callback.res +5 -2
- package/src/components/ReadModel/ReadModel_Callback.res.mjs +2 -5
- package/src/components/StateViewSlice/StateViewSlice_Builder.res +7 -6
- package/src/components/StateViewSlice/StateViewSlice_Builder.res.mjs +2 -5
- package/src/components/StateViewSlice/StateViewSlice_Callback.res +3 -1
- package/src/components/StateViewSlice/StateViewSlice_Callback.res.mjs +1 -1
- package/src/plugin/component/Plugin_Structure.res +9 -46
- package/src/plugin/component/Plugin_Structure.res.mjs +8 -40
- package/src/plugin/connect/PluginExtensionPoint_UiFragment.res.mjs +1 -1
- package/tests/admin/AdminApiSchemaDriftTest.res.mjs +1 -1
- package/tests/api/BrandedMarkerCompositionTest.res +74 -0
- package/tests/api/BrandedMarkerCompositionTest.res.mjs +74 -0
- package/tests/api/PsBrandedMarkersReadModel.res +24 -0
- package/tests/api/PsBrandedMarkersReadModel.res.mjs +87 -0
- package/tests/api/SuryToJsonSchemaTest.res +91 -3
- package/tests/api/SuryToJsonSchemaTest.res.mjs +66 -4
- package/tests/plugin/PluginStructureAccessTest.res.mjs +3 -3
- package/tests/plugin/PluginStructureTest.res +24 -5
- package/tests/plugin/PluginStructureTest.res.mjs +32 -26
- package/tests/plugin/{StateChangeSlice → StateChange}/PsAttachInvoice.res.mjs +1 -1
- package/tests/plugin/{StateChangeSlice → StateChange}/PsChangePhoto.res.mjs +1 -1
- package/tests/plugin/{StateChangeSlice → StateChange}/PsDispatchShipment.res.mjs +1 -1
- package/tests/plugin/{StateChangeSlice → StateChange}/PsGatedCommands.res.mjs +1 -1
- package/tests/plugin/{StateChangeSlice → StateChange}/PsPlaceOrder.res.mjs +1 -1
- package/tests/plugin/{StateChangeSlice → StateChange}/PsReserveStock.res.mjs +1 -1
- package/tests/plugin/{StateChangeSlice → StateChange}/PsShipOrder.res.mjs +1 -1
- package/tests/plugin/{StateChangeSlice → StateChange}/PsTransitionSwitch.res.mjs +1 -1
- package/tests/plugin/{StateChangeSlice → StateChange}/PsTypoStore.res.mjs +1 -1
- package/tests/plugin/{StateChangeSlice → StateChange}/PsUploadAvatar.res.mjs +1 -1
- package/tests/plugin/{StateChangeSlice → StateChange}/PsUploadImages.res.mjs +1 -1
- package/tests/plugin/{StateViewSlice → StateView}/PsAnnotatedView.res +4 -2
- package/tests/plugin/{StateViewSlice → StateView}/PsAnnotatedView.res.mjs +8 -4
- package/tests/plugin/{StateViewSlice → StateView}/PsAvailableProductsView.res.mjs +1 -1
- package/tests/plugin/{StateViewSlice → StateView}/PsCategoriesView.res.mjs +1 -1
- package/tests/plugin/{StateViewSlice → StateView}/PsCustomersView.res.mjs +1 -1
- package/tests/plugin/{StateViewSlice → StateView}/PsGatedView.res.mjs +1 -1
- package/tests/plugin/{StateViewSlice → StateView}/PsOrdersView.res.mjs +1 -1
- package/tests/plugin/{StateViewSlice → StateView}/PsShipmentsView.res.mjs +1 -1
- package/tests/projection/LifecycleTrailTest.res +139 -0
- package/tests/projection/LifecycleTrailTest.res.mjs +280 -0
- /package/src/admin/UiFragmentRegistry/{StateChangeSlice → StateChange}/UiFragmentRegistry.res +0 -0
- /package/src/admin/UiFragmentRegistry/{StateChangeSlice → StateChange}/UiFragmentRegistry_Behavior.res +0 -0
- /package/src/admin/UiFragmentRegistry/{StateViewSlice → StateView}/UiFragments.res +0 -0
- /package/src/admin/UiFragmentRegistry/{StateViewSlice → StateView}/UiFragments_Projection.res +0 -0
- /package/tests/plugin/{StateChangeSlice → StateChange}/PsAttachInvoice.res +0 -0
- /package/tests/plugin/{StateChangeSlice → StateChange}/PsChangePhoto.res +0 -0
- /package/tests/plugin/{StateChangeSlice → StateChange}/PsDispatchShipment.res +0 -0
- /package/tests/plugin/{StateChangeSlice → StateChange}/PsGatedCommands.res +0 -0
- /package/tests/plugin/{StateChangeSlice → StateChange}/PsPlaceOrder.res +0 -0
- /package/tests/plugin/{StateChangeSlice → StateChange}/PsReserveStock.res +0 -0
- /package/tests/plugin/{StateChangeSlice → StateChange}/PsShipOrder.res +0 -0
- /package/tests/plugin/{StateChangeSlice → StateChange}/PsTransitionSwitch.res +0 -0
- /package/tests/plugin/{StateChangeSlice → StateChange}/PsTypoStore.res +0 -0
- /package/tests/plugin/{StateChangeSlice → StateChange}/PsUploadAvatar.res +0 -0
- /package/tests/plugin/{StateChangeSlice → StateChange}/PsUploadImages.res +0 -0
- /package/tests/plugin/{StateViewSlice → StateView}/PsAvailableProductsView.res +0 -0
- /package/tests/plugin/{StateViewSlice → StateView}/PsCategoriesView.res +0 -0
- /package/tests/plugin/{StateViewSlice → StateView}/PsCustomersView.res +0 -0
- /package/tests/plugin/{StateViewSlice → StateView}/PsGatedView.res +0 -0
- /package/tests/plugin/{StateViewSlice → StateView}/PsOrdersView.res +0 -0
- /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
|
|
47
|
-
//
|
|
48
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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/
|
|
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/
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
})
|