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