@loomwork/event-runtime 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +95 -2
  4. package/dist/BranchRegistry.d.ts +140 -0
  5. package/dist/BranchRegistry.d.ts.map +1 -0
  6. package/dist/BranchRegistry.js +66 -0
  7. package/dist/BranchRegistry.js.map +1 -0
  8. package/dist/CoalescingEventStore.d.ts +97 -0
  9. package/dist/CoalescingEventStore.d.ts.map +1 -0
  10. package/dist/CoalescingEventStore.js +180 -0
  11. package/dist/CoalescingEventStore.js.map +1 -0
  12. package/dist/InMemoryArchiveStore.d.ts +19 -0
  13. package/dist/InMemoryArchiveStore.d.ts.map +1 -0
  14. package/dist/InMemoryArchiveStore.js +49 -0
  15. package/dist/InMemoryArchiveStore.js.map +1 -0
  16. package/dist/InMemoryAsyncEventStore.d.ts +111 -0
  17. package/dist/InMemoryAsyncEventStore.d.ts.map +1 -0
  18. package/dist/InMemoryAsyncEventStore.js +374 -0
  19. package/dist/InMemoryAsyncEventStore.js.map +1 -0
  20. package/dist/InMemoryBranchRegistry.d.ts +31 -0
  21. package/dist/InMemoryBranchRegistry.d.ts.map +1 -0
  22. package/dist/InMemoryBranchRegistry.js +167 -0
  23. package/dist/InMemoryBranchRegistry.js.map +1 -0
  24. package/dist/ProfilingEventStore.d.ts +75 -0
  25. package/dist/ProfilingEventStore.d.ts.map +1 -0
  26. package/dist/ProfilingEventStore.js +133 -0
  27. package/dist/ProfilingEventStore.js.map +1 -0
  28. package/dist/ShardPlan.d.ts +87 -0
  29. package/dist/ShardPlan.d.ts.map +1 -0
  30. package/dist/ShardPlan.js +193 -0
  31. package/dist/ShardPlan.js.map +1 -0
  32. package/dist/ShardedEventStore.d.ts +149 -0
  33. package/dist/ShardedEventStore.d.ts.map +1 -0
  34. package/dist/ShardedEventStore.js +468 -0
  35. package/dist/ShardedEventStore.js.map +1 -0
  36. package/dist/SubscriberRegistry.d.ts +59 -0
  37. package/dist/SubscriberRegistry.d.ts.map +1 -0
  38. package/dist/SubscriberRegistry.js +113 -0
  39. package/dist/SubscriberRegistry.js.map +1 -0
  40. package/dist/TenantScopedArchiveStore.d.ts +36 -0
  41. package/dist/TenantScopedArchiveStore.d.ts.map +1 -0
  42. package/dist/TenantScopedArchiveStore.js +96 -0
  43. package/dist/TenantScopedArchiveStore.js.map +1 -0
  44. package/dist/TenantScopedEventStore.d.ts +59 -0
  45. package/dist/TenantScopedEventStore.d.ts.map +1 -0
  46. package/dist/TenantScopedEventStore.js +227 -0
  47. package/dist/TenantScopedEventStore.js.map +1 -0
  48. package/dist/archive.d.ts +68 -0
  49. package/dist/archive.d.ts.map +1 -0
  50. package/dist/archive.js +207 -0
  51. package/dist/archive.js.map +1 -0
  52. package/dist/archiveSpi.d.ts +165 -0
  53. package/dist/archiveSpi.d.ts.map +1 -0
  54. package/dist/archiveSpi.js +184 -0
  55. package/dist/archiveSpi.js.map +1 -0
  56. package/dist/creationMarker.d.ts +13 -0
  57. package/dist/creationMarker.d.ts.map +1 -0
  58. package/dist/creationMarker.js +17 -0
  59. package/dist/creationMarker.js.map +1 -0
  60. package/dist/decoratorPlan.typecheck.d.ts +2 -0
  61. package/dist/decoratorPlan.typecheck.d.ts.map +1 -0
  62. package/dist/decoratorPlan.typecheck.js +41 -0
  63. package/dist/decoratorPlan.typecheck.js.map +1 -0
  64. package/dist/eventKindClass.d.ts +40 -0
  65. package/dist/eventKindClass.d.ts.map +1 -0
  66. package/dist/eventKindClass.js +99 -0
  67. package/dist/eventKindClass.js.map +1 -0
  68. package/dist/executionIdOrder.d.ts +21 -0
  69. package/dist/executionIdOrder.d.ts.map +1 -0
  70. package/dist/executionIdOrder.js +43 -0
  71. package/dist/executionIdOrder.js.map +1 -0
  72. package/dist/fencing.d.ts +21 -0
  73. package/dist/fencing.d.ts.map +1 -0
  74. package/dist/fencing.js +36 -0
  75. package/dist/fencing.js.map +1 -0
  76. package/dist/index.d.ts +39 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +126 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/installOptional.d.ts +52 -0
  81. package/dist/installOptional.d.ts.map +1 -0
  82. package/dist/installOptional.js +112 -0
  83. package/dist/installOptional.js.map +1 -0
  84. package/dist/keyUnavailable.d.ts +67 -0
  85. package/dist/keyUnavailable.d.ts.map +1 -0
  86. package/dist/keyUnavailable.js +87 -0
  87. package/dist/keyUnavailable.js.map +1 -0
  88. package/dist/leases.d.ts +177 -0
  89. package/dist/leases.d.ts.map +1 -0
  90. package/dist/leases.js +142 -0
  91. package/dist/leases.js.map +1 -0
  92. package/dist/payloadShredded.d.ts +30 -0
  93. package/dist/payloadShredded.d.ts.map +1 -0
  94. package/dist/payloadShredded.js +67 -0
  95. package/dist/payloadShredded.js.map +1 -0
  96. package/dist/scaleDecorators.bench.d.ts +2 -0
  97. package/dist/scaleDecorators.bench.d.ts.map +1 -0
  98. package/dist/scaleDecorators.bench.js +93 -0
  99. package/dist/scaleDecorators.bench.js.map +1 -0
  100. package/dist/sequenceRange.bench.d.ts +2 -0
  101. package/dist/sequenceRange.bench.d.ts.map +1 -0
  102. package/dist/sequenceRange.bench.js +46 -0
  103. package/dist/sequenceRange.bench.js.map +1 -0
  104. package/dist/sequenceRange.d.ts +10 -0
  105. package/dist/sequenceRange.d.ts.map +1 -0
  106. package/dist/sequenceRange.js +35 -0
  107. package/dist/sequenceRange.js.map +1 -0
  108. package/dist/timers.d.ts +87 -0
  109. package/dist/timers.d.ts.map +1 -0
  110. package/dist/timers.js +68 -0
  111. package/dist/timers.js.map +1 -0
  112. package/dist/types.d.ts +634 -0
  113. package/dist/types.d.ts.map +1 -0
  114. package/dist/types.js +429 -0
  115. package/dist/types.js.map +1 -0
  116. package/dist/writeConflicts.d.ts +46 -0
  117. package/dist/writeConflicts.d.ts.map +1 -0
  118. package/dist/writeConflicts.js +63 -0
  119. package/dist/writeConflicts.js.map +1 -0
  120. package/memory.d.ts +4 -0
  121. package/memory.js +13 -0
  122. package/package.json +67 -3
  123. package/sequence.d.ts +1 -0
  124. package/sequence.js +5 -0
  125. package/src/BranchRegistry.ts +161 -0
  126. package/src/CoalescingEventStore.ts +214 -0
  127. package/src/InMemoryArchiveStore.ts +69 -0
  128. package/src/InMemoryAsyncEventStore.ts +425 -0
  129. package/src/InMemoryBranchRegistry.ts +178 -0
  130. package/src/ProfilingEventStore.ts +153 -0
  131. package/src/ShardPlan.ts +235 -0
  132. package/src/ShardedEventStore.ts +557 -0
  133. package/src/SubscriberRegistry.ts +115 -0
  134. package/src/TenantScopedArchiveStore.ts +114 -0
  135. package/src/TenantScopedEventStore.ts +266 -0
  136. package/src/archive.ts +248 -0
  137. package/src/archiveSpi.ts +308 -0
  138. package/src/creationMarker.ts +15 -0
  139. package/src/decoratorPlan.typecheck.ts +52 -0
  140. package/src/eventKindClass.ts +127 -0
  141. package/src/executionIdOrder.ts +38 -0
  142. package/src/fencing.ts +31 -0
  143. package/src/index.ts +151 -0
  144. package/src/installOptional.ts +136 -0
  145. package/src/keyUnavailable.ts +98 -0
  146. package/src/leases.ts +319 -0
  147. package/src/payloadShredded.ts +63 -0
  148. package/src/scaleDecorators.bench.ts +107 -0
  149. package/src/sequenceRange.bench.ts +51 -0
  150. package/src/sequenceRange.ts +35 -0
  151. package/src/timers.ts +150 -0
  152. package/src/types.ts +857 -0
  153. package/src/writeConflicts.ts +56 -0
@@ -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
+ }