@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,92 @@
1
+ import { createHighlighter, type Highlighter } from 'shiki'
2
+
3
+ const LANGUAGES = [
4
+ 'bash',
5
+ 'c',
6
+ 'cpp',
7
+ 'csharp',
8
+ 'css',
9
+ 'diff',
10
+ 'dockerfile',
11
+ 'go',
12
+ 'graphql',
13
+ 'html',
14
+ 'http',
15
+ 'ini',
16
+ 'java',
17
+ 'json',
18
+ 'kotlin',
19
+ 'markdown',
20
+ 'php',
21
+ 'python',
22
+ 'ruby',
23
+ 'rust',
24
+ 'sql',
25
+ 'swift',
26
+ 'toml',
27
+ 'tsx',
28
+ 'typescript',
29
+ 'yaml',
30
+ ] as const
31
+
32
+ type Language = (typeof LANGUAGES)[number]
33
+
34
+ const ALIASES: Readonly<Record<string, Language>> = {
35
+ sh: 'bash',
36
+ shell: 'bash',
37
+ zsh: 'bash',
38
+ console: 'bash',
39
+ 'c++': 'cpp',
40
+ cs: 'csharp',
41
+ js: 'typescript',
42
+ jsx: 'tsx',
43
+ javascript: 'typescript',
44
+ mjs: 'typescript',
45
+ cjs: 'typescript',
46
+ ts: 'typescript',
47
+ py: 'python',
48
+ python3: 'python',
49
+ rb: 'ruby',
50
+ rs: 'rust',
51
+ md: 'markdown',
52
+ yml: 'yaml',
53
+ env: 'ini',
54
+ docker: 'dockerfile',
55
+ golang: 'go',
56
+ }
57
+
58
+ function resolveLanguage(requested: string | undefined): Language | null {
59
+ if (requested === undefined) return null
60
+ const lower = requested.toLowerCase()
61
+ const resolved = ALIASES[lower] ?? lower
62
+ return (LANGUAGES as readonly string[]).includes(resolved) ? (resolved as Language) : null
63
+ }
64
+
65
+ let highlighter: Promise<Highlighter> | null = null
66
+
67
+ function getHighlighter(): Promise<Highlighter> {
68
+ highlighter ??= createHighlighter({
69
+ themes: ['vitesse-light', 'vitesse-dark'],
70
+ langs: [...LANGUAGES],
71
+ })
72
+ return highlighter
73
+ }
74
+
75
+ export interface CodeHighlighter {
76
+ highlight(code: string, language: string | undefined): Promise<string | null>
77
+ }
78
+
79
+ export const codeHighlighter: CodeHighlighter = {
80
+ async highlight(code, language) {
81
+ const resolved = resolveLanguage(language)
82
+ if (resolved === null) return null
83
+
84
+ const shiki = await getHighlighter()
85
+ return shiki.codeToHtml(code, {
86
+ lang: resolved,
87
+ themes: { light: 'vitesse-light', dark: 'vitesse-dark' },
88
+ defaultColor: false,
89
+ structure: 'inline',
90
+ })
91
+ },
92
+ }
@@ -0,0 +1,76 @@
1
+ import decodeJpeg, { init as initJpegDecode } from '@jsquash/jpeg/decode.js'
2
+ import encodeJpeg, { init as initJpegEncode } from '@jsquash/jpeg/encode.js'
3
+ import decodePng, { init as initPngDecode } from '@jsquash/png/decode.js'
4
+ import encodePng, { init as initPngEncode } from '@jsquash/png/encode.js'
5
+ import resizeImage, { initResize } from '@jsquash/resize'
6
+
7
+ import { compileAsset } from './locate-wasm'
8
+
9
+ const WASM = {
10
+ png: '@jsquash/png/codec/pkg/squoosh_png_bg.wasm',
11
+ jpegDecode: '@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm',
12
+ jpegEncode: '@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm',
13
+ resize: '@jsquash/resize/lib/resize/pkg/squoosh_resize_bg.wasm',
14
+ } as const
15
+
16
+ let ready: Promise<void> | null = null
17
+
18
+ async function initOne(
19
+ init: (module: WebAssembly.Module) => Promise<unknown>,
20
+ specifier: string,
21
+ ): Promise<void> {
22
+ await init(await compileAsset(specifier))
23
+ }
24
+
25
+ function initialise(): Promise<void> {
26
+ ready ??= (async () => {
27
+ await Promise.all([
28
+ initOne(initPngDecode, WASM.png),
29
+ initOne(initPngEncode, WASM.png),
30
+ initOne(initJpegDecode, WASM.jpegDecode),
31
+ initOne(initJpegEncode, WASM.jpegEncode),
32
+ initOne(initResize, WASM.resize),
33
+ ])
34
+ })().catch((error: unknown) => {
35
+ ready = null
36
+ throw error
37
+ })
38
+ return ready
39
+ }
40
+
41
+ export interface DecodedImage {
42
+ readonly width: number
43
+ readonly height: number
44
+ readonly data: Uint8ClampedArray<ArrayBuffer>
45
+ readonly colorSpace: PredefinedColorSpace
46
+ }
47
+
48
+ export type ImageFormat = 'png' | 'jpeg'
49
+
50
+ export async function decodeImage(bytes: ArrayBuffer, format: ImageFormat): Promise<DecodedImage> {
51
+ await initialise()
52
+ return format === 'png' ? decodePng(bytes) : decodeJpeg(bytes)
53
+ }
54
+
55
+ export async function encodeImage(
56
+ image: DecodedImage,
57
+ format: ImageFormat,
58
+ quality = 82,
59
+ ): Promise<ArrayBuffer> {
60
+ await initialise()
61
+ return format === 'png' ? encodePng(image) : encodeJpeg(image, { quality })
62
+ }
63
+
64
+ export async function resizeToFit(
65
+ image: DecodedImage,
66
+ max: { readonly width: number; readonly height: number },
67
+ ): Promise<DecodedImage> {
68
+ await initialise()
69
+ const scale = Math.min(max.width / image.width, max.height / image.height, 1)
70
+ if (scale >= 1) return image
71
+
72
+ return resizeImage(image, {
73
+ width: Math.max(1, Math.round(image.width * scale)),
74
+ height: Math.max(1, Math.round(image.height * scale)),
75
+ })
76
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ type DecodedImage,
3
+ decodeImage,
4
+ encodeImage,
5
+ type ImageFormat,
6
+ resizeToFit,
7
+ } from './codec'
8
+ export { compileAsset, locateAsset } from './locate-wasm'
9
+ export { imageProcessor } from './processor'
@@ -0,0 +1,108 @@
1
+ import { access, readdir, readFile } from 'node:fs/promises'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join, parse } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const found = new Map<string, string>()
7
+
8
+ const cacheKey = (specifier: string, from: string) => `${from}\u0000${specifier}`
9
+
10
+ export function moduleFile(metaUrl: unknown, filename: unknown): string | undefined {
11
+ if (typeof metaUrl === 'string' && metaUrl.startsWith('file:')) return fileURLToPath(metaUrl)
12
+ return typeof filename === 'string' && filename !== '' ? filename : undefined
13
+ }
14
+
15
+ function ownRequire(): NodeRequire | undefined {
16
+ try {
17
+ const here = moduleFile(
18
+ import.meta.url,
19
+ typeof __filename === 'string' ? __filename : undefined,
20
+ )
21
+ return here === undefined ? undefined : createRequire(here)
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ async function exists(path: string): Promise<boolean> {
28
+ try {
29
+ await access(path)
30
+ return true
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ function storePrefix(specifier: string): string {
37
+ const parts = specifier.split('/')
38
+ return specifier.startsWith('@') ? `${parts[0]}+${parts[1]}@` : `${parts[0]}@`
39
+ }
40
+
41
+ function ancestors(from: string): string[] {
42
+ const root = parse(from).root
43
+ const chain: string[] = []
44
+ let current = from
45
+ while (true) {
46
+ chain.push(current)
47
+ if (current === root) return chain
48
+ const next = dirname(current)
49
+ if (next === current) return chain
50
+ current = next
51
+ }
52
+ }
53
+
54
+ async function candidatesUnder(nodeModules: string, specifier: string): Promise<string[]> {
55
+ const paths = [join(nodeModules, specifier)]
56
+
57
+ const prefix = storePrefix(specifier)
58
+ let entries: string[]
59
+ try {
60
+ entries = await readdir(join(nodeModules, '.pnpm'))
61
+ } catch {
62
+ return paths
63
+ }
64
+
65
+ for (const entry of entries
66
+ .filter((e) => e.startsWith(prefix))
67
+ .sort()
68
+ .reverse()) {
69
+ paths.push(join(nodeModules, '.pnpm', entry, 'node_modules', specifier))
70
+ }
71
+ return paths
72
+ }
73
+
74
+ export async function locateAsset(specifier: string, from = process.cwd()): Promise<string> {
75
+ const key = cacheKey(specifier, from)
76
+ const cached = found.get(key)
77
+ if (cached !== undefined) return cached
78
+
79
+ const required = ownRequire()
80
+ if (required !== undefined) {
81
+ try {
82
+ const path = required.resolve(specifier)
83
+ found.set(key, path)
84
+ return path
85
+ } catch {
86
+ /* ignore */
87
+ }
88
+ }
89
+
90
+ for (const dir of ancestors(from)) {
91
+ for (const candidate of await candidatesUnder(join(dir, 'node_modules'), specifier)) {
92
+ if (await exists(candidate)) {
93
+ found.set(key, candidate)
94
+ return candidate
95
+ }
96
+ }
97
+ }
98
+
99
+ throw new Error(
100
+ `Could not find "${specifier}" in any node_modules above ${from}. ` +
101
+ 'The deployment did not copy the package — check `serverExternalPackages`.',
102
+ )
103
+ }
104
+
105
+ export async function compileAsset(specifier: string): Promise<WebAssembly.Module> {
106
+ const path = await locateAsset(specifier)
107
+ return WebAssembly.compile(await readFile(/* turbopackIgnore: true */ path))
108
+ }
@@ -0,0 +1,40 @@
1
+ import { type ImageProcessor, MAX_IMAGE, type ProcessedImage, THUMBNAIL } from '@meith/attachments'
2
+
3
+ import { decodeImage, encodeImage, resizeToFit } from './codec'
4
+
5
+ const ATTACHMENT_QUALITY = 85
6
+
7
+ const THUMBNAIL_QUALITY = 70
8
+
9
+ function toBytes(buffer: ArrayBuffer): Uint8Array {
10
+ return new Uint8Array(buffer)
11
+ }
12
+
13
+ export const imageProcessor: ImageProcessor = {
14
+ async process(input) {
15
+ const decoded = await decodeImage(input.bytes.slice().buffer as ArrayBuffer, input.codec)
16
+
17
+ const fitted = await resizeToFit(decoded, input.fit ?? MAX_IMAGE)
18
+ const bytes = toBytes(await encodeImage(fitted, input.codec, ATTACHMENT_QUALITY))
19
+
20
+ const wantsThumbnail = input.thumbnail ?? true
21
+ const preview = wantsThumbnail ? await resizeToFit(fitted, THUMBNAIL) : fitted
22
+
23
+ const processed: ProcessedImage = {
24
+ bytes,
25
+ contentType: input.codec === 'png' ? 'image/png' : 'image/jpeg',
26
+ width: fitted.width,
27
+ height: fitted.height,
28
+ ...(preview === fitted
29
+ ? {}
30
+ : {
31
+ thumbnail: {
32
+ bytes: toBytes(await encodeImage(preview, 'jpeg', THUMBNAIL_QUALITY)),
33
+ contentType: 'image/jpeg',
34
+ },
35
+ }),
36
+ }
37
+
38
+ return processed
39
+ },
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export { MemoryCache } from './cache/memory-cache'
2
+ export { NextCacheDriver } from './cache/next-cache'
3
+ export { RedisCacheDriver, type RedisCacheOptions } from './cache/redis-cache'
4
+ export { LocalFileStore } from './files/local-file-store'
5
+ export { S3FileStore, type S3FileStoreConfig, type S3Like } from './files/s3-file-store'
6
+ export {
7
+ ConfiguredMailDriver,
8
+ createMailDriver,
9
+ formatSender,
10
+ HttpMailDriver,
11
+ LogMailDriver,
12
+ MemoryMailDriver,
13
+ SmtpMailDriver,
14
+ } from './mail'
15
+ export { MemoryQueue } from './queue/memory-queue'
16
+ export { PostgresQueue } from './queue/postgres-queue'
17
+ export { currentMailConfig, drivers, resetDriversForTests } from './resolve'
@@ -0,0 +1,113 @@
1
+ import { ConfigurationError, logger, type MailDriver, type OutgoingMail } from '@meith/core'
2
+ import {
3
+ canSendMail,
4
+ describeMailConfig,
5
+ type HttpMailConfig,
6
+ type MailConfig,
7
+ mailConfigProblems,
8
+ } from '@meith/settings'
9
+
10
+ import { formatSender } from './sender'
11
+ import { SmtpMailDriver } from './smtp'
12
+
13
+ export { formatSender } from './sender'
14
+ export { SmtpMailDriver } from './smtp'
15
+
16
+ export class LogMailDriver implements MailDriver {
17
+ send(mail: OutgoingMail): Promise<void> {
18
+ logger({ driver: 'log' }).info(
19
+ { to: mail.to, subject: mail.subject },
20
+ 'mail (not actually sent)',
21
+ )
22
+ return Promise.resolve()
23
+ }
24
+ }
25
+
26
+ export class MemoryMailDriver implements MailDriver {
27
+ readonly sent: OutgoingMail[] = []
28
+
29
+ send(mail: OutgoingMail): Promise<void> {
30
+ this.sent.push(mail)
31
+ return Promise.resolve()
32
+ }
33
+
34
+ reset(): void {
35
+ this.sent.length = 0
36
+ }
37
+ }
38
+
39
+ export class HttpMailDriver implements MailDriver {
40
+ constructor(private readonly config: HttpMailConfig) {}
41
+
42
+ async send(mail: OutgoingMail): Promise<void> {
43
+ const controller = new AbortController()
44
+ const timeout = setTimeout(() => controller.abort(), 10_000)
45
+
46
+ try {
47
+ const response = await fetch(this.config.endpoint, {
48
+ method: 'POST',
49
+ headers: {
50
+ 'content-type': 'application/json',
51
+ authorization: `Bearer ${this.config.token}`,
52
+ },
53
+ body: JSON.stringify({
54
+ from: formatSender(this.config.from, mail.fromName),
55
+ to: mail.to,
56
+ subject: mail.subject,
57
+ text: mail.text,
58
+ ...(mail.html ? { html: mail.html } : {}),
59
+ ...(mail.replyTo ? { reply_to: mail.replyTo } : {}),
60
+ }),
61
+ signal: controller.signal,
62
+ })
63
+
64
+ if (!response.ok) {
65
+ const body = await response.text().catch(() => '')
66
+ const detail = `${response.status} ${body.slice(0, 200)}`
67
+
68
+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {
69
+ throw new ConfigurationError(`Mail provider rejected the message: ${detail}`)
70
+ }
71
+ throw new Error(`Mail provider error: ${detail}`)
72
+ }
73
+ } finally {
74
+ clearTimeout(timeout)
75
+ }
76
+ }
77
+ }
78
+
79
+ export function createMailDriver(config: MailConfig): MailDriver {
80
+ if (config.transport === 'log') return new LogMailDriver()
81
+
82
+ const problems = mailConfigProblems(config)
83
+ if (problems.length > 0) {
84
+ throw new ConfigurationError(`Mail is not fully configured: ${problems.join(' ')}`)
85
+ }
86
+
87
+ return config.transport === 'http' ? new HttpMailDriver(config) : new SmtpMailDriver(config)
88
+ }
89
+
90
+ export class ConfiguredMailDriver implements MailDriver {
91
+ private cached: { readonly fingerprint: string; readonly driver: MailDriver } | null = null
92
+
93
+ constructor(private readonly resolve: () => Promise<MailConfig>) {}
94
+
95
+ async send(mail: OutgoingMail): Promise<void> {
96
+ const config = await this.resolve()
97
+
98
+ if (!canSendMail(config)) {
99
+ logger({ driver: 'mail' }).warn(
100
+ { to: mail.to, subject: mail.subject, config: describeMailConfig(config) },
101
+ 'mail not sent: this board has no working mail configuration',
102
+ )
103
+ return
104
+ }
105
+
106
+ const fingerprint = JSON.stringify(config)
107
+ if (this.cached === null || this.cached.fingerprint !== fingerprint) {
108
+ this.cached = { fingerprint, driver: createMailDriver(config) }
109
+ }
110
+
111
+ await this.cached.driver.send(mail)
112
+ }
113
+ }
@@ -0,0 +1,12 @@
1
+ export function formatSender(address: string, name?: string): string {
2
+ const cleaned = stripControlCharacters(name ?? '').trim()
3
+ if (cleaned === '') return address
4
+
5
+ const escaped = cleaned.replace(/([\\"])/g, '\\$1')
6
+ return `"${escaped}" <${address}>`
7
+ }
8
+
9
+ function stripControlCharacters(value: string): string {
10
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching control characters is this function's job
11
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
12
+ }
@@ -0,0 +1,77 @@
1
+ import nodemailer, { type Transporter } from 'nodemailer'
2
+
3
+ import { ConfigurationError, logger, type MailDriver, type OutgoingMail } from '@meith/core'
4
+ import type { SmtpMailConfig } from '@meith/settings'
5
+
6
+ import { formatSender } from './sender'
7
+
8
+ const CONNECTION_TIMEOUT_MS = 10_000
9
+ const GREETING_TIMEOUT_MS = 10_000
10
+ const SOCKET_TIMEOUT_MS = 20_000
11
+
12
+ function isPermanent(error: unknown): boolean {
13
+ if (typeof error !== 'object' || error === null) return false
14
+ const candidate = error as { responseCode?: unknown; code?: unknown }
15
+
16
+ if (typeof candidate.responseCode === 'number') {
17
+ return candidate.responseCode >= 500 && candidate.responseCode < 600
18
+ }
19
+
20
+ return candidate.code === 'EAUTH'
21
+ }
22
+
23
+ function describe(error: unknown): string {
24
+ if (typeof error !== 'object' || error === null) return String(error)
25
+ const candidate = error as { responseCode?: unknown; code?: unknown; message?: unknown }
26
+
27
+ const code =
28
+ typeof candidate.responseCode === 'number'
29
+ ? String(candidate.responseCode)
30
+ : typeof candidate.code === 'string'
31
+ ? candidate.code
32
+ : ''
33
+ const message = typeof candidate.message === 'string' ? candidate.message : String(error)
34
+
35
+ return (code === '' ? message : `${code} ${message}`).slice(0, 300)
36
+ }
37
+
38
+ export class SmtpMailDriver implements MailDriver {
39
+ private readonly transport: Transporter
40
+
41
+ constructor(private readonly config: SmtpMailConfig) {
42
+ this.transport = nodemailer.createTransport({
43
+ host: config.host,
44
+ port: config.port,
45
+ secure: config.security === 'tls',
46
+ requireTLS: config.security === 'starttls',
47
+ ...(config.username === '' ? {} : { auth: { user: config.username, pass: config.password } }),
48
+ connectionTimeout: CONNECTION_TIMEOUT_MS,
49
+ greetingTimeout: GREETING_TIMEOUT_MS,
50
+ socketTimeout: SOCKET_TIMEOUT_MS,
51
+ })
52
+ }
53
+
54
+ async send(mail: OutgoingMail): Promise<void> {
55
+ try {
56
+ await this.transport.sendMail({
57
+ from: formatSender(this.config.from, mail.fromName),
58
+ to: mail.to,
59
+ subject: mail.subject,
60
+ text: mail.text,
61
+ ...(mail.html === undefined ? {} : { html: mail.html }),
62
+ ...(mail.replyTo === undefined ? {} : { replyTo: mail.replyTo }),
63
+ })
64
+ } catch (error) {
65
+ const detail = describe(error)
66
+ logger({ driver: 'smtp', host: this.config.host }).warn(
67
+ { to: mail.to, err: detail },
68
+ 'smtp send failed',
69
+ )
70
+
71
+ if (isPermanent(error)) {
72
+ throw new ConfigurationError(`The SMTP server rejected the message: ${detail}`)
73
+ }
74
+ throw new Error(`SMTP error: ${detail}`, { cause: error })
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,137 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import type { EnqueueOptions, Job, QueueDriver } from '@meith/core'
4
+
5
+ interface Row {
6
+ id: string
7
+ kind: string
8
+ payload: unknown
9
+ runAt: number
10
+ attempts: number
11
+ maxAttempts: number
12
+ status: 'pending' | 'running' | 'done' | 'dead'
13
+ dedupeKey?: string
14
+ lastError?: string
15
+ }
16
+
17
+ export class MemoryQueue implements QueueDriver {
18
+ private rows: Row[] = []
19
+
20
+ private backoffMs(attempt: number): number {
21
+ return Math.min(10 * attempt * attempt, 3600) * 1000
22
+ }
23
+
24
+ enqueue<TPayload>(
25
+ kind: string,
26
+ payload: TPayload,
27
+ options: EnqueueOptions = {},
28
+ ): Promise<{ id: string; deduplicated: boolean }> {
29
+ if (options.dedupeKey) {
30
+ const live = this.rows.find(
31
+ (r) => r.dedupeKey === options.dedupeKey && r.status === 'pending',
32
+ )
33
+ if (live) return Promise.resolve({ id: live.id, deduplicated: true })
34
+ }
35
+
36
+ const row: Row = {
37
+ id: randomUUID(),
38
+ kind,
39
+ payload,
40
+ runAt: options.runAt?.getTime() ?? Date.now(),
41
+ attempts: 0,
42
+ maxAttempts: options.maxAttempts ?? 5,
43
+ status: 'pending',
44
+ }
45
+ if (options.dedupeKey) row.dedupeKey = options.dedupeKey
46
+
47
+ this.rows.push(row)
48
+ return Promise.resolve({ id: row.id, deduplicated: false })
49
+ }
50
+
51
+ async drain(
52
+ limit: number,
53
+ handler: (job: Job) => Promise<void>,
54
+ options: { readonly signal?: AbortSignal } = {},
55
+ ): Promise<{ processed: number; failed: number }> {
56
+ const now = Date.now()
57
+
58
+ const claimed = this.rows
59
+ .filter((r) => r.status === 'pending' && r.runAt <= now && r.attempts < r.maxAttempts)
60
+ .slice(0, limit)
61
+
62
+ for (const row of claimed) {
63
+ row.status = 'running'
64
+ row.attempts += 1
65
+ }
66
+
67
+ let processed = 0
68
+ let failed = 0
69
+
70
+ for (const [index, row] of claimed.entries()) {
71
+ if (options.signal?.aborted === true) {
72
+ for (const unrun of claimed.slice(index)) {
73
+ unrun.status = 'pending'
74
+ unrun.attempts -= 1
75
+ }
76
+ break
77
+ }
78
+
79
+ try {
80
+ await handler({
81
+ id: row.id,
82
+ kind: row.kind,
83
+ payload: row.payload,
84
+ attempt: row.attempts,
85
+ })
86
+ row.status = 'done'
87
+ processed += 1
88
+ } catch (error) {
89
+ failed += 1
90
+ row.lastError = error instanceof Error ? error.message : String(error)
91
+ row.status = row.attempts >= row.maxAttempts ? 'dead' : 'pending'
92
+ row.runAt = Date.now() + this.backoffMs(row.attempts)
93
+ }
94
+ }
95
+
96
+ return { processed, failed }
97
+ }
98
+
99
+ deadLettered(limit: number): Promise<readonly Job[]> {
100
+ return Promise.resolve(
101
+ this.rows
102
+ .filter((r) => r.status === 'dead')
103
+ .slice(0, limit)
104
+ .map((r) => ({
105
+ id: r.id,
106
+ kind: r.kind,
107
+ payload: r.payload,
108
+ attempt: r.attempts,
109
+ })),
110
+ )
111
+ }
112
+
113
+ retry(jobId: string): Promise<boolean> {
114
+ const row = this.rows.find((r) => r.id === jobId && r.status === 'dead')
115
+ if (!row) return Promise.resolve(false)
116
+
117
+ row.status = 'pending'
118
+ row.attempts = 0
119
+ row.runAt = Date.now()
120
+ delete row.lastError
121
+ return Promise.resolve(true)
122
+ }
123
+
124
+ size(): number {
125
+ return this.rows.length
126
+ }
127
+
128
+ pending(): readonly Job[] {
129
+ return this.rows
130
+ .filter((r) => r.status === 'pending')
131
+ .map((r) => ({ id: r.id, kind: r.kind, payload: r.payload, attempt: r.attempts }))
132
+ }
133
+
134
+ reset(): void {
135
+ this.rows = []
136
+ }
137
+ }