@reventlessdev/reventless-spec 3.0.0-alpha.61

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +625 -0
  2. package/LICENSE +202 -0
  3. package/README.md +114 -0
  4. package/package.json +34 -0
  5. package/rescript.json +25 -0
  6. package/run-generator.mjs +2 -0
  7. package/src/AnsiStyle.res +40 -0
  8. package/src/AnsiStyle.res.mjs +54 -0
  9. package/src/LogPrefix.res +168 -0
  10. package/src/LogPrefix.res.mjs +148 -0
  11. package/src/PackageVersion.res +73 -0
  12. package/src/PackageVersion.res.mjs +81 -0
  13. package/src/components/Aggregate.res +64 -0
  14. package/src/components/Aggregate.res.mjs +2 -0
  15. package/src/components/AutomationSlice.res +279 -0
  16. package/src/components/AutomationSlice.res.mjs +30 -0
  17. package/src/components/Counter.res +24 -0
  18. package/src/components/Counter.res.mjs +2 -0
  19. package/src/components/DcbDecode.res +128 -0
  20. package/src/components/DcbDecode.res.mjs +117 -0
  21. package/src/components/DcbTag.res +1256 -0
  22. package/src/components/DcbTag.res.mjs +869 -0
  23. package/src/components/DcbValidation.res +362 -0
  24. package/src/components/DcbValidation.res.mjs +360 -0
  25. package/src/components/DisplayName.res +40 -0
  26. package/src/components/DisplayName.res.mjs +26 -0
  27. package/src/components/ExtensionPoint.res +27 -0
  28. package/src/components/ExtensionPoint.res.mjs +2 -0
  29. package/src/components/InboundTranslationSlice.res +79 -0
  30. package/src/components/InboundTranslationSlice.res.mjs +2 -0
  31. package/src/components/OutboundTranslationSlice.res +108 -0
  32. package/src/components/OutboundTranslationSlice.res.mjs +2 -0
  33. package/src/components/Plugin.res +373 -0
  34. package/src/components/Plugin.res.mjs +249 -0
  35. package/src/components/ReadModel.res +199 -0
  36. package/src/components/ReadModel.res.mjs +18 -0
  37. package/src/components/Reference.res +51 -0
  38. package/src/components/Reference.res.mjs +37 -0
  39. package/src/components/StateAnnotations.res +65 -0
  40. package/src/components/StateAnnotations.res.mjs +15 -0
  41. package/src/components/StateChangeSlice.res +131 -0
  42. package/src/components/StateChangeSlice.res.mjs +2 -0
  43. package/src/components/StateViewSlice.res +99 -0
  44. package/src/components/StateViewSlice.res.mjs +2 -0
  45. package/src/components/Task.res +62 -0
  46. package/src/components/Task.res.mjs +2 -0
  47. package/src/generator/Codegen.res +703 -0
  48. package/src/generator/Codegen.res.mjs +485 -0
  49. package/src/generator/Config.res +85 -0
  50. package/src/generator/Config.res.mjs +78 -0
  51. package/src/generator/Discovery.res +204 -0
  52. package/src/generator/Discovery.res.mjs +213 -0
  53. package/src/generator/Generator_Node.res +49 -0
  54. package/src/generator/Generator_Node.res.mjs +18 -0
  55. package/src/generator/Pairing.res +386 -0
  56. package/src/generator/Pairing.res.mjs +353 -0
  57. package/src/generator/PluginGenerator.res +53 -0
  58. package/src/generator/PluginGenerator.res.mjs +70 -0
  59. package/src/types/Authorization.res +23 -0
  60. package/src/types/Authorization.res.mjs +33 -0
  61. package/src/types/Behavior.res +78 -0
  62. package/src/types/Behavior.res.mjs +2 -0
  63. package/src/types/EventMapping.res +100 -0
  64. package/src/types/EventMapping.res.mjs +15 -0
  65. package/src/types/Handler.res +30 -0
  66. package/src/types/Handler.res.mjs +2 -0
  67. package/src/types/Id.res +75 -0
  68. package/src/types/Id.res.mjs +37 -0
  69. package/src/types/Identity.res +46 -0
  70. package/src/types/Identity.res.mjs +51 -0
  71. package/src/types/Message.res +197 -0
  72. package/src/types/Message.res.mjs +108 -0
  73. package/src/types/Projection.res +220 -0
  74. package/src/types/Projection.res.mjs +44 -0
  75. package/src/types/QueryEngine.res +123 -0
  76. package/src/types/QueryEngine.res.mjs +12 -0
  77. package/src/types/ReadConsistency.res +38 -0
  78. package/src/types/ReadConsistency.res.mjs +29 -0
  79. package/src/types/Schedule.res +65 -0
  80. package/src/types/Schedule.res.mjs +68 -0
  81. package/src/types/SideEffect.res +45 -0
  82. package/src/types/SideEffect.res.mjs +2 -0
  83. package/src/types/StoredEvent.res +46 -0
  84. package/src/types/StoredEvent.res.mjs +32 -0
  85. package/src/types/Visibility.res +24 -0
  86. package/src/types/Visibility.res.mjs +25 -0
