@reventlessdev/reventless-core 3.0.0-alpha.223 → 3.0.0-alpha.224

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 (28) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +7 -7
  3. package/src/admin/Platform_Admin_Structure.res +10 -0
  4. package/src/admin/Platform_Admin_Structure.res.mjs +6 -1
  5. package/src/admin/Platform_ComponentDefinitionsApi.res +11 -1
  6. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +13 -1
  7. package/src/components/Api/Api_Ids.res +63 -0
  8. package/src/components/Api/Api_Ids.res.mjs +43 -0
  9. package/src/components/Api/GraphQL_FragmentGenerator.res +121 -28
  10. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +97 -27
  11. package/src/components/Api/QueryDbListQuery.res +13 -5
  12. package/src/components/Api/QueryDbListQuery.res.mjs +5 -3
  13. package/src/components/Api/SchemaType.res +36 -2
  14. package/src/components/Api/SchemaType.res.mjs +31 -10
  15. package/src/plugin/component/Plugin_Structure.res +19 -0
  16. package/src/plugin/component/Plugin_Structure.res.mjs +11 -2
  17. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +63 -0
  18. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +117 -4
  19. package/tests/admin/Platform_PluginStructuresApiTest.res +10 -0
  20. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +23 -2
  21. package/tests/api/Api_IdsTest.res +143 -0
  22. package/tests/api/Api_IdsTest.res.mjs +97 -0
  23. package/tests/api/GraphQL_FragmentGeneratorTest.res +169 -0
  24. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +179 -0
  25. package/tests/plugin/PluginStructureTest.res +90 -0
  26. package/tests/plugin/PluginStructureTest.res.mjs +86 -8
  27. package/tests/plugin/StateViewSlice/PsCategoriesView.res +17 -0
  28. package/tests/plugin/StateViewSlice/PsCategoriesView.res.mjs +57 -0
@@ -5,6 +5,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Stdlib_Nullable from "@rescript/runtime/lib/es6/Stdlib_Nullable.js";
7
7
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
8
+ import * as Api_Ids$ReventlessCore from "./Api_Ids.res.mjs";
8
9
 
9
10
  function encodeCursor(value) {
10
11
  return btoa(value);
@@ -47,7 +48,8 @@ function buildConnection(pageItems, hasNextPage, hasPreviousPage, cursorValueOf)
47
48
  };
48
49
  }
49
50
 
