@reventlessdev/reventless-spec 3.0.0-alpha.63 → 3.0.0-alpha.65

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.
@@ -1,61 +1,9 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Nodepath from "node:path";
4
+ import * as ComponentKind$Reventless from "../components/ComponentKind.res.mjs";
4
5
  import * as Generator_Node$Reventless from "./Generator_Node.res.mjs";
5
6
 
6
- function folderToComponentType(folder) {
7
- switch (folder) {
8
- case "Aggregate" :
9
- case "Aggregates" :
10
- return "Aggregate";
11
- case "Automation" :
12
- case "AutomationSlice" :
13
- case "AutomationSlices" :
14
- case "Automations" :
15
- return "AutomationSlice";
16
- case "ExtensionPoint" :
17
- case "ExtensionPoints" :
18
- return "ExtensionPoint";
19
- case "Extension" :
20
- case "Extensions" :
21
- return "Extension";
22
- case "InboundTranslation" :
23
- case "InboundTranslationSlice" :
24
- case "InboundTranslationSlices" :
25
- case "InboundTranslations" :
26
- return "InboundTranslationSlice";
27
- case "OutboundTranslation" :
28
- case "OutboundTranslationSlice" :
29
- case "OutboundTranslationSlices" :
30
- case "OutboundTranslations" :
31
- return "OutboundTranslationSlice";
32
- case "ReadModelStream" :
33
- case "ReadModelStreams" :
34
- return "ReadModelStream";
35
- case "ReadModel" :
36
- case "ReadModels" :
37
- return "ReadModel";
38
- case "StateChange" :
39
- case "StateChangeSlice" :
40
- case "StateChangeSlices" :
41
- case "StateChanges" :
42
- return "StateChangeSlice";
43
- case "StateViewSliceStream" :
44
- case "StateViewSliceStreams" :
45
- return "StateViewSliceStream";
46
- case "StateView" :
47
- case "StateViewSlice" :
48
- case "StateViewSlices" :
49
- case "StateViews" :
50
- return "StateViewSlice";
51
- case "Task" :
52
- case "Tasks" :
53
- return "Task";
54
- default:
55
- return;
56
- }
57
- }
58
-
59
7
  function isAlwaysExcludedDir(name) {
60
8
  if (name === "Plugin" || name === "tests") {
61
9
  return true;
@@ -146,7 +94,7 @@ function walkDir(dir, relDir, parentComponentType, epGroup, exclude, acc) {
146
94
  if (isAlwaysExcludedDir(entryName)) {
147
95
  return;
148
96
  }
149
- let ct = folderToComponentType(entryName);
97
+ let ct = ComponentKind$Reventless.folderToKind(entryName);
150
98
  if (ct === undefined) {
151
99
  return walkDir(Nodepath.join(dir, entryName), relPath, parentComponentType, epGroup, exclude, acc);
152
100
  }
@@ -199,6 +147,8 @@ function scan(srcDir, exclude) {
199
147
  return acc;
200
148
  }
201
149
 
150
+ let folderToComponentType = ComponentKind$Reventless.folderToKind;
151
+
202
152
  export {
203
153
  folderToComponentType,
204
154
  isAlwaysExcludedDir,
@@ -57,6 +57,12 @@ type resolved = {
57
57
  // StateChangeSlice stems carrying `@@reventless.async` — codegen emits
58
58
  // `Platform.StateChangeSlice.MakeAsync` for these (CommandPending response).
59
59
  asyncStateChangeSlices: Dict.t<bool>,
60
+ // Spec names of StateChangeSlices / StateViewSlices (incl. Stream) carrying
61
+ // `@@reventless.systemCallable` — codegen threads them as
62
+ // `~systemCallableComponents` on `Platform.Plugin.make` so the AWS provider
63
+ // emits the dual-auth `@aws_cognito_user_pools(...) @aws_iam` directive on
64
+ // their GraphQL fields (deploy-time SigV4 system callers). Sorted.
65
+ systemCallableComponents: array<string>,
60
66
  aggregates: array<aggregateDef>,
61
67
  readModels: array<readModelDef>,
62
68
  tasks: array<string>,
@@ -139,22 +145,21 @@ let extractTargetName = (filePath: string): option<string> => {
139
145
  }
140
146
  }
141
147
 
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 => {
148
+ // Returns true if the spec file declares the given file-level attribute
149
+ // (e.g. `@@reventless.async`, `@@reventless.systemCallable`). Falls back to
150
+ // false on read errors so a transient FS failure can't flip a component
151
+ // into the opt-in behavior — the safe default is the attribute being absent.
152
+ let hasFileAttribute = (filePath: string, ~attr: string): bool => {
147
153
  try {
148
154
  let content = Generator_Node.readFileSync(filePath)
149
155
  let found = ref(false)
150
156
  content->String.split("\n")->Array.forEach(line => {
151
157
  let trimmed = line->String.trimStart
152
- // Match `@@reventless.async` and tolerate a payload arg in the future.
158
+ // Match the bare attribute and tolerate a payload arg in the future.
153
159
  if (
154
- trimmed->String.startsWith("@@reventless.async ") ||
155
- trimmed->String.startsWith("@@reventless.async(") ||
156
- trimmed === "@@reventless.async" ||
157
- trimmed->String.startsWith("@@reventless.async\n")
160
+ trimmed->String.startsWith(attr ++ " ") ||
161
+ trimmed->String.startsWith(attr ++ "(") ||
162
+ trimmed === attr
158
163
  ) {
159
164
  found := true
160
165
  }
@@ -165,6 +170,73 @@ let hasAsyncAttribute = (filePath: string): bool => {
165
170
  }
166
171
  }
167
172
 
173
+ // `@@reventless.async` — Aggregate or StateChangeSlice opt-in to async
174
+ // command dispatch (CommandPending response).
175
+ let hasAsyncAttribute = (filePath: string): bool =>
176
+ hasFileAttribute(filePath, ~attr="@@reventless.async")
177
+
178
+ // `@@reventless.systemCallable` — StateChangeSlice / StateViewSlice opt-in to
179
+ // deploy-time IAM (SigV4) invocation of the component's GraphQL fields.
180
+ let hasSystemCallableAttribute = (filePath: string): bool =>
181
+ hasFileAttribute(filePath, ~attr="@@reventless.systemCallable")
182
+
183
+ // The effective spec name of a file: the explicit `@@reventless.spec("Name")`
184
+ // payload when present, the filename stem otherwise (mirroring the PPX's name
185
+ // derivation). Needed because `systemCallableComponents` is matched against
186
+ // `Spec.name` at deploy time — a stem-only value would silently miss specs
187
+ // with an explicit name override.
188
+ let effectiveSpecName = (filePath: string, ~stem: string): string => {
189
+ try {
190
+ let content = Generator_Node.readFileSync(filePath)
191
+ let name = ref(None)
192
+ content->String.split("\n")->Array.forEach(line => {
193
+ let trimmed = line->String.trimStart
194
+ if trimmed->String.startsWith("@@reventless.spec(") {
195
+ let firstQuote = trimmed->String.indexOf("\"")
196
+ let lastQuote = trimmed->String.lastIndexOf("\"")
197
+ if firstQuote >= 0 && lastQuote > firstQuote {
198
+ name := Some(trimmed->String.slice(~start=firstQuote + 1, ~end=lastQuote))
199
+ }
200
+ }
201
+ })
202
+ name.contents->Option.getOr(stem)
203
+ } catch {
204
+ | _ => stem
205
+ }
206
+ }
207
+
208
+ // Partition the ExtensionPoint mapping files into flat (ungrouped) and grouped
209
+ // `extensionPointDef`s: every mapping sharing an `epGroup` collects into one
210
+ // grouped entry; the ungrouped ones share a single flat (`group: None`) entry.
211
+ // Pure (no I/O) so the grouping is unit-testable — in particular the A8 fix:
212
+ // `Option.getOr`'s eager default previously re-created the group's array every
213
+ // iteration, so a group with ≥2 mappings collapsed to `[]` and the generator
214
+ // emitted a `Plugin.res` referencing a never-generated module.
215
+ let groupExtensionPoints = (epFiles: array<Discovery.discoveredFile>): array<extensionPointDef> => {
216
+ let epByGroup: Dict.t<array<string>> = Dict.make()
217
+ let flatEpMappings: array<string> = []
218
+
219
+ epFiles->Array.forEach(({stem, epGroup}) =>
220
+ switch epGroup {
221
+ | None => flatEpMappings->Array.push(stem)
222
+ | Some(g) =>
223
+ switch Dict.get(epByGroup, g) {
224
+ | Some(arr) => arr->Array.push(stem)
225
+ | None => Dict.set(epByGroup, g, [stem])
226
+ }
227
+ }
228
+ )
229
+
230
+ let extensionPoints: array<extensionPointDef> = []
231
+ if flatEpMappings->Array.length > 0 {
232
+ extensionPoints->Array.push({group: None, mappings: flatEpMappings->sortedStems})
233
+ }
234
+ Dict.toArray(epByGroup)->Array.forEach(((group, mappings)) =>
235
+ extensionPoints->Array.push({group: Some(group), mappings: mappings->sortedStems})
236
+ )
237
+ extensionPoints
238
+ }
239
+
168
240
  let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): resolved => {
169
241
  let eventMappings = findEventMappings(~srcDir)
170
242
 
@@ -181,6 +253,8 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
181
253
  let aggregateSpecs: array<string> = []
182
254
  let aggregateSpecRelPaths: Dict.t<string> = Dict.make()
183
255
  let stateChangeSliceRelPaths: Dict.t<string> = Dict.make()
256
+ // Shared by StateViewSlice and StateViewSliceStream (stems are unique).
257
+ let stateViewSliceRelPaths: Dict.t<string> = Dict.make()
184
258
  let aggregateBehaviors: array<string> = []
185
259
  let readModelStems: array<string> = []
186
260
  // Stream-enabled read model spec stems (the `ReadModelStream/` folder).
@@ -204,9 +278,15 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
204
278
  stateChangeSliceRelPaths->Dict.set(stem, relPath)
205
279
  }
206
280
  | StateViewSlice =>
207
- if !isImplStem(stem) { stateViewSlices->Array.push(stem) }
281
+ if !isImplStem(stem) {
282
+ stateViewSlices->Array.push(stem)
283
+ stateViewSliceRelPaths->Dict.set(stem, relPath)
284
+ }
208
285
  | StateViewSliceStream =>
209
- if !isImplStem(stem) { stateViewSlicesStream->Array.push(stem) }
286
+ if !isImplStem(stem) {
287
+ stateViewSlicesStream->Array.push(stem)
288
+ stateViewSliceRelPaths->Dict.set(stem, relPath)
289
+ }
210
290
  | AutomationSlice =>
211
291
  if !isImplStem(stem) {
212
292
  automationSlices->Array.push(stem)
@@ -309,35 +389,7 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
309
389
  )
310
390
 
311
391
  // ── 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
- // `Option.getOr`'s default is evaluated eagerly, so the previous shape
320
- // re-created and re-set a fresh empty array every iteration — overwriting
321
- // the group's real entry and pushing onto a now-detached array. A group
322
- // with ≥2 mappings ended up empty, emitting a Plugin.res that referenced a
323
- // never-generated module (`Array.getUnsafe(0)` on `[]`). Use a switch so
324
- // the create branch runs only when the group is genuinely absent.
325
- switch Dict.get(epByGroup, g) {
326
- | Some(arr) => arr->Array.push(stem)
327
- | None => Dict.set(epByGroup, g, [stem])
328
- }
329
- }
330
- })
331
-
332
- let extensionPoints: array<extensionPointDef> = []
333
-
334
- if flatEpMappings->Array.length > 0 {
335
- extensionPoints->Array.push({group: None, mappings: flatEpMappings->sortedStems})
336
- }
337
-
338
- Dict.toArray(epByGroup)->Array.forEach(((group, mappings)) => {
339
- extensionPoints->Array.push({group: Some(group), mappings: mappings->sortedStems})
340
- })
392
+ let extensionPoints = groupExtensionPoints(epFiles)
341
393
 
342
394
  // ── targetName extraction ──────────────────────────────────────────────────
343
395
  let buildTargets = (stems: array<string>, relPaths: Dict.t<string>): Dict.t<option<string>> => {
@@ -381,6 +433,24 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
381
433
  })
382
434
  d
383
435
  },
436
+ systemCallableComponents: {
437
+ let names = []
438
+ let collect = (stems: array<string>, relPaths: Dict.t<string>) =>
439
+ stems->Array.forEach(stem => {
440
+ switch Dict.get(relPaths, stem) {
441
+ | None => ()
442
+ | Some(relPath) =>
443
+ let filePath = Generator_Node.join([srcDir, relPath])
444
+ if hasSystemCallableAttribute(filePath) {
445
+ names->Array.push(effectiveSpecName(filePath, ~stem))
446
+ }
447
+ }
448
+ })
449
+ collect(stateChangeSlices, stateChangeSliceRelPaths)
450
+ collect(stateViewSlices, stateViewSliceRelPaths)
451
+ collect(stateViewSlicesStream, stateViewSliceRelPaths)
452
+ names->sortedStems
453
+ },
384
454
  aggregates,
385
455
  readModels,
386
456
  tasks: tasks->sortedStems,
@@ -3,6 +3,7 @@
3
3
  import * as Nodefs from "node:fs";
4
4
  import * as Nodepath from "node:path";
5
5
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
7
  import * as Generator_Node$Reventless from "./Generator_Node.res.mjs";
7
8
 
8
9
  let implSuffixForStateChange = "_Behavior";
@@ -112,7 +113,7 @@ function extractTargetName(filePath) {
112
113
  }
113
114
  }
114
115
 
115
- function hasAsyncAttribute(filePath) {
116
+ function hasFileAttribute(filePath, attr) {
116
117
  try {
117
118
  let content = Nodefs.readFileSync(filePath, "utf8");
118
119
  let found = {
@@ -120,7 +121,7 @@ function hasAsyncAttribute(filePath) {
120
121
  };
121
122
  content.split("\n").forEach(line => {
122
123
  let trimmed = line.trimStart();
123
- if (trimmed.startsWith("@@reventless.async ") || trimmed.startsWith("@@reventless.async(") || trimmed === "@@reventless.async" || trimmed.startsWith("@@reventless.async\n")) {
124
+ if (trimmed.startsWith(attr + " ") || trimmed.startsWith(attr + "(") || trimmed === attr) {
124
125
  found.contents = true;
125
126
  return;
126
127
  }
@@ -131,6 +132,71 @@ function hasAsyncAttribute(filePath) {
131
132
  }
132
133
  }
133
134
 
135
+ function hasAsyncAttribute(filePath) {
136
+ return hasFileAttribute(filePath, "@@reventless.async");
137
+ }
138
+
139
+ function hasSystemCallableAttribute(filePath) {
140
+ return hasFileAttribute(filePath, "@@reventless.systemCallable");
141
+ }
142
+
143
+ function effectiveSpecName(filePath, stem) {
144
+ try {
145
+ let content = Nodefs.readFileSync(filePath, "utf8");
146
+ let name = {
147
+ contents: undefined
148
+ };
149
+ content.split("\n").forEach(line => {
150
+ let trimmed = line.trimStart();
151
+ if (!trimmed.startsWith("@@reventless.spec(")) {
152
+ return;
153
+ }
154
+ let firstQuote = trimmed.indexOf("\"");
155
+ let lastQuote = trimmed.lastIndexOf("\"");
156
+ if (firstQuote >= 0 && lastQuote > firstQuote) {
157
+ name.contents = trimmed.slice(firstQuote + 1 | 0, lastQuote);
158
+ return;
159
+ }
160
+ });
161
+ return Stdlib_Option.getOr(name.contents, stem);
162
+ } catch (exn) {
163
+ return stem;
164
+ }
165
+ }
166
+
167
+ function groupExtensionPoints(epFiles) {
168
+ let epByGroup = {};
169
+ let flatEpMappings = [];
170
+ epFiles.forEach(param => {
171
+ let epGroup = param.epGroup;
172
+ let stem = param.stem;
173
+ if (epGroup !== undefined) {
174
+ let arr = epByGroup[epGroup];
175
+ if (arr !== undefined) {
176
+ arr.push(stem);
177
+ } else {
178
+ epByGroup[epGroup] = [stem];
179
+ }
180
+ return;
181
+ }
182
+ flatEpMappings.push(stem);
183
+ });
184
+ let extensionPoints = [];
185
+ if (flatEpMappings.length !== 0) {
186
+ extensionPoints.push({
187
+ group: undefined,
188
+ mappings: sortedStems(flatEpMappings)
189
+ });
190
+ }
191
+ Object.entries(epByGroup).forEach(param => {
192
+ extensionPoints.push({
193
+ group: param[0],
194
+ mappings: sortedStems(param[1])
195
+ });
196
+ });
197
+ return extensionPoints;
198
+ }
199
+
134
200
  function resolve(discovered, srcDir) {
135
201
  let eventMappings = findEventMappings(srcDir);
136
202
  let stateChangeSlices = [];
@@ -145,6 +211,7 @@ function resolve(discovered, srcDir) {
145
211
  let aggregateSpecs = [];
146
212
  let aggregateSpecRelPaths = {};
147
213
  let stateChangeSliceRelPaths = {};
214
+ let stateViewSliceRelPaths = {};
148
215
  let aggregateBehaviors = [];
149
216
  let readModelStems = [];
150
217
  let readModelStreamStems = [];
@@ -168,6 +235,7 @@ function resolve(discovered, srcDir) {
168
235
  case "StateViewSlice" :
169
236
  if (!isImplStem(stem)) {
170
237
  stateViewSlices.push(stem);
238
+ stateViewSliceRelPaths[stem] = relPath;
171
239
  return;
172
240
  } else {
173
241
  return;
@@ -175,6 +243,7 @@ function resolve(discovered, srcDir) {
175
243
  case "StateViewSliceStream" :
176
244
  if (!isImplStem(stem)) {
177
245
  stateViewSlicesStream.push(stem);
246
+ stateViewSliceRelPaths[stem] = relPath;
178
247
  return;
179
248
  } else {
180
249
  return;
@@ -249,7 +318,7 @@ function resolve(discovered, srcDir) {
249
318
  );
250
319
  if (behaviorStem !== undefined) {
251
320
  let relPath = aggregateSpecRelPaths[spec];
252
- let isAsync = relPath !== undefined ? hasAsyncAttribute(Nodepath.join(srcDir, relPath)) : false;
321
+ let isAsync = relPath !== undefined ? hasFileAttribute(Nodepath.join(srcDir, relPath), "@@reventless.async") : false;
253
322
  return {
254
323
  spec: spec,
255
324
  behavior: behaviorStem,
@@ -275,35 +344,7 @@ function resolve(discovered, srcDir) {
275
344
  }
276
345
  };
277
346
  let readModels = Stdlib_Array.filterMap(sortedStems(readModelStems), rm => pairReadModel(false, rm)).concat(Stdlib_Array.filterMap(sortedStems(readModelStreamStems), rm => pairReadModel(true, rm)));
278
- let epByGroup = {};
279
- let flatEpMappings = [];
280
- epFiles.forEach(param => {
281
- let epGroup = param.epGroup;
282
- let stem = param.stem;
283
- if (epGroup !== undefined) {
284
- let arr = epByGroup[epGroup];
285
- if (arr !== undefined) {
286
- arr.push(stem);
287
- } else {
288
- epByGroup[epGroup] = [stem];
289
- }
290
- return;
291
- }
292
- flatEpMappings.push(stem);
293
- });
294
- let extensionPoints = [];
295
- if (flatEpMappings.length !== 0) {
296
- extensionPoints.push({
297
- group: undefined,
298
- mappings: sortedStems(flatEpMappings)
299
- });
300
- }
301
- Object.entries(epByGroup).forEach(param => {
302
- extensionPoints.push({
303
- group: param[0],
304
- mappings: sortedStems(param[1])
305
- });
306
- });
347
+ let extensionPoints = groupExtensionPoints(epFiles);
307
348
  let buildTargets = (stems, relPaths) => {
308
349
  let dict = {};
309
350
  stems.forEach(stem => {
@@ -314,6 +355,20 @@ function resolve(discovered, srcDir) {
314
355
  return dict;
315
356
  };
316
357
  let d = {};
358
+ let names = [];
359
+ let collect = (stems, relPaths) => {
360
+ stems.forEach(stem => {
361
+ let relPath = relPaths[stem];
362
+ if (relPath === undefined) {
363
+ return;
364
+ }
365
+ let filePath = Nodepath.join(srcDir, relPath);
366
+ if (hasFileAttribute(filePath, "@@reventless.systemCallable")) {
367
+ names.push(effectiveSpecName(filePath, stem));
368
+ return;
369
+ }
370
+ });
371
+ };
317
372
  return {
318
373
  stateChangeSlices: sortedStems(stateChangeSlices),
319
374
  stateViewSlices: sortedStems(stateViewSlices),
@@ -326,11 +381,12 @@ function resolve(discovered, srcDir) {
326
381
  outboundTranslationSliceTargets: buildTargets(outboundTranslationSlices, outboundTranslationSliceRelPaths),
327
382
  asyncStateChangeSlices: (stateChangeSlices.forEach(stem => {
328
383
  let relPath = stateChangeSliceRelPaths[stem];
329
- if (relPath !== undefined && hasAsyncAttribute(Nodepath.join(srcDir, relPath))) {
384
+ if (relPath !== undefined && hasFileAttribute(Nodepath.join(srcDir, relPath), "@@reventless.async")) {
330
385
  d[stem] = true;
331
386
  return;
332
387
  }
333
388
  }), d),
389
+ systemCallableComponents: (collect(stateChangeSlices, stateChangeSliceRelPaths), collect(stateViewSlices, stateViewSliceRelPaths), collect(stateViewSlicesStream, stateViewSliceRelPaths), sortedStems(names)),
334
390
  aggregates: aggregates,
335
391
  readModels: readModels,
336
392
  tasks: sortedStems(tasks),
@@ -349,7 +405,11 @@ export {
349
405
  findEventMappings,
350
406
  sortedStems,
351
407
  extractTargetName,
408
+ hasFileAttribute,
352
409
  hasAsyncAttribute,
410
+ hasSystemCallableAttribute,
411
+ effectiveSpecName,
412
+ groupExtensionPoints,
353
413
  resolve,
354
414
  }
355
415
  /* node:fs Not a pure module */
@@ -72,6 +72,14 @@ module type T = {
72
72
  */
73
73
  let decide: (state, Spec.command) => result<array<Spec.event>, Spec.error>
74
74
 
75
+ /** Persisted-snapshot configuration (docs/plans/aggregate-snapshotting.md).
76
+ `None` — the default, auto-injected by `@@reventless.behavior` — keeps
77
+ full replay; `Some({interval, stateSchema})` writes a keep-one snapshot
78
+ every `interval` events and seeds cold replays from the latest one.
79
+ Enable with `@@reventless.snapshots(<interval>)` (requires
80
+ `@schema type state`) or a manual binding. */
81
+ let snapshot: option<Snapshot.config<state>>
82
+
75
83
  /** File URL of this module (`import.meta.url`). Used by AWS builders to derive
76
84
  the npm specifier for runtime dynamic imports. */
77
85
  let moduleUrl: string