@reventlessdev/reventless-spec 3.0.0-alpha.134 → 3.0.0-alpha.136
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 +44 -0
- package/package.json +4 -3
- package/run-check-lifecycle.mjs +2 -1
- package/run-prepare-accounts.mjs +3 -0
- package/src/components/CapabilityManifest.res +4 -0
- package/src/components/CapabilityManifest.res.mjs +13 -2
- package/src/components/DcbScopeInference.res +224 -73
- package/src/components/DcbScopeInference.res.mjs +187 -49
- package/src/components/DcbTag.res +142 -186
- package/src/components/DcbTag.res.mjs +137 -128
- package/src/components/DcbValidation.res +50 -0
- package/src/components/DcbValidation.res.mjs +51 -0
- package/src/generator/Codegen.res +2 -1
- package/src/generator/Codegen.res.mjs +1 -1
- package/src/generator/PlatformCodegen.res +1 -0
- package/src/generator/PlatformCodegen.res.mjs +5 -0
- package/src/lifecycle/CheckLifecycleModel.res +148 -8
- package/src/lifecycle/CheckLifecycleModel.res.mjs +154 -7
- package/src/semantic/Capabilities.res +17 -0
- package/src/semantic/Capabilities.res.mjs +9 -1
- package/src/semantic/CapabilityNeed.res +12 -0
- package/src/semantic/CapabilityNeed.res.mjs +9 -4
- package/src/semantic/IdentityProvider.res +127 -0
- package/src/semantic/IdentityProvider.res.mjs +65 -0
- package/src/semantic/Secrets.res +76 -0
- package/src/semantic/Secrets.res.mjs +49 -0
- package/src/types/AccountsManifest.res +287 -0
- package/src/types/AccountsManifest.res.mjs +312 -0
- package/src/types/AdminGroup.res +27 -0
- package/src/types/AdminGroup.res.mjs +9 -0
- package/src/types/OwnerScope.res +27 -0
- 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/util/Util_Password.res +60 -0
- package/src/util/Util_Password.res.mjs +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,50 @@
|
|
|
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.136 (2026-09-16)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **aws:** make the demo accounts work on a pool that signs in by email ([97b036f](https://github.com/ReventlessDev/reventless-core/commit/97b036f86979ba55dbbf93d460fe8076c35abf1a))
|
|
11
|
+
* feat(spec)!: infer the DCB partition key per slice, and drop @partitionTag where inference agrees ([6c10161](https://github.com/ReventlessDev/reventless-core/commit/6c101611d438beea4bc9175ae8ea0ea1f6f7493b))
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* **spec:** let a slice's chapter break a partition-key tie ([2b9ae77](https://github.com/ReventlessDev/reventless-core/commit/2b9ae77e98eb42177d2bdd4977bc62bb7c7911aa))
|
|
15
|
+
* **spec:** report which command yields which event or error ([ac0bdc2](https://github.com/ReventlessDev/reventless-core/commit/ac0bdc23f76a5b71b5bf5c99415ff0937690a279))
|
|
16
|
+
|
|
17
|
+
### BREAKING CHANGES
|
|
18
|
+
|
|
19
|
+
* DcbTag.derivePartitionTag is removed; a boundary whose
|
|
20
|
+
partition cannot be inferred no longer deploys.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# 3.0.0-alpha.135 (2026-09-11)
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
* **spec:** a command that declines to act is not a command that was refused ([0b3d8a4](https://github.com/ReventlessDev/reventless-core/commit/0b3d8a40092ce7ed7801a2aae5d4d7bbed03c179))
|
|
29
|
+
* feat(spec)!: a secret is a capability, so a test can know it in advance ([71507c6](https://github.com/ReventlessDev/reventless-core/commit/71507c6459b49178746f00c5040cb479d7fdade8))
|
|
30
|
+
* feat(spec)!: making a principal is a capability, so the provider is replaceable ([3e59b9b](https://github.com/ReventlessDev/reventless-core/commit/3e59b9b665cc7e4b6b6f156b63b1b6bd0c6181ca))
|
|
31
|
+
### Features
|
|
32
|
+
|
|
33
|
+
* **aws:** the first administrator is one command, not the console ([5f1a846](https://github.com/ReventlessDev/reventless-core/commit/5f1a84603b21a9feb691a08a148751c6c6e8eca8))
|
|
34
|
+
* **aws:** the rest of the cast is a file, not a comment ([aa23581](https://github.com/ReventlessDev/reventless-core/commit/aa23581f09948a47fb451ec2ecf2fc2bdd8e6ee6))
|
|
35
|
+
* **spec:** preparing a manifest is not an AWS errand ([bd7f8e2](https://github.com/ReventlessDev/reventless-core/commit/bd7f8e2f6132c0cacac2ab839165f21fe91eaf6c))
|
|
36
|
+
|
|
37
|
+
### BREAKING CHANGES
|
|
38
|
+
|
|
39
|
+
* Capabilities.t gains a secrets member. A platform that builds
|
|
40
|
+
the record literally no longer compiles until it supplies one;
|
|
41
|
+
Capabilities.none.secrets is the refusing value. Code that spreads
|
|
42
|
+
Capabilities.none is unaffected.
|
|
43
|
+
* Capabilities.t gains an identityProvider member. A platform
|
|
44
|
+
that builds the record literally no longer compiles until it supplies one;
|
|
45
|
+
Capabilities.none.identityProvider is the refusing value to pass while no
|
|
46
|
+
backend exists. Code that spreads Capabilities.none is unaffected.
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
6
50
|
# 3.0.0-alpha.134 (2026-09-09)
|
|
7
51
|
|
|
8
52
|
### Bug Fixes
|
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.136",
|
|
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.12"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
32
|
"rescript": "12.3.0",
|
package/run-check-lifecycle.mjs
CHANGED
|
@@ -21,6 +21,9 @@ type kind =
|
|
|
21
21
|
/** Sending a message, reached through `Capabilities.messaging`. Slice-declared
|
|
22
22
|
like `Geocoding`, and for the same reason carries no `field`. */
|
|
23
23
|
| Messaging
|
|
24
|
+
/** Making, grouping and unmaking principals, reached through
|
|
25
|
+
`Capabilities.identityProvider`. Slice-declared, so no `field`. */
|
|
26
|
+
| IdentityProvider
|
|
24
27
|
|
|
25
28
|
/** The declaration site: the component's spec name, and — for a store — the field
|
|
26
29
|
carrying the `@storageRef` annotation plus the store exactly as that field
|
|
@@ -80,6 +83,7 @@ let fromStructure = (structure: Plugin.pluginStructure): t => {
|
|
|
80
83
|
kind: switch need {
|
|
81
84
|
| Geocoding => Geocoding
|
|
82
85
|
| Messaging => Messaging
|
|
86
|
+
| IdentityProvider => IdentityProvider
|
|
83
87
|
},
|
|
84
88
|
key,
|
|
85
89
|
declaredBy: needs->Array.filterMap(
|
|
@@ -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,
|
|
@@ -17,7 +17,12 @@ The three rules (over the representation):
|
|
|
17
17
|
reads from a *foreign* producer (a consumed arm whose event type is produced by
|
|
18
18
|
a different slice). For `AddProduct` (`ProductAdded({productId, categoryId})`,
|
|
19
19
|
consuming `CategoryAdded({categoryId})`) the foreign-read `categoryId` is
|
|
20
|
-
removed, leaving `productId`.
|
|
20
|
+
removed, leaving `productId`. Only when that leaves nothing is a key given
|
|
21
|
+
back: one whose every foreign arm comes from a slice partitioned by that same
|
|
22
|
+
key, since reading your own entity's events is identity, not a reference.
|
|
23
|
+
That step depends on other slices' partitions, so it runs to a fixpoint.
|
|
24
|
+
When several keys are left, the slice's chapter breaks the tie if exactly one
|
|
25
|
+
of them is carried by every event its chapter writes.
|
|
21
26
|
|
|
22
27
|
2. **Cross-partition.** A key is cross-partition iff some slice reads it on a
|
|
23
28
|
*foreign* consumed event while partitioned by something else *and* the key is
|
|
@@ -50,6 +55,8 @@ type sliceShape = {
|
|
|
50
55
|
consumed: array<eventShape>,
|
|
51
56
|
produced: array<eventShape>,
|
|
52
57
|
partitionHint: option<string>,
|
|
58
|
+
/** The chapter the slice sits in (`src/<Chapter>/<Kind>/…`), when it has one. */
|
|
59
|
+
chapter?: string,
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
/** Derived scope for one tag key. */
|
|
@@ -129,39 +136,227 @@ let foreignConsumedKeys = (s: sliceShape): array<string> => {
|
|
|
129
136
|
)
|
|
130
137
|
}
|
|
131
138
|
|
|
139
|
+
/** Rule 1's outcome for a boundary, before the scope rules build on it. */
|
|
140
|
+
type partitionResolution = {
|
|
141
|
+
/** sliceName -> its partition key (absent when unresolved). */
|
|
142
|
+
partitionBySlice: dict<string>,
|
|
143
|
+
/** sliceName -> the keys rule 1 left standing; one entry means resolved. */
|
|
144
|
+
candidatesBySlice: dict<array<string>>,
|
|
145
|
+
/** sliceName -> (produced key, the foreign arms that kept it from the slice). */
|
|
146
|
+
blockersBySlice: dict<array<(string, array<string>)>>,
|
|
147
|
+
/** (sliceName, reason) for slices whose partition couldn't be inferred. */
|
|
148
|
+
ambiguities: array<(string, string)>,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let sameAssignment = (a: dict<string>, b: dict<string>): bool => {
|
|
152
|
+
let entries = d => d->Dict.toArray->Array.toSorted(((x, _), (y, _)) => String.compare(x, y))
|
|
153
|
+
entries(a) == entries(b)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
Rule 1 over a whole boundary.
|
|
158
|
+
|
|
159
|
+
1. **Seeds.** A slice whose events carry a single key is partitioned by it, whatever
|
|
160
|
+
it reads; an explicit `@partitionTag` naming a produced key is a seed too.
|
|
161
|
+
2. **Subtraction.** Otherwise, `producedKeys − foreignConsumedKeys`. One key left
|
|
162
|
+
is the partition; several go to the chapter (4), then to `@partitionTag`.
|
|
163
|
+
3. **Give-back.** Only when the subtraction leaves nothing: a key comes back when
|
|
164
|
+
every foreign arm carrying it is produced by a slice partitioned by that key.
|
|
165
|
+
An unseen producer makes the arm a reference. An unresolved producer counts as
|
|
166
|
+
possibly partitioned by any key it writes, which breaks cycles such as two
|
|
167
|
+
slices each reading the other's event; the answer counts only once a further
|
|
168
|
+
pass changes nothing, so that optimism never decides it alone.
|
|
169
|
+
4. **Chapter.** When several keys are left, keep the ones every id-carrying event
|
|
170
|
+
written in the slice's chapter carries. `PlaceOrder` is left with `orderId` and
|
|
171
|
+
`customerId`, whose reads never show the customer to be a reference; every
|
|
172
|
+
event under `Order/` carries `orderId`, and only some carry `customerId`. Only a
|
|
173
|
+
single survivor decides — a join such as `RecordProductDemand`, whose chapter's
|
|
174
|
+
events all carry both ids, stays ambiguous. It is a tie-breaker, not a rule:
|
|
175
|
+
it never overrides a seed, the subtraction or the give-back, and moving a slice
|
|
176
|
+
to another chapter can change only a partition this step decided.
|
|
177
|
+
|
|
178
|
+
Applying the give-back everywhere would be wrong: `AddProduct` reads
|
|
179
|
+
`CategoryAdded({categoryId})` from a slice partitioned by `categoryId`, which
|
|
180
|
+
would hand `categoryId` back and leave it two candidates.
|
|
181
|
+
*/
|
|
182
|
+
let resolvePartitions = (slices: array<sliceShape>): partitionResolution => {
|
|
183
|
+
let producersByEventType: dict<array<sliceShape>> = Dict.make()
|
|
184
|
+
slices->Array.forEach(s =>
|
|
185
|
+
s.produced->Array.forEach(e => {
|
|
186
|
+
let prev = producersByEventType->Dict.get(e.eventType)->Option.getOr([])
|
|
187
|
+
if !(prev->Array.some(p => p.sliceName == s.sliceName)) {
|
|
188
|
+
producersByEventType->Dict.set(e.eventType, prev->Array.concat([s]))
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
)
|
|
192
|
+
// chapter -> the keys every id-carrying event written in that chapter carries.
|
|
193
|
+
let chapterKeys = Dict.make()
|
|
194
|
+
slices->Array.forEach(s =>
|
|
195
|
+
s.chapter->Option.forEach(chapter => {
|
|
196
|
+
let events = s.produced->Array.filter(e => e.idFields->Array.length > 0)
|
|
197
|
+
events->Array.forEach(
|
|
198
|
+
e => {
|
|
199
|
+
let keys = e->keysOfEvent
|
|
200
|
+
let shared = switch chapterKeys->Dict.get(chapter) {
|
|
201
|
+
| Some(prev) => prev->Array.filter(k => keys->Array.includes(k))
|
|
202
|
+
| None => dedupSorted(keys)
|
|
203
|
+
}
|
|
204
|
+
chapterKeys->Dict.set(chapter, shared)
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
)
|
|
209
|
+
let chapterKeysOf = (s: sliceShape) => s.chapter->Option.flatMap(c => chapterKeys->Dict.get(c))
|
|
210
|
+
let byChapter = (s: sliceShape, candidates) =>
|
|
211
|
+
switch s->chapterKeysOf {
|
|
212
|
+
| Some(keys) =>
|
|
213
|
+
switch candidates->Array.filter(k => keys->Array.includes(k)) {
|
|
214
|
+
| [k] => [k]
|
|
215
|
+
| _ => candidates
|
|
216
|
+
}
|
|
217
|
+
| None => candidates
|
|
218
|
+
}
|
|
219
|
+
let foreignArms = (s: sliceShape) => {
|
|
220
|
+
let own = Set.make()
|
|
221
|
+
s.produced->Array.forEach(e => own->Set.add(e.eventType))
|
|
222
|
+
s.consumed->Array.filter(e => !(own->Set.has(e.eventType)))
|
|
223
|
+
}
|
|
224
|
+
// An arm is identity evidence for `k` iff every producer of its event type is
|
|
225
|
+
// (or, while unresolved, could be) partitioned by `k`.
|
|
226
|
+
let armIsIdentity = (e: eventShape, k, known: dict<string>) =>
|
|
227
|
+
switch producersByEventType->Dict.get(e.eventType) {
|
|
228
|
+
| None | Some([]) => false
|
|
229
|
+
| Some(producers) =>
|
|
230
|
+
producers->Array.every(p =>
|
|
231
|
+
switch known->Dict.get(p.sliceName) {
|
|
232
|
+
| Some(pk) => pk == k
|
|
233
|
+
| None => producedKeys(p)->Array.includes(k)
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
let blockersOf = (s: sliceShape, known) =>
|
|
238
|
+
producedKeys(s)->Array.filterMap(k => {
|
|
239
|
+
let arms =
|
|
240
|
+
s
|
|
241
|
+
->foreignArms
|
|
242
|
+
->Array.filter(e => e->keysOfEvent->Array.includes(k) && !armIsIdentity(e, k, known))
|
|
243
|
+
->Array.map(e => e.eventType)
|
|
244
|
+
arms->Array.length > 0 ? Some((k, arms)) : None
|
|
245
|
+
})
|
|
246
|
+
let seedOf = (s: sliceShape) => {
|
|
247
|
+
let produced = producedKeys(s)
|
|
248
|
+
switch (s.partitionHint, produced) {
|
|
249
|
+
| (Some(h), _) if produced->Array.includes(h) => Some(h)
|
|
250
|
+
| (_, [single]) => Some(single)
|
|
251
|
+
| _ => None
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
let subtracted = (s: sliceShape) => {
|
|
255
|
+
let foreign = foreignConsumedKeys(s)
|
|
256
|
+
producedKeys(s)->Array.filter(k => !(foreign->Array.includes(k)))
|
|
257
|
+
}
|
|
258
|
+
let candidatesOf = (s: sliceShape, known) =>
|
|
259
|
+
switch seedOf(s) {
|
|
260
|
+
| Some(k) => [k]
|
|
261
|
+
| None =>
|
|
262
|
+
switch subtracted(s) {
|
|
263
|
+
| [] =>
|
|
264
|
+
let blocked = s->blockersOf(known)->Array.map(((k, _)) => k)
|
|
265
|
+
s->byChapter(producedKeys(s)->Array.filter(k => !(blocked->Array.includes(k))))
|
|
266
|
+
| remaining => s->byChapter(remaining)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let pass = known => {
|
|
271
|
+
let next = Dict.make()
|
|
272
|
+
slices->Array.forEach(s =>
|
|
273
|
+
switch candidatesOf(s, known) {
|
|
274
|
+
| [k] => next->Dict.set(s.sliceName, k)
|
|
275
|
+
| _ => ()
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
next
|
|
279
|
+
}
|
|
280
|
+
// Each pass can resolve at most one more link of a chain, so a boundary that
|
|
281
|
+
// settles does so within one pass per slice; the extra pass confirms it.
|
|
282
|
+
let rec settle = (known, remaining) => {
|
|
283
|
+
let next = pass(known)
|
|
284
|
+
if sameAssignment(next, known) {
|
|
285
|
+
(next, true)
|
|
286
|
+
} else if remaining == 0 {
|
|
287
|
+
(next, false)
|
|
288
|
+
} else {
|
|
289
|
+
settle(next, remaining - 1)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
let (settled, converged) = settle(Dict.make(), slices->Array.length + 1)
|
|
293
|
+
let previous = pass(settled)
|
|
294
|
+
|
|
295
|
+
let partitionBySlice = Dict.make()
|
|
296
|
+
let candidatesBySlice = Dict.make()
|
|
297
|
+
let blockersBySlice = Dict.make()
|
|
298
|
+
let ambiguities = []
|
|
299
|
+
slices->Array.forEach(s => {
|
|
300
|
+
let candidates = candidatesOf(s, settled)
|
|
301
|
+
candidatesBySlice->Dict.set(s.sliceName, candidates)
|
|
302
|
+
switch candidates {
|
|
303
|
+
| _ if !converged && settled->Dict.get(s.sliceName) != previous->Dict.get(s.sliceName) =>
|
|
304
|
+
ambiguities->Array.push((
|
|
305
|
+
s.sliceName,
|
|
306
|
+
`partition inference did not settle — the slices it reads from keep changing each other's partition. Add an explicit @partitionTag`,
|
|
307
|
+
))
|
|
308
|
+
| [k] => partitionBySlice->Dict.set(s.sliceName, k)
|
|
309
|
+
| [] =>
|
|
310
|
+
// Name the arms that took the key. Almost always a lifecycle arm
|
|
311
|
+
// declaring the id the slice is already partitioned by, where the fix is
|
|
312
|
+
// to drop the field rather than to annotate around it.
|
|
313
|
+
let blockers = s->blockersOf(settled)
|
|
314
|
+
blockersBySlice->Dict.set(s.sliceName, blockers)
|
|
315
|
+
let blame =
|
|
316
|
+
blockers
|
|
317
|
+
->Array.map(((key, arms)) => `${arms->Array.join("/")} declares ${key}`)
|
|
318
|
+
->Array.join("; ")
|
|
319
|
+
ambiguities->Array.push((
|
|
320
|
+
s.sliceName,
|
|
321
|
+
`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`,
|
|
322
|
+
))
|
|
323
|
+
| many =>
|
|
324
|
+
let chapterNote = switch (s.chapter, s->chapterKeysOf) {
|
|
325
|
+
| (Some(chapter), Some(keys)) =>
|
|
326
|
+
` The ${chapter} chapter does not decide: the ids every event in it carries are [${keys->Array.join(
|
|
327
|
+
", ",
|
|
328
|
+
)}].`
|
|
329
|
+
| _ => ""
|
|
330
|
+
}
|
|
331
|
+
ambiguities->Array.push((
|
|
332
|
+
s.sliceName,
|
|
333
|
+
`multiple candidate partition keys (${many->Array.join(
|
|
334
|
+
", ",
|
|
335
|
+
)}) — add an explicit @partitionTag.${chapterNote}`,
|
|
336
|
+
))
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
{partitionBySlice, candidatesBySlice, blockersBySlice, ambiguities}
|
|
340
|
+
}
|
|
341
|
+
|
|
132
342
|
/**
|
|
133
343
|
Which consumed arms cost a slice its partition — the actionable half of the
|
|
134
|
-
"no own partition key" ambiguity.
|
|
344
|
+
"no own partition key" ambiguity, seen from this slice alone.
|
|
135
345
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
whose every event carries `productId`), which reads as "this id comes from a
|
|
141
|
-
foreign producer" when it is in fact this slice's own partition.
|
|
346
|
+
A slice whose produced keys all ride consumed arms from other producers, and
|
|
347
|
+
whose arms cannot be given back, is left with nothing. In practice that is almost
|
|
348
|
+
always one mistake: a *lifecycle* arm naming the id the slice is already
|
|
349
|
+
partitioned by, which reads as "this id comes from a foreign producer".
|
|
142
350
|
|
|
143
351
|
The ambiguity message can only say a partition was not found. This says which arm
|
|
144
|
-
to delete, which is the whole difference between a diagnostic and a puzzle
|
|
145
|
-
is here, beside the rule that produces the ambiguity, rather than duplicated by
|
|
146
|
-
each surface that reports one.
|
|
352
|
+
to delete, which is the whole difference between a diagnostic and a puzzle.
|
|
147
353
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
354
|
+
Alone, no producer is in sight, so every foreign arm is a reference. Returns one
|
|
355
|
+
entry per produced key a foreign arm claims, naming those arms. Empty when the
|
|
356
|
+
slice has a partition or when too many keys are left (answered by `@partitionTag`).
|
|
151
357
|
*/
|
|
152
|
-
let partitionBlockers = (s: sliceShape): array<(string, array<string>)> =>
|
|
153
|
-
|
|
154
|
-
let ownProduced = Set.make()
|
|
155
|
-
s.produced->Array.forEach(e => ownProduced->Set.add(e.eventType))
|
|
156
|
-
producedKeys(s)
|
|
157
|
-
->Array.filter(k => foreign->Array.includes(k))
|
|
158
|
-
->Array.map(k => (
|
|
159
|
-
k,
|
|
160
|
-
s.consumed
|
|
161
|
-
->Array.filter(e => !(ownProduced->Set.has(e.eventType)) && e->keysOfEvent->Array.includes(k))
|
|
162
|
-
->Array.map(e => e.eventType),
|
|
163
|
-
))
|
|
164
|
-
}
|
|
358
|
+
let partitionBlockers = (s: sliceShape): array<(string, array<string>)> =>
|
|
359
|
+
resolvePartitions([s]).blockersBySlice->Dict.get(s.sliceName)->Option.getOr([])
|
|
165
360
|
|
|
166
361
|
/**
|
|
167
362
|
Per-slice cross-partition keys for the test harness, which has no global owner
|
|
@@ -173,58 +368,14 @@ annotation so capacity/escape-hatch reads remain covered.
|
|
|
173
368
|
let crossPartitionForSlice = (s: sliceShape): array<string> => {
|
|
174
369
|
let foreign = foreignConsumedKeys(s)
|
|
175
370
|
let scalar = commandScalarKeys(s)
|
|
176
|
-
let partition =
|
|
177
|
-
| Some(h) if producedKeys(s)->Array.includes(h) => Some(h)
|
|
178
|
-
| _ =>
|
|
179
|
-
switch producedKeys(s)->Array.filter(k => !(foreign->Array.includes(k))) {
|
|
180
|
-
| [single] => Some(single)
|
|
181
|
-
| _ => None
|
|
182
|
-
}
|
|
183
|
-
}
|
|
371
|
+
let partition = resolvePartitions([s]).partitionBySlice->Dict.get(s.sliceName)
|
|
184
372
|
// A foreign read is cross-partition only when the command carries the key as a
|
|
185
373
|
// scalar (must be fanned); an array-only foreign key auto-fans partition-scoped.
|
|
186
374
|
foreign->Array.filter(k => Some(k) != partition && scalar->Array.includes(k))
|
|
187
375
|
}
|
|
188
376
|
|
|
189
377
|
let infer = (slices: array<sliceShape>): derived => {
|
|
190
|
-
|
|
191
|
-
// foreign-consumed key rides a consumed arm the slice does not itself produce.
|
|
192
|
-
// An explicit @partitionTag hint overrides the derivation (escape hatch for
|
|
193
|
-
// slices whose own events legitimately carry two owned keys).
|
|
194
|
-
let partitionBySlice = Dict.make()
|
|
195
|
-
let ambiguities = []
|
|
196
|
-
slices->Array.forEach(s => {
|
|
197
|
-
let produced = producedKeys(s)
|
|
198
|
-
switch s.partitionHint {
|
|
199
|
-
| Some(h) if produced->Array.includes(h) => partitionBySlice->Dict.set(s.sliceName, h)
|
|
200
|
-
| _ =>
|
|
201
|
-
let foreign = foreignConsumedKeys(s)
|
|
202
|
-
switch produced->Array.filter(k => !(foreign->Array.includes(k))) {
|
|
203
|
-
| [single] => partitionBySlice->Dict.set(s.sliceName, single)
|
|
204
|
-
| [] =>
|
|
205
|
-
// Name the arms that took the key. Almost always a lifecycle arm
|
|
206
|
-
// declaring the id the slice is already partitioned by, where the fix is
|
|
207
|
-
// to drop the field rather than to annotate around it.
|
|
208
|
-
let blame =
|
|
209
|
-
partitionBlockers(s)
|
|
210
|
-
->Array.map(((key, arms)) => `${arms->Array.join("/")} declares ${key}`)
|
|
211
|
-
->Array.join("; ")
|
|
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
|
-
| many =>
|
|
218
|
-
let _ =
|
|
219
|
-
ambiguities->Array.push((
|
|
220
|
-
s.sliceName,
|
|
221
|
-
`multiple candidate partition keys (${many->Array.join(
|
|
222
|
-
", ",
|
|
223
|
-
)}) — add an explicit @partitionTag`,
|
|
224
|
-
))
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
})
|
|
378
|
+
let {partitionBySlice, ambiguities} = resolvePartitions(slices)
|
|
228
379
|
|
|
229
380
|
// owner map + the set of keys that are *some* entity's partition.
|
|
230
381
|
let ownerByKey = Dict.make()
|