@reventlessdev/reventless-core 3.0.0-alpha.256 → 3.0.0-alpha.257

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 (63) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +7 -7
  3. package/src/adapter/Monitoring/Monitoring.res +79 -5
  4. package/src/adapter/Monitoring/Monitoring.res.mjs +42 -2
  5. package/src/admin/Platform_Admin_Structure.res.mjs +1 -1
  6. package/src/admin/Platform_ComponentDefinitionsApi.res +5 -1
  7. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  8. package/src/admin/Platform_UiSlots.res +21 -0
  9. package/src/admin/Platform_UiSlots.res.mjs +15 -0
  10. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs +1 -1
  11. package/src/components/Aggregate/Aggregate_Callback.res +13 -4
  12. package/src/components/Aggregate/Aggregate_Callback.res.mjs +6 -5
  13. package/src/components/Api/GraphQL_FragmentGenerator.res +2 -0
  14. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +4 -0
  15. package/src/components/Api/MCP_SchemaGenerator.res.mjs +2 -2
  16. package/src/components/Api/SchemaType.res +8 -0
  17. package/src/components/Api/SchemaType.res.mjs +15 -5
  18. package/src/components/Api/SuryToJsonSchema.res +70 -12
  19. package/src/components/Api/SuryToJsonSchema.res.mjs +43 -16
  20. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +6 -1
  21. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +1 -1
  22. package/src/plugin/component/Plugin.res +1 -0
  23. package/src/plugin/component/Plugin_Builder.res +2 -0
  24. package/src/plugin/component/Plugin_Builder.res.mjs +3 -2
  25. package/src/plugin/component/Plugin_Structure.res +105 -10
  26. package/src/plugin/component/Plugin_Structure.res.mjs +71 -27
  27. package/src/util/Validation.res +1 -1
  28. package/src/util/Validation.res.mjs +2 -2
  29. package/tests/adapter/MonitoringTest.res +46 -7
  30. package/tests/adapter/MonitoringTest.res.mjs +47 -5
  31. package/tests/admin/AdminApiSchemaDriftTest.res +1 -0
  32. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +3 -0
  33. package/tests/admin/Platform_Admin_StructureTest.res.mjs +2 -14
  34. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +1 -0
  35. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +2 -0
  36. package/tests/aggregate/AggregateCacheTest.res.mjs +1 -1
  37. package/tests/aggregate/AggregateConflictTest.res.mjs +1 -1
  38. package/tests/aggregate/AggregateFixtures.res.mjs +1 -1
  39. package/tests/aggregate/AggregateSnapshotTest.res.mjs +1 -1
  40. package/tests/api/GraphQL_FragmentGeneratorTest.res +6 -4
  41. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +5 -5
  42. package/tests/api/SuryToJsonSchemaTest.res.mjs +57 -57
  43. package/tests/api/TaggedUnionSchemaTest.res.mjs +1 -1
  44. package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +1 -1
  45. package/tests/commandtopic/CommandTopicCallbackFixtures.res.mjs +1 -1
  46. package/tests/dcb/DcbFixtures.res.mjs +1 -1
  47. package/tests/eventmapper/EventMapperFixtures.res.mjs +1 -1
  48. package/tests/extensionpoint/ExtensionPointFixtures.res.mjs +1 -1
  49. package/tests/extensionpoint/ExtensionPointOperationsTest.res.mjs +1 -1
  50. package/tests/plugin/ManifestVisibilityTest.res.mjs +1 -1
  51. package/tests/plugin/PluginStructureAccessTest.res.mjs +1 -1
  52. package/tests/plugin/PluginStructureTest.res +184 -0
  53. package/tests/plugin/PluginStructureTest.res.mjs +142 -17
  54. package/tests/plugin/StateChangeSlice/PsAttachInvoice.res.mjs +1 -1
  55. package/tests/plugin/StateChangeSlice/PsChangePhoto.res.mjs +1 -1
  56. package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +1 -1
  57. package/tests/plugin/StateChangeSlice/PsPlaceOrder.res.mjs +1 -1
  58. package/tests/plugin/StateChangeSlice/PsReserveStock.res +14 -1
  59. package/tests/plugin/StateChangeSlice/PsReserveStock.res.mjs +9 -2
  60. package/tests/plugin/StateChangeSlice/PsTypoStore.res.mjs +1 -1
  61. package/tests/plugin/StateChangeSlice/PsUploadAvatar.res.mjs +1 -1
  62. package/tests/plugin/StateChangeSlice/PsUploadImages.res.mjs +1 -1
  63. package/tests/util/CommandPublisherTest.res.mjs +1 -1
@@ -176,33 +176,72 @@ let rec isNullableType = (st: SchemaType.schemaType): bool =>
176
176
  | _ => false
177
177
  }
178
178
 
