@loomwork/event-runtime 0.0.0-stage → 0.1.0

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,557 @@
1
+ import type { EventEnvelope } from "@loomwork/core";
2
+ import type { ShardPlan } from "./ShardPlan";
3
+ import { bucketOf } from "./SubscriberRegistry";
4
+ import { compareExecutionIds } from "./executionIdOrder";
5
+ import { type DecoratorPlan, installOptional } from "./installOptional";
6
+ import {
7
+ type AtRestEventReader,
8
+ type EventStore,
9
+ type ExecutionPage,
10
+ OPTIONAL_EVENT_STORE_METHODS,
11
+ type OptionalEventStoreMethod,
12
+ type SealingCapability,
13
+ appendBatchOf,
14
+ headOf,
15
+ } from "./types";
16
+
17
+ export interface ShardedEventStoreOptions {
18
+ /**
19
+ * Which shard owns an execution, when no {@link ShardedEventStoreOptions.plan} is given.
20
+ * Must be STABLE across processes and restarts — two nodes disagreeing routes the same
21
+ * execution to two logs. Default: FNV-1a over the id.
22
+ */
23
+ shardOf?: (executionId: string, shards: number) => number;
24
+ /**
25
+ * An explicit bucket→shard map (ADR 0075). **Required to rebalance**: without it, routing
26
+ * is `hash(id) % shardCount` and changing the shard count silently re-routes almost every
27
+ * execution to a shard that has never seen its data.
28
+ *
29
+ * Omitting it keeps the pre-0075 routing exactly, so existing deployments are unaffected
30
+ * — but they cannot change shard count without moving everything. Start with a plan if
31
+ * you expect to grow.
32
+ */
33
+ plan?: ShardPlan;
34
+ /**
35
+ * How many execution→shard resolutions to remember while buckets are migrating. Bounded
36
+ * so a long-lived store cannot grow without limit. Default 10,000.
37
+ */
38
+ placementCacheSize?: number;
39
+ }
40
+
41
+ /** Result of moving one execution between shards. */
42
+ export interface MigrationResult {
43
+ executionId: string;
44
+ /** `moved` — copied then removed from the source. `already-there` — nothing to do. */
45
+ outcome: "moved" | "already-there" | "not-migrating";
46
+ events: number;
47
+ }
48
+
49
+ /**
50
+ * Routes each execution to one of N inner stores (ADR 0074 D3), with support for moving
51
+ * buckets between shards without downtime (ADR 0075).
52
+ *
53
+ * This is the item that needs **zero backend cooperation**: it takes `EventStore[]` and
54
+ * never asks what it is wrapping, so a bring-your-own backend gets the horizontal-scale
55
+ * story free, and the shards may be heterogeneous.
56
+ *
57
+ * ## Routing
58
+ *
59
+ * With a {@link ShardPlan}, executions hash to a virtual bucket and buckets map to shards
60
+ * by an explicit table. Without one, routing is `hash(id) % shardCount` — identical to
61
+ * pre-0075 behaviour, and **not rebalanceable**.
62
+ *
63
+ * ## Rebalancing
64
+ *
65
+ * `plan.rebalanced(n)` marks every bucket whose owner changes as `migrating(from → to)`.
66
+ * That is a routing change only; **no data moves**. For a migrating bucket the store
67
+ * resolves per execution which side actually holds it (one bounded point-read, then
68
+ * cached), so:
69
+ *
70
+ * - executions already on the source keep being read and written there — nothing breaks;
71
+ * - executions that do not exist yet start on the destination.
72
+ *
73
+ * A bucket therefore **drains on its own** as executions complete and retention reclaims
74
+ * them: no copying, no downtime, no coordinator. When you want convergence sooner, or a
75
+ * source shard must be decommissioned, {@link migrateExecution} moves one explicitly. Once
76
+ * {@link isBucketDrained} reports true, `plan.settled(bucket)` retires the extra lookup.
77
+ *
78
+ * ## What this does NOT do
79
+ *
80
+ * - **It does not move a live execution safely.** `migrateExecution` copies then deletes;
81
+ * an append arriving mid-move lands on the source and is dropped by the delete. Move
82
+ * terminal executions, or ones you know are not dispatched. The lazy drain above exists
83
+ * precisely so you rarely need to.
84
+ * - **Fence state does not travel.** The SPI exposes `acquireFence` but no way to read or
85
+ * write a fence, so a migrated execution arrives unfenced on its destination and a zombie
86
+ * writer holding an old token could write there. Fence-sensitive deployments should
87
+ * migrate only executions that are terminal *and* undispatched.
88
+ *
89
+ * Invariants relied on throughout: an execution lives entirely on one shard at any moment,
90
+ * and a stream that exists has a head — which is what makes "which side holds it?" a single
91
+ * indexed point-read (the HEAD, not sequence 0: an inbox topic stream or a floor-seeded stream has
92
+ * no sequence 0).
93
+ */
94
+ /**
95
+ * The optional-capability surface EVERY shard shares: an object carrying each optional
96
+ * `EventStore` method that all shards implement (bound to the first shard, so it is a real
97
+ * method, never a stub) and nothing else. Used as the `inner` of `installOptional`, whose
98
+ * rule is "install a method iff the inner store has it" — for a sharded store, "has it" means
99
+ * every shard has it. O(shards × optional methods), once, at construction.
100
+ */
101
+ function commonOptional(shards: readonly EventStore[]): EventStore {
102
+ const surface: Partial<Record<OptionalEventStoreMethod, unknown>> = {};
103
+ const first = shards[0] as EventStore;
104
+ for (const method of OPTIONAL_EVENT_STORE_METHODS) {
105
+ if (shards.every((s) => typeof s[method] === "function")) {
106
+ surface[method] = (first[method] as (...a: unknown[]) => unknown).bind(first);
107
+ }
108
+ }
109
+ // ADR 0130 D7/D8 exception: `sealingCapability` is offered when ANY shard has it, because a shard
110
+ // lacking the method seals nothing and must be judged (commonSealing), not silently hide a sealing
111
+ // shard by making the whole store look like it does not seal.
112
+ if (shards.some((s) => typeof s.sealingCapability === "function")) {
113
+ surface.sealingCapability = () => undefined;
114
+ }
115
+ return surface as unknown as EventStore;
116
+ }
117
+
118
+ /**
119
+ * ADR 0129 — an incarnation counter is per shard, so the same number can be issued by two shards
120
+ * (a migrating bucket re-creates an id on another shard). The anchor this store hands out is
121
+ * therefore `s<shardIndex>/<inner anchor>`: it starts with a letter, so it can collide neither
122
+ * with a bare incarnation (digits) nor with the legacy `<timestamp>:<id>` marker (digits first),
123
+ * and an observation taken on one shard never equals the anchor of another.
124
+ */
125
+ /**
126
+ * ADR 0130 residuals D7/D8: the shards of one store disagree about sealing (some seal and some do
127
+ * not, or they seal different paths or with different ciphers). Misconfiguration, raised live on
128
+ * every `sealingCapability()` call and never papered over, because one answer serves two questions
129
+ * that must not diverge: "what can this node open" (D7) and "does this store seal at all" (D8, a
130
+ * non-empty `covers`). Carries each shard's cipher and covers, never key material.
131
+ */
132
+ export class ShardSealingMismatchError extends Error {
133
+ readonly code = "SHARD_SEALING_MISMATCH";
134
+ readonly shards: readonly {
135
+ readonly index: number;
136
+ readonly cipher: string | undefined;
137
+ readonly covers: readonly string[] | undefined;
138
+ }[];
139
+ constructor(caps: readonly (SealingCapability | undefined)[]) {
140
+ const shards = caps.map((c, index) => ({
141
+ index,
142
+ cipher: c?.cipher,
143
+ covers: c === undefined ? undefined : [...c.covers],
144
+ }));
145
+ super(
146
+ `shards disagree about sealing (${shards
147
+ .map(
148
+ (x) =>
149
+ `#${x.index}: ${x.cipher === undefined ? "none" : `${x.cipher} [${x.covers?.join(", ")}]`}`
150
+ )
151
+ .join("; ")}); every shard must seal the same paths with the same cipher, or none may seal`
152
+ );
153
+ this.name = "ShardSealingMismatchError";
154
+ this.shards = shards;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * The sealing capability of a fleet of shards, FAIL CLOSED: no shard seals -> undefined; every shard
160
+ * reports the same cipher and the same covers -> that capability (`opens` needs every shard to
161
+ * agree); anything else throws {@link ShardSealingMismatchError}.
162
+ */
163
+ function commonSealing(
164
+ caps: readonly (SealingCapability | undefined)[]
165
+ ): SealingCapability | undefined {
166
+ const first = caps[0];
167
+ if (caps.every((c) => c === undefined)) return undefined;
168
+ if (first === undefined) throw new ShardSealingMismatchError(caps);
169
+ const covers = new Set(first.covers);
170
+ const all: SealingCapability[] = [];
171
+ for (const c of caps) {
172
+ if (c === undefined || c.cipher !== first.cipher || !sameSet(covers, c.covers)) {
173
+ throw new ShardSealingMismatchError(caps);
174
+ }
175
+ all.push(c);
176
+ }
177
+ return {
178
+ cipher: first.cipher,
179
+ covers: first.covers,
180
+ opens: (marker) => all.every((c) => c.opens(marker)),
181
+ };
182
+ }
183
+
184
+ /** `covers` is a set of paths: order and duplicates carry no meaning, so compare as sets. */
185
+ function sameSet(a: ReadonlySet<string>, b: readonly string[]): boolean {
186
+ const bs = new Set(b);
187
+ return bs.size === a.size && [...bs].every((e) => a.has(e));
188
+ }
189
+
190
+ const shardAnchor = (shard: number, inner: string): string => `s${shard}/${inner}`;
191
+ /** The inner anchor if `anchor` was issued by `shard`; otherwise the string unchanged, which no
192
+ * inner store can have issued (it starts with `s`), so the shard answers `moved`. */
193
+ const unshardAnchor = (anchor: string, shard: number): string => {
194
+ const prefix = `s${shard}/`;
195
+ return anchor.startsWith(prefix) ? anchor.slice(prefix.length) : anchor;
196
+ };
197
+
198
+ export class ShardedEventStore implements EventStore {
199
+ readonly head?: NonNullable<EventStore["head"]>;
200
+ readonly readRange?: NonNullable<EventStore["readRange"]>;
201
+ readonly acquireFence?: NonNullable<EventStore["acquireFence"]>;
202
+ readonly acquireFenceIf?: NonNullable<EventStore["acquireFenceIf"]>;
203
+ readonly appendIfUnchanged?: NonNullable<EventStore["appendIfUnchanged"]>;
204
+ readonly storeNow?: NonNullable<EventStore["storeNow"]>;
205
+ readonly sealingCapability?: NonNullable<EventStore["sealingCapability"]>;
206
+ readonly readAtRest?: NonNullable<EventStore["readAtRest"]>;
207
+ readonly deleteExecution?: NonNullable<EventStore["deleteExecution"]>;
208
+ readonly deleteThrough?: NonNullable<EventStore["deleteThrough"]>;
209
+ readonly truncatePrefix?: NonNullable<EventStore["truncatePrefix"]>;
210
+ readonly truncatePrefixKeeping?: NonNullable<EventStore["truncatePrefixKeeping"]>;
211
+ readonly listExecutions?: NonNullable<EventStore["listExecutions"]>;
212
+ readonly listExecutionsPage?: NonNullable<EventStore["listExecutionsPage"]>;
213
+
214
+ private readonly shards: readonly EventStore[];
215
+ private readonly shardOf: (executionId: string, shards: number) => number;
216
+ private plan: ShardPlan | undefined;
217
+ private readonly cacheLimit: number;
218
+ /** executionId → shard, only for executions in migrating buckets. Insertion-ordered LRU. */
219
+ private readonly placementCache = new Map<string, number>();
220
+
221
+ constructor(shards: readonly EventStore[], options: ShardedEventStoreOptions = {}) {
222
+ if (shards.length === 0) throw new Error("ShardedEventStore requires at least one shard");
223
+ this.shards = shards;
224
+ this.shardOf = options.shardOf ?? bucketOf;
225
+ this.cacheLimit = Math.max(1, options.placementCacheSize ?? 10_000);
226
+ if (options.plan !== undefined) this.setPlan(options.plan);
227
+
228
+ // Optional capabilities are exposed only when EVERY shard has them: a partial
229
+ // capability would work for some executions and not others depending on where they
230
+ // hashed, which is worse than not advertising it at all. `appendBatch` and `copyPrefix`
231
+ // are this class's own methods (they split by shard, and fall back per shard), so the
232
+ // plan covers every other optional method — a new one is a compile error here too.
233
+ type Routed = Exclude<OptionalEventStoreMethod, "appendBatch" | "copyPrefix">;
234
+ const plan = {
235
+ head: async (id) => (await this.route(id)).head!(id),
236
+ readRange: async (id, from, to) => (await this.route(id)).readRange!(id, from, to),
237
+ acquireFence: async (id) => (await this.route(id)).acquireFence!(id),
238
+ acquireFenceIf: async (id, expect) => (await this.route(id)).acquireFenceIf!(id, expect),
239
+ appendIfUnchanged: async (id, event, expect) => {
240
+ const i = await this.routeIndex(id);
241
+ const shard = this.shards[i] as EventStore;
242
+ const result = await shard.appendIfUnchanged!(
243
+ id,
244
+ event,
245
+ expect === undefined ? undefined : { ...expect, anchor: unshardAnchor(expect.anchor, i) }
246
+ );
247
+ return "anchor" in result && typeof result.anchor === "string"
248
+ ? { ...result, anchor: shardAnchor(i, result.anchor) }
249
+ : result;
250
+ },
251
+ // One clock for the fleet: every shard has it (commonOptional), read from the first shard.
252
+ storeNow: "forward",
253
+ // ADR 0130 residuals D7/D8: a stream may sit on any shard, so shards must agree. Installed when
254
+ // ANY shard has the method (commonOptional); a shard lacking it counts as sealing nothing
255
+ // (undefined). Fails closed (ShardSealingMismatchError) on any disagreement, so "can open" and
256
+ // "does seal" cannot diverge. When no shard has it the method is omitted.
257
+ sealingCapability: () => commonSealing(this.shards.map((s) => s.sealingCapability?.())),
258
+ // ADR 0130 D9: the at-rest view routes each call to the stream's shard, like `read` (installed
259
+ // only when EVERY shard offers it, via commonOptional).
260
+ readAtRest: () => {
261
+ const readers = this.shards.map((s) => (s as EventStore).readAtRest!());
262
+ const hasRange = readers.every((r) => r.readRange !== undefined);
263
+ const hasHead = readers.every((r) => r.head !== undefined);
264
+ const via = <T>(id: string, f: (r: AtRestEventReader) => Promise<T>): Promise<T> =>
265
+ this.routeIndex(id).then((i) => f(readers[i] as AtRestEventReader));
266
+ return {
267
+ read: (id) => via(id, (r) => r.read(id)),
268
+ readFrom: (id, from) => via(id, (r) => r.readFrom(id, from)),
269
+ ...(hasRange && {
270
+ readRange: (id: string, a: number, b: number) => via(id, (r) => r.readRange!(id, a, b)),
271
+ }),
272
+ ...(hasHead && { head: (id: string) => via(id, (r) => r.head!(id)) }),
273
+ };
274
+ },
275
+ deleteExecution: async (id) => {
276
+ await (await this.route(id)).deleteExecution!(id);
277
+ this.placementCache.delete(id);
278
+ },
279
+ // Bounded by `through`, so a stream a caller drains stays placed while anything above it remains.
280
+ deleteThrough: async (id, through) => {
281
+ const shard = await this.route(id);
282
+ await shard.deleteThrough!(id, through);
283
+ if ((await headOf(shard, id)) === null) this.placementCache.delete(id);
284
+ },
285
+ truncatePrefix: async (id, through) => (await this.route(id)).truncatePrefix!(id, through),
286
+ truncatePrefixKeeping: async (id, through, keep) =>
287
+ (await this.route(id)).truncatePrefixKeeping!(id, through, keep),
288
+ listExecutions: async () => {
289
+ const perShard = await Promise.all(this.shards.map((s) => s.listExecutions!()));
290
+ // Dedupe: a crash between migrateExecution's copy and delete can leave an execution
291
+ // on both sides, and enumeration must still report it once.
292
+ return [...new Set(perShard.flat())];
293
+ },
294
+ // Paged enumeration needs EVERY shard to be pageable; falling back to listExecutions
295
+ // on some would defeat the bounded-memory guarantee the page API exists to provide.
296
+ listExecutionsPage: (opts) => this.pagedScatterGather(opts),
297
+ } satisfies DecoratorPlan<EventStore, Routed>;
298
+ // `installOptional` installs a method iff its `inner` has it; here "inner" is the
299
+ // capability surface every shard shares.
300
+ installOptional<EventStore, Routed>(this, commonOptional(shards), plan);
301
+ // The structural encryption marker (`keys`, see `installOptional`) must survive sharding,
302
+ // or `Sharded(Encrypting(pg)…)` — the ADR 0074 composition — would look like a plain store
303
+ // to LogLifecycle's compaction/archive guard and to `loom doctor`. Carried up when every
304
+ // shard shares ONE KeyStore; shards that disagree (or only some encrypting) get a marker
305
+ // no snapshot store can match, so the guard fails closed rather than guessing.
306
+ const markers = shards.map((s) => (s as { keys?: unknown }).keys);
307
+ if (markers.some((k) => typeof k === "object" && k !== null)) {
308
+ const first = markers[0];
309
+ (this as { keys?: unknown }).keys = markers.every((k) => k === first)
310
+ ? first
311
+ : Object.freeze({ mixed: true });
312
+ }
313
+ // ADR 0138 H3: the tenant marker survives sharding when every shard is scoped to ONE tenant.
314
+ // Shards that disagree (or only some scoped) get a scope no archive can match, so the guard
315
+ // refuses rather than guessing.
316
+ const scopes = shards.map((s) => (s as { tenantScope?: unknown }).tenantScope);
317
+ if (scopes.some((t) => typeof t === "string")) {
318
+ const first = scopes[0];
319
+ (this as { tenantScope?: unknown }).tenantScope =
320
+ typeof first === "string" && scopes.every((t) => t === first)
321
+ ? first
322
+ : "\u0000mixed-tenant-shards";
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Install a new routing plan — how a rebalance is applied, and how a drained bucket is
328
+ * settled. **Every node sharing the log must converge on the same plan**; different plans
329
+ * on different nodes split the log.
330
+ */
331
+ setPlan(plan: ShardPlan): void {
332
+ if (plan.shardCount !== this.shards.length) {
333
+ throw new Error(
334
+ `plan expects ${plan.shardCount} shards but the store has ${this.shards.length}`
335
+ );
336
+ }
337
+ this.plan = plan;
338
+ // Cached placements were resolved against the previous plan; a bucket that settled or
339
+ // started migrating invalidates them.
340
+ this.placementCache.clear();
341
+ }
342
+
343
+ /** The active plan, if routing is plan-driven. */
344
+ get shardPlan(): ShardPlan | undefined {
345
+ return this.plan;
346
+ }
347
+
348
+ /** Resolve the store owning `executionId` right now. */
349
+ private async route(executionId: string): Promise<EventStore> {
350
+ return this.shards[await this.routeIndex(executionId)] as EventStore;
351
+ }
352
+
353
+ private async routeIndex(executionId: string): Promise<number> {
354
+ if (this.plan === undefined) {
355
+ return this.shardOf(executionId, this.shards.length);
356
+ }
357
+ const placement = this.plan.placementFor(executionId);
358
+ if (placement.kind === "settled") return placement.shard;
359
+
360
+ const cached = this.placementCache.get(executionId);
361
+ if (cached !== undefined) {
362
+ // Refresh recency (Map preserves insertion order, so re-insert moves it to the end).
363
+ this.placementCache.delete(executionId);
364
+ this.placementCache.set(executionId, cached);
365
+ return cached;
366
+ }
367
+
368
+ // Which side actually holds it? Sequence 0 is the bootstrap anchor: present for as long
369
+ // as the execution exists (truncatePrefix preserves it, deleteExecution removes it), so
370
+ // one indexed point-read answers the question. The destination is checked FIRST, so a
371
+ // migrated execution stops consulting the source immediately — and so a crash between
372
+ // copy and delete resolves to the destination, which is the surviving copy.
373
+ // The probe is the stream HEAD (one indexed MAX(sequence)), not sequence 0: a stream that never
374
+ // had a sequence 0 (an inbox topic stream starts at 2^40; a legacy inbox stream seeded at a fork
375
+ // floor starts above 0) is on a shard all the same, and a probe of 0 would route it to the
376
+ // destination while its data (and the owner's cursor) sit on the source.
377
+ const onTo = await headOf(this.shards[placement.to] as EventStore, executionId);
378
+ if (onTo !== null) return this.remember(executionId, placement.to);
379
+ const onFrom = await headOf(this.shards[placement.from] as EventStore, executionId);
380
+ if (onFrom !== null) return this.remember(executionId, placement.from);
381
+ // Unknown to both ⇒ it does not exist yet, so it belongs to the destination. Caching
382
+ // that is what makes a new execution's subsequent appends single-lookup.
383
+ return this.remember(executionId, placement.to);
384
+ }
385
+
386
+ private remember(executionId: string, shard: number): number {
387
+ this.placementCache.set(executionId, shard);
388
+ if (this.placementCache.size > this.cacheLimit) {
389
+ const oldest = this.placementCache.keys().next();
390
+ if (!oldest.done) this.placementCache.delete(oldest.value);
391
+ }
392
+ return shard;
393
+ }
394
+
395
+ /**
396
+ * Move one execution from a migrating bucket's source to its destination: copy every
397
+ * event, then delete the source copy.
398
+ *
399
+ * **The caller must ensure the execution is not being written concurrently** — an append
400
+ * arriving mid-move lands on the source and is then deleted. Terminal executions are the
401
+ * safe case, and the lazy drain described on the class means you usually do not need this
402
+ * at all; reach for it when a source shard must be decommissioned.
403
+ *
404
+ * Fence state does not move (the SPI has no way to read it), so the execution arrives
405
+ * unfenced on the destination.
406
+ */
407
+ async migrateExecution(executionId: string): Promise<MigrationResult> {
408
+ if (this.plan === undefined) return { executionId, outcome: "not-migrating", events: 0 };
409
+ const placement = this.plan.placementFor(executionId);
410
+ if (placement.kind === "settled") return { executionId, outcome: "not-migrating", events: 0 };
411
+
412
+ const current = await this.routeIndex(executionId);
413
+ if (current === placement.to) return { executionId, outcome: "already-there", events: 0 };
414
+
415
+ const source = this.shards[placement.from] as EventStore;
416
+ const target = this.shards[placement.to] as EventStore;
417
+ const events = await source.read(executionId);
418
+ if (events.length > 0) await appendBatchOf(target, events);
419
+ // Copy BEFORE delete. A crash in between leaves the execution on both sides, which
420
+ // `routeIndex` resolves to the destination (checked first) and a re-run cleans up. The
421
+ // reverse order would lose the log outright.
422
+ await source.deleteExecution?.(executionId);
423
+ this.placementCache.delete(executionId);
424
+ this.remember(executionId, placement.to);
425
+ return { executionId, outcome: "moved", events: events.length };
426
+ }
427
+
428
+ /**
429
+ * Whether a migrating bucket's source side still holds anything — i.e. whether
430
+ * `plan.settled(bucket)` is safe yet. `true` for a settled bucket (nothing to drain).
431
+ *
432
+ * Requires enumerable shards.
433
+ */
434
+ async isBucketDrained(bucket: number): Promise<boolean> {
435
+ const plan = this.plan;
436
+ if (plan === undefined) return true;
437
+ const placement = plan.placementAt(bucket);
438
+ if (placement.kind === "settled") return true;
439
+ const source = this.shards[placement.from] as EventStore;
440
+ if (source.listExecutions === undefined) {
441
+ throw new Error("isBucketDrained needs shards that support listExecutions()");
442
+ }
443
+ const ids = await source.listExecutions();
444
+ return !ids.some((id) => plan.bucketFor(id) === bucket);
445
+ }
446
+
447
+ async append(envelope: EventEnvelope): Promise<void> {
448
+ return (await this.route(envelope.executionId)).append(envelope);
449
+ }
450
+
451
+ /**
452
+ * Split a batch by shard and append each part. Shards are independent logs, so the parts
453
+ * run concurrently.
454
+ *
455
+ * Atomicity is per shard, which is exactly what the SPI requires: the contract is atomic
456
+ * PER EXECUTION, and an execution never spans shards. Whole-batch atomicity across
457
+ * shards would need a distributed transaction, costs far more than batching saves, and
458
+ * nothing needs it — a batch only groups INDEPENDENT executions, so nothing in it
459
+ * depends on anything else in it. `Promise.all` rejects if any shard does, so a caller
460
+ * still never reads a partial write as success.
461
+ */
462
+ async appendBatch(envelopes: readonly EventEnvelope[]): Promise<void> {
463
+ if (envelopes.length === 0) return;
464
+ const slots = await Promise.all(envelopes.map((e) => this.routeIndex(e.executionId)));
465
+ const bySlot = new Map<number, EventEnvelope[]>();
466
+ for (const [i, envelope] of envelopes.entries()) {
467
+ const slot = slots[i] as number;
468
+ const list = bySlot.get(slot);
469
+ if (list === undefined) bySlot.set(slot, [envelope]);
470
+ else list.push(envelope);
471
+ }
472
+ await Promise.all(
473
+ [...bySlot].map(([slot, part]) => appendBatchOf(this.shards[slot] as EventStore, part))
474
+ );
475
+ }
476
+
477
+ async read(executionId: string): Promise<EventEnvelope[]> {
478
+ return (await this.route(executionId)).read(executionId);
479
+ }
480
+
481
+ async readFrom(executionId: string, fromSequence: number): Promise<EventEnvelope[]> {
482
+ return (await this.route(executionId)).readFrom(executionId, fromSequence);
483
+ }
484
+
485
+ async subscribe(
486
+ executionId: string,
487
+ handler: (event: EventEnvelope) => void
488
+ ): Promise<() => void> {
489
+ return (await this.route(executionId)).subscribe(executionId, handler);
490
+ }
491
+
492
+ /**
493
+ * Fork. Fast when parent and child resolve to the same shard; otherwise the prefix has to
494
+ * cross a store boundary, so we use the SPI's documented per-event fallback.
495
+ */
496
+ async copyPrefix(
497
+ fromExecutionId: string,
498
+ toExecutionId: string,
499
+ throughSequence: number,
500
+ metadata: Record<string, unknown>
501
+ ): Promise<void> {
502
+ const source = await this.route(fromExecutionId);
503
+ const target = await this.route(toExecutionId);
504
+ if (source === target && source.copyPrefix !== undefined) {
505
+ return source.copyPrefix(fromExecutionId, toExecutionId, throughSequence, metadata);
506
+ }
507
+ const events = await source.read(fromExecutionId);
508
+ const copied = events
509
+ .filter((e) => e.sequence <= throughSequence)
510
+ .map((e) => ({
511
+ ...e,
512
+ id: `${toExecutionId}-${e.sequence}`,
513
+ executionId: toExecutionId,
514
+ metadata: { ...e.metadata, ...metadata },
515
+ }));
516
+ await appendBatchOf(target, copied);
517
+ }
518
+
519
+ /**
520
+ * Keyset pagination across shards. Each shard is asked for its next page beyond the
521
+ * cursor; the results are merged and truncated to `limit`, so the caller sees one
522
+ * globally ordered stream and the cursor stays a single id.
523
+ *
524
+ * Correct ONLY because every shard pages in the SPI's one order (code point, SPI 2.0.0)
525
+ * and accepts any id as a cursor: the merged page's last id is handed back to EVERY shard,
526
+ * including shards that never returned it. A shard paging in another order (a Postgres
527
+ * database collation, before L10) skips the ids that sort after the cursor in one order
528
+ * and before it in the other — silently, since every page still looks well-formed.
529
+ *
530
+ * Deduplicated for the same reason as `listExecutions`: mid-migration an execution can
531
+ * briefly exist on two shards.
532
+ */
533
+ private async pagedScatterGather(opts?: {
534
+ cursor?: string;
535
+ limit?: number;
536
+ }): Promise<ExecutionPage> {
537
+ const limit = opts?.limit ?? 1000;
538
+ const pages = await Promise.all(
539
+ this.shards.map((s) =>
540
+ s.listExecutionsPage!({ ...(opts?.cursor !== undefined && { cursor: opts.cursor }), limit })
541
+ )
542
+ );
543
+ const union = new Set(pages.flatMap((p) => p.executionIds));
544
+ const merged = [...union].sort(compareExecutionIds).slice(0, limit);
545
+ const last = merged[merged.length - 1];
546
+ // More remains if any shard reported a further page, or if the merge itself truncated.
547
+ const truncated = pages.some((p) => p.nextCursor !== undefined) || union.size > merged.length;
548
+ return {
549
+ executionIds: merged,
550
+ ...(truncated && last !== undefined && { nextCursor: last }),
551
+ };
552
+ }
553
+
554
+ async close(): Promise<void> {
555
+ await Promise.all(this.shards.map((s) => s.close()));
556
+ }
557
+ }
@@ -0,0 +1,115 @@
1
+ import type { EventEnvelope } from "@loomwork/core";
2
+
3
+ /**
4
+ * The backend-independent half of `EventStore.subscribe` (ADR 0074 D4).
5
+ *
6
+ * Every store has to do the same three things: keep a per-execution set of local handlers,
7
+ * fan an event out to them, and — for stores whose transport echoes an append back to its
8
+ * own publisher — ignore what it published itself. That was written three times
9
+ * (in-memory, SQLite, Postgres) before this existed.
10
+ *
11
+ * What it deliberately does NOT do is decide how events arrive. That is the genuinely
12
+ * backend-specific part: a global channel, a per-bucket channel, a Redis topic, a Kafka
13
+ * partition. {@link interest} exists so a transport can ask "what am I actually subscribed
14
+ * to?" and narrow what it asks for upstream instead of receiving everything and
15
+ * discarding — the difference between broadcast delivery and interest-filtered delivery.
16
+ *
17
+ * A bring-your-own backend composes this and implements only the transport.
18
+ */
19
+ export class SubscriberRegistry {
20
+ private readonly handlers = new Map<string, Set<(event: EventEnvelope) => void>>();
21
+ /**
22
+ * Fires when an execution gains its first handler or loses its last, so a transport can
23
+ * subscribe/unsubscribe upstream in step. Both arguments are supplied so a transport can
24
+ * work at whatever granularity it uses (per-execution, per-bucket).
25
+ */
26
+ private readonly onInterestChange:
27
+ | ((executionId: string, kind: "first" | "last") => void)
28
+ | undefined;
29
+
30
+ constructor(
31
+ options: { onInterestChange?: (executionId: string, kind: "first" | "last") => void } = {}
32
+ ) {
33
+ this.onInterestChange = options.onInterestChange;
34
+ }
35
+
36
+ /**
37
+ * Register a local handler. Returns an unsubscribe that removes it and, when it was the
38
+ * last for that execution, drops the entry (so {@link interest} stays tight).
39
+ */
40
+ add(executionId: string, handler: (event: EventEnvelope) => void): () => void {
41
+ let set = this.handlers.get(executionId);
42
+ const isFirst = set === undefined;
43
+ if (set === undefined) {
44
+ set = new Set();
45
+ this.handlers.set(executionId, set);
46
+ }
47
+ set.add(handler);
48
+ if (isFirst) this.onInterestChange?.(executionId, "first");
49
+ let removed = false;
50
+ return () => {
51
+ // Idempotent: a caller may unsubscribe twice, and must not then fire "last" twice or
52
+ // tear down a subscription a later subscriber re-established.
53
+ if (removed) return;
54
+ removed = true;
55
+ const current = this.handlers.get(executionId);
56
+ if (current === undefined) return;
57
+ current.delete(handler);
58
+ if (current.size === 0) {
59
+ this.handlers.delete(executionId);
60
+ this.onInterestChange?.(executionId, "last");
61
+ }
62
+ };
63
+ }
64
+
65
+ /** Whether anything local cares about this execution. The transport's filter predicate. */
66
+ has(executionId: string): boolean {
67
+ return this.handlers.has(executionId);
68
+ }
69
+
70
+ /** The executions with at least one local handler — this node's interest set. */
71
+ interest(): string[] {
72
+ return [...this.handlers.keys()];
73
+ }
74
+
75
+ /** Number of executions currently subscribed (not the number of handlers). */
76
+ get size(): number {
77
+ return this.handlers.size;
78
+ }
79
+
80
+ /**
81
+ * Fan an event out to local handlers. Synchronous, matching the store contract that a
82
+ * same-process subscriber sees the event as part of the append.
83
+ *
84
+ * Iterates a COPY: a handler that unsubscribes (or subscribes) during delivery must not
85
+ * perturb the set being walked.
86
+ */
87
+ emit(event: EventEnvelope): void {
88
+ const set = this.handlers.get(event.executionId);
89
+ if (set === undefined || set.size === 0) return;
90
+ for (const handler of [...set]) handler(event);
91
+ }
92
+
93
+ /** Drop every subscription. Used by `close()`. */
94
+ clear(): void {
95
+ this.handlers.clear();
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Map an executionId onto one of `buckets` channels, for transports that cannot afford a
101
+ * channel per execution but must not broadcast everything either (ADR 0074 D4).
102
+ *
103
+ * FNV-1a over the id: stable across processes and restarts — which is required, since two
104
+ * nodes must agree on which bucket an execution lives in — and dependency-free.
105
+ */
106
+ export function bucketOf(executionId: string, buckets: number): number {
107
+ if (buckets <= 1) return 0;
108
+ let hash = 0x811c9dc5;
109
+ for (let i = 0; i < executionId.length; i++) {
110
+ hash ^= executionId.charCodeAt(i);
111
+ // 32-bit FNV prime multiply via shifts; `>>> 0` keeps it unsigned.
112
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
113
+ }
114
+ return hash % buckets;
115
+ }