@reventlessdev/reventless-core 3.0.0-alpha.237 → 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 (49) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +8 -8
  3. package/src/Message.res +1 -1
  4. package/src/adapter/Monitoring/Monitoring.res +1 -1
  5. package/src/admin/Platform_Admin_Structure.res +1 -0
  6. package/src/admin/Platform_Admin_Structure.res.mjs +1 -0
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +2 -1
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  9. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res +1 -1
  10. package/src/components/Aggregate/Aggregate_Callback.res +2 -2
  11. package/src/components/Api/ApiAllowedStatesHelpers.res +2 -3
  12. package/src/components/Api/ApiTargetStateHelpers.res +4 -3
  13. package/src/components/Api/GraphQL_FragmentGenerator.res +122 -12
  14. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +41 -8
  15. package/src/components/Api/SuryToJsonSchema.res +8 -0
  16. package/src/components/Api/SuryToJsonSchema.res.mjs +8 -4
  17. package/src/components/Dcb/Dcb_Builder.res +79 -16
  18. package/src/components/Dcb/Dcb_Builder.res.mjs +41 -5
  19. package/src/components/EventLog/EventLog.res +1 -1
  20. package/src/plugin/component/Plugin_Builder.res +7 -0
  21. package/src/plugin/component/Plugin_Builder.res.mjs +3 -1
  22. package/src/plugin/component/Plugin_Structure.res +376 -27
  23. package/src/plugin/component/Plugin_Structure.res.mjs +175 -8
  24. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
  25. package/tests/admin/Platform_BakedManifestTest.res +1 -0
  26. package/tests/admin/Platform_BakedManifestTest.res.mjs +1 -0
  27. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +3 -0
  28. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +6 -0
  29. package/tests/admin/Platform_PluginStructuresApiTest.res +1 -0
  30. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +2 -0
  31. package/tests/aggregate/AggregateCacheTest.res +1 -1
  32. package/tests/aggregate/AggregateSnapshotTest.res +1 -1
  33. package/tests/api/GraphQL_FragmentGeneratorTest.res +171 -0
  34. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +108 -0
  35. package/tests/api/SuryToJsonSchemaTest.res +30 -3
  36. package/tests/api/SuryToJsonSchemaTest.res.mjs +49 -4
  37. package/tests/commandgenerator/OwnerStampingTest.res +57 -0
  38. package/tests/commandgenerator/OwnerStampingTest.res.mjs +19 -0
  39. package/tests/message/MessageTest.res +1 -1
  40. package/tests/plugin/HeartbeatDisconnectGraceTest.res +1 -1
  41. package/tests/plugin/PluginStructureTest.res +268 -4
  42. package/tests/plugin/PluginStructureTest.res.mjs +284 -3
  43. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +31 -0
  44. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +73 -0
  45. package/tests/plugin/StateChangeSlice/PsShipOrder.res +16 -1
  46. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -2
  47. package/tests/plugin/StateViewSlice/PsShipmentsView.res +26 -0
  48. package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +101 -0
  49. package/tests/util/CsvStreamTest.res +1 -1
@@ -5,6 +5,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
6
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
7
  import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
8
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
9
  import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
9
10
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
10
11
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
@@ -101,20 +102,24 @@ function retiredValuesFromStateSchema(stateSchema) {
101
102
  return Stdlib_Option.flatMap(retiredFromStateSchema(stateSchema), r => r.values);
102
103
  }
103
104
 
