@loomwork/event-runtime 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +95 -2
  4. package/dist/BranchRegistry.d.ts +140 -0
  5. package/dist/BranchRegistry.d.ts.map +1 -0
  6. package/dist/BranchRegistry.js +66 -0
  7. package/dist/BranchRegistry.js.map +1 -0
  8. package/dist/CoalescingEventStore.d.ts +97 -0
  9. package/dist/CoalescingEventStore.d.ts.map +1 -0
  10. package/dist/CoalescingEventStore.js +180 -0
  11. package/dist/CoalescingEventStore.js.map +1 -0
  12. package/dist/InMemoryArchiveStore.d.ts +19 -0
  13. package/dist/InMemoryArchiveStore.d.ts.map +1 -0
  14. package/dist/InMemoryArchiveStore.js +49 -0
  15. package/dist/InMemoryArchiveStore.js.map +1 -0
  16. package/dist/InMemoryAsyncEventStore.d.ts +111 -0
  17. package/dist/InMemoryAsyncEventStore.d.ts.map +1 -0
  18. package/dist/InMemoryAsyncEventStore.js +374 -0
  19. package/dist/InMemoryAsyncEventStore.js.map +1 -0
  20. package/dist/InMemoryBranchRegistry.d.ts +31 -0
  21. package/dist/InMemoryBranchRegistry.d.ts.map +1 -0
  22. package/dist/InMemoryBranchRegistry.js +167 -0
  23. package/dist/InMemoryBranchRegistry.js.map +1 -0
  24. package/dist/ProfilingEventStore.d.ts +75 -0
  25. package/dist/ProfilingEventStore.d.ts.map +1 -0
  26. package/dist/ProfilingEventStore.js +133 -0
  27. package/dist/ProfilingEventStore.js.map +1 -0
  28. package/dist/ShardPlan.d.ts +87 -0
  29. package/dist/ShardPlan.d.ts.map +1 -0
  30. package/dist/ShardPlan.js +193 -0
  31. package/dist/ShardPlan.js.map +1 -0
  32. package/dist/ShardedEventStore.d.ts +149 -0
  33. package/dist/ShardedEventStore.d.ts.map +1 -0
  34. package/dist/ShardedEventStore.js +468 -0
  35. package/dist/ShardedEventStore.js.map +1 -0
  36. package/dist/SubscriberRegistry.d.ts +59 -0
  37. package/dist/SubscriberRegistry.d.ts.map +1 -0
  38. package/dist/SubscriberRegistry.js +113 -0
  39. package/dist/SubscriberRegistry.js.map +1 -0
  40. package/dist/TenantScopedArchiveStore.d.ts +36 -0
  41. package/dist/TenantScopedArchiveStore.d.ts.map +1 -0
  42. package/dist/TenantScopedArchiveStore.js +96 -0
  43. package/dist/TenantScopedArchiveStore.js.map +1 -0
  44. package/dist/TenantScopedEventStore.d.ts +59 -0
  45. package/dist/TenantScopedEventStore.d.ts.map +1 -0
  46. package/dist/TenantScopedEventStore.js +227 -0
  47. package/dist/TenantScopedEventStore.js.map +1 -0
  48. package/dist/archive.d.ts +68 -0
  49. package/dist/archive.d.ts.map +1 -0
  50. package/dist/archive.js +207 -0
  51. package/dist/archive.js.map +1 -0
  52. package/dist/archiveSpi.d.ts +165 -0
  53. package/dist/archiveSpi.d.ts.map +1 -0
  54. package/dist/archiveSpi.js +184 -0
  55. package/dist/archiveSpi.js.map +1 -0
  56. package/dist/creationMarker.d.ts +13 -0
  57. package/dist/creationMarker.d.ts.map +1 -0
  58. package/dist/creationMarker.js +17 -0
  59. package/dist/creationMarker.js.map +1 -0
  60. package/dist/decoratorPlan.typecheck.d.ts +2 -0
  61. package/dist/decoratorPlan.typecheck.d.ts.map +1 -0
  62. package/dist/decoratorPlan.typecheck.js +41 -0
  63. package/dist/decoratorPlan.typecheck.js.map +1 -0
  64. package/dist/eventKindClass.d.ts +40 -0
  65. package/dist/eventKindClass.d.ts.map +1 -0
  66. package/dist/eventKindClass.js +99 -0
  67. package/dist/eventKindClass.js.map +1 -0
  68. package/dist/executionIdOrder.d.ts +21 -0
  69. package/dist/executionIdOrder.d.ts.map +1 -0
  70. package/dist/executionIdOrder.js +43 -0
  71. package/dist/executionIdOrder.js.map +1 -0
  72. package/dist/fencing.d.ts +21 -0
  73. package/dist/fencing.d.ts.map +1 -0
  74. package/dist/fencing.js +36 -0
  75. package/dist/fencing.js.map +1 -0
  76. package/dist/index.d.ts +39 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +126 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/installOptional.d.ts +52 -0
  81. package/dist/installOptional.d.ts.map +1 -0
  82. package/dist/installOptional.js +112 -0
  83. package/dist/installOptional.js.map +1 -0
  84. package/dist/keyUnavailable.d.ts +67 -0
  85. package/dist/keyUnavailable.d.ts.map +1 -0
  86. package/dist/keyUnavailable.js +87 -0
  87. package/dist/keyUnavailable.js.map +1 -0
  88. package/dist/leases.d.ts +177 -0
  89. package/dist/leases.d.ts.map +1 -0
  90. package/dist/leases.js +142 -0
  91. package/dist/leases.js.map +1 -0
  92. package/dist/payloadShredded.d.ts +30 -0
  93. package/dist/payloadShredded.d.ts.map +1 -0
  94. package/dist/payloadShredded.js +67 -0
  95. package/dist/payloadShredded.js.map +1 -0
  96. package/dist/scaleDecorators.bench.d.ts +2 -0
  97. package/dist/scaleDecorators.bench.d.ts.map +1 -0
  98. package/dist/scaleDecorators.bench.js +93 -0
  99. package/dist/scaleDecorators.bench.js.map +1 -0
  100. package/dist/sequenceRange.bench.d.ts +2 -0
  101. package/dist/sequenceRange.bench.d.ts.map +1 -0
  102. package/dist/sequenceRange.bench.js +46 -0
  103. package/dist/sequenceRange.bench.js.map +1 -0
  104. package/dist/sequenceRange.d.ts +10 -0
  105. package/dist/sequenceRange.d.ts.map +1 -0
  106. package/dist/sequenceRange.js +35 -0
  107. package/dist/sequenceRange.js.map +1 -0
  108. package/dist/timers.d.ts +87 -0
  109. package/dist/timers.d.ts.map +1 -0
  110. package/dist/timers.js +68 -0
  111. package/dist/timers.js.map +1 -0
  112. package/dist/types.d.ts +634 -0
  113. package/dist/types.d.ts.map +1 -0
  114. package/dist/types.js +429 -0
  115. package/dist/types.js.map +1 -0
  116. package/dist/writeConflicts.d.ts +46 -0
  117. package/dist/writeConflicts.d.ts.map +1 -0
  118. package/dist/writeConflicts.js +63 -0
  119. package/dist/writeConflicts.js.map +1 -0
  120. package/memory.d.ts +4 -0
  121. package/memory.js +13 -0
  122. package/package.json +67 -3
  123. package/sequence.d.ts +1 -0
  124. package/sequence.js +5 -0
  125. package/src/BranchRegistry.ts +161 -0
  126. package/src/CoalescingEventStore.ts +214 -0
  127. package/src/InMemoryArchiveStore.ts +69 -0
  128. package/src/InMemoryAsyncEventStore.ts +425 -0
  129. package/src/InMemoryBranchRegistry.ts +178 -0
  130. package/src/ProfilingEventStore.ts +153 -0
  131. package/src/ShardPlan.ts +235 -0
  132. package/src/ShardedEventStore.ts +557 -0
  133. package/src/SubscriberRegistry.ts +115 -0
  134. package/src/TenantScopedArchiveStore.ts +114 -0
  135. package/src/TenantScopedEventStore.ts +266 -0
  136. package/src/archive.ts +248 -0
  137. package/src/archiveSpi.ts +308 -0
  138. package/src/creationMarker.ts +15 -0
  139. package/src/decoratorPlan.typecheck.ts +52 -0
  140. package/src/eventKindClass.ts +127 -0
  141. package/src/executionIdOrder.ts +38 -0
  142. package/src/fencing.ts +31 -0
  143. package/src/index.ts +151 -0
  144. package/src/installOptional.ts +136 -0
  145. package/src/keyUnavailable.ts +98 -0
  146. package/src/leases.ts +319 -0
  147. package/src/payloadShredded.ts +63 -0
  148. package/src/scaleDecorators.bench.ts +107 -0
  149. package/src/sequenceRange.bench.ts +51 -0
  150. package/src/sequenceRange.ts +35 -0
  151. package/src/timers.ts +150 -0
  152. package/src/types.ts +857 -0
  153. package/src/writeConflicts.ts +56 -0
