@reventlessdev/reventless-core 3.0.0-alpha.259 → 3.0.0-alpha.261

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 (209) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +11 -11
  3. package/src/Behavior.res +1 -4
  4. package/src/Message.res +17 -14
  5. package/src/Projection.res +51 -25
  6. package/src/RequestContext.res +8 -9
  7. package/src/ResourceAttribution.res +28 -14
  8. package/src/adapter/Adapter.res +48 -4
  9. package/src/adapter/EventLogProvisioning/EventLogProvisioning.res +5 -5
  10. package/src/adapter/Messaging/Messaging_Log_Backend.res +10 -7
  11. package/src/adapter/Messaging/Messaging_Log_Backend.res.mjs +2 -4
  12. package/src/adapter/Monitoring/Monitoring.res +16 -17
  13. package/src/adapter/QueryInterception/QueryInterception.res +0 -1
  14. package/src/adapter/Runtime/AggregateRuntime_Builder_Common.res +12 -5
  15. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +11 -7
  16. package/src/adapter/Runtime/PluginRuntime_Builder_Micro.res +1 -7
  17. package/src/adapter/Runtime/PluginRuntime_Builder_Single.res +1 -7
  18. package/src/adapter/RuntimeExtension/RuntimeExtension.res +0 -1
  19. package/src/admin/Platform_Admin.res +17 -6
  20. package/src/admin/Platform_AdminApi.res +14 -9
  21. package/src/admin/Platform_Admin_Callback.res +1 -1
  22. package/src/admin/Platform_BakedManifest.res +34 -34
  23. package/src/admin/Platform_ComponentDefinitionsApi.res +9 -21
  24. package/src/admin/Platform_PluginStructuresApi.res +15 -12
  25. package/src/admin/UiFragmentRegistry/StateChange/UiFragmentRegistry.res +1 -1
  26. package/src/components/Aggregate/Aggregate.res +13 -12
  27. package/src/components/Aggregate/Aggregate_Builder.res +22 -21
  28. package/src/components/Aggregate/Aggregate_Callback.res +91 -86
  29. package/src/components/Api/ApiNoApiHelpers.res +1 -3
  30. package/src/components/Api/Api_Ids.res +0 -1
  31. package/src/components/Api/Api_Naming.res +41 -18
  32. package/src/components/Api/GraphQL_FragmentGenerator.res +42 -52
  33. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +11 -4
  34. package/src/components/Api/GraphQL_SchemaInspector.res +5 -3
  35. package/src/components/Api/GraphQL_Stitcher.res +9 -10
  36. package/src/components/Api/MCP_SchemaGenerator.res +8 -12
  37. package/src/components/Api/OwnerScopeDiagnostics.res +1 -2
  38. package/src/components/Api/QueryDbListQuery.res +13 -6
  39. package/src/components/Api/SchemaType.res +40 -20
  40. package/src/components/Api/SchemaType.res.mjs +16 -0
  41. package/src/components/Api/SuryToJsonSchema.res +18 -10
  42. package/src/components/Api/SuryToJsonSchema.res.mjs +135 -121
  43. package/src/components/AutomationSlice/AutomationSlice.res +4 -4
  44. package/src/components/AutomationSlice/AutomationSlice_Builder.res +15 -14
  45. package/src/components/AutomationSlice/AutomationSlice_Callback.res +3 -3
  46. package/src/components/Builder_Helpers.res +12 -23
  47. package/src/components/CommandGenerator/CommandGenerator.res +6 -1
  48. package/src/components/CommandGenerator/CommandGenerator_Builder.res +29 -21
  49. package/src/components/CommandGenerator/CommandGenerator_Callback.res +17 -21
  50. package/src/components/CommandTopic/CommandTopic.res +3 -3
  51. package/src/components/CommandTopic/CommandTopic_Builder.res +10 -10
  52. package/src/components/CommandTopic/CommandTopic_Callback.res +1 -4
  53. package/src/components/CommandTopic/CommandTopic_Helpers.res +1 -2
  54. package/src/components/Counter/Counter_Builder.res +1 -6
  55. package/src/components/Counter/Counter_Callback.res +3 -3
  56. package/src/components/Dcb/Dcb_Builder.res +970 -956
  57. package/src/components/DcbEventLog/DcbEventLog.res +4 -4
  58. package/src/components/DcbEventLog/DcbEventLog_Builder.res +0 -1
  59. package/src/components/DcbEventLog/DcbEventLog_Operations.res +17 -18
  60. package/src/components/EventCollector/EventCollector_Builder.res +2 -1
  61. package/src/components/EventLog/EventLog.res +5 -1
  62. package/src/components/EventLog/EventLog_Operations.res +12 -8
  63. package/src/components/EventMapper/EventMapper.res +8 -10
  64. package/src/components/EventMapper/EventMapper_Builder.res +4 -1
  65. package/src/components/EventMapper/EventMapper_Callback.res +46 -31
  66. package/src/components/EventTopic/EventTopic.res +3 -3
  67. package/src/components/EventTopic/EventTopic_Builder.res +1 -1
  68. package/src/components/Extension/Extension.res +4 -1
  69. package/src/components/Extension/Extension_Operations.res +11 -3
  70. package/src/components/ExtensionPoint/ExtensionPoint.res +3 -3
  71. package/src/components/ExtensionPoint/ExtensionPoint_Builder.res +9 -10
  72. package/src/components/ExtensionPoint/ExtensionPoint_Callback.res +2 -6
  73. package/src/components/ExtensionPoint/ExtensionPoint_Operations.res +13 -5
  74. package/src/components/Heartbeat/Heartbeat_Callback.res +3 -3
  75. package/src/components/InboundTranslationSlice/InboundTranslationSlice.res +4 -4
  76. package/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res +7 -10
  77. package/src/components/OutboundTranslationSlice/OutboundTranslationSlice.res +4 -4
  78. package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Builder.res +15 -14
  79. package/src/components/QueryDb/QueryDb_Adapter.res +1 -3
  80. package/src/components/QueryDb/QueryDb_Operations.res +10 -10
  81. package/src/components/ReadModel/ReadModel.res +3 -3
  82. package/src/components/ReadModel/ReadModel_Builder.res +1 -2
  83. package/src/components/ReadModel/ReadModel_Callback.res +2 -1
  84. package/src/components/SideEffectHandler/SideEffectHandler_Callback.res +5 -2
  85. package/src/components/StateChangeSlice/StateChangeSlice.res +3 -3
  86. package/src/components/StateChangeSlice/StateChangeSlice_Builder.res +23 -4
  87. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +22 -6
  88. package/src/components/StateTopic.res +1 -3
  89. package/src/components/StateViewSlice/StateViewSlice.res +4 -4
  90. package/src/components/StateViewSlice/StateViewSlice_Builder.res +79 -68
  91. package/src/components/StateViewSlice/StateViewSlice_Callback.res +3 -1
  92. package/src/components/Task/Task.res +3 -3
  93. package/src/components/Task/Task_Adapter.res +4 -1
  94. package/src/components/Task/Task_Builder.res +24 -21
  95. package/src/plugin/api/PluginBaseFragment.res +9 -3
  96. package/src/plugin/component/Capability_Inference.res +8 -10
  97. package/src/plugin/component/Plugin.res +6 -6
  98. package/src/plugin/component/Plugin_Builder.res +54 -50
  99. package/src/plugin/component/Plugin_Callback.res +10 -6
  100. package/src/plugin/component/Plugin_Helpers.res +264 -230
  101. package/src/plugin/component/Plugin_Structure.res +370 -324
  102. package/src/plugin/component/Plugin_SubscriptionSchema.res +8 -6
  103. package/src/plugin/component/SchemaWalker.res +11 -12
  104. package/src/plugin/connect/PluginConnectExtension_Mapping.res +45 -50
  105. package/src/plugin/lifecycle/PluginBehavior.res +3 -4
  106. package/src/plugin/lifecycle/PluginSpec.res +1 -2
  107. package/src/plugin/lifecycle/PluginsProjection.res +16 -5
  108. package/src/plugin/lifecycle/PluginsReadModelSpec.res +0 -2
  109. package/src/util/CommandPublisher.res +9 -2
  110. package/src/util/CsvStream.res +3 -3
  111. package/src/util/Interstack.res +7 -15
  112. package/src/util/Interstack.resi +3 -3
  113. package/src/util/LogFormat.res +5 -9
  114. package/src/util/Logger.res +1 -2
  115. package/src/util/Lru.res +2 -1
  116. package/src/util/Lru.resi +2 -3
  117. package/src/util/ScheduleOps.res +24 -27
  118. package/src/util/StorePrefixCollision.res +6 -9
  119. package/src/util/Util_Promise.res +4 -1
  120. package/src/util/Util_Promise.res.mjs +1 -1
  121. package/tests/IdentityTest.res +166 -113
  122. package/tests/ResourceAttributionTest.res +28 -25
  123. package/tests/StorePrefixCollisionTest.res +2 -1
  124. package/tests/TaskBucketNamingTest.res +3 -2
  125. package/tests/adapter/EventLogProvisioningTest.res +51 -48
  126. package/tests/adapter/MonitoringTest.res +81 -38
  127. package/tests/adapter/QueryInterceptionTest.res +2 -2
  128. package/tests/adapter/RefusalVocabularyTest.res +31 -23
  129. package/tests/adapter/RuntimeExtensionHookReachTest.res +9 -8
  130. package/tests/adapter/RuntimeExtensionTest.res +5 -3
  131. package/tests/admin/AdminApiSchemaDriftTest.res +22 -16
  132. package/tests/admin/Platform_Admin_StructureTest.res +10 -11
  133. package/tests/admin/Platform_BakedManifestTest.res +31 -35
  134. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +18 -15
  135. package/tests/admin/Platform_PluginStructuresApiTest.res +5 -3
  136. package/tests/admin/Platform_UIFragmentsApiTest.res +6 -3
  137. package/tests/aggregate/AggregateCacheTest.res +10 -12
  138. package/tests/aggregate/AggregateCallbackTest.res +75 -46
  139. package/tests/aggregate/AggregateCausationTest.res +19 -20
  140. package/tests/aggregate/AggregateConflictTest.res +41 -19
  141. package/tests/aggregate/AggregateFixtures.res +15 -5
  142. package/tests/aggregate/AggregateRejectionTest.res +97 -80
  143. package/tests/aggregate/AggregateSnapshotTest.res +44 -16
  144. package/tests/api/Api_IdsTest.res +10 -4
  145. package/tests/api/BrandedMarkerCompositionTest.res +48 -36
  146. package/tests/api/GeolocationSemanticTest.res +3 -3
  147. package/tests/api/GraphQL_FragmentGeneratorTest.res +66 -54
  148. package/tests/api/OwnerScopeDiagnosticsTest.res +31 -27
  149. package/tests/api/StorageRefFieldsTest.res +30 -18
  150. package/tests/api/SuryToJsonSchemaTest.res +1338 -981
  151. package/tests/api/SuryToJsonSchemaTest.res.mjs +31 -0
  152. package/tests/api/TaggedUnionSchemaTest.res +17 -24
  153. package/tests/commandgenerator/CommandGeneratorCallbackTest.res +235 -179
  154. package/tests/commandgenerator/CommandGeneratorFixtures.res +8 -13
  155. package/tests/commandgenerator/OwnerStampingTest.res +91 -76
  156. package/tests/commandtopic/CommandOutcomeHookTest.res +198 -155
  157. package/tests/commandtopic/CommandTopicCallbackFixtures.res +5 -4
  158. package/tests/commandtopic/CommandTopicCallbackTest.res +61 -43
  159. package/tests/commandtopic/CommandTopicHelpersRejectionTest.res +70 -55
  160. package/tests/counter/CounterCallbackTest.res +40 -34
  161. package/tests/counter/CounterFixtures.res +4 -4
  162. package/tests/dcb/DcbDecodeTest.res +115 -84
  163. package/tests/dcb/DcbEventLogOperationsTest.res +194 -143
  164. package/tests/dcb/DcbEventLogSchemaTest.res +13 -13
  165. package/tests/dcb/DcbFixtures.res +4 -5
  166. package/tests/dcb/DcbScopeInferenceTest.res +173 -114
  167. package/tests/dcb/DcbStateChangeSliceTest.res +391 -262
  168. package/tests/dcb/DcbTagTest.res +22 -11
  169. package/tests/dcb/DcbValidationTest.res +288 -218
  170. package/tests/eventlog/EventLogFixtures.res +4 -4
  171. package/tests/eventlog/EventLogOperationsTest.res +102 -67
  172. package/tests/eventlog/EventLogRetryTest.res +104 -65
  173. package/tests/eventmapper/EventMapperCallbackTest.res +118 -83
  174. package/tests/eventmapper/EventMapperFixtures.res +15 -23
  175. package/tests/eventmapper/EventMapperTest.res +54 -38
  176. package/tests/eventtopic/EventTopicFixtures.res +3 -4
  177. package/tests/eventtopic/EventTopicOperationsTest.res +41 -29
  178. package/tests/extensionpoint/ExtensionPointCallbackTest.res +58 -45
  179. package/tests/extensionpoint/ExtensionPointFixtures.res +16 -20
  180. package/tests/extensionpoint/ExtensionPointOperationsTest.res +70 -49
  181. package/tests/extensionpoint/PluginDisconnectScheduleTest.res +2 -1
  182. package/tests/logger/LogFormatTest.res +104 -82
  183. package/tests/logger/LoggerLazyTest.res +21 -12
  184. package/tests/message/MessageTest.res +96 -93
  185. package/tests/message/MetaEnvelopeTest.res +26 -30
  186. package/tests/plugin/HeartbeatDisconnectGraceTest.res +31 -25
  187. package/tests/plugin/ManifestVisibilityTest.res +20 -12
  188. package/tests/plugin/PluginDefinitionScalars.res +1 -2
  189. package/tests/plugin/PluginLifecycleCorpusTest.res +15 -12
  190. package/tests/plugin/PluginSpecExperiment.res +0 -1
  191. package/tests/plugin/PluginStructureTest.res +1533 -1030
  192. package/tests/plugin/PluginStructureTest.res.mjs +6 -0
  193. package/tests/plugin/Plugin_EventQuerySchemaTest.res +21 -10
  194. package/tests/plugin/SchemaWalkerTest.res +116 -70
  195. package/tests/plugin/StateChange/PsReserveStock.res +4 -4
  196. package/tests/plugin/StateView/PsAnnotatedView.res +6 -4
  197. package/tests/plugin/StateView/PsAnnotatedView.res.mjs +7 -3
  198. package/tests/plugin/StateView/PsCustomersView.res +4 -4
  199. package/tests/plugin/StateView/PsShipmentsView.res +3 -2
  200. package/tests/projection/LifecycleTrailTest.res +1 -4
  201. package/tests/projection/ProjectionOptimizeTest.res +13 -9
  202. package/tests/querydb/QueryDbFixtures.res +0 -1
  203. package/tests/querydb/QueryDbOperationsTest.res +99 -64
  204. package/tests/querydb/TaggedUnionStampTest.res +10 -8
  205. package/tests/sideeffecthandler/SideEffectHandlerCallbackTest.res +50 -35
  206. package/tests/sideeffecthandler/SideEffectHandlerFixtures.res +4 -10
  207. package/tests/util/CommandPublisherTest.res +0 -1
  208. package/tests/util/CsvStreamTest.res +3 -3
  209. package/tests/util/LruTest.res +84 -56
