@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.
- package/COMPATIBILITY.md +39 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/dist/config/schema.d.ts +993 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +156 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/crypto/at-rest.d.ts +6 -0
- package/dist/crypto/at-rest.d.ts.map +1 -0
- package/dist/crypto/at-rest.js +19 -0
- package/dist/crypto/at-rest.js.map +1 -0
- package/dist/crypto/keys.d.ts +2 -0
- package/dist/crypto/keys.d.ts.map +1 -0
- package/dist/crypto/keys.js +12 -0
- package/dist/crypto/keys.js.map +1 -0
- package/dist/engine/codec.d.ts +12 -0
- package/dist/engine/codec.d.ts.map +1 -0
- package/dist/engine/codec.js +21 -0
- package/dist/engine/codec.js.map +1 -0
- package/dist/engine/hooks.d.ts +84 -0
- package/dist/engine/hooks.d.ts.map +1 -0
- package/dist/engine/hooks.js +34 -0
- package/dist/engine/hooks.js.map +1 -0
- package/dist/engine/ingest.d.ts +39 -0
- package/dist/engine/ingest.d.ts.map +1 -0
- package/dist/engine/ingest.js +131 -0
- package/dist/engine/ingest.js.map +1 -0
- package/dist/engine/memory-store.d.ts +35 -0
- package/dist/engine/memory-store.d.ts.map +1 -0
- package/dist/engine/memory-store.js +205 -0
- package/dist/engine/memory-store.js.map +1 -0
- package/dist/engine/relay.d.ts +116 -0
- package/dist/engine/relay.d.ts.map +1 -0
- package/dist/engine/relay.js +180 -0
- package/dist/engine/relay.js.map +1 -0
- package/dist/engine/secrets.d.ts +18 -0
- package/dist/engine/secrets.d.ts.map +1 -0
- package/dist/engine/secrets.js +42 -0
- package/dist/engine/secrets.js.map +1 -0
- package/dist/engine/settings.d.ts +29 -0
- package/dist/engine/settings.d.ts.map +1 -0
- package/dist/engine/settings.js +13 -0
- package/dist/engine/settings.js.map +1 -0
- package/dist/engine/store.d.ts +141 -0
- package/dist/engine/store.d.ts.map +1 -0
- package/dist/engine/store.js +52 -0
- package/dist/engine/store.js.map +1 -0
- package/dist/forward/backoff.d.ts +3 -0
- package/dist/forward/backoff.d.ts.map +1 -0
- package/dist/forward/backoff.js +10 -0
- package/dist/forward/backoff.js.map +1 -0
- package/dist/forward/dispatcher.d.ts +98 -0
- package/dist/forward/dispatcher.d.ts.map +1 -0
- package/dist/forward/dispatcher.js +444 -0
- package/dist/forward/dispatcher.js.map +1 -0
- package/dist/forward/endclose-client.d.ts +42 -0
- package/dist/forward/endclose-client.d.ts.map +1 -0
- package/dist/forward/endclose-client.js +76 -0
- package/dist/forward/endclose-client.js.map +1 -0
- package/dist/forward/enrich.d.ts +39 -0
- package/dist/forward/enrich.d.ts.map +1 -0
- package/dist/forward/enrich.js +77 -0
- package/dist/forward/enrich.js.map +1 -0
- package/dist/forward/mapper.d.ts +50 -0
- package/dist/forward/mapper.d.ts.map +1 -0
- package/dist/forward/mapper.js +141 -0
- package/dist/forward/mapper.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest/adapters/generic-hmac.d.ts +3 -0
- package/dist/ingest/adapters/generic-hmac.d.ts.map +1 -0
- package/dist/ingest/adapters/generic-hmac.js +60 -0
- package/dist/ingest/adapters/generic-hmac.js.map +1 -0
- package/dist/ingest/adapters/payabli.d.ts +3 -0
- package/dist/ingest/adapters/payabli.d.ts.map +1 -0
- package/dist/ingest/adapters/payabli.js +57 -0
- package/dist/ingest/adapters/payabli.js.map +1 -0
- package/dist/ingest/adapters/registry.d.ts +4 -0
- package/dist/ingest/adapters/registry.d.ts.map +1 -0
- package/dist/ingest/adapters/registry.js +16 -0
- package/dist/ingest/adapters/registry.js.map +1 -0
- package/dist/ingest/adapters/types.d.ts +28 -0
- package/dist/ingest/adapters/types.d.ts.map +1 -0
- package/dist/ingest/adapters/types.js +5 -0
- package/dist/ingest/adapters/types.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -0
- package/dist/mask/defaults.d.ts +7 -0
- package/dist/mask/defaults.d.ts.map +1 -0
- package/dist/mask/defaults.js +51 -0
- package/dist/mask/defaults.js.map +1 -0
- package/dist/mask/paths.d.ts +7 -0
- package/dist/mask/paths.d.ts.map +1 -0
- package/dist/mask/paths.js +36 -0
- package/dist/mask/paths.js.map +1 -0
- package/dist/util/payload-shape.d.ts +6 -0
- package/dist/util/payload-shape.d.ts.map +1 -0
- package/dist/util/payload-shape.js +19 -0
- package/dist/util/payload-shape.js.map +1 -0
- package/dist/util/strings.d.ts +3 -0
- package/dist/util/strings.d.ts.map +1 -0
- package/dist/util/strings.js +7 -0
- package/dist/util/strings.js.map +1 -0
- package/package.json +57 -0
- package/src/config/schema.ts +177 -0
- package/src/crypto/at-rest.ts +21 -0
- package/src/crypto/keys.ts +13 -0
- package/src/engine/codec.ts +30 -0
- package/src/engine/hooks.ts +76 -0
- package/src/engine/ingest.ts +194 -0
- package/src/engine/memory-store.ts +232 -0
- package/src/engine/relay.ts +294 -0
- package/src/engine/secrets.ts +51 -0
- package/src/engine/settings.ts +42 -0
- package/src/engine/store.ts +188 -0
- package/src/forward/backoff.ts +10 -0
- package/src/forward/dispatcher.ts +504 -0
- package/src/forward/endclose-client.ts +102 -0
- package/src/forward/enrich.ts +106 -0
- package/src/forward/mapper.ts +191 -0
- package/src/index.ts +47 -0
- package/src/ingest/adapters/generic-hmac.ts +70 -0
- package/src/ingest/adapters/payabli.ts +67 -0
- package/src/ingest/adapters/registry.ts +21 -0
- package/src/ingest/adapters/types.ts +33 -0
- package/src/logger.ts +26 -0
- package/src/mask/defaults.ts +56 -0
- package/src/mask/paths.ts +36 -0
- package/src/util/payload-shape.ts +19 -0
- 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
|
+
}
|