@@ -0,0 +1,153 @@
1
+ import type { EventEnvelope } from "@loomwork/core";
2
+ import { type DecoratorPlan, installOptional } from "./installOptional";
3
+ import type { EventStore, OptionalEventStoreMethod } from "./types";
4
+
5
+ /** Store-side timings, in milliseconds unless stated otherwise. */
6
+ export interface WriteProfile {
7
+ /** Envelopes durably appended (counting each envelope of a batch). */
8
+ envelopes: number;
9
+ /** Calls to `append` plus calls to `appendBatch` — i.e. round-trips. */
10
+ roundTrips: number;
11
+ /** Summed wall time inside the store's write calls. */
12
+ totalWriteMs: number;
13
+ /** Longest single write call. */
14
+ maxWriteMs: number;
15
+ /** Greatest number of write calls in flight at once — the queue the log is absorbing. */
16
+ peakConcurrentWrites: number;
17
+ /** Wall time from the first write starting to the last one finishing. */
18
+ spanMs: number;
19
+ /** Envelopes per second over `spanMs`. 0 before anything is written. */
20
+ envelopesPerSec: number;
21
+ }
22
+
23
+ /**
24
+ * Measures what the durable write path actually costs, so a workload can be *profiled*
25
+ * rather than reasoned about.
26
+ *
27
+ * This exists for [ADR 0065](../../../docs/adr/0065-group-commit-deferred-evidence-gate.md)
28
+ * gate 1, which asks for a real workload where **event-write throughput — not effect
29
+ * latency, not fold cost — is the measured bottleneck**, and is explicit that a profile is
30
+ * required rather than intuition. The gate is deliberately hard to satisfy: `ExecutionJournal`
31
+ * already decouples the interpreter from write latency by queueing appends on a serial
32
+ * tail, so a workload can write a great deal without writes ever being the constraint.
33
+ *
34
+ * Point this at your own application, alongside `effectTimingMiddleware` from
35
+ * `@loomwork/middleware`, and compare: if effect time dwarfs write time, batching cannot help
36
+ * you, whatever the raw event rate looks like.
37
+ *
38
+ * Zero-cost when unwired — it is a decorator, so a store that is not wrapped is unaffected.
39
+ */
40
+ export class ProfilingEventStore implements EventStore {
41
+ readonly head?: NonNullable<EventStore["head"]>;
42
+ readonly readRange?: NonNullable<EventStore["readRange"]>;
43
+ readonly acquireFence?: NonNullable<EventStore["acquireFence"]>;
44
+ readonly acquireFenceIf?: NonNullable<EventStore["acquireFenceIf"]>;
45
+ readonly appendIfUnchanged?: NonNullable<EventStore["appendIfUnchanged"]>;
46
+ readonly storeNow?: NonNullable<EventStore["storeNow"]>;
47
+ readonly sealingCapability?: NonNullable<EventStore["sealingCapability"]>;
48
+ readonly readAtRest?: NonNullable<EventStore["readAtRest"]>;
49
+ readonly copyPrefix?: NonNullable<EventStore["copyPrefix"]>;
50
+ readonly deleteExecution?: NonNullable<EventStore["deleteExecution"]>;
51
+ readonly deleteThrough?: NonNullable<EventStore["deleteThrough"]>;
52
+ readonly truncatePrefix?: NonNullable<EventStore["truncatePrefix"]>;
53
+ readonly truncatePrefixKeeping?: NonNullable<EventStore["truncatePrefixKeeping"]>;
54
+ readonly listExecutions?: NonNullable<EventStore["listExecutions"]>;
55
+ readonly listExecutionsPage?: NonNullable<EventStore["listExecutionsPage"]>;
56
+ readonly appendBatch?: NonNullable<EventStore["appendBatch"]>;
57
+
58
+ private envelopes = 0;
59
+ private roundTrips = 0;
60
+ private totalWriteMs = 0;
61
+ private maxWriteMs = 0;
62
+ private inFlight = 0;
63
+ private peakConcurrentWrites = 0;
64
+ private firstStart: number | undefined;
65
+ private lastEnd = 0;
66
+
67
+ constructor(private readonly inner: EventStore) {
68
+ // Only advertise what the inner store has (installOptional's rule), so wrapping never
69
+ // changes which path a caller takes. Batches are timed like single appends.
70
+ const plan = {
71
+ appendBatch: (envelopes) => this.timed(envelopes.length, () => inner.appendBatch!(envelopes)),
72
+ head: "forward",
73
+ readRange: "forward",
74
+ acquireFence: "forward",
75
+ acquireFenceIf: "forward",
76
+ appendIfUnchanged: "forward",
77
+ storeNow: "forward",
78
+ sealingCapability: "forward",
79
+ readAtRest: "forward",
80
+ copyPrefix: "forward",
81
+ deleteExecution: "forward",
82
+ deleteThrough: "forward",
83
+ truncatePrefix: "forward",
84
+ truncatePrefixKeeping: "forward",
85
+ listExecutions: "forward",
86
+ listExecutionsPage: "forward",
87
+ } satisfies DecoratorPlan<EventStore, OptionalEventStoreMethod>;
88
+ installOptional<EventStore, OptionalEventStoreMethod>(this, inner, plan);
89
+ }
90
+
91
+ private async timed(envelopes: number, run: () => Promise<void>): Promise<void> {
92
+ const started = performance.now();
93
+ if (this.firstStart === undefined) this.firstStart = started;
94
+ this.inFlight++;
95
+ if (this.inFlight > this.peakConcurrentWrites) this.peakConcurrentWrites = this.inFlight;
96
+ try {
97
+ await run();
98
+ } finally {
99
+ const elapsed = performance.now() - started;
100
+ this.inFlight--;
101
+ this.roundTrips++;
102
+ this.envelopes += envelopes;
103
+ this.totalWriteMs += elapsed;
104
+ if (elapsed > this.maxWriteMs) this.maxWriteMs = elapsed;
105
+ this.lastEnd = performance.now();
106
+ }
107
+ }
108
+
109
+ append(envelope: EventEnvelope): Promise<void> {
110
+ return this.timed(1, () => this.inner.append(envelope));
111
+ }
112
+
113
+ /** Snapshot of the write profile so far. */
114
+ writeProfile(): WriteProfile {
115
+ const spanMs = this.firstStart === undefined ? 0 : this.lastEnd - this.firstStart;
116
+ return {
117
+ envelopes: this.envelopes,
118
+ roundTrips: this.roundTrips,
119
+ totalWriteMs: this.totalWriteMs,
120
+ maxWriteMs: this.maxWriteMs,
121
+ peakConcurrentWrites: this.peakConcurrentWrites,
122
+ spanMs,
123
+ envelopesPerSec: spanMs > 0 ? (this.envelopes / spanMs) * 1000 : 0,
124
+ };
125
+ }
126
+
127
+ /** Discard measurements (e.g. after a warmup phase). */
128
+ resetProfile(): void {
129
+ this.envelopes = 0;
130
+ this.roundTrips = 0;
131
+ this.totalWriteMs = 0;
132
+ this.maxWriteMs = 0;
133
+ this.peakConcurrentWrites = 0;
134
+ this.firstStart = undefined;
135
+ this.lastEnd = 0;
136
+ }
137
+
138
+ read(executionId: string): Promise<EventEnvelope[]> {
139
+ return this.inner.read(executionId);
140
+ }
141
+
142
+ readFrom(executionId: string, fromSequence: number): Promise<EventEnvelope[]> {
143
+ return this.inner.readFrom(executionId, fromSequence);
144
+ }
145
+
146
+ subscribe(executionId: string, handler: (event: EventEnvelope) => void): Promise<() => void> {
147
+ return this.inner.subscribe(executionId, handler);
148
+ }
149
+
150
+ close(): Promise<void> {
151
+ return this.inner.close();
152
+ }
153
+ }
@@ -0,0 +1,235 @@
1
+ import { bucketOf } from "./SubscriberRegistry";
2
+
3
+ /**
4
+ * Where a bucket's executions live.
5
+ *
6
+ * `migrating` is the whole point of the plan: a bucket can be *in the process of* moving
7
+ * without anything being blocked or copied, because the store resolves per execution which
8
+ * side actually holds it.
9
+ */
10
+ export type ShardPlacement =
11
+ | { readonly kind: "settled"; readonly shard: number }
12
+ | { readonly kind: "migrating"; readonly from: number; readonly to: number };
13
+
14
+ /** Serialized form. Every node sharing a log MUST load the same plan. */
15
+ export interface ShardPlanJSON {
16
+ readonly version: 1;
17
+ readonly shardCount: number;
18
+ readonly placements: ReadonlyArray<{ shard: number } | { from: number; to: number }>;
19
+ }
20
+
21
+ /** Default virtual-bucket count. Power of two, comfortably above any realistic shard count. */
22
+ export const DEFAULT_BUCKETS = 4096;
23
+
24
+ /**
25
+ * An explicit, persistable map from virtual bucket to shard — the thing that makes a
26
+ * sharded log **rebalanceable** (ADR 0075).
27
+ *
28
+ * Without it, routing is `hash(executionId) % shardCount`, and changing the shard count
29
+ * re-routes almost every execution at once: reads land on a shard that has never seen the
30
+ * data. That is not a rebalance, it is data loss with extra steps.
31
+ *
32
+ * Two things fix that, and this class is both:
33
+ *
34
+ * 1. **Virtual buckets.** Executions hash to one of `buckets` (default 4096), and buckets
35
+ * are assigned to shards by an explicit table. Growing from N to N+1 shards moves
36
+ * roughly `1/(N+1)` of the buckets instead of nearly all of them.
37
+ * 2. **A `migrating` state per bucket.** A bucket being moved names both sides, so the
38
+ * store can keep serving reads and writes for executions that still live on the source
39
+ * while new ones start on the destination.
40
+ *
41
+ * The plan is **immutable** — every mutation returns a new plan. It is a value that
42
+ * multiple nodes must agree on, so it wants to be compared, persisted, and shipped around,
43
+ * not mutated in place.
44
+ */
45
+ export class ShardPlan {
46
+ private constructor(
47
+ readonly shardCount: number,
48
+ private readonly placements: readonly ShardPlacement[]
49
+ ) {}
50
+
51
+ /** Buckets spread round-robin over `shardCount` shards, all settled. */
52
+ static uniform(shardCount: number, buckets: number = DEFAULT_BUCKETS): ShardPlan {
53
+ if (shardCount < 1) throw new Error("ShardPlan requires at least one shard");
54
+ if (buckets < shardCount) {
55
+ throw new Error(
56
+ `ShardPlan needs at least as many buckets as shards (buckets=${buckets}, shards=${shardCount})`
57
+ );
58
+ }
59
+ return new ShardPlan(
60
+ shardCount,
61
+ Array.from({ length: buckets }, (_, b) => ({ kind: "settled", shard: b % shardCount }))
62
+ );
63
+ }
64
+
65
+ static fromJSON(json: ShardPlanJSON): ShardPlan {
66
+ if (json.version !== 1) throw new Error(`unsupported ShardPlan version ${json.version}`);
67
+ const placements = json.placements.map(
68
+ (p): ShardPlacement =>
69
+ "shard" in p
70
+ ? { kind: "settled", shard: p.shard }
71
+ : { kind: "migrating", from: p.from, to: p.to }
72
+ );
73
+ const plan = new ShardPlan(json.shardCount, placements);
74
+ plan.assertValid();
75
+ return plan;
76
+ }
77
+
78
+ toJSON(): ShardPlanJSON {
79
+ return {
80
+ version: 1,
81
+ shardCount: this.shardCount,
82
+ placements: this.placements.map((p) =>
83
+ p.kind === "settled" ? { shard: p.shard } : { from: p.from, to: p.to }
84
+ ),
85
+ };
86
+ }
87
+
88
+ get buckets(): number {
89
+ return this.placements.length;
90
+ }
91
+
92
+ /** No bucket is mid-move. */
93
+ get isStable(): boolean {
94
+ return this.placements.every((p) => p.kind === "settled");
95
+ }
96
+
97
+ bucketFor(executionId: string): number {
98
+ return bucketOf(executionId, this.placements.length);
99
+ }
100
+
101
+ placementAt(bucket: number): ShardPlacement {
102
+ const placement = this.placements[bucket];
103
+ if (placement === undefined) throw new Error(`bucket ${bucket} is out of range`);
104
+ return placement;
105
+ }
106
+
107
+ placementFor(executionId: string): ShardPlacement {
108
+ return this.placementAt(this.bucketFor(executionId));
109
+ }
110
+
111
+ /** Buckets currently mid-move, in ascending order. */
112
+ migratingBuckets(): number[] {
113
+ const out: number[] = [];
114
+ for (let b = 0; b < this.placements.length; b++) {
115
+ if ((this.placements[b] as ShardPlacement).kind === "migrating") out.push(b);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Re-spread buckets over `shardCount` shards, marking every bucket whose owner changes as
122
+ * `migrating(old → new)`. Nothing is copied — the returned plan is only a routing
123
+ * decision. Executions already on the source keep being served there; new ones start on
124
+ * the destination. See {@link ShardedEventStore.migrateExecution} to actually move data,
125
+ * and {@link settled} to finish a bucket.
126
+ *
127
+ * Refuses to re-plan while a migration is outstanding: layering a second move over an
128
+ * unfinished one would produce a bucket whose executions are spread over three shards,
129
+ * which nothing here can resolve. Settle first.
130
+ */
131
+ rebalanced(shardCount: number, options: { allowShrink?: boolean } = {}): ShardPlan {
132
+ if (shardCount < 1) throw new Error("ShardPlan requires at least one shard");
133
+ if (!this.isStable) {
134
+ throw new Error(
135
+ `cannot rebalance while ${this.migratingBuckets().length} bucket(s) are still migrating — settle them first`
136
+ );
137
+ }
138
+ if (shardCount < this.shardCount && options.allowShrink !== true) {
139
+ throw new Error(
140
+ `rebalancing ${this.shardCount} → ${shardCount} shards removes capacity; pass { allowShrink: true } if that is intended`
141
+ );
142
+ }
143
+ if (this.placements.length < shardCount) {
144
+ throw new Error(
145
+ `ShardPlan needs at least as many buckets as shards (buckets=${this.placements.length}, shards=${shardCount})`
146
+ );
147
+ }
148
+
149
+ // MINIMAL MOVEMENT. The naive assignment — `bucket % shardCount` — throws away the
150
+ // existing layout and rehashes everything: measured, growing 4→5 shards moves 80% of
151
+ // buckets and 8→9 moves 89%, which is barely better than the `hash % shardCount`
152
+ // routing virtual buckets exist to escape.
153
+ //
154
+ // Instead: keep every bucket where it is, and move only the EXCESS from shards that now
155
+ // hold more than their fair share onto shards that hold less. That moves ~1/newCount of
156
+ // buckets, which is the theoretical minimum for a uniform target.
157
+ const total = this.placements.length;
158
+ const base = Math.floor(total / shardCount);
159
+ const remainder = total % shardCount;
160
+ // The first `remainder` shards carry one extra bucket, so the targets sum to `total`.
161
+ const targetFor = (shard: number): number => base + (shard < remainder ? 1 : 0);
162
+
163
+ const heldBy = new Map<number, number[]>();
164
+ for (const [bucket, placement] of this.placements.entries()) {
165
+ const shard = (placement as { shard: number }).shard;
166
+ const list = heldBy.get(shard);
167
+ if (list === undefined) heldBy.set(shard, [bucket]);
168
+ else list.push(bucket);
169
+ }
170
+
171
+ const owner = this.placements.map((p) => (p as { shard: number }).shard);
172
+ const orphans: number[] = [];
173
+ const kept = new Map<number, number>();
174
+ for (const [shard, list] of heldBy) {
175
+ if (shard >= shardCount) {
176
+ // The shard is going away entirely (a shrink); everything it holds must move.
177
+ orphans.push(...list);
178
+ continue;
179
+ }
180
+ const target = targetFor(shard);
181
+ kept.set(shard, Math.min(list.length, target));
182
+ if (list.length > target) orphans.push(...list.slice(target));
183
+ }
184
+
185
+ // Vacancies, in shard order, so the assignment is deterministic across nodes.
186
+ const vacancies: number[] = [];
187
+ for (let shard = 0; shard < shardCount; shard++) {
188
+ const have = kept.get(shard) ?? 0;
189
+ for (let i = have; i < targetFor(shard); i++) vacancies.push(shard);
190
+ }
191
+ for (const [i, bucket] of orphans.entries()) {
192
+ owner[bucket] = vacancies[i] as number;
193
+ }
194
+
195
+ const next = this.placements.map((current, bucket): ShardPlacement => {
196
+ const from = (current as { shard: number }).shard;
197
+ const to = owner[bucket] as number;
198
+ return from === to ? { kind: "settled", shard: to } : { kind: "migrating", from, to };
199
+ });
200
+ return new ShardPlan(shardCount, next);
201
+ }
202
+
203
+ /** Mark a migrating bucket as finished, owned by its destination. */
204
+ settled(bucket: number): ShardPlan {
205
+ const placement = this.placementAt(bucket);
206
+ if (placement.kind === "settled") return this;
207
+ const next = [...this.placements];
208
+ next[bucket] = { kind: "settled", shard: placement.to };
209
+ return new ShardPlan(this.shardCount, next);
210
+ }
211
+
212
+ /** Mark every migrating bucket settled. Only safe once their data has actually moved. */
213
+ settledAll(): ShardPlan {
214
+ if (this.isStable) return this;
215
+ return new ShardPlan(
216
+ this.shardCount,
217
+ this.placements.map((p) => (p.kind === "settled" ? p : { kind: "settled", shard: p.to }))
218
+ );
219
+ }
220
+
221
+ /** Every shard index the plan can route to — used to validate against a store's shards. */
222
+ private assertValid(): void {
223
+ for (const [bucket, placement] of this.placements.entries()) {
224
+ const refs =
225
+ placement.kind === "settled" ? [placement.shard] : [placement.from, placement.to];
226
+ for (const shard of refs) {
227
+ if (!Number.isInteger(shard) || shard < 0 || shard >= this.shardCount) {
228
+ throw new Error(
229
+ `ShardPlan bucket ${bucket} references shard ${shard}, outside 0..${this.shardCount - 1}`
230
+ );
231
+ }
232
+ }
233
+ }
234
+ }
235
+ }