@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.
- package/CHANGELOG.md +32 -0
- package/package.json +7 -7
- package/src/admin/Platform_Admin_Structure.res +10 -0
- package/src/admin/Platform_Admin_Structure.res.mjs +6 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res +33 -2
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +18 -2
- package/src/components/Api/Api_Ids.res +63 -0
- package/src/components/Api/Api_Ids.res.mjs +43 -0
- package/src/components/Api/GraphQL_FragmentGenerator.res +121 -28
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +97 -27
- package/src/components/Api/QueryDbListQuery.res +13 -5
- package/src/components/Api/QueryDbListQuery.res.mjs +5 -3
- package/src/components/Api/SchemaType.res +36 -2
- package/src/components/Api/SchemaType.res.mjs +31 -10
- package/src/plugin/component/Plugin_Structure.res +41 -24
- package/src/plugin/component/Plugin_Structure.res.mjs +25 -18
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +118 -7
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +168 -7
- package/tests/admin/Platform_PluginStructuresApiTest.res +10 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +23 -2
- package/tests/api/Api_IdsTest.res +143 -0
- package/tests/api/Api_IdsTest.res.mjs +97 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res +169 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +179 -0
- package/tests/plugin/PluginStructureTest.res +159 -0
- package/tests/plugin/PluginStructureTest.res.mjs +160 -6
- package/tests/plugin/StateChangeSlice/PsReserveStock.res +44 -0
- package/tests/plugin/StateChangeSlice/PsReserveStock.res.mjs +67 -0
- package/tests/plugin/StateViewSlice/PsCategoriesView.res +17 -0
- 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: [
|
|
272
|
+
stateViewSlices: [internalSlice],
|
|
259
273
|
}
|
|
260
|
-
let
|
|
261
|
-
Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId="Ordering", mixed)
|
|
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("
|
|
264
|
-
expect(
|
|
297
|
+
testSync("keeps a Public queryableDef", () =>
|
|
298
|
+
expect(mixedEntry->namesAt("readModels")->Array.includes("Products"))->toEqual(true)
|
|
265
299
|
)
|
|
266
300
|
|
|
267
|
-
|
|
268
|
-
|
|
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 = [
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
})
|