@pepys/ingest 0.0.1 → 0.1.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.
package/README.md CHANGED
@@ -25,6 +25,8 @@ bun add @pepys/ingest
25
25
  ## Features
26
26
 
27
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`.
28
30
  - The fact log port, with its S2 and memory adapters.
29
31
  - The blob store port, with its Garage and local folder adapters.
30
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pepys/ingest",
3
- "version": "0.0.1",
3
+ "version": "0.1.1",
4
4
  "description": "pepys ingest: the receive routes, the fact log and the blob store",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,8 +21,9 @@
21
21
  "access": "public"
22
22
  },
23
23
  "dependencies": {
24
- "@pepys/kit": "0.0.1",
25
- "@pepys/schema": "0.0.1",
24
+ "@impz/client": "6.12.0",
25
+ "@pepys/kit": "0.1.1",
26
+ "@pepys/schema": "0.1.1",
26
27
  "@s2-dev/streamstore": "0.30.0",
27
28
  "@zgeoff/std": "0.4.3",
28
29
  "tiny-invariant": "1.3.3",
package/src/index.ts CHANGED
@@ -11,18 +11,13 @@ export { planAppends } from './fact-log/plan-appends.ts';
11
11
  export { createS2FactLog } from './fact-log/s2/create-s2-fact-log.ts';
12
12
  export { createIngest } from './receive/create-ingest.ts';
13
13
  export type { Ingest, IngestOptions } from './receive/create-ingest.ts';
14
+ export type { PullPathName, PullRun, PullRunner } from './pull/make-pull-runner.ts';
14
15
  export type { ResyncMarks } from './receive/resync-marks/create-resync-marks.ts';
15
16
  export { createFakeSenderProof } from './sender-proof/fake/create-fake-sender-proof.ts';
16
17
  export { createImpIdentitySenderProof } from './sender-proof/imp-identity/create-imp-identity-sender-proof.ts';
18
+ export { readImpIdentitySenderProof } from './sender-proof/imp-identity/read-imp-identity-sender-proof.ts';
17
19
  export { readImpIdentityTrust } from './sender-proof/imp-identity/read-imp-identity-trust.ts';
18
-
19
- export type {
20
- ImpIdentityTrust,
21
- ImpIdentityVerifier,
22
- VerifiedImp,
23
- VerifyOptions,
24
- } from './sender-proof/imp-identity/types.ts';
25
-
20
+ export type { ImpIdentityTrust } from './sender-proof/imp-identity/types.ts';
26
21
  export { noSenderProof } from './sender-proof/none/no-sender-proof.ts';
27
22
  export { serveIngest } from './serve/serve-ingest.ts';
28
23
  export type * from './source-port/types.ts';
@@ -0,0 +1,104 @@
1
+ import type { CheckedDeployment, PullPath, Pulled, SourceDefinition } from '@pepys/kit';
2
+ import type { BatchAppender } from '../fact-log/make-batch-appender.ts';
3
+ import { findBatchProblem } from '../receive/find-batch-problem.ts';
4
+ import { planBatch } from '../receive/plan-batch.ts';
5
+ import type { ResyncMarks } from '../receive/resync-marks/create-resync-marks.ts';
6
+ import { writeBatch } from '../receive/write-batch.ts';
7
+ import type { StreamTurns } from '../receive/write-batch.ts';
8
+
9
+ export type PullPathName = 'live' | 'recovery' | 'backfill' | 'resync';
10
+
11
+ // `stored`: the batch is in the log, and the next run starts from `cursor`. `not-stored`: an append
12
+ // failed, so the next run starts from the same cursor. Any other answer is the pull's own.
13
+ export type PullRun =
14
+ | { kind: 'stored'; stored: number; cursor: string; isDone: boolean }
15
+ | { kind: 'not-stored' }
16
+ | { kind: 'refused'; reason: string }
17
+ | Exclude<Pulled, { kind: 'batch' }>;
18
+
19
+ export type PullRunner = (
20
+ source: string,
21
+ path: PullPathName,
22
+ cursor: string | null,
23
+ ) => Promise<PullRun>;
24
+
25
+ interface PullRunnerDependencies {
26
+ deployment: CheckedDeployment;
27
+
28
+ // one value per name that a pull path's `env` lists
29
+ secrets: Readonly<Record<string, string>>;
30
+ append: BatchAppender;
31
+ turns: StreamTurns;
32
+ resyncMarks: ResyncMarks;
33
+ }
34
+
35
+ // One run of one pull path: the pull, the envelope check, the appends, and then, for a resync pull,
36
+ // the removal of every mark it was given. A pull's fact is noted in no mark: a resynced fact ties
37
+ // with the body it replaces, and would mark its entity again on every run.
38
+ export function makePullRunner(dependencies: PullRunnerDependencies): PullRunner {
39
+ return async (sourceName, pathName, cursor) => {
40
+ const source = dependencies.deployment.sources.find(
41
+ (candidate) => candidate.name === sourceName,
42
+ );
43
+
44
+ const path = source === undefined ? undefined : findPullPath(source, pathName);
45
+
46
+ if (source === undefined || path === undefined) {
47
+ return { kind: 'refused', reason: `the source ${sourceName} has no ${pathName} pull` };
48
+ }
49
+
50
+ const marks = pathName === 'resync' ? dependencies.resyncMarks.list(source.name) : [];
51
+ const env: Record<string, string> = {};
52
+
53
+ for (const name of path.env ?? []) {
54
+ env[name] = dependencies.secrets[name] ?? '';
55
+ }
56
+
57
+ const pulled = await path.pull(cursor, { env, marks });
58
+
59
+ if (pulled.kind !== 'batch') {
60
+ return pulled;
61
+ }
62
+
63
+ const problem = findBatchProblem(source, pulled.batch);
64
+
65
+ if (problem !== undefined) {
66
+ return { kind: 'refused', reason: problem };
67
+ }
68
+
69
+ if (pulled.batch.facts.some((pending) => pending.blobs.length > 0)) {
70
+ return { kind: 'refused', reason: 'ingest has no blob store' };
71
+ }
72
+
73
+ const plan = planBatch(pulled.batch);
74
+
75
+ if (!plan.ok) {
76
+ return { kind: 'refused', reason: 'one fact is larger than one append of the log takes' };
77
+ }
78
+
79
+ const outcome = await writeBatch(plan.value, dependencies);
80
+
81
+ if (outcome !== 'stored') {
82
+ return outcome === 'not-stored'
83
+ ? { kind: 'not-stored' }
84
+ : { kind: 'refused', reason: 'the log refused the batch' };
85
+ }
86
+
87
+ for (const mark of marks) {
88
+ dependencies.resyncMarks.remove(source.name, mark.entity);
89
+ }
90
+
91
+ return {
92
+ kind: 'stored',
93
+ stored: pulled.batch.facts.length,
94
+ cursor: pulled.cursor,
95
+ isDone: pulled.isDone,
96
+ };
97
+ };
98
+ }
99
+
100
+ function findPullPath(source: SourceDefinition, pathName: PullPathName): PullPath | undefined {
101
+ const path = source[pathName];
102
+
103
+ return path?.kind === 'pull' ? path : undefined;
104
+ }
@@ -1,6 +1,8 @@
1
1
  import type { CheckedDeployment, SenderProof } from '@pepys/kit';
2
2
  import type { FactLog } from '../fact-log/fact-log.ts';
3
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';
4
6
  import { buildFailureAnswer } from './answers/build-failure-answer.ts';
5
7
  import { makeIgnoredRouteHandler, makeReceiveHandler } from './make-receive-handler.ts';
6
8
  import { createRecentBodies } from './recent-bodies/create-recent-bodies.ts';
@@ -11,7 +13,8 @@ import type { StreamTurns } from './write-batch.ts';
11
13
  export interface IngestOptions {
12
14
  deployment: CheckedDeployment;
13
15
 
14
- // one value per `check.secretEnv` of the deployment; a missing or empty one refuses every delivery
16
+ // One value per `check.secretEnv` and per name in a pull path's `env`. A missing or empty secret
17
+ // refuses every delivery.
15
18
  secrets: Readonly<Record<string, string>>;
16
19
  log: FactLog;
17
20
  senderProof: SenderProof;
@@ -25,6 +28,9 @@ export interface Ingest {
25
28
 
26
29
  // what a source's resync pull reads
27
30
  resyncMarks: ResyncMarks;
31
+
32
+ // one run of a pull path of the deployment, with its batch stored
33
+ runPull: PullRunner;
28
34
  }
29
35
 
30
36
  const recentBodyCapacity = 10_000;
@@ -86,5 +92,12 @@ export function createIngest(options: IngestOptions): Ingest {
86
92
  return answer;
87
93
  },
88
94
  resyncMarks: resync.marks,
95
+ runPull: makePullRunner({
96
+ deployment: options.deployment,
97
+ secrets: options.secrets,
98
+ append,
99
+ turns,
100
+ resyncMarks: resync.marks,
101
+ }),
89
102
  };
90
103
  }
@@ -137,7 +137,11 @@ export function makeReceiveHandler(
137
137
 
138
138
  dependencies.marks?.noteStored(
139
139
  digest,
140
- facts.map((pending) => ({ entity: pending.fact.entity, at: pending.fact.at })),
140
+ facts.map((pending) => ({
141
+ entity: pending.fact.entity,
142
+ at: pending.fact.at,
143
+ locator: pending.locator,
144
+ })),
141
145
  );
142
146
 
143
147
  return buildSuccessAnswer(path, 'stored', facts.length);
@@ -3,11 +3,13 @@ import type { EntityID } from '@pepys/kit';
3
3
  export interface LastBody {
4
4
  digest: string;
5
5
  at: string;
6
+ locator: string | undefined;
6
7
  }
7
8
 
8
- interface StoredFactTime {
9
+ export interface StoredFactTime {
9
10
  entity: EntityID;
10
11
  at: string;
12
+ locator: string | undefined;
11
13
  }
12
14
 
13
15
  // A new body ties with the last body stored for an entity when both have the same time and differ.
@@ -1,25 +1,27 @@
1
- import type { EntityID } from '@pepys/kit';
1
+ import type { EntityID, ResyncMark } from '@pepys/kit';
2
2
  import { collectReturnedEntities } from './collect-returned-entities.ts';
3
3
  import { collectTiedEntities } from './collect-tied-entities.ts';
4
- import type { LastBody } from './collect-tied-entities.ts';
4
+ import type { LastBody, StoredFactTime } from './collect-tied-entities.ts';
5
5
 
6
6
  // The entities whose row may be behind the source, which a source's resync pull reads again. The
7
7
  // pull reads the marks, and removes each one only after its fact is stored, so a failed pull keeps
8
8
  // it. In memory only, so a restart loses them.
9
9
  export interface ResyncMarks {
10
- list: (source: string) => readonly EntityID[];
10
+ list: (source: string) => readonly ResyncMark[];
11
11
  remove: (source: string, entity: EntityID) => void;
12
12
  }
13
13
 
14
14
  // What the receive route of one source tells the marks.
15
15
  export interface SourceMarks {
16
- noteStored: (digest: string, facts: readonly { entity: EntityID; at: string }[]) => void;
16
+ noteStored: (digest: string, facts: readonly StoredFactTime[]) => void;
17
17
  noteCopy: (digest: string, entities: readonly EntityID[]) => void;
18
18
  }
19
19
 
20
20
  interface SourceState {
21
21
  lastBodies: Map<EntityID, LastBody>;
22
- marks: Set<EntityID>;
22
+
23
+ // each marked entity, with the locator of the last fact stored for it
24
+ marks: Map<EntityID, string | undefined>;
23
25
  }
24
26
 
25
27
  export interface ResyncMarkStore {
@@ -42,7 +44,7 @@ export function createResyncMarks(options: ResyncMarksOptions): ResyncMarkStore
42
44
  return known;
43
45
  }
44
46
 
45
- const state: SourceState = { lastBodies: new Map(), marks: new Set() };
47
+ const state: SourceState = { lastBodies: new Map(), marks: new Map() };
46
48
 
47
49
  sources.set(source, state);
48
50
 
@@ -51,7 +53,8 @@ export function createResyncMarks(options: ResyncMarksOptions): ResyncMarkStore
51
53
 
52
54
  return {
53
55
  marks: {
54
- list: (source) => [...(sources.get(source)?.marks ?? [])],
56
+ list: (source) =>
57
+ [...(sources.get(source)?.marks ?? [])].map(([entity, locator]) => ({ entity, locator })),
55
58
  remove: (source, entity) => {
56
59
  sources.get(source)?.marks.delete(entity);
57
60
  },
@@ -61,41 +64,43 @@ export function createResyncMarks(options: ResyncMarksOptions): ResyncMarkStore
61
64
 
62
65
  return {
63
66
  noteStored: (digest, facts) => {
64
- addMarks(state.marks, collectTiedEntities(state.lastBodies, digest, facts), options);
67
+ const tied = collectTiedEntities(state.lastBodies, digest, facts);
65
68
 
66
69
  for (const fact of facts) {
67
70
  state.lastBodies.delete(fact.entity);
68
- state.lastBodies.set(fact.entity, { digest, at: fact.at });
71
+ state.lastBodies.set(fact.entity, { digest, at: fact.at, locator: fact.locator });
69
72
  }
70
73
 
74
+ addMarks(state, tied, options);
71
75
  trimToCapacity(state.lastBodies, options.capacity);
72
76
  },
73
77
  noteCopy: (digest, entities) => {
74
- addMarks(
75
- state.marks,
76
- collectReturnedEntities(state.lastBodies, digest, entities),
77
- options,
78
- );
78
+ addMarks(state, collectReturnedEntities(state.lastBodies, digest, entities), options);
79
79
  },
80
80
  };
81
81
  },
82
82
  };
83
83
  }
84
84
 
85
+ // The locator comes from the last body stored for the entity, which the caller has noted first.
85
86
  function addMarks(
86
- marks: Set<EntityID>,
87
+ state: SourceState,
87
88
  entities: readonly EntityID[],
88
89
  options: ResyncMarksOptions,
89
90
  ): void {
90
91
  for (const entity of entities) {
91
- marks.add(entity);
92
+ state.marks.delete(entity);
93
+ state.marks.set(entity, state.lastBodies.get(entity)?.locator);
92
94
  }
93
95
 
94
- trimToCapacity(marks, options.capacity);
96
+ trimToCapacity(state.marks, options.capacity);
95
97
  }
96
98
 
97
99
  // 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 {
100
+ function trimToCapacity(
101
+ entries: Map<EntityID, LastBody> | Map<EntityID, string | undefined>,
102
+ capacity: number,
103
+ ): void {
99
104
  for (const key of entries.keys()) {
100
105
  if (entries.size <= capacity) {
101
106
  return;
@@ -1,45 +1,37 @@
1
- import type { Sender, SenderProof } from '@pepys/kit';
2
- import type { ImpIdentityTrust, ImpIdentityVerifier, VerifiedImp } from './types.ts';
1
+ import type { verifyImpIdentity } from '@impz/client/identity';
2
+ import type { SenderProof } from '@pepys/kit';
3
+ import type { ImpIdentityTrust } from './types.ts';
3
4
 
4
- interface ImpIdentityOptions<Document> {
5
- verify: ImpIdentityVerifier<Document>['verify'];
6
- trust: ImpIdentityTrust<Document>;
5
+ interface ImpIdentityOptions {
6
+ verify: typeof verifyImpIdentity;
7
+ trust: ImpIdentityTrust;
7
8
  now: () => number;
8
9
  }
9
10
 
10
- // The broker removes any `Imp-Identity` header a guest sends and adds its own: a compact JWS. A
11
- // header of any other shape, two joined headers included, never reaches the verifier.
12
- const compactToken = /^[\w-]+\.[\w-]+\.[\w-]+$/u;
13
-
14
- // Proves the sender from the signed identity token of a worker's machine. The issuer and the
15
- // subject come only from the verified token, and every failure, a throw included, proves nothing.
16
- // It never logs the token.
17
- export function createImpIdentitySenderProof<Document>(
18
- options: ImpIdentityOptions<Document>,
19
- ): SenderProof {
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 {
20
15
  return {
21
16
  prove: async (requirement, headers) => {
22
17
  const documents = options.trust.issuers.get(requirement.issuersEnv) ?? [];
23
18
  const audience = options.trust.audiences.get(requirement.audienceEnv);
24
- const header = headers.get('imp-identity');
25
19
 
26
- if (
27
- documents.length === 0 ||
28
- audience === undefined ||
29
- header === null ||
30
- !compactToken.test(header)
31
- ) {
20
+ // The verifier throws on an empty list of documents, so a path with none refuses here.
21
+ if (documents.length === 0 || audience === undefined) {
32
22
  return null;
33
23
  }
34
24
 
35
25
  try {
36
- const verified = await options.verify(header, {
26
+ const verified = await options.verify(headers.get('imp-identity'), {
37
27
  issuers: documents,
38
28
  audience,
39
29
  nowSeconds: Math.floor(options.now() / 1000),
40
30
  });
41
31
 
42
- return verified === null ? null : toSender(verified);
32
+ return verified === null
33
+ ? null
34
+ : { issuer: verified.issuer, subject: verified.id, name: verified.name };
43
35
  } catch {
44
36
  console.error('the identity verifier failed, so the delivery proves no sender');
45
37
 
@@ -48,11 +40,3 @@ export function createImpIdentitySenderProof<Document>(
48
40
  },
49
41
  };
50
42
  }
51
-
52
- function toSender(verified: VerifiedImp): Sender | null {
53
- if (verified.issuer === '' || verified.id === '') {
54
- return null;
55
- }
56
-
57
- return { issuer: verified.issuer, subject: verified.id, name: verified.name };
58
- }
@@ -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
+ }
@@ -1,25 +1,26 @@
1
1
  import { readdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { loadIdentityIssuers } from '@impz/client/identity';
4
+ import type { IdentityTrustDocument } from '@impz/client/identity';
3
5
  import type { Result, SenderRequirement, SourceDefinition } from '@pepys/kit';
4
- import type { ImpIdentityTrust, ImpIdentityVerifier } from './types.ts';
6
+ import type { ImpIdentityTrust } from './types.ts';
5
7
 
6
- interface TrustReadOptions<Document> {
8
+ interface TrustReadOptions {
7
9
  sources: readonly SourceDefinition[];
8
10
 
9
11
  // the environment of the ingest process; never a request
10
12
  environment: Readonly<Record<string, string | undefined>>;
11
- loadIssuers: ImpIdentityVerifier<Document>['loadIssuers'];
12
13
  }
13
14
 
14
15
  // An unset or empty variable, or an empty folder, leaves its paths with no entry, so they refuse
15
- // every delivery. A folder that cannot be read, or a document the verifier refuses, fails the
16
+ // every delivery. A folder that cannot be read, or a document that the loader refuses, fails the
16
17
  // start.
17
- export async function readImpIdentityTrust<Document>(
18
- options: TrustReadOptions<Document>,
19
- ): Promise<Result<ImpIdentityTrust<Document>, string>> {
18
+ export async function readImpIdentityTrust(
19
+ options: TrustReadOptions,
20
+ ): Promise<Result<ImpIdentityTrust, string>> {
20
21
  const requirements = collectSenderRequirements(options.sources);
21
22
 
22
- const issuers = new Map<string, readonly Document[]>();
23
+ const issuers = new Map<string, readonly IdentityTrustDocument[]>();
23
24
  const audiences = new Map<string, string>();
24
25
 
25
26
  for (const name of new Set(requirements.map((requirement) => requirement.audienceEnv))) {
@@ -48,9 +49,9 @@ export async function readImpIdentityTrust<Document>(
48
49
  }
49
50
 
50
51
  try {
51
- issuers.set(name, options.loadIssuers(documents.value));
52
+ issuers.set(name, loadIdentityIssuers(documents.value));
52
53
  } catch (error) {
53
- const reason = error instanceof Error ? error.message : 'the verifier gave no reason';
54
+ const reason = error instanceof Error ? error.message : 'the loader gave no reason';
54
55
 
55
56
  return { ok: false, failure: `${name}: the trust documents are invalid: ${reason}` };
56
57
  }
@@ -1,38 +1,9 @@
1
- // The imp that a verified identity token names. The shape of the verifier's answer in the
2
- // `@impz/client/identity` entry of impz.
3
- export interface VerifiedImp {
4
- // the token's `iss`: the impd that signed it
5
- readonly issuer: string;
6
-
7
- // the token's `sub`: the imp's ID, unique on one impd only
8
- readonly id: string;
9
-
10
- // the imp's name, for logs; a name is free again after a destroy, so it is no key
11
- readonly name: string;
12
- }
1
+ import type { IdentityTrustDocument } from '@impz/client/identity';
13
2
 
14
3
  // What the deployment trusts, read from its environment at start: the trust documents of each
15
4
  // `issuersEnv` and the audience of each `audienceEnv` that its paths name. A name with no entry
16
5
  // refuses every delivery on its paths.
17
- export interface ImpIdentityTrust<Document> {
18
- readonly issuers: ReadonlyMap<string, readonly Document[]>;
6
+ export interface ImpIdentityTrust {
7
+ readonly issuers: ReadonlyMap<string, readonly IdentityTrustDocument[]>;
19
8
  readonly audiences: ReadonlyMap<string, string>;
20
9
  }
21
-
22
- export interface VerifyOptions<Document> {
23
- readonly issuers: readonly Document[];
24
- readonly audience: string;
25
- readonly nowSeconds: number;
26
- }
27
-
28
- // The verifier of `@impz/client/identity`, which owns the token format and the trust document
29
- // format. `Document` stays opaque to ingest.
30
- export interface ImpIdentityVerifier<Document> {
31
- // Answers null for a token to refuse: a bad signature, an issuer with no trust document, another
32
- // audience, or a token out of its time. Throws on an empty or doubled list of documents.
33
- verify: (header: string | null, options: VerifyOptions<Document>) => Promise<VerifiedImp | null>;
34
-
35
- // Parses the documents a deployment pinned. Throws on an invalid document, on none, and on two
36
- // with one issuer.
37
- loadIssuers: (documents: readonly unknown[]) => Document[];
38
- }
@@ -5,15 +5,17 @@ import { toTagged } from '@zgeoff/std';
5
5
  import { readIngestConfig } from '../config/read-ingest-config.ts';
6
6
  import { createS2FactLog } from '../fact-log/s2/create-s2-fact-log.ts';
7
7
  import { createIngest } from '../receive/create-ingest.ts';
8
- import { noSenderProof } from '../sender-proof/none/no-sender-proof.ts';
8
+ import { readImpIdentitySenderProof } from '../sender-proof/imp-identity/read-imp-identity-sender-proof.ts';
9
+ import { startPullTimers } from './start-pull-timers.ts';
9
10
 
10
11
  interface ServeIngestOptions {
11
12
  deployment: Deployment;
12
13
  environment: Readonly<Record<string, string | undefined>>;
13
14
  }
14
15
 
15
- // The ingest process of a deployment. It exits 1 at start on a failed deployment check or on any
16
- // missing variable, so no route ever runs with an empty secret.
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.
17
19
  export async function serveIngest(options: ServeIngestOptions): Promise<void> {
18
20
  const checked = checkDeployment(options.deployment);
19
21
 
@@ -30,15 +32,25 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
30
32
  process.exit(1);
31
33
  }
32
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
+
33
46
  // The S2 adapter creates the basin and each missing stream, so no setup command creates streams.
34
47
  const log = await createS2FactLog({ ...config.value.s2, streams: collectStreams(sources) });
35
48
 
36
- // No adapter verifies a sender yet, so a path that needs one refuses every delivery.
37
49
  const ingest = createIngest({
38
50
  deployment: checked.value,
39
51
  secrets: config.value.secrets,
40
52
  log,
41
- senderProof: noSenderProof,
53
+ senderProof: senderProof.value,
42
54
  appendRetry: { maxAttempts: 5, retryDelayMs: 200 },
43
55
  now: () => Date.now(),
44
56
  });
@@ -47,6 +59,8 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
47
59
  console.log(`route ${route.route}`, JSON.stringify(route));
48
60
  }
49
61
 
62
+ const stopPulls = startPullTimers(sources, ingest.runPull);
63
+
50
64
  const server = Bun.serve({
51
65
  hostname: config.value.host,
52
66
  port: config.value.port,
@@ -56,7 +70,11 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
56
70
  console.log(`ingest listening on ${server.hostname}:${server.port}`);
57
71
 
58
72
  const shutdown = makeShutdownHandler({
59
- stop: () => server.stop(),
73
+ stop: () => {
74
+ stopPulls();
75
+
76
+ return server.stop();
77
+ },
60
78
  logError: (message, error) => {
61
79
  console.error(message, error);
62
80
  },
@@ -70,10 +88,14 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
70
88
  }
71
89
  }
72
90
 
91
+ // every receive path's secret, and every name a pull path's `env` lists
73
92
  function collectSecretNames(sources: readonly SourceDefinition[]): string[] {
74
- const names = sources.flatMap((source) =>
75
- source.live?.kind === 'receive' ? [source.live.check.secretEnv] : [],
76
- );
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
+ ]);
77
99
 
78
100
  return [...new Set(names)];
79
101
  }
@@ -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
+ }