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

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 CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.223 (2026-08-10)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core:** collect [@ref](https://github.com/ref) declared on an array field ([6f9e2fe](https://github.com/ReventlessDev/reventless-core/commit/6f9e2fef2568ad57a8bf2efbaa9cd4830a947f26))
11
+ ### Features
12
+
13
+ * **admin:** carry Internal queryables on their own manifest field ([f5aa03d](https://github.com/ReventlessDev/reventless-core/commit/f5aa03d60b0b2554d116fe9b3784d30f2c4ef356))
14
+
15
+
16
+ # 3.0.0-alpha.222 (2026-08-10)
17
+
18
+ **Note:** Version bump only for package @reventlessdev/reventless-core
19
+
20
+
21
+
22
+
23
+
6
24
  # 3.0.0-alpha.221 (2026-08-09)
7
25
 
8
26
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.221",
3
+ "version": "3.0.0-alpha.223",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,21 +29,21 @@
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.3",
32
33
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
33
- "@reventlessdev/rescript-node": "2.0.0-alpha.3",
34
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
35
- "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.3",
36
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
35
+ "@reventlessdev/rescript-node": "2.0.0-alpha.3",
37
36
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.3",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.132",
39
- "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
37
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
40
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.106"
39
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
40
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.133",
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.107"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
45
  "sury-ppx": "11.0.0-alpha.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.64"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.65"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "12.3.0"
@@ -30,7 +30,14 @@ let sdlTypes: array<string> = [
30
30
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
31
31
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
32
32
  `type Platform_ExtensionDef {\n name: String!\n delegateNames: [String!]!\n eventTypes: [String!]!\n commandTypes: [String!]!\n}`,
33
- `type Platform_ComponentDefinitionEntry {\n pluginId: String!\n readModels: [Platform_ReadSideDef!]!\n stateViewSlices: [Platform_ReadSideDef!]!\n stateChangeSlices: [Platform_WriteSideDef!]!\n aggregates: [Platform_WriteSideDef!]!\n automationSlices: [Platform_AutomationSliceDef!]!\n outboundTranslationSlices: [Platform_OutboundTranslationSliceDef!]!\n inboundTranslationSlices: [Platform_InboundTranslationSliceDef!]!\n extensions: [Platform_ExtensionDef!]!\n}`,
33
+ // `internalQueryables` is the complement of the `readModels` / `stateViewSlices`
34
+ // filter, not an addition to either: it carries exactly the components the
35
+ // filter removed. A consumer enumerating surfaces reads the two filtered lists
36
+ // and is structurally unable to turn an Internal view into a page; a consumer
37
+ // resolving a reference target reads all three. Keeping it a separate field is
38
+ // what makes that separation structural rather than a filter every enumeration
39
+ // site has to remember.
40
+ `type Platform_ComponentDefinitionEntry {\n pluginId: String!\n readModels: [Platform_ReadSideDef!]!\n stateViewSlices: [Platform_ReadSideDef!]!\n internalQueryables: [Platform_ReadSideDef!]!\n stateChangeSlices: [Platform_WriteSideDef!]!\n aggregates: [Platform_WriteSideDef!]!\n automationSlices: [Platform_AutomationSliceDef!]!\n outboundTranslationSlices: [Platform_OutboundTranslationSliceDef!]!\n inboundTranslationSlices: [Platform_InboundTranslationSliceDef!]!\n extensions: [Platform_ExtensionDef!]!\n}`,
34
41
  ]
35
42
 
36
43
  let sdlQueryField: string = ` Platform_ComponentDefinitions: [Platform_ComponentDefinitionEntry!]!`
@@ -75,6 +82,13 @@ let encodeCommandDef = (c: commandDef): JSON.t =>
75
82
  // Internal ReadModels / StateViewSlices are carried in pluginStructure for developer
76
83
  // tooling but must stay out of the deployed AutoUI — the menu, drill-down pages and
77
84
  // queryable defs are all derived from this response, so filter them here.
85
+ //
86
+ // Hidden from the menu is not the same question as denied as a reference target: a
87
+ // denormalised lookup view is precisely the thing an author wants referenceable but
88
+ // not navigable. The filtered lists answer the first question, `internalQueryables`
89
+ // (below) answers the second. Neither is a security boundary — an Internal view
90
+ // keeps its resolver and stays queryable, so naming it here publishes a fact the
91
+ // caller is already entitled to.
78
92
  let isPublicQueryable = (q: queryableDef): bool => q.visibility != Some("Internal")
79
93
 
80
94
  let encodeQueryableDef = (r: queryableDef): JSON.t =>
@@ -190,6 +204,13 @@ let encodePluginStructureEntry = (~pluginId: string, def: pluginStructure): JSON
190
204
  ->Array.map(encodeQueryableDef)
191
205
  ->JSON.Encode.array,
192
206
  ),
207
+ (
208
+ "internalQueryables",
209
+ Array.concat(def.readModels, def.stateViewSlices)
210
+ ->Array.filter(q => !isPublicQueryable(q))
211
+ ->Array.map(encodeQueryableDef)
212
+ ->JSON.Encode.array,
213
+ ),
193
214
  ("stateChangeSlices", def.stateChangeSlices->Array.map(encodeWritableDef)->JSON.Encode.array),
194
215
  ("aggregates", def.aggregates->Array.map(encodeWritableDef)->JSON.Encode.array),
195
216
  (
@@ -15,7 +15,7 @@ let sdlTypes = [
15
15
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
16
16
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
17
17
  `type Platform_ExtensionDef {\n name: String!\n delegateNames: [String!]!\n eventTypes: [String!]!\n commandTypes: [String!]!\n}`,
18
- `type Platform_ComponentDefinitionEntry {\n pluginId: String!\n readModels: [Platform_ReadSideDef!]!\n stateViewSlices: [Platform_ReadSideDef!]!\n stateChangeSlices: [Platform_WriteSideDef!]!\n aggregates: [Platform_WriteSideDef!]!\n automationSlices: [Platform_AutomationSliceDef!]!\n outboundTranslationSlices: [Platform_OutboundTranslationSliceDef!]!\n inboundTranslationSlices: [Platform_InboundTranslationSliceDef!]!\n extensions: [Platform_ExtensionDef!]!\n}`
18
+ `type Platform_ComponentDefinitionEntry {\n pluginId: String!\n readModels: [Platform_ReadSideDef!]!\n stateViewSlices: [Platform_ReadSideDef!]!\n internalQueryables: [Platform_ReadSideDef!]!\n stateChangeSlices: [Platform_WriteSideDef!]!\n aggregates: [Platform_WriteSideDef!]!\n automationSlices: [Platform_AutomationSliceDef!]!\n outboundTranslationSlices: [Platform_OutboundTranslationSliceDef!]!\n inboundTranslationSlices: [Platform_InboundTranslationSliceDef!]!\n extensions: [Platform_ExtensionDef!]!\n}`
19
19
  ];
20
20
 
21
21
  function encodeStrings(ss) {
@@ -325,6 +325,10 @@ function encodePluginStructureEntry(pluginId, def) {
325
325
  "stateViewSlices",
326
326
  def.stateViewSlices.filter(isPublicQueryable).map(encodeQueryableDef)
327
327
  ],
328
+ [
329
+ "internalQueryables",
330
+ def.readModels.concat(def.stateViewSlices).filter(q => !Primitive_object.notequal(q.visibility, "Internal")).map(encodeQueryableDef)
331
+ ],
328
332
  [
329
333
  "stateChangeSlices",
330
334
  def.stateChangeSlices.map(encodeWritableDef)
@@ -164,20 +164,29 @@ let labelFieldsFromStateSchema = (
164
164
  // because its components never pass through `make` — has to derive its defs the
165
165
  // same way, and a second copy of this walk would be free to drift from this one.
166
166
 
167
+ // A variant's declared cross-entity references. Shared by the command and event
168
+ // walks because the two ask the identical question of identical field dicts, and
169
+ // the question is `getFieldTarget` rather than `getTarget`: a reference declared
170
+ // on an `array<string>` field sits on the element schema. Collecting it in one
171
+ // place is what stops one walk from being taught that and the other not.
172
+ let extractReferences = (properties: dict<S.t<unknown>>): array<
173
+ Reventless.Plugin.fieldReference,
174
+ > =>
175
+ properties
176
+ ->Dict.toArray
177
+ ->Array.filterMap(((fieldName, fieldSchema)) =>
178
+ Reventless.Reference.getFieldTarget(fieldSchema)->Option.map(target => (
179
+ {
180
+ Reventless.Plugin.fieldName,
181
+ entity: target.entity,
182
+ plugin: target.plugin,
183
+ }: Reventless.Plugin.fieldReference
184
+ ))
185
+ )
186
+
167
187
  let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
168
188
  let mkDef = (~variantName, ~properties) => {
169
- let references =
170
- properties
171
- ->Dict.toArray
172
- ->Array.filterMap(((fieldName, fieldSchema)) =>
173
- Reventless.Reference.getTarget(fieldSchema)->Option.map(target => (
174
- {
175
- Reventless.Plugin.fieldName,
176
- entity: target.entity,
177
- plugin: target.plugin,
178
- }: Reventless.Plugin.fieldReference
179
- ))
180
- )
189
+ let references = extractReferences(properties)
181
190
  ({
182
191
  Reventless.Plugin.name: variantName,
183
192
  // Derived for the same reason as `commandDef.schema`: an event's field
@@ -301,18 +310,7 @@ let make = (
301
310
  // `S.literal("Archive")` string rather than an `{TAG, ...}` object.
302
311
  let mkDef = (~variantName, ~properties) => {
303
312
  let (level, aggregateIdField) = commandLevelAndId(~isAggregate, ~variantName, properties)
304
- let references =
305
- properties
306
- ->Dict.toArray
307
- ->Array.filterMap(((fieldName, fieldSchema)) =>
308
- Reventless.Reference.getTarget(fieldSchema)->Option.map(target => (
309
- {
310
- Reventless.Plugin.fieldName,
311
- entity: target.entity,
312
- plugin: target.plugin,
313
- }: Reventless.Plugin.fieldReference
314
- ))
315
- )
313
+ let references = extractReferences(properties)
316
314
  // Per-variant `allowedStates` lives on the *parent* command schema
317
315
  // (the PPX attaches a single dict<variantName, [|states|]> via
318
316
  // markAllowedStates). Look it up by variant name; back-compat
@@ -140,16 +140,20 @@ function labelFieldsFromStateSchema(entityName, stateSchema) {
140
140
  };
141
141
  }
142
142
 
143
+ function extractReferences(properties) {
144
+ return Stdlib_Array.filterMap(Object.entries(properties), param => {
145
+ let fieldName = param[0];
146
+ return Stdlib_Option.map(Reference$Reventless.getFieldTarget(param[1]), target => ({
147
+ fieldName: fieldName,
148
+ entity: target.entity,
149
+ plugin: target.plugin
150
+ }));
151
+ });
152
+ }
153
+
143
154
  function toEventDef(v) {
144
155
  let mkDef = (variantName, properties) => {
145
- let references = Stdlib_Array.filterMap(Object.entries(properties), param => {
146
- let fieldName = param[0];
147
- return Stdlib_Option.map(Reference$Reventless.getTarget(param[1]), target => ({
148
- fieldName: fieldName,
149
- entity: target.entity,
150
- plugin: target.plugin
151
- }));
152
- });
156
+ let references = extractReferences(properties);
153
157
  return {
154
158
  name: variantName,
155
159
  schema: JSON.stringify(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(v)),
@@ -267,14 +271,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
267
271
  let toCommandDef = (isAggregate, mutationFieldFor, parentSchema, v) => {
268
272
  let mkDef = (variantName, properties) => {
269
273
  let match = commandLevelAndId(isAggregate, variantName, properties);
270
- let references = Stdlib_Array.filterMap(Object.entries(properties), param => {
271
- let fieldName = param[0];
272
- return Stdlib_Option.map(Reference$Reventless.getTarget(param[1]), target => ({
273
- fieldName: fieldName,
274
- entity: target.entity,
275
- plugin: target.plugin
276
- }));
277
- });
274
+ let references = extractReferences(properties);
278
275
  let allowedStates = ApiAllowedStatesHelpers$ReventlessCore.getAllowedStates(parentSchema, variantName);
279
276
  let targetState = ApiTargetStateHelpers$ReventlessCore.getTargetState(parentSchema, variantName);
280
277
  let apiExposed = false;
@@ -653,6 +650,7 @@ export {
653
650
  statusFieldFromStateSchema,
654
651
  labelFieldSourceToString,
655
652
  labelFieldsFromStateSchema,
653
+ extractReferences,
656
654
  toEventDef,
657
655
  extractEventDefs,
658
656
  extractErrorDefs,
@@ -106,6 +106,7 @@ describe("encodePluginStructureEntry", () => {
106
106
  "automationSlices",
107
107
  "extensions",
108
108
  "inboundTranslationSlices",
109
+ "internalQueryables",
109
110
  "outboundTranslationSlices",
110
111
  "pluginId",
111
112
  "readModels",
@@ -252,21 +253,68 @@ describe("visibility filtering (deployed AutoUI hides Internal)", () => {
252
253
  visibility: Some("Internal"),
253
254
  chapter: None,
254
255
  }
256
+ // A distinct name per source array: the complement has to be fed by both, and
257
+ // reusing one def would hide a version that only walks `readModels`.
258
+ let internalSlice: queryableDef = {
259
+ ...internalQbl,
260
+ name: "PendingShipments",
261
+ queryField: "Ordering_PendingShipments",
262
+ }
255
263
  let mixed: pluginStructure = {
256
264
  ...structure,
257
265
  readModels: [qbl, internalQbl],
258
- stateViewSlices: [internalQbl],
266
+ stateViewSlices: [internalSlice],
259
267
  }
260
- let mixedJson =
261
- Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId="Ordering", mixed)->JSON.stringify
268
+ let mixedEntry =
269
+ Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId="Ordering", mixed)
270
+
271
+ // Names under one key of the encoded entry. `String.includes` over the whole
272
+ // JSON can no longer answer this: an Internal view is legitimately present
273
+ // under `internalQueryables`, so a whole-document search cannot tell "kept out
274
+ // of the surfaces" from "dropped entirely" — which is the distinction the
275
+ // feature is.
276
+ let namesAt = (entry: JSON.t, key: string): array<string> =>
277
+ entry
278
+ ->JSON.Decode.object
279
+ ->Option.flatMap(o => o->Dict.get(key))
280
+ ->Option.flatMap(JSON.Decode.array)
281
+ ->Option.getOr([])
282
+ ->Array.filterMap(c =>
283
+ c->JSON.Decode.object->Option.flatMap(o => o->Dict.get("name"))->Option.flatMap(JSON.Decode.string)
284
+ )
285
+
286
+ testSync("excludes an Internal queryableDef from the encoded read-side", () => {
287
+ expect(mixedEntry->namesAt("readModels"))->toEqual(["Products"])
288
+ expect(mixedEntry->namesAt("stateViewSlices"))->toEqual([])
289
+ })
262
290
 
263
- testSync("excludes an Internal queryableDef from the encoded read-side", () =>
264
- expect(mixedJson->String.includes("AvailableProducts"))->toEqual(false)
291
+ testSync("keeps a Public queryableDef", () =>
292
+ expect(mixedEntry->namesAt("readModels")->Array.includes("Products"))->toEqual(true)
265
293
  )
266
294
 
267
- testSync("keeps a Public queryableDef", () =>
268
- expect(mixedJson->String.includes("\"name\":\"Products\""))->toEqual(true)
295
+ // Both directions, deliberately: a change that also leaked Internal views back
296
+ // into the surface lists would pass a presence-only assertion.
297
+ testSync("carries what the filter removed on internalQueryables", () =>
298
+ expect(mixedEntry->namesAt("internalQueryables"))->toEqual([
299
+ "AvailableProducts",
300
+ "PendingShipments",
301
+ ])
269
302
  )
303
+
304
+ testSync("encodes internalQueryables as [] for an all-Public entry, not a missing key", () => {
305
+ let entry = Platform_ComponentDefinitionsApi.encodePluginStructureEntry(
306
+ ~pluginId="Ordering",
307
+ {...structure, readModels: [qbl], stateViewSlices: []},
308
+ )
309
+ expect(
310
+ entry->JSON.Decode.object->Option.flatMap(o => o->Dict.get("internalQueryables")),
311
+ )->toEqual(Some(JSON.Encode.array([])))
312
+ })
313
+
314
+ testSync("declares internalQueryables on the entry type in the SDL", () => {
315
+ let sdl = Platform_ComponentDefinitionsApi.sdlTypes->Array.join("\n")
316
+ expect(sdl->String.includes("internalQueryables: [Platform_ReadSideDef!]!"))->toEqual(true)
317
+ })
270
318
  })
271
319
 
272
320
  describe("allowedStates + statusField populated", () => {
@@ -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";
@@ -146,6 +147,7 @@ globalThis.describe("encodePluginStructureEntry", () => {
146
147
  "automationSlices",
147
148
  "extensions",
148
149
  "inboundTranslationSlices",
150
+ "internalQueryables",
149
151
  "outboundTranslationSlices",
150
152
  "pluginId",
151
153
  "readModels",
@@ -268,11 +270,29 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
268
270
  visibility: internalQbl_visibility,
269
271
  chapter: undefined
270
272
  };
273
+ let internalSlice_consumedEventTypes = internalQbl_consumedEventTypes;
274
+ let internalSlice_linkedWriteSide = internalQbl_linkedWriteSide;
275
+ let internalSlice_searchableFields = internalQbl_searchableFields;
276
+ let internalSlice_labelFieldSource = "convention";
277
+ let internalSlice_visibility = "Internal";
278
+ let internalSlice = {
279
+ name: "PendingShipments",
280
+ queryField: "Ordering_PendingShipments",
281
+ schema: "{}",
282
+ consumedEventTypes: internalSlice_consumedEventTypes,
283
+ linkedWriteSide: internalSlice_linkedWriteSide,
284
+ labelField: "name",
285
+ searchableFields: internalSlice_searchableFields,
286
+ labelFieldSource: internalSlice_labelFieldSource,
287
+ statusField: undefined,
288
+ visibility: internalSlice_visibility,
289
+ chapter: undefined
290
+ };
271
291
  let mixed_readModels = [
272
292
  qbl,
273
293
  internalQbl
274
294
  ];
275
- let mixed_stateViewSlices = [internalQbl];
295
+ let mixed_stateViewSlices = [internalSlice];
276
296
  let mixed_stateChangeSlices = structure_stateChangeSlices;
277
297
  let mixed_aggregates = structure_aggregates;
278
298
  let mixed_automationSlices = structure_automationSlices;
@@ -292,12 +312,40 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
292
312
  requiredStores: undefined,
293
313
  requiredStoreDeclarations: undefined
294
314
  };
295
- let mixedJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", mixed));
315
+ let mixedEntry = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", mixed);
316
+ 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
317
  globalThis.test("excludes an Internal queryableDef from the encoded read-side", () => {
297
- globalThis.expect(mixedJson.includes("AvailableProducts")).toEqual(false);
318
+ globalThis.expect(namesAt(mixedEntry, "readModels")).toEqual(["Products"]);
319
+ globalThis.expect(namesAt(mixedEntry, "stateViewSlices")).toEqual([]);
298
320
  });
299
321
  globalThis.test("keeps a Public queryableDef", () => {
300
- globalThis.expect(mixedJson.includes("\"name\":\"Products\"")).toEqual(true);
322
+ globalThis.expect(namesAt(mixedEntry, "readModels").includes("Products")).toEqual(true);
323
+ });
324
+ globalThis.test("carries what the filter removed on internalQueryables", () => {
325
+ globalThis.expect(namesAt(mixedEntry, "internalQueryables")).toEqual([
326
+ "AvailableProducts",
327
+ "PendingShipments"
328
+ ]);
329
+ });
330
+ globalThis.test("encodes internalQueryables as [] for an all-Public entry, not a missing key", () => {
331
+ let entry = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", {
332
+ readModels: [qbl],
333
+ stateViewSlices: [],
334
+ stateChangeSlices: structure_stateChangeSlices,
335
+ aggregates: structure_aggregates,
336
+ automationSlices: structure_automationSlices,
337
+ outboundTranslationSlices: structure_outboundTranslationSlices,
338
+ inboundTranslationSlices: structure_inboundTranslationSlices,
339
+ extensions: structure_extensions,
340
+ extensionPoints: undefined,
341
+ requiredStores: undefined,
342
+ requiredStoreDeclarations: undefined
343
+ });
344
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(entry), o => o["internalQueryables"])).toEqual([]);
345
+ });
346
+ globalThis.test("declares internalQueryables on the entry type in the SDL", () => {
347
+ let sdl = Platform_ComponentDefinitionsApi$ReventlessCore.sdlTypes.join("\n");
348
+ globalThis.expect(sdl.includes("internalQueryables: [Platform_ReadSideDef!]!")).toEqual(true);
301
349
  });
302
350
  });
303
351
 
@@ -121,6 +121,20 @@ module PsChangePhotoSlice: ReventlessInfra.StateChangeSlice.T = {
121
121
  let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
122
122
  }
123
123
 
124
+ module PsReserveStockSlice: ReventlessInfra.StateChangeSlice.T = {
125
+ module Spec = PsReserveStock
126
+ module Behavior = {
127
+ type state = PsReserveStock.state
128
+ let initialState = PsReserveStock.initialState
129
+ let evolve = PsReserveStock.evolve
130
+ let decide = PsReserveStock.decide
131
+ let moduleUrl = PsReserveStock.moduleUrl
132
+ }
133
+ let isAsync = false
134
+ type component = scsComponent
135
+ let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
136
+ }
137
+
124
138
  let structure = Plugin_Structure.make(
125
139
  ~name="TestPlugin",
126
140
  ~stateChangeSlices=[module(PsPlaceOrderSlice), module(PsShipOrderSlice)],
@@ -960,4 +974,59 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
960
974
  )->toEqual([true, true, true, false, false])
961
975
  })
962
976
  })
977
+
978
+ // A plural reference is declared exactly like a singular one, and the ppx puts
979
+ // the marker where `Reference.to_` returns it: on the element schema, one level
980
+ // inside the array. Reading the field's own schema therefore answers "no
981
+ // reference" for every `@ref` array — and the consuming side treats a missing
982
+ // declaration as licence to guess, so the field resolves to whatever its name
983
+ // suggests instead of what the author wrote.
984
+ describe("plural references reach the manifest", () => {
985
+ let withPluralRefs = Plugin_Structure.make(
986
+ ~name="TestPlugin",
987
+ ~stateChangeSlices=[module(PsReserveStockSlice)],
988
+ )
989
+ let slice = withPluralRefs.stateChangeSlices->Array.getUnsafe(0)
990
+ let referencesOf = refs =>
991
+ refs->Array.map(({Reventless.Plugin.fieldName: f, entity}) => (f, entity))
992
+
993
+ // Scalar and plural in one command: the plural forms are the regression, the
994
+ // scalar one is here so a fix that reaches them by breaking it cannot pass.
995
+ testSync("a command collects its array refs alongside its scalar one", () => {
996
+ let cmd = slice.commands->Array.getUnsafe(0)
997
+ expect(cmd.references->referencesOf)->toEqual([
998
+ ("customerId", "Customers"),
999
+ ("productIds", "AvailableProducts"),
1000
+ ("warehouseIds", "Warehouses"),
1001
+ ])
1002
+ })
1003
+
1004
+ // Optionality is a statement about presence. An optional array puts the
1005
+ // element two wrappers down, which is the shape most likely to be missed by
1006
+ // an unwrap that only handles one.
1007
+ testSync("an optional array ref is collected under the field's own name", () => {
1008
+ let cmd = slice.commands->Array.getUnsafe(0)
1009
+ expect(
1010
+ cmd.references->Array.find(({fieldName}) => fieldName == "warehouseIds"),
1011
+ )->toEqual(Some({Reventless.Plugin.fieldName: "warehouseIds", entity: "Warehouses", plugin: None}))
1012
+ })
1013
+
1014
+ // The event walk is a separate function over the same field dicts. Left to
1015
+ // itself it would be fixed by accident or not at all.
1016
+ testSync("an event collects its array ref too", () => {
1017
+ let evt = slice.events->Array.getUnsafe(0)
1018
+ expect(evt.references->referencesOf)->toEqual([("productIds", "AvailableProducts")])
1019
+ })
1020
+
1021
+ // The declared entity, not the one `productIds` reads like. Before the
1022
+ // element unwrap this field resolved by heuristic to `Products`.
1023
+ testSync("the declared entity wins over the one the field name suggests", () => {
1024
+ let cmd = slice.commands->Array.getUnsafe(0)
1025
+ expect(
1026
+ cmd.references
1027
+ ->Array.find(({fieldName}) => fieldName == "productIds")
1028
+ ->Option.map(({entity}) => entity),
1029
+ )->toEqual(Some("AvailableProducts"))
1030
+ })
1031
+ })
963
1032
  })
@@ -14,6 +14,7 @@ import * as PsOrdersView$ReventlessCore from "./StateViewSlice/PsOrdersView.res.
14
14
  import * as PsPlaceOrder$ReventlessCore from "./StateChangeSlice/PsPlaceOrder.res.mjs";
15
15
  import * as PsChangePhoto$ReventlessCore from "./StateChangeSlice/PsChangePhoto.res.mjs";
16
16
  import * as CapabilityManifest$Reventless from "@reventlessdev/reventless-spec/src/components/CapabilityManifest.res.mjs";
17
+ import * as PsReserveStock$ReventlessCore from "./StateChangeSlice/PsReserveStock.res.mjs";
17
18
  import * as PsUploadAvatar$ReventlessCore from "./StateChangeSlice/PsUploadAvatar.res.mjs";
18
19
  import * as PsAnnotatedView$ReventlessCore from "./StateViewSlice/PsAnnotatedView.res.mjs";
19
20
  import * as PsAttachInvoice$ReventlessCore from "./StateChangeSlice/PsAttachInvoice.res.mjs";
@@ -276,6 +277,36 @@ let PsChangePhotoSlice = {
276
277
  make: make$8
277
278
  };
278
279
 
280
+ let Behavior$5 = {
281
+ initialState: false,
282
+ evolve: PsReserveStock$ReventlessCore.evolve,
283
+ decide: PsReserveStock$ReventlessCore.decide,
284
+ moduleUrl: PsReserveStock$ReventlessCore.moduleUrl
285
+ };
286
+
287
+ function make$9(param, param$1, param$2, param$3, param$4, param$5) {
288
+ return 0;
289
+ }
290
+
291
+ let PsReserveStockSlice_Spec = {
292
+ name: PsReserveStock$ReventlessCore.name,
293
+ moduleUrl: PsReserveStock$ReventlessCore.moduleUrl,
294
+ Id: Id$Reventless.$$String,
295
+ consumedEventSchema: PsReserveStock$ReventlessCore.consumedEventSchema,
296
+ errorSchema: PsReserveStock$ReventlessCore.errorSchema,
297
+ eventSchema: PsReserveStock$ReventlessCore.eventSchema,
298
+ commandSchema: PsReserveStock$ReventlessCore.commandSchema,
299
+ commandAuthorization: PsReserveStock$ReventlessCore.commandAuthorization,
300
+ readConsistency: PsReserveStock$ReventlessCore.readConsistency
301
+ };
302
+
303
+ let PsReserveStockSlice = {
304
+ Spec: PsReserveStockSlice_Spec,
305
+ Behavior: Behavior$5,
306
+ isAsync: false,
307
+ make: make$9
308
+ };
309
+
279
310
  let structure = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, [
280
311
  PsOrdersViewSlice,
281
312
  PsAvailableProductsViewSlice,
@@ -962,6 +993,50 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
962
993
  ]);
963
994
  });
964
995
  });
996
+ globalThis.describe("plural references reach the manifest", () => {
997
+ let withPluralRefs = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsReserveStockSlice], undefined, undefined, undefined, undefined, undefined, undefined);
998
+ let slice = withPluralRefs.stateChangeSlices[0];
999
+ let referencesOf = refs => refs.map(param => [
1000
+ param.fieldName,
1001
+ param.entity
1002
+ ]);
1003
+ globalThis.test("a command collects its array refs alongside its scalar one", () => {
1004
+ let cmd = slice.commands[0];
1005
+ globalThis.expect(referencesOf(cmd.references)).toEqual([
1006
+ [
1007
+ "customerId",
1008
+ "Customers"
1009
+ ],
1010
+ [
1011
+ "productIds",
1012
+ "AvailableProducts"
1013
+ ],
1014
+ [
1015
+ "warehouseIds",
1016
+ "Warehouses"
1017
+ ]
1018
+ ]);
1019
+ });
1020
+ globalThis.test("an optional array ref is collected under the field's own name", () => {
1021
+ let cmd = slice.commands[0];
1022
+ globalThis.expect(cmd.references.find(param => param.fieldName === "warehouseIds")).toEqual({
1023
+ fieldName: "warehouseIds",
1024
+ entity: "Warehouses",
1025
+ plugin: undefined
1026
+ });
1027
+ });
1028
+ globalThis.test("an event collects its array ref too", () => {
1029
+ let evt = slice.events[0];
1030
+ globalThis.expect(referencesOf(evt.references)).toEqual([[
1031
+ "productIds",
1032
+ "AvailableProducts"
1033
+ ]]);
1034
+ });
1035
+ globalThis.test("the declared entity wins over the one the field name suggests", () => {
1036
+ let cmd = slice.commands[0];
1037
+ globalThis.expect(Stdlib_Option.map(cmd.references.find(param => param.fieldName === "productIds"), param => param.entity)).toEqual("AvailableProducts");
1038
+ });
1039
+ });
965
1040
  });