@@ -170,91 +170,102 @@ module Make = (
170
170
  // View/automation/translation slices are consumers and need produced events to exist.
171
171
  let hasDcb = stateChangeSlices->Array.length > 0
172
172
  if hasDcb {
173
- // Partition slices by channel mode (set by MakeAsync vs Make)
174
- let syncSlices = stateChangeSlices->Array.filter((module(M: StateChangeSlice.T)) => !M.isAsync)
175
- let asyncSlices = stateChangeSlices->Array.filter((module(M: StateChangeSlice.T)) => M.isAsync)
176
-
177
- // Run validation: check produced vs consumed event compatibility
178
- let produced =
179
- stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) =>
180
- (Sc.Spec.name, Sc.Spec.eventSchema->S.castToUnknown)
181
- )
182
- let producedNamed =
183
- stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) =>
184
- (Sc.Spec.name, toRelativePath(Sc.Spec.moduleUrl), Sc.Spec.eventSchema->S.castToUnknown)
185
- )
186
- let consumed =
187
- stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) =>
188
- (Sc.Spec.name, Sc.Spec.consumedEventSchema->S.castToUnknown)
189
- )
190
- ->Array.concat(
191
- stateViewSlices->Array.map((module(V: StateViewSlice.T)) =>
192
- (V.Spec.name, V.Spec.consumedEventSchema->S.castToUnknown)
193
- ),
194
- )
195
- ->Array.concat(
196
- // Plan 04: AutomationSlice has per-source Mappings, each carrying
197
- // its own `sourceEventSchema`. Validation contributes one entry per
198
- // (slice, source) pair so unknown variants surface against the right
199
- // mapping context.
200
- automationSlices->Array.flatMap((module(A: AutomationSlice.T)) =>
201
- A.Automation.mappings->Array.map((module(M: A.Automation.Mapping)) =>
202
- (A.Spec.name, M.sourceEventSchema->S.castToUnknown)
203
- )
204
- ),
205
- )
206
- ->Array.concat(
207
- outboundTranslationSlices->Array.map((module(O: OutboundTranslationSlice.T)) =>
208
- (O.Spec.name, O.Spec.consumedEventSchema->S.castToUnknown)
209
- ),
210
- )
173
+ // Partition slices by channel mode (set by MakeAsync vs Make)
174
+ let syncSlices =
175
+ stateChangeSlices->Array.filter((module(M: StateChangeSlice.T)) => !M.isAsync)
176
+ let asyncSlices =
177
+ stateChangeSlices->Array.filter((module(M: StateChangeSlice.T)) => M.isAsync)
178
+
179
+ // Run validation: check produced vs consumed event compatibility
180
+ let produced =
181
+ stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => (
182
+ Sc.Spec.name,
183
+ Sc.Spec.eventSchema->S.castToUnknown,
184
+ ))
185
+ let producedNamed =
186
+ stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => (
187
+ Sc.Spec.name,
188
+ toRelativePath(Sc.Spec.moduleUrl),
189
+ Sc.Spec.eventSchema->S.castToUnknown,
190
+ ))
191
+ let consumed =
192
+ stateChangeSlices
193
+ ->Array.map((module(Sc: StateChangeSlice.T)) => (
194
+ Sc.Spec.name,
195
+ Sc.Spec.consumedEventSchema->S.castToUnknown,
196
+ ))
197
+ ->Array.concat(
198
+ stateViewSlices->Array.map((module(V: StateViewSlice.T)) => (
199
+ V.Spec.name,
200
+ V.Spec.consumedEventSchema->S.castToUnknown,
201
+ )),
202
+ )
203
+ ->Array.concat(
204
+ // Plan 04: AutomationSlice has per-source Mappings, each carrying
205
+ // its own `sourceEventSchema`. Validation contributes one entry per
206
+ // (slice, source) pair so unknown variants surface against the right
207
+ // mapping context.
208
+ automationSlices->Array.flatMap((module(A: AutomationSlice.T)) =>
209
+ A.Automation.mappings->Array.map((module(M: A.Automation.Mapping)) => (
210
+ A.Spec.name,
211
+ M.sourceEventSchema->S.castToUnknown,
212
+ ))
213
+ ),
214
+ )
215
+ ->Array.concat(
216
+ outboundTranslationSlices->Array.map((module(O: OutboundTranslationSlice.T)) => (
217
+ O.Spec.name,
218
+ O.Spec.consumedEventSchema->S.castToUnknown,
219
+ )),
220
+ )
211
221
 
212
- switch Reventless.DcbValidation.validateProducedAndConsumed(
213
- ~produced=produced->Array.concat(aggregateProducedEvents),
214
- ~consumed,
215
- ) {
216
- | Error(errors) =>
217
- errors->Array.forEach(err =>
218
- log.error(~comp="Dcb_Builder", `DCB validation error (${err.sliceName}): ${err.message}`)
219
- )
220
- | Ok() => ()
221
- }
222
+ switch Reventless.DcbValidation.validateProducedAndConsumed(
223
+ ~produced=produced->Array.concat(aggregateProducedEvents),
224
+ ~consumed,
225
+ ) {
226
+ | Error(errors) =>
227
+ errors->Array.forEach(err =>
228
+ log.error(~comp="Dcb_Builder", `DCB validation error (${err.sliceName}): ${err.message}`)
229
+ )
230
+ | Ok() => ()
231
+ }
222
232
 
223
- // Extract tagged fields from all produced event schemas for DynamoDB GSI creation
224
- let producedSchemas = produced->Array.map(((_, schema)) => schema)
225
- let indexes =
226
- producedSchemas
227
- ->Array.flatMap(schema => Reventless.DcbTag.extractTaggedFields(schema))
228
- ->(arr => {
229
- let seen = Set.make()
230
- arr->Array.filter(f => {
231
- if seen->Set.has(f) {
232
- false
233
- } else {
234
- seen->Set.add(f)
235
- true
236
- }
237
- })
233
+ // Extract tagged fields from all produced event schemas for DynamoDB GSI creation
234
+ let producedSchemas = produced->Array.map(((_, schema)) => schema)
235
+ let indexes =
236
+ producedSchemas
237
+ ->Array.flatMap(schema => Reventless.DcbTag.extractTaggedFields(schema))
238
+ ->(arr => {
239
+ let seen = Set.make()
240
+ arr->Array.filter(f => {
241
+ if seen->Set.has(f) {
242
+ false
243
+ } else {
244
+ seen->Set.add(f)
245
+ true
246
+ }
238
247
  })
239
- ->Array.map(tagKey => `tag_${tagKey}`)
240
- let indexes = if indexes->Array.length > 1 {
241
- indexes->Array.concat(["tag_composite"])
242
- } else {
243
- indexes
244
- }
248
+ })
249
+ ->Array.map(tagKey => `tag_${tagKey}`)
250
+ let indexes = if indexes->Array.length > 1 {
251
+ indexes->Array.concat(["tag_composite"])
252
+ } else {
253
+ indexes
254
+ }
245
255
 
246
- let partitionTag = Reventless.DcbTag.derivePartitionTag(producedNamed)
247
-
248
- // Tag keys declared `@crossPartition` across the produced event schemas.
249
- // Derived once at build time (the scope is a property of the tag key, and
250
- // must agree across every producer that carries it). Threaded to both the
251
- // decision-model query builder (fan a cross-partition scalar tag into its
252
- // own single-tag clause) and the storage adapter (read routing +
253
- // fence scope), keeping read-scope = fence-scope per tag.
254
- let crossPartitionTagKeys =
255
- producedSchemas
256
- ->Array.flatMap(schema => Reventless.DcbTag.extractCrossPartitionTagKeys(schema))
257
- ->(arr => {
256
+ let partitionTag = Reventless.DcbTag.derivePartitionTag(producedNamed)
257
+
258
+ // Tag keys declared `@crossPartition` across the produced event schemas.
259
+ // Derived once at build time (the scope is a property of the tag key, and
260
+ // must agree across every producer that carries it). Threaded to both the
261
+ // decision-model query builder (fan a cross-partition scalar tag into its
262
+ // own single-tag clause) and the storage adapter (read routing +
263
+ // fence scope), keeping read-scope = fence-scope per tag.
264
+ let crossPartitionTagKeys =
265
+ producedSchemas
266
+ ->Array.flatMap(schema => Reventless.DcbTag.extractCrossPartitionTagKeys(schema))
267
+ ->(
268
+ arr => {
258
269
  let seen = Set.make()
259
270
  arr->Array.filter(f => {
260
271
  if seen->Set.has(f) {
@@ -264,892 +275,893 @@ module Make = (
264
275
  true
265
276
  }
266
277
  })
267
- })
268
-
269
- // A tag key's scope must agree across every producer that carries it,
270
- // else the (writer-driven) fence is ambiguous. Non-fatal — surfaces a
271
- // mismatch loudly without aborting the deploy.
272
- Reventless.DcbValidation.validateCrossPartitionScope(
273
- ~producers=produced,
274
- )->Array.forEach(err =>
275
- log.error(
276
- ~comp="Dcb_Builder",
277
- `DCB cross-partition scope error (${err.sliceName}): ${err.message}`,
278
- )
278
+ }
279
279
  )
280
280
 
281
- // Map each produced event type to its full produced tag-key set. Threaded
282
- // into each StateChangeSlice so the decision-model query drops vacuous
283
- // (type, tag) clause combinations (a type that can't carry the clause's
284
- // tag) — pure dead-clause removal, results unchanged. Built from the
285
- // producer schemas (not consumers' `consumedEventSchema`, which may
286
- // under-declare tags a slice legitimately reads by).
287
- let tagKeysByEventType =
288
- producedSchemas
289
- ->Array.map(schema => Reventless.DcbTag.extractTagKeysByEventType(schema))
290
- ->Reventless.DcbTag.mergeTagKeysByEventType
291
-
292
- // Warn on composite (multi-tag) decision reads that would silently miss an
293
- // event carrying extra tags (the tag_composite key is the event's full tag
294
- // set, so composite reads are exact-match). Non-fatal — today's slices are
295
- // aligned; the guard catches a tag added to a multi-tag event later.
296
- Reventless.DcbValidation.validateCompositeReads(
297
- ~slices=stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => (
298
- Sc.Spec.name,
299
- Sc.Spec.commandSchema->S.castToUnknown,
300
- Sc.Spec.consumedEventSchema->S.castToUnknown,
301
- )),
302
- ~producedTagKeys=tagKeysByEventType,
303
- )->Array.forEach(w =>
304
- log.warn(~comp="Dcb_Builder", `DCB composite-read warning (${w.sliceName}): ${w.message}`)
281
+ // A tag key's scope must agree across every producer that carries it,
282
+ // else the (writer-driven) fence is ambiguous. Non-fatal — surfaces a
283
+ // mismatch loudly without aborting the deploy.
284
+ Reventless.DcbValidation.validateCrossPartitionScope(
285
+ ~producers=produced,
286
+ )->Array.forEach(err =>
287
+ log.error(
288
+ ~comp="Dcb_Builder",
289
+ `DCB cross-partition scope error (${err.sliceName}): ${err.message}`,
305
290
  )
306
-
307
- // --- Phase 2 (dcb-tag-scope-inference): derive scope from the global slice
308
- // graph and THREAD it into the decision-query wiring, replacing the
309
- // annotation-based extraction. The derived `tagKeysByEventType` is what
310
- // fixes the sibling leak — a foreign reference key (e.g. `categoryId` on
311
- // `ProductAdded`) is payload, so the cross-partition read clause no longer
312
- // sweeps up sibling products. Cross-partition fan-out and capacity reads are
313
- // preserved (generalised rule 3). Safety guard: if any slice's partition is
314
- // ambiguous we keep the annotated values for the whole boundary rather than
315
- // thread a partial inference. See docs/plans/done/dcb-tag-scope-inference.md.
316
- let inferenceShapes =
317
- stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) =>
318
- Reventless.DcbTag.sliceShapeFromSchemas(
319
- ~name=Sc.Spec.name,
320
- ~commandSchema=Sc.Spec.commandSchema->S.castToUnknown,
321
- ~consumedEventSchema=Sc.Spec.consumedEventSchema->S.castToUnknown,
322
- ~eventSchema=Sc.Spec.eventSchema->S.castToUnknown,
323
- )
291
+ )
292
+
293
+ // Map each produced event type to its full produced tag-key set. Threaded
294
+ // into each StateChangeSlice so the decision-model query drops vacuous
295
+ // (type, tag) clause combinations (a type that can't carry the clause's
296
+ // tag) — pure dead-clause removal, results unchanged. Built from the
297
+ // producer schemas (not consumers' `consumedEventSchema`, which may
298
+ // under-declare tags a slice legitimately reads by).
299
+ let tagKeysByEventType =
300
+ producedSchemas
301
+ ->Array.map(schema => Reventless.DcbTag.extractTagKeysByEventType(schema))
302
+ ->Reventless.DcbTag.mergeTagKeysByEventType
303
+
304
+ // Warn on composite (multi-tag) decision reads that would silently miss an
305
+ // event carrying extra tags (the tag_composite key is the event's full tag
306
+ // set, so composite reads are exact-match). Non-fatal — today's slices are
307
+ // aligned; the guard catches a tag added to a multi-tag event later.
308
+ Reventless.DcbValidation.validateCompositeReads(
309
+ ~slices=stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => (
310
+ Sc.Spec.name,
311
+ Sc.Spec.commandSchema->S.castToUnknown,
312
+ Sc.Spec.consumedEventSchema->S.castToUnknown,
313
+ )),
314
+ ~producedTagKeys=tagKeysByEventType,
315
+ )->Array.forEach(w =>
316
+ log.warn(~comp="Dcb_Builder", `DCB composite-read warning (${w.sliceName}): ${w.message}`)
317
+ )
318
+
319
+ // --- Phase 2 (dcb-tag-scope-inference): derive scope from the global slice
320
+ // graph and THREAD it into the decision-query wiring, replacing the
321
+ // annotation-based extraction. The derived `tagKeysByEventType` is what
322
+ // fixes the sibling leak — a foreign reference key (e.g. `categoryId` on
323
+ // `ProductAdded`) is payload, so the cross-partition read clause no longer
324
+ // sweeps up sibling products. Cross-partition fan-out and capacity reads are
325
+ // preserved (generalised rule 3). Safety guard: if any slice's partition is
326
+ // ambiguous we keep the annotated values for the whole boundary rather than
327
+ // thread a partial inference. See docs/plans/done/dcb-tag-scope-inference.md.
328
+ let inferenceShapes =
329
+ stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) =>
330
+ Reventless.DcbTag.sliceShapeFromSchemas(
331
+ ~name=Sc.Spec.name,
332
+ ~commandSchema=Sc.Spec.commandSchema->S.castToUnknown,
333
+ ~consumedEventSchema=Sc.Spec.consumedEventSchema->S.castToUnknown,
334
+ ~eventSchema=Sc.Spec.eventSchema->S.castToUnknown,
324
335
  )
325
- let inferred = Reventless.DcbScopeInference.infer(inferenceShapes)
326
- if inferred.crossPartitionTagKeys != crossPartitionTagKeys {
336
+ )
337
+ let inferred = Reventless.DcbScopeInference.infer(inferenceShapes)
338
+ if inferred.crossPartitionTagKeys != crossPartitionTagKeys {
339
+ log.info(
340
+ ~comp="Dcb_Builder",
341
+ `DCB scope-inference diff: crossPartitionTagKeys annotated=[${crossPartitionTagKeys->Array.join(
342
+ ", ",
343
+ )}] inferred=[${inferred.crossPartitionTagKeys->Array.join(", ")}]`,
344
+ )
345
+ }
346
+ inferred.tagKeysByEventType
347
+ ->Dict.toArray
348
+ ->Array.forEach(((eventType, inferredKeys)) => {
349
+ let annotatedKeys = tagKeysByEventType->Dict.get(eventType)->Option.getOr([])
350
+ let dropped = annotatedKeys->Array.filter(k => !(inferredKeys->Array.includes(k)))
351
+ if dropped->Array.length > 0 {
327
352
  log.info(
328
353
  ~comp="Dcb_Builder",
329
- `DCB scope-inference diff: crossPartitionTagKeys annotated=[${crossPartitionTagKeys->Array.join(
354
+ `DCB scope-inference diff: ${eventType} indexes [${inferredKeys->Array.join(
355
+ ", ",
356
+ )}] (inferred) vs [${annotatedKeys->Array.join(
330
357
  ", ",
331
- )}] inferred=[${inferred.crossPartitionTagKeys->Array.join(", ")}]`,
358
+ )}] (annotated) — payload now: [${dropped->Array.join(", ")}]`,
332
359
  )
333
360
  }
334
- inferred.tagKeysByEventType
335
- ->Dict.toArray
336
- ->Array.forEach(((eventType, inferredKeys)) => {
337
- let annotatedKeys = tagKeysByEventType->Dict.get(eventType)->Option.getOr([])
338
- let dropped = annotatedKeys->Array.filter(k => !(inferredKeys->Array.includes(k)))
339
- if dropped->Array.length > 0 {
340
- log.info(
341
- ~comp="Dcb_Builder",
342
- `DCB scope-inference diff: ${eventType} indexes [${inferredKeys->Array.join(
343
- ", ",
344
- )}] (inferred) vs [${annotatedKeys->Array.join(
345
- ", ",
346
- )}] (annotated) — payload now: [${dropped->Array.join(", ")}]`,
347
- )
348
- }
349
- })
350
- inferred.ambiguities->Array.forEach(((sliceName, reason)) =>
351
- log.warn(
352
- ~comp="Dcb_Builder",
353
- `DCB scope-inference ambiguity (${sliceName}): ${reason}`,
354
- )
361
+ })
362
+ inferred.ambiguities->Array.forEach(((sliceName, reason)) =>
363
+ log.warn(~comp="Dcb_Builder", `DCB scope-inference ambiguity (${sliceName}): ${reason}`)
364
+ )
365
+
366
+ // Validate any remaining explicit @crossPartition against the derived scope:
367
+ // contradictions (a key marked cross-partition that is the slice's own
368
+ // partition) are likely bugs; redundant annotations can simply be dropped.
369
+ let scopeAnnotations =
370
+ stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => (
371
+ Sc.Spec.name,
372
+ Reventless.DcbTag.extractCrossPartitionTagKeys(Sc.Spec.eventSchema->S.castToUnknown),
373
+ ))
374
+ let scopeIssues = Reventless.DcbValidation.validateScopeVsInference(
375
+ ~annotations=scopeAnnotations,
376
+ ~inferred,
377
+ )
378
+ scopeIssues.contradictions->Array.forEach(e =>
379
+ log.warn(~comp="Dcb_Builder", `DCB scope contradiction (${e.sliceName}): ${e.message}`)
380
+ )
381
+ scopeIssues.redundancies->Array.forEach(e =>
382
+ log.info(~comp="Dcb_Builder", `DCB scope (${e.sliceName}): ${e.message}`)
383
+ )
384
+
385
+ // All-or-nothing: thread the derived scope only when every slice resolved.
386
+ // An ambiguous boundary keeps the annotated values (no partial inference).
387
+ // Both this builder and the deployed command-handler entry point
388
+ // (DcbCommandTopicEntryPoint.mjs) route through `DcbTag.deriveEffectiveScope`
389
+ // so the runtime decision query cannot drift from this scope — see
390
+ // docs/analysis/dcb-runtime-scope-annotation-drift.md.
391
+ let effective = Reventless.DcbTag.deriveEffectiveScope(
392
+ stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => {
393
+ Reventless.DcbTag.name: Sc.Spec.name,
394
+ commandSchema: Sc.Spec.commandSchema->S.castToUnknown,
395
+ consumedEventSchema: Sc.Spec.consumedEventSchema->S.castToUnknown,
396
+ eventSchema: Sc.Spec.eventSchema->S.castToUnknown,
397
+ }),
398
+ )
399
+ let effectiveCrossPartitionTagKeys = effective.crossPartitionTagKeys
400
+ let effectiveTagKeysByEventType = effective.tagKeysByEventType
401
+
402
+ // An ambiguity is a warning above because a boundary can fall back and
403
+ // lose nothing. Losing a cross-partition key is not that: the decision
404
+ // read for every slice that references another entity silently narrows to
405
+ // its own partition, so it decides against an empty history and rejects a
406
+ // command whose facts are sitting in the log. Nothing downstream fails —
407
+ // the wrong answer *is* the failure — so this is the only place it can be
408
+ // said, and it is said at error level naming the slice to fix.
409
+ if effective.droppedCrossPartitionTagKeys->Array.length > 0 {
410
+ log.error(
411
+ ~comp="Dcb_Builder",
412
+ `DCB scope degraded (${name}): cross-partition reads of [${effective.droppedCrossPartitionTagKeys->Array.join(
413
+ ", ",
414
+ )}] were derived but dropped — an ambiguous slice forced the boundary onto @crossPartition annotations that do not carry them. Slices referencing another entity by these keys will decide against an empty history and reject valid commands. Resolve: ${effective.ambiguities
415
+ ->Array.map(((slice, reason)) => `${slice} (${reason})`)
416
+ ->Array.join(" | ")}`,
355
417
  )
418
+ }
356
419
 
