@pepys/ingest 0.0.0-stage → 0.0.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 (39) hide show
  1. package/README.md +32 -2
  2. package/package.json +32 -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 +28 -0
  19. package/src/receive/answers/build-failure-answer.ts +12 -0
  20. package/src/receive/answers/build-success-answer.ts +19 -0
  21. package/src/receive/create-ingest.ts +90 -0
  22. package/src/receive/find-batch-problem.ts +26 -0
  23. package/src/receive/make-receive-handler.ts +241 -0
  24. package/src/receive/plan-batch.ts +31 -0
  25. package/src/receive/read-decoded-body.ts +33 -0
  26. package/src/receive/read-sent-body.ts +22 -0
  27. package/src/receive/read-stream-up-to.ts +44 -0
  28. package/src/receive/recent-bodies/create-recent-bodies.ts +55 -0
  29. package/src/receive/resync-marks/collect-returned-entities.ts +16 -0
  30. package/src/receive/resync-marks/collect-tied-entities.ts +27 -0
  31. package/src/receive/resync-marks/create-resync-marks.ts +106 -0
  32. package/src/receive/write-batch.ts +79 -0
  33. package/src/sender-proof/fake/create-fake-sender-proof.ts +14 -0
  34. package/src/sender-proof/imp-identity/create-imp-identity-sender-proof.ts +58 -0
  35. package/src/sender-proof/imp-identity/read-imp-identity-trust.ts +87 -0
  36. package/src/sender-proof/imp-identity/types.ts +38 -0
  37. package/src/sender-proof/none/no-sender-proof.ts +7 -0
  38. package/src/serve/serve-ingest.ts +85 -0
  39. package/src/source-port/types.ts +64 -0
