@reventlessdev/reventless-core 3.0.0-alpha.234 → 3.0.0-alpha.236

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,23 @@
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.236 (2026-08-15)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core:** describe a DCB event log by every event it carries ([047bc0b](https://github.com/ReventlessDev/reventless-core/commit/047bc0beb9be1468410fde37a7f787a3d68f92d2))
11
+ ### Features
12
+
13
+ * **aws:** serve one baked manifest per journey ([16d6307](https://github.com/ReventlessDev/reventless-core/commit/16d63071f059cf1fc7025f9be9206dda51f71053))
14
+
15
+
16
+ # 3.0.0-alpha.235 (2026-08-15)
17
+
18
+ ### Features
19
+
20
+ * **core:** curate the pages a shell builds across a plugin's views ([589835a](https://github.com/ReventlessDev/reventless-core/commit/589835a1ad428c5e2dea8bf6e4c64e49d2f67e0d))
21
+
22
+
6
23
  # 3.0.0-alpha.234 (2026-08-14)
7
24
 
8
25
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.234",
3
+ "version": "3.0.0-alpha.236",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,16 +29,16 @@
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.6",
33
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
32
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.7",
33
+ "@reventlessdev/rescript-node": "2.0.0-alpha.7",
34
34
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
35
- "@reventlessdev/rescript-node": "2.0.0-alpha.6",
36
35
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
37
- "@reventlessdev/rescript-ssh2": "2.0.0-alpha.6",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.141",
39
- "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
36
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
37
+ "@reventlessdev/rescript-ssh2": "2.0.0-alpha.7",
40
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.113"
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.142",
40
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.114"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -20,6 +20,22 @@ type error =
20
20
  | UnknownPlugin(string)
21
21
  | UnknownView({plugin: string, view: string})
22
22
  | UnknownCommand({plugin: string, command: string})
23
+ | UnknownDerived({plugin: string, kind: string})
24
+
25
+ /** The pages a shell builds across a plugin's views rather than from any one of
26
+ them, as the vocabulary a deployment curates them by.
27
+
28
+ A closed set, and deliberately kinds rather than page names. A dashboard and
29
+ a lifecycle page are named by the framework, but a canvas is named by the
30
+ views it draws — from that deployment's hints and the view modes its shell
31
+ registered, neither of which this side can see. Curating by kind is the
32
+ question this side CAN answer, and it is also the question an author is
33
+ asking: "does this audience get calendars", not "does it get the one called
34
+ Deliveries".
35
+
36
+ Ordered as they appear in a menu (the dashboard tops the group, canvases and
37
+ schedulers tail it), so the vocabulary reads as the page set it curates. */
38
+ let derivedKinds = ["dashboard", "lifecycles", "canvas", "scheduler"]
23
39
 
24
40
  let describe = (e: error): string =>
25
41
  switch e {
@@ -28,16 +44,26 @@ let describe = (e: error): string =>
28
44
  `baked manifest: plugin "${plugin}" has no view named "${view}"`
29
45
  | UnknownCommand({plugin, command}) =>
30
46
  `baked manifest: plugin "${plugin}" has no command named "${command}"`
47
+ | UnknownDerived({plugin, kind}) =>
48
+ `baked manifest: plugin "${plugin}" names derived page kind "${kind}" — ` ++
49
+ `known kinds: ${derivedKinds->Array.join(", ")}`
31
50
  }
32
51
 
33
52
  // Views are named by component name — a view IS the surface. Commands are named
34
53
  // by command name, not by the write-side component that carries them: the
35
54
  // command is what a user invokes, and which slice or aggregate it lives on is an
36
55
  // implementation detail the author should not have to know to curate a shop.
56
+ //
57
+ // `derived` is neither: it names page KINDS, for the reason `derivedKinds`
58
+ // gives. Unlike the other two, what it selects is not filtered here — there is
59
+ // nothing in a `pluginStructure` to filter, because the pages do not exist until
60
+ // a shell builds them. It travels to the shell instead, which is the only place
61
+ // that can honour it.
37
62
  type selection = {
38
63
  plugin: string,
39
64
  views: option<array<string>>,
40
65
  commands: option<array<string>>,
66
+ derived: option<array<string>>,
41
67
  }
42
68
 
43
69
  let publicViews = (def: pluginStructure): array<queryableDef> =>
@@ -65,7 +91,16 @@ let validate = (~def: pluginStructure, sel: selection): result<unit, error> => {
65
91
  | None =>
66
92
  switch sel.commands->known(def->commandNames) {
67
93
  | Some(command) => Error(UnknownCommand({plugin: sel.plugin, command}))
68
- | None => Ok()
94
+ | None =>
95
+ // Against the vocabulary rather than against this plugin: a kind that
96
+ // generates no page here is a fact about the plugin's schemas, not a
97
+ // mistake — a shop naming `lifecycles` for a plugin whose views gain a
98
+ // status field next month is stating an intention, and failing it would
99
+ // punish the deployment for being early.
100
+ switch sel.derived->known(derivedKinds) {
101
+ | Some(kind) => Error(UnknownDerived({plugin: sel.plugin, kind}))
102
+ | None => Ok()
103
+ }
69
104
  }
70
105
  }
71
106
  }
@@ -169,6 +204,58 @@ let journeyKey = (~group: string): string => {
169
204
  `component-manifest-${slug}.json`
170
205
  }
171
206
 
207
+ let toSelection = (s: ReventlessInfra.Platform.bakedManifestSelection): selection => {
208
+ plugin: s.plugin,
209
+ views: s.views,
210
+ commands: s.commands,
211
+ derived: s.derived,
212
+ }
213
+
214
+ /** One declared journey, with everything a platform needs to serve it: the group
215
+ it serves, the file it is written to, and the include-list that decides the
216
+ file's contents. */
217
+ type journeyFile = {group: string, key: string, selections: array<selection>}
218
+
219
+ /**
220
+ The declared journeys, in declaration order, with their keys resolved.
221
+
222
+ The default journey is deliberately not among them: it is the file
223
+ `manifestUrl` points at, which is what a caller matching no declared group reads
224
+ and what every deployment had before journeys existed.
225
+ */
226
+ let journeyFiles = (~config: ReventlessInfra.Platform.bakedManifest): array<journeyFile> =>
227
+ config.journeys
228
+ ->Option.getOr([])
229
+ ->Array.map(j => {
230
+ group: j.group,
231
+ key: j.key->Option.getOr(journeyKey(~group=j.group)),
232
+ selections: j.components->Array.map(toSelection),
233
+ })
234
+
235
+ /**
236
+ Every file a bake declaration produces, as (key, selections) pairs.
237
+
238
+ The default journey first and always — it is what a caller matching no declared
239
+ group gets, which includes the no-bearer identity a local dev session starts
240
+ from — then one per declared journey.
241
+
242
+ Here rather than on either platform because several separate things have to
243
+ agree about this set on a deploy: the objects the bake writes, the IAM grant
244
+ that lets it write them, and the URLs `config.json` sends a caller to. A key
245
+ derived twice is a shop whose file is written where nothing fetches it, and the
246
+ symptom is an empty menu rather than a missing file.
247
+ */
248
+ let files = (
249
+ ~config: ReventlessInfra.Platform.bakedManifest,
250
+ ): array<(string, array<selection>)> =>
251
+ [
252
+ (config.key->Option.getOr(defaultKey), config.components->Array.map(toSelection)),
253
+ ]->Array.concat(journeyFiles(~config)->Array.map(j => (j.key, j.selections)))
254
+
255
+ /** Each declared journey's group and the URL a shell fetches its file from. */
256
+ let journeyUrls = (~config: ReventlessInfra.Platform.bakedManifest): array<(string, string)> =>
257
+ journeyFiles(~config)->Array.map(j => (j.group, urlForKey(Some(j.key))))
258
+
172
259
  // The whole file: one entry per selection, in the order the deployment declared
173
260
  // them, so the include-list also states the order a consumer reads plugins in.
174
261
  let curate = (
@@ -182,7 +269,11 @@ let curate = (
182
269
  | Some((pluginId, def)) =>
183
270
  curateStructure(~pluginId, ~def, sel)->Result.map(curated => {
184
271
  entries->Array.push(
185
- Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId, curated),
272
+ Platform_ComponentDefinitionsApi.encodePluginStructureEntry(
273
+ ~pluginId,
274
+ ~derived=?sel.derived,
275
+ curated,
276
+ ),
186
277
  )
187
278
  entries
188
279
  })
@@ -6,6 +6,13 @@ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
6
6
  import * as Plugin$ReventlessCore from "../plugin/component/Plugin.res.mjs";
7
7
  import * as Platform_ComponentDefinitionsApi$ReventlessCore from "./Platform_ComponentDefinitionsApi.res.mjs";
8
8
 
9
+ let derivedKinds = [
10
+ "dashboard",
11
+ "lifecycles",
12
+ "canvas",
13
+ "scheduler"
14
+ ];
15
+
9
16
  function describe(e) {
10
17
  switch (e.TAG) {
11
18
  case "UnknownPlugin" :
@@ -14,6 +21,8 @@ function describe(e) {
14
21
  return `baked manifest: plugin "` + e.plugin + `" has no view named "` + e.view + `"`;
15
22
  case "UnknownCommand" :
16
23
  return `baked manifest: plugin "` + e.plugin + `" has no command named "` + e.command + `"`;
24
+ case "UnknownDerived" :
25
+ return `baked manifest: plugin "` + e.plugin + `" names derived page kind "` + e.kind + `" — known kinds: ` + derivedKinds.join(", ");
17
26
  }
18
27
  }
19
28
 
@@ -52,6 +61,17 @@ function validate(def, sel) {
52
61
  command: command
53
62
  }
54
63
  };
64
+ }
65
+ let kind = known(sel.derived, derivedKinds);
66
+ if (kind !== undefined) {
67
+ return {
68
+ TAG: "Error",
69
+ _0: {
70
+ TAG: "UnknownDerived",
71
+ plugin: sel.plugin,
72
+ kind: kind
73
+ }
74
+ };
55
75
  } else {
56
76
  return {
57
77
  TAG: "Ok",
@@ -172,6 +192,40 @@ function journeyKey(group) {
172
192
  return `component-manifest-` + slug + `.json`;
173
193
  }
174
194
 
195
+ function toSelection(s) {
196
+ return {
197
+ plugin: s.plugin,
198
+ views: s.views,
199
+ commands: s.commands,
200
+ derived: s.derived
201
+ };
202
+ }
203
+
204
+ function journeyFiles(config) {
205
+ return Stdlib_Option.getOr(config.journeys, []).map(j => ({
206
+ group: j.group,
207
+ key: Stdlib_Option.getOr(j.key, journeyKey(j.group)),
208
+ selections: j.components.map(toSelection)
209
+ }));
210
+ }
211
+
212
+ function files(config) {
213
+ return [[
214
+ Stdlib_Option.getOr(config.key, defaultKey),
215
+ config.components.map(toSelection)
216
+ ]].concat(journeyFiles(config).map(j => [
217
+ j.key,
218
+ j.selections
219
+ ]));
220
+ }
221
+
222
+ function journeyUrls(config) {
223
+ return journeyFiles(config).map(j => [
224
+ j.group,
225
+ urlForKey(j.key)
226
+ ]);
227
+ }
228
+
175
229
  function curate(structures, selections) {
176
230
  return Stdlib_Result.map(Stdlib_Array.reduce(selections, {
177
231
  TAG: "Ok",
@@ -189,13 +243,14 @@ function curate(structures, selections) {
189
243
  }
190
244
  let pluginId = match[0];
191
245
  return Stdlib_Result.map(curateStructure(pluginId, match[1], sel), curated => {
192
- entries.push(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(pluginId, curated));
246
+ entries.push(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(pluginId, sel.derived, curated));
193
247
  return entries;
194
248
  });
195
249
  })), prim => prim);
196
250
  }
197
251
 
198
252
  export {
253
+ derivedKinds,
199
254
  describe,
200
255
  publicViews,
201
256
  writeSides,
@@ -207,6 +262,10 @@ export {
207
262
  defaultKey,
208
263
  urlForKey,
209
264
  journeyKey,
265
+ toSelection,
266
+ journeyFiles,
267
+ files,
268
+ journeyUrls,
210
269
  curate,
211
270
  }
212
271
  /* No side effect */
@@ -207,8 +207,21 @@ let encodeExtensionDef = (e: extensionDef): JSON.t =>
207
207
  ("commandTypes", encodeStrings(e.commandTypes)),
208
208
  ])->JSON.Encode.object
209
209
 
210
- let encodePluginStructureEntry = (~pluginId: string, def: pluginStructure): JSON.t =>
211
- Dict.fromArray([
210
+ // `derived` is curation, not structure: it says which of the pages a shell
211
+ // builds across this plugin's views that shell may build. It therefore arrives
212
+ // as an argument rather than off `def` — nothing in a compiled plugin decides
213
+ // it, and the admin path, which curates nothing, passes none.
214
+ //
215
+ // Absent key ⇒ every kind, which is what an uncurated entry means and what
216
+ // every manifest written before the field says. A curated entry that wants none
217
+ // says so with `[]`, and the two are only distinguishable because the key is
218
+ // omitted rather than nulled.
219
+ let encodePluginStructureEntry = (
220
+ ~pluginId: string,
221
+ ~derived: option<array<string>>=?,
222
+ def: pluginStructure,
223
+ ): JSON.t => {
224
+ let entry = Dict.fromArray([
212
225
  ("pluginId", JSON.Encode.string(Plugin.name(pluginId))),
213
226
  (
214
227
  "readModels",
@@ -247,4 +260,9 @@ let encodePluginStructureEntry = (~pluginId: string, def: pluginStructure): JSON
247
260
  ->JSON.Encode.array,
248
261
  ),
249
262
  ("extensions", def.extensions->Array.map(encodeExtensionDef)->JSON.Encode.array),
250
- ])->JSON.Encode.object
263
+ ])
264
+ derived->Option.forEach(kinds =>
265
+ entry->Dict.set("derivedPages", kinds->Array.map(JSON.Encode.string)->JSON.Encode.array)
266
+ )
267
+ entry->JSON.Encode.object
268
+ }
@@ -339,8 +339,8 @@ function encodeExtensionDef(e) {
339
339
  ]);
340
340
  }
341
341
 
342
- function encodePluginStructureEntry(pluginId, def) {
343
- return Object.fromEntries([
342
+ function encodePluginStructureEntry(pluginId, derived, def) {
343
+ let entry = Object.fromEntries([
344
344
  [
345
345
  "pluginId",
346
346
  Plugin$ReventlessCore.name(pluginId)
@@ -382,6 +382,10 @@ function encodePluginStructureEntry(pluginId, def) {
382
382
  def.extensions.map(encodeExtensionDef)
383
383
  ]
384
384
  ]);
385
+ Stdlib_Option.forEach(derived, kinds => {
386
+ entry["derivedPages"] = kinds.map(prim => prim);
387
+ });
388
+ return entry;
385
389
  }
386
390
 
387
391
  let sdlQueryField = ` Platform_ComponentDefinitions: [Platform_ComponentDefinitionEntry!]!`;
@@ -21,6 +21,52 @@ let toRelativePath: string => string = %raw(`function toRelativePath(moduleUrl)
21
21
  }
22
22
  }`)
23
23
 
24
+ // The tag of one compiled variant arm: `{TAG: "<Name>", …}` for a payload-bearing
25
+ // constructor, a bare string const for a payload-less one.
26
+ let variantTag = (variant: S.t<unknown>) =>
27
+ switch variant {
28
+ | S.Object({properties}) =>
29
+ switch properties->Dict.get("TAG") {
30
+ | Some(String({const: ?Some(tag)})) => Some(tag)
31
+ | _ => None
32
+ }
33
+ | S.String({const: ?Some(tag)}) => Some(tag)
34
+ | _ => None
35
+ }
36
+
37
+ /**
38
+ The event schema of a DCB event log: one union over every event its slices produce.
39
+
40
+ Flattened, not nested. Every reader of an `eventLogSchemaEntry` walks exactly one
41
+ level — `Union` → per-variant `Object` with a `TAG` const — so a union OF unions
42
+ would present each slice as an arm with no tag and read as a log carrying no
43
+ event types at all. Arms are deduped by tag because two slices producing the same
44
+ event would otherwise hand sury two arms with one discriminant.
45
+ */
46
+ let mergedEventSchema = (schemas: array<S.t<unknown>>) =>
47
+ switch schemas {
48
+ | [single] => single
49
+ | many =>
50
+ let seen = Set.make()
51
+ many
52
+ ->Array.flatMap(schema =>
53
+ switch schema {
54
+ | S.Union({anyOf}) => anyOf
55
+ | other => [other]
56
+ }
57
+ )
58
+ ->Array.filter(arm =>
59
+ switch arm->variantTag {
60
+ | Some(tag) if seen->Set.has(tag) => false
61
+ | Some(tag) =>
62
+ seen->Set.add(tag)
63
+ true
64
+ | None => true
65
+ }
66
+ )
67
+ ->S.union
68
+ }
69
+
24
70
  type dcbResult = {
25
71
  dcbEventLogOutputs: option<DcbEventLog.outputs>,
26
72
  stateChangeSlicesOutputs: dict<StateChangeSlice.outputs>,
@@ -1047,7 +1093,11 @@ module Make = (
1047
1093
  }
1048
1094
  })
1049
1095
 
1050
- // Collect all event schemas from produced events for eventLogEntries
1096
+ // Every event this plugin's slices produce lands in the one DCB event log,
1097
+ // so the log's schema entry is the union of all of them. Taking the first
1098
+ // slice's schema as representative left the readers of that entry — the
1099
+ // upload claimer's `CLAIM_REF_FIELDS`, MCP's event-history resource —
1100
+ // describing one slice and silently blind to the rest.
1051
1101
  let allProducedSchemas =
1052
1102
  stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) =>
1053
1103
  Sc.Spec.eventSchema->S.castToUnknown
@@ -1102,12 +1152,11 @@ module Make = (
1102
1152
  ->Array.concat(outboundEntries)
1103
1153
  ->Array.concat(inboundEntries),
1104
1154
  eventLogEntries: if allProducedSchemas->Array.length > 0 {
1105
- // Use the first produced event schema as representative for the event log
1106
1155
  [
1107
1156
  {
1108
1157
  ReventlessInfra.Api.busKey: name ++ "DcbEventLog",
1109
1158
  displayName: name,
1110
- eventSchema: allProducedSchemas->Array.getUnsafe(0),
1159
+ eventSchema: allProducedSchemas->mergedEventSchema,
1111
1160
  },
1112
1161
  ]
1113
1162
  } else {
@@ -48,6 +48,60 @@ let toRelativePath = (function toRelativePath(moduleUrl) {
48
48
  }
49
49
  });
50
50
 
51
+ function variantTag(variant) {
52
+ switch (variant.type) {
53
+ case "string" :
54
+ let tag = variant.const;
55
+ if (tag !== undefined) {
56
+ return tag;
57
+ } else {
58
+ return;
59
+ }
60
+ case "object" :
61
+ let match = variant.properties["TAG"];
62
+ if (match === undefined) {
63
+ return;
64
+ }
65
+ if (match.type !== "string") {
66
+ return;
67
+ }
68
+ let tag$1 = match.const;
69
+ if (tag$1 !== undefined) {
70
+ return tag$1;
71
+ } else {
72
+ return;
73
+ }
74
+ default:
75
+ return;
76
+ }
77
+ }
78
+
79
+ function mergedEventSchema(schemas) {
80
+ if (schemas.length === 1) {
81
+ return schemas[0];
82
+ }
83
+ let seen = new Set();
84
+ return S.union(schemas.flatMap(schema => {
85
+ if (schema.type === "union") {
86
+ return schema.anyOf;
87
+ } else {
88
+ return [schema];
89
+ }
90
+ }).filter(arm => {
91
+ let tag = variantTag(arm);
92
+ if (tag !== undefined) {
93
+ if (seen.has(tag)) {
94
+ return false;
95
+ } else {
96
+ seen.add(tag);
97
+ return true;
98
+ }
99
+ } else {
100
+ return true;
101
+ }
102
+ }));
103
+ }
104
+
51
105
  let emptyResult_stateChangeSlicesOutputs = {};
52
106
 
53
107
  let emptyResult_stateViewSlicesOutputs = {};
@@ -583,7 +637,7 @@ function Make(DcbEventLogStorage) {
583
637
  eventLogEntries: allProducedSchemas.length !== 0 ? [{
584
638
  busKey: name + "DcbEventLog",
585
639
  displayName: name,
586
- eventSchema: allProducedSchemas[0]
640
+ eventSchema: mergedEventSchema(allProducedSchemas)
587
641
  }] : []
588
642
  };
589
643
  };
@@ -596,6 +650,8 @@ function Make(DcbEventLogStorage) {
596
650
  export {
597
651
  log,
598
652
  toRelativePath,
653
+ variantTag,
654
+ mergedEventSchema,
599
655
  emptyResult,
600
656
  Make,
601
657
  }
@@ -89,6 +89,15 @@ let structures = [("Ordering@1.0.0", structure)]
89
89
  let bake = selections =>
90
90
  Platform_BakedManifest.curate(~structures, ~selections)
91
91
 
92
+ // Every case below varies one include-list and means "everything" by the rest,
93
+ // which is exactly what an omitted labelled argument says.
94
+ let sel = (~plugin, ~views=?, ~commands=?, ~derived=?): Platform_BakedManifest.selection => {
95
+ plugin,
96
+ views,
97
+ commands,
98
+ derived,
99
+ }
100
+
92
101
  let entries = (json: JSON.t) => json->JSON.Decode.array->Option.getOr([])
93
102
 
94
103
  let names = (entry: JSON.t, field: string) =>
@@ -110,19 +119,17 @@ let firstEntry = (r: result<JSON.t, Platform_BakedManifest.error>) =>
110
119
  describe("curate", () => {
111
120
  testSync("selects exactly the named components", () => {
112
121
  let entry =
113
- bake([{plugin: "Ordering", views: Some(["Orders"]), commands: Some(["PlaceOrder"])}])
114
- ->firstEntry
122
+ bake([sel(~plugin="Ordering", ~views=["Orders"], ~commands=["PlaceOrder"])])->firstEntry
115
123
  expect((entry->names("stateViewSlices"), entry->names("readModels")))->toEqual((["Orders"], []))
116
124
  })
117
125
 
118
126
  testSync("drops a write side whose commands were all excluded", () => {
119
- let entry =
120
- bake([{plugin: "Ordering", views: Some([]), commands: Some(["PlaceOrder"])}])->firstEntry
127
+ let entry = bake([sel(~plugin="Ordering", ~views=[], ~commands=["PlaceOrder"])])->firstEntry
121
128
  expect(entry->names("stateChangeSlices"))->toEqual(["PlaceOrder"])
122
129
  })
123
130
 
124
131
  testSync("unset views and commands include every public component", () => {
125
- let entry = bake([{plugin: "Ordering", views: None, commands: None}])->firstEntry
132
+ let entry = bake([sel(~plugin="Ordering")])->firstEntry
126
133
  expect((
127
134
  entry->names("readModels"),
128
135
  entry->names("stateViewSlices"),
@@ -134,34 +141,72 @@ describe("curate", () => {
134
141
  // from, so an Internal view a kept command points at has to survive the bake.
135
142
  testSync("carries an Internal view referenced by an included command", () => {
136
143
  let entry =
137
- bake([{plugin: "Ordering", views: Some(["Orders"]), commands: Some(["PlaceOrder"])}])
144
+ bake([sel(~plugin="Ordering", ~views=["Orders"], ~commands=["PlaceOrder"])])
138
145
  ->firstEntry
139
146
  expect(entry->names("internalQueryables"))->toEqual(["AvailableProducts"])
140
147
  })
141
148
 
142
149
  testSync("omits an Internal view no included command references", () => {
143
150
  let entry =
144
- bake([{plugin: "Ordering", views: Some(["Orders"]), commands: Some(["ImportOrders"])}])
151
+ bake([sel(~plugin="Ordering", ~views=["Orders"], ~commands=["ImportOrders"])])
145
152
  ->firstEntry
146
153
  expect(entry->names("internalQueryables"))->toEqual([])
147
154
  })
148
155
 
149
- testSync("an Internal view is never selectable as a view", () =>
156
+ // The pages a shell builds across a plugin's views do not exist on this side,
157
+ // so what the bake can do about them is state which kinds an audience gets and
158
+ // carry that to the shell. Absent and empty therefore have to stay two
159
+ // different answers all the way out to the JSON, which is what these three
160
+ // cases pin: nothing said ⇒ no key ⇒ every kind, and `[]` ⇒ a key ⇒ none.
161
+ let derivedOf = (entry: JSON.t) =>
162
+ entry
163
+ ->JSON.Decode.object
164
+ ->Option.flatMap(d => d->Dict.get("derivedPages"))
165
+ ->Option.map(v => v->JSON.Decode.array->Option.getOr([])->Array.filterMap(JSON.Decode.string))
166
+
167
+ testSync("unset derived says nothing, leaving every kind to the shell", () =>
168
+ expect(bake([sel(~plugin="Ordering")])->firstEntry->derivedOf)->toEqual(None)
169
+ )
170
+
171
+ testSync("an empty derived list is carried, and is not the same as unset", () =>
172
+ expect(bake([sel(~plugin="Ordering", ~derived=[])])->firstEntry->derivedOf)->toEqual(Some([]))
173
+ )
174
+
175
+ testSync("carries exactly the derived kinds named", () =>
176
+ expect(
177
+ bake([sel(~plugin="Ordering", ~derived=["lifecycles", "canvas"])])->firstEntry->derivedOf,
178
+ )->toEqual(Some(["lifecycles", "canvas"]))
179
+ )
180
+
181
+ // Against the vocabulary, never against this plugin: a kind that generates no
182
+ // page for these schemas is a fact about the plugin, not a typo, and a shop
183
+ // that names `lifecycles` before its views carry a status is early, not wrong.
184
+ testSync("fails naming a derived kind outside the vocabulary", () =>
185
+ expect(bake([sel(~plugin="Ordering", ~derived=["dashboards"])]))->toEqual(
186
+ Error(Platform_BakedManifest.UnknownDerived({plugin: "Ordering", kind: "dashboards"})),
187
+ )
188
+ )
189
+
190
+ testSync("accepts a kind this plugin generates no page for", () =>
150
191
  expect(
151
- bake([{plugin: "Ordering", views: Some(["AvailableProducts"]), commands: None}]),
152
- )->toEqual(
192
+ bake([sel(~plugin="Ordering", ~derived=["scheduler"])])->firstEntry->derivedOf,
193
+ )->toEqual(Some(["scheduler"]))
194
+ )
195
+
196
+ testSync("an Internal view is never selectable as a view", () =>
197
+ expect(bake([sel(~plugin="Ordering", ~views=["AvailableProducts"])]))->toEqual(
153
198
  Error(Platform_BakedManifest.UnknownView({plugin: "Ordering", view: "AvailableProducts"})),
154
199
  )
155
200
  )
156
201
 
157
202
  testSync("fails naming an unknown plugin", () =>
158
- expect(bake([{plugin: "Shipping", views: None, commands: None}]))->toEqual(
203
+ expect(bake([sel(~plugin="Shipping")]))->toEqual(
159
204
  Error(Platform_BakedManifest.UnknownPlugin("Shipping")),
160
205
  )
161
206
  )
162
207
 
163
208
  testSync("fails naming an unknown command", () =>
164
- expect(bake([{plugin: "Ordering", views: None, commands: Some(["CancelOrder"])}]))->toEqual(
209
+ expect(bake([sel(~plugin="Ordering", ~commands=["CancelOrder"])]))->toEqual(
165
210
  Error(Platform_BakedManifest.UnknownCommand({plugin: "Ordering", command: "CancelOrder"})),
166
211
  )
167
212
  )
@@ -169,7 +214,7 @@ describe("curate", () => {
169
214
  // The anti-drift assertion: include everything and the baked entry IS the
170
215
  // served entry. Any future divergence in the encoder shows up here first.
171
216
  testSync("an all-inclusive bake is byte-identical to the served entry", () => {
172
- let baked = bake([{plugin: "Ordering", views: None, commands: None}])->firstEntry
217
+ let baked = bake([sel(~plugin="Ordering")])->firstEntry
173
218
  let served = Platform_ComponentDefinitionsApi.encodePluginStructureEntry(
174
219
  ~pluginId="Ordering@1.0.0",
175
220
  structure,
@@ -182,8 +227,8 @@ describe("curate", () => {
182
227
  Platform_BakedManifest.curate(
183
228
  ~structures=[("Ordering@1.0.0", structure), ("Catalog@1.0.0", structure)],
184
229
  ~selections=[
185
- {plugin: "Catalog", views: Some([]), commands: Some([])},
186
- {plugin: "Ordering", views: Some([]), commands: Some([])},
230
+ sel(~plugin="Catalog", ~views=[], ~commands=[]),
231
+ sel(~plugin="Ordering", ~views=[], ~commands=[]),
187
232
  ],
188
233
  )
189
234
  let ids =
@@ -208,7 +253,7 @@ describe("curate", () => {
208
253
  // makes this hold — the assertion guards that property, not a second code path.
209
254
  testSync("a curated entry carries the owner field the served entry carries", () => {
210
255
  let entry =
211
- bake([{plugin: "Ordering", views: Some(["Orders"]), commands: Some(["PlaceOrder"])}])
256
+ bake([sel(~plugin="Ordering", ~views=["Orders"], ~commands=["PlaceOrder"])])
212
257
  ->firstEntry
213
258
  let ownerOf = (key, name) =>
214
259
  entry
@@ -109,6 +109,15 @@ function bake(selections) {
109
109
  return Platform_BakedManifest$ReventlessCore.curate(structures, selections);
110
110
  }
111
111
 
112
+ function sel(plugin, views, commands, derived) {
113
+ return {
114
+ plugin: plugin,
115
+ views: views,
116
+ commands: commands,
117
+ derived: derived
118
+ };
119
+ }
120
+
112
121
  function entries(json) {
113
122
  return Stdlib_Option.getOr(Stdlib_JSON.Decode.array(json), []);
114
123
  }
@@ -127,11 +136,7 @@ function firstEntry(r) {
127
136
 
128
137
  globalThis.describe("curate", () => {
129
138
  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
- }]));
139
+ let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", ["Orders"], ["PlaceOrder"], undefined)]));
135
140
  globalThis.expect([
136
141
  names(entry, "stateViewSlices"),
137
142
  names(entry, "readModels")
@@ -141,18 +146,15 @@ globalThis.describe("curate", () => {
141
146
  ]);
142
147
  });
143
148
  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
+ let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", [], ["PlaceOrder"], undefined)]));
149
150
  globalThis.expect(names(entry, "stateChangeSlices")).toEqual(["PlaceOrder"]);
150
151
  });
151
152
  globalThis.test("unset views and commands include every public component", () => {
152
153
  let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
153
154
  plugin: "Ordering",
154
155
  views: undefined,
155
- commands: undefined
156
+ commands: undefined,
157
+ derived: undefined
156
158
  }]));
157
159
  globalThis.expect([
158
160
  names(entry, "readModels"),
@@ -168,27 +170,49 @@ globalThis.describe("curate", () => {
168
170
  ]);
169
171
  });
170
172
  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
- }]));
173
+ let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", ["Orders"], ["PlaceOrder"], undefined)]));
176
174
  globalThis.expect(names(entry, "internalQueryables")).toEqual(["AvailableProducts"]);
177
175
  });
178
176
  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
- }]));
177
+ let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", ["Orders"], ["ImportOrders"], undefined)]));
184
178
  globalThis.expect(names(entry, "internalQueryables")).toEqual([]);
185
179
  });
186
- globalThis.test("an Internal view is never selectable as a view", () => {
187
- globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [{
180
+ let derivedOf = entry => Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(entry), d => d["derivedPages"]), v => Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_JSON.Decode.array(v), []), Stdlib_JSON.Decode.string));
181
+ globalThis.test("unset derived says nothing, leaving every kind to the shell", () => {
182
+ globalThis.expect(derivedOf(firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
188
183
  plugin: "Ordering",
189
- views: ["AvailableProducts"],
190
- commands: undefined
191
- }])).toEqual({
184
+ views: undefined,
185
+ commands: undefined,
186
+ derived: undefined
187
+ }])))).toEqual(undefined);
188
+ });
189
+ globalThis.test("an empty derived list is carried, and is not the same as unset", () => {
190
+ globalThis.expect(derivedOf(firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", undefined, undefined, [])])))).toEqual([]);
191
+ });
192
+ globalThis.test("carries exactly the derived kinds named", () => {
193
+ globalThis.expect(derivedOf(firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", undefined, undefined, [
194
+ "lifecycles",
195
+ "canvas"
196
+ ])])))).toEqual([
197
+ "lifecycles",
198
+ "canvas"
199
+ ]);
200
+ });
201
+ globalThis.test("fails naming a derived kind outside the vocabulary", () => {
202
+ globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", undefined, undefined, ["dashboards"])])).toEqual({
203
+ TAG: "Error",
204
+ _0: {
205
+ TAG: "UnknownDerived",
206
+ plugin: "Ordering",
207
+ kind: "dashboards"
208
+ }
209
+ });
210
+ });
211
+ globalThis.test("accepts a kind this plugin generates no page for", () => {
212
+ globalThis.expect(derivedOf(firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", undefined, undefined, ["scheduler"])])))).toEqual(["scheduler"]);
213
+ });
214
+ globalThis.test("an Internal view is never selectable as a view", () => {
215
+ globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", ["AvailableProducts"], undefined, undefined)])).toEqual({
192
216
  TAG: "Error",
193
217
  _0: {
194
218
  TAG: "UnknownView",
@@ -201,7 +225,8 @@ globalThis.describe("curate", () => {
201
225
  globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [{
202
226
  plugin: "Shipping",
203
227
  views: undefined,
204
- commands: undefined
228
+ commands: undefined,
229
+ derived: undefined
205
230
  }])).toEqual({
206
231
  TAG: "Error",
207
232
  _0: {
@@ -211,11 +236,7 @@ globalThis.describe("curate", () => {
211
236
  });
212
237
  });
213
238
  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({
239
+ globalThis.expect(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", undefined, ["CancelOrder"], undefined)])).toEqual({
219
240
  TAG: "Error",
220
241
  _0: {
221
242
  TAG: "UnknownCommand",
@@ -228,9 +249,10 @@ globalThis.describe("curate", () => {
228
249
  let baked = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [{
229
250
  plugin: "Ordering",
230
251
  views: undefined,
231
- commands: undefined
252
+ commands: undefined,
253
+ derived: undefined
232
254
  }]));
233
- let served = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering@1.0.0", structure);
255
+ let served = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering@1.0.0", undefined, structure);
234
256
  globalThis.expect(JSON.stringify(baked)).toEqual(JSON.stringify(served));
235
257
  });
236
258
  globalThis.test("one entry per selection, in declaration order", () => {
@@ -244,16 +266,8 @@ globalThis.describe("curate", () => {
244
266
  structure
245
267
  ]
246
268
  ], [
247
- {
248
- plugin: "Catalog",
249
- views: [],
250
- commands: []
251
- },
252
- {
253
- plugin: "Ordering",
254
- views: [],
255
- commands: []
256
- }
269
+ sel("Catalog", [], [], undefined),
270
+ sel("Ordering", [], [], undefined)
257
271
  ]);
258
272
  let ids;
259
273
  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)) : [];
@@ -263,11 +277,7 @@ globalThis.describe("curate", () => {
263
277
  ]);
264
278
  });
265
279
  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
- }]));
280
+ let entry = firstEntry(Platform_BakedManifest$ReventlessCore.curate(structures, [sel("Ordering", ["Orders"], ["PlaceOrder"], undefined)]));
271
281
  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
282
  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
283
  globalThis.expect([
@@ -287,6 +297,7 @@ export {
287
297
  structure,
288
298
  structures,
289
299
  bake,
300
+ sel,
290
301
  entries,
291
302
  names,
292
303
  firstEntry,
@@ -147,7 +147,7 @@ let structure = {
147
147
  requiredStoreDeclarations: undefined
148
148
  };
149
149
 
150
- let encoded = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", structure);
150
+ let encoded = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", undefined, structure);
151
151
 
152
152
  let json = JSON.stringify(encoded);
153
153
 
@@ -255,7 +255,7 @@ globalThis.describe("translation-slice externalSystem round-trip", () => {
255
255
  requiredStores: undefined,
256
256
  requiredStoreDeclarations: undefined
257
257
  };
258
- let externalJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", externalStructure));
258
+ let externalJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", undefined, externalStructure));
259
259
  globalThis.test("surfaces Some outbound externalSystem as its inner string", () => {
260
260
  globalThis.expect(externalJson.includes("\"externalSystem\":\"ShipperGateway\"")).toEqual(true);
261
261
  });
@@ -341,7 +341,7 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
341
341
  requiredStores: undefined,
342
342
  requiredStoreDeclarations: undefined
343
343
  };
344
- let mixedEntry = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", mixed);
344
+ let mixedEntry = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", undefined, mixed);
345
345
  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));
346
346
  globalThis.test("excludes an Internal queryableDef from the encoded read-side", () => {
347
347
  globalThis.expect(namesAt(mixedEntry, "readModels")).toEqual(["Products"]);
@@ -357,7 +357,7 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
357
357
  ]);
358
358
  });