179
- let rec fromSchemaType = (st: SchemaType.schemaType): JSON.t =>
179
+ // The GraphQL **input** type name an object is declared under, written onto the
180
+ // object's own JSON Schema wherever it occurs.
181
+ //
182
+ // A client that assembles a mutation must name its variables' types, and for a
183
+ // *top-level* argument `x-reventless-graphql-type` already publishes the rendered
184
+ // reference (`[Ordering_PlaceOrderLineItems!]!`, wrappers and all). That one is
185
+ // keyed by argument name and so stops at the first level; this one travels with
186
+ // the object, which is what a reader reaching an element schema inside a list has.
187
+ //
188
+ // The name is taken from the `ObjectRef` the SDL emitter names the type from, so
189
+ // there is one source and the two cannot drift — but only when the caller rooted
190
+ // the walk at the same prefix the emitter uses. That is what `inputNames` gates:
191
+ // a schema walked from an unknown root would publish a name the SDL never
192
+ // declares, and a plausible wrong name is worse than none.
193
+ let withGraphqlInput = (schema: JSON.t, name: string): JSON.t =>
194
+ switch schema->JSON.Decode.object {
195
+ | None => schema
196
+ | Some(obj) =>
197
+ obj->Dict.set("x-reventless-graphql-input", str(name))
198
+ JSON.Encode.object(obj)
199
+ }
200
+
201
+ let rec fromSchemaType = (~inputNames: bool=false, st: SchemaType.schemaType): JSON.t =>
180
202
  switch st {
181
203
  | ScalarString => jsonObject([("type", str("string"))])
182
204
  | ScalarNumber => jsonObject([("type", str("number"))])
205
+ | ScalarInt => jsonObject([("type", str("integer"))])
183
206
  | ScalarBoolean => jsonObject([("type", str("boolean"))])
184
207
  | ScalarBigInt => jsonObject([("type", str("integer"))])
185
208
  | EntityId => jsonObject([("type", str("string")), ("format", str("uuid"))])
186
209
  | DateTime => jsonObject([("type", str("string")), ("format", str("date-time"))])
187
210
  | Nullable(inner) =>
188
- let innerSchema = fromSchemaType(inner)
211
+ let innerSchema = fromSchemaType(~inputNames, inner)
189
212
  jsonObject([("oneOf", JSON.Encode.array([innerSchema, jsonObject([("type", str("null"))])]))])
190
213
  | ArrayOf(item) =>
191
- jsonObject([("type", str("array")), ("items", fromSchemaType(item))])
192
- | ObjectRef(_, fields) => objectRefToJsonSchema(fields)
214
+ jsonObject([("type", str("array")), ("items", fromSchemaType(~inputNames, item))])
215
+ | ObjectRef(name, fields) =>
216
+ let base = objectRefToJsonSchema(~inputNames, fields)
217
+ inputNames ? base->withGraphqlInput(name) : base
193
218
  | Enum(_, values) =>
194
219
  jsonObject([
195
220
  ("type", str("string")),
196
221
  ("enum", JSON.Encode.array(values->Array.map(JSON.Encode.string))),
197
222
  ])
198
- | Semantic(sem, inner) => fromSchemaType(inner)->withSemantic(sem)
223
+ // A semantic composite is one named type wherever it appears, and GraphQL
224
+ // forbids one name serving as both an object and an input — so in input
225
+ // position it is the `Input`-suffixed name that exists in the SDL, which is
226
+ // the one a client can paste into a variable declaration. Positionally-named
227
+ // objects take no suffix, so the `ObjectRef` branch above already published
228
+ // the right string for them.
229
+ | Semantic(sem, inner) =>
230
+ let base = fromSchemaType(~inputNames, inner)
231
+ let base = switch (inputNames, SchemaType.canonicalName(sem.id), inner) {
232
+ | (true, Some(name), ObjectRef(_, _)) => base->withGraphqlInput(name ++ "Input")
233
+ | _ => base
234
+ }
235
+ base->withSemantic(sem)
236
+ // A union has no input form — see the fragment generator, which emits `String`
237
+ // and says so — hence no name to publish on either arm.
199
238
  // A union is `oneOf` its arms, each an object carrying the `TAG` const it is
200
239
  // discriminated by. The discriminator is what tells a union apart from a
201
240
  // nullable object, which is also a `oneOf` of objects — a reader that misses
202
241
  // it selects one arm's fields as though they were the field's own and produces
203
242
  // a query that looks plausible and is invalid.
204
243
  | TaggedUnion(name, arms) =>
205
- let members = arms->Array.map(((tag, armType)) => armToJsonSchema(~tag, armType))
244
+ let members = arms->Array.map(((tag, armType)) => armToJsonSchema(~inputNames, ~tag, armType))
206
245
  jsonObject([("oneOf", JSON.Encode.array(members)), ("x-reventless-union", str(name))])
207
246
  | Unknown => jsonObject([("type", str("string"))])
208
247
  }
@@ -212,10 +251,14 @@ let rec fromSchemaType = (st: SchemaType.schemaType): JSON.t =>
212
251
  // the payload that says which arm this is. The GraphQL member type name rides
213
252
  // alongside so a reader mapping a raw payload to a selection does not have to
214
253
  // re-derive the naming rule in a second repo.
