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

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 (51) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +6 -6
  3. package/src/admin/Platform_Admin_Structure.res +16 -0
  4. package/src/admin/Platform_Admin_Structure.res.mjs +14 -3
  5. package/src/admin/Platform_BakedManifest.res +154 -0
  6. package/src/admin/Platform_BakedManifest.res.mjs +156 -0
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +19 -2
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +30 -2
  9. package/src/components/Api/Api_Ids.res +63 -0
  10. package/src/components/Api/Api_Ids.res.mjs +43 -0
  11. package/src/components/Api/GraphQL_FragmentGenerator.res +121 -28
  12. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +97 -27
  13. package/src/components/Api/OwnerScopeDiagnostics.res +54 -0
  14. package/src/components/Api/OwnerScopeDiagnostics.res.mjs +27 -0
  15. package/src/components/Api/QueryDbListQuery.res +38 -6
  16. package/src/components/Api/QueryDbListQuery.res.mjs +14 -5
  17. package/src/components/Api/SchemaType.res +36 -2
  18. package/src/components/Api/SchemaType.res.mjs +31 -10
  19. package/src/components/Api/SuryToJsonSchema.res +19 -0
  20. package/src/components/Api/SuryToJsonSchema.res.mjs +18 -4
  21. package/src/components/CommandGenerator/CommandGenerator_Callback.res +48 -0
  22. package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +27 -1
  23. package/src/plugin/component/Plugin_Structure.res +78 -3
  24. package/src/plugin/component/Plugin_Structure.res.mjs +38 -9
  25. package/tests/admin/Platform_BakedManifestTest.res +244 -0
  26. package/tests/admin/Platform_BakedManifestTest.res.mjs +294 -0
  27. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +73 -0
  28. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +135 -6
  29. package/tests/admin/Platform_PluginStructuresApiTest.res +12 -0
  30. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +27 -2
  31. package/tests/api/Api_IdsTest.res +143 -0
  32. package/tests/api/Api_IdsTest.res.mjs +97 -0
  33. package/tests/api/GraphQL_FragmentGeneratorTest.res +169 -0
  34. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +179 -0
  35. package/tests/api/OwnerScopeDiagnosticsTest.res +109 -0
  36. package/tests/api/OwnerScopeDiagnosticsTest.res.mjs +91 -0
  37. package/tests/api/SuryToJsonSchemaTest.res +45 -0
  38. package/tests/api/SuryToJsonSchemaTest.res.mjs +23 -0
  39. package/tests/commandgenerator/OwnerStampingTest.res +174 -0
  40. package/tests/commandgenerator/OwnerStampingTest.res.mjs +195 -0
  41. package/tests/plugin/PluginStructureAccessTest.res +110 -0
  42. package/tests/plugin/PluginStructureAccessTest.res.mjs +144 -0
  43. package/tests/plugin/PluginStructureTest.res +90 -0
  44. package/tests/plugin/PluginStructureTest.res.mjs +86 -8
  45. package/tests/plugin/StateChangeSlice/PsGatedCommands.res +28 -0
  46. package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +84 -0
  47. package/tests/plugin/StateViewSlice/PsCategoriesView.res +17 -0
  48. package/tests/plugin/StateViewSlice/PsCategoriesView.res.mjs +57 -0
  49. package/tests/plugin/StateViewSlice/PsGatedView.res +15 -0
  50. package/tests/plugin/StateViewSlice/PsGatedView.res.mjs +60 -0
  51. package/tests/plugin/pluginDefinitionRequiredScalars.txt +4 -0
