@reventlessdev/reventless-spec 3.0.0-alpha.76 → 3.0.0-alpha.78

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.78 (2026-07-16)
7
+
8
+ ### Features
9
+
10
+ * **aws:** consume per-component runtime hints; fold into shared command Lambdas ([7ba2485](https://github.com/ReventlessDev/reventless-core/commit/7ba248579f2472f2fc847b26033927199353f3e6))
11
+ * **core:** thread per-component runtime hints from plugin.json (spec/infra/core/local) ([a94f419](https://github.com/ReventlessDev/reventless-core/commit/a94f4199c4ac7eeeb24dd0a71d58fb73c1e514e6))
12
+
13
+
14
+ # 3.0.0-alpha.77 (2026-07-15)
15
+
16
+ ### Performance Improvements
17
+
18
+ * **spec:** reverse-convert DcbTag extraction instead of stringify round-trip ([4311c7b](https://github.com/ReventlessDev/reventless-core/commit/4311c7b863f99e582911ced529d77dca7cc36381))
19
+
20
+
6
21
  # 3.0.0-alpha.76 (2026-07-12)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.76",
3
+ "version": "3.0.0-alpha.78",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -21,11 +21,11 @@
21
21
  "sury-ppx": "11.0.0-alpha.2"
22
22
  },
23
23
  "devDependencies": {
24
- "rescript": "^12.3.0",
25
- "@reventlessdev/rescript-jest": "1.0.0-alpha.7"
24
+ "rescript": "12.3.0",
25
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.8"
26
26
  },
27
27
  "peerDependencies": {
28
- "rescript": "^12.3.0"
28
+ "rescript": "12.3.0"
29
29
  },
30
30
  "publishConfig": {
31
31
  "registry": "https://registry.npmjs.org"
@@ -399,7 +399,11 @@ let extractTagsFromJson = (schema: S.t<unknown>, json: JSON.t): array<tag> =>
399
399
  /**
400
400
  Extracts DCB tags from a typed event value using its sury schema.
401
401
 
402
- Serializes the value to JSON first, then delegates to `extractTagsFromJson`.
402
+ Reverse-converts the value to JSON through its schema, then delegates to
403
+ `extractTagsFromJson`. Using the schema-aware reverse convert (instead of a
404
+ `JSON.stringifyAny -> parseOrThrow` string round-trip) avoids serializing the
405
+ whole event to a string per message and emits the schema's serialized field
406
+ keys — so `@as`-renamed tagged fields are found by the same-schema walker.
403
407
 
404
408
  @example
405
409
  ```rescript
@@ -410,10 +414,8 @@ let tags = DcbTag.extractTags(
410
414
  // [{key: "categoryId", value: "cat-1"}]
411
415
  ```
412
416
  */
413
- let extractTags = (schema: S.t<'a>, value: 'a): array<tag> => {
414
- let json = value->JSON.stringifyAny->Option.getOrThrow->JSON.parseOrThrow
415
- extractTagsFromJson(schema->toUnknownSchema, json)
416
- }
417
+ let extractTags = (schema: S.t<'a>, value: 'a): array<tag> =>
418
+ extractTagsFromJson(schema->toUnknownSchema, value->S.reverseConvertToJsonOrThrow(schema))
417
419
 
418
420
  // --- Extract variant constructor names from a tagged union schema ---
419
421
  // For a variant type like `type event = ItemCreated({...}) | ItemRenamed({...})`,
@@ -596,10 +598,8 @@ let tags = DcbTag.extractTagsExpanded(
596
598
  // [{key: "orderId", value: "ord-1"}, {key: "productIds", value: "p1"}, {key: "productIds", value: "p2"}]
597
599
  ```
598
600
  */
599
- let extractTagsExpanded = (schema: S.t<'a>, value: 'a): array<tag> => {
600
- let json = value->JSON.stringifyAny->Option.getOrThrow->JSON.parseOrThrow
601
- extractTagsFromJsonExpanded(schema->toUnknownSchema, json)
602
- }
601
+ let extractTagsExpanded = (schema: S.t<'a>, value: 'a): array<tag> =>
602
+ extractTagsFromJsonExpanded(schema->toUnknownSchema, value->S.reverseConvertToJsonOrThrow(schema))
603
603
 
604
604
  // --- Automatic query construction from command schema ---
605
605
 
@@ -202,8 +202,7 @@ function extractTagsFromJson(schema, json) {
202
202
  }
203
203
 
204
204
  function extractTags(schema, value) {
205
- let json = JSON.parse(Stdlib_Option.getOrThrow(JSON.stringify(value), undefined));
206
- return extractTagsFromJson(schema, json);
205
+ return extractTagsFromJson(schema, S.reverseConvertToJsonOrThrow(value, schema));
207
206
  }
208
207
 
209
208
  function extractVariantNames(schema) {
@@ -345,8 +344,7 @@ function extractTagsFromJsonExpanded(schema, json) {
345
344
  }
346
345
 
347
346
  function extractTagsExpanded(schema, value) {
348
- let json = JSON.parse(Stdlib_Option.getOrThrow(JSON.stringify(value), undefined));
349
- return extractTagsFromJsonExpanded(schema, json);
347
+ return extractTagsFromJsonExpanded(schema, S.reverseConvertToJsonOrThrow(value, schema));
350
348
  }
351
349
 
352
350
  function hasTaggedArrayFields(schema) {
@@ -368,23 +368,6 @@ type pluginStructure = {
368
368
 
369
369
  let pluginStructureOptionSchema = _jsNullable(pluginStructureSchema, ())
370
370
 
371
- // ── Event graph types (cross-plugin component graph) ──────────────────────────
372
-
373
- @schema
374
- type graphNode = {pluginName: string, componentName: string, kind: string}
375
-
376
- @schema
377
- type graphEdge = {
378
- source: graphNode,
379
- target: graphNode,
380
- mechanism: string,
381
- viaEvents: array<string>,
382
- implicit: bool,
383
- }
384
-
385
- @schema
386
- type platformEventGraph = {nodes: array<graphNode>, edges: array<graphEdge>}
387
-
388
371
  /**
389
372
  The self-description of a deployed plugin, persisted in the plugin's event store.
390
373
 
@@ -190,25 +190,6 @@ let pluginStructureSchema = S.schema(s => ({
190
190
 
191
191
  let pluginStructureOptionSchema = SuryResMjs.js_nullable(pluginStructureSchema);
192
192
 
193
- let graphNodeSchema = S.schema(s => ({
194
- pluginName: s.m(S.string),
195
- componentName: s.m(S.string),
196
- kind: s.m(S.string)
197
- }));
198
-
199
- let graphEdgeSchema = S.schema(s => ({
200
- source: s.m(graphNodeSchema),
201
- target: s.m(graphNodeSchema),
202
- mechanism: s.m(S.string),
203
- viaEvents: s.m(S.array(S.string)),
204
- implicit: s.m(S.bool)
205
- }));
206
-
207
- let platformEventGraphSchema = S.schema(s => ({
208
- nodes: s.m(S.array(graphNodeSchema)),
209
- edges: s.m(S.array(graphEdgeSchema))
210
- }));
211
-
212
193
  let pluginDefinitionSchema = S.schema(s => ({
213
194
  id: s.m(S.string),
214
195
  name: s.m(S.string),
@@ -262,9 +243,6 @@ export {
262
243
  extensionPointDefArrayOptionSchema,
263
244
  pluginStructureSchema,
264
245
  pluginStructureOptionSchema,
265
- graphNodeSchema,
266
- graphEdgeSchema,
267
- platformEventGraphSchema,
268
246
  pluginDefinitionSchema,
269
247
  }
270
248
  /* pluginKindSchema Not a pure module */
@@ -249,6 +249,86 @@ let renderExtensionMakeParam = (extensions: array<string>): option<string> =>
249
249
  Some(" ~extensions=[" ++ entries->Array.join(", ") ++ "],")
250
250
  }
251
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
+
252
332
  let renderTaskMakeParam = (tasks: array<string>): option<string> =>
253
333
  if tasks->Array.length === 0 {
254
334
  None
@@ -705,8 +785,14 @@ let renderComposition = (
705
785
  ->Array.join(", ") ++ "],",
706
786
  )
707
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),
708
791
  ]
709
792
 
793
+ // Typo protection: warn on `runtime` keys that name no discovered component.
794
+ validateComponentRuntimeKeys(~resolved, ~componentRuntime=config.componentRuntime)
795
+
710
796
  makeParams->Array.filterMap(x => x)->Array.forEach(line => lines->Array.push(line))
711
797
  switch uiFragmentsParam {
712
798
  | Some(uiLines) => uiLines->Array.forEach(line => lines->Array.push(line))
@@ -178,6 +178,59 @@ function renderExtensionMakeParam(extensions) {
178
178
  return " ~extensions=[" + entries.join(", ") + "],";
179
179
  }
180
180
 
181
+ function renderRuntimeHints(h) {
182
+ let optInt = o => {
183
+ if (o !== undefined) {
184
+ return "Some(" + o.toString() + ")";
185
+ } else {
186
+ return "None";
187
+ }
188
+ };
189
+ return "{ReventlessInfra.RuntimeHints.memorySize: " + optInt(h.memorySize) + ", timeout: " + optInt(h.timeout) + "}";
190
+ }
191
+
192
+ function collectComponentNames(resolved) {
193
+ return [
194
+ resolved.aggregates.map(param => param.spec),
195
+ resolved.readModels.map(param => param.readModel),
196
+ resolved.stateChangeSlices,
197
+ resolved.stateViewSlices,
198
+ resolved.stateViewSlicesStream,
199
+ resolved.automationSlices,
200
+ resolved.outboundTranslationSlices,
201
+ resolved.inboundTranslationSlices,
202
+ resolved.tasks,
203
+ resolved.extensions,
204
+ resolved.extensionPoints.map(param => {
205
+ let group = param.group;
206
+ if (group !== undefined) {
207
+ return group;
208
+ } else {
209
+ return stripSuffix(param.mappings[0], "Mapping");
210
+ }
211
+ })
212
+ ].flat();
213
+ }
214
+
215
+ function validateComponentRuntimeKeys(resolved, componentRuntime) {
216
+ let known = collectComponentNames(resolved);
217
+ Object.keys(componentRuntime).forEach(name => {
218
+ if (!known.includes(name)) {
219
+ console.warn("Generator: plugin.json `runtime` override for `" + name + "` matches no component in this plugin. Known components: " + known.join(", "));
220
+ return;
221
+ }
222
+ });
223
+ }
224
+
225
+ function renderComponentRuntimeParam(componentRuntime) {
226
+ let entries = Object.entries(componentRuntime);
227
+ if (entries.length === 0) {
228
+ return;
229
+ }
230
+ let rendered = entries.map(param => "(\"" + param[0] + "\", " + renderRuntimeHints(param[1]) + ")").join(", ");
231
+ return " ~componentRuntime=Dict.fromArray([" + rendered + "]),";
232
+ }
233
+
181
234
  function renderTaskMakeParam(tasks) {
182
235
  if (tasks.length === 0) {
183
236
  return;
@@ -438,8 +491,10 @@ function renderComposition(config, resolved, componentChapters) {
438
491
  renderMakeParam("outboundTranslationSlices", resolved.outboundTranslationSlices, "Slice"),
439
492
  renderMakeParam("inboundTranslationSlices", resolved.inboundTranslationSlices, "Slice"),
440
493
  hasPluginStructure ? " ~pluginStructure=pluginStructure," : undefined,
441
- resolved.systemCallableComponents.length !== 0 ? " ~systemCallableComponents=[" + resolved.systemCallableComponents.map(n => "\"" + n + "\"").join(", ") + "]," : undefined
494
+ resolved.systemCallableComponents.length !== 0 ? " ~systemCallableComponents=[" + resolved.systemCallableComponents.map(n => "\"" + n + "\"").join(", ") + "]," : undefined,
495
+ renderComponentRuntimeParam(config.componentRuntime)
442
496
  ];
497
+ validateComponentRuntimeKeys(resolved, config.componentRuntime);
443
498
  Stdlib_Array.filterMap(makeParams, x => x).forEach(line => {
444
499
  lines.push(line);
445
500
  });
@@ -493,6 +548,10 @@ export {
493
548
  renderAggregateMakeParam,
494
549
  renderEpMakeParam,
495
550
  renderExtensionMakeParam,
551
+ renderRuntimeHints,
552
+ collectComponentNames,
553
+ validateComponentRuntimeKeys,
554
+ renderComponentRuntimeParam,
496
555
  renderTaskMakeParam,
497
556
  renderPluginStructureCall,
498
557
  validateUniqueSpecStems,
@@ -3,10 +3,22 @@
3
3
 
4
4
  type variant = Composition | Aws({compositionNamespace: string})
5
5
 
6
+ // Per-component runtime resource hints, parsed from the optional `runtime`
7
+ // block in plugin.json and keyed by component name. Both fields are optional;
8
+ // an omitted field falls through to the per-kind builder default. See
9
+ // docs/plans/configurable-component-runtime-resources.md.
10
+ type runtimeHints = {
11
+ memorySize: option<int>,
12
+ timeout: option<int>,
13
+ }
14
+
6
15
  type config = {
7
16
  name: string,
8
17
  heartbeatInterval: int,
9
18
  exclude: array<string>,
19
+ // Keyed by component name (e.g. "Customers", "PlaceOrder"). Empty when the
20
+ // plugin.json has no `runtime` block.
21
+ componentRuntime: dict<runtimeHints>,
10
22
  variant: variant,
11
23
  }
12
24
 
@@ -41,6 +53,31 @@ let getStrArrayField = (json: JSON.t, key: string): option<array<string>> =>
41
53
  ->Option.flatMap(JSON.Decode.array)
42
54
  ->Option.map(arr => arr->Array.filterMap(JSON.Decode.string))
43
55
 
56
+ // Parse the optional `runtime` object: each key is a component name, each value
57
+ // an object with optional `memorySize` / `timeout` ints. A missing block yields
58
+ // an empty dict; a non-object entry is skipped.
59
+ let getComponentRuntime = (json: JSON.t): dict<runtimeHints> => {
60
+ let result: dict<runtimeHints> = Dict.make()
61
+ json
62
+ ->JSON.Decode.object
63
+ ->Option.flatMap(d => d->Dict.get("runtime"))
64
+ ->Option.flatMap(JSON.Decode.object)
65
+ ->Option.forEach(runtimeObj =>
66
+ runtimeObj
67
+ ->Dict.toArray
68
+ ->Array.forEach(((componentName, entry)) =>
69
+ result->Dict.set(
70
+ componentName,
71
+ {
72
+ memorySize: getIntField(entry, "memorySize"),
73
+ timeout: getIntField(entry, "timeout"),
74
+ },
75
+ )
76
+ )
77
+ )
78
+ result
79
+ }
80
+
44
81
  let read = (~srcDir: string): config => {
45
82
  // Read package.json `name` from the parent of srcDir
46
83
  let packageJsonPath = Generator_Node.join([Generator_Node.dirname(srcDir), "package.json"])
@@ -63,6 +100,7 @@ let read = (~srcDir: string): config => {
63
100
  ->Option.flatMap(j => getIntField(j, "heartbeatInterval"))
64
101
  ->Option.getOr(5),
65
102
  exclude: pluginJson->Option.flatMap(j => getStrArrayField(j, "exclude"))->Option.getOr([]),
103
+ componentRuntime: pluginJson->Option.map(getComponentRuntime)->Option.getOr(Dict.make()),
66
104
  variant: Composition,
67
105
  }
68
106
  }
@@ -27,6 +27,20 @@ function getStrArrayField(json, key) {
27
27
  return Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), d => d[key]), Stdlib_JSON.Decode.array), arr => Stdlib_Array.filterMap(arr, Stdlib_JSON.Decode.string));
28
28
  }
29
29
 
30
+ function getComponentRuntime(json) {
31
+ let result = {};
32
+ Stdlib_Option.forEach(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), d => d["runtime"]), Stdlib_JSON.Decode.object), runtimeObj => {
33
+ Object.entries(runtimeObj).forEach(param => {
34
+ let entry = param[1];
35
+ result[param[0]] = {
36
+ memorySize: getIntField(entry, "memorySize"),
37
+ timeout: getIntField(entry, "timeout")
38
+ };
39
+ });
40
+ });
41
+ return result;
42
+ }
43
+
30
44
  function read(srcDir) {
31
45
  let packageJsonPath = Nodepath.join(Nodepath.dirname(srcDir), "package.json");
32
46
  let packageJsonName = Stdlib_Option.flatMap(readJson(packageJsonPath), j => getStrField(j, "name"));
@@ -36,6 +50,7 @@ function read(srcDir) {
36
50
  name: PluginName$Reventless.resolve(Stdlib_Option.flatMap(pluginJson, j => getStrField(j, "name")), packageJsonName),
37
51
  heartbeatInterval: Stdlib_Option.getOr(Stdlib_Option.flatMap(pluginJson, j => getIntField(j, "heartbeatInterval")), 5),
38
52
  exclude: Stdlib_Option.getOr(Stdlib_Option.flatMap(pluginJson, j => getStrArrayField(j, "exclude")), []),
53
+ componentRuntime: Stdlib_Option.getOr(Stdlib_Option.map(pluginJson, getComponentRuntime), {}),
39
54
  variant: "Composition"
40
55
  };
41
56
  }
@@ -48,6 +63,7 @@ export {
48
63
  getStrField,
49
64
  getIntField,
50
65
  getStrArrayField,
66
+ getComponentRuntime,
51
67
  read,
52
68
  }
53
69
  /* node:fs Not a pure module */
@@ -46,10 +46,12 @@ if (srcDirArg === "") {
46
46
  let config_name = init.name;
47
47
  let config_heartbeatInterval = init.heartbeatInterval;
48
48
  let config_exclude = init.exclude;
49
+ let config_componentRuntime = init.componentRuntime;
49
50
  let config = {
50
51
  name: config_name,
51
52
  heartbeatInterval: config_heartbeatInterval,
52
53
  exclude: config_exclude,
54
+ componentRuntime: config_componentRuntime,
53
55
  variant: variant
54
56
  };
55
57
  let discovered = Discovery$Reventless.scan(srcDir, config_exclude);