@strav/queue 0.4.31 → 1.0.0-alpha.4

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,32 @@
1
+ /**
2
+ * `bun strav scheduler:work` — run the minute-tick scheduler loop.
3
+ *
4
+ * Long-running; SIGINT / SIGTERM aborts the loop, which (per Scheduler's
5
+ * semantics) returns within one tick.
6
+ */
7
+
8
+ import { Command, ExitCode } from '@strav/cli'
9
+ import { Scheduler } from '../scheduler.ts'
10
+
11
+ export class SchedulerWork extends Command {
12
+ static signature = 'scheduler:work'
13
+ static description = 'Run the scheduler tick loop until interrupted.'
14
+
15
+ override async execute(): Promise<number> {
16
+ const scheduler = this.app.resolve(Scheduler)
17
+ const controller = new AbortController()
18
+ const sigint = () => controller.abort()
19
+ const sigterm = () => controller.abort()
20
+ process.once('SIGINT', sigint)
21
+ process.once('SIGTERM', sigterm)
22
+
23
+ this.info(`Scheduler started (${scheduler.all().length} entries).`)
24
+ try {
25
+ await scheduler.run(controller.signal)
26
+ return ExitCode.Success
27
+ } finally {
28
+ process.off('SIGINT', sigint)
29
+ process.off('SIGTERM', sigterm)
30
+ }
31
+ }
32
+ }
package/src/cron.ts ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `CronExpression` — parses a 5-field cron string + matches it against
3
+ * a `Date`.
4
+ *
5
+ * Fields, in order:
6
+ * 1. minute (0–59)
7
+ * 2. hour (0–23)
8
+ * 3. day-of-month (1–31)
9
+ * 4. month (1–12)
10
+ * 5. day-of-week (0–6, Sunday = 0)
11
+ *
12
+ * Per-field syntax:
13
+ * - `*` — any value
14
+ * - `N` — exactly N
15
+ * - `A-B` — every value in `[A, B]` inclusive
16
+ * - `A,B,C` — list of values (each item is itself one of the
17
+ * above forms)
18
+ * - `*\/N` — every Nth value across the full range
19
+ * - `A-B/N` — every Nth value across `[A, B]`
20
+ *
21
+ * Time zone: matches against the UTC components of the `Date` —
22
+ * `.getUTCMinutes()` / `.getUTCHours()` / etc. Predictable across
23
+ * machines; apps that need wall-clock scheduling translate by hand at
24
+ * the call site (or supply a `Date` already shifted to local).
25
+ *
26
+ * Name aliases (`jan` / `mon` / etc.) are not supported in V1; use
27
+ * numbers.
28
+ */
29
+
30
+ const FIELDS = [
31
+ { name: 'minute', min: 0, max: 59 },
32
+ { name: 'hour', min: 0, max: 23 },
33
+ { name: 'day-of-month', min: 1, max: 31 },
34
+ { name: 'month', min: 1, max: 12 },
35
+ { name: 'day-of-week', min: 0, max: 6 },
36
+ ] as const
37
+
38
+ export class CronExpression {
39
+ /** The expanded set of acceptable values per field — `Set<number>` × 5. */
40
+ private readonly fields: ReadonlyArray<ReadonlySet<number>>
41
+
42
+ constructor(public readonly expression: string) {
43
+ const parts = expression.trim().split(/\s+/)
44
+ if (parts.length !== 5) {
45
+ throw new Error(
46
+ `CronExpression: expected 5 space-separated fields, got ${parts.length}: "${expression}"`,
47
+ )
48
+ }
49
+ this.fields = parts.map((part, i) => {
50
+ const spec = FIELDS[i] as (typeof FIELDS)[number]
51
+ return parseField(part, spec.min, spec.max, spec.name)
52
+ })
53
+ }
54
+
55
+ /** True iff `date`'s UTC components fall within every field's accepted set. */
56
+ matches(date: Date): boolean {
57
+ const minute = date.getUTCMinutes()
58
+ const hour = date.getUTCHours()
59
+ const dayOfMonth = date.getUTCDate()
60
+ // JS months are 0–11; cron is 1–12.
61
+ const month = date.getUTCMonth() + 1
62
+ const dayOfWeek = date.getUTCDay()
63
+
64
+ return (
65
+ (this.fields[0] as ReadonlySet<number>).has(minute) &&
66
+ (this.fields[1] as ReadonlySet<number>).has(hour) &&
67
+ (this.fields[2] as ReadonlySet<number>).has(dayOfMonth) &&
68
+ (this.fields[3] as ReadonlySet<number>).has(month) &&
69
+ (this.fields[4] as ReadonlySet<number>).has(dayOfWeek)
70
+ )
71
+ }
72
+ }
73
+
74
+ /** Parse one field. Handles `,` lists by recursing on each segment. */
75
+ function parseField(part: string, min: number, max: number, label: string): ReadonlySet<number> {
76
+ if (part === '') {
77
+ throw new Error(`CronExpression: empty ${label} field.`)
78
+ }
79
+ const out = new Set<number>()
80
+ for (const segment of part.split(',')) {
81
+ parseSegment(segment, min, max, label, out)
82
+ }
83
+ return out
84
+ }
85
+
86
+ /** Parse a single segment: `*`, `N`, `A-B`, `*\/N`, `A-B/N`. */
87
+ function parseSegment(segment: string, min: number, max: number, label: string, into: Set<number>) {
88
+ // Step syntax: split off `/N` if present.
89
+ let baseSegment = segment
90
+ let step = 1
91
+ const slashIdx = segment.indexOf('/')
92
+ if (slashIdx !== -1) {
93
+ baseSegment = segment.slice(0, slashIdx)
94
+ const stepText = segment.slice(slashIdx + 1)
95
+ const parsed = Number.parseInt(stepText, 10)
96
+ if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== stepText) {
97
+ throw new Error(`CronExpression: bad step in ${label} "${segment}".`)
98
+ }
99
+ step = parsed
100
+ }
101
+
102
+ let start: number
103
+ let end: number
104
+ if (baseSegment === '*') {
105
+ start = min
106
+ end = max
107
+ } else if (baseSegment.includes('-')) {
108
+ const [aText, bText] = baseSegment.split('-')
109
+ if (aText === undefined || bText === undefined) {
110
+ throw new Error(`CronExpression: bad range in ${label} "${segment}".`)
111
+ }
112
+ const a = Number.parseInt(aText, 10)
113
+ const b = Number.parseInt(bText, 10)
114
+ if (
115
+ !Number.isInteger(a) ||
116
+ !Number.isInteger(b) ||
117
+ String(a) !== aText ||
118
+ String(b) !== bText
119
+ ) {
120
+ throw new Error(`CronExpression: bad range in ${label} "${segment}".`)
121
+ }
122
+ start = a
123
+ end = b
124
+ } else {
125
+ const n = Number.parseInt(baseSegment, 10)
126
+ if (!Number.isInteger(n) || String(n) !== baseSegment) {
127
+ throw new Error(`CronExpression: bad value in ${label} "${segment}".`)
128
+ }
129
+ start = n
130
+ end = n
131
+ }
132
+
133
+ if (start < min || end > max) {
134
+ throw new Error(`CronExpression: ${label} value out of range [${min}, ${max}]: "${segment}".`)
135
+ }
136
+ if (start > end) {
137
+ throw new Error(`CronExpression: ${label} range start > end: "${segment}".`)
138
+ }
139
+
140
+ for (let v = start; v <= end; v += step) {
141
+ into.add(v)
142
+ }
143
+ }
144
+
145
+ // ─────────────────────────────────────────────────────────────────────────────
146
+ // Public helpers — convenience constructors for the common cadences.
147
+ // Apps reaching beyond these use `cron(expression)` directly.
148
+ // ─────────────────────────────────────────────────────────────────────────────
149
+
150
+ /** Every minute — `* * * * *`. */
151
+ export function everyMinute(): CronExpression {
152
+ return new CronExpression('* * * * *')
153
+ }
154
+
155
+ /** Every N minutes — emits a cron expression with `*\/N` in the minute field. Throws on non-positive N. */
156
+ export function everyMinutes(n: number): CronExpression {
157
+ if (!Number.isInteger(n) || n <= 0) {
158
+ throw new Error(`everyMinutes: expected a positive integer, got ${n}.`)
159
+ }
160
+ return new CronExpression(`*/${n} * * * *`)
161
+ }
162
+
163
+ /** Top of every hour — `0 * * * *`. */
164
+ export function hourly(): CronExpression {
165
+ return new CronExpression('0 * * * *')
166
+ }
167
+
168
+ /** Midnight UTC daily — `0 0 * * *`. */
169
+ export function daily(): CronExpression {
170
+ return new CronExpression('0 0 * * *')
171
+ }
172
+
173
+ /** Daily at a specific UTC time — `dailyAt('14:30')` → `30 14 * * *`. */
174
+ export function dailyAt(time: string): CronExpression {
175
+ const match = time.match(/^(\d{1,2}):(\d{2})$/)
176
+ if (!match) {
177
+ throw new Error(`dailyAt: expected HH:MM (24-hour), got "${time}".`)
178
+ }
179
+ const hour = Number.parseInt(match[1] as string, 10)
180
+ const minute = Number.parseInt(match[2] as string, 10)
181
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
182
+ throw new Error(`dailyAt: time out of range "${time}".`)
183
+ }
184
+ return new CronExpression(`${minute} ${hour} * * *`)
185
+ }
186
+
187
+ /**
188
+ * Escape hatch for non-trivial schedules — accepts any valid 5-field cron
189
+ * expression. Apps that want weekly / monthly / "Mondays at 9" etc. use
190
+ * this directly.
191
+ */
192
+ export function cron(expression: string): CronExpression {
193
+ return new CronExpression(expression)
194
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * `DatabaseQueue` — Postgres-backed `Queue` driver.
3
+ *
4
+ * Persists each `dispatch` / `dispatchLater` as a `_strav_jobs` row;
5
+ * Workers (next M3 slice) `SELECT FOR UPDATE SKIP LOCKED` to claim
6
+ * available rows and run `handle()`.
7
+ *
8
+ * **Queue-until-commit semantics.** When `dispatch()` is called inside
9
+ * a `UnitOfWork.run(...)` or `TenantManager.withTenant(...)` scope, the
10
+ * driver routes the INSERT through the ambient transaction's executor
11
+ * (read from `transactionalStorage`). The new row commits + rolls back
12
+ * atomically with the surrounding transaction:
13
+ *
14
+ * - If the transaction COMMITs, the queue row is visible to Workers.
15
+ * The dispatched job runs.
16
+ * - If the transaction ROLLBACKs, the row never existed. The job is
17
+ * dropped.
18
+ *
19
+ * This is exactly the spec's M3 spike ("flush queue on commit; drop
20
+ * on rollback") — Postgres's transactional atomicity gives us the
21
+ * semantic for free; no deferred-callback machinery needed.
22
+ *
23
+ * Outside a transactional scope, `dispatch` writes against
24
+ * `this.db` directly (auto-commit).
25
+ *
26
+ * `dispatchSync` bypasses persistence entirely — instantiates the Job
27
+ * via the container and runs `handle()` in-process, just like
28
+ * `SyncQueue.dispatchSync`. The caller's session continues without a
29
+ * Worker ever seeing the work.
30
+ */
31
+
32
+ import {
33
+ currentTransactionalContext,
34
+ type Database,
35
+ type DatabaseExecutor,
36
+ type PostgresDatabase,
37
+ } from '@strav/database'
38
+ import { type Container, type Logger, ulid } from '@strav/kernel'
39
+ import type { JobClass, JobContext, PayloadOf } from './job.ts'
40
+ import { jobSchema } from './job_schema.ts'
41
+ import type { DispatchLaterOptions, DispatchOptions, Queue } from './queue.ts'
42
+
43
+ export interface DatabaseQueueOptions {
44
+ /** Postgres pool used for INSERTs outside an ambient transaction. */
45
+ db: PostgresDatabase | Database
46
+ /**
47
+ * Container used to construct Job instances for `dispatchSync`. The
48
+ * Worker (separate slice) also goes through the container, so the
49
+ * same `@inject()`-driven wiring resolves consistently.
50
+ */
51
+ container: Container
52
+ /** Optional Logger attached to `dispatchSync` `JobContext.log`. Default: no-op. */
53
+ logger?: Logger
54
+ /** Default `max_attempts` when neither the JobClass nor `DispatchOptions` specifies one. Default `3`. */
55
+ defaultAttempts?: number
56
+ /** Default queue name when neither the JobClass nor `DispatchOptions` specifies one. Default `'default'`. */
57
+ defaultQueue?: string
58
+ }
59
+
60
+ export class DatabaseQueue implements Queue {
61
+ private readonly db: Database
62
+ private readonly container: Container
63
+ private readonly logger: Logger
64
+ private readonly defaultAttempts: number
65
+ private readonly defaultQueue: string
66
+
67
+ constructor(opts: DatabaseQueueOptions) {
68
+ this.db = opts.db
69
+ this.container = opts.container
70
+ this.logger = opts.logger ?? createNoopLogger()
71
+ this.defaultAttempts = opts.defaultAttempts ?? 3
72
+ this.defaultQueue = opts.defaultQueue ?? 'default'
73
+ }
74
+
75
+ async dispatch<TJob extends JobClass>(
76
+ jobClass: TJob,
77
+ payload: PayloadOf<TJob>,
78
+ opts?: DispatchOptions,
79
+ ): Promise<string> {
80
+ return this.insertJob(jobClass, payload, /* delaySeconds */ 0, opts)
81
+ }
82
+
83
+ async dispatchLater<TJob extends JobClass>(
84
+ at: Date | number,
85
+ jobClass: TJob,
86
+ payload: PayloadOf<TJob>,
87
+ opts?: DispatchLaterOptions,
88
+ ): Promise<string> {
89
+ const delaySeconds = computeDelaySeconds(at)
90
+ return this.insertJob(jobClass, payload, delaySeconds, opts)
91
+ }
92
+
93
+ async dispatchSync<TJob extends JobClass>(
94
+ jobClass: TJob,
95
+ payload: PayloadOf<TJob>,
96
+ ): Promise<void> {
97
+ const jobId = ulid()
98
+ const job = this.container.make(jobClass)
99
+ const ctx: JobContext = {
100
+ jobId,
101
+ attempt: 1,
102
+ payload,
103
+ // SyncQueue parity — dispatchSync runs to completion in one tick;
104
+ // a never-aborted signal keeps handlers written against the
105
+ // production contract working unchanged.
106
+ signal: new AbortController().signal,
107
+ log: this.logger,
108
+ }
109
+ await job.handle(ctx)
110
+ }
111
+
112
+ /**
113
+ * Single INSERT path shared by `dispatch` + `dispatchLater`. Reads
114
+ * the ambient transactional context — when present, the INSERT
115
+ * routes through `ctx.tx` so the row is part of the surrounding
116
+ * transaction's atomicity guarantee.
117
+ */
118
+ private async insertJob<TJob extends JobClass>(
119
+ jobClass: TJob,
120
+ payload: PayloadOf<TJob>,
121
+ delaySeconds: number,
122
+ opts: DispatchOptions | undefined,
123
+ ): Promise<string> {
124
+ const jobId = ulid()
125
+ const queue = opts?.queue ?? jobClass.queue ?? this.defaultQueue
126
+ const maxAttempts = opts?.attempts ?? jobClass.maxAttempts ?? this.defaultAttempts
127
+
128
+ const executor: DatabaseExecutor = currentTransactionalContext()?.tx ?? this.db
129
+ // `available_at` is computed in Postgres so the queue's notion of
130
+ // "now" is the DB clock — the only clock the Worker reads.
131
+ // Mixing wall-clock from the dispatcher with DB-clock from the
132
+ // Worker invites skew bugs.
133
+ const availableAtFragment =
134
+ delaySeconds > 0 ? `now() + interval '${delaySeconds} seconds'` : 'now()'
135
+ await executor.execute(
136
+ `INSERT INTO ${quoteIdent(jobSchema.name)} (
137
+ "id", "queue", "job_name", "payload", "attempts", "max_attempts", "available_at", "created_at", "updated_at"
138
+ ) VALUES (
139
+ $1, $2, $3, $4::jsonb, 0, $5, ${availableAtFragment}, now(), now()
140
+ )`,
141
+ [jobId, queue, jobClass.jobName, JSON.stringify(payload), maxAttempts],
142
+ )
143
+ return jobId
144
+ }
145
+ }
146
+
147
+ /** Normalize `at` (Date | seconds-from-now) → seconds-from-now ≥ 0. */
148
+ function computeDelaySeconds(at: Date | number): number {
149
+ if (typeof at === 'number') {
150
+ if (at < 0) {
151
+ throw new Error(`DatabaseQueue.dispatchLater: delay must be non-negative, got ${at}.`)
152
+ }
153
+ return at
154
+ }
155
+ // `at` is a Date — past values clamp to 0 (immediately available).
156
+ const deltaMs = at.getTime() - Date.now()
157
+ return Math.max(0, Math.ceil(deltaMs / 1000))
158
+ }
159
+
160
+ /** Single-quote-aware identifier quoter for the schema table name. */
161
+ function quoteIdent(name: string): string {
162
+ return `"${name.replace(/"/g, '""')}"`
163
+ }
164
+
165
+ /** Bare Logger that drops every call — same shape as SyncQueue's. */
166
+ function createNoopLogger(): Logger {
167
+ const noop = () => undefined
168
+ return {
169
+ debug: noop,
170
+ info: noop,
171
+ warn: noop,
172
+ error: noop,
173
+ fatal: noop,
174
+ trace: noop,
175
+ child: () => createNoopLogger(),
176
+ } as unknown as Logger
177
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `strav_failed_jobs` schema — terminal failure dead-letter table.
3
+ *
4
+ * When `Worker.processOne()` exhausts a job's `max_attempts`, the row
5
+ * moves from `strav_jobs` to `strav_failed_jobs` in a single
6
+ * transaction — INSERT here, DELETE there. Apps inspect this table
7
+ * to triage what blew up; the `queue:retry` / `queue:flush` console
8
+ * commands (lands with `@strav/cli` in M4) operate on these rows.
9
+ *
10
+ * Columns:
11
+ * - `id` (char(26), ULID PK) — fresh per failure, not the original
12
+ * job id (the original is gone with the row).
13
+ * - `queue` (varchar(64)) — copied from `strav_jobs.queue`.
14
+ * - `job_name` (varchar(128)) — copied from `strav_jobs.job_name`.
15
+ * - `payload` (jsonb) — copied verbatim so retries can replay it.
16
+ * - `exception` (text) — the thrown error, serialized via
17
+ * `error.stack ?? String(error)`. Long; not indexed.
18
+ * - `attempts` (integer) — how many total attempts the job got
19
+ * before terminal failure.
20
+ * - `failed_at` (timestamptz) — wall-clock time of the move.
21
+ * - `created_at` / `updated_at` — provided by `t.timestamps()`.
22
+ *
23
+ * Not `tenanted: true` — same system-level scope as `strav_jobs`.
24
+ */
25
+
26
+ import { Archetype, defineSchema } from '@strav/database'
27
+
28
+ export const failedJobsSchema = defineSchema('strav_failed_jobs', Archetype.Entity, (t) => {
29
+ t.id()
30
+ t.string('queue').max(64)
31
+ t.string('job_name').max(128)
32
+ t.json<unknown>('payload')
33
+ t.text('exception')
34
+ t.integer('attempts')
35
+ t.timestamp('failed_at')
36
+ t.timestamps()
37
+ })
package/src/index.ts CHANGED
@@ -1,3 +1,62 @@
1
- export * from './queue/index.ts'
2
- export * from './scheduler/index.ts'
3
- export * from './providers/index.ts'
1
+ // Public API of @strav/queue.
2
+ //
3
+ // V1 ships:
4
+ // - Job + JobContext + JobClass + PayloadOf — the unit of work + types.
5
+ // - JobRegistry + Bun.Glob auto-discovery + isJobClass type-guard.
6
+ // - Queue interface with dispatch / dispatchLater / dispatchSync.
7
+ // - SyncQueue — in-process synchronous driver for tests + single-process dev.
8
+ // - DatabaseQueue — Postgres-backed driver with queue-until-commit semantics
9
+ // via @strav/database's transactional ALS.
10
+ // - jobSchema — the `strav_jobs` Schema apps register + migrate.
11
+ // - Worker — consumer side: SELECT FOR UPDATE SKIP LOCKED poll loop,
12
+ // attempt counter, exponential backoff with jitter, per-attempt
13
+ // timeout, graceful shutdown via AbortSignal.
14
+ // - CronExpression + helpers (everyMinute / everyMinutes / hourly /
15
+ // daily / dailyAt / cron) — 5-field cron with UTC-based matching.
16
+ // - Scheduler + schedulerRunsSchema — recurring dispatch with optional
17
+ // onOneServer advisory lock (built on TenantManager.withLock).
18
+ //
19
+ // - failedJobsSchema — the `strav_failed_jobs` dead-letter table.
20
+ // Worker moves terminal failures here atomically (INSERT + DELETE
21
+ // in one tx).
22
+ //
23
+ // Still deferred (waiting on @strav/cli — M4):
24
+ // - queue:retry / queue:flush console commands (re-enqueue / drop
25
+ // failed rows in bulk).
26
+
27
+ export {
28
+ QueueConsoleProvider,
29
+ QueueFailed,
30
+ QueueFlush,
31
+ QueueRetry,
32
+ QueueWork,
33
+ SchedulerList,
34
+ SchedulerRun,
35
+ SchedulerWork,
36
+ } from './console/index.ts'
37
+ export {
38
+ CronExpression,
39
+ cron,
40
+ daily,
41
+ dailyAt,
42
+ everyMinute,
43
+ everyMinutes,
44
+ hourly,
45
+ } from './cron.ts'
46
+ export { DatabaseQueue, type DatabaseQueueOptions } from './database_queue.ts'
47
+ export { failedJobsSchema } from './failed_jobs_schema.ts'
48
+ export {
49
+ Job,
50
+ type JobClass,
51
+ type JobConfig,
52
+ type JobContext,
53
+ type JobFailedContext,
54
+ type PayloadOf,
55
+ } from './job.ts'
56
+ export { isJobClass, JobRegistry } from './job_registry.ts'
57
+ export { jobSchema } from './job_schema.ts'
58
+ export type { DispatchLaterOptions, DispatchOptions, Queue } from './queue.ts'
59
+ export { type ScheduleOptions, Scheduler, type SchedulerOptions } from './scheduler.ts'
60
+ export { schedulerRunsSchema } from './scheduler_runs_schema.ts'
61
+ export { SyncQueue, type SyncQueueOptions } from './sync_queue.ts'
62
+ export { type JobResult, Worker, type WorkerOptions } from './worker.ts'
package/src/job.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `Job` — the unit of background work.
3
+ *
4
+ * Subclasses declare a stable `static jobName` (the wire identifier the
5
+ * `JobRegistry` uses to route a serialized payload back to a class) and
6
+ * implement `handle(ctx)`. The Worker constructs jobs through the
7
+ * container so `@inject()`-marked subclasses get their dependencies
8
+ * resolved the usual way; the payload arrives as JSON on the
9
+ * `JobContext`.
10
+ *
11
+ * Configuration overrides (max attempts, backoff, timeout, queue name)
12
+ * are static on the subclass. The Worker reads them per-attempt — see
13
+ * the `JobConfig` interface below.
14
+ *
15
+ * ```ts
16
+ * @inject()
17
+ * class SendWelcomeEmail extends Job<{ userId: string }> {
18
+ * static override readonly jobName = 'mail.welcome'
19
+ *
20
+ * constructor(
21
+ * private readonly users: UserRepository,
22
+ * private readonly mail: MailManager,
23
+ * ) {
24
+ * super()
25
+ * }
26
+ *
27
+ * async handle(ctx: JobContext<{ userId: string }>): Promise<void> {
28
+ * const user = await this.users.findOrFail(ctx.payload.userId)
29
+ * await this.mail.send(new WelcomeEmail(user))
30
+ * }
31
+ * }
32
+ * ```
33
+ */
34
+
35
+ import type { Logger } from '@strav/kernel'
36
+
37
+ /**
38
+ * Per-invocation context passed to `Job.handle(ctx)`. Workers populate
39
+ * every field — testing harnesses can build one by hand.
40
+ */
41
+ export interface JobContext<TPayload = unknown> {
42
+ /** Stable id assigned at dispatch (typically a ULID). */
43
+ jobId: string
44
+ /** 1-based attempt counter. `attempt === 1` is the first run. */
45
+ attempt: number
46
+ /** The deserialized payload. */
47
+ payload: TPayload
48
+ /**
49
+ * Cancellation signal. Aborted when the Worker's shutdown grace
50
+ * period elapses or the job exceeds its `timeout`. Handlers that
51
+ * loop / stream should check `ctx.signal.aborted` and bail.
52
+ */
53
+ signal: AbortSignal
54
+ /**
55
+ * Job-scoped logger — prebound with `jobId` + `jobName` + `attempt`.
56
+ * Standard Logger surface (`debug`/`info`/`warn`/`error`).
57
+ */
58
+ log: Logger
59
+ }
60
+
61
+ /**
62
+ * Optional retry hook context — same shape as `JobContext` plus the
63
+ * error that caused the failure.
64
+ */
65
+ export interface JobFailedContext<TPayload = unknown> extends JobContext<TPayload> {
66
+ /** The thrown value from the failed attempt. Already wrapped by the kernel error stack. */
67
+ error: unknown
68
+ }
69
+
70
+ /**
71
+ * The `static` config shape a Job subclass may set. Every field is
72
+ * optional — the Worker falls back to driver defaults when omitted.
73
+ */
74
+ export interface JobConfig {
75
+ /** Total attempts (including the first). Default: driver-configured, typically 3. */
76
+ maxAttempts?: number
77
+ /**
78
+ * Backoff in seconds for `attempt` (1-based). The Worker calls this
79
+ * AFTER a failed attempt to schedule the next try. Default: exponential
80
+ * with jitter (driver-configured).
81
+ */
82
+ backoff?(attempt: number): number
83
+ /** Per-attempt time limit (seconds). Default: driver-configured. */
84
+ timeout?: number
85
+ /** Named queue to dispatch onto. Default: `'default'`. */
86
+ queue?: string
87
+ }
88
+
89
+ /**
90
+ * The abstract Job base class. Subclasses implement `handle` and set
91
+ * `static jobName`. Other static fields are optional configuration —
92
+ * see {@link JobConfig}.
93
+ *
94
+ * Generic parameter `TPayload` is the shape the dispatcher serializes
95
+ * and the handler deserializes. Workers JSON.stringify/parse it
96
+ * verbatim — keep it serializable (no Dates without a custom revival
97
+ * step, no class instances).
98
+ */
99
+ export abstract class Job<TPayload = unknown> {
100
+ /**
101
+ * Stable wire identifier mapping this class in the JobRegistry.
102
+ * Subclasses MUST override. Convention: `<package>.<verb>` or
103
+ * `<resource>.<verb>` (`mail.welcome`, `user.cleanup`).
104
+ *
105
+ * The base class declares an empty string so type-checking holds;
106
+ * `JobRegistry.register()` rejects classes that didn't override.
107
+ */
108
+ static readonly jobName: string = ''
109
+
110
+ // Optional static configuration. Declared on the base so subclasses
111
+ // can shadow with `static override readonly <field> = <value>` under
112
+ // `noImplicitOverride`. The Worker reads these per-attempt; omitted
113
+ // fields fall back to driver defaults. Mirrors the {@link JobConfig}
114
+ // interface — the interface drives `JobClass`'s typing, the static
115
+ // fields here drive the override-friendly inheritance shape.
116
+ static readonly maxAttempts?: number
117
+ static readonly backoff?: (attempt: number) => number
118
+ static readonly timeout?: number
119
+ static readonly queue?: string
120
+
121
+ /** The handler. Workers call this once they've constructed the job + built the context. */
122
+ abstract handle(context: JobContext<TPayload>): Promise<void>
123
+
124
+ /**
125
+ * Optional failure hook. Fires when an attempt throws AND there are
126
+ * still retries left; also fires once on the FINAL failure (so apps
127
+ * can route to a dead-letter, post a Slack message, etc.). The Worker
128
+ * runs this in a try/catch — a throw from `failed()` is logged but
129
+ * doesn't change the retry decision.
130
+ */
131
+ failed?(context: JobFailedContext<TPayload>): Promise<void>
132
+ }
133
+
134
+ /**
135
+ * Constructor reference for a Job subclass. The Worker / Queue store
136
+ * these in the registry, look them up by `jobName`, and instantiate
137
+ * via the container.
138
+ */
139
+ export interface JobClass<TPayload = unknown> extends JobConfig {
140
+ // biome-ignore lint/suspicious/noExplicitAny: ctor params are decided per-subclass; the @inject() flow resolves them.
141
+ new (...args: any[]): Job<TPayload>
142
+ readonly jobName: string
143
+ }
144
+
145
+ /**
146
+ * Helper type: extract the payload type from a `JobClass`.
147
+ *
148
+ * ```ts
149
+ * type WelcomePayload = PayloadOf<typeof SendWelcomeEmail>
150
+ * // → { userId: string }
151
+ * ```
152
+ */
153
+ export type PayloadOf<T> = T extends JobClass<infer P> ? P : never