@@ -0,0 +1,244 @@
1
+ // The baked manifest is curation, so what matters is exactly which components
2
+ // survive an include-list — and, decisively, that an entry which includes
3
+ // everything is byte-identical to what the served query returns. Anything less
4
+ // and a shell reading the file is reading a second, quietly different contract.
5
+
6
+ open JestGlobals
7
+ open Reventless.Plugin
8
+
9
+ let queryable = (~name, ~visibility=?, ~ownerField=?, ()): queryableDef => {
10
+ name,
11
+ queryField: `Ordering_${name}`,
12
+ schema: "{}",
13
+ consumedEventTypes: [],
14
+ linkedWriteSide: [],
15
+ labelField: "displayName",
16
+ searchableFields: ["displayName"],
17
+ labelFieldSource: None,
18
+ statusField: None,
19
+ visibility,
20
+ chapter: None,
21
+ singleQueryField: Some(`Ordering_${name}Single`),
22
+ idField: Some("id"),
23
+ idFieldSource: Some("convention"),
24
+ requiredAccess: None,
25
+ ownerField,
26
+ }
27
+
28
+ let command = (~name, ~references=[], ~ownerField=?, ()): commandDef => {
29
+ name,
30
+ schema: "{}",
31
+ level: Collection,
32
+ aggregateIdField: None,
33
+ mutationField: name->String.toLowerCase,
34
+ references,
35
+ allowedStates: None,
36
+ targetState: None,
37
+ apiExposed: Some(true),
38
+ requiredAccess: None,
39
+ ownerField,
40
+ }
41
+
42
+ let writable = (~name, ~commands): writableDef => {
43
+ name,
44
+ commands,
45
+ linkedViews: [],
46
+ consistencyRead: None,
47
+ producedEventTypes: [],
48
+ consumedEventTypes: [],
49
+ events: [],
50
+ errors: [],
51
+ chapter: None,
52
+ }
53
+
54
+ // Mirrors the hybrid example's shape: a public order list, an Internal
55
+ // denormalised product mirror a command @refs, one end-user command and one
56
+ // operator command.
57
+ let structure: pluginStructure = {
58
+ readModels: [queryable(~name="Customers", ())],
59
+ stateViewSlices: [
60
+ queryable(~name="Orders", ~ownerField="customerId", ()),
61
+ queryable(~name="AvailableProducts", ~visibility="Internal", ()),
62
+ ],
63
+ stateChangeSlices: [
64
+ writable(
65
+ ~name="PlaceOrder",
66
+ ~commands=[
67
+ command(
68
+ ~name="PlaceOrder",
69
+ ~references=[{fieldName: "productIds", entity: "AvailableProducts", plugin: None}],
70
+ ~ownerField="customerId",
71
+ (),
72
+ ),
73
+ ],
74
+ ),
75
+ writable(~name="ImportOrders", ~commands=[command(~name="ImportOrders", ())]),
76
+ ],
77
+ aggregates: [],
78
+ automationSlices: [],
79
+ outboundTranslationSlices: [],
80
+ inboundTranslationSlices: [],
81
+ extensions: [],
82
+ extensionPoints: None,
83
+ requiredStores: None,
84
+ requiredStoreDeclarations: None,
85
+ }
86
+
87
+ let structures = [("Ordering@1.0.0", structure)]
88
+
89
+ let bake = selections =>
90
+ Platform_BakedManifest.curate(~structures, ~selections)
91
+
92
+ let entries = (json: JSON.t) => json->JSON.Decode.array->Option.getOr([])
93
+
94
+ let names = (entry: JSON.t, field: string) =>
95
+ entry
96
+ ->JSON.Decode.object
97
+ ->Option.flatMap(d => d->Dict.get(field))
98
+ ->Option.flatMap(JSON.Decode.array)
99
+ ->Option.getOr([])
100
+ ->Array.filterMap(v =>
101
+ v->JSON.Decode.object->Option.flatMap(d => d->Dict.get("name"))->Option.flatMap(JSON.Decode.string)
102
+ )
103
+
104
+ let firstEntry = (r: result<JSON.t, Platform_BakedManifest.error>) =>
105
+ switch r {
106
+ | Ok(json) => json->entries->Array.getUnsafe(0)
107
+ | Error(e) => JsError.throwWithMessage(Platform_BakedManifest.describe(e))
108
+ }
109
+
110
+ describe("curate", () => {
111
+ testSync("selects exactly the named components", () => {
112
+ let entry =
113
+ bake([{plugin: "Ordering", views: Some(["Orders"]), commands: Some(["PlaceOrder"])}])
114
+ ->firstEntry
115
+ expect((entry->names("stateViewSlices"), entry->names("readModels")))->toEqual((["Orders"], []))
116
+ })
117
+
118
+ testSync("drops a write side whose commands were all excluded", () => {
119
+ let entry =
120
+ bake([{plugin: "Ordering", views: Some([]), commands: Some(["PlaceOrder"])}])->firstEntry
121
+ expect(entry->names("stateChangeSlices"))->toEqual(["PlaceOrder"])
122
+ })
123
+
124
+ testSync("unset views and commands include every public component", () => {
125
+ let entry = bake([{plugin: "Ordering", views: None, commands: None}])->firstEntry
126
+ expect((
127
+ entry->names("readModels"),
128
+ entry->names("stateViewSlices"),
129
+ entry->names("stateChangeSlices"),
130
+ ))->toEqual((["Customers"], ["Orders"], ["PlaceOrder", "ImportOrders"]))
131
+ })
132
+
133
+ // A shell reading a baked file has no admin API to recover a reference target
134
+ // from, so an Internal view a kept command points at has to survive the bake.
135
+ testSync("carries an Internal view referenced by an included command", () => {
136
+ let entry =
137
+ bake([{plugin: "Ordering", views: Some(["Orders"]), commands: Some(["PlaceOrder"])}])
138
+ ->firstEntry
139
+ expect(entry->names("internalQueryables"))->toEqual(["AvailableProducts"])
140
+ })
141
+
142
+ testSync("omits an Internal view no included command references", () => {
143
+ let entry =
144
+ bake([{plugin: "Ordering", views: Some(["Orders"]), commands: Some(["ImportOrders"])}])
145
+ ->firstEntry
146
+ expect(entry->names("internalQueryables"))->toEqual([])
147
+ })
148
+
149
+ testSync("an Internal view is never selectable as a view", () =>
150
+ expect(
151
+ bake([{plugin: "Ordering", views: Some(["AvailableProducts"]), commands: None}]),
152
+ )->toEqual(
153
+ Error(Platform_BakedManifest.UnknownView({plugin: "Ordering", view: "AvailableProducts"})),
154
+ )
155
+ )
156
+
157
+ testSync("fails naming an unknown plugin", () =>
158
+ expect(bake([{plugin: "Shipping", views: None, commands: None}]))->toEqual(
159
+ Error(Platform_BakedManifest.UnknownPlugin("Shipping")),
160
+ )
161
+ )
162
+
163
+ testSync("fails naming an unknown command", () =>
164
+ expect(bake([{plugin: "Ordering", views: None, commands: Some(["CancelOrder"])}]))->toEqual(
165
+ Error(Platform_BakedManifest.UnknownCommand({plugin: "Ordering", command: "CancelOrder"})),
166
+ )
167
+ )
168
+
169
+ // The anti-drift assertion: include everything and the baked entry IS the
170
+ // served entry. Any future divergence in the encoder shows up here first.
171
+ testSync("an all-inclusive bake is byte-identical to the served entry", () => {
172
+ let baked = bake([{plugin: "Ordering", views: None, commands: None}])->firstEntry
173
+ let served = Platform_ComponentDefinitionsApi.encodePluginStructureEntry(
174
+ ~pluginId="Ordering@1.0.0",
175
+ structure,
176
+ )
177
+ expect(baked->JSON.stringify)->toEqual(served->JSON.stringify)
178
+ })
179
+
180
+ testSync("one entry per selection, in declaration order", () => {
181
+ let json =
182
+ Platform_BakedManifest.curate(
183
+ ~structures=[("Ordering@1.0.0", structure), ("Catalog@1.0.0", structure)],
184
+ ~selections=[
185
+ {plugin: "Catalog", views: Some([]), commands: Some([])},
186
+ {plugin: "Ordering", views: Some([]), commands: Some([])},
187
+ ],
188
+ )
189
+ let ids =
190
+ switch json {
191
+ | Ok(j) =>
192
+ j
193
+ ->entries
194
+ ->Array.filterMap(e =>
195
+ e
196
+ ->JSON.Decode.object
197
+ ->Option.flatMap(d => d->Dict.get("pluginId"))
198
+ ->Option.flatMap(JSON.Decode.string)
199
+ )
200
+ | Error(_) => []
201
+ }
202
+ expect(ids)->toEqual(["Catalog", "Ordering"])
203
+ })
204
+
205
+ // A shell reading a baked manifest has no admin API to ask instead, so a field
206
+ // the curation step forgot to copy is a field that view can never learn about.
207
+ // `curate` reuses the served encoder rather than writing its own, which is what
208
+ // makes this hold — the assertion guards that property, not a second code path.
209
+ testSync("a curated entry carries the owner field the served entry carries", () => {
210
+ let entry =
211
+ bake([{plugin: "Ordering", views: Some(["Orders"]), commands: Some(["PlaceOrder"])}])
212
+ ->firstEntry
213
+ let ownerOf = (key, name) =>
214
+ entry
215
+ ->JSON.Decode.object
216
+ ->Option.flatMap(d => d->Dict.get(key))
217
+ ->Option.flatMap(JSON.Decode.array)
218
+ ->Option.getOr([])
219
+ ->Array.filterMap(JSON.Decode.object)
220
+ ->Array.find(d =>
221
+ d->Dict.get("name")->Option.flatMap(JSON.Decode.string) == Some(name)
222
+ )
223
+ ->Option.flatMap(d => d->Dict.get("ownerField"))
224
+ ->Option.flatMap(JSON.Decode.string)
225
+ let commandOwner =
226
+ entry
227
+ ->JSON.Decode.object
228
+ ->Option.flatMap(d => d->Dict.get("stateChangeSlices"))
229
+ ->Option.flatMap(JSON.Decode.array)
230
+ ->Option.getOr([])
231
+ ->Array.filterMap(JSON.Decode.object)
232
+ ->Array.flatMap(w =>
233
+ w->Dict.get("commands")->Option.flatMap(JSON.Decode.array)->Option.getOr([])
234
+ )
235
+ ->Array.filterMap(JSON.Decode.object)
236
+ ->Array.get(0)
237
+ ->Option.flatMap(d => d->Dict.get("ownerField"))
238
+ ->Option.flatMap(JSON.Decode.string)
239
+ expect((ownerOf("stateViewSlices", "Orders"), commandOwner))->toEqual((
240
+ Some("customerId"),
241
+ Some("customerId"),
242
+ ))
243
+ })
244
+ })
@@ -0,0 +1,294 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
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";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
8
+ import * as Platform_BakedManifest$ReventlessCore from "../../src/admin/Platform_BakedManifest.res.mjs";
9
+ import * as Platform_ComponentDefinitionsApi$ReventlessCore from "../../src/admin/Platform_ComponentDefinitionsApi.res.mjs";
10
+
11
+ function queryable(name, visibility, ownerField, param) {
12
+ return {
13
+ name: name,
14
+ queryField: `Ordering_` + name,
15
+ schema: "{}",
16
+ consumedEventTypes: [],
17
+ linkedWriteSide: [],
18
+ labelField: "displayName",
19
+ searchableFields: ["displayName"],
20
+ labelFieldSource: undefined,
21
+ statusField: undefined,
22
+ ownerField: ownerField,
23
+ visibility: visibility,
24
+ chapter: undefined,
25
+ singleQueryField: `Ordering_` + name + `Single`,
26
+ idField: "id",
27
+ idFieldSource: "convention",
28
+ requiredAccess: undefined
29
+ };
30
+ }
31
+
32
+ function command(name, referencesOpt, ownerField, param) {
33
+ let references = referencesOpt !== undefined ? referencesOpt : [];
34
+ return {
35
+ name: name,
36
+ schema: "{}",
37
+ level: "Collection",
38
+ aggregateIdField: undefined,
39
+ mutationField: name.toLowerCase(),
40
+ references: references,
41
+ allowedStates: undefined,
42
+ targetState: undefined,
43
+ apiExposed: true,
44
+ requiredAccess: undefined,
45
+ ownerField: ownerField
46
+ };
47
+ }
48
+
49
+ function writable(name, commands) {
50
+ return {
51
+ name: name,
52
+ commands: commands,
53
+ producedEventTypes: [],
54
+ consumedEventTypes: [],
55
+ linkedViews: [],
56
+ consistencyRead: undefined,
57
+ events: [],
58
+ errors: [],
59
+ chapter: undefined
60
+ };
61
+ }
62
+
63
+ let structure_readModels = [queryable("Customers", undefined, undefined, undefined)];
64
+
65
+ let structure_stateViewSlices = [
66
+ queryable("Orders", undefined, "customerId", undefined),
67
+ queryable("AvailableProducts", "Internal", undefined, undefined)
68
+ ];
69
+
70
+ let structure_stateChangeSlices = [
71
+ writable("PlaceOrder", [command("PlaceOrder", [{
72
+ fieldName: "productIds",
73
+ entity: "AvailableProducts",
74
+ plugin: undefined
75
+ }], "customerId", undefined)]),
76
+ writable("ImportOrders", [command("ImportOrders", undefined, undefined, undefined)])
77
+ ];
78
+
79
+ let structure_aggregates = [];
80
+
81
+ let structure_automationSlices = [];
82
+
83
+ let structure_outboundTranslationSlices = [];
84
+
85
+ let structure_inboundTranslationSlices = [];
86
+
87
+ let structure_extensions = [];
88
+
89
+ let structure = {
90
+ readModels: structure_readModels,
91
+ stateViewSlices: structure_stateViewSlices,
92
+ stateChangeSlices: structure_stateChangeSlices,
93
+ aggregates: structure_aggregates,
94
+ automationSlices: structure_automationSlices,
95
+ outboundTranslationSlices: structure_outboundTranslationSlices,
96
+ inboundTranslationSlices: structure_inboundTranslationSlices,
97
+ extensions: structure_extensions,
98
+ extensionPoints: undefined,
99
+ requiredStores: undefined,
100
+ requiredStoreDeclarations: undefined
101
+ };
102
+
103
+ let structures = [[
104
+ "Ordering@1.0.0",
105
+ structure
106
+ ]];
107
+
108
+ function bake(selections) {
109
+ return Platform_BakedManifest$ReventlessCore.curate(structures, selections);
110
+ }
111
+
112
+ function entries(json) {
113
+ return Stdlib_Option.getOr(Stdlib_JSON.Decode.array(json), []);
114
+ }
115
+
116
+ function names(entry, field) {
117
+ return Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(entry), d => d[field]), Stdlib_JSON.Decode.array), []), v => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(v), d => d["name"]), Stdlib_JSON.Decode.string));
118
+ }
119
+
120
+ function firstEntry(r) {
121
+ if (r.TAG === "Ok") {
122
+ return entries(r._0)[0];
123
+ } else {
124
+ return Stdlib_JsError.throwWithMessage(Platform_BakedManifest$ReventlessCore.describe(r._0));
125
+ }
126
+ }
127
+
128
+ globalThis.describe("curate", () => {
129
+ globalThis.test("selects exactly the named components", () => {
130
+ let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
131
+ plugin: "Ordering",
132
+ views: ["Orders"],
133
+ commands: ["PlaceOrder"]
134
+ }]));
135
+ globalThis.expect([
136
+ names(entry, "stateViewSlices"),
137
+ names(entry, "readModels")
138
+ ]).toEqual([
139
+ ["Orders"],
140
+ []
141
+ ]);
142
+ });
143
+ globalThis.test("drops a write side whose commands were all excluded", () => {
144
+ let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
145
+ plugin: "Ordering",
146
+ views: [],
147
+ commands: ["PlaceOrder"]
148
+ }]));
149
+ globalThis.expect(names(entry, "stateChangeSlices")).toEqual(["PlaceOrder"]);
150
+ });
151
+ globalThis.test("unset views and commands include every public component", () => {
152
+ let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
153
+ plugin: "Ordering",
154
+ views: undefined,
155
+ commands: undefined
156
+ }]));
157
+ globalThis.expect([
158
+ names(entry, "readModels"),
159
+ names(entry, "stateViewSlices"),
160
+ names(entry, "stateChangeSlices")
161
+ ]).toEqual([
162
+ ["Customers"],
163
+ ["Orders"],
164
+ [
165
+ "PlaceOrder",
166
+ "ImportOrders"
167
+ ]
168
+ ]);
169
+ });
170
+ globalThis.test("carries an Internal view referenced by an included command", () => {
171
+ let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
172
+ plugin: "Ordering",
173
+ views: ["Orders"],
174
+ commands: ["PlaceOrder"]
175
+ }]));
176
+ globalThis.expect(names(entry, "internalQueryables")).toEqual(["AvailableProducts"]);
177
+ });
178
+ globalThis.test("omits an Internal view no included command references", () => {
179
+ let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
180
+ plugin: "Ordering",
181
+ views: ["Orders"],
182
+ commands: ["ImportOrders"]
183
+ }]));
184
+ globalThis.expect(names(entry, "internalQueryables")).toEqual([]);
185
+ });
186
+ globalThis.test("an Internal view is never selectable as a view", () => {
187
+ globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [{
188
+ plugin: "Ordering",
189
+ views: ["AvailableProducts"],
190
+ commands: undefined
191
+ }])).toEqual({
192
+ TAG: "Error",
193
+ _0: {
194
+ TAG: "UnknownView",
195
+ plugin: "Ordering",
196
+ view: "AvailableProducts"
197
+ }
198
+ });
199
+ });
200
+ globalThis.test("fails naming an unknown plugin", () => {
201
+ globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [{
202
+ plugin: "Shipping",
203
+ views: undefined,
204
+ commands: undefined
205
+ }])).toEqual({
206
+ TAG: "Error",
207
+ _0: {
208
+ TAG: "UnknownPlugin",
209
+ _0: "Shipping"
210
+ }
211
+ });
212
+ });
213
+ globalThis.test("fails naming an unknown command", () => {
214
+ globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [{
215
+ plugin: "Ordering",
216
+ views: undefined,
217
+ commands: ["CancelOrder"]
218
+ }])).toEqual({
219
+ TAG: "Error",
220
+ _0: {
221
+ TAG: "UnknownCommand",
222
+ plugin: "Ordering",
223
+ command: "CancelOrder"
224
+ }
225
+ });
226
+ });
227
+ globalThis.test("an all-inclusive bake is byte-identical to the served entry", () => {
228
+ let baked = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
229
+ plugin: "Ordering",
230
+ views: undefined,
231
+ commands: undefined
232
+ }]));
233
+ let served = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering@1.0.0", structure);
234
+ globalThis.expect(JSON.stringify(baked)).toEqual(JSON.stringify(served));
235
+ });
236
+ globalThis.test("one entry per selection, in declaration order", () => {
237
+ let json = Platform_BakedManifest$ReventlessCore.curate([
238
+ [
239
+ "Ordering@1.0.0",
240
+ structure
241
+ ],
242
+ [
243
+ "Catalog@1.0.0",
244
+ structure
245
+ ]
246
+ ], [
247
+ {
248
+ plugin: "Catalog",
249
+ views: [],
250
+ commands: []
251
+ },
252
+ {
253
+ plugin: "Ordering",
254
+ views: [],
255
+ commands: []
256
+ }
257
+ ]);
258
+ let ids;
259
+ ids = json.TAG === "Ok" ? Stdlib_Array.filterMap(entries(json._0), e => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(e), d => d["pluginId"]), Stdlib_JSON.Decode.string)) : [];
260
+ globalThis.expect(ids).toEqual([
261
+ "Catalog",
262
+ "Ordering"
263
+ ]);
264
+ });
265
+ globalThis.test("a curated entry carries the owner field the served entry carries", () => {
266
+ let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
267
+ plugin: "Ordering",
268
+ views: ["Orders"],
269
+ commands: ["PlaceOrder"]
270
+ }]));
271
+ let ownerOf = (key, name) => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(entry), d => d[key]), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.object).find(d => Primitive_object.equal(Stdlib_Option.flatMap(d["name"], Stdlib_JSON.Decode.string), name)), d => d["ownerField"]), Stdlib_JSON.Decode.string);
272
+ let commandOwner = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Array.filterMap(Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(entry), d => d["stateChangeSlices"]), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.object).flatMap(w => Stdlib_Option.getOr(Stdlib_Option.flatMap(w["commands"], Stdlib_JSON.Decode.array), [])), Stdlib_JSON.Decode.object)[0], d => d["ownerField"]), Stdlib_JSON.Decode.string);
273
+ globalThis.expect([
274
+ ownerOf("stateViewSlices", "Orders"),
275
+ commandOwner
276
+ ]).toEqual([
277
+ "customerId",
278
+ "customerId"
279
+ ]);
280
+ });
281
+ });
282
+
283
+ export {
284
+ queryable,
285
+ command,
286
+ writable,
287
+ structure,
288
+ structures,
289
+ bake,
290
+ entries,
291
+ names,
292
+ firstEntry,
293
+ }
294
+ /* structure Not a pure module */
@@ -17,6 +17,8 @@ let cmd: commandDef = {
17
17
  allowedStates: None,
18
18
  targetState: None,
19
19
  apiExposed: Some(true),
20
+ requiredAccess: None,
21
+ ownerField: None,
20
22
  }
