@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,425 @@
1
+ import type { EventEnvelope } from "@loomwork/core";
2
+ import { SubscriberRegistry } from "./SubscriberRegistry";
3
+ import { isAnsweringKind } from "./eventKindClass";
4
+ import { compareExecutionIds } from "./executionIdOrder";
5
+ import { StaleFenceError, fenceOf } from "./fencing";
6
+ import { lowerBoundBySequence, sliceBySequence } from "./sequenceRange";
7
+ import type {
8
+ AcquireFenceIfResult,
9
+ AppendIfUnchangedResult,
10
+ EventStore,
11
+ TipObservation,
12
+ } from "./types";
13
+ import { DuplicateEnvelopeIdError, DuplicateSequenceError } from "./writeConflicts";
14
+
15
+ export interface InMemoryAsyncEventStoreOptions {
16
+ /** Artificial per-operation delay (ms) to model network latency. Default 0. */
17
+ latencyMs?: number;
18
+ /**
19
+ * If provided and it returns true for an envelope, `append` rejects with the
20
+ * given/derived error instead of storing it — for exercising the durable-write
21
+ * failure path. Default: never fails.
22
+ */
23
+ failAppendWhen?: (envelope: EventEnvelope) => boolean | string | Error;
24
+ }
25
+
26
+ /**
27
+ * Reference **async** EventStore: a genuine Promise-returning backend (unlike the
28
+ * synchronous SQLite/in-memory stores, which merely satisfy the async interface).
29
+ * Use it as a starting point for a real network backend, or in tests to exercise
30
+ * the runtime's async write pipeline, ordering, and failure handling.
31
+ */
32
+ export class InMemoryAsyncEventStore implements EventStore {
33
+ private readonly log = new Map<string, EventEnvelope[]>();
34
+ /**
35
+ * The executionIds in SPI order (code point), built lazily by `listExecutionsPage` and
36
+ * dropped only when the SET of executions changes (a new one, or a delete). A full paged
37
+ * walk is then one O(n log n) sort plus O(log n) per page, not a re-sort per page.
38
+ */
39
+ private sortedIds: string[] | undefined;
40
+ /**
41
+ * #309 — every envelope id in the store, the index behind the `id` PRIMARY KEY both SQL
42
+ * backends have. GLOBAL, not per-execution, because that is what the SQL constraint is:
43
+ * two executions may not share an envelope id either.
44
+ *
45
+ * It has to be maintained everywhere the log changes, not just on append — a stale entry
46
+ * after a delete would refuse a legitimate re-append forever, and an un-removed one is a
47
+ * leak in a store whose whole job is to be re-created cheaply in tests. The removal side
48
+ * is only correct BECAUSE the invariant holds: an id belongs to exactly one envelope, so
49
+ * dropping the ids of the events being dropped cannot orphan someone else's claim.
50
+ *
51
+ * Costs one hash-table slot per event; the id strings themselves are already retained by
52
+ * the envelopes in `log`, so this is not a second copy of the data.
53
+ */
54
+ private readonly ids = new Set<string>();
55
+ private readonly subscribers = new SubscriberRegistry();
56
+ private readonly fences = new Map<string, number>();
57
+ /**
58
+ * ADR 0129 — the store-assigned incarnation of each execution, taken from a store-wide
59
+ * monotonic counter when its sequence 0 first lands and dropped with the log. Store metadata
60
+ * only: never in an envelope, a fold, a snapshot or a hash, so replay stays byte-identical.
61
+ */
62
+ private readonly incarnations = new Map<string, number>();
63
+ private nextIncarnation = 1;
64
+ private readonly latencyMs: number;
65
+ private readonly failAppendWhen:
66
+ | ((envelope: EventEnvelope) => boolean | string | Error)
67
+ | undefined;
68
+
69
+ constructor(options: InMemoryAsyncEventStoreOptions = {}) {
70
+ this.latencyMs = options.latencyMs ?? 0;
71
+ this.failAppendWhen = options.failAppendWhen;
72
+ }
73
+
74
+ private async tick(): Promise<void> {
75
+ if (this.latencyMs > 0) await new Promise((r) => setTimeout(r, this.latencyMs));
76
+ else await Promise.resolve();
77
+ }
78
+
79
+ async append(envelope: EventEnvelope): Promise<void> {
80
+ await this.tick();
81
+ this.assertAppendable(envelope);
82
+ this.commit(envelope);
83
+ this.subscribers.emit(envelope);
84
+ }
85
+
86
+ /**
87
+ * ADR 0074 D1 — the reference implementation of the `appendBatch` contract, and what a
88
+ * bring-your-own backend can read to see what it has to honour.
89
+ *
90
+ * One `tick()` for the whole batch, because the point of the method is one round-trip.
91
+ * Atomicity is why validation is a separate pass: EVERY envelope is checked before ANY
92
+ * is committed, so a fenced-out or rejected envelope leaves the log exactly as it was
93
+ * rather than half-applied. Fencing is checked per envelope, since a batch may span
94
+ * executions.
95
+ */
96
+ async appendBatch(envelopes: readonly EventEnvelope[]): Promise<void> {
97
+ if (envelopes.length === 0) return;
98
+ await this.tick();
99
+ // A batch must be checked against ITSELF as well as the log: two envelopes claiming
100
+ // the same slot — or the same id — are both appendable against the durable state, and
101
+ // only the pairwise check catches them. Tracked lazily, so the common single-execution
102
+ // monotonic batch allocates nothing.
103
+ let claimedSlots: Map<string, Set<number>> | undefined;
104
+ let claimedIds: Set<string> | undefined;
105
+ for (const envelope of envelopes) {
106
+ this.assertAppendable(envelope);
107
+ const seen = claimedSlots?.get(envelope.executionId);
108
+ if (seen?.has(envelope.sequence) === true) {
109
+ throw new DuplicateSequenceError(envelope.executionId, envelope.sequence);
110
+ }
111
+ if (claimedIds?.has(envelope.id) === true) {
112
+ throw new DuplicateEnvelopeIdError(envelope.id, envelope.executionId);
113
+ }
114
+ claimedSlots ??= new Map();
115
+ const set = seen ?? new Set<number>();
116
+ set.add(envelope.sequence);
117
+ claimedSlots.set(envelope.executionId, set);
118
+ claimedIds ??= new Set();
119
+ claimedIds.add(envelope.id);
120
+ }
121
+ for (const envelope of envelopes) this.commit(envelope);
122
+ // Fan out only once the whole batch has landed — a subscriber must never observe a
123
+ // partially applied batch.
124
+ for (const envelope of envelopes) this.subscribers.emit(envelope);
125
+ }
126
+
127
+ /**
128
+ * Throws if this envelope may not land: a duplicate `(executionId, sequence)`, a
129
+ * duplicate envelope `id`, K9.4 fencing, or an injected failure.
130
+ */
131
+ private assertAppendable(envelope: EventEnvelope, checkFence = true): void {
132
+ // K9.4 — fencing: once a token has been acquired for this execution, an
133
+ // append missing the current token is a zombie write and is rejected.
134
+ const currentFence = checkFence ? this.fences.get(envelope.executionId) : undefined;
135
+ if (currentFence !== undefined) {
136
+ const fence = fenceOf(envelope);
137
+ if (fence === undefined || fence < currentFence) {
138
+ throw new StaleFenceError(envelope.executionId, fence, currentFence);
139
+ }
140
+ }
141
+ // #306 — the same `UNIQUE (execution_id, sequence)` both SQL backends enforce. Not a
142
+ // redundant belt beside fencing: fencing engages only once a token has been ACQUIRED,
143
+ // and sequences are allocated per-process from folded state, so two unfenced writers
144
+ // recovering the same execution allocate the same next sequence with neither a zombie.
145
+ // O(log n) against the sorted log — `commit` maintains that order for exactly this.
146
+ const list = this.log.get(envelope.executionId);
147
+ if (list !== undefined) {
148
+ const at = lowerBoundBySequence(list, envelope.sequence);
149
+ if (at < list.length && (list[at] as EventEnvelope).sequence === envelope.sequence) {
150
+ throw new DuplicateSequenceError(envelope.executionId, envelope.sequence);
151
+ }
152
+ }
153
+ // #309 — the events table's OTHER key. Checked after the slot, so a writer that lost a
154
+ // sequence race is told that (the actionable failure) rather than being told about the
155
+ // id its own naming convention derived from the sequence it lost.
156
+ if (this.ids.has(envelope.id)) {
157
+ throw new DuplicateEnvelopeIdError(envelope.id, envelope.executionId);
158
+ }
159
+ const verdict = this.failAppendWhen?.(envelope);
160
+ if (verdict) {
161
+ throw verdict instanceof Error
162
+ ? verdict
163
+ : new Error(typeof verdict === "string" ? verdict : `append rejected: ${envelope.id}`);
164
+ }
165
+ }
166
+
167
+ /** Insert into the execution's log, keeping it sorted by sequence. */
168
+ private commit(envelope: EventEnvelope): void {
169
+ this.ids.add(envelope.id);
170
+ const list = this.log.get(envelope.executionId) ?? [];
171
+ // Keep each execution's log sorted by sequence so reads can binary-search.
172
+ // Appends are normally monotonic (a plain end-push); only an out-of-order
173
+ // arrival triggers an insertion.
174
+ if (
175
+ list.length === 0 ||
176
+ (list[list.length - 1] as EventEnvelope).sequence <= envelope.sequence
177
+ ) {
178
+ list.push(envelope);
179
+ } else {
180
+ list.splice(lowerBoundBySequence(list, envelope.sequence), 0, envelope);
181
+ }
182
+ this.setLog(envelope.executionId, list);
183
+ }
184
+
185
+ async read(executionId: string): Promise<EventEnvelope[]> {
186
+ await this.tick();
187
+ // Already sorted by the append invariant — return a defensive copy, no re-sort.
188
+ return [...(this.log.get(executionId) ?? [])];
189
+ }
190
+
191
+ async readFrom(executionId: string, fromSequence: number): Promise<EventEnvelope[]> {
192
+ await this.tick();
193
+ const list = this.log.get(executionId) ?? [];
194
+ return list.slice(lowerBoundBySequence(list, fromSequence));
195
+ }
196
+
197
+ async head(executionId: string): Promise<number | null> {
198
+ await this.tick();
199
+ const list = this.log.get(executionId) ?? [];
200
+ return list.length === 0 ? null : (list[list.length - 1]?.sequence ?? null);
201
+ }
202
+
203
+ async readRange(
204
+ executionId: string,
205
+ fromSequence: number,
206
+ toSequence: number
207
+ ): Promise<EventEnvelope[]> {
208
+ await this.tick();
209
+ return sliceBySequence(this.log.get(executionId) ?? [], fromSequence, toSequence);
210
+ }
211
+
212
+ async copyPrefix(
213
+ fromExecutionId: string,
214
+ toExecutionId: string,
215
+ throughSequence: number,
216
+ metadata: Record<string, unknown>
217
+ ): Promise<void> {
218
+ await this.tick();
219
+ const src = this.log.get(fromExecutionId) ?? [];
220
+ const dst = this.log.get(toExecutionId) ?? [];
221
+ // Built fully before anything is published, so a rejected copy leaves the target
222
+ // untouched — the same all-or-nothing shape the SQL backends get from their INSERT
223
+ // … SELECT, which would abort on the primary key rather than half-populate a fork.
224
+ const copied: EventEnvelope[] = [];
225
+ for (const e of src) {
226
+ if (e.sequence > throughSequence) continue;
227
+ // Re-stamp only; no payload (de)serialization.
228
+ const stamped = {
229
+ ...e,
230
+ executionId: toExecutionId,
231
+ id: `${toExecutionId}-${e.sequence}`,
232
+ metadata: { ...e.metadata, ...metadata },
233
+ };
234
+ // #309 — the re-stamped ids must be as unique as any other. A fork into a target
235
+ // that already holds events is a broken fork, and this is where it surfaces; the
236
+ // check also keeps the id index single-owner, which is what makes removal on
237
+ // delete/truncate safe.
238
+ if (this.ids.has(stamped.id)) {
239
+ throw new DuplicateEnvelopeIdError(stamped.id, toExecutionId);
240
+ }
241
+ copied.push(stamped);
242
+ }
243
+ for (const e of copied) {
244
+ this.ids.add(e.id);
245
+ dst.push(e);
246
+ }
247
+ this.setLog(toExecutionId, dst);
248
+ }
249
+
250
+ async acquireFence(executionId: string): Promise<number> {
251
+ await this.tick();
252
+ const next = (this.fences.get(executionId) ?? 0) + 1;
253
+ this.fences.set(executionId, next);
254
+ return next;
255
+ }
256
+
257
+ /**
258
+ * ADR 0118 D1 — one synchronous step after the tick, so it cannot interleave with an append's
259
+ * own check-and-commit (which is also synchronous after ITS tick): the token compare-and-set
260
+ * plus a scan of the tail past `parkSeq`, which is short (the events appended since the park).
261
+ */
262
+ async acquireFenceIf(
263
+ executionId: string,
264
+ expect: { fence: number; parkSeq: number }
265
+ ): Promise<AcquireFenceIfResult> {
266
+ await this.tick();
267
+ const current = this.fences.get(executionId) ?? 0;
268
+ if (current !== expect.fence) return { stale: true, fence: current };
269
+ const list = this.log.get(executionId) ?? [];
270
+ for (let i = lowerBoundBySequence(list, expect.parkSeq + 1); i < list.length; i++) {
271
+ if (isAnsweringKind(list[i] as EventEnvelope)) return { stale: true, fence: current };
272
+ }
273
+ this.fences.set(executionId, current + 1);
274
+ return { fence: current + 1 };
275
+ }
276
+
277
+ /**
278
+ * SPI 2.2.0 (ADR 0116 amendment) — one synchronous step after the tick, so the existence,
279
+ * head and token checks and the commit cannot interleave with any other append, acquire or
280
+ * delete. The token is compared, never presented: the fence rule is deliberately skipped.
281
+ */
282
+ async appendIfUnchanged(
283
+ executionId: string,
284
+ event: EventEnvelope | null,
285
+ expect?: TipObservation
286
+ ): Promise<AppendIfUnchangedResult> {
287
+ await this.tick();
288
+ if (event !== null && (expect === undefined || event.executionId !== executionId)) {
289
+ throw new TypeError(
290
+ "appendIfUnchanged: an event needs `expect` and the matching executionId"
291
+ );
292
+ }
293
+ const list = this.log.get(executionId);
294
+ const fence = this.fences.get(executionId) ?? 0;
295
+ if (list === undefined || list.length === 0 || (list[0] as EventEnvelope).sequence !== 0) {
296
+ return { appended: false, reason: "gone", head: null, fence, anchor: null };
297
+ }
298
+ const head = (list[list.length - 1] as EventEnvelope).sequence;
299
+ const anchor = String(this.incarnations.get(executionId));
300
+ if (event === null) return { appended: false, reason: "observed", head, fence, anchor };
301
+ const want = expect as TipObservation;
302
+ if (
303
+ head !== want.head ||
304
+ fence !== want.fence ||
305
+ anchor !== want.anchor ||
306
+ event.sequence !== head + 1
307
+ ) {
308
+ return { appended: false, reason: "moved", head, fence, anchor };
309
+ }
310
+ this.assertAppendable(event, false);
311
+ this.commit(event);
312
+ this.subscribers.emit(event);
313
+ return { appended: true };
314
+ }
315
+
316
+ async deleteExecution(executionId: string): Promise<void> {
317
+ await this.tick();
318
+ // Release the ids too, or a re-created execution could never re-append the events it
319
+ // just erased — and the index would grow forever across a retention sweep.
320
+ for (const e of this.log.get(executionId) ?? []) this.ids.delete(e.id);
321
+ if (this.log.delete(executionId)) this.sortedIds = undefined;
322
+ this.fences.delete(executionId);
323
+ this.incarnations.delete(executionId);
324
+ }
325
+
326
+ async deleteThrough(executionId: string, through: number): Promise<void> {
327
+ await this.tick();
328
+ const list = this.log.get(executionId);
329
+ if (list === undefined) return;
330
+ // One synchronous pass (no reader sees half of it): sequence 0 goes too, unlike truncatePrefix.
331
+ const kept: EventEnvelope[] = [];
332
+ for (const e of list) {
333
+ if (e.sequence > through) kept.push(e);
334
+ else this.ids.delete(e.id);
335
+ }
336
+ if (kept.length === 0) {
337
+ await this.deleteExecution(executionId);
338
+ return;
339
+ }
340
+ this.log.set(executionId, kept);
341
+ }
342
+
343
+ private setLog(executionId: string, list: EventEnvelope[]): void {
344
+ if (!this.log.has(executionId)) this.sortedIds = undefined;
345
+ if (list[0]?.sequence === 0 && !this.incarnations.has(executionId)) {
346
+ this.incarnations.set(executionId, this.nextIncarnation++);
347
+ }
348
+ this.log.set(executionId, list);
349
+ }
350
+
351
+ async listExecutions(): Promise<string[]> {
352
+ await this.tick();
353
+ return [...this.log.keys()];
354
+ }
355
+
356
+ async listExecutionsPage(opts?: {
357
+ cursor?: string;
358
+ limit?: number;
359
+ }): Promise<{ executionIds: string[]; nextCursor?: string }> {
360
+ await this.tick();
361
+ const limit = opts?.limit ?? 1000;
362
+ const cursor = opts?.cursor;
363
+ // SPI 2.0.0 — code point order, and the cursor may be any string: binary-search the
364
+ // first id strictly after it.
365
+ this.sortedIds ??= [...this.log.keys()].sort(compareExecutionIds);
366
+ const sorted = this.sortedIds;
367
+ let start = 0;
368
+ if (cursor !== undefined) {
369
+ let hi = sorted.length;
370
+ while (start < hi) {
371
+ const mid = (start + hi) >>> 1;
372
+ if (compareExecutionIds(sorted[mid] as string, cursor) > 0) hi = mid;
373
+ else start = mid + 1;
374
+ }
375
+ }
376
+ const executionIds = sorted.slice(start, start + limit);
377
+ const last = executionIds[executionIds.length - 1];
378
+ return {
379
+ executionIds,
380
+ ...(last !== undefined && start + limit < sorted.length && { nextCursor: last }),
381
+ };
382
+ }
383
+
384
+ async truncatePrefix(executionId: string, throughSequence: number): Promise<void> {
385
+ await this.truncatePrefixKeeping(executionId, throughSequence, []);
386
+ }
387
+
388
+ async truncatePrefixKeeping(
389
+ executionId: string,
390
+ throughSequence: number,
391
+ keepEventTypes: readonly string[]
392
+ ): Promise<void> {
393
+ await this.tick();
394
+ const list = this.log.get(executionId);
395
+ if (list === undefined) return;
396
+ const keepTypes = new Set(keepEventTypes);
397
+ // Preserve sequence 0 (bootstrap anchor) and every kept-type event; drop the rest of
398
+ // (0..throughSequence]. One synchronous pass, so no reader sees a half-truncated prefix.
399
+ const kept: EventEnvelope[] = [];
400
+ for (const e of list) {
401
+ if (e.sequence === 0 || e.sequence > throughSequence || keepTypes.has(e.eventType)) {
402
+ kept.push(e);
403
+ } else {
404
+ this.ids.delete(e.id); // compacted away — its id is free again
405
+ }
406
+ }
407
+ this.setLog(executionId, kept);
408
+ }
409
+
410
+ async subscribe(
411
+ executionId: string,
412
+ handler: (event: EventEnvelope) => void
413
+ ): Promise<() => void> {
414
+ await this.tick();
415
+ return this.subscribers.add(executionId, handler);
416
+ }
417
+
418
+ async close(): Promise<void> {
419
+ this.ids.clear();
420
+ this.log.clear();
421
+ this.incarnations.clear();
422
+ this.sortedIds = undefined;
423
+ this.subscribers.clear();
424
+ }
425
+ }
@@ -0,0 +1,178 @@
1
+ import {
2
+ BranchPromotionConflictError,
3
+ type BranchRef,
4
+ type BranchRegistry,
5
+ type LineageRecord,
6
+ type ListRefsOptions,
7
+ type PromoteOptions,
8
+ assertListRefsLimit,
9
+ } from "./BranchRegistry";
10
+ import { compareExecutionIds } from "./executionIdOrder";
11
+
12
+ export interface InMemoryBranchRegistryOptions {
13
+ /** Injectable clock for createdAt/updatedAt (defaults to Date.now). */
14
+ now?: () => number;
15
+ }
16
+
17
+ /**
18
+ * Ephemeral, Map-based {@link BranchRegistry} — the browser-safe / test reference impl
19
+ * alongside the Sqlite and Postgres ones. Pure JS, no native deps. Behaviour matches
20
+ * the durable backends (verified by `runBranchRegistryConformance` in `@loomwork/testing`).
21
+ */
22
+ export class InMemoryBranchRegistry implements BranchRegistry {
23
+ private readonly refs = new Map<string, BranchRef>();
24
+ private readonly lineage = new Map<string, LineageRecord>();
25
+ private readonly now: () => number;
26
+ // SPI 2.0.0 (L21, ADR 0108 addendum) — ref names in Unicode CODE POINT order
27
+ // (`compareExecutionIds`), kept sorted incrementally rather than re-sorted per call.
28
+ // Mirrors `InMemoryAsyncEventStore`'s `sortedIds` cache, so a page costs a binary search
29
+ // plus the page size, not an O(n log n) re-sort of the whole registry on every call.
30
+ //
31
+ // NOT `String.prototype.localeCompare`: it is locale- and normalization-aware, so two
32
+ // DISTINCT, canonically-equivalent names (an NFC "é", U+00E9, and its NFD decomposition
33
+ // "e\u0301") can compare equal. `listRefs`'s `after` filter used to test
34
+ // `name.localeCompare(after) > 0`, so a name tied with the cursor by that comparator was
35
+ // silently excluded — an L10-style skip at exactly the page boundary `pruneBranchRefs`
36
+ // resumes from.
37
+ //
38
+ // Review round 1 (P3) — `deleteRef` does NOT remove the name from this array; it becomes
39
+ // a TOMBSTONE (absent from `refs`, still present here) that `listRefs` skips while paging,
40
+ // and that gets swept out the next time this array is rebuilt. Deleting never reorders the
41
+ // survivors, so there is nothing to re-sort — `pruneBranchRefs` interleaves one `listRefs`
42
+ // page with N `deleteRef` calls, and invalidating per delete forced a full re-sort on
43
+ // every single page (measured: 100k refs, 179s). New names are binary-INSERTed in place
44
+ // instead, which also keeps a revived name (deleted, then `putRef`-created again under the
45
+ // same string) from producing a duplicate entry — see `insertSorted`. Compacted lazily in
46
+ // `listRefs`, once tombstones make up at least half the array, rather than never (an
47
+ // unbounded array over a long-lived registry) or on every delete (the cost above).
48
+ private sortedNames: string[] | undefined;
49
+
50
+ constructor(options: InMemoryBranchRegistryOptions = {}) {
51
+ this.now = options.now ?? (() => Date.now());
52
+ }
53
+
54
+ /** Binary-insert `name` into `sortedNames`, no-op if it is already there (live or a tombstone). */
55
+ private insertSorted(name: string): void {
56
+ if (this.sortedNames === undefined) return; // computed fresh, lazily, on the next listRefs
57
+ const arr = this.sortedNames;
58
+ let lo = 0;
59
+ let hi = arr.length;
60
+ while (lo < hi) {
61
+ const mid = (lo + hi) >>> 1;
62
+ if (compareExecutionIds(arr[mid] as string, name) < 0) lo = mid + 1;
63
+ else hi = mid;
64
+ }
65
+ if (arr[lo] !== name) arr.splice(lo, 0, name);
66
+ }
67
+
68
+ async putRef(ref: BranchRef): Promise<void> {
69
+ if (!this.refs.has(ref.name)) this.insertSorted(ref.name);
70
+ this.refs.set(ref.name, { ...ref });
71
+ }
72
+
73
+ async getRef(name: string): Promise<BranchRef | null> {
74
+ const ref = this.refs.get(name);
75
+ return ref !== undefined ? { ...ref } : null;
76
+ }
77
+
78
+ async listRefs(options?: ListRefsOptions): Promise<BranchRef[]> {
79
+ assertListRefsLimit(options?.limit);
80
+ // Lazy compaction: `deleteRef` never removes a name from `sortedNames` (see its doc
81
+ // comment), so once tombstones make up at least half the array, drop them by rebuilding
82
+ // from the live map — bounding how long a dead name can inflate every later scan, without
83
+ // paying for a rebuild on every delete.
84
+ if (this.sortedNames !== undefined && this.sortedNames.length >= this.refs.size * 2) {
85
+ this.sortedNames = undefined;
86
+ }
87
+ this.sortedNames ??= [...this.refs.keys()].sort(compareExecutionIds);
88
+ const sorted = this.sortedNames;
89
+ const after = options?.after;
90
+ // Binary-search the first name strictly after the cursor (any string, not only one
91
+ // this store returned) — the same seek `InMemoryAsyncEventStore.listExecutionsPage`
92
+ // does for execution ids. Tombstones do not disturb this: removing a name from `refs`
93
+ // never changes the relative order of the names that remain in `sortedNames`.
94
+ let start = 0;
95
+ if (after !== undefined) {
96
+ let hi = sorted.length;
97
+ while (start < hi) {
98
+ const mid = (start + hi) >>> 1;
99
+ if (compareExecutionIds(sorted[mid] as string, after) > 0) hi = mid;
100
+ else start = mid + 1;
101
+ }
102
+ }
103
+ const limit = options?.limit;
104
+ const page: BranchRef[] = [];
105
+ for (let i = start; i < sorted.length; i++) {
106
+ const ref = this.refs.get(sorted[i] as string);
107
+ if (ref === undefined) continue; // a tombstone — deleted since this name was indexed
108
+ page.push({ ...ref });
109
+ if (limit !== undefined && page.length >= limit) break;
110
+ }
111
+ return page;
112
+ }
113
+
114
+ async moveTip(name: string, tip: number): Promise<void> {
115
+ const ref = this.refs.get(name);
116
+ if (ref === undefined) return;
117
+ ref.tip = tip;
118
+ ref.updatedAt = this.now();
119
+ }
120
+
121
+ async promote(fromName: string, toName: string, options?: PromoteOptions): Promise<void> {
122
+ const from = this.refs.get(fromName);
123
+ if (from === undefined) {
124
+ throw new Error(`promote: branch "${fromName}" does not exist`);
125
+ }
126
+ const now = this.now();
127
+ const to = this.refs.get(toName);
128
+ // CAS. Single-threaded by construction here, so read-then-write IS atomic; the durable
129
+ // backends do the same check inside one statement/transaction.
130
+ if (options !== undefined && "expect" in options) {
131
+ const actual = to?.executionId ?? null;
132
+ if (actual !== (options.expect ?? null)) {
133
+ throw new BranchPromotionConflictError(toName, options.expect ?? null, actual);
134
+ }
135
+ }
136
+ if (to === undefined) {
137
+ this.insertSorted(toName); // a new name enters the order
138
+ this.refs.set(toName, {
139
+ name: toName,
140
+ executionId: from.executionId,
141
+ tip: from.tip,
142
+ createdAt: now,
143
+ updatedAt: now,
144
+ });
145
+ } else {
146
+ to.executionId = from.executionId;
147
+ to.tip = from.tip;
148
+ to.updatedAt = now;
149
+ }
150
+ }
151
+
152
+ async deleteRef(name: string): Promise<void> {
153
+ // Deliberately does NOT touch `sortedNames` — see its doc comment. The name becomes a
154
+ // tombstone, skipped by `listRefs` and compacted away lazily.
155
+ this.refs.delete(name);
156
+ }
157
+
158
+ async recordLineage(rec: Omit<LineageRecord, "createdAt">): Promise<void> {
159
+ this.lineage.set(rec.executionId, { ...rec, createdAt: this.now() });
160
+ }
161
+
162
+ async getLineage(executionId: string): Promise<LineageRecord | null> {
163
+ const rec = this.lineage.get(executionId);
164
+ return rec !== undefined ? { ...rec } : null;
165
+ }
166
+
167
+ async removeLineage(executionId: string): Promise<void> {
168
+ this.lineage.delete(executionId);
169
+ }
170
+
171
+ async children(executionId: string): Promise<LineageRecord[]> {
172
+ return [...this.lineage.values()]
173
+ .filter((l) => l.parentExecutionId === executionId)
174
+ .map((l) => ({ ...l }));
175
+ }
176
+
177
+ async close(): Promise<void> {}
178
+ }