@end-close/relay 0.12.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.
Files changed (134) hide show
  1. package/COMPATIBILITY.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -0
  4. package/dist/config/schema.d.ts +993 -0
  5. package/dist/config/schema.d.ts.map +1 -0
  6. package/dist/config/schema.js +156 -0
  7. package/dist/config/schema.js.map +1 -0
  8. package/dist/crypto/at-rest.d.ts +6 -0
  9. package/dist/crypto/at-rest.d.ts.map +1 -0
  10. package/dist/crypto/at-rest.js +19 -0
  11. package/dist/crypto/at-rest.js.map +1 -0
  12. package/dist/crypto/keys.d.ts +2 -0
  13. package/dist/crypto/keys.d.ts.map +1 -0
  14. package/dist/crypto/keys.js +12 -0
  15. package/dist/crypto/keys.js.map +1 -0
  16. package/dist/engine/codec.d.ts +12 -0
  17. package/dist/engine/codec.d.ts.map +1 -0
  18. package/dist/engine/codec.js +21 -0
  19. package/dist/engine/codec.js.map +1 -0
  20. package/dist/engine/hooks.d.ts +84 -0
  21. package/dist/engine/hooks.d.ts.map +1 -0
  22. package/dist/engine/hooks.js +34 -0
  23. package/dist/engine/hooks.js.map +1 -0
  24. package/dist/engine/ingest.d.ts +39 -0
  25. package/dist/engine/ingest.d.ts.map +1 -0
  26. package/dist/engine/ingest.js +131 -0
  27. package/dist/engine/ingest.js.map +1 -0
  28. package/dist/engine/memory-store.d.ts +35 -0
  29. package/dist/engine/memory-store.d.ts.map +1 -0
  30. package/dist/engine/memory-store.js +205 -0
  31. package/dist/engine/memory-store.js.map +1 -0
  32. package/dist/engine/relay.d.ts +116 -0
  33. package/dist/engine/relay.d.ts.map +1 -0
  34. package/dist/engine/relay.js +180 -0
  35. package/dist/engine/relay.js.map +1 -0
  36. package/dist/engine/secrets.d.ts +18 -0
  37. package/dist/engine/secrets.d.ts.map +1 -0
  38. package/dist/engine/secrets.js +42 -0
  39. package/dist/engine/secrets.js.map +1 -0
  40. package/dist/engine/settings.d.ts +29 -0
  41. package/dist/engine/settings.d.ts.map +1 -0
  42. package/dist/engine/settings.js +13 -0
  43. package/dist/engine/settings.js.map +1 -0
  44. package/dist/engine/store.d.ts +141 -0
  45. package/dist/engine/store.d.ts.map +1 -0
  46. package/dist/engine/store.js +52 -0
  47. package/dist/engine/store.js.map +1 -0
  48. package/dist/forward/backoff.d.ts +3 -0
  49. package/dist/forward/backoff.d.ts.map +1 -0
  50. package/dist/forward/backoff.js +10 -0
  51. package/dist/forward/backoff.js.map +1 -0
  52. package/dist/forward/dispatcher.d.ts +98 -0
  53. package/dist/forward/dispatcher.d.ts.map +1 -0
  54. package/dist/forward/dispatcher.js +444 -0
  55. package/dist/forward/dispatcher.js.map +1 -0
  56. package/dist/forward/endclose-client.d.ts +42 -0
  57. package/dist/forward/endclose-client.d.ts.map +1 -0
  58. package/dist/forward/endclose-client.js +76 -0
  59. package/dist/forward/endclose-client.js.map +1 -0
  60. package/dist/forward/enrich.d.ts +39 -0
  61. package/dist/forward/enrich.d.ts.map +1 -0
  62. package/dist/forward/enrich.js +77 -0
  63. package/dist/forward/enrich.js.map +1 -0
  64. package/dist/forward/mapper.d.ts +50 -0
  65. package/dist/forward/mapper.d.ts.map +1 -0
  66. package/dist/forward/mapper.js +141 -0
  67. package/dist/forward/mapper.js.map +1 -0
  68. package/dist/index.d.ts +36 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.js +24 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/ingest/adapters/generic-hmac.d.ts +3 -0
  73. package/dist/ingest/adapters/generic-hmac.d.ts.map +1 -0
  74. package/dist/ingest/adapters/generic-hmac.js +60 -0
  75. package/dist/ingest/adapters/generic-hmac.js.map +1 -0
  76. package/dist/ingest/adapters/payabli.d.ts +3 -0
  77. package/dist/ingest/adapters/payabli.d.ts.map +1 -0
  78. package/dist/ingest/adapters/payabli.js +57 -0
  79. package/dist/ingest/adapters/payabli.js.map +1 -0
  80. package/dist/ingest/adapters/registry.d.ts +4 -0
  81. package/dist/ingest/adapters/registry.d.ts.map +1 -0
  82. package/dist/ingest/adapters/registry.js +16 -0
  83. package/dist/ingest/adapters/registry.js.map +1 -0
  84. package/dist/ingest/adapters/types.d.ts +28 -0
  85. package/dist/ingest/adapters/types.d.ts.map +1 -0
  86. package/dist/ingest/adapters/types.js +5 -0
  87. package/dist/ingest/adapters/types.js.map +1 -0
  88. package/dist/logger.d.ts +12 -0
  89. package/dist/logger.d.ts.map +1 -0
  90. package/dist/logger.js +14 -0
  91. package/dist/logger.js.map +1 -0
  92. package/dist/mask/defaults.d.ts +7 -0
  93. package/dist/mask/defaults.d.ts.map +1 -0
  94. package/dist/mask/defaults.js +51 -0
  95. package/dist/mask/defaults.js.map +1 -0
  96. package/dist/mask/paths.d.ts +7 -0
  97. package/dist/mask/paths.d.ts.map +1 -0
  98. package/dist/mask/paths.js +36 -0
  99. package/dist/mask/paths.js.map +1 -0
  100. package/dist/util/payload-shape.d.ts +6 -0
  101. package/dist/util/payload-shape.d.ts.map +1 -0
  102. package/dist/util/payload-shape.js +19 -0
  103. package/dist/util/payload-shape.js.map +1 -0
  104. package/dist/util/strings.d.ts +3 -0
  105. package/dist/util/strings.d.ts.map +1 -0
  106. package/dist/util/strings.js +7 -0
  107. package/dist/util/strings.js.map +1 -0
  108. package/package.json +57 -0
  109. package/src/config/schema.ts +177 -0
  110. package/src/crypto/at-rest.ts +21 -0
  111. package/src/crypto/keys.ts +13 -0
  112. package/src/engine/codec.ts +30 -0
  113. package/src/engine/hooks.ts +76 -0
  114. package/src/engine/ingest.ts +194 -0
  115. package/src/engine/memory-store.ts +232 -0
  116. package/src/engine/relay.ts +294 -0
  117. package/src/engine/secrets.ts +51 -0
  118. package/src/engine/settings.ts +42 -0
  119. package/src/engine/store.ts +188 -0
  120. package/src/forward/backoff.ts +10 -0
  121. package/src/forward/dispatcher.ts +504 -0
  122. package/src/forward/endclose-client.ts +102 -0
  123. package/src/forward/enrich.ts +106 -0
  124. package/src/forward/mapper.ts +191 -0
  125. package/src/index.ts +47 -0
  126. package/src/ingest/adapters/generic-hmac.ts +70 -0
  127. package/src/ingest/adapters/payabli.ts +67 -0
  128. package/src/ingest/adapters/registry.ts +21 -0
  129. package/src/ingest/adapters/types.ts +33 -0
  130. package/src/logger.ts +26 -0
  131. package/src/mask/defaults.ts +56 -0
  132. package/src/mask/paths.ts +36 -0
  133. package/src/util/payload-shape.ts +19 -0
  134. package/src/util/strings.ts +7 -0