215
- and armToJsonSchema = (~tag: string, armType: SchemaType.schemaType): JSON.t =>
254
+ and armToJsonSchema = (
255
+ ~inputNames: bool=false,
256
+ ~tag: string,
257
+ armType: SchemaType.schemaType,
258
+ ): JSON.t =>
216
259
  switch armType {
217
260
  | ObjectRef(memberName, fields) =>
218
- let base = objectRefToJsonSchema(fields)
261
+ let base = objectRefToJsonSchema(~inputNames, fields)
219
262
  switch base->JSON.Decode.object {
220
263
  | Some(obj) =>
221
264
  switch obj->Dict.get("properties")->Option.flatMap(JSON.Decode.object) {
@@ -232,7 +275,7 @@ and armToJsonSchema = (~tag: string, armType: SchemaType.schemaType): JSON.t =>
232
275
  JSON.Encode.object(obj)
233
276
  | None => base
234
277
  }
235
- | other => fromSchemaType(other)
278
+ | other => fromSchemaType(~inputNames, other)
236
279
  }
237
280
 
238
281
  // Attach a type-carried semantic to a field's JSON Schema.
@@ -313,11 +356,17 @@ and withSemantic = (fieldSchema: JSON.t, sem: Reventless.Semantic.t): JSON.t =>
313
356
  // way — off the sury schema by the caller. What it marks is a field whose value
314
357
  // must not be rendered into content somebody receives, which is a property of
315
358
  // the domain model rather than of the field's shape.
359
+ // `inputNames` says which side of the wire this schema describes. It changes only
360
+ // the name a semantic composite is published under — `MoneyInput` where a command
361
+ // takes one, `Money` where a view returns one — because GraphQL forbids one name
362
+ // serving as both. Emitting the input name on a view would name a type that need
363
+ // not exist in the SDL at all.
316
364
  and objectRefToJsonSchema = (
317
365
  ~annotations: option<Reventless.StateAnnotations.stateAnnotationSpec>=?,
318
366
  ~optional: array<string>=[],
319
367
  ~owners: array<string>=[],
320
368
  ~sensitive: array<string>=[],
369
+ ~inputNames: bool=false,
321
370
  fields: dict<SchemaType.schemaType>,
322
371
  ): JSON.t => {
323
372
  let props = Dict.make()
@@ -336,7 +385,7 @@ and objectRefToJsonSchema = (
336
385
  ->Dict.toArray
337
386
  ->Array.filter(((fieldName, _)) => !(internal->Array.includes(fieldName)))
338
387
  ->Array.forEach(((fieldName, fieldType)) => {
339
- let baseSchema = fromSchemaType(fieldType)
388
+ let baseSchema = fromSchemaType(~inputNames, fieldType)
340
389
  let withAnnotations = switch annotations {
341
390
  | Some(spec) => mergeAnnotations(baseSchema, fieldName, spec)
342
391
  | None => baseSchema
@@ -394,8 +443,16 @@ and objectRefToJsonSchema = (
394
443
 
395
444
  // ── Public API (sury → JSON Schema via SchemaType) ───────────────────────
396
445
 
397
- let deriveObjectSchema = (schema: S.t<unknown>): JSON.t =>
398
- switch SchemaType.fromSuryObject(~typeName="", schema) {
446
+ // `typeName` roots the walk where the SDL emitter roots its own — the mutation
447
+ // field name for a command — so the nested type names this publishes are the
448
+ // ones the SDL declares. It is only consulted when `inputNames` asks for those
449
+ // names; every other caller leaves both alone and gets today's schema exactly.
450
+ let deriveObjectSchema = (
451
+ ~inputNames: bool=false,
452
+ ~typeName: string="",
453
+ schema: S.t<unknown>,
454
+ ): JSON.t =>
455
+ switch SchemaType.fromSuryObject(~typeName, schema) {
399
456
  | Some(fields) =>
400
457
  let annotations = Reventless.StateAnnotations.getSpec(schema)
401
458
  let objSchema = objectRefToJsonSchema(
@@ -403,6 +460,7 @@ let deriveObjectSchema = (schema: S.t<unknown>): JSON.t =>
403
460
  ~optional=SchemaType.optionalFieldNames(schema),
404
461
  ~owners=Reventless.Owner.fieldNames(schema),
405
462
  ~sensitive=Reventless.Sensitive.fieldNames(schema),
463
+ ~inputNames,
406
464
  fields,
407
465
  )
408
466
  // Surface component-level hints on the top-level object schema.
@@ -160,7 +160,18 @@ function isNullableType(_st) {
160
160
  };
161
161
  }
162
162
 
163
- function fromSchemaType(st) {
163
+ function withGraphqlInput(schema, name) {
164
+ let obj = Stdlib_JSON.Decode.object(schema);
165
+ if (obj !== undefined) {
166
+ obj["x-reventless-graphql-input"] = name;
167
+ return obj;
168
+ } else {
169
+ return schema;
170
+ }
171
+ }
172
+
173
+ function fromSchemaType(inputNamesOpt, st) {
174
+ let inputNames = inputNamesOpt !== undefined ? inputNamesOpt : false;
164
175
  if (typeof st !== "object") {
165
176
  switch (st) {
166
177
  case "ScalarNumber" :
@@ -173,6 +184,7 @@ function fromSchemaType(st) {
173
184
  "type",
174
185
  "boolean"
175
186
  ]]);
187
+ case "ScalarInt" :
176
188
  case "ScalarBigInt" :
177
189
  return Object.fromEntries([[
178
190
  "type",
@@ -210,7 +222,7 @@ function fromSchemaType(st) {
210
222
  } else {
211
223
  switch (st.TAG) {
212
224
  case "Nullable" :
213
- let innerSchema = fromSchemaType(st._0);
225
+ let innerSchema = fromSchemaType(inputNames, st._0);
214
226
  return Object.fromEntries([[
215
227
  "oneOf",
216
228
  [
@@ -229,11 +241,16 @@ function fromSchemaType(st) {
229
241
  ],
230
242
  [
231
243
  "items",
232
- fromSchemaType(st._0)
244
+ fromSchemaType(inputNames, st._0)
233
245
  ]
234
246
  ]);
235
247
  case "ObjectRef" :
236
- return objectRefToJsonSchema(undefined, undefined, undefined, undefined, st._1);
248
+ let base = objectRefToJsonSchema(undefined, undefined, undefined, undefined, inputNames, st._1);
249
+ if (inputNames) {
250
+ return withGraphqlInput(base, st._0);
251
+ } else {
252
+ return base;
253
+ }
237
254
  case "Enum" :
238
255
  return Object.fromEntries([
239
256
  [
@@ -246,9 +263,14 @@ function fromSchemaType(st) {
246
263
  ]
247
264
  ]);
248
265
  case "Semantic" :
249
- return withSemantic(fromSchemaType(st._1), st._0);
266
+ let inner = st._1;
267
+ let sem = st._0;
268
+ let base$1 = fromSchemaType(inputNames, inner);
269
+ let match = SchemaType$ReventlessCore.canonicalName(sem.id);
270
+ let base$2 = inputNames && match !== undefined && typeof inner === "object" && inner.TAG === "ObjectRef" ? withGraphqlInput(base$1, match + "Input") : base$1;
271
+ return withSemantic(base$2, sem);
250
272
  case "TaggedUnion" :
251
- let members = st._1.map(param => armToJsonSchema(param[0], param[1]));
273
+ let members = st._1.map(param => armToJsonSchema(inputNames, param[0], param[1]));
252
274
  return Object.fromEntries([
253
275
  [
254
276
  "oneOf",
@@ -263,14 +285,15 @@ function fromSchemaType(st) {
263
285
  }
264
286
  }
265
287
 
266
- function armToJsonSchema(tag, armType) {
288
+ function armToJsonSchema(inputNamesOpt, tag, armType) {
289
+ let inputNames = inputNamesOpt !== undefined ? inputNamesOpt : false;
267
290
  if (typeof armType !== "object") {
268
- return fromSchemaType(armType);
291
+ return fromSchemaType(inputNames, armType);
269
292
  }
270
293
  if (armType.TAG !== "ObjectRef") {
271
- return fromSchemaType(armType);
294
+ return fromSchemaType(inputNames, armType);
272
295
  }
273
- let base = objectRefToJsonSchema(undefined, undefined, undefined, undefined, armType._1);
296
+ let base = objectRefToJsonSchema(undefined, undefined, undefined, undefined, inputNames, armType._1);
274
297
  let obj = Stdlib_JSON.Decode.object(base);
275
298
  if (obj === undefined) {
276
299
  return base;
@@ -341,17 +364,18 @@ function withSemantic(fieldSchema, sem) {
341
364
  return obj;
342
365
  }
343
366
 
344
- function objectRefToJsonSchema(annotations, optionalOpt, ownersOpt, sensitiveOpt, fields) {
367
+ function objectRefToJsonSchema(annotations, optionalOpt, ownersOpt, sensitiveOpt, inputNamesOpt, fields) {
345
368
  let optional = optionalOpt !== undefined ? optionalOpt : [];
346
369
  let owners = ownersOpt !== undefined ? ownersOpt : [];
347
370
  let sensitive = sensitiveOpt !== undefined ? sensitiveOpt : [];
371
+ let inputNames = inputNamesOpt !== undefined ? inputNamesOpt : false;
348
372
  let props = {};
349
373
  let required = [];
350
374
  let internal = Stdlib_Option.getOr(Stdlib_Option.flatMap(annotations, spec => spec.internal), []);
351
375
  Object.entries(fields).filter(param => !internal.includes(param[0])).forEach(param => {
352
376
  let fieldType = param[1];
353
377
  let fieldName = param[0];
354
- let baseSchema = fromSchemaType(fieldType);
378
+ let baseSchema = fromSchemaType(inputNames, fieldType);
355
379
  let withAnnotations = annotations !== undefined ? mergeAnnotations(baseSchema, fieldName, annotations) : baseSchema;
356
380
  let withAnnotations$1;
357
381
  if (owners.includes(fieldName)) {
@@ -401,8 +425,10 @@ function objectRefToJsonSchema(annotations, optionalOpt, ownersOpt, sensitiveOpt
401
425
  return Object.fromEntries(entries);
402
426
  }
403
427
 
404
- function deriveObjectSchema(schema) {
405
- let fields = SchemaType$ReventlessCore.fromSuryObject("", schema);
428
+ function deriveObjectSchema(inputNamesOpt, typeNameOpt, schema) {
429
+ let inputNames = inputNamesOpt !== undefined ? inputNamesOpt : false;
430
+ let typeName = typeNameOpt !== undefined ? typeNameOpt : "";
431
+ let fields = SchemaType$ReventlessCore.fromSuryObject(typeName, schema);
406
432
  if (fields === undefined) {
407
433
  return Object.fromEntries([[
408
434
  "type",
@@ -410,7 +436,7 @@ function deriveObjectSchema(schema) {
410
436
  ]]);
411
437
  }
412
438
  let annotations = StateAnnotations$Reventless.getSpec(schema);
413
- let objSchema = objectRefToJsonSchema(annotations, SchemaType$ReventlessCore.optionalFieldNames(schema), Owner$Reventless.fieldNames(schema), Sensitive$Reventless.fieldNames(schema), fields);
439
+ let objSchema = objectRefToJsonSchema(annotations, SchemaType$ReventlessCore.optionalFieldNames(schema), Owner$Reventless.fieldNames(schema), Sensitive$Reventless.fieldNames(schema), inputNames, fields);
414
440
  if (annotations === undefined) {
415
441
  return objSchema;
416
442
  }
@@ -434,7 +460,7 @@ function deriveObjectSchema(schema) {
434
460
  }
435
461
 
436
462
  function toJsonSchema(schema) {
437
- return fromSchemaType(SchemaType$ReventlessCore.fromSury("", "", schema));
463
+ return fromSchemaType(undefined, SchemaType$ReventlessCore.fromSury("", "", schema));
438
464
  }
439
465
 
440
466
  export {
@@ -444,6 +470,7 @@ export {
444
470
  withOptionalPlugin,
445
471
  mergeAnnotations,
446
472
  isNullableType,
473
+ withGraphqlInput,
447
474
  fromSchemaType,
448
475
  armToJsonSchema,
449
476
  withSemantic,
@@ -313,9 +313,14 @@ module Make = (
313
313
  )
314
314
  })
315
315
  ->Effect.flatMap(((state, headPosition, _)) =>
316
+ // Identity, not content: the decision model folds the whole matched
317
+ // history, so serialising it here makes the line's size a function of
318
+ // how long the entity has been in use.
316
319
  EffectLogger.logDebug(
317
320
  ~comp,
318
- `deciding on state: ${state->JSON.stringifyAny->Option.getOr("<unserializable>")}`,
321
+ `deciding: id=${entityId->Option.getOr("-")} head=${headPosition->Option.getOr(
322
+ "-",
323
+ )} cmd=${cmdJson->LogFormat.cmdName}`,
319
324
  )->Effect.flatMap(_ =>
320
325
  switch Behavior.decide(state, command'.command) {
321
326
  | Ok(newEvents) if newEvents->Array.length == 0 =>
@@ -164,7 +164,7 @@ function Make(Spec) {
164
164
  }), param => {
165
165
  let headPosition = param[1];
166
166
  let state = param[0];
167
- return Effect.flatMap(EffectLogger$ReventlessCore.logDebug(comp, undefined, `deciding on state: ` + Stdlib_Option.getOr(JSON.stringify(state), "<unserializable>")), () => {
167
+ return Effect.flatMap(EffectLogger$ReventlessCore.logDebug(comp, undefined, `deciding: id=` + Stdlib_Option.getOr(entityId, "-") + ` head=` + Stdlib_Option.getOr(headPosition, "-") + ` cmd=` + LogFormat$ReventlessCore.cmdName(cmdJson)), () => {
168
168
  let newEvents = Behavior.decide(state, command$p.command);
169
169
  if (newEvents.TAG === "Ok") {
170
170
  let newEvents$1 = newEvents._0;
@@ -52,6 +52,7 @@ module type T = {
52
52
  ~extensions: array<module(ReventlessInfra.Extension.Blueprint)>=?,
53
53
  ~extensionPoints: array<module(ReventlessInfra.ExtensionPointMapping.Mapping)>=?,
54
54
  ~componentChapters: dict<string>=?,
55
+ ~lifecycleModel: array<Reventless.Plugin.derivedEdge>=?,
55
56
  ) => Reventless.Plugin.pluginStructure
56
57
  }
57
58
 
@@ -1104,6 +1104,7 @@ module Make = (
1104
1104
  ~extensions: array<module(ReventlessInfra.Extension.Blueprint)>=[],
1105
1105
  ~extensionPoints: array<module(ReventlessInfra.ExtensionPointMapping.Mapping)>=[],
1106
1106
  ~componentChapters: dict<string>=Dict.make(),
1107
+ ~lifecycleModel: array<Reventless.Plugin.derivedEdge>=[],
1107
1108
  ): Reventless.Plugin.pluginStructure =>
1108
1109
  Plugin_Structure.make(
1109
1110
  ~name,
@@ -1117,6 +1118,7 @@ module Make = (
1117
1118
  ~extensions,
1118
1119
  ~extensionPoints,
1119
1120
  ~componentChapters,
1121
+ ~lifecycleModel,
1120
1122
  )
1121
1123
 
1122
1124
  let make = (
@@ -95,7 +95,7 @@ function Make(Spec) {
95
95
  pages: pages
96
96
  };
97
97
  };
98
- let makePluginDefinition = (name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChangeSlicesOpt, automationSlicesOpt, outboundTranslationSlicesOpt, inboundTranslationSlicesOpt, extensionsOpt, extensionPointsOpt, componentChaptersOpt) => {
98
+ let makePluginDefinition = (name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChangeSlicesOpt, automationSlicesOpt, outboundTranslationSlicesOpt, inboundTranslationSlicesOpt, extensionsOpt, extensionPointsOpt, componentChaptersOpt, lifecycleModelOpt) => {
99
99
  let aggregates = aggregatesOpt !== undefined ? aggregatesOpt : [];
100
100
  let readModels = readModelsOpt !== undefined ? readModelsOpt : [];
101
101
  let stateViewSlices = stateViewSlicesOpt !== undefined ? stateViewSlicesOpt : [];
@@ -106,7 +106,8 @@ function Make(Spec) {
106
106
  let extensions = extensionsOpt !== undefined ? extensionsOpt : [];
107
107
  let extensionPoints = extensionPointsOpt !== undefined ? extensionPointsOpt : [];
108
108
  let componentChapters = componentChaptersOpt !== undefined ? componentChaptersOpt : ({});
109
- return Plugin_Structure$ReventlessCore.make(name, aggregates, readModels, stateViewSlices, stateChangeSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, extensions, extensionPoints, componentChapters);
109
+ let lifecycleModel = lifecycleModelOpt !== undefined ? lifecycleModelOpt : [];
110
+ return Plugin_Structure$ReventlessCore.make(name, aggregates, readModels, stateViewSlices, stateChangeSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, extensions, extensionPoints, componentChapters, lifecycleModel);
110
111
  };
111
112
  let make = (name, heartbeatInterval, extensionPointsOpt, extensionsOpt, aggregatesOpt, readModelsOpt, tasksOpt, stateChangeSlicesOpt, stateViewSlicesOpt, automationSlicesOpt, outboundTranslationSlicesOpt, inboundTranslationSlicesOpt, systemCallableComponentsOpt, componentRuntimeOpt, uiFragments, pluginStructure, opts) => {
112
113
  let extensionPoints = extensionPointsOpt !== undefined ? extensionPointsOpt : [];
@@ -4,6 +4,10 @@
4
4
 
5
5
  let log = Logger.fromEnv()
6
6
 
7
+ // Set by `check:lifecycle`, and by nothing else. See the note where it is read.
8
+ @val external _declaredTransitionsOnly: option<string> = "process.env.REVENTLESS_DECLARED_TRANSITIONS_ONLY"
9
+ let declaredTransitionsOnly = _declaredTransitionsOnly->Option.isSome
10
+
7
11
  // Whether a field can name a record, stated over `SchemaType`'s IR so every
8
12
  // shape it grows is excluded before it exists. `Nullable` unwraps; a `Semantic`
9
13
  // wrapper is refused — a bucket key or URL is not prose.
@@ -413,6 +417,15 @@ let checkDeclaredTransitions = (
413
417
  if Array.length(known) == 0 {
414
418
  unvalidated := unvalidated.contents + 1
415
419
  } else {
420
+ // Said, because the two are fixed differently: an authored edge is
421
+ // edited, a harvested one is re-derived. A harvested state the view no
422
+ // longer declares means the committed model went stale, and stopping is
423
+ // right — published, it would offer the command on no row at all.
424
+ let hint = switch cmd.allowedStatesSource {
425
+ | Some("derived") =>
426
+ " This edge came from the component's own scenarios, so the committed lifecycle model is stale."
427
+ | _ => ""
428
+ }
416
429
  declared
417
430
  ->Array.filter(state => !(known->Array.includes(state)))
418
431
  ->Array.forEach(state =>
@@ -421,7 +434,7 @@ let checkDeclaredTransitions = (
421
434
  `${w.name}.${cmd.name} declares state "${state}", which none of its ` ++
422
435
  `linked views declare — ${w.linkedViews->Array.join(
423
436
  ", ",
424
- )} know ${known->Array.join(", ")}.`,
437
+ )} know ${known->Array.join(", ")}.${hint}`,
425
438
  )
426
439
  ->ignore
427
440
  )
@@ -443,7 +456,7 @@ let checkDeclaredTransitions = (
443
456
 
444
457
  if Array.length(failures) > 0 {
445
458
  JsError.throwWithMessage(
446
- `${pluginName}: a declared transition names states that do not exist.\n` ++
459
+ `${pluginName}: a transition names states that do not exist.\n` ++
447
460
  failures->Array.join("\n"),
448
461
  )
449
462
  }
@@ -580,10 +593,18 @@ let extractReferences = (properties: dict<S.t<unknown>>): array<
580
593
  > =>
581
594
  properties
582
595
  ->Dict.toArray
583
- ->Array.filterMap(((fieldName, fieldSchema)) =>
584
- Reventless.Reference.getFieldTarget(fieldSchema)->Option.map(target => (
596
+ ->Array.flatMap(((fieldName, fieldSchema)) =>
597
+ // `collectFieldTargets`, not `getFieldTarget`: a reference declared on a field
598
+ // of a record the field holds (an order line's `productId`) is one this
599
+ // command declares, and it arrives named by its own path. Commands and events
600
+ // both come through here, which is what stops one of them learning about
601
+ // nesting and the other not.
602
+ Reventless.Reference.collectFieldTargets(fieldName, fieldSchema)->Array.map(((
603
+ path,
604
+ target,
605
+ )) => (
585
606
  {
586
- Reventless.Plugin.fieldName,
607
+ Reventless.Plugin.fieldName: path,
587
608
  entity: target.entity,
588
609
  plugin: target.plugin,
589
610
  }: Reventless.Plugin.fieldReference
@@ -637,6 +658,10 @@ let extractErrorDefs = (errorSchema: S.t<unknown>): array<Reventless.Plugin.erro
637
658
 
638
659
  // Aggregate commands that initialize a new aggregate instance are Collection-level
639
660
  // (shown as table-top buttons); all others are Instance-level (shown per-row).
661
+ //
662
+ // A guess from the command's name stem, and it misreads `Enroll`, `Provision`,
663
+ // `Onboard`. It answers only where the harvested model does not: a plugin with no
664
+ // corpus, or one whose linked views declare no lifecycle to label a history with.
640
665
  let isCreateCommandName = name =>
641
666
  ["Add", "Create", "Register", "Open", "Initialize", "Submit", "Start", "Place"]->Array.some(p =>
642
667
  name->String.startsWith(p)
@@ -724,13 +749,17 @@ let toCommandDef = (
724
749
  // (see the call sites below), so `Transition` itself asserts nothing about
725
750
  // representation and stays parameterised all the way down.
726
751
  ~commandTransition: unknown => Reventless.Transition.t<string>,
752
+ // What this component's own scenarios say about each command, harvested at
753
+ // build time and committed beside the plugin. `None` for a command no corpus
754
+ // covers, which is most of them in a plugin that ships no tests.
755
+ ~derivedEdgeFor: string => option<Reventless.Plugin.derivedEdge>,
727
756
  v: S.t<unknown>,
728
757
  ): option<Reventless.Plugin.commandDef> => {
729
758
  // Build a commandDef for one variant. `properties` is the variant's field dict —
730
759
  // empty for a payload-less variant (e.g. `| Archive`), which compiles to a bare
731
760
  // `S.literal("Archive")` string rather than an `{TAG, ...}` object.
732
761
  let mkDef = (~variantName, ~properties) => {
733
- let (level, aggregateIdField) = commandLevelAndId(~isAggregate, ~variantName, properties)
762
+ let (guessedLevel, aggregateIdField) = commandLevelAndId(~isAggregate, ~variantName, properties)
734
763
  let references = extractReferences(properties)
735
764
  // Evaluated against a synthetic value per constructor, the same shape the
736
765
  // resolver builds at call time: a payload-bearing variant compiles to
@@ -742,12 +771,57 @@ let toCommandDef = (
742
771
  // The spec's own switch, which is exhaustive — so it also speaks for a
743
772
  // constructor the host did not declare but spliced from a trait.
744
773
  //
745
- // An edge is ONE declaration, so the two fields are read off it together.
746
774
  // `targetState: None` ⇒ AutoUI's board resolver falls back to its name-stem
747
775
  // heuristic.
748
776
  let declared = commandTransition(syntheticCommand)
749
- let allowedStates = Reventless.Transition.allowedStates(declared)
750
- let targetState = Reventless.Transition.targetState(declared)
777
+ let derived = derivedEdgeFor(variantName)
778
+
779
+ // Where the scenarios answer, they answer; where they are silent, the
780
+ // annotation stands. The halves are resolved separately because they are
781
+ // silent separately — a corpus routinely shows a command taking effect
782
+ // without ever showing where it lands — and because an empty derivation is
783
+ // not a derivation: `Some([])` matches no row's lifecycle tag, so publishing
784
+ // one offers the command on no row at all while the annotation that would
785
+ // have been right sits unread beside it.
786
+ //
787
+ // The one claim the scenarios may not answer for is `Unrestricted`: it says
788
+ // the command is legal in EVERY state, and a corpus only covers the states
789
+ // somebody wrote a scenario for, so deriving a from-set there would shrink a
790
+ // deliberate claim to an accident of coverage and withdraw the command from
791
+ // the rows nobody tested. `Undeclared` — the ppx's injected default — erases
792
+ // to the same pair of `None`s and is silence, which the scenarios may answer.
793
+ let unrestricted = Reventless.Transition.isUnrestricted(declared)
794
+ let (allowedStates, allowedStatesSource) = switch (
795
+ unrestricted
796
+ ? None
797
+ : derived->Option.flatMap(d =>
798
+ Array.length(d.allowedStates) > 0 ? Some(d.allowedStates) : None
799
+ ),
800
+ Reventless.Transition.allowedStates(declared),
801
+ ) {
802
+ | (Some(observed), _) => (Some(observed), Some("derived"))
803
+ | (None, Some(states)) => (Some(states), Some("declared"))
804
+ | (None, None) => (None, unrestricted ? Some("unrestricted") : None)
805
+ }
806
+ // The target is not protected the same way, because the two halves fail
807
+ // differently: a from-set RESTRICTS, and narrowing one wrongly takes a
808
+ // command away from rows it belongs on, while a target only tells a diagram
809
+ // where an edge lands. Supplying one the author left unstated adds
810
+ // information and withdraws nothing — which is what `Customer.Register`'s
811
+ // "the row's status is the view's to derive" asks for.
812
+ let targetState = switch derived->Option.flatMap(d =>
813
+ // Two observed targets are an edge `targetState` cannot express. Reported
814
+ // as a contradiction by the harvest; here the declaration is left to speak
815
+ // rather than one of the two picked.
816
+ switch d.targets {
817
+ | [only] => Some(only)
818
+ | _ => None
819
+ }
820
+ ) {
821
+ | Some(observed) => Some(observed)
822
+ | None => Reventless.Transition.targetState(declared)
823
+ }
824
+ let level = derived->Option.flatMap(d => d.level)->Option.getOr(guessedLevel)
751
825
  // API-exposed iff the whole command isn't @noApi and this variant
752
826
  // isn't in its @noApi-variants set — mirrors the API-generation filter
753
827
  // (Plugin_Helpers / PluginBaseFragment). Drives the event-graph API badge.
@@ -763,7 +837,12 @@ let toCommandDef = (
763
837
  // argument type names are composed *from* that field name, so there is
764
838
  // nothing to publish for it either.
765
839
  let mutationField = apiExposed ? mutationFieldFor(variantName) : ""
766
- let jsonSchema = v->SuryToJsonSchema.deriveObjectSchema
840
+ // Rooted at the mutation field, and asking for the input type names, so an
841
+ // object nested inside an argument (an order line inside `lineItems`) carries
842
+ // the name the SDL declares it under — which `x-reventless-graphql-type`, keyed
843
+ // by top-level argument, has no place to put.
844
+ let jsonSchema =
845
+ v->SuryToJsonSchema.deriveObjectSchema(~inputNames=apiExposed, ~typeName=mutationField)
767
846
  let annotatedSchema = if apiExposed {
768
847
  GraphQL_FragmentGenerator.mutationArgTypes(~fieldName=mutationField, v)->Option.mapOr(
769
848
  jsonSchema,
@@ -785,6 +864,7 @@ let toCommandDef = (
785
864
  references,
786
865
  allowedStates,
787
866
  targetState,
867
+ allowedStatesSource: ?allowedStatesSource,
788
868
  apiExposed: Some(apiExposed),
789
869
  requiredAccess,
790
870
  // Resolved from this constructor's own properties, not the union's: two
@@ -817,6 +897,7 @@ let extractCommandDefs = (
817
897
  ~mutationFieldFor: string => string,
818
898
  ~commandAuthorization: unknown => Reventless.Authorization.permission,
819
899
  ~commandTransition: unknown => Reventless.Transition.t<string>,
900
+ ~derivedEdgeFor: string => option<Reventless.Plugin.derivedEdge>=_ => None,
820
901
  commandSchema: S.t<unknown>,
821
902
  ): array<Reventless.Plugin.commandDef> =>
822
903
  switch commandSchema {
@@ -828,6 +909,7 @@ let extractCommandDefs = (
828
909
  ~parentSchema=commandSchema,
829
910
  ~commandAuthorization,
830
911
  ~commandTransition,
912
+ ~derivedEdgeFor,
831
913
  v,
832
914
  )
833
915
  )
@@ -839,6 +921,7 @@ let extractCommandDefs = (
839
921
  ~parentSchema=commandSchema,
840
922
  ~commandAuthorization,
841
923
  ~commandTransition,
924
+ ~derivedEdgeFor,
842
925
  commandSchema,
843
926
  )->Option.mapOr([], def => [def])
844
927
  }
@@ -911,8 +994,18 @@ let make = (
911
994
  // Component name → chapter, captured from each component's source folder by the
912
995
  // plugin generator. Keyed by `Spec.name`; no entry renders flat.
913
996
  ~componentChapters: dict<string>=Dict.make(),
997
+ // What the plugin's own scenarios say about each command's lifecycle edge,
998
+ // harvested by `check:lifecycle` and committed as `src/LifecycleModel.res`.
999
+ ~lifecycleModel: array<Reventless.Plugin.derivedEdge>=[],
914
1000
  ): Reventless.Plugin.pluginStructure => {
915
1001
  let chapterOf = (compName: string): option<string> => componentChapters->Dict.get(compName)
1002
+ // The harvest compares its own reading of the corpus with the DECLARATION, so
1003
+ // it needs a structure the model has not already been folded into. Reading a
1004
+ // derived value back as though it were the claim would confirm every edge and
1005
+ // hide exactly the disagreements the check exists to find.
1006
+ let model = declaredTransitionsOnly ? [] : lifecycleModel
1007
+ let derivedEdgeFor = (~component: string, command: string) =>
1008
+ model->Array.find(e => e.component == component && e.command == command)
916
1009
  // Payload-less variants dropped: the graph must not claim an edge a DCB lookup
917
1010
  // cannot WHERE-clause on.
918
1011
  let eventVariantNames = schema => Reventless.DcbTag.extractVariantNames(schema)
@@ -1391,6 +1484,7 @@ let make = (
1391
1484
  ),
1392
1485
  ~commandAuthorization=SCS.Spec.commandAuthorization->Obj.magic,
1393
1486
  ~commandTransition=SCS.Spec.commandTransition->Obj.magic,
1487
+ ~derivedEdgeFor=derivedEdgeFor(~component=SCS.Spec.name, ...),
1394
1488
  SCS.Spec.commandSchema->S.castToUnknown,
1395
1489
  ),
1396
1490
  producedEventTypes: produced,
@@ -1416,6 +1510,7 @@ let make = (
1416
1510
  ~mutationFieldFor=variantName => Api_Naming.aggregateMutationField(~plugin=name, ~aggregate=A.Spec.name, ~command=variantName),
1417
1511
  ~commandAuthorization=A.Spec.commandAuthorization->Obj.magic,
1418
1512
  ~commandTransition=A.Spec.commandTransition->Obj.magic,
1513
+ ~derivedEdgeFor=derivedEdgeFor(~component=A.Spec.name, ...),
1419
1514
  A.Spec.commandSchema->S.castToUnknown,
1420
1515
  ),
1421
1516
  producedEventTypes: produced,