@@ -0,0 +1,362 @@
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
+ let tagName =
17
+ items
18
+ ->Array.find(item => item.location == "TAG")
19
+ ->Option.flatMap(item =>
20
+ switch item.schema {
21
+ | String({const}) => Some(const)
22
+ | _ => None
23
+ }
24
+ )
25
+ tagName->Option.map(tagName => {
26
+ let taggedFields =
27
+ properties
28
+ ->Dict.toArray
29
+ ->Array.filterMap(((fieldName, fieldSchema)) =>
30
+ if DcbTag.isTagged(fieldSchema) {
31
+ Some(fieldName)
32
+ } else {
33
+ None
34
+ }
35
+ )
36
+ {tagName, fields: properties, taggedFields}
37
+ })
38
+ | _ => None
39
+ }
40
+
41
+ // Extract all variant infos from a schema (handles Union and single Object)
42
+ let extractAllVariants = (schema: S.t<unknown>): array<variantInfo> =>
43
+ switch schema {
44
+ | Union({anyOf}) => anyOf->Array.filterMap(extractVariantInfo)
45
+ | Object(_) =>
46
+ switch extractVariantInfo(schema) {
47
+ | Some(info) => [info]
48
+ | None => []
49
+ }
50
+ | String({const: ?Some(name)}) =>
51
+ // Single payload-less variant — sury compiles `type t = Foo` to String({const: "Foo"})
52
+ [{tagName: name, fields: Dict.make(), taggedFields: []}]
53
+ | _ => []
54
+ }
55
+
56
+ // Check if a variant has no payload fields (only TAG)
57
+ let isPayloadLess = (info: variantInfo): bool => info.fields->Dict.toArray->Array.length == 0
58
+
59
+ // Compare two sury schema types for structural compatibility
60
+ let rec schemasAreCompatible = (a: S.t<unknown>, b: S.t<unknown>): bool =>
61
+ switch (a, b) {
62
+ | (String(_), String(_)) => true
63
+ | (Number(_), Number(_)) => true
64
+ | (Boolean(_), Boolean(_)) => true
65
+ | (BigInt(_), BigInt(_)) => true
66
+ | (Array({additionalItems: Schema(aItem)}), Array({additionalItems: Schema(bItem)})) =>
67
+ schemasAreCompatible(aItem, bItem)
68
+ | (Union(_), Union(_)) => true
69
+ | (Object(_), Object(_)) => true
70
+ | _ => false
71
+ }
72
+
73
+ // Get a human-readable type name for a schema
74
+ let schemaTypeName = (schema: S.t<unknown>): string =>
75
+ switch schema {
76
+ | String(_) => "string"
77
+ | Number(_) => "float"
78
+ | Boolean(_) => "bool"
79
+ | BigInt(_) => "bigint"
80
+ | Array(_) => "array"
81
+ | Object(_) => "object"
82
+ | Union(_) => "union"
83
+ | Never(_) => "never"
84
+ | Unknown(_) => "unknown"
85
+ | Ref(_) => "ref"
86
+ | _ => "unknown"
87
+ }
88
+
89
+ type producerEntry = {
90
+ sliceName: string,
91
+ variant: variantInfo,
92
+ }
93
+
94
+ let dedupeKeys = (keys: array<string>): array<string> => {
95
+ let seen = Set.make()
96
+ keys->Array.filter(k =>
97
+ if seen->Set.has(k) {
98
+ false
99
+ } else {
100
+ seen->Set.add(k)
101
+ true
102
+ }
103
+ )
104
+ }
105
+
106
+ /**
107
+ Warns when a slice issues a **composite** (multi-tag) decision read for an event
108
+ type whose *produced* tag set is a strict superset of the query's tags.
109
+
110
+ The `tag_composite` GSI key is built from **all** of an event's tags, so a
111
+ composite query matches only events tagged *exactly* its query set. An event that
112
+ also carries an extra tag is therefore silently missed by the composite read,
113
+ while its per-tag fences still move (Issue 5). This commonly bites when a tag is
114
+ later added to a multi-tag event without updating the reading slice.
115
+
116
+ Only all-scalar multi-tag commands build composite reads: a command carrying a
117
+ tagged array reads via per-element OR (single-tag) clauses, which cannot hit the
118
+ exact-match trap. Returns one `validationError` (warning) per offending
119
+ (slice, consumed type) pair.
120
+
121
+ @param slices `(sliceName, commandSchema, consumedEventSchema)` per consumer.
122
+ @param producedTagKeys event type → its produced tag-key set (from the event-log schema).
123
+ */
124
+ let validateCompositeReads = (
125
+ ~slices: array<(string, S.t<unknown>, S.t<unknown>)>,
126
+ ~producedTagKeys: dict<array<string>>,
127
+ ): array<validationError> => {
128
+ let warnings: array<validationError> = []
129
+ slices->Array.forEach(((sliceName, commandSchema, consumedSchema)) =>
130
+ if !DcbTag.hasTaggedArrayFields(commandSchema) {
131
+ let commandTagKeysByVariant = DcbTag.extractTagKeysByEventType(commandSchema)
132
+ let consumedTypes = DcbTag.extractVariantNames(consumedSchema)
133
+ commandTagKeysByVariant
134
+ ->Dict.valuesToArray
135
+ ->Array.forEach(cmdKeys => {
136
+ let querySet = dedupeKeys(cmdKeys)
137
+ if querySet->Array.length >= 2 {
138
+ consumedTypes->Array.forEach(consumedType =>
139
+ switch producedTagKeys->Dict.get(consumedType) {
140
+ | None => ()
141
+ | Some(producedKeys) =>
142
+ let producedSet = dedupeKeys(producedKeys)
143
+ let isSuperset = querySet->Array.every(k => producedSet->Array.includes(k))
144
+ if isSuperset && producedSet->Array.length > querySet->Array.length {
145
+ let extra = producedSet->Array.filter(k => !(querySet->Array.includes(k)))
146
+ let _ = warnings->Array.push({
147
+ sliceName,
148
+ message: `composite read on tags [${querySet->Array.join(
149
+ ", ",
150
+ )}] will silently miss '${consumedType}', which also carries [${extra->Array.join(
151
+ ", ",
152
+ )}] — the tag_composite key includes ALL of an event's tags, so a composite query matches only events tagged exactly [${querySet->Array.join(
153
+ ", ",
154
+ )}]. Align the event's tag set with the query, or read '${consumedType}' via a single-tag clause.`,
155
+ })
156
+ }
157
+ }
158
+ )
159
+ }
160
+ })
161
+ }
162
+ )
163
+ warnings
164
+ }
165
+
166
+ let validateProducedAndConsumed = (
167
+ ~produced: array<(string, S.t<unknown>)>,
168
+ ~consumed: array<(string, S.t<unknown>)>,
169
+ ): result<unit, array<validationError>> => {
170
+ let errors: array<validationError> = []
171
+
172
+ // Build a map of TAG name -> array of (sliceName, variantInfo) for produced events
173
+ let producerMap: dict<array<producerEntry>> = Dict.make()
174
+ produced->Array.forEach(((sliceName, schema)) => {
175
+ let variants = extractAllVariants(schema)
176
+ variants->Array.forEach(variant => {
177
+ let existing = producerMap->Dict.get(variant.tagName)->Option.getOr([])
178
+ producerMap->Dict.set(variant.tagName, existing->Array.concat([{sliceName, variant}]))
179
+ })
180
+ })
181
+
182
+ // Rule 1: Payload equivalence across producers
183
+ // All producers of the same TAG must have identical fields, types, and tag annotations
184
+ producerMap->Dict.toArray->Array.forEach(((tagName, entries)) => {
185
+ if entries->Array.length > 1 {
186
+ let first = entries->Array.getUnsafe(0)
187
+ let firstFields = first.variant.fields->Dict.keysToArray->Array.toSorted(String.compare)
188
+ let firstTagged = first.variant.taggedFields->Array.toSorted(String.compare)
189
+
190
+ entries->Array.forEachWithIndex((entry, idx) => {
191
+ if idx > 0 {
192
+ let entryFields = entry.variant.fields->Dict.keysToArray->Array.toSorted(String.compare)
193
+ let entryTagged = entry.variant.taggedFields->Array.toSorted(String.compare)
194
+
195
+ // Check field names match
196
+ if firstFields->Array.length != entryFields->Array.length ||
197
+ firstFields->Array.some(f => !(entryFields->Array.includes(f))) {
198
+ let missingInEntry =
199
+ firstFields->Array.filter(f => !(entryFields->Array.includes(f)))
200
+ let extraInEntry =
201
+ entryFields->Array.filter(f => !(firstFields->Array.includes(f)))
202
+ let parts = []
203
+ if missingInEntry->Array.length > 0 {
204
+ let _ =
205
+ parts->Array.push(
206
+ `'${first.sliceName}' has fields [${missingInEntry->Array.join(", ")}] which '${entry.sliceName}' is missing`,
207
+ )
208
+ }
209
+ if extraInEntry->Array.length > 0 {
210
+ let _ =
211
+ parts->Array.push(
212
+ `'${entry.sliceName}' has fields [${extraInEntry->Array.join(", ")}] which '${first.sliceName}' is missing`,
213
+ )
214
+ }
215
+ let _ =
216
+ errors->Array.push({
217
+ sliceName: entry.sliceName,
218
+ message: `Producers '${first.sliceName}' and '${entry.sliceName}' both produce '${tagName}' but with different fields: ${parts->Array.join("; ")}`,
219
+ })
220
+ } else {
221
+ // Fields match by name — check types are compatible
222
+ firstFields->Array.forEach(fieldName => {
223
+ let firstSchema = first.variant.fields->Dict.getUnsafe(fieldName)
224
+ let entrySchema = entry.variant.fields->Dict.getUnsafe(fieldName)
225
+ if !schemasAreCompatible(firstSchema, entrySchema) {
226
+ let _ =
227
+ errors->Array.push({
228
+ sliceName: entry.sliceName,
229
+ 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}'`,
230
+ })
231
+ }
232
+ })
233
+ }
234
+
235
+ // Check tag annotations match
236
+ if firstTagged->Array.length != entryTagged->Array.length ||
237
+ firstTagged->Array.some(f => !(entryTagged->Array.includes(f))) {
238
+ let untaggedInEntry =
239
+ firstTagged->Array.filter(f => !(entryTagged->Array.includes(f)))
240
+ let untaggedInFirst =
241
+ entryTagged->Array.filter(f => !(firstTagged->Array.includes(f)))
242
+ let parts = []
243
+ if untaggedInEntry->Array.length > 0 {
244
+ let _ =
245
+ parts->Array.push(
246
+ `[${untaggedInEntry->Array.join(", ")}] tagged in '${first.sliceName}' but not in '${entry.sliceName}'`,
247
+ )
248
+ }
249
+ if untaggedInFirst->Array.length > 0 {
250
+ let _ =
251
+ parts->Array.push(
252
+ `[${untaggedInFirst->Array.join(", ")}] tagged in '${entry.sliceName}' but not in '${first.sliceName}'`,
253
+ )
254
+ }
255
+ let _ =
256
+ errors->Array.push({
257
+ sliceName: entry.sliceName,
258
+ message: `Producers '${first.sliceName}' and '${entry.sliceName}' both produce '${tagName}' but tag annotations differ: ${parts->Array.join("; ")}`,
259
+ })
260
+ }
261
+ }
262
+ })
263
+ }
264
+ })
265
+
266
+ // Rule 2: Every consumed event has a producer
267
+ // Rule 3: Consumed fields exist in produced shape (payload-less always valid)
268
+ // Rule 4: Type compatibility
269
+ consumed->Array.forEach(((sliceName, schema)) => {
270
+ let variants = extractAllVariants(schema)
271
+ variants->Array.forEach(variant => {
272
+ switch producerMap->Dict.get(variant.tagName) {
273
+ | None =>
274
+ let _ =
275
+ errors->Array.push({
276
+ sliceName,
277
+ message: `Slice '${sliceName}' consumes '${variant.tagName}' but no slice produces it`,
278
+ })
279
+ | Some(producers) =>
280
+ // Use first producer as the authoritative shape (Rule 1 ensures they're all identical)
281
+ let producer = producers->Array.getUnsafe(0)
282
+
283
+ // Skip field checks for payload-less consumed variants
284
+ if !isPayloadLess(variant) {
285
+ variant.fields->Dict.toArray->Array.forEach(((fieldName, consumedFieldSchema)) => {
286
+ switch producer.variant.fields->Dict.get(fieldName) {
287
+ | None =>
288
+ let _ =
289
+ errors->Array.push({
290
+ sliceName,
291
+ message: `Slice '${sliceName}' consumes '${variant.tagName}.${fieldName}' (${schemaTypeName(consumedFieldSchema)}) but produced '${variant.tagName}' has no field '${fieldName}'`,
292
+ })
293
+ | Some(producedFieldSchema) =>
294
+ if !schemasAreCompatible(consumedFieldSchema, producedFieldSchema) {
295
+ let _ =
296
+ errors->Array.push({
297
+ sliceName,
298
+ message: `Slice '${sliceName}' consumes '${variant.tagName}.${fieldName}' as ${schemaTypeName(consumedFieldSchema)} but producer declares it as ${schemaTypeName(producedFieldSchema)}`,
299
+ })
300
+ }
301
+ }
302
+ })
303
+ }
304
+ }
305
+ })
306
+ })
307
+
308
+ if errors->Array.length > 0 {
309
+ Error(errors)
310
+ } else {
311
+ Ok()
312
+ }
313
+ }
314
+
315
+ /**
316
+ Validates that DCB tag *scope* (`PartitionScoped` default vs `@crossPartition`)
317
+ agrees across every produced event type that carries a given tag key.
318
+
319
+ The scope is a property of the tag *key*: read routing and — decisively — the
320
+ consistency fence are driven by one global per-key flag (the fence is bumped by
321
+ *writers*, so a key cannot be cross-partition for one producer and
322
+ partition-scoped for another without making the fence ambiguous). This returns
323
+ one warning per producer that carries a key some *other* producer declared
324
+ `@crossPartition` but this one left partition-scoped.
325
+
326
+ @param producers `(sliceName, producedEventSchema)` per producing slice.
327
+ */
328
+ let validateCrossPartitionScope = (
329
+ ~producers: array<(string, S.t<unknown>)>,
330
+ ): array<validationError> => {
331
+ let perProducer = producers->Array.map(((name, schema)) => {
332
+ let cpSet = Set.make()
333
+ DcbTag.extractCrossPartitionTagKeys(schema)->Array.forEach(k => cpSet->Set.add(k))
334
+ let carried = Set.make()
335
+ DcbTag.extractTagKeysByEventType(schema)
336
+ ->Dict.valuesToArray
337
+ ->Array.forEach(ks => ks->Array.forEach(k => carried->Set.add(k)))
338
+ (name, cpSet, carried)
339
+ })
340
+
341
+ let globalCp = Set.make()
342
+ perProducer->Array.forEach(((_, cpSet, _)) =>
343
+ cpSet->Set.values->Array.fromIterator->Array.forEach(k => globalCp->Set.add(k))
344
+ )
345
+
346
+ let warnings: array<validationError> = []
347
+ perProducer->Array.forEach(((name, cpSet, carried)) =>
348
+ carried
349
+ ->Set.values
350
+ ->Array.fromIterator
351
+ ->Array.toSorted((a, b) => String.compare(a, b))
352
+ ->Array.forEach(k =>
353
+ if globalCp->Set.has(k) && !(cpSet->Set.has(k)) {
354
+ let _ = warnings->Array.push({
355
+ sliceName: name,
356
+ 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.`,
357
+ })
358
+ }
359
+ )
360
+ )
361
+ warnings
362
+ }