@reventlessdev/reventless-core 3.0.0-alpha.238 → 3.0.0-alpha.240

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 (178) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +12 -12
  3. package/src/Message.res +11 -11
  4. package/src/Message.res.mjs +15 -14
  5. package/src/Projection.res +2 -2
  6. package/src/Projection.res.mjs +5 -4
  7. package/src/adapter/Monitoring/Monitoring.res +24 -2
  8. package/src/adapter/Monitoring/Monitoring.res.mjs +3 -3
  9. package/src/admin/Platform_AdminApi.res.mjs +3 -3
  10. package/src/admin/Platform_Admin_Structure.res +33 -112
  11. package/src/admin/Platform_Admin_Structure.res.mjs +8 -138
  12. package/src/admin/Platform_ComponentDefinitionsApi.res +2 -1
  13. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  14. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs +12 -12
  15. package/src/admin/UiFragmentRegistry/StateViewSlice/UiFragments.res.mjs +14 -14
  16. package/src/components/Aggregate/Aggregate_Callback.res +2 -2
  17. package/src/components/Aggregate/Aggregate_Callback.res.mjs +4 -4
  18. package/src/components/Api/ApiAllowedStatesHelpers.res.mjs +3 -3
  19. package/src/components/Api/ApiNoApiHelpers.res.mjs +5 -5
  20. package/src/components/Api/ApiTargetStateHelpers.res.mjs +3 -3
  21. package/src/components/Api/GraphQL_FragmentGenerator.res +140 -24
  22. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +48 -21
  23. package/src/components/Api/GraphQL_SchemaInspector.res +1 -1
  24. package/src/components/Api/GraphQL_SchemaInspector.res.mjs +1 -1
  25. package/src/components/Api/MCP_SchemaGenerator.res +1 -1
  26. package/src/components/Api/MCP_SchemaGenerator.res.mjs +1 -1
  27. package/src/components/Api/SchemaType.res +6 -10
  28. package/src/components/Api/SchemaType.res.mjs +4 -4
  29. package/src/components/Api/StorageRefFields.res +1 -1
  30. package/src/components/Api/StorageRefFields.res.mjs +1 -1
  31. package/src/components/Api/SuryToJsonSchema.res +19 -0
  32. package/src/components/Api/SuryToJsonSchema.res.mjs +10 -5
  33. package/src/components/AutomationSlice/AutomationSlice_Callback.res +3 -4
  34. package/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs +22 -23
  35. package/src/components/CommandTopic/CommandTopic_Callback.res +1 -2
  36. package/src/components/CommandTopic/CommandTopic_Callback.res.mjs +3 -4
  37. package/src/components/CommandTopic/CommandTopic_Helpers.res +1 -1
  38. package/src/components/CommandTopic/CommandTopic_Helpers.res.mjs +3 -3
  39. package/src/components/Counter/Counter.res.mjs +2 -2
  40. package/src/components/Counter/Counter_Callback.res.mjs +4 -4
  41. package/src/components/Counter/Counter_Operations.res.mjs +4 -4
  42. package/src/components/Dcb/Dcb_Builder.res +1 -1
  43. package/src/components/Dcb/Dcb_Builder.res.mjs +8 -6
  44. package/src/components/DcbEventLog/DcbEventLog_Builder.res.mjs +4 -3
  45. package/src/components/EventCollector/EventCollector_Builder.res +1 -1
  46. package/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res +1 -2
  47. package/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res.mjs +11 -12
  48. package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res +3 -4
  49. package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs +24 -25
  50. package/src/components/SideEffectHandler/SideEffectHandler_Callback.res +1 -1
  51. package/src/components/SideEffectHandler/SideEffectHandler_Callback.res.mjs +3 -3
  52. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +2 -2
  53. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +4 -4
  54. package/src/components/StateViewSlice/StateViewSlice_Builder.res +1 -1
  55. package/src/components/StateViewSlice/StateViewSlice_Builder.res.mjs +3 -3
  56. package/src/plugin/api/PluginBaseFragment.res +12 -29
  57. package/src/plugin/api/PluginBaseFragment.res.mjs +3 -22
  58. package/src/plugin/component/Capability_Inference.res +118 -1
  59. package/src/plugin/component/Capability_Inference.res.mjs +128 -1
  60. package/src/plugin/component/Plugin_Builder.res +9 -2
  61. package/src/plugin/component/Plugin_Builder.res.mjs +5 -2
  62. package/src/plugin/component/Plugin_Helpers.res +11 -11
  63. package/src/plugin/component/Plugin_Helpers.res.mjs +11 -10
  64. package/src/plugin/component/Plugin_Structure.res +458 -296
  65. package/src/plugin/component/Plugin_Structure.res.mjs +259 -182
  66. package/src/plugin/component/Plugin_SubscriptionSchema.res +1 -1
  67. package/src/plugin/component/Plugin_SubscriptionSchema.res.mjs +1 -1
  68. package/src/plugin/component/SchemaWalker.res +11 -11
  69. package/src/plugin/component/SchemaWalker.res.mjs +8 -9
  70. package/src/plugin/connect/PluginExtensionPoint_Plugin.res.mjs +15 -15
  71. package/src/plugin/lifecycle/PluginBehavior.res.mjs +55 -55
  72. package/src/plugin/lifecycle/PluginSpec.res +22 -7
  73. package/src/plugin/lifecycle/PluginSpec.res.mjs +91 -36
  74. package/src/plugin/lifecycle/PluginsReadModelSpec.res +36 -9
  75. package/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs +33 -22
  76. package/src/util/Interstack.res +3 -3
  77. package/src/util/LogFormat.res +1 -2
  78. package/src/util/LogFormat.res.mjs +2 -2
  79. package/tests/IdentityTest.res +6 -7
  80. package/tests/IdentityTest.res.mjs +7 -9
  81. package/tests/adapter/MonitoringTest.res +30 -3
  82. package/tests/adapter/MonitoringTest.res.mjs +31 -12
  83. package/tests/adapter/RuntimeExtensionHookReachTest.res +0 -1
  84. package/tests/adapter/RuntimeExtensionHookReachTest.res.mjs +5 -7
  85. package/tests/admin/Platform_Admin_StructureTest.res +254 -0
  86. package/tests/admin/Platform_Admin_StructureTest.res.mjs +219 -0
  87. package/tests/admin/Platform_BakedManifestTest.res +1 -0
  88. package/tests/admin/Platform_BakedManifestTest.res.mjs +1 -0
  89. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +3 -0
  90. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +6 -0
  91. package/tests/admin/Platform_PluginStructuresApiTest.res +1 -0
  92. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +2 -0
  93. package/tests/aggregate/AggregateCacheTest.res +0 -1
  94. package/tests/aggregate/AggregateCacheTest.res.mjs +13 -15
  95. package/tests/aggregate/AggregateConflictTest.res +0 -1
  96. package/tests/aggregate/AggregateConflictTest.res.mjs +7 -9
  97. package/tests/aggregate/AggregateFixtures.res +0 -1
  98. package/tests/aggregate/AggregateFixtures.res.mjs +19 -21
  99. package/tests/aggregate/AggregateSnapshotTest.res +1 -2
  100. package/tests/aggregate/AggregateSnapshotTest.res.mjs +15 -16
  101. package/tests/api/ApiNoApiTest.res.mjs +17 -9
  102. package/tests/api/GraphQL_FragmentGeneratorTest.res +232 -0
  103. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +207 -55
  104. package/tests/api/PsInternalFieldsReadModel.res +21 -0
  105. package/tests/api/PsInternalFieldsReadModel.res.mjs +74 -0
  106. package/tests/api/StorageRefFieldsTest.res.mjs +13 -13
  107. package/tests/api/SuryToJsonSchemaTest.res +73 -3
  108. package/tests/api/SuryToJsonSchemaTest.res.mjs +364 -816
  109. package/tests/commandgenerator/CommandGeneratorFixtures.res +0 -1
  110. package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +19 -21
  111. package/tests/commandgenerator/OwnerStampingTest.res +0 -1
  112. package/tests/commandgenerator/OwnerStampingTest.res.mjs +9 -11
  113. package/tests/commandtopic/CommandTopicCallbackFixtures.res +0 -1
  114. package/tests/commandtopic/CommandTopicCallbackFixtures.res.mjs +7 -9
  115. package/tests/counter/CounterFixtures.res +0 -1
  116. package/tests/counter/CounterFixtures.res.mjs +1 -4
  117. package/tests/dcb/DcbDecodeTest.res.mjs +24 -24
  118. package/tests/dcb/DcbEventLogOperationsTest.res +2 -2
  119. package/tests/dcb/DcbEventLogOperationsTest.res.mjs +3 -3
  120. package/tests/dcb/DcbEventLogSchemaTest.res.mjs +7 -7
  121. package/tests/dcb/DcbFixtures.res +0 -1
  122. package/tests/dcb/DcbFixtures.res.mjs +87 -89
  123. package/tests/dcb/DcbTagTest.res.mjs +6 -6
  124. package/tests/dcb/DcbValidationTest.res.mjs +52 -52
  125. package/tests/eventlog/EventLogFixtures.res +0 -1
  126. package/tests/eventlog/EventLogFixtures.res.mjs +7 -9
  127. package/tests/eventmapper/EventMapperFixtures.res +0 -1
  128. package/tests/eventmapper/EventMapperFixtures.res.mjs +24 -26
  129. package/tests/eventtopic/EventTopicFixtures.res +0 -1
  130. package/tests/eventtopic/EventTopicFixtures.res.mjs +7 -9
  131. package/tests/extensionpoint/ExtensionPointFixtures.res +0 -1
  132. package/tests/extensionpoint/ExtensionPointFixtures.res.mjs +10 -12
  133. package/tests/extensionpoint/ExtensionPointOperationsTest.res +1 -2
  134. package/tests/extensionpoint/ExtensionPointOperationsTest.res.mjs +9 -10
  135. package/tests/logger/LogFormatTest.res +3 -2
  136. package/tests/logger/LogFormatTest.res.mjs +7 -9
  137. package/tests/message/MessageTest.res +2 -2
  138. package/tests/message/MessageTest.res.mjs +8 -7
  139. package/tests/message/MetaEnvelopeTest.res +5 -5
  140. package/tests/message/MetaEnvelopeTest.res.mjs +12 -11
  141. package/tests/plugin/ManifestVisibilityTest.res.mjs +7 -7
  142. package/tests/plugin/PluginDefinitionScalars.res +5 -3
  143. package/tests/plugin/PluginDefinitionScalars.res.mjs +2 -2
  144. package/tests/plugin/PluginLifecycleCorpusTest.res +4 -3
  145. package/tests/plugin/PluginLifecycleCorpusTest.res.mjs +3 -6
  146. package/tests/plugin/PluginSpecExperiment.res +0 -1
  147. package/tests/plugin/PluginSpecExperiment.res.mjs +12 -14
  148. package/tests/plugin/PluginStructureTest.res +292 -2
  149. package/tests/plugin/PluginStructureTest.res.mjs +391 -88
  150. package/tests/plugin/Plugin_EventQuerySchemaTest.res.mjs +3 -3
  151. package/tests/plugin/SchemaWalkerTest.res +0 -1
  152. package/tests/plugin/SchemaWalkerTest.res.mjs +17 -19
  153. package/tests/plugin/StateChangeSlice/PsAttachInvoice.res.mjs +5 -5
  154. package/tests/plugin/StateChangeSlice/PsChangePhoto.res.mjs +8 -8
  155. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +5 -5
  156. package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +9 -9
  157. package/tests/plugin/StateChangeSlice/PsPlaceOrder.res.mjs +5 -5
  158. package/tests/plugin/StateChangeSlice/PsReserveStock.res.mjs +8 -8
  159. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +15 -15
  160. package/tests/plugin/StateChangeSlice/PsTypoStore.res +31 -0
  161. package/tests/plugin/StateChangeSlice/PsTypoStore.res.mjs +62 -0
  162. package/tests/plugin/StateChangeSlice/PsUploadAvatar.res.mjs +8 -8
  163. package/tests/plugin/StateChangeSlice/PsUploadImages.res +40 -0
  164. package/tests/plugin/StateChangeSlice/PsUploadImages.res.mjs +66 -0
  165. package/tests/plugin/StateViewSlice/PsAnnotatedView.res.mjs +13 -12
  166. package/tests/plugin/StateViewSlice/PsAvailableProductsView.res.mjs +8 -8
  167. package/tests/plugin/StateViewSlice/PsCategoriesView.res.mjs +6 -6
  168. package/tests/plugin/StateViewSlice/PsCustomersView.res.mjs +10 -10
  169. package/tests/plugin/StateViewSlice/PsGatedView.res.mjs +6 -6
  170. package/tests/plugin/StateViewSlice/PsOrdersView.res.mjs +9 -9
  171. package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +11 -10
  172. package/tests/plugin/pluginDefinitionRequiredScalars.txt +15 -0
  173. package/tests/querydb/QueryDbFixtures.res +0 -1
  174. package/tests/querydb/QueryDbFixtures.res.mjs +5 -7
  175. package/tests/sideeffecthandler/SideEffectHandlerFixtures.res +0 -1
  176. package/tests/sideeffecthandler/SideEffectHandlerFixtures.res.mjs +4 -6
  177. package/tests/util/CommandPublisherTest.res +0 -1
  178. package/tests/util/CommandPublisherTest.res.mjs +4 -6