105
+ function namedWhenRetiredFromStateSchema(stateSchema) {
106
+ return Stdlib_Option.mapOr(retiredFromStateSchema(stateSchema), false, r => r.namedWhenRetired);
107
+ }
108
+
104
109
  function checkRetiredValue(entityName, stateSchema) {
105
110
  let match = retiredFromStateSchema(stateSchema);
106
111
  if (match === undefined) {
107
- return;
112
+ return "NotDeclared";
108
113
  }
109
114
  let values = match.values;
110
115
  if (values === undefined) {
111
- return;
116
+ return "NotDeclared";
112
117
  }
113
118
  let field = match.field;
114
119
  let named = values.join(", ");
115
120
  let lifecycle = lifecycleFieldFromStateSchema(entityName, stateSchema);
116
121
  if (Primitive_object.notequal(lifecycle, field)) {
117
- 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 @allowedStates can name loses the command filtering the state form exists for.`);
122
+ 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 @transition can name loses the command filtering the state form exists for.`);
118
123
  }
119
124
  let declared;
120
125
  declared = stateSchema.type === "object" ? Stdlib_Option.getOr(Stdlib_Option.map(stateSchema.items.find(item => item.location === field), item => {
@@ -138,12 +143,138 @@ function checkRetiredValue(entityName, stateSchema) {
138
143
  return [];
139
144
  }
140
145
  }), []) : [];
141
- if (declared.length !== 0) {
142
- 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(", ") + `.`));
143
- 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"));
144
165
  }
145
166
  }
146
167
 
168
+ function lifecycleStatesFromStateSchema(entityName, stateSchema) {
169
+ return Stdlib_Option.flatMap(lifecycleFieldFromStateSchema(entityName, stateSchema), field => {
170
+ if (stateSchema.type === "object") {
171
+ return Stdlib_Option.map(stateSchema.items.find(item => item.location === field), item => {
172
+ let match = shapeOfItem(entityName, item);
173
+ if (typeof match !== "object") {
174
+ return [];
175
+ }
176
+ switch (match.TAG) {
177
+ case "Nullable" :
178
+ let match$1 = match._0;
179
+ if (typeof match$1 !== "object") {
180
+ return [];
181
+ } else if (match$1.TAG === "Enum") {
182
+ return match$1._1;
183
+ } else {
184
+ return [];
185
+ }
186
+ case "Enum" :
187
+ return match._1;
188
+ default:
189
+ return [];
190
+ }
191
+ });
192
+ }
193
+ });
194
+ }
195
+
196
+ function checkDeclaredTransitions(pluginName, writables, lifecycleStatesByView) {
197
+ let unvalidated = {
198
+ contents: 0
199
+ };
200
+ let failures = [];
201
+ writables.forEach(w => {
202
+ w.commands.forEach(cmd => {
203
+ let t = cmd.targetState;
204
+ let declared = Stdlib_Option.getOr(cmd.allowedStates, []).concat(t !== undefined ? [t] : []);
205
+ if (declared.length === 0) {
206
+ return;
207
+ }
208
+ let known = Stdlib_Array.reduce(w.linkedViews, [], (acc, view) => {
209
+ let states = lifecycleStatesByView[view];
210
+ if (states !== undefined) {
211
+ return acc.concat(states);
212
+ } else {
213
+ return acc;
214
+ }
215
+ });
216
+ if (known.length === 0) {
217
+ unvalidated.contents = unvalidated.contents + 1 | 0;
218
+ } else {
219
+ declared.filter(state => !known.includes(state)).forEach(state => {
220
+ failures.push(w.name + `.` + cmd.name + `: @transition names "` + state + `", which none of its ` + (`linked views declare — ` + w.linkedViews.join(", ") + ` know ` + known.join(", ") + `.`));
221
+ });
222
+ }
223
+ });
224
+ });
225
+ if (unvalidated.contents > 0) {
226
+ log.warn("Plugin_Structure", undefined, pluginName + `: ` + unvalidated.contents.toString() + ` command(s) declare a @transition but no linked view declares a lifecycle to check it against.`);
227
+ }
228
+ if (failures.length !== 0) {
229
+ return Stdlib_JsError.throwWithMessage(pluginName + `: @transition names states that do not exist.\n` + failures.join("\n"));
230
+ }
231
+ }
232
+
233
+ function lifecycleTopologyFindings(writables, lifecycleStatesByView) {
234
+ let findings = [];
235
+ Object.entries(lifecycleStatesByView).forEach(param => {
236
+ let states = param[1];
237
+ let view = param[0];
238
+ let edges = Stdlib_Array.reduce(writables, [], (acc, w) => {
239
+ if (w.linkedViews.includes(view)) {
240
+ return acc.concat(Stdlib_Array.reduce(w.commands, [], (inner, cmd) => {
241
+ let match = cmd.allowedStates;
242
+ let match$1 = cmd.targetState;
243
+ if (match !== undefined && match$1 !== undefined) {
244
+ return inner.concat(match.map(from => [
245
+ from,
246
+ match$1
247
+ ]));
248
+ } else {
249
+ return inner;
250
+ }
251
+ }));
252
+ } else {
253
+ return acc;
254
+ }
255
+ });
256
+ if (edges.length === 0) {
257
+ return;
258
+ }
259
+ let initial = states[0];
260
+ let reachable = edges.map(param => param[1]);
261
+ states.forEach(state => {
262
+ if (!reachable.includes(state) && Primitive_object.notequal(state, initial)) {
263
+ findings.push([
264
+ view,
265
+ `no command declares a transition INTO "` + state + `" — it is unreachable unless something outside this plugin's declarations puts a row there.`
266
+ ]);
267
+ return;
268
+ }
269
+ });
270
+ });
271
+ return findings;
272
+ }
273
+
274
+ function checkLifecycleTopology(pluginName, writables, lifecycleStatesByView) {
275
+ lifecycleTopologyFindings(writables, lifecycleStatesByView).forEach(param => log.warn("Plugin_Structure", undefined, pluginName + `/` + param[0] + `: ` + param[1]));
276
+ }
277
+
147
278
  function labelFieldSourceToString(s) {
148
279
  switch (s) {
149
280
  case "Annotation" :
@@ -587,13 +718,37 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
587
718
  return "Internal";
588
719
  }
589
720
  };
