@reventlessdev/reventless-core 3.0.0-alpha.222 → 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 (30) hide show
  1. package/CHANGELOG.md +32 -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 +33 -2
  6. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +18 -2
  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 +41 -24
  16. package/src/plugin/component/Plugin_Structure.res.mjs +25 -18
  17. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +118 -7
  18. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +168 -7
  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 +159 -0
  26. package/tests/plugin/PluginStructureTest.res.mjs +160 -6
  27. package/tests/plugin/StateChangeSlice/PsReserveStock.res +44 -0
  28. package/tests/plugin/StateChangeSlice/PsReserveStock.res.mjs +67 -0
  29. package/tests/plugin/StateViewSlice/PsCategoriesView.res +17 -0
  30. package/tests/plugin/StateViewSlice/PsCategoriesView.res.mjs +57 -0
@@ -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 = {
@@ -106,6 +109,7 @@ describe("encodePluginStructureEntry", () => {
106
109
  "automationSlices",
107
110
  "extensions",
108
111
  "inboundTranslationSlices",
112
+ "internalQueryables",
109
113
  "outboundTranslationSlices",
110
114
  "pluginId",
111
115
  "readModels",
@@ -251,22 +255,72 @@ describe("visibility filtering (deployed AutoUI hides Internal)", () => {
251
255
  statusField: None,
252
256
  visibility: Some("Internal"),
253
257
  chapter: None,
258
+ singleQueryField: Some("Ordering_AvailableProduct"),
259
+ idField: Some("productId"),
260
+ idFieldSource: Some("sole"),
261
+ }
262
+ // A distinct name per source array: the complement has to be fed by both, and
263
+ // reusing one def would hide a version that only walks `readModels`.
264
+ let internalSlice: queryableDef = {
265
+ ...internalQbl,
266
+ name: "PendingShipments",
267
+ queryField: "Ordering_PendingShipments",
254
268
  }
255
269
  let mixed: pluginStructure = {
256
270
  ...structure,
257
271
  readModels: [qbl, internalQbl],
258
- stateViewSlices: [internalQbl],
272
+ stateViewSlices: [internalSlice],
259
273
  }
260
- let mixedJson =
261
- Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId="Ordering", mixed)->JSON.stringify
274
+ let mixedEntry =
275
+ Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId="Ordering", mixed)
276
+
277
+ // Names under one key of the encoded entry. `String.includes` over the whole
278
+ // JSON can no longer answer this: an Internal view is legitimately present
279
+ // under `internalQueryables`, so a whole-document search cannot tell "kept out
280
+ // of the surfaces" from "dropped entirely" — which is the distinction the
281
+ // feature is.
282
+ let namesAt = (entry: JSON.t, key: string): array<string> =>
283
+ entry
284
+ ->JSON.Decode.object
285
+ ->Option.flatMap(o => o->Dict.get(key))
286
+ ->Option.flatMap(JSON.Decode.array)
287
+ ->Option.getOr([])
288
+ ->Array.filterMap(c =>
289
+ c->JSON.Decode.object->Option.flatMap(o => o->Dict.get("name"))->Option.flatMap(JSON.Decode.string)
290
+ )
291
+
292
+ testSync("excludes an Internal queryableDef from the encoded read-side", () => {
293
+ expect(mixedEntry->namesAt("readModels"))->toEqual(["Products"])
294
+ expect(mixedEntry->namesAt("stateViewSlices"))->toEqual([])
295
+ })
262
296
 
263
- testSync("excludes an Internal queryableDef from the encoded read-side", () =>
264
- expect(mixedJson->String.includes("AvailableProducts"))->toEqual(false)
297
+ testSync("keeps a Public queryableDef", () =>
298
+ expect(mixedEntry->namesAt("readModels")->Array.includes("Products"))->toEqual(true)
265
299
  )
266
300
 
267
- testSync("keeps a Public queryableDef", () =>
268
- expect(mixedJson->String.includes("\"name\":\"Products\""))->toEqual(true)
301
+ // Both directions, deliberately: a change that also leaked Internal views back
302
+ // into the surface lists would pass a presence-only assertion.
303
+ testSync("carries what the filter removed on internalQueryables", () =>
304
+ expect(mixedEntry->namesAt("internalQueryables"))->toEqual([
305
+ "AvailableProducts",
306
+ "PendingShipments",
307
+ ])
269
308
  )
309
+
310
+ testSync("encodes internalQueryables as [] for an all-Public entry, not a missing key", () => {
311
+ let entry = Platform_ComponentDefinitionsApi.encodePluginStructureEntry(
312
+ ~pluginId="Ordering",
313
+ {...structure, readModels: [qbl], stateViewSlices: []},
314
+ )
315
+ expect(
316
+ entry->JSON.Decode.object->Option.flatMap(o => o->Dict.get("internalQueryables")),
317
+ )->toEqual(Some(JSON.Encode.array([])))
318
+ })
319
+
320
+ testSync("declares internalQueryables on the entry type in the SDL", () => {
321
+ let sdl = Platform_ComponentDefinitionsApi.sdlTypes->Array.join("\n")
322
+ expect(sdl->String.includes("internalQueryables: [Platform_ReadSideDef!]!"))->toEqual(true)
323
+ })
270
324
  })
271
325
 
272
326
  describe("allowedStates + statusField populated", () => {
@@ -294,6 +348,9 @@ describe("allowedStates + statusField populated", () => {
294
348
  statusField: Some("status"),
295
349
  visibility: None,
296
350
  chapter: None,
351
+ singleQueryField: Some("Platform_Plugin"),
352
+ idField: None,
353
+ idFieldSource: None,
297
354
  }
298
355
 
299
356
  let wblWithStates: writableDef = {
@@ -347,3 +404,57 @@ describe("allowedStates + statusField populated", () => {
347
404
  expect(json->String.includes("\"errors\":null"))->toEqual(false)
348
405
  })
349
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
+ })
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
6
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
6
7
  import * as Platform_ComponentDefinitionsApi$ReventlessCore from "../../src/admin/Platform_ComponentDefinitionsApi.res.mjs";
@@ -33,6 +34,12 @@ let qbl_searchableFields = ["displayName"];
33
34
 
34
35
  let qbl_labelFieldSource = "annotation";
35
36
 
37
+ let qbl_singleQueryField = "Catalog_Product";
38
+
39
+ let qbl_idField = "productId";
40
+
41
+ let qbl_idFieldSource = "convention";
42
+
36
43
  let qbl = {
37
44
  name: "Products",
38
45
  queryField: "Catalog_Products",
@@ -44,7 +51,10 @@ let qbl = {
44
51
  labelFieldSource: qbl_labelFieldSource,
45
52
  statusField: undefined,
46
53
  visibility: undefined,
47
- chapter: undefined
54
+ chapter: undefined,
55
+ singleQueryField: qbl_singleQueryField,
56
+ idField: qbl_idField,
57
+ idFieldSource: qbl_idFieldSource
48
58
  };
49
59
 
50
60
  let wbl_commands = [cmd];
@@ -146,6 +156,7 @@ globalThis.describe("encodePluginStructureEntry", () => {
146
156
  "automationSlices",
147
157
  "extensions",
148
158
  "inboundTranslationSlices",
159
+ "internalQueryables",
149
160
  "outboundTranslationSlices",
150
161
  "pluginId",
151
162
  "readModels",
@@ -255,6 +266,9 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
255
266
  let internalQbl_searchableFields = ["name"];
256
267
  let internalQbl_labelFieldSource = "convention";
257
268
  let internalQbl_visibility = "Internal";
269
+ let internalQbl_singleQueryField = "Ordering_AvailableProduct";
270
+ let internalQbl_idField = "productId";
271
+ let internalQbl_idFieldSource = "sole";
258
272
  let internalQbl = {
259
273
  name: "AvailableProducts",
260
274
  queryField: "Ordering_AvailableProducts",
@@ -266,13 +280,40 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
266
280
  labelFieldSource: internalQbl_labelFieldSource,
267
281
  statusField: undefined,
268
282
  visibility: internalQbl_visibility,
269
- chapter: undefined
283
+ chapter: undefined,
284
+ singleQueryField: internalQbl_singleQueryField,
285
+ idField: internalQbl_idField,
286
+ idFieldSource: internalQbl_idFieldSource
287
+ };
288
+ let internalSlice_consumedEventTypes = internalQbl_consumedEventTypes;
289
+ let internalSlice_linkedWriteSide = internalQbl_linkedWriteSide;
290
+ let internalSlice_searchableFields = internalQbl_searchableFields;
291
+ let internalSlice_labelFieldSource = "convention";
292
+ let internalSlice_visibility = "Internal";
293
+ let internalSlice_singleQueryField = "Ordering_AvailableProduct";
294
+ let internalSlice_idField = "productId";
295
+ let internalSlice_idFieldSource = "sole";
296
+ let internalSlice = {
297
+ name: "PendingShipments",
298
+ queryField: "Ordering_PendingShipments",
299
+ schema: "{}",
300
+ consumedEventTypes: internalSlice_consumedEventTypes,
301
+ linkedWriteSide: internalSlice_linkedWriteSide,
302
+ labelField: "name",
303
+ searchableFields: internalSlice_searchableFields,
304
+ labelFieldSource: internalSlice_labelFieldSource,
305
+ statusField: undefined,
306
+ visibility: internalSlice_visibility,
307
+ chapter: undefined,
308
+ singleQueryField: internalSlice_singleQueryField,
309
+ idField: internalSlice_idField,
310
+ idFieldSource: internalSlice_idFieldSource
270
311
  };
271
312
  let mixed_readModels = [
272
313
  qbl,
273
314
  internalQbl
274
315
  ];
275
- let mixed_stateViewSlices = [internalQbl];
316
+ let mixed_stateViewSlices = [internalSlice];
276
317
  let mixed_stateChangeSlices = structure_stateChangeSlices;
277
318
  let mixed_aggregates = structure_aggregates;
278
319
  let mixed_automationSlices = structure_automationSlices;
@@ -292,12 +333,40 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
292
333
  requiredStores: undefined,
293
334
  requiredStoreDeclarations: undefined
294
335
  };
295
- let mixedJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", mixed));
336
+ let mixedEntry = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", mixed);
337
+ let namesAt = (entry, key) => Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(entry), o => o[key]), Stdlib_JSON.Decode.array), []), c => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(c), o => o["name"]), Stdlib_JSON.Decode.string));
296
338
  globalThis.test("excludes an Internal queryableDef from the encoded read-side", () => {
297
- globalThis.expect(mixedJson.includes("AvailableProducts")).toEqual(false);
339
+ globalThis.expect(namesAt(mixedEntry, "readModels")).toEqual(["Products"]);
340
+ globalThis.expect(namesAt(mixedEntry, "stateViewSlices")).toEqual([]);
298
341
  });
