@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,194 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { EventEmitter } from 'node:events'
3
+ import { StoreError, StoreUnavailableError, type ControlStore, type EventStore, type RouteProvider } from './store.js'
4
+ import type { PayloadCodec } from './codec.js'
5
+ import { adapterFor, hasAdapter } from '../ingest/adapters/registry.js'
6
+ import type { ProcessorAdapter, RawRequest } from '../ingest/adapters/types.js'
7
+ import type { Json } from '../mask/paths.js'
8
+ import { RelayHooks, type IngestOutcome } from './hooks.js'
9
+ import { noopLogger, type Logger } from '../logger.js'
10
+ import type { SecretResolver } from './secrets.js'
11
+ import { jsonTopLevelKeys, requestHeaderNames } from '../util/payload-shape.js'
12
+ import { escapeRe } from '../util/strings.js'
13
+
14
+ // The framework-agnostic ingest path: verify → encrypt → persist → ack. A host adapts its
15
+ // HTTP request into a RawRequest and maps the IngestResult back onto its response.
16
+
17
+ // Headers persisted alongside the payload for debugging/replay. Auth headers are
18
+ // deliberately excluded — secrets never reach the database.
19
+ const PERSISTED_HEADERS = ['content-type', 'user-agent']
20
+
21
+ export interface IngestDeps {
22
+ store: EventStore
23
+ control: ControlStore
24
+ routes: RouteProvider
25
+ secrets: SecretResolver
26
+ /** How buffered payloads are stored (encrypted or plain). */
27
+ codec: PayloadCodec
28
+ /** Emits 'event' whenever a new deliverable event lands, so the dispatcher wakes immediately. */
29
+ signal?: EventEmitter
30
+ hooks?: RelayHooks
31
+ logger?: Logger
32
+ /** Additional processor adapters keyed by route `source` (override built-ins by name). */
33
+ adapters?: Record<string, ProcessorAdapter>
34
+ }
35
+
36
+ export type IngestResultOutcome =
37
+ | IngestOutcome
38
+ | 'unknown_route'
39
+ | 'secret_unavailable'
40
+ | 'unknown_source'
41
+ | 'persist_failed'
42
+ | 'unavailable'
43
+
44
+ /** HTTP-shaped so any host can answer the processor without knowing the engine's rules. */
45
+ export interface IngestResult {
46
+ status: 200 | 400 | 401 | 404 | 413 | 500 | 503
47
+ body: { status: string } | { error: string }
48
+ outcome: IngestResultOutcome
49
+ /** Store id of the buffered event; present for `accepted` and `filtered`. Correlate with the `settled` hook. */
50
+ id?: string
51
+ }
52
+
53
+ export function eventTypeMatches(patterns: string[], eventType: string | null): boolean {
54
+ if (patterns.length === 0) return true
55
+ if (eventType === null) return false
56
+ return patterns.some((p) =>
57
+ p.includes('*') ? new RegExp(`^${p.split('*').map(escapeRe).join('.*')}$`).test(eventType) : p === eventType,
58
+ )
59
+ }
60
+
61
+ /** Idempotency key for one inbound event: stable across redeliveries of the same processor event. */
62
+ export function eventIdempotencyKey(source: string, eventId: string): string {
63
+ return 'sha256:' + createHash('sha256').update(`${source}:${eventId}`).digest('hex')
64
+ }
65
+
66
+ export async function ingestWebhook(
67
+ deps: IngestDeps,
68
+ routeId: string,
69
+ raw: RawRequest,
70
+ ): Promise<IngestResult> {
71
+ const hooks = deps.hooks ?? new RelayHooks()
72
+ const logger = deps.logger ?? noopLogger
73
+ try {
74
+ return await ingestInner(deps, routeId, raw, hooks, logger)
75
+ } catch (err) {
76
+ // Any store failure (route lookup, killswitch, insert): 503 when the store says it is
77
+ // temporarily unavailable so the processor retries, 500 otherwise. Never the raw error.
78
+ if (!(err instanceof StoreError)) throw err
79
+ logger.error('ingest store failure', { route: routeId, op: err.op, error: err.message })
80
+ hooks.emit('error', { kind: 'ingest_persist', error: err, routeId, op: err.op })
81
+ if (err instanceof StoreUnavailableError) {
82
+ return { status: 503, body: { error: 'temporarily unavailable' }, outcome: 'unavailable' }
83
+ }
84
+ return { status: 500, body: { error: 'internal error' }, outcome: 'persist_failed' }
85
+ }
86
+ }
87
+
88
+ async function ingestInner(
89
+ deps: IngestDeps,
90
+ routeId: string,
91
+ raw: RawRequest,
92
+ hooks: RelayHooks,
93
+ logger: Logger,
94
+ ): Promise<IngestResult> {
95
+ const { store, control, routes, secrets, codec, signal } = deps
96
+
97
+ const route = await routes.get(routeId)
98
+ if (!route) return { status: 404, body: { error: 'unknown route' }, outcome: 'unknown_route' }
99
+
100
+ const { rawBody } = raw
101
+ const bodyBytes = Buffer.isBuffer(rawBody) ? rawBody.length : 0
102
+ const ingested = (outcome: IngestOutcome, eventType: string | null = null) =>
103
+ hooks.emit('ingest', { routeId, outcome, eventType, bodyBytes })
104
+
105
+ // Panic refuses at the door; the processor's own retries carry the window.
106
+ if ((await control.getKillswitch()) === 'panic') {
107
+ ingested('panic')
108
+ return { status: 503, body: { error: 'relay is in panic mode' }, outcome: 'panic' }
109
+ }
110
+
111
+ if (!Buffer.isBuffer(rawBody) || rawBody.length === 0) {
112
+ ingested('rejected_json')
113
+ return { status: 400, body: { error: 'empty body' }, outcome: 'rejected_json' }
114
+ }
115
+ if (rawBody.length > route.max_body_bytes) {
116
+ ingested('rejected_size')
117
+ return { status: 413, body: { error: 'body too large' }, outcome: 'rejected_size' }
118
+ }
119
+
120
+ if (!hasAdapter(route.source, deps.adapters)) {
121
+ logger.error('ingest route has no adapter', { route: routeId, source: route.source })
122
+ return { status: 500, body: { error: 'internal error' }, outcome: 'unknown_source' }
123
+ }
124
+ const adapter = adapterFor(route.source, deps.adapters)
125
+ const secret = secrets.resolve(route.auth.secret_env)
126
+ if (secret === undefined) {
127
+ logger.error('ingest secret unavailable', { route: routeId, secret_env: route.auth.secret_env })
128
+ return { status: 500, body: { error: 'internal error' }, outcome: 'secret_unavailable' }
129
+ }
130
+ const verdict = adapter.verify(raw, route, { secret })
131
+ if (!verdict.ok) {
132
+ ingested('rejected_auth')
133
+ logger.warn('ingest rejected', { route: routeId, reason: verdict.reason })
134
+ return { status: 401, body: { error: 'verification failed' }, outcome: 'rejected_auth' }
135
+ }
136
+
137
+ let body: Json
138
+ try {
139
+ body = JSON.parse(rawBody.toString('utf8')) as Json
140
+ } catch {
141
+ ingested('rejected_json')
142
+ return { status: 400, body: { error: 'invalid JSON' }, outcome: 'rejected_json' }
143
+ }
144
+
145
+ const eventId = adapter.extractEventId(body, raw, route)
146
+ const eventType = adapter.extractEventType(body, raw, route)
147
+ const filtered = Boolean(route.events && !eventTypeMatches(route.events, eventType))
148
+ // Shape-only debug metadata (no values).
149
+ logger.debug('ingest shape', {
150
+ route: routeId,
151
+ event_type: eventType,
152
+ body_bytes: rawBody.length,
153
+ remote_ip: raw.remoteIp,
154
+ header_names: requestHeaderNames(raw.headers as Record<string, unknown>),
155
+ payload_keys: jsonTopLevelKeys(body),
156
+ })
157
+
158
+ const { payload, iv } = codec.encode(rawBody)
159
+ const headersJson = JSON.stringify(
160
+ Object.fromEntries(
161
+ PERSISTED_HEADERS.map((h) => [h, raw.headers[h]]).filter(([, v]) => v !== undefined),
162
+ ),
163
+ )
164
+
165
+ const inserted = await store.insert({
166
+ route_id: routeId,
167
+ source: route.source,
168
+ event_id: eventId,
169
+ event_type: eventType,
170
+ payload,
171
+ payload_iv: iv,
172
+ headers_json: headersJson,
173
+ received_at: new Date().toISOString(),
174
+ status: filtered ? 'dropped_by_filter' : 'pending',
175
+ idempotency_key: eventIdempotencyKey(route.source, eventId),
176
+ })
177
+
178
+ if (inserted.duplicate) {
179
+ ingested('duplicate', eventType)
180
+ logger.info('duplicate event acked', { route: routeId, event_type: eventType })
181
+ return { status: 200, body: { status: 'duplicate' }, outcome: 'duplicate' }
182
+ }
183
+
184
+ ingested(filtered ? 'filtered' : 'accepted', eventType)
185
+ hooks.emit('stored', { routeId, id: inserted.id, filtered })
186
+ logger.info('event ingested', { route: routeId, event_type: eventType, filtered })
187
+ if (!filtered) signal?.emit('event', routeId)
188
+ return {
189
+ status: 200,
190
+ body: { status: filtered ? 'filtered' : 'accepted' },
191
+ outcome: filtered ? 'filtered' : 'accepted',
192
+ id: inserted.id,
193
+ }
194
+ }
@@ -0,0 +1,232 @@
1
+ import type {
2
+ EventRecord,
3
+ EventStatus,
4
+ EventStore,
5
+ EventStoreAdmin,
6
+ EventSummary,
7
+ InsertResult,
8
+ Lease,
9
+ NewEvent,
10
+ RouteStats,
11
+ } from './store.js'
12
+
13
+ // In-process EventStore for development, tests and single-process experiments. Not
14
+ // durable: everything is lost when the process exits. It is the reference semantics for
15
+ // the store contract (see the contract test suite).
16
+
17
+ const DUE: EventStatus[] = ['pending', 'retry']
18
+ const TERMINAL: EventStatus[] = ['delivered', 'dropped_by_filter']
19
+
20
+ export class MemoryEventStore implements EventStore, EventStoreAdmin {
21
+ private rows = new Map<string, EventRecord>()
22
+ private byKey = new Map<string, string>()
23
+ private nextId = 1
24
+
25
+ async insert(e: NewEvent): Promise<InsertResult> {
26
+ if (this.byKey.has(e.idempotency_key)) return { duplicate: true }
27
+ const id = String(this.nextId++)
28
+ this.rows.set(id, {
29
+ ...e,
30
+ id,
31
+ attempts: 0,
32
+ next_attempt_at: e.received_at,
33
+ delivered_at: null,
34
+ bulk_request_id: null,
35
+ last_error: null,
36
+ claimed_by: null,
37
+ lease_until: null,
38
+ })
39
+ this.byKey.set(e.idempotency_key, id)
40
+ return { duplicate: false, id }
41
+ }
42
+
43
+ async routesWithDueEvents(now: string): Promise<string[]> {
44
+ const out = new Set<string>()
45
+ for (const r of this.rows.values()) if (this.isDue(r, now)) out.add(r.route_id)
46
+ return [...out]
47
+ }
48
+
49
+ async claimDue(routeId: string, now: string, limit: number, lease: Lease): Promise<EventRecord[]> {
50
+ const due = [...this.rows.values()]
51
+ // Map iteration is insertion order, and ids are assigned in insertion order.
52
+ .filter((r) => r.route_id === routeId && this.isDue(r, now))
53
+ .slice(0, limit)
54
+ for (const r of due) {
55
+ r.status = 'delivering'
56
+ r.next_attempt_at = now
57
+ r.claimed_by = lease.owner
58
+ r.lease_until = lease.until
59
+ }
60
+ return due.map((r) => ({ ...r }))
61
+ }
62
+
63
+ async markDelivered(ids: string[], deliveredAt: string, bulkRequestId: string | null): Promise<void> {
64
+ for (const r of this.pick(ids)) {
65
+ r.status = 'delivered'
66
+ r.delivered_at = deliveredAt
67
+ r.bulk_request_id = bulkRequestId
68
+ r.last_error = null
69
+ this.clearLease(r)
70
+ }
71
+ }
72
+
73
+ async markFailed(ids: string[], nextAttemptAt: string, error: string): Promise<void> {
74
+ for (const r of this.pick(ids)) {
75
+ r.status = 'retry'
76
+ r.attempts += 1
77
+ r.next_attempt_at = nextAttemptAt
78
+ r.last_error = error.slice(0, 500)
79
+ this.clearLease(r)
80
+ }
81
+ }
82
+
83
+ async markParked(ids: string[], error: string): Promise<void> {
84
+ for (const r of this.pick(ids)) {
85
+ r.status = 'parked'
86
+ r.last_error = error.slice(0, 500)
87
+ this.clearLease(r)
88
+ }
89
+ }
90
+
91
+ async releaseDelivering(ids: string[], nextAttemptAt: string, error: string): Promise<number> {
92
+ let n = 0
93
+ for (const r of this.pick(ids)) {
94
+ if (r.status !== 'delivering') continue
95
+ r.status = 'retry'
96
+ r.attempts += 1
97
+ r.next_attempt_at = nextAttemptAt
98
+ r.last_error = error.slice(0, 500)
99
+ this.clearLease(r)
100
+ n++
101
+ }
102
+ return n
103
+ }
104
+
105
+ async recoverDelivering(now: string, owner?: string): Promise<number> {
106
+ let n = 0
107
+ for (const r of this.rows.values()) {
108
+ if (r.status !== 'delivering') continue
109
+ const mine = owner !== undefined && r.claimed_by === owner
110
+ if (r.lease_until === null || r.lease_until < now || mine) {
111
+ r.status = 'retry'
112
+ r.next_attempt_at = now
113
+ this.clearLease(r)
114
+ n++
115
+ }
116
+ }
117
+ return n
118
+ }
119
+
120
+ async parkExpired(now: string, maxAgeMs: number): Promise<number> {
121
+ const cutoff = new Date(Date.parse(now) - maxAgeMs).toISOString()
122
+ let n = 0
123
+ for (const r of this.rows.values()) {
124
+ if (r.status === 'retry' && r.received_at < cutoff) {
125
+ r.status = 'parked'
126
+ r.last_error = 'retry window exhausted'
127
+ n++
128
+ }
129
+ }
130
+ return n
131
+ }
132
+
133
+ async pruneBatch(now: string, deliveredDays: number, ledgerDays: number, limit: number) {
134
+ const wipeCutoff = new Date(Date.parse(now) - deliveredDays * 86_400_000).toISOString()
135
+ const deleteCutoff = new Date(Date.parse(now) - ledgerDays * 86_400_000).toISOString()
136
+ const terminal = [...this.rows.values()].filter((r) => TERMINAL.includes(r.status))
137
+ const toWipe = terminal.filter((r) => r.received_at < wipeCutoff && r.payload.length > 0).slice(0, limit)
138
+ if (toWipe.length > 0) {
139
+ for (const r of toWipe) {
140
+ r.payload = Buffer.alloc(0)
141
+ r.payload_iv = null
142
+ r.headers_json = '{}'
143
+ }
144
+ return { wiped: toWipe.length, deleted: 0 }
145
+ }
146
+ const toDelete = terminal.filter((r) => r.received_at < deleteCutoff).slice(0, limit)
147
+ for (const r of toDelete) {
148
+ this.rows.delete(r.id)
149
+ this.byKey.delete(r.idempotency_key)
150
+ }
151
+ return { wiped: 0, deleted: toDelete.length }
152
+ }
153
+
154
+ // ── admin ──
155
+ async getById(id: string): Promise<EventRecord | undefined> {
156
+ const r = this.rows.get(id)
157
+ return r ? { ...r } : undefined
158
+ }
159
+
160
+ async list(filter: { status?: EventStatus; route?: string; limit?: number }): Promise<EventSummary[]> {
161
+ return [...this.rows.values()]
162
+ .filter((r) => (!filter.status || r.status === filter.status) && (!filter.route || r.route_id === filter.route))
163
+ .sort((a, b) => Number(b.id) - Number(a.id))
164
+ .slice(0, filter.limit ?? 50)
165
+ .map(({ payload: _p, payload_iv: _iv, headers_json: _h, idempotency_key: _k, claimed_by: _c, lease_until: _l, ...rest }) => rest)
166
+ }
167
+
168
+ async countByStatus(): Promise<Record<string, number>> {
169
+ const out: Record<string, number> = {}
170
+ for (const r of this.rows.values()) out[r.status] = (out[r.status] ?? 0) + 1
171
+ return out
172
+ }
173
+
174
+ async perRouteStats(): Promise<RouteStats[]> {
175
+ const byRoute = new Map<string, RouteStats>()
176
+ for (const r of this.rows.values()) {
177
+ const s =
178
+ byRoute.get(r.route_id) ??
179
+ ({ route_id: r.route_id, counts: {}, last_delivered_at: null, oldest_pending_at: null } as RouteStats)
180
+ s.counts[r.status] = (s.counts[r.status] ?? 0) + 1
181
+ if (r.delivered_at && (!s.last_delivered_at || r.delivered_at > s.last_delivered_at)) {
182
+ s.last_delivered_at = r.delivered_at
183
+ }
184
+ if (DUE.includes(r.status) && (!s.oldest_pending_at || r.received_at < s.oldest_pending_at)) {
185
+ s.oldest_pending_at = r.received_at
186
+ }
187
+ byRoute.set(r.route_id, s)
188
+ }
189
+ return [...byRoute.values()]
190
+ }
191
+
192
+ async replay(id: string): Promise<boolean> {
193
+ const r = this.rows.get(id)
194
+ if (!r || r.status !== 'parked') return false
195
+ this.requeue(r)
196
+ return true
197
+ }
198
+
199
+ async replayAllParked(): Promise<number> {
200
+ let n = 0
201
+ for (const r of this.rows.values()) {
202
+ if (r.status !== 'parked') continue
203
+ this.requeue(r)
204
+ n++
205
+ }
206
+ return n
207
+ }
208
+
209
+ private requeue(r: EventRecord): void {
210
+ r.status = 'retry'
211
+ r.attempts = 0
212
+ r.next_attempt_at = new Date().toISOString()
213
+ r.last_error = null
214
+ }
215
+
216
+ private isDue(r: EventRecord, now: string): boolean {
217
+ return DUE.includes(r.status) && r.next_attempt_at !== null && r.next_attempt_at <= now
218
+ }
219
+
220
+ private pick(ids: string[]): EventRecord[] {
221
+ return ids.map((id) => this.rows.get(id)).filter((r): r is EventRecord => r !== undefined)
222
+ }
223
+
224
+ private clearLease(r: EventRecord): void {
225
+ r.claimed_by = null
226
+ r.lease_until = null
227
+ }
228
+ }
229
+
230
+ export function memoryStore(): MemoryEventStore {
231
+ return new MemoryEventStore()
232
+ }