@reventlessdev/reventless-spec 3.0.0-alpha.61

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 (86) hide show
  1. package/CHANGELOG.md +625 -0
  2. package/LICENSE +202 -0
  3. package/README.md +114 -0
  4. package/package.json +34 -0
  5. package/rescript.json +25 -0
  6. package/run-generator.mjs +2 -0
  7. package/src/AnsiStyle.res +40 -0
  8. package/src/AnsiStyle.res.mjs +54 -0
  9. package/src/LogPrefix.res +168 -0
  10. package/src/LogPrefix.res.mjs +148 -0
  11. package/src/PackageVersion.res +73 -0
  12. package/src/PackageVersion.res.mjs +81 -0
  13. package/src/components/Aggregate.res +64 -0
  14. package/src/components/Aggregate.res.mjs +2 -0
  15. package/src/components/AutomationSlice.res +279 -0
  16. package/src/components/AutomationSlice.res.mjs +30 -0
  17. package/src/components/Counter.res +24 -0
  18. package/src/components/Counter.res.mjs +2 -0
  19. package/src/components/DcbDecode.res +128 -0
  20. package/src/components/DcbDecode.res.mjs +117 -0
  21. package/src/components/DcbTag.res +1256 -0
  22. package/src/components/DcbTag.res.mjs +869 -0
  23. package/src/components/DcbValidation.res +362 -0
  24. package/src/components/DcbValidation.res.mjs +360 -0
  25. package/src/components/DisplayName.res +40 -0
  26. package/src/components/DisplayName.res.mjs +26 -0
  27. package/src/components/ExtensionPoint.res +27 -0
  28. package/src/components/ExtensionPoint.res.mjs +2 -0
  29. package/src/components/InboundTranslationSlice.res +79 -0
  30. package/src/components/InboundTranslationSlice.res.mjs +2 -0
  31. package/src/components/OutboundTranslationSlice.res +108 -0
  32. package/src/components/OutboundTranslationSlice.res.mjs +2 -0
  33. package/src/components/Plugin.res +373 -0
  34. package/src/components/Plugin.res.mjs +249 -0
  35. package/src/components/ReadModel.res +199 -0
  36. package/src/components/ReadModel.res.mjs +18 -0
  37. package/src/components/Reference.res +51 -0
  38. package/src/components/Reference.res.mjs +37 -0
  39. package/src/components/StateAnnotations.res +65 -0
  40. package/src/components/StateAnnotations.res.mjs +15 -0
  41. package/src/components/StateChangeSlice.res +131 -0
  42. package/src/components/StateChangeSlice.res.mjs +2 -0
  43. package/src/components/StateViewSlice.res +99 -0
  44. package/src/components/StateViewSlice.res.mjs +2 -0
  45. package/src/components/Task.res +62 -0
  46. package/src/components/Task.res.mjs +2 -0
  47. package/src/generator/Codegen.res +703 -0
  48. package/src/generator/Codegen.res.mjs +485 -0
  49. package/src/generator/Config.res +85 -0
  50. package/src/generator/Config.res.mjs +78 -0
  51. package/src/generator/Discovery.res +204 -0
  52. package/src/generator/Discovery.res.mjs +213 -0
  53. package/src/generator/Generator_Node.res +49 -0
  54. package/src/generator/Generator_Node.res.mjs +18 -0
  55. package/src/generator/Pairing.res +386 -0
  56. package/src/generator/Pairing.res.mjs +353 -0
  57. package/src/generator/PluginGenerator.res +53 -0
  58. package/src/generator/PluginGenerator.res.mjs +70 -0
  59. package/src/types/Authorization.res +23 -0
  60. package/src/types/Authorization.res.mjs +33 -0
  61. package/src/types/Behavior.res +78 -0
  62. package/src/types/Behavior.res.mjs +2 -0
  63. package/src/types/EventMapping.res +100 -0
  64. package/src/types/EventMapping.res.mjs +15 -0
  65. package/src/types/Handler.res +30 -0
  66. package/src/types/Handler.res.mjs +2 -0
  67. package/src/types/Id.res +75 -0
  68. package/src/types/Id.res.mjs +37 -0
  69. package/src/types/Identity.res +46 -0
  70. package/src/types/Identity.res.mjs +51 -0
  71. package/src/types/Message.res +197 -0
  72. package/src/types/Message.res.mjs +108 -0
  73. package/src/types/Projection.res +220 -0
  74. package/src/types/Projection.res.mjs +44 -0
  75. package/src/types/QueryEngine.res +123 -0
  76. package/src/types/QueryEngine.res.mjs +12 -0
  77. package/src/types/ReadConsistency.res +38 -0
  78. package/src/types/ReadConsistency.res.mjs +29 -0
  79. package/src/types/Schedule.res +65 -0
  80. package/src/types/Schedule.res.mjs +68 -0
  81. package/src/types/SideEffect.res +45 -0
  82. package/src/types/SideEffect.res.mjs +2 -0
  83. package/src/types/StoredEvent.res +46 -0
  84. package/src/types/StoredEvent.res.mjs +32 -0
  85. package/src/types/Visibility.res +24 -0
  86. package/src/types/Visibility.res.mjs +25 -0
