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

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,13 @@
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.63 (2026-07-02)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **spec,interop,layer-builder:** generator/protocol/build failure modes (plan A8,A9) ([66d7a54](https://github.com/ReventlessDev/reventless-core/commit/66d7a54e3a0afdbfe3ea2975f517d1d64d52c180))
11
+
12
+
6
13
  # 3.0.0-alpha.62 (2026-06-29)
7
14
 
8
15
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.62",
3
+ "version": "3.0.0-alpha.63",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -120,7 +120,15 @@ let makeDecoder = (schema: S.t<'event>): makeDecoderResult<'event> => {
120
120
  try {
121
121
  Some(JSON.Object(jsonDict)->S.parseJsonOrThrow(schema))
122
122
  } catch {
123
- | _ => None
123
+ | _ =>
124
+ // A parse failure here means a stored event no longer matches the
125
+ // current schema (drift). Dropping it silently hid real data loss;
126
+ // surface it as a warning so it's diagnosable.
127
+ Console.warn(
128
+ "DcbDecode: dropped event `" ++
129
+ eventType ++ "` — payload does not match the current schema (drift?)",
130
+ )
131
+ None
124
132
  }
125
133
  }
126
134
 
@@ -100,6 +100,7 @@ function makeDecoder(schema) {
100
100
  try {
101
101
  return Primitive_option.some(S.parseJsonOrThrow(jsonDict, schema));
102
102
  } catch (exn) {
103
+ console.warn("DcbDecode: dropped event `" + eventType + "` — payload does not match the current schema (drift?)");
103
104
  return;
104
105
  }
105
106
  };
@@ -56,7 +56,10 @@ let extractAllVariants = (schema: S.t<unknown>): array<variantInfo> =>
56
56
  // Check if a variant has no payload fields (only TAG)
57
57
  let isPayloadLess = (info: variantInfo): bool => info.fields->Dict.toArray->Array.length == 0
58
58
 
59
- // Compare two sury schema types for structural compatibility
59
+ // Compare two sury schema types for structural compatibility. Objects and
60
+ // unions recurse: previously any (Object, Object) / (Union, Union) pair was
61
+ // declared compatible, so nested payload drift (a renamed/retyped nested field,
62
+ // a changed variant) passed validation undetected.
60
63
  let rec schemasAreCompatible = (a: S.t<unknown>, b: S.t<unknown>): bool =>
61
64
  switch (a, b) {
62
65
  | (String(_), String(_)) => true
@@ -65,10 +68,35 @@ let rec schemasAreCompatible = (a: S.t<unknown>, b: S.t<unknown>): bool =>
65
68
  | (BigInt(_), BigInt(_)) => true
66
69
  | (Array({additionalItems: Schema(aItem)}), Array({additionalItems: Schema(bItem)})) =>
67
70
  schemasAreCompatible(aItem, bItem)
68
- | (Union(_), Union(_)) => true
69
- | (Object(_), Object(_)) => true
71
+ | (Object({properties: aProps}), Object({properties: bProps})) => propsCompatible(aProps, bProps)
72
+ | (Union(_), Union(_)) =>
73
+ // Same set of variant tags, and each shared variant's payload compatible.
74
+ let av = extractAllVariants(a)
75
+ let bv = extractAllVariants(b)
76
+ let at = av->Array.map(v => v.tagName)->Array.toSorted(String.compare)
77
+ let bt = bv->Array.map(v => v.tagName)->Array.toSorted(String.compare)
78
+ at == bt &&
79
+ at->Array.every(tag =>
80
+ switch (av->Array.find(v => v.tagName == tag), bv->Array.find(v => v.tagName == tag)) {
81
+ | (Some(x), Some(y)) => propsCompatible(x.fields, y.fields)
82
+ | _ => false
83
+ }
84
+ )
70
85
  | _ => false
71
86
  }
87
+ // Two property maps are compatible when they carry the same field names and each
88
+ // field's schema is compatible.
89
+ and propsCompatible = (aProps: dict<S.t<unknown>>, bProps: dict<S.t<unknown>>): bool => {
90
+ let aKeys = aProps->Dict.keysToArray->Array.toSorted(String.compare)
91
+ let bKeys = bProps->Dict.keysToArray->Array.toSorted(String.compare)
92
+ aKeys == bKeys &&
93
+ aKeys->Array.every(k =>
94
+ switch (aProps->Dict.get(k), bProps->Dict.get(k)) {
95
+ | (Some(av), Some(bv)) => schemasAreCompatible(av, bv)
96
+ | _ => false
97
+ }
98
+ )
99
+ }
72
100
 
73
101
  // Get a human-readable type name for a schema
74
102
  let schemaTypeName = (schema: S.t<unknown>): string =>
@@ -95,15 +95,56 @@ function schemasAreCompatible(_a, _b) {
95
95
  _a = aItem;
96
96
  continue;
97
97
  case "object" :
98
- return b.type === "object";
98
+ if (b.type === "object") {
99
+ return propsCompatible(a.properties, b.properties);
100
+ } else {
101
+ return false;
102
+ }
99
103
  case "union" :
100
- return b.type === "union";
104
+ if (b.type !== "union") {
105
+ return false;
106
+ }
107
+ let av = extractAllVariants(a);
108
+ let bv = extractAllVariants(b);
109
+ let at = av.map(v => v.tagName).toSorted(Primitive_string.compare);
110
+ let bt = bv.map(v => v.tagName).toSorted(Primitive_string.compare);
111
+ if (Primitive_object.equal(at, bt)) {
112
+ return at.every(tag => {
113
+ let match = av.find(v => v.tagName === tag);
114
+ let match$1 = bv.find(v => v.tagName === tag);
115
+ if (match !== undefined && match$1 !== undefined) {
116
+ return propsCompatible(match.fields, match$1.fields);
117
+ } else {
118
+ return false;
119
+ }
120
+ });
121
+ } else {
122
+ return false;
123
+ }
101
124
  default:
102
125
  return false;
103
126
  }
104
127
  };
105
128
  }
106
129
 
130
+ function propsCompatible(aProps, bProps) {
131
+ let aKeys = Object.keys(aProps).toSorted(Primitive_string.compare);
132
+ let bKeys = Object.keys(bProps).toSorted(Primitive_string.compare);
133
+ if (Primitive_object.equal(aKeys, bKeys)) {
134
+ return aKeys.every(k => {
135
+ let match = aProps[k];
136
+ let match$1 = bProps[k];
137
+ if (match !== undefined && match$1 !== undefined) {
138
+ return schemasAreCompatible(match, match$1);
139
+ } else {
140
+ return false;
141
+ }
142
+ });
143
+ } else {
144
+ return false;
145
+ }
146
+ }
147
+
107
148
  function schemaTypeName(schema) {
108
149
  switch (schema.type) {
109
150
  case "never" :
@@ -381,6 +422,7 @@ export {
381
422
  extractAllVariants,
382
423
  isPayloadLess,
383
424
  schemasAreCompatible,
425
+ propsCompatible,
384
426
  schemaTypeName,
385
427
  dedupeKeys,
386
428
  validateCompositeReads,
@@ -316,12 +316,16 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
316
316
  switch epGroup {
317
317
  | None => flatEpMappings->Array.push(stem)
318
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)
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
+ }
325
329
  }
326
330
  })