299
342
  globalThis.test("keeps a Public queryableDef", () => {
300
- globalThis.expect(mixedJson.includes("\"name\":\"Products\"")).toEqual(true);
343
+ globalThis.expect(namesAt(mixedEntry, "readModels").includes("Products")).toEqual(true);
344
+ });
345
+ globalThis.test("carries what the filter removed on internalQueryables", () => {
346
+ globalThis.expect(namesAt(mixedEntry, "internalQueryables")).toEqual([
347
+ "AvailableProducts",
348
+ "PendingShipments"
349
+ ]);
350
+ });
351
+ globalThis.test("encodes internalQueryables as [] for an all-Public entry, not a missing key", () => {
352
+ let entry = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", {
353
+ readModels: [qbl],
354
+ stateViewSlices: [],
355
+ stateChangeSlices: structure_stateChangeSlices,
356
+ aggregates: structure_aggregates,
357
+ automationSlices: structure_automationSlices,
358
+ outboundTranslationSlices: structure_outboundTranslationSlices,
359
+ inboundTranslationSlices: structure_inboundTranslationSlices,
360
+ extensions: structure_extensions,
361
+ extensionPoints: undefined,
362
+ requiredStores: undefined,
363
+ requiredStoreDeclarations: undefined
364
+ });
365
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(entry), o => o["internalQueryables"])).toEqual([]);
366
+ });
367
+ globalThis.test("declares internalQueryables on the entry type in the SDL", () => {
368
+ let sdl = Platform_ComponentDefinitionsApi$ReventlessCore.sdlTypes.join("\n");
369
+ globalThis.expect(sdl.includes("internalQueryables: [Platform_ReadSideDef!]!")).toEqual(true);
301
370
  });