@@ -0,0 +1,703 @@
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
+ let renderTaskMakeParam = (tasks: array<string>): option<string> =>
253
+ if tasks->Array.length === 0 {
254
+ None
255
+ } else {
256
+ let entries = tasks->Array.map(s => "module(" ++ s ++ "Task)")
257
+ Some(" ~tasks=[" ++ entries->Array.join(", ") ++ "],")
258
+ }
259
+
260
+ let renderPluginStructureCall = (
261
+ ~name: string,
262
+ ~aggregates: array<Pairing.aggregateDef>,
263
+ ~readModels: array<Pairing.readModelDef>,
264
+ ~stateViewSlices: array<string>,
265
+ ~stateViewSlicesStream: array<string>,
266
+ ~stateChangeSlices: array<string>,
267
+ ~automationSlices: array<string>,
268
+ ~outboundTranslationSlices: array<string>,
269
+ ~inboundTranslationSlices: array<string>,
270
+ ~extensions: array<string>,
271
+ ~extensionPoints: array<Pairing.extensionPointDef>,
272
+ ): option<array<string>> => {
273
+ // The structure call carries the EP *mapping* files (one per Delegate
274
+ // connection), not the wrapped ExtensionPoint module — Plugin_Structure reads
275
+ // each mapping's Delegate to derive the extension point's source event types.
276
+ let epMappingStems = extensionPoints->Array.flatMap(({mappings}) => mappings)
277
+ let hasComponents =
278
+ aggregates->Array.length > 0 ||
279
+ readModels->Array.length > 0 ||
280
+ stateViewSlices->Array.length > 0 ||
281
+ stateViewSlicesStream->Array.length > 0 ||
282
+ stateChangeSlices->Array.length > 0 ||
283
+ automationSlices->Array.length > 0 ||
284
+ outboundTranslationSlices->Array.length > 0 ||
285
+ inboundTranslationSlices->Array.length > 0 ||
286
+ extensions->Array.length > 0 ||
287
+ epMappingStems->Array.length > 0
288
+ if !hasComponents {
289
+ None
290
+ } else {
291
+ let ls: array<string> = []
292
+ ls->Array.push(" let pluginStructure = Platform.Plugin.makePluginDefinition(")
293
+ ls->Array.push(" ~name=\"" ++ name ++ "\",")
294
+ if aggregates->Array.length > 0 {
295
+ let entries = aggregates->Array.map(({spec}) => "module(" ++ spec ++ "Aggregate)")
296
+ ls->Array.push(" ~aggregates=[" ++ entries->Array.join(", ") ++ "],")
297
+ }
298
+ if readModels->Array.length > 0 {
299
+ let entries = readModels->Array.map(({readModel}) => {
300
+ let wrapperName = if readModel->String.endsWith("ReadModel") {
301
+ readModel
302
+ } else {
303
+ readModel ++ "ReadModel"
304
+ }
305
+ "module(" ++ wrapperName ++ ")"
306
+ })
307
+ ls->Array.push(" ~readModels=[" ++ entries->Array.join(", ") ++ "],")
308
+ }
309
+ let allStateViewEntries = Array.flat([
310
+ stateViewSlices->Array.map(s => "module(" ++ s ++ "Slice)"),
311
+ stateViewSlicesStream->Array.map(s => "module(" ++ s ++ "StreamSlice)"),
312
+ ])
313
+ if allStateViewEntries->Array.length > 0 {
314
+ ls->Array.push(" ~stateViewSlices=[" ++ allStateViewEntries->Array.join(", ") ++ "],")
315
+ }
316
+ if stateChangeSlices->Array.length > 0 {
317
+ let entries = stateChangeSlices->Array.map(s => "module(" ++ s ++ "Slice)")
318
+ ls->Array.push(" ~stateChangeSlices=[" ++ entries->Array.join(", ") ++ "],")
319
+ }
320
+ if automationSlices->Array.length > 0 {
321
+ let entries = automationSlices->Array.map(s => "module(" ++ s ++ "Slice)")
322
+ ls->Array.push(" ~automationSlices=[" ++ entries->Array.join(", ") ++ "],")
323
+ }
324
+ if outboundTranslationSlices->Array.length > 0 {
325
+ let entries = outboundTranslationSlices->Array.map(s => "module(" ++ s ++ "Slice)")
326
+ ls->Array.push(" ~outboundTranslationSlices=[" ++ entries->Array.join(", ") ++ "],")
327
+ }
328
+ if inboundTranslationSlices->Array.length > 0 {
329
+ let entries = inboundTranslationSlices->Array.map(s => "module(" ++ s ++ "Slice)")
330
+ ls->Array.push(" ~inboundTranslationSlices=[" ++ entries->Array.join(", ") ++ "],")
331
+ }
332
+ if extensions->Array.length > 0 {
333
+ let entries = extensions->Array.map(s => "module(" ++ s ++ ")")
334
+ ls->Array.push(" ~extensions=[" ++ entries->Array.join(", ") ++ "],")
335
+ }
336
+ if epMappingStems->Array.length > 0 {
337
+ let entries = epMappingStems->Array.map(s => "module(" ++ s ++ ")")
338
+ ls->Array.push(" ~extensionPoints=[" ++ entries->Array.join(", ") ++ "],")
339
+ }
340
+ ls->Array.push(" )")
341
+ Some(ls)
342
+ }
343
+ }
344
+
345
+ // ── Duplicate spec-stem lint ─────────────────────────────────────────────────
346
+ // Within one plugin, every spec file's stem produces a top-level ReScript
347
+ // module name. Two spec files with the same stem (across folders) collide at
348
+ // link time with a confusing error. We catch the collision here with a clear
349
+ // message naming both file paths.
350
+
351
+ let validateUniqueSpecStems = (~discovered: array<Discovery.discoveredFile>) => {
352
+ // Each discovered .res file becomes a top-level ReScript module named after
353
+ // its filename stem. Two files with the same stem in different folders
354
+ // collide at link time, so we surface the conflict here with both paths.
355
+ let byStem: Dict.t<array<string>> = Dict.make()
356
+ discovered->Array.forEach((d: Discovery.discoveredFile) => {
357
+ let existing = byStem->Dict.get(d.stem)->Option.getOr([])
358
+ existing->Array.push(d.relPath)
359
+ byStem->Dict.set(d.stem, existing)
360
+ })
361
+ byStem
362
+ ->Dict.toArray
363
+ ->Array.forEach(((stem, paths)) => {
364
+ if paths->Array.length > 1 {
365
+ let pathList = paths->Array.toSorted((a, b) => if a < b {-1.0} else if a > b {1.0} else {0.0})->Array.join("\n - ")
366
+ JsError.throwWithMessage(
367
+ "Generator: stem `" ++
368
+ stem ++
369
+ "` is used by multiple files. Filename stems must be unique across a plugin so the generated module names don't collide:\n - " ++
370
+ pathList,
371
+ )
372
+ }
373
+ })
374
+ }
375
+
376
+ // ── targetName validation ────────────────────────────────────────────────────
377
+
378
+ let validateSliceTargets = (~resolved: Pairing.resolved) => {
379
+ let knownReceivers = Array.concat(
380
+ resolved.aggregates->Array.map(({spec}) => spec),
381
+ resolved.stateChangeSlices,
382
+ )
383
+
384
+ let check = (~kind: string, stem: string, target: option<string>) =>
385
+ switch target {
386
+ | None =>
387
+ JsError.throwWithMessage(
388
+ "Generator: " ++
389
+ kind ++
390
+ " `" ++
391
+ stem ++
392
+ "` is missing `let targetName = \"...\"`. Valid targets: " ++
393
+ knownReceivers->Array.join(", "),
394
+ )
395
+ | Some(t) when !(knownReceivers->Array.includes(t)) =>
396
+ JsError.throwWithMessage(
397
+ "Generator: " ++
398
+ kind ++
399
+ " `" ++
400
+ stem ++
401
+ "` declares targetName = \"" ++
402
+ t ++
403
+ "\" but no such command receiver exists. Valid targets: " ++
404
+ knownReceivers->Array.join(", "),
405
+ )
406
+ | Some(_) => ()
407
+ }
408
+
409
+ resolved.automationSlices->Array.forEach(stem =>
410
+ check(
411
+ ~kind="AutomationSlice",
412
+ stem,
413
+ resolved.automationSliceTargets->Dict.get(stem)->Option.getOr(None),
414
+ )
415
+ )
416
+
417
+ resolved.inboundTranslationSlices->Array.forEach(stem =>
418
+ check(
419
+ ~kind="InboundTranslationSlice",
420
+ stem,
421
+ resolved.inboundTranslationSliceTargets->Dict.get(stem)->Option.getOr(None),
422
+ )
423
+ )
424
+
425
+ // OutboundTranslationSlice: only validate when targetName = Some(name) (None = fire-and-forget)
426
+ resolved.outboundTranslationSlices->Array.forEach(stem => {
427
+ switch resolved.outboundTranslationSliceTargets->Dict.get(stem)->Option.getOr(None) {
428
+ | Some(t) when !(knownReceivers->Array.includes(t)) =>
429
+ JsError.throwWithMessage(
430
+ "Generator: OutboundTranslationSlice `" ++
431
+ stem ++
432
+ "` declares targetName = Some(\"" ++
433
+ t ++
434
+ "\") but no such command receiver exists. Valid targets: " ++
435
+ knownReceivers->Array.join(", "),
436
+ )
437
+ | _ => ()
438
+ }
439
+ })
440
+ }
441
+
442
+ // ── Main.res render (AWS mode only) ─────────────────────────────────────────
443
+
444
+ let renderMain = (~config: Config.config): string => {
445
+ let name = config.name
446
+ [
447
+ "// AUTO-GENERATED — do not edit. Run `npm run generate` to update.",
448
+ "// " ++ name ++ " plugin — AWS deployment.",
449
+ "",
450
+ "module Platform = ReventlessAws.Platform.Make()",
451
+ "module " ++ name ++ " = Plugin.Make(Platform)",
452
+ "",
453
+ "let default = Platform.deployPlugin(",
454
+ " ~plugin=module(" ++ name ++ "),",
455
+ ")",
456
+ "",
457
+ ]->Array.join("\n")
458
+ }
459
+
460
+ // ── Aws-variant render ───────────────────────────────────────────────────────
461
+ // The AWS Plugin.res delegates to the source plugin's `Plugin.Make` functor;
462
+ // all slice/aggregate/readmodel declarations and the federation override
463
+ // env-var read live in the Composition variant. The wrapper just retypes the
464
+ // Platform constraint and re-exports `make` for the AWS deploy path.
465
+
466
+ // Convert a PascalCase plugin name to SCREAMING_SNAKE_CASE for env var naming.
467
+ // "Ordering" → "ORDERING"; "OnlineShop" → "ONLINE_SHOP".
468
+ let pluginNameToEnvBase = (name: string): string =>
469
+ name
470
+ ->String.split("")
471
+ ->Array.mapWithIndex((ch, i) => {
472
+ let isUpper = ch !== "" && ch === ch->String.toUpperCase && ch !== ch->String.toLowerCase
473
+ i > 0 && isUpper ? "_" ++ ch : ch
474
+ })
475
+ ->Array.join("")
476
+ ->String.toUpperCase
477
+
478
+ let renderAwsWrapper = (~compositionNamespace: string): string => {
479
+ [
480
+ "// AUTO-GENERATED — do not edit. Run `npm run generate` to update.",
481
+ "",
482
+ "module Make = (",
483
+ " Platform: ReventlessInfra.Platform.T",
484
+ " with type api = ReventlessAws.Types.AppSync.api",
485
+ " and type role = ReventlessAws.Types.AppSync.role,",
486
+ ") => {",
487
+ " module Composition = " ++ compositionNamespace ++ ".Plugin.Make(Platform)",
488
+ " let make = () => Composition.make()",
489
+ "}",
490
+ "",
491
+ ]->Array.join("\n")
492
+ }
493
+
494
+ // ── Composition-variant render ───────────────────────────────────────────────
495
+
496
+ let renderComposition = (~config: Config.config, ~resolved: Pairing.resolved): string => {
497
+ let lines: array<string> = []
498
+
499
+ // Header
500
+ lines->Array.push("// AUTO-GENERATED — do not edit. Run `npm run generate` to update.")
501
+ // Federation override URL — empty/unset means Auto UI renders every fragment.
502
+ // Read once at module init; same env var is honoured by the AWS wrapper.
503
+ lines->Array.push("")
504
+ lines->Array.push(
505
+ "@val external uiBundleUrl: option<string> = \"process.env." ++
506
+ pluginNameToEnvBase(config.name) ++
507
+ "_UI_BUNDLE_URL\"",
508
+ )
509
+ lines->Array.push("")
510
+ lines->Array.push("module Make = (Platform: ReventlessInfra.Platform.T) => {")
511
+
512
+ let push = (sectionLines: array<string>) => {
513
+ sectionLines->Array.forEach(l => lines->Array.push(l))
514
+ lines->Array.push("")
515
+ }
516
+
517
+ // StateChangeSlices — per-stem MakeAsync opt-in via @@reventless.async
518
+ if resolved.stateChangeSlices->Array.length > 0 {
519
+ lines->Array.push(" // StateChangeSlices")
520
+ renderStateChangeSlices(
521
+ resolved.stateChangeSlices,
522
+ resolved.asyncStateChangeSlices,
523
+ )->push
524
+ }
525
+
526
+ // StateViewSlices
527
+ if resolved.stateViewSlices->Array.length > 0 {
528
+ lines->Array.push(" // StateViewSlices")
529
+ renderSlices(
530
+ ~platformFactory="StateViewSlice",
531
+ ~suffix="Slice",
532
+ ~implSuffix=Pairing.implSuffixForStateView,
533
+ resolved.stateViewSlices,
534
+ )->push
535
+ }
536
+
537
+ // StateViewSliceStreams
538
+ if resolved.stateViewSlicesStream->Array.length > 0 {
539
+ lines->Array.push(" // StateViewSliceStreams")
540
+ renderSlices(
541
+ ~platformFactory="StateViewSliceStream",
542
+ ~suffix="StreamSlice",
543
+ ~implSuffix=Pairing.implSuffixForStateView,
544
+ resolved.stateViewSlicesStream,
545
+ )->push
546
+ }
547
+
548
+ // AutomationSlices — 2-arg form: (Spec, _Automation)
549
+ if resolved.automationSlices->Array.length > 0 {
550
+ lines->Array.push(" // AutomationSlices")
551
+ renderAutomationSlices(resolved.automationSlices)->push
552
+ }
553
+
554
+ // OutboundTranslationSlices
555
+ if resolved.outboundTranslationSlices->Array.length > 0 {
556
+ lines->Array.push(" // OutboundTranslationSlices")
557
+ renderSlices(
558
+ ~platformFactory="OutboundTranslationSlice",
559
+ ~suffix="Slice",
560
+ ~implSuffix=Pairing.implSuffixForTranslation,
561
+ resolved.outboundTranslationSlices,
562
+ )->push
563
+ }
564
+
565
+ // InboundTranslationSlices
566
+ if resolved.inboundTranslationSlices->Array.length > 0 {
567
+ lines->Array.push(" // InboundTranslationSlices")
568
+ renderSlices(
569
+ ~platformFactory="InboundTranslationSlice",
570
+ ~suffix="Slice",
571
+ ~implSuffix=Pairing.implSuffixForTranslation,
572
+ resolved.inboundTranslationSlices,
573
+ )->push
574
+ }
575
+
576
+ // Aggregates
577
+ if resolved.aggregates->Array.length > 0 {
578
+ lines->Array.push(" // Aggregates")
579
+ renderAggregates(resolved.aggregates)->push
580
+ }
581
+
582
+ // ReadModels
583
+ if resolved.readModels->Array.length > 0 {
584
+ lines->Array.push(" // ReadModels")
585
+ renderReadModels(resolved.readModels)->push
586
+ }
587
+
588
+ // Tasks
589
+ if resolved.tasks->Array.length > 0 {
590
+ lines->Array.push(" // Tasks")
591
+ renderTasks(resolved.tasks)->push
592
+ }
593
+
594
+ // ExtensionPoints
595
+ if resolved.extensionPoints->Array.length > 0 {
596
+ lines->Array.push(" // ExtensionPoints")
597
+ renderExtensionPoints(resolved.extensionPoints)->push
598
+ }
599
+
600
+ // Extensions
601
+ if resolved.extensions->Array.length > 0 {
602
+ lines->Array.push(" // Extensions")
603
+ renderExtensions(resolved.extensions)->push
604
+ }
605
+
606
+ // pluginStructure
607
+ let pluginStructureLines = renderPluginStructureCall(
608
+ ~name=config.name,
609
+ ~aggregates=resolved.aggregates,
610
+ ~readModels=resolved.readModels,
611
+ ~stateViewSlices=resolved.stateViewSlices,
612
+ ~stateViewSlicesStream=resolved.stateViewSlicesStream,
613
+ ~stateChangeSlices=resolved.stateChangeSlices,
614
+ ~automationSlices=resolved.automationSlices,
615
+ ~outboundTranslationSlices=resolved.outboundTranslationSlices,
616
+ ~inboundTranslationSlices=resolved.inboundTranslationSlices,
617
+ ~extensions=resolved.extensions,
618
+ ~extensionPoints=resolved.extensionPoints,
619
+ )
620
+ let hasPluginStructure = pluginStructureLines->Option.isSome
621
+ switch pluginStructureLines {
622
+ | Some(defLines) =>
623
+ defLines->Array.forEach(l => lines->Array.push(l))
624
+ lines->Array.push("")
625
+ | None => ()
626
+ }
627
+
628
+ // make() call — Composition variant only (Aws variant uses renderAwsWrapper).
629
+ // Auto UI is always derived from pluginStructure by the host-shell. The
630
+ // uiFragments manifest is purely a federation-override map: empty
631
+ // remoteEntryUrl ⇒ Auto UI renders the fragment. The wiring is emitted
632
+ // whenever pluginStructure exists, so any plugin (aggregate/DCB/hybrid)
633
+ // can opt into a custom React bundle via the env var.
634
+ lines->Array.push(" let make = () =>")
635
+ lines->Array.push(" Platform.Plugin.make(")
636
+ lines->Array.push(" ~name=\"" ++ config.name ++ "\",")
637
+ lines->Array.push(" ~heartbeatInterval=" ++ config.heartbeatInterval->Int.toString ++ ",")
638
+
639
+ let uiFragmentsParam = if hasPluginStructure {
640
+ let ls: array<string> = []
641
+ ls->Array.push(" ~uiFragments=?uiBundleUrl->Option.map(url =>")
642
+ ls->Array.push(" Platform.Plugin.makeAutoUIManifest(")
643
+ ls->Array.push(" ~remoteEntryUrl=url,")
644
+ ls->Array.push(" ~name=\"" ++ config.name ++ "\",")
645
+ ls->Array.push(" ~pluginStructure,")
646
+ ls->Array.push(" ~readModelPositions=[\"platform-summary\"],")
647
+ ls->Array.push(" ~aggregatePositions=[\"resource-detail\"],")
648
+ ls->Array.push(" )")
649
+ ls->Array.push(" ),")
650
+ Some(ls)
651
+ } else {
652
+ None
653
+ }
654
+
655
+ let makeParams = [
656
+ renderEpMakeParam(resolved.extensionPoints),
657
+ renderExtensionMakeParam(resolved.extensions),
658
+ renderAggregateMakeParam(resolved.aggregates),
659
+ renderReadModelMakeParam(resolved.readModels),
660
+ renderTaskMakeParam(resolved.tasks),
661
+ renderMakeParam(~param="stateChangeSlices", ~items=resolved.stateChangeSlices, ~moduleSuffix="Slice"),
662
+ renderStateViewSlicesMakeParam(resolved.stateViewSlices, resolved.stateViewSlicesStream),
663
+ renderMakeParam(~param="automationSlices", ~items=resolved.automationSlices, ~moduleSuffix="Slice"),
664
+ renderMakeParam(
665
+ ~param="outboundTranslationSlices",
666
+ ~items=resolved.outboundTranslationSlices,
667
+ ~moduleSuffix="Slice",
668
+ ),
669
+ renderMakeParam(
670
+ ~param="inboundTranslationSlices",
671
+ ~items=resolved.inboundTranslationSlices,
672
+ ~moduleSuffix="Slice",
673
+ ),
674
+ hasPluginStructure ? Some(" ~pluginStructure=pluginStructure,") : None,
675
+ ]
676
+
677
+ makeParams->Array.filterMap(x => x)->Array.forEach(line => lines->Array.push(line))
678
+ switch uiFragmentsParam {
679
+ | Some(uiLines) => uiLines->Array.forEach(line => lines->Array.push(line))
680
+ | None => ()
681
+ }
682
+
683
+ lines->Array.push(" )")
684
+ lines->Array.push("}")
685
+ lines->Array.push("")
686
+
687
+ lines->Array.join("\n")
688
+ }
689
+
690
+ // ── Top-level render ─────────────────────────────────────────────────────────
691
+
692
+ let render = (
693
+ ~config: Config.config,
694
+ ~resolved: Pairing.resolved,
695
+ ~discovered: array<Discovery.discoveredFile>,
696
+ ): string => {
697
+ validateUniqueSpecStems(~discovered)
698
+ validateSliceTargets(~resolved)
699
+ switch config.variant {
700
+ | Aws({compositionNamespace}) => renderAwsWrapper(~compositionNamespace)
701
+ | Composition => renderComposition(~config, ~resolved)
702
+ }
703
+ }