@reventlessdev/reventless-spec 3.0.0-alpha.61 → 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,12 +3,31 @@
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.61 (2026-06-23)
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
+
13
+ # 3.0.0-alpha.62 (2026-06-29)
14
+
15
+ ### Features
16
+
17
+ * external-system boxes for translation slices (Event Graph data) ([3f8ad39](https://github.com/ReventlessDev/reventless-core/commit/3f8ad39b78a3cb1182d59a0e1fb203b7dcb7379b))
7
18
 
8
- **Note:** Version bump only for package @reventlessdev/reventless-spec
9
19
 
20
+ # 3.0.0-alpha.61 (2026-06-27)
10
21
 
22
+ ### Bug Fixes
23
+
24
+ * **dcb:** harden scope inference against real catalog (partitionHint + rule 3) ([acea3f8](https://github.com/ReventlessDev/reventless-core/commit/acea3f8bb0e96a0993d81fd1aa521e9456982a13))
25
+ * **dcb:** only infer cross-partition for SCALAR foreign references ([57416bf](https://github.com/ReventlessDev/reventless-core/commit/57416bf150df4c801577a60bf72f69abe9c701a8))
26
+ ### Features
11
27
 
28
+ * **dcb:** add tag-scope inference core + runtime diff logging (Phase 1) ([5e17560](https://github.com/ReventlessDev/reventless-core/commit/5e17560fefc4272deb8b501dcb8ecef11c3a7c23))
29
+ * **dcb:** thread inferred tag scope into the decision-query wiring (Phase 2) ([63445b2](https://github.com/ReventlessDev/reventless-core/commit/63445b239bc368932b043872ca16b6c35f723566))
30
+ * **dcb:** validate [@cross](https://github.com/cross)Partition annotations against inferred scope ([acbb387](https://github.com/ReventlessDev/reventless-core/commit/acbb3870d32bbd4ef7e61ed52795800b87660e93))
12
31
 
13
32
 
14
33
  # 3.0.0-alpha.60 (2026-06-22)
package/package.json CHANGED
@@ -1,16 +1,11 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.61",
3
+ "version": "3.0.0-alpha.63",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
7
7
  "generate-plugin": "./run-generator.mjs"
8
8
  },
9
- "scripts": {
10
- "build": "rescript build",
11
- "start": "rescript start",
12
- "clean": "rescript clean"
13
- },
14
9
  "dependencies": {
15
10
  "jsonschema2graphql": "1.1.1",
16
11
  "sury": "11.0.0-alpha.4",
@@ -30,5 +25,9 @@
30
25
  "url": "git+https://github.com/ReventlessDev/reventless-core.git",
31
26
  "directory": "reventless/reventless-spec"
32
27
  },
33
- "gitHead": "c85024f7b9724d4215d34ecc712a14b43eac289d"
34
- }
28
+ "scripts": {
29
+ "build": "rescript build",
30
+ "start": "rescript start",
31
+ "clean": "rescript clean"
32
+ }
33
+ }
@@ -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
  };
@@ -0,0 +1,244 @@
1
+ /**
2
+ DCB tag-scope inference (Phase 1 — pure core).
3
+
4
+ Derives DCB tag scope from the *global* slice graph instead of hand-placed
5
+ `@partitionTag` / `@crossPartition` / `@noTag` annotations. This module is
6
+ deliberately **schema-agnostic**: its input carries only the structural shape of
7
+ each slice (variant names + `*Id`-shaped fields), never an `S.t` schema and never
8
+ a tag-metadata flag. That is precisely what we are replacing — so both the runtime
9
+ (building shapes from `S.t` schemas, via `DcbTag.sliceShapeFromSchemas`) and the
10
+ VS Code tooling (building shapes from parsed `.res` source) can feed the same
11
+ `infer`. See `docs/plans/dcb-tag-scope-inference.md` § "Phase 1 design".
12
+
13
+ The three rules (over the representation):
14
+
15
+ 1. **Owner / partition.** A slice's partition key is the key its *own* emitted
16
+ events are identified by — computed as `producedKeys(S)` minus the keys `S`
17
+ reads from a *foreign* producer (a consumed arm whose event type is produced by
18
+ a different slice). For `AddProduct` (`ProductAdded({productId, categoryId})`,
19
+ consuming `CategoryAdded({categoryId})`) the foreign-read `categoryId` is
20
+ removed, leaving `productId`. No fixpoint needed.
21
+
22
+ 2. **Cross-partition.** A key is cross-partition iff some slice reads it on a
23
+ *foreign* consumed event while partitioned by something else *and* the key is
24
+ another entity's partition. `AddProduct` reads `categoryId` (Category's
25
+ partition) while partitioned by `productId` ⇒ `categoryId` is cross-partition.
26
+
27
+ 3. **Index vs payload.** A `*Id` on an *emitted* event is indexed iff it is the
28
+ producing slice's own partition key. Foreign reference keys (e.g. `categoryId`
29
+ on `ProductAdded`) are payload ⇒ not indexed ⇒ the sibling-leak GSI write never
30
+ happens.
31
+ */
32
+
33
+ /** A `*Id` / `*Ids`-shaped field, identified by name only (no schema, no tag flag). */
34
+ type idField = {name: string, isList: bool}
35
+
36
+ /** One variant arm: its constructor name and the `*Id` fields it carries. */
37
+ type eventShape = {eventType: string, idFields: array<idField>}
38
+
39
+ /**
40
+ The structural shape of one slice, the boundary type both adapters produce:
41
+ - `command` — the `*Id` fields on the slice's command,
42
+ - `consumed` — the arms (and their `*Id` fields) the slice reads,
43
+ - `produced` — the arms (and their `*Id` fields) the slice writes,
44
+ - `partitionHint` — an explicit `@partitionTag` escape hatch (when the dev marked
45
+ the partition because the slice's own events legitimately carry two owned keys,
46
+ e.g. `RecordProductDemand`). Overrides the inferred partition.
47
+ */
48
+ type sliceShape = {
49
+ sliceName: string,
50
+ command: array<idField>,
51
+ consumed: array<eventShape>,
52
+ produced: array<eventShape>,
53
+ partitionHint: option<string>,
54
+ }
55
+
56
+ /** Derived scope for one tag key. */
57
+ type scope = Partition | CrossPartition | Payload
58
+
59
+ /**
60
+ The full derivation. `crossPartitionTagKeys` and `tagKeysByEventType` are the two
61
+ values the runtime threads today (via `DcbTag.extractCrossPartitionTagKeys` /
62
+ `mergeTagKeysByEventType`); under inference they are produced here.
63
+ */
64
+ type derived = {
65
+ /** sliceName -> its inferred partition key (absent when ambiguous). */
66
+ partitionBySlice: dict<string>,
67
+ /** tag key -> the name of a slice that owns it as its partition. */
68
+ ownerByKey: dict<string>,
69
+ /** keys read cross-partition by some slice (sorted, deduped). */
70
+ crossPartitionTagKeys: array<string>,
71
+ /** produced eventType -> its indexed (non-payload) tag keys (sorted). */
72
+ tagKeysByEventType: dict<array<string>>,
73
+ /** (sliceName, reason) for slices whose partition couldn't be inferred. */
74
+ ambiguities: array<(string, string)>,
75
+ }
76
+
77
+ /**
78
+ The tag key for a `*Id`-shaped field. A plural `*Ids: array<string>` shares the
79
+ singular producer's key (trailing `s` stripped — `productIds` -> `productId`);
80
+ a scalar `*Id` uses the field name verbatim. Mirrors the PPX's `*Ids` rule.
81
+ */
82
+ let tagKeyOf = (f: idField): string =>
83
+ if f.isList && f.name->String.endsWith("s") {
84
+ f.name->String.slice(~start=0, ~end=f.name->String.length - 1)
85
+ } else {
86
+ f.name
87
+ }
88
+
89
+ let dedupSorted = (keys: array<string>): array<string> => {
90
+ let seen = Set.make()
91
+ keys->Array.forEach(k => seen->Set.add(k))
92
+ Array.fromIterator(seen->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
93
+ }
94
+
95
+ let keysOfEvent = (e: eventShape): array<string> => e.idFields->Array.map(tagKeyOf)
96
+
97
+ let producedKeys = (s: sliceShape): array<string> =>
98
+ dedupSorted(s.produced->Array.flatMap(keysOfEvent))
99
+
100
+ let consumedKeys = (s: sliceShape): array<string> =>
101
+ dedupSorted(s.consumed->Array.flatMap(keysOfEvent))
102
+
103
+ /**
104
+ Keys the command carries as a **scalar** (`*Id: string`, not `*Ids: array<string>`).
105
+ A scalar tag is AND-ed with the partition into one composite clause unless it is
106
+ read cross-partition, so only a scalar foreign reference needs the cross-partition
107
+ fan. A foreign key the command carries *only* as an array already fans per element
108
+ and stays partition-scoped (it reads the foreign entity's own partition) — the
109
+ `PlaceOrder`/`productIds` shape — so it is **not** cross-partition.
110
+ */
111
+ let commandScalarKeys = (s: sliceShape): array<string> =>
112
+ dedupSorted(s.command->Array.filter(f => !f.isList)->Array.map(tagKeyOf))
113
+
114
+ /**
115
+ Infers DCB tag scope for a set of slices (one DCB consistency boundary / plugin).
116
+ Pure and total — never throws; unresolvable partitions land in `ambiguities`.
117
+ */
118
+ /**
119
+ The keys a slice reads from a *foreign* event — a consumed arm whose event type
120
+ the slice does **not** itself produce. These are the candidate cross-entity
121
+ references; they cannot be the slice's own partition. Defined on a single shape
122
+ so it works both globally (in `infer`) and per-slice (in the GWT harness, which
123
+ sees only one slice).
124
+ */
125
+ let foreignConsumedKeys = (s: sliceShape): array<string> => {
126
+ let ownProduced = Set.make()
127
+ s.produced->Array.forEach(e => ownProduced->Set.add(e.eventType))
128
+ dedupSorted(
129
+ s.consumed->Array.flatMap(e => ownProduced->Set.has(e.eventType) ? [] : e->keysOfEvent),
130
+ )
131
+ }
132
+
133
+ /**
134
+ Per-slice cross-partition keys for the test harness, which has no global owner
135
+ map: a foreign-read key that is not the slice's own partition is read across
136
+ partitions. Matches `infer`'s global rule 2 for the common "reference another
137
+ entity" case; the harness unions this with any explicit `@crossPartition`
138
+ annotation so capacity/escape-hatch reads remain covered.
139
+ */
140
+ let crossPartitionForSlice = (s: sliceShape): array<string> => {
141
+ let foreign = foreignConsumedKeys(s)
142
+ let scalar = commandScalarKeys(s)
143
+ let partition = switch s.partitionHint {
144
+ | Some(h) if producedKeys(s)->Array.includes(h) => Some(h)
145
+ | _ =>
146
+ switch producedKeys(s)->Array.filter(k => !(foreign->Array.includes(k))) {
147
+ | [single] => Some(single)
148
+ | _ => None
149
+ }
150
+ }
151
+ // A foreign read is cross-partition only when the command carries the key as a
152
+ // scalar (must be fanned); an array-only foreign key auto-fans partition-scoped.
153
+ foreign->Array.filter(k => Some(k) != partition && scalar->Array.includes(k))
154
+ }
155
+
156
+ let infer = (slices: array<sliceShape>): derived => {
157
+ // Rule 1 — partition(S) = producedKeys(S) \ foreignConsumedKeys(S), where a
158
+ // foreign-consumed key rides a consumed arm the slice does not itself produce.
159
+ // An explicit @partitionTag hint overrides the derivation (escape hatch for
160
+ // slices whose own events legitimately carry two owned keys).
161
+ let partitionBySlice = Dict.make()
162
+ let ambiguities = []
163
+ slices->Array.forEach(s => {
164
+ let produced = producedKeys(s)
165
+ switch s.partitionHint {
166
+ | Some(h) if produced->Array.includes(h) => partitionBySlice->Dict.set(s.sliceName, h)
167
+ | _ =>
168
+ let foreign = foreignConsumedKeys(s)
169
+ switch produced->Array.filter(k => !(foreign->Array.includes(k))) {
170
+ | [single] => partitionBySlice->Dict.set(s.sliceName, single)
171
+ | [] =>
172
+ let _ = ambiguities->Array.push((
173
+ s.sliceName,
174
+ "no own partition key — every produced *Id is read from a foreign producer (pure join?); add an explicit @partitionTag",
175
+ ))
176
+ | many =>
177
+ let _ = ambiguities->Array.push((
178
+ s.sliceName,
179
+ `multiple candidate partition keys (${many->Array.join(", ")}) — add an explicit @partitionTag`,
180
+ ))
181
+ }
182
+ }
183
+ })
184
+
185
+ // owner map + the set of keys that are *some* entity's partition.
186
+ let ownerByKey = Dict.make()
187
+ let ownedPartitionKeys = Set.make()
188
+ slices->Array.forEach(s =>
189
+ switch partitionBySlice->Dict.get(s.sliceName) {
190
+ | Some(k) =>
191
+ ownedPartitionKeys->Set.add(k)
192
+ switch ownerByKey->Dict.get(k) {
193
+ | Some(_) => ()
194
+ | None => ownerByKey->Dict.set(k, s.sliceName)
195
+ }
196
+ | None => ()
197
+ }
198
+ )
199
+
200
+ // Rule 2 — a key read on a foreign consumed event that is another entity's
201
+ // partition (and not this slice's own partition) is cross-partition — but only
202
+ // when the command carries it as a SCALAR. A foreign key the command carries
203
+ // only as an array auto-fans per element and stays partition-scoped (it reads
204
+ // the foreign entity's own partition), so it is not cross-partition.
205
+ let crossKeys = Set.make()
206
+ slices->Array.forEach(s => {
207
+ let own = partitionBySlice->Dict.get(s.sliceName)
208
+ let scalar = commandScalarKeys(s)
209
+ s->consumedKeys->Array.forEach(k =>
210
+ if ownedPartitionKeys->Set.has(k) && Some(k) != own && scalar->Array.includes(k) {
211
+ crossKeys->Set.add(k)
212
+ }
213
+ )
214
+ })
215
+ let crossPartitionTagKeys =
216
+ Array.fromIterator(crossKeys->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
217
+
218
+ // Rule 3 — a produced key is indexed iff it is the producing slice's own
219
+ // partition OR some slice issues a decision read of *that event type* by it
220
+ // (the key appears on a consumed arm naming the event type). Foreign reference
221
+ // keys that nobody reads this event type by are payload ⇒ no GSI write ⇒ the
222
+ // sibling leak is impossible. The read-by-anybody arm is what keeps a composite
223
+ // own-stream read (e.g. ProductDemandRecorded read by orderId) — and an M:N
224
+ // capacity read — correctly indexed without a hand annotation.
225
+ let readKeysByEventType = Dict.make()
226
+ slices->Array.forEach(s =>
227
+ s.consumed->Array.forEach(e => {
228
+ let prev = readKeysByEventType->Dict.get(e.eventType)->Option.getOr([])
229
+ readKeysByEventType->Dict.set(e.eventType, prev->Array.concat(e->keysOfEvent))
230
+ })
231
+ )
232
+ let tagKeysByEventType = Dict.make()
233
+ slices->Array.forEach(s => {
234
+ let own = partitionBySlice->Dict.get(s.sliceName)
235
+ s.produced->Array.forEach(e => {
236
+ let readKeys = readKeysByEventType->Dict.get(e.eventType)->Option.getOr([])
237
+ let indexed =
238
+ e->keysOfEvent->Array.filter(k => Some(k) == own || readKeys->Array.includes(k))
239
+ tagKeysByEventType->Dict.set(e.eventType, dedupSorted(indexed))
240
+ })
241
+ })
242
+
243
+ {partitionBySlice, ownerByKey, crossPartitionTagKeys, tagKeysByEventType, ambiguities}
244
+ }
@@ -0,0 +1,177 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
5
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
6
+
7
+ function tagKeyOf(f) {
8
+ if (f.isList && f.name.endsWith("s")) {
9
+ return f.name.slice(0, f.name.length - 1 | 0);
10
+ } else {
11
+ return f.name;
12
+ }
13
+ }
14
+
15
+ function dedupSorted(keys) {
16
+ let seen = new Set();
17
+ keys.forEach(k => {
18
+ seen.add(k);
19
+ });
20
+ return Array.from(seen.values()).toSorted(Primitive_string.compare);
21
+ }
22
+
23
+ function keysOfEvent(e) {
24
+ return e.idFields.map(tagKeyOf);
25
+ }
26
+
27
+ function producedKeys(s) {
28
+ return dedupSorted(s.produced.flatMap(keysOfEvent));
29
+ }
30
+
31
+ function consumedKeys(s) {
32
+ return dedupSorted(s.consumed.flatMap(keysOfEvent));
33
+ }
34
+
35
+ function commandScalarKeys(s) {
36
+ return dedupSorted(s.command.filter(f => !f.isList).map(tagKeyOf));
37
+ }
38
+
39
+ function foreignConsumedKeys(s) {
40
+ let ownProduced = new Set();
41
+ s.produced.forEach(e => {
42
+ ownProduced.add(e.eventType);
43
+ });
44
+ return dedupSorted(s.consumed.flatMap(e => {
45
+ if (ownProduced.has(e.eventType)) {
46
+ return [];
47
+ } else {
48
+ return e.idFields.map(tagKeyOf);
49
+ }
50
+ }));
51
+ }
52
+
53
+ function crossPartitionForSlice(s) {
54
+ let foreign = foreignConsumedKeys(s);
55
+ let scalar = commandScalarKeys(s);
56
+ let h = s.partitionHint;
57
+ let partition;
58
+ let exit = 0;
59
+ if (h !== undefined && producedKeys(s).includes(h)) {
60
+ partition = h;
61
+ } else {
62
+ exit = 1;
63
+ }
64
+ if (exit === 1) {
65
+ let match = producedKeys(s).filter(k => !foreign.includes(k));
66
+ partition = match.length !== 1 ? undefined : match[0];
67
+ }
68
+ return foreign.filter(k => {
69
+ if (Primitive_object.notequal(k, partition)) {
70
+ return scalar.includes(k);
71
+ } else {
72
+ return false;
73
+ }
74
+ });
75
+ }
76
+
77
+ function infer(slices) {
78
+ let partitionBySlice = {};
79
+ let ambiguities = [];
80
+ slices.forEach(s => {
81
+ let produced = producedKeys(s);
82
+ let h = s.partitionHint;
83
+ if (h !== undefined && produced.includes(h)) {
84
+ partitionBySlice[s.sliceName] = h;
85
+ return;
86
+ }
87
+ let foreign = foreignConsumedKeys(s);
88
+ let many = produced.filter(k => !foreign.includes(k));
89
+ let len = many.length;
90
+ if (len !== 1) {
91
+ if (len !== 0) {
92
+ ambiguities.push([
93
+ s.sliceName,
94
+ `multiple candidate partition keys (` + many.join(", ") + `) — add an explicit @partitionTag`
95
+ ]);
96
+ } else {
97
+ ambiguities.push([
98
+ s.sliceName,
99
+ "no own partition key — every produced *Id is read from a foreign producer (pure join?); add an explicit @partitionTag"
100
+ ]);
101
+ }
102
+ return;
103
+ }
104
+ let single = many[0];
105
+ partitionBySlice[s.sliceName] = single;
106
+ });
107
+ let ownerByKey = {};
108
+ let ownedPartitionKeys = new Set();
109
+ slices.forEach(s => {
110
+ let k = partitionBySlice[s.sliceName];
111
+ if (k === undefined) {
112
+ return;
113
+ }
114
+ ownedPartitionKeys.add(k);
115
+ let match = ownerByKey[k];
116
+ if (match !== undefined) {
117
+ return;
118
+ } else {
119
+ ownerByKey[k] = s.sliceName;
120
+ return;
121
+ }
122
+ });
123
+ let crossKeys = new Set();
124
+ slices.forEach(s => {
125
+ let own = partitionBySlice[s.sliceName];
126
+ let scalar = commandScalarKeys(s);
127
+ consumedKeys(s).forEach(k => {
128
+ if (ownedPartitionKeys.has(k) && Primitive_object.notequal(k, own) && scalar.includes(k)) {
129
+ crossKeys.add(k);
130
+ return;
131
+ }
132
+ });
133
+ });
134
+ let crossPartitionTagKeys = Array.from(crossKeys.values()).toSorted(Primitive_string.compare);
135
+ let readKeysByEventType = {};
136
+ slices.forEach(s => {
137
+ s.consumed.forEach(e => {
138
+ let prev = Stdlib_Option.getOr(readKeysByEventType[e.eventType], []);
139
+ readKeysByEventType[e.eventType] = prev.concat(e.idFields.map(tagKeyOf));
140
+ });
141
+ });
142
+ let tagKeysByEventType = {};
143
+ slices.forEach(s => {
144
+ let own = partitionBySlice[s.sliceName];
145
+ s.produced.forEach(e => {
146
+ let readKeys = Stdlib_Option.getOr(readKeysByEventType[e.eventType], []);
147
+ let indexed = e.idFields.map(tagKeyOf).filter(k => {
148
+ if (Primitive_object.equal(k, own)) {
149
+ return true;
150
+ } else {
151
+ return readKeys.includes(k);
152
+ }
153
+ });
154
+ tagKeysByEventType[e.eventType] = dedupSorted(indexed);
155
+ });
156
+ });
157
+ return {
158
+ partitionBySlice: partitionBySlice,
159
+ ownerByKey: ownerByKey,
160
+ crossPartitionTagKeys: crossPartitionTagKeys,
161
+ tagKeysByEventType: tagKeysByEventType,
162
+ ambiguities: ambiguities
163
+ };
164
+ }
165
+
166
+ export {
167
+ tagKeyOf,
168
+ dedupSorted,
169
+ keysOfEvent,
170
+ producedKeys,
171
+ consumedKeys,
172
+ commandScalarKeys,
173
+ foreignConsumedKeys,
174
+ crossPartitionForSlice,
175
+ infer,
176
+ }
177
+ /* No side effect */
@@ -926,6 +926,56 @@ let extractCrossPartitionTagKeys = (schema: S.t<'event>): array<string> => {
926
926
  Array.fromIterator(seen->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
927
927
  }
928
928
 
929
+ // --- Schema -> DcbScopeInference shapes (the runtime adapter) ---
930
+
931
+ /**
932
+ Collects the `*Id` / `*Ids`-shaped fields of one object-variant's properties as
933
+ `DcbScopeInference.idField`s — by **name**, independent of any DCB tag flag. This
934
+ is the un-annotated structural view the scope inference consumes.
935
+ */
936
+ let idFieldsOfProperties = (properties: dict<S.t<unknown>>): array<DcbScopeInference.idField> =>
937
+ properties
938
+ ->Dict.toArray
939
+ ->Array.filterMap(((name, fieldSchema)) =>
940
+ if name->String.endsWith("Ids") || name->String.endsWith("Id") {
941
+ let isList = switch fieldSchema {
942
+ | Array(_) => true
943
+ | _ => false
944
+ }
945
+ Some({DcbScopeInference.name, isList})
946
+ } else {
947
+ None
948
+ }
949
+ )
950
+
951
+ /**
952
+ Extracts the `DcbScopeInference.eventShape`s (variant name + `*Id` fields) from a
953
+ variant schema. Payload-less arms are kept (no id fields); non-variant schemas
954
+ return a single shape.
955
+ */
956
+ let eventShapesOfSchema = (schema: S.t<'a>): array<DcbScopeInference.eventShape> => {
957
+ let ofVariant = (variantSchema: S.t<unknown>): option<DcbScopeInference.eventShape> =>
958
+ switch variantSchema {
959
+ | Object({items, properties}) =>
960
+ items
961
+ ->Array.find(item => item.location == "TAG")
962
+ ->Option.flatMap(item =>
963
+ switch item.schema {
964
+ | String({const}) => Some(const)
965
+ | _ => None
966
+ }
967
+ )
968
+ ->Option.map(eventType => {DcbScopeInference.eventType, idFields: idFieldsOfProperties(properties)})
969
+ | String({const}) => Some({DcbScopeInference.eventType: const, idFields: []})
970
+ | _ => None
971
+ }
972
+ switch schema->toUnknownSchema {
973
+ | Union({anyOf}) => anyOf->Array.filterMap(ofVariant)
974
+ | Object(_) as obj => ofVariant(obj)->Option.mapOr([], s => [s])
975
+ | _ => []
976
+ }
977
+ }
978
+
929
979
  // --- Partition tag derivation ---
930
980
 
931
981
  /**
@@ -968,6 +1018,33 @@ let extractPartitionTagFields = (schema: S.t<'event>): array<string> => {
968
1018
  }
969
1019
  }
970
1020
 
1021
+ /**
1022
+ Builds the `DcbScopeInference.sliceShape` for one slice from its sury schemas.
1023
+ The `command` fields are flattened across command variants; `consumed` / `produced`
1024
+ keep their per-arm structure. Schema-coupling lives here so the inference core
1025
+ stays schema-agnostic.
1026
+ */
1027
+ let sliceShapeFromSchemas = (
1028
+ ~name: string,
1029
+ ~commandSchema: S.t<'c>,
1030
+ ~consumedEventSchema: S.t<'ce>,
1031
+ ~eventSchema: S.t<'e>,
1032
+ ): DcbScopeInference.sliceShape => {
1033
+ // An explicit @partitionTag on the produced event is the escape hatch for
1034
+ // slices whose own events carry two owned keys (e.g. RecordProductDemand).
1035
+ let partitionHint = switch extractPartitionTagFields(eventSchema) {
1036
+ | [single] => Some(single)
1037
+ | _ => None
1038
+ }
1039
+ {
1040
+ sliceName: name,
1041
+ command: eventShapesOfSchema(commandSchema)->Array.flatMap(e => e.idFields),
1042
+ consumed: eventShapesOfSchema(consumedEventSchema),
1043
+ produced: eventShapesOfSchema(eventSchema),
1044
+ partitionHint,
1045
+ }
1046
+ }
1047
+
971
1048
  /**
972
1049
  Checks whether any single variant in a schema has multiple tagged fields.
973
1050
  If so, a partition tag annotation is needed to disambiguate.
@@ -578,6 +578,63 @@ function extractCrossPartitionTagKeys(schema) {
578
578
  return Array.from(seen.values()).toSorted(Primitive_string.compare);
579
579
  }
580
580
 
581
+ function idFieldsOfProperties(properties) {
582
+ return Stdlib_Array.filterMap(Object.entries(properties), param => {
583
+ let name = param[0];
584
+ if (!(name.endsWith("Ids") || name.endsWith("Id"))) {
585
+ return;
586
+ }
587
+ let isList;
588
+ isList = param[1].type === "array";
589
+ return {
590
+ name: name,
591
+ isList: isList
592
+ };
593
+ });
594
+ }
595
+
596
+ function eventShapesOfSchema(schema) {
597
+ let ofVariant = variantSchema => {
598
+ switch (variantSchema.type) {
599
+ case "string" :
600
+ let $$const = variantSchema.const;
601
+ if ($$const !== undefined) {
602
+ return {
603
+ eventType: $$const,
604
+ idFields: []
605
+ };
606
+ } else {
607
+ return;
608
+ }
609
+ case "object" :
610
+ let properties = variantSchema.properties;
611
+ return Stdlib_Option.map(Stdlib_Option.flatMap(variantSchema.items.find(item => item.location === "TAG"), item => {
612
+ let match = item.schema;
613
+ if (match.type !== "string") {
614
+ return;
615
+ }
616
+ let $$const = match.const;
617
+ if ($$const !== undefined) {
618
+ return $$const;
619
+ }
620
+ }), eventType => ({
621
+ eventType: eventType,
622
+ idFields: idFieldsOfProperties(properties)
623
+ }));
624
+ default:
625
+ return;
626
+ }
627
+ };
628
+ switch (schema.type) {
629
+ case "object" :
630
+ return Stdlib_Option.mapOr(ofVariant(schema), [], s => [s]);
631
+ case "union" :
632
+ return Stdlib_Array.filterMap(schema.anyOf, ofVariant);
633
+ default:
634
+ return [];
635
+ }
636
+ }
637
+
581
638
  function extractPartitionTagFields(schema) {
582
639
  switch (schema.type) {
583
640
  case "object" :
@@ -608,6 +665,18 @@ function extractPartitionTagFields(schema) {
608
665
  }
609
666
  }
610
667
 
668
+ function sliceShapeFromSchemas(name, commandSchema, consumedEventSchema, eventSchema) {
669
+ let match = extractPartitionTagFields(eventSchema);
670
+ let partitionHint = match.length !== 1 ? undefined : match[0];
671
+ return {
672
+ sliceName: name,
673
+ command: eventShapesOfSchema(commandSchema).flatMap(e => e.idFields),
674
+ consumed: eventShapesOfSchema(consumedEventSchema),
675
+ produced: eventShapesOfSchema(eventSchema),
676
+ partitionHint: partitionHint
677
+ };
678
+ }
679
+
611
680
  function hasMultiTagVariant(schema) {
612
681
  switch (schema.type) {
613
682
  case "object" :
@@ -857,7 +926,10 @@ export {
857
926
  extractTaggedFields,
858
927
  crossPartitionKeysOfProperties,
859
928
  extractCrossPartitionTagKeys,
929
+ idFieldsOfProperties,
930
+ eventShapesOfSchema,
860
931
  extractPartitionTagFields,
932
+ sliceShapeFromSchemas,
861
933
  hasMultiTagVariant,
862
934
  findMultiTagVariantNames,
863
935
  extractCompositePartitionFieldsFromProperties,
@@ -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 =>
@@ -360,3 +388,49 @@ let validateCrossPartitionScope = (
360
388
  )
361
389
  warnings
362
390
  }
391
+
392
+ /** Two-bucket result of validating annotations against the inferred scope. */
393
+ type scopeInferenceIssues = {
394
+ /** Annotations that conflict with inference — likely bugs (warn/error). */
395
+ contradictions: array<validationError>,
396
+ /** Annotations inference already derives — safe to delete (info). */
397
+ redundancies: array<validationError>,
398
+ }
399
+
400
+ /**
401
+ Validates explicit `@crossPartition` annotations against the *inferred* scope,
402
+ now that inference drives the decision-query wiring. Two cases per annotated key:
403
+
404
+ - **Contradiction** — the key is marked `@crossPartition` on a slice that inference
405
+ resolves as that slice's *own partition*. A slice's own identity is never a
406
+ cross-partition read; the annotation is wrong and (were it to drive the wiring)
407
+ would fan the partition into a spurious secondary read. Surface loudly.
408
+ - **Redundant** — the key is marked `@crossPartition` and inference *also* derives
409
+ it as cross-partition from the slice graph. Harmless, but the annotation can be
410
+ dropped — that is the whole point of inference.
411
+
412
+ @param annotations `(sliceName, @crossPartition keys on the slice's produced event)`.
413
+ */
414
+ let validateScopeVsInference = (
415
+ ~annotations: array<(string, array<string>)>,
416
+ ~inferred: DcbScopeInference.derived,
417
+ ): scopeInferenceIssues => {
418
+ let contradictions: array<validationError> = []
419
+ let redundancies: array<validationError> = []
420
+ annotations->Array.forEach(((name, cpKeys)) =>
421
+ cpKeys->Array.forEach(k =>
422
+ if inferred.partitionBySlice->Dict.get(name) == Some(k) {
423
+ let _ = contradictions->Array.push({
424
+ sliceName: name,
425
+ message: `tag '${k}' is marked @crossPartition but inference resolves it as this slice's own partition key — a slice's own identity is never a cross-partition read. Remove the annotation.`,
426
+ })
427
+ } else if inferred.crossPartitionTagKeys->Array.includes(k) {
428
+ let _ = redundancies->Array.push({
429
+ sliceName: name,
430
+ message: `tag '${k}' is marked @crossPartition but the framework already infers it as a cross-partition read from the slice graph — the annotation is redundant and can be removed.`,
431
+ })
432
+ }
433
+ )
434
+ )
435
+ {contradictions, redundancies}
436
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
5
6
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
6
7
  import * as DcbTag$Reventless from "./DcbTag.res.mjs";
7
8
 
@@ -94,15 +95,56 @@ function schemasAreCompatible(_a, _b) {
94
95
  _a = aItem;
95
96
  continue;
96
97
  case "object" :
97
- return b.type === "object";
98
+ if (b.type === "object") {
99
+ return propsCompatible(a.properties, b.properties);
100
+ } else {
101
+ return false;
102
+ }
98
103
  case "union" :
99
- 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
+ }
100
124
  default:
101
125
  return false;
102
126
  }
103
127
  };
104
128
  }
105
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
+
106
148
  function schemaTypeName(schema) {
107
149
  switch (schema.type) {
108
150
  case "never" :
@@ -346,15 +388,46 @@ function validateCrossPartitionScope(producers) {
346
388
  return warnings;
347
389
  }
348
390
 
391
+ function validateScopeVsInference(annotations, inferred) {
392
+ let contradictions = [];
393
+ let redundancies = [];
394
+ annotations.forEach(param => {
395
+ let name = param[0];
396
+ param[1].forEach(k => {
397
+ if (Primitive_object.equal(inferred.partitionBySlice[name], k)) {
398
+ contradictions.push({
399
+ sliceName: name,
400
+ message: `tag '` + k + `' is marked @crossPartition but inference resolves it as this slice's own partition key — a slice's own identity is never a cross-partition read. Remove the annotation.`
401
+ });
402
+ return;
403
+ } else if (inferred.crossPartitionTagKeys.includes(k)) {
404
+ redundancies.push({
405
+ sliceName: name,
406
+ message: `tag '` + k + `' is marked @crossPartition but the framework already infers it as a cross-partition read from the slice graph — the annotation is redundant and can be removed.`
407
+ });
408
+ return;
409
+ } else {
410
+ return;
411
+ }
412
+ });
413
+ });
414
+ return {
415
+ contradictions: contradictions,
416
+ redundancies: redundancies
417
+ };
418
+ }
419
+
349
420
  export {
350
421
  extractVariantInfo,
351
422
  extractAllVariants,
352
423
  isPayloadLess,
353
424
  schemasAreCompatible,
425
+ propsCompatible,
354
426
  schemaTypeName,
355
427
  dedupeKeys,
356
428
  validateCompositeReads,
357
429
  validateProducedAndConsumed,
358
430
  validateCrossPartitionScope,
431
+ validateScopeVsInference,
359
432
  }
360
433
  /* DcbTag-Reventless Not a pure module */
@@ -51,6 +51,12 @@ module type Spec = {
51
51
  /** Name of the aggregate or StateChangeSlice that receives the produced command. */
52
52
  let targetName: string
53
53
 
54
+ /** Optional display name of the foreign system this anti-corruption slice receives
55
+ from (e.g. `"SupplierFeed"`). Drives the **external box** drawn outside the plugin
56
+ in the Event Graph / Context Map (see docs/plans/translation-external-boxes.md).
57
+ Auto-injected by `@@reventless.spec` defaulting to `None` — set it to name the box. */
58
+ let externalSystem: option<string>
59
+
54
60
  /** Authorization rule evaluated at the GraphQL resolver entry before any
55
61
  external input is translated. Auto-injected by `@@reventless.spec` and
56
62
  on structurally-detected inline spec modules — defaults to
@@ -75,6 +75,12 @@ module type Spec = {
75
75
 
76
76
  /** Name of the aggregate or StateChangeSlice that receives the inbound command, or None for fire-and-forget. */
77
77
  let targetName: option<string>
78
+
79
+ /** Optional display name of the foreign system this anti-corruption slice publishes
80
+ to (e.g. `"EmailService"`). Drives the **external box** drawn outside the plugin
81
+ in the Event Graph / Context Map (see docs/plans/translation-external-boxes.md).
82
+ Auto-injected by `@@reventless.spec` defaulting to `None` — set it to name the box. */
83
+ let externalSystem: option<string>
78
84
  }
79
85
 
80
86
  /**
@@ -243,6 +243,8 @@ type outboundTranslationSliceDef = {
243
243
  consumedEventTypes: array<string>,
244
244
  inboundCommandTypes: array<string>,
245
245
  targetName: @s.matches(stringOptionSchema) option<string>,
246
+ // Foreign system this slice publishes to — drives the external box (Event Graph).
247
+ externalSystem: @s.matches(stringOptionSchema) option<string>,
246
248
  }
247
249
 
248
250
  @schema
@@ -250,6 +252,8 @@ type inboundTranslationSliceDef = {
250
252
  name: string,
251
253
  commandTypes: array<string>,
252
254
  targetName: string,
255
+ // Foreign system this slice receives from — drives the external box (Event Graph).
256
+ externalSystem: @s.matches(stringOptionSchema) option<string>,
253
257
  }
254
258
 
255
259
  @schema
@@ -132,13 +132,15 @@ let outboundTranslationSliceDefSchema = S.schema(s => ({
132
132
  name: s.m(S.string),
133
133
  consumedEventTypes: s.m(S.array(S.string)),
134
134
  inboundCommandTypes: s.m(S.array(S.string)),
135
- targetName: s.m(stringOptionSchema)
135
+ targetName: s.m(stringOptionSchema),
136
+ externalSystem: s.m(stringOptionSchema)
136
137
  }));
137
138
 
138
139
  let inboundTranslationSliceDefSchema = S.schema(s => ({
139
140
  name: s.m(S.string),
140
141
  commandTypes: s.m(S.array(S.string)),
141
- targetName: s.m(S.string)
142
+ targetName: s.m(S.string),
143
+ externalSystem: s.m(stringOptionSchema)
142
144
  }));
143
145
 
144
146
  let extensionDefSchema = S.schema(s => ({
@@ -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);