@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.
- package/README.md +34 -2
- package/package.json +33 -4
- package/src/blob-store/blob-store.ts +6 -0
- package/src/blob-store/build-blob-hash.ts +6 -0
- package/src/blob-store/garage/create-garage-blob-store.ts +82 -0
- package/src/blob-store/local-folder/create-local-folder-blob-store.ts +56 -0
- package/src/config/read-ingest-config.ts +50 -0
- package/src/fact-log/check-append-batch.ts +45 -0
- package/src/fact-log/fact-log.ts +35 -0
- package/src/fact-log/make-batch-appender.ts +198 -0
- package/src/fact-log/memory/create-memory-fact-log.ts +174 -0
- package/src/fact-log/parse-stored-body.ts +18 -0
- package/src/fact-log/parse-stored-fact.ts +17 -0
- package/src/fact-log/pick-settlement.ts +20 -0
- package/src/fact-log/plan-appends.ts +45 -0
- package/src/fact-log/s2/create-s2-fact-log.ts +171 -0
- package/src/fact-log/s2/find-append-outcome.ts +35 -0
- package/src/index.ts +23 -0
- package/src/pull/make-pull-runner.ts +104 -0
- package/src/receive/answers/build-failure-answer.ts +12 -0
- package/src/receive/answers/build-success-answer.ts +19 -0
- package/src/receive/create-ingest.ts +103 -0
- package/src/receive/find-batch-problem.ts +26 -0
- package/src/receive/make-receive-handler.ts +245 -0
- package/src/receive/plan-batch.ts +31 -0
- package/src/receive/read-decoded-body.ts +33 -0
- package/src/receive/read-sent-body.ts +22 -0
- package/src/receive/read-stream-up-to.ts +44 -0
- package/src/receive/recent-bodies/create-recent-bodies.ts +55 -0
- package/src/receive/resync-marks/collect-returned-entities.ts +16 -0
- package/src/receive/resync-marks/collect-tied-entities.ts +29 -0
- package/src/receive/resync-marks/create-resync-marks.ts +111 -0
- package/src/receive/write-batch.ts +79 -0
- package/src/sender-proof/fake/create-fake-sender-proof.ts +14 -0
- package/src/sender-proof/imp-identity/create-imp-identity-sender-proof.ts +42 -0
- package/src/sender-proof/imp-identity/read-imp-identity-sender-proof.ts +35 -0
- package/src/sender-proof/imp-identity/read-imp-identity-trust.ts +88 -0
- package/src/sender-proof/imp-identity/types.ts +9 -0
- package/src/sender-proof/none/no-sender-proof.ts +7 -0
- package/src/serve/serve-ingest.ts +107 -0
- package/src/serve/start-pull-timers.ts +69 -0
- package/src/source-port/types.ts +64 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { CheckedDeployment, SenderProof } from '@pepys/kit';
|
|
2
|
+
import type { FactLog } from '../fact-log/fact-log.ts';
|
|
3
|
+
import { makeBatchAppender } from '../fact-log/make-batch-appender.ts';
|
|
4
|
+
import { makePullRunner } from '../pull/make-pull-runner.ts';
|
|
5
|
+
import type { PullRunner } from '../pull/make-pull-runner.ts';
|
|
6
|
+
import { buildFailureAnswer } from './answers/build-failure-answer.ts';
|
|
7
|
+
import { makeIgnoredRouteHandler, makeReceiveHandler } from './make-receive-handler.ts';
|
|
8
|
+
import { createRecentBodies } from './recent-bodies/create-recent-bodies.ts';
|
|
9
|
+
import { createResyncMarks } from './resync-marks/create-resync-marks.ts';
|
|
10
|
+
import type { ResyncMarks } from './resync-marks/create-resync-marks.ts';
|
|
11
|
+
import type { StreamTurns } from './write-batch.ts';
|
|
12
|
+
|
|
13
|
+
export interface IngestOptions {
|
|
14
|
+
deployment: CheckedDeployment;
|
|
15
|
+
|
|
16
|
+
// One value per `check.secretEnv` and per name in a pull path's `env`. A missing or empty secret
|
|
17
|
+
// refuses every delivery.
|
|
18
|
+
secrets: Readonly<Record<string, string>>;
|
|
19
|
+
log: FactLog;
|
|
20
|
+
senderProof: SenderProof;
|
|
21
|
+
appendRetry: { maxAttempts: number; retryDelayMs: number };
|
|
22
|
+
now: () => number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Ingest {
|
|
26
|
+
// every receive route of the deployment; any other path answers 404
|
|
27
|
+
handleRequest: (request: Request) => Promise<Response>;
|
|
28
|
+
|
|
29
|
+
// what a source's resync pull reads
|
|
30
|
+
resyncMarks: ResyncMarks;
|
|
31
|
+
|
|
32
|
+
// one run of a pull path of the deployment, with its batch stored
|
|
33
|
+
runPull: PullRunner;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const recentBodyCapacity = 10_000;
|
|
37
|
+
const recentBodyMaxAgeMs = 3_600_000;
|
|
38
|
+
|
|
39
|
+
// The receive routes of a deployment over a given log, as one fetch function with no listener.
|
|
40
|
+
export function createIngest(options: IngestOptions): Ingest {
|
|
41
|
+
const append = makeBatchAppender({ log: options.log, ...options.appendRetry });
|
|
42
|
+
|
|
43
|
+
const turns: StreamTurns = new Map();
|
|
44
|
+
|
|
45
|
+
const resync = createResyncMarks({ capacity: recentBodyCapacity });
|
|
46
|
+
|
|
47
|
+
const handlers = new Map<string, (request: Request) => Promise<Response>>();
|
|
48
|
+
|
|
49
|
+
for (const source of options.deployment.sources) {
|
|
50
|
+
const path = source.live;
|
|
51
|
+
|
|
52
|
+
if (path?.kind !== 'receive') {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const secret = options.secrets[path.check.secretEnv] ?? '';
|
|
57
|
+
|
|
58
|
+
handlers.set(
|
|
59
|
+
path.route,
|
|
60
|
+
makeReceiveHandler(source, path, {
|
|
61
|
+
secret,
|
|
62
|
+
senderProof: options.senderProof,
|
|
63
|
+
append,
|
|
64
|
+
turns,
|
|
65
|
+
recentBodies: createRecentBodies({
|
|
66
|
+
capacity: recentBodyCapacity,
|
|
67
|
+
maxAgeMs: recentBodyMaxAgeMs,
|
|
68
|
+
now: options.now,
|
|
69
|
+
}),
|
|
70
|
+
marks: source.resync === undefined ? null : resync.createSourceMarks(source.name),
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
for (const route of path.ignoredRoutes ?? []) {
|
|
75
|
+
handlers.set(
|
|
76
|
+
route,
|
|
77
|
+
makeIgnoredRouteHandler(path, { secret, senderProof: options.senderProof }),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
handleRequest: async (request) => {
|
|
84
|
+
const handler = handlers.get(new URL(request.url).pathname);
|
|
85
|
+
|
|
86
|
+
if (handler === undefined) {
|
|
87
|
+
return buildFailureAnswer(404, 'not found');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const answer = await handler(request);
|
|
91
|
+
|
|
92
|
+
return answer;
|
|
93
|
+
},
|
|
94
|
+
resyncMarks: resync.marks,
|
|
95
|
+
runPull: makePullRunner({
|
|
96
|
+
deployment: options.deployment,
|
|
97
|
+
secrets: options.secrets,
|
|
98
|
+
append,
|
|
99
|
+
turns,
|
|
100
|
+
resyncMarks: resync.marks,
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parseFactEnvelope } from '@pepys/kit';
|
|
2
|
+
import type { Batch, SourceContext } from '@pepys/kit';
|
|
3
|
+
|
|
4
|
+
// Every fact is checked before any is written: its stream must be one of the source's families, and
|
|
5
|
+
// its envelope must parse.
|
|
6
|
+
export function findBatchProblem(source: SourceContext, batch: Batch): string | undefined {
|
|
7
|
+
const streams = new Set<string>(source.families.map((family) => family.stream));
|
|
8
|
+
|
|
9
|
+
for (const [index, pending] of batch.facts.entries()) {
|
|
10
|
+
if (!streams.has(pending.stream)) {
|
|
11
|
+
return `fact ${index}: the source ${source.name} has no stream ${pending.stream}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const envelope = parseFactEnvelope(pending.fact);
|
|
15
|
+
|
|
16
|
+
if (!envelope.ok) {
|
|
17
|
+
const issues = envelope.failure.issues.map(
|
|
18
|
+
(issue) => `${issue.path.join('.')}: ${issue.message}`,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
return `fact ${index} is not a valid envelope: ${issues.join('; ')}`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
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) => ({
|
|
141
|
+
entity: pending.fact.entity,
|
|
142
|
+
at: pending.fact.at,
|
|
143
|
+
locator: pending.locator,
|
|
144
|
+
})),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return buildSuccessAnswer(path, 'stored', facts.length);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A route of the path's `ignoredRoutes`: the same method, size, check and sender as the path, then
|
|
152
|
+
// the ignored answer. It reads no body further and stores nothing.
|
|
153
|
+
export function makeIgnoredRouteHandler(
|
|
154
|
+
path: ReceivePath,
|
|
155
|
+
dependencies: Pick<ReceiveDependencies, 'secret' | 'senderProof'>,
|
|
156
|
+
): Handler {
|
|
157
|
+
return async (request) => {
|
|
158
|
+
const checked = await readCheckedDelivery(request, path, dependencies);
|
|
159
|
+
|
|
160
|
+
return checked.kind === 'answer' ? checked.answer : buildSuccessAnswer(path, 'ignored', 0);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
type CheckedDelivery =
|
|
165
|
+
| { kind: 'answer'; answer: Response }
|
|
166
|
+
| { kind: 'checked'; sent: Uint8Array; sender: Sender | undefined };
|
|
167
|
+
|
|
168
|
+
// 405, 413, then 401 unless the check passes on the bytes as sent, then 401 unless the sender is
|
|
169
|
+
// proven on a path that needs one. No code of the source has seen the delivery yet.
|
|
170
|
+
async function readCheckedDelivery(
|
|
171
|
+
request: Request,
|
|
172
|
+
path: ReceivePath,
|
|
173
|
+
dependencies: Pick<ReceiveDependencies, 'secret' | 'senderProof'>,
|
|
174
|
+
): Promise<CheckedDelivery> {
|
|
175
|
+
if (request.method !== 'POST') {
|
|
176
|
+
return { kind: 'answer', answer: buildFailureAnswer(405, 'method not allowed') };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const sent = await readSentBody(request, path.bodyByteLimit ?? defaultBodyByteLimit);
|
|
180
|
+
|
|
181
|
+
if (sent === 'too-large') {
|
|
182
|
+
return { kind: 'answer', answer: buildFailureAnswer(413, 'body too large') };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!isDeliveryAuthentic(path.check, dependencies.secret, request.headers, sent)) {
|
|
186
|
+
return { kind: 'answer', answer: buildFailureAnswer(401, 'not authorised') };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (path.sender === undefined) {
|
|
190
|
+
return { kind: 'checked', sent, sender: undefined };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const sender = await readProvenSender(path.sender, request.headers, dependencies.senderProof);
|
|
194
|
+
|
|
195
|
+
if (sender === null) {
|
|
196
|
+
return { kind: 'answer', answer: buildFailureAnswer(401, 'not authorised') };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { kind: 'checked', sent, sender };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A proof that throws proves nothing. The error may quote the request, so only its fact is logged.
|
|
203
|
+
async function readProvenSender(
|
|
204
|
+
requirement: SenderRequirement,
|
|
205
|
+
headers: Headers,
|
|
206
|
+
senderProof: SenderProof,
|
|
207
|
+
): Promise<Sender | null> {
|
|
208
|
+
try {
|
|
209
|
+
const sender = await senderProof.prove(requirement, headers);
|
|
210
|
+
|
|
211
|
+
return sender;
|
|
212
|
+
} catch {
|
|
213
|
+
console.error('the sender proof threw, so the delivery proves no sender');
|
|
214
|
+
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// On a path with a sender, one body from two senders is two deliveries.
|
|
220
|
+
function buildRecentBodyKey(digest: string, sender: Sender | undefined): string {
|
|
221
|
+
return sender === undefined ? digest : JSON.stringify([digest, sender.issuer, sender.subject]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function pickDecodeFailure(failure: 'unsupported' | 'too-large' | 'corrupt'): Response {
|
|
225
|
+
return match(failure)
|
|
226
|
+
.with('unsupported', () => buildFailureAnswer(415, 'content encoding not accepted'))
|
|
227
|
+
.with('too-large', () => buildFailureAnswer(413, 'body too large'))
|
|
228
|
+
.with('corrupt', () => buildFailureAnswer(400, 'the body does not decode'))
|
|
229
|
+
.exhaustive();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The source's own code. It is pure, so a throw is a fault of the deployment.
|
|
233
|
+
function readSourceDelivery(
|
|
234
|
+
source: SourceDefinition,
|
|
235
|
+
path: ReceivePath,
|
|
236
|
+
delivery: Parameters<ReceivePath['read']>[0],
|
|
237
|
+
): Received | 'threw' {
|
|
238
|
+
try {
|
|
239
|
+
return path.read(delivery, { name: source.name, families: source.families });
|
|
240
|
+
} catch (error: unknown) {
|
|
241
|
+
console.error(`the read of the source ${source.name} threw`, error);
|
|
242
|
+
|
|
243
|
+
return 'threw';
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -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,29 @@
|
|
|
1
|
+
import type { EntityID } from '@pepys/kit';
|
|
2
|
+
|
|
3
|
+
export interface LastBody {
|
|
4
|
+
digest: string;
|
|
5
|
+
at: string;
|
|
6
|
+
locator: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StoredFactTime {
|
|
10
|
+
entity: EntityID;
|
|
11
|
+
at: string;
|
|
12
|
+
locator: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A new body ties with the last body stored for an entity when both have the same time and differ.
|
|
16
|
+
// Two bodies with one time cannot be ordered by their facts, so the resync pull reads the entity.
|
|
17
|
+
export function collectTiedEntities(
|
|
18
|
+
lastBodies: ReadonlyMap<EntityID, LastBody>,
|
|
19
|
+
digest: string,
|
|
20
|
+
facts: readonly StoredFactTime[],
|
|
21
|
+
): EntityID[] {
|
|
22
|
+
return facts
|
|
23
|
+
.filter((fact) => {
|
|
24
|
+
const last = lastBodies.get(fact.entity);
|
|
25
|
+
|
|
26
|
+
return last !== undefined && last.at === fact.at && last.digest !== digest;
|
|
27
|
+
})
|
|
28
|
+
.map((fact) => fact.entity);
|
|
29
|
+
}
|