@reventlessdev/reventless-spec 3.0.0-alpha.133 → 3.0.0-alpha.135
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 +33 -0
- package/package.json +4 -3
- package/run-check-lifecycle.mjs +2 -1
- package/run-prepare-accounts.mjs +3 -0
- package/src/AnsiStyle.res +0 -1
- package/src/LogPrefix.res +4 -13
- package/src/components/AutomationSlice.res +1 -5
- package/src/components/CapabilityManifest.res +6 -3
- package/src/components/CapabilityManifest.res.mjs +13 -2
- package/src/components/DcbDecode.res +6 -2
- package/src/components/DcbScopeInference.res +16 -12
- package/src/components/DcbTag.res +74 -46
- package/src/components/DcbValidation.res +114 -89
- package/src/components/DisplayName.res +4 -2
- package/src/components/ExtensionPoint.res +0 -1
- package/src/components/FieldDefault.res +2 -4
- package/src/components/InboundTranslationSlice.res +0 -2
- package/src/components/OutboundTranslationSlice.res +10 -8
- package/src/components/Owner.res +4 -4
- package/src/components/Plugin.res +10 -4
- package/src/components/ReadModel.res +0 -1
- package/src/components/Reference.res +2 -8
- package/src/components/Sensitive.res +4 -4
- package/src/components/StateAnnotations.res +4 -2
- package/src/components/StateChangeSlice.res +0 -2
- package/src/components/StateViewSlice.res +0 -2
- package/src/components/TaggedUnion.res +1 -2
- package/src/components/Task.res +0 -1
- package/src/components/TraitCertificate.res +2 -2
- package/src/components/TraitManifest.res +1 -1
- package/src/generator/CertifyTrait.res +2 -4
- package/src/generator/Codegen.res +77 -68
- package/src/generator/Discovery.res +29 -12
- package/src/generator/GraftTrait.res +17 -9
- package/src/generator/Pairing.res +39 -13
- package/src/generator/PlatformCodegen.res +14 -11
- package/src/generator/PlatformCodegen.res.mjs +5 -0
- package/src/generator/PlatformManifests.res +1 -3
- package/src/generator/PluginGenerator.res +0 -1
- package/src/generator/TraitManifestCli.res +7 -7
- package/src/lifecycle/CheckLifecycleModel.res +206 -148
- package/src/lifecycle/CheckLifecycleModel.res.mjs +23 -5
- package/src/semantic/Bytes.res +0 -1
- package/src/semantic/CalendarDate.res +2 -2
- package/src/semantic/Capabilities.res +21 -6
- package/src/semantic/Capabilities.res.mjs +18 -12
- package/src/semantic/CapabilityNeed.res +16 -5
- package/src/semantic/CapabilityNeed.res.mjs +9 -4
- package/src/semantic/CaptionedImage.res +0 -1
- package/src/semantic/Color.res +0 -1
- package/src/semantic/Currency.res +189 -170
- package/src/semantic/DateRange.res +3 -4
- package/src/semantic/DateTime.res +5 -5
- package/src/semantic/Duration.res +2 -2
- package/src/semantic/Email.res +0 -1
- package/src/semantic/FileRef.res +2 -2
- package/src/semantic/GeoPoint.res +17 -21
- package/src/semantic/Geocoding.res +8 -9
- package/src/semantic/Geolocation.res +6 -9
- package/src/semantic/IdentityProvider.res +127 -0
- package/src/semantic/IdentityProvider.res.mjs +65 -0
- package/src/semantic/ImageRef.res +2 -2
- package/src/semantic/MemberRef.res +0 -1
- package/src/semantic/Messaging.res +141 -21
- package/src/semantic/Messaging.res.mjs +51 -8
- package/src/semantic/Money.res +23 -20
- package/src/semantic/Offload.res +6 -4
- package/src/semantic/Percent.res +3 -3
- package/src/semantic/Phone.res +0 -1
- package/src/semantic/RowImage.res +4 -3
- package/src/semantic/Secrets.res +76 -0
- package/src/semantic/Secrets.res.mjs +49 -0
- package/src/semantic/Semantic.res +8 -11
- package/src/semantic/StorageRef.res +17 -17
- package/src/semantic/Template.res +5 -7
- package/src/semantic/UploadableFile.res +0 -1
- package/src/semantic/UploadableImage.res +0 -1
- package/src/semantic/Url.res +3 -3
- package/src/types/AccountsManifest.res +281 -0
- package/src/types/AccountsManifest.res.mjs +311 -0
- package/src/types/AdminGroup.res +27 -0
- package/src/types/AdminGroup.res.mjs +9 -0
- package/src/types/Authorization.res +1 -2
- package/src/types/Handler.res +0 -1
- package/src/types/Identity.res +1 -3
- package/src/types/Lifecycle.res +7 -6
- package/src/types/Message.res +6 -4
- package/src/types/OwnerScope.res +28 -3
- package/src/types/OwnerScope.res.mjs +12 -0
- package/src/types/PrepareAccounts.res +119 -0
- package/src/types/PrepareAccounts.res.mjs +148 -0
- package/src/types/Projection.res +9 -2
- package/src/types/StoredEvent.res +2 -4
- package/src/types/Transition.res +8 -4
- package/src/util/Util_Password.res +60 -0
- package/src/util/Util_Password.res.mjs +53 -0
- package/src/util/Util_Sury.res +1 -4
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,39 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.135 (2026-09-11)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **spec:** a command that declines to act is not a command that was refused ([0b3d8a4](https://github.com/ReventlessDev/reventless-core/commit/0b3d8a40092ce7ed7801a2aae5d4d7bbed03c179))
|
|
11
|
+
* feat(spec)!: a secret is a capability, so a test can know it in advance ([71507c6](https://github.com/ReventlessDev/reventless-core/commit/71507c6459b49178746f00c5040cb479d7fdade8))
|
|
12
|
+
* feat(spec)!: making a principal is a capability, so the provider is replaceable ([3e59b9b](https://github.com/ReventlessDev/reventless-core/commit/3e59b9b665cc7e4b6b6f156b63b1b6bd0c6181ca))
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **aws:** the first administrator is one command, not the console ([5f1a846](https://github.com/ReventlessDev/reventless-core/commit/5f1a84603b21a9feb691a08a148751c6c6e8eca8))
|
|
16
|
+
* **aws:** the rest of the cast is a file, not a comment ([aa23581](https://github.com/ReventlessDev/reventless-core/commit/aa23581f09948a47fb451ec2ecf2fc2bdd8e6ee6))
|
|
17
|
+
* **spec:** preparing a manifest is not an AWS errand ([bd7f8e2](https://github.com/ReventlessDev/reventless-core/commit/bd7f8e2f6132c0cacac2ab839165f21fe91eaf6c))
|
|
18
|
+
|
|
19
|
+
### BREAKING CHANGES
|
|
20
|
+
|
|
21
|
+
* Capabilities.t gains a secrets member. A platform that builds
|
|
22
|
+
the record literally no longer compiles until it supplies one;
|
|
23
|
+
Capabilities.none.secrets is the refusing value. Code that spreads
|
|
24
|
+
Capabilities.none is unaffected.
|
|
25
|
+
* Capabilities.t gains an identityProvider member. A platform
|
|
26
|
+
that builds the record literally no longer compiles until it supplies one;
|
|
27
|
+
Capabilities.none.identityProvider is the refusing value to pass while no
|
|
28
|
+
backend exists. Code that spreads Capabilities.none is unaffected.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# 3.0.0-alpha.134 (2026-09-09)
|
|
33
|
+
|
|
34
|
+
### Bug Fixes
|
|
35
|
+
|
|
36
|
+
* **spec:** let the formatter settle the lifecycle model's layout ([09e1742](https://github.com/ReventlessDev/reventless-core/commit/09e17429c24d3b84ee6d324b0641d62db4f0d9f9))
|
|
37
|
+
|
|
38
|
+
|
|
6
39
|
# 3.0.0-alpha.133 (2026-09-08)
|
|
7
40
|
|
|
8
41
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-spec",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.135",
|
|
4
4
|
"description": "Specifications for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"graft-trait": "./run-graft-trait.mjs",
|
|
10
10
|
"certify-trait": "./run-certify-trait.mjs",
|
|
11
11
|
"trait-manifest": "./run-trait-manifest.mjs",
|
|
12
|
-
"check-lifecycle": "./run-check-lifecycle.mjs"
|
|
12
|
+
"check-lifecycle": "./run-check-lifecycle.mjs",
|
|
13
|
+
"prepare-accounts": "./run-prepare-accounts.mjs"
|
|
13
14
|
},
|
|
14
15
|
"jest": {
|
|
15
16
|
"testMatch": [
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
"sury": "11.0.0-rc.2",
|
|
26
27
|
"sury-ppx": "11.0.0-rc.2",
|
|
27
28
|
"yaml": "^2.8.3",
|
|
28
|
-
"@reventlessdev/rescript-node": "2.0.0-alpha.
|
|
29
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.11"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
32
|
"rescript": "12.3.0",
|
package/run-check-lifecycle.mjs
CHANGED
package/src/AnsiStyle.res
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
depend on it, so the sink decision lives here as the single source of
|
|
8
8
|
truth. `useAnsi()` ⇒ TTY text mode (colour/bold); `isJsonSink()` ⇒
|
|
9
9
|
structured JSON for any non-TTY collector (CloudWatch, Datadog, …). */
|
|
10
|
-
|
|
11
10
|
@val external _isTty: option<bool> = "process.stdout.isTTY"
|
|
12
11
|
@val external _logFormat: option<string> = "process.env.REVENTLESS_LOG_FORMAT"
|
|
13
12
|
|
package/src/LogPrefix.res
CHANGED
|
@@ -18,10 +18,9 @@ Resolution priority for a comp string like `"Kind(Name)"`:
|
|
|
18
18
|
`Plugin_Builder` registers every component (and the plugin's self-name) when
|
|
19
19
|
constructing a plugin, so all transformation candidates resolve to a
|
|
20
20
|
real plugin name. */
|
|
21
|
-
|
|
22
|
-
// Ambient plugin name. Set inside `Plugin_Builder.Make.construct` so
|
|
21
|
+
let // Ambient plugin name. Set inside `Plugin_Builder.Make.construct` so
|
|
23
22
|
// synchronous logs during construction carry a `[Name]` prefix.
|
|
24
|
-
|
|
23
|
+
currentPluginName: ref<option<string>> = ref(None)
|
|
25
24
|
|
|
26
25
|
// Registry: component name → owning plugin name. Populated at construct time.
|
|
27
26
|
let componentPluginRegistry: ref<dict<string>> = ref(Dict.make())
|
|
@@ -40,14 +39,7 @@ let hashStr = (s: string): int => {
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
// Foreground colors that don't collide with level indicators (cyan / yellow / red).
|
|
43
|
-
let pluginColors = [
|
|
44
|
-
"\x1b[32m", // green
|
|
45
|
-
"\x1b[34m", // blue
|
|
46
|
-
"\x1b[35m", // magenta
|
|
47
|
-
"\x1b[92m", // bright green
|
|
48
|
-
"\x1b[94m", // bright blue
|
|
49
|
-
"\x1b[95m", // bright magenta
|
|
50
|
-
]
|
|
42
|
+
let pluginColors = ["\x1b[32m", "\x1b[34m", "\x1b[35m", "\x1b[92m", "\x1b[94m", "\x1b[95m"] // green // blue // magenta // bright green // bright blue // bright magenta
|
|
51
43
|
|
|
52
44
|
let pluginColor = (name: string): string =>
|
|
53
45
|
pluginColors->Array.getUnsafe(mod(hashStr(name), pluginColors->Array.length))
|
|
@@ -99,8 +91,7 @@ let lastDotSegment = (s: string): option<string> => {
|
|
|
99
91
|
}
|
|
100
92
|
}
|
|
101
93
|
|
|
102
|
-
let lookup = (name: string): option<string> =>
|
|
103
|
-
componentPluginRegistry.contents->Dict.get(name)
|
|
94
|
+
let lookup = (name: string): option<string> => componentPluginRegistry.contents->Dict.get(name)
|
|
104
95
|
|
|
105
96
|
// Last-resort candidate: the inner name is a registered component name carrying a
|
|
106
97
|
// component-kind suffix (`Customers` + `ReadModel` → `CustomersReadModel`), which is
|
|
@@ -42,7 +42,6 @@ let maxRetries = 3
|
|
|
42
42
|
let heartbeatInterval = 60
|
|
43
43
|
```
|
|
44
44
|
*/
|
|
45
|
-
|
|
46
45
|
/**
|
|
47
46
|
The lean Spec for an AutomationSlice — types, identity, schemas, sweep config.
|
|
48
47
|
|
|
@@ -196,9 +195,7 @@ module type Automation = {
|
|
|
196
195
|
+ `module type Mapping = M.Mapping` produces exactly this shape; legacy
|
|
197
196
|
3-file files re-export it via `module type Mapping = <Stem>_Mappings.Mapping`.
|
|
198
197
|
*/
|
|
199
|
-
module type Mapping = Mapping
|
|
200
|
-
with type todoItem = Spec.todoItem
|
|
201
|
-
and type command = Spec.command
|
|
198
|
+
module type Mapping = Mapping with type todoItem = Spec.todoItem and type command = Spec.command
|
|
202
199
|
|
|
203
200
|
/**
|
|
204
201
|
The per-source mappings registered with this slice. Each entry is a first-class
|
|
@@ -318,4 +315,3 @@ module Mappings = {
|
|
|
318
315
|
and type command = Target.command
|
|
319
316
|
}
|
|
320
317
|
}
|
|
321
|
-
|
|
@@ -12,7 +12,6 @@ spell one fact differently.
|
|
|
12
12
|
The platform generator unions these files across a deployment's plugins and
|
|
13
13
|
emits the platform's capability list from them.
|
|
14
14
|
*/
|
|
15
|
-
|
|
16
15
|
@schema
|
|
17
16
|
type kind =
|
|
18
17
|
| ObjectStore
|
|
@@ -22,6 +21,9 @@ type kind =
|
|
|
22
21
|
/** Sending a message, reached through `Capabilities.messaging`. Slice-declared
|
|
23
22
|
like `Geocoding`, and for the same reason carries no `field`. */
|
|
24
23
|
| Messaging
|
|
24
|
+
/** Making, grouping and unmaking principals, reached through
|
|
25
|
+
`Capabilities.identityProvider`. Slice-declared, so no `field`. */
|
|
26
|
+
| IdentityProvider
|
|
25
27
|
|
|
26
28
|
/** The declaration site: the component's spec name, and — for a store — the field
|
|
27
29
|
carrying the `@storageRef` annotation plus the store exactly as that field
|
|
@@ -81,10 +83,11 @@ let fromStructure = (structure: Plugin.pluginStructure): t => {
|
|
|
81
83
|
kind: switch need {
|
|
82
84
|
| Geocoding => Geocoding
|
|
83
85
|
| Messaging => Messaging
|
|
86
|
+
| IdentityProvider => IdentityProvider
|
|
84
87
|
},
|
|
85
88
|
key,
|
|
86
|
-
declaredBy: needs->Array.filterMap(
|
|
87
|
-
d.capability == key ? Some({component: d.component}) : None
|
|
89
|
+
declaredBy: needs->Array.filterMap(
|
|
90
|
+
d => d.capability == key ? Some({component: d.component}) : None,
|
|
88
91
|
),
|
|
89
92
|
})
|
|
90
93
|
)
|
|
@@ -10,7 +10,8 @@ import * as CapabilityNeed$Reventless from "../semantic/CapabilityNeed.res.mjs";
|
|
|
10
10
|
let kindSchema = Sury.union([
|
|
11
11
|
Sury.literal("ObjectStore"),
|
|
12
12
|
Sury.literal("Geocoding"),
|
|
13
|
-
Sury.literal("Messaging")
|
|
13
|
+
Sury.literal("Messaging"),
|
|
14
|
+
Sury.literal("IdentityProvider")
|
|
14
15
|
]);
|
|
15
16
|
|
|
16
17
|
let provenanceSchema = Sury.$schema(s => ({
|
|
@@ -48,7 +49,17 @@ function fromStructure(structure) {
|
|
|
48
49
|
let capabilityKeys = Belt_SetString.toArray(Belt_SetString.fromArray(needs.map(d => d.capability)));
|
|
49
50
|
let capabilities = Stdlib_Array.filterMap(capabilityKeys, key => Stdlib_Option.map(CapabilityNeed$Reventless.fromString(key), need => {
|
|
50
51
|
let tmp;
|
|
51
|
-
|
|
52
|
+
switch (need) {
|
|
53
|
+
case "Geocoding" :
|
|
54
|
+
tmp = "Geocoding";
|
|
55
|
+
break;
|
|
56
|
+
case "Messaging" :
|
|
57
|
+
tmp = "Messaging";
|
|
58
|
+
break;
|
|
59
|
+
case "IdentityProvider" :
|
|
60
|
+
tmp = "IdentityProvider";
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
52
63
|
return {
|
|
53
64
|
kind: tmp,
|
|
54
65
|
key: key,
|
|
@@ -59,13 +59,17 @@ external unsafeCast: 'a => 'b = "%identity"
|
|
|
59
59
|
// Build a map of TAG name -> constructed payload-less variant value
|
|
60
60
|
// by encoding each payload-less variant through sury and storing the result.
|
|
61
61
|
// This is done once per schema, not per event.
|
|
62
|
-
let buildPayloadLessConstructors = (schema: S.t<'event>, lookup: dict<variantKind>): dict<
|
|
62
|
+
let buildPayloadLessConstructors = (schema: S.t<'event>, lookup: dict<variantKind>): dict<
|
|
63
|
+
'event,
|
|
64
|
+
> => {
|
|
63
65
|
let constructors = Dict.make()
|
|
64
66
|
// For payload-less variants, we need to figure out what ReScript value
|
|
65
67
|
// corresponds to each TAG name. sury's schema for a union with payload-less
|
|
66
68
|
// variants encodes them as bare strings. So `| ItemCreated` serializes to
|
|
67
69
|
// JSON string "ItemCreated". We can use Util_Sury.fromJson with the string.
|
|
68
|
-
lookup
|
|
70
|
+
lookup
|
|
71
|
+
->Dict.toArray
|
|
72
|
+
->Array.forEach(((tagName, kind)) =>
|
|
69
73
|
switch kind {
|
|
70
74
|
| PayloadLess =>
|
|
71
75
|
try {
|
|
@@ -29,7 +29,6 @@ The three rules (over the representation):
|
|
|
29
29
|
on `ProductAdded`) are payload ⇒ not indexed ⇒ the sibling-leak GSI write never
|
|
30
30
|
happens.
|
|
31
31
|
*/
|
|
32
|
-
|
|
33
32
|
/** A `*Id` / `*Ids`-shaped field, identified by name only (no schema, no tag flag). */
|
|
34
33
|
type idField = {name: string, isList: bool}
|
|
35
34
|
|
|
@@ -210,15 +209,19 @@ let infer = (slices: array<sliceShape>): derived => {
|
|
|
210
209
|
partitionBlockers(s)
|
|
211
210
|
->Array.map(((key, arms)) => `${arms->Array.join("/")} declares ${key}`)
|
|
212
211
|
->Array.join("; ")
|
|
213
|
-
let _ =
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
212
|
+
let _ =
|
|
213
|
+
ambiguities->Array.push((
|
|
214
|
+
s.sliceName,
|
|
215
|
+
`no own partition key — every produced *Id is read from a foreign producer (${blame}). If that field is this slice's own partition, remove it from the consumed arm; if the slice really is a pure join, add an explicit @partitionTag`,
|
|
216
|
+
))
|
|
217
217
|
| many =>
|
|
218
|
-
let _ =
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
218
|
+
let _ =
|
|
219
|
+
ambiguities->Array.push((
|
|
220
|
+
s.sliceName,
|
|
221
|
+
`multiple candidate partition keys (${many->Array.join(
|
|
222
|
+
", ",
|
|
223
|
+
)}) — add an explicit @partitionTag`,
|
|
224
|
+
))
|
|
222
225
|
}
|
|
223
226
|
}
|
|
224
227
|
})
|
|
@@ -247,7 +250,9 @@ let infer = (slices: array<sliceShape>): derived => {
|
|
|
247
250
|
slices->Array.forEach(s => {
|
|
248
251
|
let own = partitionBySlice->Dict.get(s.sliceName)
|
|
249
252
|
let scalar = commandScalarKeys(s)
|
|
250
|
-
s
|
|
253
|
+
s
|
|
254
|
+
->consumedKeys
|
|
255
|
+
->Array.forEach(k =>
|
|
251
256
|
if ownedPartitionKeys->Set.has(k) && Some(k) != own && scalar->Array.includes(k) {
|
|
252
257
|
crossKeys->Set.add(k)
|
|
253
258
|
}
|
|
@@ -275,8 +280,7 @@ let infer = (slices: array<sliceShape>): derived => {
|
|
|
275
280
|
let own = partitionBySlice->Dict.get(s.sliceName)
|
|
276
281
|
s.produced->Array.forEach(e => {
|
|
277
282
|
let readKeys = readKeysByEventType->Dict.get(e.eventType)->Option.getOr([])
|
|
278
|
-
let indexed =
|
|
279
|
-
e->keysOfEvent->Array.filter(k => Some(k) == own || readKeys->Array.includes(k))
|
|
283
|
+
let indexed = e->keysOfEvent->Array.filter(k => Some(k) == own || readKeys->Array.includes(k))
|
|
280
284
|
tagKeysByEventType->Dict.set(e.eventType, dedupSorted(indexed))
|
|
281
285
|
})
|
|
282
286
|
})
|
|
@@ -91,7 +91,10 @@ type appendCondition = {
|
|
|
91
91
|
let dcbTagId: S.Metadata.Id.t<bool> = S.Metadata.Id.make(~namespace="dcb", ~name="tag")
|
|
92
92
|
|
|
93
93
|
/** Internal sury metadata ID used to mark the partition tag field. */
|
|
94
|
-
let dcbPartitionTagId: S.Metadata.Id.t<bool> = S.Metadata.Id.make(
|
|
94
|
+
let dcbPartitionTagId: S.Metadata.Id.t<bool> = S.Metadata.Id.make(
|
|
95
|
+
~namespace="dcb",
|
|
96
|
+
~name="partitionTag",
|
|
97
|
+
)
|
|
95
98
|
|
|
96
99
|
/**
|
|
97
100
|
Internal sury metadata ID marking a tag field as *cross-partition* — readable
|
|
@@ -101,22 +104,27 @@ partition-scoped behaviour. The scope is a property of the tag *key* and must
|
|
|
101
104
|
agree across every event type that carries it (the fence-scope half depends on
|
|
102
105
|
it). See `docs/analysis/dcb-consistency-check-issues.md` Issue 13.
|
|
103
106
|
*/
|
|
104
|
-
let dcbCrossPartitionId: S.Metadata.Id.t<bool> =
|
|
105
|
-
|
|
107
|
+
let dcbCrossPartitionId: S.Metadata.Id.t<bool> = S.Metadata.Id.make(
|
|
108
|
+
~namespace="dcb",
|
|
109
|
+
~name="crossPartition",
|
|
110
|
+
)
|
|
106
111
|
|
|
107
112
|
/** Metadata value for a composite partition member field. */
|
|
108
113
|
type compositePartitionMemberMeta = {position: int, sep: string}
|
|
109
114
|
|
|
110
115
|
/** Internal sury metadata ID used to mark a composite partition member field. */
|
|
111
|
-
let dcbCompositePartitionMemberId: S.Metadata.Id.t<
|
|
112
|
-
|
|
116
|
+
let dcbCompositePartitionMemberId: S.Metadata.Id.t<
|
|
117
|
+
compositePartitionMemberMeta,
|
|
118
|
+
> = S.Metadata.Id.make(~namespace="dcb", ~name="compositePartitionMember")
|
|
113
119
|
|
|
114
120
|
/**
|
|
115
121
|
Internal sury metadata ID carrying an explicit tag-key override. When present,
|
|
116
122
|
tag extraction uses the stored string as the tag `key` instead of the field name.
|
|
117
123
|
*/
|
|
118
|
-
let dcbTagKeyOverrideId: S.Metadata.Id.t<string> =
|
|
119
|
-
|
|
124
|
+
let dcbTagKeyOverrideId: S.Metadata.Id.t<string> = S.Metadata.Id.make(
|
|
125
|
+
~namespace="dcb",
|
|
126
|
+
~name="tagKeyOverride",
|
|
127
|
+
)
|
|
120
128
|
|
|
121
129
|
/**
|
|
122
130
|
A sury string schema annotated as a DCB tag field.
|
|
@@ -139,7 +147,8 @@ and array element types.
|
|
|
139
147
|
})
|
|
140
148
|
```
|
|
141
149
|
*/
|
|
142
|
-
let string: S.t<string> =
|
|
150
|
+
let string: S.t<string> =
|
|
151
|
+
S.string->S.Metadata.set(~id=dcbTagId, true)
|
|
143
152
|
|
|
144
153
|
/**
|
|
145
154
|
A sury string schema annotated as a DCB tag field with an explicit tag-key override.
|
|
@@ -169,7 +178,8 @@ let stringForKey = (~key: string): S.t<string> =>
|
|
|
169
178
|
A sury int schema annotated as a DCB tag field.
|
|
170
179
|
Use with `@s.matches(DcbTag.int)` on integer fields that should be extracted as tags.
|
|
171
180
|
*/
|
|
172
|
-
let int: S.t<int> =
|
|
181
|
+
let int: S.t<int> =
|
|
182
|
+
S.int->S.Metadata.set(~id=dcbTagId, true)
|
|
173
183
|
|
|
174
184
|
/**
|
|
175
185
|
A sury string schema annotated as both a DCB tag field AND the partition key.
|
|
@@ -518,12 +528,15 @@ let extractTagsFromJson = (schema: S.t<unknown>, json: JSON.t): array<tag> =>
|
|
|
518
528
|
| AnyOf({anyOf}) =>
|
|
519
529
|
switch json->JSON.Decode.object {
|
|
520
530
|
| Some(jsonDict) =>
|
|
521
|
-
let jsonTag =
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
531
|
+
let jsonTag =
|
|
532
|
+
jsonDict
|
|
533
|
+
->Dict.get("TAG")
|
|
534
|
+
->Option.flatMap(j =>
|
|
535
|
+
switch j {
|
|
536
|
+
| JSON.String(s) => Some(s)
|
|
537
|
+
| _ => None
|
|
538
|
+
}
|
|
539
|
+
)
|
|
527
540
|
anyOf->Array.reduce([], (acc, variantSchema) =>
|
|
528
541
|
if acc->Array.length > 0 {
|
|
529
542
|
acc
|
|
@@ -674,10 +687,7 @@ Fields holding records are descended into on the same terms as in
|
|
|
674
687
|
`extractTagsFromProperties` — the two walks agree there and differ only on scalar
|
|
675
688
|
arrays; see that function.
|
|
676
689
|
*/
|
|
677
|
-
let extractTagsFromPropertiesExpanded = (
|
|
678
|
-
properties: dict<S.t<unknown>>,
|
|
679
|
-
jsonDict: dict<JSON.t>,
|
|
680
|
-
) =>
|
|
690
|
+
let extractTagsFromPropertiesExpanded = (properties: dict<S.t<unknown>>, jsonDict: dict<JSON.t>) =>
|
|
681
691
|
properties
|
|
682
692
|
->Dict.toArray
|
|
683
693
|
->Array.flatMap(((fieldName, fieldSchema)) =>
|
|
@@ -705,12 +715,15 @@ let extractTagsFromJsonExpanded = (schema: S.t<unknown>, json: JSON.t): array<ta
|
|
|
705
715
|
| AnyOf({anyOf}) =>
|
|
706
716
|
switch json->JSON.Decode.object {
|
|
707
717
|
| Some(jsonDict) =>
|
|
708
|
-
let jsonTag =
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
718
|
+
let jsonTag =
|
|
719
|
+
jsonDict
|
|
720
|
+
->Dict.get("TAG")
|
|
721
|
+
->Option.flatMap(j =>
|
|
722
|
+
switch j {
|
|
723
|
+
| JSON.String(s) => Some(s)
|
|
724
|
+
| _ => None
|
|
725
|
+
}
|
|
726
|
+
)
|
|
714
727
|
anyOf->Array.reduce([], (acc, variantSchema) =>
|
|
715
728
|
if acc->Array.length > 0 {
|
|
716
729
|
acc
|
|
@@ -873,8 +886,7 @@ let narrowEventTypesForTags = (
|
|
|
873
886
|
eventTypes->Array.filter(eventType =>
|
|
874
887
|
switch tagKeysByEventType->Dict.get(eventType) {
|
|
875
888
|
| None => true
|
|
876
|
-
| Some(producedKeys) =>
|
|
877
|
-
tags->Array.every(tag => producedKeys->Array.includes(tag.key))
|
|
889
|
+
| Some(producedKeys) => tags->Array.every(tag => producedKeys->Array.includes(tag.key))
|
|
878
890
|
}
|
|
879
891
|
)
|
|
880
892
|
|
|
@@ -922,7 +934,8 @@ let buildQueryFromCommand = (
|
|
|
922
934
|
~tagKeysByEventType: dict<array<string>>=Dict.make(),
|
|
923
935
|
~crossPartitionTagKeys: array<string>=[],
|
|
924
936
|
): query => {
|
|
925
|
-
let typesForTags = clauseTags =>
|
|
937
|
+
let typesForTags = clauseTags =>
|
|
938
|
+
narrowEventTypesForTags(eventTypes, clauseTags, tagKeysByEventType)
|
|
926
939
|
if hasTaggedArrayFields(schema) {
|
|
927
940
|
// Array fields already fan out per element into single-tag clauses, so a
|
|
928
941
|
// cross-partition array tag is already its own clause (the adapter routes it
|
|
@@ -940,8 +953,7 @@ let buildQueryFromCommand = (
|
|
|
940
953
|
// must read "all of the course" AND "all of the student" as two single-tag
|
|
941
954
|
// reads — a composite read of the exact `{course, student}` pair is neither.
|
|
942
955
|
// Without a cross-partition tag the default composite clause is preserved.
|
|
943
|
-
let hasCrossPartition =
|
|
944
|
-
tags->Array.some(tag => crossPartitionTagKeys->Array.includes(tag.key))
|
|
956
|
+
let hasCrossPartition = tags->Array.some(tag => crossPartitionTagKeys->Array.includes(tag.key))
|
|
945
957
|
if hasCrossPartition && tags->Array.length > 1 {
|
|
946
958
|
tags->Array.map(tag => {
|
|
947
959
|
let clauseTags = [{key: tag.key, value: tag.value}]
|
|
@@ -1347,9 +1359,9 @@ type compositePartitionFieldInfo = {name: string, position: int, sep: string}
|
|
|
1347
1359
|
Extracts all composite partition member fields from a single object-variant schema.
|
|
1348
1360
|
Returns an array sorted by `position`.
|
|
1349
1361
|
*/
|
|
1350
|
-
let extractCompositePartitionFieldsFromProperties = (
|
|
1351
|
-
|
|
1352
|
-
|
|
1362
|
+
let extractCompositePartitionFieldsFromProperties = (properties: dict<S.t<unknown>>): array<
|
|
1363
|
+
compositePartitionFieldInfo,
|
|
1364
|
+
> =>
|
|
1353
1365
|
properties
|
|
1354
1366
|
->Dict.toArray
|
|
1355
1367
|
->Array.filterMap(((fieldName, fieldSchema)) =>
|
|
@@ -1396,7 +1408,15 @@ let getCompositePartitionKeyValue = (tags: array<tag>, spec: compositePartitionS
|
|
|
1396
1408
|
spec.keys
|
|
1397
1409
|
->Array.mapWithIndex((fieldName, i) => {
|
|
1398
1410
|
let v =
|
|
1399
|
-
tags
|
|
1411
|
+
tags
|
|
1412
|
+
->Array.findMap(t =>
|
|
1413
|
+
if t.key == fieldName {
|
|
1414
|
+
Some(t.value)
|
|
1415
|
+
} else {
|
|
1416
|
+
None
|
|
1417
|
+
}
|
|
1418
|
+
)
|
|
1419
|
+
->Option.getOr("")
|
|
1400
1420
|
if i == 0 {
|
|
1401
1421
|
v
|
|
1402
1422
|
} else {
|
|
@@ -1450,7 +1470,9 @@ let derivePartitionTag = (
|
|
|
1450
1470
|
|
|
1451
1471
|
let allPartitionFields = {
|
|
1452
1472
|
let seen = Set.make()
|
|
1453
|
-
schemas
|
|
1473
|
+
schemas
|
|
1474
|
+
->Array.flatMap(schema => extractPartitionTagFields(schema))
|
|
1475
|
+
->Array.filter(f => {
|
|
1454
1476
|
if seen->Set.has(f) {
|
|
1455
1477
|
false
|
|
1456
1478
|
} else {
|
|
@@ -1461,27 +1483,28 @@ let derivePartitionTag = (
|
|
|
1461
1483
|
}
|
|
1462
1484
|
|
|
1463
1485
|
if hasComposite && allPartitionFields->Array.length > 0 {
|
|
1464
|
-
JsError.throwWithMessage(
|
|
1465
|
-
`DCB spec mixes @compositePartitionTag and @partitionTag — use one strategy per schema`,
|
|
1466
|
-
)
|
|
1486
|
+
JsError.throwWithMessage(`DCB spec mixes @compositePartitionTag and @partitionTag — use one strategy per schema`)
|
|
1467
1487
|
}
|
|
1468
1488
|
|
|
1469
1489
|
if hasComposite {
|
|
1470
1490
|
if allCompositeFields->Array.length < 2 {
|
|
1471
1491
|
JsError.throwWithMessage(
|
|
1472
|
-
`@compositePartitionTag requires at least 2 annotated fields — only ${allCompositeFields
|
|
1492
|
+
`@compositePartitionTag requires at least 2 annotated fields — only ${allCompositeFields
|
|
1493
|
+
->Array.length
|
|
1494
|
+
->Int.toString} found`,
|
|
1473
1495
|
)
|
|
1474
1496
|
}
|
|
1475
1497
|
let sorted = allCompositeFields->Array.toSorted((a, b) => Int.compare(a.position, b.position))
|
|
1476
1498
|
let keys = sorted->Array.map(info => info.name)
|
|
1477
|
-
let seps =
|
|
1478
|
-
info.sep
|
|
1479
|
-
)
|
|
1499
|
+
let seps =
|
|
1500
|
+
sorted->Array.slice(~start=0, ~end=sorted->Array.length - 1)->Array.map(info => info.sep)
|
|
1480
1501
|
Composite({keys, seps})
|
|
1481
1502
|
} else {
|
|
1482
1503
|
let allTaggedFields = {
|
|
1483
1504
|
let seen = Set.make()
|
|
1484
|
-
schemas
|
|
1505
|
+
schemas
|
|
1506
|
+
->Array.flatMap(schema => extractTaggedFields(schema))
|
|
1507
|
+
->Array.filter(f => {
|
|
1485
1508
|
if seen->Set.has(f) {
|
|
1486
1509
|
false
|
|
1487
1510
|
} else {
|
|
@@ -1492,7 +1515,8 @@ let derivePartitionTag = (
|
|
|
1492
1515
|
}
|
|
1493
1516
|
|
|
1494
1517
|
switch allTaggedFields {
|
|
1495
|
-
| [] =>
|
|
1518
|
+
| [] =>
|
|
1519
|
+
JsError.throwWithMessage("DCB spec has no tagged fields — cannot derive partition tag")
|
|
1496
1520
|
| [singleField] => Simple({key: singleField})
|
|
1497
1521
|
| multipleFields => {
|
|
1498
1522
|
let needsExplicitPartition = schemas->Array.some(schema => hasMultiTagVariant(schema))
|
|
@@ -1514,11 +1538,15 @@ let derivePartitionTag = (
|
|
|
1514
1538
|
| [singlePartition] => Simple({key: singlePartition})
|
|
1515
1539
|
| [] =>
|
|
1516
1540
|
JsError.throwWithMessage(
|
|
1517
|
-
`DCB spec has variants with multiple tagged fields (${multipleFields->Array.join(
|
|
1541
|
+
`DCB spec has variants with multiple tagged fields (${multipleFields->Array.join(
|
|
1542
|
+
", ",
|
|
1543
|
+
)}) but none is annotated with @partitionTag — affected: ${context} — mark one field as the partition key`,
|
|
1518
1544
|
)
|
|
1519
1545
|
| multiplePartitions =>
|
|
1520
1546
|
JsError.throwWithMessage(
|
|
1521
|
-
`DCB spec has multiple fields annotated with @partitionTag (${multiplePartitions->Array.join(
|
|
1547
|
+
`DCB spec has multiple fields annotated with @partitionTag (${multiplePartitions->Array.join(
|
|
1548
|
+
", ",
|
|
1549
|
+
)}) — only one is allowed — affected: ${context}`,
|
|
1522
1550
|
)
|
|
1523
1551
|
}
|
|
1524
1552
|
} else {
|