357
- // Validate any remaining explicit @crossPartition against the derived scope:
358
- // contradictions (a key marked cross-partition that is the slice's own
359
- // partition) are likely bugs; redundant annotations can simply be dropped.
360
- let scopeAnnotations =
361
- stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => (
362
- Sc.Spec.name,
363
- Reventless.DcbTag.extractCrossPartitionTagKeys(Sc.Spec.eventSchema->S.castToUnknown),
364
- ))
365
- let scopeIssues = Reventless.DcbValidation.validateScopeVsInference(
366
- ~annotations=scopeAnnotations,
367
- ~inferred,
368
- )
369
- scopeIssues.contradictions->Array.forEach(e =>
370
- log.warn(~comp="Dcb_Builder", `DCB scope contradiction (${e.sliceName}): ${e.message}`)
371
- )
372
- scopeIssues.redundancies->Array.forEach(e =>
373
- log.info(~comp="Dcb_Builder", `DCB scope (${e.sliceName}): ${e.message}`)
420
+ module DcbEventLog = DcbEventLog_Builder.Make(DcbEventLogStorage, DcbEventTopicPublisher)
421
+ let dcbEventLog = DcbEventLog.make(
422
+ ~name,
423
+ ~indexes,
424
+ ~partitionTag,
425
+ ~crossPartitionTagKeys=effectiveCrossPartitionTagKeys,
426
+ ~opts,
427
+ )
428
+
429
+ // Notify platform hook that DCB EventLog was created (AWS extracts table name)
430
+ HooksConfig.hooks.onDcbEventLogCreated->Option.forEach(hook => hook(dcbEventLog->Obj.magic))
431
+
432
+ // Create sync CommandTopic for sync StateChangeSlices (and all other DCB slice types)
433
+ module DcbCommandTopicSpec = {
434
+ module Id = Reventless.Id.String
435
+ let name = childName
436
+ @schema
437
+ type command = JSON.t
438
+ }
439
+ module DcbCommandTopic = CommandTopic_Builder.Make(
440
+ DcbCommandTopicSpec,
441
+ DcbCommandTopicChannel,
442
+ )
443
+ let dcbCommandTopic = DcbCommandTopic.make(
444
+ ~name=`${name}Dcb`,
445
+ ~owner={kind: ComponentType.Plugin, name},
446
+ ~opts,
447
+ )
448
+
449
+ // Notify platform hook that DCB CommandTopic was created (AWS extracts SQS queue URL)
450
+ HooksConfig.hooks.onDcbCommandTopicCreated->Option.forEach(hook =>
451
+ hook(dcbCommandTopic->Obj.magic)
452
+ )
453
+
454
+ let publishJsons =
455
+ dcbCommandTopic
456
+ ->Component.operations
457
+ ->Pulumi.Output.apply(ops => ops.publishJsons)
458
+
459
+ // Create FIFO CommandTopic for async StateChangeSlices (only if any are configured)
460
+ module DcbCommandTopicSpecAsync = {
461
+ module Id = Reventless.Id.String
462
+ let name = childName ++ "Async"
463
+ @schema
464
+ type command = JSON.t
465
+ }
466
+ module DcbAsyncCommandTopic = CommandTopic_Builder.Make(
467
+ DcbCommandTopicSpecAsync,
468
+ DcbCommandTopicChannelAsync,
469
+ )
470
+ let asyncDcbCommandTopicOpt = if asyncSlices->Array.length > 0 {
471
+ let t = DcbAsyncCommandTopic.make(
472
+ ~name=`${name}DcbAsync`,
473
+ ~owner={kind: ComponentType.Plugin, name},
474
+ ~opts,
374
475
  )
476
+ HooksConfig.hooks.onDcbCommandTopicCreated->Option.forEach(hook => hook(t->Obj.magic))
477
+ Some(t)
478
+ } else {
479
+ None
480
+ }
375
481
 
376
- // All-or-nothing: thread the derived scope only when every slice resolved.
377
- // An ambiguous boundary keeps the annotated values (no partial inference).
378
- // Both this builder and the deployed command-handler entry point
379
- // (DcbCommandTopicEntryPoint.mjs) route through `DcbTag.deriveEffectiveScope`
380
- // so the runtime decision query cannot drift from this scope — see
381
- // docs/analysis/dcb-runtime-scope-annotation-drift.md.
382
- let effective = Reventless.DcbTag.deriveEffectiveScope(
383
- stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => {
384
- Reventless.DcbTag.name: Sc.Spec.name,
385
- commandSchema: Sc.Spec.commandSchema->S.castToUnknown,
386
- consumedEventSchema: Sc.Spec.consumedEventSchema->S.castToUnknown,
387
- eventSchema: Sc.Spec.eventSchema->S.castToUnknown,
388
- }),
389
- )
390
- let effectiveCrossPartitionTagKeys = effective.crossPartitionTagKeys
391
- let effectiveTagKeysByEventType = effective.tagKeysByEventType
392
-
393
- // An ambiguity is a warning above because a boundary can fall back and
394
- // lose nothing. Losing a cross-partition key is not that: the decision
395
- // read for every slice that references another entity silently narrows to
396
- // its own partition, so it decides against an empty history and rejects a
397
- // command whose facts are sitting in the log. Nothing downstream fails —
398
- // the wrong answer *is* the failure — so this is the only place it can be
399
- // said, and it is said at error level naming the slice to fix.
400
- if effective.droppedCrossPartitionTagKeys->Array.length > 0 {
401
- log.error(
402
- ~comp="Dcb_Builder",
403
- `DCB scope degraded (${name}): cross-partition reads of [${effective.droppedCrossPartitionTagKeys->Array.join(
404
- ", ",
405
- )}] were derived but dropped — an ambiguous slice forced the boundary onto @crossPartition annotations that do not carry them. Slices referencing another entity by these keys will decide against an empty history and reject valid commands. Resolve: ${effective.ambiguities
406
- ->Array.map(((slice, reason)) => `${slice} (${reason})`)
407
- ->Array.join(" | ")}`,
482
+ let stateChangeSlicesOutputs =
483
+ syncSlices
484
+ ->Array.map((module(StateChangeSlice: StateChangeSlice.T)) => {
485
+ let ch = StateChangeSlice.make(
486
+ ~dcbEventLog,
487
+ ~publishJsons,
488
+ ~tagKeysByEventType=effectiveTagKeysByEventType,
489
+ ~crossPartitionTagKeys=effectiveCrossPartitionTagKeys,
490
+ ~runtime=?componentRuntime->Dict.get(StateChangeSlice.Spec.name),
491
+ ~opts,
408
492
  )
409
- }
410
-
411
- module DcbEventLog = DcbEventLog_Builder.Make(
412
- DcbEventLogStorage,
413
- DcbEventTopicPublisher,
414
- )
415
- let dcbEventLog =
416
- DcbEventLog.make(
417
- ~name,
418
- ~indexes,
419
- ~partitionTag,
493
+ (StateChangeSlice.Spec.name, ch->Component.outputs)
494
+ })
495
+ ->Dict.fromArray
496
+
497
+ // Create async slice components using the FIFO CommandTopic's publishJsons
498
+ let asyncStateChangeSlicesOutputs = switch asyncDcbCommandTopicOpt {
499
+ | None => Dict.make()
500
+ | Some(asyncDcbCommandTopic) =>
501
+ let asyncPublishJsons =
502
+ asyncDcbCommandTopic->Component.operations->Pulumi.Output.apply(ops => ops.publishJsons)
503
+ asyncSlices
504
+ ->Array.map((module(StateChangeSlice: StateChangeSlice.T)) => {
505
+ let ch = StateChangeSlice.make(
506
+ ~dcbEventLog,
507
+ ~publishJsons=asyncPublishJsons,
508
+ ~tagKeysByEventType=effectiveTagKeysByEventType,
420
509
  ~crossPartitionTagKeys=effectiveCrossPartitionTagKeys,
510
+ ~runtime=?componentRuntime->Dict.get(StateChangeSlice.Spec.name),
421
511
  ~opts,
422
512
  )
513
+ (StateChangeSlice.Spec.name, ch->Component.outputs)
514
+ })
515
+ ->Dict.fromArray
516
+ }
423
517
 
424
- // Notify platform hook that DCB EventLog was created (AWS extracts table name)
425
- HooksConfig.hooks.onDcbEventLogCreated->Option.forEach(hook =>
426
- hook(dcbEventLog->Obj.magic)
427
- )
428
-
429
- // Create sync CommandTopic for sync StateChangeSlices (and all other DCB slice types)
430
- module DcbCommandTopicSpec = {
431
- module Id = Reventless.Id.String
432
- let name = childName
433
- @schema
434
- type command = JSON.t
435
- }
436
- module DcbCommandTopic = CommandTopic_Builder.Make(
437
- DcbCommandTopicSpec,
438
- DcbCommandTopicChannel,
439
- )
440
- let dcbCommandTopic = DcbCommandTopic.make(~name=`${name}Dcb`, ~owner={kind: ComponentType.Plugin, name}, ~opts)
441
-
442
- // Notify platform hook that DCB CommandTopic was created (AWS extracts SQS queue URL)
443
- HooksConfig.hooks.onDcbCommandTopicCreated->Option.forEach(hook =>
444
- hook(dcbCommandTopic->Obj.magic)
445
- )
446
-
447
- let publishJsons =
448
- dcbCommandTopic
449
- ->Component.operations
450
- ->Pulumi.Output.apply(ops => ops.publishJsons)
451
-
452
- // Create FIFO CommandTopic for async StateChangeSlices (only if any are configured)
453
- module DcbCommandTopicSpecAsync = {
454
- module Id = Reventless.Id.String
455
- let name = childName ++ "Async"
456
- @schema
457
- type command = JSON.t
458
- }
459
- module DcbAsyncCommandTopic = CommandTopic_Builder.Make(
460
- DcbCommandTopicSpecAsync,
461
- DcbCommandTopicChannelAsync,
462
- )
463
- let asyncDcbCommandTopicOpt = if asyncSlices->Array.length > 0 {
464
- let t = DcbAsyncCommandTopic.make(~name=`${name}DcbAsync`, ~owner={kind: ComponentType.Plugin, name}, ~opts)
465
- HooksConfig.hooks.onDcbCommandTopicCreated->Option.forEach(hook => hook(t->Obj.magic))
466
- Some(t)
467
- } else {
468
- None
469
- }
470
-
471
- let stateChangeSlicesOutputs =
472
- syncSlices
473
- ->Array.map((module(StateChangeSlice: StateChangeSlice.T)) => {
474
- let ch = StateChangeSlice.make(
475
- ~dcbEventLog,
476
- ~publishJsons,
477
- ~tagKeysByEventType=effectiveTagKeysByEventType,
478
- ~crossPartitionTagKeys=effectiveCrossPartitionTagKeys,
479
- ~runtime=?componentRuntime->Dict.get(StateChangeSlice.Spec.name),
480
- ~opts,
518
+ // Phase 1: Register DCB mutation SDL + resolver stubs via platform hook
519
+ switch HooksConfig.hooks.mutationResolverHook {
520
+ | Some(registerResolver) =>
521
+ syncSlices->Array.forEach((module(S: StateChangeSlice.T)) => {
522
+ let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
523
+ if !ApiNoApiHelpers.isNoApi(commandSchema) {
524
+ registerResolver(
525
+ ~kind=Dcb,
526
+ ~fields=Api_Naming.sliceMutationFields(
527
+ ~plugin=apiNamePrefix,
528
+ ~slice=S.Spec.name,
529
+ ~commandSchema,
530
+ )->Array.map(((f, _)) => f),
531
+ ~commandSchema,
532
+ ~commandAuthorization=S.Spec.commandAuthorization->Obj.magic,
481
533
  )
482
- (StateChangeSlice.Spec.name, ch->Component.outputs)
483
- })
484
- ->Dict.fromArray
485
-
486
- // Create async slice components using the FIFO CommandTopic's publishJsons
487
- let asyncStateChangeSlicesOutputs =
488
- switch asyncDcbCommandTopicOpt {
489
- | None => Dict.make()
490
- | Some(asyncDcbCommandTopic) =>
491
- let asyncPublishJsons =
492
- asyncDcbCommandTopic->Component.operations->Pulumi.Output.apply(ops => ops.publishJsons)
493
- asyncSlices
494
- ->Array.map((module(StateChangeSlice: StateChangeSlice.T)) => {
495
- let ch = StateChangeSlice.make(
496
- ~dcbEventLog,
497
- ~publishJsons=asyncPublishJsons,
498
- ~tagKeysByEventType=effectiveTagKeysByEventType,
499
- ~crossPartitionTagKeys=effectiveCrossPartitionTagKeys,
500
- ~runtime=?componentRuntime->Dict.get(StateChangeSlice.Spec.name),
501
- ~opts,
502
- )
503
- (StateChangeSlice.Spec.name, ch->Component.outputs)
504
- })
505
- ->Dict.fromArray
506
534
  }
507
-
508
- // Phase 1: Register DCB mutation SDL + resolver stubs via platform hook
509
- switch HooksConfig.hooks.mutationResolverHook {
510
- | Some(registerResolver) =>
511
- syncSlices->Array.forEach((
512
- module(S: StateChangeSlice.T),
513
- ) => {
514
- let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
515
- if !(ApiNoApiHelpers.isNoApi(commandSchema)) {
516
- registerResolver(
517
- ~kind=Dcb,
518
- ~fields=Api_Naming.sliceMutationFields(
519
- ~plugin=apiNamePrefix,
520
- ~slice=S.Spec.name,
521
- ~commandSchema,
522
- )->Array.map(((f, _)) => f),
535
+ })
536
+ asyncSlices->Array.forEach((module(S: StateChangeSlice.T)) => {
537
+ let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
538
+ if !ApiNoApiHelpers.isNoApi(commandSchema) {
539
+ registerResolver(
540
+ ~kind=Dcb,
541
+ ~fields=Api_Naming.sliceMutationFields(
542
+ ~plugin=apiNamePrefix,
543
+ ~slice=S.Spec.name,
523
544
  ~commandSchema,
524
- ~commandAuthorization=S.Spec.commandAuthorization->Obj.magic,
525
- )
526
- }
527
- })
528
- asyncSlices->Array.forEach((
529
- module(S: StateChangeSlice.T),
530
- ) => {
531
- let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
532
- if !(ApiNoApiHelpers.isNoApi(commandSchema)) {
533
- registerResolver(
534
- ~kind=Dcb,
535
- ~fields=Api_Naming.sliceMutationFields(
545
+ )->Array.map(((f, _)) => f),
546
+ ~commandSchema,
547
+ ~commandAuthorization=S.Spec.commandAuthorization->Obj.magic,
548
+ )
549
+ }
550
+ })
551
+ | None => ()
552
+ }
553
+
554
+ // Phase 2: Bind generateCommand to resolver stubs when publishJsons resolves
555
+ // Sync slices use sync CommandTopic ops (publishJsonsAndWait → CommandAccepted/Rejected)
556
+ // Async slices use async CommandTopic ops (publishJsons only → CommandPending)
557
+ switch HooksConfig.hooks.mutationBindHook {
558
+ | Some(bindHandler) =>
559
+ let _ =
560
+ dcbCommandTopic
561
+ ->Component.operations
562
+ ->Pulumi.Output.apply(ops => {
563
+ syncSlices->Array.forEach((module(S: StateChangeSlice.T)) => {
564
+ let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
565
+ if !ApiNoApiHelpers.isNoApi(commandSchema) {
566
+ // One generateCommand backs every constructor's field — the TAG is
567
+ // injected by the resolver per field, so a single binding suffices.
568
+ let generateCommand = CommandGenerator_Callback.makeGenerateCommand(
569
+ ~publishJsons=ops.publishJsons,
570
+ ~publishJsonsAndWait=?ops.publishJsonsAndWait,
571
+ ~serviceName=S.Spec.name,
572
+ ~commandSchema=S.Spec.commandSchema->Obj.magic,
573
+ ~componentKind=CommandGenerator_Callback.StateChangeSlice,
574
+ ~stripIdFromParams=false,
575
+ )
576
+ Api_Naming.sliceMutationFields(
536
577
  ~plugin=apiNamePrefix,
537
578
  ~slice=S.Spec.name,
538
579
  ~commandSchema,
539
- )->Array.map(((f, _)) => f),
540
- ~commandSchema,
541
- ~commandAuthorization=S.Spec.commandAuthorization->Obj.magic,
542
- )
543
- }
580
+ )->Array.forEach(
581
+ ((fieldName, _)) => bindHandler(~field=fieldName, ~generateCommand),
582
+ )
583
+ }
584
+ })
544
585
  })
586
+ switch asyncDcbCommandTopicOpt {
545
587
  | None => ()
546
- }
547
-
548
- // Phase 2: Bind generateCommand to resolver stubs when publishJsons resolves
549
- // Sync slices use sync CommandTopic ops (publishJsonsAndWait → CommandAccepted/Rejected)
550
- // Async slices use async CommandTopic ops (publishJsons only → CommandPending)
551
- switch HooksConfig.hooks.mutationBindHook {
552
- | Some(bindHandler) =>
588
+ | Some(asyncDcbCommandTopic) =>
553
589
  let _ =
554
- dcbCommandTopic
590
+ asyncDcbCommandTopic
555
591
  ->Component.operations
556
- ->Pulumi.Output.apply(ops => {
557
- syncSlices->Array.forEach((
558
- module(S: StateChangeSlice.T),
559
- ) => {
592
+ ->Pulumi.Output.apply(asyncOps => {
593
+ asyncSlices->Array.forEach((module(S: StateChangeSlice.T)) => {
560
594
  let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
561
- if !(ApiNoApiHelpers.isNoApi(commandSchema)) {
595
+ if !ApiNoApiHelpers.isNoApi(commandSchema) {
562
596
  // One generateCommand backs every constructor's field — the TAG is
563
597
  // injected by the resolver per field, so a single binding suffices.
564
598
  let generateCommand = CommandGenerator_Callback.makeGenerateCommand(
565
- ~publishJsons=ops.publishJsons,
566
- ~publishJsonsAndWait=?ops.publishJsonsAndWait,
599
+ ~publishJsons=asyncOps.publishJsons,
600
+ ~publishJsonsAndWait=?asyncOps.publishJsonsAndWait,
567
601
  ~serviceName=S.Spec.name,
568
602
  ~commandSchema=S.Spec.commandSchema->Obj.magic,
569
603
  ~componentKind=CommandGenerator_Callback.StateChangeSlice,
570
604
  ~stripIdFromParams=false,
571
605
  )
572
- Api_Naming.sliceMutationFields(~plugin=apiNamePrefix, ~slice=S.Spec.name, ~commandSchema)
573
- ->Array.forEach(((fieldName, _)) => bindHandler(~field=fieldName, ~generateCommand))
606
+ Api_Naming.sliceMutationFields(
607
+ ~plugin=apiNamePrefix,
608
+ ~slice=S.Spec.name,
609
+ ~commandSchema,
610
+ )->Array.forEach(
611
+ ((fieldName, _)) => bindHandler(~field=fieldName, ~generateCommand),
612
+ )
574
613
  }
575
614
  })
576
615
  })
577
- switch asyncDcbCommandTopicOpt {
578
- | None => ()
579
- | Some(asyncDcbCommandTopic) =>
580
- let _ =
581
- asyncDcbCommandTopic
582
- ->Component.operations
583
- ->Pulumi.Output.apply(asyncOps => {
584
- asyncSlices->Array.forEach((
585
- module(S: StateChangeSlice.T),
586
- ) => {
587
- let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
588
- if !(ApiNoApiHelpers.isNoApi(commandSchema)) {
589
- // One generateCommand backs every constructor's field — the TAG is
590
- // injected by the resolver per field, so a single binding suffices.
591
- let generateCommand = CommandGenerator_Callback.makeGenerateCommand(
592
- ~publishJsons=asyncOps.publishJsons,
593
- ~publishJsonsAndWait=?asyncOps.publishJsonsAndWait,
594
- ~serviceName=S.Spec.name,
595
- ~commandSchema=S.Spec.commandSchema->Obj.magic,
596
- ~componentKind=CommandGenerator_Callback.StateChangeSlice,
597
- ~stripIdFromParams=false,
598
- )
599
- Api_Naming.sliceMutationFields(~plugin=apiNamePrefix, ~slice=S.Spec.name, ~commandSchema)
600
- ->Array.forEach(((fieldName, _)) => bindHandler(~field=fieldName, ~generateCommand))
601
- }
602
- })
603
- })
604
- }
605
- | None => ()
606
616
  }
617
+ | None => ()
618
+ }
607
619
 
608
- // Populate query field names registry for all slice types BEFORE creating them
609
- stateViewSlices->Array.forEach((
610
- module(V: StateViewSlice.T),
611
- ) => {
612
- let qn = Api_Naming.queryFieldNamesForStateView(~plugin=name, ~viewName=V.Spec.name)
613
- let label = Plugin_Structure.labelFieldsFromStateSchema(
614
- ~entityName=V.Spec.name,
615
- V.Spec.stateSchema->S.castToUnknown,
616
- )
617
- let qn = {
620
+ // Populate query field names registry for all slice types BEFORE creating them
621
+ stateViewSlices->Array.forEach((module(V: StateViewSlice.T)) => {
622
+ let qn = Api_Naming.queryFieldNamesForStateView(~plugin=name, ~viewName=V.Spec.name)
623
+ let label = Plugin_Structure.labelFieldsFromStateSchema(
624
+ ~entityName=V.Spec.name,
625
+ V.Spec.stateSchema->S.castToUnknown,
626
+ )
627
+ let qn = {
628
+ ...qn,
629
+ labelField: label.field,
630
+ connectionFilterTypeName: qn.returnTypeName ++ "Filter",
631
+ }
632
+ let qn = switch V.Spec.subIdConfig {
633
+ | Some(_) => {
618
634
  ...qn,
619
- labelField: label.field,
620
- connectionFilterTypeName: qn.returnTypeName ++ "Filter",
635
+ itemsFieldName: qn.singleFieldName ++ "Items",
636
+ itemsFilterTypeName: qn.returnTypeName ++ "ItemsFilter",
621
637
  }
622
- let qn = switch V.Spec.subIdConfig {
623
- | Some(_) =>
624
- {
625
- ...qn,
626
- itemsFieldName: qn.singleFieldName ++ "Items",
627
- itemsFilterTypeName: qn.returnTypeName ++ "ItemsFilter",
628
- }
629
- | None => qn
630
- }
631
- Plugin_Helpers.queryFieldNamesRegistry->Dict.set(V.Spec.name, qn)
632
- Plugin_Helpers.stateSchemaRegistry->Dict.set(
633
- V.Spec.name,
634
- V.Spec.stateSchema->S.castToUnknown,
638
+ | None => qn
639
+ }
640
+ Plugin_Helpers.queryFieldNamesRegistry->Dict.set(V.Spec.name, qn)
641
+ Plugin_Helpers.stateSchemaRegistry->Dict.set(
642
+ V.Spec.name,
643
+ V.Spec.stateSchema->S.castToUnknown,
644
+ )
645
+ })
646
+
647
+ automationSlices->Array.forEach((module(A: AutomationSlice.T)) => {
648
+ let qn = Api_Naming.queryFieldNamesForSliceQueryDb(~plugin=name, ~queryDbName=A.queryDbName)
649
+ Plugin_Helpers.queryFieldNamesRegistry->Dict.set(A.queryDbName, qn)
650
+ })
651
+
652
+ outboundTranslationSlices->Array.forEach((module(O: OutboundTranslationSlice.T)) => {
653
+ let qn = Api_Naming.queryFieldNamesForSliceQueryDb(~plugin=name, ~queryDbName=O.queryDbName)
654
+ Plugin_Helpers.queryFieldNamesRegistry->Dict.set(O.queryDbName, qn)
655
+ })
656
+
657
+ inboundTranslationSlices->Array.forEach((module(I: InboundTranslationSlice.T)) => {
658
+ let qn = Api_Naming.queryFieldNamesForSliceQueryDb(~plugin=name, ~queryDbName=I.queryDbName)
659
+ Plugin_Helpers.queryFieldNamesRegistry->Dict.set(I.queryDbName, qn)
660
+ })
661
+
662
+ // Create StateViewSlices
663
+ let stateViewSlicesOutputs =
664
+ stateViewSlices
665
+ ->Array.map((module(StateViewSlice: StateViewSlice.T)) => {
666
+ let sv = StateViewSlice.make(
667
+ ~dcbEventLog,
668
+ ~runtime=?componentRuntime->Dict.get(StateViewSlice.Spec.name),
669
+ ~opts,
635
670
  )
671
+ (StateViewSlice.Spec.name, sv->Component.outputs)
636
672
  })
637
-
638
- automationSlices->Array.forEach((
639
- module(A: AutomationSlice.T),
640
- ) => {
641
- let qn = Api_Naming.queryFieldNamesForSliceQueryDb(~plugin=name, ~queryDbName=A.queryDbName)
642
- Plugin_Helpers.queryFieldNamesRegistry->Dict.set(A.queryDbName, qn)
643
- })
644
-
645
- outboundTranslationSlices->Array.forEach((
646
- module(O: OutboundTranslationSlice.T),
647
- ) => {
648
- let qn = Api_Naming.queryFieldNamesForSliceQueryDb(~plugin=name, ~queryDbName=O.queryDbName)
649
- Plugin_Helpers.queryFieldNamesRegistry->Dict.set(O.queryDbName, qn)
673
+ ->Dict.fromArray
674
+
675
+ // Build the full topic dict: aggregate event topics + this plugin's
676
+ // own DCB event topic (keyed by `<pluginName>DcbEventLog` per Plan 03
677
+ // convention). AutomationSlice mappings reference topics by these keys.
678
+ let allEventTopics = aggregateEventTopics->Dict.copy
679
+ allEventTopics->Dict.set(name ++ "DcbEventLog", (dcbEventLog->Component.outputs).eventTopic)
680
+
681
+ // Create AutomationSlices
682
+ let automationSlicesOutputs =
683
+ automationSlices
684
+ ->Array.map((module(AutoSlice: AutomationSlice.T)) => {
685
+ let context: Reventless.AutomationSlice.context = {
686
+ environment,
687
+ platformName,
688
+ pluginName: name,
689
+ sliceName: AutoSlice.Spec.name,
690
+ }
691
+ let as_ = AutoSlice.make(
692
+ ~allEventTopics,
693
+ ~publishJsons,
694
+ ~context,
695
+ ~runtime=?componentRuntime->Dict.get(AutoSlice.Spec.name),
696
+ ~opts,
697
+ )
698
+ (AutoSlice.Spec.name, as_->Component.outputs)
650
699
  })
651
-
652
- inboundTranslationSlices->Array.forEach((
653
- module(I: InboundTranslationSlice.T),
654
- ) => {
655
- let qn = Api_Naming.queryFieldNamesForSliceQueryDb(~plugin=name, ~queryDbName=I.queryDbName)
656
- Plugin_Helpers.queryFieldNamesRegistry->Dict.set(I.queryDbName, qn)
700
+ ->Dict.fromArray
701
+
702
+ // Create OutboundTranslationSlices
703
+ let outboundTranslationSlicesOutputs =
704
+ outboundTranslationSlices
705
+ ->Array.map((module(OTS: OutboundTranslationSlice.T)) => {
706
+ // Where this slice's inbound command goes. `targetName` naming an
707
+ // Aggregate routes to that aggregate's CommandTopic; anything else —
708
+ // a DCB slice, or no target at all — keeps the plugin's DCB topic,
709
+ // which is what every slice wanted before aggregates were reachable.
710
+ let targetPublishJsons =
711
+ OTS.Spec.targetName
712
+ ->Option.flatMap(target => publishToAggregates->Dict.get(target))
713
+ ->Option.getOr(publishJsons)
714
+
715
+ let ots = OTS.make(
716
+ ~dcbEventLog,
717
+ // Same dict the AutomationSlices get, so an outbound slice can
718
+ // name an Aggregate source by its Spec.name.
719
+ ~allEventTopics,
720
+ ~publishJsons=targetPublishJsons,
721
+ ~runtime=?componentRuntime->Dict.get(OTS.Spec.name),
722
+ ~opts,
723
+ )
724
+ (OTS.Spec.name, ots->Component.outputs)
657
725
  })
726
+ ->Dict.fromArray
727
+
728
+ // Create InboundTranslationSlices
729
+ let inboundTranslationSliceData =
730
+ inboundTranslationSlices->Array.map((module(ITS: InboundTranslationSlice.T)) => {
731
+ let its = ITS.make(
732
+ ~publishJsons,
733
+ ~runtime=?componentRuntime->Dict.get(ITS.Spec.name),
734
+ ~opts,
735
+ )
736
+ let fieldName = Api_Naming.sliceMutationField(~plugin=name, ~slice=ITS.Spec.name)
658
737
 
659
- // Create StateViewSlices
660
- let stateViewSlicesOutputs =
661
- stateViewSlices
662
- ->Array.map((module(StateViewSlice: StateViewSlice.T)) => {
663
- let sv = StateViewSlice.make(
664
- ~dcbEventLog,
665
- ~runtime=?componentRuntime->Dict.get(StateViewSlice.Spec.name),
666
- ~opts,
667
- )
668
- (StateViewSlice.Spec.name, sv->Component.outputs)
669
- })
670
- ->Dict.fromArray
671
-
672
- // Build the full topic dict: aggregate event topics + this plugin's
673
- // own DCB event topic (keyed by `<pluginName>DcbEventLog` per Plan 03
674
- // convention). AutomationSlice mappings reference topics by these keys.
675
- let allEventTopics = aggregateEventTopics->Dict.copy
676
- allEventTopics->Dict.set(
677
- name ++ "DcbEventLog",
678
- (dcbEventLog->Component.outputs).eventTopic,
679
- )
680
-
681
- // Create AutomationSlices
682
- let automationSlicesOutputs =
683
- automationSlices
684
- ->Array.map((module(AutoSlice: AutomationSlice.T)) => {
685
- let context: Reventless.AutomationSlice.context = {
686
- environment,
687
- platformName,
688
- pluginName: name,
689
- sliceName: AutoSlice.Spec.name,
690
- }
691
- let as_ = AutoSlice.make(
692
- ~allEventTopics,
693
- ~publishJsons,
694
- ~context,
695
- ~runtime=?componentRuntime->Dict.get(AutoSlice.Spec.name),
696
- ~opts,
697
- )
698
- (AutoSlice.Spec.name, as_->Component.outputs)
699
- })
700
- ->Dict.fromArray
701
-
702
- // Create OutboundTranslationSlices
703
- let outboundTranslationSlicesOutputs =
704
- outboundTranslationSlices
705
- ->Array.map((module(OTS: OutboundTranslationSlice.T)) => {
706
- // Where this slice's inbound command goes. `targetName` naming an
707
- // Aggregate routes to that aggregate's CommandTopic; anything else —
708
- // a DCB slice, or no target at all — keeps the plugin's DCB topic,
709
- // which is what every slice wanted before aggregates were reachable.
710
- let targetPublishJsons =
711
- OTS.Spec.targetName
712
- ->Option.flatMap(target => publishToAggregates->Dict.get(target))
713
- ->Option.getOr(publishJsons)
714
-
715
- let ots = OTS.make(
716
- ~dcbEventLog,
717
- // Same dict the AutomationSlices get, so an outbound slice can
718
- // name an Aggregate source by its Spec.name.
719
- ~allEventTopics,
720
- ~publishJsons=targetPublishJsons,
721
- ~runtime=?componentRuntime->Dict.get(OTS.Spec.name),
722
- ~opts,
738
+ switch HooksConfig.hooks.inboundMutationResolverHook {
739
+ | Some(registerResolver) =>
740
+ registerResolver(
741
+ ~fieldName,
742
+ ~externalInputSchema=ITS.Spec.externalInputSchema->S.castToUnknown,
723
743
  )
724
- (OTS.Spec.name, ots->Component.outputs)
725
- })
726
- ->Dict.fromArray
727
-
728
- // Create InboundTranslationSlices
729
- let inboundTranslationSliceData =
730
- inboundTranslationSlices->Array.map((
731
- module(ITS: InboundTranslationSlice.T),
732
- ) => {
733
- let its = ITS.make(
734
- ~publishJsons,
735
- ~runtime=?componentRuntime->Dict.get(ITS.Spec.name),
736
- ~opts,
737
- )
738
- let fieldName = Api_Naming.sliceMutationField(~plugin=name, ~slice=ITS.Spec.name)
744
+ | None => ()
745
+ }
739
746
 
740
- switch HooksConfig.hooks.inboundMutationResolverHook {
741
- | Some(registerResolver) =>
742
- registerResolver(
743
- ~fieldName,
744
- ~externalInputSchema=ITS.Spec.externalInputSchema->S.castToUnknown,
745
- )
746
- | None => ()
747
- }
747
+ switch HooksConfig.hooks.inboundMutationBindReceiveHook {
748
+ | Some(bindReceive) =>
749
+ let _ =
750
+ its
751
+ ->Component.operations
752
+ ->Pulumi.Output.apply(ops => bindReceive(~fieldName, ~receive=ops.receive))
753
+ | None => ()
754
+ }
748
755
 
749
- switch HooksConfig.hooks.inboundMutationBindReceiveHook {
750
- | Some(bindReceive) =>
751
- let _ =
752
- its
753
- ->Component.operations
754
- ->Pulumi.Output.apply(ops => bindReceive(~fieldName, ~receive=ops.receive))
755
- | None => ()
756
- }
756
+ (ITS.Spec.name, fieldName, its, ITS.Spec.externalInputSchema->S.castToUnknown)
757
+ })
757
758
 
758
- (ITS.Spec.name, fieldName, its, ITS.Spec.externalInputSchema->S.castToUnknown)
759
- })
759
+ let inboundTranslationSlicesOutputs =
760
+ inboundTranslationSliceData
761
+ ->Array.map(((specName, _, its, _)) => (specName, its->Component.outputs))
762
+ ->Dict.fromArray
760
763
 
761
- let inboundTranslationSlicesOutputs =
762
- inboundTranslationSliceData
763
- ->Array.map(((specName, _, its, _)) => (specName, its->Component.outputs))
764
- ->Dict.fromArray
764
+ // Collect InboundTranslationSlice receive functions for composite handler routing
765
+ let inboundReceiversOutput =
766
+ inboundTranslationSliceData
767
+ ->Array.map(((_, fieldName, its, _)) =>
768
+ its
769
+ ->Component.operations
770
+ ->Pulumi.Output.apply(ops => (fieldName, ops.receive))
771
+ )
772
+ ->Pulumi.Output.all
773
+ ->Pulumi.Output.apply(pairs => pairs->Dict.fromArray)
774
+
775
+ // Composite handler: SQS commands (StateChangeSlice) + direct invocations (InboundTranslation)
776
+ // + AppSync direct invocations (CommandGenerator.payload format)
777
+ let dcbHandlerBase = DcbCommandTopic.makeFilteringHandler(dcbCommandTopic)
778
+
779
+ // generateCommand for AppSync direct invocations, one per command constructor.
780
+ //
781
+ // Every one of them shares this CommandTopic's publishJsons — the reason a
782
+ // single function used to suffice — and they differ in exactly one argument:
783
+ // the schema. That argument is not only a validator. `makeGenerateCommand`
784
+ // also reads it to find the fields a command marks as its owner, and stamps
785
+ // them with the caller. Handing it the permissive `S.json` (as this did, on
786
+ // the still-true grounds that AppSync has already validated against the SDL)
787
+ // makes that lookup answer "no owner fields" for every command there is — so
788
+ // the write publishes whatever owner the CLIENT sent, silently, and the row
789
+ // is wrong from then on.
790
+ //
791
+ // Per-slice decoding still happens inside `buildSliceHandler`; nothing here
792
+ // re-validates. The schema travels for what it says about ownership.
793
+ let dcbCommandSchemasByTag = {
794
+ let byTag = Dict.make()
795
+ stateChangeSlices->Array.forEach((module(S: StateChangeSlice.T)) => {
796
+ let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
797
+ if !ApiNoApiHelpers.isNoApi(commandSchema) {
798
+ Api_Naming.sliceMutationFields(
799
+ ~plugin=apiNamePrefix,
800
+ ~slice=S.Spec.name,
801
+ ~commandSchema,
802
+ )->Array.forEach(((_, tag)) => {
803
+ // The resolver injects the TAG alone, so two slices claiming one
804
+ // constructor name would make the stamp ambiguous — and the wrong
805
+ // arm of that ambiguity writes a row owned by nobody's decision.
806
+ // Multi-command slices cannot reach here (their mutation fields
807
+ // collide first), which leaves single-command slices, where the
808
+ // field names differ and nothing else would notice.
809
+ switch byTag->Dict.get(tag) {
810
+ | Some(_) =>
811
+ log.error(
812
+ ~comp="Dcb_Builder",
813
+ `Two DCB slices in "${name}" both declare the command "${tag}". ` ++
814
+ "Owner stamping resolves a command by that name alone, so it cannot " ++ "tell them apart. Rename one of the constructors.",
815
+ )
816
+ | None => byTag->Dict.set(tag, commandSchema)
817
+ }
818
+ })
819
+ }
820
+ })
821
+ byTag
822
+ }
765
823
 
766
- // Collect InboundTranslationSlice receive functions for composite handler routing
767
- let inboundReceiversOutput =
768
- inboundTranslationSliceData
769
- ->Array.map(((_, fieldName, its, _)) =>
770
- its
771
- ->Component.operations
772
- ->Pulumi.Output.apply(ops => (fieldName, ops.receive))
773
- )
774
- ->Pulumi.Output.all
775
- ->Pulumi.Output.apply(pairs => pairs->Dict.fromArray)
776
-
777
- // Composite handler: SQS commands (StateChangeSlice) + direct invocations (InboundTranslation)
778
- // + AppSync direct invocations (CommandGenerator.payload format)
779
- let dcbHandlerBase = DcbCommandTopic.makeFilteringHandler(dcbCommandTopic)
780
-
781
- // generateCommand for AppSync direct invocations, one per command constructor.
782
- //
783
- // Every one of them shares this CommandTopic's publishJsons — the reason a
784
- // single function used to suffice — and they differ in exactly one argument:
785
- // the schema. That argument is not only a validator. `makeGenerateCommand`
786
- // also reads it to find the fields a command marks as its owner, and stamps
787
- // them with the caller. Handing it the permissive `S.json` (as this did, on
788
- // the still-true grounds that AppSync has already validated against the SDL)
789
- // makes that lookup answer "no owner fields" for every command there is — so
790
- // the write publishes whatever owner the CLIENT sent, silently, and the row
791
- // is wrong from then on.
792
- //
793
- // Per-slice decoding still happens inside `buildSliceHandler`; nothing here
794
- // re-validates. The schema travels for what it says about ownership.
795
- let dcbCommandSchemasByTag = {
824
+ let dcbGenerateCommandOutput =
825
+ dcbCommandTopic
826
+ ->Component.operations
827
+ ->Pulumi.Output.apply(ops => {
828
+ let make = commandSchema =>
829
+ CommandGenerator_Callback.makeGenerateCommand(
830
+ ~publishJsons=ops.publishJsons,
831
+ ~publishJsonsAndWait=?ops.publishJsonsAndWait,
832
+ ~serviceName=name,
833
+ ~commandSchema,
834
+ ~componentKind=CommandGenerator_Callback.StateChangeSlice,
835
+ ~stripIdFromParams=false,
836
+ )
796
837
  let byTag = Dict.make()
797
- stateChangeSlices->Array.forEach((module(S: StateChangeSlice.T)) => {
798
- let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
799
- if !(ApiNoApiHelpers.isNoApi(commandSchema)) {
800
- Api_Naming.sliceMutationFields(
801
- ~plugin=apiNamePrefix,
802
- ~slice=S.Spec.name,
803
- ~commandSchema,
804
- )->Array.forEach(((_, tag)) => {
805
- // The resolver injects the TAG alone, so two slices claiming one
806
- // constructor name would make the stamp ambiguous — and the wrong
807
- // arm of that ambiguity writes a row owned by nobody's decision.
808
- // Multi-command slices cannot reach here (their mutation fields
809
- // collide first), which leaves single-command slices, where the
810
- // field names differ and nothing else would notice.
811
- switch byTag->Dict.get(tag) {
812
- | Some(_) =>
813
- log.error(
838
+ dcbCommandSchemasByTag
839
+ ->Dict.toArray
840
+ ->Array.forEach(((tag, schema)) => byTag->Dict.set(tag, make(schema)))
841
+ // A command no slice claims keeps the permissive generator — what every
842
+ // command got before this change — so nothing that reaches this handler
843
+ // by another route starts failing. It also cannot stamp, which is why
844
+ // the unknown case is logged rather than left to look like a success.
845
+ (byTag, make(S.json->S.castToUnknown))
846
+ })
847
+
848
+ let dcbHandler =
849
+ (dcbHandlerBase, inboundReceiversOutput, dcbGenerateCommandOutput)
850
+ ->Pulumi.Output.all3
851
+ ->Pulumi.Output.apply(((baseHandler, receivers, (generatorsByTag, generateFallback))) => {
852
+ let composite = (event, ctx) => {
853
+ let raw: dict<JSON.t> = event->Obj.magic
854
+ switch raw->Dict.get("__inboundTranslation") {
855
+ | Some(_) =>
856
+ let fieldName =
857
+ raw
858
+ ->Dict.get("fieldName")
859
+ ->Option.flatMap(JSON.Decode.string)
860
+ ->Option.getOr("")
861
+ let args = raw->Dict.get("arguments")->Option.getOr(JSON.Encode.null)
862
+ switch receivers->Dict.get(fieldName) {
863
+ | Some(receiveFn) =>
864
+ Effect.promise(async () => {
865
+ let result = await receiveFn(args)
866
+ let response =
867
+ result
868
+ ->InboundTranslationSlice_Callback.receiveResultToOutcome
869
+ ->CommandTopic.commandOutcomeToJson
870
+ response->Obj.magic
871
+ })
872
+ | None => baseHandler(event, ctx)
873
+ }
874
+ | None =>
875
+ // Check for CommandGenerator.payload format (AppSync direct invocation)
876
+ switch (raw->Dict.get("command"), raw->Dict.get("arguments")) {
877
+ | (Some(JSON.String(_)), Some(_)) =>
878
+ let payload: CommandGenerator.payload = event->Obj.magic
879
+ let generateCommand = switch generatorsByTag->Dict.get(payload.command) {
880
+ | Some(g) => g
881
+ | None =>
882
+ log.warn(
814
883
  ~comp="Dcb_Builder",
815
- `Two DCB slices in "${name}" both declare the command "${tag}". ` ++
816
- "Owner stamping resolves a command by that name alone, so it cannot " ++
817
- "tell them apart. Rename one of the constructors.",
884
+ `No DCB slice in "${name}" declares the command "${payload.command}", ` ++
885
+ "so it is dispatched without an owner stamp. Any field the command " ++ "marks as its owner keeps the value the caller sent.",
818
886
  )
819
- | None => byTag->Dict.set(tag, commandSchema)
820
- }
821
- })
822
- }
823
- })
824
- byTag
825
- }
826
-
827
- let dcbGenerateCommandOutput =
828
- dcbCommandTopic
829
- ->Component.operations
830
- ->Pulumi.Output.apply(ops => {
831
- let make = commandSchema =>
832
- CommandGenerator_Callback.makeGenerateCommand(
833
- ~publishJsons=ops.publishJsons,
834
- ~publishJsonsAndWait=?ops.publishJsonsAndWait,
835
- ~serviceName=name,
836
- ~commandSchema,
837
- ~componentKind=CommandGenerator_Callback.StateChangeSlice,
838
- ~stripIdFromParams=false,
839
- )
840
- let byTag = Dict.make()
841
- dcbCommandSchemasByTag
842
- ->Dict.toArray
843
- ->Array.forEach(((tag, schema)) => byTag->Dict.set(tag, make(schema)))
844
- // A command no slice claims keeps the permissive generator — what every
845
- // command got before this change — so nothing that reaches this handler
846
- // by another route starts failing. It also cannot stamp, which is why
847
- // the unknown case is logged rather than left to look like a success.
848
- (byTag, make(S.json->S.castToUnknown))
849
- })
850
-
851
- let dcbHandler =
852
- (dcbHandlerBase, inboundReceiversOutput, dcbGenerateCommandOutput)
853
- ->Pulumi.Output.all3
854
- ->Pulumi.Output.apply(((baseHandler, receivers, (generatorsByTag, generateFallback))) => {
855
- let composite = (event, ctx) => {
856
- let raw: dict<JSON.t> = event->Obj.magic
857
- switch raw->Dict.get("__inboundTranslation") {
858
- | Some(_) =>
859
- let fieldName =
860
- raw
861
- ->Dict.get("fieldName")
862
- ->Option.flatMap(JSON.Decode.string)
863
- ->Option.getOr("")
864
- let args = raw->Dict.get("arguments")->Option.getOr(JSON.Encode.null)
865
- switch receivers->Dict.get(fieldName) {
866
- | Some(receiveFn) =>
867
- Effect.promise(async () => {
868
- let result = await receiveFn(args)
869
- let response =
870
- result
871
- ->InboundTranslationSlice_Callback.receiveResultToOutcome
872
- ->CommandTopic.commandOutcomeToJson
873
- response->Obj.magic
874
- })
875
- | None => baseHandler(event, ctx)
876
- }
877
- | None =>
878
- // Check for CommandGenerator.payload format (AppSync direct invocation)
879
- switch (raw->Dict.get("command"), raw->Dict.get("arguments")) {
880
- | (Some(JSON.String(_)), Some(_)) =>
881
- let payload: CommandGenerator.payload = event->Obj.magic
882
- let generateCommand = switch generatorsByTag->Dict.get(payload.command) {
883
- | Some(g) => g
884
- | None =>
885
- log.warn(
886
- ~comp="Dcb_Builder",
887
- `No DCB slice in "${name}" declares the command "${payload.command}", ` ++
888
- "so it is dispatched without an owner stamp. Any field the command " ++
889
- "marks as its owner keeps the value the caller sent.",
890
- )
891
- generateFallback
892
- }
893
- (generateCommand(payload)->Effect.map(msgId => msgId->Obj.magic))->Obj.magic
894
- | _ => baseHandler(event, ctx)
887
+ generateFallback
895
888
  }
889
+ generateCommand(payload)->Effect.map(msgId => msgId->Obj.magic)->Obj.magic
890
+ | _ => baseHandler(event, ctx)
896
891
  }
897
892
  }
898
- composite->Obj.magic
899
- })
900
-
901
- // Resources the sync Lambda needs access to. An InboundTranslationSlice
902
- // exposes its audit table under `queryDb.resources` — its top-level
903
- // `resources` is empty — and the sync DCB command Lambda is the Route 0
904
- // target that writes those audit rows. Granting only `outputs.resources`
905
- // left the Lambda role without `PutItem` on the audit table, so every
906
- // audit write failed with AccessDenied (swallowed at the write site,
907
- // surfacing only as a permanently empty audit view). Include the audit
908
- // table's resources so the role can write it.
909
- let dcbResources = Array.concat(
910
- stateChangeSlicesOutputs->Dict.valuesToArray->Array.flatMap(outputs => outputs.resources),
911
- inboundTranslationSlicesOutputs
912
- ->Dict.valuesToArray
913
- ->Array.flatMap(outputs => Array.concat(outputs.resources, outputs.queryDb.resources)),
914
- )
915
-
916
- // Resources the async Lambda needs access to
917
- let asyncDcbResources =
918
- asyncStateChangeSlicesOutputs->Dict.valuesToArray->Array.flatMap(outputs => outputs.resources)
919
-
920
- let inboundFieldNames =
921
- inboundTranslationSliceData->Array.map(((_, fieldName, _, _)) => fieldName)
922
- let inboundSchemas = inboundTranslationSliceData->Array.map(((_, _, _, schema)) => schema)
923
-
924
- // Collect DCB mutation field names + TAGs for AppSync resolver creation
925
- // (excludes @noApi slices — includes both sync and async)
926
- // One (fieldName, TAG) pair per API-exposed command constructor. The AWS
927
- // resolver layer (CommandGeneratorResolvers_AppSync.makeDcb) zips these into
928
- // one resolver per field, each injecting its own constructor TAG into the
929
- // Lambda payload — so a multi-command slice's every command is dispatchable.
930
- let dcbMutationData =
931
- stateChangeSlices->Array.flatMap((
932
- module(S: StateChangeSlice.T),
933
- ) => {
934
- let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
935
- if ApiNoApiHelpers.isNoApi(commandSchema) {
936
- []
937
- } else {
938
- Api_Naming.sliceMutationFields(~plugin=apiNamePrefix, ~slice=S.Spec.name, ~commandSchema)
939
- }
940
- })
941
- let dcbFieldNames = dcbMutationData->Array.map(((f, _)) => f)
942
- let dcbTags = dcbMutationData->Array.map(((_, t)) => t)
943
-
944
- let dcbConnectFn = (~runtime) => {
945
- DcbCommandTopic.connect(~runtime, ~resources=dcbResources, dcbCommandTopic)
946
-
947
- if inboundFieldNames->Array.length > 0 {
948
- switch HooksConfig.hooks.inboundAppSyncResolverHook {
949
- | Some(hook) =>
950
- hook({
951
- runtime: runtime->Obj.magic,
952
- fieldNames: inboundFieldNames,
953
- externalInputSchemas: inboundSchemas,
954
- opts,
955
- })
956
- | None => ()
957
- }
958
893
  }
894
+ composite->Obj.magic
895
+ })
959
896
 
960
- if dcbFieldNames->Array.length > 0 {
961
- switch HooksConfig.hooks.dcbAppSyncResolverHook {
962
- | Some(hook) =>
963
- hook({
964
- runtime: runtime->Obj.magic,
965
- fieldNames: dcbFieldNames,
966
- tags: dcbTags,
967
- onAdminApi,
968
- opts,
969
- })
970
- | None => ()
971
- }
897
+ // Resources the sync Lambda needs access to. An InboundTranslationSlice
898
+ // exposes its audit table under `queryDb.resources` — its top-level
899
+ // `resources` is empty — and the sync DCB command Lambda is the Route 0
900
+ // target that writes those audit rows. Granting only `outputs.resources`
901
+ // left the Lambda role without `PutItem` on the audit table, so every
902
+ // audit write failed with AccessDenied (swallowed at the write site,
903
+ // surfacing only as a permanently empty audit view). Include the audit
904
+ // table's resources so the role can write it.
905
+ let dcbResources = Array.concat(
906
+ stateChangeSlicesOutputs->Dict.valuesToArray->Array.flatMap(outputs => outputs.resources),
907
+ inboundTranslationSlicesOutputs
908
+ ->Dict.valuesToArray
909
+ ->Array.flatMap(outputs => Array.concat(outputs.resources, outputs.queryDb.resources)),
910
+ )
911
+
912
+ // Resources the async Lambda needs access to
913
+ let asyncDcbResources =
914
+ asyncStateChangeSlicesOutputs
915
+ ->Dict.valuesToArray
916
+ ->Array.flatMap(outputs => outputs.resources)
917
+
918
+ let inboundFieldNames =
919
+ inboundTranslationSliceData->Array.map(((_, fieldName, _, _)) => fieldName)
920
+ let inboundSchemas = inboundTranslationSliceData->Array.map(((_, _, _, schema)) => schema)
921
+
922
+ // Collect DCB mutation field names + TAGs for AppSync resolver creation
923
+ // (excludes @noApi slices — includes both sync and async)
924
+ // One (fieldName, TAG) pair per API-exposed command constructor. The AWS
925
+ // resolver layer (CommandGeneratorResolvers_AppSync.makeDcb) zips these into
926
+ // one resolver per field, each injecting its own constructor TAG into the
927
+ // Lambda payload — so a multi-command slice's every command is dispatchable.
928
+ let dcbMutationData = stateChangeSlices->Array.flatMap((module(S: StateChangeSlice.T)) => {
929
+ let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
930
+ if ApiNoApiHelpers.isNoApi(commandSchema) {
931
+ []
932
+ } else {
933
+ Api_Naming.sliceMutationFields(~plugin=apiNamePrefix, ~slice=S.Spec.name, ~commandSchema)
934
+ }
935
+ })
936
+ let dcbFieldNames = dcbMutationData->Array.map(((f, _)) => f)
937
+ let dcbTags = dcbMutationData->Array.map(((_, t)) => t)
938
+
939
+ let dcbConnectFn = (~runtime) => {
940
+ DcbCommandTopic.connect(~runtime, ~resources=dcbResources, dcbCommandTopic)
941
+
942
+ if inboundFieldNames->Array.length > 0 {
943
+ switch HooksConfig.hooks.inboundAppSyncResolverHook {
944
+ | Some(hook) =>
945
+ hook({
946
+ runtime: runtime->Obj.magic,
947
+ fieldNames: inboundFieldNames,
948
+ externalInputSchemas: inboundSchemas,
949
+ opts,
950
+ })
951
+ | None => ()
972
952
  }
973
953
  }
974
954
 
975
- // Notify platform hook that all DCB slices are created (AWS calls finish on bundled builders).
976
- // Pass dcbEventLog so the platform can wait for its operations to resolve before calling finish().
977
- HooksConfig.hooks.onDcbSlicesCreated->Option.forEach(hook =>
978
- hook(dcbEventLog->Obj.magic)
979
- )
955
+ if dcbFieldNames->Array.length > 0 {
956
+ switch HooksConfig.hooks.dcbAppSyncResolverHook {
957
+ | Some(hook) =>
958
+ hook({
959
+ runtime: runtime->Obj.magic,
960
+ fieldNames: dcbFieldNames,
961
+ tags: dcbTags,
962
+ onAdminApi,
963
+ opts,
964
+ })
965
+ | None => ()
966
+ }
967
+ }
968
+ }
980
969
 
981
- // Per-component runtime overrides (plugin.json `runtime`) for the
982
- // StateChangeSlices hosted on each shared DCB command Lambda. Only
983
- // explicit overrides contribute (default 0); the platform folds them
984
- // with the per-flavor commandHandlerConfig floor. Sync slices land on
985
- // `<Plugin>DcbCmdHandler`, async on `<Plugin>DcbAsyncCmdHandler`.
986
- let sliceMemoryFloor = slices =>
987
- slices->Array.reduce(0, (acc, module(M: StateChangeSlice.T)) =>
988
- Math.Int.max(acc, ReventlessInfra.RuntimeHints.resolveMemory(componentRuntime->Dict.get(M.Spec.name), ~default=0))
970
+ // Notify platform hook that all DCB slices are created (AWS calls finish on bundled builders).
971
+ // Pass dcbEventLog so the platform can wait for its operations to resolve before calling finish().
972
+ HooksConfig.hooks.onDcbSlicesCreated->Option.forEach(hook => hook(dcbEventLog->Obj.magic))
973
+
974
+ // Per-component runtime overrides (plugin.json `runtime`) for the
975
+ // StateChangeSlices hosted on each shared DCB command Lambda. Only
976
+ // explicit overrides contribute (default 0); the platform folds them
977
+ // with the per-flavor commandHandlerConfig floor. Sync slices land on
978
+ // `<Plugin>DcbCmdHandler`, async on `<Plugin>DcbAsyncCmdHandler`.
979
+ let sliceMemoryFloor = slices =>
980
+ slices->Array.reduce(0, (acc, module(M: StateChangeSlice.T)) =>
981
+ Math.Int.max(
982
+ acc,
983
+ ReventlessInfra.RuntimeHints.resolveMemory(
984
+ componentRuntime->Dict.get(M.Spec.name),
985
+ ~default=0,
986
+ ),
989
987
  )
990
- let sliceTimeoutFloor = slices =>
991
- slices->Array.reduce(0, (acc, module(M: StateChangeSlice.T)) =>
992
- Math.Int.max(acc, ReventlessInfra.RuntimeHints.resolveTimeout(componentRuntime->Dict.get(M.Spec.name), ~default=0))
988
+ )
989
+ let sliceTimeoutFloor = slices =>
990
+ slices->Array.reduce(0, (acc, module(M: StateChangeSlice.T)) =>
991
+ Math.Int.max(
992
+ acc,
993
+ ReventlessInfra.RuntimeHints.resolveTimeout(
994
+ componentRuntime->Dict.get(M.Spec.name),
995
+ ~default=0,
996
+ ),
993
997
  )
998
+ )
994
999
 
995
- let dcbRuntimeSetup = () => {
996
- dcbCommandTopic->RuntimeBuilder.forDcbCommandTopic(
997
- ~handler=dcbHandler,
998
- ~connect=dcbConnectFn,
999
- ~memorySize=sliceMemoryFloor(syncSlices),
1000
- ~timeout=sliceTimeoutFloor(syncSlices),
1001
- )
1002
- // Set up async CommandTopic Lambda if any async slices are configured
1003
- asyncDcbCommandTopicOpt->Option.forEach(asyncDcbCommandTopic => {
1004
- let asyncDcbHandler = DcbAsyncCommandTopic.makeFilteringHandler(asyncDcbCommandTopic)
1005
- let asyncDcbConnectFn = (~runtime) =>
1006
- DcbAsyncCommandTopic.connect(
1007
- ~runtime,
1008
- ~resources=asyncDcbResources,
1009
- asyncDcbCommandTopic,
1010
- )
1011
- asyncDcbCommandTopic->RuntimeBuilder.forDcbCommandTopic(
1012
- ~handler=asyncDcbHandler->Obj.magic,
1013
- ~connect=asyncDcbConnectFn,
1014
- ~memorySize=sliceMemoryFloor(asyncSlices),
1015
- ~timeout=sliceTimeoutFloor(asyncSlices),
1000
+ let dcbRuntimeSetup = () => {
1001
+ dcbCommandTopic->RuntimeBuilder.forDcbCommandTopic(
1002
+ ~handler=dcbHandler,
1003
+ ~connect=dcbConnectFn,
1004
+ ~memorySize=sliceMemoryFloor(syncSlices),
1005
+ ~timeout=sliceTimeoutFloor(syncSlices),
1006
+ )
1007
+ // Set up async CommandTopic Lambda if any async slices are configured
1008
+ asyncDcbCommandTopicOpt->Option.forEach(asyncDcbCommandTopic => {
1009
+ let asyncDcbHandler = DcbAsyncCommandTopic.makeFilteringHandler(asyncDcbCommandTopic)
1010
+ let asyncDcbConnectFn = (~runtime) =>
1011
+ DcbAsyncCommandTopic.connect(
1012
+ ~runtime,
1013
+ ~resources=asyncDcbResources,
1014
+ asyncDcbCommandTopic,
1016
1015
  )
1017
- })
1018
- }
1016
+ asyncDcbCommandTopic->RuntimeBuilder.forDcbCommandTopic(
1017
+ ~handler=asyncDcbHandler->Obj.magic,
1018
+ ~connect=asyncDcbConnectFn,
1019
+ ~memorySize=sliceMemoryFloor(asyncSlices),
1020
+ ~timeout=sliceTimeoutFloor(asyncSlices),
1021
+ )
1022
+ })
1023
+ }
1019
1024
 
1020
- // DCB-specific API schema entries
1021
- //
1022
- // Stage E2: a DCB StateChangeSlice has a single GraphQL field but its
1023
- // command type may declare multiple constructors with different
1024
- // authorization rules. The Cognito group directive operates at field
1025
- // granularity, so we read the auth for the first constructor (matching
1026
- // the existing dcbTags convention at line 549 above). When all
1027
- // constructors share the file-level default, this is exact; when they
1028
- // differ, resolver-level enforcement still fires inside the per-slice
1029
- // handler (the file-level rule applied here is the least-restrictive
1030
- // bound).
1031
- let permissionForFirstConstructor = (
1032
- ~commandSchema: S.t<unknown>,
1033
- ~commandAuthorization: unknown => Reventless.Authorization.permission,
1034
- ): option<Reventless.Authorization.permission> => {
1035
- let names = Reventless.DcbTag.extractAllVariantNames(commandSchema->Obj.magic)
1036
- switch names->Array.get(0) {
1037
- | None => None
1038
- | Some(first) =>
1039
- let hasPayload =
1040
- Reventless.DcbTag.isVariantPayloadBearing(commandSchema->Obj.magic, first)
1041
- let syntheticCmd: unknown =
1042
- hasPayload ? {"TAG": first}->Obj.magic : first->Obj.magic
1043
- Some(commandAuthorization(syntheticCmd))
1044
- }
1025
+ // DCB-specific API schema entries
1026
+ //
1027
+ // Stage E2: a DCB StateChangeSlice has a single GraphQL field but its
1028
+ // command type may declare multiple constructors with different
1029
+ // authorization rules. The Cognito group directive operates at field
1030
+ // granularity, so we read the auth for the first constructor (matching
1031
+ // the existing dcbTags convention at line 549 above). When all
1032
+ // constructors share the file-level default, this is exact; when they
1033
+ // differ, resolver-level enforcement still fires inside the per-slice
1034
+ // handler (the file-level rule applied here is the least-restrictive
1035
+ // bound).
1036
+ let permissionForFirstConstructor = (
1037
+ ~commandSchema: S.t<unknown>,
1038
+ ~commandAuthorization: unknown => Reventless.Authorization.permission,
1039
+ ): option<Reventless.Authorization.permission> => {
1040
+ let names = Reventless.DcbTag.extractAllVariantNames(commandSchema->Obj.magic)
1041
+ switch names->Array.get(0) {
1042
+ | None => None
1043
+ | Some(first) =>
1044
+ let hasPayload = Reventless.DcbTag.isVariantPayloadBearing(
1045
+ commandSchema->Obj.magic,
1046
+ first,
1047
+ )
1048
+ let syntheticCmd: unknown = hasPayload ? {"TAG": first}->Obj.magic : first->Obj.magic
1049
+ Some(commandAuthorization(syntheticCmd))
1045
1050
  }
1046
- let mutationEntriesFromSlices =
1047
- stateChangeSlices->Array.filterMap((
1048
- module(S: StateChangeSlice.T),
1049
- ) => {
1050
- let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
1051
- let fieldSpecs =
1052
- Api_Naming.sliceMutationFields(~plugin=apiNamePrefix, ~slice=S.Spec.name, ~commandSchema)
1053
- if ApiNoApiHelpers.isNoApi(commandSchema) || fieldSpecs->Array.length == 0 {
1054
- None
1055
- } else {
1056
- let sliceDef =
1057
- pluginStructure->Option.flatMap(s =>
1058
- s.stateChangeSlices->Array.find(d => d.name == S.Spec.name)
1059
- )
1060
- // One SDL field per constructor, each with its own constructor's args and
1061
- // its own per-constructor authorization rule.
1062
- let commandAuthorization: unknown => Reventless.Authorization.permission =
1063
- S.Spec.commandAuthorization->Obj.magic
1064
- let fieldPermissions = Dict.make()
1065
- fieldSpecs->Array.forEach(((fieldName, ctor)) => {
1066
- let hasPayload =
1067
- Reventless.DcbTag.isVariantPayloadBearing(commandSchema->Obj.magic, ctor)
1068
- let syntheticCmd: unknown =
1069
- hasPayload ? {"TAG": ctor}->Obj.magic : ctor->Obj.magic
1070
- fieldPermissions->Dict.set(fieldName, commandAuthorization(syntheticCmd))
1071
- })
1072
- Some({
1073
- ReventlessInfra.Api.fieldNames: fieldSpecs->Array.map(((f, _)) => f),
1074
- commandSchema,
1075
- fieldPermissions,
1076
- // Slice key fields (e.g. `orderId`) are payload args; there is no
1077
- // separate aggregate `id`. A multi-variant slice command is a sury
1078
- // `Union`, so without this the generator would inject `id: ID!`.
1079
- injectIdArg: false,
1080
- systemCallable: systemCallableComponents->Array.includes(S.Spec.name),
1081
- linkedViews: ?sliceDef->Option.map(d => d.linkedViews),
1082
- consistencyRead: ?sliceDef->Option.flatMap(d => d.consistencyRead),
1083
- })
1084
- }
1085
- })
1086
-
1087
- let mutationEntriesFromInboundSlices =
1088
- inboundTranslationSlices->Array.map((
1089
- module(ITS: InboundTranslationSlice.T),
1090
- ) => {
1091
- let fieldName = Api_Naming.sliceMutationField(~plugin=name, ~slice=ITS.Spec.name)
1051
+ }
1052
+ let mutationEntriesFromSlices =
1053
+ stateChangeSlices->Array.filterMap((module(S: StateChangeSlice.T)) => {
1054
+ let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
1055
+ let fieldSpecs = Api_Naming.sliceMutationFields(
1056
+ ~plugin=apiNamePrefix,
1057
+ ~slice=S.Spec.name,
1058
+ ~commandSchema,
1059
+ )
1060
+ if ApiNoApiHelpers.isNoApi(commandSchema) || fieldSpecs->Array.length == 0 {
1061
+ None
1062
+ } else {
1063
+ let sliceDef =
1064
+ pluginStructure->Option.flatMap(s =>
1065
+ s.stateChangeSlices->Array.find(d => d.name == S.Spec.name)
1066
+ )
1067
+ // One SDL field per constructor, each with its own constructor's args and
1068
+ // its own per-constructor authorization rule.
1069
+ let commandAuthorization: unknown => Reventless.Authorization.permission =
1070
+ S.Spec.commandAuthorization->Obj.magic
1092
1071
  let fieldPermissions = Dict.make()
1093
- switch permissionForFirstConstructor(
1094
- ~commandSchema=ITS.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema,
1095
- ~commandAuthorization=ITS.Spec.commandAuthorization->Obj.magic,
1096
- ) {
1097
- | Some(rule) => fieldPermissions->Dict.set(fieldName, rule)
1098
- | None => ()
1099
- }
1100
- {
1101
- ReventlessInfra.Api.fieldNames: [fieldName],
1102
- commandSchema: ITS.Spec.externalInputSchema->S.castToUnknown,
1072
+ fieldSpecs->Array.forEach(((fieldName, ctor)) => {
1073
+ let hasPayload = Reventless.DcbTag.isVariantPayloadBearing(
1074
+ commandSchema->Obj.magic,
1075
+ ctor,
1076
+ )
1077
+ let syntheticCmd: unknown = hasPayload ? {"TAG": ctor}->Obj.magic : ctor->Obj.magic
1078
+ fieldPermissions->Dict.set(fieldName, commandAuthorization(syntheticCmd))
1079
+ })
1080
+ Some({
1081
+ ReventlessInfra.Api.fieldNames: fieldSpecs->Array.map(((f, _)) => f),
1082
+ commandSchema,
1103
1083
  fieldPermissions,
1084
+ // Slice key fields (e.g. `orderId`) are payload args; there is no
1085
+ // separate aggregate `id`. A multi-variant slice command is a sury
1086
+ // `Union`, so without this the generator would inject `id: ID!`.
1104
1087
  injectIdArg: false,
1105
- }
1106
- })
1107
-
1108
- let stateViewEntries = stateViewSlices->Array.map((
1109
- module(V: StateViewSlice.T),
1110
- ) => {
1111
- let qn = Api_Naming.queryFieldNamesForStateView(~plugin=name, ~viewName=V.Spec.name)
1112
- let subIdField = V.Spec.subIdConfig->Option.map(c => c.subIdField)
1113
- let indexes = V.Spec.config.indexes
1114
- let indexQueries = if indexes->Array.length > 0 {Some(indexes)} else {None}
1115
- let resolved = Api_Naming.resolvedFieldsOfConfig(~plugin=name, V.Spec.config)
1116
- let resolvedFields = if resolved->Array.length > 0 {Some(resolved)} else {None}
1117
- {
1118
- ReventlessInfra.Api.singleFieldName: qn.singleFieldName,
1119
- listFieldName: qn.listFieldName,
1120
- returnTypeName: qn.returnTypeName,
1121
- stateSchema: V.Spec.stateSchema->Reventless.DcbTag.toUnknownSchema,
1122
- authorization: None,
1123
- permission: V.Spec.authorization,
1124
- systemCallable: systemCallableComponents->Array.includes(V.Spec.name),
1125
- includeIdParam: qn.includeIdParam,
1126
- connectionSpec: true,
1127
- subIdField: ?subIdField,
1128
- indexQueries: ?indexQueries,
1129
- resolvedFields: ?resolvedFields,
1088
+ systemCallable: systemCallableComponents->Array.includes(S.Spec.name),
1089
+ linkedViews: ?(sliceDef->Option.map(d => d.linkedViews)),
1090
+ consistencyRead: ?(sliceDef->Option.flatMap(d => d.consistencyRead)),
1091
+ })
1130
1092
  }
1131
1093
  })
1132
1094
 
1133
- let automationEntries = automationSlices->Array.map((
1134
- module(A: AutomationSlice.T),
1135
- ) => {
1136
- let qn = Api_Naming.queryFieldNamesForSliceQueryDb(~plugin=name, ~queryDbName=A.queryDbName)
1095
+ let mutationEntriesFromInboundSlices =
1096
+ inboundTranslationSlices->Array.map((module(ITS: InboundTranslationSlice.T)) => {
1097
+ let fieldName = Api_Naming.sliceMutationField(~plugin=name, ~slice=ITS.Spec.name)
1098
+ let fieldPermissions = Dict.make()
1099
+ switch permissionForFirstConstructor(
1100
+ ~commandSchema=ITS.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema,
1101
+ ~commandAuthorization=ITS.Spec.commandAuthorization->Obj.magic,
1102
+ ) {
1103
+ | Some(rule) => fieldPermissions->Dict.set(fieldName, rule)
1104
+ | None => ()
1105
+ }
1137
1106
  {
1138
- ReventlessInfra.Api.singleFieldName: qn.singleFieldName,
1139
- listFieldName: qn.listFieldName,
1140
- returnTypeName: qn.returnTypeName,
1141
- stateSchema: AutomationSlice_Callback.todoRowSchemaFor(
1142
- A.Spec.todoItemSchema,
1143
- )->S.castToUnknown,
1144
- authorization: None,
1145
- connectionSpec: true,
1107
+ ReventlessInfra.Api.fieldNames: [fieldName],
1108
+ commandSchema: ITS.Spec.externalInputSchema->S.castToUnknown,
1109
+ fieldPermissions,
1110
+ injectIdArg: false,
1146
1111
  }
1147
1112
  })
1148
1113
 
1149
- let outboundEntries = outboundTranslationSlices->Array.map((
1150
- module(O: OutboundTranslationSlice.T),
1151
- ) => {
1152
- let qn = Api_Naming.queryFieldNamesForSliceQueryDb(~plugin=name, ~queryDbName=O.queryDbName)
1114
+ let stateViewEntries = stateViewSlices->Array.map((module(V: StateViewSlice.T)) => {
1115
+ let qn = Api_Naming.queryFieldNamesForStateView(~plugin=name, ~viewName=V.Spec.name)
1116
+ let subIdField = V.Spec.subIdConfig->Option.map(c => c.subIdField)
1117
+ let indexes = V.Spec.config.indexes
1118
+ let indexQueries = if indexes->Array.length > 0 {
1119
+ Some(indexes)
1120
+ } else {
1121
+ None
1122
+ }
1123
+ let resolved = Api_Naming.resolvedFieldsOfConfig(~plugin=name, V.Spec.config)
1124
+ let resolvedFields = if resolved->Array.length > 0 {
1125
+ Some(resolved)
1126
+ } else {
1127
+ None
1128
+ }
1129
+ {
1130
+ ReventlessInfra.Api.singleFieldName: qn.singleFieldName,
1131
+ listFieldName: qn.listFieldName,
1132
+ returnTypeName: qn.returnTypeName,
1133
+ stateSchema: V.Spec.stateSchema->Reventless.DcbTag.toUnknownSchema,
1134
+ authorization: None,
1135
+ permission: V.Spec.authorization,
1136
+ systemCallable: systemCallableComponents->Array.includes(V.Spec.name),
1137
+ includeIdParam: qn.includeIdParam,
1138
+ connectionSpec: true,
1139
+ ?subIdField,
1140
+ ?indexQueries,
1141
+ ?resolvedFields,
1142
+ }
1143
+ })
1144
+
1145
+ let automationEntries = automationSlices->Array.map((module(A: AutomationSlice.T)) => {
1146
+ let qn = Api_Naming.queryFieldNamesForSliceQueryDb(~plugin=name, ~queryDbName=A.queryDbName)
1147
+ {
1148
+ ReventlessInfra.Api.singleFieldName: qn.singleFieldName,
1149
+ listFieldName: qn.listFieldName,
1150
+ returnTypeName: qn.returnTypeName,
1151
+ stateSchema: AutomationSlice_Callback.todoRowSchemaFor(
1152
+ A.Spec.todoItemSchema,
1153
+ )->S.castToUnknown,
1154
+ authorization: None,
1155
+ connectionSpec: true,
1156
+ }
1157
+ })
1158
+
1159
+ let outboundEntries =
1160
+ outboundTranslationSlices->Array.map((module(O: OutboundTranslationSlice.T)) => {
1161
+ let qn = Api_Naming.queryFieldNamesForSliceQueryDb(
1162
+ ~plugin=name,
1163
+ ~queryDbName=O.queryDbName,
1164
+ )
1153
1165
  {
1154
1166
  ReventlessInfra.Api.singleFieldName: qn.singleFieldName,
1155
1167
  listFieldName: qn.listFieldName,
@@ -1162,10 +1174,12 @@ module Make = (
1162
1174
  }
1163
1175
  })
1164
1176
 
1165
- let inboundEntries = inboundTranslationSlices->Array.map((
1166
- module(I: InboundTranslationSlice.T),
1167
- ) => {
1168
- let qn = Api_Naming.queryFieldNamesForSliceQueryDb(~plugin=name, ~queryDbName=I.queryDbName)
1177
+ let inboundEntries =
1178
+ inboundTranslationSlices->Array.map((module(I: InboundTranslationSlice.T)) => {
1179
+ let qn = Api_Naming.queryFieldNamesForSliceQueryDb(
1180
+ ~plugin=name,
1181
+ ~queryDbName=I.queryDbName,
1182
+ )
1169
1183
  {
1170
1184
  ReventlessInfra.Api.singleFieldName: qn.singleFieldName,
1171
1185
  listFieldName: qn.listFieldName,
@@ -1176,76 +1190,76 @@ module Make = (
1176
1190
  }
1177
1191
  })
1178
1192
 
1179
- // Every event this plugin's slices produce lands in the one DCB event log,
1180
- // so the log's schema entry is the union of all of them. Taking the first
1181
- // slice's schema as representative left the readers of that entry — the
1182
- // upload claimer's `CLAIM_REF_FIELDS`, MCP's event-history resource —
1183
- // describing one slice and silently blind to the rest.
1184
- let allProducedSchemas =
1185
- stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) =>
1186
- Sc.Spec.eventSchema->S.castToUnknown
1187
- )
1193
+ // Every event this plugin's slices produce lands in the one DCB event log,
1194
+ // so the log's schema entry is the union of all of them. Taking the first
1195
+ // slice's schema as representative left the readers of that entry — the
1196
+ // upload claimer's `CLAIM_REF_FIELDS`, MCP's event-history resource —
1197
+ // describing one slice and silently blind to the rest.
1198
+ let allProducedSchemas =
1199
+ stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) =>
1200
+ Sc.Spec.eventSchema->S.castToUnknown
1201
+ )
1188
1202
 
1189
- // Derive the DCB command topic's queue URL *after* the component's
1190
- // construct has stored its real resources. This reads `Component.outputs`
1191
- // as a synchronous side channel inside a callback gated on a DIFFERENT
1192
- // output (`Component.operations`, which resolves only post-construct) —
1193
- // reading `resources[0].id` at build time instead captured the SQS
1194
- // Queue's not-yet-resolved `.id`, which degenerated to the
1195
- // `{BS_PRIVATE_NESTED_SOME_NONE: 0}` nested-option sentinel and silently
1196
- // broke the Plugin EventCollector's sqs:SendMessage grant (cross-plugin
1197
- // extension → DCB-slice publishes → IAM AccessDenied).
1198
- //
1199
- // CONSUMER CONSTRAINT: because the value comes from that gated
1200
- // side-channel read (not from the `operations` payload itself), it must
1201
- // be resolved with a direct `.apply`, NOT batched through
1202
- // `Pulumi.Output.all`/`all2` — `all` invokes the callback at a different
1203
- // point in the graph where the side-channel read still yields the
1204
- // sentinel (verified on both preview and up). The EC grant builder
1205
- // therefore resolves it per-target. See
1206
- // docs/analysis/ec-publish-to-aggregates-grant-broken.md.
1207
- let dcbCommandTopicQueueUrl =
1208
- dcbCommandTopic
1209
- ->Component.operations
1210
- ->Pulumi.Output.flatMap(_ops => {
1211
- let outputs: CommandTopic.outputs = dcbCommandTopic->Component.outputs->Obj.magic
1212
- switch outputs.resources->Array.get(0) {
1213
- | Some(r) => r.id
1214
- | None => Pulumi.Output.make("")
1215
- }
1216
- })
1217
- {
1218
- dcbEventLogOutputs: Some(dcbEventLog->Component.outputs),
1219
- stateChangeSlicesOutputs: Dict.fromArray(
1220
- Array.concat(
1221
- stateChangeSlicesOutputs->Dict.toArray,
1222
- asyncStateChangeSlicesOutputs->Dict.toArray,
1223
- ),
1203
+ // Derive the DCB command topic's queue URL *after* the component's
1204
+ // construct has stored its real resources. This reads `Component.outputs`
1205
+ // as a synchronous side channel inside a callback gated on a DIFFERENT
1206
+ // output (`Component.operations`, which resolves only post-construct) —
1207
+ // reading `resources[0].id` at build time instead captured the SQS
1208
+ // Queue's not-yet-resolved `.id`, which degenerated to the
1209
+ // `{BS_PRIVATE_NESTED_SOME_NONE: 0}` nested-option sentinel and silently
1210
+ // broke the Plugin EventCollector's sqs:SendMessage grant (cross-plugin
1211
+ // extension → DCB-slice publishes → IAM AccessDenied).
1212
+ //
1213
+ // CONSUMER CONSTRAINT: because the value comes from that gated
1214
+ // side-channel read (not from the `operations` payload itself), it must
1215
+ // be resolved with a direct `.apply`, NOT batched through
1216
+ // `Pulumi.Output.all`/`all2` — `all` invokes the callback at a different
1217
+ // point in the graph where the side-channel read still yields the
1218
+ // sentinel (verified on both preview and up). The EC grant builder
1219
+ // therefore resolves it per-target. See
1220
+ // docs/analysis/ec-publish-to-aggregates-grant-broken.md.
1221
+ let dcbCommandTopicQueueUrl =
1222
+ dcbCommandTopic
1223
+ ->Component.operations
1224
+ ->Pulumi.Output.flatMap(_ops => {
1225
+ let outputs: CommandTopic.outputs = dcbCommandTopic->Component.outputs->Obj.magic
1226
+ switch outputs.resources->Array.get(0) {
1227
+ | Some(r) => r.id
1228
+ | None => Pulumi.Output.make("")
1229
+ }
1230
+ })
1231
+ {
1232
+ dcbEventLogOutputs: Some(dcbEventLog->Component.outputs),
1233
+ stateChangeSlicesOutputs: Dict.fromArray(
1234
+ Array.concat(
1235
+ stateChangeSlicesOutputs->Dict.toArray,
1236
+ asyncStateChangeSlicesOutputs->Dict.toArray,
1224
1237
  ),
1225
- stateViewSlicesOutputs,
1226
- automationSlicesOutputs,
1227
- outboundTranslationSlicesOutputs,
1228
- inboundTranslationSlicesOutputs,
1229
- dcbRuntimeSetup: Some(dcbRuntimeSetup),
1230
- dcbPublishJsons: Some(publishJsons),
1231
- dcbCommandTopicQueueUrl,
1232
- mutationEntries: Array.concat(mutationEntriesFromSlices, mutationEntriesFromInboundSlices),
1233
- queryEntries: stateViewEntries
1234
- ->Array.concat(automationEntries)
1235
- ->Array.concat(outboundEntries)
1236
- ->Array.concat(inboundEntries),
1237
- eventLogEntries: if allProducedSchemas->Array.length > 0 {
1238
- [
1239
- {
1240
- ReventlessInfra.Api.busKey: name ++ "DcbEventLog",
1241
- displayName: name,
1242
- eventSchema: allProducedSchemas->mergedEventSchema,
1243
- },
1244
- ]
1245
- } else {
1246
- []
1247
- },
1248
- }
1238
+ ),
1239
+ stateViewSlicesOutputs,
1240
+ automationSlicesOutputs,
1241
+ outboundTranslationSlicesOutputs,
1242
+ inboundTranslationSlicesOutputs,
1243
+ dcbRuntimeSetup: Some(dcbRuntimeSetup),
1244
+ dcbPublishJsons: Some(publishJsons),
1245
+ dcbCommandTopicQueueUrl,
1246
+ mutationEntries: Array.concat(mutationEntriesFromSlices, mutationEntriesFromInboundSlices),
1247
+ queryEntries: stateViewEntries
1248
+ ->Array.concat(automationEntries)
1249
+ ->Array.concat(outboundEntries)
1250
+ ->Array.concat(inboundEntries),
1251
+ eventLogEntries: if allProducedSchemas->Array.length > 0 {
1252
+ [
1253
+ {
1254
+ ReventlessInfra.Api.busKey: name ++ "DcbEventLog",
1255
+ displayName: name,
1256
+ eventSchema: allProducedSchemas->mergedEventSchema,
1257
+ },
1258
+ ]
1259
+ } else {
1260
+ []
1261
+ },
1262
+ }
1249
1263
  } else {
1250
1264
  emptyResult
1251
1265
  }