@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.
- package/CHANGELOG.md +625 -0
- package/LICENSE +202 -0
- package/README.md +114 -0
- package/package.json +34 -0
- package/rescript.json +25 -0
- package/run-generator.mjs +2 -0
- package/src/AnsiStyle.res +40 -0
- package/src/AnsiStyle.res.mjs +54 -0
- package/src/LogPrefix.res +168 -0
- package/src/LogPrefix.res.mjs +148 -0
- package/src/PackageVersion.res +73 -0
- package/src/PackageVersion.res.mjs +81 -0
- package/src/components/Aggregate.res +64 -0
- package/src/components/Aggregate.res.mjs +2 -0
- package/src/components/AutomationSlice.res +279 -0
- package/src/components/AutomationSlice.res.mjs +30 -0
- package/src/components/Counter.res +24 -0
- package/src/components/Counter.res.mjs +2 -0
- package/src/components/DcbDecode.res +128 -0
- package/src/components/DcbDecode.res.mjs +117 -0
- package/src/components/DcbTag.res +1256 -0
- package/src/components/DcbTag.res.mjs +869 -0
- package/src/components/DcbValidation.res +362 -0
- package/src/components/DcbValidation.res.mjs +360 -0
- package/src/components/DisplayName.res +40 -0
- package/src/components/DisplayName.res.mjs +26 -0
- package/src/components/ExtensionPoint.res +27 -0
- package/src/components/ExtensionPoint.res.mjs +2 -0
- package/src/components/InboundTranslationSlice.res +79 -0
- package/src/components/InboundTranslationSlice.res.mjs +2 -0
- package/src/components/OutboundTranslationSlice.res +108 -0
- package/src/components/OutboundTranslationSlice.res.mjs +2 -0
- package/src/components/Plugin.res +373 -0
- package/src/components/Plugin.res.mjs +249 -0
- package/src/components/ReadModel.res +199 -0
- package/src/components/ReadModel.res.mjs +18 -0
- package/src/components/Reference.res +51 -0
- package/src/components/Reference.res.mjs +37 -0
- package/src/components/StateAnnotations.res +65 -0
- package/src/components/StateAnnotations.res.mjs +15 -0
- package/src/components/StateChangeSlice.res +131 -0
- package/src/components/StateChangeSlice.res.mjs +2 -0
- package/src/components/StateViewSlice.res +99 -0
- package/src/components/StateViewSlice.res.mjs +2 -0
- package/src/components/Task.res +62 -0
- package/src/components/Task.res.mjs +2 -0
- package/src/generator/Codegen.res +703 -0
- package/src/generator/Codegen.res.mjs +485 -0
- package/src/generator/Config.res +85 -0
- package/src/generator/Config.res.mjs +78 -0
- package/src/generator/Discovery.res +204 -0
- package/src/generator/Discovery.res.mjs +213 -0
- package/src/generator/Generator_Node.res +49 -0
- package/src/generator/Generator_Node.res.mjs +18 -0
- package/src/generator/Pairing.res +386 -0
- package/src/generator/Pairing.res.mjs +353 -0
- package/src/generator/PluginGenerator.res +53 -0
- package/src/generator/PluginGenerator.res.mjs +70 -0
- package/src/types/Authorization.res +23 -0
- package/src/types/Authorization.res.mjs +33 -0
- package/src/types/Behavior.res +78 -0
- package/src/types/Behavior.res.mjs +2 -0
- package/src/types/EventMapping.res +100 -0
- package/src/types/EventMapping.res.mjs +15 -0
- package/src/types/Handler.res +30 -0
- package/src/types/Handler.res.mjs +2 -0
- package/src/types/Id.res +75 -0
- package/src/types/Id.res.mjs +37 -0
- package/src/types/Identity.res +46 -0
- package/src/types/Identity.res.mjs +51 -0
- package/src/types/Message.res +197 -0
- package/src/types/Message.res.mjs +108 -0
- package/src/types/Projection.res +220 -0
- package/src/types/Projection.res.mjs +44 -0
- package/src/types/QueryEngine.res +123 -0
- package/src/types/QueryEngine.res.mjs +12 -0
- package/src/types/ReadConsistency.res +38 -0
- package/src/types/ReadConsistency.res.mjs +29 -0
- package/src/types/Schedule.res +65 -0
- package/src/types/Schedule.res.mjs +68 -0
- package/src/types/SideEffect.res +45 -0
- package/src/types/SideEffect.res.mjs +2 -0
- package/src/types/StoredEvent.res +46 -0
- package/src/types/StoredEvent.res.mjs +32 -0
- package/src/types/Visibility.res +24 -0
- package/src/types/Visibility.res.mjs +25 -0
|
@@ -0,0 +1,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)
|