@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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +95 -2
- package/dist/BranchRegistry.d.ts +140 -0
- package/dist/BranchRegistry.d.ts.map +1 -0
- package/dist/BranchRegistry.js +66 -0
- package/dist/BranchRegistry.js.map +1 -0
- package/dist/CoalescingEventStore.d.ts +97 -0
- package/dist/CoalescingEventStore.d.ts.map +1 -0
- package/dist/CoalescingEventStore.js +180 -0
- package/dist/CoalescingEventStore.js.map +1 -0
- package/dist/InMemoryArchiveStore.d.ts +19 -0
- package/dist/InMemoryArchiveStore.d.ts.map +1 -0
- package/dist/InMemoryArchiveStore.js +49 -0
- package/dist/InMemoryArchiveStore.js.map +1 -0
- package/dist/InMemoryAsyncEventStore.d.ts +111 -0
- package/dist/InMemoryAsyncEventStore.d.ts.map +1 -0
- package/dist/InMemoryAsyncEventStore.js +374 -0
- package/dist/InMemoryAsyncEventStore.js.map +1 -0
- package/dist/InMemoryBranchRegistry.d.ts +31 -0
- package/dist/InMemoryBranchRegistry.d.ts.map +1 -0
- package/dist/InMemoryBranchRegistry.js +167 -0
- package/dist/InMemoryBranchRegistry.js.map +1 -0
- package/dist/ProfilingEventStore.d.ts +75 -0
- package/dist/ProfilingEventStore.d.ts.map +1 -0
- package/dist/ProfilingEventStore.js +133 -0
- package/dist/ProfilingEventStore.js.map +1 -0
- package/dist/ShardPlan.d.ts +87 -0
- package/dist/ShardPlan.d.ts.map +1 -0
- package/dist/ShardPlan.js +193 -0
- package/dist/ShardPlan.js.map +1 -0
- package/dist/ShardedEventStore.d.ts +149 -0
- package/dist/ShardedEventStore.d.ts.map +1 -0
- package/dist/ShardedEventStore.js +468 -0
- package/dist/ShardedEventStore.js.map +1 -0
- package/dist/SubscriberRegistry.d.ts +59 -0
- package/dist/SubscriberRegistry.d.ts.map +1 -0
- package/dist/SubscriberRegistry.js +113 -0
- package/dist/SubscriberRegistry.js.map +1 -0
- package/dist/TenantScopedArchiveStore.d.ts +36 -0
- package/dist/TenantScopedArchiveStore.d.ts.map +1 -0
- package/dist/TenantScopedArchiveStore.js +96 -0
- package/dist/TenantScopedArchiveStore.js.map +1 -0
- package/dist/TenantScopedEventStore.d.ts +59 -0
- package/dist/TenantScopedEventStore.d.ts.map +1 -0
- package/dist/TenantScopedEventStore.js +227 -0
- package/dist/TenantScopedEventStore.js.map +1 -0
- package/dist/archive.d.ts +68 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +207 -0
- package/dist/archive.js.map +1 -0
- package/dist/archiveSpi.d.ts +165 -0
- package/dist/archiveSpi.d.ts.map +1 -0
- package/dist/archiveSpi.js +184 -0
- package/dist/archiveSpi.js.map +1 -0
- package/dist/creationMarker.d.ts +13 -0
- package/dist/creationMarker.d.ts.map +1 -0
- package/dist/creationMarker.js +17 -0
- package/dist/creationMarker.js.map +1 -0
- package/dist/decoratorPlan.typecheck.d.ts +2 -0
- package/dist/decoratorPlan.typecheck.d.ts.map +1 -0
- package/dist/decoratorPlan.typecheck.js +41 -0
- package/dist/decoratorPlan.typecheck.js.map +1 -0
- package/dist/eventKindClass.d.ts +40 -0
- package/dist/eventKindClass.d.ts.map +1 -0
- package/dist/eventKindClass.js +99 -0
- package/dist/eventKindClass.js.map +1 -0
- package/dist/executionIdOrder.d.ts +21 -0
- package/dist/executionIdOrder.d.ts.map +1 -0
- package/dist/executionIdOrder.js +43 -0
- package/dist/executionIdOrder.js.map +1 -0
- package/dist/fencing.d.ts +21 -0
- package/dist/fencing.d.ts.map +1 -0
- package/dist/fencing.js +36 -0
- package/dist/fencing.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +126 -0
- package/dist/index.js.map +1 -0
- package/dist/installOptional.d.ts +52 -0
- package/dist/installOptional.d.ts.map +1 -0
- package/dist/installOptional.js +112 -0
- package/dist/installOptional.js.map +1 -0
- package/dist/keyUnavailable.d.ts +67 -0
- package/dist/keyUnavailable.d.ts.map +1 -0
- package/dist/keyUnavailable.js +87 -0
- package/dist/keyUnavailable.js.map +1 -0
- package/dist/leases.d.ts +177 -0
- package/dist/leases.d.ts.map +1 -0
- package/dist/leases.js +142 -0
- package/dist/leases.js.map +1 -0
- package/dist/payloadShredded.d.ts +30 -0
- package/dist/payloadShredded.d.ts.map +1 -0
- package/dist/payloadShredded.js +67 -0
- package/dist/payloadShredded.js.map +1 -0
- package/dist/scaleDecorators.bench.d.ts +2 -0
- package/dist/scaleDecorators.bench.d.ts.map +1 -0
- package/dist/scaleDecorators.bench.js +93 -0
- package/dist/scaleDecorators.bench.js.map +1 -0
- package/dist/sequenceRange.bench.d.ts +2 -0
- package/dist/sequenceRange.bench.d.ts.map +1 -0
- package/dist/sequenceRange.bench.js +46 -0
- package/dist/sequenceRange.bench.js.map +1 -0
- package/dist/sequenceRange.d.ts +10 -0
- package/dist/sequenceRange.d.ts.map +1 -0
- package/dist/sequenceRange.js +35 -0
- package/dist/sequenceRange.js.map +1 -0
- package/dist/timers.d.ts +87 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +68 -0
- package/dist/timers.js.map +1 -0
- package/dist/types.d.ts +634 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +429 -0
- package/dist/types.js.map +1 -0
- package/dist/writeConflicts.d.ts +46 -0
- package/dist/writeConflicts.d.ts.map +1 -0
- package/dist/writeConflicts.js +63 -0
- package/dist/writeConflicts.js.map +1 -0
- package/memory.d.ts +4 -0
- package/memory.js +13 -0
- package/package.json +67 -3
- package/sequence.d.ts +1 -0
- package/sequence.js +5 -0
- package/src/BranchRegistry.ts +161 -0
- package/src/CoalescingEventStore.ts +214 -0
- package/src/InMemoryArchiveStore.ts +69 -0
- package/src/InMemoryAsyncEventStore.ts +425 -0
- package/src/InMemoryBranchRegistry.ts +178 -0
- package/src/ProfilingEventStore.ts +153 -0
- package/src/ShardPlan.ts +235 -0
- package/src/ShardedEventStore.ts +557 -0
- package/src/SubscriberRegistry.ts +115 -0
- package/src/TenantScopedArchiveStore.ts +114 -0
- package/src/TenantScopedEventStore.ts +266 -0
- package/src/archive.ts +248 -0
- package/src/archiveSpi.ts +308 -0
- package/src/creationMarker.ts +15 -0
- package/src/decoratorPlan.typecheck.ts +52 -0
- package/src/eventKindClass.ts +127 -0
- package/src/executionIdOrder.ts +38 -0
- package/src/fencing.ts +31 -0
- package/src/index.ts +151 -0
- package/src/installOptional.ts +136 -0
- package/src/keyUnavailable.ts +98 -0
- package/src/leases.ts +319 -0
- package/src/payloadShredded.ts +63 -0
- package/src/scaleDecorators.bench.ts +107 -0
- package/src/sequenceRange.bench.ts +51 -0
- package/src/sequenceRange.ts +35 -0
- package/src/timers.ts +150 -0
- package/src/types.ts +857 -0
- 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
|
+
}
|