@reventlessdev/reventless-core 3.0.0-alpha.190 → 3.0.0-alpha.192

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 (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +5 -5
  3. package/src/admin/Platform_Admin.res +2 -2
  4. package/src/admin/Platform_Admin.res.mjs +6 -2
  5. package/src/admin/Platform_Admin_Structure.res +4 -0
  6. package/src/admin/Platform_Admin_Structure.res.mjs +5 -1
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +5 -1
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  9. package/src/components/Aggregate/Aggregate.res +7 -7
  10. package/src/components/Aggregate/Aggregate.res.mjs +7 -2
  11. package/src/components/Aggregate/Aggregate_Builder.res +4 -1
  12. package/src/components/Aggregate/Aggregate_Builder.res.mjs +11 -3
  13. package/src/components/Builder_Helpers.res +37 -4
  14. package/src/components/Builder_Helpers.res.mjs +24 -2
  15. package/src/components/Dcb/Dcb_Builder.res +2 -2
  16. package/src/components/Dcb/Dcb_Builder.res.mjs +4 -4
  17. package/src/components/SideEffectHandler/SideEffectHandler.res +11 -0
  18. package/src/components/SideEffectHandler/SideEffectHandler_Builder.res +2 -0
  19. package/src/components/SideEffectHandler/SideEffectHandler_Builder.res.mjs +3 -1
  20. package/src/components/Task/Task_Builder.res +11 -0
  21. package/src/components/Task/Task_Builder.res.mjs +2 -0
  22. package/src/plugin/component/Capability_Inference.res +68 -0
  23. package/src/plugin/component/Capability_Inference.res.mjs +79 -0
  24. package/src/plugin/component/Plugin_Builder.res +2 -2
  25. package/src/plugin/component/Plugin_Builder.res.mjs +4 -4
  26. package/src/plugin/component/Plugin_Helpers.res +18 -7
  27. package/src/plugin/component/Plugin_Helpers.res.mjs +20 -1
  28. package/src/plugin/component/Plugin_Structure.res +215 -79
  29. package/src/plugin/component/Plugin_Structure.res.mjs +195 -50
  30. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +5 -0
  31. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +15 -4
  32. package/tests/message/MessageTest.res +1 -0
  33. package/tests/message/MessageTest.res.mjs +2 -1
  34. package/tests/plugin/PluginStructureTest.res +391 -0
  35. package/tests/plugin/PluginStructureTest.res.mjs +355 -0
  36. package/tests/plugin/StateChangeSlice/PsChangePhoto.res +33 -0
  37. package/tests/plugin/StateChangeSlice/PsChangePhoto.res.mjs +64 -0
@@ -0,0 +1,79 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
6
+
7
+ let refLikeNames = [
8
+ "file",
9
+ "attachment",
10
+ "upload"
11
+ ];
12
+
13
+ let refLikeSuffixes = [
14
+ "storageref",
15
+ "fileref",
16
+ "attachmentref"
17
+ ];
18
+
19
+ let imageLikeNames = [
20
+ "image",
21
+ "imageurl",
22
+ "photo",
23
+ "photourl",
24
+ "avatar",
25
+ "avatarurl",
26
+ "thumbnail"
27
+ ];
28
+
29
+ function nameMatches(field) {
30
+ let lower = field.toLowerCase();
31
+ if (refLikeNames.includes(lower) || imageLikeNames.includes(lower)) {
32
+ return true;
33
+ } else {
34
+ return refLikeSuffixes.some(suffix => lower.endsWith(suffix));
35
+ }
36
+ }
37
+
38
+ function warningsFromProperties(component, properties) {
39
+ return Stdlib_Array.filterMap(Object.entries(properties), param => {
40
+ let fieldSchema = param[1];
41
+ let field = param[0];
42
+ if (fieldSchema.type === "string" && field !== "TAG" && nameMatches(field) && Stdlib_Option.isNone(StorageRef$Reventless.getStore(fieldSchema))) {
43
+ return {
44
+ component: component,
45
+ field: field
46
+ };
47
+ }
48
+ });
49
+ }
50
+
51
+ function scanSchema(component, schema) {
52
+ let fromVariant = v => {
53
+ if (v.type === "object") {
54
+ return warningsFromProperties(component, v.properties);
55
+ } else {
56
+ return [];
57
+ }
58
+ };
59
+ if (schema.type === "union") {
60
+ return schema.anyOf.flatMap(fromVariant);
61
+ } else {
62
+ return fromVariant(schema);
63
+ }
64
+ }
65
+
66
+ function message(w) {
67
+ return w.component + `.` + w.field + ` looks like a stored-object reference but declares no store — annotate it \`@storageRef("<store>")\` so the deployment provisions the store it needs, or rename the field if it holds an external URL. Heuristic matches are never provisioned.`;
68
+ }
69
+
70
+ export {
71
+ refLikeNames,
72
+ refLikeSuffixes,
73
+ imageLikeNames,
74
+ nameMatches,
75
+ warningsFromProperties,
76
+ scanSchema,
77
+ message,
78
+ }
79
+ /* StorageRef-Reventless Not a pure module */
@@ -267,13 +267,13 @@ module Make = (
267
267
  module(R: ReventlessInfra.ReadModel.T with type api = api and type role = role),
268
268
  ) => {
269
269
  let qn = Api_Naming.queryFieldNamesForReadModel(~plugin=name, ~name=R.Spec.name)
270
- let (labelField, _searchableFields) = Plugin_Structure.labelFieldsFromStateSchema(
270
+ let label = Plugin_Structure.labelFieldsFromStateSchema(
271
271
  ~entityName=R.Spec.name,
272
272
  R.Spec.stateSchema->S.castToUnknown,
273
273
  )
274
274
  let qn = {
275
275
  ...qn,
276
- labelField,
276
+ labelField: label.field,
277
277
  connectionFilterTypeName: qn.returnTypeName ++ "Filter",
278
278
  }
279
279
  let qn = switch R.Spec.subIdConfig {
@@ -208,13 +208,13 @@ function Make(Spec) {
208
208
  let eventLogEntries = eventLogEntriesFromAggregates.concat(dcbResult.eventLogEntries);
209
209
  readModels.forEach(R => {
210
210
  let qn = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(extra$1, R.Spec.name, undefined);
211
- let match = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(R.Spec.name, R.Spec.stateSchema);
211
+ let label = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(R.Spec.name, R.Spec.stateSchema);
212
212
  let newrecord = {...qn};
213
- newrecord.labelField = match[0];
213
+ newrecord.labelField = label.field;
214
214
  newrecord.connectionFilterTypeName = qn.returnTypeName + "Filter";
215
- let match$1 = R.Spec.subIdConfig;
215
+ let match = R.Spec.subIdConfig;
216
216
  let qn$1;
217
- if (match$1 !== undefined) {
217
+ if (match !== undefined) {
218
218
  let newrecord$1 = {...newrecord};
219
219
  newrecord$1.itemsFilterTypeName = newrecord.returnTypeName + "ItemsFilter";
220
220
  newrecord$1.itemsFieldName = newrecord.singleFieldName + "Items";
@@ -434,6 +434,8 @@ let createTasks = (
434
434
  ~opts=Some(opts),
435
435
  ))
436
436
  )
437
+ // Every task is constructed, so every side-effect handler has registered.
438
+ finishTasks()
437
439
  tasksOutputs.contents
438
440
  }
439
441
 
@@ -1246,23 +1248,32 @@ let serializeTasksOutputs = (pluginOutputs: Plugin.outputs): Pulumi.Output.t<JSO
1246
1248
  // Serialize event mappers from aggregates to JSON array for the "eventMappers" stack export.
1247
1249
  let serializeEventMappersOutputs = (pluginOutputs: Plugin.outputs): Pulumi.Output.t<JSON.t> =>
1248
1250
  pluginOutputs.aggregates->Pulumi.Output.flatMap(aggregates =>
1251
+ // Resolve each `eventMapper` through its own Output before collecting, and only
1252
+ // then drop the absent ones. `Output.all` deeply unwraps the *contents* of its
1253
+ // elements, so collecting first would hand `toResolvedOutputs` a record whose
1254
+ // fields Pulumi has already resolved — see `serializePlainDictExport` above for
1255
+ // the same hazard. Absence is carried as `None` through the collection instead.
1249
1256
  aggregates
1250
1257
  ->Dict.valuesToArray
1251
- ->Array.filterMap((agg: Aggregate.outputs) =>
1252
- agg.eventMapper->Option.map(eventMapperOutput =>
1253
- eventMapperOutput->Pulumi.Output.flatMap(em =>
1258
+ ->Array.map((agg: Aggregate.outputs) =>
1259
+ agg.eventMapper->Pulumi.Output.flatMap(em =>
1260
+ switch em {
1261
+ | Some(em) =>
1254
1262
  em
1255
1263
  ->EventMapper.toResolvedOutputs
1256
1264
  ->Pulumi.Output.apply(resolved =>
1257
- resolved->S.reverseConvertToJsonOrThrow(
1258
- ReventlessInterop.EventMapper.resolvedOutputsSchema,
1265
+ Some(
1266
+ resolved->S.reverseConvertToJsonOrThrow(
1267
+ ReventlessInterop.EventMapper.resolvedOutputsSchema,
1268
+ ),
1259
1269
  )
1260
1270
  )
1261
- )
1271
+ | None => Pulumi.Output.make(None)
1272
+ }
1262
1273
  )
1263
1274
  )
1264
1275
  ->Pulumi.Output.all
1265
- ->Pulumi.Output.apply(arr => arr->JSON.Encode.array)
1276
+ ->Pulumi.Output.apply(arr => arr->Array.keepSome->JSON.Encode.array)
1266
1277
  )
1267
1278
 
1268
1279
  // Export platform admin component outputs as individual top-level Pulumi stack exports.
@@ -248,6 +248,7 @@ function createTasks(tasks, aggregatesOutputs, scheduler, schedulerRoleUrn, publ
248
248
  let bucketName = bucketNameOpt !== undefined ? bucketNameOpt : "Bucket";
249
249
  return ResourceQueryRuntime$ReventlessCore.bucketNameOfTaskExn(tasksOutputs.contents, taskName, bucketName);
250
250
  }, scheduler, schedulerRoleUrn, publishToAggregates, queryEngine, resourceNaming, aggregatesOutputs, componentRuntime[SpecificTask.Spec.name], opts)));
251
+ Builder_Helpers$ReventlessCore.finishTasks();
251
252
  return tasksOutputs.contents;
252
253
  }
253
254
 
@@ -627,7 +628,13 @@ function serializeTasksOutputs(pluginOutputs) {
627
628
  }
628
629
 
629
630
  function serializeEventMappersOutputs(pluginOutputs) {
630
- return Output$Pulumi.flatMap(pluginOutputs.aggregates, aggregates => Pulumi.all(Stdlib_Array.filterMap(Object.values(aggregates), agg => Stdlib_Option.map(agg.eventMapper, eventMapperOutput => Output$Pulumi.flatMap(eventMapperOutput, em => EventMapper$ReventlessCore.toResolvedOutputs(em).apply(resolved => S.reverseConvertToJsonOrThrow(resolved, EventMapper$ReventlessInterop.resolvedOutputsSchema)))))).apply(arr => arr));
631
+ return Output$Pulumi.flatMap(pluginOutputs.aggregates, aggregates => Pulumi.all(Object.values(aggregates).map(agg => Output$Pulumi.flatMap(agg.eventMapper, em => {
632
+ if (em !== undefined) {
633
+ return EventMapper$ReventlessCore.toResolvedOutputs(em).apply(resolved => S.reverseConvertToJsonOrThrow(resolved, EventMapper$ReventlessInterop.resolvedOutputsSchema));
634
+ } else {
635
+ return Pulumi.output(undefined);
636
+ }
637
+ }))).apply(Stdlib_Array.keepSome));
631
638
  }
632
639
 
633
640
  function exportPlatformOutputs(extensionPointsOutputs, aggregatesOutputs, readModelsOutputs, dcbEventLogOutputs, stateChangeSlicesOutputs, stateViewSlicesOutputs, automationSlicesOutputs, outboundTranslationSlicesOutputs, inboundTranslationSlicesOutputs) {
@@ -913,6 +920,14 @@ let finishAggregates = Builder_Helpers$ReventlessCore.finishAggregates;
913
920
 
914
921
  let addEventMappers = Builder_Helpers$ReventlessCore.addEventMappers;
915
922
 
923
+ let taskSideEffectGates = Builder_Helpers$ReventlessCore.taskSideEffectGates;
924
+
925
+ let taskSideEffectFinishFns = Builder_Helpers$ReventlessCore.taskSideEffectFinishFns;
926
+
927
+ let registerTaskSideEffectHandler = Builder_Helpers$ReventlessCore.registerTaskSideEffectHandler;
928
+
929
+ let finishTasks = Builder_Helpers$ReventlessCore.finishTasks;
930
+
916
931
  let readModelNamesForSourceName = Builder_Helpers$ReventlessCore.readModelNamesForSourceName;
917
932
 
918
933
  let publishToReadModels = Builder_Helpers$ReventlessCore.publishToReadModels;
@@ -968,6 +983,10 @@ export {
968
983
  createAggregatesWithoutEventMappers,
969
984
  finishAggregates,
970
985
  addEventMappers,
986
+ taskSideEffectGates,
987
+ taskSideEffectFinishFns,
988
+ registerTaskSideEffectHandler,
989
+ finishTasks,
971
990
  readModelNamesForSourceName,
972
991
  publishToReadModels,
973
992
  finishReadModels,
@@ -5,26 +5,53 @@
5
5
 
6
6
  let log = Logger.fromEnv()
7
7
 
8
- // Returns (labelField, searchableFields) from a state schema.
9
- // Source ladder:
10
- // 1. @displayName spec present → "displayName" + spec.fields (raw underlying fields)
11
- // 2. First non-id, non-`*Id`/`*Ids`, non-TAG, string-typed field in declaration order
12
- // 3. "id" fallback with a logged warning (no searchable fields)
13
- //
14
- // `*Id` / `*Ids` fields (e.g. `productId`, `customerId`, `orderIds`) are entity
15
- // references, not human-readable labels — skipping them lets a sibling like
16
- // `name` win even when it appears later in declaration order.
17
- let isIdLikeFieldName = (name: string): bool =>
18
- name == "id" || name->String.endsWith("Id") || name->String.endsWith("Ids")
19
-
20
- // Resolve the field that holds the entity's lifecycle status, used by
21
- // AutoUI to filter the per-row command menu against each command's
22
- // `allowedStates`. Resolution order:
8
+ // What a field *is* is `SchemaType`'s question, and it is the IR every other
9
+ // schema consumer here reads. The two predicates below are the only two answers
10
+ // this module needs; both are stated over the IR rather than over sury shapes,
11
+ // so a field that is a date, a reference or an enum is one for the label picker
12
+ // too.
13
+
14
+ // Whether a field can name a record. `Nullable` unwraps — an optional name is
15
+ // still the entity's name, absent on some rows, which is a rendering question
16
+ // rather than a declaration one. A `Semantic` wrapper is refused: it is the
17
+ // schema saying the string means something other than prose (a storage ref, a
18
+ // URL), and a bucket key is not a name. `DateTime`, `EntityId`, `Enum` and every
19
+ // composite shape fall out of the catch-all without being named — which is the
20
+ // point of reading an IR rather than restating it: the next shape it grows is
21
+ // excluded before it exists.
22
+ let rec isLabelShape = (t: SchemaType.schemaType): bool =>
23
+ switch t {
24
+ | ScalarString => true
25
+ | Nullable(inner) => isLabelShape(inner)
26
+ | _ => false
27
+ }
28
+
29
+ // Whether a field can hold a lifecycle status: a closed set of values, or an
30
+ // optional one. A free-text `status: string` is not a lifecycle — filtering a
31
+ // command menu against `allowedStates` needs states to compare with.
32
+ let rec isStatusShape = (t: SchemaType.schemaType): bool =>
33
+ switch t {
34
+ | Enum(_, _) => true
35
+ | Nullable(inner) => isStatusShape(inner)
36
+ | _ => false
37
+ }
38
+
39
+ // Field names that say "this one is the record's name" in the only way
40
+ // available short of the `@displayName` annotation. Matched case-insensitively
41
+ // and *exactly*: `customerName` holds a customer's name, not this record's.
42
+ let conventionalLabelNames = ["name", "title", "label", "displayname"]
43
+
44
+ let shapeOfItem = (~entityName: string, item: S.item): SchemaType.schemaType =>
45
+ SchemaType.fromSury(~parentName=entityName, ~fieldName=item.location, item.schema)
46
+
47
+ // Resolve the field that holds the entity's lifecycle status, used to filter a
48
+ // per-row command menu against each command's `allowedStates`. Resolution order:
23
49
  // 1. Field annotated `@status` (PPX-emitted; see StateAnnotations).
24
- // 2. Field literally named `"status"` (convention; mirrors how
25
- // labelField falls back to a conventionally-named string field).
50
+ // 2. Field literally named `"status"` whose IR shape is an enum (convention;
51
+ // mirrors how labelField falls back to a conventionally-named field).
26
52
  // 3. None — filter is inert for this read model.
27
53
  let statusFieldFromStateSchema = (
54
+ ~entityName: string,
28
55
  stateSchema: S.t<unknown>,
29
56
  ): option<string> => {
30
57
  let annotated = switch Reventless.StateAnnotations.getSpec(stateSchema) {
@@ -37,40 +64,96 @@ let statusFieldFromStateSchema = (
37
64
  switch stateSchema {
38
65
  | Object({items}) =>
39
66
  items
40
- ->Array.find(item => item.location == "status")
67
+ ->Array.find(item =>
68
+ item.location == "status" && isStatusShape(shapeOfItem(~entityName, item))
69
+ )
41
70
  ->Option.map(item => item.location)
42
71
  | _ => None
43
72
  }
44
73
  }
45
74
  }
46
75
 
76
+ // Which rung of the ladder below produced the label. Published on `queryableDef`
77
+ // as `labelFieldSource`, because the four rungs are not equally believable and a
78
+ // consumer with a name rule of its own has to rank the declaration against it:
79
+ // rung 1 is the author saying which field names the record, rungs 2 and 3 are
80
+ // guesses this repo makes on their behalf, and rung 4 is the admission that
81
+ // there was nothing to guess from. Convention and position are both guesses and
82
+ // nothing here branches on the difference — but a conventional name is the one a
83
+ // client can independently arrive at, while a positional pick is a fact only
84
+ // this side knows.
85
+ type labelFieldSource =
86
+ | Annotation
87
+ | Convention
88
+ | Position
89
+ | Fallback
90
+
91
+ let labelFieldSourceToString = (s: labelFieldSource): string =>
92
+ switch s {
93
+ | Annotation => "annotation"
94
+ | Convention => "convention"
95
+ | Position => "position"
96
+ | Fallback => "fallback"
97
+ }
98
+
99
+ type labelResolution = {
100
+ field: string,
101
+ searchableFields: array<string>,
102
+ source: labelFieldSource,
103
+ }
104
+
105
+ // Resolves the field a state is named by, from a state schema.
106
+ // Source ladder:
107
+ // 1. @displayName spec present → "displayName" + spec.fields (raw underlying fields)
108
+ // 2. A candidate field named `name`/`title`/`label`/`displayName`
109
+ // 3. The first candidate in declaration order
110
+ // 4. "id" fallback with a logged warning (no searchable fields)
111
+ //
112
+ // A *candidate* is a non-TAG field, not literally named `id`, whose IR shape can
113
+ // name a record (`isLabelShape`). Rungs 2 and 3 are both guesses, and the order
114
+ // between them matters: declaration order alone means a state gains a new name
115
+ // whenever a field is inserted above the old one — a `placedAt` added so date
116
+ // views have something to key off renames every order to a timestamp.
117
+ //
118
+ // `id` is excluded here rather than by the IR because `SchemaType` treats a name
119
+ // that short as an ordinary string; every other reference — `*Id`/`*Ids`, a DCB
120
+ // tag, a `Reference.to` — is already an `EntityId` there.
47
121
  let labelFieldsFromStateSchema = (
48
122
  ~entityName: string,
49
123
  stateSchema: S.t<unknown>,
50
- ): (string, array<string>) =>
124
+ ): labelResolution =>
51
125
  switch Reventless.DisplayName.getSpec(stateSchema) {
52
- | Some(spec) => ("displayName", spec.fields)
126
+ | Some(spec) => {field: "displayName", searchableFields: spec.fields, source: Annotation}
53
127
  | None =>
54
- let firstStringItem = switch stateSchema {
128
+ let candidates = switch stateSchema {
55
129
  | Object({items}) =>
56
- items->Array.find(item =>
130
+ items->Array.filter(item =>
57
131
  item.location != "TAG" &&
58
- !isIdLikeFieldName(item.location) &&
59
- switch item.schema {
60
- | String(_) => true
61
- | _ => false
62
- }
132
+ item.location != "id" &&
133
+ isLabelShape(shapeOfItem(~entityName, item))
63
134
  )
64
- | _ => None
135
+ | _ => []
136
+ }
137
+ let conventional = candidates->Array.find(item => {
138
+ let lower = item.location->String.toLowerCase
139
+ conventionalLabelNames->Array.some(n => n == lower)
140
+ })
141
+ let picked = switch conventional {
142
+ | Some(item) => Some((item, Convention))
143
+ | None => candidates->Array.get(0)->Option.map(item => (item, Position))
65
144
  }
66
- switch firstStringItem {
67
- | Some(item) => (item.location, [item.location])
145
+ switch picked {
146
+ | Some((item, source)) => {
147
+ field: item.location,
148
+ searchableFields: [item.location],
149
+ source,
150
+ }
68
151
  | None =>
69
152
  log.warn(
70
153
  ~comp="Plugin_Structure",
71
154
  `${entityName}: no @displayName annotation and no suitable string field — labelField falls back to "id"`,
72
155
  )
73
- ("id", [])
156
+ {field: "id", searchableFields: [], source: Fallback}
74
157
  }
75
158
  }
76
159
 
@@ -398,23 +481,34 @@ let make = (
398
481
  // An unqualified store belongs to the declaring plugin, so every collected
399
482
  // entry is qualified to `{plugin}.{store}` — one shape, and the string is
400
483
  // directly the store's identity. Many fields legitimately name one store, so
401
- // the result is deduplicated.
402
-
403
- let storesFromProperties = (properties: dict<S.t<unknown>>): array<string> =>
484
+ // the key set is deduplicated.
485
+ //
486
+ // Each collected entry keeps its declaration site `(component, field)` as
487
+ // provenance, and `requiredStores` is derived from the collected entries —
488
+ // one walk, so the capability manifest's provenance and the deploy's key set
489
+ // cannot come from different readings of the schemas.
490
+
491
+ let storesFromProperties = (~component, properties: dict<S.t<unknown>>): array<
492
+ Reventless.Plugin.requiredStoreDeclaration,
493
+ > =>
404
494
  properties
405
495
  ->Dict.toArray
406
- ->Array.filterMap(((_, fieldSchema)) =>
407
- Reventless.StorageRef.getStore(fieldSchema)->Option.map(target =>
408
- target.plugin->Option.getOr(name) ++ "." ++ target.store
409
- )
496
+ ->Array.filterMap(((field, fieldSchema)) =>
497
+ Reventless.StorageRef.getStore(fieldSchema)->Option.map(target => {
498
+ Reventless.Plugin.store: target.plugin->Option.getOr(name) ++ "." ++ target.store,
499
+ component,
500
+ field,
501
+ })
410
502
  )
411
503
 
412
504
  // Commands and events are unions of per-variant objects; state is a single
413
505
  // object. Both reduce to "the properties of every object in this schema".
414
- let storesFromSchema = (schema: S.t<unknown>): array<string> => {
506
+ let storesFromSchema = (~component, schema: S.t<unknown>): array<
507
+ Reventless.Plugin.requiredStoreDeclaration,
508
+ > => {
415
509
  let fromVariant = v =>
416
510
  switch v {
417
- | S.Object({properties}) => storesFromProperties(properties)
511
+ | S.Object({properties}) => storesFromProperties(~component, properties)
418
512
  | _ => []
419
513
  }
420
514
  switch schema {
@@ -423,37 +517,82 @@ let make = (
423
517
  }
424
518
  }
425
519
 
426
- let requiredStores =
520
+ let compareDeclarations = (
521
+ a: Reventless.Plugin.requiredStoreDeclaration,
522
+ b: Reventless.Plugin.requiredStoreDeclaration,
523
+ ) => {
524
+ let byStore = String.compare(a.store, b.store)
525
+ if byStore != Ordering.equal {
526
+ byStore
527
+ } else {
528
+ let byComponent = String.compare(a.component, b.component)
529
+ if byComponent != Ordering.equal {
530
+ byComponent
531
+ } else {
532
+ String.compare(a.field, b.field)
533
+ }
534
+ }
535
+ }
536
+
537
+ // The (component, schema) sites the store walk reads. One list feeds both the
538
+ // declared-store collection and the heuristic lint below, so a field is read
539
+ // exactly once: either its declaration is collected, or its name is eligible
540
+ // to warn — never a third reading that could disagree with both.
541
+ let storeDeclarationSites: array<(string, S.t<unknown>)> =
427
542
  [
428
- aggregates->Array.flatMap((module(A: ReventlessInfra.Aggregate.T with type api = api)) =>
429
- Array.concat(
430
- storesFromSchema(A.Spec.commandSchema->S.castToUnknown),
431
- storesFromSchema(A.Spec.eventSchema->S.castToUnknown),
432
- )
433
- ),
434
- stateChangeSlices->Array.flatMap((module(SCS: ReventlessInfra.StateChangeSlice.T)) =>
435
- Array.concat(
436
- storesFromSchema(SCS.Spec.commandSchema->S.castToUnknown),
437
- storesFromSchema(SCS.Spec.eventSchema->S.castToUnknown),
438
- )
439
- ),
440
- stateViewSlices->Array.flatMap((module(SVS: ReventlessInfra.StateViewSlice.T)) =>
441
- storesFromSchema(SVS.Spec.stateSchema->S.castToUnknown)
442
- ),
443
- readModels->Array.flatMap((
543
+ aggregates->Array.flatMap((module(A: ReventlessInfra.Aggregate.T with type api = api)) => [
544
+ (A.Spec.name, A.Spec.commandSchema->S.castToUnknown),
545
+ (A.Spec.name, A.Spec.eventSchema->S.castToUnknown),
546
+ ]),
547
+ stateChangeSlices->Array.flatMap((module(SCS: ReventlessInfra.StateChangeSlice.T)) => [
548
+ (SCS.Spec.name, SCS.Spec.commandSchema->S.castToUnknown),
549
+ (SCS.Spec.name, SCS.Spec.eventSchema->S.castToUnknown),
550
+ ]),
551
+ stateViewSlices->Array.map((module(SVS: ReventlessInfra.StateViewSlice.T)) => (
552
+ SVS.Spec.name,
553
+ SVS.Spec.stateSchema->S.castToUnknown,
554
+ )),
555
+ readModels->Array.map((
444
556
  module(R: ReventlessInfra.ReadModel.T with type api = api and type role = role),
445
- ) => storesFromSchema(R.Spec.stateSchema->S.castToUnknown)),
446
- automationSlices->Array.flatMap((module(AS: ReventlessInfra.AutomationSlice.T)) =>
447
- storesFromSchema(AS.Spec.commandSchema->S.castToUnknown)
448
- ),
449
- inboundTranslationSlices->Array.flatMap((
557
+ ) => (R.Spec.name, R.Spec.stateSchema->S.castToUnknown)),
558
+ automationSlices->Array.map((module(AS: ReventlessInfra.AutomationSlice.T)) => (
559
+ AS.Spec.name,
560
+ AS.Spec.commandSchema->S.castToUnknown,
561
+ )),
562
+ inboundTranslationSlices->Array.map((
450
563
  module(ITS: ReventlessInfra.InboundTranslationSlice.T),
451
- ) => storesFromSchema(ITS.Spec.commandSchema->S.castToUnknown)),
452
- ]
453
- ->Array.flat
564
+ ) => (ITS.Spec.name, ITS.Spec.commandSchema->S.castToUnknown)),
565
+ ]->Array.flat
566
+
567
+ // Sorted so the emitted manifest is deterministic; identical triples collapse
568
+ // (one store named by a component's command field and again by its event
569
+ // field under the same field name is one declaration site).
570
+ let requiredStoreDeclarations =
571
+ storeDeclarationSites
572
+ ->Array.flatMap(((component, schema)) => storesFromSchema(~component, schema))
573
+ ->Array.toSorted(compareDeclarations)
574
+ ->Array.reduce([], (acc, d) =>
575
+ switch acc->Array.last {
576
+ | Some(prev) if prev == d => acc
577
+ | _ => acc->Array.concat([d])
578
+ }
579
+ )
580
+
581
+ let requiredStores =
582
+ requiredStoreDeclarations
583
+ ->Array.map(d => d.store)
454
584
  ->Belt.Set.String.fromArray
455
585
  ->Belt.Set.String.toArray
456
586
 
587
+ // Heuristic-only matches — a field *named* like a stored-object ref with no
588
+ // `@storageRef` — warn and provision nothing. Declaration outranks inference;
589
+ // the warning names the annotation that would settle it.
590
+ storeDeclarationSites->Array.forEach(((component, schema)) =>
591
+ Capability_Inference.scanSchema(~component, schema)->Array.forEach(w =>
592
+ log.warn(~comp="Plugin_Structure", Capability_Inference.message(w))
593
+ )
594
+ )
595
+
457
596
  // ── Build queryable defs ───────────────────────────────────────────────────
458
597
  //
459
598
  // Internal ReadModels and StateViewSlices (marked `@@reventless.visibility(Internal)`)
@@ -475,10 +614,7 @@ let make = (
475
614
  ) => {
476
615
  let qf = Api_Naming.queryFieldNamesForReadModel(~plugin=name, ~name=R.Spec.name)
477
616
  let stateSchema = R.Spec.stateSchema->S.castToUnknown
478
- let (labelField, searchableFields) = labelFieldsFromStateSchema(
479
- ~entityName=R.Spec.name,
480
- stateSchema,
481
- )
617
+ let label = labelFieldsFromStateSchema(~entityName=R.Spec.name, stateSchema)
482
618
  // The events this read model projects from, qualified to the plugin's event ids so
483
619
  // they match the producers' nodes in the graph. Was empty — which dropped projection
484
620
  // edges for any event reaching the read model via a DCB-log-sourced mapping (a classic
@@ -490,9 +626,10 @@ let make = (
490
626
  schema: stateSchema->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
491
627
  consumedEventTypes: consumed,
492
628
  linkedWriteSide: linkedWriteSideFor(consumed),
493
- labelField,
494
- searchableFields,
495
- statusField: statusFieldFromStateSchema(stateSchema),
629
+ labelField: label.field,
630
+ searchableFields: label.searchableFields,
631
+ labelFieldSource: Some(labelFieldSourceToString(label.source)),
632
+ statusField: statusFieldFromStateSchema(~entityName=R.Spec.name, stateSchema),
496
633
  visibility: visibilityTag(R.Spec.visibility),
497
634
  chapter: chapterOf(R.Spec.name),
498
635
  }: Reventless.Plugin.queryableDef)
@@ -503,19 +640,17 @@ let make = (
503
640
  let qf = Api_Naming.queryFieldNamesForStateView(~plugin=name, ~viewName=SVS.Spec.name)
504
641
  let (_, consumed) = svsConsumed->Array.getUnsafe(i)
505
642
  let stateSchema = SVS.Spec.stateSchema->S.castToUnknown
506
- let (labelField, searchableFields) = labelFieldsFromStateSchema(
507
- ~entityName=SVS.Spec.name,
508
- stateSchema,
509
- )
643
+ let label = labelFieldsFromStateSchema(~entityName=SVS.Spec.name, stateSchema)
510
644
  ({
511
645
  Reventless.Plugin.name: SVS.Spec.name,
512
646
  queryField: qf.listFieldName,
513
647
  schema: stateSchema->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
514
648
  consumedEventTypes: consumed,
515
649
  linkedWriteSide: linkedWriteSideFor(consumed),
516
- labelField,
517
- searchableFields,
518
- statusField: statusFieldFromStateSchema(stateSchema),
650
+ labelField: label.field,
651
+ searchableFields: label.searchableFields,
652
+ labelFieldSource: Some(labelFieldSourceToString(label.source)),
653
+ statusField: statusFieldFromStateSchema(~entityName=SVS.Spec.name, stateSchema),
519
654
  visibility: visibilityTag(SVS.Spec.visibility),
520
655
  chapter: chapterOf(SVS.Spec.name),
521
656
  }: Reventless.Plugin.queryableDef)
@@ -676,5 +811,6 @@ let make = (
676
811
  extensions: extensionDefs,
677
812
  extensionPoints: Some(extensionPointDefs),
678
813
  requiredStores: Some(requiredStores),
814
+ requiredStoreDeclarations: Some(requiredStoreDeclarations),
679
815
  }
680
816
  }