@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,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
|
+
}
|