@reventlessdev/reventless-spec 3.0.0-alpha.100

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 (139) hide show
  1. package/CHANGELOG.md +931 -0
  2. package/LICENSE +202 -0
  3. package/README.md +109 -0
  4. package/package.json +49 -0
  5. package/rescript.json +32 -0
  6. package/run-generator.mjs +2 -0
  7. package/run-platform-generator.mjs +2 -0
  8. package/scripts/generate-currency.mjs +215 -0
  9. package/scripts/iso-4217-list-one.xml +1956 -0
  10. package/src/AnsiStyle.res +40 -0
  11. package/src/AnsiStyle.res.mjs +54 -0
  12. package/src/LogPrefix.res +192 -0
  13. package/src/LogPrefix.res.mjs +159 -0
  14. package/src/PackageVersion.res +67 -0
  15. package/src/PackageVersion.res.mjs +81 -0
  16. package/src/components/Aggregate.res +64 -0
  17. package/src/components/Aggregate.res.mjs +2 -0
  18. package/src/components/AutomationSlice.res +279 -0
  19. package/src/components/AutomationSlice.res.mjs +30 -0
  20. package/src/components/CapabilityManifest.res +74 -0
  21. package/src/components/CapabilityManifest.res.mjs +61 -0
  22. package/src/components/ComponentKind.res +99 -0
  23. package/src/components/ComponentKind.res.mjs +125 -0
  24. package/src/components/Counter.res +24 -0
  25. package/src/components/Counter.res.mjs +2 -0
  26. package/src/components/DcbDecode.res +118 -0
  27. package/src/components/DcbDecode.res.mjs +100 -0
  28. package/src/components/DcbScopeInference.res +244 -0
  29. package/src/components/DcbScopeInference.res.mjs +177 -0
  30. package/src/components/DcbTag.res +1335 -0
  31. package/src/components/DcbTag.res.mjs +898 -0
  32. package/src/components/DcbValidation.res +427 -0
  33. package/src/components/DcbValidation.res.mjs +423 -0
  34. package/src/components/DisplayName.res +40 -0
  35. package/src/components/DisplayName.res.mjs +26 -0
  36. package/src/components/ExtensionPoint.res +27 -0
  37. package/src/components/ExtensionPoint.res.mjs +2 -0
  38. package/src/components/InboundTranslationSlice.res +85 -0
  39. package/src/components/InboundTranslationSlice.res.mjs +2 -0
  40. package/src/components/OutboundTranslationSlice.res +153 -0
  41. package/src/components/OutboundTranslationSlice.res.mjs +2 -0
  42. package/src/components/Plugin.res +538 -0
  43. package/src/components/Plugin.res.mjs +264 -0
  44. package/src/components/PluginName.res +39 -0
  45. package/src/components/PluginName.res.mjs +45 -0
  46. package/src/components/ReadModel.res +199 -0
  47. package/src/components/ReadModel.res.mjs +18 -0
  48. package/src/components/Reference.res +55 -0
  49. package/src/components/Reference.res.mjs +50 -0
  50. package/src/components/Snapshot.res +26 -0
  51. package/src/components/Snapshot.res.mjs +2 -0
  52. package/src/components/StateAnnotations.res +97 -0
  53. package/src/components/StateAnnotations.res.mjs +15 -0
  54. package/src/components/StateChangeSlice.res +131 -0
  55. package/src/components/StateChangeSlice.res.mjs +2 -0
  56. package/src/components/StateViewSlice.res +123 -0
  57. package/src/components/StateViewSlice.res.mjs +2 -0
  58. package/src/components/Task.res +62 -0
  59. package/src/components/Task.res.mjs +2 -0
  60. package/src/generator/Codegen.res +842 -0
  61. package/src/generator/Codegen.res.mjs +565 -0
  62. package/src/generator/Config.res +106 -0
  63. package/src/generator/Config.res.mjs +69 -0
  64. package/src/generator/Discovery.res +230 -0
  65. package/src/generator/Discovery.res.mjs +198 -0
  66. package/src/generator/Generator_Node.res +14 -0
  67. package/src/generator/Generator_Node.res.mjs +18 -0
  68. package/src/generator/Pairing.res +460 -0
  69. package/src/generator/Pairing.res.mjs +415 -0
  70. package/src/generator/PlatformCodegen.res +207 -0
  71. package/src/generator/PlatformCodegen.res.mjs +154 -0
  72. package/src/generator/PlatformGenerator.res +126 -0
  73. package/src/generator/PlatformGenerator.res.mjs +114 -0
  74. package/src/generator/PlatformManifests.res +203 -0
  75. package/src/generator/PlatformManifests.res.mjs +212 -0
  76. package/src/generator/PluginGenerator.res +57 -0
  77. package/src/generator/PluginGenerator.res.mjs +73 -0
  78. package/src/semantic/Bytes.res +54 -0
  79. package/src/semantic/Bytes.res.mjs +38 -0
  80. package/src/semantic/Capabilities.res +43 -0
  81. package/src/semantic/Capabilities.res.mjs +17 -0
  82. package/src/semantic/Color.res +51 -0
  83. package/src/semantic/Color.res.mjs +29 -0
  84. package/src/semantic/Currency.res +598 -0
  85. package/src/semantic/Currency.res.mjs +743 -0
  86. package/src/semantic/DateRange.res +148 -0
  87. package/src/semantic/DateRange.res.mjs +74 -0
  88. package/src/semantic/Duration.res +53 -0
  89. package/src/semantic/Duration.res.mjs +26 -0
  90. package/src/semantic/Email.res +51 -0
  91. package/src/semantic/Email.res.mjs +31 -0
  92. package/src/semantic/GeoPoint.res +226 -0
  93. package/src/semantic/GeoPoint.res.mjs +190 -0
  94. package/src/semantic/Geocoding.res +127 -0
  95. package/src/semantic/Geocoding.res.mjs +36 -0
  96. package/src/semantic/Money.res +196 -0
  97. package/src/semantic/Money.res.mjs +138 -0
  98. package/src/semantic/Offload.res +294 -0
  99. package/src/semantic/Offload.res.mjs +191 -0
  100. package/src/semantic/Percent.res +53 -0
  101. package/src/semantic/Percent.res.mjs +33 -0
  102. package/src/semantic/Phone.res +55 -0
  103. package/src/semantic/Phone.res.mjs +29 -0
  104. package/src/semantic/Semantic.res +162 -0
  105. package/src/semantic/Semantic.res.mjs +95 -0
  106. package/src/semantic/StorageRef.res +164 -0
  107. package/src/semantic/StorageRef.res.mjs +111 -0
  108. package/src/semantic/Url.res +66 -0
  109. package/src/semantic/Url.res.mjs +48 -0
  110. package/src/types/Authorization.res +23 -0
  111. package/src/types/Authorization.res.mjs +33 -0
  112. package/src/types/Behavior.res +86 -0
  113. package/src/types/Behavior.res.mjs +2 -0
  114. package/src/types/DateTime.res +29 -0
  115. package/src/types/DateTime.res.mjs +16 -0
  116. package/src/types/EventMapping.res +100 -0
  117. package/src/types/EventMapping.res.mjs +15 -0
  118. package/src/types/Handler.res +30 -0
  119. package/src/types/Handler.res.mjs +2 -0
  120. package/src/types/Id.res +75 -0
  121. package/src/types/Id.res.mjs +37 -0
  122. package/src/types/Identity.res +46 -0
  123. package/src/types/Identity.res.mjs +51 -0
  124. package/src/types/Message.res +326 -0
  125. package/src/types/Message.res.mjs +186 -0
  126. package/src/types/Projection.res +220 -0
  127. package/src/types/Projection.res.mjs +44 -0
  128. package/src/types/QueryEngine.res +123 -0
  129. package/src/types/QueryEngine.res.mjs +12 -0
  130. package/src/types/ReadConsistency.res +38 -0
  131. package/src/types/ReadConsistency.res.mjs +29 -0
  132. package/src/types/Schedule.res +65 -0
  133. package/src/types/Schedule.res.mjs +68 -0
  134. package/src/types/SideEffect.res +45 -0
  135. package/src/types/SideEffect.res.mjs +2 -0
  136. package/src/types/StoredEvent.res +46 -0
  137. package/src/types/StoredEvent.res.mjs +32 -0
  138. package/src/types/Visibility.res +24 -0
  139. package/src/types/Visibility.res.mjs +25 -0
