@end-close/relay-sqlite 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.
@@ -0,0 +1,335 @@
1
+ import type { Db } from '../db.js'
2
+
3
+ import type { EventStatus, RouteStats } from '@end-close/relay'
4
+
5
+ export type { EventStatus, RouteStats }
6
+
7
+ /** A raw row of the events table. */
8
+ export interface EventRow {
9
+ id: number
10
+ route_id: string
11
+ source: string
12
+ event_id: string
13
+ event_type: string | null
14
+ payload_enc: Buffer
15
+ payload_iv: Buffer
16
+ headers_json: string
17
+ received_at: string
18
+ status: EventStatus
19
+ attempts: number
20
+ next_attempt_at: string | null
21
+ delivered_at: string | null
22
+ bulk_request_id: string | null
23
+ last_error: string | null
24
+ idempotency_key: string
25
+ claimed_by: string | null
26
+ lease_until: string | null
27
+ }
28
+
29
+ export interface InsertEvent {
30
+ route_id: string
31
+ source: string
32
+ event_id: string
33
+ event_type: string | null
34
+ payload_enc: Buffer
35
+ payload_iv: Buffer
36
+ headers_json: string
37
+ received_at: string
38
+ status: EventStatus
39
+ idempotency_key: string
40
+ }
41
+
42
+ export type EventSummary = Omit<
43
+ EventRow,
44
+ 'payload_enc' | 'payload_iv' | 'headers_json' | 'idempotency_key' | 'claimed_by' | 'lease_until'
45
+ >
46
+
47
+ export class EventsRepo {
48
+ constructor(private db: Db) {}
49
+
50
+ /** Returns the new row id, or null if the idempotency key already exists (duplicate delivery). */
51
+ insert(e: InsertEvent): number | null {
52
+ const res = this.db
53
+ .prepare(
54
+ `INSERT INTO events
55
+ (route_id, source, event_id, event_type, payload_enc, payload_iv,
56
+ headers_json, received_at, status, next_attempt_at, idempotency_key)
57
+ VALUES
58
+ (@route_id, @source, @event_id, @event_type, @payload_enc, @payload_iv,
59
+ @headers_json, @received_at, @status, @received_at, @idempotency_key)
60
+ ON CONFLICT (idempotency_key) DO NOTHING`,
61
+ )
62
+ .run(e)
63
+ return res.changes === 0 ? null : Number(res.lastInsertRowid)
64
+ }
65
+
66
+ /** Claim due events for a route, oldest first, mark them 'delivering' and lease them. */
67
+ claimDue(routeId: string, now: string, limit: number, owner: string, leaseUntil: string): EventRow[] {
68
+ const claim = this.db.transaction(() => {
69
+ const rows = this.db
70
+ .prepare(
71
+ `SELECT * FROM events
72
+ WHERE route_id = ? AND status IN ('pending','retry') AND next_attempt_at <= ?
73
+ ORDER BY id ASC LIMIT ?`,
74
+ )
75
+ .all(routeId, now, limit) as EventRow[]
76
+ if (rows.length > 0) {
77
+ const ids = rows.map((r) => r.id)
78
+ this.db
79
+ .prepare(
80
+ `UPDATE events SET status = 'delivering', next_attempt_at = ?, claimed_by = ?, lease_until = ?
81
+ WHERE id IN (${ids.map(() => '?').join(',')})`,
82
+ )
83
+ .run(now, owner, leaseUntil, ...ids)
84
+ for (const r of rows) {
85
+ r.status = 'delivering'
86
+ r.next_attempt_at = now
87
+ r.claimed_by = owner
88
+ r.lease_until = leaseUntil
89
+ }
90
+ }
91
+ return rows
92
+ })
93
+ return claim()
94
+ }
95
+
96
+ markDelivered(ids: number[], deliveredAt: string, bulkRequestId: string | null): void {
97
+ if (ids.length === 0) return
98
+ this.db
99
+ .prepare(
100
+ `UPDATE events SET status = 'delivered', delivered_at = ?, bulk_request_id = ?, last_error = NULL,
101
+ claimed_by = NULL, lease_until = NULL
102
+ WHERE id IN (${ids.map(() => '?').join(',')})`,
103
+ )
104
+ .run(deliveredAt, bulkRequestId, ...ids)
105
+ }
106
+
107
+ markFailed(ids: number[], nextAttemptAt: string, error: string): void {
108
+ if (ids.length === 0) return
109
+ this.db
110
+ .prepare(
111
+ `UPDATE events SET status = 'retry', attempts = attempts + 1,
112
+ next_attempt_at = ?, last_error = ?, claimed_by = NULL, lease_until = NULL
113
+ WHERE id IN (${ids.map(() => '?').join(',')})`,
114
+ )
115
+ .run(nextAttemptAt, error.slice(0, 500), ...ids)
116
+ }
117
+
118
+ markParked(ids: number[], error: string): void {
119
+ if (ids.length === 0) return
120
+ this.db
121
+ .prepare(
122
+ `UPDATE events SET status = 'parked', last_error = ?, claimed_by = NULL, lease_until = NULL
123
+ WHERE id IN (${ids.map(() => '?').join(',')})`,
124
+ )
125
+ .run(error.slice(0, 500), ...ids)
126
+ }
127
+
128
+ /**
129
+ * Rows stuck in 'delivering' whose lease expired (or that belong to `owner`, or that
130
+ * predate leases) go back to 'retry'.
131
+ */
132
+ recoverDelivering(now: string, owner?: string): number {
133
+ return this.db
134
+ .prepare(
135
+ `UPDATE events SET status = 'retry', next_attempt_at = ?, claimed_by = NULL, lease_until = NULL
136
+ WHERE status = 'delivering'
137
+ AND (lease_until IS NULL OR lease_until < ? OR claimed_by = ?)`,
138
+ )
139
+ .run(now, now, owner ?? null).changes
140
+ }
141
+
142
+ /**
143
+ * Return leftover 'delivering' rows to 'retry' without touching already-settled rows.
144
+ * Used when a dispatch batch throws after claimDue. Increments attempts so a persist
145
+ * failure after a successful POST backs off instead of tight-looping.
146
+ */
147
+ releaseDelivering(ids: number[], nextAttemptAt: string, error: string): number {
148
+ if (ids.length === 0) return 0
149
+ return this.db
150
+ .prepare(
151
+ `UPDATE events SET status = 'retry', attempts = attempts + 1,
152
+ next_attempt_at = ?, last_error = ?, claimed_by = NULL, lease_until = NULL
153
+ WHERE status = 'delivering' AND id IN (${ids.map(() => '?').join(',')})`,
154
+ )
155
+ .run(nextAttemptAt, error.slice(0, 500), ...ids).changes
156
+ }
157
+
158
+ routesWithDueEvents(now: string): string[] {
159
+ return (
160
+ this.db
161
+ .prepare(
162
+ `SELECT DISTINCT route_id FROM events
163
+ WHERE status IN ('pending','retry') AND next_attempt_at <= ?`,
164
+ )
165
+ .all(now) as { route_id: string }[]
166
+ ).map((r) => r.route_id)
167
+ }
168
+
169
+ countByStatus(): Record<string, number> {
170
+ const rows = this.db
171
+ .prepare('SELECT status, COUNT(*) AS n FROM events GROUP BY status')
172
+ .all() as { status: string; n: number }[]
173
+ return Object.fromEntries(rows.map((r) => [r.status, r.n]))
174
+ }
175
+
176
+ getById(id: number): EventRow | undefined {
177
+ return this.db.prepare('SELECT * FROM events WHERE id = ?').get(id) as EventRow | undefined
178
+ }
179
+
180
+ /** Park events that have been retrying longer than maxAgeMs (never silently dropped). */
181
+ parkExpired(now: string, maxAgeMs: number): number {
182
+ const cutoff = new Date(Date.parse(now) - maxAgeMs).toISOString()
183
+ return this.db
184
+ .prepare(
185
+ `UPDATE events SET status = 'parked', last_error = 'retry window exhausted'
186
+ WHERE status = 'retry' AND received_at < ?`,
187
+ )
188
+ .run(cutoff).changes
189
+ }
190
+
191
+ perRouteStats(): RouteStats[] {
192
+ const counts = this.db
193
+ .prepare('SELECT route_id, status, COUNT(*) AS n FROM events GROUP BY route_id, status')
194
+ .all() as { route_id: string; status: EventStatus; n: number }[]
195
+ const extremes = this.db
196
+ .prepare(
197
+ `SELECT route_id,
198
+ MAX(delivered_at) AS last_delivered_at,
199
+ MIN(CASE WHEN status IN ('pending','retry') THEN received_at END) AS oldest_pending_at
200
+ FROM events GROUP BY route_id`,
201
+ )
202
+ .all() as {
203
+ route_id: string
204
+ last_delivered_at: string | null
205
+ oldest_pending_at: string | null
206
+ }[]
207
+ const byRoute = new Map<string, RouteStats>()
208
+ for (const row of counts) {
209
+ const stats =
210
+ byRoute.get(row.route_id) ??
211
+ ({ route_id: row.route_id, counts: {}, last_delivered_at: null, oldest_pending_at: null } as RouteStats)
212
+ stats.counts[row.status] = row.n
213
+ byRoute.set(row.route_id, stats)
214
+ }
215
+ for (const row of extremes) {
216
+ const stats = byRoute.get(row.route_id)
217
+ if (stats) {
218
+ stats.last_delivered_at = row.last_delivered_at
219
+ stats.oldest_pending_at = row.oldest_pending_at
220
+ }
221
+ }
222
+ return [...byRoute.values()]
223
+ }
224
+
225
+ /** Payload-free event listing for the admin plane. */
226
+ list(filter: { status?: EventStatus; route?: string; limit?: number }): EventSummary[] {
227
+ const clauses: string[] = []
228
+ const params: unknown[] = []
229
+ if (filter.status) {
230
+ clauses.push('status = ?')
231
+ params.push(filter.status)
232
+ }
233
+ if (filter.route) {
234
+ clauses.push('route_id = ?')
235
+ params.push(filter.route)
236
+ }
237
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''
238
+ return this.db
239
+ .prepare(
240
+ `SELECT id, route_id, source, event_id, event_type, received_at, status,
241
+ attempts, next_attempt_at, delivered_at, bulk_request_id, last_error
242
+ FROM events ${where} ORDER BY id DESC LIMIT ?`,
243
+ )
244
+ .all(...params, filter.limit ?? 50) as EventSummary[]
245
+ }
246
+
247
+ /** Re-queue a parked event. Attempts reset so backoff starts fresh. */
248
+ replay(id: number): boolean {
249
+ return (
250
+ this.db
251
+ .prepare(
252
+ `UPDATE events SET status = 'retry', attempts = 0, next_attempt_at = ?, last_error = NULL
253
+ WHERE id = ? AND status = 'parked'`,
254
+ )
255
+ .run(new Date().toISOString(), id).changes === 1
256
+ )
257
+ }
258
+
259
+ replayAllParked(): number {
260
+ return this.db
261
+ .prepare(
262
+ `UPDATE events SET status = 'retry', attempts = 0, next_attempt_at = ?, last_error = NULL
263
+ WHERE status = 'parked'`,
264
+ )
265
+ .run(new Date().toISOString()).changes
266
+ }
267
+
268
+ /**
269
+ * Retention. Terminal events (delivered / dropped_by_filter) lose their payload after
270
+ * `deliveredDays` (row kept as the idempotency ledger) and the row itself after
271
+ * `ledgerDays`. Parked events are never touched — they are unresolved by definition.
272
+ */
273
+ prune(now: string, deliveredDays: number, ledgerDays: number): { wiped: number; deleted: number } {
274
+ let wiped = 0
275
+ let deleted = 0
276
+ for (;;) {
277
+ const batch = this.pruneBatch(now, deliveredDays, ledgerDays, 500)
278
+ wiped += batch.wiped
279
+ deleted += batch.deleted
280
+ if (batch.wiped === 0 && batch.deleted === 0) break
281
+ }
282
+ return { wiped, deleted }
283
+ }
284
+
285
+ /**
286
+ * One short exclusive lock: wipe a limited set of expired payloads, or delete a
287
+ * limited set of expired ledger rows, not both. Caller loops (and yields) so ingest
288
+ * and dispatch can run between batches on EFS.
289
+ */
290
+ pruneBatch(
291
+ now: string,
292
+ deliveredDays: number,
293
+ ledgerDays: number,
294
+ limit: number,
295
+ ): { wiped: number; deleted: number } {
296
+ const wipeCutoff = new Date(Date.parse(now) - deliveredDays * 86_400_000).toISOString()
297
+ const deleteCutoff = new Date(Date.parse(now) - ledgerDays * 86_400_000).toISOString()
298
+ const wipeIds = (
299
+ this.db
300
+ .prepare(
301
+ `SELECT id FROM events
302
+ WHERE status IN ('delivered','dropped_by_filter')
303
+ AND received_at < ? AND length(payload_enc) > 0
304
+ LIMIT ?`,
305
+ )
306
+ .all(wipeCutoff, limit) as { id: number }[]
307
+ ).map((r) => r.id)
308
+ if (wipeIds.length > 0) {
309
+ const wiped = this.db
310
+ .prepare(
311
+ `UPDATE events SET payload_enc = x'', payload_iv = x'', headers_json = '{}'
312
+ WHERE id IN (${wipeIds.map(() => '?').join(',')})`,
313
+ )
314
+ .run(...wipeIds).changes
315
+ return { wiped, deleted: 0 }
316
+ }
317
+ const deleteIds = (
318
+ this.db
319
+ .prepare(
320
+ `SELECT id FROM events
321
+ WHERE status IN ('delivered','dropped_by_filter') AND received_at < ?
322
+ LIMIT ?`,
323
+ )
324
+ .all(deleteCutoff, limit) as { id: number }[]
325
+ ).map((r) => r.id)
326
+ if (deleteIds.length === 0) return { wiped: 0, deleted: 0 }
327
+ const deleted = this.db
328
+ .prepare(`DELETE FROM events WHERE id IN (${deleteIds.map(() => '?').join(',')})`)
329
+ .run(...deleteIds).changes
330
+ return { wiped: 0, deleted }
331
+ }
332
+ }
333
+
334
+
335
+
package/src/repo/kv.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { Killswitch } from '@end-close/relay'
2
+ import type { Db } from '../db.js'
3
+
4
+ export type GlobalKillswitch = Killswitch
5
+
6
+ /** kv key prefix for per-route pause flags: `route_paused.<routeId>` = '1'. */
7
+ export const ROUTE_PAUSED_PREFIX = 'route_paused.'
8
+
9
+ export class KvRepo {
10
+ constructor(private db: Db) {}
11
+
12
+ get(key: string): string | undefined {
13
+ const row = this.db.prepare('SELECT value FROM kv WHERE key = ?').get(key) as
14
+ | { value: string }
15
+ | undefined
16
+ return row?.value
17
+ }
18
+
19
+ set(key: string, value: string): void {
20
+ this.db
21
+ .prepare(
22
+ `INSERT INTO kv (key, value, updated_at) VALUES (?, ?, ?)
23
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
24
+ )
25
+ .run(key, value, new Date().toISOString())
26
+ }
27
+
28
+ delete(key: string): void {
29
+ this.db.prepare('DELETE FROM kv WHERE key = ?').run(key)
30
+ }
31
+
32
+ globalKillswitch(): GlobalKillswitch {
33
+ const v = this.get('killswitch.global')
34
+ return v === 'pause' || v === 'panic' ? v : 'none'
35
+ }
36
+
37
+ setGlobalKillswitch(state: GlobalKillswitch): void {
38
+ this.set('killswitch.global', state)
39
+ }
40
+
41
+ isRoutePaused(routeId: string): boolean {
42
+ return this.get(ROUTE_PAUSED_PREFIX + routeId) === '1'
43
+ }
44
+
45
+ setRoutePaused(routeId: string, paused: boolean): void {
46
+ if (paused) this.set(ROUTE_PAUSED_PREFIX + routeId, '1')
47
+ else this.delete(ROUTE_PAUSED_PREFIX + routeId)
48
+ }
49
+
50
+ /** Every paused route id in one query. */
51
+ pausedRoutes(): Set<string> {
52
+ const rows = this.db
53
+ .prepare(`SELECT key FROM kv WHERE key LIKE ? AND value = '1'`)
54
+ .all(ROUTE_PAUSED_PREFIX + '%') as { key: string }[]
55
+ return new Set(rows.map((r) => r.key.slice(ROUTE_PAUSED_PREFIX.length)))
56
+ }
57
+ }
package/src/store.ts ADDED
@@ -0,0 +1,156 @@
1
+ import type { Db } from './db.js'
2
+ import { EventsRepo, type EventRow } from './repo/events.js'
3
+ import { KvRepo } from './repo/kv.js'
4
+ import { BUSY_RETRY_ATTEMPTS, INGEST_BUSY_RETRY_ATTEMPTS, isSqliteBusy, withBusyRetry } from './busy.js'
5
+ import {
6
+ noopLogger,
7
+ StoreError,
8
+ StoreUnavailableError,
9
+ type ControlStore,
10
+ type Logger,
11
+ type EventRecord,
12
+ type EventStatus,
13
+ type EventStore,
14
+ type EventStoreAdmin,
15
+ type EventSummary,
16
+ type InsertResult,
17
+ type Killswitch,
18
+ type Lease,
19
+ type NewEvent,
20
+ type RouteStats,
21
+ } from '@end-close/relay'
22
+
23
+ // SQLite implementations of the engine's storage contracts. Lock contention (SQLITE_BUSY,
24
+ // common on network filesystems such as EFS) is retried here and surfaced as
25
+ // StoreUnavailableError; every other failure becomes a StoreError carrying the operation
26
+ // name, with the original error as `cause`.
27
+
28
+ /** Run one synchronous better-sqlite3 call with busy retry and error classification. */
29
+ export async function runSqlite<T>(
30
+ op: string,
31
+ fn: () => T,
32
+ opts: { attempts?: number; logger?: Logger } = {},
33
+ ): Promise<T> {
34
+ try {
35
+ return await withBusyRetry(op, fn, { attempts: opts.attempts ?? BUSY_RETRY_ATTEMPTS, logger: opts.logger ?? noopLogger })
36
+ } catch (err) {
37
+ if (isSqliteBusy(err)) throw new StoreUnavailableError((err as Error).message, op, { cause: err })
38
+ if (err instanceof StoreError) throw err
39
+ throw new StoreError((err as Error).message, op, { cause: err })
40
+ }
41
+ }
42
+
43
+ function toRecord(row: EventRow): EventRecord {
44
+ const { payload_enc, payload_iv, ...rest } = row
45
+ return {
46
+ ...rest,
47
+ id: String(row.id),
48
+ payload: payload_enc,
49
+ payload_iv: payload_iv.length === 0 ? null : payload_iv,
50
+ }
51
+ }
52
+
53
+ function toSummary<T extends { id: number }>(row: T): Omit<T, 'id'> & { id: string } {
54
+ return { ...row, id: String(row.id) }
55
+ }
56
+
57
+ const numericIds = (ids: string[]) => ids.map(Number)
58
+
59
+ export class SqliteEventStore implements EventStore, EventStoreAdmin {
60
+ readonly repo: EventsRepo
61
+ private log: Logger
62
+
63
+ constructor(db: Db, opts: { logger?: Logger } = {}) {
64
+ this.repo = new EventsRepo(db)
65
+ this.log = opts.logger ?? noopLogger
66
+ }
67
+
68
+ private run<T>(op: string, fn: () => T, attempts?: number): Promise<T> {
69
+ return runSqlite(op, fn, { logger: this.log, ...(attempts !== undefined ? { attempts } : {}) })
70
+ }
71
+
72
+ async insert(e: NewEvent): Promise<InsertResult> {
73
+ const { payload, payload_iv, ...rest } = e
74
+ const row = { ...rest, payload_enc: payload, payload_iv: payload_iv ?? Buffer.alloc(0) }
75
+ const id = await this.run('insert', () => this.repo.insert(row), INGEST_BUSY_RETRY_ATTEMPTS)
76
+ return id === null ? { duplicate: true } : { duplicate: false, id: String(id) }
77
+ }
78
+ routesWithDueEvents(now: string): Promise<string[]> {
79
+ return this.run('routesWithDueEvents', () => this.repo.routesWithDueEvents(now))
80
+ }
81
+ async claimDue(routeId: string, now: string, limit: number, lease: Lease): Promise<EventRecord[]> {
82
+ const rows = await this.run('claimDue', () =>
83
+ this.repo.claimDue(routeId, now, limit, lease.owner, lease.until),
84
+ )
85
+ return rows.map(toRecord)
86
+ }
87
+ markDelivered(ids: string[], deliveredAt: string, bulkRequestId: string | null): Promise<void> {
88
+ return this.run('markDelivered', () =>
89
+ this.repo.markDelivered(numericIds(ids), deliveredAt, bulkRequestId),
90
+ )
91
+ }
92
+ markFailed(ids: string[], nextAttemptAt: string, error: string): Promise<void> {
93
+ return this.run('markFailed', () => this.repo.markFailed(numericIds(ids), nextAttemptAt, error))
94
+ }
95
+ markParked(ids: string[], error: string): Promise<void> {
96
+ return this.run('markParked', () => this.repo.markParked(numericIds(ids), error))
97
+ }
98
+ releaseDelivering(ids: string[], nextAttemptAt: string, error: string): Promise<number> {
99
+ return this.run('releaseDelivering', () =>
100
+ this.repo.releaseDelivering(numericIds(ids), nextAttemptAt, error),
101
+ )
102
+ }
103
+ recoverDelivering(now: string, owner?: string): Promise<number> {
104
+ return this.run('recoverDelivering', () => this.repo.recoverDelivering(now, owner))
105
+ }
106
+ parkExpired(now: string, maxAgeMs: number): Promise<number> {
107
+ return this.run('parkExpired', () => this.repo.parkExpired(now, maxAgeMs))
108
+ }
109
+ pruneBatch(now: string, deliveredDays: number, ledgerDays: number, limit: number) {
110
+ return this.run('prune', () => this.repo.pruneBatch(now, deliveredDays, ledgerDays, limit))
111
+ }
112
+
113
+ // ── admin capability ──
114
+ async getById(id: string): Promise<EventRecord | undefined> {
115
+ const row = await this.run('getById', () => this.repo.getById(Number(id)))
116
+ return row ? toRecord(row) : undefined
117
+ }
118
+ async list(filter: { status?: EventStatus; route?: string; limit?: number }): Promise<EventSummary[]> {
119
+ const rows = await this.run('list', () => this.repo.list(filter))
120
+ return rows.map(toSummary)
121
+ }
122
+ countByStatus(): Promise<Record<string, number>> {
123
+ return this.run('countByStatus', () => this.repo.countByStatus())
124
+ }
125
+ perRouteStats(): Promise<RouteStats[]> {
126
+ return this.run('perRouteStats', () => this.repo.perRouteStats())
127
+ }
128
+ replay(id: string): Promise<boolean> {
129
+ return this.run('replay', () => this.repo.replay(Number(id)))
130
+ }
131
+ replayAllParked(): Promise<number> {
132
+ return this.run('replayAllParked', () => this.repo.replayAllParked())
133
+ }
134
+ }
135
+
136
+ export class SqliteControlStore implements ControlStore {
137
+ readonly kv: KvRepo
138
+ private log: Logger
139
+ constructor(db: Db, opts: { logger?: Logger } = {}) {
140
+ this.kv = new KvRepo(db)
141
+ this.log = opts.logger ?? noopLogger
142
+ }
143
+ getKillswitch(): Promise<Killswitch> {
144
+ return runSqlite('killswitch', () => this.kv.globalKillswitch(), { logger: this.log })
145
+ }
146
+ setKillswitch(state: Killswitch): Promise<void> {
147
+ return runSqlite('setKillswitch', () => this.kv.setGlobalKillswitch(state), { logger: this.log })
148
+ }
149
+ isRoutePaused(routeId: string): Promise<boolean> {
150
+ return runSqlite('isPaused', () => this.kv.isRoutePaused(routeId), { logger: this.log })
151
+ }
152
+ setRoutePaused(routeId: string, paused: boolean): Promise<void> {
153
+ return runSqlite('setPaused', () => this.kv.setRoutePaused(routeId, paused), { logger: this.log })
154
+ }
155
+ }
156
+