@pepys/ingest 0.3.1 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pepys/ingest",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "pepys ingest: the receive routes, the fact log and the blob store",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@impz/client": "6.12.0",
25
- "@pepys/kit": "0.3.1",
26
- "@pepys/schema": "0.3.1",
25
+ "@pepys/kit": "0.4.0",
26
+ "@pepys/schema": "0.4.0",
27
27
  "@s2-dev/streamstore": "0.30.0",
28
28
  "@zgeoff/std": "0.4.3",
29
29
  "tiny-invariant": "1.3.3",
@@ -1,11 +1,14 @@
1
- import type { FactEnvelope, Result, StreamName, StreamOffset } from '@pepys/kit';
1
+ import type { FactEnvelope, Logger, Result, StreamName, StreamOffset } from '@pepys/kit';
2
2
  import { toTagged } from '@zgeoff/std';
3
- import { P, match } from 'ts-pattern';
4
3
  import type { FactLog, StoredFact } from './fact-log.ts';
5
- import { pickSettlement } from './pick-settlement.ts';
4
+ import { pickAttemptStep } from './pick-attempt-step.ts';
5
+ import type { AttemptResult } from './pick-attempt-step.ts';
6
+ import { pickRetryStep } from './pick-retry-step.ts';
7
+ import { pickSettledAttempt } from './pick-settled-attempt.ts';
6
8
 
7
9
  interface BatchAppenderOptions {
8
10
  log: FactLog;
11
+ logger: Logger;
9
12
  maxAttempts: number;
10
13
  retryDelayMs: number;
11
14
  }
@@ -31,100 +34,59 @@ export function makeBatchAppender(options: BatchAppenderOptions): BatchAppender
31
34
  for (let attempt = 1; ; attempt += 1) {
32
35
  const result = await runAttempt(options, stream, facts, pendingTail);
33
36
 
34
- if (result.kind === 'stored') {
35
- return { ok: true, value: result.stored };
36
- }
37
+ const step = pickRetryStep(result, attempt, options);
37
38
 
38
- if (result.kind === 'refused') {
39
- return { ok: false, failure: result.failure };
39
+ if (step.kind === 'answer') {
40
+ return step.answer;
40
41
  }
41
42
 
42
- pendingTail = result.pendingTail;
43
-
44
- if (attempt >= options.maxAttempts) {
45
- return { ok: false, failure: { kind: 'not-stored', attempts: attempt } };
46
- }
43
+ pendingTail = step.pendingTail;
47
44
 
48
- await Bun.sleep(options.retryDelayMs * attempt);
45
+ await Bun.sleep(step.delayMs);
49
46
  }
50
47
  };
51
48
  }
52
49
 