@@ -0,0 +1,842 @@
1
+ // Renders a Pairing.resolved into Plugin.res source text.
2
+
3
+ // Strip suffix from a string if it ends with that suffix.
4
+ let stripSuffix = (s: string, suffix: string): string =>
5
+ if s->String.endsWith(suffix) {
6
+ s->String.slice(~start=0, ~end=s->String.length - suffix->String.length)
7
+ } else {
8
+ s
9
+ }
10
+
11
+ // Derive EP module name from mapping stem: "ProductsExtensionPointMapping" → "ProductsExtensionPoint"
12
+ let epModuleName = (mappingStem: string): string => stripSuffix(mappingStem, "Mapping")
13
+
14
+ // ── Section renderers ────────────────────────────────────────────────────────
15
+
16
+ let renderSlices = (
17
+ ~platformFactory: string,
18
+ ~suffix: string,
19
+ ~implSuffix: string,
20
+ stems: array<string>,
21
+ ): array<string> =>
22
+ stems->Array.map(stem =>
23
+ " module "
24
+ ++ stem
25
+ ++ suffix
26
+ ++ " = Platform."
27
+ ++ platformFactory
28
+ ++ ".Make("
29
+ ++ stem
30
+ ++ ", "
31
+ ++ stem
32
+ ++ implSuffix
33
+ ++ ")"
34
+ )
35
+
36
+ // StateChangeSlices opt into async dispatch via `@@reventless.async` on the
37
+ // spec file; the rendered factory becomes MakeAsync (CommandPending response).
38
+ let renderStateChangeSlices = (
39
+ stems: array<string>,
40
+ asyncStems: Dict.t<bool>,
41
+ ): array<string> =>
42
+ stems->Array.map(stem => {
43
+ let factory = switch Dict.get(asyncStems, stem) {
44
+ | Some(true) => "MakeAsync"
45
+ | _ => "Make"
46
+ }
47
+ " module "
48
+ ++ stem
49
+ ++ "Slice = Platform.StateChangeSlice."
50
+ ++ factory
51
+ ++ "("
52
+ ++ stem
53
+ ++ ", "
54
+ ++ stem
55
+ ++ Pairing.implSuffixForStateChange
56
+ ++ ")"
57
+ })
58
+
59
+ // AutomationSlice — 2-arg form. The merged `_Automation.res` shape exposes
60
+ // both `process` and the `mappings` array, so plugin assembly drops to
61
+ // `Platform.AutomationSlice.Make(<Stem>, <Stem>_Automation)`. Legacy 3-file
62
+ // shapes use a one-line bridge in `_Automation.res` to re-export `mappings`
63
+ // from the sibling `_Mappings.res`.
64
+ let renderAutomationSlices = (stems: array<string>): array<string> =>
65
+ stems->Array.map(stem =>
66
+ " module "
67
+ ++ stem
68
+ ++ "Slice = Platform.AutomationSlice.Make("
69
+ ++ stem
70
+ ++ ", "
71
+ ++ stem
72
+ ++ "_Automation)"
73
+ )
74
+
75
+ let renderAggregates = (aggregates: array<Pairing.aggregateDef>): array<string> =>
76
+ aggregates->Array.flatMap(({spec, behavior, eventMappings, isAsync}) => {
77
+ let em = eventMappings->Option.getOr("ReventlessInfra.NoEventMappings.Make(" ++ spec ++ ")")
78
+ let factory = isAsync ? "MakeAsync" : "Make"
79
+ [
80
+ " module " ++ spec ++ "Aggregate = Platform.Aggregate." ++ factory ++ "(",
81
+ " " ++ spec ++ ",",
82
+ " " ++ behavior ++ ",",
83
+ " " ++ em ++ ",",
84
+ " )",
85
+ ]
86
+ })
87
+
88
+ let renderReadModels = (readModels: array<Pairing.readModelDef>): array<string> =>
89
+ readModels->Array.flatMap(({readModel, projections, stream}) => {
90
+ // The projections file declares `let mappings` via `@@reventless.mappings`,
91
+ // so we reference it directly. The wrapping module name appends `ReadModel`
92
+ // so the LHS doesn't shadow the bare-named spec module (e.g., `Categories`).
93
+ let wrapperName = if readModel->String.endsWith("ReadModel") {
94
+ readModel
95
+ } else {
96
+ readModel ++ "ReadModel"
97
+ }
98
+ // `ReadModelStream/` read models get a DynamoDB-Stream-backed QueryDb so the
99
+ // platform wires a StateTopic Lambda for AppSync Events (Source B) live updates.
100
+ let factory = stream ? "Platform.ReadModelStream.Make" : "Platform.ReadModel.Make"
101
+ [
102
+ " module " ++ wrapperName ++ " = " ++ factory ++ "(" ++ readModel ++ ", " ++ projections ++ ")",
103
+ ]
104
+ })
105
+
106
+ let renderTasks = (tasks: array<string>): array<string> =>
107
+ tasks->Array.map(stem =>
108
+ " module " ++ stem ++ "Task = Platform.Task.Make(" ++ stem ++ ")"
109
+ )
110
+
111
+ let renderExtensionPoints = (extensionPoints: array<Pairing.extensionPointDef>): array<string> =>
112
+ extensionPoints->Array.flatMap(({group, mappings}) => {
113
+ let count = mappings->Array.length
114
+ let firstMapping = mappings->Array.getUnsafe(0)
115
+ let moduleName = switch group {
116
+ | None => epModuleName(firstMapping)
117
+ | Some(g) => g
118
+ }
119
+ switch count {
120
+ | 0 => []
121
+ | 1 =>
122
+ [" module " ++ moduleName ++ " = Platform.ExtensionPoint.Make(" ++ firstMapping ++ ")"]
123
+ | 2 =>
124
+ let m2 = mappings->Array.getUnsafe(1)
125
+ [
126
+ " module " ++ moduleName ++ " = Platform.ExtensionPoint.Make2(",
127
+ " " ++ firstMapping ++ ",",
128
+ " " ++ m2 ++ ",",
129
+ " )",
130
+ ]
131
+ | 3 =>
132
+ let m2 = mappings->Array.getUnsafe(1)
133
+ let m3 = mappings->Array.getUnsafe(2)
134
+ [
135
+ " module " ++ moduleName ++ " = Platform.ExtensionPoint.Make3(",
136
+ " " ++ firstMapping ++ ",",
137
+ " " ++ m2 ++ ",",
138
+ " " ++ m3 ++ ",",
139
+ " )",
140
+ ]
141
+ | _ =>
142
+ // MakeMulti — inline module expression
143
+ let mappingLines =
144
+ mappings->Array.map(m => " module(" ++ m ++ "),")
145
+ Array.flat([
146
+ [
147
+ " module " ++ moduleName ++ " = Platform.ExtensionPoint.MakeMulti({",
148
+ " module Spec = " ++ firstMapping ++ ".ExtensionPoint",
149
+ " module type Mapping = ReventlessInfra.ExtensionPointMapping.T with module ExtensionPoint := Spec",
150
+ " let name = Spec.name",
151
+ " let moduleUrl: string = %raw(`import.meta.url`)",
152
+ " let mappings: array<module(Mapping)> = [",
153
+ ],
154
+ mappingLines,
155
+ [
156
+ " ]",
157
+ " })",
158
+ ],
159
+ ])
160
+ }
161
+ })
162
+
163
+ let renderExtensions = (extensions: array<string>): array<string> =>
164
+ extensions->Array.map(stem =>
165
+ " module " ++ stem ++ " = Platform.Extension.Make(" ++ stem ++ ".Mapping)"
166
+ )
167
+
168
+ // AWS-specific renderers were removed in favour of the canonical ones — the
169
+ // AWS Plugin.res now emits `open <Namespace>` so bare stems resolve, and the
170
+ // AWS Platform.{Aggregate,ReadModel}.Make functors delegate to the
171
+ // `*_Builder_Single` strategy under the hood (see reventless-aws/src/Platform.res).
172
+
173
+ // ── make() call ──────────────────────────────────────────────────────────────
174
+
175
+ let renderMakeParam = (
176
+ ~param: string,
177
+ ~items: array<string>,
178
+ ~moduleSuffix: string,
179
+ ): option<string> =>
180
+ if items->Array.length === 0 {
181
+ None
182
+ } else {
183
+ let entries = items->Array.map(s => "module(" ++ s ++ moduleSuffix ++ ")")
184
+ Some(" ~" ++ param ++ "=[" ++ entries->Array.join(", ") ++ "],")
185
+ }
186
+
187
+ let renderStateViewSlicesMakeParam = (
188
+ slices: array<string>,
189
+ streamSlices: array<string>,
190
+ ): option<string> => {
191
+ let entries = Array.flat([
192
+ slices->Array.map(s => "module(" ++ s ++ "Slice)"),
193
+ streamSlices->Array.map(s => "module(" ++ s ++ "StreamSlice)"),
194
+ ])
195
+ if entries->Array.length === 0 {
196
+ None
197
+ } else {
198
+ Some(" ~stateViewSlices=[" ++ entries->Array.join(", ") ++ "],")
199
+ }
200
+ }
201
+
202
+ let renderReadModelMakeParam = (readModels: array<Pairing.readModelDef>): option<string> =>
203
+ if readModels->Array.length === 0 {
204
+ None
205
+ } else {
206
+ // The wrapping module name in Plugin.res is the legacy `<rm>` (when the
207
+ // spec already ends in `ReadModel`) or `<rm>ReadModel` (post-Phase-3.3,
208
+ // when the spec is the bare plural like `Categories`). Mirrors the LHS
209
+ // chosen by [renderReadModels] above.
210
+ let entries = readModels->Array.map(({readModel}) => {
211
+ let wrapperName = if readModel->String.endsWith("ReadModel") {
212
+ readModel
213
+ } else {
214
+ readModel ++ "ReadModel"
215
+ }
216
+ "module(" ++ wrapperName ++ ")"
217
+ })
218
+ Some(" ~readModels=[" ++ entries->Array.join(", ") ++ "],")
219
+ }
220
+
221
+ let renderAggregateMakeParam = (aggregates: array<Pairing.aggregateDef>): option<string> =>
222
+ if aggregates->Array.length === 0 {
223
+ None
224
+ } else {
225
+ let entries = aggregates->Array.map(({spec}) => "module(" ++ spec ++ "Aggregate)")
226
+ Some(" ~aggregates=[" ++ entries->Array.join(", ") ++ "],")
227
+ }
228
+
229
+ let renderEpMakeParam = (extensionPoints: array<Pairing.extensionPointDef>): option<string> =>
230
+ if extensionPoints->Array.length === 0 {
231
+ None
232
+ } else {
233
+ let entries = extensionPoints->Array.map(({group, mappings}) => {
234
+ let firstMapping = mappings->Array.getUnsafe(0)
235
+ let moduleName = switch group {
236
+ | None => epModuleName(firstMapping)
237
+ | Some(g) => g
238
+ }
239
+ "module(" ++ moduleName ++ ")"
240
+ })
241
+ Some(" ~extensionPoints=[" ++ entries->Array.join(", ") ++ "],")
242
+ }
243
+
244
+ let renderExtensionMakeParam = (extensions: array<string>): option<string> =>
245
+ if extensions->Array.length === 0 {
246
+ None
247
+ } else {
248
+ let entries = extensions->Array.map(s => "module(" ++ s ++ ")")
249
+ Some(" ~extensions=[" ++ entries->Array.join(", ") ++ "],")
250
+ }
251
+
252
+ // ── Per-component runtime hints (plugin.json `runtime` block) ─────────────────
253
+ // Emits `~componentRuntime=Dict.fromArray([("Customers", {memorySize: Some(2048),
254
+ // timeout: None}), …])` into `Platform.Plugin.make(...)`. Only emitted when the
255
+ // plugin declares a `runtime` block, so plugins without one keep a byte-identical
256
+ // generated Plugin.res.
257
+
258
+ let renderRuntimeHints = (h: Config.runtimeHints): string => {
259
+ let optInt = (o: option<int>) =>
260
+ switch o {
261
+ | Some(n) => "Some(" ++ n->Int.toString ++ ")"
262
+ | None => "None"
263
+ }
264
+ // Qualify the first field with the full module path so the record type resolves
265
+ // at the `Dict.fromArray([...])` call site (type-directed disambiguation does not
266
+ // propagate through the tuple/dict, so a bare `{memorySize, …}` has no type in scope).
267
+ "{ReventlessInfra.RuntimeHints.memorySize: " ++
268
+ optInt(h.memorySize) ++
269
+ ", timeout: " ++
270
+ optInt(h.timeout) ++ "}"
271
+ }
272
+
273
+ // Every component name reachable from the resolved plugin — the valid keys for
274
+ // the plugin.json `runtime` block. Mirrors the module names emitted above.
275
+ let collectComponentNames = (resolved: Pairing.resolved): array<string> =>
276
+ Array.flat([
277
+ resolved.aggregates->Array.map(({spec}) => spec),
278
+ resolved.readModels->Array.map(({readModel}) => readModel),
279
+ resolved.stateChangeSlices,
280
+ resolved.stateViewSlices,
281
+ resolved.stateViewSlicesStream,
282
+ resolved.automationSlices,
283
+ resolved.outboundTranslationSlices,
284
+ resolved.inboundTranslationSlices,
285
+ resolved.tasks,
286
+ resolved.extensions,
287
+ resolved.extensionPoints->Array.map(({group, mappings}) =>
288
+ switch group {
289
+ | Some(g) => g
290
+ | None => epModuleName(mappings->Array.getUnsafe(0))
291
+ }
292
+ ),
293
+ ])
294
+
295
+ // Typo protection: a `runtime` key that names no component is a warning, not a
296
+ // silent no-op. The override is still emitted (harmless — the deploy loop simply
297
+ // never looks it up).
298
+ let validateComponentRuntimeKeys = (
299
+ ~resolved: Pairing.resolved,
300
+ ~componentRuntime: dict<Config.runtimeHints>,
301
+ ): unit => {
302
+ let known = collectComponentNames(resolved)
303
+ componentRuntime
304
+ ->Dict.keysToArray
305
+ ->Array.forEach(name =>
306
+ if !(known->Array.includes(name)) {
307
+ Console.warn(
308
+ "Generator: plugin.json `runtime` override for `" ++
309
+ name ++
310
+ "` matches no component in this plugin. Known components: " ++
311
+ known->Array.join(", "),
312
+ )
313
+ }
314
+ )
315
+ }
316
+
317
+ let renderComponentRuntimeParam = (
318
+ componentRuntime: dict<Config.runtimeHints>,
319
+ ): option<string> => {
320
+ let entries = componentRuntime->Dict.toArray
321
+ if entries->Array.length === 0 {
322
+ None
323
+ } else {
324
+ let rendered =
325
+ entries
326
+ ->Array.map(((name, hints)) => "(\"" ++ name ++ "\", " ++ renderRuntimeHints(hints) ++ ")")
327
+ ->Array.join(", ")
328
+ Some(" ~componentRuntime=Dict.fromArray([" ++ rendered ++ "]),")
329
+ }
330
+ }
331
+
332
+ let renderTaskMakeParam = (tasks: array<string>): option<string> =>
333
+ if tasks->Array.length === 0 {
334
+ None
335
+ } else {
336
+ let entries = tasks->Array.map(s => "module(" ++ s ++ "Task)")
337
+ Some(" ~tasks=[" ++ entries->Array.join(", ") ++ "],")
338
+ }
339
+
340
+ let renderPluginStructureCall = (
341
+ ~name: string,
342
+ ~aggregates: array<Pairing.aggregateDef>,
343
+ ~readModels: array<Pairing.readModelDef>,
344
+ ~stateViewSlices: array<string>,
345
+ ~stateViewSlicesStream: array<string>,
346
+ ~stateChangeSlices: array<string>,
347
+ ~automationSlices: array<string>,
348
+ ~outboundTranslationSlices: array<string>,
349
+ ~inboundTranslationSlices: array<string>,
350
+ ~extensions: array<string>,
351
+ ~extensionPoints: array<Pairing.extensionPointDef>,
352
+ ~componentChapters: array<(string, string)>,
353
+ ): option<array<string>> => {
354
+ // The structure call carries the EP *mapping* files (one per Delegate
355
+ // connection), not the wrapped ExtensionPoint module — Plugin_Structure reads
356
+ // each mapping's Delegate to derive the extension point's source event types.
357
+ let epMappingStems = extensionPoints->Array.flatMap(({mappings}) => mappings)
358
+ let hasComponents =
359
+ aggregates->Array.length > 0 ||
360
+ readModels->Array.length > 0 ||
361
+ stateViewSlices->Array.length > 0 ||
362
+ stateViewSlicesStream->Array.length > 0 ||
363
+ stateChangeSlices->Array.length > 0 ||
364
+ automationSlices->Array.length > 0 ||
365
+ outboundTranslationSlices->Array.length > 0 ||
366
+ inboundTranslationSlices->Array.length > 0 ||
367
+ extensions->Array.length > 0 ||
368
+ epMappingStems->Array.length > 0
369
+ if !hasComponents {
370
+ None
371
+ } else {
372
+ let ls: array<string> = []
373
+ ls->Array.push(" let pluginStructure = Platform.Plugin.makePluginDefinition(")
374
+ ls->Array.push(" ~name=\"" ++ name ++ "\",")
375
+ if aggregates->Array.length > 0 {
376
+ let entries = aggregates->Array.map(({spec}) => "module(" ++ spec ++ "Aggregate)")
377
+ ls->Array.push(" ~aggregates=[" ++ entries->Array.join(", ") ++ "],")
378
+ }
379
+ if readModels->Array.length > 0 {
380
+ let entries = readModels->Array.map(({readModel}) => {
381
+ let wrapperName = if readModel->String.endsWith("ReadModel") {
382
+ readModel
383
+ } else {
384
+ readModel ++ "ReadModel"
385
+ }
386
+ "module(" ++ wrapperName ++ ")"
387
+ })
388
+ ls->Array.push(" ~readModels=[" ++ entries->Array.join(", ") ++ "],")
389
+ }
390
+ let allStateViewEntries = Array.flat([
391
+ stateViewSlices->Array.map(s => "module(" ++ s ++ "Slice)"),
392
+ stateViewSlicesStream->Array.map(s => "module(" ++ s ++ "StreamSlice)"),
393
+ ])
394
+ if allStateViewEntries->Array.length > 0 {
395
+ ls->Array.push(" ~stateViewSlices=[" ++ allStateViewEntries->Array.join(", ") ++ "],")
396
+ }
397
+ if stateChangeSlices->Array.length > 0 {
398
+ let entries = stateChangeSlices->Array.map(s => "module(" ++ s ++ "Slice)")
399
+ ls->Array.push(" ~stateChangeSlices=[" ++ entries->Array.join(", ") ++ "],")
400
+ }
401
+ if automationSlices->Array.length > 0 {
402
+ let entries = automationSlices->Array.map(s => "module(" ++ s ++ "Slice)")
403
+ ls->Array.push(" ~automationSlices=[" ++ entries->Array.join(", ") ++ "],")
404
+ }
405
+ if outboundTranslationSlices->Array.length > 0 {
406
+ let entries = outboundTranslationSlices->Array.map(s => "module(" ++ s ++ "Slice)")
407
+ ls->Array.push(" ~outboundTranslationSlices=[" ++ entries->Array.join(", ") ++ "],")
408
+ }
409
+ if inboundTranslationSlices->Array.length > 0 {
410
+ let entries = inboundTranslationSlices->Array.map(s => "module(" ++ s ++ "Slice)")
411
+ ls->Array.push(" ~inboundTranslationSlices=[" ++ entries->Array.join(", ") ++ "],")
412
+ }
413
+ if extensions->Array.length > 0 {
414
+ let entries = extensions->Array.map(s => "module(" ++ s ++ ")")
415
+ ls->Array.push(" ~extensions=[" ++ entries->Array.join(", ") ++ "],")
416
+ }
417
+ if epMappingStems->Array.length > 0 {
418
+ let entries = epMappingStems->Array.map(s => "module(" ++ s ++ ")")
419
+ ls->Array.push(" ~extensionPoints=[" ++ entries->Array.join(", ") ++ "],")
420
+ }
421
+ // Chapter grouping bands per component, captured from the source folder layout.
422
+ // Only emitted when at least one component lives under a chapter folder, so
423
+ // plugins with a flat `src/` keep a byte-identical generated Plugin.res.
424
+ if componentChapters->Array.length > 0 {
425
+ let entries =
426
+ componentChapters->Array.map(((stem, chapter)) =>
427
+ "(\"" ++ stem ++ "\", \"" ++ chapter ++ "\")"
428
+ )
429
+ ls->Array.push(" ~componentChapters=Dict.fromArray([" ++ entries->Array.join(", ") ++ "]),")
430
+ }
431
+ ls->Array.push(" )")
432
+ Some(ls)
433
+ }
434
+ }
435
+
436
+ // ── Duplicate spec-stem lint ─────────────────────────────────────────────────
437
+ // Within one plugin, every spec file's stem produces a top-level ReScript
438
+ // module name. Two spec files with the same stem (across folders) collide at
439
+ // link time with a confusing error. We catch the collision here with a clear
440
+ // message naming both file paths.
441
+
442
+ let validateUniqueSpecStems = (~discovered: array<Discovery.discoveredFile>) => {
443
+ // Each discovered .res file becomes a top-level ReScript module named after
444
+ // its filename stem. Two files with the same stem in different folders
445
+ // collide at link time, so we surface the conflict here with both paths.
446
+ let byStem: Dict.t<array<string>> = Dict.make()
447
+ discovered->Array.forEach((d: Discovery.discoveredFile) => {
448
+ let existing = byStem->Dict.get(d.stem)->Option.getOr([])
449
+ existing->Array.push(d.relPath)
450
+ byStem->Dict.set(d.stem, existing)
451
+ })
452
+ byStem
453
+ ->Dict.toArray
454
+ ->Array.forEach(((stem, paths)) => {
455
+ if paths->Array.length > 1 {
456
+ let pathList = paths->Array.toSorted((a, b) => if a < b {-1.0} else if a > b {1.0} else {0.0})->Array.join("\n - ")
457
+ JsError.throwWithMessage(
458
+ "Generator: stem `" ++
459
+ stem ++
460
+ "` is used by multiple files. Filename stems must be unique across a plugin so the generated module names don't collide:\n - " ++
461
+ pathList,
462
+ )
463
+ }
464
+ })
465
+ }
466
+
467
+ // ── targetName validation ────────────────────────────────────────────────────
468
+
469
+ let validateSliceTargets = (~resolved: Pairing.resolved) => {
470
+ let knownReceivers = Array.concat(
471
+ resolved.aggregates->Array.map(({spec}) => spec),
472
+ resolved.stateChangeSlices,
473
+ )
474
+
475
+ let check = (~kind: string, stem: string, target: option<string>) =>
476
+ switch target {
477
+ | None =>
478
+ JsError.throwWithMessage(
479
+ "Generator: " ++
480
+ kind ++
481
+ " `" ++
482
+ stem ++
483
+ "` is missing `let targetName = \"...\"`. Valid targets: " ++
484
+ knownReceivers->Array.join(", "),
485
+ )
486
+ | Some(t) when !(knownReceivers->Array.includes(t)) =>
487
+ JsError.throwWithMessage(
488
+ "Generator: " ++
489
+ kind ++
490
+ " `" ++
491
+ stem ++
492
+ "` declares targetName = \"" ++
493
+ t ++
494
+ "\" but no such command receiver exists. Valid targets: " ++
495
+ knownReceivers->Array.join(", "),
496
+ )
497
+ | Some(_) => ()
498
+ }
499
+
500
+ resolved.automationSlices->Array.forEach(stem =>
501
+ check(
502
+ ~kind="AutomationSlice",
503
+ stem,
504
+ resolved.automationSliceTargets->Dict.get(stem)->Option.getOr(None),
505
+ )
506
+ )
507
+
508
+ resolved.inboundTranslationSlices->Array.forEach(stem =>
509
+ check(
510
+ ~kind="InboundTranslationSlice",
511
+ stem,
512
+ resolved.inboundTranslationSliceTargets->Dict.get(stem)->Option.getOr(None),
513
+ )
514
+ )
515
+
516
+ // OutboundTranslationSlice: only validate when targetName = Some(name) (None = fire-and-forget)
517
+ resolved.outboundTranslationSlices->Array.forEach(stem => {
518
+ switch resolved.outboundTranslationSliceTargets->Dict.get(stem)->Option.getOr(None) {
519
+ | Some(t) when !(knownReceivers->Array.includes(t)) =>
520
+ JsError.throwWithMessage(
521
+ "Generator: OutboundTranslationSlice `" ++
522
+ stem ++
523
+ "` declares targetName = Some(\"" ++
524
+ t ++
525
+ "\") but no such command receiver exists. Valid targets: " ++
526
+ knownReceivers->Array.join(", "),
527
+ )
528
+ | _ => ()
529
+ }
530
+ })
531
+ }
532
+
533
+ // ── Main.res render (AWS mode only) ─────────────────────────────────────────
534
+
535
+ let renderMain = (~config: Config.config): string => {
536
+ let name = config.name
537
+ [
538
+ "// AUTO-GENERATED — do not edit. Run `npm run generate` to update.",
539
+ "// " ++ name ++ " plugin — AWS deployment.",
540
+ "",
541
+ // Deploy-time bootstrap seam (no-op unless a package registers a
542
+ // contribution). PreDeploy runs before the platform/plugin graph builds.
543
+ "ReventlessInfra.DeployBootstrap.run(PreDeploy)",
544
+ "",
545
+ "module Platform = ReventlessAws.Platform.Make()",
546
+ "module " ++ name ++ " = Plugin.Make(Platform)",
547
+ "",
548
+ "let default = Platform.deployPlugin(",
549
+ " ~plugin=module(" ++ name ++ "),",
550
+ ")",
551
+ "",
552
+ // PostDeploy runs after the graph is registered (exports, cross-stack output).
553
+ "ReventlessInfra.DeployBootstrap.run(PostDeploy)",
554
+ "",
555
+ ]->Array.join("\n")
556
+ }
557
+
558
+ // ── Aws-variant render ───────────────────────────────────────────────────────
559
+ // The AWS Plugin.res delegates to the source plugin's `Plugin.Make` functor;
560
+ // all slice/aggregate/readmodel declarations and the federation override
561
+ // env-var read live in the Composition variant. The wrapper just retypes the
562
+ // Platform constraint and re-exports `make` for the AWS deploy path.
563
+
564
+ // Convert a PascalCase plugin name to SCREAMING_SNAKE_CASE for env var naming.
565
+ // "Ordering" → "ORDERING"; "OnlineShop" → "ONLINE_SHOP".
566
+ let pluginNameToEnvBase = (name: string): string =>
567
+ name
568
+ ->String.split("")
569
+ ->Array.mapWithIndex((ch, i) => {
570
+ let isUpper = ch !== "" && ch === ch->String.toUpperCase && ch !== ch->String.toLowerCase
571
+ i > 0 && isUpper ? "_" ++ ch : ch
572
+ })
573
+ ->Array.join("")
574
+ ->String.toUpperCase
575
+
576
+ let renderAwsWrapper = (~compositionNamespace: string): string => {
577
+ [
578
+ "// AUTO-GENERATED — do not edit. Run `npm run generate` to update.",
579
+ "",
580
+ "module Make = (",
581
+ " Platform: ReventlessInfra.Platform.T",
582
+ " with type api = ReventlessAws.Types.AppSync.api",
583
+ " and type role = ReventlessAws.Types.AppSync.role,",
584
+ ") => {",
585
+ " module Composition = " ++ compositionNamespace ++ ".Plugin.Make(Platform)",
586
+ " let make = () => Composition.make()",
587
+ "}",
588
+ "",
589
+ ]->Array.join("\n")
590
+ }
591
+
592
+ // ── Composition-variant render ───────────────────────────────────────────────
593
+
594
+ let renderComposition = (
595
+ ~config: Config.config,
596
+ ~resolved: Pairing.resolved,
597
+ ~componentChapters: array<(string, string)>,
598
+ ): string => {
599
+ let lines: array<string> = []
600
+
601
+ // Header
602
+ lines->Array.push("// AUTO-GENERATED — do not edit. Run `npm run generate` to update.")
603
+ // Federation override URL — empty/unset means Auto UI renders every fragment.
604
+ // Read once at module init; same env var is honoured by the AWS wrapper.
605
+ lines->Array.push("")
606
+ lines->Array.push(
607
+ "@val external uiBundleUrl: option<string> = \"process.env." ++
608
+ pluginNameToEnvBase(config.name) ++
609
+ "_UI_BUNDLE_URL\"",
610
+ )
611
+ lines->Array.push("")
612
+ lines->Array.push("module Make = (Platform: ReventlessInfra.Platform.T) => {")
613
+
614
+ let push = (sectionLines: array<string>) => {
615
+ sectionLines->Array.forEach(l => lines->Array.push(l))
616
+ lines->Array.push("")
617
+ }
618
+
619
+ // StateChangeSlices — per-stem MakeAsync opt-in via @@reventless.async
620
+ if resolved.stateChangeSlices->Array.length > 0 {
621
+ lines->Array.push(" // StateChangeSlices")
622
+ renderStateChangeSlices(
623
+ resolved.stateChangeSlices,
624
+ resolved.asyncStateChangeSlices,
625
+ )->push
626
+ }
627
+
628
+ // StateViewSlices
629
+ if resolved.stateViewSlices->Array.length > 0 {
630
+ lines->Array.push(" // StateViewSlices")
631
+ renderSlices(
632
+ ~platformFactory="StateViewSlice",
633
+ ~suffix="Slice",
634
+ ~implSuffix=Pairing.implSuffixForStateView,
635
+ resolved.stateViewSlices,
636
+ )->push
637
+ }
638
+
639
+ // StateViewSliceStreams
640
+ if resolved.stateViewSlicesStream->Array.length > 0 {
641
+ lines->Array.push(" // StateViewSliceStreams")
642
+ renderSlices(
643
+ ~platformFactory="StateViewSliceStream",
644
+ ~suffix="StreamSlice",
645
+ ~implSuffix=Pairing.implSuffixForStateView,
646
+ resolved.stateViewSlicesStream,
647
+ )->push
648
+ }
649
+
650
+ // AutomationSlices — 2-arg form: (Spec, _Automation)
651
+ if resolved.automationSlices->Array.length > 0 {
652
+ lines->Array.push(" // AutomationSlices")
653
+ renderAutomationSlices(resolved.automationSlices)->push
654
+ }
655
+
656
+ // OutboundTranslationSlices
657
+ if resolved.outboundTranslationSlices->Array.length > 0 {
658
+ lines->Array.push(" // OutboundTranslationSlices")
659
+ renderSlices(
660
+ ~platformFactory="OutboundTranslationSlice",
661
+ ~suffix="Slice",
662
+ ~implSuffix=Pairing.implSuffixForTranslation,
663
+ resolved.outboundTranslationSlices,
664
+ )->push
665
+ }
666
+
667
+ // InboundTranslationSlices
668
+ if resolved.inboundTranslationSlices->Array.length > 0 {
669
+ lines->Array.push(" // InboundTranslationSlices")
670
+ renderSlices(
671
+ ~platformFactory="InboundTranslationSlice",
672
+ ~suffix="Slice",
673
+ ~implSuffix=Pairing.implSuffixForTranslation,
674
+ resolved.inboundTranslationSlices,
675
+ )->push
676
+ }
677
+
678
+ // Aggregates
679
+ if resolved.aggregates->Array.length > 0 {
680
+ lines->Array.push(" // Aggregates")
681
+ renderAggregates(resolved.aggregates)->push
682
+ }
683
+
684
+ // ReadModels
685
+ if resolved.readModels->Array.length > 0 {
686
+ lines->Array.push(" // ReadModels")
687
+ renderReadModels(resolved.readModels)->push
688
+ }
689
+
690
+ // Tasks
691
+ if resolved.tasks->Array.length > 0 {
692
+ lines->Array.push(" // Tasks")
693
+ renderTasks(resolved.tasks)->push
694
+ }
695
+
696
+ // ExtensionPoints
697
+ if resolved.extensionPoints->Array.length > 0 {
698
+ lines->Array.push(" // ExtensionPoints")
699
+ renderExtensionPoints(resolved.extensionPoints)->push
700
+ }
701
+
702
+ // Extensions
703
+ if resolved.extensions->Array.length > 0 {
704
+ lines->Array.push(" // Extensions")
705
+ renderExtensions(resolved.extensions)->push
706
+ }
707
+
708
+ // pluginStructure
709
+ let pluginStructureLines = renderPluginStructureCall(
710
+ ~name=config.name,
711
+ ~aggregates=resolved.aggregates,
712
+ ~readModels=resolved.readModels,
713
+ ~stateViewSlices=resolved.stateViewSlices,
714
+ ~stateViewSlicesStream=resolved.stateViewSlicesStream,
715
+ ~stateChangeSlices=resolved.stateChangeSlices,
716
+ ~automationSlices=resolved.automationSlices,
717
+ ~outboundTranslationSlices=resolved.outboundTranslationSlices,
718
+ ~inboundTranslationSlices=resolved.inboundTranslationSlices,
719
+ ~extensions=resolved.extensions,
720
+ ~extensionPoints=resolved.extensionPoints,
721
+ ~componentChapters,
722
+ )
723
+ let hasPluginStructure = pluginStructureLines->Option.isSome
724
+ switch pluginStructureLines {
725
+ | Some(defLines) =>
726
+ defLines->Array.forEach(l => lines->Array.push(l))
727
+ lines->Array.push("")
728
+ | None => ()
729
+ }
730
+
731
+ // make() call — Composition variant only (Aws variant uses renderAwsWrapper).
732
+ // Auto UI is always derived from pluginStructure by the host-shell. The
733
+ // uiFragments manifest is purely a federation-override map: empty
734
+ // remoteEntryUrl ⇒ Auto UI renders the fragment. The wiring is emitted
735
+ // whenever pluginStructure exists, so any plugin (aggregate/DCB/hybrid)
736
+ // can opt into a custom React bundle via the env var.
737
+ lines->Array.push(" let make = () =>")
738
+ lines->Array.push(" Platform.Plugin.make(")
739
+ lines->Array.push(" ~name=\"" ++ config.name ++ "\",")
740
+ lines->Array.push(" ~heartbeatInterval=" ++ config.heartbeatInterval->Int.toString ++ ",")
741
+
742
+ let uiFragmentsParam = if hasPluginStructure {
743
+ let ls: array<string> = []
744
+ ls->Array.push(" ~uiFragments=?uiBundleUrl->Option.map(url =>")
745
+ ls->Array.push(" Platform.Plugin.makeAutoUIManifest(")
746
+ ls->Array.push(" ~remoteEntryUrl=url,")
747
+ ls->Array.push(" ~name=\"" ++ config.name ++ "\",")
748
+ ls->Array.push(" ~pluginStructure,")
749
+ ls->Array.push(" ~readModelPositions=[\"platform-summary\"],")
750
+ ls->Array.push(" ~aggregatePositions=[\"resource-detail\"],")
751
+ ls->Array.push(" )")
752
+ ls->Array.push(" ),")
753
+ Some(ls)
754
+ } else {
755
+ None
756
+ }
757
+
758
+ let makeParams = [
759
+ renderEpMakeParam(resolved.extensionPoints),
760
+ renderExtensionMakeParam(resolved.extensions),
761
+ renderAggregateMakeParam(resolved.aggregates),
762
+ renderReadModelMakeParam(resolved.readModels),
763
+ renderTaskMakeParam(resolved.tasks),
764
+ renderMakeParam(~param="stateChangeSlices", ~items=resolved.stateChangeSlices, ~moduleSuffix="Slice"),
765
+ renderStateViewSlicesMakeParam(resolved.stateViewSlices, resolved.stateViewSlicesStream),
766
+ renderMakeParam(~param="automationSlices", ~items=resolved.automationSlices, ~moduleSuffix="Slice"),
767
+ renderMakeParam(
768
+ ~param="outboundTranslationSlices",
769
+ ~items=resolved.outboundTranslationSlices,
770
+ ~moduleSuffix="Slice",
771
+ ),
772
+ renderMakeParam(
773
+ ~param="inboundTranslationSlices",
774
+ ~items=resolved.inboundTranslationSlices,
775
+ ~moduleSuffix="Slice",
776
+ ),
777
+ hasPluginStructure ? Some(" ~pluginStructure=pluginStructure,") : None,
778
+ // Components carrying `@@reventless.systemCallable` — only emitted when at
779
+ // least one opts in, so existing generated Plugin.res files stay identical.
780
+ resolved.systemCallableComponents->Array.length > 0
781
+ ? Some(
782
+ " ~systemCallableComponents=[" ++
783
+ resolved.systemCallableComponents
784
+ ->Array.map(n => "\"" ++ n ++ "\"")
785
+ ->Array.join(", ") ++ "],",
786
+ )
787
+ : None,
788
+ // Per-component runtime hints from plugin.json `runtime` (only emitted when
789
+ // present, so plugins without one keep a byte-identical generated Plugin.res).
790
+ renderComponentRuntimeParam(config.componentRuntime),
791
+ ]
792
+
793
+ // Typo protection: warn on `runtime` keys that name no discovered component.
794
+ validateComponentRuntimeKeys(~resolved, ~componentRuntime=config.componentRuntime)
795
+
796
+ makeParams->Array.filterMap(x => x)->Array.forEach(line => lines->Array.push(line))
797
+ switch uiFragmentsParam {
798
+ | Some(uiLines) => uiLines->Array.forEach(line => lines->Array.push(line))
799
+ | None => ()
800
+ }
801
+
802
+ lines->Array.push(" )")
803
+ lines->Array.push("}")
804
+ lines->Array.push("")
805
+
806
+ lines->Array.join("\n")
807
+ }
808
+
809
+ // ── Top-level render ─────────────────────────────────────────────────────────
810
+
811
+ let render = (
812
+ ~config: Config.config,
813
+ ~resolved: Pairing.resolved,
814
+ ~discovered: array<Discovery.discoveredFile>,
815
+ ): string => {
816
+ validateUniqueSpecStems(~discovered)
817
+ validateSliceTargets(~resolved)
818
+ // Chapter map, keyed by each component's spec stem (= its `Spec.name` for every
819
+ // graph-node kind, which is how `Plugin_Structure` looks the chapter up). Filtered
820
+ // to the actual component stems so body files (`_Behavior`, `_Projections`, …) —
821
+ // which `Discovery` also surfaces and which share their component's chapter — don't
822
+ // leak noise entries into the generated call. Tasks / extensions / extension points
823
+ // carry no chapter field, so they are excluded too.
824
+ let componentStems = Array.flat([
825
+ resolved.aggregates->Array.map(({spec}) => spec),
826
+ resolved.readModels->Array.map(({readModel}) => readModel),
827
+ resolved.stateChangeSlices,
828
+ resolved.stateViewSlices,
829
+ resolved.stateViewSlicesStream,
830
+ resolved.automationSlices,
831
+ resolved.outboundTranslationSlices,
832
+ resolved.inboundTranslationSlices,
833
+ ])
834
+ let componentChapters =
835
+ Discovery.chaptersByStem(discovered)->Array.filter(((stem, _)) =>
836
+ componentStems->Array.includes(stem)
837
+ )
838
+ switch config.variant {
839
+ | Aws({compositionNamespace}) => renderAwsWrapper(~compositionNamespace)
840
+ | Composition => renderComposition(~config, ~resolved, ~componentChapters)
841
+ }
842
+ }