@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.
- 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,308 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
|
|
3
|
+
// ── ADR 0138 — the compaction archive SPI ────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// `LogLifecycle.compact` removes the prefix of a log below its checkpoint. Under ADR 0138 it MOVES
|
|
6
|
+
// that prefix: the removed envelopes are appended here first, then truncated. Nothing in loom
|
|
7
|
+
// deletes a fact the log held, so an auditor can recover `archive ∪ surviving log == original log`.
|
|
8
|
+
//
|
|
9
|
+
// The archive is deliberately NOT an EventStore and NOT the content store: `readRange` of the log
|
|
10
|
+
// never sees it (replay and resume cannot read it), no sweep enumerates it, and per-subject erasure
|
|
11
|
+
// is a property of ONE store (a sealing wrapper over the same KeyStore as the event store).
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* SPI version of {@link ArchiveStore} (ADR 0138). Pinned by `runArchiveStoreConformance`.
|
|
15
|
+
*
|
|
16
|
+
* 1.0.0: `append` / `headers` / `read` / `listExecutions` (paged) / optional `deleteExecution` /
|
|
17
|
+
* `close`; every segment header carries the log `incarnation` it belongs to and an optional `kind`.
|
|
18
|
+
*
|
|
19
|
+
* 1.1.0 (ADR 0139 N1/N7, additive): the optional `replaceSegments(key, expected, next)`, an ATOMIC
|
|
20
|
+
* compare-and-swap of a key's whole segment list (a concurrent `append` makes it fail with
|
|
21
|
+
* {@link ArchiveConflictError}; nothing is written), and the optional header field `senderDigest`
|
|
22
|
+
* (a digest of the senders of a PLAINTEXT reserved-stream segment, which the digest itself masks). A
|
|
23
|
+
* 1.0.0 store conforms; the erasure that needs `replaceSegments` refuses an archive without it.
|
|
24
|
+
*/
|
|
25
|
+
export const ARCHIVE_STORE_SPI_VERSION = "1.1.0";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a segment holds. Absent / `"events"`: the exact envelopes a compaction removed. `"legacy-checkpoint"`:
|
|
29
|
+
* ONE synthetic envelope carrying a checkpoint (snapshot) written before the archive existed, byte-exact:
|
|
30
|
+
* its folded state is the only copy of the settled steps' inputs the pre-0138 compactions removed.
|
|
31
|
+
*/
|
|
32
|
+
export type ArchiveSegmentKind = "events" | "legacy-checkpoint";
|
|
33
|
+
|
|
34
|
+
/** The `eventType` of the synthetic envelope inside a `"legacy-checkpoint"` segment. */
|
|
35
|
+
export const ARCHIVE_LEGACY_CHECKPOINT_EVENT = "ARCHIVE_LEGACY_CHECKPOINT";
|
|
36
|
+
|
|
37
|
+
/** What a segment says about itself without its envelopes. */
|
|
38
|
+
export interface ArchiveSegmentHeader {
|
|
39
|
+
/** The lowest sequence this segment covers (inclusive). */
|
|
40
|
+
readonly from: number;
|
|
41
|
+
/** The highest sequence it covers (inclusive): the compaction checkpoint's sequence. */
|
|
42
|
+
readonly through: number;
|
|
43
|
+
/** How many envelopes it holds (sequences inside `[from, through]` a compaction kept are absent). */
|
|
44
|
+
readonly count: number;
|
|
45
|
+
/** {@link segmentDigest} of the envelopes, in order. Stored and returned verbatim. */
|
|
46
|
+
readonly digest: string;
|
|
47
|
+
/**
|
|
48
|
+
* The incarnation of the log this segment came from: the {@link segmentDigest} of its bootstrap
|
|
49
|
+
* event (sequence 0, which survives every compaction; the id alone is `<executionId>-0` and says
|
|
50
|
+
* nothing). A segment of another incarnation under the same execution id (an id REUSED after a
|
|
51
|
+
* delete) is refused with {@link ArchiveIncarnationError}, never inherited. Two runs whose
|
|
52
|
+
* bootstrap envelopes are byte-identical (same input, same millisecond) are not told apart here;
|
|
53
|
+
* their later events then disagree, which the compaction and the audit read catch byte-for-byte.
|
|
54
|
+
*/
|
|
55
|
+
readonly incarnation: string;
|
|
56
|
+
/** Absent means `"events"`. Stored and returned verbatim. */
|
|
57
|
+
readonly kind?: ArchiveSegmentKind;
|
|
58
|
+
/**
|
|
59
|
+
* SPI 1.1.0 (ADR 0139 N7), PLAINTEXT reserved-stream segments only: the sender digest of the
|
|
60
|
+
* envelopes' senders. The segment `digest` masks `payload.sender` (an erasure may redact it), so
|
|
61
|
+
* without this the sender field would have no bit-rot detection; a redaction recomputes it in the same
|
|
62
|
+
* atomic {@link ArchiveStore.replaceSegments}. Stored and returned verbatim; absent on every other segment
|
|
63
|
+
* (a sealing archive authenticates the sender with AEAD instead and never stores it).
|
|
64
|
+
*/
|
|
65
|
+
readonly senderDigest?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One contiguous range of removed envelopes. */
|
|
69
|
+
export interface ArchiveSegment extends ArchiveSegmentHeader {
|
|
70
|
+
/** The exact envelopes the log held, ascending by sequence. */
|
|
71
|
+
readonly envelopes: readonly EventEnvelope[];
|
|
72
|
+
/**
|
|
73
|
+
* Opaque sealed form. A sealing decorator (`EncryptingArchiveStore`) stores its ciphertext here
|
|
74
|
+
* and an EMPTY `envelopes`; a plain backend persists it verbatim and never looks inside.
|
|
75
|
+
*/
|
|
76
|
+
readonly sealed?: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Where compaction puts the events it removes (ADR 0138). Append-only: there is no per-segment
|
|
81
|
+
* delete, only {@link ArchiveStore.deleteExecution} for the existing whole-execution retention path.
|
|
82
|
+
*
|
|
83
|
+
* Contract (pinned by the conformance kit):
|
|
84
|
+
* - `append` is IDEMPOTENT for an identical segment (same `from`, `through`, `digest`): a crash
|
|
85
|
+
* between "archive" and "truncate" is retried by re-compaction, and the retry must not fail.
|
|
86
|
+
* - Any other segment must start strictly after the last one's `through`; an overlap or a rewind
|
|
87
|
+
* rejects with {@link ArchiveConflictError} and writes NOTHING.
|
|
88
|
+
* - `headers` and `read` return segments in ascending `from`. `read` returns every segment that
|
|
89
|
+
* overlaps the range, WHOLE (the caller trims), exactly what was appended.
|
|
90
|
+
* - An unknown execution reads as `[]`.
|
|
91
|
+
*/
|
|
92
|
+
export interface ArchiveStore {
|
|
93
|
+
append(executionId: string, segment: ArchiveSegment): Promise<void>;
|
|
94
|
+
/** The segment headers, ascending. Cheap: no envelopes are read. */
|
|
95
|
+
headers(executionId: string): Promise<ArchiveSegmentHeader[]>;
|
|
96
|
+
/**
|
|
97
|
+
* One page of the execution ids that hold at least one segment, ascending in code point order,
|
|
98
|
+
* keyset-paged like `EventStore.listExecutionsPage`: pass the previous page's `nextCursor`
|
|
99
|
+
* (exclusive); no `nextCursor` means the end. Lets a sweep enumerate what the archive cites.
|
|
100
|
+
*/
|
|
101
|
+
listExecutions(opts?: { cursor?: string; limit?: number }): Promise<ArchiveExecutionPage>;
|
|
102
|
+
read(
|
|
103
|
+
executionId: string,
|
|
104
|
+
range?: { readonly from?: number; readonly through?: number }
|
|
105
|
+
): Promise<ArchiveSegment[]>;
|
|
106
|
+
/** OPTIONAL. The whole-execution delete of the retention path (`LogLifecycle.applyRetention`). */
|
|
107
|
+
deleteExecution?(executionId: string): Promise<void>;
|
|
108
|
+
/**
|
|
109
|
+
* OPTIONAL (SPI 1.1.0, ADR 0139 N1). Atomically replace ALL of `executionId`'s segments with `next`, IF
|
|
110
|
+
* its current headers are exactly `expected` (ascending, as {@link ArchiveStore.headers} returned
|
|
111
|
+
* them, `senderDigest` included): one compare-and-swap, so a concurrent writer (a trim on another node
|
|
112
|
+
* appending a segment after the caller read) makes it reject with {@link ArchiveConflictError} and
|
|
113
|
+
* write NOTHING, and the caller re-reads and retries. Used by erasure to redact a sender in place; the
|
|
114
|
+
* `next` segments must be ascending and non-overlapping, and of the incarnation `expected` holds.
|
|
115
|
+
*/
|
|
116
|
+
replaceSegments?(
|
|
117
|
+
executionId: string,
|
|
118
|
+
expected: readonly ArchiveSegmentHeader[],
|
|
119
|
+
next: readonly ArchiveSegment[]
|
|
120
|
+
): Promise<void>;
|
|
121
|
+
close(): Promise<void>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One page of {@link ArchiveStore.listExecutions}. */
|
|
125
|
+
export interface ArchiveExecutionPage {
|
|
126
|
+
readonly executionIds: string[];
|
|
127
|
+
readonly nextCursor?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The names of the OPTIONAL {@link ArchiveStore} methods, for the decorator plan. */
|
|
131
|
+
export const OPTIONAL_ARCHIVE_STORE_METHODS = ["deleteExecution", "replaceSegments"] as const;
|
|
132
|
+
|
|
133
|
+
/** A segment that overlaps or rewinds past what the archive already holds (nothing was written). */
|
|
134
|
+
export class ArchiveConflictError extends Error {
|
|
135
|
+
readonly retryable = false;
|
|
136
|
+
constructor(
|
|
137
|
+
readonly executionId: string,
|
|
138
|
+
readonly attempted: ArchiveSegmentHeader,
|
|
139
|
+
readonly lastThrough: number,
|
|
140
|
+
/** Set by {@link ArchiveStore.replaceSegments}: why the compare-and-swap failed. */
|
|
141
|
+
readonly replaceReason?: string
|
|
142
|
+
) {
|
|
143
|
+
super(
|
|
144
|
+
replaceReason !== undefined
|
|
145
|
+
? `archive "${executionId}": replaceSegments refused - ${replaceReason}. Nothing was written; re-read the headers and retry.`
|
|
146
|
+
: `archive "${executionId}": segment [${attempted.from}..${attempted.through}] does not start after the archive's last sequence ${lastThrough}, and is not an exact repeat of an archived segment. Segments are append-only and contiguous; nothing was written.`
|
|
147
|
+
);
|
|
148
|
+
this.name = "ArchiveConflictError";
|
|
149
|
+
Object.setPrototypeOf(this, ArchiveConflictError.prototype);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The archive holds segments of another incarnation of this execution id (the id was deleted and
|
|
155
|
+
* REUSED): appending to or reading them as this run's history would mix two runs. Nothing was
|
|
156
|
+
* written. Never retryable.
|
|
157
|
+
*/
|
|
158
|
+
export class ArchiveIncarnationError extends Error {
|
|
159
|
+
readonly retryable = false;
|
|
160
|
+
constructor(
|
|
161
|
+
readonly executionId: string,
|
|
162
|
+
readonly archived: string,
|
|
163
|
+
readonly current: string
|
|
164
|
+
) {
|
|
165
|
+
super(
|
|
166
|
+
`archive "${executionId}": the archive holds segments of a DIFFERENT run (incarnation ${archived}) than the log's (${current}) — the execution id was reused after its earlier run was deleted. The old run's archive was not removed with it, so it is not this run's history and is neither read as such nor extended. Remove the previous run's segments with the retention path (ArchiveStore.deleteExecution), or use a fresh execution id.`
|
|
167
|
+
);
|
|
168
|
+
this.name = "ArchiveIncarnationError";
|
|
169
|
+
Object.setPrototypeOf(this, ArchiveIncarnationError.prototype);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A segment's envelopes cannot be serialized to JSON (circular, BigInt, too deep): it cannot be archived or verified. */
|
|
174
|
+
export class ArchiveUnserializableError extends Error {
|
|
175
|
+
readonly retryable = false;
|
|
176
|
+
constructor(cause: unknown) {
|
|
177
|
+
super(
|
|
178
|
+
`archive: the envelopes cannot be serialized to JSON (${cause instanceof Error ? cause.message : String(cause)}) — an archived event must be plain JSON data, byte-exact.`,
|
|
179
|
+
{ cause }
|
|
180
|
+
);
|
|
181
|
+
this.name = "ArchiveUnserializableError";
|
|
182
|
+
Object.setPrototypeOf(this, ArchiveUnserializableError.prototype);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The envelopes of a segment do not hash to the digest it recorded: the archive was altered. */
|
|
187
|
+
export class ArchiveDigestError extends Error {
|
|
188
|
+
readonly retryable = false;
|
|
189
|
+
constructor(
|
|
190
|
+
readonly executionId: string,
|
|
191
|
+
readonly from: number,
|
|
192
|
+
readonly through: number,
|
|
193
|
+
readonly expected: string,
|
|
194
|
+
readonly actual: string
|
|
195
|
+
) {
|
|
196
|
+
super(
|
|
197
|
+
`archive "${executionId}": segment [${from}..${through}] does not match its recorded digest (expected ${expected}, computed ${actual}) — the archived events were altered or corrupted.`
|
|
198
|
+
);
|
|
199
|
+
this.name = "ArchiveDigestError";
|
|
200
|
+
Object.setPrototypeOf(this, ArchiveDigestError.prototype);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const headerOf = (s: ArchiveSegmentHeader): ArchiveSegmentHeader => ({
|
|
205
|
+
from: s.from,
|
|
206
|
+
through: s.through,
|
|
207
|
+
count: s.count,
|
|
208
|
+
digest: s.digest,
|
|
209
|
+
incarnation: s.incarnation,
|
|
210
|
+
...(s.kind !== undefined && { kind: s.kind }),
|
|
211
|
+
...(s.senderDigest !== undefined && { senderDigest: s.senderDigest }),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const sameHeader = (a: ArchiveSegmentHeader, b: ArchiveSegmentHeader): boolean =>
|
|
215
|
+
a.from === b.from &&
|
|
216
|
+
a.through === b.through &&
|
|
217
|
+
a.count === b.count &&
|
|
218
|
+
a.digest === b.digest &&
|
|
219
|
+
a.incarnation === b.incarnation &&
|
|
220
|
+
(a.kind ?? "events") === (b.kind ?? "events") &&
|
|
221
|
+
(a.senderDigest ?? null) === (b.senderDigest ?? null);
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The shared `replaceSegments` rule for a backend, given the headers stored now: throws
|
|
225
|
+
* {@link ArchiveConflictError} unless they are exactly `expected`, and a `RangeError` for a `next` list
|
|
226
|
+
* that is not ascending, non-overlapping and well-formed. Nothing is written by this check.
|
|
227
|
+
*/
|
|
228
|
+
export function assertReplaceable(
|
|
229
|
+
executionId: string,
|
|
230
|
+
stored: readonly ArchiveSegmentHeader[],
|
|
231
|
+
expected: readonly ArchiveSegmentHeader[],
|
|
232
|
+
next: readonly ArchiveSegmentHeader[]
|
|
233
|
+
): void {
|
|
234
|
+
const attempted =
|
|
235
|
+
next[0] ?? expected[0] ?? stored[0] ?? ({ from: 0, through: 0 } as ArchiveSegmentHeader);
|
|
236
|
+
const last = stored[stored.length - 1]?.through ?? -1;
|
|
237
|
+
if (stored.length !== expected.length) {
|
|
238
|
+
throw new ArchiveConflictError(
|
|
239
|
+
executionId,
|
|
240
|
+
attempted,
|
|
241
|
+
last,
|
|
242
|
+
`the key now holds ${stored.length} segment(s), the caller read ${expected.length}`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
for (let i = 0; i < stored.length; i++) {
|
|
246
|
+
if (!sameHeader(stored[i]!, expected[i]!)) {
|
|
247
|
+
throw new ArchiveConflictError(
|
|
248
|
+
executionId,
|
|
249
|
+
attempted,
|
|
250
|
+
last,
|
|
251
|
+
`segment ${i} [${stored[i]!.from}..${stored[i]!.through}] changed since the caller read it`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
let prev = Number.NEGATIVE_INFINITY;
|
|
256
|
+
for (const h of next) {
|
|
257
|
+
if (!Number.isSafeInteger(h.from) || !Number.isSafeInteger(h.through) || h.from > h.through) {
|
|
258
|
+
throw new RangeError(`archive segment [${h.from}..${h.through}] is malformed`);
|
|
259
|
+
}
|
|
260
|
+
if (h.from <= prev) {
|
|
261
|
+
throw new RangeError(
|
|
262
|
+
`replaceSegments: segments must be ascending and non-overlapping ([${h.from}..${h.through}] follows ${prev})`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
prev = h.through;
|
|
266
|
+
}
|
|
267
|
+
const incarnation = expected[0]?.incarnation;
|
|
268
|
+
if (incarnation !== undefined) {
|
|
269
|
+
for (const h of next) {
|
|
270
|
+
if (h.incarnation !== incarnation) {
|
|
271
|
+
throw new ArchiveIncarnationError(executionId, incarnation, h.incarnation);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The shared `append` rule for a backend, given the headers already stored (ascending): `"noop"`
|
|
279
|
+
* for an exact repeat of a stored segment, `"append"` when the segment starts after the last one,
|
|
280
|
+
* else throws {@link ArchiveConflictError}.
|
|
281
|
+
*/
|
|
282
|
+
export function classifyAppend(
|
|
283
|
+
executionId: string,
|
|
284
|
+
stored: readonly ArchiveSegmentHeader[],
|
|
285
|
+
segment: ArchiveSegmentHeader
|
|
286
|
+
): "append" | "noop" {
|
|
287
|
+
if (!Number.isSafeInteger(segment.from) || !Number.isSafeInteger(segment.through)) {
|
|
288
|
+
throw new RangeError("archive segment bounds must be safe integers");
|
|
289
|
+
}
|
|
290
|
+
if (segment.from > segment.through) {
|
|
291
|
+
throw new RangeError(`archive segment [${segment.from}..${segment.through}] is inverted`);
|
|
292
|
+
}
|
|
293
|
+
for (const h of stored) {
|
|
294
|
+
if (h.incarnation !== segment.incarnation) {
|
|
295
|
+
throw new ArchiveIncarnationError(executionId, h.incarnation, segment.incarnation);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const h of stored) {
|
|
299
|
+
if (h.from === segment.from && h.through === segment.through && h.digest === segment.digest) {
|
|
300
|
+
return "noop";
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const last = stored[stored.length - 1];
|
|
304
|
+
if (last !== undefined && segment.from <= last.through) {
|
|
305
|
+
throw new ArchiveConflictError(executionId, segment, last.through);
|
|
306
|
+
}
|
|
307
|
+
return "append";
|
|
308
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SPI 2.2.0 — the LEGACY (pre-ADR 0129) opaque creation marker `appendIfUnchanged` compares (see `TipObservation`):
|
|
5
|
+
* derived from an execution's sequence-0 envelope, so a log deleted and re-created under the same
|
|
6
|
+
* id reads as a different execution. Envelope ids are deterministic, hence the timestamp too.
|
|
7
|
+
* Every reference store derives it with this one function so they agree byte for byte.
|
|
8
|
+
*
|
|
9
|
+
* SPI 2.3.0 (ADR 0129): this is no longer the anchor of a log whose store assigned an
|
|
10
|
+
* incarnation (it cannot tell two byte-identical re-creations apart). It remains the anchor of a
|
|
11
|
+
* log created before the store gained that metadata, and of a store that cannot assign one.
|
|
12
|
+
*/
|
|
13
|
+
export function creationMarker(first: Pick<EventEnvelope, "id" | "timestamp">): string {
|
|
14
|
+
return `${first.timestamp}:${first.id}`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { DecoratorPlan } from "./installOptional";
|
|
2
|
+
import type { EventStore, OptionalEventStoreMethod } from "./types";
|
|
3
|
+
|
|
4
|
+
// ── Compile-time proof that a DecoratorPlan cannot silently drop a method ────────
|
|
5
|
+
//
|
|
6
|
+
// This is a SOURCE file on purpose: `pnpm typecheck` (tsc --build) excludes `*.test.ts`, so a
|
|
7
|
+
// type-level negative in a test file would never be checked. If `DecoratorPlan` ever lets a
|
|
8
|
+
// plan omit an optional method again (a homomorphic mapped type over `EventStore` inherits the
|
|
9
|
+
// methods' `?` and makes every key optional), the `@ts-expect-error` below becomes unused and
|
|
10
|
+
// the build fails. Nothing here runs; it exports nothing.
|
|
11
|
+
|
|
12
|
+
type Plan = DecoratorPlan<EventStore, OptionalEventStoreMethod>;
|
|
13
|
+
|
|
14
|
+
const complete = {
|
|
15
|
+
appendBatch: "forward",
|
|
16
|
+
readRange: "forward",
|
|
17
|
+
head: "forward",
|
|
18
|
+
acquireFence: "forward",
|
|
19
|
+
acquireFenceIf: "forward",
|
|
20
|
+
appendIfUnchanged: "forward",
|
|
21
|
+
storeNow: "forward",
|
|
22
|
+
sealingCapability: "forward",
|
|
23
|
+
readAtRest: "forward",
|
|
24
|
+
copyPrefix: "forward",
|
|
25
|
+
deleteExecution: "forward",
|
|
26
|
+
deleteThrough: "forward",
|
|
27
|
+
truncatePrefix: "forward",
|
|
28
|
+
truncatePrefixKeeping: "forward",
|
|
29
|
+
listExecutions: "forward",
|
|
30
|
+
listExecutionsPage: { refuse: "a refusal is a decision, and still a key" },
|
|
31
|
+
} satisfies Plan;
|
|
32
|
+
void complete;
|
|
33
|
+
|
|
34
|
+
// @ts-expect-error — listExecutionsPage is missing: a plan must decide every optional method.
|
|
35
|
+
const missingOne: Plan = {
|
|
36
|
+
appendBatch: "forward",
|
|
37
|
+
readRange: "forward",
|
|
38
|
+
head: "forward",
|
|
39
|
+
acquireFence: "forward",
|
|
40
|
+
acquireFenceIf: "forward",
|
|
41
|
+
appendIfUnchanged: "forward",
|
|
42
|
+
storeNow: "forward",
|
|
43
|
+
sealingCapability: "forward",
|
|
44
|
+
readAtRest: "forward",
|
|
45
|
+
copyPrefix: "forward",
|
|
46
|
+
deleteExecution: "forward",
|
|
47
|
+
deleteThrough: "forward",
|
|
48
|
+
truncatePrefix: "forward",
|
|
49
|
+
truncatePrefixKeeping: "forward",
|
|
50
|
+
listExecutions: "forward",
|
|
51
|
+
};
|
|
52
|
+
void missingOne;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { type EventEnvelope, type EventKind, KERNEL_EVENT_KINDS } from "@loomwork/core";
|
|
2
|
+
|
|
3
|
+
// ADR 0118 D1 — which kernel event kinds ANSWER a park.
|
|
4
|
+
//
|
|
5
|
+
// `acquireFenceIf(id, { fence, parkSeq })` (landing with R8k) refuses when an ANSWERING event
|
|
6
|
+
// exists past the park it is answering: another resumer's `EXECUTION_RESUMED`, a later park, or a
|
|
7
|
+
// terminal fact. Every other event is an audit append or an application event that follows a park without answering it
|
|
8
|
+
// (an unfenced `EXECUTION_DENIED`, a late `OUTCOME_RECORDED`, a `STEP_FAILED` while parked, a
|
|
9
|
+
// `MESSAGE_CONSUMED` written by the running owner, ...) and must NOT make the acquire stale, or a
|
|
10
|
+
// legitimate resume would be refused for an audit row.
|
|
11
|
+
//
|
|
12
|
+
// The map is a REQUIRED `Record<EventKind, ...>` with no default: adding a kind to
|
|
13
|
+
// `KERNEL_EVENT_KINDS` without deciding is a compile error here. The fitness test in
|
|
14
|
+
// `@loomwork/workflow-runtime` pins its keys equal to the fold's handler table.
|
|
15
|
+
|
|
16
|
+
/** Whether an event of this kind, appended after a park, answers it. */
|
|
17
|
+
export type EventKindClass = "answering" | "ignorable";
|
|
18
|
+
|
|
19
|
+
export const EVENT_KIND_CLASS: Readonly<Record<EventKind, EventKindClass>> = {
|
|
20
|
+
// ── answering: they move the execution out of the park (or supersede it) ──
|
|
21
|
+
EXECUTION_RESUMED: "answering", // any payload, incl. the `null` status-only re-open (ADR 0104)
|
|
22
|
+
EXECUTION_SUSPENDED: "answering", // a later park supersedes this one
|
|
23
|
+
EXECUTION_COMPLETED: "answering",
|
|
24
|
+
EXECUTION_FAILED: "answering",
|
|
25
|
+
EXECUTION_CANCELLED: "answering",
|
|
26
|
+
EXECUTION_CONTINUED: "answering", // ADR 0109: terminal, names the successor
|
|
27
|
+
|
|
28
|
+
// ── ignorable: audit / bookkeeping that never answers a park by itself ──
|
|
29
|
+
EXECUTION_STARTED: "ignorable", // sequence 0; precedes every park
|
|
30
|
+
EXECUTION_DENIED: "ignorable", // unfenced, appended after a park (`recordExecutionDenied`)
|
|
31
|
+
OUTCOME_RECORDED: "ignorable", // ADR 0116: terminal-only late attach, or a live emit
|
|
32
|
+
MESSAGE_CONSUMED: "ignorable", // ADR 0110: written by the running owner; the RESUMED answers
|
|
33
|
+
// ADR 0130 residuals D9 (EVENT_SCHEMA 1.25.0; written by quarantineInbox / releaseInbox / auto-release): both land under the park's
|
|
34
|
+
// conditional fence and leave the park unanswered, exactly like MESSAGE_CONSUMED.
|
|
35
|
+
INBOX_QUARANTINED: "ignorable", // the topic's head is held; the park is not answered
|
|
36
|
+
INBOX_RELEASED: "ignorable", // a held entry is released or dropped; the delivery's own facts answer
|
|
37
|
+
EXECUTION_MIGRATED: "ignorable",
|
|
38
|
+
STEP_STARTED: "ignorable",
|
|
39
|
+
STEP_COMPLETED: "ignorable",
|
|
40
|
+
STEP_FAILED: "ignorable", // ADR 0105: a sibling failing while parked
|
|
41
|
+
STEP_CANCELLED: "ignorable",
|
|
42
|
+
STEP_RETRYING: "ignorable",
|
|
43
|
+
PARALLEL_STARTED: "ignorable",
|
|
44
|
+
PARALLEL_COMPLETED: "ignorable", // ADR 0112: the scope outcome, written before a rethrow
|
|
45
|
+
DECISION_RECORDED: "ignorable",
|
|
46
|
+
EFFECT_DENIED: "ignorable",
|
|
47
|
+
EFFECT_INDOUBT: "ignorable",
|
|
48
|
+
EGRESS_DENIED: "ignorable",
|
|
49
|
+
SAFETY_BLOCKED: "ignorable",
|
|
50
|
+
QUOTA_EXCEEDED: "ignorable",
|
|
51
|
+
RESOURCE_METERED: "ignorable",
|
|
52
|
+
APPROVAL_GRANTED: "ignorable",
|
|
53
|
+
TIMER_FIRED: "ignorable",
|
|
54
|
+
PAYLOAD_SHREDDED: "ignorable",
|
|
55
|
+
ERASURE_PENDING: "ignorable",
|
|
56
|
+
EXECUTION_ARCHIVED: "ignorable",
|
|
57
|
+
EXECUTION_DELETED: "ignorable",
|
|
58
|
+
EVENTS_REHOMED: "ignorable",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The two envelope fields the classification reads. */
|
|
62
|
+
export interface ClassifiableEvent {
|
|
63
|
+
readonly eventType: string;
|
|
64
|
+
readonly metadata?: Readonly<Record<string, unknown>> | undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const KERNEL_KIND_SET: ReadonlySet<string> = new Set(KERNEL_EVENT_KINDS);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether an appended event answers a park (what `acquireFenceIf` counts past `parkSeq`).
|
|
71
|
+
*
|
|
72
|
+
* - A kind in {@link KERNEL_EVENT_KINDS} is classified by its {@link EVENT_KIND_CLASS} entry.
|
|
73
|
+
* - A kind NOT in it is an APPLICATION event (`ctx.emit`, a `ctx.stream` delta, a parallel
|
|
74
|
+
* sibling's output) and is IGNORABLE: counting it would let a sibling that keeps emitting after
|
|
75
|
+
* the park make every `acquireFenceIf` stale, so the execution could never be resumed.
|
|
76
|
+
* - EXCEPT an unknown kind that carries `metadata.kernel === true`: a kernel kind written by a
|
|
77
|
+
* NEWER node in a mixed fleet. This node cannot classify it, so it is ANSWERING (fail-closed:
|
|
78
|
+
* the cost of a wrong "answering" is a `contended` retry, of a wrong "ignorable" a fenced-out
|
|
79
|
+
* live body). This is why every kernel kind added after 1.23.0 MUST be stamped
|
|
80
|
+
* `metadata.kernel: true` by its writer — an unstamped new kernel kind is indistinguishable
|
|
81
|
+
* from an app event and would classify as ignorable on an older node.
|
|
82
|
+
*
|
|
83
|
+
* Own-property membership (a `Set`), so `"__proto__"` / `"constructor"` are unknown kinds, not
|
|
84
|
+
* `Object.prototype` members; the stamp must be literally `true`.
|
|
85
|
+
*/
|
|
86
|
+
export function isAnsweringKind(event: ClassifiableEvent): boolean {
|
|
87
|
+
if (KERNEL_KIND_SET.has(event.eventType)) {
|
|
88
|
+
return EVENT_KIND_CLASS[event.eventType as EventKind] === "answering";
|
|
89
|
+
}
|
|
90
|
+
return event.metadata?.kernel === true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const QUIESCENT_TERMINAL_EVENTS = new Set([
|
|
94
|
+
"EXECUTION_COMPLETED",
|
|
95
|
+
"EXECUTION_FAILED",
|
|
96
|
+
"EXECUTION_CANCELLED",
|
|
97
|
+
"EXECUTION_CONTINUED",
|
|
98
|
+
]);
|
|
99
|
+
const QUIESCENT_TERMINAL_STATUS = new Set(["completed", "failed", "cancelled", "continued"]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Does the log show NO runner? The ONE rule `shredSubject` (before it takes a fence) and the
|
|
103
|
+
* erasure coordinator (in the destroy window, for a compacted log with a sealed checkpoint) share.
|
|
104
|
+
* Judged from data that sealing never hides: the LAST answering event of `tail`
|
|
105
|
+
* (`isAnsweringKind`, ignoring audit rows and the shredder's own events). A terminal kind or a park
|
|
106
|
+
* is quiescent; a resume (a terminal followed by a re-open included) or a start with no park is
|
|
107
|
+
* live. With no answering event in `tail`, the checkpoint's `snapshotStatus` decides when it is
|
|
108
|
+
* readable; if it is not (a retry after the key was destroyed), the erase intent already in the
|
|
109
|
+
* log is the proof, because it was only written to a quiescent log. Quiescent is NOT "terminal
|
|
110
|
+
* chain head": a parked (`suspended`) owner is quiescent.
|
|
111
|
+
*/
|
|
112
|
+
export function isQuiescent(
|
|
113
|
+
tail: readonly EventEnvelope[],
|
|
114
|
+
snapshotStatus: string | undefined,
|
|
115
|
+
hasIntent: boolean
|
|
116
|
+
): boolean {
|
|
117
|
+
const last = [...tail].reverse().find((e) => isAnsweringKind(e));
|
|
118
|
+
if (last !== undefined) {
|
|
119
|
+
return (
|
|
120
|
+
QUIESCENT_TERMINAL_EVENTS.has(last.eventType) || last.eventType === "EXECUTION_SUSPENDED"
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (snapshotStatus !== undefined) {
|
|
124
|
+
return QUIESCENT_TERMINAL_STATUS.has(snapshotStatus) || snapshotStatus === "suspended";
|
|
125
|
+
}
|
|
126
|
+
return hasIntent;
|
|
127
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE order of executionIds that `listExecutionsPage` promises (EventStore SPI 2.0.0, ADR 0108):
|
|
3
|
+
* Unicode code point order — the same as UTF-8 byte order, SQL `COLLATE "C"` and SQLite's
|
|
4
|
+
* BINARY collation, so a SQL backend can serve it from a plain b-tree.
|
|
5
|
+
*
|
|
6
|
+
* Not JavaScript's default string order: `<` and `Array.prototype.sort()` compare UTF-16
|
|
7
|
+
* code UNITS, which agree with code points everywhere except that an astral character (a
|
|
8
|
+
* surrogate pair, 0xD800–0xDFFF) sorts BELOW U+E000–U+FFFF by code unit and ABOVE them by
|
|
9
|
+
* code point. `ShardedEventStore` merges shard pages with this comparator and resumes every
|
|
10
|
+
* shard from the merged page's last id, so it and every store must agree exactly — a store
|
|
11
|
+
* paging in any other order (a Postgres database collation, say) silently skips ids.
|
|
12
|
+
*
|
|
13
|
+
* The parity with UTF-8 byte order holds for WELL-FORMED ids only. A lone surrogate has no
|
|
14
|
+
* UTF-8 encoding: node-postgres sends it as U+FFFD, so a Postgres store neither keeps nor
|
|
15
|
+
* orders such an id the way this function does. Execution ids should be well-formed Unicode
|
|
16
|
+
* (every id generator loom ships produces ASCII).
|
|
17
|
+
*
|
|
18
|
+
* O(length of the common prefix), no allocation.
|
|
19
|
+
*/
|
|
20
|
+
export function compareExecutionIds(a: string, b: string): number {
|
|
21
|
+
const n = Math.min(a.length, b.length);
|
|
22
|
+
for (let i = 0; i < n; i++) {
|
|
23
|
+
const x = a.charCodeAt(i);
|
|
24
|
+
const y = b.charCodeAt(i);
|
|
25
|
+
if (x === y) continue;
|
|
26
|
+
// Below 0xD800 code-unit order is code-point order. When both units are at or above it,
|
|
27
|
+
// lift the surrogates above U+E000–U+FFFF (and lower those to make room), which is
|
|
28
|
+
// exactly where their code points sit. (A lone surrogate gets a consistent position here,
|
|
29
|
+
// but no store is obliged to agree: it cannot round-trip through UTF-8.)
|
|
30
|
+
if (x >= 0xd800 && y >= 0xd800) return surrogateLast(x) - surrogateLast(y);
|
|
31
|
+
return x - y;
|
|
32
|
+
}
|
|
33
|
+
return a.length - b.length;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function surrogateLast(unit: number): number {
|
|
37
|
+
return unit >= 0xe000 ? unit - 0x800 : unit + 0x2000;
|
|
38
|
+
}
|
package/src/fencing.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* K9.4 — a fenced store rejected an append carrying a stale (or missing)
|
|
5
|
+
* fencing token: the execution was re-dispatched under a newer token, so the
|
|
6
|
+
* writer is a "zombie" — a network-partitioned or presumed-dead worker whose
|
|
7
|
+
* writes must not land after its execution moved elsewhere. Extends the D3
|
|
8
|
+
* single-writer invariant to the failure domain.
|
|
9
|
+
*/
|
|
10
|
+
export class StaleFenceError extends Error {
|
|
11
|
+
constructor(
|
|
12
|
+
readonly executionId: string,
|
|
13
|
+
readonly staleFence: number | undefined,
|
|
14
|
+
readonly currentFence: number,
|
|
15
|
+
/** The error this was re-raised from, when a decorator rewrote it (e.g. tenant unscoping). */
|
|
16
|
+
readonly cause?: unknown
|
|
17
|
+
) {
|
|
18
|
+
super(
|
|
19
|
+
`stale fence for execution "${executionId}": append carried ` +
|
|
20
|
+
`${staleFence === undefined ? "no token" : `token ${staleFence}`}, current fence is ${currentFence}`
|
|
21
|
+
);
|
|
22
|
+
this.name = "StaleFenceError";
|
|
23
|
+
Object.setPrototypeOf(this, StaleFenceError.prototype);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The fencing token an envelope carries (in `metadata.fence`), if any. */
|
|
28
|
+
export function fenceOf(envelope: EventEnvelope): number | undefined {
|
|
29
|
+
const fence = envelope.metadata?.fence;
|
|
30
|
+
return typeof fence === "number" ? fence : undefined;
|
|
31
|
+
}
|