@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,386 @@
1
+ // Applies pairing rules to discovered files.
2
+ // - Aggregates: pairs Foo + FooBehavior; finds Foo_EventMappings if present
3
+ // - ReadModels: pairs FooReadModel + FooProjections; extracts mapping module names from projections file
4
+ // - Slices (Plan 02): pair X + X_<Kind> where Kind is the implementation
5
+ // suffix matching the slice's folder. The slice arrays carry only spec
6
+ // stems; the corresponding impl stem is `<stem>_<implSuffix>`.
7
+ // * StateChangeSlice / StateViewSlice → Behavior / Projection
8
+ // * AutomationSlice → Automation
9
+ // * In/OutboundTranslationSlice → Translation
10
+ // - ExtensionPoints: groups by epGroup, counts mappings, selects Make variant
11
+
12
+ // Implementation-file suffix per slice kind. Used to filter impl files out
13
+ // of the slice arrays (they're paired with their spec stems via this
14
+ // suffix at codegen time) and to compute the impl module reference.
15
+ let implSuffixForStateChange = "_Behavior"
16
+ let implSuffixForStateView = "_Projection"
17
+ let implSuffixForAutomation = "_Automation"
18
+ let implSuffixForTranslation = "_Translation"
19
+ // Plan 04: AutomationSlice splits source-side concerns into a `_Mappings`
20
+ // sibling. Treated as an impl file so it doesn't surface as a slice spec.
21
+ let mappingsSuffixForAutomation = "_Mappings"
22
+
23
+ let isImplStem = (stem: string): bool =>
24
+ stem->String.endsWith(implSuffixForStateChange)
25
+ || stem->String.endsWith(implSuffixForStateView)
26
+ || stem->String.endsWith(implSuffixForAutomation)
27
+ || stem->String.endsWith(implSuffixForTranslation)
28
+ || stem->String.endsWith(mappingsSuffixForAutomation)
29
+
30
+ type aggregateDef = {spec: string, behavior: string, eventMappings: option<string>, isAsync: bool}
31
+ // readModelDef pairs a ReadModel spec with its sibling `_Projections.res`
32
+ // file. Codegen emits `Platform.ReadModel.Make(<spec>, <projections>)` and
33
+ // expects the projections file to declare `let mappings` via
34
+ // `@@reventless.mappings`. `stream: true` (the `ReadModelStream/` folder) flips
35
+ // the emitted factory to `Platform.ReadModelStream.Make` — a DynamoDB-Stream-
36
+ // backed QueryDb that drives AppSync Events (Source B) live updates. Stream and
37
+ // non-stream read models are otherwise identical downstream (resolvers, AutoUI
38
+ // manifest, pluginStructure, query field).
39
+ type readModelDef = {
40
+ readModel: string,
41
+ projections: string,
42
+ stream: bool,
43
+ }
44
+ type extensionPointDef = {group: option<string>, mappings: array<string>}
45
+
46
+ type resolved = {
47
+ stateChangeSlices: array<string>,
48
+ stateViewSlices: array<string>,
49
+ stateViewSlicesStream: array<string>,
50
+ automationSlices: array<string>,
51
+ inboundTranslationSlices: array<string>,
52
+ outboundTranslationSlices: array<string>,
53
+ // stem → declared targetName (None = not declared or explicitly None)
54
+ automationSliceTargets: Dict.t<option<string>>,
55
+ inboundTranslationSliceTargets: Dict.t<option<string>>,
56
+ outboundTranslationSliceTargets: Dict.t<option<string>>,
57
+ // StateChangeSlice stems carrying `@@reventless.async` — codegen emits
58
+ // `Platform.StateChangeSlice.MakeAsync` for these (CommandPending response).
59
+ asyncStateChangeSlices: Dict.t<bool>,
60
+ aggregates: array<aggregateDef>,
61
+ readModels: array<readModelDef>,
62
+ tasks: array<string>,
63
+ extensionPoints: array<extensionPointDef>,
64
+ extensions: array<string>,
65
+ }
66
+
67
+ // Scan srcDir recursively for `<Agg>_EventMappings.res` and `<Agg>_Mappings.res`
68
+ // files and collect {AggName → mappingStem} dict. Supports the legacy flat
69
+ // layout (`srcDir/EventMappings/`), the per-entity layout
70
+ // (`srcDir/<Agg>/Aggregate/<Agg>_EventMappings.res`), AND the Phase-3-2
71
+ // post-rename form (`<Agg>_Mappings.res`). `Plugin/`, `tests/`, `lib/` are
72
+ // skipped. The new `_Mappings.res` form is only matched inside an
73
+ // `Aggregate/` (or `EventMappings/`) folder so AutomationSlice's own
74
+ // `_Mappings.res` siblings don't get scooped up.
75
+ let findEventMappings = (~srcDir: string): Dict.t<string> => {
76
+ let dict = Dict.make()
77
+ let parentDirIsAggregate = (relSegments: array<string>): bool =>
78
+ switch relSegments->Array.length {
79
+ | 0 => false
80
+ | n =>
81
+ let parent = relSegments->Array.getUnsafe(n - 1)
82
+ parent === "Aggregate" || parent === "EventMappings"
83
+ }
84
+ let rec walk = (dir: string, segments: array<string>) =>
85
+ Generator_Node.readDir(dir)->Array.forEach(entry => {
86
+ let name = entry->Generator_Node.direntName
87
+ if entry->Generator_Node.isDirectory {
88
+ if name !== "Plugin" && name !== "tests" && name !== "lib" {
89
+ walk(Generator_Node.join([dir, name]), Array.concat(segments, [name]))
90
+ }
91
+ } else if entry->Generator_Node.isFile {
92
+ if name->String.endsWith("_Mappings.res") && parentDirIsAggregate(segments) {
93
+ let stem = name->String.slice(~start=0, ~end=name->String.length - 4)
94
+ // "_Mappings" = 9 chars
95
+ let aggName = stem->String.slice(~start=0, ~end=stem->String.length - 9)
96
+ Dict.set(dict, aggName, stem)
97
+ } else if name->String.endsWith("_EventMappings.res") {
98
+ let stem = name->String.slice(~start=0, ~end=name->String.length - 4)
99
+ // "_EventMappings" = 14 chars
100
+ let aggName = stem->String.slice(~start=0, ~end=stem->String.length - 14)
101
+ // Don't overwrite a `_Mappings.res` discovered earlier.
102
+ switch Dict.get(dict, aggName) {
103
+ | Some(_) => ()
104
+ | None => Dict.set(dict, aggName, stem)
105
+ }
106
+ }
107
+ }
108
+ })
109
+ if Generator_Node.existsSync(srcDir) {
110
+ walk(srcDir, [])
111
+ }
112
+ dict
113
+ }
114
+
115
+ let sortedStems = (stems: array<string>): array<string> =>
116
+ stems->Array.toSorted((a, b) => if a < b {-1.0} else if a > b {1.0} else {0.0})
117
+
118
+ // Read a slice spec file and extract the value of `let targetName = ...`.
119
+ // Returns Some(name) for string literals (including inside Some("...")),
120
+ // or None for `let targetName = None` or when the line is absent.
121
+ let extractTargetName = (filePath: string): option<string> => {
122
+ try {
123
+ let content = Generator_Node.readFileSync(filePath)
124
+ let result = ref(None)
125
+ content->String.split("\n")->Array.forEach(line => {
126
+ let trimmed = line->String.trimStart
127
+ if trimmed->String.startsWith("let targetName = ") {
128
+ let firstQuote = trimmed->String.indexOf("\"")
129
+ let lastQuote = trimmed->String.lastIndexOf("\"")
130
+ if firstQuote >= 0 && lastQuote > firstQuote {
131
+ result := Some(trimmed->String.slice(~start=firstQuote + 1, ~end=lastQuote))
132
+ }
133
+ // `let targetName = None` → no quotes found → result stays None
134
+ }
135
+ })
136
+ result.contents
137
+ } catch {
138
+ | _ => None
139
+ }
140
+ }
141
+
142
+ // Returns true if the spec file declares `@@reventless.async` at the file
143
+ // level (Aggregate or StateChangeSlice opt-in to CommandPending response).
144
+ // Falls back to false on read errors so a transient FS failure can't flip
145
+ // component to async — the safe default is sync.
146
+ let hasAsyncAttribute = (filePath: string): bool => {
147
+ try {
148
+ let content = Generator_Node.readFileSync(filePath)
149
+ let found = ref(false)
150
+ content->String.split("\n")->Array.forEach(line => {
151
+ let trimmed = line->String.trimStart
152
+ // Match `@@reventless.async` and tolerate a payload arg in the future.
153
+ if (
154
+ trimmed->String.startsWith("@@reventless.async ") ||
155
+ trimmed->String.startsWith("@@reventless.async(") ||
156
+ trimmed === "@@reventless.async" ||
157
+ trimmed->String.startsWith("@@reventless.async\n")
158
+ ) {
159
+ found := true
160
+ }
161
+ })
162
+ found.contents
163
+ } catch {
164
+ | _ => false
165
+ }
166
+ }
167
+
168
+ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): resolved => {
169
+ let eventMappings = findEventMappings(~srcDir)
170
+
171
+ let stateChangeSlices = []
172
+ let stateViewSlices = []
173
+ let stateViewSlicesStream = []
174
+ let automationSlices = []
175
+ let inboundTranslationSlices = []
176
+ let outboundTranslationSlices = []
177
+ // relPath dicts for slices that declare targetName
178
+ let automationSliceRelPaths: Dict.t<string> = Dict.make()
179
+ let inboundTranslationSliceRelPaths: Dict.t<string> = Dict.make()
180
+ let outboundTranslationSliceRelPaths: Dict.t<string> = Dict.make()
181
+ let aggregateSpecs: array<string> = []
182
+ let aggregateSpecRelPaths: Dict.t<string> = Dict.make()
183
+ let stateChangeSliceRelPaths: Dict.t<string> = Dict.make()
184
+ let aggregateBehaviors: array<string> = []
185
+ let readModelStems: array<string> = []
186
+ // Stream-enabled read model spec stems (the `ReadModelStream/` folder).
187
+ let readModelStreamStems: array<string> = []
188
+ // projectionsByRelPath: stem → relPath mapping for projections files (shared
189
+ // by ReadModel and ReadModelStream — the projection file shape is identical).
190
+ let projectionsByRelPath: Dict.t<string> = Dict.make()
191
+ let tasks = []
192
+ let epFiles: array<Discovery.discoveredFile> = []
193
+ let extensions = []
194
+
195
+ // Classify discovered files by component type. For the five slice
196
+ // families, impl files (X_Behavior / X_Projection / X_Automation /
197
+ // X_Translation) are filtered out: they're paired with their spec stems
198
+ // at codegen time via the `_<ImplKind>` suffix convention.
199
+ discovered->Array.forEach(({stem, componentType, epGroup, relPath}) => {
200
+ switch componentType {
201
+ | StateChangeSlice =>
202
+ if !isImplStem(stem) {
203
+ stateChangeSlices->Array.push(stem)
204
+ stateChangeSliceRelPaths->Dict.set(stem, relPath)
205
+ }
206
+ | StateViewSlice =>
207
+ if !isImplStem(stem) { stateViewSlices->Array.push(stem) }
208
+ | StateViewSliceStream =>
209
+ if !isImplStem(stem) { stateViewSlicesStream->Array.push(stem) }
210
+ | AutomationSlice =>
211
+ if !isImplStem(stem) {
212
+ automationSlices->Array.push(stem)
213
+ automationSliceRelPaths->Dict.set(stem, relPath)
214
+ }
215
+ | InboundTranslationSlice =>
216
+ if !isImplStem(stem) {
217
+ inboundTranslationSlices->Array.push(stem)
218
+ inboundTranslationSliceRelPaths->Dict.set(stem, relPath)
219
+ }
220
+ | OutboundTranslationSlice =>
221
+ if !isImplStem(stem) {
222
+ outboundTranslationSlices->Array.push(stem)
223
+ outboundTranslationSliceRelPaths->Dict.set(stem, relPath)
224
+ }
225
+ | Aggregate =>
226
+ if stem->String.endsWith("Behavior") {
227
+ aggregateBehaviors->Array.push(stem)
228
+ } else {
229
+ aggregateSpecs->Array.push(stem)
230
+ aggregateSpecRelPaths->Dict.set(stem, relPath)
231
+ }
232
+ | ReadModel =>
233
+ if stem->String.endsWith("Projections") {
234
+ Dict.set(projectionsByRelPath, stem, relPath)
235
+ } else {
236
+ readModelStems->Array.push(stem)
237
+ }
238
+ | ReadModelStream =>
239
+ if stem->String.endsWith("Projections") {
240
+ Dict.set(projectionsByRelPath, stem, relPath)
241
+ } else {
242
+ readModelStreamStems->Array.push(stem)
243
+ }
244
+ | Task => tasks->Array.push(stem)
245
+ | ExtensionPoint => epFiles->Array.push({stem, componentType, epGroup, relPath})
246
+ | Extension => extensions->Array.push(stem)
247
+ }
248
+ })
249
+
250
+ // ── Aggregate pairing ──────────────────────────────────────────────────────
251
+ // Tries the post-Phase-3.1 `<Spec>_Behavior` form first, then falls back
252
+ // to the legacy `<Spec>Behavior` (no underscore).
253
+ let aggregates =
254
+ aggregateSpecs
255
+ ->sortedStems
256
+ ->Array.filterMap(spec => {
257
+ let underscored = spec ++ "_Behavior"
258
+ let bare = spec ++ "Behavior"
259
+ let behaviorStem = if aggregateBehaviors->Array.includes(underscored) {
260
+ Some(underscored)
261
+ } else if aggregateBehaviors->Array.includes(bare) {
262
+ Some(bare)
263
+ } else {
264
+ None
265
+ }
266
+ switch behaviorStem {
267
+ | Some(behavior) =>
268
+ let isAsync = switch Dict.get(aggregateSpecRelPaths, spec) {
269
+ | None => false
270
+ | Some(relPath) => hasAsyncAttribute(Generator_Node.join([srcDir, relPath]))
271
+ }
272
+ Some({
273
+ spec,
274
+ behavior,
275
+ eventMappings: Dict.get(eventMappings, spec),
276
+ isAsync,
277
+ })
278
+ | None =>
279
+ Console.warn("Generator: Aggregate spec `" ++ spec ++ "` has no matching `" ++ underscored ++ "` or `" ++ bare ++ "` — skipping")
280
+ None
281
+ }
282
+ })
283
+
284
+ // ── ReadModel pairing ──────────────────────────────────────────────────────
285
+ // Pairs spec `<Plural>` with impl `<Plural>_Projections.res`. The legacy
286
+ // unpaired forms (`<Plural>ReadModel.res` / `<Plural>Projections.res`) are
287
+ // no longer recognised — see PR3-5 of the close-ppx-gaps-and-unify-naming
288
+ // plan for the migration; downstream codebases should rename to the
289
+ // underscored form.
290
+ let pairReadModel = (~stream: bool, rm: string): option<readModelDef> => {
291
+ // Strip optional ReadModel suffix to get the base plural name.
292
+ let baseName = if rm->String.endsWith("ReadModel") {
293
+ rm->String.slice(~start=0, ~end=rm->String.length - 9)
294
+ } else {
295
+ rm
296
+ }
297
+ let underscoredProj = baseName ++ "_Projections"
298
+ switch Dict.get(projectionsByRelPath, underscoredProj) {
299
+ | None =>
300
+ Console.warn("Generator: ReadModel `" ++ rm ++ "` has no matching `" ++ underscoredProj ++ ".res` — skipping")
301
+ None
302
+ | Some(_) =>
303
+ Some({readModel: rm, projections: underscoredProj, stream})
304
+ }
305
+ }
306
+ let readModels = Array.concat(
307
+ readModelStems->sortedStems->Array.filterMap(rm => pairReadModel(~stream=false, rm)),
308
+ readModelStreamStems->sortedStems->Array.filterMap(rm => pairReadModel(~stream=true, rm)),
309
+ )
310
+
311
+ // ── ExtensionPoint grouping ────────────────────────────────────────────────
312
+ let epByGroup: Dict.t<array<string>> = Dict.make()
313
+ let flatEpMappings: array<string> = []
314
+
315
+ epFiles->Array.forEach(({stem, epGroup}) => {
316
+ switch epGroup {
317
+ | None => flatEpMappings->Array.push(stem)
318
+ | Some(g) =>
319
+ let arr = Dict.get(epByGroup, g)->Option.getOr({
320
+ let a: array<string> = []
321
+ Dict.set(epByGroup, g, a)
322
+ a
323
+ })
324
+ arr->Array.push(stem)
325
+ }
326
+ })
327
+
328
+ let extensionPoints: array<extensionPointDef> = []
329
+
330
+ if flatEpMappings->Array.length > 0 {
331
+ extensionPoints->Array.push({group: None, mappings: flatEpMappings->sortedStems})
332
+ }
333
+
334
+ Dict.toArray(epByGroup)->Array.forEach(((group, mappings)) => {
335
+ extensionPoints->Array.push({group: Some(group), mappings: mappings->sortedStems})
336
+ })
337
+
338
+ // ── targetName extraction ──────────────────────────────────────────────────
339
+ let buildTargets = (stems: array<string>, relPaths: Dict.t<string>): Dict.t<option<string>> => {
340
+ let dict = Dict.make()
341
+ stems->Array.forEach(stem => {
342
+ let target = switch Dict.get(relPaths, stem) {
343
+ | None => None
344
+ | Some(relPath) => extractTargetName(Generator_Node.join([srcDir, relPath]))
345
+ }
346
+ dict->Dict.set(stem, target)
347
+ })
348
+ dict
349
+ }
350
+
351
+ {
352
+ stateChangeSlices: stateChangeSlices->sortedStems,
353
+ stateViewSlices: stateViewSlices->sortedStems,
354
+ stateViewSlicesStream: stateViewSlicesStream->sortedStems,
355
+ automationSlices: automationSlices->sortedStems,
356
+ inboundTranslationSlices: inboundTranslationSlices->sortedStems,
357
+ outboundTranslationSlices: outboundTranslationSlices->sortedStems,
358
+ automationSliceTargets: buildTargets(automationSlices, automationSliceRelPaths),
359
+ inboundTranslationSliceTargets: buildTargets(
360
+ inboundTranslationSlices,
361
+ inboundTranslationSliceRelPaths,
362
+ ),
363
+ outboundTranslationSliceTargets: buildTargets(
364
+ outboundTranslationSlices,
365
+ outboundTranslationSliceRelPaths,
366
+ ),
367
+ asyncStateChangeSlices: {
368
+ let d = Dict.make()
369
+ stateChangeSlices->Array.forEach(stem => {
370
+ switch Dict.get(stateChangeSliceRelPaths, stem) {
371
+ | None => ()
372
+ | Some(relPath) =>
373
+ if hasAsyncAttribute(Generator_Node.join([srcDir, relPath])) {
374
+ d->Dict.set(stem, true)
375
+ }
376
+ }
377
+ })
378
+ d
379
+ },
380
+ aggregates,
381
+ readModels,
382
+ tasks: tasks->sortedStems,
383
+ extensionPoints,
384
+ extensions: extensions->sortedStems,
385
+ }
386
+ }