@@ -41,8 +41,8 @@ let rec isLifecycleShape = (t: SchemaType.schemaType): bool =>
41
41
  // and *exactly*: `customerName` holds a customer's name, not this record's.
42
42
  let conventionalLabelNames = ["name", "title", "label", "displayname"]
43
43
 
44
- let shapeOfItem = (~entityName: string, item: S.item): SchemaType.schemaType =>
45
- SchemaType.fromSury(~parentName=entityName, ~fieldName=item.location, item.schema)
44
+ let shapeOfField = (~entityName: string, ~name: string, schema: S.t<unknown>): SchemaType.schemaType =>
45
+ SchemaType.fromSury(~parentName=entityName, ~fieldName=name, schema)
46
46
 
47
47
  // Resolve the field that holds the entity's lifecycle, used to filter a per-row
48
48
  // command menu against each command's `allowedStates`. Resolution order:
@@ -69,12 +69,14 @@ let lifecycleFieldFromStateSchema = (
69
69
  | Some(_) as some => some
70
70
  | None =>
71
71
  switch stateSchema {
72
- | Object({items}) =>
73
- items
74
- ->Array.find(item =>
75
- item.location == "lifecycle" && isLifecycleShape(shapeOfItem(~entityName, item))
72
+ | Object({properties}) =>
73
+ properties
74
+ ->Dict.get("lifecycle")
75
+ ->Option.flatMap(schema =>
76
+ isLifecycleShape(shapeOfField(~entityName, ~name="lifecycle", schema))
77
+ ? Some("lifecycle")
78
+ : None
76
79
  )
77
- ->Option.map(item => item.location)
78
80
  | _ => None
79
81
  }
80
82
  }
@@ -108,17 +110,55 @@ let retiredFieldFromStateSchema = (stateSchema: S.t<unknown>): option<string> =>
108
110
  let retiredValuesFromStateSchema = (stateSchema: S.t<unknown>): option<array<string>> =>
109
111
  retiredFromStateSchema(stateSchema)->Option.flatMap(r => r.values)
110
112
 
113
+ // Whether a reference to a retired row of this view still resolves its name —
114
+ // `@namedWhenRetired`. Read off the retirement rather than from a second
115
+ // annotation, so a record cannot declare the reach of a retirement it does not
116
+ // have; the PPX refuses that pairing, and reading it here from the same place
117
+ // keeps the two halves agreeing by construction rather than by review.
118
+ let namedWhenRetiredFromStateSchema = (stateSchema: S.t<unknown>): bool =>
119
+ retiredFromStateSchema(stateSchema)->Option.mapOr(false, r => r.namedWhenRetired)
120
+
121
+ // What one record's `@retired` declaration could be told about its own field.
122
+ //
123
+ // Three outcomes rather than a bool, because "nothing to check" and "could not
124
+ // check" are different facts and only the second is worth a plugin's attention.
125
+ type retiredCheck =
126
+ | NotDeclared
127
+ // Why the names could not be compared. A fatal rule that is invisible when it
128
+ // does not run is the failure mode the transition check spends a counter to
129
+ // avoid, so this is reported rather than skipped in silence.
130
+ | Unchecked(string)
131
+ | Checked(array<string>)
132
+
111
133
  // The check the PPX cannot make, in the one place that can: the payload is a
112
134
  // constructor reference the PPX only ever sees as a name, and whether that name
113
135
  // is a case of the field's enum needs the schema.
114
136
  //
115
- // Two rules, and the second is the one the form exists for. A `value` on a field
116
- // that is not the record's lifecycle would keep the read narrowing while silently
117
- // losing the command filtering that motivates it — `@transition` is written in
118
- // terms of the lifecycle field, so a retirement state anywhere else is a state no
119
- // command can name.
120
- let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): unit =>
137
+ // Two rules, and they are held to different standards on purpose.
138
+ //
139
+ // **A name the field's enum does not declare is unambiguously wrong** — no domain
140
+ // means it — and the symptom is a data-exposure bug: the retirement predicate
141
+ // compares every row against a state no row is ever in, so every row stays
142
+ // visible to every caller while the annotation sits on the schema looking like
143
+ // enforcement. That is returned as a failure for the caller to raise on.
144
+ //
145
+ // It is the same fault the PPX already refuses to compile when the enum is
146
+ // declared in the same file, and the PPX says so in its own message. This is the
147
+ // residue that a per-file pass cannot reach: field form, enum imported from
148
+ // elsewhere. Two rungs of one ladder — until this was promoted, which rung you
149
+ // landed on decided whether a data-exposure bug stopped the build, and the
150
+ // arbiter was where the enum happened to be declared.
151
+ //
152
+ // **A `value` on a field that is not the record's lifecycle stays a warning.**
153
+ // It would keep the read narrowing while silently losing the command filtering
154
+ // that motivates it — `@transition` is written in terms of the lifecycle field,
155
+ // so a retirement state anywhere else is a state no command can name. That is a
156
+ // modelling judgement rather than a wrong name, and judgement calls are what the
157
+ // withdrawn dead-end rule taught us not to hard-fail on.
158
+ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): retiredCheck =>
121
159
  switch retiredFromStateSchema(stateSchema) {
160
+ // The boolean form names no state, so there is nothing to compare. Not a skip.
161
+ | None | Some({values: None}) => NotDeclared
122
162
  | Some({field, values: Some(values)}) =>
123
163
  let named = values->Array.join(", ")
124
164
  let lifecycle = lifecycleFieldFromStateSchema(~entityName, stateSchema)
@@ -133,11 +173,11 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): unit =
133
173
  )