966
1041
 
967
1042
  export {
@@ -974,6 +1049,7 @@ export {
974
1049
  PsAttachInvoiceSlice,
975
1050
  PsUploadAvatarSlice,
976
1051
  PsChangePhotoSlice,
1052
+ PsReserveStockSlice,
977
1053
  structure,
978
1054
  }
979
1055
  /* structure Not a pure module */
@@ -0,0 +1,44 @@
1
+ // Test fixture spec where every reference carrier is PLURAL.
2
+ //
3
+ // `Reference.to_` returns an *element* schema, so the ppx annotates the `string`
4
+ // inside `array<string>` and the field's own schema carries no marker. A walk
5
+ // that asks the field directly finds nothing and drops the declaration — and a
6
+ // dropped `@ref` is not the same as an absent one: the consuming side falls back
7
+ // to a naming heuristic and resolves the field to whatever entity the name
8
+ // suggests, so the author's declaration is both ignored and reported as missing.
9
+ //
10
+ // The command declares the scalar and plural forms side by side so a regression
11
+ // shows up as one of the two disappearing rather than as an empty list, and
12
+ // `warehouseIds?` covers the plural-inside-optional shape, where the array sits
13
+ // one wrapper further down still.
14
+
15
+ @@reventless.spec("ReserveStock")
16
+
17
+ type state = bool
18
+ let initialState = false
19
+
20
+ @schema
21
+ type consumedEvent = StockReserved({reservationId: string})
22
+
23
+ let evolve = (_state, _event) => true
24
+
25
+ @schema
26
+ type command =
27
+ | ReserveStock({
28
+ @partitionTag reservationId: string,
29
+ @ref("Customers") customerId: string,
30
+ @ref("AvailableProducts") productIds: array<string>,
31
+ @ref("Warehouses") warehouseIds?: array<string>,
32
+ })
33
+
34
+ @schema
35
+ type error = OutOfStock
36
+
37
+ @schema
38
+ type event =
39
+ StockReserved({
40
+ @partitionTag reservationId: string,
41
+ @ref("AvailableProducts") productIds: array<string>,
42
+ })
43
+
44
+ let decide = (_state, _command): result<array<event>, error> => Ok([])
@@ -0,0 +1,67 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
5
+ import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
6
+
7
+ let consumedEventSchema = S.schema(s => ({
8
+ TAG: "StockReserved",
9
+ reservationId: s.m(DcbTag$Reventless.string)
10
+ }));
11
+
12
+ function evolve(_state, _event) {
13
+ return true;
14
+ }
15
+
16
+ let commandSchema = S.schema(s => ({
17
+ TAG: "ReserveStock",
18
+ reservationId: s.m(DcbTag$Reventless.partition),
19
+ customerId: s.m(Reference$Reventless.to_(undefined, undefined, "Customers")),
20
+ productIds: s.m(S.array(Reference$Reventless.to_(undefined, "productId", "AvailableProducts"))),
21
+ warehouseIds: s.m(S.option(S.array(Reference$Reventless.to_(undefined, "warehouseId", "Warehouses"))))
22
+ }));
23
+
24
+ let errorSchema = S.literal("OutOfStock");
25
+
26
+ let eventSchema = S.schema(s => ({
27
+ TAG: "StockReserved",
28
+ reservationId: s.m(DcbTag$Reventless.partition),
29
+ productIds: s.m(S.array(Reference$Reventless.to_(undefined, "productId", "AvailableProducts")))
30
+ }));
31
+
32
+ function decide(_state, _command) {
33
+ return {
34
+ TAG: "Ok",
35
+ _0: []
36
+ };
37
+ }
38
+
39
+ function commandAuthorization(param) {
40
+ return "AllowAuthenticated";
41
+ }
42
+
43
+ let name = "ReserveStock";
44
+
45
+ let Id;
46
+
47
+ let initialState = false;
48
+
49
+ let moduleUrl = "@reventlessdev/reventless-core/tests/plugin/StateChangeSlice/PsReserveStock.res.mjs";
50
+
51
+ let readConsistency = "EscalateOnRetry";
52
+
53
+ export {
54
+ name,
55
+ Id,
56
+ initialState,
57
+ consumedEventSchema,
58
+ evolve,
59
+ commandSchema,
60
+ errorSchema,
61
+ eventSchema,
62
+ decide,
63
+ moduleUrl,
64
+ commandAuthorization,
65
+ readConsistency,
66
+ }
67
+ /* consumedEventSchema Not a pure module */