@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 +21 -2
- package/package.json +7 -8
- package/src/components/DcbDecode.res +9 -1
- package/src/components/DcbDecode.res.mjs +1 -0
- package/src/components/DcbScopeInference.res +244 -0
- package/src/components/DcbScopeInference.res.mjs +177 -0
- package/src/components/DcbTag.res +77 -0
- package/src/components/DcbTag.res.mjs +72 -0
- package/src/components/DcbValidation.res +77 -3
- package/src/components/DcbValidation.res.mjs +75 -2
- package/src/components/InboundTranslationSlice.res +6 -0
- package/src/components/OutboundTranslationSlice.res +6 -0
- package/src/components/Plugin.res +4 -0
- package/src/components/Plugin.res.mjs +4 -2
- package/src/generator/Pairing.res +10 -6
- package/src/generator/Pairing.res.mjs +6 -4
- package/src/generator/PluginGenerator.res +5 -0
- package/src/generator/PluginGenerator.res.mjs +1 -0
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.
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
| _ =>
|
|
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
|
-
| (
|
|
69
|
-
| (
|
|
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
|
-
|
|
98
|
+
if (b.type === "object") {
|
|
99
|
+
return propsCompatible(a.properties, b.properties);
|
|
100
|
+
} else {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
98
103
|
case "union" :
|
|
99
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
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);
|