53
- interface Stored {
54
- firstOffset: StreamOffset;
55
- count: number;
56
- }
57
-
58
- type StopFailure = Extract<AppendFailure, { kind: 'refused' }>;
59
-
60
- // What one attempt leaves: the answer, or the tail that earlier attempts with an unknown outcome
61
- // aimed at, which a later attempt can still land on. `null` when no such attempt is open.
62
- type AttemptResult =
63
- | { kind: 'stored'; stored: Stored }
64
- | { kind: 'refused'; failure: StopFailure }
65
- | { kind: 'again'; pendingTail: StreamOffset | null };
66
-
50
+ // Every attempt that is still open aimed at the same tail: a new tail comes only after the offsets
51
+ // there are known to hold another writer's records.
67
52
  async function runAttempt(
68
- options: BatchAppenderOptions,
53
+ options: Pick<BatchAppenderOptions, 'log' | 'logger'>,
69
54
  stream: StreamName,
70
55
  facts: readonly FactEnvelope<unknown>[],
71
56
  pendingTail: StreamOffset | null,
72
57
  ): Promise<AttemptResult> {
73
- // Every attempt that is still open aimed at the same tail: a new tail comes only after the
74
- // offsets there are known to hold another writer's records.
75
- const read =
58
+ const aim =
76
59
  pendingTail === null
77
- ? await readTail(options.log, stream)
78
- : { ok: true as const, value: pendingTail };
60
+ ? await readAimedTail(options, stream)
61
+ : { kind: 'aim' as const, tail: pendingTail };
79
62
 
80
- if (read === null) {
81
- return { kind: 'again', pendingTail };
63
+ if (aim.kind !== 'aim') {
64
+ return aim;
82
65
  }
83
66
 
84
- if (!read.ok) {
85
- return { kind: 'refused', failure: read.failure };
86
- }
67
+ const outcome = await options.log.appendBatch(stream, facts, aim.tail);
87
68
 
88
- const aimed = read.value;
89
-
90
- const outcome = await options.log.appendBatch(stream, facts, aimed);
91
-
92
- return match(outcome)
93
- .with({ kind: 'acknowledged' }, (acknowledged) => ({
94
- kind: 'stored' as const,
95
- stored: { firstOffset: acknowledged.firstOffset, count: acknowledged.count },
96
- }))
97
- .with({ kind: 'rejected', reason: 'tail-mismatch' }, () =>
98
- pendingTail === null
99
- ? { kind: 'again' as const, pendingTail }
100
- : settleAttempt(options, stream, facts, aimed),
101
- )
102
- .with(
103
- { kind: 'rejected', reason: P.union('unknown-stream', 'invalid-envelope', 'too-large') },
104
- (rejected) => ({
105
- kind: 'refused' as const,
106
- failure: { kind: 'refused' as const, reason: rejected.reason },
107
- }),
108
- )
109
- .with({ kind: 'unknown' }, () => settleAttempt(options, stream, facts, aimed))
110
- .exhaustive();
69
+ const step = pickAttemptStep(outcome, aim.tail, pendingTail);
70
+
71
+ return step.kind === 'settle' ? settleAttempt(options, stream, facts, step.tail) : step;
111
72
  }
112
73
 
113
- // A failed read stored nothing, so the next attempt repeats it. Returns `null` for a failed read,
114
- // and the failure for a stream the log lacks. An error the append throws is a refusal the log gives
115
- // no outcome for, such as a bad token, and a retry cannot change it.
116
- async function readTail(
117
- log: FactLog,
74
+ // A failed read stored nothing, so the next attempt repeats it. An error the append throws is a
75
+ // refusal the log gives no outcome for, such as a bad token, and a retry cannot change it.
76
+ async function readAimedTail(
77
+ options: Pick<BatchAppenderOptions, 'log' | 'logger'>,
118
78
  stream: StreamName,
119
- ): Promise<Result<StreamOffset, StopFailure> | null> {
79
+ ): Promise<{ kind: 'aim'; tail: StreamOffset } | AttemptResult> {
120
80
  try {
121
- const tail = await log.readTail(stream);
81
+ const tail = await options.log.readTail(stream);
122
82
 
123
- return tail.ok ? tail : { ok: false, failure: { kind: 'refused', reason: 'unknown-stream' } };
83
+ return tail.ok
84
+ ? { kind: 'aim', tail: tail.value }
85
+ : { kind: 'refused', failure: { kind: 'refused', reason: 'unknown-stream' } };
124
86
  } catch (error: unknown) {
125
- console.error('a read before an append failed', error);
87
+ options.logger.error('a read before an append failed', error);
126
88
 
127
- return null;
89
+ return { kind: 'again', pendingTail: null };
128
90
  }
129
91
  }
130
92
 
@@ -132,38 +94,22 @@ async function readTail(
132
94
  // with the run's, in order. A search for the first ID anywhere after the tail would take another
133
95
  // writer's copy of it for the whole run.
134
96
  async function settleAttempt(
135
- options: BatchAppenderOptions,
97
+ options: Pick<BatchAppenderOptions, 'log' | 'logger'>,
136
98
  stream: StreamName,
137
99
  facts: readonly FactEnvelope<unknown>[],
138
100
  tail: StreamOffset,
139
101
  ): Promise<AttemptResult> {
140
102
  const stored = await readRun(options.log, stream, tail, facts.length).catch((error: unknown) => {
141
- console.error('a read that settles an append failed', error);
103
+ options.logger.error('a read that settles an append failed', error);
142
104
 
143
105
  return null;
144
106
  });
145
107
 
146
- if (stored === null) {
147
- return { kind: 'again', pendingTail: tail };
148
- }
149
-
150
- if (!stored.ok) {
151
- return { kind: 'refused', failure: { kind: 'refused', reason: 'unknown-stream' } };
152
- }
153
-
154
- return match(
155
- pickSettlement(
156
- facts.map((fact) => fact.id),
157
- stored.value,
158
- ),
159
- )
160
- .with('landed', () => ({
161
- kind: 'stored' as const,
162
- stored: { firstOffset: tail, count: facts.length },
163
- }))
164
- .with('not-landed', () => ({ kind: 'again' as const, pendingTail: tail }))
165
- .with('taken', () => ({ kind: 'again' as const, pendingTail: null }))
166
- .exhaustive();
108
+ return pickSettledAttempt(
109
+ stored,
110
+ facts.map((fact) => fact.id),
111
+ tail,
112
+ );
167
113
  }
168
114
 
169
115
  // A read can end before `count` records, so it repeats from the next offset until it has them all
@@ -0,0 +1,53 @@
1
+ import type { StreamOffset } from '@pepys/kit';
2
+ import { P, match } from 'ts-pattern';
3
+ import type { AppendBatchOutcome } from './fact-log.ts';
4
+
5
+ export interface StoredRun {
6
+ firstOffset: StreamOffset;
7
+ count: number;
8
+ }
9
+
10
+ // a reason the log gives that no retry changes
11
+ export interface StopFailure {
12
+ kind: 'refused';
13
+ reason: 'unknown-stream' | 'invalid-envelope' | 'too-large';
14
+ }
15
+
16
+ // What one attempt leaves: the answer, or the tail that earlier attempts with an unknown outcome
17
+ // aimed at, which a later attempt can still land on. `null` when no such attempt is open.
18
+ export type AttemptResult =
19
+ | { kind: 'stored'; stored: StoredRun }
20
+ | { kind: 'refused'; failure: StopFailure }
21
+ | { kind: 'again'; pendingTail: StreamOffset | null };
22
+
23
+ // `settle`: read the offsets from `tail` to learn whether the run is there.
24
+ export type AttemptStep = AttemptResult | { kind: 'settle'; tail: StreamOffset };
25
+
26
+ // What follows an append that aimed at `aimed`. A tail mismatch with no attempt open means another
27
+ // writer moved the tail, so the next attempt reads it again. With an attempt open, the mismatch may
28
+ // be that attempt landing late, so the offsets it aimed at are read.
29
+ export function pickAttemptStep(
30
+ outcome: AppendBatchOutcome,
31
+ aimed: StreamOffset,
32
+ pendingTail: StreamOffset | null,
33
+ ): AttemptStep {
34
+ return match(outcome)
35
+ .with({ kind: 'acknowledged' }, (acknowledged) => ({
36
+ kind: 'stored' as const,
37
+ stored: { firstOffset: acknowledged.firstOffset, count: acknowledged.count },
38
+ }))
39
+ .with({ kind: 'rejected', reason: 'tail-mismatch' }, () =>
40
+ pendingTail === null
41
+ ? { kind: 'again' as const, pendingTail }
42
+ : { kind: 'settle' as const, tail: aimed },
43
+ )
44
+ .with(
45
+ { kind: 'rejected', reason: P.union('unknown-stream', 'invalid-envelope', 'too-large') },
46
+ (rejected) => ({
47
+ kind: 'refused' as const,
48
+ failure: { kind: 'refused' as const, reason: rejected.reason },
49
+ }),
50
+ )
51
+ .with({ kind: 'unknown' }, () => ({ kind: 'settle' as const, tail: aimed }))
52
+ .exhaustive();
53
+ }
@@ -0,0 +1,47 @@
1
+ import type { Result, StreamOffset } from '@pepys/kit';
2
+ import { match } from 'ts-pattern';
3
+ import type { AttemptResult, StopFailure, StoredRun } from './pick-attempt-step.ts';
4
+
5
+ interface RetryLimits {
6
+ maxAttempts: number;
7
+ retryDelayMs: number;
8
+ }
9
+
10
+ export type RetryStep =
11
+ | {
12
+ kind: 'answer';
13
+ answer: Result<StoredRun, StopFailure | { kind: 'not-stored'; attempts: number }>;
14
+ }
15
+ | { kind: 'retry'; pendingTail: StreamOffset | null; delayMs: number };
16
+
17
+ // The wait before attempt `attempt + 1` grows by `retryDelayMs` with each attempt.
18
+ export function pickRetryStep(
19
+ result: AttemptResult,
20
+ attempt: number,
21
+ limits: RetryLimits,
22
+ ): RetryStep {
23
+ return match(result)
24
+ .with({ kind: 'stored' }, (stored) => ({
25
+ kind: 'answer' as const,
26
+ answer: { ok: true as const, value: stored.stored },
27
+ }))
28
+ .with({ kind: 'refused' }, (refused) => ({
29
+ kind: 'answer' as const,
30
+ answer: { ok: false as const, failure: refused.failure },
31
+ }))
32
+ .with({ kind: 'again' }, (again): RetryStep => {
33
+ if (attempt >= limits.maxAttempts) {
34
+ return {
35
+ kind: 'answer',
36
+ answer: { ok: false, failure: { kind: 'not-stored', attempts: attempt } },
37
+ };
38
+ }
39
+
40
+ return {
41
+ kind: 'retry',
42
+ pendingTail: again.pendingTail,
43
+ delayMs: limits.retryDelayMs * attempt,
44
+ };
45
+ })
46
+ .exhaustive();
47
+ }
@@ -0,0 +1,30 @@
1
+ import type { FactID, Result, StreamOffset } from '@pepys/kit';
2
+ import { match } from 'ts-pattern';
3
+ import type { StoredFact } from './fact-log.ts';
4
+ import type { AttemptResult } from './pick-attempt-step.ts';
5
+ import { pickSettlement } from './pick-settlement.ts';
6
+
7
+ // `stored` is the read of the offsets `[tail, tail + n)` that an attempt aimed at, or `null` when
8
+ // the read failed, which leaves the attempt open.
9
+ export function pickSettledAttempt(
10
+ stored: Result<readonly StoredFact[], { reason: 'unknown-stream' }> | null,
11
+ ids: readonly FactID[],
12
+ tail: StreamOffset,
13
+ ): AttemptResult {
14
+ if (stored === null) {
15
+ return { kind: 'again', pendingTail: tail };
16
+ }
17
+
18
+ if (!stored.ok) {
19
+ return { kind: 'refused', failure: { kind: 'refused', reason: 'unknown-stream' } };
20
+ }
21
+
22
+ return match(pickSettlement(ids, stored.value))
23
+ .with('landed', () => ({
24
+ kind: 'stored' as const,
25
+ stored: { firstOffset: tail, count: ids.length },
26
+ }))
27
+ .with('not-landed', () => ({ kind: 'again' as const, pendingTail: tail }))
28
+ .with('taken', () => ({ kind: 'again' as const, pendingTail: null }))
29
+ .exhaustive();
30
+ }
@@ -1,4 +1,5 @@
1
- import type { FactEnvelope, Result, StreamName, StreamOffset } from '@pepys/kit';
1
+ import { createLogger } from '@pepys/kit';
2
+ import type { FactEnvelope, Logger, Result, StreamName, StreamOffset } from '@pepys/kit';
2
3
  import {
3
4
  AppendInput,
4
5
  AppendRecord,
@@ -18,6 +19,9 @@ interface S2FactLogParameters {
18
19
  accessToken: string;
19
20
  basin: string;
20
21
  streams: readonly StreamName[];
22
+
23
+ // defaults to a logger that writes to stderr
24
+ logger?: Logger | undefined;
21
25
  }
22
26
 
23
27
  // Creates the basin and the streams when they are missing. A stream that exists keeps its records
@@ -29,6 +33,8 @@ export async function createS2FactLog(parameters: S2FactLogParameters): Promise<
29
33
  requestTimeoutMillis: 5000,
30
34
  };
31
35
 
36
+ const logger = parameters.logger ?? createLogger(process.stderr);
37
+
32
38
  const client = new S2(options);
33
39
 
34
40
  // A client retry could store a record twice before the writer knows; the writer owns retries.
@@ -45,7 +51,7 @@ export async function createS2FactLog(parameters: S2FactLogParameters): Promise<
45
51
 
46
52
  return {
47
53
  appendBatch: (stream, facts, expectedTail) =>
48
- appendBatch(appendBasin, stream, facts, expectedTail),
54
+ appendBatch({ basin: appendBasin, logger }, stream, facts, expectedTail),
49
55
  read: (stream, from, count) => readFacts(basin, stream, from, count),
50
56
  readTail: (stream) => readTail(basin, stream),
51
57
  };
@@ -78,8 +84,13 @@ async function createInfiniteStream(basin: S2Basin, stream: StreamName): Promise
78
84
  }
79
85
  }
80
86
 
87
+ interface S2Writer {
88
+ basin: S2Basin;
89
+ logger: Logger;
90
+ }
91
+
81
92
  async function appendBatch(
82
- basin: S2Basin,
93
+ writer: S2Writer,
83
94
  stream: StreamName,
84
95
  facts: readonly FactEnvelope<unknown>[],
85
96
  expectedTail: StreamOffset,
@@ -97,7 +108,7 @@ async function appendBatch(
97
108
  );
98
109
 
99
110
  try {
100
- const ack = await basin.stream(stream).append(input);
111
+ const ack = await writer.basin.stream(stream).append(input);
101
112
 
102
113
  return {
103
114
  kind: 'acknowledged',
@@ -112,7 +123,7 @@ async function appendBatch(
112
123
  }
113
124
 
114
125
  if (outcome.kind === 'unknown') {
115
- console.error('an s2 append has an unknown outcome', error);
126
+ writer.logger.error('an s2 append has an unknown outcome', error);
116
127
  }
117
128
 
118
129
  return outcome;
package/src/index.ts CHANGED
@@ -20,4 +20,3 @@ export { readImpIdentityTrust } from './sender-proof/imp-identity/read-imp-ident
20
20
  export type { ImpIdentityTrust } from './sender-proof/imp-identity/types.ts';
21
21
  export { noSenderProof } from './sender-proof/none/no-sender-proof.ts';
22
22
  export { serveIngest } from './serve/serve-ingest.ts';
23
- export type * from './source-port/types.ts';
@@ -1,7 +1,6 @@
1
- import type { CheckedDeployment, PullPath, Pulled, SourceDefinition } from '@pepys/kit';
1
+ import type { CheckedDeployment, Logger, PullPath, Pulled, SourceDefinition } from '@pepys/kit';
2
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';
3
+ import { pickBatchPlan } from '../receive/pick-batch-plan.ts';
5
4
  import type { ResyncMarks } from '../receive/resync-marks/create-resync-marks.ts';
6
5
  import { writeBatch } from '../receive/write-batch.ts';
7
6
  import type { StreamTurns } from '../receive/write-batch.ts';
@@ -20,6 +19,7 @@ export type PullRunner = (
20
19
  source: string,
21
20
  path: PullPathName,
22
21
  cursor: string | null,
22
+ signal: AbortSignal,
23
23
  ) => Promise<PullRun>;
24
24
 
25
25
  interface PullRunnerDependencies {
@@ -31,13 +31,14 @@ interface PullRunnerDependencies {
31
31
  turns: StreamTurns;
32
32
  resyncMarks: ResyncMarks;
33
33
  now: () => number;
34
+ logger: Logger;
34
35
  }
35
36
 
36
37
  // For a resync pull, removes each mark it was given that no tie set again since. A pull's fact is
37
38
  // noted in no mark: a resynced fact ties with the body it replaces, and would mark its entity again
38
39
  // on every run.
39
40
  export function makePullRunner(dependencies: PullRunnerDependencies): PullRunner {
40
- return async (sourceName, pathName, cursor) => {
41
+ return async (sourceName, pathName, cursor, signal) => {
41
42
  const source = dependencies.deployment.sources.find(
42
43
  (candidate) => candidate.name === sourceName,
43
44
  );
@@ -55,26 +56,22 @@ export function makePullRunner(dependencies: PullRunnerDependencies): PullRunner
55
56
  env[name] = dependencies.secrets[name] ?? '';
56
57
  }
57
58
 
58
- const pulled = await path.pull(cursor, { env, marks, now: dependencies.now });
59
+ const pulled = await path.pull(cursor, {
60
+ env,
61
+ marks,
62
+ now: dependencies.now,
63
+ log: dependencies.logger,
64
+ signal,
65
+ });
59
66
 
60
67
  if (pulled.kind !== 'batch') {
61
68
  return pulled;
62
69
  }
63
70
 
64
- const problem = findBatchProblem(source, pulled.batch);
65
-
66
- if (problem !== undefined) {
67
- return { kind: 'refused', reason: problem };
68
- }
69
-
70
- if (pulled.batch.facts.some((pending) => pending.blobs.length > 0)) {
71
- return { kind: 'refused', reason: 'ingest has no blob store' };
72
- }
73
-
74
- const plan = planBatch(pulled.batch);
71
+ const plan = pickBatchPlan(source, pulled.batch);
75
72
 
76
73
  if (!plan.ok) {
77
- return { kind: 'refused', reason: 'one fact is larger than one append of the log takes' };
74
+ return { kind: 'refused', reason: plan.failure.reason };
78
75
  }
79
76
 
80
77
  const outcome = await writeBatch(plan.value, dependencies);
@@ -1,4 +1,5 @@
1
- import type { CheckedDeployment, SenderProof } from '@pepys/kit';
1
+ import { createLogger } from '@pepys/kit';
2
+ import type { CheckedDeployment, Logger, SenderProof } from '@pepys/kit';
2
3
  import type { FactLog } from '../fact-log/fact-log.ts';
3
4
  import { makeBatchAppender } from '../fact-log/make-batch-appender.ts';
4
5
  import { makePullRunner } from '../pull/make-pull-runner.ts';
@@ -20,6 +21,9 @@ export interface IngestOptions {
20
21
  senderProof: SenderProof;
21
22
  appendRetry: { maxAttempts: number; retryDelayMs: number };
22
23
  now: () => number;
24
+
25
+ // Defaults to a logger that writes to stderr. A source's `read` and `pull` get it as `log`.
26
+ logger?: Logger | undefined;
23
27
  }
24
28
 
25
29
  export interface Ingest {
@@ -38,7 +42,8 @@ const recentBodyMaxAgeMs = 3_600_000;
38
42
 
39
43
  // The receive routes of a deployment over a given log, as one fetch function with no listener.
40
44
  export function createIngest(options: IngestOptions): Ingest {
41
- const append = makeBatchAppender({ log: options.log, ...options.appendRetry });
45
+ const logger = options.logger ?? createLogger(process.stderr);
46
+ const append = makeBatchAppender({ log: options.log, logger, ...options.appendRetry });
42
47
 
43
48
  const turns: StreamTurns = new Map();
44
49
 
@@ -60,6 +65,7 @@ export function createIngest(options: IngestOptions): Ingest {
60
65
  makeReceiveHandler(source, path, {
61
66
  secret,
62
67
  senderProof: options.senderProof,
68
+ logger,
63
69
  append,
64
70
  turns,
65
71
  recentBodies: createRecentBodies({
@@ -74,7 +80,7 @@ export function createIngest(options: IngestOptions): Ingest {
74
80
  for (const route of path.ignoredRoutes ?? []) {
75
81
  handlers.set(
76
82
  route,
77
- makeIgnoredRouteHandler(path, { secret, senderProof: options.senderProof }),
83
+ makeIgnoredRouteHandler(path, { secret, senderProof: options.senderProof, logger }),
78
84
  );
79
85
  }
80
86
  }
@@ -99,6 +105,7 @@ export function createIngest(options: IngestOptions): Ingest {
99
105
  turns,
100
106
  resyncMarks: resync.marks,
101
107
  now: options.now,
108
+ logger,
102
109
  }),
103
110
  };
104
111
  }
@@ -3,7 +3,10 @@ import type { Batch, SourceContext } from '@pepys/kit';
3
3
 
4
4
  // Every fact is checked before any is written: its stream must be one of the source's families, and
5
5
  // its envelope must parse.
6
- export function findBatchProblem(source: SourceContext, batch: Batch): string | undefined {
6
+ export function findBatchProblem(
7
+ source: Pick<SourceContext, 'name' | 'families'>,
8
+ batch: Batch,
9
+ ): string | undefined {
7
10
  const streams = new Set<string>(source.families.map((family) => family.stream));
8
11
 
9
12
  for (const [index, pending] of batch.facts.entries()) {
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { isDeliveryAuthentic } from '@pepys/kit';
3
3
  import type {
4
+ Logger,
4
5
  ReceivePath,
5
6
  Received,
6
7
  Sender,
@@ -12,8 +13,7 @@ import { match } from 'ts-pattern';
12
13
  import type { BatchAppender } from '../fact-log/make-batch-appender.ts';
13
14
  import { buildFailureAnswer, buildRetryAnswer } from './answers/build-failure-answer.ts';
14
15
  import { buildSuccessAnswer } from './answers/build-success-answer.ts';
15
- import { findBatchProblem } from './find-batch-problem.ts';
16
- import { planBatch } from './plan-batch.ts';
16
+ import { pickReceivedStep } from './pick-received-step.ts';
17
17
  import { readDecodedBody } from './read-decoded-body.ts';
18
18
  import { readSentBody } from './read-sent-body.ts';
19
19
  import type { RecentBodies } from './recent-bodies/create-recent-bodies.ts';
@@ -25,6 +25,7 @@ export interface ReceiveDependencies {
25
25
  // the value of the path's `check.secretEnv`; an empty secret passes no delivery
26
26
  secret: string;
27
27
  senderProof: SenderProof;
28
+ logger: Logger;
28
29
  append: BatchAppender;
29
30
  turns: StreamTurns;
30
31
  recentBodies: RecentBodies;
@@ -77,51 +78,33 @@ export function makeReceiveHandler(
77
78
  return pickDecodeFailure(body);
78
79
  }
79
80
 
80
- const received = readSourceDelivery(source, path, {
81
+ const received = readSourceDelivery(source, path, dependencies.logger, {
81
82
  headers: request.headers,
82
83
  body,
83
84
  ...(checked.sender === undefined ? {} : { sender: checked.sender }),
84
85
  });
85
86
 
86
- if (received === 'threw') {
87
- return buildFailureAnswer(500, 'the source could not read the delivery');
88
- }
87
+ const step = pickReceivedStep(source, received, factLimit);
89
88
 
90
- if (received.kind === 'refused') {
91
- return buildFailureAnswer(received.status, received.reason);
89
+ if (step.kind === 'refuse') {
90
+ return buildFailureAnswer(step.status, step.reason);
92
91
  }
93
92
 
94
- if (received.kind === 'ignored' || received.batch.facts.length === 0) {
93
+ if (step.kind === 'ignore') {
95
94
  return buildSuccessAnswer(path, 'ignored', 0);
96
95
  }
97
96
 
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(
97
+ if (step.kind === 'has-blob') {
98
+ dependencies.logger.error(
112
99
  `the source ${source.name} read a fact with a blob, and ingest has no blob store`,
113
100
  );
114
101
 
115
102
  return buildFailureAnswer(500, 'ingest has no blob store');
116
103
  }
117
104
 
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
- }
105
+ const facts = step.facts;
123
106
 
124
- const outcome = await writeBatch(plan.value, dependencies);
107
+ const outcome = await writeBatch(step.plan, dependencies);
125
108
 
126
109
  if (outcome === 'not-stored') {
127
110
  return buildRetryAnswer();
@@ -152,7 +135,7 @@ export function makeReceiveHandler(
152
135
  // the ignored answer. It reads no body further and stores nothing.
153
136
  export function makeIgnoredRouteHandler(
154
137
  path: ReceivePath,
155
- dependencies: Pick<ReceiveDependencies, 'secret' | 'senderProof'>,
138
+ dependencies: Pick<ReceiveDependencies, 'secret' | 'senderProof' | 'logger'>,
156
139
  ): Handler {
157
140
  return async (request) => {
158
141
  const checked = await readCheckedDelivery(request, path, dependencies);
@@ -170,7 +153,7 @@ type CheckedDelivery =
170
153
  async function readCheckedDelivery(
171
154
  request: Request,
172
155
  path: ReceivePath,
173
- dependencies: Pick<ReceiveDependencies, 'secret' | 'senderProof'>,
156
+ dependencies: Pick<ReceiveDependencies, 'secret' | 'senderProof' | 'logger'>,
174
157
  ): Promise<CheckedDelivery> {
175
158
  if (request.method !== 'POST') {
176
159
  return { kind: 'answer', answer: buildFailureAnswer(405, 'method not allowed') };
@@ -190,7 +173,7 @@ async function readCheckedDelivery(
190
173
  return { kind: 'checked', sent, sender: undefined };
191
174
  }
192
175
 
193
- const sender = await readProvenSender(path.sender, request.headers, dependencies.senderProof);
176
+ const sender = await readProvenSender(path.sender, request.headers, dependencies);
194
177
 
195
178
  if (sender === null) {
196
179
  return { kind: 'answer', answer: buildFailureAnswer(401, 'not authorised') };
@@ -203,14 +186,14 @@ async function readCheckedDelivery(
203
186
  async function readProvenSender(
204
187
  requirement: SenderRequirement,
205
188
  headers: Headers,
206
- senderProof: SenderProof,
189
+ dependencies: Pick<ReceiveDependencies, 'senderProof' | 'logger'>,
207
190
  ): Promise<Sender | null> {
208
191
  try {
209
- const sender = await senderProof.prove(requirement, headers);
192
+ const sender = await dependencies.senderProof.prove(requirement, headers);
210
193
 
211
194
  return sender;
212
195
  } catch {
213
- console.error('the sender proof threw, so the delivery proves no sender');
196
+ dependencies.logger.error('the sender proof threw, so the delivery proves no sender');
214
197
 
215
198
  return null;
216
199
  }
@@ -233,12 +216,13 @@ function pickDecodeFailure(failure: 'unsupported' | 'too-large' | 'corrupt'): Re
233
216
  function readSourceDelivery(
234
217
  source: SourceDefinition,
235
218
  path: ReceivePath,
219
+ logger: Logger,
236
220
  delivery: Parameters<ReceivePath['read']>[0],
237
221
  ): Received | 'threw' {
238
222
  try {
239
- return path.read(delivery, { name: source.name, families: source.families });
223
+ return path.read(delivery, { name: source.name, families: source.families, log: logger });
240
224
  } catch (error: unknown) {
241
- console.error(`the read of the source ${source.name} threw`, error);
225
+ logger.error(`the read of the source ${source.name} threw`, error);
242
226
 
243
227
  return 'threw';
244
228
  }
@@ -0,0 +1,37 @@
1
+ import type { Batch, Result, SourceContext } from '@pepys/kit';
2
+ import { findBatchProblem } from './find-batch-problem.ts';
3
+ import { planBatch } from './plan-batch.ts';
4
+ import type { BatchPlan } from './plan-batch.ts';
5
+
6
+ export interface BatchRefusal {
7
+ kind: 'bad-batch' | 'has-blob' | 'too-large';
8
+ reason: string;
9
+ }
10
+
11
+ // The checks a batch passes before any of it is written, in order: every fact's stream and
12
+ // envelope, no fact with a blob, then the cut into appends.
13
+ export function pickBatchPlan(
14
+ source: Pick<SourceContext, 'name' | 'families'>,
15
+ batch: Batch,
16
+ ): Result<BatchPlan, BatchRefusal> {
17
+ const problem = findBatchProblem(source, batch);
18
+
19
+ if (problem !== undefined) {
20
+ return { ok: false, failure: { kind: 'bad-batch', reason: problem } };
21
+ }
22
+
23
+ if (batch.facts.some((pending) => pending.blobs.length > 0)) {
24
+ return { ok: false, failure: { kind: 'has-blob', reason: 'ingest has no blob store' } };
25
+ }
26
+
27
+ const plan = planBatch(batch);
28
+
29
+ if (!plan.ok) {
30
+ return {
31
+ ok: false,
32
+ failure: { kind: 'too-large', reason: 'one fact is larger than one append of the log takes' },
33
+ };
34
+ }
35
+
36
+ return plan;
37
+ }
@@ -0,0 +1,56 @@
1
+ import type { PendingFact, Received, SourceContext } from '@pepys/kit';
2
+ import { match } from 'ts-pattern';
3
+ import { pickBatchPlan } from './pick-batch-plan.ts';
4
+ import type { BatchPlan } from './plan-batch.ts';
5
+
6
+ // `has-blob`: the source read a fact with a blob, a fault of the deployment that ingest answers 500.
7
+ export type ReceivedStep =
8
+ | { kind: 'refuse'; status: number; reason: string }
9
+ | { kind: 'has-blob' }
10
+ | { kind: 'ignore' }
11
+ | { kind: 'write'; facts: readonly PendingFact[]; plan: BatchPlan };
12
+
13
+ // What follows the source's read of a delivery, `threw` when the read threw.
14
+ export function pickReceivedStep(
15
+ source: Pick<SourceContext, 'name' | 'families'>,
16
+ received: Received | 'threw',
17
+ factLimit: number,
18
+ ): ReceivedStep {
19
+ if (received === 'threw') {
20
+ return { kind: 'refuse', status: 500, reason: 'the source could not read the delivery' };
21
+ }
22
+
23
+ if (received.kind === 'refused') {
24
+ return { kind: 'refuse', status: received.status, reason: received.reason };
25
+ }
26
+
27
+ if (received.kind === 'ignored' || received.batch.facts.length === 0) {
28
+ return { kind: 'ignore' };
29
+ }
30
+
31
+ const facts = received.batch.facts;
32
+
33
+ if (facts.length > factLimit) {
34
+ return { kind: 'refuse', status: 413, reason: `a batch holds ${factLimit} facts at most` };
35
+ }
36
+
37
+ const plan = pickBatchPlan(source, received.batch);
38
+
39
+ if (!plan.ok) {
40
+ return match(plan.failure.kind)
41
+ .with('bad-batch', () => ({
42
+ kind: 'refuse' as const,
43
+ status: 422,
44
+ reason: plan.failure.reason,
45
+ }))
46
+ .with('has-blob', () => ({ kind: 'has-blob' as const }))
47
+ .with('too-large', () => ({
48
+ kind: 'refuse' as const,
49
+ status: 413,
50
+ reason: plan.failure.reason,
51
+ }))
52
+ .exhaustive();
53
+ }
54
+
55
+ return { kind: 'write', facts, plan: plan.value };
56
+ }
@@ -1,4 +1,4 @@
1
- import type { StreamName } from '@pepys/kit';
1
+ import type { Logger, StreamName } from '@pepys/kit';
2
2
  import type { BatchAppender } from '../fact-log/make-batch-appender.ts';
3
3
  import type { BatchPlan } from './plan-batch.ts';
4
4
 
@@ -11,6 +11,7 @@ export type StreamTurns = Map<StreamName, Promise<void>>;
11
11
  interface WriteBatchDependencies {
12
12
  append: BatchAppender;
13
13
  turns: StreamTurns;
14
+ logger: Logger;
14
15
  }
15
16
 
16
17
  // The streams append at the same time, and the appends of one stream one after the other. Every
@@ -21,12 +22,12 @@ export async function writeBatch(
21
22
  ): Promise<BatchOutcome> {
22
23
  const settled = await Promise.all(
23
24
  [...plan].map(([stream, runs]) =>
24
- withStreamTurn(dependencies.turns, stream, async () => {
25
+ withStreamTurn(dependencies, stream, async () => {
25
26
  for (const run of runs) {
26
27
  const appended = await dependencies.append(stream, run);
27
28
 
28
29
  if (!appended.ok) {
29
- console.error(`an append to ${stream} failed`, appended.failure);
30
+ dependencies.logger.error(`an append to ${stream} failed`, appended.failure);
30
31
 
31
32
  return appended.failure.kind;
32
33
  }
@@ -42,10 +43,11 @@ export async function writeBatch(
42
43
 
43
44
  // A throw is a refusal the log gives no outcome for, such as a bad access token.
44
45
  async function withStreamTurn(
45
- turns: StreamTurns,
46
+ dependencies: Pick<WriteBatchDependencies, 'turns' | 'logger'>,
46
47
  stream: StreamName,
47
48
  work: () => Promise<BatchOutcome>,
48
49
  ): Promise<BatchOutcome> {
50
+ const turns = dependencies.turns;
49
51
  const previous = turns.get(stream) ?? Promise.resolve();
50
52
  const done = Promise.withResolvers<void>();
51
53
 
@@ -56,7 +58,7 @@ async function withStreamTurn(
56
58
 
57
59
  return await work();
58
60
  } catch (error: unknown) {
59
- console.error(`an append to ${stream} threw`, error);
61
+ dependencies.logger.error(`an append to ${stream} threw`, error);
60
62
 
61
63
  return 'refused';
62
64
  } finally {
@@ -1,17 +1,23 @@
1
1
  import type { verifyImpIdentity } from '@impz/client/identity';
2
- import type { SenderProof } from '@pepys/kit';
2
+ import { createLogger } from '@pepys/kit';
3
+ import type { Logger, SenderProof } from '@pepys/kit';
3
4
  import type { ImpIdentityTrust } from './types.ts';
4
5
 
5
6
  interface ImpIdentityOptions {
6
7
  verify: typeof verifyImpIdentity;
7
8
  trust: ImpIdentityTrust;
8
9
  now: () => number;
10
+
11
+ // defaults to a logger that writes to stderr
12
+ logger?: Logger | undefined;
9
13
  }
10
14
 
11
15
  // Proves the sender from the signed identity token in the `imp-identity` header. The issuer and
12
16
  // the subject come only from the verified token, and every failure, a throw included, proves
13
17
  // nothing. It never logs the token.
14
18
  export function createImpIdentitySenderProof(options: ImpIdentityOptions): SenderProof {
19
+ const logger = options.logger ?? createLogger(process.stderr);
20
+
15
21
  return {
16
22
  prove: async (requirement, headers) => {
17
23
  const documents = options.trust.issuers.get(requirement.issuersEnv) ?? [];
@@ -33,7 +39,7 @@ export function createImpIdentitySenderProof(options: ImpIdentityOptions): Sende
33
39
  ? null
34
40
  : { issuer: verified.issuer, subject: verified.id, name: verified.name };
35
41
  } catch {
36
- console.error('the identity verifier failed, so the delivery proves no sender');
42
+ logger.error('the identity verifier failed, so the delivery proves no sender');
37
43
 
38
44
  return null;
39
45
  }
@@ -1,5 +1,5 @@
1
1
  import { verifyImpIdentity } from '@impz/client/identity';
2
- import type { Result, SenderProof, SourceDefinition } from '@pepys/kit';
2
+ import type { Logger, Result, SenderProof, SourceDefinition } from '@pepys/kit';
3
3
  import { createImpIdentitySenderProof } from './create-imp-identity-sender-proof.ts';
4
4
  import { readImpIdentityTrust } from './read-imp-identity-trust.ts';
5
5
 
@@ -7,6 +7,9 @@ interface SenderProofReadOptions {
7
7
  sources: readonly SourceDefinition[];
8
8
  environment: Readonly<Record<string, string | undefined>>;
9
9
  now: () => number;
10
+
11
+ // defaults to a logger that writes to stderr
12
+ logger?: Logger | undefined;
10
13
  }
11
14
 
12
15
  // The sender proof that `serveIngest` runs: the trust of the deployment's environment and the
@@ -30,6 +33,7 @@ export async function readImpIdentitySenderProof(
30
33
  verify: verifyImpIdentity,
31
34
  trust: trust.value,
32
35
  now: options.now,
36
+ logger: options.logger,
33
37
  }),
34
38
  };
35
39
  }
@@ -1,4 +1,4 @@
1
- import { checkDeployment, listReceiveRoutes } from '@pepys/kit';
1
+ import { checkDeployment, createLogger, listReceiveRoutes } from '@pepys/kit';
2
2
  import type { Deployment, SourceDefinition, StreamName } from '@pepys/kit';
3
3
  import { makeShutdownHandler } from '@pepys/schema';
4
4
  import { toTagged } from '@zgeoff/std';
@@ -11,16 +11,20 @@ import { startPullTimers } from './start-pull-timers.ts';
11
11
  interface ServeIngestOptions {
12
12
  deployment: Deployment;
13
13
  environment: Readonly<Record<string, string | undefined>>;
14
+
15
+ // false leaves SIGINT and SIGTERM to the caller, who stops ingest through the returned function
16
+ exitOnSignal?: boolean;
14
17
  }
15
18
 
16
19
  // The ingest process of a deployment. It exits 1 at start on a failed deployment check, on any
17
20
  // missing variable, or on a trust folder it cannot load, so no route ever runs with an empty secret.
18
21
  // A path whose trust documents or audience are unset starts, and refuses every delivery.
19
- export async function serveIngest(options: ServeIngestOptions): Promise<void> {
22
+ export async function serveIngest(options: ServeIngestOptions): Promise<() => Promise<void>> {
23
+ const logger = createLogger(process.stderr);
20
24
  const checked = checkDeployment(options.deployment);
21
25
 
22
26
  if (!checked.ok) {
23
- console.error('the deployment fails its check', JSON.stringify(checked.failure));
27
+ logger.error('the deployment fails its check', checked.failure);
24
28
  process.exit(1);
25
29
  }
26
30
 
@@ -28,7 +32,7 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
28
32
  const config = readIngestConfig(options.environment, collectSecretNames(sources));
29
33
 
30
34
  if (!config.ok) {
31
- console.error(config.failure.join('\n'));
35
+ logger.error('the ingest config is incomplete', config.failure);
32
36
  process.exit(1);
33
37
  }
34
38
 
@@ -36,15 +40,20 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
36
40
  sources,
37
41
  environment: options.environment,
38
42
  now: () => Date.now(),
43
+ logger,
39
44
  });
40
45
 
41
46
  if (!senderProof.ok) {
42
- console.error(senderProof.failure);
47
+ logger.error('the sender proof cannot start', senderProof.failure);
43
48
  process.exit(1);
44
49
  }
45
50
 
46
51
  // 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) });
52
+ const log = await createS2FactLog({
53
+ ...config.value.s2,
54
+ streams: collectStreams(sources),
55
+ logger,
56
+ });
48
57
 
49
58
  const ingest = createIngest({
50
59
  deployment: checked.value,
@@ -53,30 +62,38 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
53
62
  senderProof: senderProof.value,
54
63
  appendRetry: { maxAttempts: 5, retryDelayMs: 200 },
55
64
  now: () => Date.now(),
65
+ logger,
56
66
  });
57
67
 
58
68
  for (const route of listReceiveRoutes(options.deployment)) {
59
69
  console.log(`route ${route.route}`, JSON.stringify(route));
60
70
  }
61
71
 
62
- const stopPulls = startPullTimers(sources, ingest.runPull);
63
-
72
+ // The server starts first, so a failed listen leaves no timer behind.
64
73
  const server = Bun.serve({
65
74
  hostname: config.value.host,
66
75
  port: config.value.port,
67
76
  fetch: ingest.handleRequest,
68
77
  });
69
78
 
79
+ const stopPulls = startPullTimers(sources, ingest.runPull, logger);
80
+
70
81
  console.log(`ingest listening on ${server.hostname}:${server.port}`);
71
82
 
72
- const shutdown = makeShutdownHandler({
73
- stop: () => {
74
- stopPulls();
83
+ const stop = async (): Promise<void> => {
84
+ await stopPulls();
75
85
 
76
- return server.stop();
77
- },
86
+ await server.stop();
87
+ };
88
+
89
+ if (options.exitOnSignal === false) {
90
+ return stop;
91
+ }
92
+
93
+ const shutdown = makeShutdownHandler({
94
+ stop,
78
95
  logError: (message, error) => {
79
- console.error(message, error);
96
+ logger.error(message, error);
80
97
  },
81
98
  exit: (code) => process.exit(code),
82
99
  });
@@ -86,6 +103,8 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
86
103
  void shutdown();
87
104
  });
88
105
  }
106
+
107
+ return stop;
89
108
  }
90
109
 
91
110
  // every receive path's secret, and every name a pull path's `env` lists
@@ -1,15 +1,20 @@
1
- import type { SourceDefinition } from '@pepys/kit';
1
+ import type { Logger, SourceDefinition } from '@pepys/kit';
2
2
  import { match } from 'ts-pattern';
3
3
  import type { PullPathName, PullRun, PullRunner } from '../pull/make-pull-runner.ts';
4
4
 
5
5
  const pullPathNames: readonly PullPathName[] = ['live', 'recovery', 'backfill', 'resync'];
6
6
 
7
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.
8
+ // pull from no cursor. A run never overlaps the one before it. Returns the function that stops them:
9
+ // it aborts each run in flight and settles once every run has ended.
9
10
  export function startPullTimers(
10
11
  sources: readonly SourceDefinition[],
11
12
  runPull: PullRunner,
12
- ): () => void {
13
+ logger: Logger,
14
+ ): () => Promise<void> {
15
+ const controller = new AbortController();
16
+ const activeRuns = new Set<Promise<void>>();
17
+
13
18
  const timers = sources.flatMap((source) =>
14
19
  pullPathNames.flatMap((pathName) => {
15
20
  const path = source[pathName];
@@ -22,24 +27,34 @@ export function startPullTimers(
22
27
  let isRunning = false;
23
28
 
24
29
  const runOnce = async (): Promise<void> => {
25
- if (isRunning) {
30
+ if (isRunning || controller.signal.aborted) {
26
31
  return;
27
32
  }
28
33
 
29
34
  isRunning = true;
30
35
 
36
+ const ended = Promise.withResolvers<void>();
37
+
38
+ activeRuns.add(ended.promise);
39
+
31
40
  try {
32
- const run = await runPull(source.name, pathName, cursor);
41
+ const run = await runPull(source.name, pathName, cursor, controller.signal);
33
42
 
34
43
  cursor = pickNextCursor(run, cursor);
35
44
 
36
- if (run.kind !== 'stored') {
37
- console.error(`the ${pathName} pull of ${source.name} did not store`, run);
45
+ // a run that the stop cut short is no failure
46
+ if (run.kind !== 'stored' && !controller.signal.aborted) {
47
+ logger.error(`the ${pathName} pull of ${source.name} did not store`, run);
38
48
  }
39
49
  } catch (error: unknown) {
40
- console.error(`the ${pathName} pull of ${source.name} threw`, error);
50
+ if (!controller.signal.aborted) {
51
+ logger.error(`the ${pathName} pull of ${source.name} threw`, error);
52
+ }
41
53
  } finally {
42
54
  isRunning = false;
55
+
56
+ activeRuns.delete(ended.promise);
57
+ ended.resolve();
43
58
  }
44
59
  };
45
60
 
@@ -51,10 +66,14 @@ export function startPullTimers(
51
66
  }),
52
67
  );
53
68
 
54
- return () => {
69
+ return async () => {
55
70
  for (const timer of timers) {
56
71
  clearInterval(timer);
57
72
  }
73
+
74
+ controller.abort();
75
+
76
+ await Promise.all(activeRuns);
58
77
  };
59
78
  }
60
79
 
@@ -1,64 +0,0 @@
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
- }