@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,1256 @@
1
+ // --- Tag types ---
2
+
3
+ /**
4
+ A key-value tag attached to a DCB event for content-based filtering.
5
+
6
+ Tags are extracted from `@s.matches(DcbTag.string)` or `@s.matches(DcbTag.int)`
7
+ annotated fields in an event's schema. The `key` is the field name and the
8
+ `value` is the serialized field value.
9
+
10
+ @example
11
+ ```rescript
12
+ // CatalogEventLog.res
13
+ @schema type event =
14
+ | CategoryAdded({categoryId: @s.matches(DcbTag.string) string, name: string})
15
+ | CategoryArchived({categoryId: @s.matches(DcbTag.string) string})
16
+ // Produces tag: {key: "categoryId", value: "cat-1"}
17
+ ```
18
+ */
19
+ @schema
20
+ type tag = {key: string, value: string}
21
+
22
+ /**
23
+ Identifies which tag field is used as the storage partition key.
24
+ The partition key determines how events are distributed across DynamoDB partitions.
25
+ */
26
+ type partitionTag = {key: string}
27
+
28
+ /**
29
+ A single clause in a DCB event query.
30
+
31
+ Combines an optional list of event type names with optional tags.
32
+ An event matches a `queryItem` if it matches ALL specified tags AND
33
+ its type is in the `eventTypes` list (or the list is absent).
34
+ */
35
+ type queryItem = {
36
+ eventTypes?: array<string>,
37
+ tags?: array<tag>,
38
+ }
39
+
40
+ /**
41
+ A DCB event log query — an array of `queryItem` clauses.
42
+
43
+ An event matches the query if it satisfies ANY of the clauses (OR semantics
44
+ across clauses, AND semantics within a clause).
45
+
46
+ @example
47
+ ```rescript
48
+ // Read CategoryAdded and CategoryArchived events for category "cat-1"
49
+ let q: DcbTag.query = [
50
+ {
51
+ eventTypes: ["CategoryAdded", "CategoryArchived"],
52
+ tags: [{key: "categoryId", value: "cat-1"}],
53
+ }
54
+ ]
55
+ ```
56
+ */
57
+ type query = array<queryItem>
58
+
59
+ /**
60
+ An opaque string that identifies a position in the DCB event log sequence.
61
+ Returned by `append` and accepted by `read` / `readStream` as the `~after` cursor.
62
+ */
63
+ type sequencePosition = string
64
+
65
+ /**
66
+ An optimistic-concurrency condition for a DCB `append` operation.
67
+
68
+ The append succeeds only if the events matching `query` have not changed
69
+ since the position `after`. Omit `after` to assert "no matching events exist yet".
70
+
71
+ @example
72
+ ```rescript
73
+ // Append only if no CategoryAdded events for "cat-1" exist yet
74
+ let condition: DcbTag.appendCondition = {
75
+ query: [{
76
+ eventTypes: ["CategoryAdded"],
77
+ tags: [{key: "categoryId", value: "cat-1"}],
78
+ }],
79
+ }
80
+ ```
81
+ */
82
+ type appendCondition = {
83
+ query: query,
84
+ after?: sequencePosition,
85
+ }
86
+
87
+ // --- Sury metadata for tag annotation ---
88
+
89
+ /** Internal sury metadata ID used to mark DCB-tagged schema fields. */
90
+ let dcbTagId: S.Metadata.Id.t<bool> = S.Metadata.Id.make(~namespace="dcb", ~name="tag")
91
+
92
+ /** Internal sury metadata ID used to mark the partition tag field. */
93
+ let dcbPartitionTagId: S.Metadata.Id.t<bool> = S.Metadata.Id.make(~namespace="dcb", ~name="partitionTag")
94
+
95
+ /**
96
+ Internal sury metadata ID marking a tag field as *cross-partition* — readable
97
+ across every partition that carries it (a secondary-tag read), not just the
98
+ partition it is the partition key of. Opt-in; absence = the default
99
+ partition-scoped behaviour. The scope is a property of the tag *key* and must
100
+ agree across every event type that carries it (the fence-scope half depends on
101
+ it). See `docs/analysis/dcb-consistency-check-issues.md` Issue 13.
102
+ */
103
+ let dcbCrossPartitionId: S.Metadata.Id.t<bool> =
104
+ S.Metadata.Id.make(~namespace="dcb", ~name="crossPartition")
105
+
106
+ /** Metadata value for a composite partition member field. */
107
+ type compositePartitionMemberMeta = {position: int, sep: string}
108
+
109
+ /** Internal sury metadata ID used to mark a composite partition member field. */
110
+ let dcbCompositePartitionMemberId: S.Metadata.Id.t<compositePartitionMemberMeta> =
111
+ S.Metadata.Id.make(~namespace="dcb", ~name="compositePartitionMember")
112
+
113
+ /**
114
+ Internal sury metadata ID carrying an explicit tag-key override. When present,
115
+ tag extraction uses the stored string as the tag `key` instead of the field name.
116
+ */
117
+ let dcbTagKeyOverrideId: S.Metadata.Id.t<string> =
118
+ S.Metadata.Id.make(~namespace="dcb", ~name="tagKeyOverride")
119
+
120
+ /**
121
+ A sury string schema annotated as a DCB tag field.
122
+
123
+ Use with `@s.matches(DcbTag.string)` on event and command record fields that
124
+ should be extracted as content-based routing tags. Works on both scalar fields
125
+ and array element types.
126
+
127
+ @example
128
+ ```rescript
129
+ // Scalar tag (single-entity query)
130
+ @schema type event =
131
+ | ProductAdded({productId: @s.matches(DcbTag.string) string, name: string, price: float})
132
+
133
+ // Array tag (cross-entity query — automatic per-element OR clauses)
134
+ @schema type command =
135
+ | PlaceOrder({
136
+ orderId: @s.matches(DcbTag.string) string,
137
+ productId: array<@s.matches(DcbTag.string) string>,
138
+ })
139
+ ```
140
+ */
141
+ let string: S.t<string> = S.string->S.Metadata.set(~id=dcbTagId, true)
142
+
143
+ /**
144
+ A sury string schema annotated as a DCB tag field with an explicit tag-key override.
145
+
146
+ Use when a field's record name differs from the desired tag key — for example,
147
+ plural-named multi-value fields whose elements should still be stored under the
148
+ singular tag key shared with single-value producers.
149
+
150
+ The PPX emits this constructor automatically for `*Ids: array<string>` fields
151
+ (stripping the trailing `s` to derive the key) and for `@dcbTag("explicitKey")`
152
+ annotations carrying a string payload.
153
+
154
+ @example
155
+ ```rescript
156
+ @schema type event =
157
+ OrderPlaced({
158
+ orderId: string,
159
+ productIds: array<@s.matches(DcbTag.stringForKey(~key="productId")) string>,
160
+ })
161
+ // productIds: ["p1", "p2"] → tags [{key: "productId", value: "p1"}, {key: "productId", value: "p2"}]
162
+ ```
163
+ */
164
+ let stringForKey = (~key: string): S.t<string> =>
165
+ S.string->S.Metadata.set(~id=dcbTagId, true)->S.Metadata.set(~id=dcbTagKeyOverrideId, key)
166
+
167
+ /**
168
+ A sury int schema annotated as a DCB tag field.
169
+ Use with `@s.matches(DcbTag.int)` on integer fields that should be extracted as tags.
170
+ */
171
+ let int: S.t<int> = S.int->S.Metadata.set(~id=dcbTagId, true)
172
+
173
+ /**
174
+ A sury string schema annotated as both a DCB tag field AND the partition key.
175
+
176
+ Use with `@s.matches(DcbTag.partition)` on the field whose value should become
177
+ the DynamoDB partition key. Required when a DCB spec has multiple tagged fields;
178
+ optional (auto-selected) when only one tagged field exists.
179
+
180
+ @example
181
+ ```rescript
182
+ @schema type event =
183
+ | OrderPlaced({
184
+ orderId: @s.matches(DcbTag.partition) string,
185
+ customerId: @s.matches(DcbTag.string) string,
186
+ })
187
+ ```
188
+ */
189
+ let partition: S.t<string> =
190
+ S.string->S.Metadata.set(~id=dcbTagId, true)->S.Metadata.set(~id=dcbPartitionTagId, true)
191
+
192
+ /**
193
+ A sury string schema annotated as a DCB tag field with *cross-partition* read
194
+ scope.
195
+
196
+ Use via the `@crossPartition` PPX field annotation (mirroring `@partitionTag` —
197
+ no arguments). A single-tag decision read of such a tag routes to the per-tag
198
+ `tag_<key>` GSI so it returns every event carrying the tag across *all*
199
+ partitions (a secondary-tag read), and the tag's consistency fence is bumped by
200
+ *every* carrier (primary or secondary) so optimistic concurrency catches a
201
+ concurrent secondary-tag writer. The default (un-annotated) scope stays
202
+ partition-scoped. The canonical use is an M:N invariant where the event ties two
203
+ entities but can be partitioned by only one (course-subscription capacity,
204
+ "≤ N orders per product", reservations).
205
+
206
+ @example
207
+ ```rescript
208
+ @schema type event =
209
+ | StudentSubscribed({
210
+ courseId: @s.matches(DcbTag.partition) string,
211
+ studentId: @s.matches(DcbTag.crossPartition) string,
212
+ })
213
+ ```
214
+ */
215
+ let crossPartition: S.t<string> =
216
+ S.string->S.Metadata.set(~id=dcbTagId, true)->S.Metadata.set(~id=dcbCrossPartitionId, true)
217
+
218
+ /**
219
+ A sury string schema marking a field as a composite partition key member.
220
+
221
+ Use via the `@compositePartitionTag` PPX annotation. The annotation injects
222
+ `@s.matches(DcbTag.compositePartitionMember(~position=N, ~sep="S"))` automatically.
223
+ Each such field is also a regular DCB tag (individually queryable).
224
+
225
+ @param position Zero-based index of this field in the composite key construction order.
226
+ @param sep Separator placed after this field's value (ignored on the last field).
227
+ */
228
+ let compositePartitionMember = (~position: int, ~sep: string="/"): S.t<string> =>
229
+ S.string
230
+ ->S.Metadata.set(~id=dcbTagId, true)
231
+ ->S.Metadata.set(~id=dcbCompositePartitionMemberId, {position, sep})
232
+
233
+ /** Returns `true` if the schema was annotated as a composite partition member. */
234
+ let isCompositePartitionMember = (fieldSchema: S.t<unknown>): bool =>
235
+ S.Metadata.get(fieldSchema, ~id=dcbCompositePartitionMemberId)->Option.isSome
236
+
237
+ /**
238
+ Composite partition key specification — the ordered field names and inter-field separators.
239
+ `seps[i]` is placed between `keys[i]` and `keys[i+1]`; length is always `keys.length - 1`.
240
+ */
241
+ type compositePartitionSpec = {
242
+ keys: array<string>,
243
+ seps: array<string>,
244
+ }
245
+
246
+ /** Union of simple and composite partition tag strategies. */
247
+ type derivedPartitionTag =
248
+ | Simple(partitionTag)
249
+ | Composite(compositePartitionSpec)
250
+
251
+ // --- Tag extraction from sury schemas ---
252
+
253
+ external toUnknownSchema: S.t<'a> => S.t<unknown> = "%identity"
254
+
255
+ /** Returns `true` if the schema was annotated with `DcbTag.string` or `DcbTag.int`. */
256
+ let isTagged = (fieldSchema: S.t<unknown>) =>
257
+ S.Metadata.get(fieldSchema, ~id=dcbTagId)->Option.isSome
258
+
259
+ /**
260
+ Returns `true` if the schema is an array whose item schema is DCB-tagged.
261
+ Used by `extractTagsFromPropertiesExpanded` to detect `array<@s.matches(DcbTag.string) string>`.
262
+ */
263
+ let isTaggedArray = (fieldSchema: S.t<unknown>) =>
264
+ switch fieldSchema {
265
+ | Array({additionalItems: Schema(itemSchema)}) => isTagged(itemSchema)
266
+ | _ => false
267
+ }
268
+
269
+ /** Returns `true` if the schema was annotated with `DcbTag.partition`. */
270
+ let isPartitionTag = (fieldSchema: S.t<unknown>) =>
271
+ S.Metadata.get(fieldSchema, ~id=dcbPartitionTagId)->Option.isSome
272
+
273
+ /** Returns `true` if the schema was annotated with `DcbTag.crossPartition`. */
274
+ let isCrossPartitionTag = (fieldSchema: S.t<unknown>) =>
275
+ S.Metadata.get(fieldSchema, ~id=dcbCrossPartitionId)->Option.isSome
276
+
277
+ /**
278
+ Returns `true` if the schema is an array whose item schema is a cross-partition
279
+ DCB tag (`array<@s.matches(DcbTag.crossPartition) string>`).
280
+ */
281
+ let isCrossPartitionTaggedArray = (fieldSchema: S.t<unknown>) =>
282
+ switch fieldSchema {
283
+ | Array({additionalItems: Schema(itemSchema)}) => isCrossPartitionTag(itemSchema)
284
+ | _ => false
285
+ }
286
+
287
+ /**
288
+ Resolves the tag key for a scalar tagged field: the explicit override metadata if
289
+ present, otherwise the field name.
290
+ */
291
+ let resolveTagKey = (fieldName: string, fieldSchema: S.t<unknown>): string =>
292
+ S.Metadata.get(fieldSchema, ~id=dcbTagKeyOverrideId)->Option.getOr(fieldName)
293
+
294
+ /**
295
+ Resolves the tag key for an array tagged field. The override metadata sits on the
296
+ inner element schema; falls back to the field name when no override is set.
297
+ */
298
+ let resolveArrayTagKey = (fieldName: string, fieldSchema: S.t<unknown>): string =>
299
+ switch fieldSchema {
300
+ | Array({additionalItems: Schema(itemSchema)}) =>
301
+ S.Metadata.get(itemSchema, ~id=dcbTagKeyOverrideId)->Option.getOr(fieldName)
302
+ | _ => fieldName
303
+ }
304
+
305
+ /** Converts a JSON value to its string representation for use as a tag value. */
306
+ let jsonValueToString = json =>
307
+ switch json {
308
+ | JSON.String(s) => s
309
+ | JSON.Number(n) => n->Float.toString
310
+ | JSON.Boolean(b) => b ? "true" : "false"
311
+ | _ => json->JSON.stringify
312
+ }
313
+
314
+ /**
315
+ Extracts tags from a flat JSON object given a map of field schemas.
316
+ Only fields whose schema is tagged (via `DcbTag.string` / `DcbTag.int`) are extracted.
317
+ */
318
+ let extractTagsFromProperties = (properties: dict<S.t<unknown>>, jsonDict: dict<JSON.t>) =>
319
+ properties
320
+ ->Dict.toArray
321
+ ->Array.filterMap(((fieldName, fieldSchema)) =>
322
+ if isTagged(fieldSchema) {
323
+ jsonDict
324
+ ->Dict.get(fieldName)
325
+ ->Option.map(jsonValue => {
326
+ key: resolveTagKey(fieldName, fieldSchema),
327
+ value: jsonValue->jsonValueToString,
328
+ })
329
+ } else {
330
+ None
331
+ }
332
+ )
333
+
334
+ /**
335
+ Extracts DCB tags from an event JSON value using the event's sury schema.
336
+
337
+ Supports both union (variant) types and plain object types. For variant types
338
+ only the fields of the matching variant branch are inspected.
339
+ */
340
+ let extractTagsFromJson = (schema: S.t<unknown>, json: JSON.t): array<tag> =>
341
+ switch schema {
342
+ | Union({anyOf}) =>
343
+ switch json->JSON.Decode.object {
344
+ | Some(jsonDict) =>
345
+ let jsonTag = jsonDict->Dict.get("TAG")->Option.flatMap(j =>
346
+ switch j {
347
+ | JSON.String(s) => Some(s)
348
+ | _ => None
349
+ }
350
+ )
351
+ anyOf->Array.reduce([], (acc, variantSchema) =>
352
+ if acc->Array.length > 0 {
353
+ acc
354
+ } else {
355
+ switch variantSchema {
356
+ | Object({items, properties}) =>
357
+ let variantTag = items
358
+ ->Array.find(item => item.location == "TAG")
359
+ ->Option.flatMap(item =>
360
+ switch item.schema {
361
+ | String({const}) => Some(const)
362
+ | _ => None
363
+ }
364
+ )
365
+ if variantTag == jsonTag {
366
+ extractTagsFromProperties(properties, jsonDict)
367
+ } else {
368
+ []
369
+ }
370
+ | _ => []
371
+ }
372
+ }
373
+ )
374
+ | None => []
375
+ }
376
+ | Object({properties}) =>
377
+ switch json->JSON.Decode.object {
378
+ | Some(jsonDict) => extractTagsFromProperties(properties, jsonDict)
379
+ | None => []
380
+ }
381
+ | _ => []
382
+ }
383
+
384
+ /**
385
+ Extracts DCB tags from a typed event value using its sury schema.
386
+
387
+ Serializes the value to JSON first, then delegates to `extractTagsFromJson`.
388
+
389
+ @example
390
+ ```rescript
391
+ let tags = DcbTag.extractTags(
392
+ CatalogEventLog.eventSchema,
393
+ CategoryAdded({categoryId: "cat-1", name: "Electronics"}),
394
+ )
395
+ // [{key: "categoryId", value: "cat-1"}]
396
+ ```
397
+ */
398
+ let extractTags = (schema: S.t<'a>, value: 'a): array<tag> => {
399
+ let json = value->JSON.stringifyAny->Option.getOrThrow->JSON.parseOrThrow
400
+ extractTagsFromJson(schema->toUnknownSchema, json)
401
+ }
402
+
403
+ // --- Extract variant constructor names from a tagged union schema ---
404
+ // For a variant type like `type event = ItemCreated({...}) | ItemRenamed({...})`,
405
+ // this extracts ["ItemCreated", "ItemRenamed"]
406
+
407
+ /**
408
+ Extracts all variant constructor names from a tagged union schema.
409
+
410
+ Works on any `@schema`-annotated variant type: events, commands, errors, consumed events.
411
+ For `CatalogEventLog.event` returns
412
+ `["ProductAdded", "ProductNameUpdated", ..., "CategoryAdded", ...]`.
413
+ Used by the DCB runtime to build `queryItem.eventTypes` arrays automatically.
414
+ */
415
+ let extractVariantNames = (schema: S.t<'a>): array<string> => {
416
+ switch schema->toUnknownSchema {
417
+ | Union({anyOf}) =>
418
+ anyOf->Array.filterMap(variantSchema =>
419
+ switch variantSchema {
420
+ | Object({items}) =>
421
+ items
422
+ ->Array.find(item => item.location == "TAG")
423
+ ->Option.flatMap(item =>
424
+ switch item.schema {
425
+ | String({const}) => Some(const)
426
+ | _ => None
427
+ }
428
+ )
429
+ // Payload-less variants (sury-compiled `S.literal("Name")` strings) are
430
+ // intentionally excluded here: DCB event-type lookups can't WHERE-clause
431
+ // on bare-string events, so Plugin_Structure.consumedEventTypes and
432
+ // related graph fields would otherwise claim cross-component edges the
433
+ // runtime can't honour. Callers that need every constructor (e.g.
434
+ // mapping-mode `acceptedTags` filters that match a JSON envelope's
435
+ // `event` TAG against the Delegate's full constructor set) should call
436
+ // [`extractAllVariantNames`] instead.
437
+ | _ => None
438
+ }
439
+ )
440
+ | Object({items}) =>
441
+ items
442
+ ->Array.find(item => item.location == "TAG")
443
+ ->Option.flatMap(item =>
444
+ switch item.schema {
445
+ | String({const}) => Some([const])
446
+ | _ => None
447
+ }
448
+ )
449
+ ->Option.getOr([])
450
+ | _ => []
451
+ }
452
+ }
453
+
454
+ /**
455
+ Like [`extractVariantNames`], but also includes payload-less variants
456
+ (constructors compiled to `S.literal("Name")`).
457
+
458
+ Use for **command schemas** where every constructor must be addressable
459
+ (GraphQL mutation field derivation, plugin schema reporting, runtime
460
+ dispatch). [`extractVariantNames`] keeps the payload-less filter required by
461
+ DCB event-type lookups, where bare-string events carry no `type` field for
462
+ WHERE-clause filtering.
463
+ */
464
+ let extractAllVariantNames = (schema: S.t<'a>): array<string> => {
465
+ switch schema->toUnknownSchema {
466
+ | Union({anyOf}) =>
467
+ anyOf->Array.filterMap(variantSchema =>
468
+ switch variantSchema {
469
+ | Object({items}) =>
470
+ items
471
+ ->Array.find(item => item.location == "TAG")
472
+ ->Option.flatMap(item =>
473
+ switch item.schema {
474
+ | String({const}) => Some(const)
475
+ | _ => None
476
+ }
477
+ )
478
+ | String({const}) => Some(const)
479
+ | _ => None
480
+ }
481
+ )
482
+ | Object({items}) =>
483
+ items
484
+ ->Array.find(item => item.location == "TAG")
485
+ ->Option.flatMap(item =>
486
+ switch item.schema {
487
+ | String({const}) => Some([const])
488
+ | _ => None
489
+ }
490
+ )
491
+ ->Option.getOr([])
492
+ | String({const: ?Some(name)}) => [name]
493
+ | _ => []
494
+ }
495
+ }
496
+
497
+ /**
498
+ Returns `true` if the given variant constructor (by name) carries a record
499
+ payload (compiled to `{TAG, ...}`), `false` if payload-less (compiled to a
500
+ bare string literal). Returns `false` for names not found in the schema.
501
+
502
+ Used by resolver shims to synthesize the correct runtime shape when feeding
503
+ a command value into the PPX-generated `commandAuthorization` switch.
504
+ */
505
+ let isVariantPayloadBearing = (schema: S.t<'a>, name: string): bool => {
506
+ // Returns true iff a record-payload variant in the schema has TAG === name.
507
+ // Payload-less variants compile to S.literal("X") (String const), so they
508
+ // are not "payload-bearing" — only Object schemas with a matching TAG are.
509
+ let names = extractAllVariantNames(schema)
510
+ if !(names->Array.includes(name)) {
511
+ false
512
+ } else {
513
+ switch schema->toUnknownSchema {
514
+ | Union({anyOf}) =>
515
+ anyOf->Array.some(variantSchema =>
516
+ switch variantSchema {
517
+ | Object({items}) =>
518
+ items
519
+ ->Array.find(item => item.location == "TAG")
520
+ ->Option.flatMap(item =>
521
+ switch item.schema {
522
+ | String({const}) => Some(const == name)
523
+ | _ => None
524
+ }
525
+ )
526
+ ->Option.getOr(false)
527
+ | _ => false
528
+ }
529
+ )
530
+ | Object({items}) =>
531
+ items
532
+ ->Array.find(item => item.location == "TAG")
533
+ ->Option.flatMap(item =>
534
+ switch item.schema {
535
+ | String({const}) => Some(const == name)
536
+ | _ => None
537
+ }
538
+ )
539
+ ->Option.getOr(false)
540
+ | _ => false
541
+ }
542
+ }
543
+ }
544
+
545
+ // --- Array-expanded tag extraction ---
546
+
547
+ /**
548
+ Extracts tags from a flat JSON object, expanding array values into per-element tags.
549
+
550
+ For scalar tagged fields, behaves identically to `extractTagsFromProperties`.
551
+ For array tagged fields, produces one tag per element. Tag keys honour an optional
552
+ `DcbTag.stringForKey(~key=...)` override on the (inner) schema; otherwise the field
553
+ name is used.
554
+ */
555
+ let extractTagsFromPropertiesExpanded = (
556
+ properties: dict<S.t<unknown>>,
557
+ jsonDict: dict<JSON.t>,
558
+ ) =>
559
+ properties
560
+ ->Dict.toArray
561
+ ->Array.flatMap(((fieldName, fieldSchema)) =>
562
+ if isTagged(fieldSchema) {
563
+ switch jsonDict->Dict.get(fieldName) {
564
+ | Some(jsonValue) => [
565
+ {key: resolveTagKey(fieldName, fieldSchema), value: jsonValue->jsonValueToString},
566
+ ]
567
+ | None => []
568
+ }
569
+ } else if isTaggedArray(fieldSchema) {
570
+ switch jsonDict->Dict.get(fieldName) {
571
+ | Some(JSON.Array(elements)) => {
572
+ let tagKey = resolveArrayTagKey(fieldName, fieldSchema)
573
+ elements->Array.map(element => {key: tagKey, value: element->jsonValueToString})
574
+ }
575
+ | _ => []
576
+ }
577
+ } else {
578
+ []
579
+ }
580
+ )
581
+
582
+ /**
583
+ Extracts DCB tags from an event JSON value, expanding array values into per-element tags.
584
+
585
+ Like `extractTagsFromJson` but array tagged fields produce one tag per element
586
+ instead of a single tag with the stringified array.
587
+ */
588
+ let extractTagsFromJsonExpanded = (schema: S.t<unknown>, json: JSON.t): array<tag> =>
589
+ switch schema {
590
+ | Union({anyOf}) =>
591
+ switch json->JSON.Decode.object {
592
+ | Some(jsonDict) =>
593
+ let jsonTag = jsonDict->Dict.get("TAG")->Option.flatMap(j =>
594
+ switch j {
595
+ | JSON.String(s) => Some(s)
596
+ | _ => None
597
+ }
598
+ )
599
+ anyOf->Array.reduce([], (acc, variantSchema) =>
600
+ if acc->Array.length > 0 {
601
+ acc
602
+ } else {
603
+ switch variantSchema {
604
+ | Object({items, properties}) =>
605
+ let variantTag = items
606
+ ->Array.find(item => item.location == "TAG")
607
+ ->Option.flatMap(item =>
608
+ switch item.schema {
609
+ | String({const}) => Some(const)
610
+ | _ => None
611
+ }
612
+ )
613
+ if variantTag == jsonTag {
614
+ extractTagsFromPropertiesExpanded(properties, jsonDict)
615
+ } else {
616
+ []
617
+ }
618
+ | _ => []
619
+ }
620
+ }
621
+ )
622
+ | None => []
623
+ }
624
+ | Object({properties}) =>
625
+ switch json->JSON.Decode.object {
626
+ | Some(jsonDict) => extractTagsFromPropertiesExpanded(properties, jsonDict)
627
+ | None => []
628
+ }
629
+ | _ => []
630
+ }
631
+
632
+ /**
633
+ Extracts DCB tags from a typed value, expanding array tagged fields into per-element tags.
634
+
635
+ @example
636
+ ```rescript
637
+ let tags = DcbTag.extractTagsExpanded(
638
+ PlaceOrder.commandSchema,
639
+ PlaceOrder({orderId: "ord-1", customerId: "c1", productIds: ["p1", "p2"]}),
640
+ )
641
+ // [{key: "orderId", value: "ord-1"}, {key: "productIds", value: "p1"}, {key: "productIds", value: "p2"}]
642
+ ```
643
+ */
644
+ let extractTagsExpanded = (schema: S.t<'a>, value: 'a): array<tag> => {
645
+ let json = value->JSON.stringifyAny->Option.getOrThrow->JSON.parseOrThrow
646
+ extractTagsFromJsonExpanded(schema->toUnknownSchema, json)
647
+ }
648
+
649
+ // --- Automatic query construction from command schema ---
650
+
651
+ /**
652
+ Returns `true` if any field in the schema is a tagged array
653
+ (`array<@s.matches(DcbTag.string) string>`).
654
+ Used to automatically determine whether to build single-clause or multi-clause queries.
655
+ */
656
+ let hasTaggedArrayFields = (schema: S.t<'a>): bool =>
657
+ switch schema->toUnknownSchema {
658
+ | Union({anyOf}) =>
659
+ anyOf->Array.some(variantSchema =>
660
+ switch variantSchema {
661
+ | Object({properties}) =>
662
+ properties->Dict.toArray->Array.some(((_, fieldSchema)) => isTaggedArray(fieldSchema))
663
+ | _ => false
664
+ }
665
+ )
666
+ | Object({properties}) =>
667
+ properties->Dict.toArray->Array.some(((_, fieldSchema)) => isTaggedArray(fieldSchema))
668
+ | _ => false
669
+ }
670
+
671
+ /**
672
+ Collects the produced tag keys of one event-schema variant: the resolved tag key
673
+ of every scalar tagged field plus the resolved (override-aware) tag key of every
674
+ tagged-array field.
675
+ */
676
+ let tagKeysOfProperties = (properties: dict<S.t<unknown>>): array<string> =>
677
+ properties
678
+ ->Dict.toArray
679
+ ->Array.filterMap(((fieldName, fieldSchema)) =>
680
+ if isTagged(fieldSchema) {
681
+ Some(resolveTagKey(fieldName, fieldSchema))
682
+ } else if isTaggedArray(fieldSchema) {
683
+ Some(resolveArrayTagKey(fieldName, fieldSchema))
684
+ } else {
685
+ None
686
+ }
687
+ )
688
+
689
+ /**
690
+ Maps each event-type (variant constructor name) to the set of DCB tag keys that
691
+ type can carry, read from a *produced* event-log schema.
692
+
693
+ This is the lookup the query builder uses to drop vacuous (type, tag) clause
694
+ combinations: an event type is kept in a tag clause only if its produced tag set
695
+ contains that tag. Built from the producer schema (not a consumer's
696
+ `consumedEventSchema`, which may legitimately under-declare tags it reads by).
697
+
698
+ For `CatalogEventLog.event` (`ProductAdded({productId})` | `CategoryAdded({categoryId})`)
699
+ returns `{"ProductAdded": ["productId"], "CategoryAdded": ["categoryId"]}`.
700
+ */
701
+ let extractTagKeysByEventType = (schema: S.t<'a>): dict<array<string>> => {
702
+ let result = Dict.make()
703
+ let addVariant = (variantSchema: S.t<unknown>) =>
704
+ switch variantSchema {
705
+ | Object({items, properties}) =>
706
+ items
707
+ ->Array.find(item => item.location == "TAG")
708
+ ->Option.flatMap(item =>
709
+ switch item.schema {
710
+ | String({const}) => Some(const)
711
+ | _ => None
712
+ }
713
+ )
714
+ ->Option.forEach(eventType => result->Dict.set(eventType, tagKeysOfProperties(properties)))
715
+ | _ => ()
716
+ }
717
+ switch schema->toUnknownSchema {
718
+ | Union({anyOf}) => anyOf->Array.forEach(addVariant)
719
+ | Object(_) as obj => addVariant(obj)
720
+ | _ => ()
721
+ }
722
+ result
723
+ }
724
+
725
+ /**
726
+ Merges several per-event-type tag-key maps into one (later entries win on key
727
+ collision, which is irrelevant here since a given event type is produced once).
728
+ */
729
+ let mergeTagKeysByEventType = (maps: array<dict<array<string>>>): dict<array<string>> => {
730
+ let merged = Dict.make()
731
+ maps->Array.forEach(m =>
732
+ m->Dict.toArray->Array.forEach(((eventType, keys)) => merged->Dict.set(eventType, keys))
733
+ )
734
+ merged
735
+ }
736
+
737
+ /**
738
+ Narrows a clause's event-type list to those types whose produced tag set can
739
+ carry *all* of the clause's tags. A type absent from `tagKeysByEventType` is
740
+ kept (we can't prove it vacuous). Removing a vacuous (type, tag) pairing cannot
741
+ change query results — such a type can never satisfy the clause's tags anyway.
742
+ */
743
+ let narrowEventTypesForTags = (
744
+ eventTypes: array<string>,
745
+ tags: array<tag>,
746
+ tagKeysByEventType: dict<array<string>>,
747
+ ): array<string> =>
748
+ eventTypes->Array.filter(eventType =>
749
+ switch tagKeysByEventType->Dict.get(eventType) {
750
+ | None => true
751
+ | Some(producedKeys) =>
752
+ tags->Array.every(tag => producedKeys->Array.includes(tag.key))
753
+ }
754
+ )
755
+
756
+ /**
757
+ Builds a DCB query from a command value and its schema.
758
+
759
+ Automatically detects the query mode from the schema:
760
+ - If the schema has tagged array fields → cross-entity mode: each tag becomes
761
+ its own OR clause (per-element expansion for arrays).
762
+ - Otherwise → single-entity mode: all tags go into one AND clause.
763
+
764
+ When `~tagKeysByEventType` is supplied (the produced event-log schema's
765
+ type→tag-key map), each clause drops event types whose produced tag set cannot
766
+ carry the clause's tag(s) — e.g. a `CatalogProductSynced` type is removed from
767
+ an `orderId` clause. This is pure dead-clause removal: a vacuous (type, tag)
768
+ pairing matches nothing, so results are unchanged. A type that carries the tag
769
+ as a *secondary* tag is retained (a legitimate cross-partition read), and a type
770
+ absent from the map is kept (cannot be proven vacuous).
771
+
772
+ @example
773
+ ```rescript
774
+ // Single-entity command → single AND clause
775
+ let query = DcbTag.buildQueryFromCommand(
776
+ ~eventTypes=["ItemCreated"],
777
+ ~schema=CreateItem.commandSchema,
778
+ ~value=CreateItem({itemId: "item-1", name: "Test"}),
779
+ )
780
+ // [{eventTypes: ["ItemCreated"], tags: [{key: "itemId", value: "item-1"}]}]
781
+
782
+ // Cross-entity command → per-element OR clauses
783
+ let query = DcbTag.buildQueryFromCommand(
784
+ ~eventTypes=["OrderPlaced", "CatalogProductSynced"],
785
+ ~schema=PlaceOrder.commandSchema,
786
+ ~value=PlaceOrder({orderId: "ord-1", customerId: "c1", productId: ["p1", "p2"]}),
787
+ )
788
+ // [{eventTypes: [...], tags: [{key: "orderId", value: "ord-1"}]},
789
+ // {eventTypes: [...], tags: [{key: "productId", value: "p1"}]},
790
+ // {eventTypes: [...], tags: [{key: "productId", value: "p2"}]}]
791
+ ```
792
+ */
793
+ let buildQueryFromCommand = (
794
+ ~eventTypes,
795
+ ~schema: S.t<'a>,
796
+ ~value: 'a,
797
+ ~tagKeysByEventType: dict<array<string>>=Dict.make(),
798
+ ~crossPartitionTagKeys: array<string>=[],
799
+ ): query => {
800
+ let typesForTags = clauseTags => narrowEventTypesForTags(eventTypes, clauseTags, tagKeysByEventType)
801
+ if hasTaggedArrayFields(schema) {
802
+ // Array fields already fan out per element into single-tag clauses, so a
803
+ // cross-partition array tag is already its own clause (the adapter routes it
804
+ // by `crossPartitionTagKeys`).
805
+ let tags = extractTagsExpanded(schema, value)
806
+ tags->Array.map(tag => {
807
+ let clauseTags = [{key: tag.key, value: tag.value}]
808
+ {eventTypes: typesForTags(clauseTags), tags: clauseTags}
809
+ })
810
+ } else {
811
+ let tags = extractTags(schema, value)
812
+ // When any command tag is cross-partition, fan every scalar tag out into its
813
+ // own single-tag clause instead of AND-ing them into one composite
814
+ // (exact-pair) clause. An M:N command (`SubscribeStudent({courseId, studentId})`)
815
+ // must read "all of the course" AND "all of the student" as two single-tag
816
+ // reads — a composite read of the exact `{course, student}` pair is neither.
817
+ // Without a cross-partition tag the default composite clause is preserved.
818
+ let hasCrossPartition =
819
+ tags->Array.some(tag => crossPartitionTagKeys->Array.includes(tag.key))
820
+ if hasCrossPartition && tags->Array.length > 1 {
821
+ tags->Array.map(tag => {
822
+ let clauseTags = [{key: tag.key, value: tag.value}]
823
+ {eventTypes: typesForTags(clauseTags), tags: clauseTags}
824
+ })
825
+ } else {
826
+ [{eventTypes: typesForTags(tags), tags}]
827
+ }
828
+ }
829
+ }
830
+
831
+ // --- Extract tagged field names from event schema ---
832
+
833
+ /**
834
+ Extracts the names of all DCB-tagged fields across all variants of an event schema.
835
+
836
+ Returns a sorted, deduplicated list of field names annotated with
837
+ `@s.matches(DcbTag.string)` or `@s.matches(DcbTag.int)`.
838
+
839
+ For `CatalogEventLog.event` returns `["categoryId", "productId"]`.
840
+ */
841
+ let extractTaggedFields = (schema: S.t<'event>): array<string> => {
842
+ switch schema->toUnknownSchema {
843
+ | Union({anyOf}) =>
844
+ // For union types, collect tagged fields from all variants
845
+ let allFields = anyOf->Array.flatMap(variantSchema =>
846
+ switch variantSchema {
847
+ | Object({properties}) =>
848
+ properties
849
+ ->Dict.toArray
850
+ ->Array.filterMap(((fieldName, fieldSchema)) =>
851
+ if isTagged(fieldSchema) {
852
+ Some(fieldName)
853
+ } else {
854
+ None
855
+ }
856
+ )
857
+ | _ => []
858
+ }
859
+ )
860
+ // Deduplicate field names using Set
861
+ let fieldSet = Set.make()
862
+ allFields->Array.forEach(field => fieldSet->Set.add(field))
863
+ Array.fromIterator(fieldSet->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
864
+
865
+ | Object({properties}) =>
866
+ properties
867
+ ->Dict.toArray
868
+ ->Array.filterMap(((fieldName, fieldSchema)) =>
869
+ if isTagged(fieldSchema) {
870
+ Some(fieldName)
871
+ } else {
872
+ None
873
+ }
874
+ )
875
+ ->Array.toSorted((a, b) => String.compare(a, b))
876
+
877
+ | _ => []
878
+ }
879
+ }
880
+
881
+ /**
882
+ Collects the *cross-partition* tag keys of one object-variant's properties: the
883
+ resolved (override-aware) tag key of every scalar field marked
884
+ `DcbTag.crossPartition`, plus that of every array field whose element is.
885
+ */
886
+ let crossPartitionKeysOfProperties = (properties: dict<S.t<unknown>>): array<string> =>
887
+ properties
888
+ ->Dict.toArray
889
+ ->Array.filterMap(((fieldName, fieldSchema)) =>
890
+ if isTagged(fieldSchema) && isCrossPartitionTag(fieldSchema) {
891
+ Some(resolveTagKey(fieldName, fieldSchema))
892
+ } else if isCrossPartitionTaggedArray(fieldSchema) {
893
+ Some(resolveArrayTagKey(fieldName, fieldSchema))
894
+ } else {
895
+ None
896
+ }
897
+ )
898
+
899
+ /**
900
+ Extracts the set of DCB tag keys declared cross-partition (`@crossPartition`)
901
+ across all variants of an event or command schema.
902
+
903
+ Returns a sorted, deduplicated list of tag keys. The scope is a property of the
904
+ tag key, so this set is derived once at build time (from the *produced* event
905
+ schemas) and threaded to both the decision-model query builder (to fan a
906
+ cross-partition scalar tag into its own single-tag clause) and the storage
907
+ adapter (read routing + fence scope).
908
+
909
+ For an event schema `StudentSubscribed({@partitionTag courseId, @crossPartition studentId})`
910
+ returns `["studentId"]`.
911
+ */
912
+ let extractCrossPartitionTagKeys = (schema: S.t<'event>): array<string> => {
913
+ let keys = switch schema->toUnknownSchema {
914
+ | Union({anyOf}) =>
915
+ anyOf->Array.flatMap(variantSchema =>
916
+ switch variantSchema {
917
+ | Object({properties}) => crossPartitionKeysOfProperties(properties)
918
+ | _ => []
919
+ }
920
+ )
921
+ | Object({properties}) => crossPartitionKeysOfProperties(properties)
922
+ | _ => []
923
+ }
924
+ let seen = Set.make()
925
+ keys->Array.forEach(k => seen->Set.add(k))
926
+ Array.fromIterator(seen->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
927
+ }
928
+
929
+ // --- Partition tag derivation ---
930
+
931
+ /**
932
+ Extracts field names annotated with `@s.matches(DcbTag.partition)` from an event schema.
933
+ */
934
+ let extractPartitionTagFields = (schema: S.t<'event>): array<string> => {
935
+ switch schema->toUnknownSchema {
936
+ | Union({anyOf}) =>
937
+ let allFields = anyOf->Array.flatMap(variantSchema =>
938
+ switch variantSchema {
939
+ | Object({properties}) =>
940
+ properties
941
+ ->Dict.toArray
942
+ ->Array.filterMap(((fieldName, fieldSchema)) =>
943
+ if isPartitionTag(fieldSchema) {
944
+ Some(fieldName)
945
+ } else {
946
+ None
947
+ }
948
+ )
949
+ | _ => []
950
+ }
951
+ )
952
+ let fieldSet = Set.make()
953
+ allFields->Array.forEach(field => fieldSet->Set.add(field))
954
+ Array.fromIterator(fieldSet->Set.values)
955
+
956
+ | Object({properties}) =>
957
+ properties
958
+ ->Dict.toArray
959
+ ->Array.filterMap(((fieldName, fieldSchema)) =>
960
+ if isPartitionTag(fieldSchema) {
961
+ Some(fieldName)
962
+ } else {
963
+ None
964
+ }
965
+ )
966
+
967
+ | _ => []
968
+ }
969
+ }
970
+
971
+ /**
972
+ Checks whether any single variant in a schema has multiple tagged fields.
973
+ If so, a partition tag annotation is needed to disambiguate.
974
+ */
975
+ let hasMultiTagVariant = (schema: S.t<unknown>): bool =>
976
+ switch schema {
977
+ | Union({anyOf}) =>
978
+ anyOf->Array.some(variantSchema =>
979
+ switch variantSchema {
980
+ | Object({properties}) => {
981
+ let tagCount =
982
+ properties
983
+ ->Dict.toArray
984
+ ->Array.filter(((_, fieldSchema)) => isTagged(fieldSchema))
985
+ ->Array.length
986
+ tagCount > 1
987
+ }
988
+ | _ => false
989
+ }
990
+ )
991
+ | Object({properties}) => {
992
+ let tagCount =
993
+ properties
994
+ ->Dict.toArray
995
+ ->Array.filter(((_, fieldSchema)) => isTagged(fieldSchema))
996
+ ->Array.length
997
+ tagCount > 1
998
+ }
999
+ | _ => false
1000
+ }
1001
+
1002
+ /**
1003
+ Returns the names of variants within a schema that have multiple tagged fields.
1004
+ Used to build diagnostic context for partition tag errors.
1005
+ */
1006
+ let findMultiTagVariantNames = (schema: S.t<unknown>): array<string> => {
1007
+ // Extract the variant name from a single object-variant schema via its TAG item.
1008
+ let variantName = (variantSchema: S.t<unknown>): option<string> =>
1009
+ switch variantSchema {
1010
+ | Object({items, properties}) => {
1011
+ let tagCount =
1012
+ properties
1013
+ ->Dict.toArray
1014
+ ->Array.filter(((_, fieldSchema)) => isTagged(fieldSchema))
1015
+ ->Array.length
1016
+ if tagCount > 1 {
1017
+ Some(
1018
+ items
1019
+ ->Array.find(item => item.location == "TAG")
1020
+ ->Option.flatMap(item =>
1021
+ switch item.schema {
1022
+ | String({const}) => Some(const)
1023
+ | _ => None
1024
+ }
1025
+ )
1026
+ ->Option.getOr("(unknown)"),
1027
+ )
1028
+ } else {
1029
+ None
1030
+ }
1031
+ }
1032
+ | _ => None
1033
+ }
1034
+
1035
+ switch schema {
1036
+ | Union({anyOf}) => anyOf->Array.filterMap(variantName)
1037
+ | _ =>
1038
+ // Single-variant event type — schema is the object directly
1039
+ switch variantName(schema) {
1040
+ | Some(name) => [name]
1041
+ | None => []
1042
+ }
1043
+ }
1044
+ }
1045
+
1046
+ // --- Composite partition key helpers ---
1047
+
1048
+ type compositePartitionFieldInfo = {name: string, position: int, sep: string}
1049
+
1050
+ /**
1051
+ Extracts all composite partition member fields from a single object-variant schema.
1052
+ Returns an array sorted by `position`.
1053
+ */
1054
+ let extractCompositePartitionFieldsFromProperties = (
1055
+ properties: dict<S.t<unknown>>,
1056
+ ): array<compositePartitionFieldInfo> =>
1057
+ properties
1058
+ ->Dict.toArray
1059
+ ->Array.filterMap(((fieldName, fieldSchema)) =>
1060
+ switch S.Metadata.get(fieldSchema, ~id=dcbCompositePartitionMemberId) {
1061
+ | Some(meta) => Some({name: fieldName, position: meta.position, sep: meta.sep})
1062
+ | None => None
1063
+ }
1064
+ )
1065
+ ->Array.toSorted((a, b) => Int.compare(a.position, b.position))
1066
+
1067
+ /**
1068
+ Extracts all composite partition member fields across all variants of a schema.
1069
+ Returns deduplicated entries sorted by position (assumes all variants agree on positions).
1070
+ */
1071
+ let extractCompositePartitionFields = (schema: S.t<'event>): array<compositePartitionFieldInfo> => {
1072
+ let seen = Set.make()
1073
+ let collect = (properties: dict<S.t<unknown>>) =>
1074
+ extractCompositePartitionFieldsFromProperties(properties)->Array.filter(info => {
1075
+ if seen->Set.has(info.name) {
1076
+ false
1077
+ } else {
1078
+ seen->Set.add(info.name)
1079
+ true
1080
+ }
1081
+ })
1082
+ switch schema->toUnknownSchema {
1083
+ | Union({anyOf}) =>
1084
+ anyOf->Array.flatMap(variantSchema =>
1085
+ switch variantSchema {
1086
+ | Object({properties}) => collect(properties)
1087
+ | _ => []
1088
+ }
1089
+ )
1090
+ | Object({properties}) => collect(properties)
1091
+ | _ => []
1092
+ }
1093
+ }
1094
+
1095
+ /**
1096
+ Computes the composite partition key value from an array of tags and a spec.
1097
+ Joins the tag values in key order, inserting separators between them.
1098
+ */
1099
+ let getCompositePartitionKeyValue = (tags: array<tag>, spec: compositePartitionSpec): string =>
1100
+ spec.keys
1101
+ ->Array.mapWithIndex((fieldName, i) => {
1102
+ let v =
1103
+ tags->Array.findMap(t => if t.key == fieldName {Some(t.value)} else {None})->Option.getOr("")
1104
+ if i == 0 {
1105
+ v
1106
+ } else {
1107
+ spec.seps->Array.getUnsafe(i - 1) ++ v
1108
+ }
1109
+ })
1110
+ ->Array.join("")
1111
+
1112
+ // --- Partition tag derivation ---
1113
+
1114
+ /**
1115
+ Derives the partition tag strategy from an array of named event schemas.
1116
+
1117
+ Returns `Simple(partitionTag)` when the schema uses `@partitionTag` (or a single tag),
1118
+ or `Composite(compositePartitionSpec)` when it uses `@compositePartitionTag`.
1119
+
1120
+ Rules for simple strategy:
1121
+ - If only one tagged field exists across all schemas, it is automatically selected.
1122
+ - If multiple tagged fields exist but each event variant has at most one tagged
1123
+ field (multi-entity DCB), the first field alphabetically is selected.
1124
+ - If any event variant has multiple tagged fields and exactly one is annotated
1125
+ with `DcbTag.partition`, that one is selected.
1126
+ - If any event variant has multiple tagged fields and none (or multiple) are
1127
+ annotated with `DcbTag.partition`, throws an error naming the affected slice,
1128
+ variant(s), and source file path.
1129
+
1130
+ Throws when:
1131
+ - A schema mixes `@compositePartitionTag` and `@partitionTag` fields.
1132
+ - Fewer than 2 fields are annotated with `@compositePartitionTag`.
1133
+ */
1134
+ let derivePartitionTag = (
1135
+ namedSchemas: array<(string, string, S.t<unknown>)>,
1136
+ ): derivedPartitionTag => {
1137
+ let schemas = namedSchemas->Array.map(((_, _, schema)) => schema)
1138
+
1139
+ let allCompositeFields = {
1140
+ let seen = Set.make()
1141
+ schemas
1142
+ ->Array.flatMap(schema => extractCompositePartitionFields(schema))
1143
+ ->Array.filter(info => {
1144
+ if seen->Set.has(info.name) {
1145
+ false
1146
+ } else {
1147
+ seen->Set.add(info.name)
1148
+ true
1149
+ }
1150
+ })
1151
+ }
1152
+
1153
+ let hasComposite = allCompositeFields->Array.length > 0
1154
+
1155
+ let allPartitionFields = {
1156
+ let seen = Set.make()
1157
+ schemas->Array.flatMap(schema => extractPartitionTagFields(schema))->Array.filter(f => {
1158
+ if seen->Set.has(f) {
1159
+ false
1160
+ } else {
1161
+ seen->Set.add(f)
1162
+ true
1163
+ }
1164
+ })
1165
+ }
1166
+
1167
+ if hasComposite && allPartitionFields->Array.length > 0 {
1168
+ JsError.throwWithMessage(
1169
+ `DCB spec mixes @compositePartitionTag and @partitionTag — use one strategy per schema`,
1170
+ )
1171
+ }
1172
+
1173
+ if hasComposite {
1174
+ if allCompositeFields->Array.length < 2 {
1175
+ JsError.throwWithMessage(
1176
+ `@compositePartitionTag requires at least 2 annotated fields — only ${allCompositeFields->Array.length->Int.toString} found`,
1177
+ )
1178
+ }
1179
+ let sorted = allCompositeFields->Array.toSorted((a, b) => Int.compare(a.position, b.position))
1180
+ let keys = sorted->Array.map(info => info.name)
1181
+ let seps = sorted->Array.slice(~start=0, ~end=sorted->Array.length - 1)->Array.map(info =>
1182
+ info.sep
1183
+ )
1184
+ Composite({keys, seps})
1185
+ } else {
1186
+ let allTaggedFields = {
1187
+ let seen = Set.make()
1188
+ schemas->Array.flatMap(schema => extractTaggedFields(schema))->Array.filter(f => {
1189
+ if seen->Set.has(f) {
1190
+ false
1191
+ } else {
1192
+ seen->Set.add(f)
1193
+ true
1194
+ }
1195
+ })
1196
+ }
1197
+
1198
+ switch allTaggedFields {
1199
+ | [] => JsError.throwWithMessage("DCB spec has no tagged fields — cannot derive partition tag")
1200
+ | [singleField] => Simple({key: singleField})
1201
+ | multipleFields => {
1202
+ let needsExplicitPartition = schemas->Array.some(schema => hasMultiTagVariant(schema))
1203
+
1204
+ if needsExplicitPartition {
1205
+ let context =
1206
+ namedSchemas
1207
+ ->Array.filterMap(((sliceName, path, schema)) => {
1208
+ let variantNames = findMultiTagVariantNames(schema)
1209
+ if variantNames->Array.length > 0 {
1210
+ Some(`${sliceName} (${variantNames->Array.join(", ")}) @ ${path}`)
1211
+ } else {
1212
+ None
1213
+ }
1214
+ })
1215
+ ->Array.join(", ")
1216
+
1217
+ switch allPartitionFields {
1218
+ | [singlePartition] => Simple({key: singlePartition})
1219
+ | [] =>
1220
+ JsError.throwWithMessage(
1221
+ `DCB spec has variants with multiple tagged fields (${multipleFields->Array.join(", ")}) but none is annotated with @partitionTag — affected: ${context} — mark one field as the partition key`,
1222
+ )
1223
+ | multiplePartitions =>
1224
+ JsError.throwWithMessage(
1225
+ `DCB spec has multiple fields annotated with @partitionTag (${multiplePartitions->Array.join(", ")}) — only one is allowed — affected: ${context}`,
1226
+ )
1227
+ }
1228
+ } else {
1229
+ let sorted = multipleFields->Array.toSorted((a, b) => String.compare(a, b))
1230
+ Simple({key: sorted->Array.getUnsafe(0)})
1231
+ }
1232
+ }
1233
+ }
1234
+ }
1235
+ }
1236
+
1237
+ /**
1238
+ Extracts the partition tag value from a query.
1239
+ Returns the value of the first tag matching the partition tag key, or None if not found.
1240
+ */
1241
+ let getPartitionTagValue = (query: query, pt: partitionTag): option<string> =>
1242
+ query
1243
+ ->Array.filterMap(queryItem =>
1244
+ switch queryItem.tags {
1245
+ | Some(tags) =>
1246
+ tags->Array.findMap(tag =>
1247
+ if tag.key == pt.key {
1248
+ Some(tag.value)
1249
+ } else {
1250
+ None
1251
+ }
1252
+ )
1253
+ | None => None
1254
+ }
1255
+ )
1256
+ ->Array.get(0)