359
359
  globalThis.test("encodes internalQueryables as [] for an all-Public entry, not a missing key", () => {
360
- let entry = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", {
360
+ let entry = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", undefined, {
361
361
  readModels: [qbl],
362
362
  stateViewSlices: [],
363
363
  stateChangeSlices: structure_stateChangeSlices,
@@ -459,7 +459,7 @@ globalThis.describe("allowedStates + statusField populated", () => {
459
459
  requiredStores: undefined,
460
460
  requiredStoreDeclarations: undefined
461
461
  };
462
- let json = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Platform", structureWithStates));
462
+ let json = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Platform", undefined, structureWithStates));
463
463
  globalThis.test("encodes populated allowedStates as a JSON array", () => {
464
464
  globalThis.expect(json.includes("\"allowedStates\":[\"Inactive\"]")).toEqual(true);
465
465
  });
@@ -481,7 +481,7 @@ globalThis.describe("singleQueryField reaches the caller", () => {
481
481
  globalThis.expect(json.includes("\"singleQueryField\":\"Catalog_Product\"")).toEqual(true);
482
482
  });
483
483
  globalThis.test("encodes an unstated singular as null", () => {
484
- let unstated = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", {
484
+ let unstated = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", undefined, {
485
485
  readModels: [{
486
486
  name: "Products",
487
487
  queryField: "Catalog_Products",
@@ -526,7 +526,7 @@ globalThis.describe("idField / idFieldSource reach the caller", () => {
526
526
  globalThis.expect(json.includes("\"idFieldSource\":\"convention\"")).toEqual(true);
527
527
  });
528
528
  globalThis.test("encodes an unresolved key as null on both fields", () => {
529
- let unresolved = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", {
529
+ let unresolved = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", undefined, {
530
530
  readModels: [{
531
531
  name: "Products",
532
532
  queryField: "Catalog_Products",
@@ -0,0 +1,77 @@
1
+ // What a DCB event log tells its readers it carries.
2
+ //
3
+ // A plugin's slices all append to ONE log, but each slice declares only its own
4
+ // events. The log's schema entry used to be the first slice's schema, taken as
5
+ // representative — and every reader of that entry believed it: the upload
6
+ // claimer granted and untagged refs for one slice's events, and MCP described
7
+ // the log's history as carrying that slice's event types and no others. Nothing
8
+ // failed; the rest was simply invisible, which for the claimer means objects
9
+ // that keep their pending tag and expire while an event references them.
10
+ //
11
+ // So the assertions here are about completeness, and about the shape that
12
+ // completeness has to arrive in: readers walk exactly one level, `Union` →
13
+ // per-variant `Object` with a `TAG`, so a union of unions would be as blind as
14
+ // the single schema it replaced while looking like it covered everything.
15
+
16
+ open JestGlobals
17
+
18
+ let variant = (~tag: string, fields: array<(string, S.t<unknown>)>): S.t<unknown> =>
19
+ S.object(s => {
20
+ let _ = s.field("TAG", S.literal(tag)->S.castToUnknown)
21
+ fields->Array.forEach(((name, schema)) => {
22
+ let _ = s.field(name, schema)
23
+ })
24
+ ()
25
+ })->S.castToUnknown
26
+
27
+ let ref_ = (~store: string) => Reventless.StorageRef.forStore(~store)->S.castToUnknown
28
+
29
+ let addCategory = S.union([
30
+ variant(~tag="CategoryAdded", [("imageUrl", ref_(~store="categoryImages"))]),
31
+ ])->S.castToUnknown
32
+
33
+ let addProduct = S.union([
34
+ variant(~tag="ProductAdded", [("imageUrl", ref_(~store="productImages"))]),
35
+ ])->S.castToUnknown
36
+
37
+ let renameCategory = S.union([
38
+ variant(~tag="CategoryRenamed", [("name", S.string->S.castToUnknown)]),
39
+ ])->S.castToUnknown
40
+
41
+ describe("Dcb_Builder.mergedEventSchema", () => {
42
+ testSync("carries every slice's event types, not the first slice's", () => {
43
+ let merged = [addCategory, addProduct, renameCategory]->Dcb_Builder.mergedEventSchema
44
+ expect(Reventless.DcbTag.extractVariantNames(merged))->toEqual([
45
+ "CategoryAdded",
46
+ "ProductAdded",
47
+ "CategoryRenamed",
48
+ ])
49
+ })
50
+
51
+ // The claimer's whole input. One slice short here is a store's objects
52
+ // expiring under a live reference.
53
+ testSync("surfaces the ref fields of every slice that declares one", () => {
54
+ let merged = [addCategory, addProduct, renameCategory]->Dcb_Builder.mergedEventSchema
55
+ expect(
56
+ StorageRefFields.fromEventSchema(~plugin="Catalog", merged)->Array.map(e => (
57
+ e.eventType,
58
+ e.fields->Array.map(f => f.store),
59
+ )),
60
+ )->toEqual([
61
+ ("CategoryAdded", ["Catalog.categoryImages"]),
62
+ ("ProductAdded", ["Catalog.productImages"]),
63
+ ])
64
+ })
65
+
66
+ // Two slices producing the same event would otherwise hand sury two arms
67
+ // under one discriminant, and every reader a duplicate.
68
+ testSync("keeps one arm per event type", () => {
69
+ let merged = [addProduct, addProduct]->Dcb_Builder.mergedEventSchema
70
+ expect(Reventless.DcbTag.extractVariantNames(merged))->toEqual(["ProductAdded"])
71
+ })
72
+
73
+ testSync("leaves a lone slice's schema as it is", () => {
74
+ let merged = [addProduct]->Dcb_Builder.mergedEventSchema
75
+ expect(Reventless.DcbTag.extractVariantNames(merged))->toEqual(["ProductAdded"])
76
+ })
77
+ })
@@ -0,0 +1,90 @@
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 StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
6
+ import * as Dcb_Builder$ReventlessCore from "../../src/components/Dcb/Dcb_Builder.res.mjs";
7
+ import * as StorageRefFields$ReventlessCore from "../../src/components/Api/StorageRefFields.res.mjs";
8
+
9
+ function variant(tag, fields) {
10
+ return S.object(s => {
11
+ s.f("TAG", S.literal(tag));
12
+ fields.forEach(param => {
13
+ s.f(param[0], param[1]);
14
+ });
15
+ });
16
+ }
17
+
18
+ function ref_(store) {
19
+ return StorageRef$Reventless.forStore(undefined, store);
20
+ }
21
+
22
+ let addCategory = S.union([variant("CategoryAdded", [[
23
+ "imageUrl",
24
+ StorageRef$Reventless.forStore(undefined, "categoryImages")
25
+ ]])]);
26
+
27
+ let addProduct = S.union([variant("ProductAdded", [[
28
+ "imageUrl",
29
+ StorageRef$Reventless.forStore(undefined, "productImages")
30
+ ]])]);
31
+
32
+ let renameCategory = S.union([variant("CategoryRenamed", [[
33
+ "name",
34
+ S.string
35
+ ]])]);
36
+
37
+ globalThis.describe("Dcb_Builder.mergedEventSchema", () => {
38
+ globalThis.test("carries every slice's event types, not the first slice's", () => {
39
+ let merged = Dcb_Builder$ReventlessCore.mergedEventSchema([
40
+ addCategory,
41
+ addProduct,
42
+ renameCategory
43
+ ]);
44
+ globalThis.expect(DcbTag$Reventless.extractVariantNames(merged)).toEqual([
45
+ "CategoryAdded",
46
+ "ProductAdded",
47
+ "CategoryRenamed"
48
+ ]);
49
+ });
50
+ globalThis.test("surfaces the ref fields of every slice that declares one", () => {
51
+ let merged = Dcb_Builder$ReventlessCore.mergedEventSchema([
52
+ addCategory,
53
+ addProduct,
54
+ renameCategory
55
+ ]);
56
+ globalThis.expect(StorageRefFields$ReventlessCore.fromEventSchema("Catalog", merged).map(e => [
57
+ e.eventType,
58
+ e.fields.map(f => f.store)
59
+ ])).toEqual([
60
+ [
61
+ "CategoryAdded",
62
+ ["Catalog.categoryImages"]
63
+ ],
64
+ [
65
+ "ProductAdded",
66
+ ["Catalog.productImages"]
67
+ ]
68
+ ]);
69
+ });
70
+ globalThis.test("keeps one arm per event type", () => {
71
+ let merged = Dcb_Builder$ReventlessCore.mergedEventSchema([
72
+ addProduct,
73
+ addProduct
74
+ ]);
75
+ globalThis.expect(DcbTag$Reventless.extractVariantNames(merged)).toEqual(["ProductAdded"]);
76
+ });
77
+ globalThis.test("leaves a lone slice's schema as it is", () => {
78
+ let merged = Dcb_Builder$ReventlessCore.mergedEventSchema([addProduct]);
79
+ globalThis.expect(DcbTag$Reventless.extractVariantNames(merged)).toEqual(["ProductAdded"]);
80
+ });
81
+ });
82
+
83
+ export {
84
+ variant,
85
+ ref_,
86
+ addCategory,
87
+ addProduct,
88
+ renameCategory,
89
+ }
90
+ /* addCategory Not a pure module */
@@ -120,7 +120,7 @@ globalThis.describe("requiredAccess derived from the authorization rule", () =>
120
120
  globalThis.expect(viewNamed("Orders")).toEqual(undefined);
121
121
  });
122
122
  globalThis.test("nothing to require encodes as null, not an empty list", () => {
123
- let json = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("AccessPlugin", structure));
123
+ let json = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("AccessPlugin", undefined, structure));
124
124
  globalThis.expect([
125
125
  json.includes(`"requiredAccess":null`),
126
126
  json.includes(`"requiredAccess":["Admin","Ops"]`),