327
331
 
@@ -3,7 +3,6 @@
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";
7
6
  import * as Generator_Node$Reventless from "./Generator_Node.res.mjs";
8
7
 
9
8
  let implSuffixForStateChange = "_Behavior";
@@ -282,9 +281,12 @@ function resolve(discovered, srcDir) {
282
281
  let epGroup = param.epGroup;
283
282
  let stem = param.stem;
284
283
  if (epGroup !== undefined) {
285
- let a = [];
286
- let arr = Stdlib_Option.getOr(epByGroup[epGroup], (epByGroup[epGroup] = a, a));
287
- arr.push(stem);
284
+ let arr = epByGroup[epGroup];
285
+ if (arr !== undefined) {
286
+ arr.push(stem);
287
+ } else {
288
+ epByGroup[epGroup] = [stem];
289
+ }
288
290
  return;
289
291
  }
290
292
  flatEpMappings.push(stem);
@@ -2,6 +2,8 @@
2
2
  // Usage: generate-plugin <srcDir>
3
3
  // generate-plugin --aws <Namespace> <srcDir>
4
4
 
5
+ @val external processExit: int => unit = "process.exit"
6
+
5
7
  let () = {
6
8
  let argv2 = Generator_Node.argv->Array.get(2)->Option.getOr("")
7
9
  let argv3 = Generator_Node.argv->Array.get(3)->Option.getOr("")
@@ -23,6 +25,9 @@ let () = {
23
25
  Console.error("Usage: generate-plugin <srcDir>")
24
26
  Console.error(" generate-plugin --aws <Namespace> <srcDir>")
25
27
  }
28
+ // Exit non-zero on a usage error so `prebuild` (and CI) actually fail
29
+ // instead of continuing green with no Plugin.res generated.
30
+ processExit(1)
26
31
  } else {
27
32
  // Resolve to absolute path (handles relative paths and trailing slashes)
28
33
  let srcDir = Generator_Node.resolve([srcDirArg])
@@ -39,6 +39,7 @@ if (srcDirArg === "") {
39
39
  console.error("Usage: generate-plugin <srcDir>");
40
40
  console.error(" generate-plugin --aws <Namespace> <srcDir>");
41
41
  }
42
+ process.exit(1);
42
43
  } else {
43
44
  let srcDir = Nodepath.resolve(srcDirArg);
44
45
  let init = Config$Reventless.read(srcDir);