@strav/queue 0.1.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/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@strav/queue",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Background job processing and task scheduling for the Strav framework",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "bun",
9
+ "framework",
10
+ "typescript",
11
+ "strav",
12
+ "queue",
13
+ "scheduler"
14
+ ],
15
+ "files": [
16
+ "src/",
17
+ "package.json",
18
+ "tsconfig.json",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "exports": {
22
+ ".": "./src/index.ts",
23
+ "./queue": "./src/queue/index.ts",
24
+ "./queue/*": "./src/queue/*.ts",
25
+ "./scheduler": "./src/scheduler/index.ts",
26
+ "./scheduler/*": "./src/scheduler/*.ts",
27
+ "./providers": "./src/providers/index.ts",
28
+ "./providers/*": "./src/providers/*.ts"
29
+ },
30
+ "peerDependencies": {
31
+ "@strav/kernel": "0.1.0",
32
+ "@strav/database": "0.1.0"
33
+ },
34
+ "scripts": {
35
+ "test": "bun test tests/",
36
+ "typecheck": "tsc --noEmit"
37
+ }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './queue/index.ts'
2
+ export * from './scheduler/index.ts'
3
+ export * from './providers/index.ts'
@@ -0,0 +1,3 @@
1
+ export { default as QueueProvider } from './queue_provider.ts'
2
+
3
+ export type { QueueProviderOptions } from './queue_provider.ts'
@@ -0,0 +1,29 @@
1
+ import ServiceProvider from '@stravigor/kernel/core/service_provider'
2
+ import type Application from '@stravigor/kernel/core/application'
3
+ import Queue from '../queue/queue.ts'
4
+
5
+ export interface QueueProviderOptions {
6
+ /** Whether to auto-create the jobs tables. Default: `true` */
7
+ ensureTables?: boolean
8
+ }
9
+
10
+ export default class QueueProvider extends ServiceProvider {
11
+ readonly name = 'queue'
12
+ override readonly dependencies = ['database']
13
+
14
+ constructor(private options?: QueueProviderOptions) {
15
+ super()
16
+ }
17
+
18
+ override register(app: Application): void {
19
+ app.singleton(Queue)
20
+ }
21
+
22
+ override async boot(app: Application): Promise<void> {
23
+ app.resolve(Queue)
24
+
25
+ if (this.options?.ensureTables !== false) {
26
+ await Queue.ensureTables()
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,11 @@
1
+ export { default as Queue } from './queue.ts'
2
+ export { default as Worker } from './worker.ts'
3
+ export type {
4
+ JobOptions,
5
+ QueueConfig,
6
+ JobMeta,
7
+ JobRecord,
8
+ FailedJobRecord,
9
+ JobHandler,
10
+ } from './queue.ts'
11
+ export type { WorkerOptions } from './worker.ts'
@@ -0,0 +1,347 @@
1
+ import { inject } from '@stravigor/kernel/core/inject'
2
+ import Configuration from '@stravigor/kernel/config/configuration'
3
+ import Database from '@stravigor/database/database/database'
4
+ import Emitter from '@stravigor/kernel/events/emitter'
5
+ import { ConfigurationError } from '@stravigor/kernel/exceptions/errors'
6
+
7
+ export interface JobOptions {
8
+ queue?: string
9
+ delay?: number
10
+ attempts?: number
11
+ timeout?: number
12
+ }
13
+
14
+ export interface QueueConfig {
15
+ default: string
16
+ maxAttempts: number
17
+ timeout: number
18
+ retryBackoff: 'exponential' | 'linear'
19
+ sleep: number
20
+ }
21
+
22
+ /** Metadata passed to job handlers alongside the payload. */
23
+ export interface JobMeta {
24
+ id: number
25
+ queue: string
26
+ job: string
27
+ attempts: number
28
+ maxAttempts: number
29
+ }
30
+
31
+ /** A raw job row from the _strav_jobs table. */
32
+ export interface JobRecord {
33
+ id: number
34
+ queue: string
35
+ job: string
36
+ payload: unknown
37
+ attempts: number
38
+ maxAttempts: number
39
+ timeout: number
40
+ availableAt: Date
41
+ reservedAt: Date | null
42
+ createdAt: Date
43
+ }
44
+
45
+ /** A raw row from the _strav_failed_jobs table. */
46
+ export interface FailedJobRecord {
47
+ id: number
48
+ queue: string
49
+ job: string
50
+ payload: unknown
51
+ error: string
52
+ failedAt: Date
53
+ }
54
+
55
+ export type JobHandler<T = any> = (payload: T, meta: JobMeta) => void | Promise<void>
56
+
57
+ /**
58
+ * PostgreSQL-backed job queue.
59
+ *
60
+ * Resolved once via the DI container — stores the database reference
61
+ * and parsed config for Worker and all static methods.
62
+ *
63
+ * @example
64
+ * app.singleton(Queue)
65
+ * app.resolve(Queue)
66
+ * await Queue.ensureTables()
67
+ *
68
+ * Queue.handle('send-email', async (payload) => { ... })
69
+ * await Queue.push('send-email', { to: 'user@example.com' })
70
+ */
71
+ @inject
72
+ export default class Queue {
73
+ private static _db: Database
74
+ private static _config: QueueConfig
75
+ private static _handlers = new Map<string, JobHandler>()
76
+
77
+ constructor(db: Database, config: Configuration) {
78
+ Queue._db = db
79
+ Queue._config = {
80
+ default: config.get('queue.default', 'default') as string,
81
+ maxAttempts: config.get('queue.maxAttempts', 3) as number,
82
+ timeout: config.get('queue.timeout', 60_000) as number,
83
+ retryBackoff: config.get('queue.retryBackoff', 'exponential') as 'exponential' | 'linear',
84
+ sleep: config.get('queue.sleep', 1000) as number,
85
+ }
86
+ }
87
+
88
+ static get db(): Database {
89
+ if (!Queue._db)
90
+ throw new ConfigurationError(
91
+ 'Queue not configured. Resolve Queue through the container first.'
92
+ )
93
+ return Queue._db
94
+ }
95
+
96
+ static get config(): QueueConfig {
97
+ return Queue._config
98
+ }
99
+
100
+ static get handlers(): Map<string, JobHandler> {
101
+ return Queue._handlers
102
+ }
103
+
104
+ /** Create the internal jobs and failed_jobs tables if they don't exist. */
105
+ static async ensureTables(): Promise<void> {
106
+ const sql = Queue.db.sql
107
+
108
+ await sql`
109
+ CREATE TABLE IF NOT EXISTS "_strav_jobs" (
110
+ "id" BIGSERIAL PRIMARY KEY,
111
+ "queue" VARCHAR(255) NOT NULL DEFAULT 'default',
112
+ "job" VARCHAR(255) NOT NULL,
113
+ "payload" JSONB NOT NULL DEFAULT '{}',
114
+ "attempts" INT NOT NULL DEFAULT 0,
115
+ "max_attempts" INT NOT NULL DEFAULT 3,
116
+ "timeout" INT NOT NULL DEFAULT 60000,
117
+ "available_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
118
+ "reserved_at" TIMESTAMPTZ,
119
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
120
+ )
121
+ `
122
+
123
+ await sql`
124
+ CREATE INDEX IF NOT EXISTS "idx_strav_jobs_queue_available"
125
+ ON "_strav_jobs" ("queue", "available_at")
126
+ WHERE "reserved_at" IS NULL
127
+ `
128
+
129
+ await sql`
130
+ CREATE TABLE IF NOT EXISTS "_strav_failed_jobs" (
131
+ "id" BIGSERIAL PRIMARY KEY,
132
+ "queue" VARCHAR(255) NOT NULL,
133
+ "job" VARCHAR(255) NOT NULL,
134
+ "payload" JSONB NOT NULL DEFAULT '{}',
135
+ "error" TEXT NOT NULL,
136
+ "failed_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
137
+ )
138
+ `
139
+ }
140
+
141
+ /** Register a handler for a named job. */
142
+ static handle<T = any>(name: string, handler: JobHandler<T>): void {
143
+ Queue._handlers.set(name, handler)
144
+ }
145
+
146
+ /**
147
+ * Push a job onto the queue. Returns the job ID.
148
+ */
149
+ static async push<T = any>(name: string, payload: T, options?: JobOptions): Promise<number> {
150
+ const sql = Queue.db.sql
151
+ const queue = options?.queue ?? Queue._config.default
152
+ const maxAttempts = options?.attempts ?? Queue._config.maxAttempts
153
+ const timeout = options?.timeout ?? Queue._config.timeout
154
+ const availableAt = options?.delay ? new Date(Date.now() + options.delay) : new Date()
155
+
156
+ const rows = await sql`
157
+ INSERT INTO "_strav_jobs" ("queue", "job", "payload", "max_attempts", "timeout", "available_at")
158
+ VALUES (${queue}, ${name}, ${JSON.stringify(payload)}, ${maxAttempts}, ${timeout}, ${availableAt})
159
+ RETURNING "id"
160
+ `
161
+
162
+ const id = Number((rows[0] as Record<string, unknown>).id)
163
+
164
+ if (Emitter.listenerCount('queue:dispatched') > 0) {
165
+ Emitter.emit('queue:dispatched', { id, name, queue, payload }).catch(() => {})
166
+ }
167
+
168
+ return id
169
+ }
170
+
171
+ /**
172
+ * Create a listener function suitable for Emitter.on().
173
+ * When the event fires, the payload is pushed onto the queue.
174
+ *
175
+ * @example
176
+ * Emitter.on('user.registered', Queue.listener('send-welcome-email'))
177
+ */
178
+ static listener(jobName: string, options?: JobOptions): (payload: any) => Promise<void> {
179
+ return async (payload: any) => {
180
+ await Queue.push(jobName, payload, options)
181
+ }
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Introspection / Management
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /** Count of pending (unreserved) jobs in a queue. */
189
+ static async size(queue?: string): Promise<number> {
190
+ const sql = Queue.db.sql
191
+ const q = queue ?? Queue._config.default
192
+
193
+ const rows = await sql`
194
+ SELECT COUNT(*)::int AS count FROM "_strav_jobs"
195
+ WHERE "queue" = ${q} AND "reserved_at" IS NULL
196
+ `
197
+ return (rows[0] as Record<string, unknown>).count as number
198
+ }
199
+
200
+ /** List pending jobs, most recent first. */
201
+ static async pending(queue?: string, limit = 25): Promise<JobRecord[]> {
202
+ const sql = Queue.db.sql
203
+ const q = queue ?? Queue._config.default
204
+
205
+ const rows = await sql`
206
+ SELECT * FROM "_strav_jobs"
207
+ WHERE "queue" = ${q}
208
+ ORDER BY "available_at" ASC
209
+ LIMIT ${limit}
210
+ `
211
+ return rows.map(hydrateJob)
212
+ }
213
+
214
+ /** List failed jobs, most recent first. */
215
+ static async failed(queue?: string, limit = 25): Promise<FailedJobRecord[]> {
216
+ const sql = Queue.db.sql
217
+
218
+ if (queue) {
219
+ const rows = await sql`
220
+ SELECT * FROM "_strav_failed_jobs"
221
+ WHERE "queue" = ${queue}
222
+ ORDER BY "failed_at" DESC
223
+ LIMIT ${limit}
224
+ `
225
+ return rows.map(hydrateFailedJob)
226
+ }
227
+
228
+ const rows = await sql`
229
+ SELECT * FROM "_strav_failed_jobs"
230
+ ORDER BY "failed_at" DESC
231
+ LIMIT ${limit}
232
+ `
233
+ return rows.map(hydrateFailedJob)
234
+ }
235
+
236
+ /** Delete all pending jobs in a queue. Returns the number deleted. */
237
+ static async clear(queue?: string): Promise<number> {
238
+ const sql = Queue.db.sql
239
+ const q = queue ?? Queue._config.default
240
+
241
+ const rows = await sql`
242
+ DELETE FROM "_strav_jobs" WHERE "queue" = ${q}
243
+ `
244
+ return rows.count
245
+ }
246
+
247
+ /** Move all failed jobs back to the jobs table. Returns the number retried. */
248
+ static async retryFailed(queue?: string): Promise<number> {
249
+ const sql = Queue.db.sql
250
+
251
+ if (queue) {
252
+ const failed = await sql`
253
+ DELETE FROM "_strav_failed_jobs" WHERE "queue" = ${queue} RETURNING *
254
+ `
255
+ for (const row of failed) {
256
+ const r = row as Record<string, unknown>
257
+ await sql`
258
+ INSERT INTO "_strav_jobs" ("queue", "job", "payload", "max_attempts")
259
+ VALUES (${r.queue}, ${r.job}, ${typeof r.payload === 'string' ? r.payload : JSON.stringify(r.payload)}, ${Queue._config.maxAttempts})
260
+ `
261
+ }
262
+ return failed.length
263
+ }
264
+
265
+ const failed = await sql`
266
+ DELETE FROM "_strav_failed_jobs" RETURNING *
267
+ `
268
+ for (const row of failed) {
269
+ const r = row as Record<string, unknown>
270
+ await sql`
271
+ INSERT INTO "_strav_jobs" ("queue", "job", "payload", "max_attempts")
272
+ VALUES (${r.queue}, ${r.job}, ${typeof r.payload === 'string' ? r.payload : JSON.stringify(r.payload)}, ${Queue._config.maxAttempts})
273
+ `
274
+ }
275
+ return failed.length
276
+ }
277
+
278
+ /** Delete all failed jobs for a queue (or all queues). Returns the number deleted. */
279
+ static async clearFailed(queue?: string): Promise<number> {
280
+ const sql = Queue.db.sql
281
+
282
+ if (queue) {
283
+ const rows = await sql`
284
+ DELETE FROM "_strav_failed_jobs" WHERE "queue" = ${queue}
285
+ `
286
+ return rows.count
287
+ }
288
+
289
+ const rows = await sql`
290
+ DELETE FROM "_strav_failed_jobs"
291
+ `
292
+ return rows.count
293
+ }
294
+
295
+ /** Delete all jobs and failed jobs across all queues. For dev/test only. */
296
+ static async flush(): Promise<void> {
297
+ const sql = Queue.db.sql
298
+ await sql`DELETE FROM "_strav_jobs"`
299
+ await sql`DELETE FROM "_strav_failed_jobs"`
300
+ }
301
+
302
+ /** Reset static state. For testing only. */
303
+ static reset(): void {
304
+ Queue._handlers.clear()
305
+ }
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Hydration helpers
310
+ // ---------------------------------------------------------------------------
311
+
312
+ function parsePayload(raw: unknown): unknown {
313
+ if (typeof raw === 'string') {
314
+ try {
315
+ return JSON.parse(raw)
316
+ } catch {
317
+ return raw
318
+ }
319
+ }
320
+ return raw
321
+ }
322
+
323
+ export function hydrateJob(row: Record<string, unknown>): JobRecord {
324
+ return {
325
+ id: Number(row.id),
326
+ queue: row.queue as string,
327
+ job: row.job as string,
328
+ payload: parsePayload(row.payload),
329
+ attempts: row.attempts as number,
330
+ maxAttempts: row.max_attempts as number,
331
+ timeout: row.timeout as number,
332
+ availableAt: row.available_at as Date,
333
+ reservedAt: (row.reserved_at as Date) ?? null,
334
+ createdAt: row.created_at as Date,
335
+ }
336
+ }
337
+
338
+ function hydrateFailedJob(row: Record<string, unknown>): FailedJobRecord {
339
+ return {
340
+ id: Number(row.id),
341
+ queue: row.queue as string,
342
+ job: row.job as string,
343
+ payload: parsePayload(row.payload),
344
+ error: row.error as string,
345
+ failedAt: row.failed_at as Date,
346
+ }
347
+ }
@@ -0,0 +1,221 @@
1
+ import Queue, { hydrateJob } from './queue.ts'
2
+ import Emitter from '@stravigor/kernel/events/emitter'
3
+ import type { JobRecord, JobMeta } from './queue.ts'
4
+
5
+ export interface WorkerOptions {
6
+ queue?: string
7
+ sleep?: number
8
+ }
9
+
10
+ /**
11
+ * Processes jobs from the queue.
12
+ *
13
+ * Uses `SELECT ... FOR UPDATE SKIP LOCKED` for safe concurrent polling.
14
+ * Supports job timeouts, exponential/linear backoff for retries,
15
+ * and graceful shutdown on SIGINT/SIGTERM.
16
+ *
17
+ * @example
18
+ * const worker = new Worker({ queue: 'emails', sleep: 500 })
19
+ * await worker.start() // blocks until worker.stop() is called
20
+ */
21
+ export default class Worker {
22
+ private running = false
23
+ private processing = false
24
+ private queue: string
25
+ private sleep: number
26
+
27
+ constructor(options: WorkerOptions = {}) {
28
+ this.queue = options.queue ?? Queue.config.default
29
+ this.sleep = options.sleep ?? Queue.config.sleep
30
+ }
31
+
32
+ /** Start the worker loop. Blocks until stop() is called. */
33
+ async start(): Promise<void> {
34
+ this.running = true
35
+
36
+ const onSignal = () => this.stop()
37
+ process.on('SIGINT', onSignal)
38
+ process.on('SIGTERM', onSignal)
39
+
40
+ let pollCount = 0
41
+
42
+ try {
43
+ while (this.running) {
44
+ // Periodically release stale jobs (every 60 cycles)
45
+ if (pollCount % 60 === 0) {
46
+ await this.releaseStaleJobs()
47
+ }
48
+ pollCount++
49
+
50
+ const job = await this.fetchNext()
51
+ if (job) {
52
+ this.processing = true
53
+ await this.process(job)
54
+ this.processing = false
55
+ } else {
56
+ await Bun.sleep(this.sleep)
57
+ }
58
+ }
59
+ } finally {
60
+ process.off('SIGINT', onSignal)
61
+ process.off('SIGTERM', onSignal)
62
+ }
63
+ }
64
+
65
+ /** Signal the worker to stop after the current job completes. */
66
+ stop(): void {
67
+ this.running = false
68
+ }
69
+
70
+ /** Whether the worker is currently processing a job. */
71
+ get busy(): boolean {
72
+ return this.processing
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Internal
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Fetch the next available job using FOR UPDATE SKIP LOCKED.
81
+ * Atomically reserves it by setting reserved_at and incrementing attempts.
82
+ */
83
+ private async fetchNext(): Promise<JobRecord | null> {
84
+ const sql = Queue.db.sql
85
+
86
+ const rows = await sql.begin(async (tx: any) => {
87
+ const result = await tx`
88
+ SELECT * FROM "_strav_jobs"
89
+ WHERE "queue" = ${this.queue}
90
+ AND "available_at" <= NOW()
91
+ AND "reserved_at" IS NULL
92
+ ORDER BY "available_at" ASC
93
+ LIMIT 1
94
+ FOR UPDATE SKIP LOCKED
95
+ `
96
+ if (result.length === 0) return []
97
+
98
+ const job = result[0] as Record<string, unknown>
99
+ await tx`
100
+ UPDATE "_strav_jobs"
101
+ SET "reserved_at" = NOW(), "attempts" = "attempts" + 1
102
+ WHERE "id" = ${job.id}
103
+ `
104
+ return [{ ...job, attempts: (job.attempts as number) + 1 }]
105
+ })
106
+
107
+ return rows.length > 0 ? hydrateJob(rows[0] as Record<string, unknown>) : null
108
+ }
109
+
110
+ /** Process a single job: run handler, handle success/failure. */
111
+ private async process(job: JobRecord): Promise<void> {
112
+ const handler = Queue.handlers.get(job.job)
113
+
114
+ if (!handler) {
115
+ await this.fail(job, new Error(`No handler registered for job "${job.job}"`))
116
+ return
117
+ }
118
+
119
+ const meta: JobMeta = {
120
+ id: job.id,
121
+ queue: job.queue,
122
+ job: job.job,
123
+ attempts: job.attempts,
124
+ maxAttempts: job.maxAttempts,
125
+ }
126
+
127
+ const start = performance.now()
128
+
129
+ try {
130
+ await Promise.race([
131
+ Promise.resolve(handler(job.payload, meta)),
132
+ new Promise<never>((_, reject) =>
133
+ setTimeout(
134
+ () => reject(new Error(`Job "${job.job}" timed out after ${job.timeout}ms`)),
135
+ job.timeout
136
+ )
137
+ ),
138
+ ])
139
+ await this.complete(job)
140
+
141
+ if (Emitter.listenerCount('queue:processed') > 0) {
142
+ const duration = performance.now() - start
143
+ Emitter.emit('queue:processed', {
144
+ job: job.job,
145
+ id: job.id,
146
+ queue: job.queue,
147
+ duration,
148
+ }).catch(() => {})
149
+ }
150
+ } catch (error) {
151
+ const err = error instanceof Error ? error : new Error(String(error))
152
+ if (job.attempts >= job.maxAttempts) {
153
+ await this.fail(job, err)
154
+
155
+ if (Emitter.listenerCount('queue:failed') > 0) {
156
+ const duration = performance.now() - start
157
+ Emitter.emit('queue:failed', {
158
+ job: job.job,
159
+ id: job.id,
160
+ queue: job.queue,
161
+ error: err.message,
162
+ duration,
163
+ }).catch(() => {})
164
+ }
165
+ } else {
166
+ await this.release(job)
167
+ }
168
+ }
169
+ }
170
+
171
+ /** Delete a completed job. */
172
+ private async complete(job: JobRecord): Promise<void> {
173
+ await Queue.db.sql`DELETE FROM "_strav_jobs" WHERE "id" = ${job.id}`
174
+ }
175
+
176
+ /** Move a job to the failed_jobs table and delete from jobs. */
177
+ private async fail(job: JobRecord, error: Error): Promise<void> {
178
+ const sql = Queue.db.sql
179
+ await sql.begin(async (tx: any) => {
180
+ await tx`
181
+ INSERT INTO "_strav_failed_jobs" ("queue", "job", "payload", "error")
182
+ VALUES (${job.queue}, ${job.job}, ${JSON.stringify(job.payload)}, ${error.message})
183
+ `
184
+ await tx`DELETE FROM "_strav_jobs" WHERE "id" = ${job.id}`
185
+ })
186
+ }
187
+
188
+ /** Release a job back to the queue with incremented backoff delay. */
189
+ private async release(job: JobRecord): Promise<void> {
190
+ const delay = this.backoffDelay(job.attempts)
191
+ const availableAt = new Date(Date.now() + delay)
192
+
193
+ await Queue.db.sql`
194
+ UPDATE "_strav_jobs"
195
+ SET "reserved_at" = NULL, "available_at" = ${availableAt}
196
+ WHERE "id" = ${job.id}
197
+ `
198
+ }
199
+
200
+ /** Calculate backoff delay in ms based on attempt number. */
201
+ backoffDelay(attempts: number): number {
202
+ if (Queue.config.retryBackoff === 'linear') {
203
+ return attempts * 5_000
204
+ }
205
+ // Exponential: 2^attempts * 1000, with jitter
206
+ const base = Math.pow(2, attempts) * 1000
207
+ const jitter = Math.random() * 1000
208
+ return base + jitter
209
+ }
210
+
211
+ /** Release jobs that have been reserved for too long (crashed workers). */
212
+ private async releaseStaleJobs(): Promise<void> {
213
+ await Queue.db.sql`
214
+ UPDATE "_strav_jobs"
215
+ SET "reserved_at" = NULL
216
+ WHERE "reserved_at" IS NOT NULL
217
+ AND "queue" = ${this.queue}
218
+ AND "reserved_at" < NOW() - MAKE_INTERVAL(secs => "timeout" * 2.0 / 1000)
219
+ `
220
+ }
221
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Minimal 5-field cron expression parser.
3
+ *
4
+ * Supports: `*`, exact (`5`), range (`1-5`), list (`1,3,5`),
5
+ * step (`*​/10`), and range+step (`1-30/5`).
6
+ *
7
+ * Fields: minute (0–59), hour (0–23), day-of-month (1–31),
8
+ * month (1–12), day-of-week (0–6, 0 = Sunday).
9
+ */
10
+
11
+ export interface CronExpression {
12
+ minute: number[]
13
+ hour: number[]
14
+ dayOfMonth: number[]
15
+ month: number[]
16
+ dayOfWeek: number[]
17
+ }
18
+
19
+ const FIELD_RANGES: [number, number][] = [
20
+ [0, 59], // minute
21
+ [0, 23], // hour
22
+ [1, 31], // day of month
23
+ [1, 12], // month
24
+ [0, 6], // day of week
25
+ ]
26
+
27
+ // Parse a 5-field cron string into expanded numeric arrays.
28
+ export function parseCron(expression: string): CronExpression {
29
+ const parts = expression.trim().split(/\s+/)
30
+ if (parts.length !== 5) {
31
+ throw new Error(
32
+ `Invalid cron expression "${expression}": expected 5 fields, got ${parts.length}`
33
+ )
34
+ }
35
+
36
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts.map((part, i) =>
37
+ parseField(part, FIELD_RANGES[i]![0], FIELD_RANGES[i]![1])
38
+ )
39
+
40
+ return {
41
+ minute: minute!,
42
+ hour: hour!,
43
+ dayOfMonth: dayOfMonth!,
44
+ month: month!,
45
+ dayOfWeek: dayOfWeek!,
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Check if a Date matches a parsed cron expression.
51
+ *
52
+ * Standard cron rule: if both day-of-month and day-of-week are restricted
53
+ * (not wildcards), either match satisfies the condition.
54
+ */
55
+ export function cronMatches(cron: CronExpression, date: Date): boolean {
56
+ const minute = date.getUTCMinutes()
57
+ const hour = date.getUTCHours()
58
+ const dayOfMonth = date.getUTCDate()
59
+ const month = date.getUTCMonth() + 1
60
+ const dayOfWeek = date.getUTCDay()
61
+
62
+ if (!cron.minute.includes(minute)) return false
63
+ if (!cron.hour.includes(hour)) return false
64
+ if (!cron.month.includes(month)) return false
65
+
66
+ // Standard cron: day-of-month and day-of-week are OR'd when both are restricted
67
+ const domRestricted = cron.dayOfMonth.length < 31
68
+ const dowRestricted = cron.dayOfWeek.length < 7
69
+
70
+ if (domRestricted && dowRestricted) {
71
+ return cron.dayOfMonth.includes(dayOfMonth) || cron.dayOfWeek.includes(dayOfWeek)
72
+ }
73
+
74
+ if (!cron.dayOfMonth.includes(dayOfMonth)) return false
75
+ if (!cron.dayOfWeek.includes(dayOfWeek)) return false
76
+
77
+ return true
78
+ }
79
+
80
+ /**
81
+ * Compute the next Date (UTC) after `after` that matches the expression.
82
+ * Searches up to 2 years ahead to prevent infinite loops.
83
+ */
84
+ export function nextCronDate(cron: CronExpression, after: Date): Date {
85
+ const date = new Date(after.getTime())
86
+ // Advance to the next whole minute
87
+ date.setUTCSeconds(0, 0)
88
+ date.setUTCMinutes(date.getUTCMinutes() + 1)
89
+
90
+ const limit = after.getTime() + 2 * 365 * 24 * 60 * 60 * 1000
91
+
92
+ while (date.getTime() <= limit) {
93
+ if (cronMatches(cron, date)) return date
94
+ date.setUTCMinutes(date.getUTCMinutes() + 1)
95
+ }
96
+
97
+ throw new Error('Could not find next matching date within 2 years')
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Field parsing
102
+ // ---------------------------------------------------------------------------
103
+
104
+ function parseField(field: string, min: number, max: number): number[] {
105
+ const values = new Set<number>()
106
+
107
+ for (const part of field.split(',')) {
108
+ const stepMatch = part.match(/^(.+)\/(\d+)$/)
109
+
110
+ if (stepMatch) {
111
+ const [, rangePart, stepStr] = stepMatch
112
+ const step = parseInt(stepStr!, 10)
113
+ if (step <= 0) throw new Error(`Invalid step value: ${stepStr}`)
114
+
115
+ const [start, end] = rangePart === '*' ? [min, max] : parseRange(rangePart!, min, max)
116
+ for (let i = start; i <= end; i += step) {
117
+ values.add(i)
118
+ }
119
+ } else if (part === '*') {
120
+ for (let i = min; i <= max; i++) values.add(i)
121
+ } else if (part.includes('-')) {
122
+ const [start, end] = parseRange(part, min, max)
123
+ for (let i = start; i <= end; i++) values.add(i)
124
+ } else {
125
+ const n = parseInt(part, 10)
126
+ if (isNaN(n) || n < min || n > max) {
127
+ throw new Error(`Invalid cron value "${part}": must be ${min}–${max}`)
128
+ }
129
+ values.add(n)
130
+ }
131
+ }
132
+
133
+ return [...values].sort((a, b) => a - b)
134
+ }
135
+
136
+ function parseRange(part: string, min: number, max: number): [number, number] {
137
+ const [startStr, endStr] = part.split('-')
138
+ const start = parseInt(startStr!, 10)
139
+ const end = parseInt(endStr!, 10)
140
+
141
+ if (isNaN(start) || isNaN(end) || start < min || end > max || start > end) {
142
+ throw new Error(`Invalid cron range "${part}": must be within ${min}–${max}`)
143
+ }
144
+
145
+ return [start, end]
146
+ }
@@ -0,0 +1,8 @@
1
+ export { default as Scheduler } from './scheduler.ts'
2
+ export { Schedule } from './schedule.ts'
3
+ export { default as SchedulerRunner } from './runner.ts'
4
+ export { parseCron, cronMatches, nextCronDate } from './cron.ts'
5
+ export type { CronExpression } from './cron.ts'
6
+ export { TimeUnit } from './schedule.ts'
7
+ export type { TaskHandler } from './schedule.ts'
8
+ export type { RunnerOptions } from './runner.ts'
@@ -0,0 +1,116 @@
1
+ import Scheduler from './scheduler.ts'
2
+ import type { Schedule } from './schedule.ts'
3
+
4
+ export interface RunnerOptions {
5
+ /** Timezone for schedule evaluation. @default 'UTC' */
6
+ timezone?: string
7
+ }
8
+
9
+ /**
10
+ * Long-running scheduler loop.
11
+ *
12
+ * Aligns to minute boundaries (standard cron behaviour), checks which
13
+ * tasks are due, and executes them concurrently. Supports graceful
14
+ * shutdown via SIGINT/SIGTERM and per-task overlap prevention.
15
+ *
16
+ * @example
17
+ * const runner = new SchedulerRunner()
18
+ * await runner.start() // blocks until runner.stop()
19
+ */
20
+ export default class SchedulerRunner {
21
+ private running = false
22
+ private active = new Set<string>()
23
+
24
+ /** Start the scheduler loop. Blocks until stop() is called. */
25
+ async start(): Promise<void> {
26
+ this.running = true
27
+
28
+ const onSignal = () => this.stop()
29
+ process.on('SIGINT', onSignal)
30
+ process.on('SIGTERM', onSignal)
31
+
32
+ try {
33
+ while (this.running) {
34
+ await this.sleepUntilNextMinute()
35
+ if (!this.running) break
36
+
37
+ const now = new Date()
38
+ const due = Scheduler.due(now)
39
+
40
+ if (due.length > 0) {
41
+ await this.runAll(due)
42
+ }
43
+ }
44
+
45
+ // Wait for active tasks to finish before exiting
46
+ if (this.active.size > 0) {
47
+ await this.waitForActive()
48
+ }
49
+ } finally {
50
+ process.off('SIGINT', onSignal)
51
+ process.off('SIGTERM', onSignal)
52
+ }
53
+ }
54
+
55
+ /** Signal the runner to stop after the current tick completes. */
56
+ stop(): void {
57
+ this.running = false
58
+ }
59
+
60
+ /** Number of tasks currently executing. */
61
+ get activeCount(): number {
62
+ return this.active.size
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Internal
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /** Sleep until the start of the next minute (XX:XX:00.000). */
70
+ private async sleepUntilNextMinute(): Promise<void> {
71
+ const now = Date.now()
72
+ const nextMinute = Math.ceil(now / 60_000) * 60_000
73
+ const delay = nextMinute - now
74
+
75
+ // Minimum 1s, maximum 60s — avoid sleeping 0ms on exact boundaries
76
+ const ms = Math.max(1_000, Math.min(delay, 60_000))
77
+ await Bun.sleep(ms)
78
+ }
79
+
80
+ /** Execute all due tasks concurrently. */
81
+ private async runAll(tasks: Schedule[]): Promise<void> {
82
+ const promises: Promise<void>[] = []
83
+
84
+ for (const task of tasks) {
85
+ if (task.preventsOverlap && this.active.has(task.name)) {
86
+ continue
87
+ }
88
+
89
+ promises.push(this.runTask(task))
90
+ }
91
+
92
+ await Promise.allSettled(promises)
93
+ }
94
+
95
+ /** Execute a single task with overlap tracking and error handling. */
96
+ private async runTask(task: Schedule): Promise<void> {
97
+ this.active.add(task.name)
98
+ try {
99
+ await task.handler()
100
+ } catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error)
102
+ console.error(`[scheduler] Task "${task.name}" failed: ${message}`)
103
+ } finally {
104
+ this.active.delete(task.name)
105
+ }
106
+ }
107
+
108
+ /** Wait for all active tasks to complete (for graceful shutdown). */
109
+ private async waitForActive(): Promise<void> {
110
+ const maxWait = 30_000
111
+ const start = Date.now()
112
+ while (this.active.size > 0 && Date.now() - start < maxWait) {
113
+ await Bun.sleep(100)
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,262 @@
1
+ import { parseCron, cronMatches } from './cron.ts'
2
+ import type { CronExpression } from './cron.ts'
3
+
4
+ export type TaskHandler = () => void | Promise<void>
5
+
6
+ export enum TimeUnit {
7
+ Minutes = 'minutes',
8
+ Hours = 'hours',
9
+ Days = 'days',
10
+ }
11
+
12
+ const DAY_NAMES: Record<string, number> = {
13
+ sunday: 0,
14
+ sun: 0,
15
+ monday: 1,
16
+ mon: 1,
17
+ tuesday: 2,
18
+ tue: 2,
19
+ wednesday: 3,
20
+ wed: 3,
21
+ thursday: 4,
22
+ thu: 4,
23
+ friday: 5,
24
+ fri: 5,
25
+ saturday: 6,
26
+ sat: 6,
27
+ }
28
+
29
+ /**
30
+ * A scheduled task definition with a fluent configuration API.
31
+ *
32
+ * @example
33
+ * new Schedule('cleanup', handler).dailyAt('02:30').withoutOverlapping()
34
+ */
35
+ export class Schedule {
36
+ readonly name: string
37
+ readonly handler: TaskHandler
38
+
39
+ private _cron: CronExpression | null = null
40
+ private _noOverlap = false
41
+
42
+ private _sporadicMin: number | null = null
43
+ private _sporadicMax: number | null = null
44
+ private _sporadicUnit: TimeUnit | null = null
45
+ private _nextRunAt: Date | null = null
46
+
47
+ constructor(name: string, handler: TaskHandler) {
48
+ this.name = name
49
+ this.handler = handler
50
+ }
51
+
52
+ // ── Raw cron ──────────────────────────────────────────────────────────────
53
+
54
+ /** Set a raw 5-field cron expression. */
55
+ cron(expression: string): this {
56
+ this._cron = parseCron(expression)
57
+ return this
58
+ }
59
+
60
+ // ── Minute-based ──────────────────────────────────────────────────────────
61
+
62
+ /** Run every minute. */
63
+ everyMinute(): this {
64
+ return this.cron('* * * * *')
65
+ }
66
+
67
+ /** Run every 2 minutes. */
68
+ everyTwoMinutes(): this {
69
+ return this.cron('*/2 * * * *')
70
+ }
71
+
72
+ /** Run every 5 minutes. */
73
+ everyFiveMinutes(): this {
74
+ return this.cron('*/5 * * * *')
75
+ }
76
+
77
+ /** Run every 10 minutes. */
78
+ everyTenMinutes(): this {
79
+ return this.cron('*/10 * * * *')
80
+ }
81
+
82
+ /** Run every 15 minutes. */
83
+ everyFifteenMinutes(): this {
84
+ return this.cron('*/15 * * * *')
85
+ }
86
+
87
+ /** Run every 30 minutes. */
88
+ everyThirtyMinutes(): this {
89
+ return this.cron('*/30 * * * *')
90
+ }
91
+
92
+ // ── Hourly ────────────────────────────────────────────────────────────────
93
+
94
+ /** Run once per hour at minute 0. */
95
+ hourly(): this {
96
+ return this.cron('0 * * * *')
97
+ }
98
+
99
+ /** Run once per hour at the given minute. */
100
+ hourlyAt(minute: number): this {
101
+ return this.cron(`${minute} * * * *`)
102
+ }
103
+
104
+ // ── Daily ─────────────────────────────────────────────────────────────────
105
+
106
+ /** Run once per day at midnight. */
107
+ daily(): this {
108
+ return this.cron('0 0 * * *')
109
+ }
110
+
111
+ /** Run once per day at the given time (HH:MM). */
112
+ dailyAt(time: string): this {
113
+ const [hour, minute] = parseTime(time)
114
+ return this.cron(`${minute} ${hour} * * *`)
115
+ }
116
+
117
+ /** Run twice per day at the given hours (minute 0). */
118
+ twiceDaily(hour1: number, hour2: number): this {
119
+ return this.cron(`0 ${hour1},${hour2} * * *`)
120
+ }
121
+
122
+ // ── Weekly ────────────────────────────────────────────────────────────────
123
+
124
+ /** Run once per week on Sunday at midnight. */
125
+ weekly(): this {
126
+ return this.cron('0 0 * * 0')
127
+ }
128
+
129
+ /** Run once per week on the given day and optional time. */
130
+ weeklyOn(day: string | number, time?: string): this {
131
+ const dow = typeof day === 'string' ? dayToNumber(day) : day
132
+ const [hour, minute] = time ? parseTime(time) : [0, 0]
133
+ return this.cron(`${minute} ${hour} * * ${dow}`)
134
+ }
135
+
136
+ // ── Monthly ───────────────────────────────────────────────────────────────
137
+
138
+ /** Run once per month on the 1st at midnight. */
139
+ monthly(): this {
140
+ return this.cron('0 0 1 * *')
141
+ }
142
+
143
+ /** Run once per month on the given day and optional time. */
144
+ monthlyOn(day: number, time?: string): this {
145
+ const [hour, minute] = time ? parseTime(time) : [0, 0]
146
+ return this.cron(`${minute} ${hour} ${day} * *`)
147
+ }
148
+
149
+ // ── Sporadic ──────────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * Run at random intervals between `min` and `max` in the given unit.
153
+ * Simulates human-like, non-periodic scheduling.
154
+ *
155
+ * @example
156
+ * Scheduler.task('scrape', handler).sporadically(5, 30, TimeUnit.Minutes)
157
+ */
158
+ sporadically(min: number, max: number, unit: TimeUnit): this {
159
+ if (min < 0 || max < 0) {
160
+ throw new Error('sporadically: min and max must be non-negative')
161
+ }
162
+ if (min >= max) {
163
+ throw new Error('sporadically: min must be less than max')
164
+ }
165
+
166
+ this._sporadicMin = min
167
+ this._sporadicMax = max
168
+ this._sporadicUnit = unit
169
+ this._cron = null
170
+ this._nextRunAt = this.computeNextRun(new Date())
171
+ return this
172
+ }
173
+
174
+ // ── Options ───────────────────────────────────────────────────────────────
175
+
176
+ /** Prevent overlapping runs within this process. */
177
+ withoutOverlapping(): this {
178
+ this._noOverlap = true
179
+ return this
180
+ }
181
+
182
+ // ── Internal ──────────────────────────────────────────────────────────────
183
+
184
+ /** Check if this task is due at the given Date (evaluated in UTC). */
185
+ isDue(now: Date): boolean {
186
+ if (this._sporadicMin !== null) {
187
+ if (!this._nextRunAt) return false
188
+ if (now >= this._nextRunAt) {
189
+ this._nextRunAt = this.computeNextRun(now)
190
+ return true
191
+ }
192
+ return false
193
+ }
194
+
195
+ if (!this._cron) return false
196
+ return cronMatches(this._cron, now)
197
+ }
198
+
199
+ /** Whether overlap prevention is enabled. */
200
+ get preventsOverlap(): boolean {
201
+ return this._noOverlap
202
+ }
203
+
204
+ /** The parsed cron expression (for testing/debugging). */
205
+ get expression(): CronExpression | null {
206
+ return this._cron
207
+ }
208
+
209
+ /** The next scheduled run time (for sporadic schedules). */
210
+ get nextRunAt(): Date | null {
211
+ return this._nextRunAt
212
+ }
213
+
214
+ /** Compute the next random run time from a reference point. */
215
+ private computeNextRun(from: Date): Date {
216
+ const ms = unitToMs(this._sporadicUnit!)
217
+ const minMs = this._sporadicMin! * ms
218
+ const maxMs = this._sporadicMax! * ms
219
+ const delay = minMs + Math.random() * (maxMs - minMs)
220
+ return new Date(from.getTime() + delay)
221
+ }
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Helpers
226
+ // ---------------------------------------------------------------------------
227
+
228
+ function parseTime(time: string): [number, number] {
229
+ const parts = time.split(':')
230
+ if (parts.length !== 2) {
231
+ throw new Error(`Invalid time format "${time}": expected HH:MM`)
232
+ }
233
+ const hour = parseInt(parts[0]!, 10)
234
+ const minute = parseInt(parts[1]!, 10)
235
+ if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
236
+ throw new Error(`Invalid time "${time}": hour must be 0–23, minute must be 0–59`)
237
+ }
238
+ return [hour, minute]
239
+ }
240
+
241
+ function unitToMs(unit: TimeUnit): number {
242
+ switch (unit) {
243
+ case TimeUnit.Minutes:
244
+ return 60_000
245
+ case TimeUnit.Hours:
246
+ return 3_600_000
247
+ case TimeUnit.Days:
248
+ return 86_400_000
249
+ }
250
+ }
251
+
252
+ function dayToNumber(day: string): number {
253
+ const n = DAY_NAMES[day.toLowerCase()]
254
+ if (n === undefined) {
255
+ throw new Error(
256
+ `Invalid day name "${day}": expected one of ${Object.keys(DAY_NAMES)
257
+ .filter((_, i) => i % 2 === 0)
258
+ .join(', ')}`
259
+ )
260
+ }
261
+ return n
262
+ }
@@ -0,0 +1,47 @@
1
+ import { Schedule } from './schedule.ts'
2
+ import type { TaskHandler } from './schedule.ts'
3
+
4
+ /**
5
+ * Static task registry for periodic jobs.
6
+ *
7
+ * No DI, no database — tasks are registered in code and evaluated
8
+ * in-memory by the {@link SchedulerRunner}.
9
+ *
10
+ * @example
11
+ * Scheduler.task('cleanup:sessions', async () => {
12
+ * await db.sql`DELETE FROM "_strav_sessions" WHERE "expires_at" < NOW()`
13
+ * }).hourly()
14
+ *
15
+ * Scheduler.task('reports:daily', () => generateDailyReport()).dailyAt('02:00')
16
+ */
17
+ export default class Scheduler {
18
+ private static _tasks: Schedule[] = []
19
+
20
+ /**
21
+ * Register a periodic task. Returns the {@link Schedule} for fluent configuration.
22
+ *
23
+ * @example
24
+ * Scheduler.task('prune-cache', () => cache.flush()).everyFifteenMinutes()
25
+ */
26
+ static task(name: string, handler: TaskHandler): Schedule {
27
+ const schedule = new Schedule(name, handler)
28
+ Scheduler._tasks.push(schedule)
29
+ return schedule
30
+ }
31
+
32
+ /** All registered tasks. */
33
+ static get tasks(): readonly Schedule[] {
34
+ return Scheduler._tasks
35
+ }
36
+
37
+ /** Return tasks that are due at the given time (defaults to now, UTC). */
38
+ static due(now?: Date): Schedule[] {
39
+ const date = now ?? new Date()
40
+ return Scheduler._tasks.filter(t => t.isDue(date))
41
+ }
42
+
43
+ /** Clear all registered tasks. For testing. */
44
+ static reset(): void {
45
+ Scheduler._tasks = []
46
+ }
47
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"],
4
+ "exclude": ["node_modules", "tests"]
5
+ }