@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,114 @@
1
+ import type {
2
+ ArchiveExecutionPage,
3
+ ArchiveSegment,
4
+ ArchiveSegmentHeader,
5
+ ArchiveStore,
6
+ } from "./archiveSpi";
7
+ import { compareExecutionIds } from "./executionIdOrder";
8
+
9
+ const SEP = ":"; // the same tenant separator as TenantScopedEventStore / TenantScopedSnapshotStore
10
+
11
+ /**
12
+ * ADR 0138 H3 - the archive half of tenant scoping (see `TenantScopedEventStore`). Key-prefixes the
13
+ * executionId so a `LogLifecycle` over a tenant-scoped event store, given a shared archive, can only
14
+ * address its own tenant's segments: another tenant's history cannot be read, extended, or (on the
15
+ * plain-delete retention path) deleted, by construction. `LogLifecycle.compact` REFUSES a tenant-scoped
16
+ * event store paired with an archive that is not scoped to the SAME tenant (`tenantScope` is the marker).
17
+ *
18
+ * The sealing markers of a wrapped `EncryptingArchiveStore` (`keys`, `sealsSegments`, `subjectOf`)
19
+ * are forwarded, so the erasure-boundary guard sees through this decorator.
20
+ */
21
+ export class TenantScopedArchiveStore implements ArchiveStore {
22
+ readonly deleteExecution?: NonNullable<ArchiveStore["deleteExecution"]>;
23
+ readonly replaceSegments?: NonNullable<ArchiveStore["replaceSegments"]>;
24
+
25
+ constructor(
26
+ private readonly inner: ArchiveStore,
27
+ private readonly tenant: string
28
+ ) {
29
+ if (tenant === "" || tenant.includes(SEP)) {
30
+ throw new TypeError(
31
+ `TenantScopedArchiveStore: tenant must be non-empty and must not contain the separator "${SEP}" (got ${JSON.stringify(tenant)})`
32
+ );
33
+ }
34
+ const innerDelete = inner.deleteExecution;
35
+ if (innerDelete !== undefined) {
36
+ this.deleteExecution = (id) => innerDelete.call(inner, this.scope(id));
37
+ }
38
+ const innerReplace = inner.replaceSegments;
39
+ if (innerReplace !== undefined) {
40
+ this.replaceSegments = (id, expected, next) =>
41
+ innerReplace.call(inner, this.scope(id), expected, next);
42
+ }
43
+ }
44
+
45
+ /** Structural marker: the tenant this archive is scoped to (read by `LogLifecycle.compact`). */
46
+ get tenantScope(): string {
47
+ return this.tenant;
48
+ }
49
+ get keys(): unknown {
50
+ return (this.inner as { keys?: unknown }).keys;
51
+ }
52
+ get sealsSegments(): unknown {
53
+ return (this.inner as { sealsSegments?: unknown }).sealsSegments;
54
+ }
55
+ get subjectOf(): unknown {
56
+ return (this.inner as { subjectOf?: unknown }).subjectOf;
57
+ }
58
+
59
+ private scope(id: string): string {
60
+ return `${this.tenant}${SEP}${id}`;
61
+ }
62
+
63
+ append(executionId: string, segment: ArchiveSegment): Promise<void> {
64
+ return this.inner.append(this.scope(executionId), segment);
65
+ }
66
+
67
+ headers(executionId: string): Promise<ArchiveSegmentHeader[]> {
68
+ return this.inner.headers(this.scope(executionId));
69
+ }
70
+
71
+ read(
72
+ executionId: string,
73
+ range?: { readonly from?: number; readonly through?: number }
74
+ ): Promise<ArchiveSegment[]> {
75
+ return this.inner.read(this.scope(executionId), range);
76
+ }
77
+
78
+ async listExecutions(
79
+ opts: { cursor?: string; limit?: number } = {}
80
+ ): Promise<ArchiveExecutionPage> {
81
+ const prefix = `${this.tenant}${SEP}`;
82
+ // Seek to the tenant's contiguous id range, and stop at the first id past it.
83
+ let cursor =
84
+ opts.cursor === undefined ? `${this.tenant}9\u{10FFFF}` : `${prefix}${opts.cursor}`;
85
+ for (;;) {
86
+ const page = await this.inner.listExecutions({
87
+ cursor,
88
+ ...(opts.limit !== undefined && { limit: opts.limit }),
89
+ });
90
+ const executionIds: string[] = [];
91
+ let past = false;
92
+ for (const id of page.executionIds) {
93
+ if (id.startsWith(prefix)) executionIds.push(id.slice(prefix.length));
94
+ else if (compareExecutionIds(id, prefix) > 0) {
95
+ past = true;
96
+ break;
97
+ }
98
+ }
99
+ if (past || page.nextCursor === undefined) return { executionIds };
100
+ const last = executionIds[executionIds.length - 1];
101
+ if (last !== undefined) return { executionIds, nextCursor: last };
102
+ if (page.nextCursor === cursor) {
103
+ throw new TypeError(
104
+ "TenantScopedArchiveStore: listExecutions returned a non-advancing cursor"
105
+ );
106
+ }
107
+ cursor = page.nextCursor;
108
+ }
109
+ }
110
+
111
+ close(): Promise<void> {
112
+ return this.inner.close();
113
+ }
114
+ }
@@ -0,0 +1,266 @@
1
+ import type { EventEnvelope } from "@loomwork/core";
2
+ import { compareExecutionIds } from "./executionIdOrder";
3
+ import { StaleFenceError } from "./fencing";
4
+ import { type DecoratorPlan, installOptional } from "./installOptional";
5
+ import type { EventStore, ExecutionPage, OptionalEventStoreMethod } from "./types";
6
+ import { DuplicateEnvelopeIdError, DuplicateSequenceError } from "./writeConflicts";
7
+
8
+ // The tenant/execution key separator — a control char that can't appear in a
9
+ // normal executionId, so prefixing is unambiguous and un-stripping is exact.
10
+ const SEP = ":"; // ":" is not in the ULID alphabet — an unambiguous, collision-free tenant separator
11
+
12
+ /**
13
+ * One tenant's page of a shared store's keyset-paged enumeration.
14
+ *
15
+ * The cursor is an OUTER id — this tenant's unprefixed executionId — because that is what a
16
+ * caller holds: `ShardedEventStore` (the ADR 0074 composition, sharding outermost) resumes
17
+ * every shard from the last id of its MERGED page, which it only ever sees unprefixed. Before
18
+ * L10 this passed the inner, prefixed cursor out, and got the unprefixed one back in, so
19
+ * `Sharded(TenantScoped(…))` compared `e-04` against `acme:e-…` and re-served its first page
20
+ * forever.
21
+ *
22
+ * Since EventStore SPI 2.0.0 every store pages in code point order, where the ids under
23
+ * `prefix` are CONTIGUOUS. So a page seeks to the tenant (the empty id `prefix` included —
24
+ * a blank stream is still this tenant's, and repair must be able to find it) and stops at
25
+ * the first id past the prefix, instead of walking the whole shared store. The cursor is
26
+ * exclusive, so a fresh scan cannot start AT `prefix`; it starts at {@link seekBelow}, the
27
+ * largest practical string below `prefix` (`acme9\u{10FFFF}` for `acme:`): every other
28
+ * tenant whose name extends this one with a character below `:` (`acme-eu`, `acme.io`,
29
+ * `acme0`…`acme9`) sorts before it and is never read.
30
+ *
31
+ * It does not stop early with an empty page and a cursor: a non-final page with no ids is not
32
+ * something consumers are written for (`ShardedEventStore` reads an empty merged page as the
33
+ * end). Memory stays one inner page.
34
+ */
35
+ /**
36
+ * The exclusive cursor a fresh tenant scan starts from: `SEP` is `:` (U+003A), so the string
37
+ * just below `tenant + ":"` is `tenant + "9"` (U+0039) followed by the highest code point.
38
+ * Only an id that continues past `tenant + "9\u{10FFFF}"` (never produced by an id
39
+ * generator) could sit between it and the tenant's range, and would be walked and dropped.
40
+ * The tenant's blank stream (`tenant + ":"`) sorts after it, so repair still finds it.
41
+ */
42
+ function seekBelow(tenant: string): string {
43
+ return `${tenant}9\u{10FFFF}`;
44
+ }
45
+
46
+ async function pageWithinPrefix(
47
+ inner: EventStore,
48
+ tenant: string,
49
+ prefix: string,
50
+ opts: { cursor?: string; limit?: number } | undefined
51
+ ): Promise<ExecutionPage> {
52
+ let cursor = opts?.cursor === undefined ? seekBelow(tenant) : `${prefix}${opts.cursor}`;
53
+ for (;;) {
54
+ const page = await inner.listExecutionsPage!({
55
+ cursor,
56
+ ...(opts?.limit !== undefined && { limit: opts.limit }),
57
+ });
58
+ const executionIds: string[] = [];
59
+ let pastPrefix = false;
60
+ for (const id of page.executionIds) {
61
+ if (id.startsWith(prefix)) executionIds.push(id.slice(prefix.length));
62
+ // Every id after the tenant's range sorts after `prefix` without starting with it.
63
+ else if (compareExecutionIds(id, prefix) > 0) {
64
+ pastPrefix = true;
65
+ break;
66
+ }
67
+ }
68
+ const last = executionIds[executionIds.length - 1];
69
+ if (pastPrefix || page.nextCursor === undefined) return { executionIds };
70
+ if (last !== undefined) return { executionIds, nextCursor: last };
71
+ if (page.nextCursor === cursor) {
72
+ // The same guard `iterateExecutionIds` applies — except this loop is internal, so
73
+ // without it a store bug would spin here where no caller could see it.
74
+ throw new TypeError(
75
+ "TenantScopedEventStore: listExecutionsPage returned a non-advancing cursor (store bug)"
76
+ );
77
+ }
78
+ cursor = page.nextCursor;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * K12.5 — a tenant-scoped view of a shared {@link EventStore}. Every execution
84
+ * id is key-prefixed with the tenant on the way in and un-prefixed on the way
85
+ * out, so a store scoped to tenant A can only ever ADDRESS keys under A —
86
+ * reading or subscribing to tenant B's executions is impossible **by
87
+ * construction**, not by a filter that could be bypassed. The app sees its own
88
+ * unprefixed ids throughout (append/read/subscribe/fork all round-trip).
89
+ *
90
+ * Default scoping mechanism per the amendment (A7): key-prefixing. Postgres
91
+ * row-level security is an optional hardening layer on top, not a replacement.
92
+ */
93
+ export class TenantScopedEventStore implements EventStore {
94
+ readonly appendBatch?: NonNullable<EventStore["appendBatch"]>;
95
+ readonly head?: NonNullable<EventStore["head"]>;
96
+ readonly readRange?: NonNullable<EventStore["readRange"]>;
97
+ readonly acquireFence?: NonNullable<EventStore["acquireFence"]>;
98
+ readonly acquireFenceIf?: NonNullable<EventStore["acquireFenceIf"]>;
99
+ readonly appendIfUnchanged?: NonNullable<EventStore["appendIfUnchanged"]>;
100
+ readonly storeNow?: NonNullable<EventStore["storeNow"]>;
101
+ readonly sealingCapability?: NonNullable<EventStore["sealingCapability"]>;
102
+ readonly readAtRest?: NonNullable<EventStore["readAtRest"]>;
103
+ readonly copyPrefix?: NonNullable<EventStore["copyPrefix"]>;
104
+ readonly deleteExecution?: NonNullable<EventStore["deleteExecution"]>;
105
+ readonly deleteThrough?: NonNullable<EventStore["deleteThrough"]>;
106
+ readonly truncatePrefix?: NonNullable<EventStore["truncatePrefix"]>;
107
+ readonly truncatePrefixKeeping?: NonNullable<EventStore["truncatePrefixKeeping"]>;
108
+ readonly listExecutions?: NonNullable<EventStore["listExecutions"]>;
109
+ readonly listExecutionsPage?: NonNullable<EventStore["listExecutionsPage"]>;
110
+
111
+ constructor(
112
+ private readonly inner: EventStore,
113
+ private readonly tenant: string
114
+ ) {
115
+ if (tenant === "" || tenant.includes(SEP)) {
116
+ // Prefix scoping is only isolation if no tenant's prefix is a prefix of another's:
117
+ // tenant "a" scopes "b:x" to "a:b:x", which is tenant "a:b"'s "x".
118
+ throw new TypeError(
119
+ `TenantScopedEventStore: tenant must be non-empty and must not contain the separator "${SEP}" (got ${JSON.stringify(tenant)})`
120
+ );
121
+ }
122
+ const scope = (id: string): string => `${this.tenant}${SEP}${id}`;
123
+ const prefix = `${this.tenant}${SEP}`;
124
+ // Every optional method decided here, over the whole union (ADR 0074 Amendment): a new
125
+ // SPI method is a compile error until this decorator forwards, overrides or refuses it.
126
+ const plan = {
127
+ // Order preserved: the SPI's per-execution order is array order.
128
+ appendBatch: (envs) =>
129
+ inner.appendBatch!(envs.map((e) => this.scopeEnvelope(e))).catch(this.rethrowUnscoped),
130
+ head: (id) => inner.head!(scope(id)),
131
+ readRange: (id, from, to) =>
132
+ inner.readRange!(scope(id), from, to).then((evs) => evs.map((e) => this.unscope(e))),
133
+ acquireFence: (id) => inner.acquireFence!(scope(id)),
134
+ acquireFenceIf: (id, expect) => inner.acquireFenceIf!(scope(id), expect),
135
+ appendIfUnchanged: (id, event, expect) =>
136
+ inner.appendIfUnchanged!(
137
+ scope(id),
138
+ event === null ? null : this.scopeEnvelope(event),
139
+ expect
140
+ ).catch(this.rethrowUnscoped),
141
+ storeNow: "forward", // the store clock is not tenant data
142
+ sealingCapability: "forward", // what the store seals is not tenant data
143
+ // The at-rest view is tenant-scoped like `read`: ids scoped on the way in, unscoped on the way out.
144
+ readAtRest: () => {
145
+ const raw = inner.readAtRest!();
146
+ const back = async (p: Promise<EventEnvelope[]>) => (await p).map((e) => this.unscope(e));
147
+ return {
148
+ read: (id) => back(raw.read(scope(id))),
149
+ readFrom: (id, from) => back(raw.readFrom(scope(id), from)),
150
+ ...(raw.readRange !== undefined && {
151
+ readRange: (id: string, a: number, b: number) => back(raw.readRange!(scope(id), a, b)),
152
+ }),
153
+ ...(raw.head !== undefined && { head: (id: string) => raw.head!(scope(id)) }),
154
+ };
155
+ },
156
+ // Fork stays IN-TENANT: both ids are scoped, so a fork can never cross the
157
+ // tenant boundary (a cross-tenant copy is unaddressable here).
158
+ copyPrefix: (from, to, through, metadata) =>
159
+ inner.copyPrefix!(scope(from), scope(to), through, metadata).catch(this.rethrowUnscoped),
160
+ deleteExecution: (id) => inner.deleteExecution!(scope(id)),
161
+ deleteThrough: (id, through) => inner.deleteThrough!(scope(id), through),
162
+ truncatePrefix: (id, through) => inner.truncatePrefix!(scope(id), through),
163
+ truncatePrefixKeeping: (id, through, keep) =>
164
+ inner.truncatePrefixKeeping!(scope(id), through, keep),
165
+ // Enumerate only THIS tenant's executions: keep the ones under our prefix, strip it.
166
+ // Cross-tenant ids are invisible (isolation by construction, K12.5).
167
+ listExecutions: async () =>
168
+ (await inner.listExecutions!())
169
+ .filter((id) => id.startsWith(prefix))
170
+ .map((id) => id.slice(prefix.length)),
171
+ listExecutionsPage: (opts) => pageWithinPrefix(inner, this.tenant, prefix, opts),
172
+ } satisfies DecoratorPlan<EventStore, OptionalEventStoreMethod>;
173
+ installOptional<EventStore, OptionalEventStoreMethod>(this, inner, plan);
174
+ }
175
+
176
+ /** Structural marker: the tenant this view is scoped to (`LogLifecycle.compact` pairs it with the archive's). */
177
+ get tenantScope(): string {
178
+ return this.tenant;
179
+ }
180
+
181
+ private scope(id: string): string {
182
+ return `${this.tenant}${SEP}${id}`;
183
+ }
184
+
185
+ /** Rewrite a stored envelope's scoped executionId/id back to the app's view. */
186
+ private unscope(e: EventEnvelope): EventEnvelope {
187
+ const prefix = `${this.tenant}${SEP}`;
188
+ if (!e.executionId.startsWith(prefix)) return e;
189
+ const executionId = e.executionId.slice(prefix.length);
190
+ return { ...e, executionId, id: e.id.startsWith(prefix) ? e.id.slice(prefix.length) : e.id };
191
+ }
192
+
193
+ /** Prefix an envelope for storage under the tenant scope. */
194
+ private scopeEnvelope(e: EventEnvelope): EventEnvelope {
195
+ return { ...e, executionId: this.scope(e.executionId), id: this.scope(e.id) };
196
+ }
197
+
198
+ async append(envelope: EventEnvelope): Promise<void> {
199
+ return this.inner.append(this.scopeEnvelope(envelope)).catch(this.rethrowUnscoped);
200
+ }
201
+
202
+ /**
203
+ * L20 — the store's write refusals name the id it WROTE, which is the scoped one (`acme:x`).
204
+ * The app never sees scoped ids anywhere else, and callers key on the refusal's id: a
205
+ * dispatcher decides "another holder owns THIS execution" by comparing it with its own
206
+ * (`isSupersededRefusal`), so a prefixed id made every fenced-out attempt read as a genuine
207
+ * failure and the ping-pong came back under tenancy. The three normalized refusals are
208
+ * re-raised as the same class and name with the ids unscoped and the original as `cause`.
209
+ * Anything else — and a refusal naming another tenant's key, which cannot be unscoped — passes
210
+ * through untouched.
211
+ */
212
+ private readonly rethrowUnscoped = (err: unknown): never => {
213
+ throw this.unscopeError(err);
214
+ };
215
+
216
+ private unscopeError(err: unknown): unknown {
217
+ if (err === null || typeof err !== "object") return err;
218
+ const prefix = `${this.tenant}${SEP}`;
219
+ const e = err as Record<string, unknown>;
220
+ const executionId = e.executionId;
221
+ if (typeof executionId !== "string" || !executionId.startsWith(prefix)) return err;
222
+ const id = executionId.slice(prefix.length);
223
+ switch (e.name) {
224
+ case "StaleFenceError":
225
+ return new StaleFenceError(
226
+ id,
227
+ e.staleFence as number | undefined,
228
+ e.currentFence as number,
229
+ err
230
+ );
231
+ case "DuplicateSequenceError":
232
+ return new DuplicateSequenceError(id, e.sequence as number, err);
233
+ case "DuplicateEnvelopeIdError": {
234
+ const envelopeId = String(e.envelopeId);
235
+ return new DuplicateEnvelopeIdError(
236
+ envelopeId.startsWith(prefix) ? envelopeId.slice(prefix.length) : envelopeId,
237
+ id,
238
+ err
239
+ );
240
+ }
241
+ default:
242
+ return err;
243
+ }
244
+ }
245
+
246
+ async read(executionId: string): Promise<EventEnvelope[]> {
247
+ return (await this.inner.read(this.scope(executionId))).map((e) => this.unscope(e));
248
+ }
249
+
250
+ async readFrom(executionId: string, fromSequence: number): Promise<EventEnvelope[]> {
251
+ return (await this.inner.readFrom(this.scope(executionId), fromSequence)).map((e) =>
252
+ this.unscope(e)
253
+ );
254
+ }
255
+
256
+ async subscribe(
257
+ executionId: string,
258
+ handler: (event: EventEnvelope) => void
259
+ ): Promise<() => void> {
260
+ return this.inner.subscribe(this.scope(executionId), (e) => handler(this.unscope(e)));
261
+ }
262
+
263
+ async close(): Promise<void> {
264
+ return this.inner.close();
265
+ }
266
+ }
package/src/archive.ts ADDED
@@ -0,0 +1,248 @@
1
+ import { createHash } from "node:crypto";
2
+ import { type EventEnvelope, canonicalJson } from "@loomwork/core";
3
+ import {
4
+ ArchiveDigestError,
5
+ type ArchiveExecutionPage,
6
+ type ArchiveSegment,
7
+ type ArchiveSegmentKind,
8
+ type ArchiveStore,
9
+ ArchiveUnserializableError,
10
+ } from "./archiveSpi";
11
+ import { compareExecutionIds } from "./executionIdOrder";
12
+ import {
13
+ INBOX_STREAM_PREFIX,
14
+ QUARANTINE_STREAM_PREFIX,
15
+ RESERVED_LATE_PREFIX,
16
+ archiveChainOf,
17
+ isReservedStream,
18
+ } from "./types";
19
+
20
+ /**
21
+ * The JSON form of the envelopes, which is what every backend stores and returns (a `Date` becomes
22
+ * its ISO string, `toJSON` runs, `undefined` members drop). Digesting the round-tripped value makes
23
+ * the digest the same on the write side and on a read that came back through any JSON store, and a
24
+ * value that cannot be JSON (circular, BigInt, a nesting JSON cannot hold) a typed refusal at
25
+ * archive time instead of a `TypeError` mid-compaction or a false digest mismatch on audit.
26
+ */
27
+ function jsonForm(envelopes: readonly EventEnvelope[]): unknown {
28
+ try {
29
+ return JSON.parse(JSON.stringify(envelopes));
30
+ } catch (err) {
31
+ throw new ArchiveUnserializableError(err);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * The digest of a run of envelopes: SHA-256 (hex) over their canonical JSON (sorted keys, so a
37
+ * backend that reorders object keys on a JSON round trip, such as Postgres `jsonb`, still agrees).
38
+ */
39
+ export function segmentDigest(envelopes: readonly EventEnvelope[]): string {
40
+ const form = jsonForm(envelopes);
41
+ try {
42
+ return createHash("sha256").update(canonicalJson(form)).digest("hex");
43
+ } catch (err) {
44
+ throw new ArchiveUnserializableError(err);
45
+ }
46
+ }
47
+
48
+ /** Build a segment from the envelopes a compaction removes. `envelopes` must be non-empty and ascending. */
49
+ export function buildSegment(
50
+ envelopes: readonly EventEnvelope[],
51
+ incarnation: string,
52
+ from: number = envelopes[0]!.sequence,
53
+ through: number = envelopes[envelopes.length - 1]!.sequence,
54
+ kind?: ArchiveSegmentKind
55
+ ): ArchiveSegment {
56
+ return {
57
+ from,
58
+ through,
59
+ count: envelopes.length,
60
+ digest: segmentDigest(envelopes),
61
+ incarnation,
62
+ ...(kind !== undefined && kind !== "events" && { kind }),
63
+ envelopes,
64
+ };
65
+ }
66
+
67
+ // ── ADR 0139 D12 - the sender field of an archived inbox envelope is the one erasable part ──────────
68
+
69
+ /** `payload.sender` after the sender was erased (sealed under their subject and shredded, or redacted). */
70
+ export const SENDER_ERASED = Object.freeze({ erased: true as const });
71
+
72
+ /**
73
+ * The erased-sender marker carrying a STABLE, NON-IDENTIFYING per-sender reference (ADR 0139 N3): the sealed
74
+ * sender's opaque handle, or a random one a plaintext redaction minted per erased subject. Two messages
75
+ * from one erased sender still share it and two different erased senders do not, so the owner's dedupe
76
+ * (which keyed on the identity before the erasure) keeps telling them apart; it names nobody.
77
+ */
78
+ export function senderErasedMarker(ref?: string): { erased: true; ref?: string } {
79
+ return ref === undefined ? { erased: true } : { erased: true, ref };
80
+ }
81
+
82
+ /** Whether a stored `sender` value is the {@link SENDER_ERASED} marker. */
83
+ export function isSenderErased(v: unknown): boolean {
84
+ return typeof v === "object" && v !== null && (v as { erased?: unknown }).erased === true;
85
+ }
86
+
87
+ /**
88
+ * The envelope with an inbox message's `payload.sender` masked (`null`). The digest of a RESERVED
89
+ * stream's segment is taken over this form, so the one thing an erasure may change afterwards - the
90
+ * sender's identity, sealed under THEIR subject and shredded with it, or redacted in a plaintext archive
91
+ * (a recorded redaction, ADR 0139 D12) - does not turn into a digest mismatch on the audit read. Every
92
+ * other byte stays covered, and a live envelope is compared with its archived copy by
93
+ * {@link sameReservedEnvelope}, which still tells two DIFFERENT live senders apart.
94
+ */
95
+ export function maskSender(e: EventEnvelope): EventEnvelope {
96
+ if (e.eventType !== "INBOX_MESSAGE") return e;
97
+ const p = e.payload as { sender?: unknown } | null | undefined;
98
+ if (p === null || typeof p !== "object" || p.sender === undefined) return e;
99
+ return { ...e, payload: { ...p, sender: null } };
100
+ }
101
+
102
+ /** {@link segmentDigest} over {@link maskSender}-masked envelopes (the digest of a reserved-stream segment). */
103
+ export function reservedSegmentDigest(envelopes: readonly EventEnvelope[]): string {
104
+ return segmentDigest(envelopes.map(maskSender));
105
+ }
106
+
107
+ /**
108
+ * ADR 0139 N7 - the digest of the SENDERS of a run of reserved-stream envelopes (SHA-256 over the canonical
109
+ * JSON of each envelope's `payload.sender`, `null` for one without). A plaintext reserved segment carries it
110
+ * beside its (sender-masked) `digest`, so the one field an erasure may redact still has bit-rot detection;
111
+ * a redaction recomputes it in the same atomic replace.
112
+ */
113
+ export function senderDigestOf(envelopes: readonly EventEnvelope[]): string {
114
+ const senders = envelopes.map((e) =>
115
+ e.eventType === "INBOX_MESSAGE"
116
+ ? ((e.payload as { sender?: unknown } | null)?.sender ?? null)
117
+ : null
118
+ );
119
+ try {
120
+ return createHash("sha256")
121
+ .update(canonicalJson(JSON.parse(JSON.stringify(senders))))
122
+ .digest("hex");
123
+ } catch (err) {
124
+ throw new ArchiveUnserializableError(err);
125
+ }
126
+ }
127
+
128
+ /** {@link buildSegment} with the reserved-stream digest. */
129
+ export function buildReservedSegment(
130
+ envelopes: readonly EventEnvelope[],
131
+ incarnation: string,
132
+ from: number = envelopes[0]!.sequence,
133
+ through: number = envelopes[envelopes.length - 1]!.sequence
134
+ ): ArchiveSegment {
135
+ return {
136
+ ...buildSegment(envelopes, incarnation, from, through),
137
+ digest: reservedSegmentDigest(envelopes),
138
+ senderDigest: senderDigestOf(envelopes),
139
+ };
140
+ }
141
+
142
+ /** {@link assertSegmentDigest} for a reserved-stream segment. */
143
+ export function assertReservedSegmentDigest(executionId: string, segment: ArchiveSegment): void {
144
+ // The sender digest is verified ONLY WHEN PRESENT: a segment written before ARCHIVE_STORE_SPI 1.1.0 (or by
145
+ // an archive that does not persist the field) has none, and still reads - its sender field simply has no
146
+ // bit-rot detection of its own (ADR 0139 D19; pre-1.1.0 compatibility path, not a downgrade to exploit:
147
+ // a writer of the archive could equally rewrite the segment).
148
+ const actual = reservedSegmentDigest(segment.envelopes);
149
+ if (
150
+ segment.senderDigest !== undefined &&
151
+ senderDigestOf(segment.envelopes) !== segment.senderDigest
152
+ ) {
153
+ throw new ArchiveDigestError(
154
+ executionId,
155
+ segment.from,
156
+ segment.through,
157
+ `sender:${segment.senderDigest}`,
158
+ `sender:${senderDigestOf(segment.envelopes)}`
159
+ );
160
+ }
161
+ if (actual !== segment.digest || segment.envelopes.length !== segment.count) {
162
+ throw new ArchiveDigestError(
163
+ executionId,
164
+ segment.from,
165
+ segment.through,
166
+ segment.digest,
167
+ actual
168
+ );
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Are `live` and `archived` the same inbox envelope? Equal masked digests, and the senders either equal
174
+ * or at least one of them erased (an erased sender is the same fact with its identity removed).
175
+ */
176
+ export function sameReservedEnvelope(live: EventEnvelope, archived: EventEnvelope): boolean {
177
+ if (segmentDigest([maskSender(live)]) !== segmentDigest([maskSender(archived)])) return false;
178
+ const a = (live.payload as { sender?: unknown } | null)?.sender;
179
+ const b = (archived.payload as { sender?: unknown } | null)?.sender;
180
+ if (isSenderErased(a) || isSenderErased(b)) return true;
181
+ return segmentDigest([{ ...live, payload: a }]) === segmentDigest([{ ...archived, payload: b }]);
182
+ }
183
+
184
+ /** One page of ids from an ascending list, keyset by exclusive cursor (shared by the backends' tests and memory store). */
185
+ export function pageIds(
186
+ sorted: readonly string[],
187
+ opts: { cursor?: string; limit?: number } = {}
188
+ ): ArchiveExecutionPage {
189
+ const limit = opts.limit ?? 500;
190
+ const start =
191
+ opts.cursor === undefined
192
+ ? 0
193
+ : sorted.findIndex((id) => compareExecutionIds(id, opts.cursor!) > 0);
194
+ if (start === -1) return { executionIds: [] };
195
+ const page = sorted.slice(start, start + limit);
196
+ const more = start + limit < sorted.length;
197
+ return more ? { executionIds: page, nextCursor: page[page.length - 1]! } : { executionIds: page };
198
+ }
199
+
200
+ /** Throws {@link ArchiveDigestError} when `segment`'s envelopes do not hash to its digest. */
201
+ export function assertSegmentDigest(executionId: string, segment: ArchiveSegment): void {
202
+ const actual = segmentDigest(segment.envelopes);
203
+ if (actual !== segment.digest || segment.envelopes.length !== segment.count) {
204
+ throw new ArchiveDigestError(
205
+ executionId,
206
+ segment.from,
207
+ segment.through,
208
+ segment.digest,
209
+ actual
210
+ );
211
+ }
212
+ }
213
+
214
+ /**
215
+ * The ids of the reserved streams of `chainId` the archive holds segments for (keyset prefix scans, ADR
216
+ * 0139): the chain's inbox (legacy, per-topic, registry) and quarantine streams, and the late runs split
217
+ * from them. The late keys all sit under one prefix, so their scan walks every late run of every chain
218
+ * (they exist only after a raced send or a node without an archive floor: a handful at most); the listing
219
+ * is paged by 200.
220
+ */
221
+ export async function archivedReservedStreams(
222
+ archive: Pick<ArchiveStore, "listExecutions">,
223
+ chainId: string
224
+ ): Promise<string[]> {
225
+ const found: string[] = [];
226
+ const own = [`${INBOX_STREAM_PREFIX}${chainId}`, `${QUARANTINE_STREAM_PREFIX}${chainId}`];
227
+ const prefixes = [...own, ...own.map((p) => `${RESERVED_LATE_PREFIX}${p}`)];
228
+ for (const prefix of prefixes) {
229
+ // The cursor is EXCLUSIVE and the stream whose id IS the prefix must be listed, so start one
230
+ // character short of it and skip what sorts before the prefix.
231
+ let cursor: string | undefined = prefix.slice(0, -1);
232
+ for (;;) {
233
+ const page: ArchiveExecutionPage = await archive.listExecutions({ cursor, limit: 200 });
234
+ let past = false;
235
+ for (const id of page.executionIds) {
236
+ if (!id.startsWith(prefix)) {
237
+ if (compareExecutionIds(id, prefix) < 0) continue;
238
+ past = true;
239
+ break;
240
+ }
241
+ if (isReservedStream(id) && archiveChainOf(id) === chainId) found.push(id);
242
+ }
243
+ if (past || page.nextCursor === undefined || page.nextCursor === cursor) break;
244
+ cursor = page.nextCursor;
245
+ }
246
+ }
247
+ return found;
248
+ }