134
174
  }
135
175
  let declared = switch stateSchema {
136
- | Object({items}) =>
137
- items
138
- ->Array.find(item => item.location == field)
139
- ->Option.map(item =>
140
- switch shapeOfItem(~entityName, item) {
176
+ | Object({properties}) =>
177
+ properties
178
+ ->Dict.get(field)
179
+ ->Option.map(schema =>
180
+ switch shapeOfField(~entityName, ~name=field, schema) {
141
181
  | Enum(_, values) => values
142
182
  | Nullable(Enum(_, values)) => values
143
183
  | _ => []
@@ -146,24 +186,56 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): unit =
146
186
  ->Option.getOr([])
147
187
  | _ => []
148
188
  }
149
- // Reported per state rather than as a set: one wrong entry among three still
150
- // narrows something, so the symptom is a subset of rows leaking rather than
151
- // all of them — which is harder to spot than the single-value case was.
152
- if Array.length(declared) > 0 {
153
- values
154
- ->Array.filter(v => !(declared->Array.includes(v)))
155
- ->Array.forEach(v =>
156
- log.warn(
157
- ~comp="Plugin_Structure",
158
- `${entityName}: @retired(${v}) names a state "${field}" does not declare — known values: ${declared->Array.join(
159
- ", ",
160
- )}.`,
161
- )
189
+ if Array.length(declared) == 0 {
190
+ Unchecked(
191
+ `${entityName}: @retired(${named}) is on "${field}", whose shape carries no cases to check the names against.`,
192
+ )
193
+ } else {
194
+ // Reported per state rather than as a set: one wrong entry among three still
195
+ // narrows something, so the symptom is a subset of rows leaking rather than
196
+ // all of them — which is harder to spot than the single-value case was.
197
+ Checked(
198
+ values
199
+ ->Array.filter(v => !(declared->Array.includes(v)))
200
+ ->Array.map(
201
+ v =>
202
+ `${entityName}: @retired(${v}) names a state "${field}" does not declare — known values: ${declared->Array.join(
203
+ ", ",
204
+ )}.`,
205
+ ),
162
206
  )
163
207
  }
164
- | _ => ()
165
208
  }
166
209
 
210
+ // Raised together, after every view has been walked, so an author sees every bad
211
+ // name at once rather than the first one and then a rebuild.
212
+ //
213
+ // Retroactive in a way the transition check was not: `@transition` was new when
214
+ // its check landed, so nothing deployed could carry a stale name, while `@retired`
215
+ // has been shipping. A deployed plugin holding a misspelled retired value gets a
216
+ // red build on its next deploy — which is the point, and is why the examples were
217
+ // swept before this was promoted.
218
+ let reportRetiredStates = (
219
+ ~pluginName: string,
220
+ ~failures: array<string>,
221
+ ~unchecked: array<string>,
222
+ ): unit => {
223
+ if Array.length(unchecked) > 0 {
224
+ log.warn(
225
+ ~comp="Plugin_Structure",
226
+ `${pluginName}: ${unchecked
227
+ ->Array.length
228
+ ->Int.toString} @retired declaration(s) could not be checked.\n` ++
229
+ unchecked->Array.join("\n"),
230
+ )
231
+ }
232
+ if Array.length(failures) > 0 {
233
+ JsError.throwWithMessage(
234
+ `${pluginName}: @retired names states that do not exist.\n` ++ failures->Array.join("\n"),
235
+ )
236
+ }
237
+ }
238
+
167
239
  // The states a record's lifecycle field can hold. The same extraction
168
240
  // `checkRetiredValue` does, keyed on the declared lifecycle field rather than the
169
241
  // retired one — which is the field a command's `@transition` is written in terms
@@ -175,11 +247,11 @@ let lifecycleStatesFromStateSchema = (
175
247
  ): option<array<string>> =>
176
248
  lifecycleFieldFromStateSchema(~entityName, stateSchema)->Option.flatMap(field =>
177
249
  switch stateSchema {
178
- | Object({items}) =>
179
- items
180
- ->Array.find(item => item.location == field)
181
- ->Option.map(item =>
182
- switch shapeOfItem(~entityName, item) {
250
+ | Object({properties}) =>
251
+ properties
252
+ ->Dict.get(field)
253
+ ->Option.map(schema =>
254
+ switch shapeOfField(~entityName, ~name=field, schema) {
183
255
  | Enum(_, values) => values
184
256
  | Nullable(Enum(_, values)) => values
185
257
  | _ => []
@@ -307,27 +379,21 @@ let lifecycleTopologyFindings = (
307
379
  lifecycleStatesByView
308
380
  ->Dict.toArray
309
381
  ->Array.forEach(((view, states)) => {
310
- // Every edge any command declares into or out of this view's lifecycle.
311
- let edges = writables->Array.reduce([], (acc, w) =>
382
+ // Every state some command declares a transition INTO. The question this
383
+ // check asks is only about arrival, so it reads the target and never the
384
+ // from-set — which is what lets a creating command (`@transition(() => X)`,
385
+ // a target and no from-set, because it runs from no row) count towards
386
+ // reachability the same way an edge out of another state does.
387
+ let reachable = writables->Array.reduce([], (acc, w) =>
312
388
  w.linkedViews->Array.includes(view)
313
- ? Array.concat(
314
- acc,
315
- w.commands->Array.reduce([], (inner, cmd) =>
316
- switch (cmd.allowedStates, cmd.targetState) {
317
- | (Some(froms), Some(to)) =>
318
- Array.concat(inner, froms->Array.map(from => (from, to)))
319
- | _ => inner
320
- }
321
- ),
322
- )
389
+ ? Array.concat(acc, w.commands->Array.filterMap(cmd => cmd.targetState))
323
390
  : acc
324
391
  )
325
- if Array.length(edges) > 0 {
392
+ if Array.length(reachable) > 0 {
326
393
  // Rows start in the first declared state — the same convention the
327
394
  // lifecycle diagram uses — so nothing pointing at it is expected rather
328
395
  // than suspicious.
329
396
  let initial = states->Array.get(0)
330
- let reachable = edges->Array.map(((_, to)) => to)
331
397
 
332
398
  states->Array.forEach(state => {
333
399
  if !(reachable->Array.includes(state)) && Some(state) != initial {
@@ -453,26 +519,26 @@ let labelFieldsFromStateSchema = (
453
519
  | Some(spec) => {field: "displayName", searchableFields: spec.fields, source: Annotation}
454
520
  | None =>
455
521
  let candidates = switch stateSchema {
456
- | Object({items}) =>
457
- items->Array.filter(item =>
458
- item.location != "TAG" &&
459
- item.location != "id" &&
460
- isLabelShape(shapeOfItem(~entityName, item))
522
+ | Object({properties}) =>
523
+ properties
524
+ ->Dict.toArray
525
+ ->Array.filter(((name, schema)) =>
526
+ name != "TAG" && name != "id" && isLabelShape(shapeOfField(~entityName, ~name, schema))
461
527
  )
462
528
  | _ => []
463
529
  }
464
- let conventional = candidates->Array.find(item => {
465
- let lower = item.location->String.toLowerCase
530
+ let conventional = candidates->Array.find(((name, _)) => {
531
+ let lower = name->String.toLowerCase
466
532
  conventionalLabelNames->Array.some(n => n == lower)
467
533
  })
468
534
  let picked = switch conventional {
469
- | Some(item) => Some((item, Convention))
470
- | None => candidates->Array.get(0)->Option.map(item => (item, Position))
535
+ | Some((name, _)) => Some((name, Convention))
536
+ | None => candidates->Array.get(0)->Option.map(((name, _)) => (name, Position))
471
537
  }
472
538
  switch picked {
473
- | Some((item, source)) => {
474
- field: item.location,
475
- searchableFields: [item.location],
539
+ | Some((name, source)) => {
540
+ field: name,
541
+ searchableFields: [name],
476
542
  source,
477
543
  }
478
544
  | None =>
@@ -543,7 +609,7 @@ let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
543
609
 
544
610
  let extractEventDefs = (eventSchema: S.t<unknown>): array<Reventless.Plugin.eventDef> =>
545
611
  switch eventSchema {
546
- | Union({anyOf}) => anyOf->Array.filterMap(toEventDef)
612
+ | AnyOf({anyOf}) => anyOf->Array.filterMap(toEventDef)
547
613
  | _ => toEventDef(eventSchema)->Option.mapOr([], def => [def])
548
614
  }
549
615
 
@@ -557,6 +623,302 @@ let extractErrorDefs = (errorSchema: S.t<unknown>): array<Reventless.Plugin.erro
557
623
  {name, schema, references}: Reventless.Plugin.errorDef
558
624
  ))
559
625
 
626
+ // The command walk, module-level for the same reason as the event walk above:
627
+ // the synthetic Platform_Admin structure cannot reach `make` — it has no
628
+ // `module(Aggregate.T)` to hand it — and hand-writing a second copy of this walk
629
+ // is what let its command metadata drift from the SDL generated off the same
630
+ // schema.
631
+
632
+ // Aggregate commands that initialize a new aggregate instance are Collection-level
633
+ // (shown as table-top buttons); all others are Instance-level (shown per-row).
634
+ let isCreateCommandName = name =>
635
+ ["Add", "Create", "Register", "Open", "Initialize", "Submit", "Start", "Place"]->Array.some(p =>
636
+ name->String.startsWith(p)
637
+ )
638
+
639
+ let commandLevelAndId = (~isAggregate, ~variantName, properties: dict<S.t<unknown>>) =>
640
+ if isAggregate {
641
+ if isCreateCommandName(variantName) {
642
+ (Reventless.Plugin.Collection, None)
643
+ } else {
644
+ (Reventless.Plugin.Instance, None)
645
+ }
646
+ } else {
647
+ let taggedFields =
648
+ properties
649
+ ->Dict.toArray
650
+ ->Array.filter(((fieldName, fieldSchema)) =>
651
+ fieldName != "TAG" &&
652
+ (Reventless.DcbTag.isTagged(fieldSchema) ||
653
+ Reventless.DcbTag.isTaggedArray(fieldSchema))
654
+ )
655
+ let taggedField =
656
+ taggedFields
657
+ ->Array.find(((_, fieldSchema)) => Reventless.DcbTag.isPartitionTag(fieldSchema))
658
+ ->Option.orElse(taggedFields->Array.get(0))
659
+ switch taggedField {
660
+ | Some((fieldName, _)) =>
661
+ if isCreateCommandName(variantName) {
662
+ // Creation command: Collection-level, but UUID is injected into the tagged ID field.
663
+ (Reventless.Plugin.Collection, Some(fieldName))
664
+ } else {
665
+ (Reventless.Plugin.Instance, Some(fieldName))
666
+ }
667
+ | None => (Reventless.Plugin.Collection, None)
668
+ }
669
+ }
670
+
671
+ // A rule the server enforces, expressed as the keys a client checks against
672
+ // `identity.groups ++ config.accessTiers`. `AllowGroups` is satisfied by ANY of
673
+ // its groups (`Authorization.isAllowed` is `some`, not `every`), so the array is
674
+ // an any-of and a client must read it that way.
675
+ //
676
+ // `AllowAuthenticated` / `AllowAnonymous` ask for nothing a client can check —
677
+ // anyone holding a session already satisfies them — so they publish no keys
678
+ // rather than a key everyone holds.
679
+ //
680
+ // `DenyAll` also publishes none, deliberately. An unsatisfiable key would render
681
+ // as locked-with-upsell in a tiered shell: a surface advertised as purchasable
682
+ // that no purchase unlocks. A component nobody may call belongs in no menu, and
683
+ // that is an omission for the enumerating side to make, not a key to invent here.
684
+ let accessKeysFor = (rule: Reventless.Authorization.permission): option<array<string>> =>
685
+ switch rule {
686
+ | AllowGroups(groups) if groups->Array.length > 0 => Some(groups)
687
+ | AllowGroups(_) | AllowAuthenticated | AllowAnonymous | DenyAll => None
688
+ }
689
+
690
+ // Write each mutation argument's rendered GraphQL type onto the property it
691
+ // belongs to, so a consumer assembling its own mutation document declares the
692
+ // variable the server actually expects instead of guessing `String!`.
693
+ //
694
+ // Mutates the freshly derived schema in place — `deriveObjectSchema` has just
695
+ // built it and nothing else holds it yet. A property with no matching
696
+ // argument is left alone rather than annotated with a guess.
697
+ let annotateArgTypes = (schema: JSON.t, argTypes: dict<string>): JSON.t => {
698
+ schema
699
+ ->JSON.Decode.object
700
+ ->Option.flatMap(o => o->Dict.get("properties"))
701
+ ->Option.flatMap(JSON.Decode.object)
702
+ ->Option.forEach(props =>
703
+ props
704
+ ->Dict.toArray
705
+ ->Array.forEach(((key, prop)) =>
706
+ switch (argTypes->Dict.get(key), prop->JSON.Decode.object) {
707
+ | (Some(gqlType), Some(p)) =>
708
+ p->Dict.set("x-reventless-graphql-type", JSON.Encode.string(gqlType))
709
+ | _ => ()
710
+ }
711
+ )
712
+ )
713
+ schema
714
+ }
715
+
716
+ let toCommandDef = (
717
+ ~isAggregate,
718
+ ~mutationFieldFor: string => string,
719
+ ~parentSchema: S.t<unknown>,
720
+ // The PPX-generated `command => permission`. Per VARIANT, not per component:
721
+ // one aggregate carries commands with very different audiences, and a
722
+ // component-level shortcut would gate `AddProduct` and `PlaceOrder` alike.
723
+ ~commandAuthorization: unknown => Reventless.Authorization.permission,
724
+ v: S.t<unknown>,
725
+ ): option<Reventless.Plugin.commandDef> => {
726
+ // Build a commandDef for one variant. `properties` is the variant's field dict —
727
+ // empty for a payload-less variant (e.g. `| Archive`), which compiles to a bare
728
+ // `S.literal("Archive")` string rather than an `{TAG, ...}` object.
729
+ let mkDef = (~variantName, ~properties) => {
730
+ let (level, aggregateIdField) = commandLevelAndId(~isAggregate, ~variantName, properties)
731
+ let references = extractReferences(properties)
732
+ // Per-variant `allowedStates` lives on the *parent* command schema
733
+ // (the PPX attaches a single dict<variantName, [|states|]> via
734
+ // markAllowedStates). Look it up by variant name; back-compat
735
+ // None when the variant lacks a @transition annotation.
736
+ let allowedStates = ApiAllowedStatesHelpers.getAllowedStates(parentSchema, ~variantName)
737
+ // The `@transition` target (the command's *to* status), read the same way
738
+ // as allowedStates. None ⇒ AutoUI's board resolver falls back to its
739
+ // name-stem heuristic.
740
+ let targetState = ApiTargetStateHelpers.getTargetState(parentSchema, ~variantName)
741
+ // API-exposed iff the whole command isn't @noApi and this variant
742
+ // isn't in its @noApi-variants set — mirrors the API-generation filter
743
+ // (Plugin_Helpers / PluginBaseFragment). Drives the event-graph API badge.
744
+ let apiExposed =
745
+ !ApiNoApiHelpers.isNoApi(parentSchema) &&
746
+ switch ApiNoApiHelpers.getExcludedVariants(parentSchema) {
747
+ | Some(excluded) => !(excluded->Set.has(variantName))
748
+ | None => true
749
+ }
750
+ // Evaluated against a synthetic value per constructor, the same shape the
751
+ // resolver builds at call time: a payload-bearing variant compiles to
752
+ // `{TAG, ...}`, a payload-less one to a bare string.
753
+ let syntheticCommand: unknown =
754
+ Reventless.DcbTag.isVariantPayloadBearing(parentSchema, variantName)
755
+ ? {"TAG": variantName}->Obj.magic
756
+ : variantName->Obj.magic
757
+ let requiredAccess = accessKeysFor(commandAuthorization(syntheticCommand))
758
+ // See the note on the record's `mutationField` for why a non-exposed
759
+ // variant gets the empty sentinel. It has no callable field, and the
760
+ // argument type names are composed *from* that field name, so there is
761
+ // nothing to publish for it either.
762
+ let mutationField = apiExposed ? mutationFieldFor(variantName) : ""
763
+ let jsonSchema = v->SuryToJsonSchema.deriveObjectSchema
764
+ let annotatedSchema = if apiExposed {
765
+ GraphQL_FragmentGenerator.mutationArgTypes(~fieldName=mutationField, v)->Option.mapOr(
766
+ jsonSchema,
767
+ annotateArgTypes(jsonSchema, _),
768
+ )
769
+ } else {
770
+ jsonSchema
771
+ }
772
+ ({
773
+ Reventless.Plugin.name: variantName,
774
+ // The derived schema, not sury's raw one: `S.toJSONSchema` carries the
775
+ // shape and drops every `x-reventless-*` marker the PPX put on the
776
+ // fields, so a command's `@storageRef`/`@semantic`/`@ref` reached the
777
+ // wire on the read side and nowhere on the write side. A reader that
778
+ // matches a field against its setter — or picks the upload endpoint of
779
+ // the store a command argument declares — then has nothing to match on.
780
+ // `MCP_SchemaGenerator` already derives these same variant schemas.
781
+ //
782
+ // Carries `x-reventless-graphql-type` per property — see
783
+ // `annotateArgTypes`.
784
+ schema: annotatedSchema->JSON.stringify,
785
+ level,
786
+ aggregateIdField,
787
+ // A non-exposed (`@noApi`) variant has no callable mutation field. For a
788
+ // single-exposed-command slice, `mutationFieldFor` resolves *every*
789
+ // variant — including the `@noApi` one — to the slice's one mutation
790
+ // field, so emitting it here would hand the non-exposed variant a
791
+ // sibling's callable-looking field (e.g. `ReopenOrder` →
792
+ // `Ordering_CancelOrder`). Emit an empty sentinel instead; the variant
793
+ // stays listed with `apiExposed: false` for the event-graph badge, but
794
+ // no consumer can mistake it for a callable field. Exposed variants are
795
+ // byte-identical.
796
+ mutationField,
797
+ references,
798
+ allowedStates,
799
+ targetState,
800
+ apiExposed: Some(apiExposed),
801
+ requiredAccess,
802
+ // Resolved from this constructor's own properties, not the union's: two
803
+ // commands in one slice can disagree about whether they record an owner,
804
+ // and the write path stamps per constructor for the same reason.
805
+ ownerField: Reventless.Owner.fieldNamesOfProperties(properties)->Array.get(0),
806
+ }: Reventless.Plugin.commandDef)
807
+ }
808
+ switch v {
809
+ | Object({properties}) =>
810
+ properties
811
+ ->Dict.get("TAG")
812
+ ->Option.flatMap(tagSchema =>
813
+ switch tagSchema {
814
+ | String({const: ?Some(variantName)}) => Some(mkDef(~variantName, ~properties))
815
+ | _ => None
816
+ }
817
+ )
818
+ // Payload-less command variants (`| Archive`) compile to a bare string literal,
819
+ // not an `{TAG, ...}` object. They still get a generated mutation (API generation
820
+ // walks the schema via extractAllVariantNames), so surface them here too —
821
+ // otherwise the event graph hides a command the API actually exposes.
822
+ | String({const: ?Some(variantName)}) => Some(mkDef(~variantName, ~properties=Dict.make()))
823
+ | _ => None
824
+ }
825
+ }
826
+
827
+ let extractCommandDefs = (
828
+ ~isAggregate,
829
+ ~mutationFieldFor: string => string,
830
+ ~commandAuthorization: unknown => Reventless.Authorization.permission,
831
+ commandSchema: S.t<unknown>,
832
+ ): array<Reventless.Plugin.commandDef> =>
833
+ switch commandSchema {
834
+ | AnyOf({anyOf}) =>
835
+ anyOf->Array.filterMap(v =>
836
+ toCommandDef(
837
+ ~isAggregate,
838
+ ~mutationFieldFor,
839
+ ~parentSchema=commandSchema,
840
+ ~commandAuthorization,
841
+ v,
842
+ )
843
+ )
844
+ | _ =>
845
+ // Single-variant command types compile to a bare Object schema, not a Union.
846
+ toCommandDef(
847
+ ~isAggregate,
848
+ ~mutationFieldFor,
849
+ ~parentSchema=commandSchema,
850
+ ~commandAuthorization,
851
+ commandSchema,
852
+ )->Option.mapOr([], def => [def])
853
+ }
854
+
855
+ // Omitted for the default `Public` so a published def stays compact: absent and
856
+ // "public" are the same answer, and writing one would make every ordinary view
857
+ // carry a word that says nothing.
858
+ let visibilityTag = (v: Reventless.Visibility.t): option<string> =>
859
+ switch v {
860
+ | Public => None
861
+ | Internal => Some("Internal")
862
+ }
863
+
864
+ /**
865
+ A read model's published `queryableDef`, assembled from its **spec** rather than
866
+ from a built component.
867
+
868
+ `make` cannot serve the platform's own components: it takes
869
+ `module(ReadModel.T)` values, and the platform has no built module to hand
870
+ itself at structure-assembly time — the components it is describing are the ones
871
+ being assembled. That is a real constraint and this routes around it rather than
872
+ pretending it away, by taking the schemas the extractors actually need.
873
+
874
+ Everything here is the same helper `make` calls on the same schema, so the
875
+ platform's own view is described by the mechanism that describes everyone
876
+ else's. The alternative — a hand-written record — is what let the admin's
877
+ metadata drift from the SDL generated off the same spec, four times over.
878
+
879
+ `queryField` and `singleQueryField` come from `Api_Naming`, which is the only
880
+ place that decides how a name singularises. For the admin these are
881
+ byte-identical to the names it used to hand-write; `singularize` already handled
882
+ the plural-spec-name / singular-type shape that looked bespoke.
883
+ */
884
+ let queryableDefFromSpec = (
885
+ ~plugin: string,
886
+ ~name: string,
887
+ ~stateSchema: S.t<unknown>,
888
+ ~authorization: Reventless.Authorization.permission,
889
+ ~visibility: Reventless.Visibility.t=Public,
890
+ ~consumedEventTypes: array<string>=[],
891
+ ~linkedWriteSide: array<string>=[],
892
+ ~chapter: option<string>=?,
893
+ ): Reventless.Plugin.queryableDef => {
894
+ let qf = Api_Naming.queryFieldNamesForReadModel(~plugin, ~name)
895
+ let label = labelFieldsFromStateSchema(~entityName=name, stateSchema)
896
+ // The same call the capability deriver makes, so the published key and the key
897
+ // the generated filter/order-by is built from cannot disagree.
898
+ let keyField = GraphQL_FragmentGenerator.resolveKeyField(~entityName=name, stateSchema)
899
+ {
900
+ Reventless.Plugin.name: name,
901
+ queryField: qf.listFieldName,
902
+ schema: stateSchema->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
903
+ consumedEventTypes,
904
+ linkedWriteSide,
905
+ labelField: label.field,
906
+ searchableFields: label.searchableFields,
907
+ labelFieldSource: Some(labelFieldSourceToString(label.source)),
908
+ lifecycleField: lifecycleFieldFromStateSchema(~entityName=name, stateSchema),
909
+ ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
910
+ retiredField: retiredFieldFromStateSchema(stateSchema),
911
+ retiredValues: retiredValuesFromStateSchema(stateSchema),
912
+ namedWhenRetired: Some(namedWhenRetiredFromStateSchema(stateSchema)),
913
+ visibility: visibilityTag(visibility),
914
+ chapter,
915
+ singleQueryField: Some(qf.singleFieldName),
916
+ idField: keyField->Option.map(((f, _)) => f),
917
+ idFieldSource: keyField->Option.map(((_, rung)) => rung),
918
+ requiredAccess: accessKeysFor(authorization),
919
+ }
920
+ }
921
+
560
922
  let make = (
561
923
  type api role,
562
924
  ~name: string,
@@ -587,229 +949,6 @@ let make = (
587
949
  let commandVariantNames = schema => Reventless.DcbTag.extractAllVariantNames(schema)
588
950
  let qualify = (~prefix, names) => names->Array.map(n => prefix ++ "." ++ n)
589
951
 
590
- // Aggregate commands that initialize a new aggregate instance are Collection-level
591
- // (shown as table-top buttons); all others are Instance-level (shown per-row).
592
- let isCreateCommandName = name =>
593
- ["Add", "Create", "Register", "Open", "Initialize", "Submit", "Start", "Place"]->Array.some(p =>
594
- name->String.startsWith(p)
595
- )
596
-
597
- let commandLevelAndId = (~isAggregate, ~variantName, properties: dict<S.t<unknown>>) =>
598
- if isAggregate {
599
- if isCreateCommandName(variantName) {
600
- (Reventless.Plugin.Collection, None)
601
- } else {
602
- (Reventless.Plugin.Instance, None)
603
- }
604
- } else {
605
- let taggedFields =
606
- properties
607
- ->Dict.toArray
608
- ->Array.filter(((fieldName, fieldSchema)) =>
609
- fieldName != "TAG" &&
610
- (Reventless.DcbTag.isTagged(fieldSchema) ||
611
- Reventless.DcbTag.isTaggedArray(fieldSchema))
612
- )
613
- let taggedField =
614
- taggedFields
615
- ->Array.find(((_, fieldSchema)) => Reventless.DcbTag.isPartitionTag(fieldSchema))
616
- ->Option.orElse(taggedFields->Array.get(0))
617
- switch taggedField {
618
- | Some((fieldName, _)) =>
619
- if isCreateCommandName(variantName) {
620
- // Creation command: Collection-level, but UUID is injected into the tagged ID field.
621
- (Reventless.Plugin.Collection, Some(fieldName))
622
- } else {
623
- (Reventless.Plugin.Instance, Some(fieldName))
624
- }
625
- | None => (Reventless.Plugin.Collection, None)
626
- }
627
- }
628
-
629
- // A rule the server enforces, expressed as the keys a client checks against
630
- // `identity.groups ++ config.accessTiers`. `AllowGroups` is satisfied by ANY of
631
- // its groups (`Authorization.isAllowed` is `some`, not `every`), so the array is
632
- // an any-of and a client must read it that way.
633
- //
634
- // `AllowAuthenticated` / `AllowAnonymous` ask for nothing a client can check —
635
- // anyone holding a session already satisfies them — so they publish no keys
636
- // rather than a key everyone holds.
637
- //
638
- // `DenyAll` also publishes none, deliberately. An unsatisfiable key would render
639
- // as locked-with-upsell in a tiered shell: a surface advertised as purchasable
640
- // that no purchase unlocks. A component nobody may call belongs in no menu, and
641
- // that is an omission for the enumerating side to make, not a key to invent here.
642
- let accessKeysFor = (rule: Reventless.Authorization.permission): option<array<string>> =>
643
- switch rule {
644
- | AllowGroups(groups) if groups->Array.length > 0 => Some(groups)
645
- | AllowGroups(_) | AllowAuthenticated | AllowAnonymous | DenyAll => None
646
- }
647
-
648
- // Write each mutation argument's rendered GraphQL type onto the property it
649
- // belongs to, so a consumer assembling its own mutation document declares the
650
- // variable the server actually expects instead of guessing `String!`.
651
- //
652
- // Mutates the freshly derived schema in place — `deriveObjectSchema` has just
653
- // built it and nothing else holds it yet. A property with no matching
654
- // argument is left alone rather than annotated with a guess.
655
- let annotateArgTypes = (schema: JSON.t, argTypes: dict<string>): JSON.t => {
656
- schema
657
- ->JSON.Decode.object
658
- ->Option.flatMap(o => o->Dict.get("properties"))
659
- ->Option.flatMap(JSON.Decode.object)
660
- ->Option.forEach(props =>
661
- props
662
- ->Dict.toArray
663
- ->Array.forEach(((key, prop)) =>
664
- switch (argTypes->Dict.get(key), prop->JSON.Decode.object) {
665
- | (Some(gqlType), Some(p)) =>
666
- p->Dict.set("x-reventless-graphql-type", JSON.Encode.string(gqlType))
667
- | _ => ()
668
- }
669
- )
670
- )
671
- schema
672
- }
673
-
674
- let toCommandDef = (
675
- ~isAggregate,
676
- ~mutationFieldFor: string => string,
677
- ~parentSchema: S.t<unknown>,
678
- // The PPX-generated `command => permission`. Per VARIANT, not per component:
679
- // one aggregate carries commands with very different audiences, and a
680
- // component-level shortcut would gate `AddProduct` and `PlaceOrder` alike.
681
- ~commandAuthorization: unknown => Reventless.Authorization.permission,
682
- v: S.t<unknown>,
683
- ): option<Reventless.Plugin.commandDef> => {
684
- // Build a commandDef for one variant. `properties` is the variant's field dict —
685
- // empty for a payload-less variant (e.g. `| Archive`), which compiles to a bare
686
- // `S.literal("Archive")` string rather than an `{TAG, ...}` object.
687
- let mkDef = (~variantName, ~properties) => {
688
- let (level, aggregateIdField) = commandLevelAndId(~isAggregate, ~variantName, properties)
689
- let references = extractReferences(properties)
690
- // Per-variant `allowedStates` lives on the *parent* command schema
691
- // (the PPX attaches a single dict<variantName, [|states|]> via
692
- // markAllowedStates). Look it up by variant name; back-compat
693
- // None when the variant lacks a @transition annotation.
694
- let allowedStates = ApiAllowedStatesHelpers.getAllowedStates(parentSchema, ~variantName)
695
- // The `@transition` target (the command's *to* status), read the same way
696
- // as allowedStates. None ⇒ AutoUI's board resolver falls back to its
697
- // name-stem heuristic.
698
- let targetState = ApiTargetStateHelpers.getTargetState(parentSchema, ~variantName)
699
- // API-exposed iff the whole command isn't @noApi and this variant
700
- // isn't in its @noApi-variants set — mirrors the API-generation filter
701
- // (Plugin_Helpers / PluginBaseFragment). Drives the event-graph API badge.
702
- let apiExposed =
703
- !ApiNoApiHelpers.isNoApi(parentSchema) &&
704
- switch ApiNoApiHelpers.getExcludedVariants(parentSchema) {
705
- | Some(excluded) => !(excluded->Set.has(variantName))
706
- | None => true
707
- }
708
- // Evaluated against a synthetic value per constructor, the same shape the
709
- // resolver builds at call time: a payload-bearing variant compiles to
710
- // `{TAG, ...}`, a payload-less one to a bare string.
711
- let syntheticCommand: unknown =
712
- Reventless.DcbTag.isVariantPayloadBearing(parentSchema, variantName)
713
- ? {"TAG": variantName}->Obj.magic
714
- : variantName->Obj.magic
715
- let requiredAccess = accessKeysFor(commandAuthorization(syntheticCommand))
716
- // See the note on the record's `mutationField` for why a non-exposed
717
- // variant gets the empty sentinel. It has no callable field, and the
718
- // argument type names are composed *from* that field name, so there is
719
- // nothing to publish for it either.
720
- let mutationField = apiExposed ? mutationFieldFor(variantName) : ""
721
- let jsonSchema = v->SuryToJsonSchema.deriveObjectSchema
722
- let annotatedSchema = if apiExposed {
723
- GraphQL_FragmentGenerator.mutationArgTypes(~fieldName=mutationField, v)->Option.mapOr(
724
- jsonSchema,
725
- annotateArgTypes(jsonSchema, _),
726
- )
727
- } else {
728
- jsonSchema
729
- }
730
- ({
731
- Reventless.Plugin.name: variantName,
732
- // The derived schema, not sury's raw one: `S.toJSONSchema` carries the
733
- // shape and drops every `x-reventless-*` marker the PPX put on the
734
- // fields, so a command's `@storageRef`/`@semantic`/`@ref` reached the
735
- // wire on the read side and nowhere on the write side. A reader that
736
- // matches a field against its setter — or picks the upload endpoint of
737
- // the store a command argument declares — then has nothing to match on.
738
- // `MCP_SchemaGenerator` already derives these same variant schemas.
739
- //
740
- // Carries `x-reventless-graphql-type` per property — see
741
- // `annotateArgTypes`.
742
- schema: annotatedSchema->JSON.stringify,
743
- level,
744
- aggregateIdField,
745
- // A non-exposed (`@noApi`) variant has no callable mutation field. For a
746
- // single-exposed-command slice, `mutationFieldFor` resolves *every*
747
- // variant — including the `@noApi` one — to the slice's one mutation
748
- // field, so emitting it here would hand the non-exposed variant a
749
- // sibling's callable-looking field (e.g. `ReopenOrder` →
750
- // `Ordering_CancelOrder`). Emit an empty sentinel instead; the variant
751
- // stays listed with `apiExposed: false` for the event-graph badge, but
752
- // no consumer can mistake it for a callable field. Exposed variants are
753
- // byte-identical.
754
- mutationField,
755
- references,
756
- allowedStates,
757
- targetState,
758
- apiExposed: Some(apiExposed),
759
- requiredAccess,
760
- // Resolved from this constructor's own properties, not the union's: two
761
- // commands in one slice can disagree about whether they record an owner,
762
- // and the write path stamps per constructor for the same reason.
763
- ownerField: Reventless.Owner.fieldNamesOfProperties(properties)->Array.get(0),
764
- }: Reventless.Plugin.commandDef)
765
- }
766
- switch v {
767
- | Object({properties}) =>
768
- properties
769
- ->Dict.get("TAG")
770
- ->Option.flatMap(tagSchema =>
771
- switch tagSchema {
772
- | String({const: ?Some(variantName)}) => Some(mkDef(~variantName, ~properties))
773
- | _ => None
774
- }
775
- )
776
- // Payload-less command variants (`| Archive`) compile to a bare string literal,
777
- // not an `{TAG, ...}` object. They still get a generated mutation (API generation
778
- // walks the schema via extractAllVariantNames), so surface them here too —
779
- // otherwise the event graph hides a command the API actually exposes.
780
- | String({const: ?Some(variantName)}) => Some(mkDef(~variantName, ~properties=Dict.make()))
781
- | _ => None
782
- }
783
- }
784
-
785
- let extractCommandDefs = (
786
- ~isAggregate,
787
- ~mutationFieldFor: string => string,
788
- ~commandAuthorization: unknown => Reventless.Authorization.permission,
789
- commandSchema: S.t<unknown>,
790
- ): array<Reventless.Plugin.commandDef> =>
791
- switch commandSchema {
792
- | Union({anyOf}) =>
793
- anyOf->Array.filterMap(v =>
794
- toCommandDef(
795
- ~isAggregate,
796
- ~mutationFieldFor,
797
- ~parentSchema=commandSchema,
798
- ~commandAuthorization,
799
- v,
800
- )
801
- )
802
- | _ =>
803
- // Single-variant command types compile to a bare Object schema, not a Union.
804
- toCommandDef(
805
- ~isAggregate,
806
- ~mutationFieldFor,
807
- ~parentSchema=commandSchema,
808
- ~commandAuthorization,
809
- commandSchema,
810
- )->Option.mapOr([], def => [def])
811
- }
812
-
813
952
  // ── Per-component event type extraction ────────────────────────────────────
814
953
 
815
954
  let scsProduced =
@@ -965,7 +1104,7 @@ let make = (
965
1104
  | _ => []
966
1105
  }
967
1106
  switch schema {
968
- | Union({anyOf}) => anyOf->Array.flatMap(fromVariant)
1107
+ | AnyOf({anyOf}) => anyOf->Array.flatMap(fromVariant)
969
1108
  | other => fromVariant(other)
970
1109
  }
971
1110
  }
@@ -1037,6 +1176,18 @@ let make = (
1037
1176
  ->Belt.Set.String.fromArray
1038
1177
  ->Belt.Set.String.toArray
1039
1178
 
1179
+ // Two stores one edit apart are a typo that would provision twice. A hard
1180
+ // failure, unlike the heuristic lint below: nothing downstream can see the
1181
+ // mistake, and by deploy time both buckets exist.
1182
+ switch Capability_Inference.collisions(requiredStoreDeclarations) {
1183
+ | [] => ()
1184
+ | found =>
1185
+ JsError.throwWithMessage(
1186
+ `${name}: two declared object stores look like one store misspelled.\n` ++
1187
+ found->Array.map(Capability_Inference.collisionMessage)->Array.join("\n"),
1188
+ )
1189
+ }
1190
+
1040
1191
  // Heuristic-only matches — a field *named* like a stored-object ref with no
1041
1192
  // `@storageRef` — warn and provision nothing. Declaration outranks inference;
1042
1193
  // the warning names the annotation that would settle it.
@@ -1054,12 +1205,6 @@ let make = (
1054
1205
  // graph and dead-code analysis — read them so an Internal view still shows up there. The
1055
1206
  // deployed AutoUI's consumers (Platform_ComponentDefinitionsApi menu/pages) re-filter on
1056
1207
  // the tag so the live UI keeps hiding them — see Visibility.res, which documents this contract.
1057
- let visibilityTag = (v: Reventless.Visibility.t): option<string> =>
1058
- switch v {
1059
- | Public => None
1060
- | Internal => Some("Internal")
1061
- }
1062
-
1063
1208
  // View name -> the states its lifecycle field can hold, collected as the view
1064
1209
  // defs are built so the transition check below has both sides in one place.
1065
1210
  let lifecycleStatesByView: dict<array<string>> = Dict.make()
@@ -1071,6 +1216,17 @@ let make = (
1071
1216
  }
1072
1217
  }
1073
1218
 
1219
+ // Collected as the view defs are built and reported once, so a plugin with
1220
+ // three bad names fails naming three rather than one at a time.
1221
+ let retiredFailures = []
1222
+ let retiredUnchecked = []
1223
+ let recordRetired = (~entityName, stateSchema) =>
1224
+ switch checkRetiredValue(~entityName, stateSchema) {
1225
+ | NotDeclared => ()
1226
+ | Unchecked(why) => retiredUnchecked->Array.push(why)->ignore
1227
+ | Checked(failures) => failures->Array.forEach(f => retiredFailures->Array.push(f)->ignore)
1228
+ }
1229
+
1074
1230
  let readModelDefs =
1075
1231
  readModels
1076
1232
  ->Array.map((
@@ -1090,7 +1246,7 @@ let make = (
1090
1246
  // edges for any event reaching the read model via a DCB-log-sourced mapping (a classic
1091
1247
  // aggregate→view link is also drawn from the producer's linkedViews, deduped downstream).
1092
1248
  let consumed = qualify(~prefix=name, R.consumedEventNames)
1093
- checkRetiredValue(~entityName=R.Spec.name, stateSchema)
1249
+ recordRetired(~entityName=R.Spec.name, stateSchema)
1094
1250
  recordLifecycle(~entityName=R.Spec.name, stateSchema)
1095
1251
  ({
1096
1252
  Reventless.Plugin.name: R.Spec.name,
@@ -1107,6 +1263,7 @@ let make = (
1107
1263
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
1108
1264
  retiredField: retiredFieldFromStateSchema(stateSchema),
1109
1265
  retiredValues: retiredValuesFromStateSchema(stateSchema),
1266
+ namedWhenRetired: Some(namedWhenRetiredFromStateSchema(stateSchema)),
1110
1267
  visibility: visibilityTag(R.Spec.visibility),
1111
1268
  chapter: chapterOf(R.Spec.name),
1112
1269
  // Taken from the `qf` record, never re-derived: `Api_Naming` is the only
@@ -1129,7 +1286,7 @@ let make = (
1129
1286
  ~entityName=SVS.Spec.name,
1130
1287
  stateSchema,
1131
1288
  )
1132
- checkRetiredValue(~entityName=SVS.Spec.name, stateSchema)
1289
+ recordRetired(~entityName=SVS.Spec.name, stateSchema)
1133
1290
  recordLifecycle(~entityName=SVS.Spec.name, stateSchema)
1134
1291
  ({
1135
1292
  Reventless.Plugin.name: SVS.Spec.name,
@@ -1144,6 +1301,7 @@ let make = (
1144
1301
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
1145
1302
  retiredField: retiredFieldFromStateSchema(stateSchema),
1146
1303
  retiredValues: retiredValuesFromStateSchema(stateSchema),
1304
+ namedWhenRetired: Some(namedWhenRetiredFromStateSchema(stateSchema)),
1147
1305
  visibility: visibilityTag(SVS.Spec.visibility),
1148
1306
  chapter: chapterOf(SVS.Spec.name),
1149
1307
  singleQueryField: Some(qf.singleFieldName),
@@ -1313,6 +1471,10 @@ let make = (
1313
1471
  ~writables=Array.concat(stateChangeDefs, aggregateDefs),
1314
1472
  ~lifecycleStatesByView,
1315
1473
  )
1474
+ // Also a second pass, for a different reason: the failures are gathered per
1475
+ // view as those defs are built, and raising inline would report the first bad
1476
+ // name and hide the rest.
1477
+ reportRetiredStates(~pluginName=name, ~failures=retiredFailures, ~unchecked=retiredUnchecked)
1316
1478
 
1317
1479
  {
1318
1480
  readModels: readModelDefs,