@meith/drivers 0.16.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,218 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import { and, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm'
4
+
5
+ import { type EnqueueOptions, type Job, logger, type QueueDriver } from '@meith/core'
6
+ import { type Database, getDb, jobs, resultRows } from '@meith/db'
7
+
8
+ const LEASE_SECONDS = 300
9
+
10
+ function backoffSeconds(attempt: number): number {
11
+ return Math.min(10 * attempt * attempt, 3600)
12
+ }
13
+
14
+ export class PostgresQueue implements QueueDriver {
15
+ private readonly workerId = `${process.pid}-${randomUUID().slice(0, 8)}`
16
+
17
+ constructor(private readonly db: Database = getDb()) {}
18
+
19
+ async enqueue<TPayload>(
20
+ kind: string,
21
+ payload: TPayload,
22
+ options: EnqueueOptions = {},
23
+ ): Promise<{ id: string; deduplicated: boolean }> {
24
+ const row = {
25
+ kind,
26
+ payload: payload as never,
27
+ runAt: options.runAt ?? new Date(),
28
+ maxAttempts: options.maxAttempts ?? 5,
29
+ idempotencyKey: options.dedupeKey ?? null,
30
+ }
31
+
32
+ if (options.dedupeKey) {
33
+ const inserted = await this.db
34
+ .insert(jobs)
35
+ .values(row)
36
+ .onConflictDoNothing()
37
+ .returning({ id: jobs.id })
38
+
39
+ const first = inserted[0]
40
+ if (!first) {
41
+ const existing = await this.db
42
+ .select({ id: jobs.id })
43
+ .from(jobs)
44
+ .where(and(eq(jobs.idempotencyKey, options.dedupeKey), eq(jobs.status, 'pending')))
45
+ .limit(1)
46
+
47
+ return { id: String(existing[0]?.id ?? ''), deduplicated: true }
48
+ }
49
+ return { id: String(first.id), deduplicated: false }
50
+ }
51
+
52
+ const inserted = await this.db.insert(jobs).values(row).returning({ id: jobs.id })
53
+ return { id: String(inserted[0]!.id), deduplicated: false }
54
+ }
55
+
56
+ async drain(
57
+ limit: number,
58
+ handler: (job: Job) => Promise<void>,
59
+ options: { readonly signal?: AbortSignal } = {},
60
+ ): Promise<{ processed: number; failed: number }> {
61
+ const claimed = await this.claim(limit)
62
+
63
+ let processed = 0
64
+ let failed = 0
65
+
66
+ for (const [index, job] of claimed.entries()) {
67
+ if (options.signal?.aborted === true) {
68
+ await this.unclaim(claimed.slice(index))
69
+ break
70
+ }
71
+
72
+ try {
73
+ await handler(job)
74
+ await this.db
75
+ .update(jobs)
76
+ .set({ status: 'done', completedAt: new Date(), lockedUntil: null, lockedBy: null })
77
+ .where(eq(jobs.id, Number(job.id)))
78
+ processed += 1
79
+ } catch (error) {
80
+ failed += 1
81
+ await this.recordFailure(job, error)
82
+ }
83
+ }
84
+
85
+ return { processed, failed }
86
+ }
87
+
88
+ private async claim(limit: number): Promise<Job[]> {
89
+ const now = new Date()
90
+ const leaseUntil = new Date(now.getTime() + LEASE_SECONDS * 1000)
91
+
92
+ const rows = await this.db.execute(sql`
93
+ UPDATE ${jobs}
94
+ SET status = 'running',
95
+ attempts = ${jobs.attempts} + 1,
96
+ locked_until = ${leaseUntil},
97
+ locked_by = ${this.workerId}
98
+ WHERE id IN (
99
+ SELECT id
100
+ FROM ${jobs}
101
+ WHERE run_at <= ${now}
102
+ AND (
103
+ status = 'pending'
104
+ -- Reclaim rows whose lease expired: the worker holding them died.
105
+ OR (status = 'running' AND locked_until < ${now})
106
+ )
107
+ AND attempts < max_attempts
108
+ ORDER BY run_at
109
+ LIMIT ${limit}
110
+ FOR UPDATE SKIP LOCKED
111
+ )
112
+ RETURNING id, kind, payload, attempts, correlation_id
113
+ `)
114
+
115
+ return resultRows(rows).map((r) => {
116
+ const job: Job = {
117
+ id: String(r.id),
118
+ kind: String(r.kind),
119
+ payload: r.payload,
120
+ attempt: Number(r.attempts),
121
+ }
122
+ const correlationId = r.correlation_id
123
+ return typeof correlationId === 'string' ? { ...job, requestId: correlationId } : job
124
+ })
125
+ }
126
+
127
+ private async unclaim(jobs_: readonly Job[]): Promise<void> {
128
+ if (jobs_.length === 0) return
129
+
130
+ const ids = jobs_.map((job) => Number(job.id))
131
+
132
+ await this.db
133
+ .update(jobs)
134
+ .set({
135
+ status: 'pending',
136
+ attempts: sql`${jobs.attempts} - 1`,
137
+ lockedUntil: null,
138
+ lockedBy: null,
139
+ })
140
+ .where(inArray(jobs.id, ids))
141
+ }
142
+
143
+ private async recordFailure(job: Job, error: unknown): Promise<void> {
144
+ const message = error instanceof Error ? error.message : String(error)
145
+ const exhausted = await this.isExhausted(job)
146
+
147
+ await this.db
148
+ .update(jobs)
149
+ .set({
150
+ status: exhausted ? 'dead' : 'pending',
151
+ lastError: message.slice(0, 2000),
152
+ runAt: new Date(Date.now() + backoffSeconds(job.attempt) * 1000),
153
+ lockedUntil: null,
154
+ lockedBy: null,
155
+ })
156
+ .where(eq(jobs.id, Number(job.id)))
157
+
158
+ logger({ jobId: job.id, kind: job.kind, attempt: job.attempt }).error(
159
+ { err: message },
160
+ exhausted ? 'job dead-lettered' : 'job failed, will retry',
161
+ )
162
+ }
163
+
164
+ private async isExhausted(job: Job): Promise<boolean> {
165
+ const rows = await this.db
166
+ .select({ attempts: jobs.attempts, maxAttempts: jobs.maxAttempts })
167
+ .from(jobs)
168
+ .where(eq(jobs.id, Number(job.id)))
169
+ .limit(1)
170
+
171
+ const row = rows[0]
172
+ return row ? row.attempts >= row.maxAttempts : true
173
+ }
174
+
175
+ async deadLettered(limit: number): Promise<readonly Job[]> {
176
+ const rows = await this.db
177
+ .select({
178
+ id: jobs.id,
179
+ kind: jobs.kind,
180
+ payload: jobs.payload,
181
+ attempts: jobs.attempts,
182
+ })
183
+ .from(jobs)
184
+ .where(eq(jobs.status, 'dead'))
185
+ .limit(limit)
186
+
187
+ return rows.map((r) => ({
188
+ id: String(r.id),
189
+ kind: r.kind,
190
+ payload: r.payload,
191
+ attempt: r.attempts,
192
+ }))
193
+ }
194
+
195
+ async retry(jobId: string): Promise<boolean> {
196
+ const id = Number(jobId)
197
+ if (!Number.isSafeInteger(id)) return false
198
+
199
+ const requeued = await this.db
200
+ .update(jobs)
201
+ .set({
202
+ status: 'pending',
203
+ attempts: 0,
204
+ runAt: new Date(),
205
+ lastError: null,
206
+ lockedUntil: null,
207
+ lockedBy: null,
208
+ })
209
+ .where(and(eq(jobs.id, id), eq(jobs.status, 'dead')))
210
+ .returning({ id: jobs.id })
211
+
212
+ return requeued.length > 0
213
+ }
214
+
215
+ static completedBefore(cutoff: Date) {
216
+ return and(eq(jobs.status, 'done'), or(isNull(jobs.completedAt), lt(jobs.completedAt, cutoff)))
217
+ }
218
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,89 @@
1
+ import {
2
+ type CacheDriver,
3
+ ConfigurationError,
4
+ type Drivers,
5
+ env,
6
+ type FileStore,
7
+ type MailDriver,
8
+ type QueueDriver,
9
+ } from '@meith/core'
10
+ import { getDb, PostgresSettingsRepository } from '@meith/db'
11
+ import {
12
+ type MailConfig,
13
+ mailConfigFromEnvironment,
14
+ mailConfigFromSettings,
15
+ NO_MAIL,
16
+ SettingsSnapshot,
17
+ } from '@meith/settings'
18
+
19
+ import { NextCacheDriver } from './cache/next-cache'
20
+ import { RedisCacheDriver } from './cache/redis-cache'
21
+ import { LocalFileStore } from './files/local-file-store'
22
+ import { S3FileStore } from './files/s3-file-store'
23
+ import { ConfiguredMailDriver } from './mail'
24
+ import { MemoryQueue } from './queue/memory-queue'
25
+ import { PostgresQueue } from './queue/postgres-queue'
26
+
27
+ let bundle: Drivers | undefined
28
+
29
+ function buildQueue(): QueueDriver {
30
+ switch (env.QUEUE_DRIVER) {
31
+ case 'postgres':
32
+ return new PostgresQueue()
33
+ case 'memory':
34
+ return new MemoryQueue()
35
+ }
36
+ }
37
+
38
+ function buildCache(): CacheDriver {
39
+ switch (env.CACHE_DRIVER) {
40
+ case 'next':
41
+ case 'memory':
42
+ return new NextCacheDriver()
43
+ case 'redis': {
44
+ if (!env.REDIS_URL) {
45
+ throw new ConfigurationError('CACHE_DRIVER=redis requires REDIS_URL to be set.')
46
+ }
47
+ return new RedisCacheDriver({ url: env.REDIS_URL })
48
+ }
49
+ }
50
+ }
51
+
52
+ function buildFiles(): FileStore {
53
+ switch (env.FILESTORE_DRIVER) {
54
+ case 'local':
55
+ return new LocalFileStore(env.UPLOADS_DIR)
56
+ case 's3':
57
+ return S3FileStore.fromEnv(env)
58
+ }
59
+ }
60
+
61
+ export async function currentMailConfig(): Promise<MailConfig> {
62
+ if (env.DEMO_MODE) return NO_MAIL
63
+
64
+ const fromEnvironment = mailConfigFromEnvironment(env)
65
+ if (fromEnvironment !== null) return fromEnvironment
66
+
67
+ if (env.DATA_SOURCE !== 'postgres') return NO_MAIL
68
+
69
+ const overrides = await new PostgresSettingsRepository(getDb()).loadAll()
70
+ return mailConfigFromSettings(SettingsSnapshot.fromOverrides(new Map(overrides)))
71
+ }
72
+
73
+ function buildMail(): MailDriver {
74
+ return new ConfiguredMailDriver(currentMailConfig)
75
+ }
76
+
77
+ export function drivers(): Drivers {
78
+ bundle ??= {
79
+ queue: buildQueue(),
80
+ cache: buildCache(),
81
+ files: buildFiles(),
82
+ mail: buildMail(),
83
+ }
84
+ return bundle
85
+ }
86
+
87
+ export function resetDriversForTests(): void {
88
+ bundle = undefined
89
+ }