@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.
- package/LICENSE.md +165 -0
- package/package.json +47 -0
- package/src/cache/memory-cache.ts +61 -0
- package/src/cache/next-cache.ts +29 -0
- package/src/cache/redis-cache.ts +128 -0
- package/src/files/local-file-store.ts +59 -0
- package/src/files/s3-file-store.ts +190 -0
- package/src/highlighting/index.ts +1 -0
- package/src/highlighting/shiki-highlighter.ts +92 -0
- package/src/images/codec.ts +76 -0
- package/src/images/index.ts +9 -0
- package/src/images/locate-wasm.ts +108 -0
- package/src/images/processor.ts +40 -0
- package/src/index.ts +17 -0
- package/src/mail/index.ts +113 -0
- package/src/mail/sender.ts +12 -0
- package/src/mail/smtp.ts +77 -0
- package/src/queue/memory-queue.ts +137 -0
- package/src/queue/postgres-queue.ts +218 -0
- package/src/resolve.ts +89 -0
|
@@ -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
|
+
}
|