@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
package/sequence.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./dist/sequenceRange";
package/sequence.js ADDED
@@ -0,0 +1,5 @@
1
+ // Browser-safe subpath: only the pure sequence-range helpers, so importing them
2
+ // never pulls the SQLite store (better-sqlite3 / node:fs) that the package barrel
3
+ // re-exports. (These packages compile to CommonJS, which bundlers cannot
4
+ // tree-shake — hence a dedicated subpath rather than relying on `sideEffects`.)
5
+ module.exports = require("./dist/sequenceRange");
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Git-style branching for event-sourced executions — branch only, no merge.
3
+ *
4
+ * A {@link BranchRef} is a movable pointer (like a git branch) naming the head
5
+ * execution of a line of work. A {@link LineageRecord} is an immutable edge in
6
+ * the fork DAG (child → parent at a sequence). Together they let speculative
7
+ * agent executions fork from any point, run independently, and be selected with
8
+ * `promote()` ("pick-a-winner"). There is deliberately no merge/rebase: two
9
+ * divergent ExecutionStates have no principled join, so we don't fake one.
10
+ */
11
+
12
+ /**
13
+ * SPI version for the {@link BranchRegistry} contract (ADR 0033). Pinned by
14
+ * `runBranchRegistryConformance` in `@loomwork/testing`.
15
+ *
16
+ * **2.0.0 (L21, ADR 0108 addendum, MAJOR):** `listRefs` orders names in Unicode **code
17
+ * point** order — the same order `EventStore.listExecutionsPage` pins (ADR 0108, SPI
18
+ * 2.0.0): UTF-8 byte order, SQL `COLLATE "C"`, SQLite BINARY, and `compareExecutionIds`
19
+ * (`@loomwork/event-runtime`) as the reference comparator. Not a database's default collation,
20
+ * and not `String.prototype.localeCompare` — both can disagree with code point order, and
21
+ * `localeCompare` can return 0 for two DISTINCT, canonically-equivalent names (an NFC "é"
22
+ * and its NFD decomposition). `after` may be **any string**, not only a name a previous
23
+ * `listRefs` call returned, and the page holds the names strictly after it. Before this, the
24
+ * three backends ordered `listRefs` three different ways (Postgres: database collation;
25
+ * SQLite: BINARY, already conforming; in-memory: `localeCompare`), so a cursor produced by
26
+ * one order and consumed by another skipped or repeated refs at a page boundary — the same
27
+ * failure mode ADR 0108 fixed for execution ids. MAJOR because a store conforming to 1.x
28
+ * (any order, an opaque cursor) can fail the new conformance case (the ADR 0091 rule); no
29
+ * method was added or removed.
30
+ */
31
+ export const BRANCH_REGISTRY_SPI_VERSION = "2.0.0";
32
+
33
+ /** A named, movable pointer to an execution head. */
34
+ export interface BranchRef {
35
+ /** Branch name, e.g. "main" or "spec/scale-replicas". Unique. */
36
+ name: string;
37
+ /** The execution this ref currently points at. */
38
+ executionId: string;
39
+ /** Latest known sequence on that execution (advanced via `moveTip`). */
40
+ tip: number;
41
+ /** If this branch was created by a fork, the execution it forked from. */
42
+ parentExecutionId?: string;
43
+ /** If this branch was created by a fork, the sequence it forked at. */
44
+ forkSequence?: number;
45
+ createdAt: number;
46
+ updatedAt: number;
47
+ }
48
+
49
+ /** An immutable edge in the fork DAG: `executionId` forked from `parentExecutionId` at `forkSequence`. */
50
+ export interface LineageRecord {
51
+ executionId: string;
52
+ parentExecutionId: string;
53
+ forkSequence: number;
54
+ createdAt: number;
55
+ }
56
+
57
+ /** Paging for {@link BranchRegistry.listRefs}. Keyset, because refs are unique and name-ordered. */
58
+ export interface ListRefsOptions {
59
+ /** Max rows to return. Must be a positive integer — an unbounded page is not a default. */
60
+ limit?: number;
61
+ /**
62
+ * Return refs whose name sorts strictly AFTER this one, in Unicode CODE POINT order
63
+ * (SPI 2.0.0, ADR 0108 addendum / L21 — {@link BRANCH_REGISTRY_SPI_VERSION}). May be ANY
64
+ * string, not only a name a previous `listRefs` call returned — pass the last name of the
65
+ * previous page.
66
+ */
67
+ after?: string;
68
+ }
69
+
70
+ /** Options for {@link BranchRegistry.promote}. */
71
+ export interface PromoteOptions {
72
+ /**
73
+ * Compare-and-set. The executionId `toName` is expected to name right now, or `null` for
74
+ * "must not exist yet". The repoint happens only if the expectation holds; otherwise it
75
+ * throws {@link BranchPromotionConflictError} and **nothing moves**.
76
+ *
77
+ * Without it, `promote` is last-write-wins: two nodes promoting different winners onto
78
+ * `main` both succeed and the loser silently wins. That is the 1.0.0 behaviour, preserved
79
+ * byte-identically when `expect` is omitted.
80
+ */
81
+ expect?: string | null;
82
+ }
83
+
84
+ /**
85
+ * A compare-and-set {@link BranchRegistry.promote} lost: `toName` does not name what the
86
+ * caller expected, so the promotion was refused. The registry is untouched — re-read the ref
87
+ * and decide, rather than retrying blind.
88
+ */
89
+ export class BranchPromotionConflictError extends Error {
90
+ constructor(
91
+ readonly toName: string,
92
+ readonly expected: string | null,
93
+ readonly actual: string | null
94
+ ) {
95
+ super(
96
+ `promote onto "${toName}" refused: expected it to name ${expected === null ? "nothing" : `"${expected}"`}, but it names ${actual === null ? "nothing" : `"${actual}"`}`
97
+ );
98
+ this.name = "BranchPromotionConflictError";
99
+ Object.setPrototypeOf(this, BranchPromotionConflictError.prototype);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Refuse a nonsense page size before it reaches a backend. SQLite treats `LIMIT -1` as "no
105
+ * limit", so an unvalidated negative silently returns the whole table — the opposite of what
106
+ * a caller asking for a page wants.
107
+ */
108
+ export function assertListRefsLimit(limit: number | undefined): void {
109
+ if (limit === undefined) return;
110
+ if (!Number.isInteger(limit) || limit <= 0) {
111
+ throw new RangeError(`listRefs: limit must be a positive integer, got ${limit}`);
112
+ }
113
+ }
114
+
115
+ export interface BranchRegistry {
116
+ /** Create or move a ref (upsert by name). */
117
+ putRef(ref: BranchRef): Promise<void>;
118
+ getRef(name: string): Promise<BranchRef | null>;
119
+ /**
120
+ * Refs in name order — Unicode CODE POINT order (SPI 2.0.0, see
121
+ * {@link BRANCH_REGISTRY_SPI_VERSION}), never a database collation or
122
+ * `String.prototype.localeCompare`. Unpaged by default; pass `limit`/`after` to walk a
123
+ * registry that has grown — forking is cheap, so the ref table is unbounded by design.
124
+ */
125
+ listRefs(options?: ListRefsOptions): Promise<BranchRef[]>;
126
+ /** Advance a ref's tip as its execution grows. No-op if the ref is absent. */
127
+ moveTip(name: string, tip: number): Promise<void>;
128
+ /**
129
+ * Pick-a-winner: repoint `toName` onto the execution `fromName` currently
130
+ * names, adopting its (executionId, tip). This is the entire "merge"
131
+ * replacement — a pointer move, no history rewrite, no state reconciliation.
132
+ * The losing execution remains immutable and independently resumable.
133
+ * Throws if `fromName` does not exist. Pass `options.expect` to make it a compare-and-set
134
+ * (see {@link PromoteOptions}) — without it, a concurrent promotion is a silent overwrite.
135
+ */
136
+ promote(fromName: string, toName: string, options?: PromoteOptions): Promise<void>;
137
+ deleteRef(name: string): Promise<void>;
138
+
139
+ /** Record a fork edge in the DAG. */
140
+ recordLineage(rec: Omit<LineageRecord, "createdAt">): Promise<void>;
141
+ getLineage(executionId: string): Promise<LineageRecord | null>;
142
+ /**
143
+ * OPTIONAL: forget one fork edge — the child's edge to its parent.
144
+ *
145
+ * Lineage is the alternatives-considered audit trail and must outlive a branch's own
146
+ * life. It must NOT outlive the executions it describes: once retention has reclaimed a
147
+ * branch, its edge points at data that no longer exists, and keeping a permanent index of
148
+ * deleted executions contradicts the retention policy that deleted them. It also costs —
149
+ * `LogLifecycle` walks `children()` on every sweep, so dead edges make retention slower
150
+ * forever, in proportion to every fork ever taken.
151
+ *
152
+ * Called by `LogLifecycle` at the moment an execution is deleted, which is the only point
153
+ * where the id is known and the data is provably gone. A store that omits it simply keeps
154
+ * its edges (today's behaviour, byte-identically).
155
+ */
156
+ removeLineage?(executionId: string): Promise<void>;
157
+ /** Direct children forked from `executionId`. */
158
+ children(executionId: string): Promise<LineageRecord[]>;
159
+
160
+ close(): Promise<void>;
161
+ }
@@ -0,0 +1,214 @@
1
+ import type { EventEnvelope } from "@loomwork/core";
2
+ import { type DecoratorPlan, installOptional } from "./installOptional";
3
+ import { type EventStore, type OptionalEventStoreMethod, appendBatchOf } from "./types";
4
+
5
+ export interface CoalescingEventStoreOptions {
6
+ /**
7
+ * Flush once this many envelopes are queued. The measured knee is ~16; past ~64 both
8
+ * throughput and tail latency regress. Default 16.
9
+ */
10
+ maxBatch?: number;
11
+ /**
12
+ * Upper bound in milliseconds on how long the FIRST envelope of a batch waits for
13
+ * company. Default 0 — flush on the next microtask, which under concurrency already
14
+ * collects everything enqueued in the same turn without adding measurable latency.
15
+ * Raise it only if you are deliberately trading latency for batch size.
16
+ */
17
+ maxLingerMs?: number;
18
+ }
19
+
20
+ interface Pending {
21
+ envelope: EventEnvelope;
22
+ resolve: () => void;
23
+ reject: (err: unknown) => void;
24
+ }
25
+
26
+ /**
27
+ * Merges concurrent appends **across executions** into one `appendBatch` (ADR 0074 D2).
28
+ *
29
+ * Why a decorator and not the journal: `ExecutionJournal` is per-execution, so a
30
+ * journal-side batcher can only ever hold one execution's envelopes — a 1-step flow emits
31
+ * four, and with a realistic slow effect the serial tail drains between `STEP_STARTED` and
32
+ * `STEP_COMPLETED`, so it collects one. Measured; it is ADR 0065's own gate-2 case. A store
33
+ * decorator sees every live execution at once and can actually fill a batch.
34
+ *
35
+ * **Ordering.** Each `ExecutionJournal` still serializes its own execution (D3 unchanged) —
36
+ * it awaits each `append` before issuing the next, so this store never holds two envelopes
37
+ * of one execution concurrently through the normal path. Should it anyway (a second writer,
38
+ * a caller not using the journal), the queue is FIFO and `appendBatch` is specified to
39
+ * apply in array order, so arrival order is preserved regardless.
40
+ *
41
+ * **Durability.** An `append` promise resolves only once its batch has committed. Nothing
42
+ * resolves early, so every `drain()` barrier keeps exactly the meaning it has today. This
43
+ * is the property that distinguishes coalescing from the durability classes ADR 0065
44
+ * rejected on invariants.
45
+ *
46
+ * **Failure.** A batch rejection rejects every caller in that batch, so a partial write can
47
+ * never look like success. Combined with `appendBatch`'s all-or-nothing contract, a failed
48
+ * flush leaves the log untouched and every writer informed — **but only when the inner store
49
+ * implements `appendBatch`**. Over a store without it, `appendBatchOf` falls back to a
50
+ * sequential `append` loop, which is not atomic: envelopes ahead of the failing one are
51
+ * already durable while their writers are told the batch failed. Every decorator between
52
+ * this one and the backend must therefore carry `appendBatch` down (ADR 0074 Amendment —
53
+ * `runEventStoreDecoratorConformance` checks it).
54
+ *
55
+ * **Blast radius.** One stale-fence zombie in a batch rejects the whole batch, so every
56
+ * healthy writer coalesced with it is told "failed" too (nothing of theirs lands; they abort
57
+ * and recovery re-drives them from the log). Coalescing widens a fencing rejection from one
58
+ * execution to up to `maxBatch` of them — a price worth paying while fencing is rare.
59
+ *
60
+ * Inert unless wired: compose it explicitly in `storage`, outermost-but-one (sharding
61
+ * outside it, since routing must pick a shard before a batch is formed for that shard).
62
+ */
63
+ export class CoalescingEventStore implements EventStore {
64
+ readonly head?: NonNullable<EventStore["head"]>;
65
+ readonly readRange?: NonNullable<EventStore["readRange"]>;
66
+ readonly acquireFence?: NonNullable<EventStore["acquireFence"]>;
67
+ readonly acquireFenceIf?: NonNullable<EventStore["acquireFenceIf"]>;
68
+ readonly appendIfUnchanged?: NonNullable<EventStore["appendIfUnchanged"]>;
69
+ readonly storeNow?: NonNullable<EventStore["storeNow"]>;
70
+ readonly sealingCapability?: NonNullable<EventStore["sealingCapability"]>;
71
+ readonly readAtRest?: NonNullable<EventStore["readAtRest"]>;
72
+ readonly copyPrefix?: NonNullable<EventStore["copyPrefix"]>;
73
+ readonly deleteExecution?: NonNullable<EventStore["deleteExecution"]>;
74
+ readonly deleteThrough?: NonNullable<EventStore["deleteThrough"]>;
75
+ readonly truncatePrefix?: NonNullable<EventStore["truncatePrefix"]>;
76
+ readonly truncatePrefixKeeping?: NonNullable<EventStore["truncatePrefixKeeping"]>;
77
+ readonly listExecutions?: NonNullable<EventStore["listExecutions"]>;
78
+ readonly listExecutionsPage?: NonNullable<EventStore["listExecutionsPage"]>;
79
+
80
+ private readonly maxBatch: number;
81
+ private readonly maxLingerMs: number;
82
+ private queue: Pending[] = [];
83
+ private flushScheduled = false;
84
+ private timer: ReturnType<typeof setTimeout> | undefined;
85
+ /** Serializes flushes so two batches can never be in flight against the inner store. */
86
+ private tail: Promise<void> = Promise.resolve();
87
+ private closed = false;
88
+
89
+ constructor(
90
+ private readonly inner: EventStore,
91
+ options: CoalescingEventStoreOptions = {}
92
+ ) {
93
+ this.maxBatch = Math.max(1, options.maxBatch ?? 16);
94
+ this.maxLingerMs = Math.max(0, options.maxLingerMs ?? 0);
95
+ // Pass through every optional capability the inner store has, so wrapping never
96
+ // silently downgrades a backend (a missing `readRange` would push callers onto the
97
+ // slow fallback for no reason). `appendBatch` is this class's own method — batching is
98
+ // what it is for — so the plan covers every other optional method.
99
+ const plan = {
100
+ head: "forward",
101
+ readRange: "forward",
102
+ acquireFence: "forward",
103
+ acquireFenceIf: "forward",
104
+ appendIfUnchanged: "forward",
105
+ storeNow: "forward",
106
+ sealingCapability: "forward",
107
+ readAtRest: "forward",
108
+ copyPrefix: "forward",
109
+ deleteExecution: "forward",
110
+ deleteThrough: "forward",
111
+ truncatePrefix: "forward",
112
+ truncatePrefixKeeping: "forward",
113
+ listExecutions: "forward",
114
+ listExecutionsPage: "forward",
115
+ } satisfies DecoratorPlan<EventStore, Exclude<OptionalEventStoreMethod, "appendBatch">>;
116
+ installOptional<EventStore, Exclude<OptionalEventStoreMethod, "appendBatch">>(
117
+ this,
118
+ inner,
119
+ plan
120
+ );
121
+ }
122
+
123
+ append(envelope: EventEnvelope): Promise<void> {
124
+ if (this.closed) return Promise.reject(new Error("CoalescingEventStore is closed"));
125
+ return new Promise<void>((resolve, reject) => {
126
+ this.queue.push({ envelope, resolve, reject });
127
+ if (this.queue.length >= this.maxBatch) this.flushNow();
128
+ else this.scheduleFlush();
129
+ });
130
+ }
131
+
132
+ /**
133
+ * A caller that already has a batch skips the queue entirely — it is by definition
134
+ * already coalesced, and threading it through the queue would only risk splitting it.
135
+ */
136
+ appendBatch(envelopes: readonly EventEnvelope[]): Promise<void> {
137
+ if (envelopes.length === 0) return Promise.resolve();
138
+ if (this.closed) return Promise.reject(new Error("CoalescingEventStore is closed"));
139
+ return this.enqueueFlush(() => appendBatchOf(this.inner, envelopes));
140
+ }
141
+
142
+ private scheduleFlush(): void {
143
+ if (this.flushScheduled) return;
144
+ this.flushScheduled = true;
145
+ if (this.maxLingerMs === 0) {
146
+ // Next microtask: collects everything enqueued in this turn — which under concurrency
147
+ // is the whole in-flight set — without deferring to a timer.
148
+ queueMicrotask(() => this.flushNow());
149
+ } else {
150
+ this.timer = setTimeout(() => this.flushNow(), this.maxLingerMs);
151
+ }
152
+ }
153
+
154
+ private flushNow(): void {
155
+ if (this.timer !== undefined) {
156
+ clearTimeout(this.timer);
157
+ this.timer = undefined;
158
+ }
159
+ this.flushScheduled = false;
160
+ if (this.queue.length === 0) return;
161
+ const batch = this.queue;
162
+ this.queue = [];
163
+ void this.enqueueFlush(async () => {
164
+ await appendBatchOf(
165
+ this.inner,
166
+ batch.map((p) => p.envelope)
167
+ );
168
+ }).then(
169
+ () => {
170
+ for (const p of batch) p.resolve();
171
+ },
172
+ (err: unknown) => {
173
+ // Every writer in a failed batch learns it failed — a partial write must never be
174
+ // reported as success.
175
+ for (const p of batch) p.reject(err);
176
+ }
177
+ );
178
+ }
179
+
180
+ /** Chain onto the serial tail so batches commit one at a time, in enqueue order. */
181
+ private enqueueFlush(run: () => Promise<void>): Promise<void> {
182
+ const result = this.tail.then(run);
183
+ // The tail itself must never reject, or every later flush would inherit the failure.
184
+ this.tail = result.then(
185
+ () => undefined,
186
+ () => undefined
187
+ );
188
+ return result;
189
+ }
190
+
191
+ read(executionId: string): Promise<EventEnvelope[]> {
192
+ return this.inner.read(executionId);
193
+ }
194
+
195
+ readFrom(executionId: string, fromSequence: number): Promise<EventEnvelope[]> {
196
+ return this.inner.readFrom(executionId, fromSequence);
197
+ }
198
+
199
+ subscribe(executionId: string, handler: (event: EventEnvelope) => void): Promise<() => void> {
200
+ return this.inner.subscribe(executionId, handler);
201
+ }
202
+
203
+ /** Flush anything queued and wait for in-flight batches. Safe to call repeatedly. */
204
+ async drain(): Promise<void> {
205
+ this.flushNow();
206
+ await this.tail;
207
+ }
208
+
209
+ async close(): Promise<void> {
210
+ await this.drain();
211
+ this.closed = true;
212
+ await this.inner.close();
213
+ }
214
+ }
@@ -0,0 +1,69 @@
1
+ import { pageIds } from "./archive";
2
+ import {
3
+ type ArchiveExecutionPage,
4
+ type ArchiveSegment,
5
+ type ArchiveSegmentHeader,
6
+ type ArchiveStore,
7
+ assertReplaceable,
8
+ classifyAppend,
9
+ headerOf,
10
+ } from "./archiveSpi";
11
+ import { compareExecutionIds } from "./executionIdOrder";
12
+
13
+ /** In-memory {@link ArchiveStore}: for tests, the memory storage kind and in-memory event stores. */
14
+ export class InMemoryArchiveStore implements ArchiveStore {
15
+ private readonly byExecution = new Map<string, ArchiveSegment[]>();
16
+
17
+ async append(executionId: string, segment: ArchiveSegment): Promise<void> {
18
+ const stored = this.byExecution.get(executionId) ?? [];
19
+ if (classifyAppend(executionId, stored, segment) === "noop") return;
20
+ // A copy through JSON, as a durable backend would hold it: the caller's objects never alias.
21
+ const copy = JSON.parse(JSON.stringify(segment)) as ArchiveSegment;
22
+ this.byExecution.set(executionId, [...stored, copy]);
23
+ }
24
+
25
+ async headers(executionId: string): Promise<ArchiveSegmentHeader[]> {
26
+ return (this.byExecution.get(executionId) ?? []).map(headerOf);
27
+ }
28
+
29
+ async listExecutions(
30
+ opts: { cursor?: string; limit?: number } = {}
31
+ ): Promise<ArchiveExecutionPage> {
32
+ const ids = [...this.byExecution.entries()]
33
+ .filter(([, segs]) => segs.length > 0)
34
+ .map(([id]) => id)
35
+ .sort(compareExecutionIds);
36
+ return pageIds(ids, opts);
37
+ }
38
+
39
+ async read(
40
+ executionId: string,
41
+ range: { readonly from?: number; readonly through?: number } = {}
42
+ ): Promise<ArchiveSegment[]> {
43
+ return (this.byExecution.get(executionId) ?? [])
44
+ .filter(
45
+ (s) =>
46
+ (range.from === undefined || s.through >= range.from) &&
47
+ (range.through === undefined || s.from <= range.through)
48
+ )
49
+ .map((s) => JSON.parse(JSON.stringify(s)) as ArchiveSegment);
50
+ }
51
+
52
+ async replaceSegments(
53
+ executionId: string,
54
+ expected: readonly ArchiveSegmentHeader[],
55
+ next: readonly ArchiveSegment[]
56
+ ): Promise<void> {
57
+ const stored = this.byExecution.get(executionId) ?? [];
58
+ assertReplaceable(executionId, stored.map(headerOf), expected, next);
59
+ const copy = JSON.parse(JSON.stringify(next)) as ArchiveSegment[];
60
+ if (copy.length === 0) this.byExecution.delete(executionId);
61
+ else this.byExecution.set(executionId, copy);
62
+ }
63
+
64
+ async deleteExecution(executionId: string): Promise<void> {
65
+ this.byExecution.delete(executionId);
66
+ }
67
+
68
+ async close(): Promise<void> {}
69
+ }