@pepys/ingest 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.
Files changed (42) hide show
  1. package/README.md +34 -2
  2. package/package.json +33 -4
  3. package/src/blob-store/blob-store.ts +6 -0
  4. package/src/blob-store/build-blob-hash.ts +6 -0
  5. package/src/blob-store/garage/create-garage-blob-store.ts +82 -0
  6. package/src/blob-store/local-folder/create-local-folder-blob-store.ts +56 -0
  7. package/src/config/read-ingest-config.ts +50 -0
  8. package/src/fact-log/check-append-batch.ts +45 -0
  9. package/src/fact-log/fact-log.ts +35 -0
  10. package/src/fact-log/make-batch-appender.ts +198 -0
  11. package/src/fact-log/memory/create-memory-fact-log.ts +174 -0
  12. package/src/fact-log/parse-stored-body.ts +18 -0
  13. package/src/fact-log/parse-stored-fact.ts +17 -0
  14. package/src/fact-log/pick-settlement.ts +20 -0
  15. package/src/fact-log/plan-appends.ts +45 -0
  16. package/src/fact-log/s2/create-s2-fact-log.ts +171 -0
  17. package/src/fact-log/s2/find-append-outcome.ts +35 -0
  18. package/src/index.ts +23 -0
  19. package/src/pull/make-pull-runner.ts +104 -0
  20. package/src/receive/answers/build-failure-answer.ts +12 -0
  21. package/src/receive/answers/build-success-answer.ts +19 -0
  22. package/src/receive/create-ingest.ts +103 -0
  23. package/src/receive/find-batch-problem.ts +26 -0
  24. package/src/receive/make-receive-handler.ts +245 -0
  25. package/src/receive/plan-batch.ts +31 -0
  26. package/src/receive/read-decoded-body.ts +33 -0
  27. package/src/receive/read-sent-body.ts +22 -0
  28. package/src/receive/read-stream-up-to.ts +44 -0
  29. package/src/receive/recent-bodies/create-recent-bodies.ts +55 -0
  30. package/src/receive/resync-marks/collect-returned-entities.ts +16 -0
  31. package/src/receive/resync-marks/collect-tied-entities.ts +29 -0
  32. package/src/receive/resync-marks/create-resync-marks.ts +111 -0
  33. package/src/receive/write-batch.ts +79 -0
  34. package/src/sender-proof/fake/create-fake-sender-proof.ts +14 -0
  35. package/src/sender-proof/imp-identity/create-imp-identity-sender-proof.ts +42 -0
  36. package/src/sender-proof/imp-identity/read-imp-identity-sender-proof.ts +35 -0
  37. package/src/sender-proof/imp-identity/read-imp-identity-trust.ts +88 -0
  38. package/src/sender-proof/imp-identity/types.ts +9 -0
  39. package/src/sender-proof/none/no-sender-proof.ts +7 -0
  40. package/src/serve/serve-ingest.ts +107 -0
  41. package/src/serve/start-pull-timers.ts +69 -0
  42. package/src/source-port/types.ts +64 -0
