@reventlessdev/reventless-spec 3.0.0-alpha.100
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 +931 -0
- package/LICENSE +202 -0
- package/README.md +109 -0
- package/package.json +49 -0
- package/rescript.json +32 -0
- package/run-generator.mjs +2 -0
- package/run-platform-generator.mjs +2 -0
- package/scripts/generate-currency.mjs +215 -0
- package/scripts/iso-4217-list-one.xml +1956 -0
- package/src/AnsiStyle.res +40 -0
- package/src/AnsiStyle.res.mjs +54 -0
- package/src/LogPrefix.res +192 -0
- package/src/LogPrefix.res.mjs +159 -0
- package/src/PackageVersion.res +67 -0
- package/src/PackageVersion.res.mjs +81 -0
- package/src/components/Aggregate.res +64 -0
- package/src/components/Aggregate.res.mjs +2 -0
- package/src/components/AutomationSlice.res +279 -0
- package/src/components/AutomationSlice.res.mjs +30 -0
- package/src/components/CapabilityManifest.res +74 -0
- package/src/components/CapabilityManifest.res.mjs +61 -0
- package/src/components/ComponentKind.res +99 -0
- package/src/components/ComponentKind.res.mjs +125 -0
- package/src/components/Counter.res +24 -0
- package/src/components/Counter.res.mjs +2 -0
- package/src/components/DcbDecode.res +118 -0
- package/src/components/DcbDecode.res.mjs +100 -0
- package/src/components/DcbScopeInference.res +244 -0
- package/src/components/DcbScopeInference.res.mjs +177 -0
- package/src/components/DcbTag.res +1335 -0
- package/src/components/DcbTag.res.mjs +898 -0
- package/src/components/DcbValidation.res +427 -0
- package/src/components/DcbValidation.res.mjs +423 -0
- package/src/components/DisplayName.res +40 -0
- package/src/components/DisplayName.res.mjs +26 -0
- package/src/components/ExtensionPoint.res +27 -0
- package/src/components/ExtensionPoint.res.mjs +2 -0
- package/src/components/InboundTranslationSlice.res +85 -0
- package/src/components/InboundTranslationSlice.res.mjs +2 -0
- package/src/components/OutboundTranslationSlice.res +153 -0
- package/src/components/OutboundTranslationSlice.res.mjs +2 -0
- package/src/components/Plugin.res +538 -0
- package/src/components/Plugin.res.mjs +264 -0
- package/src/components/PluginName.res +39 -0
- package/src/components/PluginName.res.mjs +45 -0
- package/src/components/ReadModel.res +199 -0
- package/src/components/ReadModel.res.mjs +18 -0
- package/src/components/Reference.res +55 -0
- package/src/components/Reference.res.mjs +50 -0
- package/src/components/Snapshot.res +26 -0
- package/src/components/Snapshot.res.mjs +2 -0
- package/src/components/StateAnnotations.res +97 -0
- package/src/components/StateAnnotations.res.mjs +15 -0
- package/src/components/StateChangeSlice.res +131 -0
- package/src/components/StateChangeSlice.res.mjs +2 -0
- package/src/components/StateViewSlice.res +123 -0
- package/src/components/StateViewSlice.res.mjs +2 -0
- package/src/components/Task.res +62 -0
- package/src/components/Task.res.mjs +2 -0
- package/src/generator/Codegen.res +842 -0
- package/src/generator/Codegen.res.mjs +565 -0
- package/src/generator/Config.res +106 -0
- package/src/generator/Config.res.mjs +69 -0
- package/src/generator/Discovery.res +230 -0
- package/src/generator/Discovery.res.mjs +198 -0
- package/src/generator/Generator_Node.res +14 -0
- package/src/generator/Generator_Node.res.mjs +18 -0
- package/src/generator/Pairing.res +460 -0
- package/src/generator/Pairing.res.mjs +415 -0
- package/src/generator/PlatformCodegen.res +207 -0
- package/src/generator/PlatformCodegen.res.mjs +154 -0
- package/src/generator/PlatformGenerator.res +126 -0
- package/src/generator/PlatformGenerator.res.mjs +114 -0
- package/src/generator/PlatformManifests.res +203 -0
- package/src/generator/PlatformManifests.res.mjs +212 -0
- package/src/generator/PluginGenerator.res +57 -0
- package/src/generator/PluginGenerator.res.mjs +73 -0
- package/src/semantic/Bytes.res +54 -0
- package/src/semantic/Bytes.res.mjs +38 -0
- package/src/semantic/Capabilities.res +43 -0
- package/src/semantic/Capabilities.res.mjs +17 -0
- package/src/semantic/Color.res +51 -0
- package/src/semantic/Color.res.mjs +29 -0
- package/src/semantic/Currency.res +598 -0
- package/src/semantic/Currency.res.mjs +743 -0
- package/src/semantic/DateRange.res +148 -0
- package/src/semantic/DateRange.res.mjs +74 -0
- package/src/semantic/Duration.res +53 -0
- package/src/semantic/Duration.res.mjs +26 -0
- package/src/semantic/Email.res +51 -0
- package/src/semantic/Email.res.mjs +31 -0
- package/src/semantic/GeoPoint.res +226 -0
- package/src/semantic/GeoPoint.res.mjs +190 -0
- package/src/semantic/Geocoding.res +127 -0
- package/src/semantic/Geocoding.res.mjs +36 -0
- package/src/semantic/Money.res +196 -0
- package/src/semantic/Money.res.mjs +138 -0
- package/src/semantic/Offload.res +294 -0
- package/src/semantic/Offload.res.mjs +191 -0
- package/src/semantic/Percent.res +53 -0
- package/src/semantic/Percent.res.mjs +33 -0
- package/src/semantic/Phone.res +55 -0
- package/src/semantic/Phone.res.mjs +29 -0
- package/src/semantic/Semantic.res +162 -0
- package/src/semantic/Semantic.res.mjs +95 -0
- package/src/semantic/StorageRef.res +164 -0
- package/src/semantic/StorageRef.res.mjs +111 -0
- package/src/semantic/Url.res +66 -0
- package/src/semantic/Url.res.mjs +48 -0
- package/src/types/Authorization.res +23 -0
- package/src/types/Authorization.res.mjs +33 -0
- package/src/types/Behavior.res +86 -0
- package/src/types/Behavior.res.mjs +2 -0
- package/src/types/DateTime.res +29 -0
- package/src/types/DateTime.res.mjs +16 -0
- package/src/types/EventMapping.res +100 -0
- package/src/types/EventMapping.res.mjs +15 -0
- package/src/types/Handler.res +30 -0
- package/src/types/Handler.res.mjs +2 -0
- package/src/types/Id.res +75 -0
- package/src/types/Id.res.mjs +37 -0
- package/src/types/Identity.res +46 -0
- package/src/types/Identity.res.mjs +51 -0
- package/src/types/Message.res +326 -0
- package/src/types/Message.res.mjs +186 -0
- package/src/types/Projection.res +220 -0
- package/src/types/Projection.res.mjs +44 -0
- package/src/types/QueryEngine.res +123 -0
- package/src/types/QueryEngine.res.mjs +12 -0
- package/src/types/ReadConsistency.res +38 -0
- package/src/types/ReadConsistency.res.mjs +29 -0
- package/src/types/Schedule.res +65 -0
- package/src/types/Schedule.res.mjs +68 -0
- package/src/types/SideEffect.res +45 -0
- package/src/types/SideEffect.res.mjs +2 -0
- package/src/types/StoredEvent.res +46 -0
- package/src/types/StoredEvent.res.mjs +32 -0
- package/src/types/Visibility.res +24 -0
- package/src/types/Visibility.res.mjs +25 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
type validationError = {
|
|
2
|
+
sliceName: string,
|
|
3
|
+
message: string,
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
type variantInfo = {
|
|
7
|
+
tagName: string,
|
|
8
|
+
fields: dict<S.t<unknown>>,
|
|
9
|
+
taggedFields: array<string>,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Extract variant info (TAG name, fields, tagged fields) from a single variant schema
|
|
13
|
+
let extractVariantInfo = (variantSchema: S.t<unknown>): option<variantInfo> =>
|
|
14
|
+
switch variantSchema {
|
|
15
|
+
| Object({items, properties}) =>
|
|
16
|
+
DcbTag.variantTagName(items)->Option.map(tagName => {
|
|
17
|
+
let taggedFields =
|
|
18
|
+
properties
|
|
19
|
+
->Dict.toArray
|
|
20
|
+
->Array.filterMap(((fieldName, fieldSchema)) =>
|
|
21
|
+
if DcbTag.isTagged(fieldSchema) {
|
|
22
|
+
Some(fieldName)
|
|
23
|
+
} else {
|
|
24
|
+
None
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
{tagName, fields: properties, taggedFields}
|
|
28
|
+
})
|
|
29
|
+
| _ => None
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Extract all variant infos from a schema (handles Union and single Object)
|
|
33
|
+
let extractAllVariants = (schema: S.t<unknown>): array<variantInfo> =>
|
|
34
|
+
switch schema {
|
|
35
|
+
| Union({anyOf}) => anyOf->Array.filterMap(extractVariantInfo)
|
|
36
|
+
| Object(_) =>
|
|
37
|
+
switch extractVariantInfo(schema) {
|
|
38
|
+
| Some(info) => [info]
|
|
39
|
+
| None => []
|
|
40
|
+
}
|
|
41
|
+
| String({const: ?Some(name)}) =>
|
|
42
|
+
// Single payload-less variant — sury compiles `type t = Foo` to String({const: "Foo"})
|
|
43
|
+
[{tagName: name, fields: Dict.make(), taggedFields: []}]
|
|
44
|
+
| _ => []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check if a variant has no payload fields (only TAG)
|
|
48
|
+
let isPayloadLess = (info: variantInfo): bool => info.fields->Dict.toArray->Array.length == 0
|
|
49
|
+
|
|
50
|
+
// Compare two sury schema types for structural compatibility. Objects and
|
|
51
|
+
// unions recurse: previously any (Object, Object) / (Union, Union) pair was
|
|
52
|
+
// declared compatible, so nested payload drift (a renamed/retyped nested field,
|
|
53
|
+
// a changed variant) passed validation undetected.
|
|
54
|
+
let rec schemasAreCompatible = (a: S.t<unknown>, b: S.t<unknown>): bool =>
|
|
55
|
+
switch (a, b) {
|
|
56
|
+
| (String(_), String(_)) => true
|
|
57
|
+
| (Number(_), Number(_)) => true
|
|
58
|
+
| (Boolean(_), Boolean(_)) => true
|
|
59
|
+
| (BigInt(_), BigInt(_)) => true
|
|
60
|
+
| (Array({additionalItems: Schema(aItem)}), Array({additionalItems: Schema(bItem)})) =>
|
|
61
|
+
schemasAreCompatible(aItem, bItem)
|
|
62
|
+
| (Object({properties: aProps}), Object({properties: bProps})) => propsCompatible(aProps, bProps)
|
|
63
|
+
| (Union(_), Union(_)) =>
|
|
64
|
+
// Same set of variant tags, and each shared variant's payload compatible.
|
|
65
|
+
let av = extractAllVariants(a)
|
|
66
|
+
let bv = extractAllVariants(b)
|
|
67
|
+
let at = av->Array.map(v => v.tagName)->Array.toSorted(String.compare)
|
|
68
|
+
let bt = bv->Array.map(v => v.tagName)->Array.toSorted(String.compare)
|
|
69
|
+
at == bt &&
|
|
70
|
+
at->Array.every(tag =>
|
|
71
|
+
switch (av->Array.find(v => v.tagName == tag), bv->Array.find(v => v.tagName == tag)) {
|
|
72
|
+
| (Some(x), Some(y)) => propsCompatible(x.fields, y.fields)
|
|
73
|
+
| _ => false
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
| _ => false
|
|
77
|
+
}
|
|
78
|
+
// Two property maps are compatible when they carry the same field names and each
|
|
79
|
+
// field's schema is compatible.
|
|
80
|
+
and propsCompatible = (aProps: dict<S.t<unknown>>, bProps: dict<S.t<unknown>>): bool => {
|
|
81
|
+
let aKeys = aProps->Dict.keysToArray->Array.toSorted(String.compare)
|
|
82
|
+
let bKeys = bProps->Dict.keysToArray->Array.toSorted(String.compare)
|
|
83
|
+
aKeys == bKeys &&
|
|
84
|
+
aKeys->Array.every(k =>
|
|
85
|
+
switch (aProps->Dict.get(k), bProps->Dict.get(k)) {
|
|
86
|
+
| (Some(av), Some(bv)) => schemasAreCompatible(av, bv)
|
|
87
|
+
| _ => false
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Get a human-readable type name for a schema
|
|
93
|
+
let schemaTypeName = (schema: S.t<unknown>): string =>
|
|
94
|
+
switch schema {
|
|
95
|
+
| String(_) => "string"
|
|
96
|
+
| Number(_) => "float"
|
|
97
|
+
| Boolean(_) => "bool"
|
|
98
|
+
| BigInt(_) => "bigint"
|
|
99
|
+
| Array(_) => "array"
|
|
100
|
+
| Object(_) => "object"
|
|
101
|
+
| Union(_) => "union"
|
|
102
|
+
| Never(_) => "never"
|
|
103
|
+
| Unknown(_) => "unknown"
|
|
104
|
+
| Ref(_) => "ref"
|
|
105
|
+
| _ => "unknown"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type producerEntry = {
|
|
109
|
+
sliceName: string,
|
|
110
|
+
variant: variantInfo,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let dedupeKeys = (keys: array<string>): array<string> => {
|
|
114
|
+
let seen = Set.make()
|
|
115
|
+
keys->Array.filter(k =>
|
|
116
|
+
if seen->Set.has(k) {
|
|
117
|
+
false
|
|
118
|
+
} else {
|
|
119
|
+
seen->Set.add(k)
|
|
120
|
+
true
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
Warns when a slice issues a **composite** (multi-tag) decision read for an event
|
|
127
|
+
type whose *produced* tag set is a strict superset of the query's tags.
|
|
128
|
+
|
|
129
|
+
The `tag_composite` GSI key is built from **all** of an event's tags, so a
|
|
130
|
+
composite query matches only events tagged *exactly* its query set. An event that
|
|
131
|
+
also carries an extra tag is therefore silently missed by the composite read,
|
|
132
|
+
while its per-tag fences still move (Issue 5). This commonly bites when a tag is
|
|
133
|
+
later added to a multi-tag event without updating the reading slice.
|
|
134
|
+
|
|
135
|
+
Only all-scalar multi-tag commands build composite reads: a command carrying a
|
|
136
|
+
tagged array reads via per-element OR (single-tag) clauses, which cannot hit the
|
|
137
|
+
exact-match trap. Returns one `validationError` (warning) per offending
|
|
138
|
+
(slice, consumed type) pair.
|
|
139
|
+
|
|
140
|
+
@param slices `(sliceName, commandSchema, consumedEventSchema)` per consumer.
|
|
141
|
+
@param producedTagKeys event type → its produced tag-key set (from the event-log schema).
|
|
142
|
+
*/
|
|
143
|
+
let validateCompositeReads = (
|
|
144
|
+
~slices: array<(string, S.t<unknown>, S.t<unknown>)>,
|
|
145
|
+
~producedTagKeys: dict<array<string>>,
|
|
146
|
+
): array<validationError> => {
|
|
147
|
+
let warnings: array<validationError> = []
|
|
148
|
+
slices->Array.forEach(((sliceName, commandSchema, consumedSchema)) =>
|
|
149
|
+
if !DcbTag.hasTaggedArrayFields(commandSchema) {
|
|
150
|
+
let commandTagKeysByVariant = DcbTag.extractTagKeysByEventType(commandSchema)
|
|
151
|
+
let consumedTypes = DcbTag.extractVariantNames(consumedSchema)
|
|
152
|
+
commandTagKeysByVariant
|
|
153
|
+
->Dict.valuesToArray
|
|
154
|
+
->Array.forEach(cmdKeys => {
|
|
155
|
+
let querySet = dedupeKeys(cmdKeys)
|
|
156
|
+
if querySet->Array.length >= 2 {
|
|
157
|
+
consumedTypes->Array.forEach(consumedType =>
|
|
158
|
+
switch producedTagKeys->Dict.get(consumedType) {
|
|
159
|
+
| None => ()
|
|
160
|
+
| Some(producedKeys) =>
|
|
161
|
+
let producedSet = dedupeKeys(producedKeys)
|
|
162
|
+
let isSuperset = querySet->Array.every(k => producedSet->Array.includes(k))
|
|
163
|
+
if isSuperset && producedSet->Array.length > querySet->Array.length {
|
|
164
|
+
let extra = producedSet->Array.filter(k => !(querySet->Array.includes(k)))
|
|
165
|
+
let _ = warnings->Array.push({
|
|
166
|
+
sliceName,
|
|
167
|
+
message: `composite read on tags [${querySet->Array.join(
|
|
168
|
+
", ",
|
|
169
|
+
)}] will silently miss '${consumedType}', which also carries [${extra->Array.join(
|
|
170
|
+
", ",
|
|
171
|
+
)}] — the tag_composite key includes ALL of an event's tags, so a composite query matches only events tagged exactly [${querySet->Array.join(
|
|
172
|
+
", ",
|
|
173
|
+
)}]. Align the event's tag set with the query, or read '${consumedType}' via a single-tag clause.`,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
warnings
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let validateProducedAndConsumed = (
|
|
186
|
+
~produced: array<(string, S.t<unknown>)>,
|
|
187
|
+
~consumed: array<(string, S.t<unknown>)>,
|
|
188
|
+
): result<unit, array<validationError>> => {
|
|
189
|
+
let errors: array<validationError> = []
|
|
190
|
+
|
|
191
|
+
// Build a map of TAG name -> array of (sliceName, variantInfo) for produced events
|
|
192
|
+
let producerMap: dict<array<producerEntry>> = Dict.make()
|
|
193
|
+
produced->Array.forEach(((sliceName, schema)) => {
|
|
194
|
+
let variants = extractAllVariants(schema)
|
|
195
|
+
variants->Array.forEach(variant => {
|
|
196
|
+
let existing = producerMap->Dict.get(variant.tagName)->Option.getOr([])
|
|
197
|
+
producerMap->Dict.set(variant.tagName, existing->Array.concat([{sliceName, variant}]))
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// Rule 1: Payload equivalence across producers
|
|
202
|
+
// All producers of the same TAG must have identical fields, types, and tag annotations
|
|
203
|
+
producerMap->Dict.toArray->Array.forEach(((tagName, entries)) => {
|
|
204
|
+
if entries->Array.length > 1 {
|
|
205
|
+
let first = entries->Array.getUnsafe(0)
|
|
206
|
+
let firstFields = first.variant.fields->Dict.keysToArray->Array.toSorted(String.compare)
|
|
207
|
+
let firstTagged = first.variant.taggedFields->Array.toSorted(String.compare)
|
|
208
|
+
|
|
209
|
+
entries->Array.forEachWithIndex((entry, idx) => {
|
|
210
|
+
if idx > 0 {
|
|
211
|
+
let entryFields = entry.variant.fields->Dict.keysToArray->Array.toSorted(String.compare)
|
|
212
|
+
let entryTagged = entry.variant.taggedFields->Array.toSorted(String.compare)
|
|
213
|
+
|
|
214
|
+
// Check field names match
|
|
215
|
+
if firstFields->Array.length != entryFields->Array.length ||
|
|
216
|
+
firstFields->Array.some(f => !(entryFields->Array.includes(f))) {
|
|
217
|
+
let missingInEntry =
|
|
218
|
+
firstFields->Array.filter(f => !(entryFields->Array.includes(f)))
|
|
219
|
+
let extraInEntry =
|
|
220
|
+
entryFields->Array.filter(f => !(firstFields->Array.includes(f)))
|
|
221
|
+
let parts = []
|
|
222
|
+
if missingInEntry->Array.length > 0 {
|
|
223
|
+
let _ =
|
|
224
|
+
parts->Array.push(
|
|
225
|
+
`'${first.sliceName}' has fields [${missingInEntry->Array.join(", ")}] which '${entry.sliceName}' is missing`,
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
if extraInEntry->Array.length > 0 {
|
|
229
|
+
let _ =
|
|
230
|
+
parts->Array.push(
|
|
231
|
+
`'${entry.sliceName}' has fields [${extraInEntry->Array.join(", ")}] which '${first.sliceName}' is missing`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
let _ =
|
|
235
|
+
errors->Array.push({
|
|
236
|
+
sliceName: entry.sliceName,
|
|
237
|
+
message: `Producers '${first.sliceName}' and '${entry.sliceName}' both produce '${tagName}' but with different fields: ${parts->Array.join("; ")}`,
|
|
238
|
+
})
|
|
239
|
+
} else {
|
|
240
|
+
// Fields match by name — check types are compatible
|
|
241
|
+
firstFields->Array.forEach(fieldName => {
|
|
242
|
+
let firstSchema = first.variant.fields->Dict.getUnsafe(fieldName)
|
|
243
|
+
let entrySchema = entry.variant.fields->Dict.getUnsafe(fieldName)
|
|
244
|
+
if !schemasAreCompatible(firstSchema, entrySchema) {
|
|
245
|
+
let _ =
|
|
246
|
+
errors->Array.push({
|
|
247
|
+
sliceName: entry.sliceName,
|
|
248
|
+
message: `Producers '${first.sliceName}' and '${entry.sliceName}' both produce '${tagName}' but field '${fieldName}' has type '${schemaTypeName(firstSchema)}' in '${first.sliceName}' and '${schemaTypeName(entrySchema)}' in '${entry.sliceName}'`,
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Check tag annotations match
|
|
255
|
+
if firstTagged->Array.length != entryTagged->Array.length ||
|
|
256
|
+
firstTagged->Array.some(f => !(entryTagged->Array.includes(f))) {
|
|
257
|
+
let untaggedInEntry =
|
|
258
|
+
firstTagged->Array.filter(f => !(entryTagged->Array.includes(f)))
|
|
259
|
+
let untaggedInFirst =
|
|
260
|
+
entryTagged->Array.filter(f => !(firstTagged->Array.includes(f)))
|
|
261
|
+
let parts = []
|
|
262
|
+
if untaggedInEntry->Array.length > 0 {
|
|
263
|
+
let _ =
|
|
264
|
+
parts->Array.push(
|
|
265
|
+
`[${untaggedInEntry->Array.join(", ")}] tagged in '${first.sliceName}' but not in '${entry.sliceName}'`,
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
if untaggedInFirst->Array.length > 0 {
|
|
269
|
+
let _ =
|
|
270
|
+
parts->Array.push(
|
|
271
|
+
`[${untaggedInFirst->Array.join(", ")}] tagged in '${entry.sliceName}' but not in '${first.sliceName}'`,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
let _ =
|
|
275
|
+
errors->Array.push({
|
|
276
|
+
sliceName: entry.sliceName,
|
|
277
|
+
message: `Producers '${first.sliceName}' and '${entry.sliceName}' both produce '${tagName}' but tag annotations differ: ${parts->Array.join("; ")}`,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// Rule 2: Every consumed event has a producer
|
|
286
|
+
// Rule 3: Consumed fields exist in produced shape (payload-less always valid)
|
|
287
|
+
// Rule 4: Type compatibility
|
|
288
|
+
consumed->Array.forEach(((sliceName, schema)) => {
|
|
289
|
+
let variants = extractAllVariants(schema)
|
|
290
|
+
variants->Array.forEach(variant => {
|
|
291
|
+
switch producerMap->Dict.get(variant.tagName) {
|
|
292
|
+
| None =>
|
|
293
|
+
let _ =
|
|
294
|
+
errors->Array.push({
|
|
295
|
+
sliceName,
|
|
296
|
+
message: `Slice '${sliceName}' consumes '${variant.tagName}' but no slice produces it`,
|
|
297
|
+
})
|
|
298
|
+
| Some(producers) =>
|
|
299
|
+
// Use first producer as the authoritative shape (Rule 1 ensures they're all identical)
|
|
300
|
+
let producer = producers->Array.getUnsafe(0)
|
|
301
|
+
|
|
302
|
+
// Skip field checks for payload-less consumed variants
|
|
303
|
+
if !isPayloadLess(variant) {
|
|
304
|
+
variant.fields->Dict.toArray->Array.forEach(((fieldName, consumedFieldSchema)) => {
|
|
305
|
+
switch producer.variant.fields->Dict.get(fieldName) {
|
|
306
|
+
| None =>
|
|
307
|
+
let _ =
|
|
308
|
+
errors->Array.push({
|
|
309
|
+
sliceName,
|
|
310
|
+
message: `Slice '${sliceName}' consumes '${variant.tagName}.${fieldName}' (${schemaTypeName(consumedFieldSchema)}) but produced '${variant.tagName}' has no field '${fieldName}'`,
|
|
311
|
+
})
|
|
312
|
+
| Some(producedFieldSchema) =>
|
|
313
|
+
if !schemasAreCompatible(consumedFieldSchema, producedFieldSchema) {
|
|
314
|
+
let _ =
|
|
315
|
+
errors->Array.push({
|
|
316
|
+
sliceName,
|
|
317
|
+
message: `Slice '${sliceName}' consumes '${variant.tagName}.${fieldName}' as ${schemaTypeName(consumedFieldSchema)} but producer declares it as ${schemaTypeName(producedFieldSchema)}`,
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
if errors->Array.length > 0 {
|
|
328
|
+
Error(errors)
|
|
329
|
+
} else {
|
|
330
|
+
Ok()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
Validates that DCB tag *scope* (`PartitionScoped` default vs `@crossPartition`)
|
|
336
|
+
agrees across every produced event type that carries a given tag key.
|
|
337
|
+
|
|
338
|
+
The scope is a property of the tag *key*: read routing and — decisively — the
|
|
339
|
+
consistency fence are driven by one global per-key flag (the fence is bumped by
|
|
340
|
+
*writers*, so a key cannot be cross-partition for one producer and
|
|
341
|
+
partition-scoped for another without making the fence ambiguous). This returns
|
|
342
|
+
one warning per producer that carries a key some *other* producer declared
|
|
343
|
+
`@crossPartition` but this one left partition-scoped.
|
|
344
|
+
|
|
345
|
+
@param producers `(sliceName, producedEventSchema)` per producing slice.
|
|
346
|
+
*/
|
|
347
|
+
let validateCrossPartitionScope = (
|
|
348
|
+
~producers: array<(string, S.t<unknown>)>,
|
|
349
|
+
): array<validationError> => {
|
|
350
|
+
let perProducer = producers->Array.map(((name, schema)) => {
|
|
351
|
+
let cpSet = Set.make()
|
|
352
|
+
DcbTag.extractCrossPartitionTagKeys(schema)->Array.forEach(k => cpSet->Set.add(k))
|
|
353
|
+
let carried = Set.make()
|
|
354
|
+
DcbTag.extractTagKeysByEventType(schema)
|
|
355
|
+
->Dict.valuesToArray
|
|
356
|
+
->Array.forEach(ks => ks->Array.forEach(k => carried->Set.add(k)))
|
|
357
|
+
(name, cpSet, carried)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
let globalCp = Set.make()
|
|
361
|
+
perProducer->Array.forEach(((_, cpSet, _)) =>
|
|
362
|
+
cpSet->Set.values->Array.fromIterator->Array.forEach(k => globalCp->Set.add(k))
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
let warnings: array<validationError> = []
|
|
366
|
+
perProducer->Array.forEach(((name, cpSet, carried)) =>
|
|
367
|
+
carried
|
|
368
|
+
->Set.values
|
|
369
|
+
->Array.fromIterator
|
|
370
|
+
->Array.toSorted((a, b) => String.compare(a, b))
|
|
371
|
+
->Array.forEach(k =>
|
|
372
|
+
if globalCp->Set.has(k) && !(cpSet->Set.has(k)) {
|
|
373
|
+
let _ = warnings->Array.push({
|
|
374
|
+
sliceName: name,
|
|
375
|
+
message: `tag '${k}' is declared @crossPartition by another producer but is partition-scoped here — DCB tag scope must agree across every event type that carries the tag (read-scope and fence-scope follow one global per-key flag). Add @crossPartition to '${k}' here, or remove it from the other producer.`,
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
warnings
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Two-bucket result of validating annotations against the inferred scope. */
|
|
384
|
+
type scopeInferenceIssues = {
|
|
385
|
+
/** Annotations that conflict with inference — likely bugs (warn/error). */
|
|
386
|
+
contradictions: array<validationError>,
|
|
387
|
+
/** Annotations inference already derives — safe to delete (info). */
|
|
388
|
+
redundancies: array<validationError>,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
Validates explicit `@crossPartition` annotations against the *inferred* scope,
|
|
393
|
+
now that inference drives the decision-query wiring. Two cases per annotated key:
|
|
394
|
+
|
|
395
|
+
- **Contradiction** — the key is marked `@crossPartition` on a slice that inference
|
|
396
|
+
resolves as that slice's *own partition*. A slice's own identity is never a
|
|
397
|
+
cross-partition read; the annotation is wrong and (were it to drive the wiring)
|
|
398
|
+
would fan the partition into a spurious secondary read. Surface loudly.
|
|
399
|
+
- **Redundant** — the key is marked `@crossPartition` and inference *also* derives
|
|
400
|
+
it as cross-partition from the slice graph. Harmless, but the annotation can be
|
|
401
|
+
dropped — that is the whole point of inference.
|
|
402
|
+
|
|
403
|
+
@param annotations `(sliceName, @crossPartition keys on the slice's produced event)`.
|
|
404
|
+
*/
|
|
405
|
+
let validateScopeVsInference = (
|
|
406
|
+
~annotations: array<(string, array<string>)>,
|
|
407
|
+
~inferred: DcbScopeInference.derived,
|
|
408
|
+
): scopeInferenceIssues => {
|
|
409
|
+
let contradictions: array<validationError> = []
|
|
410
|
+
let redundancies: array<validationError> = []
|
|
411
|
+
annotations->Array.forEach(((name, cpKeys)) =>
|
|
412
|
+
cpKeys->Array.forEach(k =>
|
|
413
|
+
if inferred.partitionBySlice->Dict.get(name) == Some(k) {
|
|
414
|
+
let _ = contradictions->Array.push({
|
|
415
|
+
sliceName: name,
|
|
416
|
+
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.`,
|
|
417
|
+
})
|
|
418
|
+
} else if inferred.crossPartitionTagKeys->Array.includes(k) {
|
|
419
|
+
let _ = redundancies->Array.push({
|
|
420
|
+
sliceName: name,
|
|
421
|
+
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.`,
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
{contradictions, redundancies}
|
|
427
|
+
}
|