@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.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/busy.d.ts +13 -0
- package/dist/busy.d.ts.map +1 -0
- package/dist/busy.js +45 -0
- package/dist/busy.js.map +1 -0
- package/dist/db.d.ts +4 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +20 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations.d.ts +8 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +62 -0
- package/dist/migrations.js.map +1 -0
- package/dist/repo/events.d.ts +93 -0
- package/dist/repo/events.d.ts.map +1 -0
- package/dist/repo/events.js +227 -0
- package/dist/repo/events.js.map +1 -0
- package/dist/repo/kv.d.ts +19 -0
- package/dist/repo/kv.d.ts.map +1 -0
- package/dist/repo/kv.js +45 -0
- package/dist/repo/kv.js.map +1 -0
- package/dist/store.d.ts +52 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +121 -0
- package/dist/store.js.map +1 -0
- package/package.json +60 -0
- package/src/busy.ts +58 -0
- package/src/db.ts +21 -0
- package/src/index.ts +7 -0
- package/src/migrations.ts +80 -0
- package/src/repo/events.ts +335 -0
- package/src/repo/kv.ts +57 -0
- package/src/store.ts +156 -0
|
@@ -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
|
+
|