@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
package/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.192 (2026-07-30)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core,aws:** provision the side-effect handler Lambda, which never existed ([d07311e](https://github.com/ReventlessDev/reventless-core/commit/d07311e0cfc368e9b3d51596e775a58b55a2fc0e))
11
+ * **core:** resolve each eventMapper before collecting, not after ([12060d8](https://github.com/ReventlessDev/reventless-core/commit/12060d84733e1636255be03c432f959139600dbf))
12
+ ### Features
13
+
14
+ * **spec,core,local:** emit capabilities.json from the plugin build ([57a3276](https://github.com/ReventlessDev/reventless-core/commit/57a3276a6b02af93490a8b460e3aa158b7e0e0f8))
15
+ * **spec,core:** derive the platform capability list — inference and generation (stage 3) ([8ab4d25](https://github.com/ReventlessDev/reventless-core/commit/8ab4d2504bd717487fbc87a0f5a053024c8258f9))
16
+
17
+
18
+ # 3.0.0-alpha.191 (2026-07-29)
19
+
20
+ ### Bug Fixes
21
+
22
+ * **plugin:** the field a read model is named by ([c679bb9](https://github.com/ReventlessDev/reventless-core/commit/c679bb9f610be9947417174bc3dfceea38207ba0))
23
+ ### Features
24
+
25
+ * **plugin:** why a read model is named by that field ([699ef8e](https://github.com/ReventlessDev/reventless-core/commit/699ef8e72f1b1d82fb04672739e5e4a8db61ae9a))
26
+
27
+
6
28
  # 3.0.0-alpha.190 (2026-07-28)
7
29
 
8
30
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.190",
3
+ "version": "3.0.0-alpha.192",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -28,17 +28,17 @@
28
28
  "dependencies": {
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
- "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
32
31
  "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.14",
33
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.13",
34
- "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
33
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
35
34
  "@reventlessdev/rescript-node-streams": "1.1.0-alpha.13",
35
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
37
37
  "@reventlessdev/rescript-ssh2": "1.1.0-alpha.14",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.108",
39
38
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.110",
40
40
  "@reventlessdev/reventless-interop": "3.0.0-alpha.29",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.83"
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.85"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -252,7 +252,7 @@ module Make = (
252
252
  // pushed SDL — CreateResolver then loops on
253
253
  // `NotFoundException: No field named ...`.
254
254
  let registryKey = entry.specName->Option.getOr(entityName)
255
- let (labelField, _searchableFields) = Plugin_Structure.labelFieldsFromStateSchema(
255
+ let label: Plugin_Structure.labelResolution = Plugin_Structure.labelFieldsFromStateSchema(
256
256
  ~entityName,
257
257
  entry.stateSchema,
258
258
  )
@@ -263,7 +263,7 @@ module Make = (
263
263
  pluralTypeName: entry.listFieldName,
264
264
  includeIdParam: entry.includeIdParam->Option.getOr(true),
265
265
  connectionSpec: entry.connectionSpec->Option.getOr(true),
266
- labelField,
266
+ labelField: label.field,
267
267
  connectionFilterTypeName: entry.returnTypeName ++ "Filter",
268
268
  }
269
269
  Plugin_Helpers.queryFieldNamesRegistry->Dict.set(registryKey, qn)
@@ -110,13 +110,13 @@ function Make(RuntimeEnvironment) {
110
110
  let prefix = "Platform_";
111
111
  let entityName = entry.returnTypeName.startsWith(prefix) ? entry.returnTypeName.slice(prefix.length, entry.returnTypeName.length) : entry.returnTypeName;
112
112
  let registryKey = Stdlib_Option.getOr(entry.specName, entityName);
113
- let match = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(entityName, entry.stateSchema);
113
+ let label = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(entityName, entry.stateSchema);
114
114
  let qn_singleFieldName = entry.singleFieldName;
115
115
  let qn_listFieldName = entry.listFieldName;
116
116
  let qn_returnTypeName = entry.returnTypeName;
117
117
  let qn_pluralTypeName = entry.listFieldName;
118
118
  let qn_connectionFilterTypeName = entry.returnTypeName + "Filter";
119
- let qn_labelField = match[0];
119
+ let qn_labelField = label.field;
120
120
  let qn_includeIdParam = Stdlib_Option.getOr(entry.includeIdParam, true);
121
121
  let qn_connectionSpec = Stdlib_Option.getOr(entry.connectionSpec, true);
122
122
  let qn = {
@@ -247,6 +247,10 @@ function Make(RuntimeEnvironment) {
247
247
  createAggregatesWithoutEventMappers: Builder_Helpers$ReventlessCore.createAggregatesWithoutEventMappers,
248
248
  finishAggregates: Builder_Helpers$ReventlessCore.finishAggregates,
249
249
  addEventMappers: Builder_Helpers$ReventlessCore.addEventMappers,
250
+ taskSideEffectGates: Builder_Helpers$ReventlessCore.taskSideEffectGates,
251
+ taskSideEffectFinishFns: Builder_Helpers$ReventlessCore.taskSideEffectFinishFns,
252
+ registerTaskSideEffectHandler: Builder_Helpers$ReventlessCore.registerTaskSideEffectHandler,
253
+ finishTasks: Builder_Helpers$ReventlessCore.finishTasks,
250
254
  readModelNamesForSourceName: Builder_Helpers$ReventlessCore.readModelNamesForSourceName,
251
255
  publishToReadModels: Builder_Helpers$ReventlessCore.publishToReadModels,
252
256
  finishReadModels: Builder_Helpers$ReventlessCore.finishReadModels,
@@ -111,6 +111,9 @@ let pluginReadModel: queryableDef = {
111
111
  linkedWriteSide: ["Plugin"],
112
112
  labelField: "name",
113
113
  searchableFields: ["name"],
114
+ // Hand-rolled rather than resolved, but the rung is the same one
115
+ // `labelFieldsFromStateSchema` would report for a field literally named `name`.
116
+ labelFieldSource: Some("convention"),
114
117
  statusField: Some("status"),
115
118
  visibility: None,
116
119
  chapter: None,
@@ -127,4 +130,5 @@ let structure: pluginStructure = {
127
130
  extensions: [],
128
131
  extensionPoints: None,
129
132
  requiredStores: None,
133
+ requiredStoreDeclarations: None,
130
134
  }
@@ -126,6 +126,8 @@ let pluginReadModel_linkedWriteSide = ["Plugin"];
126
126
 
127
127
  let pluginReadModel_searchableFields = ["name"];
128
128
 
129
+ let pluginReadModel_labelFieldSource = "convention";
130
+
129
131
  let pluginReadModel_statusField = "status";
130
132
 
131
133
  let pluginReadModel = {
@@ -136,6 +138,7 @@ let pluginReadModel = {
136
138
  linkedWriteSide: pluginReadModel_linkedWriteSide,
137
139
  labelField: "name",
138
140
  searchableFields: pluginReadModel_searchableFields,
141
+ labelFieldSource: pluginReadModel_labelFieldSource,
139
142
  statusField: pluginReadModel_statusField,
140
143
  visibility: undefined,
141
144
  chapter: undefined
@@ -167,7 +170,8 @@ let structure = {
167
170
  inboundTranslationSlices: structure_inboundTranslationSlices,
168
171
  extensions: structure_extensions,
169
172
  extensionPoints: undefined,
170
- requiredStores: undefined
173
+ requiredStores: undefined,
174
+ requiredStoreDeclarations: undefined
171
175
  };
172
176
 
173
177
  let pluginName = pluginId;
@@ -11,7 +11,7 @@ let sdlTypes: array<string> = [
11
11
  `type Platform_CommandDef {\n name: String!\n schema: String!\n level: String!\n aggregateIdField: String\n mutationField: String!\n references: [Platform_FieldReference!]!\n allowedStates: [String!]\n targetState: String\n apiExposed: Boolean\n}`,
12
12
  `type Platform_EventDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
13
13
  `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n}`,
14
- `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n statusField: String\n}`,
14
+ `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n}`,
15
15
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n}`,
16
16
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n}`,
17
17
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n}`,
@@ -72,6 +72,10 @@ let encodeQueryableDef = (r: queryableDef): JSON.t =>
72
72
  ("linkedWriteSide", encodeStrings(r.linkedWriteSide)),
73
73
  ("labelField", JSON.Encode.string(r.labelField)),
74
74
  ("searchableFields", encodeStrings(r.searchableFields)),
75
+ (
76
+ "labelFieldSource",
77
+ r.labelFieldSource->Option.mapOr(JSON.Encode.null, JSON.Encode.string),
78
+ ),
75
79
  ("statusField", r.statusField->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
76
80
  ("chapter", r.chapter->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
77
81
  ])->JSON.Encode.object
@@ -9,7 +9,7 @@ let sdlTypes = [
9
9
  `type Platform_CommandDef {\n name: String!\n schema: String!\n level: String!\n aggregateIdField: String\n mutationField: String!\n references: [Platform_FieldReference!]!\n allowedStates: [String!]\n targetState: String\n apiExposed: Boolean\n}`,
10
10
  `type Platform_EventDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
11
11
  `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n}`,
12
- `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n statusField: String\n}`,
12
+ `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n}`,
13
13
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n}`,
14
14
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n}`,
15
15
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n}`,
@@ -116,6 +116,10 @@ function encodeQueryableDef(r) {
116
116
  "searchableFields",
117
117
  r.searchableFields.map(prim => prim)
118
118
  ],
119
+ [
120
+ "labelFieldSource",
121
+ Stdlib_Option.mapOr(r.labelFieldSource, null, prim => prim)
122
+ ],
119
123
  [
120
124
  "statusField",
121
125
  Stdlib_Option.mapOr(r.statusField, null, prim => prim)
@@ -55,13 +55,13 @@ let toResolvedOutputs = (
55
55
  }
56
56
  resolved
57
57
  })
58
- let eventMapperResolved = switch outputs.eventMapper {
59
- | Some(emOutput) =>
60
- emOutput
61
- ->Pulumi.Output.flatMap(EventMapper.toResolvedOutputs)
62
- ->Pulumi.Output.apply(resolved => Some(resolved))
63
- | None => Pulumi.Output.make(None)
64
- }
58
+ let eventMapperResolved =
59
+ outputs.eventMapper->Pulumi.Output.flatMap(em =>
60
+ switch em {
61
+ | Some(em) => em->EventMapper.toResolvedOutputs->Pulumi.Output.apply(r => Some(r))
62
+ | None => Pulumi.Output.make(None)
63
+ }
64
+ )
65
65
  (commandGeneratorResolved, commandTopicResolved, eventLogResolved, eventMapperResolved)
66
66
  ->Pulumi.Output.all4
67
67
  ->Pulumi.Output.apply(((commandGenerator, commandTopic, eventLog, eventMapper)) =>
@@ -39,8 +39,13 @@ function toResolvedOutputs(outputs) {
39
39
  resources: param[1]
40
40
  }
41
41
  }));
42
- let emOutput = outputs.eventMapper;
43
- let eventMapperResolved = emOutput !== undefined ? Output$Pulumi.flatMap(emOutput, EventMapper$ReventlessCore.toResolvedOutputs).apply(resolved => resolved) : Pulumi.output(undefined);
42
+ let eventMapperResolved = Output$Pulumi.flatMap(outputs.eventMapper, em => {
43
+ if (em !== undefined) {
44
+ return EventMapper$ReventlessCore.toResolvedOutputs(em).apply(r => r);
45
+ } else {
46
+ return Pulumi.output(undefined);
47
+ }
48
+ });
44
49
  return Pulumi.all([
45
50
  commandGeneratorResolved,
46
51
  commandTopicResolved,
@@ -58,7 +58,7 @@ module Make = (
58
58
  {
59
59
  ...aggregate->Component.outputs,
60
60
  eventMapper: eventMapper->Pulumi.Output.apply(eventMapper =>
61
- eventMapper->Component.outputs
61
+ Some(eventMapper->Component.outputs)
62
62
  ),
63
63
  }
64
64
  } else {
@@ -175,6 +175,9 @@ module Make = (
175
175
  commandGenerator: commandGenerator->Component.wrappedOutputs,
176
176
  commandTopic: commandTopic->Component.wrappedOutputs,
177
177
  eventLog: eventLog->Component.outputs,
178
+ // Resolved-absent rather than an absent field: an aggregate without
179
+ // EventMappings still has to answer the question.
180
+ eventMapper: Pulumi.Output.make(None),
178
181
  addEventMapper: self->addEventMapperFn(~opts, ...),
179
182
  }
180
183
  self->Component.setOutputs(aggOutputs)
@@ -89,6 +89,7 @@ function Make(Spec) {
89
89
  let aggOutputs_commandGenerator = Component$ReventlessCore.wrappedOutputs(commandGenerator);
90
90
  let aggOutputs_commandTopic = Component$ReventlessCore.wrappedOutputs(commandTopic);
91
91
  let aggOutputs_eventLog = Component$ReventlessCore.outputs(eventLog);
92
+ let aggOutputs_eventMapper = Pulumi.output(undefined);
92
93
  let aggOutputs_addEventMapper = (none, none$1) => {
93
94
  if (EventMappings.mappings.length === 0) {
94
95
  return Component$ReventlessCore.outputs(extra);
@@ -97,15 +98,22 @@ function Make(Spec) {
97
98
  Component$ReventlessCore.operations(extra),
98
99
  Component$ReventlessCore.outputs(extra).commandTopic
99
100
  ]).apply(param => SpecificEventMapper.make(ComponentType$ReventlessCore.name(Spec.name, Aggregate$ReventlessCore.componentType), none, none$1, param[0].publishJsons, param[1].resources, undefined, undefined, opts));
100
- let newrecord = {...Component$ReventlessCore.outputs(extra)};
101
- newrecord.eventMapper = eventMapper.apply(Component$ReventlessCore.outputs);
102
- return newrecord;
101
+ let init = Component$ReventlessCore.outputs(extra);
102
+ return {
103
+ name: init.name,
104
+ commandGenerator: init.commandGenerator,
105
+ commandTopic: init.commandTopic,
106
+ eventLog: init.eventLog,
107
+ eventMapper: eventMapper.apply(eventMapper => Component$ReventlessCore.outputs(eventMapper)),
108
+ addEventMapper: init.addEventMapper
109
+ };
103
110
  };
104
111
  let aggOutputs = {
105
112
  name: aggOutputs_name,
106
113
  commandGenerator: aggOutputs_commandGenerator,
107
114
  commandTopic: aggOutputs_commandTopic,
108
115
  eventLog: aggOutputs_eventLog,
116
+ eventMapper: aggOutputs_eventMapper,
109
117
  addEventMapper: aggOutputs_addEventMapper
110
118
  };
111
119
  return Component$ReventlessCore.setOutputs(extra, aggOutputs);
@@ -58,16 +58,23 @@ let finishAggregates = (
58
58
  // to ensure forCommandTopic has registered specs before finish() runs.
59
59
  let allCommandTopicOutputs =
60
60
  allOutputs->Array.map(aggregateOutputs => aggregateOutputs.commandTopic)
61
- let eventMapperOutputs =
62
- allOutputs
63
- ->Array.map(aggregateOutputs => aggregateOutputs.eventMapper)
64
- ->Array.keepSome
61
+ let eventMapperOutputs = allOutputs->Array.map(aggregateOutputs => aggregateOutputs.eventMapper)
65
62
 
66
63
  let _ =
67
64
  (eventMapperOutputs->Pulumi.Output.all, allCommandTopicOutputs->Pulumi.Output.all)
68
65
  ->Pulumi.Output.all2
69
66
  ->Pulumi.Output.apply(((eventMapperOutputs, _)) =>
67
+ // Filtered after resolution, not before: the absent ones are now `None`
68
+ // inside a resolved array rather than missing fields.
69
+ //
70
+ // `eventCollector` here is a plain record, not an Output: `Output.all` above
71
+ // deeply unwrapped the contents of each element. That is fine because `all`
72
+ // accepts resolved values, but nothing in this block may call
73
+ // `EventMapper.toResolvedOutputs` — it expects unresolved fields and would
74
+ // throw `m.apply is not a function`. Resolve at the depth where the record is
75
+ // still unresolved instead, as `serializeEventMappersOutputs` does.
70
76
  eventMapperOutputs
77
+ ->Array.keepSome
71
78
  ->Array.map(eventMapperOutput => eventMapperOutput.eventCollector)
72
79
  ->Pulumi.Output.all
73
80
  ->Pulumi.Output.apply(_ =>
@@ -89,6 +96,32 @@ let addEventMappers = (
89
96
  aggregatesOutputs
90
97
  }
91
98
 
99
+ // Side-effect handlers registered by Task_Builder, with the Output that tells us the
100
+ // handler has reached its runtime builder. Registration happens inside an
101
+ // `allCommandTopics` apply, so `make` returning is not enough — a synchronous
102
+ // `finish()` would build the shared runtime before any handler had registered, or
103
+ // worse, from only the first one.
104
+ let taskSideEffectGates: array<Pulumi.Output.t<unit>> = []
105
+ let taskSideEffectFinishFns: array<unit => unit> = []
106
+
107
+ let registerTaskSideEffectHandler = (~gate, ~finish) => {
108
+ let _ = taskSideEffectGates->Array.push(gate)
109
+ let _ = taskSideEffectFinishFns->Array.push(finish)
110
+ }
111
+
112
+ // Provision side-effect handler runtimes once every task's handler is ready. Same
113
+ // shape as `finishAggregates`: collect the gates, wait on all of them, then run the
114
+ // finish functions. Adapters guard against repeat calls.
115
+ let finishTasks = () =>
116
+ if taskSideEffectGates->Array.length > 0 {
117
+ let _ =
118
+ taskSideEffectGates
119
+ ->Pulumi.Output.all
120
+ ->Pulumi.Output.apply(_ =>
121
+ taskSideEffectFinishFns->Array.forEach(finishFn => finishFn())
122
+ )
123
+ }
124
+
92
125
  let readModelNamesForSourceName = Dict.make()
93
126
  let publishToReadModels = Dict.make()
94
127
 
@@ -33,11 +33,11 @@ function createAggregatesWithoutEventMappers(aggregates, api, componentRuntime,
33
33
  function finishAggregates(aggregatesOutputs) {
34
34
  let allOutputs = Object.values(aggregatesOutputs);
35
35
  let allCommandTopicOutputs = allOutputs.map(aggregateOutputs => aggregateOutputs.commandTopic);
36
- let eventMapperOutputs = Stdlib_Array.keepSome(allOutputs.map(aggregateOutputs => aggregateOutputs.eventMapper));
36
+ let eventMapperOutputs = allOutputs.map(aggregateOutputs => aggregateOutputs.eventMapper);
37
37
  Pulumi.all([
38
38
  Pulumi.all(eventMapperOutputs),
39
39
  Pulumi.all(allCommandTopicOutputs)
40
- ]).apply(param => Pulumi.all(param[0].map(eventMapperOutput => eventMapperOutput.eventCollector)).apply(param => {
40
+ ]).apply(param => Pulumi.all(Stdlib_Array.keepSome(param[0]).map(eventMapperOutput => eventMapperOutput.eventCollector)).apply(param => {
41
41
  Object.values(aggregateFinishFns).forEach(finishFn => finishFn());
42
42
  }));
43
43
  }
@@ -48,6 +48,24 @@ function addEventMappers(allEventTopics, queryEngine) {
48
48
  return aggregatesOutputs;
49
49
  }
50
50
 
51
+ let taskSideEffectGates = [];
52
+
53
+ let taskSideEffectFinishFns = [];
54
+
55
+ function registerTaskSideEffectHandler(gate, finish) {
56
+ taskSideEffectGates.push(gate);
57
+ taskSideEffectFinishFns.push(finish);
58
+ }
59
+
60
+ function finishTasks() {
61
+ if (taskSideEffectGates.length !== 0) {
62
+ Pulumi.all(taskSideEffectGates).apply(param => {
63
+ taskSideEffectFinishFns.forEach(finishFn => finishFn());
64
+ });
65
+ return;
66
+ }
67
+ }
68
+
51
69
  let readModelNamesForSourceName = {};
52
70
 
53
71
  let publishToReadModels = {};
@@ -131,6 +149,10 @@ export {
131
149
  createAggregatesWithoutEventMappers,
132
150
  finishAggregates,
133
151
  addEventMappers,
152
+ taskSideEffectGates,
153
+ taskSideEffectFinishFns,
154
+ registerTaskSideEffectHandler,
155
+ finishTasks,
134
156
  readModelNamesForSourceName,
135
157
  publishToReadModels,
136
158
  finishReadModels,
@@ -526,13 +526,13 @@ module Make = (
526
526
  module(V: StateViewSlice.T),
527
527
  ) => {
528
528
  let qn = Api_Naming.queryFieldNamesForStateView(~plugin=name, ~viewName=V.Spec.name)
529
- let (labelField, _searchableFields) = Plugin_Structure.labelFieldsFromStateSchema(
529
+ let label = Plugin_Structure.labelFieldsFromStateSchema(
530
530
  ~entityName=V.Spec.name,
531
531
  V.Spec.stateSchema->S.castToUnknown,
532
532
  )
533
533
  let qn = {
534
534
  ...qn,
535
- labelField,
535
+ labelField: label.field,
536
536
  connectionFilterTypeName: qn.returnTypeName ++ "Filter",
537
537
  }
538
538
  let qn = switch V.Spec.subIdConfig {
@@ -276,13 +276,13 @@ function Make(DcbEventLogStorage) {
276
276
  }
277
277
  stateViewSlices.forEach(V => {
278
278
  let qn = Api_Naming$ReventlessCore.queryFieldNamesForStateView(name, V.Spec.name, undefined);
279
- let match = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(V.Spec.name, V.Spec.stateSchema);
279
+ let label = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(V.Spec.name, V.Spec.stateSchema);
280
280
  let newrecord = {...qn};
281
- newrecord.labelField = match[0];
281
+ newrecord.labelField = label.field;
282
282
  newrecord.connectionFilterTypeName = qn.returnTypeName + "Filter";
283
- let match$1 = V.Spec.subIdConfig;
283
+ let match = V.Spec.subIdConfig;
284
284
  let qn$1;
285
- if (match$1 !== undefined) {
285
+ if (match !== undefined) {
286
286
  let newrecord$1 = {...newrecord};
287
287
  newrecord$1.itemsFilterTypeName = newrecord.returnTypeName + "ItemsFilter";
288
288
  newrecord$1.itemsFieldName = newrecord.singleFieldName + "Items";
@@ -32,4 +32,15 @@ module type T = {
32
32
  ~extraEnvVars: dict<Pulumi.Input.t<string>>=?,
33
33
  ~opts: Pulumi.ComponentResource.options=?,
34
34
  ) => component
35
+
36
+ /** Provision the runtime for every handler registered by `make`.
37
+ *
38
+ * Adapters that share one runtime across all side-effect handlers can only build
39
+ * it once every handler has registered, so nothing is provisioned by `make`
40
+ * alone. Every other component type has this seam
41
+ * (`ReadModel_Builder.finish`, `Aggregate_Builder.finish`, …); the side-effect
42
+ * handler went without one, which is why its Lambda was never created. Call it
43
+ * after the last handler is constructed — `Builder_Helpers.finishTasks` gates it
44
+ * on their readiness. Idempotent. */
45
+ let finish: unit => unit
35
46
  }
@@ -122,4 +122,6 @@ module Make = (
122
122
  ~opts,
123
123
  )
124
124
  }
125
+
126
+ let finish = () => EventCollectorRuntimeBuilder.finish()
125
127
  }
@@ -55,8 +55,10 @@ function Make(RuntimeEnvironment) {
55
55
  });
56
56
  }, opts);
57
57
  };
58
+ let finish = () => EventCollectorRuntimeBuilder.finish();
58
59
  return {
59
- make: make
60
+ make: make,
61
+ finish: finish
60
62
  };
61
63
  }));
62
64
  }
@@ -93,6 +93,17 @@ module Make = (
93
93
  )
94
94
  )
95
95
 
96
+ // The handler's runtime is provisioned by `SpecificSideEffectHandler.finish()`,
97
+ // not by `make` — see `SideEffectHandler.T.finish`. Register it with the gate that
98
+ // says this handler has reached its runtime builder: `operations` is set at the end
99
+ // of the builder's construct, after `forEventCollector` has run.
100
+ sideEffectHandler->Option.forEach(handler =>
101
+ Builder_Helpers.registerTaskSideEffectHandler(
102
+ ~gate=handler->Component.operations->Pulumi.Output.apply(_ => ()),
103
+ ~finish=SpecificSideEffectHandler.finish,
104
+ )
105
+ )
106
+
96
107
  let taskActionsHandler = (taskActions, operations: option<SideEffectHandler.operations>) => {
97
108
  taskActions
98
109
  ->Array.map(async taskAction => {
@@ -12,6 +12,7 @@ import * as Component$ReventlessCore from "../Component.res.mjs";
12
12
  import * as Util_Promise$ReventlessCore from "../../util/Util_Promise.res.mjs";
13
13
  import * as ComponentType$ReventlessCore from "../../ComponentType.res.mjs";
14
14
  import * as RuntimeHints$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/RuntimeHints.res.mjs";
15
+ import * as Builder_Helpers$ReventlessCore from "../Builder_Helpers.res.mjs";
15
16
 
16
17
  let log = Logger$ReventlessCore.fromEnv();
17
18
 
@@ -40,6 +41,7 @@ function Make(Spec) {
40
41
  let publishCommands = (aggregateName, cmdJsons) => Stdlib_Option.getOrThrow(publishToAggregates[aggregateName], undefined)(cmdJsons);
41
42
  let config = Spec.setup(queryEngine, queryBucketName, opts);
42
43
  let sideEffectHandler = Stdlib_Option.map(config.sideEffects, sideEffects => SpecificSideEffectHandler.make(extra$1, sideEffects, Aggregate$ReventlessCore.allEventTopics(allAggregates), allCommandTopics, undefined, queryEngine, scheduler, resourceNaming, undefined, undefined, undefined, opts));
44
+ Stdlib_Option.forEach(sideEffectHandler, handler => Builder_Helpers$ReventlessCore.registerTaskSideEffectHandler(Component$ReventlessCore.operations(handler).apply(param => {}), SpecificSideEffectHandler.finish));
43
45
  let taskActionsHandler = (taskActions, operations) => Util_Promise$ReventlessCore.toUnit(Promise.all(taskActions.map(async taskAction => {
44
46
  switch (taskAction.TAG) {
45
47
  case "PublishCommands" :
@@ -0,0 +1,68 @@
1
+ // Heuristic detection of fields that look like stored-object references but
2
+ // carry no `@storageRef` declaration.
3
+ //
4
+ // Declarations are authoritative: `Plugin_Structure` collects them into
5
+ // `requiredStores`, the capability manifest renders them, and the platform
6
+ // provisions from the union. This module is the second rung — the UI's
7
+ // field-name heuristics restated over the same schema walk — and it is a lint,
8
+ // not a mechanism: a heuristic match produces a warning naming the field and
9
+ // the annotation that would settle it, and never provisions anything. Guessing
10
+ // is a poor basis for creating and destroying infrastructure; a field named
11
+ // `imageUrl` is genuinely ambiguous between an uploaded object and an external
12
+ // URL, and only its author can say which.
13
+
14
+ /** One heuristic-only match: a field whose name says "stored object" on a
15
+ component whose schema does not declare a store for it. */
16
+ type warning = {component: string, field: string}
17
+
18
+ // The two name rules the UI's semantic layer applies, restated here so the
19
+ // lint warns about exactly the fields the UI would silently upgrade to an
20
+ // upload input if an endpoint happened to exist. Matched case-insensitively
21
+ // and exactly (suffix rules aside): `customerFile` names a customer's file,
22
+ // not necessarily a stored object.
23
+ let refLikeNames = ["file", "attachment", "upload"]
24
+ let refLikeSuffixes = ["storageref", "fileref", "attachmentref"]
25
+ let imageLikeNames = ["image", "imageurl", "photo", "photourl", "avatar", "avatarurl", "thumbnail"]
26
+
27
+ let nameMatches = (field: string): bool => {
28
+ let lower = field->String.toLowerCase
29
+ refLikeNames->Array.includes(lower) ||
30
+ imageLikeNames->Array.includes(lower) ||
31
+ refLikeSuffixes->Array.some(suffix => lower->String.endsWith(suffix))
32
+ }
33
+
34
+ let warningsFromProperties = (~component, properties: dict<S.t<unknown>>): array<warning> =>
35
+ properties
36
+ ->Dict.toArray
37
+ ->Array.filterMap(((field, fieldSchema)) =>
38
+ switch fieldSchema {
39
+ | String(_) if field != "TAG" &&
40
+ nameMatches(field) &&
41
+ Reventless.StorageRef.getStore(fieldSchema)->Option.isNone =>
42
+ Some({component, field})
43
+ | _ => None
44
+ }
45
+ )
46
+
47
+ /** Scan one component schema — a command/event union or a state object — for
48
+ heuristic-only matches. The walk mirrors `Plugin_Structure`'s declared-store
49
+ walk over the same schemas, so a field is either collected there (declared)
50
+ or eligible to warn here, never both. */
51
+ let scanSchema = (~component: string, schema: S.t<unknown>): array<warning> => {
52
+ let fromVariant = v =>
53
+ switch v {
54
+ | S.Object({properties}) => warningsFromProperties(~component, properties)
55
+ | _ => []
56
+ }
57
+ switch schema {
58
+ | Union({anyOf}) => anyOf->Array.flatMap(fromVariant)
59
+ | other => fromVariant(other)
60
+ }
61
+ }
62
+
63
+ /** The warning text: name the field, name the annotation that would settle it,
64
+ and say what to do when the heuristic is wrong. */
65
+ let message = (w: warning): string =>
66
+ `${w.component}.${w.field} looks like a stored-object reference but declares no store — ` ++
67
+ `annotate it \`@storageRef("<store>")\` so the deployment provisions the store it needs, ` ++
68
+ `or rename the field if it holds an external URL. Heuristic matches are never provisioned.`