@@ -0,0 +1,241 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isDeliveryAuthentic } from '@pepys/kit';
3
+ import type {
4
+ ReceivePath,
5
+ Received,
6
+ Sender,
7
+ SenderProof,
8
+ SenderRequirement,
9
+ SourceDefinition,
10
+ } from '@pepys/kit';
11
+ import { match } from 'ts-pattern';
12
+ import type { BatchAppender } from '../fact-log/make-batch-appender.ts';
13
+ import { buildFailureAnswer, buildRetryAnswer } from './answers/build-failure-answer.ts';
14
+ import { buildSuccessAnswer } from './answers/build-success-answer.ts';
15
+ import { findBatchProblem } from './find-batch-problem.ts';
16
+ import { planBatch } from './plan-batch.ts';
17
+ import { readDecodedBody } from './read-decoded-body.ts';
18
+ import { readSentBody } from './read-sent-body.ts';
19
+ import type { RecentBodies } from './recent-bodies/create-recent-bodies.ts';
20
+ import type { SourceMarks } from './resync-marks/create-resync-marks.ts';
21
+ import { writeBatch } from './write-batch.ts';
22
+ import type { StreamTurns } from './write-batch.ts';
23
+
24
+ export interface ReceiveDependencies {
25
+ // the value of the path's `check.secretEnv`; an empty secret passes no delivery
26
+ secret: string;
27
+ senderProof: SenderProof;
28
+ append: BatchAppender;
29
+ turns: StreamTurns;
30
+ recentBodies: RecentBodies;
31
+
32
+ // null for a source with no resync pull, which reads no marks
33
+ marks: SourceMarks | null;
34
+ }
35
+
36
+ type Handler = (request: Request) => Promise<Response>;
37
+
38
+ const defaultBodyByteLimit = 1_048_576;
39
+ const defaultFactLimit = 100;
40
+
41
+ // One delivery on the route of a receive path. Each step answers and stops, or goes on.
42
+ export function makeReceiveHandler(
43
+ source: SourceDefinition,
44
+ path: ReceivePath,
45
+ dependencies: ReceiveDependencies,
46
+ ): Handler {
47
+ const byteLimit = path.bodyByteLimit ?? defaultBodyByteLimit;
48
+ const factLimit = path.factLimit ?? defaultFactLimit;
49
+
50
+ return async (request) => {
51
+ const checked = await readCheckedDelivery(request, path, dependencies);
52
+
53
+ if (checked.kind === 'answer') {
54
+ return checked.answer;
55
+ }
56
+
57
+ // The same answer as the first time, with no append, for a body this route stored in the last
58
+ // hour. A body is remembered only once it is stored, so a delivery that got 503 is never dropped.
59
+ const digest = createHash('sha256').update(checked.sent).digest('hex');
60
+ const key = buildRecentBodyKey(digest, checked.sender);
61
+ const known = dependencies.recentBodies.find(key);
62
+
63
+ if (known !== undefined) {
64
+ dependencies.marks?.noteCopy(digest, known.entities);
65
+
66
+ return buildSuccessAnswer(path, 'stored', known.stored);
67
+ }
68
+
69
+ const body = await readDecodedBody(
70
+ checked.sent,
71
+ request.headers.get('content-encoding'),
72
+ path.encodings ?? [],
73
+ byteLimit,
74
+ );
75
+
76
+ if (typeof body === 'string') {
77
+ return pickDecodeFailure(body);
78
+ }
79
+
80
+ const received = readSourceDelivery(source, path, {
81
+ headers: request.headers,
82
+ body,
83
+ ...(checked.sender === undefined ? {} : { sender: checked.sender }),
84
+ });
85
+
86
+ if (received === 'threw') {
87
+ return buildFailureAnswer(500, 'the source could not read the delivery');
88
+ }
89
+
90
+ if (received.kind === 'refused') {
91
+ return buildFailureAnswer(received.status, received.reason);
92
+ }
93
+
94
+ if (received.kind === 'ignored' || received.batch.facts.length === 0) {
95
+ return buildSuccessAnswer(path, 'ignored', 0);
96
+ }
97
+
98
+ const facts = received.batch.facts;
99
+
100
+ if (facts.length > factLimit) {
101
+ return buildFailureAnswer(413, `a batch holds ${factLimit} facts at most`);
102
+ }
103
+
104
+ const problem = findBatchProblem(source, received.batch);
105
+
106
+ if (problem !== undefined) {
107
+ return buildFailureAnswer(422, problem);
108
+ }
109
+
110
+ if (facts.some((pending) => pending.blobs.length > 0)) {
111
+ console.error(
112
+ `the source ${source.name} read a fact with a blob, and ingest has no blob store`,
113
+ );
114
+
115
+ return buildFailureAnswer(500, 'ingest has no blob store');
116
+ }
117
+
118
+ const plan = planBatch(received.batch);
119
+
120
+ if (!plan.ok) {
121
+ return buildFailureAnswer(413, 'one fact is larger than one append of the log takes');
122
+ }
123
+
124
+ const outcome = await writeBatch(plan.value, dependencies);
125
+
126
+ if (outcome === 'not-stored') {
127
+ return buildRetryAnswer();
128
+ }
129
+
130
+ if (outcome === 'refused') {
131
+ return buildFailureAnswer(500, 'the log refused the batch');
132
+ }
133
+
134
+ const entities = facts.map((pending) => pending.fact.entity);
135
+
136
+ dependencies.recentBodies.remember(key, { stored: facts.length, entities });
137
+
138
+ dependencies.marks?.noteStored(
139
+ digest,
140
+ facts.map((pending) => ({ entity: pending.fact.entity, at: pending.fact.at })),
141
+ );
142
+
143
+ return buildSuccessAnswer(path, 'stored', facts.length);
144
+ };
145
+ }
146
+
147
+ // A route of the path's `ignoredRoutes`: the same method, size, check and sender as the path, then
148
+ // the ignored answer. It reads no body further and stores nothing.
149
+ export function makeIgnoredRouteHandler(
150
+ path: ReceivePath,
151
+ dependencies: Pick<ReceiveDependencies, 'secret' | 'senderProof'>,
152
+ ): Handler {
153
+ return async (request) => {
154
+ const checked = await readCheckedDelivery(request, path, dependencies);
155
+
156
+ return checked.kind === 'answer' ? checked.answer : buildSuccessAnswer(path, 'ignored', 0);
157
+ };
158
+ }
159
+
160
+ type CheckedDelivery =
161
+ | { kind: 'answer'; answer: Response }
162
+ | { kind: 'checked'; sent: Uint8Array; sender: Sender | undefined };
163
+
164
+ // 405, 413, then 401 unless the check passes on the bytes as sent, then 401 unless the sender is
165
+ // proven on a path that needs one. No code of the source has seen the delivery yet.
166
+ async function readCheckedDelivery(
167
+ request: Request,
168
+ path: ReceivePath,
169
+ dependencies: Pick<ReceiveDependencies, 'secret' | 'senderProof'>,
170
+ ): Promise<CheckedDelivery> {
171
+ if (request.method !== 'POST') {
172
+ return { kind: 'answer', answer: buildFailureAnswer(405, 'method not allowed') };
173
+ }
174
+
175
+ const sent = await readSentBody(request, path.bodyByteLimit ?? defaultBodyByteLimit);
176
+
177
+ if (sent === 'too-large') {
178
+ return { kind: 'answer', answer: buildFailureAnswer(413, 'body too large') };
179
+ }
180
+
181
+ if (!isDeliveryAuthentic(path.check, dependencies.secret, request.headers, sent)) {
182
+ return { kind: 'answer', answer: buildFailureAnswer(401, 'not authorised') };
183
+ }
184
+
185
+ if (path.sender === undefined) {
186
+ return { kind: 'checked', sent, sender: undefined };
187
+ }
188
+
189
+ const sender = await readProvenSender(path.sender, request.headers, dependencies.senderProof);
190
+
191
+ if (sender === null) {
192
+ return { kind: 'answer', answer: buildFailureAnswer(401, 'not authorised') };
193
+ }
194
+
195
+ return { kind: 'checked', sent, sender };
196
+ }
197
+
198
+ // A proof that throws proves nothing. The error may quote the request, so only its fact is logged.
199
+ async function readProvenSender(
200
+ requirement: SenderRequirement,
201
+ headers: Headers,
202
+ senderProof: SenderProof,
203
+ ): Promise<Sender | null> {
204
+ try {
205
+ const sender = await senderProof.prove(requirement, headers);
206
+
207
+ return sender;
208
+ } catch {
209
+ console.error('the sender proof threw, so the delivery proves no sender');
210
+
211
+ return null;
212
+ }
213
+ }
214
+
215
+ // On a path with a sender, one body from two senders is two deliveries.
216
+ function buildRecentBodyKey(digest: string, sender: Sender | undefined): string {
217
+ return sender === undefined ? digest : JSON.stringify([digest, sender.issuer, sender.subject]);
218
+ }
219
+
220
+ function pickDecodeFailure(failure: 'unsupported' | 'too-large' | 'corrupt'): Response {
221
+ return match(failure)
222
+ .with('unsupported', () => buildFailureAnswer(415, 'content encoding not accepted'))
223
+ .with('too-large', () => buildFailureAnswer(413, 'body too large'))
224
+ .with('corrupt', () => buildFailureAnswer(400, 'the body does not decode'))
225
+ .exhaustive();
226
+ }
227
+
228
+ // The source's own code. It is pure, so a throw is a fault of the deployment.
229
+ function readSourceDelivery(
230
+ source: SourceDefinition,
231
+ path: ReceivePath,
232
+ delivery: Parameters<ReceivePath['read']>[0],
233
+ ): Received | 'threw' {
234
+ try {
235
+ return path.read(delivery, { name: source.name, families: source.families });
236
+ } catch (error: unknown) {
237
+ console.error(`the read of the source ${source.name} threw`, error);
238
+
239
+ return 'threw';
240
+ }
241
+ }
@@ -0,0 +1,31 @@
1
+ import type { Batch, FactEnvelope, Result, StreamName } from '@pepys/kit';
2
+ import { planAppends } from '../fact-log/plan-appends.ts';
3
+
4
+ export type BatchPlan = ReadonlyMap<StreamName, readonly (readonly FactEnvelope<unknown>[])[]>;
5
+
6
+ // Groups the facts by stream, in batch order, and cuts each stream's facts into appends. One fact
7
+ // that no append can hold refuses the whole batch, so a batch never stores in part for its content.
8
+ export function planBatch(batch: Batch): Result<BatchPlan, { reason: 'too-large' }> {
9
+ const byStream = new Map<StreamName, FactEnvelope<unknown>[]>();
10
+
11
+ for (const pending of batch.facts) {
12
+ const facts = byStream.get(pending.stream) ?? [];
13
+
14
+ facts.push(pending.fact);
15
+ byStream.set(pending.stream, facts);
16
+ }
17
+
18
+ const plan = new Map<StreamName, readonly (readonly FactEnvelope<unknown>[])[]>();
19
+
20
+ for (const [stream, facts] of byStream) {
21
+ const runs = planAppends(facts);
22
+
23
+ if (!runs.ok) {
24
+ return { ok: false, failure: { reason: 'too-large' } };
25
+ }
26
+
27
+ plan.set(stream, runs.value);
28
+ }
29
+
30
+ return { ok: true, value: plan };
31
+ }
@@ -0,0 +1,33 @@
1
+ import { readStreamUpTo } from './read-stream-up-to.ts';
2
+
3
+ export type DecodedBody = Uint8Array | 'unsupported' | 'too-large' | 'corrupt';
4
+
5
+ // No `content-encoding`: the bytes as sent. `gzip` on a path that takes it: the bytes gunzipped as a
6
+ // stream that stops at `limitBytes` decoded bytes, so a small body cannot unpack to a large one. Any
7
+ // other value is `unsupported`, and a gzip body that does not gunzip is `corrupt`.
8
+ export async function readDecodedBody(
9
+ sent: Uint8Array,
10
+ encoding: string | null,
11
+ accepted: readonly 'gzip'[],
12
+ limitBytes: number,
13
+ ): Promise<DecodedBody> {
14
+ if (encoding === null) {
15
+ return sent;
16
+ }
17
+
18
+ if (encoding.trim().toLowerCase() !== 'gzip' || !accepted.includes('gzip')) {
19
+ return 'unsupported';
20
+ }
21
+
22
+ // the copy is a Uint8Array over an ArrayBuffer, which the ESNext lib's BlobPart needs; a plugin's
23
+ // own compiler checks this file with its own lib
24
+ const decoded = new Blob([new Uint8Array(sent)])
25
+ .stream()
26
+ .pipeThrough(new DecompressionStream('gzip'));
27
+
28
+ try {
29
+ return await readStreamUpTo(decoded, limitBytes);
30
+ } catch {
31
+ return 'corrupt';
32
+ }
33
+ }
@@ -0,0 +1,22 @@
1
+ import { readStreamUpTo } from './read-stream-up-to.ts';
2
+
3
+ // The body as sent, up to `limitBytes`. A `content-length` over the limit refuses before any read,
4
+ // and the count while reading refuses a body whose header understates it.
5
+ export async function readSentBody(
6
+ request: Request,
7
+ limitBytes: number,
8
+ ): Promise<Uint8Array | 'too-large'> {
9
+ const declared = Number(request.headers.get('content-length') ?? '0');
10
+
11
+ if (declared > limitBytes) {
12
+ return 'too-large';
13
+ }
14
+
15
+ if (request.body === null) {
16
+ return new Uint8Array(0);
17
+ }
18
+
19
+ const body = await readStreamUpTo(request.body, limitBytes);
20
+
21
+ return body;
22
+ }
@@ -0,0 +1,44 @@
1
+ // Reads a stream until it ends, or cancels it as soon as more than `limitBytes` have arrived, so a
2
+ // body over the limit is never held whole.
3
+ export async function readStreamUpTo(
4
+ stream: ReadableStream<Uint8Array>,
5
+ limitBytes: number,
6
+ ): Promise<Uint8Array | 'too-large'> {
7
+ const reader = stream.getReader();
8
+ const chunks: Uint8Array[] = [];
9
+ let total = 0;
10
+
11
+ for (;;) {
12
+ const read = await reader.read();
13
+
14
+ if (read.done) {
15
+ break;
16
+ }
17
+
18
+ total += read.value.byteLength;
19
+
20
+ if (total > limitBytes) {
21
+ await reader.cancel();
22
+
23
+ return 'too-large';
24
+ }
25
+
26
+ chunks.push(read.value);
27
+ }
28
+
29
+ return joinChunks(chunks, total);
30
+ }
31
+
32
+ function joinChunks(chunks: readonly Uint8Array[], total: number): Uint8Array {
33
+ const joined = new Uint8Array(total);
34
+
35
+ let offset = 0;
36
+
37
+ for (const chunk of chunks) {
38
+ joined.set(chunk, offset);
39
+
40
+ offset += chunk.byteLength;
41
+ }
42
+
43
+ return joined;
44
+ }
@@ -0,0 +1,55 @@
1
+ import type { EntityID } from '@pepys/kit';
2
+
3
+ interface RecentBody {
4
+ stored: number;
5
+ entities: readonly EntityID[];
6
+ }
7
+
8
+ // The bodies one route stored in the last hour, by key. In memory only, so a restart forgets them.
9
+ export interface RecentBodies {
10
+ find: (key: string) => RecentBody | undefined;
11
+ remember: (key: string, body: RecentBody) => void;
12
+ }
13
+
14
+ interface RecentBodiesOptions {
15
+ capacity: number;
16
+ maxAgeMs: number;
17
+ now: () => number;
18
+ }
19
+
20
+ interface Entry extends RecentBody {
21
+ atMs: number;
22
+ }
23
+
24
+ export function createRecentBodies(options: RecentBodiesOptions): RecentBodies {
25
+ // a Map keeps insertion order, and an entry is always set anew, so the oldest entry is first
26
+ const entries = new Map<string, Entry>();
27
+
28
+ const trimExpired = (): void => {
29
+ const oldestKept = options.now() - options.maxAgeMs;
30
+
31
+ for (const [key, entry] of entries) {
32
+ if (entry.atMs > oldestKept && entries.size <= options.capacity) {
33
+ return;
34
+ }
35
+
36
+ entries.delete(key);
37
+ }
38
+ };
39
+
40
+ return {
41
+ find: (key) => {
42
+ trimExpired();
43
+
44
+ const entry = entries.get(key);
45
+
46
+ return entry === undefined ? undefined : { stored: entry.stored, entities: entry.entities };
47
+ },
48
+ remember: (key, body) => {
49
+ entries.delete(key);
50
+ entries.set(key, { ...body, atMs: options.now() });
51
+
52
+ trimExpired();
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,16 @@
1
+ import type { EntityID } from '@pepys/kit';
2
+ import type { LastBody } from './collect-tied-entities.ts';
3
+
4
+ // A dropped copy of an earlier body may be a true return to an earlier state (A, B, A) when another
5
+ // body was stored for one of its entities since.
6
+ export function collectReturnedEntities(
7
+ lastBodies: ReadonlyMap<EntityID, LastBody>,
8
+ digest: string,
9
+ entities: readonly EntityID[],
10
+ ): EntityID[] {
11
+ return entities.filter((entity) => {
12
+ const last = lastBodies.get(entity);
13
+
14
+ return last !== undefined && last.digest !== digest;
15
+ });
16
+ }
@@ -0,0 +1,27 @@
1
+ import type { EntityID } from '@pepys/kit';
2
+
3
+ export interface LastBody {
4
+ digest: string;
5
+ at: string;
6
+ }
7
+
8
+ interface StoredFactTime {
9
+ entity: EntityID;
10
+ at: string;
11
+ }
12
+
13
+ // A new body ties with the last body stored for an entity when both have the same time and differ.
14
+ // Two bodies with one time cannot be ordered by their facts, so the resync pull reads the entity.
15
+ export function collectTiedEntities(
16
+ lastBodies: ReadonlyMap<EntityID, LastBody>,
17
+ digest: string,
18
+ facts: readonly StoredFactTime[],
19
+ ): EntityID[] {
20
+ return facts
21
+ .filter((fact) => {
22
+ const last = lastBodies.get(fact.entity);
23
+
24
+ return last !== undefined && last.at === fact.at && last.digest !== digest;
25
+ })
26
+ .map((fact) => fact.entity);
27
+ }
@@ -0,0 +1,106 @@
1
+ import type { EntityID } from '@pepys/kit';
2
+ import { collectReturnedEntities } from './collect-returned-entities.ts';
3
+ import { collectTiedEntities } from './collect-tied-entities.ts';
4
+ import type { LastBody } from './collect-tied-entities.ts';
5
+
6
+ // The entities whose row may be behind the source, which a source's resync pull reads again. The
7
+ // pull reads the marks, and removes each one only after its fact is stored, so a failed pull keeps
8
+ // it. In memory only, so a restart loses them.
9
+ export interface ResyncMarks {
10
+ list: (source: string) => readonly EntityID[];
11
+ remove: (source: string, entity: EntityID) => void;
12
+ }
13
+
14
+ // What the receive route of one source tells the marks.
15
+ export interface SourceMarks {
16
+ noteStored: (digest: string, facts: readonly { entity: EntityID; at: string }[]) => void;
17
+ noteCopy: (digest: string, entities: readonly EntityID[]) => void;
18
+ }
19
+
20
+ interface SourceState {
21
+ lastBodies: Map<EntityID, LastBody>;
22
+ marks: Set<EntityID>;
23
+ }
24
+
25
+ export interface ResyncMarkStore {
26
+ marks: ResyncMarks;
27
+ createSourceMarks: (source: string) => SourceMarks;
28
+ }
29
+
30
+ // `capacity` bounds the marks and the last bodies of each source; the oldest go first
31
+ interface ResyncMarksOptions {
32
+ capacity: number;
33
+ }
34
+
35
+ export function createResyncMarks(options: ResyncMarksOptions): ResyncMarkStore {
36
+ const sources = new Map<string, SourceState>();
37
+
38
+ const getState = (source: string): SourceState => {
39
+ const known = sources.get(source);
40
+
41
+ if (known !== undefined) {
42
+ return known;
43
+ }
44
+
45
+ const state: SourceState = { lastBodies: new Map(), marks: new Set() };
46
+
47
+ sources.set(source, state);
48
+
49
+ return state;
50
+ };
51
+
52
+ return {
53
+ marks: {
54
+ list: (source) => [...(sources.get(source)?.marks ?? [])],
55
+ remove: (source, entity) => {
56
+ sources.get(source)?.marks.delete(entity);
57
+ },
58
+ },
59
+ createSourceMarks: (source) => {
60
+ const state = getState(source);
61
+
62
+ return {
63
+ noteStored: (digest, facts) => {
64
+ addMarks(state.marks, collectTiedEntities(state.lastBodies, digest, facts), options);
65
+
66
+ for (const fact of facts) {
67
+ state.lastBodies.delete(fact.entity);
68
+ state.lastBodies.set(fact.entity, { digest, at: fact.at });
69
+ }
70
+
71
+ trimToCapacity(state.lastBodies, options.capacity);
72
+ },
73
+ noteCopy: (digest, entities) => {
74
+ addMarks(
75
+ state.marks,
76
+ collectReturnedEntities(state.lastBodies, digest, entities),
77
+ options,
78
+ );
79
+ },
80
+ };
81
+ },
82
+ };
83
+ }
84
+
85
+ function addMarks(
86
+ marks: Set<EntityID>,
87
+ entities: readonly EntityID[],
88
+ options: ResyncMarksOptions,
89
+ ): void {
90
+ for (const entity of entities) {
91
+ marks.add(entity);
92
+ }
93
+
94
+ trimToCapacity(marks, options.capacity);
95
+ }
96
+
97
+ // drops the oldest entries first: a Map and a Set keep insertion order
98
+ function trimToCapacity(entries: Map<EntityID, LastBody> | Set<EntityID>, capacity: number): void {
99
+ for (const key of entries.keys()) {
100
+ if (entries.size <= capacity) {
101
+ return;
102
+ }
103
+
104
+ entries.delete(key);
105
+ }
106
+ }
@@ -0,0 +1,79 @@
1
+ import type { StreamName } from '@pepys/kit';
2
+ import type { BatchAppender } from '../fact-log/make-batch-appender.ts';
3
+ import type { BatchPlan } from './plan-batch.ts';
4
+
5
+ export type BatchOutcome = 'stored' | 'refused' | 'not-stored';
6
+
7
+ // One chain of turns per stream, for the whole process. A delivery holds a stream's turn for all of
8
+ // its appends on that stream, so the appends of two deliveries never interleave.
9
+ export type StreamTurns = Map<StreamName, Promise<void>>;
10
+
11
+ interface WriteBatchDependencies {
12
+ append: BatchAppender;
13
+ turns: StreamTurns;
14
+ }
15
+
16
+ // The streams append at the same time, and the appends of one stream one after the other. Every
17
+ // stream settles before the answer, so a failure on one stream never leaves another in flight.
18
+ export async function writeBatch(
19
+ plan: BatchPlan,
20
+ dependencies: WriteBatchDependencies,
21
+ ): Promise<BatchOutcome> {
22
+ const settled = await Promise.all(
23
+ [...plan].map(([stream, runs]) =>
24
+ withStreamTurn(dependencies.turns, stream, async () => {
25
+ for (const run of runs) {
26
+ const appended = await dependencies.append(stream, run);
27
+
28
+ if (!appended.ok) {
29
+ console.error(`an append to ${stream} failed`, appended.failure);
30
+
31
+ return appended.failure.kind;
32
+ }
33
+ }
34
+
35
+ return 'stored' as const;
36
+ }),
37
+ ),
38
+ );
39
+
40
+ return pickBatchOutcome(settled);
41
+ }
42
+
43
+ // A throw is a refusal the log gives no outcome for, such as a bad access token.
44
+ async function withStreamTurn(
45
+ turns: StreamTurns,
46
+ stream: StreamName,
47
+ work: () => Promise<BatchOutcome>,
48
+ ): Promise<BatchOutcome> {
49
+ const previous = turns.get(stream) ?? Promise.resolve();
50
+ const done = Promise.withResolvers<void>();
51
+
52
+ turns.set(stream, done.promise);
53
+
54
+ try {
55
+ await previous;
56
+
57
+ return await work();
58
+ } catch (error: unknown) {
59
+ console.error(`an append to ${stream} threw`, error);
60
+
61
+ return 'refused';
62
+ } finally {
63
+ done.resolve();
64
+
65
+ if (turns.get(stream) === done.promise) {
66
+ turns.delete(stream);
67
+ }
68
+ }
69
+ }
70
+
71
+ // A refusal wins over `not-stored`: a repeat cannot mend it, so the sender must not be told to
72
+ // repeat.
73
+ function pickBatchOutcome(settled: readonly BatchOutcome[]): BatchOutcome {
74
+ if (settled.includes('refused')) {
75
+ return 'refused';
76
+ }
77
+
78
+ return settled.includes('not-stored') ? 'not-stored' : 'stored';
79
+ }
@@ -0,0 +1,14 @@
1
+ import type { Sender, SenderProof } from '@pepys/kit';
2
+
3
+ // The test stand-in for a verifier of signed identity tokens: the value of the `imp-identity`
4
+ // header proves the sender it is mapped to, and any other value proves nothing.
5
+ export function createFakeSenderProof(senders: Readonly<Record<string, Sender>>): SenderProof {
6
+ return {
7
+ prove: (_requirement, headers) => {
8
+ const token = headers.get('imp-identity');
9
+ const sender = token !== null && Object.hasOwn(senders, token) ? senders[token] : undefined;
10
+
11
+ return Promise.resolve(sender ?? null);
12
+ },
13
+ };
14
+ }