package/README.md CHANGED
@@ -1,3 +1,35 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
+ <h1>@pepys/ingest</h1>
2
3
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
+ <p>
5
+ <a href="https://www.npmjs.com/package/@pepys/ingest"><img src="https://img.shields.io/npm/v/@pepys/ingest" alt="npm version"></a>
6
+ <a href="../../LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
7
+ </p>
8
+
9
+ <p>
10
+ <a href="../../docs/README.md">Documentation</a>
11
+ </p>
12
+ </div>
13
+
14
+ pepys ingest, for Bun: it receives each source's deliveries and appends them to the fact log.
15
+
16
+ A deployment serves one receive route for each receive path of its sources. Each route checks its
17
+ delivery, and appends the facts it reads to the fact log.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ bun add @pepys/ingest
23
+ ```
24
+
25
+ ## Features
26
+
27
+ - `serveIngest` and `createIngest`: the receive routes of a deployment.
28
+ - The sender proof port, with the adapter that verifies a worker machine's identity token through
29
+ `@impz/client/identity`.
30
+ - The fact log port, with its S2 and memory adapters.
31
+ - The blob store port, with its Garage and local folder adapters.
32
+
33
+ ## Documentation
34
+
35
+ The [pepys docs](../../docs/README.md) hold the design of ingest and the log.
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@pepys/ingest",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "pepys ingest: the receive routes, the fact log and the blob store",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zgeoff/pepys.git",
9
+ "directory": "packages/ingest"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "!src/**/*.test.ts",
14
+ "!src/**/test-utils",
15
+ "!src/**/run-*-fixture.ts",
16
+ "!src/**/fixtures"
17
+ ],
18
+ "type": "module",
19
+ "exports": "./src/index.ts",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "@impz/client": "6.12.0",
25
+ "@pepys/kit": "0.1.0",
26
+ "@pepys/schema": "0.1.0",
27
+ "@s2-dev/streamstore": "0.30.0",
28
+ "@zgeoff/std": "0.4.3",
29
+ "tiny-invariant": "1.3.3",
30
+ "ts-pattern": "5.9.2"
31
+ },
32
+ "devDependencies": {
33
+ "zod": "4.6.7"
34
+ }
35
+ }
@@ -0,0 +1,6 @@
1
+ import type { BlobHash, Result } from '@pepys/kit';
2
+
3
+ export interface BlobStore {
4
+ write: (bytes: Uint8Array) => Promise<Result<BlobHash, { reason: 'unavailable' }>>;
5
+ read: (hash: BlobHash) => Promise<Result<Uint8Array, { reason: 'missing' | 'unavailable' }>>;
6
+ }
@@ -0,0 +1,6 @@
1
+ import type { BlobHash } from '@pepys/kit';
2
+ import { toTagged } from '@zgeoff/std';
3
+
4
+ export function buildBlobHash(bytes: Uint8Array): BlobHash {
5
+ return toTagged<BlobHash>(new Bun.CryptoHasher('sha256').update(bytes).digest('hex'));
6
+ }
@@ -0,0 +1,82 @@
1
+ import type { BlobHash, Result } from '@pepys/kit';
2
+ import type { BlobStore } from '../blob-store.ts';
3
+ import { buildBlobHash } from '../build-blob-hash.ts';
4
+
5
+ interface GarageBlobStoreParameters {
6
+ endpoint: string;
7
+ region: string;
8
+ bucket: string;
9
+ accessKeyID: string;
10
+ secretAccessKey: string;
11
+ requestTimeoutMs: number;
12
+ }
13
+
14
+ export function createGarageBlobStore(parameters: GarageBlobStoreParameters): BlobStore {
15
+ const client = new Bun.S3Client({
16
+ endpoint: parameters.endpoint,
17
+ region: parameters.region,
18
+ bucket: parameters.bucket,
19
+ accessKeyId: parameters.accessKeyID,
20
+ secretAccessKey: parameters.secretAccessKey,
21
+ });
22
+
23
+ return {
24
+ write: (bytes) => writeBlob(client, bytes, parameters.requestTimeoutMs),
25
+ read: (hash) => readBlob(client, hash, parameters.requestTimeoutMs),
26
+ };
27
+ }
28
+
29
+ async function writeBlob(
30
+ client: Bun.S3Client,
31
+ bytes: Uint8Array,
32
+ timeoutMs: number,
33
+ ): Promise<Result<BlobHash, { reason: 'unavailable' }>> {
34
+ const hash = buildBlobHash(bytes);
35
+
36
+ try {
37
+ await withTimeout(timeoutMs, () => client.write(hash, bytes));
38
+ } catch {
39
+ return { ok: false, failure: { reason: 'unavailable' } };
40
+ }
41
+
42
+ return { ok: true, value: hash };
43
+ }
44
+
45
+ // The S3 client sets no request timeout of its own, so a store that never answers would hold the
46
+ // caller forever.
47
+ async function withTimeout<Value>(
48
+ timeoutMs: number,
49
+ request: () => Promise<Value>,
50
+ ): Promise<Value> {
51
+ const timeout = Promise.withResolvers<never>();
52
+
53
+ const timer = setTimeout(() => {
54
+ timeout.reject(new Error(`the request had no answer after ${timeoutMs} ms`));
55
+ }, timeoutMs);
56
+
57
+ try {
58
+ return await Promise.race([request(), timeout.promise]);
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+
64
+ async function readBlob(
65
+ client: Bun.S3Client,
66
+ hash: BlobHash,
67
+ timeoutMs: number,
68
+ ): Promise<Result<Uint8Array, { reason: 'missing' | 'unavailable' }>> {
69
+ try {
70
+ return { ok: true, value: await withTimeout(timeoutMs, () => client.file(hash).bytes()) };
71
+ } catch (error: unknown) {
72
+ if (isMissingKey(error)) {
73
+ return { ok: false, failure: { reason: 'missing' } };
74
+ }
75
+
76
+ return { ok: false, failure: { reason: 'unavailable' } };
77
+ }
78
+ }
79
+
80
+ function isMissingKey(error: unknown): boolean {
81
+ return error instanceof Error && 'code' in error && error.code === 'NoSuchKey';
82
+ }
@@ -0,0 +1,56 @@
1
+ import { rename, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { BlobHash, Result } from '@pepys/kit';
4
+ import type { BlobStore } from '../blob-store.ts';
5
+ import { buildBlobHash } from '../build-blob-hash.ts';
6
+
7
+ export function createLocalFolderBlobStore(directory: string): BlobStore {
8
+ return {
9
+ write: (bytes) => writeBlob(directory, bytes),
10
+ read: (hash) => readBlob(directory, hash),
11
+ };
12
+ }
13
+
14
+ // A blob lands under its hash only once its bytes are complete, so a reader never sees part of one.
15
+ async function writeBlob(
16
+ directory: string,
17
+ bytes: Uint8Array,
18
+ ): Promise<Result<BlobHash, { reason: 'unavailable' }>> {
19
+ const hash = buildBlobHash(bytes);
20
+ const partial = join(directory, `${hash}.${crypto.randomUUID()}.partial`);
21
+
22
+ try {
23
+ await writeFile(partial, bytes);
24
+ await rename(partial, join(directory, hash));
25
+ } catch {
26
+ await rm(partial, { force: true });
27
+
28
+ return { ok: false, failure: { reason: 'unavailable' } };
29
+ }
30
+
31
+ return { ok: true, value: hash };
32
+ }
33
+
34
+ const blobHashPattern = /^[0-9a-f]{64}$/u;
35
+
36
+ async function readBlob(
37
+ directory: string,
38
+ hash: BlobHash,
39
+ ): Promise<Result<Uint8Array, { reason: 'missing' | 'unavailable' }>> {
40
+ // A hash that is not a SHA-256 in hex could name a path outside the folder.
41
+ if (!blobHashPattern.test(hash)) {
42
+ return { ok: false, failure: { reason: 'missing' } };
43
+ }
44
+
45
+ const file = Bun.file(join(directory, hash));
46
+
47
+ if (!(await file.exists())) {
48
+ return { ok: false, failure: { reason: 'missing' } };
49
+ }
50
+
51
+ try {
52
+ return { ok: true, value: await file.bytes() };
53
+ } catch {
54
+ return { ok: false, failure: { reason: 'unavailable' } };
55
+ }
56
+ }
@@ -0,0 +1,50 @@
1
+ import type { Result } from '@pepys/kit';
2
+ import { parsePort } from '@pepys/schema';
3
+
4
+ export interface IngestConfig {
5
+ // the listen address; 127.0.0.1 unless the deployment sets another
6
+ host: string;
7
+ port: number;
8
+ s2: { endpoint: string; accessToken: string; basin: string };
9
+
10
+ // one value per name the deployment's paths read, each set and not empty
11
+ secrets: Readonly<Record<string, string>>;
12
+ }
13
+
14
+ const s2Names = ['PEPYS_S2_ENDPOINT', 'PEPYS_S2_ACCESS_TOKEN', 'PEPYS_S2_BASIN'] as const;
15
+
16
+ // Reads every variable once and names each one that is missing, so ingest stops at start with the
17
+ // whole list and never serves a route with an empty secret.
18
+ export function readIngestConfig(
19
+ environment: Readonly<Record<string, string | undefined>>,
20
+ secretNames: readonly string[],
21
+ ): Result<IngestConfig, string[]> {
22
+ const required = [...s2Names, ...secretNames];
23
+ const missing = required.filter((name) => (environment[name] ?? '') === '');
24
+ const portText = environment['PEPYS_INGEST_PORT'] ?? '';
25
+ const port = portText === '' ? ({ ok: true, value: 8080 } as const) : parsePort(portText);
26
+ const host = environment['PEPYS_INGEST_HOST'] ?? '';
27
+
28
+ const problems = [
29
+ ...missing.map((name) => `${name} is not set`),
30
+ ...(port.ok ? [] : ['PEPYS_INGEST_PORT is not a port number']),
31
+ ];
32
+
33
+ if (!port.ok || problems.length > 0) {
34
+ return { ok: false, failure: problems };
35
+ }
36
+
37
+ return {
38
+ ok: true,
39
+ value: {
40
+ host: host === '' ? '127.0.0.1' : host,
41
+ port: port.value,
42
+ s2: {
43
+ endpoint: environment['PEPYS_S2_ENDPOINT'] ?? '',
44
+ accessToken: environment['PEPYS_S2_ACCESS_TOKEN'] ?? '',
45
+ basin: environment['PEPYS_S2_BASIN'] ?? '',
46
+ },
47
+ secrets: Object.fromEntries(secretNames.map((name) => [name, environment[name] ?? ''])),
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,45 @@
1
+ import type { FactEnvelope, Result } from '@pepys/kit';
2
+ import { parseStoredBody } from './parse-stored-body.ts';
3
+
4
+ // The limits of one s2-lite append. A record meters as its stored body in UTF-8 bytes plus 8, and
5
+ // pepys records have no headers.
6
+ export const appendLimits = { records: 1000, meteredBytes: 1_048_576 } as const;
7
+ const recordOverheadBytes = 8;
8
+
9
+ export type AppendBatchProblem =
10
+ | { reason: 'too-large' }
11
+ | { reason: 'invalid-envelope'; index: number };
12
+
13
+ // The metered size of a stored body.
14
+ export function toMeteredBytes(body: string): number {
15
+ return Buffer.byteLength(body, 'utf8') + recordOverheadBytes;
16
+ }
17
+
18
+ // Checks what an append needs before it reaches a stream, so every adapter refuses what s2-lite
19
+ // refuses. An empty batch, or one over the record limit, is too large; then each fact must parse as
20
+ // an envelope; then the batch must fit the byte limit. Returns the stored bodies, in order.
21
+ export function checkAppendBatch(
22
+ facts: readonly FactEnvelope<unknown>[],
23
+ ): Result<readonly string[], AppendBatchProblem> {
24
+ if (facts.length === 0 || facts.length > appendLimits.records) {
25
+ return { ok: false, failure: { reason: 'too-large' } };
26
+ }
27
+
28
+ const bodies: string[] = [];
29
+
30
+ for (const [index, fact] of facts.entries()) {
31
+ const body = parseStoredBody(fact);
32
+
33
+ if (body === null) {
34
+ return { ok: false, failure: { reason: 'invalid-envelope', index } };
35
+ }
36
+
37
+ bodies.push(body);
38
+ }
39
+
40
+ const meteredBytes = bodies.reduce((sum, body) => sum + toMeteredBytes(body), 0);
41
+
42
+ return meteredBytes > appendLimits.meteredBytes
43
+ ? { ok: false, failure: { reason: 'too-large' } }
44
+ : { ok: true, value: bodies };
45
+ }
@@ -0,0 +1,35 @@
1
+ import type { FactEnvelope, Result, StreamName, StreamOffset } from '@pepys/kit';
2
+
3
+ // Fact `i` of an acknowledged batch has the offset `firstOffset + i`.
4
+ export type AppendBatchOutcome =
5
+ | { kind: 'acknowledged'; firstOffset: StreamOffset; count: number }
6
+
7
+ // Nothing was stored.
8
+ | { kind: 'rejected'; reason: 'tail-mismatch' | 'unknown-stream' | 'too-large' }
9
+ | { kind: 'rejected'; reason: 'invalid-envelope'; index: number }
10
+
11
+ // The reply was lost: the batch is stored whole, or not at all.
12
+ | { kind: 'unknown' };
13
+
14
+ export interface StoredFact {
15
+ offset: StreamOffset;
16
+ fact: FactEnvelope<unknown>;
17
+ body: string;
18
+ appendedAtMs: number;
19
+ }
20
+
21
+ export interface FactLog {
22
+ // Stores every fact, in order, in one append that the log refuses unless the stream's tail is
23
+ // `expectedTail`. All the facts land or none does.
24
+ appendBatch: (
25
+ stream: StreamName,
26
+ facts: readonly FactEnvelope<unknown>[],
27
+ expectedTail: StreamOffset,
28
+ ) => Promise<AppendBatchOutcome>;
29
+ read: (
30
+ stream: StreamName,
31
+ from: StreamOffset,
32
+ count: number,
33
+ ) => Promise<Result<readonly StoredFact[], { reason: 'unknown-stream' }>>;
34
+ readTail: (stream: StreamName) => Promise<Result<StreamOffset, { reason: 'unknown-stream' }>>;
35
+ }
@@ -0,0 +1,198 @@
1
+ import type { FactEnvelope, Result, StreamName, StreamOffset } from '@pepys/kit';
2
+ import { toTagged } from '@zgeoff/std';
3
+ import { P, match } from 'ts-pattern';
4
+ import type { FactLog, StoredFact } from './fact-log.ts';
5
+ import { pickSettlement } from './pick-settlement.ts';
6
+
7
+ interface BatchAppenderOptions {
8
+ log: FactLog;
9
+ maxAttempts: number;
10
+ retryDelayMs: number;
11
+ }
12
+
13
+ // `refused`: the log gave a reason that no retry changes. `not-stored`: no acknowledgement after
14
+ // `attempts` attempts, so the facts may or may not be in the log.
15
+ export type AppendFailure =
16
+ | { kind: 'refused'; reason: 'unknown-stream' | 'invalid-envelope' | 'too-large' }
17
+ | { kind: 'not-stored'; attempts: number };
18
+
19
+ export type BatchAppender = (
20
+ stream: StreamName,
21
+ facts: readonly FactEnvelope<unknown>[],
22
+ ) => Promise<Result<{ firstOffset: StreamOffset; count: number }, AppendFailure>>;
23
+
24
+ // Owns the retries of one append. The guard goes on every attempt, the first included. After an
25
+ // attempt whose outcome is unknown, the appender reads the offsets that the attempt aimed at before
26
+ // it sends another, so the run is stored once.
27
+ export function makeBatchAppender(options: BatchAppenderOptions): BatchAppender {
28
+ return async (stream, facts) => {
29
+ let pendingTail: StreamOffset | null = null;
30
+
31
+ for (let attempt = 1; ; attempt += 1) {
32
+ const result = await runAttempt(options, stream, facts, pendingTail);
33
+
34
+ if (result.kind === 'stored') {
35
+ return { ok: true, value: result.stored };
36
+ }
37
+
38
+ if (result.kind === 'refused') {
39
+ return { ok: false, failure: result.failure };
40
+ }
41
+
42
+ pendingTail = result.pendingTail;
43
+
44
+ if (attempt >= options.maxAttempts) {
45
+ return { ok: false, failure: { kind: 'not-stored', attempts: attempt } };
46
+ }
47
+
48
+ await Bun.sleep(options.retryDelayMs * attempt);
49
+ }
50
+ };
51
+ }
52
+
53
+ interface Stored {
54
+ firstOffset: StreamOffset;
55
+ count: number;
56
+ }
57
+
58
+ type StopFailure = Extract<AppendFailure, { kind: 'refused' }>;
59
+
60
+ // What one attempt leaves: the answer, or the tail that earlier attempts with an unknown outcome
61
+ // aimed at, which a later attempt can still land on. `null` when no such attempt is open.
62
+ type AttemptResult =
63
+ | { kind: 'stored'; stored: Stored }
64
+ | { kind: 'refused'; failure: StopFailure }
65
+ | { kind: 'again'; pendingTail: StreamOffset | null };
66
+
67
+ async function runAttempt(
68
+ options: BatchAppenderOptions,
69
+ stream: StreamName,
70
+ facts: readonly FactEnvelope<unknown>[],
71
+ pendingTail: StreamOffset | null,
72
+ ): Promise<AttemptResult> {
73
+ // Every attempt that is still open aimed at the same tail: a new tail comes only after the
74
+ // offsets there are known to hold another writer's records.
75
+ const read =
76
+ pendingTail === null
77
+ ? await readTail(options.log, stream)
78
+ : { ok: true as const, value: pendingTail };
79
+
80
+ if (read === null) {
81
+ return { kind: 'again', pendingTail };
82
+ }
83
+
84
+ if (!read.ok) {
85
+ return { kind: 'refused', failure: read.failure };
86
+ }
87
+
88
+ const aimed = read.value;
89
+
90
+ const outcome = await options.log.appendBatch(stream, facts, aimed);
91
+
92
+ return match(outcome)
93
+ .with({ kind: 'acknowledged' }, (acknowledged) => ({
94
+ kind: 'stored' as const,
95
+ stored: { firstOffset: acknowledged.firstOffset, count: acknowledged.count },
96
+ }))
97
+ .with({ kind: 'rejected', reason: 'tail-mismatch' }, () =>
98
+ pendingTail === null
99
+ ? { kind: 'again' as const, pendingTail }
100
+ : settleAttempt(options, stream, facts, aimed),
101
+ )
102
+ .with(
103
+ { kind: 'rejected', reason: P.union('unknown-stream', 'invalid-envelope', 'too-large') },
104
+ (rejected) => ({
105
+ kind: 'refused' as const,
106
+ failure: { kind: 'refused' as const, reason: rejected.reason },
107
+ }),
108
+ )
109
+ .with({ kind: 'unknown' }, () => settleAttempt(options, stream, facts, aimed))
110
+ .exhaustive();
111
+ }
112
+
113
+ // A failed read stored nothing, so the next attempt repeats it. Returns `null` for a failed read,
114
+ // and the failure for a stream the log lacks. An error the append throws is a refusal the log gives
115
+ // no outcome for, such as a bad token, and a retry cannot change it.
116
+ async function readTail(
117
+ log: FactLog,
118
+ stream: StreamName,
119
+ ): Promise<Result<StreamOffset, StopFailure> | null> {
120
+ try {
121
+ const tail = await log.readTail(stream);
122
+
123
+ return tail.ok ? tail : { ok: false, failure: { kind: 'refused', reason: 'unknown-stream' } };
124
+ } catch (error: unknown) {
125
+ console.error('a read before an append failed', error);
126
+
127
+ return null;
128
+ }
129
+ }
130
+
131
+ // Reads exactly the offsets `[tail, tail + n)` that an attempt aimed at, and compares their fact IDs
132
+ // with the run's, in order. A search for the first ID anywhere after the tail would take another
133
+ // writer's copy of it for the whole run.
134
+ async function settleAttempt(
135
+ options: BatchAppenderOptions,
136
+ stream: StreamName,
137
+ facts: readonly FactEnvelope<unknown>[],
138
+ tail: StreamOffset,
139
+ ): Promise<AttemptResult> {
140
+ const stored = await readRun(options.log, stream, tail, facts.length).catch((error: unknown) => {
141
+ console.error('a read that settles an append failed', error);
142
+
143
+ return null;
144
+ });
145
+
146
+ if (stored === null) {
147
+ return { kind: 'again', pendingTail: tail };
148
+ }
149
+
150
+ if (!stored.ok) {
151
+ return { kind: 'refused', failure: { kind: 'refused', reason: 'unknown-stream' } };
152
+ }
153
+
154
+ return match(
155
+ pickSettlement(
156
+ facts.map((fact) => fact.id),
157
+ stored.value,
158
+ ),
159
+ )
160
+ .with('landed', () => ({
161
+ kind: 'stored' as const,
162
+ stored: { firstOffset: tail, count: facts.length },
163
+ }))
164
+ .with('not-landed', () => ({ kind: 'again' as const, pendingTail: tail }))
165
+ .with('taken', () => ({ kind: 'again' as const, pendingTail: null }))
166
+ .exhaustive();
167
+ }
168
+
169
+ // A read can end before `count` records, so it repeats from the next offset until it has them all
170
+ // or the stream ends.
171
+ async function readRun(
172
+ log: FactLog,
173
+ stream: StreamName,
174
+ from: StreamOffset,
175
+ count: number,
176
+ ): Promise<Result<readonly StoredFact[], { reason: 'unknown-stream' }>> {
177
+ const records: StoredFact[] = [];
178
+
179
+ while (records.length < count) {
180
+ const read = await log.read(
181
+ stream,
182
+ toTagged<StreamOffset>(from + records.length),
183
+ count - records.length,
184
+ );
185
+
186
+ if (!read.ok) {
187
+ return read;
188
+ }
189
+
190
+ if (read.value.length === 0) {
191
+ break;
192
+ }
193
+
194
+ records.push(...read.value);
195
+ }
196
+
197
+ return { ok: true, value: records };
198
+ }