21
23
 
22
24
  let qbl: queryableDef = {
@@ -31,6 +33,11 @@ let qbl: queryableDef = {
31
33
  statusField: None,
32
34
  visibility: None,
33
35
  chapter: None,
36
+ singleQueryField: Some("Catalog_Product"),
37
+ idField: Some("productId"),
38
+ idFieldSource: Some("convention"),
39
+ requiredAccess: None,
40
+ ownerField: None,
34
41
  }
35
42
 
36
43
  let wbl: writableDef = {
@@ -252,6 +259,11 @@ describe("visibility filtering (deployed AutoUI hides Internal)", () => {
252
259
  statusField: None,
253
260
  visibility: Some("Internal"),
254
261
  chapter: None,
262
+ singleQueryField: Some("Ordering_AvailableProduct"),
263
+ idField: Some("productId"),
264
+ idFieldSource: Some("sole"),
265
+ requiredAccess: None,
266
+ ownerField: None,
255
267
  }
256
268
  // A distinct name per source array: the complement has to be fed by both, and
257
269
  // reusing one def would hide a version that only walks `readModels`.
@@ -328,6 +340,8 @@ describe("allowedStates + statusField populated", () => {
328
340
  allowedStates: Some(["Inactive"]),
329
341
  targetState: Some("Active"),
330
342
  apiExposed: Some(true),
343
+ requiredAccess: None,
344
+ ownerField: None,
331
345
  }
332
346
 
333
347
  let qblWithStatus: queryableDef = {
@@ -342,6 +356,11 @@ describe("allowedStates + statusField populated", () => {
342
356
  statusField: Some("status"),
343
357
  visibility: None,
344
358
  chapter: None,
359
+ singleQueryField: Some("Platform_Plugin"),
360
+ idField: None,
361
+ idFieldSource: None,
362
+ requiredAccess: None,
363
+ ownerField: None,
345
364
  }
346
365
 
347
366
  let wblWithStates: writableDef = {
@@ -395,3 +414,57 @@ describe("allowedStates + statusField populated", () => {
395
414
  expect(json->String.includes("\"errors\":null"))->toEqual(false)
396
415
  })
397
416
  })
417
+
418
+ // The name exists so a consumer can stop re-implementing `Api_Naming.singularize`;
419
+ // it is only worth publishing if it reaches the caller intact, so assert the wire
420
+ // value and the SDL that types it, not just the record field.
421
+ describe("singleQueryField reaches the caller", () => {
422
+ testSync("encodes the stated singular beside the list field", () => {
423
+ expect(json->String.includes("\"queryField\":\"Catalog_Products\""))->toEqual(true)
424
+ expect(json->String.includes("\"singleQueryField\":\"Catalog_Product\""))->toEqual(true)
425
+ })
426
+
427
+ // Nullable on purpose: a structure persisted before the field existed, or a
428
+ // hand-rolled def that declines to state it, must decode rather than fail.
429
+ testSync("encodes an unstated singular as null", () => {
430
+ let unstated =
431
+ {...structure, readModels: [{...qbl, singleQueryField: None}]}
432
+ ->Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId="Catalog", _)
433
+ ->JSON.stringify
434
+ expect(unstated->String.includes("\"singleQueryField\":null"))->toEqual(true)
435
+ })
436
+
437
+ testSync("types it as a nullable String on the read-side SDL", () => {
438
+ let sdl = Platform_ComponentDefinitionsApi.sdlTypes->Array.join("\n")
439
+ expect(sdl->String.includes("singleQueryField: String\n"))->toEqual(true)
440
+ expect(sdl->String.includes("singleQueryField: String!"))->toEqual(false)
441
+ })
442
+ })
443
+
444
+ // The key and its rung travel together: a consumer ranking its own guess against
445
+ // this one needs both, so a version that carried the field and dropped the
446
+ // provenance would be worse than useless.
447
+ describe("idField / idFieldSource reach the caller", () => {
448
+ testSync("encodes the key field and the rung that produced it", () => {
449
+ expect(json->String.includes("\"idField\":\"productId\""))->toEqual(true)
450
+ expect(json->String.includes("\"idFieldSource\":\"convention\""))->toEqual(true)
451
+ })
452
+
453
+ // A state whose key cannot be resolved (several `*Id` fields and no name match,
454
+ // or none at all) has nothing to report — null, not an invented field name.
455
+ testSync("encodes an unresolved key as null on both fields", () => {
456
+ let unresolved =
457
+ {...structure, readModels: [{...qbl, idField: None, idFieldSource: None}]}
458
+ ->Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId="Catalog", _)
459
+ ->JSON.stringify
460
+ expect(unresolved->String.includes("\"idField\":null"))->toEqual(true)
461
+ expect(unresolved->String.includes("\"idFieldSource\":null"))->toEqual(true)
462
+ })
463
+
464
+ testSync("types both as nullable Strings on the read-side SDL", () => {
465
+ let sdl = Platform_ComponentDefinitionsApi.sdlTypes->Array.join("\n")
466
+ expect(sdl->String.includes("idField: String\n"))->toEqual(true)
467
+ expect(sdl->String.includes("idFieldSource: String\n"))->toEqual(true)
468
+ expect(sdl->String.includes("idField: String!"))->toEqual(false)
469
+ })
470
+ })