50
- function run(items, argsDict, capability, labelField, decodeLocalId) {
51
+ function run(items, argsDict, capability, labelField, decodeLocalIdOpt) {
52
+ let decodeLocalId = decodeLocalIdOpt !== undefined ? decodeLocalIdOpt : Api_Ids$ReventlessCore.toLocalId;
51
53
  let filterDict = Stdlib_Option.getOr(Stdlib_Option.flatMap(argsDict["filter"], Stdlib_JSON.Decode.object), {});
52
54
  let search = Stdlib_Option.flatMap(filterDict["search"], Stdlib_JSON.Decode.string);
53
55
  let searchPrefix = Stdlib_Option.flatMap(filterDict["searchPrefix"], Stdlib_JSON.Decode.string);
@@ -83,10 +85,10 @@ function run(items, argsDict, capability, labelField, decodeLocalId) {
83
85
  let itemId = getId(item);
84
86
  let itemLocalId = decodeLocalId(itemId);
85
87
  passIds = ids.some(i => {
86
- if (i === itemId) {
88
+ if (i === itemId || Primitive_object.equal(itemLocalId, i)) {
87
89
  return true;
88
90
  } else {
89
- return Primitive_object.equal(itemLocalId, i);
91
+ return Primitive_object.equal(decodeLocalId(i), itemId);
90
92
  }
91
93
  });
92
94
  } else {
@@ -32,8 +32,34 @@ let isIdsFieldName = (name: string): bool => {
32
32
  len > 3 && String.endsWith(lower, "ids")
33
33
  }
34
34
 
35
+ /**
36
+ The GraphQL type name a semantic **composite** is emitted under.
37
+
38
+ The branded scalars are absent on purpose: they refine a `string` or a number
39
+ without changing its shape, so each is already a `String` or a `Float` in GraphQL
40
+ and has no definition that could be duplicated. Only the semantics that turn a
41
+ field into an object or an enum need a name of their own — without one, the same
42
+ type is emitted once per *field* that uses it, under the field path that reached
43
+ it (`Catalog_AddProductPriceCurrency`, `Ordering_SyncNewProductPriceCurrency`, …
44
+ six copies of ISO 4217 in one example schema).
45
+
46
+ Naming them is what makes the definitions identical across plugins, which is the
47
+ property merged-API composition needs: AppSync unions same-named types from
48
+ different source APIs, and a union of identical definitions is that definition.
49
+ The `Node` / `PageInfo` / `SortOrder` base types already rely on exactly this.
50
+ */
51
+ let semanticCompositeNames = [
52
+ (Reventless.Semantic.Id.money, "Money"),
53
+ (Reventless.Semantic.Id.dateRange, "DateRange"),
54
+ (Reventless.Semantic.Id.geoPoint, "GeoPoint"),
55
+ ]
56
+
57
+ let canonicalName = (id: string): option<string> =>
58
+ semanticCompositeNames
59
+ ->Array.find(((semanticId, _)) => semanticId == id)
60
+ ->Option.map(((_, name)) => name)
61
+
35
62
  let rec fromSury = (~parentName: string, ~fieldName: string, schema: S.t<unknown>): schemaType => {
36
- let shape = shapeOf(~parentName, ~fieldName, schema)
37
63
  // Semantics the IR already has a dedicated shape for keep it: `dateTime` and
38
64
  // `reference` are read below via `isDateTime` / `getTarget`, both of which now
39
65
  // consult the generic marker, and their `format` output is a published
@@ -42,8 +68,16 @@ let rec fromSury = (~parentName: string, ~fieldName: string, schema: S.t<unknown
42
68
  switch Reventless.Semantic.get(schema) {
43
69
  | Some({id} as sem)
44
70
  if id !== Reventless.Semantic.Id.dateTime && id !== Reventless.Semantic.Id.reference =>
71
+ // A composite is one type wherever it appears, so its shape is walked under
72
+ // its own name rather than under the field path that reached it. That
73
+ // renames what is nested inside it too — `Money`'s currency enum becomes
74
+ // `MoneyCurrency` for every field, rather than one enum per price field.
75
+ let shape = switch canonicalName(id) {
76
+ | Some(name) => shapeOf(~parentName=name, ~fieldName="", schema)
77
+ | None => shapeOf(~parentName, ~fieldName, schema)
78
+ }
45
79
  Semantic(sem, shape)
46
- | _ => shape
80
+ | _ => shapeOf(~parentName, ~fieldName, schema)
47
81
  }
48
82
  }
49
83
 
@@ -27,22 +27,41 @@ function isIdsFieldName(name) {
27
27
  }
28
28
  }
29
29
 
30
+ let semanticCompositeNames = [
31
+ [
32
+ Semantic$Reventless.Id.money,
33
+ "Money"
34
+ ],
35
+ [
36
+ Semantic$Reventless.Id.dateRange,
37
+ "DateRange"
38
+ ],
39
+ [
40
+ Semantic$Reventless.Id.geoPoint,
41
+ "GeoPoint"
42
+ ]
43
+ ];
44
+
45
+ function canonicalName(id) {
46
+ return Stdlib_Option.map(semanticCompositeNames.find(param => param[0] === id), param => param[1]);
47
+ }
48
+
30
49
  function fromSury(parentName, fieldName, schema) {
31
- let shape = shapeOf(parentName, fieldName, schema);
32
50
  let sem = Semantic$Reventless.get(schema);
33
51
  if (sem === undefined) {
34
- return shape;
52
+ return shapeOf(parentName, fieldName, schema);
35
53
  }
36
54
  let id = sem.id;
37
- if (id !== Semantic$Reventless.Id.dateTime && id !== Semantic$Reventless.Id.reference) {
38
- return {
39
- TAG: "Semantic",
40
- _0: sem,
41
- _1: shape
42
- };
43
- } else {
44
- return shape;
55
+ if (id === Semantic$Reventless.Id.dateTime || id === Semantic$Reventless.Id.reference) {
56
+ return shapeOf(parentName, fieldName, schema);
45
57
  }
58
+ let name = canonicalName(id);
59
+ let shape = name !== undefined ? shapeOf(name, "", schema) : shapeOf(parentName, fieldName, schema);
60
+ return {
61
+ TAG: "Semantic",
62
+ _0: sem,
63
+ _1: shape
64
+ };
46
65
  }
47
66
 
48
67
  function shapeOf(parentName, fieldName, schema) {
@@ -195,6 +214,8 @@ export {
195
214
  isDateTime,
196
215
  isIdFieldName,
197
216
  isIdsFieldName,
217
+ semanticCompositeNames,
218
+ canonicalName,
198
219
  fromSury,
199
220
  shapeOf,
200
221
  optionalFieldNames,
@@ -650,6 +650,12 @@ let make = (
650
650
  let qf = Api_Naming.queryFieldNamesForReadModel(~plugin=name, ~name=R.Spec.name)
651
651
  let stateSchema = R.Spec.stateSchema->S.castToUnknown
652
652
  let label = labelFieldsFromStateSchema(~entityName=R.Spec.name, stateSchema)
653
+ // The same call the capability deriver makes, so the published key and the
654
+ // key the generated filter/order-by is built from cannot disagree.
655
+ let keyField = GraphQL_FragmentGenerator.resolveKeyField(
656
+ ~entityName=R.Spec.name,
657
+ stateSchema,
658
+ )
653
659
  // The events this read model projects from, qualified to the plugin's event ids so
654
660
  // they match the producers' nodes in the graph. Was empty — which dropped projection
655
661
  // edges for any event reaching the read model via a DCB-log-sourced mapping (a classic
@@ -667,6 +673,12 @@ let make = (
667
673
  statusField: statusFieldFromStateSchema(~entityName=R.Spec.name, stateSchema),
668
674
  visibility: visibilityTag(R.Spec.visibility),
669
675
  chapter: chapterOf(R.Spec.name),
676
+ // Taken from the `qf` record, never re-derived: `Api_Naming` is the only
677
+ // place that decides how `Products`/`Categories` singularise, and the
678
+ // point of publishing the name is that a consumer stops guessing it.
679
+ singleQueryField: Some(qf.singleFieldName),
680
+ idField: keyField->Option.map(((f, _)) => f),
681
+ idFieldSource: keyField->Option.map(((_, rung)) => rung),
670
682
  }: Reventless.Plugin.queryableDef)
671
683
  })
672
684
 
@@ -676,6 +688,10 @@ let make = (
676
688
  let (_, consumed) = svsConsumed->Array.getUnsafe(i)
677
689
  let stateSchema = SVS.Spec.stateSchema->S.castToUnknown
678
690
  let label = labelFieldsFromStateSchema(~entityName=SVS.Spec.name, stateSchema)
691
+ let keyField = GraphQL_FragmentGenerator.resolveKeyField(
692
+ ~entityName=SVS.Spec.name,
693
+ stateSchema,
694
+ )
679
695
  ({
680
696
  Reventless.Plugin.name: SVS.Spec.name,
681
697
  queryField: qf.listFieldName,
@@ -688,6 +704,9 @@ let make = (
688
704
  statusField: statusFieldFromStateSchema(~entityName=SVS.Spec.name, stateSchema),
689
705
  visibility: visibilityTag(SVS.Spec.visibility),
690
706
  chapter: chapterOf(SVS.Spec.name),
707
+ singleQueryField: Some(qf.singleFieldName),
708
+ idField: keyField->Option.map(((f, _)) => f),
709
+ idFieldSource: keyField->Option.map(((_, rung)) => rung),
691
710
  }: Reventless.Plugin.queryableDef)
692
711
  })
693
712
 
@@ -19,6 +19,7 @@ import * as SuryToJsonSchema$ReventlessCore from "../../components/Api/SuryToJso
19
19
  import * as Capability_Inference$ReventlessCore from "./Capability_Inference.res.mjs";
20
20
  import * as ApiTargetStateHelpers$ReventlessCore from "../../components/Api/ApiTargetStateHelpers.res.mjs";
21
21
  import * as ApiAllowedStatesHelpers$ReventlessCore from "../../components/Api/ApiAllowedStatesHelpers.res.mjs";
22
+ import * as GraphQL_FragmentGenerator$ReventlessCore from "../../components/Api/GraphQL_FragmentGenerator.res.mjs";
22
23
 
23
24
  let log = Logger$ReventlessCore.fromEnv();
24
25
 
@@ -500,6 +501,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
500
501
  let qf = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(name, R.Spec.name, undefined);
501
502
  let stateSchema = R.Spec.stateSchema;
502
503
  let label = labelFieldsFromStateSchema(R.Spec.name, stateSchema);
504
+ let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(R.Spec.name, stateSchema);
503
505
  let consumed = qualify(name, R.consumedEventNames);
504
506
  return {
505
507
  name: R.Spec.name,
@@ -512,7 +514,10 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
512
514
  labelFieldSource: labelFieldSourceToString(label.source),
513
515
  statusField: statusFieldFromStateSchema(R.Spec.name, stateSchema),
514
516
  visibility: visibilityTag(R.Spec.visibility),
515
- chapter: componentChapters[R.Spec.name]
517
+ chapter: componentChapters[R.Spec.name],
518
+ singleQueryField: qf.singleFieldName,
519
+ idField: Stdlib_Option.map(keyField, param => param[0]),
520
+ idFieldSource: Stdlib_Option.map(keyField, param => param[1])
516
521
  };
517
522
  });
518
523
  let stateViewDefs = stateViewSlices.map((SVS, i) => {
@@ -521,6 +526,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
521
526
  let consumed = match[1];
522
527
  let stateSchema = SVS.Spec.stateSchema;
523
528
  let label = labelFieldsFromStateSchema(SVS.Spec.name, stateSchema);
529
+ let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(SVS.Spec.name, stateSchema);
524
530
  return {
525
531
  name: SVS.Spec.name,
526
532
  queryField: qf.listFieldName,
@@ -532,7 +538,10 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
532
538
  labelFieldSource: labelFieldSourceToString(label.source),
533
539
  statusField: statusFieldFromStateSchema(SVS.Spec.name, stateSchema),
534
540
  visibility: visibilityTag(SVS.Spec.visibility),
535
- chapter: componentChapters[SVS.Spec.name]
541
+ chapter: componentChapters[SVS.Spec.name],
542
+ singleQueryField: qf.singleFieldName,
543
+ idField: Stdlib_Option.map(keyField, param => param[0]),
544
+ idFieldSource: Stdlib_Option.map(keyField, param => param[1])
536
545
  };
537
546
  });
538
547
  let stateChangeDefs = stateChangeSlices.map((SCS, i) => {
@@ -31,6 +31,9 @@ let qbl: queryableDef = {
31
31
  statusField: None,
32
32
  visibility: None,
33
33
  chapter: None,
34
+ singleQueryField: Some("Catalog_Product"),
35
+ idField: Some("productId"),
36
+ idFieldSource: Some("convention"),
34
37
  }
35
38
 
36
39
  let wbl: writableDef = {
@@ -252,6 +255,9 @@ describe("visibility filtering (deployed AutoUI hides Internal)", () => {
252
255
  statusField: None,
253
256
  visibility: Some("Internal"),
254
257
  chapter: None,
258
+ singleQueryField: Some("Ordering_AvailableProduct"),
259
+ idField: Some("productId"),
260
+ idFieldSource: Some("sole"),
255
261
  }
256
262
  // A distinct name per source array: the complement has to be fed by both, and
257
263
  // reusing one def would hide a version that only walks `readModels`.
@@ -342,6 +348,9 @@ describe("allowedStates + statusField populated", () => {
342
348
  statusField: Some("status"),
343
349
  visibility: None,
344
350
  chapter: None,
351
+ singleQueryField: Some("Platform_Plugin"),
352
+ idField: None,
353
+ idFieldSource: None,
345
354
  }
346
355
 
347
356
  let wblWithStates: writableDef = {
@@ -395,3 +404,57 @@ describe("allowedStates + statusField populated", () => {
395
404
  expect(json->String.includes("\"errors\":null"))->toEqual(false)
396
405
  })
397
406
  })
407
+
408
+ // The name exists so a consumer can stop re-implementing `Api_Naming.singularize`;
409
+ // it is only worth publishing if it reaches the caller intact, so assert the wire
410
+ // value and the SDL that types it, not just the record field.
411
+ describe("singleQueryField reaches the caller", () => {
412
+ testSync("encodes the stated singular beside the list field", () => {
413
+ expect(json->String.includes("\"queryField\":\"Catalog_Products\""))->toEqual(true)
414
+ expect(json->String.includes("\"singleQueryField\":\"Catalog_Product\""))->toEqual(true)
415
+ })
416
+
417
+ // Nullable on purpose: a structure persisted before the field existed, or a
418
+ // hand-rolled def that declines to state it, must decode rather than fail.
419
+ testSync("encodes an unstated singular as null", () => {
420
+ let unstated =
421
+ {...structure, readModels: [{...qbl, singleQueryField: None}]}
422
+ ->Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId="Catalog", _)
423
+ ->JSON.stringify
424
+ expect(unstated->String.includes("\"singleQueryField\":null"))->toEqual(true)
425
+ })
426
+
427
+ testSync("types it as a nullable String on the read-side SDL", () => {
428
+ let sdl = Platform_ComponentDefinitionsApi.sdlTypes->Array.join("\n")
429
+ expect(sdl->String.includes("singleQueryField: String\n"))->toEqual(true)
430
+ expect(sdl->String.includes("singleQueryField: String!"))->toEqual(false)
431
+ })
432
+ })
433
+
434
+ // The key and its rung travel together: a consumer ranking its own guess against
435
+ // this one needs both, so a version that carried the field and dropped the
436
+ // provenance would be worse than useless.
437
+ describe("idField / idFieldSource reach the caller", () => {
438
+ testSync("encodes the key field and the rung that produced it", () => {
439
+ expect(json->String.includes("\"idField\":\"productId\""))->toEqual(true)
440
+ expect(json->String.includes("\"idFieldSource\":\"convention\""))->toEqual(true)
441
+ })
442
+
443
+ // A state whose key cannot be resolved (several `*Id` fields and no name match,
444
+ // or none at all) has nothing to report — null, not an invented field name.
445
+ testSync("encodes an unresolved key as null on both fields", () => {
446
+ let unresolved =
447
+ {...structure, readModels: [{...qbl, idField: None, idFieldSource: None}]}
448
+ ->Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId="Catalog", _)
449
+ ->JSON.stringify
450
+ expect(unresolved->String.includes("\"idField\":null"))->toEqual(true)
451
+ expect(unresolved->String.includes("\"idFieldSource\":null"))->toEqual(true)
452
+ })
453
+
454
+ testSync("types both as nullable Strings on the read-side SDL", () => {
455
+ let sdl = Platform_ComponentDefinitionsApi.sdlTypes->Array.join("\n")
456
+ expect(sdl->String.includes("idField: String\n"))->toEqual(true)
457
+ expect(sdl->String.includes("idFieldSource: String\n"))->toEqual(true)
458
+ expect(sdl->String.includes("idField: String!"))->toEqual(false)
459
+ })
460
+ })
@@ -34,6 +34,12 @@ let qbl_searchableFields = ["displayName"];
34
34
 
35
35
  let qbl_labelFieldSource = "annotation";
36
36
 
37
+ let qbl_singleQueryField = "Catalog_Product";
38
+
39
+ let qbl_idField = "productId";
40
+
41
+ let qbl_idFieldSource = "convention";
42
+
37
43
  let qbl = {
38
44
  name: "Products",
39
45
  queryField: "Catalog_Products",
@@ -45,7 +51,10 @@ let qbl = {
45
51
  labelFieldSource: qbl_labelFieldSource,
46
52
  statusField: undefined,
47
53
  visibility: undefined,
48
- chapter: undefined
54
+ chapter: undefined,
55
+ singleQueryField: qbl_singleQueryField,
56
+ idField: qbl_idField,
57
+ idFieldSource: qbl_idFieldSource
49
58
  };
50
59
 
51
60
  let wbl_commands = [cmd];
@@ -257,6 +266,9 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
257
266
  let internalQbl_searchableFields = ["name"];
258
267
  let internalQbl_labelFieldSource = "convention";
259
268
  let internalQbl_visibility = "Internal";
269
+ let internalQbl_singleQueryField = "Ordering_AvailableProduct";
270
+ let internalQbl_idField = "productId";
271
+ let internalQbl_idFieldSource = "sole";
260
272
  let internalQbl = {
261
273
  name: "AvailableProducts",
262
274
  queryField: "Ordering_AvailableProducts",
@@ -268,13 +280,19 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
268
280
  labelFieldSource: internalQbl_labelFieldSource,
269
281
  statusField: undefined,
270
282
  visibility: internalQbl_visibility,
271
- chapter: undefined
283
+ chapter: undefined,
284
+ singleQueryField: internalQbl_singleQueryField,
285
+ idField: internalQbl_idField,
286
+ idFieldSource: internalQbl_idFieldSource
272
287
  };
273
288
  let internalSlice_consumedEventTypes = internalQbl_consumedEventTypes;
274
289
  let internalSlice_linkedWriteSide = internalQbl_linkedWriteSide;
275
290
  let internalSlice_searchableFields = internalQbl_searchableFields;
276
291
  let internalSlice_labelFieldSource = "convention";
277
292
  let internalSlice_visibility = "Internal";
293
+ let internalSlice_singleQueryField = "Ordering_AvailableProduct";
294
+ let internalSlice_idField = "productId";
295
+ let internalSlice_idFieldSource = "sole";
278
296
  let internalSlice = {
279
297
  name: "PendingShipments",
280
298
  queryField: "Ordering_PendingShipments",
@@ -286,7 +304,10 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
286
304
  labelFieldSource: internalSlice_labelFieldSource,
287
305
  statusField: undefined,
288
306
  visibility: internalSlice_visibility,
289
- chapter: undefined
307
+ chapter: undefined,
308
+ singleQueryField: internalSlice_singleQueryField,
309
+ idField: internalSlice_idField,
310
+ idFieldSource: internalSlice_idFieldSource
290
311
  };
291
312
  let mixed_readModels = [
292
313
  qbl,
@@ -371,6 +392,7 @@ globalThis.describe("allowedStates + statusField populated", () => {
371
392
  let qblWithStatus_searchableFields = ["name"];
372
393
  let qblWithStatus_labelFieldSource = "convention";
373
394
  let qblWithStatus_statusField = "status";
395
+ let qblWithStatus_singleQueryField = "Platform_Plugin";
374
396
  let qblWithStatus = {
375
397
  name: "Plugin",
376
398
  queryField: "Platform_Plugins",
@@ -382,7 +404,10 @@ globalThis.describe("allowedStates + statusField populated", () => {
382
404
  labelFieldSource: qblWithStatus_labelFieldSource,
383
405
  statusField: qblWithStatus_statusField,
384
406
  visibility: undefined,
385
- chapter: undefined
407
+ chapter: undefined,
408
+ singleQueryField: qblWithStatus_singleQueryField,
409
+ idField: undefined,
410
+ idFieldSource: undefined
386
411
  };
387
412
  let wblWithStates_commands = [cmdWithStates];
388
413
  let wblWithStates_producedEventTypes = [];
@@ -438,6 +463,94 @@ globalThis.describe("allowedStates + statusField populated", () => {
438
463
  });
439
464
  });
440
465
 
466
+ globalThis.describe("singleQueryField reaches the caller", () => {
467
+ globalThis.test("encodes the stated singular beside the list field", () => {
468
+ globalThis.expect(json.includes("\"queryField\":\"Catalog_Products\"")).toEqual(true);
469
+ globalThis.expect(json.includes("\"singleQueryField\":\"Catalog_Product\"")).toEqual(true);
470
+ });
471
+ globalThis.test("encodes an unstated singular as null", () => {
472
+ let unstated = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", {
473
+ readModels: [{
474
+ name: "Products",
475
+ queryField: "Catalog_Products",
476
+ schema: "{}",
477
+ consumedEventTypes: qbl_consumedEventTypes,
478
+ linkedWriteSide: qbl_linkedWriteSide,
479
+ labelField: "displayName",
480
+ searchableFields: qbl_searchableFields,
481
+ labelFieldSource: "annotation",
482
+ statusField: undefined,
483
+ visibility: undefined,
484
+ chapter: undefined,
485
+ singleQueryField: undefined,
486
+ idField: "productId",
487
+ idFieldSource: "convention"
488
+ }],
489
+ stateViewSlices: structure_stateViewSlices,
490
+ stateChangeSlices: structure_stateChangeSlices,
491
+ aggregates: structure_aggregates,
492
+ automationSlices: structure_automationSlices,
493
+ outboundTranslationSlices: structure_outboundTranslationSlices,
494
+ inboundTranslationSlices: structure_inboundTranslationSlices,
495
+ extensions: structure_extensions,
496
+ extensionPoints: undefined,
497
+ requiredStores: undefined,
498
+ requiredStoreDeclarations: undefined
499
+ }));
500
+ globalThis.expect(unstated.includes("\"singleQueryField\":null")).toEqual(true);
501
+ });
502
+ globalThis.test("types it as a nullable String on the read-side SDL", () => {
503
+ let sdl = Platform_ComponentDefinitionsApi$ReventlessCore.sdlTypes.join("\n");
504
+ globalThis.expect(sdl.includes("singleQueryField: String\n")).toEqual(true);
505
+ globalThis.expect(sdl.includes("singleQueryField: String!")).toEqual(false);
506
+ });
507
+ });
508
+
509
+ globalThis.describe("idField / idFieldSource reach the caller", () => {
510
+ globalThis.test("encodes the key field and the rung that produced it", () => {
511
+ globalThis.expect(json.includes("\"idField\":\"productId\"")).toEqual(true);
512
+ globalThis.expect(json.includes("\"idFieldSource\":\"convention\"")).toEqual(true);
513
+ });
514
+ globalThis.test("encodes an unresolved key as null on both fields", () => {
515
+ let unresolved = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", {
516
+ readModels: [{
517
+ name: "Products",
518
+ queryField: "Catalog_Products",
519
+ schema: "{}",
520
+ consumedEventTypes: qbl_consumedEventTypes,
521
+ linkedWriteSide: qbl_linkedWriteSide,
522
+ labelField: "displayName",
523
+ searchableFields: qbl_searchableFields,
524
+ labelFieldSource: "annotation",
525
+ statusField: undefined,
526
+ visibility: undefined,
527
+ chapter: undefined,
528
+ singleQueryField: "Catalog_Product",
529
+ idField: undefined,
530
+ idFieldSource: undefined
531
+ }],
532
+ stateViewSlices: structure_stateViewSlices,
533
+ stateChangeSlices: structure_stateChangeSlices,
534
+ aggregates: structure_aggregates,
535
+ automationSlices: structure_automationSlices,
536
+ outboundTranslationSlices: structure_outboundTranslationSlices,
537
+ inboundTranslationSlices: structure_inboundTranslationSlices,
538
+ extensions: structure_extensions,
539
+ extensionPoints: undefined,
540
+ requiredStores: undefined,
541
+ requiredStoreDeclarations: undefined
542
+ }));
543
+ globalThis.expect(unresolved.includes("\"idField\":null")).toEqual(true);
544
+ globalThis.expect(unresolved.includes("\"idFieldSource\":null")).toEqual(true);
545
+ });
546
+ globalThis.test("types both as nullable Strings on the read-side SDL", () => {
547
+ let sdl = Platform_ComponentDefinitionsApi$ReventlessCore.sdlTypes.join("\n");
548
+ globalThis.expect(sdl.includes("idField: String\n")).toEqual(true);
549
+ globalThis.expect(sdl.includes("idFieldSource: String\n")).toEqual(true);
550
+ globalThis.expect(sdl.includes("idField: String!")).toEqual(false);
551
+ });
552
+ });
553
+
441
554
  export {
442
555
  cmd,
443
556
  qbl,
@@ -19,6 +19,9 @@ let publicRm: queryableDef = {
19
19
  statusField: None,
20
20
  visibility: None,
21
21
  chapter: Some("Inventory"),
22
+ singleQueryField: Some("Catalog_Product"),
23
+ idField: Some("productId"),
24
+ idFieldSource: Some("convention"),
22
25
  }
23
26
 
24
27
  // The component `Platform_ComponentDefinitions` drops and this query must keep.
@@ -113,6 +116,13 @@ describe("Platform_PluginStructures entry", () => {
113
116
  )->toEqual(true)
114
117
  )
115
118
 
119
+ // Both read-side queries share `encodeQueryableDef`, so this asserts the singular
120
+ // name is not a ComponentDefinitions-only field: a developer tool building a detail
121
+ // query reads it from here.
122
+ testSync("encodes the singular query field beside the list one", () =>
123
+ expect(json->String.includes("\"singleQueryField\":\"Catalog_Product\""))->toEqual(true)
124
+ )
125
+
116
126
  testSync("encodes required stores with their provenance", () =>
117
127
  expect(json->String.includes("\"requiredStores\":[\"Catalog.images\"]"))->toEqual(true)
118
128
  )
@@ -16,6 +16,12 @@ let publicRm_labelFieldSource = "annotation";
16
16
 
17
17
  let publicRm_chapter = "Inventory";
18
18
 
19
+ let publicRm_singleQueryField = "Catalog_Product";
20
+
21
+ let publicRm_idField = "productId";
22
+
23
+ let publicRm_idFieldSource = "convention";
24
+
19
25
  let publicRm = {
20
26
  name: "Products",
21
27
  queryField: "Catalog_Products",
@@ -27,7 +33,10 @@ let publicRm = {
27
33
  labelFieldSource: publicRm_labelFieldSource,
28
34
  statusField: undefined,
29
35
  visibility: undefined,
30
- chapter: publicRm_chapter
36
+ chapter: publicRm_chapter,
37
+ singleQueryField: publicRm_singleQueryField,
38
+ idField: publicRm_idField,
39
+ idFieldSource: publicRm_idFieldSource
31
40
  };
32
41
 
33
42
  let internalRm_consumedEventTypes = publicRm_consumedEventTypes;
@@ -42,6 +51,12 @@ let internalRm_visibility = "Internal";
42
51
 
43
52
  let internalRm_chapter = "Inventory";
44
53
 
54
+ let internalRm_singleQueryField = "Catalog_Product";
55
+
56
+ let internalRm_idField = "productId";
57
+
58
+ let internalRm_idFieldSource = "convention";
59
+
45
60
  let internalRm = {
46
61
  name: "ProductAudit",
47
62
  queryField: "Catalog_ProductAudit",
@@ -53,7 +68,10 @@ let internalRm = {
53
68
  labelFieldSource: internalRm_labelFieldSource,
54
69
  statusField: undefined,
55
70
  visibility: internalRm_visibility,
56
- chapter: internalRm_chapter
71
+ chapter: internalRm_chapter,
72
+ singleQueryField: internalRm_singleQueryField,
73
+ idField: internalRm_idField,
74
+ idFieldSource: internalRm_idFieldSource
57
75
  };
58
76
 
59
77
  let structure_readModels = [
@@ -150,6 +168,9 @@ globalThis.describe("Platform_PluginStructures entry", () => {
150
168
  globalThis.test("encodes extension points with their source event types", () => {
151
169
  globalThis.expect(json.includes("\"extensionPoints\":[{\"name\":\"Catalog.Products\",\"delegateNames\":[\"onAdded\"],\"sourceEventTypes\":[\"Catalog.ProductAdded\"],\"commandTypes\":null}]")).toEqual(true);
152
170
  });
171
+ globalThis.test("encodes the singular query field beside the list one", () => {
172
+ globalThis.expect(json.includes("\"singleQueryField\":\"Catalog_Product\"")).toEqual(true);
173
+ });
153
174
  globalThis.test("encodes required stores with their provenance", () => {
154
175
  globalThis.expect(json.includes("\"requiredStores\":[\"Catalog.images\"]")).toEqual(true);
155
176
  });