@@ -0,0 +1,294 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { refEnrichment, relayConfigSchema, type RelayConfig, type RouteConfig } from '../config/schema.js'
4
+ import { deriveKey } from '../crypto/keys.js'
5
+ import { EndCloseClient } from '../forward/endclose-client.js'
6
+ import { Dispatcher, type DispatchCounts } from '../forward/dispatcher.js'
7
+ import type { Enrichment } from '../forward/enrich.js'
8
+ import { mapEvent, type MappedEvent } from '../forward/mapper.js'
9
+ import { hasAdapter } from '../ingest/adapters/registry.js'
10
+ import type { ProcessorAdapter, RawRequest } from '../ingest/adapters/types.js'
11
+ import type { Json } from '../mask/paths.js'
12
+ import { noopLogger, type Logger } from '../logger.js'
13
+ import { sleep } from '../util/strings.js'
14
+ import { aesGcmCodec, plainCodec } from './codec.js'
15
+ import { RelayHooks, type RelayEventName, type RelayHandler } from './hooks.js'
16
+ import { ingestWebhook, type IngestResult } from './ingest.js'
17
+ import { toSecretResolver, type SecretResolver } from './secrets.js'
18
+ import {
19
+ DEFAULT_DISPATCH,
20
+ DEFAULT_RETENTION,
21
+ type DispatchSettings,
22
+ type RetentionSettings,
23
+ } from './settings.js'
24
+ import {
25
+ hasAdmin,
26
+ MemoryControlStore,
27
+ staticRoutes,
28
+ type ControlStore,
29
+ type EventStore,
30
+ type RouteProvider,
31
+ } from './store.js'
32
+
33
+ // The embeddable engine: everything the application does between "webhook arrives" and
34
+ // "record accepted by End Close", with storage, secrets, logging and observability
35
+ // supplied by the host.
36
+
37
+ export interface RelayOptions {
38
+ /** Route definitions: the same shape as the `routes` block of relay.yaml. */
39
+ routes: RouteConfig[] | RouteProvider
40
+ store: EventStore
41
+ /** Killswitch and per-route pause state. Default: in-memory, nothing paused. */
42
+ control?: ControlStore
43
+ /** Where `auth.secret_env` references resolve. */
44
+ secrets: SecretResolver | Record<string, string>
45
+ endclose: { apiKey: string; baseUrl?: string; fetch?: typeof fetch }
46
+ /** Explicit: encrypt buffered payloads at rest under this key, or store them as-is. */
47
+ encryption: { dataKey: string | Buffer } | 'none'
48
+ /** Keys the deterministic `hash` transform. Never leaves the host. */
49
+ maskingKey: string | Buffer
50
+ dispatch?: Partial<DispatchSettings>
51
+ /** `false` disables retention pruning entirely. */
52
+ retention?: Partial<RetentionSettings> | false
53
+ /** Default: silent. */
54
+ logger?: Logger | null
55
+ /** Additional processor adapters keyed by route `source`. */
56
+ adapters?: Record<string, ProcessorAdapter>
57
+ /**
58
+ * Host functions a route's map may name with `enrich: <name>` on `description` or a
59
+ * `metadata` entry. Each runs in-process per event after mapping, receives the value at
60
+ * that field's `source`, and returns what is forwarded (validated like any mapped value).
61
+ * Throw to retry the event with backoff; throw `EnrichmentError` to park it; return
62
+ * `undefined` to omit the field. Bounded by `dispatch.enrichTimeoutMs` per call.
63
+ */
64
+ enrichments?: Record<string, Enrichment>
65
+ /** Lease owner for claimed batches. Give each long-lived replica a stable id. */
66
+ instanceId?: string
67
+ hooks?: RelayHooks
68
+ /** Supply a pre-built client (the application shares one with telemetry). */
69
+ client?: EndCloseClient
70
+ }
71
+
72
+ export type DispatchOnceResult = DispatchCounts
73
+
74
+ export interface FlushResult extends DispatchOnceResult {
75
+ /** True when nothing deliverable remained when flush returned. */
76
+ drained: boolean
77
+ /**
78
+ * Why flush stopped early: the deadline passed, forwarding is paused (killswitch or
79
+ * every due route), or due events belong to routes the provider no longer knows.
80
+ */
81
+ reason?: 'timeout' | 'paused' | 'unroutable'
82
+ }
83
+
84
+ export interface Relay {
85
+ /** Framework-agnostic webhook entrypoint. */
86
+ ingest(routeId: string, req: RawRequest): Promise<IngestResult>
87
+ /** Start the background dispatch loop (long-lived processes). */
88
+ start(): void
89
+ /** Stop the loop and drain in-flight work. */
90
+ stop(): Promise<void>
91
+ /** Run one dispatch cycle (cron / serverless / tests). */
92
+ dispatchOnce(opts?: { prune?: boolean }): Promise<DispatchOnceResult>
93
+ /**
94
+ * Run dispatch cycles until nothing deliverable remains or `timeoutMs` (default 30 s)
95
+ * passes, retrying as backoff timers expire. Returns immediately if forwarding is
96
+ * paused. Events still `retried` when it returns need a later cycle or a durable store.
97
+ */
98
+ flush(opts?: { timeoutMs?: number }): Promise<FlushResult>
99
+ /** Run retention pruning to completion. */
100
+ prune(): Promise<{ wiped: number; deleted: number }>
101
+ /**
102
+ * Map a sample payload through a route without storing or sending anything. Enrichments
103
+ * are not run: their fields are listed in `report.enriched` and `pending`.
104
+ */
105
+ preview(route: RouteConfig, sample: Json, receivedAt?: string): MappedEvent
106
+ /** Decode a buffered payload. Sensitive: the caller is responsible for auditing. */
107
+ readPayload(id: string): Promise<Buffer | undefined>
108
+ on<E extends RelayEventName>(name: E, handler: RelayHandler<E>): () => void
109
+ readonly store: EventStore
110
+ readonly control: ControlStore
111
+ }
112
+
113
+ function toKey(name: string, v: string | Buffer): Buffer {
114
+ if (typeof v === 'string') return deriveKey(name, v)
115
+ if (v.length !== 32) throw new Error(`${name} must be a 32-byte Buffer or a string of 16+ chars`)
116
+ return v
117
+ }
118
+
119
+ /** Reject routes whose `source` has no adapter (built-in or host-registered). */
120
+ export function assertKnownSources(
121
+ routes: RouteConfig[],
122
+ adapters?: Record<string, ProcessorAdapter>,
123
+ ): void {
124
+ for (const r of routes) {
125
+ if (!hasAdapter(r.source, adapters)) {
126
+ throw new Error(`route ${r.id}: no adapter for source "${r.source}"`)
127
+ }
128
+ }
129
+ }
130
+
131
+ /** Every `enrich:` reference in a route's map, as [field, enrichment name]. */
132
+ export function routeEnrichments(route: RouteConfig): [field: string, enrichment: string][] {
133
+ const out: [string, string][] = []
134
+ const desc = route.map.description === undefined ? undefined : refEnrichment(route.map.description)
135
+ if (desc !== undefined) out.push(['description', desc])
136
+ for (const [key, ref] of Object.entries(route.map.metadata)) {
137
+ const name = refEnrichment(ref)
138
+ if (name !== undefined) out.push([`metadata.${key}`, name])
139
+ }
140
+ return out
141
+ }
142
+
143
+ /** Reject routes whose map names an enrichment the host has not registered. */
144
+ export function assertKnownEnrichments(
145
+ routes: RouteConfig[],
146
+ enrichments?: Record<string, unknown>,
147
+ ): void {
148
+ for (const r of routes) {
149
+ for (const [field, name] of routeEnrichments(r)) {
150
+ if (!enrichments || !Object.hasOwn(enrichments, name)) {
151
+ throw new Error(`route ${r.id}: ${field} references unknown enrichment "${name}"`)
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Validate a routes document (parsed YAML or a plain object) into RouteConfig[]. Applies
159
+ * defaults, the hard-denylist check on metadata names, duplicate-id, unknown-source and
160
+ * unknown-enrichment checks. Pass the host's extra adapters and enrichments so routes
161
+ * that use them validate too; with none registered, any `enrich:` reference is rejected.
162
+ */
163
+ export function parseRoutes(
164
+ doc: unknown,
165
+ opts: { adapters?: Record<string, ProcessorAdapter>; enrichments?: Record<string, unknown> } = {},
166
+ ): RouteConfig[] {
167
+ const config: RelayConfig = relayConfigSchema.parse(doc)
168
+ const seen = new Set<string>()
169
+ for (const route of config.routes) {
170
+ if (seen.has(route.id)) throw new Error(`duplicate route id: ${route.id}`)
171
+ seen.add(route.id)
172
+ }
173
+ assertKnownSources(config.routes, opts.adapters)
174
+ assertKnownEnrichments(config.routes, opts.enrichments)
175
+ return config.routes
176
+ }
177
+
178
+ const FLUSH_POLL_MIN_MS = 50
179
+ const FLUSH_POLL_MAX_MS = 1000
180
+ // Asking for "due" events at this time returns every route holding pending/retry rows.
181
+ const FAR_FUTURE = '9999-12-31T23:59:59.999Z'
182
+
183
+ export function createRelay(opts: RelayOptions): Relay {
184
+ if (Array.isArray(opts.routes)) {
185
+ assertKnownSources(opts.routes, opts.adapters)
186
+ assertKnownEnrichments(opts.routes, opts.enrichments)
187
+ }
188
+ const routes = Array.isArray(opts.routes) ? staticRoutes(opts.routes) : opts.routes
189
+ const control = opts.control ?? new MemoryControlStore()
190
+ const secrets = toSecretResolver(opts.secrets)
191
+ const logger = opts.logger ?? noopLogger
192
+ const hooks = opts.hooks ?? new RelayHooks()
193
+ const codec =
194
+ opts.encryption === 'none' ? plainCodec : aesGcmCodec(toKey('dataKey', opts.encryption.dataKey))
195
+ const maskingKey = toKey('maskingKey', opts.maskingKey)
196
+ const client =
197
+ opts.client ??
198
+ new EndCloseClient(
199
+ opts.endclose.baseUrl ?? 'https://api.endclose.com/v1',
200
+ opts.endclose.apiKey,
201
+ opts.endclose.fetch ?? fetch,
202
+ )
203
+ const dispatch: DispatchSettings = { ...DEFAULT_DISPATCH, ...opts.dispatch }
204
+ const retention = opts.retention === false ? null : { ...DEFAULT_RETENTION, ...opts.retention }
205
+ const signal = new EventEmitter()
206
+ const { store } = opts
207
+
208
+ const ingestDeps = {
209
+ store,
210
+ control,
211
+ routes,
212
+ secrets,
213
+ codec,
214
+ signal,
215
+ hooks,
216
+ logger,
217
+ ...(opts.adapters ? { adapters: opts.adapters } : {}),
218
+ }
219
+
220
+ const dispatcher = new Dispatcher({
221
+ store,
222
+ control,
223
+ routes,
224
+ dispatch,
225
+ retention,
226
+ client,
227
+ codec,
228
+ maskingKey,
229
+ instanceId: opts.instanceId ?? randomUUID(),
230
+ signal,
231
+ hooks,
232
+ logger,
233
+ ...(opts.enrichments ? { enrichments: opts.enrichments } : {}),
234
+ })
235
+
236
+ const dispatchOnce = async (o: { prune?: boolean } = {}): Promise<DispatchOnceResult> => {
237
+ const { delivered, retried, parked } = await dispatcher.runOnce()
238
+ if (o.prune) await dispatcher.pruneNow()
239
+ return { delivered, retried, parked }
240
+ }
241
+
242
+ const flush = async ({ timeoutMs = 30_000 } = {}): Promise<FlushResult> => {
243
+ const deadline = Date.now() + timeoutMs
244
+ const totals: FlushResult = { delivered: 0, retried: 0, parked: 0, drained: false }
245
+ let pollMs = FLUSH_POLL_MIN_MS
246
+ for (;;) {
247
+ const c = await dispatcher.runOnce()
248
+ totals.delivered += c.delivered
249
+ totals.retried += c.retried
250
+ totals.parked += c.parked
251
+ if (c.halted) return { ...totals, reason: 'paused' }
252
+
253
+ const remaining = deadline - Date.now()
254
+ if (remaining <= 0) return { ...totals, reason: 'timeout' }
255
+ const touched = c.delivered + c.retried + c.parked
256
+ if (touched > 0) continue // there may be more than one batch's worth; go straight back
257
+
258
+ const backlog = await store.routesWithDueEvents(FAR_FUTURE)
259
+ if (backlog.length === 0) return { ...totals, drained: true }
260
+ // Backlog confined to routes the cycle deliberately skipped will not clear on its
261
+ // own. Anything else is waiting on a backoff timer.
262
+ const skipped = new Map(c.skipped.map((s) => [s.routeId, s.reason]))
263
+ if (backlog.every((r) => skipped.has(r))) {
264
+ const allPaused = backlog.every((r) => skipped.get(r) === 'paused')
265
+ return { ...totals, reason: allPaused ? 'paused' : 'unroutable' }
266
+ }
267
+
268
+ await sleep(Math.min(pollMs, remaining))
269
+ pollMs = Math.min(pollMs * 2, FLUSH_POLL_MAX_MS)
270
+ }
271
+ }
272
+
273
+ const readPayload = async (id: string): Promise<Buffer | undefined> => {
274
+ if (!hasAdmin(store)) throw new Error('store does not support inspection (EventStoreAdmin)')
275
+ const row = await store.getById(id)
276
+ if (!row || row.payload.length === 0) return undefined
277
+ return codec.decode(row.payload, row.payload_iv)
278
+ }
279
+
280
+ return {
281
+ ingest: (routeId, req) => ingestWebhook(ingestDeps, routeId, req),
282
+ start: () => dispatcher.start(),
283
+ stop: () => dispatcher.stop(),
284
+ dispatchOnce,
285
+ flush,
286
+ prune: () => dispatcher.pruneNow(),
287
+ preview: (route, sample, receivedAt = new Date().toISOString()) =>
288
+ mapEvent(route, sample, receivedAt, maskingKey),
289
+ readPayload,
290
+ on: (name, handler) => hooks.on(name, handler),
291
+ store,
292
+ control,
293
+ }
294
+ }
@@ -0,0 +1,51 @@
1
+ // Secret resolution for the engine. Route configs reference secrets by NAME only (the
2
+ // `secret_env` field); the host decides where the value comes from. The application uses the
3
+ // process environment; an embedding application may use a static map loaded from its
4
+ // secret manager at startup.
5
+ //
6
+ // Resolution is synchronous by design: it sits on the webhook verification hot path, and
7
+ // secrets should be materialised once at boot rather than fetched per request.
8
+
9
+ export interface SecretResolver {
10
+ /** The secret value for `ref`, or undefined when it is not available. */
11
+ resolve(ref: string): string | undefined
12
+ /** Whether `ref` is currently available (defaults to `resolve(ref) !== undefined`). */
13
+ has?(ref: string): boolean
14
+ }
15
+
16
+ export class SecretUnavailableError extends Error {
17
+ constructor(readonly ref: string) {
18
+ super(`missing required secret env var: ${ref}`)
19
+ }
20
+ }
21
+
22
+ /** Resolve from an environment-shaped record; empty strings count as unset. */
23
+ export function envSecrets(env: Record<string, string | undefined> = process.env): SecretResolver {
24
+ return {
25
+ resolve: (ref) => {
26
+ const v = env[ref]
27
+ return v ? v : undefined
28
+ },
29
+ }
30
+ }
31
+
32
+ export function staticSecrets(map: Record<string, string>): SecretResolver {
33
+ return { resolve: (ref) => map[ref] }
34
+ }
35
+
36
+ export function toSecretResolver(s: SecretResolver | Record<string, string>): SecretResolver {
37
+ return typeof (s as SecretResolver).resolve === 'function'
38
+ ? (s as SecretResolver)
39
+ : staticSecrets(s as Record<string, string>)
40
+ }
41
+
42
+ export function hasSecret(resolver: SecretResolver, ref: string): boolean {
43
+ return resolver.has ? resolver.has(ref) : resolver.resolve(ref) !== undefined
44
+ }
45
+
46
+ /** Resolve or throw `SecretUnavailableError`. */
47
+ export function requireSecret(resolver: SecretResolver, ref: string): string {
48
+ const v = resolver.resolve(ref)
49
+ if (v === undefined) throw new SecretUnavailableError(ref)
50
+ return v
51
+ }
@@ -0,0 +1,42 @@
1
+ // Tuning knobs shared by createRelay() and the Dispatcher. One shape, camelCase.
2
+
3
+ export interface DispatchSettings {
4
+ /** Records per bulk POST. */
5
+ batchMax: number
6
+ /** How often the loop wakes when idle (it also wakes immediately on ingest). */
7
+ pollIntervalMs: number
8
+ backoffBaseMs: number
9
+ backoffCapMs: number
10
+ /** Retrying events park (never dropped) after this long. */
11
+ parkAfterMs: number
12
+ /** How long a claimed batch is protected from recovery by other instances. */
13
+ leaseMs: number
14
+ /** How often a running instance sweeps for expired leases left by crashed peers. */
15
+ recoverIntervalMs: number
16
+ /**
17
+ * Upper bound on one `enrich` call. A slower call counts as a transient failure and the
18
+ * event retries with backoff. Enrichments run one at a time per event, so keep
19
+ * batchMax × (enrichments per event) × enrichTimeoutMs well under leaseMs.
20
+ */
21
+ enrichTimeoutMs: number
22
+ }
23
+
24
+ export interface RetentionSettings {
25
+ /** Payloads of delivered/filtered events are wiped after this many days. */
26
+ deliveredDays: number
27
+ /** Their rows (the idempotency ledger) are deleted after this many days. */
28
+ ledgerDays: number
29
+ }
30
+
31
+ export const DEFAULT_DISPATCH: DispatchSettings = {
32
+ batchMax: 100,
33
+ pollIntervalMs: 250,
34
+ backoffBaseMs: 1000,
35
+ backoffCapMs: 600_000,
36
+ parkAfterMs: 7 * 24 * 3600 * 1000,
37
+ leaseMs: 600_000,
38
+ recoverIntervalMs: 60_000,
39
+ enrichTimeoutMs: 5_000,
40
+ }
41
+
42
+ export const DEFAULT_RETENTION: RetentionSettings = { deliveredDays: 7, ledgerDays: 30 }
@@ -0,0 +1,188 @@
1
+ import type { RouteConfig } from '../config/schema.js'
2
+
3
+ // The persistence contracts the engine depends on. A host supplies implementations: the
4
+ // application uses SQLite; an embedding application may use its own database. Every
5
+ // method is async so implementations can be backed by a network database.
6
+
7
+ export type EventStatus =
8
+ | 'pending'
9
+ | 'retry'
10
+ | 'delivering'
11
+ | 'delivered'
12
+ | 'parked'
13
+ | 'dropped_by_filter'
14
+
15
+ export interface EventRecord {
16
+ /** Opaque store-assigned id. */
17
+ id: string
18
+ route_id: string
19
+ source: string
20
+ event_id: string
21
+ event_type: string | null
22
+ /** Whatever the PayloadCodec produced; opaque to the store. */
23
+ payload: Buffer
24
+ payload_iv: Buffer | null
25
+ headers_json: string
26
+ received_at: string
27
+ status: EventStatus
28
+ attempts: number
29
+ next_attempt_at: string | null
30
+ delivered_at: string | null
31
+ bulk_request_id: string | null
32
+ last_error: string | null
33
+ idempotency_key: string
34
+ claimed_by: string | null
35
+ lease_until: string | null
36
+ }
37
+
38
+ export interface NewEvent {
39
+ route_id: string
40
+ source: string
41
+ event_id: string
42
+ event_type: string | null
43
+ payload: Buffer
44
+ payload_iv: Buffer | null
45
+ headers_json: string
46
+ received_at: string
47
+ status: EventStatus
48
+ idempotency_key: string
49
+ }
50
+
51
+ export interface RouteStats {
52
+ route_id: string
53
+ counts: Partial<Record<EventStatus, number>>
54
+ last_delivered_at: string | null
55
+ oldest_pending_at: string | null
56
+ }
57
+
58
+ export type EventSummary = Omit<
59
+ EventRecord,
60
+ 'payload' | 'payload_iv' | 'headers_json' | 'idempotency_key' | 'claimed_by' | 'lease_until'
61
+ >
62
+
63
+ export type InsertResult = { duplicate: false; id: string } | { duplicate: true }
64
+
65
+ /** A store operation failed. `op` names the operation for logs and hooks. */
66
+ export class StoreError extends Error {
67
+ constructor(
68
+ message: string,
69
+ readonly op: string,
70
+ options?: { cause?: unknown },
71
+ ) {
72
+ super(message, options)
73
+ this.name = 'StoreError'
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The store is temporarily unable to serve a request (lock contention, connection
79
+ * loss). Ingest answers 503 so the processor retries; any other StoreError is 500.
80
+ */
81
+ export class StoreUnavailableError extends StoreError {
82
+ constructor(message: string, op: string, options?: { cause?: unknown }) {
83
+ super(message, op, options)
84
+ this.name = 'StoreUnavailableError'
85
+ }
86
+ }
87
+
88
+ export function storeOp(err: unknown): string | undefined {
89
+ return err instanceof StoreError ? err.op : undefined
90
+ }
91
+
92
+ /** What the engine needs from event storage. */
93
+ export interface EventStore {
94
+ /** Persist a new event; `duplicate: true` when the idempotency key already exists. */
95
+ insert(e: NewEvent): Promise<InsertResult>
96
+ /**
97
+ * Routes with `pending`/`retry` events whose next attempt is at or before `now`.
98
+ * Called with a far-future `now` it answers "which routes hold any backlog at all".
99
+ */
100
+ routesWithDueEvents(now: string): Promise<string[]>
101
+ /**
102
+ * Atomically select up to `limit` due events for a route (oldest first), mark them
103
+ * `delivering` and record the lease. Concurrent callers must never receive overlapping
104
+ * rows (a SQL store would use SELECT ... FOR UPDATE SKIP LOCKED).
105
+ */
106
+ claimDue(routeId: string, now: string, limit: number, lease: Lease): Promise<EventRecord[]>
107
+ markDelivered(ids: string[], deliveredAt: string, bulkRequestId: string | null): Promise<void>
108
+ markFailed(ids: string[], nextAttemptAt: string, error: string): Promise<void>
109
+ markParked(ids: string[], error: string): Promise<void>
110
+ /** Return the given rows to `retry` if (and only if) they are still `delivering`. */
111
+ releaseDelivering(ids: string[], nextAttemptAt: string, error: string): Promise<number>
112
+ /**
113
+ * Return `delivering` rows whose lease has expired — or that were claimed by `owner`,
114
+ * so a restarted instance reclaims its own work immediately — to `retry`.
115
+ */
116
+ recoverDelivering(now: string, owner?: string): Promise<number>
117
+ /** Park events that have been retrying longer than `maxAgeMs`. */
118
+ parkExpired(now: string, maxAgeMs: number): Promise<number>
119
+ /** One bounded retention step; callers loop until it returns zeros. */
120
+ pruneBatch(
121
+ now: string,
122
+ deliveredDays: number,
123
+ ledgerDays: number,
124
+ limit: number,
125
+ ): Promise<{ wiped: number; deleted: number }>
126
+ close?(): Promise<void>
127
+ }
128
+
129
+ export interface Lease {
130
+ owner: string
131
+ until: string
132
+ }
133
+
134
+ /** Optional inspection/replay capability used by an operator surface. */
135
+ export interface EventStoreAdmin {
136
+ getById(id: string): Promise<EventRecord | undefined>
137
+ list(filter: { status?: EventStatus; route?: string; limit?: number }): Promise<EventSummary[]>
138
+ countByStatus(): Promise<Record<string, number>>
139
+ perRouteStats(): Promise<RouteStats[]>
140
+ replay(id: string): Promise<boolean>
141
+ replayAllParked(): Promise<number>
142
+ }
143
+
144
+ export function hasAdmin(store: EventStore): store is EventStore & EventStoreAdmin {
145
+ return typeof (store as Partial<EventStoreAdmin>).list === 'function'
146
+ }
147
+
148
+ export type Killswitch = 'none' | 'pause' | 'panic'
149
+
150
+ /** Runtime-mutable operator state: the global killswitch and per-route pauses. */
151
+ export interface ControlStore {
152
+ getKillswitch(): Promise<Killswitch>
153
+ setKillswitch(state: Killswitch): Promise<void>
154
+ isRoutePaused(routeId: string): Promise<boolean>
155
+ setRoutePaused(routeId: string, paused: boolean): Promise<void>
156
+ }
157
+
158
+ /** Where the engine reads route definitions. Static for an embedding app; live for the application. */
159
+ export interface RouteProvider {
160
+ get(id: string): Promise<RouteConfig | undefined>
161
+ all(): Promise<RouteConfig[]>
162
+ }
163
+
164
+ export function staticRoutes(routes: RouteConfig[]): RouteProvider {
165
+ const byId = new Map(routes.map((r) => [r.id, r]))
166
+ return {
167
+ get: async (id) => byId.get(id),
168
+ all: async () => [...byId.values()],
169
+ }
170
+ }
171
+
172
+ export class MemoryControlStore implements ControlStore {
173
+ private killswitch: Killswitch = 'none'
174
+ private paused = new Set<string>()
175
+ async getKillswitch(): Promise<Killswitch> {
176
+ return this.killswitch
177
+ }
178
+ async setKillswitch(state: Killswitch): Promise<void> {
179
+ this.killswitch = state
180
+ }
181
+ async isRoutePaused(routeId: string): Promise<boolean> {
182
+ return this.paused.has(routeId)
183
+ }
184
+ async setRoutePaused(routeId: string, paused: boolean): Promise<void> {
185
+ if (paused) this.paused.add(routeId)
186
+ else this.paused.delete(routeId)
187
+ }
188
+ }
@@ -0,0 +1,10 @@
1
+ // Exponential backoff with ±20% jitter, Sequin-style: base * 2^attempts, capped.
2
+ export function backoffMs(attempts: number, baseMs = 1000, capMs = 600_000): number {
3
+ const raw = Math.min(baseMs * 2 ** Math.min(attempts, 30), capMs)
4
+ const jitter = 0.8 + Math.random() * 0.4
5
+ return Math.round(raw * jitter)
6
+ }
7
+
8
+ export function nextAttemptAt(attempts: number, baseMs?: number, capMs?: number): string {
9
+ return new Date(Date.now() + backoffMs(attempts, baseMs, capMs)).toISOString()
10
+ }