302
371
  });
303
372
 
@@ -323,6 +392,7 @@ globalThis.describe("allowedStates + statusField populated", () => {
323
392
  let qblWithStatus_searchableFields = ["name"];
324
393
  let qblWithStatus_labelFieldSource = "convention";
325
394
  let qblWithStatus_statusField = "status";
395
+ let qblWithStatus_singleQueryField = "Platform_Plugin";
326
396
  let qblWithStatus = {
327
397
  name: "Plugin",
328
398
  queryField: "Platform_Plugins",
@@ -334,7 +404,10 @@ globalThis.describe("allowedStates + statusField populated", () => {
334
404
  labelFieldSource: qblWithStatus_labelFieldSource,
335
405
  statusField: qblWithStatus_statusField,
336
406
  visibility: undefined,
337
- chapter: undefined
407
+ chapter: undefined,
408
+ singleQueryField: qblWithStatus_singleQueryField,
409
+ idField: undefined,
410
+ idFieldSource: undefined
338
411
  };
339
412
  let wblWithStates_commands = [cmdWithStates];
340
413
  let wblWithStates_producedEventTypes = [];
@@ -390,6 +463,94 @@ globalThis.describe("allowedStates + statusField populated", () => {
390
463
  });
391
464
  });
392
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
+
393
554
  export {
394
555
  cmd,
395
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
  });
@@ -0,0 +1,143 @@
1
+ // The id contract: which form each door accepts, in one place.
2
+ //
3
+ // A row advertises a Relay global id (`btoa("<Type>:<localId>")`) while the
4
+ // storage key is the local id. Before this, the typed doors took only the local
5
+ // id, so the obvious `X(id: row.id)` returned null with no error. These pin the
6
+ // rule that makes either form work — and, just as importantly, the cases where a
7
+ // string must NOT be treated as a global id.
8
+
9
+ open JestGlobals
10
+
11
+ @val external btoa: string => string = "btoa"
12
+
13
+ describe("Api_Ids.encode / decode", () => {
14
+ testSync("round-trips a type name and a local id", () => {
15
+ let g = Api_Ids.encode(~typeName="Ordering_Customer", ~localId="cust-42")
16
+ expect(g)->toEqual(btoa("Ordering_Customer:cust-42"))
17
+ expect(Api_Ids.decode(g))->toEqual(Some(("Ordering_Customer", "cust-42")))
18
+ })
19
+
20
+ // Ids carrying a colon of their own: only the first one separates.
21
+ testSync("a local id containing a colon survives the round trip", () => {
22
+ let g = Api_Ids.encode(~typeName="Platform_Plugin", ~localId="Catalog@1.0.0:beta")
23
+ expect(Api_Ids.decode(g))->toEqual(Some(("Platform_Plugin", "Catalog@1.0.0:beta")))
24
+ })
25
+
26
+ testSync("a plain storage key is not a global id", () =>
27
+ expect(Api_Ids.decode("cust-42"))->toEqual(None)
28
+ )
29
+
30
+ // `atob` accepts this and yields text with no colon — decoding must still say no.
31
+ testSync("valid base64 without a colon is not a global id", () =>
32
+ expect(Api_Ids.decode(btoa("nocolonhere")))->toEqual(None)
33
+ )
34
+
35
+ // A leading colon would mean an empty type name — nothing `node` could resolve.
36
+ testSync("a decoded value starting with the separator is not a global id", () =>
37
+ expect(Api_Ids.decode(btoa(":cust-42")))->toEqual(None)
38
+ )
39
+ })
40
+
41
+ describe("Api_Ids.alternateKey — the fallback a door tries on a miss", () => {
42
+ testSync("a global id offers the storage key inside it", () =>
43
+ expect(Api_Ids.alternateKey(Api_Ids.encode(~typeName="Catalog_Product", ~localId="p-1")))
44
+ ->toEqual(Some("p-1"))
45
+ )
46
+
47
+ testSync("a plain key offers nothing, so the lookup is never retried", () =>
48
+ expect(Api_Ids.alternateKey("p-1"))->toEqual(None)
49
+ )
50
+
51
+ // The reason doors try the raw key FIRST and only fall back here: a storage key
52
+ // that happens to be valid base64 must keep resolving to its own row.
53
+ testSync("a key that merely looks like base64 is not silently rewritten", () => {
54
+ let looksBase64 = btoa("Type:inner")
55
+ expect(Api_Ids.alternateKey(looksBase64))->toEqual(Some("inner"))
56
+ // …but the door asks for `looksBase64` first, so a row stored under that exact
57
+ // string wins. `alternateKey` only ever supplies the second attempt.
58
+ expect(Api_Ids.alternateKey("cGxhaW4="))->toEqual(None)
59
+ })
60
+ })
61
+
62
+ // A resolver that stamps the Relay `id` onto a row must not stamp it onto the
63
+ // *stored* row: `JSON.Decode.object` hands back the very object the in-memory
64
+ // QueryDb holds, so a `Dict.set` writes through. The symptom was a row whose
65
+ // advertised id depended on query history — a list answered the storage key
66
+ // until someone loaded the detail page, and the global id ever after.
67
+ describe("stamping an id onto a row leaves the stored row alone", () => {
68
+ let storedRow = () => {
69
+ let d = Dict.make()
70
+ d->Dict.set("id", JSON.Encode.string("cust-42"))
71
+ d->Dict.set("email", JSON.Encode.string("a@b.c"))
72
+ JSON.Encode.object(d)
73
+ }
74
+
75
+ testSync("Dict.copy before set — the pattern the resolvers use", () => {
76
+ let row = storedRow()
77
+ let stamped = {
78
+ let obj = row->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
79
+ obj->Dict.set(
80
+ "id",
81
+ Api_Ids.encode(~typeName="Ordering_Customer", ~localId="cust-42")->JSON.Encode.string,
82
+ )
83
+ JSON.Encode.object(obj)
84
+ }
85
+ let idOf = j =>
86
+ j->JSON.Decode.object->Option.flatMap(d => d->Dict.get("id"))->Option.flatMap(JSON.Decode.string)
87
+ expect(idOf(stamped))->toEqual(Some(Api_Ids.encode(~typeName="Ordering_Customer", ~localId="cust-42")))
88
+ // The stored row still carries its storage key.
89
+ expect(idOf(row))->toEqual(Some("cust-42"))
90
+ })
91
+ })
92
+
93
+ // `filter.ids` is the door that has always taken either form. Which side needs
94
+ // decoding flipped when the typed doors stopped stamping global ids onto rows:
95
+ // the row now carries its storage key and the *argument* may be the global one.
96
+ describe("filter.ids matches whichever form each side holds", () => {
97
+ let row = (id: string) => {
98
+ let d = Dict.make()
99
+ d->Dict.set("id", JSON.Encode.string(id))
100
+ d->Dict.set("name", JSON.Encode.string("Alpha"))
101
+ JSON.Encode.object(d)
102
+ }
103
+ let idsIn = (~rowId, ~filterIds) => {
104
+ let args = Dict.make()
105
+ args->Dict.set(
106
+ "filter",
107
+ JSON.Encode.object(
108
+ Dict.fromArray([("ids", JSON.Encode.array(filterIds->Array.map(JSON.Encode.string)))]),
109
+ ),
110
+ )
111
+ let conn = QueryDbListQuery.run(
112
+ ~items=[row(rowId)],
113
+ ~argsDict=args,
114
+ ~capability=GraphQL_FragmentGenerator.emptyCapability,
115
+ ~labelField="name",
116
+ )
117
+ conn
118
+ ->JSON.Decode.object
119
+ ->Option.flatMap(d => d->Dict.get("edges"))
120
+ ->Option.flatMap(JSON.Decode.array)
121
+ ->Option.getOr([])
122
+ ->Array.length
123
+ }
124
+ let globalFor = localId => Api_Ids.encode(~typeName="Catalog_Product", ~localId)
125
+
126
+ testSync("storage key row, storage key filter", () =>
127
+ expect(idsIn(~rowId="p-1", ~filterIds=["p-1"]))->toBe(1)
128
+ )
129
+
130
+ // The case the flip fixes: a client passes back the global id it was given by
131
+ // `node`, while the list rows carry storage keys.
132
+ testSync("storage key row, global id filter", () =>
133
+ expect(idsIn(~rowId="p-1", ~filterIds=[globalFor("p-1")]))->toBe(1)
134
+ )
135
+
136
+ testSync("global id row, storage key filter", () =>
137
+ expect(idsIn(~rowId=globalFor("p-1"), ~filterIds=["p-1"]))->toBe(1)
138
+ )
139
+
140
+ testSync("a filter for another row still excludes it", () =>
141
+ expect(idsIn(~rowId="p-1", ~filterIds=[globalFor("p-2")]))->toBe(0)
142
+ )
143
+ })