@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.
Files changed (26) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +8 -8
  3. package/src/admin/Platform_Admin_Structure.res +1 -0
  4. package/src/admin/Platform_Admin_Structure.res.mjs +1 -0
  5. package/src/admin/Platform_ComponentDefinitionsApi.res +2 -1
  6. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  7. package/src/components/Api/GraphQL_FragmentGenerator.res +122 -12
  8. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +41 -8
  9. package/src/components/Api/SuryToJsonSchema.res +8 -0
  10. package/src/components/Api/SuryToJsonSchema.res.mjs +8 -4
  11. package/src/plugin/component/Plugin_Builder.res +7 -0
  12. package/src/plugin/component/Plugin_Builder.res.mjs +3 -1
  13. package/src/plugin/component/Plugin_Structure.res +109 -22
  14. package/src/plugin/component/Plugin_Structure.res.mjs +47 -7
  15. package/tests/admin/Platform_BakedManifestTest.res +1 -0
  16. package/tests/admin/Platform_BakedManifestTest.res.mjs +1 -0
  17. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +3 -0
  18. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +6 -0
  19. package/tests/admin/Platform_PluginStructuresApiTest.res +1 -0
  20. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +2 -0
  21. package/tests/api/GraphQL_FragmentGeneratorTest.res +171 -0
  22. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +108 -0
  23. package/tests/api/SuryToJsonSchemaTest.res +30 -3
  24. package/tests/api/SuryToJsonSchemaTest.res.mjs +49 -4
  25. package/tests/plugin/PluginStructureTest.res +104 -1
  26. 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 the second is the one the form exists for. A `value` on a field
116
- // that is not the record's lifecycle would keep the read narrowing while silently
117
- // losing the command filtering that motivates it — `@transition` is written in
118
- // terms of the lifecycle field, so a retirement state anywhere else is a state no
119
- // command can name.
120
- let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): unit =>
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
- // Reported per state rather than as a set: one wrong entry among three still
150
- // narrows something, so the symptom is a subset of rows leaking rather than
151
- // all of them — which is harder to spot than the single-value case was.
152
- if Array.length(declared) > 0 {
153
- values
154
- ->Array.filter(v => !(declared->Array.includes(v)))
155
- ->Array.forEach(v =>
156
- log.warn(
157
- ~comp="Plugin_Structure",
158
- `${entityName}: @retired(${v}) names a state "${field}" does not declare — known values: ${declared->Array.join(
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
- checkRetiredValue(~entityName=R.Spec.name, stateSchema)
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
- checkRetiredValue(~entityName=SVS.Spec.name, stateSchema)
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 !== 0) {
143
- values.filter(v => !declared.includes(v)).forEach(v => log.warn("Plugin_Structure", undefined, entityName + `: @retired(` + v + `) names a state "` + field + `" does not declare — known values: ` + declared.join(", ") + `.`));
144
- return;
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
- checkRetiredValue(R.Spec.name, stateSchema);
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
- checkRetiredValue(SVS.Spec.name, stateSchema);
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,
@@ -25,6 +25,7 @@ let queryable = (~name, ~visibility=?, ~ownerField=?, ()): queryableDef => {
25
25
  ownerField,
26
26
  retiredField: None,
27
27
  retiredValues: None,
28
+ namedWhenRetired: None,
28
29
  }
29
30
 
30
31
  let command = (~name, ~references=[], ~ownerField=?, ()): commandDef => {
@@ -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",
@@ -26,6 +26,7 @@ let publicRm: queryableDef = {
26
26
  ownerField: None,
27
27
  retiredField: None,
28
28
  retiredValues: None,
29
+ namedWhenRetired: None,
29
30
  }
30
31
 
31
32
  // The component `Platform_ComponentDefinitions` drops and this query must keep.
@@ -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
+ })