721
+ let lifecycleStatesByView = {};
722
+ let recordLifecycle = (entityName, stateSchema) => {
723
+ let states = lifecycleStatesFromStateSchema(entityName, stateSchema);
724
+ if (states !== undefined && states.length !== 0) {
725
+ lifecycleStatesByView[entityName] = states;
726
+ return;
727
+ }
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
+ };
590
744
  let readModelDefs = readModels.map(R => {
591
745
  let qf = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(name, R.Spec.name, undefined);
592
746
  let stateSchema = R.Spec.stateSchema;
593
747
  let label = labelFieldsFromStateSchema(R.Spec.name, stateSchema);
594
748
  let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(R.Spec.name, stateSchema);
595
749
  let consumed = qualify(name, R.consumedEventNames);
596
- checkRetiredValue(R.Spec.name, stateSchema);
750
+ recordRetired(R.Spec.name, stateSchema);
751
+ recordLifecycle(R.Spec.name, stateSchema);
597
752
  return {
598
753
  name: R.Spec.name,
599
754
  queryField: qf.listFieldName,
@@ -607,6 +762,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
607
762
  ownerField: Owner$Reventless.fieldNames(stateSchema)[0],
608
763
  retiredField: retiredFieldFromStateSchema(stateSchema),
609
764
  retiredValues: retiredValuesFromStateSchema(stateSchema),
765
+ namedWhenRetired: namedWhenRetiredFromStateSchema(stateSchema),
610
766
  visibility: visibilityTag(R.Spec.visibility),
611
767
  chapter: componentChapters[R.Spec.name],
612
768
  singleQueryField: qf.singleFieldName,
@@ -622,7 +778,8 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
622
778
  let stateSchema = SVS.Spec.stateSchema;
623
779
  let label = labelFieldsFromStateSchema(SVS.Spec.name, stateSchema);
624
780
  let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(SVS.Spec.name, stateSchema);
625
- checkRetiredValue(SVS.Spec.name, stateSchema);
781
+ recordRetired(SVS.Spec.name, stateSchema);
782
+ recordLifecycle(SVS.Spec.name, stateSchema);
626
783
  return {
627
784
  name: SVS.Spec.name,
628
785
  queryField: qf.listFieldName,
@@ -636,6 +793,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
636
793
  ownerField: Owner$Reventless.fieldNames(stateSchema)[0],
637
794
  retiredField: retiredFieldFromStateSchema(stateSchema),
638
795
  retiredValues: retiredValuesFromStateSchema(stateSchema),
796
+ namedWhenRetired: namedWhenRetiredFromStateSchema(stateSchema),
639
797
  visibility: visibilityTag(SVS.Spec.visibility),
640
798
  chapter: componentChapters[SVS.Spec.name],
641
799
  singleQueryField: qf.singleFieldName,
@@ -735,6 +893,9 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
735
893
  commandTypes: Belt_SetString.toArray(Belt_SetString.fromArray(match[2]))
736
894
  };
737
895
  });
896
+ checkDeclaredTransitions(name, stateChangeDefs.concat(aggregateDefs), lifecycleStatesByView);
897
+ checkLifecycleTopology(name, stateChangeDefs.concat(aggregateDefs), lifecycleStatesByView);
898
+ reportRetiredStates(name, retiredFailures, retiredUnchecked);
738
899
  return {
739
900
  readModels: readModelDefs,
740
901
  stateViewSlices: stateViewDefs,
@@ -760,7 +921,13 @@ export {
760
921
  retiredFromStateSchema,
761
922
  retiredFieldFromStateSchema,
762
923
  retiredValuesFromStateSchema,
924
+ namedWhenRetiredFromStateSchema,
763
925
  checkRetiredValue,
926
+ reportRetiredStates,
927
+ lifecycleStatesFromStateSchema,
928
+ checkDeclaredTransitions,
929
+ lifecycleTopologyFindings,
930
+ checkLifecycleTopology,
764
931
  labelFieldSourceToString,
765
932
  labelFieldsFromStateSchema,
766
933
  extractReferences,
@@ -1,5 +1,5 @@
1
1
  // Second mapping on the admin PluginExtensionPoint, routing UI-fragment lifecycle to the
2
- // admin UiFragmentRegistry StateChangeSlice (docs/plans/event-sourced-fragment-registries.md).
2
+ // admin UiFragmentRegistry StateChangeSlice (docs/plans/done/event-sourced-fragment-registries.md).
3
3
  //
4
4
  // The EP runtime fans every incoming command through ALL mappings and flattens the results
5
5
  // (ExtensionPoint_Callback.mapIncomingCommands). This mapping's Delegate is the slice, so its
@@ -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,
@@ -1,5 +1,5 @@
1
1
  // Tests for the Aggregate_Callback in-process replay cache (Phase 1 of
2
- // docs/plans/aggregate-snapshotting.md): a warm same-id command skips the
2
+ // docs/plans/done/aggregate-snapshotting.md): a warm same-id command skips the
3
3
  // event-log replay and decides on the cached (state, sequenceNr); the OCC
4
4
  // append fences staleness — a stale cache conflicts, invalidates, and the
5
5
  // retry replays cold.
@@ -1,4 +1,4 @@
1
- // Step 5 of docs/plans/aggregate-snapshotting.md — the Aggregate_Callback
1
+ // Step 5 of docs/plans/done/aggregate-snapshotting.md — the Aggregate_Callback
2
2
  // snapshot wiring: a snapshot-enabled behavior seeds cold replays from the
3
3
  // latest persisted snapshot (hash-gated) and writes a fresh one every
4
4
  // `interval` events, fire-and-forget. Snapshots never affect correctness — the
@@ -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
+ })