@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,504 @@
1
+ import type { EventEmitter } from 'node:events'
2
+ import {
3
+ storeOp,
4
+ type ControlStore,
5
+ type EventRecord,
6
+ type EventStore,
7
+ type RouteProvider,
8
+ } from '../engine/store.js'
9
+ import type { PayloadCodec } from '../engine/codec.js'
10
+ import type { DispatchSettings, RetentionSettings } from '../engine/settings.js'
11
+ import type { RouteConfig } from '../config/schema.js'
12
+ import type { Json } from '../mask/paths.js'
13
+ import { RelayHooks } from '../engine/hooks.js'
14
+ import { noopLogger, type Logger } from '../logger.js'
15
+ import { jsonTopLevelKeys } from '../util/payload-shape.js'
16
+ import { sleep } from '../util/strings.js'
17
+ import { nextAttemptAt } from './backoff.js'
18
+ import { mapEvent, MappingError, type EndCloseRecord, type MappedEvent } from './mapper.js'
19
+ import {
20
+ EnrichmentError,
21
+ validateEnrichedValue,
22
+ withTimeout,
23
+ type EnrichContext,
24
+ type Enrichment,
25
+ } from './enrich.js'
26
+ import {
27
+ EndCloseClient,
28
+ PermanentHttpError,
29
+ TransientHttpError,
30
+ type BulkResultItem,
31
+ } from './endclose-client.js'
32
+
33
+ const IN_REQUEST_RETRIES = 2
34
+ const RESULT_POLL_ATTEMPTS = 5
35
+ const RESULT_POLL_DELAY_MS = 1000
36
+ const PRUNE_INTERVAL_MS = 3600_000
37
+ const PRUNE_BATCH = 50
38
+ const PRUNE_YIELD_MS = 25
39
+
40
+ export interface DispatcherDeps {
41
+ store: EventStore
42
+ control: ControlStore
43
+ routes: RouteProvider
44
+ dispatch: DispatchSettings
45
+ /** null disables retention pruning. */
46
+ retention: RetentionSettings | null
47
+ client: EndCloseClient
48
+ codec: PayloadCodec
49
+ maskingKey: Buffer
50
+ /** Lease owner recorded on claimed rows; a stable id lets a restarted instance reclaim its own. */
51
+ instanceId: string
52
+ signal: EventEmitter
53
+ hooks?: RelayHooks
54
+ logger?: Logger
55
+ /** Host functions routes may name with `enrich:`; run per event after mapping. */
56
+ enrichments?: Record<string, Enrichment>
57
+ }
58
+
59
+ export interface DispatchCounts {
60
+ delivered: number
61
+ retried: number
62
+ parked: number
63
+ }
64
+
65
+ /** What one dispatch cycle did, and what it deliberately left alone. */
66
+ export interface CycleSummary extends DispatchCounts {
67
+ /** Routes that had due events when the cycle started. */
68
+ due: string[]
69
+ /** Due routes the cycle did not touch, and why. */
70
+ skipped: { routeId: string; reason: 'paused' | 'unknown_route' }[]
71
+ /** True when the global killswitch stopped the cycle before any forwarding. */
72
+ halted: boolean
73
+ }
74
+
75
+ type Abort = () => boolean
76
+
77
+ export class Dispatcher {
78
+ private log: Logger
79
+ private hooks: RelayHooks
80
+ private running = false
81
+ private wakeRequested = false
82
+ // Rows this instance left 'delivering' (a crash, or a failed release) are reclaimed
83
+ // by owner on the next cycle regardless of lease. Expired leases left by anyone are
84
+ // swept every recoverIntervalMs, so a long-lived instance also covers crashed peers.
85
+ private reclaimOwner: string | undefined
86
+ private lastSweepAt = 0
87
+ private inFlight: Promise<void> = Promise.resolve()
88
+ private pruneWork: Promise<unknown> = Promise.resolve()
89
+ private timer: NodeJS.Timeout | undefined
90
+ private pruneTimer: NodeJS.Timeout | undefined
91
+ private stopped: Abort = () => !this.running
92
+
93
+ constructor(private deps: DispatcherDeps) {
94
+ this.log = deps.logger ?? noopLogger
95
+ this.hooks = deps.hooks ?? new RelayHooks()
96
+ this.reclaimOwner = deps.instanceId
97
+ }
98
+
99
+ start(): void {
100
+ if (this.running) return
101
+ this.running = true
102
+ this.deps.signal.on('event', this.onSignal)
103
+ this.timer = setInterval(() => this.wake(), this.deps.dispatch.pollIntervalMs)
104
+ if (this.deps.retention) {
105
+ this.pruneTimer = setInterval(() => this.schedulePrune(), PRUNE_INTERVAL_MS)
106
+ this.schedulePrune()
107
+ }
108
+ this.wake()
109
+ }
110
+
111
+ private onSignal = () => this.wake()
112
+
113
+ /**
114
+ * Run exactly one dispatch cycle, serialised with the background loop if it is running.
115
+ * Errors propagate to the caller (they are also emitted as hooks).
116
+ */
117
+ runOnce(): Promise<CycleSummary> {
118
+ const run = this.inFlight.then(() => this.cycle().catch((err) => {
119
+ this.reportCycleError(err)
120
+ throw err
121
+ }))
122
+ this.inFlight = run.then(() => {}, () => {})
123
+ return run
124
+ }
125
+
126
+ /** Run retention pruning to completion, serialised with any scheduled prune. */
127
+ pruneNow(): Promise<{ wiped: number; deleted: number }> {
128
+ const run = this.pruneWork.then(() => this.runPrune())
129
+ this.pruneWork = run.then(() => {}, () => {})
130
+ return run
131
+ }
132
+
133
+ /** Stop accepting new work and wait for the in-flight cycle and prune to drain. */
134
+ async stop(): Promise<void> {
135
+ this.running = false
136
+ this.deps.signal.off('event', this.onSignal)
137
+ if (this.timer) clearInterval(this.timer)
138
+ if (this.pruneTimer) clearInterval(this.pruneTimer)
139
+ await this.inFlight
140
+ await this.pruneWork
141
+ }
142
+
143
+ private wake(): void {
144
+ if (!this.running) return
145
+ this.wakeRequested = true
146
+ this.inFlight = this.inFlight.then(async () => {
147
+ while (this.wakeRequested && this.running) {
148
+ this.wakeRequested = false
149
+ try {
150
+ await this.cycle(this.stopped)
151
+ } catch (err) {
152
+ this.reportCycleError(err)
153
+ }
154
+ }
155
+ })
156
+ }
157
+
158
+ private reportCycleError(err: unknown): void {
159
+ const op = storeOp(err)
160
+ this.log.error('dispatch cycle failed', { error: (err as Error).message, ...(op ? { op } : {}) })
161
+ this.hooks.emit('error', { kind: 'dispatch_cycle', error: err, ...(op ? { op } : {}) })
162
+ }
163
+
164
+ private async cycle(abort: Abort = () => false): Promise<CycleSummary> {
165
+ const now = new Date().toISOString()
166
+ const summary: CycleSummary = { delivered: 0, retried: 0, parked: 0, due: [], skipped: [], halted: false }
167
+
168
+ const sweepDue = Date.now() - this.lastSweepAt >= this.deps.dispatch.recoverIntervalMs
169
+ if (this.reclaimOwner !== undefined || sweepDue) {
170
+ try {
171
+ // The owner clause is only safe as a one-off (nothing of ours is in flight yet);
172
+ // the periodic sweep relies on lease expiry alone.
173
+ const recovered = await this.deps.store.recoverDelivering(now, this.reclaimOwner)
174
+ this.reclaimOwner = undefined
175
+ if (recovered > 0) this.log.info('recovered in-flight events', { count: recovered })
176
+ if (sweepDue) {
177
+ // Same cadence for the other housekeeping write; parkAfterMs is days, not seconds.
178
+ await this.deps.store.parkExpired(now, this.deps.dispatch.parkAfterMs)
179
+ this.lastSweepAt = Date.now()
180
+ }
181
+ } catch (err) {
182
+ this.log.error('recover delivering failed', { error: (err as Error).message })
183
+ this.hooks.emit('error', { kind: 'recover_delivering', error: err })
184
+ }
185
+ }
186
+
187
+ if ((await this.deps.control.getKillswitch()) !== 'none') {
188
+ summary.halted = true
189
+ return summary // pause/panic: buffer, do not forward
190
+ }
191
+
192
+ summary.due = await this.deps.store.routesWithDueEvents(now)
193
+ for (const routeId of summary.due) {
194
+ if (abort()) break
195
+ const [route, paused] = await Promise.all([
196
+ this.deps.routes.get(routeId),
197
+ this.deps.control.isRoutePaused(routeId),
198
+ ])
199
+ // An unknown route is the stronger condition: a pause flag left behind by a removed
200
+ // route must not disguise it as merely paused.
201
+ if (!route) {
202
+ summary.skipped.push({ routeId, reason: 'unknown_route' })
203
+ continue
204
+ }
205
+ if (paused) {
206
+ summary.skipped.push({ routeId, reason: 'paused' })
207
+ continue
208
+ }
209
+
210
+ const claimed = await this.deps.store.claimDue(routeId, now, this.deps.dispatch.batchMax, {
211
+ owner: this.deps.instanceId,
212
+ until: new Date(Date.now() + this.deps.dispatch.leaseMs).toISOString(),
213
+ })
214
+ if (claimed.length === 0) continue
215
+ try {
216
+ await this.deliverBatch(route, claimed, summary)
217
+ } finally {
218
+ await this.releaseLeftover(claimed, 'left delivering after batch')
219
+ }
220
+ }
221
+ return summary
222
+ }
223
+
224
+ private async deliverBatch(route: RouteConfig, claimed: EventRecord[], counts: DispatchCounts): Promise<void> {
225
+ const routeId = route.id
226
+ // Map and enrich each event independently; an event that cannot be mapped parks, one
227
+ // whose enrichment fails transiently retries, and neither wedges the batch.
228
+ const records: EndCloseRecord[] = []
229
+ const mapped: EventRecord[] = []
230
+ for (const event of claimed) {
231
+ let payloadKeys: string | undefined
232
+ let bodyBytes: number | undefined
233
+ let stage: 'mapping' | 'enrichment' = 'mapping'
234
+ try {
235
+ const plaintext = this.deps.codec.decode(event.payload, event.payload_iv)
236
+ bodyBytes = plaintext.length
237
+ const payload = JSON.parse(plaintext.toString('utf8')) as Json
238
+ payloadKeys = jsonTopLevelKeys(payload)
239
+ const result = mapEvent(route, payload, event.received_at, this.deps.maskingKey)
240
+ let { record } = result
241
+ if (result.pending.length > 0) {
242
+ stage = 'enrichment'
243
+ record = await this.resolveEnrichments(event, payload, result)
244
+ }
245
+ records.push(record)
246
+ mapped.push(event)
247
+ } catch (err) {
248
+ if (err instanceof MappingError || err instanceof EnrichmentError) {
249
+ const reason = `${stage} failed: ${err.message}`
250
+ await this.deps.store.markParked([event.id], reason)
251
+ this.recordParked([event], reason, counts)
252
+ this.log.warn(`event parked: ${stage} failed`, {
253
+ route: routeId,
254
+ event_id: event.event_id,
255
+ body_bytes: bodyBytes,
256
+ payload_keys: payloadKeys,
257
+ })
258
+ } else if (stage === 'enrichment') {
259
+ // The host's lookup threw or timed out: fixable on their side, so retry later.
260
+ await this.retryOne(event, `enrichment failed: ${(err as Error).message}`, counts)
261
+ } else {
262
+ throw err
263
+ }
264
+ }
265
+ }
266
+ if (records.length === 0) return
267
+
268
+ try {
269
+ const accepted = await this.postWithRetries(records)
270
+ await this.settleResults(accepted.id, mapped, counts)
271
+ this.log.info('batch forwarded', {
272
+ route: routeId,
273
+ events: mapped.length,
274
+ bulk_request_id: accepted.id,
275
+ })
276
+ this.hooks.emit('batch.forwarded', { routeId, events: mapped.length, bulkRequestId: accepted.id })
277
+ } catch (err) {
278
+ if (err instanceof PermanentHttpError && (err.status === 400 || err.status === 422)) {
279
+ const reason = `${err.message}: ${err.body}`
280
+ await this.deps.store.markParked(mapped.map((e) => e.id), reason)
281
+ this.recordParked(mapped, reason, counts)
282
+ this.log.error('batch parked: permanent rejection', { route: routeId, status: err.status })
283
+ this.hooks.emit('batch.parked', { routeId, status: err.status, events: mapped.length })
284
+ } else {
285
+ // Transient network failure, 5xx, or auth problem (fixable server-side or in
286
+ // config): schedule redelivery with backoff. attempts is per-event.
287
+ const maxAttempts = Math.max(...mapped.map((e) => e.attempts))
288
+ const next = nextAttemptAt(maxAttempts, this.deps.dispatch.backoffBaseMs, this.deps.dispatch.backoffCapMs)
289
+ const message = (err as Error).message
290
+ await this.deps.store.markFailed(mapped.map((e) => e.id), next, message)
291
+ counts.retried += mapped.length
292
+ this.hooks.emit('forward', { routeId, result: 'retried', count: mapped.length })
293
+ for (const e of mapped) this.hooks.emit('settled', { id: e.id, routeId, result: 'retried', error: message })
294
+ this.log.warn('batch delivery failed, will retry', {
295
+ route: routeId,
296
+ events: mapped.length,
297
+ error: message,
298
+ next_attempt_at: next,
299
+ })
300
+ }
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Fill the fields mapEvent left to the host, one enrichment at a time. Throws
306
+ * EnrichmentError for a bad reference or return value (park); rethrows anything else
307
+ * the host threw, including a timeout (retry).
308
+ */
309
+ private async resolveEnrichments(event: EventRecord, payload: Json, mapped: MappedEvent): Promise<EndCloseRecord> {
310
+ const routeId = event.route_id
311
+ const record: EndCloseRecord = { ...mapped.record, metadata: { ...mapped.record.metadata } }
312
+ for (const { field, enrichment, input } of mapped.pending) {
313
+ const emit = (result: 'applied' | 'omitted' | 'failed' | 'rejected', error?: string) =>
314
+ this.hooks.emit('enrich', { routeId, id: event.id, field, enrichment, result, ...(error ? { error } : {}) })
315
+ const fn = this.deps.enrichments?.[enrichment]
316
+ if (!fn) {
317
+ const err = new EnrichmentError(`${field} references unknown enrichment "${enrichment}"`)
318
+ emit('rejected', err.message)
319
+ throw err
320
+ }
321
+ const ctx: EnrichContext = {
322
+ routeId,
323
+ source: event.source,
324
+ eventId: event.event_id,
325
+ eventType: event.event_type,
326
+ receivedAt: event.received_at,
327
+ field,
328
+ payload,
329
+ }
330
+ let value: Json | undefined
331
+ try {
332
+ const returned: unknown = await withTimeout(() => fn(input, ctx), this.deps.dispatch.enrichTimeoutMs)
333
+ value = validateEnrichedValue(returned, field)
334
+ if (field === 'description' && value !== undefined && typeof value !== 'string') {
335
+ throw new EnrichmentError(`enriched description must be a string`)
336
+ }
337
+ } catch (err) {
338
+ const message = (err as Error).message
339
+ emit(err instanceof EnrichmentError ? 'rejected' : 'failed', `${enrichment}: ${message}`)
340
+ const wrapped = `${field} (${enrichment}): ${message}`
341
+ throw err instanceof EnrichmentError
342
+ ? new EnrichmentError(wrapped, { cause: err })
343
+ : new Error(wrapped, { cause: err })
344
+ }
345
+ if (value === undefined) {
346
+ emit('omitted')
347
+ continue
348
+ }
349
+ if (field === 'description') record.description = value as string
350
+ else record.metadata[field.slice('metadata.'.length)] = value
351
+ emit('applied')
352
+ }
353
+ return record
354
+ }
355
+
356
+ /** Schedule one event for redelivery with backoff, leaving the rest of its batch alone. */
357
+ private async retryOne(event: EventRecord, message: string, counts: DispatchCounts): Promise<void> {
358
+ const next = nextAttemptAt(event.attempts, this.deps.dispatch.backoffBaseMs, this.deps.dispatch.backoffCapMs)
359
+ await this.deps.store.markFailed([event.id], next, message)
360
+ counts.retried += 1
361
+ this.hooks.emit('forward', { routeId: event.route_id, result: 'retried', count: 1 })
362
+ this.hooks.emit('settled', { id: event.id, routeId: event.route_id, result: 'retried', error: message })
363
+ this.log.warn('event enrichment failed, will retry', {
364
+ route: event.route_id,
365
+ event_id: event.event_id,
366
+ error: message,
367
+ next_attempt_at: next,
368
+ })
369
+ }
370
+
371
+ private async postWithRetries(records: EndCloseRecord[]) {
372
+ let lastErr: unknown
373
+ for (let attempt = 0; attempt <= IN_REQUEST_RETRIES; attempt++) {
374
+ try {
375
+ return await this.deps.client.bulkCreateRecords(records)
376
+ } catch (err) {
377
+ lastErr = err
378
+ if (!(err instanceof TransientHttpError)) throw err
379
+ await sleep(500 * (attempt + 1))
380
+ }
381
+ }
382
+ throw lastErr
383
+ }
384
+
385
+ /**
386
+ * Bulk processing is async server-side: poll briefly for per-row results. Rows the API
387
+ * rejected park; everything else is delivered. If results don't settle in time, mark
388
+ * delivered with the bulk_request_id recorded for later inspection.
389
+ */
390
+ private async settleResults(bulkRequestId: string, mapped: EventRecord[], counts: DispatchCounts): Promise<void> {
391
+ const deliveredAt = new Date().toISOString()
392
+ for (let i = 0; i < RESULT_POLL_ATTEMPTS; i++) {
393
+ let status
394
+ try {
395
+ status = await this.deps.client.getBulkRequest(bulkRequestId)
396
+ } catch {
397
+ break // polling is best-effort; the batch was accepted
398
+ }
399
+ if (status.status === 'pending' || status.status === 'processing') {
400
+ await sleep(RESULT_POLL_DELAY_MS)
401
+ continue
402
+ }
403
+ const failed = new Set(
404
+ (status.results ?? [])
405
+ .filter((r: BulkResultItem) => r.status === 'failed' || r.status === 'rejected')
406
+ .map((r: BulkResultItem) => r.external_id ?? String(r.index)),
407
+ )
408
+ if (failed.size === 0) break
409
+ const parked: EventRecord[] = []
410
+ const ok: EventRecord[] = []
411
+ mapped.forEach((event, index) => {
412
+ const key = extResultKey(event, index)
413
+ ;(failed.has(key.externalId) || failed.has(key.index) ? parked : ok).push(event)
414
+ })
415
+ const reason = 'rejected by End Close bulk processing'
416
+ await this.deps.store.markParked(parked.map((e) => e.id), reason)
417
+ await this.deps.store.markDelivered(ok.map((e) => e.id), deliveredAt, bulkRequestId)
418
+ this.recordDelivered(ok, deliveredAt, counts)
419
+ this.recordParked(parked, reason, counts)
420
+ return
421
+ }
422
+ await this.deps.store.markDelivered(mapped.map((e) => e.id), deliveredAt, bulkRequestId)
423
+ this.recordDelivered(mapped, deliveredAt, counts)
424
+ }
425
+
426
+ private recordDelivered(events: EventRecord[], deliveredAt: string, counts: DispatchCounts): void {
427
+ if (events.length === 0) return
428
+ counts.delivered += events.length
429
+ this.hooks.emit('forward', { routeId: events[0]!.route_id, result: 'delivered', count: events.length })
430
+ for (const e of events) {
431
+ this.hooks.emit('delivered', { routeId: e.route_id, receivedAt: e.received_at, deliveredAt })
432
+ this.hooks.emit('settled', { id: e.id, routeId: e.route_id, result: 'delivered' })
433
+ }
434
+ }
435
+
436
+ private recordParked(events: EventRecord[], reason: string, counts: DispatchCounts): void {
437
+ if (events.length === 0) return
438
+ counts.parked += events.length
439
+ this.hooks.emit('forward', { routeId: events[0]!.route_id, result: 'parked', count: events.length })
440
+ for (const e of events) {
441
+ this.hooks.emit('settled', { id: e.id, routeId: e.route_id, result: 'parked', error: reason })
442
+ }
443
+ }
444
+
445
+ private schedulePrune(): void {
446
+ // Prune must not block forwarding: failures are logged and emitted, never propagated.
447
+ this.pruneWork = this.pruneWork.then(() => this.runPrune(this.stopped)).catch(() => {})
448
+ }
449
+
450
+ /** Bounded batches with a yield between them so ingest and dispatch keep running. */
451
+ private async runPrune(abort: Abort = () => false): Promise<{ wiped: number; deleted: number }> {
452
+ const { retention } = this.deps
453
+ if (!retention) return { wiped: 0, deleted: 0 }
454
+ const now = new Date().toISOString()
455
+ let wiped = 0
456
+ let deleted = 0
457
+ try {
458
+ while (!abort()) {
459
+ const batch = await this.deps.store.pruneBatch(
460
+ now,
461
+ retention.deliveredDays,
462
+ retention.ledgerDays,
463
+ PRUNE_BATCH,
464
+ )
465
+ wiped += batch.wiped
466
+ deleted += batch.deleted
467
+ this.hooks.emit('prune', { wiped: batch.wiped, deleted: batch.deleted })
468
+ if (batch.wiped === 0 && batch.deleted === 0) break
469
+ await sleep(PRUNE_YIELD_MS)
470
+ }
471
+ if (wiped || deleted) this.log.info('retention prune', { wiped, deleted })
472
+ } catch (err) {
473
+ this.log.error('retention prune failed', { error: (err as Error).message })
474
+ this.hooks.emit('error', { kind: 'prune', error: err })
475
+ throw err
476
+ }
477
+ return { wiped, deleted }
478
+ }
479
+
480
+ private async releaseLeftover(claimed: EventRecord[], error: string): Promise<void> {
481
+ const ids = claimed.map((e) => e.id)
482
+ try {
483
+ const n = await this.deps.store.releaseDelivering(ids, new Date().toISOString(), error)
484
+ if (n > 0) {
485
+ this.log.warn('released leftover delivering events', { count: n, error })
486
+ }
487
+ } catch (err) {
488
+ this.reclaimOwner = this.deps.instanceId
489
+ this.log.error('failed to release leftover delivering events', {
490
+ error: (err as Error).message,
491
+ count: ids.length,
492
+ })
493
+ this.hooks.emit('error', { kind: 'recover_delivering', error: err })
494
+ }
495
+ }
496
+ }
497
+
498
+ function extResultKey(event: EventRecord, index: number): { externalId: string; index: string } {
499
+ // Result rows may be keyed by external_id or by index depending on API version.
500
+ const externalId = event.event_id.includes(':')
501
+ ? event.event_id.slice(event.event_id.indexOf(':') + 1)
502
+ : event.event_id
503
+ return { externalId, index: String(index) }
504
+ }
@@ -0,0 +1,102 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { EndCloseRecord } from './mapper.js'
3
+
4
+ export interface BulkRequestSummary {
5
+ id: string
6
+ status: string
7
+ total_items?: number
8
+ failed_items?: number
9
+ skipped_items?: number
10
+ }
11
+
12
+ export interface BulkResultItem {
13
+ index?: number
14
+ external_id?: string
15
+ status: string
16
+ error?: string
17
+ }
18
+
19
+ export class TransientHttpError extends Error {
20
+ constructor(
21
+ message: string,
22
+ readonly status?: number,
23
+ ) {
24
+ super(message)
25
+ }
26
+ }
27
+
28
+ export class PermanentHttpError extends Error {
29
+ constructor(
30
+ message: string,
31
+ readonly status: number,
32
+ readonly body: string,
33
+ ) {
34
+ super(message)
35
+ }
36
+ }
37
+
38
+ const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504])
39
+
40
+ export class EndCloseClient {
41
+ constructor(
42
+ private baseUrl: string,
43
+ private apiKey: string,
44
+ private fetchImpl: typeof fetch = fetch,
45
+ ) {}
46
+
47
+ /** Deterministic per-batch idempotency key so a retried POST returns the original bulk request. */
48
+ static idempotencyKey(records: EndCloseRecord[]): string {
49
+ const h = createHash('sha256')
50
+ for (const r of records) h.update(`${r.data_stream_key}:${r.external_id}\n`)
51
+ return 'relay-' + h.digest('hex').slice(0, 40)
52
+ }
53
+
54
+ async bulkCreateRecords(records: EndCloseRecord[]): Promise<BulkRequestSummary> {
55
+ return this.request('POST', '/records/bulk', {
56
+ idempotencyKey: EndCloseClient.idempotencyKey(records),
57
+ body: { on_conflict: 'skip', records },
58
+ }) as Promise<BulkRequestSummary>
59
+ }
60
+
61
+ async getBulkRequest(id: string): Promise<BulkRequestSummary & { results?: BulkResultItem[] }> {
62
+ return this.request('GET', `/bulk_requests/${id}`, {}) as Promise<
63
+ BulkRequestSummary & { results?: BulkResultItem[] }
64
+ >
65
+ }
66
+
67
+ /** Operational call-home. Failures must never affect ingest or dispatch. */
68
+ async postRelayEvent(event: { name: string; properties: Record<string, unknown> }): Promise<void> {
69
+ await this.request('POST', '/relays/events', { body: event, timeoutMs: 5_000 })
70
+ }
71
+
72
+ private async request(
73
+ method: string,
74
+ path: string,
75
+ opts: { idempotencyKey?: string; body?: unknown; timeoutMs?: number },
76
+ ): Promise<unknown> {
77
+ const headers: Record<string, string> = {
78
+ 'X-API-KEY': this.apiKey,
79
+ 'Content-Type': 'application/json',
80
+ }
81
+ if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey
82
+
83
+ let res: Response
84
+ try {
85
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, {
86
+ method,
87
+ headers,
88
+ body: opts.body === undefined ? null : JSON.stringify(opts.body),
89
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000),
90
+ })
91
+ } catch (err) {
92
+ throw new TransientHttpError(`network error: ${(err as Error).message}`)
93
+ }
94
+
95
+ const text = await res.text()
96
+ if (res.ok) return text ? JSON.parse(text) : {}
97
+ if (TRANSIENT_STATUSES.has(res.status)) {
98
+ throw new TransientHttpError(`HTTP ${res.status}`, res.status)
99
+ }
100
+ throw new PermanentHttpError(`HTTP ${res.status}`, res.status, text.slice(0, 500))
101
+ }
102
+ }