@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,111 @@
|
|
|
1
|
+
import type { EntityID, ResyncMark } from '@pepys/kit';
|
|
2
|
+
import { collectReturnedEntities } from './collect-returned-entities.ts';
|
|
3
|
+
import { collectTiedEntities } from './collect-tied-entities.ts';
|
|
4
|
+
import type { LastBody, StoredFactTime } 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 ResyncMark[];
|
|
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 StoredFactTime[]) => void;
|
|
17
|
+
noteCopy: (digest: string, entities: readonly EntityID[]) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SourceState {
|
|
21
|
+
lastBodies: Map<EntityID, LastBody>;
|
|
22
|
+
|
|
23
|
+
// each marked entity, with the locator of the last fact stored for it
|
|
24
|
+
marks: Map<EntityID, string | undefined>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResyncMarkStore {
|
|
28
|
+
marks: ResyncMarks;
|
|
29
|
+
createSourceMarks: (source: string) => SourceMarks;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `capacity` bounds the marks and the last bodies of each source; the oldest go first
|
|
33
|
+
interface ResyncMarksOptions {
|
|
34
|
+
capacity: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createResyncMarks(options: ResyncMarksOptions): ResyncMarkStore {
|
|
38
|
+
const sources = new Map<string, SourceState>();
|
|
39
|
+
|
|
40
|
+
const getState = (source: string): SourceState => {
|
|
41
|
+
const known = sources.get(source);
|
|
42
|
+
|
|
43
|
+
if (known !== undefined) {
|
|
44
|
+
return known;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const state: SourceState = { lastBodies: new Map(), marks: new Map() };
|
|
48
|
+
|
|
49
|
+
sources.set(source, state);
|
|
50
|
+
|
|
51
|
+
return state;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
marks: {
|
|
56
|
+
list: (source) =>
|
|
57
|
+
[...(sources.get(source)?.marks ?? [])].map(([entity, locator]) => ({ entity, locator })),
|
|
58
|
+
remove: (source, entity) => {
|
|
59
|
+
sources.get(source)?.marks.delete(entity);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
createSourceMarks: (source) => {
|
|
63
|
+
const state = getState(source);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
noteStored: (digest, facts) => {
|
|
67
|
+
const tied = collectTiedEntities(state.lastBodies, digest, facts);
|
|
68
|
+
|
|
69
|
+
for (const fact of facts) {
|
|
70
|
+
state.lastBodies.delete(fact.entity);
|
|
71
|
+
state.lastBodies.set(fact.entity, { digest, at: fact.at, locator: fact.locator });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
addMarks(state, tied, options);
|
|
75
|
+
trimToCapacity(state.lastBodies, options.capacity);
|
|
76
|
+
},
|
|
77
|
+
noteCopy: (digest, entities) => {
|
|
78
|
+
addMarks(state, collectReturnedEntities(state.lastBodies, digest, entities), options);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The locator comes from the last body stored for the entity, which the caller has noted first.
|
|
86
|
+
function addMarks(
|
|
87
|
+
state: SourceState,
|
|
88
|
+
entities: readonly EntityID[],
|
|
89
|
+
options: ResyncMarksOptions,
|
|
90
|
+
): void {
|
|
91
|
+
for (const entity of entities) {
|
|
92
|
+
state.marks.delete(entity);
|
|
93
|
+
state.marks.set(entity, state.lastBodies.get(entity)?.locator);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
trimToCapacity(state.marks, options.capacity);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// drops the oldest entries first: a Map and a Set keep insertion order
|
|
100
|
+
function trimToCapacity(
|
|
101
|
+
entries: Map<EntityID, LastBody> | Map<EntityID, string | undefined>,
|
|
102
|
+
capacity: number,
|
|
103
|
+
): void {
|
|
104
|
+
for (const key of entries.keys()) {
|
|
105
|
+
if (entries.size <= capacity) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
entries.delete(key);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { verifyImpIdentity } from '@impz/client/identity';
|
|
2
|
+
import type { SenderProof } from '@pepys/kit';
|
|
3
|
+
import type { ImpIdentityTrust } from './types.ts';
|
|
4
|
+
|
|
5
|
+
interface ImpIdentityOptions {
|
|
6
|
+
verify: typeof verifyImpIdentity;
|
|
7
|
+
trust: ImpIdentityTrust;
|
|
8
|
+
now: () => number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Proves the sender from the signed identity token in the `imp-identity` header. The issuer and
|
|
12
|
+
// the subject come only from the verified token, and every failure, a throw included, proves
|
|
13
|
+
// nothing. It never logs the token.
|
|
14
|
+
export function createImpIdentitySenderProof(options: ImpIdentityOptions): SenderProof {
|
|
15
|
+
return {
|
|
16
|
+
prove: async (requirement, headers) => {
|
|
17
|
+
const documents = options.trust.issuers.get(requirement.issuersEnv) ?? [];
|
|
18
|
+
const audience = options.trust.audiences.get(requirement.audienceEnv);
|
|
19
|
+
|
|
20
|
+
// The verifier throws on an empty list of documents, so a path with none refuses here.
|
|
21
|
+
if (documents.length === 0 || audience === undefined) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const verified = await options.verify(headers.get('imp-identity'), {
|
|
27
|
+
issuers: documents,
|
|
28
|
+
audience,
|
|
29
|
+
nowSeconds: Math.floor(options.now() / 1000),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return verified === null
|
|
33
|
+
? null
|
|
34
|
+
: { issuer: verified.issuer, subject: verified.id, name: verified.name };
|
|
35
|
+
} catch {
|
|
36
|
+
console.error('the identity verifier failed, so the delivery proves no sender');
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { verifyImpIdentity } from '@impz/client/identity';
|
|
2
|
+
import type { Result, SenderProof, SourceDefinition } from '@pepys/kit';
|
|
3
|
+
import { createImpIdentitySenderProof } from './create-imp-identity-sender-proof.ts';
|
|
4
|
+
import { readImpIdentityTrust } from './read-imp-identity-trust.ts';
|
|
5
|
+
|
|
6
|
+
interface SenderProofReadOptions {
|
|
7
|
+
sources: readonly SourceDefinition[];
|
|
8
|
+
environment: Readonly<Record<string, string | undefined>>;
|
|
9
|
+
now: () => number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The sender proof that `serveIngest` runs: the trust of the deployment's environment and the
|
|
13
|
+
// verifier of `@impz/client/identity`. It fails only when a set folder cannot be read or holds an
|
|
14
|
+
// invalid document.
|
|
15
|
+
export async function readImpIdentitySenderProof(
|
|
16
|
+
options: SenderProofReadOptions,
|
|
17
|
+
): Promise<Result<SenderProof, string>> {
|
|
18
|
+
const trust = await readImpIdentityTrust({
|
|
19
|
+
sources: options.sources,
|
|
20
|
+
environment: options.environment,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!trust.ok) {
|
|
24
|
+
return trust;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
value: createImpIdentitySenderProof({
|
|
30
|
+
verify: verifyImpIdentity,
|
|
31
|
+
trust: trust.value,
|
|
32
|
+
now: options.now,
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadIdentityIssuers } from '@impz/client/identity';
|
|
4
|
+
import type { IdentityTrustDocument } from '@impz/client/identity';
|
|
5
|
+
import type { Result, SenderRequirement, SourceDefinition } from '@pepys/kit';
|
|
6
|
+
import type { ImpIdentityTrust } from './types.ts';
|
|
7
|
+
|
|
8
|
+
interface TrustReadOptions {
|
|
9
|
+
sources: readonly SourceDefinition[];
|
|
10
|
+
|
|
11
|
+
// the environment of the ingest process; never a request
|
|
12
|
+
environment: Readonly<Record<string, string | undefined>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// An unset or empty variable, or an empty folder, leaves its paths with no entry, so they refuse
|
|
16
|
+
// every delivery. A folder that cannot be read, or a document that the loader refuses, fails the
|
|
17
|
+
// start.
|
|
18
|
+
export async function readImpIdentityTrust(
|
|
19
|
+
options: TrustReadOptions,
|
|
20
|
+
): Promise<Result<ImpIdentityTrust, string>> {
|
|
21
|
+
const requirements = collectSenderRequirements(options.sources);
|
|
22
|
+
|
|
23
|
+
const issuers = new Map<string, readonly IdentityTrustDocument[]>();
|
|
24
|
+
const audiences = new Map<string, string>();
|
|
25
|
+
|
|
26
|
+
for (const name of new Set(requirements.map((requirement) => requirement.audienceEnv))) {
|
|
27
|
+
const audience = options.environment[name] ?? '';
|
|
28
|
+
|
|
29
|
+
if (audience !== '') {
|
|
30
|
+
audiences.set(name, audience);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const name of new Set(requirements.map((requirement) => requirement.issuersEnv))) {
|
|
35
|
+
const folder = options.environment[name] ?? '';
|
|
36
|
+
|
|
37
|
+
if (folder === '') {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const documents = await readFolderDocuments(folder);
|
|
42
|
+
|
|
43
|
+
if (!documents.ok) {
|
|
44
|
+
return { ok: false, failure: `${name}: ${documents.failure}` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (documents.value.length === 0) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
issuers.set(name, loadIdentityIssuers(documents.value));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const reason = error instanceof Error ? error.message : 'the loader gave no reason';
|
|
55
|
+
|
|
56
|
+
return { ok: false, failure: `${name}: the trust documents are invalid: ${reason}` };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { ok: true, value: { issuers, audiences } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function collectSenderRequirements(sources: readonly SourceDefinition[]): SenderRequirement[] {
|
|
64
|
+
return sources.flatMap((source) =>
|
|
65
|
+
source.live?.kind === 'receive' && source.live.sender !== undefined ? [source.live.sender] : [],
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readFolderDocuments(folder: string): Promise<Result<unknown[], string>> {
|
|
70
|
+
try {
|
|
71
|
+
const entries = await readdir(folder);
|
|
72
|
+
|
|
73
|
+
const files = entries.filter((file) => file.endsWith('.json')).toSorted();
|
|
74
|
+
const documents: unknown[] = [];
|
|
75
|
+
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const document: unknown = await Bun.file(join(folder, file)).json();
|
|
78
|
+
|
|
79
|
+
documents.push(document);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { ok: true, value: documents };
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const reason = error instanceof Error ? error.message : 'no reason given';
|
|
85
|
+
|
|
86
|
+
return { ok: false, failure: `the trust documents cannot be read: ${reason}` };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { IdentityTrustDocument } from '@impz/client/identity';
|
|
2
|
+
|
|
3
|
+
// What the deployment trusts, read from its environment at start: the trust documents of each
|
|
4
|
+
// `issuersEnv` and the audience of each `audienceEnv` that its paths name. A name with no entry
|
|
5
|
+
// refuses every delivery on its paths.
|
|
6
|
+
export interface ImpIdentityTrust {
|
|
7
|
+
readonly issuers: ReadonlyMap<string, readonly IdentityTrustDocument[]>;
|
|
8
|
+
readonly audiences: ReadonlyMap<string, string>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SenderProof } from '@pepys/kit';
|
|
2
|
+
|
|
3
|
+
// Proves no sender, so a path that needs one refuses every delivery. Ingest uses it for a path whose
|
|
4
|
+
// trust documents are missing.
|
|
5
|
+
export const noSenderProof: SenderProof = {
|
|
6
|
+
prove: () => Promise.resolve(null),
|
|
7
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { checkDeployment, listReceiveRoutes } from '@pepys/kit';
|
|
2
|
+
import type { Deployment, SourceDefinition, StreamName } from '@pepys/kit';
|
|
3
|
+
import { makeShutdownHandler } from '@pepys/schema';
|
|
4
|
+
import { toTagged } from '@zgeoff/std';
|
|
5
|
+
import { readIngestConfig } from '../config/read-ingest-config.ts';
|
|
6
|
+
import { createS2FactLog } from '../fact-log/s2/create-s2-fact-log.ts';
|
|
7
|
+
import { createIngest } from '../receive/create-ingest.ts';
|
|
8
|
+
import { readImpIdentitySenderProof } from '../sender-proof/imp-identity/read-imp-identity-sender-proof.ts';
|
|
9
|
+
import { startPullTimers } from './start-pull-timers.ts';
|
|
10
|
+
|
|
11
|
+
interface ServeIngestOptions {
|
|
12
|
+
deployment: Deployment;
|
|
13
|
+
environment: Readonly<Record<string, string | undefined>>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The ingest process of a deployment. It exits 1 at start on a failed deployment check, on any
|
|
17
|
+
// missing variable, or on a trust folder it cannot load, so no route ever runs with an empty secret.
|
|
18
|
+
// A path whose trust documents or audience are unset starts, and refuses every delivery.
|
|
19
|
+
export async function serveIngest(options: ServeIngestOptions): Promise<void> {
|
|
20
|
+
const checked = checkDeployment(options.deployment);
|
|
21
|
+
|
|
22
|
+
if (!checked.ok) {
|
|
23
|
+
console.error('the deployment fails its check', JSON.stringify(checked.failure));
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sources = checked.value.sources;
|
|
28
|
+
const config = readIngestConfig(options.environment, collectSecretNames(sources));
|
|
29
|
+
|
|
30
|
+
if (!config.ok) {
|
|
31
|
+
console.error(config.failure.join('\n'));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const senderProof = await readImpIdentitySenderProof({
|
|
36
|
+
sources,
|
|
37
|
+
environment: options.environment,
|
|
38
|
+
now: () => Date.now(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!senderProof.ok) {
|
|
42
|
+
console.error(senderProof.failure);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The S2 adapter creates the basin and each missing stream, so no setup command creates streams.
|
|
47
|
+
const log = await createS2FactLog({ ...config.value.s2, streams: collectStreams(sources) });
|
|
48
|
+
|
|
49
|
+
const ingest = createIngest({
|
|
50
|
+
deployment: checked.value,
|
|
51
|
+
secrets: config.value.secrets,
|
|
52
|
+
log,
|
|
53
|
+
senderProof: senderProof.value,
|
|
54
|
+
appendRetry: { maxAttempts: 5, retryDelayMs: 200 },
|
|
55
|
+
now: () => Date.now(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
for (const route of listReceiveRoutes(options.deployment)) {
|
|
59
|
+
console.log(`route ${route.route}`, JSON.stringify(route));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const stopPulls = startPullTimers(sources, ingest.runPull);
|
|
63
|
+
|
|
64
|
+
const server = Bun.serve({
|
|
65
|
+
hostname: config.value.host,
|
|
66
|
+
port: config.value.port,
|
|
67
|
+
fetch: ingest.handleRequest,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
console.log(`ingest listening on ${server.hostname}:${server.port}`);
|
|
71
|
+
|
|
72
|
+
const shutdown = makeShutdownHandler({
|
|
73
|
+
stop: () => {
|
|
74
|
+
stopPulls();
|
|
75
|
+
|
|
76
|
+
return server.stop();
|
|
77
|
+
},
|
|
78
|
+
logError: (message, error) => {
|
|
79
|
+
console.error(message, error);
|
|
80
|
+
},
|
|
81
|
+
exit: (code) => process.exit(code),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
85
|
+
process.on(signal, () => {
|
|
86
|
+
void shutdown();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// every receive path's secret, and every name a pull path's `env` lists
|
|
92
|
+
function collectSecretNames(sources: readonly SourceDefinition[]): string[] {
|
|
93
|
+
const names = sources.flatMap((source) => [
|
|
94
|
+
...(source.live?.kind === 'receive' ? [source.live.check.secretEnv] : []),
|
|
95
|
+
...[source.live, source.recovery, source.backfill, source.resync].flatMap((path) =>
|
|
96
|
+
path?.kind === 'pull' ? (path.env ?? []) : [],
|
|
97
|
+
),
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
return [...new Set(names)];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function collectStreams(sources: readonly SourceDefinition[]): StreamName[] {
|
|
104
|
+
const streams = sources.flatMap((source) => source.families.map((family) => family.stream));
|
|
105
|
+
|
|
106
|
+
return [...new Set(streams)].map((stream) => toTagged<StreamName>(stream));
|
|
107
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SourceDefinition } from '@pepys/kit';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
3
|
+
import type { PullPathName, PullRun, PullRunner } from '../pull/make-pull-runner.ts';
|
|
4
|
+
|
|
5
|
+
const pullPathNames: readonly PullPathName[] = ['live', 'recovery', 'backfill', 'resync'];
|
|
6
|
+
|
|
7
|
+
// One timer per pull path with an interval. The cursor lives in memory, so a restart starts each
|
|
8
|
+
// pull from no cursor. A run never overlaps the one before it. Returns the function that stops them.
|
|
9
|
+
export function startPullTimers(
|
|
10
|
+
sources: readonly SourceDefinition[],
|
|
11
|
+
runPull: PullRunner,
|
|
12
|
+
): () => void {
|
|
13
|
+
const timers = sources.flatMap((source) =>
|
|
14
|
+
pullPathNames.flatMap((pathName) => {
|
|
15
|
+
const path = source[pathName];
|
|
16
|
+
|
|
17
|
+
if (path?.kind !== 'pull' || path.intervalMs === null) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let cursor: string | null = null;
|
|
22
|
+
let isRunning = false;
|
|
23
|
+
|
|
24
|
+
const runOnce = async (): Promise<void> => {
|
|
25
|
+
if (isRunning) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
isRunning = true;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const run = await runPull(source.name, pathName, cursor);
|
|
33
|
+
|
|
34
|
+
cursor = pickNextCursor(run, cursor);
|
|
35
|
+
|
|
36
|
+
if (run.kind !== 'stored') {
|
|
37
|
+
console.error(`the ${pathName} pull of ${source.name} did not store`, run);
|
|
38
|
+
}
|
|
39
|
+
} catch (error: unknown) {
|
|
40
|
+
console.error(`the ${pathName} pull of ${source.name} threw`, error);
|
|
41
|
+
} finally {
|
|
42
|
+
isRunning = false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return [
|
|
47
|
+
setInterval(() => {
|
|
48
|
+
void runOnce();
|
|
49
|
+
}, path.intervalMs),
|
|
50
|
+
];
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
for (const timer of timers) {
|
|
56
|
+
clearInterval(timer);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// An expired cursor starts the pull again from none; any other failure keeps the cursor.
|
|
62
|
+
function pickNextCursor(run: PullRun, cursor: string | null): string | null {
|
|
63
|
+
return match(run)
|
|
64
|
+
.with({ kind: 'stored' }, (stored) => stored.cursor)
|
|
65
|
+
.with({ kind: 'expired' }, () => null)
|
|
66
|
+
.with({ kind: 'not-stored' }, { kind: 'refused' }, { kind: 'unavailable' }, () => cursor)
|
|
67
|
+
.with({ kind: 'rejected' }, () => cursor)
|
|
68
|
+
.exhaustive();
|
|
69
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BlobHash,
|
|
3
|
+
ConsumerDeclaration,
|
|
4
|
+
EntityKind,
|
|
5
|
+
FactEnvelope,
|
|
6
|
+
LinkTypeDeclaration,
|
|
7
|
+
Result,
|
|
8
|
+
StreamName,
|
|
9
|
+
} from '@pepys/kit';
|
|
10
|
+
|
|
11
|
+
export interface FactFamily<Payload> {
|
|
12
|
+
stream: StreamName;
|
|
13
|
+
types: readonly string[];
|
|
14
|
+
api: string;
|
|
15
|
+
parsePayload: (raw: unknown) => Result<Payload, { reason: string }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Provisional: source-retention
|
|
19
|
+
export interface SourceDefinition {
|
|
20
|
+
name: string;
|
|
21
|
+
families: readonly FactFamily<unknown>[];
|
|
22
|
+
entityKinds: readonly EntityKind[];
|
|
23
|
+
linkTypes: readonly LinkTypeDeclaration[];
|
|
24
|
+
consumers: readonly ConsumerDeclaration[];
|
|
25
|
+
live: ReceivePath | PullPath;
|
|
26
|
+
recovery: PullPath;
|
|
27
|
+
backfill: PullPath;
|
|
28
|
+
resync: PullPath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ReceivePath {
|
|
32
|
+
kind: 'receive';
|
|
33
|
+
route: string;
|
|
34
|
+
receive: (request: Request) => Promise<Received>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Received =
|
|
38
|
+
| { kind: 'refused'; status: 400 | 401 | 405 | 422; reason: string }
|
|
39
|
+
| { kind: 'ignored' }
|
|
40
|
+
| { kind: 'batch'; batch: Batch };
|
|
41
|
+
|
|
42
|
+
export interface PullPath {
|
|
43
|
+
kind: 'pull';
|
|
44
|
+
name: string;
|
|
45
|
+
intervalMs: number | null;
|
|
46
|
+
pull: (cursor: string | null) => Promise<Pulled>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Provisional: source-retention
|
|
50
|
+
export type Pulled =
|
|
51
|
+
| { kind: 'batch'; batch: Batch; cursor: string; isDone: boolean }
|
|
52
|
+
| { kind: 'expired' }
|
|
53
|
+
| { kind: 'unavailable'; retryAfterMs: number }
|
|
54
|
+
| { kind: 'rejected'; reason: string };
|
|
55
|
+
|
|
56
|
+
export interface Batch {
|
|
57
|
+
facts: readonly PendingFact[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface PendingFact {
|
|
61
|
+
stream: StreamName;
|
|
62
|
+
fact: FactEnvelope<unknown>;
|
|
63
|
+
blobs: readonly { hash: BlobHash; bytes: Uint8Array }[];
|
|
64
|
+
}
|