@reventlessdev/reventless-spec 3.0.0-alpha.64 → 3.0.0-alpha.66

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.66 (2026-07-05)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **spec:** republish DcbTag to ship [@schema-generated](https://github.com/schema-generated) derivedPartitionTagSchema ([c110c26](https://github.com/ReventlessDev/reventless-core/commit/c110c26ba54617a8455e205ed77aeba5212f6170))
11
+
12
+
13
+ # 3.0.0-alpha.65 (2026-07-05)
14
+
15
+ ### Features
16
+
17
+ * @[@reventless](https://github.com/reventless).systemCallable slice opt-in for deploy-time system callers ([c5ed537](https://github.com/ReventlessDev/reventless-core/commit/c5ed537309f8e4b7d4a4d4af1ed1ec83d060aea3))
18
+ * **reventless-aws:** Postgres change-feed relay auto-wiring (B2.3d) ([62b430f](https://github.com/ReventlessDev/reventless-core/commit/62b430f1e6d8ec1f172de0cca324f7f26aaf5fcb))
19
+
20
+
6
21
  # 3.0.0-alpha.64 (2026-07-03)
7
22
 
8
23
  **Note:** Version bump only for package @reventlessdev/reventless-spec
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.64",
3
+ "version": "3.0.0-alpha.66",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -23,6 +23,7 @@ type tag = {key: string, value: string}
23
23
  Identifies which tag field is used as the storage partition key.
24
24
  The partition key determines how events are distributed across DynamoDB partitions.
25
25
  */
26
+ @schema
26
27
  type partitionTag = {key: string}
27
28
 
28
29
  /**
@@ -238,12 +239,18 @@ let isCompositePartitionMember = (fieldSchema: S.t<unknown>): bool =>
238
239
  Composite partition key specification — the ordered field names and inter-field separators.
239
240
  `seps[i]` is placed between `keys[i]` and `keys[i+1]`; length is always `keys.length - 1`.
240
241
  */
242
+ @schema
241
243
  type compositePartitionSpec = {
242
244
  keys: array<string>,
243
245
  seps: array<string>,
244
246
  }
245
247
 
248
+ // Republish note: alpha.65 shipped a stale compiled interface that omitted the
249
+ // @schema-generated `derivedPartitionTagSchema`, breaking downstream
250
+ // `Reventless.DcbTag.derivedPartitionTagSchema` references (e.g. reventless-aws
251
+ // PgChangeFeedRelay). This forces a clean rebuild + republish.
246
252
  /** Union of simple and composite partition tag strategies. */
253
+ @schema
247
254
  type derivedPartitionTag =
248
255
  | Simple(partitionTag)
249
256
  | Composite(compositePartitionSpec)
@@ -14,6 +14,10 @@ let tagSchema = S.schema(s => ({
14
14
  value: s.m(S.string)
15
15
  }));
16
16
 
17
+ let partitionTagSchema = S.schema(s => ({
18
+ key: s.m(S.string)
19
+ }));
20
+
17
21
  let dcbTagId = S.Metadata.Id.make("dcb", "tag");
18
22
 
19
23
  let dcbPartitionTagId = S.Metadata.Id.make("dcb", "partitionTag");
@@ -48,6 +52,22 @@ function isCompositePartitionMember(fieldSchema) {
48
52
  return Stdlib_Option.isSome(S.Metadata.get(fieldSchema, dcbCompositePartitionMemberId));
49
53
  }
50
54
 
55
+ let compositePartitionSpecSchema = S.schema(s => ({
56
+ keys: s.m(S.array(S.string)),
57
+ seps: s.m(S.array(S.string))
58
+ }));
59
+
60
+ let derivedPartitionTagSchema = S.union([
61
+ S.schema(s => ({
62
+ TAG: "Simple",
63
+ _0: s.m(partitionTagSchema)
64
+ })),
65
+ S.schema(s => ({
66
+ TAG: "Composite",
67
+ _0: s.m(compositePartitionSpecSchema)
68
+ }))
69
+ ]);
70
+
51
71
  function isTagged(fieldSchema) {
52
72
  return Stdlib_Option.isSome(S.Metadata.get(fieldSchema, dcbTagId));
53
73
  }
@@ -800,6 +820,7 @@ function getPartitionTagValue(query, pt) {
800
820
 
801
821
  export {
802
822
  tagSchema,
823
+ partitionTagSchema,
803
824
  dcbTagId,
804
825
  dcbPartitionTagId,
805
826
  dcbCrossPartitionId,
@@ -812,6 +833,8 @@ export {
812
833
  crossPartition,
813
834
  compositePartitionMember,
814
835
  isCompositePartitionMember,
836
+ compositePartitionSpecSchema,
837
+ derivedPartitionTagSchema,
815
838
  isTagged,
816
839
  isTaggedArray,
817
840
  isPartitionTag,
@@ -672,6 +672,16 @@ let renderComposition = (~config: Config.config, ~resolved: Pairing.resolved): s
672
672
  ~moduleSuffix="Slice",
673
673
  ),
674
674
  hasPluginStructure ? Some(" ~pluginStructure=pluginStructure,") : None,
675
+ // Components carrying `@@reventless.systemCallable` — only emitted when at
676
+ // least one opts in, so existing generated Plugin.res files stay identical.
677
+ resolved.systemCallableComponents->Array.length > 0
678
+ ? Some(
679
+ " ~systemCallableComponents=[" ++
680
+ resolved.systemCallableComponents
681
+ ->Array.map(n => "\"" ++ n ++ "\"")
682
+ ->Array.join(", ") ++ "],",
683
+ )
684
+ : None,
675
685
  ]
676
686
 
677
687
  makeParams->Array.filterMap(x => x)->Array.forEach(line => lines->Array.push(line))
@@ -428,7 +428,8 @@ function renderComposition(config, resolved) {
428
428
  renderMakeParam("automationSlices", resolved.automationSlices, "Slice"),
429
429
  renderMakeParam("outboundTranslationSlices", resolved.outboundTranslationSlices, "Slice"),
430
430
  renderMakeParam("inboundTranslationSlices", resolved.inboundTranslationSlices, "Slice"),
431
- hasPluginStructure ? " ~pluginStructure=pluginStructure," : undefined
431
+ hasPluginStructure ? " ~pluginStructure=pluginStructure," : undefined,
432
+ resolved.systemCallableComponents.length !== 0 ? " ~systemCallableComponents=[" + resolved.systemCallableComponents.map(n => "\"" + n + "\"").join(", ") + "]," : undefined
432
433
  ];
433
434
  Stdlib_Array.filterMap(makeParams, x => x).forEach(line => {
434
435
  lines.push(line);
@@ -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,41 @@ 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
+
168
208
  // Partition the ExtensionPoint mapping files into flat (ungrouped) and grouped
169
209
  // `extensionPointDef`s: every mapping sharing an `epGroup` collects into one
170
210
  // grouped entry; the ungrouped ones share a single flat (`group: None`) entry.
@@ -213,6 +253,8 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
213
253
  let aggregateSpecs: array<string> = []
214
254
  let aggregateSpecRelPaths: Dict.t<string> = Dict.make()
215
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()
216
258
  let aggregateBehaviors: array<string> = []
217
259
  let readModelStems: array<string> = []
218
260
  // Stream-enabled read model spec stems (the `ReadModelStream/` folder).
@@ -236,9 +278,15 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
236
278
  stateChangeSliceRelPaths->Dict.set(stem, relPath)
237
279
  }
238
280
  | StateViewSlice =>
239
- if !isImplStem(stem) { stateViewSlices->Array.push(stem) }
281
+ if !isImplStem(stem) {
282
+ stateViewSlices->Array.push(stem)
283
+ stateViewSliceRelPaths->Dict.set(stem, relPath)
284
+ }
240
285
  | StateViewSliceStream =>
241
- if !isImplStem(stem) { stateViewSlicesStream->Array.push(stem) }
286
+ if !isImplStem(stem) {
287
+ stateViewSlicesStream->Array.push(stem)
288
+ stateViewSliceRelPaths->Dict.set(stem, relPath)
289
+ }
242
290
  | AutomationSlice =>
243
291
  if !isImplStem(stem) {
244
292
  automationSlices->Array.push(stem)
@@ -385,6 +433,24 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
385
433
  })
386
434
  d
387
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
+ },
388
454
  aggregates,
389
455
  readModels,
390
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,38 @@ 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
+
134
167
  function groupExtensionPoints(epFiles) {
135
168
  let epByGroup = {};
136
169
  let flatEpMappings = [];
@@ -178,6 +211,7 @@ function resolve(discovered, srcDir) {
178
211
  let aggregateSpecs = [];
179
212
  let aggregateSpecRelPaths = {};
180
213
  let stateChangeSliceRelPaths = {};
214
+ let stateViewSliceRelPaths = {};
181
215
  let aggregateBehaviors = [];
182
216
  let readModelStems = [];
183
217
  let readModelStreamStems = [];
@@ -201,6 +235,7 @@ function resolve(discovered, srcDir) {
201
235
  case "StateViewSlice" :
202
236
  if (!isImplStem(stem)) {
203
237
  stateViewSlices.push(stem);
238
+ stateViewSliceRelPaths[stem] = relPath;
204
239
  return;
205
240
  } else {
206
241
  return;
@@ -208,6 +243,7 @@ function resolve(discovered, srcDir) {
208
243
  case "StateViewSliceStream" :
209
244
  if (!isImplStem(stem)) {
210
245
  stateViewSlicesStream.push(stem);
246
+ stateViewSliceRelPaths[stem] = relPath;
211
247
  return;
212
248
  } else {
213
249
  return;
@@ -282,7 +318,7 @@ function resolve(discovered, srcDir) {
282
318
  );
283
319
  if (behaviorStem !== undefined) {
284
320
  let relPath = aggregateSpecRelPaths[spec];
285
- let isAsync = relPath !== undefined ? hasAsyncAttribute(Nodepath.join(srcDir, relPath)) : false;
321
+ let isAsync = relPath !== undefined ? hasFileAttribute(Nodepath.join(srcDir, relPath), "@@reventless.async") : false;
286
322
  return {
287
323
  spec: spec,
288
324
  behavior: behaviorStem,
@@ -319,6 +355,20 @@ function resolve(discovered, srcDir) {
319
355
  return dict;
320
356
  };
321
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
+ };
322
372
  return {
323
373
  stateChangeSlices: sortedStems(stateChangeSlices),
324
374
  stateViewSlices: sortedStems(stateViewSlices),
@@ -331,11 +381,12 @@ function resolve(discovered, srcDir) {
331
381
  outboundTranslationSliceTargets: buildTargets(outboundTranslationSlices, outboundTranslationSliceRelPaths),
332
382
  asyncStateChangeSlices: (stateChangeSlices.forEach(stem => {
333
383
  let relPath = stateChangeSliceRelPaths[stem];
334
- if (relPath !== undefined && hasAsyncAttribute(Nodepath.join(srcDir, relPath))) {
384
+ if (relPath !== undefined && hasFileAttribute(Nodepath.join(srcDir, relPath), "@@reventless.async")) {
335
385
  d[stem] = true;
336
386
  return;
337
387
  }
338
388
  }), d),
389
+ systemCallableComponents: (collect(stateChangeSlices, stateChangeSliceRelPaths), collect(stateViewSlices, stateViewSliceRelPaths), collect(stateViewSlicesStream, stateViewSliceRelPaths), sortedStems(names)),
339
390
  aggregates: aggregates,
340
391
  readModels: readModels,
341
392
  tasks: sortedStems(tasks),
@@ -354,7 +405,10 @@ export {
354
405
  findEventMappings,
355
406
  sortedStems,
356
407
  extractTargetName,
408
+ hasFileAttribute,
357
409
  hasAsyncAttribute,
410
+ hasSystemCallableAttribute,
411
+ effectiveSpecName,
358
412
  groupExtensionPoints,
359
413
  resolve,
360
414
  }