@reventlessdev/reventless-spec 3.0.0-alpha.135 → 3.0.0-alpha.137

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 CHANGED
@@ -3,6 +3,34 @@
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.137 (2026-09-20)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **spec:** resolve the drift guard's paths from the module, not the cwd ([7f4d8de](https://github.com/ReventlessDev/reventless-core/commit/7f4d8de896f37ce4a855ecc6a980788f01d0e1c7))
11
+ ### Features
12
+
13
+ * **spec:** enumerate the transparent-string semantics, and check the list against the sources ([995c31c](https://github.com/ReventlessDev/reventless-core/commit/995c31c5eb1a9bdb2ad88d39b2b9a8a5348be0da))
14
+
15
+
16
+ # 3.0.0-alpha.136 (2026-09-16)
17
+
18
+ ### Bug Fixes
19
+
20
+ * **aws:** make the demo accounts work on a pool that signs in by email ([97b036f](https://github.com/ReventlessDev/reventless-core/commit/97b036f86979ba55dbbf93d460fe8076c35abf1a))
21
+ * feat(spec)!: infer the DCB partition key per slice, and drop @partitionTag where inference agrees ([6c10161](https://github.com/ReventlessDev/reventless-core/commit/6c101611d438beea4bc9175ae8ea0ea1f6f7493b))
22
+ ### Features
23
+
24
+ * **spec:** let a slice's chapter break a partition-key tie ([2b9ae77](https://github.com/ReventlessDev/reventless-core/commit/2b9ae77e98eb42177d2bdd4977bc62bb7c7911aa))
25
+ * **spec:** report which command yields which event or error ([ac0bdc2](https://github.com/ReventlessDev/reventless-core/commit/ac0bdc23f76a5b71b5bf5c99415ff0937690a279))
26
+
27
+ ### BREAKING CHANGES
28
+
29
+ * DcbTag.derivePartitionTag is removed; a boundary whose
30
+ partition cannot be inferred no longer deploys.
31
+
32
+
33
+
6
34
  # 3.0.0-alpha.135 (2026-09-11)
7
35
 
8
36
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.135",
3
+ "version": "3.0.0-alpha.137",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "sury": "11.0.0-rc.2",
27
27
  "sury-ppx": "11.0.0-rc.2",
28
28
  "yaml": "^2.8.3",
29
- "@reventlessdev/rescript-node": "2.0.0-alpha.11"
29
+ "@reventlessdev/rescript-node": "2.0.0-alpha.12"
30
30
  },
31
31
  "devDependencies": {
32
32
  "rescript": "12.3.0",
@@ -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`. No fixpoint needed.
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
- Rule 1 subtracts `foreignConsumedKeys` from `producedKeys`, so a slice whose only
137
- produced key is also declared on a consumed arm it does not itself produce is left
138
- with nothing. In practice that is almost always one mistake: a *lifecycle* arm
139
- naming the id it is already partitioned by (`ProductAdded({productId})` on a slice
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 — so it
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
- Returns one entry per produced key that a foreign arm claims, naming those arms.
149
- Empty when the slice has a partition (nothing to explain) or when the produced
150
- keys are simply too many (a different ambiguity, answered by `@partitionTag`).
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
- let foreign = foreignConsumedKeys(s)
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 = switch s.partitionHint {
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
- // Rule 1 — partition(S) = producedKeys(S) \ foreignConsumedKeys(S), where a
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()
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
3
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
5
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
5
6
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
@@ -50,79 +51,214 @@ function foreignConsumedKeys(s) {
50
51
  }));
51
52
  }
52
53
 
53
- function partitionBlockers(s) {
54
- let foreign = foreignConsumedKeys(s);
55
- let ownProduced = new Set();
56
- s.produced.forEach(e => {
57
- ownProduced.add(e.eventType);
58
- });
59
- return producedKeys(s).filter(k => foreign.includes(k)).map(k => [
60
- k,
61
- s.consumed.filter(e => {
62
- if (ownProduced.has(e.eventType)) {
63
- return false;
64
- } else {
65
- return e.idFields.map(tagKeyOf).includes(k);
66
- }
67
- }).map(e => e.eventType)
68
- ]);
54
+ function sameAssignment(a, b) {
55
+ let entries = d => Object.entries(d).toSorted((param, param$1) => Primitive_string.compare(param[0], param$1[0]));
56
+ return Primitive_object.equal(entries(a), entries(b));
69
57
  }
70
58
 
71
- function crossPartitionForSlice(s) {
72
- let foreign = foreignConsumedKeys(s);
73
- let scalar = commandScalarKeys(s);
74
- let h = s.partitionHint;
75
- let partition;
76
- let exit = 0;
77
- if (h !== undefined && producedKeys(s).includes(h)) {
78
- partition = h;
79
- } else {
80
- exit = 1;
81
- }
82
- if (exit === 1) {
83
- let match = producedKeys(s).filter(k => !foreign.includes(k));
84
- partition = match.length !== 1 ? undefined : match[0];
85
- }
86
- return foreign.filter(k => {
87
- if (Primitive_object.notequal(k, partition)) {
88
- return scalar.includes(k);
59
+ function resolvePartitions(slices) {
60
+ let producersByEventType = {};
61
+ slices.forEach(s => {
62
+ s.produced.forEach(e => {
63
+ let prev = Stdlib_Option.getOr(producersByEventType[e.eventType], []);
64
+ if (!prev.some(p => p.sliceName === s.sliceName)) {
65
+ producersByEventType[e.eventType] = prev.concat([s]);
66
+ return;
67
+ }
68
+ });
69
+ });
70
+ let chapterKeys = {};
71
+ slices.forEach(s => Stdlib_Option.forEach(s.chapter, chapter => {
72
+ let events = s.produced.filter(e => e.idFields.length !== 0);
73
+ events.forEach(e => {
74
+ let keys = e.idFields.map(tagKeyOf);
75
+ let prev = chapterKeys[chapter];
76
+ let shared = prev !== undefined ? prev.filter(k => keys.includes(k)) : dedupSorted(keys);
77
+ chapterKeys[chapter] = shared;
78
+ });
79
+ }));
80
+ let chapterKeysOf = s => Stdlib_Option.flatMap(s.chapter, c => chapterKeys[c]);
81
+ let byChapter = (s, candidates) => {
82
+ let keys = chapterKeysOf(s);
83
+ if (keys === undefined) {
84
+ return candidates;
85
+ }
86
+ let match = candidates.filter(k => keys.includes(k));
87
+ if (match.length !== 1) {
88
+ return candidates;
89
+ } else {
90
+ return [match[0]];
91
+ }
92
+ };
93
+ let foreignArms = s => {
94
+ let own = new Set();
95
+ s.produced.forEach(e => {
96
+ own.add(e.eventType);
97
+ });
98
+ return s.consumed.filter(e => !own.has(e.eventType));
99
+ };
100
+ let armIsIdentity = (e, k, known) => {
101
+ let producers = producersByEventType[e.eventType];
102
+ if (producers !== undefined && producers.length !== 0) {
103
+ return producers.every(p => {
104
+ let pk = known[p.sliceName];
105
+ if (pk !== undefined) {
106
+ return pk === k;
107
+ } else {
108
+ return producedKeys(p).includes(k);
109
+ }
110
+ });
89
111
  } else {
90
112
  return false;
91
113
  }
114
+ };
115
+ let blockersOf = (s, known) => Stdlib_Array.filterMap(producedKeys(s), k => {
116
+ let arms = foreignArms(s).filter(e => {
117
+ if (e.idFields.map(tagKeyOf).includes(k)) {
118
+ return !armIsIdentity(e, k, known);
119
+ } else {
120
+ return false;
121
+ }
122
+ }).map(e => e.eventType);
123
+ if (arms.length !== 0) {
124
+ return [
125
+ k,
126
+ arms
127
+ ];
128
+ }
92
129
  });
93
- }
94
-
95
- function infer(slices) {
130
+ let seedOf = s => {
131
+ let produced = producedKeys(s);
132
+ let match = s.partitionHint;
133
+ if (match !== undefined && produced.includes(match)) {
134
+ return match;
135
+ }
136
+ if (produced.length !== 1) {
137
+ return;
138
+ } else {
139
+ return produced[0];
140
+ }
141
+ };
142
+ let subtracted = s => {
143
+ let foreign = foreignConsumedKeys(s);
144
+ return producedKeys(s).filter(k => !foreign.includes(k));
145
+ };
146
+ let candidatesOf = (s, known) => {
147
+ let k = seedOf(s);
148
+ if (k !== undefined) {
149
+ return [k];
150
+ }
151
+ let remaining = subtracted(s);
152
+ if (remaining.length !== 0) {
153
+ return byChapter(s, remaining);
154
+ }
155
+ let blocked = blockersOf(s, known).map(param => param[0]);
156
+ return byChapter(s, producedKeys(s).filter(k => !blocked.includes(k)));
157
+ };
158
+ let pass = known => {
159
+ let next = {};
160
+ slices.forEach(s => {
161
+ let match = candidatesOf(s, known);
162
+ if (match.length !== 1) {
163
+ return;
164
+ }
165
+ let k = match[0];
166
+ next[s.sliceName] = k;
167
+ });
168
+ return next;
169
+ };
170
+ let settle = (_known, _remaining) => {
171
+ while (true) {
172
+ let remaining = _remaining;
173
+ let known = _known;
174
+ let next = pass(known);
175
+ if (sameAssignment(next, known)) {
176
+ return [
177
+ next,
178
+ true
179
+ ];
180
+ }
181
+ if (remaining === 0) {
182
+ return [
183
+ next,
184
+ false
185
+ ];
186
+ }
187
+ _remaining = remaining - 1 | 0;
188
+ _known = next;
189
+ continue;
190
+ };
191
+ };
192
+ let match = settle({}, slices.length + 1 | 0);
193
+ let converged = match[1];
194
+ let settled = match[0];
195
+ let previous = pass(settled);
96
196
  let partitionBySlice = {};
197
+ let candidatesBySlice = {};
198
+ let blockersBySlice = {};
97
199
  let ambiguities = [];
98
200
  slices.forEach(s => {
99
- let produced = producedKeys(s);
100
- let h = s.partitionHint;
101
- if (h !== undefined && produced.includes(h)) {
102
- partitionBySlice[s.sliceName] = h;
201
+ let candidates = candidatesOf(s, settled);
202
+ candidatesBySlice[s.sliceName] = candidates;
203
+ if (!converged && Primitive_object.notequal(settled[s.sliceName], previous[s.sliceName])) {
204
+ ambiguities.push([
205
+ s.sliceName,
206
+ `partition inference did not settle — the slices it reads from keep changing each other's partition. Add an explicit @partitionTag`
207
+ ]);
103
208
  return;
104
209
  }
105
- let foreign = foreignConsumedKeys(s);
106
- let many = produced.filter(k => !foreign.includes(k));
107
- let len = many.length;
210
+ let len = candidates.length;
108
211
  if (len !== 1) {
109
212
  if (len !== 0) {
213
+ let match = s.chapter;
214
+ let match$1 = chapterKeysOf(s);
215
+ let chapterNote = match !== undefined && match$1 !== undefined ? ` The ` + match + ` chapter does not decide: the ids every event in it carries are [` + match$1.join(", ") + `].` : "";
110
216
  ambiguities.push([
111
217
  s.sliceName,
112
- `multiple candidate partition keys (` + many.join(", ") + `) — add an explicit @partitionTag`
218
+ `multiple candidate partition keys (` + candidates.join(", ") + `) — add an explicit @partitionTag.` + chapterNote
113
219
  ]);
114
220
  return;
115
221
  }
116
- let blame = partitionBlockers(s).map(param => param[1].join("/") + ` declares ` + param[0]).join("; ");
222
+ let blockers = blockersOf(s, settled);
223
+ blockersBySlice[s.sliceName] = blockers;
224
+ let blame = blockers.map(param => param[1].join("/") + ` declares ` + param[0]).join("; ");
117
225
  ambiguities.push([
118
226
  s.sliceName,
119
227
  `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`
120
228
  ]);
121
229
  return;
122
230
  }
123
- let single = many[0];
124
- partitionBySlice[s.sliceName] = single;
231
+ let k = candidates[0];
232
+ partitionBySlice[s.sliceName] = k;
125
233
  });
234
+ return {
235
+ partitionBySlice: partitionBySlice,
236
+ candidatesBySlice: candidatesBySlice,
237
+ blockersBySlice: blockersBySlice,
238
+ ambiguities: ambiguities
239
+ };
240
+ }
241
+
242
+ function partitionBlockers(s) {
243
+ return Stdlib_Option.getOr(resolvePartitions([s]).blockersBySlice[s.sliceName], []);
244
+ }
245
+
246
+ function crossPartitionForSlice(s) {
247
+ let foreign = foreignConsumedKeys(s);
248
+ let scalar = commandScalarKeys(s);
249
+ let partition = resolvePartitions([s]).partitionBySlice[s.sliceName];
250
+ return foreign.filter(k => {
251
+ if (Primitive_object.notequal(k, partition)) {
252
+ return scalar.includes(k);
253
+ } else {
254
+ return false;
255
+ }
256
+ });
257
+ }
258
+
259
+ function infer(slices) {
260
+ let match = resolvePartitions(slices);
261
+ let partitionBySlice = match.partitionBySlice;
126
262
  let ownerByKey = {};
127
263
  let ownedPartitionKeys = new Set();
128
264
  slices.forEach(s => {
@@ -178,7 +314,7 @@ function infer(slices) {
178
314
  ownerByKey: ownerByKey,
179
315
  crossPartitionTagKeys: crossPartitionTagKeys,
180
316
  tagKeysByEventType: tagKeysByEventType,
181
- ambiguities: ambiguities
317
+ ambiguities: match.ambiguities
182
318
  };
183
319
  }
184
320
 
@@ -190,6 +326,8 @@ export {
190
326
  consumedKeys,
191
327
  commandScalarKeys,
192
328
  foreignConsumedKeys,
329
+ sameAssignment,
330
+ resolvePartitions,
193
331
  partitionBlockers,
194
332
  crossPartitionForSlice,
195
333
  infer,