@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.
- package/CHANGELOG.md +40 -0
- package/package.json +12 -12
- package/src/Message.res +11 -11
- package/src/Message.res.mjs +15 -14
- package/src/Projection.res +2 -2
- package/src/Projection.res.mjs +5 -4
- package/src/adapter/Monitoring/Monitoring.res +24 -2
- package/src/adapter/Monitoring/Monitoring.res.mjs +3 -3
- package/src/admin/Platform_AdminApi.res.mjs +3 -3
- package/src/admin/Platform_Admin_Structure.res +33 -112
- package/src/admin/Platform_Admin_Structure.res.mjs +8 -138
- package/src/admin/Platform_ComponentDefinitionsApi.res +2 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
- package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs +12 -12
- package/src/admin/UiFragmentRegistry/StateViewSlice/UiFragments.res.mjs +14 -14
- package/src/components/Aggregate/Aggregate_Callback.res +2 -2
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +4 -4
- package/src/components/Api/ApiAllowedStatesHelpers.res.mjs +3 -3
- package/src/components/Api/ApiNoApiHelpers.res.mjs +5 -5
- package/src/components/Api/ApiTargetStateHelpers.res.mjs +3 -3
- package/src/components/Api/GraphQL_FragmentGenerator.res +140 -24
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +48 -21
- package/src/components/Api/GraphQL_SchemaInspector.res +1 -1
- package/src/components/Api/GraphQL_SchemaInspector.res.mjs +1 -1
- package/src/components/Api/MCP_SchemaGenerator.res +1 -1
- package/src/components/Api/MCP_SchemaGenerator.res.mjs +1 -1
- package/src/components/Api/SchemaType.res +6 -10
- package/src/components/Api/SchemaType.res.mjs +4 -4
- package/src/components/Api/StorageRefFields.res +1 -1
- package/src/components/Api/StorageRefFields.res.mjs +1 -1
- package/src/components/Api/SuryToJsonSchema.res +19 -0
- package/src/components/Api/SuryToJsonSchema.res.mjs +10 -5
- package/src/components/AutomationSlice/AutomationSlice_Callback.res +3 -4
- package/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs +22 -23
- package/src/components/CommandTopic/CommandTopic_Callback.res +1 -2
- package/src/components/CommandTopic/CommandTopic_Callback.res.mjs +3 -4
- package/src/components/CommandTopic/CommandTopic_Helpers.res +1 -1
- package/src/components/CommandTopic/CommandTopic_Helpers.res.mjs +3 -3
- package/src/components/Counter/Counter.res.mjs +2 -2
- package/src/components/Counter/Counter_Callback.res.mjs +4 -4
- package/src/components/Counter/Counter_Operations.res.mjs +4 -4
- package/src/components/Dcb/Dcb_Builder.res +1 -1
- package/src/components/Dcb/Dcb_Builder.res.mjs +8 -6
- package/src/components/DcbEventLog/DcbEventLog_Builder.res.mjs +4 -3
- package/src/components/EventCollector/EventCollector_Builder.res +1 -1
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res +1 -2
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res.mjs +11 -12
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res +3 -4
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs +24 -25
- package/src/components/SideEffectHandler/SideEffectHandler_Callback.res +1 -1
- package/src/components/SideEffectHandler/SideEffectHandler_Callback.res.mjs +3 -3
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +2 -2
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +4 -4
- package/src/components/StateViewSlice/StateViewSlice_Builder.res +1 -1
- package/src/components/StateViewSlice/StateViewSlice_Builder.res.mjs +3 -3
- package/src/plugin/api/PluginBaseFragment.res +12 -29
- package/src/plugin/api/PluginBaseFragment.res.mjs +3 -22
- package/src/plugin/component/Capability_Inference.res +118 -1
- package/src/plugin/component/Capability_Inference.res.mjs +128 -1
- package/src/plugin/component/Plugin_Builder.res +9 -2
- package/src/plugin/component/Plugin_Builder.res.mjs +5 -2
- package/src/plugin/component/Plugin_Helpers.res +11 -11
- package/src/plugin/component/Plugin_Helpers.res.mjs +11 -10
- package/src/plugin/component/Plugin_Structure.res +458 -296
- package/src/plugin/component/Plugin_Structure.res.mjs +259 -182
- package/src/plugin/component/Plugin_SubscriptionSchema.res +1 -1
- package/src/plugin/component/Plugin_SubscriptionSchema.res.mjs +1 -1
- package/src/plugin/component/SchemaWalker.res +11 -11
- package/src/plugin/component/SchemaWalker.res.mjs +8 -9
- package/src/plugin/connect/PluginExtensionPoint_Plugin.res.mjs +15 -15
- package/src/plugin/lifecycle/PluginBehavior.res.mjs +55 -55
- package/src/plugin/lifecycle/PluginSpec.res +22 -7
- package/src/plugin/lifecycle/PluginSpec.res.mjs +91 -36
- package/src/plugin/lifecycle/PluginsReadModelSpec.res +36 -9
- package/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs +33 -22
- package/src/util/Interstack.res +3 -3
- package/src/util/LogFormat.res +1 -2
- package/src/util/LogFormat.res.mjs +2 -2
- package/tests/IdentityTest.res +6 -7
- package/tests/IdentityTest.res.mjs +7 -9
- package/tests/adapter/MonitoringTest.res +30 -3
- package/tests/adapter/MonitoringTest.res.mjs +31 -12
- package/tests/adapter/RuntimeExtensionHookReachTest.res +0 -1
- package/tests/adapter/RuntimeExtensionHookReachTest.res.mjs +5 -7
- package/tests/admin/Platform_Admin_StructureTest.res +254 -0
- package/tests/admin/Platform_Admin_StructureTest.res.mjs +219 -0
- package/tests/admin/Platform_BakedManifestTest.res +1 -0
- package/tests/admin/Platform_BakedManifestTest.res.mjs +1 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +3 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +6 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res +1 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +2 -0
- package/tests/aggregate/AggregateCacheTest.res +0 -1
- package/tests/aggregate/AggregateCacheTest.res.mjs +13 -15
- package/tests/aggregate/AggregateConflictTest.res +0 -1
- package/tests/aggregate/AggregateConflictTest.res.mjs +7 -9
- package/tests/aggregate/AggregateFixtures.res +0 -1
- package/tests/aggregate/AggregateFixtures.res.mjs +19 -21
- package/tests/aggregate/AggregateSnapshotTest.res +1 -2
- package/tests/aggregate/AggregateSnapshotTest.res.mjs +15 -16
- package/tests/api/ApiNoApiTest.res.mjs +17 -9
- package/tests/api/GraphQL_FragmentGeneratorTest.res +232 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +207 -55
- package/tests/api/PsInternalFieldsReadModel.res +21 -0
- package/tests/api/PsInternalFieldsReadModel.res.mjs +74 -0
- package/tests/api/StorageRefFieldsTest.res.mjs +13 -13
- package/tests/api/SuryToJsonSchemaTest.res +73 -3
- package/tests/api/SuryToJsonSchemaTest.res.mjs +364 -816
- package/tests/commandgenerator/CommandGeneratorFixtures.res +0 -1
- package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +19 -21
- package/tests/commandgenerator/OwnerStampingTest.res +0 -1
- package/tests/commandgenerator/OwnerStampingTest.res.mjs +9 -11
- package/tests/commandtopic/CommandTopicCallbackFixtures.res +0 -1
- package/tests/commandtopic/CommandTopicCallbackFixtures.res.mjs +7 -9
- package/tests/counter/CounterFixtures.res +0 -1
- package/tests/counter/CounterFixtures.res.mjs +1 -4
- package/tests/dcb/DcbDecodeTest.res.mjs +24 -24
- package/tests/dcb/DcbEventLogOperationsTest.res +2 -2
- package/tests/dcb/DcbEventLogOperationsTest.res.mjs +3 -3
- package/tests/dcb/DcbEventLogSchemaTest.res.mjs +7 -7
- package/tests/dcb/DcbFixtures.res +0 -1
- package/tests/dcb/DcbFixtures.res.mjs +87 -89
- package/tests/dcb/DcbTagTest.res.mjs +6 -6
- package/tests/dcb/DcbValidationTest.res.mjs +52 -52
- package/tests/eventlog/EventLogFixtures.res +0 -1
- package/tests/eventlog/EventLogFixtures.res.mjs +7 -9
- package/tests/eventmapper/EventMapperFixtures.res +0 -1
- package/tests/eventmapper/EventMapperFixtures.res.mjs +24 -26
- package/tests/eventtopic/EventTopicFixtures.res +0 -1
- package/tests/eventtopic/EventTopicFixtures.res.mjs +7 -9
- package/tests/extensionpoint/ExtensionPointFixtures.res +0 -1
- package/tests/extensionpoint/ExtensionPointFixtures.res.mjs +10 -12
- package/tests/extensionpoint/ExtensionPointOperationsTest.res +1 -2
- package/tests/extensionpoint/ExtensionPointOperationsTest.res.mjs +9 -10
- package/tests/logger/LogFormatTest.res +3 -2
- package/tests/logger/LogFormatTest.res.mjs +7 -9
- package/tests/message/MessageTest.res +2 -2
- package/tests/message/MessageTest.res.mjs +8 -7
- package/tests/message/MetaEnvelopeTest.res +5 -5
- package/tests/message/MetaEnvelopeTest.res.mjs +12 -11
- package/tests/plugin/ManifestVisibilityTest.res.mjs +7 -7
- package/tests/plugin/PluginDefinitionScalars.res +5 -3
- package/tests/plugin/PluginDefinitionScalars.res.mjs +2 -2
- package/tests/plugin/PluginLifecycleCorpusTest.res +4 -3
- package/tests/plugin/PluginLifecycleCorpusTest.res.mjs +3 -6
- package/tests/plugin/PluginSpecExperiment.res +0 -1
- package/tests/plugin/PluginSpecExperiment.res.mjs +12 -14
- package/tests/plugin/PluginStructureTest.res +292 -2
- package/tests/plugin/PluginStructureTest.res.mjs +391 -88
- package/tests/plugin/Plugin_EventQuerySchemaTest.res.mjs +3 -3
- package/tests/plugin/SchemaWalkerTest.res +0 -1
- package/tests/plugin/SchemaWalkerTest.res.mjs +17 -19
- package/tests/plugin/StateChangeSlice/PsAttachInvoice.res.mjs +5 -5
- package/tests/plugin/StateChangeSlice/PsChangePhoto.res.mjs +8 -8
- package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +5 -5
- package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +9 -9
- package/tests/plugin/StateChangeSlice/PsPlaceOrder.res.mjs +5 -5
- package/tests/plugin/StateChangeSlice/PsReserveStock.res.mjs +8 -8
- package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +15 -15
- package/tests/plugin/StateChangeSlice/PsTypoStore.res +31 -0
- package/tests/plugin/StateChangeSlice/PsTypoStore.res.mjs +62 -0
- package/tests/plugin/StateChangeSlice/PsUploadAvatar.res.mjs +8 -8
- package/tests/plugin/StateChangeSlice/PsUploadImages.res +40 -0
- package/tests/plugin/StateChangeSlice/PsUploadImages.res.mjs +66 -0
- package/tests/plugin/StateViewSlice/PsAnnotatedView.res.mjs +13 -12
- package/tests/plugin/StateViewSlice/PsAvailableProductsView.res.mjs +8 -8
- package/tests/plugin/StateViewSlice/PsCategoriesView.res.mjs +6 -6
- package/tests/plugin/StateViewSlice/PsCustomersView.res.mjs +10 -10
- package/tests/plugin/StateViewSlice/PsGatedView.res.mjs +6 -6
- package/tests/plugin/StateViewSlice/PsOrdersView.res.mjs +9 -9
- package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +11 -10
- package/tests/plugin/pluginDefinitionRequiredScalars.txt +15 -0
- package/tests/querydb/QueryDbFixtures.res +0 -1
- package/tests/querydb/QueryDbFixtures.res.mjs +5 -7
- package/tests/sideeffecthandler/SideEffectHandlerFixtures.res +0 -1
- package/tests/sideeffecthandler/SideEffectHandlerFixtures.res.mjs +4 -6
- package/tests/util/CommandPublisherTest.res +0 -1
- 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
|
|
45
|
-
SchemaType.fromSury(~parentName=entityName, ~fieldName=
|
|
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({
|
|
73
|
-
|
|
74
|
-
->
|
|
75
|
-
|
|
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
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
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({
|
|
137
|
-
|
|
138
|
-
->
|
|
139
|
-
->Option.map(
|
|
140
|
-
switch
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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({
|
|
179
|
-
|
|
180
|
-
->
|
|
181
|
-
->Option.map(
|
|
182
|
-
switch
|
|
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
|
|
311
|
-
|
|
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(
|
|
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({
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
isLabelShape(
|
|
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(
|
|
465
|
-
let lower =
|
|
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(
|
|
470
|
-
| None => candidates->Array.get(0)->Option.map(
|
|
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((
|
|
474
|
-
field:
|
|
475
|
-
searchableFields: [
|
|
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
|
-
|
|
|
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
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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,
|