@reventlessdev/reventless-core 3.0.0-alpha.238 → 3.0.0-alpha.239
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 +12 -0
- package/package.json +8 -8
- package/src/admin/Platform_Admin_Structure.res +1 -0
- package/src/admin/Platform_Admin_Structure.res.mjs +1 -0
- package/src/admin/Platform_ComponentDefinitionsApi.res +2 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
- package/src/components/Api/GraphQL_FragmentGenerator.res +122 -12
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +41 -8
- package/src/components/Api/SuryToJsonSchema.res +8 -0
- package/src/components/Api/SuryToJsonSchema.res.mjs +8 -4
- package/src/plugin/component/Plugin_Builder.res +7 -0
- package/src/plugin/component/Plugin_Builder.res.mjs +3 -1
- package/src/plugin/component/Plugin_Structure.res +109 -22
- package/src/plugin/component/Plugin_Structure.res.mjs +47 -7
- package/tests/admin/Platform_BakedManifestTest.res +1 -0
- package/tests/admin/Platform_BakedManifestTest.res.mjs +1 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +3 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +6 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res +1 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +2 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res +171 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +108 -0
- package/tests/api/SuryToJsonSchemaTest.res +30 -3
- package/tests/api/SuryToJsonSchemaTest.res.mjs +49 -4
- package/tests/plugin/PluginStructureTest.res +104 -1
- package/tests/plugin/PluginStructureTest.res.mjs +114 -2
|
@@ -108,17 +108,55 @@ let retiredFieldFromStateSchema = (stateSchema: S.t<unknown>): option<string> =>
|
|
|
108
108
|
let retiredValuesFromStateSchema = (stateSchema: S.t<unknown>): option<array<string>> =>
|
|
109
109
|
retiredFromStateSchema(stateSchema)->Option.flatMap(r => r.values)
|
|
110
110
|
|
|
111
|
+
// Whether a reference to a retired row of this view still resolves its name —
|
|
112
|
+
// `@namedWhenRetired`. Read off the retirement rather than from a second
|
|
113
|
+
// annotation, so a record cannot declare the reach of a retirement it does not
|
|
114
|
+
// have; the PPX refuses that pairing, and reading it here from the same place
|
|
115
|
+
// keeps the two halves agreeing by construction rather than by review.
|
|
116
|
+
let namedWhenRetiredFromStateSchema = (stateSchema: S.t<unknown>): bool =>
|
|
117
|
+
retiredFromStateSchema(stateSchema)->Option.mapOr(false, r => r.namedWhenRetired)
|
|
118
|
+
|
|
119
|
+
// What one record's `@retired` declaration could be told about its own field.
|
|
120
|
+
//
|
|
121
|
+
// Three outcomes rather than a bool, because "nothing to check" and "could not
|
|
122
|
+
// check" are different facts and only the second is worth a plugin's attention.
|
|
123
|
+
type retiredCheck =
|
|
124
|
+
| NotDeclared
|
|
125
|
+
// Why the names could not be compared. A fatal rule that is invisible when it
|
|
126
|
+
// does not run is the failure mode the transition check spends a counter to
|
|
127
|
+
// avoid, so this is reported rather than skipped in silence.
|
|
128
|
+
| Unchecked(string)
|
|
129
|
+
| Checked(array<string>)
|
|
130
|
+
|
|
111
131
|
// The check the PPX cannot make, in the one place that can: the payload is a
|
|
112
132
|
// constructor reference the PPX only ever sees as a name, and whether that name
|
|
113
133
|
// is a case of the field's enum needs the schema.
|
|
114
134
|
//
|
|
115
|
-
// Two rules, and
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
135
|
+
// Two rules, and they are held to different standards on purpose.
|
|
136
|
+
//
|
|
137
|
+
// **A name the field's enum does not declare is unambiguously wrong** — no domain
|
|
138
|
+
// means it — and the symptom is a data-exposure bug: the retirement predicate
|
|
139
|
+
// compares every row against a state no row is ever in, so every row stays
|
|
140
|
+
// visible to every caller while the annotation sits on the schema looking like
|
|
141
|
+
// enforcement. That is returned as a failure for the caller to raise on.
|
|
142
|
+
//
|
|
143
|
+
// It is the same fault the PPX already refuses to compile when the enum is
|
|
144
|
+
// declared in the same file, and the PPX says so in its own message. This is the
|
|
145
|
+
// residue that a per-file pass cannot reach: field form, enum imported from
|
|
146
|
+
// elsewhere. Two rungs of one ladder — until this was promoted, which rung you
|
|
147
|
+
// landed on decided whether a data-exposure bug stopped the build, and the
|
|
148
|
+
// arbiter was where the enum happened to be declared.
|
|
149
|
+
//
|
|
150
|
+
// **A `value` on a field that is not the record's lifecycle stays a warning.**
|
|
151
|
+
// It would keep the read narrowing while silently losing the command filtering
|
|
152
|
+
// that motivates it — `@transition` is written in terms of the lifecycle field,
|
|
153
|
+
// so a retirement state anywhere else is a state no command can name. That is a
|
|
154
|
+
// modelling judgement rather than a wrong name, and judgement calls are what the
|
|
155
|
+
// withdrawn dead-end rule taught us not to hard-fail on.
|
|
156
|
+
let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): retiredCheck =>
|
|
121
157
|
switch retiredFromStateSchema(stateSchema) {
|
|
158
|
+
// The boolean form names no state, so there is nothing to compare. Not a skip.
|
|
159
|
+
| None | Some({values: None}) => NotDeclared
|
|
122
160
|
| Some({field, values: Some(values)}) =>
|
|
123
161
|
let named = values->Array.join(", ")
|
|
124
162
|
let lifecycle = lifecycleFieldFromStateSchema(~entityName, stateSchema)
|
|
@@ -146,24 +184,56 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): unit =
|
|
|
146
184
|
->Option.getOr([])
|
|
147
185
|
| _ => []
|
|
148
186
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
187
|
+
if Array.length(declared) == 0 {
|
|
188
|
+
Unchecked(
|
|
189
|
+
`${entityName}: @retired(${named}) is on "${field}", whose shape carries no cases to check the names against.`,
|
|
190
|
+
)
|
|
191
|
+
} else {
|
|
192
|
+
// Reported per state rather than as a set: one wrong entry among three still
|
|
193
|
+
// narrows something, so the symptom is a subset of rows leaking rather than
|
|
194
|
+
// all of them — which is harder to spot than the single-value case was.
|
|
195
|
+
Checked(
|
|
196
|
+
values
|
|
197
|
+
->Array.filter(v => !(declared->Array.includes(v)))
|
|
198
|
+
->Array.map(
|
|
199
|
+
v =>
|
|
200
|
+
`${entityName}: @retired(${v}) names a state "${field}" does not declare — known values: ${declared->Array.join(
|
|
201
|
+
", ",
|
|
202
|
+
)}.`,
|
|
203
|
+
),
|
|
162
204
|
)
|
|
163
205
|
}
|
|
164
|
-
| _ => ()
|
|
165
206
|
}
|
|
166
207
|
|
|
208
|
+
// Raised together, after every view has been walked, so an author sees every bad
|
|
209
|
+
// name at once rather than the first one and then a rebuild.
|
|
210
|
+
//
|
|
211
|
+
// Retroactive in a way the transition check was not: `@transition` was new when
|
|
212
|
+
// its check landed, so nothing deployed could carry a stale name, while `@retired`
|
|
213
|
+
// has been shipping. A deployed plugin holding a misspelled retired value gets a
|
|
214
|
+
// red build on its next deploy — which is the point, and is why the examples were
|
|
215
|
+
// swept before this was promoted.
|
|
216
|
+
let reportRetiredStates = (
|
|
217
|
+
~pluginName: string,
|
|
218
|
+
~failures: array<string>,
|
|
219
|
+
~unchecked: array<string>,
|
|
220
|
+
): unit => {
|
|
221
|
+
if Array.length(unchecked) > 0 {
|
|
222
|
+
log.warn(
|
|
223
|
+
~comp="Plugin_Structure",
|
|
224
|
+
`${pluginName}: ${unchecked
|
|
225
|
+
->Array.length
|
|
226
|
+
->Int.toString} @retired declaration(s) could not be checked.\n` ++
|
|
227
|
+
unchecked->Array.join("\n"),
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
if Array.length(failures) > 0 {
|
|
231
|
+
JsError.throwWithMessage(
|
|
232
|
+
`${pluginName}: @retired names states that do not exist.\n` ++ failures->Array.join("\n"),
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
167
237
|
// The states a record's lifecycle field can hold. The same extraction
|
|
168
238
|
// `checkRetiredValue` does, keyed on the declared lifecycle field rather than the
|
|
169
239
|
// retired one — which is the field a command's `@transition` is written in terms
|
|
@@ -1071,6 +1141,17 @@ let make = (
|
|
|
1071
1141
|
}
|
|
1072
1142
|
}
|
|
1073
1143
|
|
|
1144
|
+
// Collected as the view defs are built and reported once, so a plugin with
|
|
1145
|
+
// three bad names fails naming three rather than one at a time.
|
|
1146
|
+
let retiredFailures = []
|
|
1147
|
+
let retiredUnchecked = []
|
|
1148
|
+
let recordRetired = (~entityName, stateSchema) =>
|
|
1149
|
+
switch checkRetiredValue(~entityName, stateSchema) {
|
|
1150
|
+
| NotDeclared => ()
|
|
1151
|
+
| Unchecked(why) => retiredUnchecked->Array.push(why)->ignore
|
|
1152
|
+
| Checked(failures) => failures->Array.forEach(f => retiredFailures->Array.push(f)->ignore)
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1074
1155
|
let readModelDefs =
|
|
1075
1156
|
readModels
|
|
1076
1157
|
->Array.map((
|
|
@@ -1090,7 +1171,7 @@ let make = (
|
|
|
1090
1171
|
// edges for any event reaching the read model via a DCB-log-sourced mapping (a classic
|
|
1091
1172
|
// aggregate→view link is also drawn from the producer's linkedViews, deduped downstream).
|
|
1092
1173
|
let consumed = qualify(~prefix=name, R.consumedEventNames)
|
|
1093
|
-
|
|
1174
|
+
recordRetired(~entityName=R.Spec.name, stateSchema)
|
|
1094
1175
|
recordLifecycle(~entityName=R.Spec.name, stateSchema)
|
|
1095
1176
|
({
|
|
1096
1177
|
Reventless.Plugin.name: R.Spec.name,
|
|
@@ -1107,6 +1188,7 @@ let make = (
|
|
|
1107
1188
|
ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
|
|
1108
1189
|
retiredField: retiredFieldFromStateSchema(stateSchema),
|
|
1109
1190
|
retiredValues: retiredValuesFromStateSchema(stateSchema),
|
|
1191
|
+
namedWhenRetired: Some(namedWhenRetiredFromStateSchema(stateSchema)),
|
|
1110
1192
|
visibility: visibilityTag(R.Spec.visibility),
|
|
1111
1193
|
chapter: chapterOf(R.Spec.name),
|
|
1112
1194
|
// Taken from the `qf` record, never re-derived: `Api_Naming` is the only
|
|
@@ -1129,7 +1211,7 @@ let make = (
|
|
|
1129
1211
|
~entityName=SVS.Spec.name,
|
|
1130
1212
|
stateSchema,
|
|
1131
1213
|
)
|
|
1132
|
-
|
|
1214
|
+
recordRetired(~entityName=SVS.Spec.name, stateSchema)
|
|
1133
1215
|
recordLifecycle(~entityName=SVS.Spec.name, stateSchema)
|
|
1134
1216
|
({
|
|
1135
1217
|
Reventless.Plugin.name: SVS.Spec.name,
|
|
@@ -1144,6 +1226,7 @@ let make = (
|
|
|
1144
1226
|
ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
|
|
1145
1227
|
retiredField: retiredFieldFromStateSchema(stateSchema),
|
|
1146
1228
|
retiredValues: retiredValuesFromStateSchema(stateSchema),
|
|
1229
|
+
namedWhenRetired: Some(namedWhenRetiredFromStateSchema(stateSchema)),
|
|
1147
1230
|
visibility: visibilityTag(SVS.Spec.visibility),
|
|
1148
1231
|
chapter: chapterOf(SVS.Spec.name),
|
|
1149
1232
|
singleQueryField: Some(qf.singleFieldName),
|
|
@@ -1313,6 +1396,10 @@ let make = (
|
|
|
1313
1396
|
~writables=Array.concat(stateChangeDefs, aggregateDefs),
|
|
1314
1397
|
~lifecycleStatesByView,
|
|
1315
1398
|
)
|
|
1399
|
+
// Also a second pass, for a different reason: the failures are gathered per
|
|
1400
|
+
// view as those defs are built, and raising inline would report the first bad
|
|
1401
|
+
// name and hide the rest.
|
|
1402
|
+
reportRetiredStates(~pluginName=name, ~failures=retiredFailures, ~unchecked=retiredUnchecked)
|
|
1316
1403
|
|
|
1317
1404
|
{
|
|
1318
1405
|
readModels: readModelDefs,
|
|
@@ -102,14 +102,18 @@ function retiredValuesFromStateSchema(stateSchema) {
|
|
|
102
102
|
return Stdlib_Option.flatMap(retiredFromStateSchema(stateSchema), r => r.values);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
function namedWhenRetiredFromStateSchema(stateSchema) {
|
|
106
|
+
return Stdlib_Option.mapOr(retiredFromStateSchema(stateSchema), false, r => r.namedWhenRetired);
|
|
107
|
+
}
|
|
108
|
+
|
|
105
109
|
function checkRetiredValue(entityName, stateSchema) {
|
|
106
110
|
let match = retiredFromStateSchema(stateSchema);
|
|
107
111
|
if (match === undefined) {
|
|
108
|
-
return;
|
|
112
|
+
return "NotDeclared";
|
|
109
113
|
}
|
|
110
114
|
let values = match.values;
|
|
111
115
|
if (values === undefined) {
|
|
112
|
-
return;
|
|
116
|
+
return "NotDeclared";
|
|
113
117
|
}
|
|
114
118
|
let field = match.field;
|
|
115
119
|
let named = values.join(", ");
|
|
@@ -139,9 +143,25 @@ function checkRetiredValue(entityName, stateSchema) {
|
|
|
139
143
|
return [];
|
|
140
144
|
}
|
|
141
145
|
}), []) : [];
|
|
142
|
-
if (declared.length
|
|
143
|
-
|
|
144
|
-
|
|
146
|
+
if (declared.length === 0) {
|
|
147
|
+
return {
|
|
148
|
+
TAG: "Unchecked",
|
|
149
|
+
_0: entityName + `: @retired(` + named + `) is on "` + field + `", whose shape carries no cases to check the names against.`
|
|
150
|
+
};
|
|
151
|
+
} else {
|
|
152
|
+
return {
|
|
153
|
+
TAG: "Checked",
|
|
154
|
+
_0: values.filter(v => !declared.includes(v)).map(v => entityName + `: @retired(` + v + `) names a state "` + field + `" does not declare — known values: ` + declared.join(", ") + `.`)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function reportRetiredStates(pluginName, failures, unchecked) {
|
|
160
|
+
if (unchecked.length !== 0) {
|
|
161
|
+
log.warn("Plugin_Structure", undefined, pluginName + `: ` + unchecked.length.toString() + ` @retired declaration(s) could not be checked.\n` + unchecked.join("\n"));
|
|
162
|
+
}
|
|
163
|
+
if (failures.length !== 0) {
|
|
164
|
+
return Stdlib_JsError.throwWithMessage(pluginName + `: @retired names states that do not exist.\n` + failures.join("\n"));
|
|
145
165
|
}
|
|
146
166
|
}
|
|
147
167
|
|
|
@@ -706,13 +726,28 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
|
|
|
706
726
|
return;
|
|
707
727
|
}
|
|
708
728
|
};
|
|
729
|
+
let retiredFailures = [];
|
|
730
|
+
let retiredUnchecked = [];
|
|
731
|
+
let recordRetired = (entityName, stateSchema) => {
|
|
732
|
+
let why = checkRetiredValue(entityName, stateSchema);
|
|
733
|
+
if (typeof why !== "object") {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (why.TAG === "Unchecked") {
|
|
737
|
+
retiredUnchecked.push(why._0);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
why._0.forEach(f => {
|
|
741
|
+
retiredFailures.push(f);
|
|
742
|
+
});
|
|
743
|
+
};
|
|
709
744
|
let readModelDefs = readModels.map(R => {
|
|
710
745
|
let qf = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(name, R.Spec.name, undefined);
|
|
711
746
|
let stateSchema = R.Spec.stateSchema;
|
|
712
747
|
let label = labelFieldsFromStateSchema(R.Spec.name, stateSchema);
|
|
713
748
|
let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(R.Spec.name, stateSchema);
|
|
714
749
|
let consumed = qualify(name, R.consumedEventNames);
|
|
715
|
-
|
|
750
|
+
recordRetired(R.Spec.name, stateSchema);
|
|
716
751
|
recordLifecycle(R.Spec.name, stateSchema);
|
|
717
752
|
return {
|
|
718
753
|
name: R.Spec.name,
|
|
@@ -727,6 +762,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
|
|
|
727
762
|
ownerField: Owner$Reventless.fieldNames(stateSchema)[0],
|
|
728
763
|
retiredField: retiredFieldFromStateSchema(stateSchema),
|
|
729
764
|
retiredValues: retiredValuesFromStateSchema(stateSchema),
|
|
765
|
+
namedWhenRetired: namedWhenRetiredFromStateSchema(stateSchema),
|
|
730
766
|
visibility: visibilityTag(R.Spec.visibility),
|
|
731
767
|
chapter: componentChapters[R.Spec.name],
|
|
732
768
|
singleQueryField: qf.singleFieldName,
|
|
@@ -742,7 +778,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
|
|
|
742
778
|
let stateSchema = SVS.Spec.stateSchema;
|
|
743
779
|
let label = labelFieldsFromStateSchema(SVS.Spec.name, stateSchema);
|
|
744
780
|
let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(SVS.Spec.name, stateSchema);
|
|
745
|
-
|
|
781
|
+
recordRetired(SVS.Spec.name, stateSchema);
|
|
746
782
|
recordLifecycle(SVS.Spec.name, stateSchema);
|
|
747
783
|
return {
|
|
748
784
|
name: SVS.Spec.name,
|
|
@@ -757,6 +793,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
|
|
|
757
793
|
ownerField: Owner$Reventless.fieldNames(stateSchema)[0],
|
|
758
794
|
retiredField: retiredFieldFromStateSchema(stateSchema),
|
|
759
795
|
retiredValues: retiredValuesFromStateSchema(stateSchema),
|
|
796
|
+
namedWhenRetired: namedWhenRetiredFromStateSchema(stateSchema),
|
|
760
797
|
visibility: visibilityTag(SVS.Spec.visibility),
|
|
761
798
|
chapter: componentChapters[SVS.Spec.name],
|
|
762
799
|
singleQueryField: qf.singleFieldName,
|
|
@@ -858,6 +895,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
|
|
|
858
895
|
});
|
|
859
896
|
checkDeclaredTransitions(name, stateChangeDefs.concat(aggregateDefs), lifecycleStatesByView);
|
|
860
897
|
checkLifecycleTopology(name, stateChangeDefs.concat(aggregateDefs), lifecycleStatesByView);
|
|
898
|
+
reportRetiredStates(name, retiredFailures, retiredUnchecked);
|
|
861
899
|
return {
|
|
862
900
|
readModels: readModelDefs,
|
|
863
901
|
stateViewSlices: stateViewDefs,
|
|
@@ -883,7 +921,9 @@ export {
|
|
|
883
921
|
retiredFromStateSchema,
|
|
884
922
|
retiredFieldFromStateSchema,
|
|
885
923
|
retiredValuesFromStateSchema,
|
|
924
|
+
namedWhenRetiredFromStateSchema,
|
|
886
925
|
checkRetiredValue,
|
|
926
|
+
reportRetiredStates,
|
|
887
927
|
lifecycleStatesFromStateSchema,
|
|
888
928
|
checkDeclaredTransitions,
|
|
889
929
|
lifecycleTopologyFindings,
|
|
@@ -22,6 +22,7 @@ function queryable(name, visibility, ownerField, param) {
|
|
|
22
22
|
ownerField: ownerField,
|
|
23
23
|
retiredField: undefined,
|
|
24
24
|
retiredValues: undefined,
|
|
25
|
+
namedWhenRetired: undefined,
|
|
25
26
|
visibility: visibility,
|
|
26
27
|
chapter: undefined,
|
|
27
28
|
singleQueryField: `Ordering_` + name + `Single`,
|
|
@@ -40,6 +40,7 @@ let qbl: queryableDef = {
|
|
|
40
40
|
ownerField: None,
|
|
41
41
|
retiredField: None,
|
|
42
42
|
retiredValues: None,
|
|
43
|
+
namedWhenRetired: None,
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
let wbl: writableDef = {
|
|
@@ -268,6 +269,7 @@ describe("visibility filtering (deployed AutoUI hides Internal)", () => {
|
|
|
268
269
|
ownerField: None,
|
|
269
270
|
retiredField: None,
|
|
270
271
|
retiredValues: None,
|
|
272
|
+
namedWhenRetired: None,
|
|
271
273
|
}
|
|
272
274
|
// A distinct name per source array: the complement has to be fed by both, and
|
|
273
275
|
// reusing one def would hide a version that only walks `readModels`.
|
|
@@ -367,6 +369,7 @@ describe("allowedStates + lifecycleField populated", () => {
|
|
|
367
369
|
ownerField: None,
|
|
368
370
|
retiredField: None,
|
|
369
371
|
retiredValues: None,
|
|
372
|
+
namedWhenRetired: None,
|
|
370
373
|
}
|
|
371
374
|
|
|
372
375
|
let wblWithStates: writableDef = {
|
|
@@ -55,6 +55,7 @@ let qbl = {
|
|
|
55
55
|
ownerField: undefined,
|
|
56
56
|
retiredField: undefined,
|
|
57
57
|
retiredValues: undefined,
|
|
58
|
+
namedWhenRetired: undefined,
|
|
58
59
|
visibility: undefined,
|
|
59
60
|
chapter: undefined,
|
|
60
61
|
singleQueryField: qbl_singleQueryField,
|
|
@@ -288,6 +289,7 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
|
|
|
288
289
|
ownerField: undefined,
|
|
289
290
|
retiredField: undefined,
|
|
290
291
|
retiredValues: undefined,
|
|
292
|
+
namedWhenRetired: undefined,
|
|
291
293
|
visibility: internalQbl_visibility,
|
|
292
294
|
chapter: undefined,
|
|
293
295
|
singleQueryField: internalQbl_singleQueryField,
|
|
@@ -316,6 +318,7 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
|
|
|
316
318
|
ownerField: undefined,
|
|
317
319
|
retiredField: undefined,
|
|
318
320
|
retiredValues: undefined,
|
|
321
|
+
namedWhenRetired: undefined,
|
|
319
322
|
visibility: internalSlice_visibility,
|
|
320
323
|
chapter: undefined,
|
|
321
324
|
singleQueryField: internalSlice_singleQueryField,
|
|
@@ -422,6 +425,7 @@ globalThis.describe("allowedStates + lifecycleField populated", () => {
|
|
|
422
425
|
ownerField: undefined,
|
|
423
426
|
retiredField: undefined,
|
|
424
427
|
retiredValues: undefined,
|
|
428
|
+
namedWhenRetired: undefined,
|
|
425
429
|
visibility: undefined,
|
|
426
430
|
chapter: undefined,
|
|
427
431
|
singleQueryField: qblWithStatus_singleQueryField,
|
|
@@ -503,6 +507,7 @@ globalThis.describe("singleQueryField reaches the caller", () => {
|
|
|
503
507
|
ownerField: undefined,
|
|
504
508
|
retiredField: undefined,
|
|
505
509
|
retiredValues: undefined,
|
|
510
|
+
namedWhenRetired: undefined,
|
|
506
511
|
visibility: undefined,
|
|
507
512
|
chapter: undefined,
|
|
508
513
|
singleQueryField: undefined,
|
|
@@ -550,6 +555,7 @@ globalThis.describe("idField / idFieldSource reach the caller", () => {
|
|
|
550
555
|
ownerField: undefined,
|
|
551
556
|
retiredField: undefined,
|
|
552
557
|
retiredValues: undefined,
|
|
558
|
+
namedWhenRetired: undefined,
|
|
553
559
|
visibility: undefined,
|
|
554
560
|
chapter: undefined,
|
|
555
561
|
singleQueryField: "Catalog_Product",
|
|
@@ -35,6 +35,7 @@ let publicRm = {
|
|
|
35
35
|
ownerField: undefined,
|
|
36
36
|
retiredField: undefined,
|
|
37
37
|
retiredValues: undefined,
|
|
38
|
+
namedWhenRetired: undefined,
|
|
38
39
|
visibility: undefined,
|
|
39
40
|
chapter: publicRm_chapter,
|
|
40
41
|
singleQueryField: publicRm_singleQueryField,
|
|
@@ -74,6 +75,7 @@ let internalRm = {
|
|
|
74
75
|
ownerField: undefined,
|
|
75
76
|
retiredField: undefined,
|
|
76
77
|
retiredValues: undefined,
|
|
78
|
+
namedWhenRetired: undefined,
|
|
77
79
|
visibility: internalRm_visibility,
|
|
78
80
|
chapter: internalRm_chapter,
|
|
79
81
|
singleQueryField: internalRm_singleQueryField,
|
|
@@ -340,3 +340,174 @@ describe("GraphQL_FragmentGenerator.mutationArgTypes", () => {
|
|
|
340
340
|
expect((mismatched, field->String.length > 0))->toEqual(([], true))
|
|
341
341
|
})
|
|
342
342
|
})
|
|
343
|
+
|
|
344
|
+
module RefDoorRow = {
|
|
345
|
+
@schema
|
|
346
|
+
type state = {id: string, name: string}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let queryFor = (fragment, needle) =>
|
|
350
|
+
GraphQL_Stitcher.decode(fragment).queries->Array.find(q => q->String.includes(needle))
|
|
351
|
+
|
|
352
|
+
let refDoorFragment = (~subIdField=?) =>
|
|
353
|
+
GraphQL_FragmentGenerator.generate(
|
|
354
|
+
~mutationEntries=[],
|
|
355
|
+
~queryEntries=[
|
|
356
|
+
{
|
|
357
|
+
ReventlessInfra.Api.singleFieldName: "Catalog_Product",
|
|
358
|
+
listFieldName: "Catalog_Products",
|
|
359
|
+
returnTypeName: "Catalog_Product",
|
|
360
|
+
stateSchema: RefDoorRow.stateSchema->S.castToUnknown,
|
|
361
|
+
authorization: None,
|
|
362
|
+
connectionSpec: true,
|
|
363
|
+
subIdField: ?subIdField,
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
// What a caller holding a pointer to a withheld row may learn about it, and what
|
|
369
|
+
// the two doors that could never answer for one can now be asked.
|
|
370
|
+
describe("the reference door and the archive argument", () => {
|
|
371
|
+
open Expect
|
|
372
|
+
|
|
373
|
+
// The narrowness is the type's, not a rule each backend re-implements: a caller
|
|
374
|
+
// cannot ask this door for a price, because there is no price on it.
|
|
375
|
+
testSync("projects a reference to id, label and the state that retired it", () => {
|
|
376
|
+
let sdl = GraphQL_FragmentGenerator.deriveRefTypeSdl(~returnTypeName="Catalog_Product")
|
|
377
|
+
expect((
|
|
378
|
+
sdl->String.includes("type Catalog_ProductRef"),
|
|
379
|
+
sdl->String.includes("label: String!"),
|
|
380
|
+
sdl->String.includes("retiredState: String"),
|
|
381
|
+
sdl->String.includes("price"),
|
|
382
|
+
))->toEqual((true, true, true, false))
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
// Emitted for every view rather than only the annotated ones, on the reasoning
|
|
386
|
+
// `includeRetired` already states: a field that appears with an annotation makes
|
|
387
|
+
// adding or removing it a breaking schema change.
|
|
388
|
+
testSync("names the door after the list field it resolves against", () =>
|
|
389
|
+
expect(
|
|
390
|
+
GraphQL_FragmentGenerator.deriveRefsQueryField(
|
|
391
|
+
~listFieldName="Catalog_Products",
|
|
392
|
+
~returnTypeName="Catalog_Product",
|
|
393
|
+
),
|
|
394
|
+
)->toEqual(" Catalog_ProductsRefs(ids: [ID!]!): [Catalog_ProductRef!]!")
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
// Every assertion above reads a `derive*` helper directly, which is how a door
|
|
398
|
+
// that no `generate` call emitted still passed them all: the SDL both AppSync
|
|
399
|
+
// APIs are built from comes from `generate`, while the adapter provisions a
|
|
400
|
+
// `<list>Refs` resolver per queryable on the premise that it is declared there.
|
|
401
|
+
// AppSync rejects a resolver for a field its schema does not have, so the
|
|
402
|
+
// mismatch surfaced as a failed deploy rather than a missing feature.
|
|
403
|
+
testSync("emits the door from generate, not only from the helper", () =>
|
|
404
|
+
expect((
|
|
405
|
+
queryFor(refDoorFragment(), "Catalog_ProductsRefs(ids: [ID!]!)")->Option.isSome,
|
|
406
|
+
typeDefFor(refDoorFragment(), "type Catalog_ProductRef")->Option.isSome,
|
|
407
|
+
))->toEqual((true, true))
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
// The condition the resolver side uses, mirrored: a composite-key view gets no
|
|
411
|
+
// door, because the read behind it is a BatchGetItem that would need both keys.
|
|
412
|
+
// Emitting the field for one would restore the same mismatch, reversed.
|
|
413
|
+
testSync("withholds the door from a composite-key view", () =>
|
|
414
|
+
expect((
|
|
415
|
+
queryFor(refDoorFragment(~subIdField="lineNo"), "Catalog_ProductsRefs")->Option.isSome,
|
|
416
|
+
typeDefFor(refDoorFragment(~subIdField="lineNo"), "type Catalog_ProductRef")->Option.isSome,
|
|
417
|
+
))->toEqual((false, false))
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
// The dead end this closes: the archive toggle put retired rows on screen and
|
|
421
|
+
// clicking one read a door that refused them to every caller alive, elevated
|
|
422
|
+
// included, because there was no way to ask.
|
|
423
|
+
testSync("lets the single-entity and by-ids doors be asked for the archive", () =>
|
|
424
|
+
expect((
|
|
425
|
+
GraphQL_FragmentGenerator.deriveObjectQueryField(
|
|
426
|
+
~singleFieldName="Catalog_Product",
|
|
427
|
+
~typeName="Catalog_Product",
|
|
428
|
+
)->String.includes("includeRetired: Boolean"),
|
|
429
|
+
GraphQL_FragmentGenerator.deriveByIdsQueryField(
|
|
430
|
+
~listFieldName="Catalog_Products",
|
|
431
|
+
~returnTypeName="Catalog_Product",
|
|
432
|
+
)->String.includes("includeRetired: Boolean"),
|
|
433
|
+
))->toEqual((true, true))
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
// A sub-id read keeps its sort-key argument beside the new one; dropping it
|
|
437
|
+
// would silently turn a two-key door into a one-key one.
|
|
438
|
+
testSync("keeps the sub-id argument when the view has one", () =>
|
|
439
|
+
expect(
|
|
440
|
+
GraphQL_FragmentGenerator.deriveObjectQueryField(
|
|
441
|
+
~singleFieldName="Ordering_OrderLine",
|
|
442
|
+
~typeName="Ordering_OrderLine",
|
|
443
|
+
~subIdField="lineNo",
|
|
444
|
+
),
|
|
445
|
+
)->toEqual(" Ordering_OrderLine(id: ID!, lineNo: String!, includeRetired: Boolean): Ordering_OrderLine")
|
|
446
|
+
)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// The by-index door is asserted whole rather than by `String.includes`, because
|
|
450
|
+
// what was wrong with it was never a missing substring: the field declared an
|
|
451
|
+
// `id` its resolver did not read and omitted the key its resolver did, so every
|
|
452
|
+
// part of the signature is load-bearing.
|
|
453
|
+
describe("the by-index door", () => {
|
|
454
|
+
let index = (~index, ~idField=?, ~subIdField=?): Reventless.ReadModel.indexConfig => {
|
|
455
|
+
index,
|
|
456
|
+
type_: "S",
|
|
457
|
+
idField: ?idField,
|
|
458
|
+
subIdField: ?subIdField,
|
|
459
|
+
projectionType: ALL,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
testSync("takes the index value it filters on, and can be asked for the archive", () =>
|
|
463
|
+
expect(
|
|
464
|
+
GraphQL_FragmentGenerator.deriveIndexQueryField(
|
|
465
|
+
~singleFieldName="Catalog_Product",
|
|
466
|
+
~indexConfig=index(~index="categoryId"),
|
|
467
|
+
~connectionTypeName="Catalog_ProductConnection",
|
|
468
|
+
),
|
|
469
|
+
)->toEqual(
|
|
470
|
+
" Catalog_ProductByCategoryId(categoryId: String!, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): Catalog_ProductConnection!",
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
// A named index has two different strings in play — `byOwner` names the index,
|
|
475
|
+
// `ownerId` names the column. The field is named after the first and keyed on
|
|
476
|
+
// the second, and swapping them is how the local adapter came to offer an
|
|
477
|
+
// argument the row had no field for.
|
|
478
|
+
testSync("names the field after the index and keys it on the column", () =>
|
|
479
|
+
expect(
|
|
480
|
+
GraphQL_FragmentGenerator.deriveIndexQueryField(
|
|
481
|
+
~singleFieldName="Ordering_Order",
|
|
482
|
+
~indexConfig=index(~index="byOwner", ~idField="ownerId"),
|
|
483
|
+
~connectionTypeName="Ordering_OrderConnection",
|
|
484
|
+
),
|
|
485
|
+
)->toEqual(
|
|
486
|
+
" Ordering_OrderByOwner(ownerId: String!, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): Ordering_OrderConnection!",
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
// Optional, unlike the partition argument: naming the index value is the point
|
|
491
|
+
// of the door, narrowing to one sort value is a refinement.
|
|
492
|
+
testSync("offers the sort key the sort template has always read", () =>
|
|
493
|
+
expect(
|
|
494
|
+
GraphQL_FragmentGenerator.deriveIndexQueryField(
|
|
495
|
+
~singleFieldName="Ordering_Order",
|
|
496
|
+
~indexConfig=index(~index="byCustomer", ~idField="customerId", ~subIdField="placedAt"),
|
|
497
|
+
~connectionTypeName="Ordering_OrderConnection",
|
|
498
|
+
),
|
|
499
|
+
)->toEqual(
|
|
500
|
+
" Ordering_OrderByCustomer(customerId: String!, placedAt: String, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): Ordering_OrderConnection!",
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
// One derivation, called by every backend. The local adapter used to spell the
|
|
505
|
+
// name out itself and produced `XByByOwner` where this produces `XByOwner`.
|
|
506
|
+
testSync("drops a leading `by` exactly once", () =>
|
|
507
|
+
expect((
|
|
508
|
+
GraphQL_FragmentGenerator.indexQueryFieldName(~singleFieldName="X", ~index="byOwner"),
|
|
509
|
+
GraphQL_FragmentGenerator.indexQueryFieldName(~singleFieldName="X", ~index="ownerId"),
|
|
510
|
+
GraphQL_FragmentGenerator.indexQueryFieldName(~singleFieldName="X", ~index="by"),
|
|
511
|
+
))->toEqual(("XByOwner", "XByOwnerId", "XByBy"))
|
|
512
|
+
)
|
|
513
|
+
})
|