@strav/queue 0.4.30 → 1.0.0-alpha.3

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,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,52 @@
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
+ CronExpression,
29
+ cron,
30
+ daily,
31
+ dailyAt,
32
+ everyMinute,
33
+ everyMinutes,
34
+ hourly,
35
+ } from './cron.ts'
36
+ export { DatabaseQueue, type DatabaseQueueOptions } from './database_queue.ts'
37
+ export { failedJobsSchema } from './failed_jobs_schema.ts'
38
+ export {
39
+ Job,
40
+ type JobClass,
41
+ type JobConfig,
42
+ type JobContext,
43
+ type JobFailedContext,
44
+ type PayloadOf,
45
+ } from './job.ts'
46
+ export { isJobClass, JobRegistry } from './job_registry.ts'
47
+ export { jobSchema } from './job_schema.ts'
48
+ export type { DispatchLaterOptions, DispatchOptions, Queue } from './queue.ts'
49
+ export { type ScheduleOptions, Scheduler, type SchedulerOptions } from './scheduler.ts'
50
+ export { schedulerRunsSchema } from './scheduler_runs_schema.ts'
51
+ export { SyncQueue, type SyncQueueOptions } from './sync_queue.ts'
52
+ 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
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `JobRegistry` — runtime catalog of every registered `Job` class.
3
+ *
4
+ * Apps register jobs explicitly (`register(JobClass)` / `registerAll
5
+ * ([...])`) or via `discover(pattern)`, which uses `Bun.Glob` to scan
6
+ * files + dynamically `import()` each, registering every exported
7
+ * class that satisfies {@link isJobClass}. Same shape as
8
+ * `SchemaRegistry.discover` in `@strav/database`.
9
+ *
10
+ * The registry rejects two different classes claiming the same
11
+ * `jobName` (a wire-protocol collision — the Worker can't disambiguate
12
+ * which class to instantiate). Re-imports of the same class via a
13
+ * barrel are deduped by identity.
14
+ */
15
+
16
+ import { ConfigError } from '@strav/kernel'
17
+ import { Job, type JobClass } from './job.ts'
18
+
19
+ export class JobRegistry {
20
+ private readonly byName = new Map<string, JobClass>()
21
+
22
+ /** Register one Job class. Throws when `jobName` is empty or already taken. */
23
+ register(jobClass: JobClass): this {
24
+ if (!jobClass.jobName) {
25
+ throw new ConfigError(
26
+ `JobRegistry: ${jobClass.name || 'anonymous job'} must declare a non-empty \`static jobName\` — that's the wire identifier the Worker dispatches against.`,
27
+ )
28
+ }
29
+ const existing = this.byName.get(jobClass.jobName)
30
+ if (existing === jobClass) return this
31
+ if (existing) {
32
+ throw new ConfigError(
33
+ `JobRegistry: jobName "${jobClass.jobName}" is already registered to a different class (${existing.name} vs ${jobClass.name}). Pick a different jobName — the Worker uses it to route serialized payloads back to a class.`,
34
+ )
35
+ }
36
+ this.byName.set(jobClass.jobName, jobClass)
37
+ return this
38
+ }
39
+
40
+ /** Register several. Order matters only insofar as register() throws on conflict. */
41
+ registerAll(jobClasses: readonly JobClass[]): this {
42
+ for (const cls of jobClasses) this.register(cls)
43
+ return this
44
+ }
45
+
46
+ /**
47
+ * Auto-discover Job classes by glob pattern. For each matched file:
48
+ * 1. dynamically `import()` it,
49
+ * 2. iterate every exported value,
50
+ * 3. register every one that satisfies {@link isJobClass}.
51
+ *
52
+ * `pattern` is a `Bun.Glob`-compatible string (or array). `cwd`
53
+ * defaults to `process.cwd()` (typically the repo root). Returns
54
+ * `this` for chaining.
55
+ *
56
+ * Re-exports of the same class via multiple files dedupe by object
57
+ * identity — typical barrel patterns work. Two DIFFERENT classes
58
+ * sharing a `jobName` still throw `ConfigError`.
59
+ *
60
+ * Files exporting no Job classes (helpers, type-only re-exports) are
61
+ * silently skipped.
62
+ */
63
+ async discover(pattern: string | string[], options: { cwd?: string } = {}): Promise<this> {
64
+ const patterns = Array.isArray(pattern) ? pattern : [pattern]
65
+ const cwd = options.cwd ?? process.cwd()
66
+ const files = new Set<string>()
67
+ for (const p of patterns) {
68
+ const glob = new Bun.Glob(p)
69
+ for await (const file of glob.scan({ cwd, absolute: true })) {
70
+ files.add(file)
71
+ }
72
+ }
73
+ for (const file of files) {
74
+ const mod = (await import(file)) as Record<string, unknown>
75
+ for (const value of Object.values(mod)) {
76
+ if (!isJobClass(value)) continue
77
+ this.register(value)
78
+ }
79
+ }
80
+ return this
81
+ }
82
+
83
+ /** Resolve by `jobName`. Returns `undefined` for unknown names. */
84
+ get(jobName: string): JobClass | undefined {
85
+ return this.byName.get(jobName)
86
+ }
87
+
88
+ /** Throwing variant — use when "this job must exist" is a hard precondition. */
89
+ getOrFail(jobName: string): JobClass {
90
+ const cls = this.byName.get(jobName)
91
+ if (!cls) {
92
+ throw new ConfigError(
93
+ `JobRegistry: no Job is registered under "${jobName}". ` +
94
+ 'Either the dispatcher serialized an unknown class, or the Worker is running against a different registry than the dispatcher used.',
95
+ )
96
+ }
97
+ return cls
98
+ }
99
+
100
+ /** True when a Job with this `jobName` is registered. */
101
+ has(jobName: string): boolean {
102
+ return this.byName.has(jobName)
103
+ }
104
+
105
+ /** Every registered class, in insertion order. */
106
+ all(): readonly JobClass[] {
107
+ return [...this.byName.values()]
108
+ }
109
+
110
+ /** Test helper: wipe the registry. */
111
+ clear(): void {
112
+ this.byName.clear()
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Type-guard: a value looks like a Job class — a function whose
118
+ * prototype extends `Job` and which declares a non-empty `jobName`.
119
+ * Used by `discover()` to filter exported values; exported so apps can
120
+ * hand-roll their own discovery loops.
121
+ *
122
+ * The prototype check is conservative — a class that doesn't extend
123
+ * `Job` (even one that happens to look duck-typed) is rejected. That's
124
+ * intentional: the `failed?` hook + future Worker extensions key off
125
+ * `instanceof Job`, so the registry's identity guarantee has to match.
126
+ */
127
+ export function isJobClass(value: unknown): value is JobClass {
128
+ if (typeof value !== 'function') return false
129
+ // The Job constructor itself isn't registerable (it's abstract); only
130
+ // subclasses are.
131
+ if (value === Job) return false
132
+ if (!(value.prototype instanceof Job)) return false
133
+ const jobName = (value as { jobName?: unknown }).jobName
134
+ return typeof jobName === 'string' && jobName.length > 0
135
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `strav_jobs` schema — the backing table for `DatabaseQueue`.
3
+ *
4
+ * Naming convention: framework-managed tables that apps register +
5
+ * migrate use the `strav_*` prefix (no leading underscore — those are
6
+ * reserved for raw-DDL tables created by the runner / DDL emitter:
7
+ * `_strav_migrations`, `_strav_tenant_sequences`). Apps register
8
+ * `jobSchema` with their SchemaRegistry and `generateMigration` picks
9
+ * it up like any other schema.
10
+ *
11
+ * Columns:
12
+ * - `id` (char(26), ULID PK) — globally unique across queues.
13
+ * - `queue` (varchar(64)) — named queue. Default `'default'`. Workers
14
+ * poll one queue at a time, so this is the routing key.
15
+ * - `job_name` (varchar(128)) — `JobRegistry` lookup key. The Worker
16
+ * deserializes the row by mapping this back to a `JobClass`.
17
+ * - `payload` (jsonb) — the `JSON.stringify`'d job payload.
18
+ * - `attempts` (integer, default 0) — count of attempts so far. The
19
+ * Worker increments before running `handle()`.
20
+ * - `max_attempts` (integer, default 3) — total retries allowed.
21
+ * `attempts >= max_attempts` after a failed attempt → terminal
22
+ * failure, moves to `failed_jobs` (V1 just leaves it; failed-jobs
23
+ * handling is its own slice).
24
+ * - `available_at` (timestamptz) — the earliest time a Worker may
25
+ * pick this row up. `now()` for immediate dispatch; `dispatchLater`
26
+ * pushes this into the future.
27
+ * - `reserved_at` (timestamptz, nullable) — when a Worker locked
28
+ * the row via `SELECT FOR UPDATE SKIP LOCKED`. NULL means
29
+ * unreserved.
30
+ * - `created_at` / `updated_at` — provided by `t.timestamps()`.
31
+ *
32
+ * Not `tenanted: true` — the queue is system-level. Multi-tenant apps
33
+ * that want per-tenant queues can clone this schema with a tenant_id
34
+ * FK + RLS, but that's a follow-up.
35
+ */
36
+
37
+ import { Archetype, defineSchema } from '@strav/database'
38
+
39
+ export const jobSchema = defineSchema('strav_jobs', Archetype.Entity, (t) => {
40
+ t.id()
41
+ t.string('queue').max(64).default('default')
42
+ t.string('job_name').max(128)
43
+ t.json<unknown>('payload')
44
+ t.integer('attempts').default(0)
45
+ t.integer('max_attempts').default(3)
46
+ t.timestamp('available_at')
47
+ t.timestamp('reserved_at').nullable()
48
+ t.timestamps()
49
+ })
package/src/queue.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `Queue` — the contract every backend implements.
3
+ *
4
+ * V1 ships two: `SyncQueue` (in-process, no persistence — for tests +
5
+ * single-process dev) and `DatabaseQueue` (Postgres-backed, the
6
+ * production driver). Both implement this interface; apps depend on
7
+ * the abstract `Queue` symbol via DI.
8
+ *
9
+ * Method semantics:
10
+ * - `dispatch(JobClass, payload, opts?)` — enqueue for a Worker to
11
+ * pick up. Returns the assigned `jobId` (ULID).
12
+ * - `dispatchLater(at, JobClass, payload, opts?)` — same as dispatch
13
+ * but the row isn't picked up until `at`. `at` is either a `Date`
14
+ * (absolute) or a number of seconds from now (relative).
15
+ * - `dispatchSync(JobClass, payload)` — instantiate + run `handle()`
16
+ * synchronously in the caller's process. No persistence, no
17
+ * retries, no Worker required. Useful for tests, for dev mode,
18
+ * and for callers that genuinely want the work done inline (rare
19
+ * in production — most jobs exist precisely to defer work).
20
+ */
21
+
22
+ import type { JobClass, PayloadOf } from './job.ts'
23
+
24
+ export interface DispatchOptions {
25
+ /** Named queue to dispatch onto. Default: the JobClass's `static queue` or `'default'`. */
26
+ queue?: string
27
+ /**
28
+ * Override total attempts (including the first). Default: the
29
+ * JobClass's `static maxAttempts`, or the driver default (typically 3).
30
+ */
31
+ attempts?: number
32
+ }
33
+
34
+ export interface DispatchLaterOptions extends DispatchOptions {
35
+ // No additional options today; the slot exists so future drivers can
36
+ // grow it (e.g. `priority` or `deduplicationKey`) without breaking
37
+ // the call sites.
38
+ }
39
+
40
+ export interface Queue {
41
+ /** Enqueue immediately. Returns the assigned job id (ULID). */
42
+ dispatch<TJob extends JobClass>(
43
+ jobClass: TJob,
44
+ payload: PayloadOf<TJob>,
45
+ opts?: DispatchOptions,
46
+ ): Promise<string>
47
+
48
+ /**
49
+ * Enqueue with a delay. `at` is either a `Date` (absolute wall-clock
50
+ * time) or a positive number of seconds from now. Returns the
51
+ * assigned job id (ULID).
52
+ *
53
+ * `Date` values in the past are clamped to "now" — i.e. the job
54
+ * becomes immediately eligible. Negative numbers throw.
55
+ */
56
+ dispatchLater<TJob extends JobClass>(
57
+ at: Date | number,
58
+ jobClass: TJob,
59
+ payload: PayloadOf<TJob>,
60
+ opts?: DispatchLaterOptions,
61
+ ): Promise<string>
62
+
63
+ /**
64
+ * Run the job synchronously in the caller's process. No persistence,
65
+ * no retries — if `handle()` throws, the throw propagates. Returns
66
+ * when `handle()` resolves.
67
+ */
68
+ dispatchSync<TJob extends JobClass>(jobClass: TJob, payload: PayloadOf<TJob>): Promise<void>
69
+ }