mysql-boss 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.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +688 -0
  3. package/dist/cjs/archive.d.ts +8 -0
  4. package/dist/cjs/archive.d.ts.map +1 -0
  5. package/dist/cjs/archive.js +82 -0
  6. package/dist/cjs/archive.js.map +1 -0
  7. package/dist/cjs/boss.d.ts +45 -0
  8. package/dist/cjs/boss.d.ts.map +1 -0
  9. package/dist/cjs/boss.js +205 -0
  10. package/dist/cjs/boss.js.map +1 -0
  11. package/dist/cjs/claim.d.ts +5 -0
  12. package/dist/cjs/claim.d.ts.map +1 -0
  13. package/dist/cjs/claim.js +47 -0
  14. package/dist/cjs/claim.js.map +1 -0
  15. package/dist/cjs/connection.d.ts +4 -0
  16. package/dist/cjs/connection.d.ts.map +1 -0
  17. package/dist/cjs/connection.js +26 -0
  18. package/dist/cjs/connection.js.map +1 -0
  19. package/dist/cjs/cron/next.d.ts +3 -0
  20. package/dist/cjs/cron/next.d.ts.map +1 -0
  21. package/dist/cjs/cron/next.js +179 -0
  22. package/dist/cjs/cron/next.js.map +1 -0
  23. package/dist/cjs/cron/parse.d.ts +11 -0
  24. package/dist/cjs/cron/parse.d.ts.map +1 -0
  25. package/dist/cjs/cron/parse.js +99 -0
  26. package/dist/cjs/cron/parse.js.map +1 -0
  27. package/dist/cjs/dlq.d.ts +11 -0
  28. package/dist/cjs/dlq.d.ts.map +1 -0
  29. package/dist/cjs/dlq.js +92 -0
  30. package/dist/cjs/dlq.js.map +1 -0
  31. package/dist/cjs/errors.d.ts +10 -0
  32. package/dist/cjs/errors.d.ts.map +1 -0
  33. package/dist/cjs/errors.js +25 -0
  34. package/dist/cjs/errors.js.map +1 -0
  35. package/dist/cjs/fail.d.ts +8 -0
  36. package/dist/cjs/fail.d.ts.map +1 -0
  37. package/dist/cjs/fail.js +19 -0
  38. package/dist/cjs/fail.js.map +1 -0
  39. package/dist/cjs/heartbeat.d.ts +4 -0
  40. package/dist/cjs/heartbeat.d.ts.map +1 -0
  41. package/dist/cjs/heartbeat.js +18 -0
  42. package/dist/cjs/heartbeat.js.map +1 -0
  43. package/dist/cjs/index.d.ts +70 -0
  44. package/dist/cjs/index.d.ts.map +1 -0
  45. package/dist/cjs/index.js +10 -0
  46. package/dist/cjs/index.js.map +1 -0
  47. package/dist/cjs/migrate.d.ts +4 -0
  48. package/dist/cjs/migrate.d.ts.map +1 -0
  49. package/dist/cjs/migrate.js +14 -0
  50. package/dist/cjs/migrate.js.map +1 -0
  51. package/dist/cjs/package.json +1 -0
  52. package/dist/cjs/retry-util.d.ts +2 -0
  53. package/dist/cjs/retry-util.d.ts.map +1 -0
  54. package/dist/cjs/retry-util.js +31 -0
  55. package/dist/cjs/retry-util.js.map +1 -0
  56. package/dist/cjs/sql.d.ts +82 -0
  57. package/dist/cjs/sql.d.ts.map +1 -0
  58. package/dist/cjs/sql.js +396 -0
  59. package/dist/cjs/sql.js.map +1 -0
  60. package/dist/cjs/sweep.d.ts +4 -0
  61. package/dist/cjs/sweep.d.ts.map +1 -0
  62. package/dist/cjs/sweep.js +46 -0
  63. package/dist/cjs/sweep.js.map +1 -0
  64. package/dist/cjs/tick.d.ts +11 -0
  65. package/dist/cjs/tick.d.ts.map +1 -0
  66. package/dist/cjs/tick.js +82 -0
  67. package/dist/cjs/tick.js.map +1 -0
  68. package/dist/cjs/util.d.ts +2 -0
  69. package/dist/cjs/util.d.ts.map +1 -0
  70. package/dist/cjs/util.js +7 -0
  71. package/dist/cjs/util.js.map +1 -0
  72. package/dist/cjs/worker.d.ts +42 -0
  73. package/dist/cjs/worker.d.ts.map +1 -0
  74. package/dist/cjs/worker.js +232 -0
  75. package/dist/cjs/worker.js.map +1 -0
  76. package/dist/esm/archive.d.ts +8 -0
  77. package/dist/esm/archive.d.ts.map +1 -0
  78. package/dist/esm/archive.js +76 -0
  79. package/dist/esm/archive.js.map +1 -0
  80. package/dist/esm/boss.d.ts +45 -0
  81. package/dist/esm/boss.d.ts.map +1 -0
  82. package/dist/esm/boss.js +201 -0
  83. package/dist/esm/boss.js.map +1 -0
  84. package/dist/esm/claim.d.ts +5 -0
  85. package/dist/esm/claim.d.ts.map +1 -0
  86. package/dist/esm/claim.js +44 -0
  87. package/dist/esm/claim.js.map +1 -0
  88. package/dist/esm/connection.d.ts +4 -0
  89. package/dist/esm/connection.d.ts.map +1 -0
  90. package/dist/esm/connection.js +22 -0
  91. package/dist/esm/connection.js.map +1 -0
  92. package/dist/esm/cron/next.d.ts +3 -0
  93. package/dist/esm/cron/next.d.ts.map +1 -0
  94. package/dist/esm/cron/next.js +176 -0
  95. package/dist/esm/cron/next.js.map +1 -0
  96. package/dist/esm/cron/parse.d.ts +11 -0
  97. package/dist/esm/cron/parse.d.ts.map +1 -0
  98. package/dist/esm/cron/parse.js +96 -0
  99. package/dist/esm/cron/parse.js.map +1 -0
  100. package/dist/esm/dlq.d.ts +11 -0
  101. package/dist/esm/dlq.d.ts.map +1 -0
  102. package/dist/esm/dlq.js +87 -0
  103. package/dist/esm/dlq.js.map +1 -0
  104. package/dist/esm/errors.d.ts +10 -0
  105. package/dist/esm/errors.d.ts.map +1 -0
  106. package/dist/esm/errors.js +19 -0
  107. package/dist/esm/errors.js.map +1 -0
  108. package/dist/esm/fail.d.ts +8 -0
  109. package/dist/esm/fail.d.ts.map +1 -0
  110. package/dist/esm/fail.js +16 -0
  111. package/dist/esm/fail.js.map +1 -0
  112. package/dist/esm/heartbeat.d.ts +4 -0
  113. package/dist/esm/heartbeat.d.ts.map +1 -0
  114. package/dist/esm/heartbeat.js +15 -0
  115. package/dist/esm/heartbeat.js.map +1 -0
  116. package/dist/esm/index.d.ts +70 -0
  117. package/dist/esm/index.d.ts.map +1 -0
  118. package/dist/esm/index.js +3 -0
  119. package/dist/esm/index.js.map +1 -0
  120. package/dist/esm/migrate.d.ts +4 -0
  121. package/dist/esm/migrate.d.ts.map +1 -0
  122. package/dist/esm/migrate.js +11 -0
  123. package/dist/esm/migrate.js.map +1 -0
  124. package/dist/esm/retry-util.d.ts +2 -0
  125. package/dist/esm/retry-util.d.ts.map +1 -0
  126. package/dist/esm/retry-util.js +28 -0
  127. package/dist/esm/retry-util.js.map +1 -0
  128. package/dist/esm/sql.d.ts +82 -0
  129. package/dist/esm/sql.d.ts.map +1 -0
  130. package/dist/esm/sql.js +392 -0
  131. package/dist/esm/sql.js.map +1 -0
  132. package/dist/esm/sweep.d.ts +4 -0
  133. package/dist/esm/sweep.d.ts.map +1 -0
  134. package/dist/esm/sweep.js +43 -0
  135. package/dist/esm/sweep.js.map +1 -0
  136. package/dist/esm/tick.d.ts +11 -0
  137. package/dist/esm/tick.d.ts.map +1 -0
  138. package/dist/esm/tick.js +77 -0
  139. package/dist/esm/tick.js.map +1 -0
  140. package/dist/esm/util.d.ts +2 -0
  141. package/dist/esm/util.d.ts.map +1 -0
  142. package/dist/esm/util.js +4 -0
  143. package/dist/esm/util.js.map +1 -0
  144. package/dist/esm/worker.d.ts +42 -0
  145. package/dist/esm/worker.d.ts.map +1 -0
  146. package/dist/esm/worker.js +228 -0
  147. package/dist/esm/worker.js.map +1 -0
  148. package/package.json +64 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Edafe Emunotor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,688 @@
1
+ # mysql-boss
2
+
3
+ [![CI](https://github.com/V-84/mysql-boss/actions/workflows/ci.yml/badge.svg)](https://github.com/V-84/mysql-boss/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/V-84/mysql-boss/branch/main/graph/badge.svg)](https://codecov.io/gh/V-84/mysql-boss)
5
+
6
+ A [pg-boss](https://github.com/timgit/pg-boss)-inspired job queue built for **MySQL 8.0.1+**. Zero runtime dependencies beyond [`mysql2`](https://github.com/sidorares/node-mysql2).
7
+
8
+ Correctness first, throughput second. Uses `FOR UPDATE SKIP LOCKED` for non-blocking, contention-safe job claims with lease-based crash recovery.
9
+
10
+ ## Features
11
+
12
+ - **At-least-once delivery** with lease-based crash recovery
13
+ - **Exactly-one-holder guarantee** — only one worker holds a job at any time
14
+ - **Retries** with fixed or exponential backoff
15
+ - **Dead-letter queue** for permanently failed jobs, with replay
16
+ - **Cron scheduling** — 5-field Vixie cron with timezone support (DST-aware)
17
+ - **Priority queues** — higher-priority jobs dequeue first
18
+ - **Archive** — completed jobs move to a separate table for audit/history
19
+ - **Singleton jobs** — deduplicate by key within a queue
20
+ - **Graceful shutdown** with configurable drain timeout
21
+
22
+ ## Requirements
23
+
24
+ - **Node.js** >= 20
25
+ - **MySQL** >= 8.0.1 (uses `FOR UPDATE SKIP LOCKED`, descending indexes, `DEFAULT (expression)`)
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ npm install mysql-boss mysql2
31
+ ```
32
+
33
+ `mysql2` is a peer dependency — you provide the connection pool.
34
+
35
+ ## Quick start
36
+
37
+ ```typescript
38
+ import mysql from "mysql2/promise";
39
+ import { MysqlBoss } from "mysql-boss";
40
+
41
+ // 1. Create a mysql2 pool (you own it — mysql-boss never closes it)
42
+ const pool = mysql.createPool({
43
+ host: "localhost",
44
+ user: "root",
45
+ password: "secret",
46
+ database: "myapp",
47
+ waitForConnections: true,
48
+ connectionLimit: 10,
49
+ });
50
+
51
+ // 2. Create the boss
52
+ const boss = new MysqlBoss({ pool });
53
+
54
+ // 3. Run migrations (idempotent — safe to call on every startup)
55
+ await boss.migrate();
56
+
57
+ // 4. Register a worker
58
+ boss.work("send-email", async (job, { signal }) => {
59
+ const { to, subject, body } = job.payload;
60
+ await sendEmail(to, subject, body);
61
+ // Returning without throwing = success → job archived
62
+ });
63
+
64
+ // 5. Enqueue a job
65
+ await boss.enqueue("send-email", {
66
+ to: "user@example.com",
67
+ subject: "Welcome!",
68
+ body: "Thanks for signing up.",
69
+ });
70
+
71
+ // 6. Shut down gracefully on SIGTERM
72
+ process.on("SIGTERM", async () => {
73
+ await boss.stop();
74
+ await pool.end();
75
+ });
76
+ ```
77
+
78
+ ### JavaScript (CommonJS)
79
+
80
+ ```javascript
81
+ const mysql = require("mysql2/promise");
82
+ const { MysqlBoss } = require("mysql-boss");
83
+
84
+ const pool = mysql.createPool({ /* ... */ });
85
+ const boss = new MysqlBoss({ pool });
86
+
87
+ await boss.migrate();
88
+
89
+ boss.work("process-order", async (job) => {
90
+ console.log("Processing order:", job.payload.orderId);
91
+ });
92
+
93
+ await boss.enqueue("process-order", { orderId: "abc-123" });
94
+ ```
95
+
96
+ ---
97
+
98
+ ## API reference
99
+
100
+ ### `new MysqlBoss(options)`
101
+
102
+ Creates a new instance. Validates configuration synchronously — throws `ConfigError` on invalid options.
103
+
104
+ ```typescript
105
+ import type { Pool } from "mysql2/promise";
106
+
107
+ interface MysqlBossOptions {
108
+ pool: Pool; // Required. Your mysql2 promise pool.
109
+ tablePrefix?: string; // Default: "mysql_boss". Set "" for legacy unprefixed table names.
110
+ pollIntervalMs?: number; // Default: 2000. How often to poll for jobs (ms). ±20% jitter applied.
111
+ batchSize?: number; // Default: 10. Jobs claimed per poll (1–100).
112
+ concurrency?: number; // Default: batchSize. Max in-flight jobs per worker.
113
+ leaseSeconds?: number; // Default: 300. How long a job is locked before recovery.
114
+ heartbeatSeconds?: number; // Default: 100. Lease renewal interval.
115
+ sweepIntervalMs?: number; // Default: 60000. Stale-job recovery check interval (ms).
116
+ tickIntervalMs?: number; // Default: 30000. Cron schedule check interval (ms).
117
+ archiveRetentionDays?: number; // Default: 14. Days to keep archived (completed) jobs. Integer >= 1.
118
+ drainTimeoutMs?: number; // Default: 30000. Max time to wait for in-flight jobs during stop().
119
+ }
120
+ ```
121
+
122
+ **Constraints:**
123
+ - `leaseSeconds` must be >= `3 * heartbeatSeconds` (throws `ConfigError`)
124
+ - `batchSize` must be between 1 and 100 (throws `ConfigError`)
125
+ - `archiveRetentionDays` must be a positive integer (throws `ConfigError`)
126
+ - `tablePrefix` may contain only ASCII letters, numbers, and underscores, up to 50 characters
127
+
128
+ ```typescript
129
+ // Example: low-latency config for small jobs
130
+ const boss = new MysqlBoss({
131
+ pool,
132
+ pollIntervalMs: 500,
133
+ batchSize: 20,
134
+ concurrency: 20,
135
+ leaseSeconds: 60,
136
+ heartbeatSeconds: 15,
137
+ });
138
+ ```
139
+
140
+ ---
141
+
142
+ ### `boss.migrate()`
143
+
144
+ ```typescript
145
+ await boss.migrate(): Promise<void>
146
+ ```
147
+
148
+ Creates all required tables (`mysql_boss_jobs`, `mysql_boss_jobs_archive`, `mysql_boss_jobs_dead`, `mysql_boss_schedules` by default) if they don't exist. **Idempotent** — safe to call on every app startup.
149
+
150
+ Use `tablePrefix` when multiple applications share a database:
151
+
152
+ ```typescript
153
+ const boss = new MysqlBoss({ pool, tablePrefix: "billing" });
154
+ await boss.migrate();
155
+ // billing_jobs, billing_jobs_archive, billing_jobs_dead, billing_schedules
156
+ ```
157
+
158
+ Set `tablePrefix: ""` to retain the original unprefixed table names. Changing the
159
+ prefix points the instance at a different set of tables; it does not rename or
160
+ migrate data from an existing set.
161
+
162
+ ---
163
+
164
+ ### `boss.enqueue(queue, payload, options?)`
165
+
166
+ ```typescript
167
+ await boss.enqueue<T>(queue: string, payload: T, opts?: EnqueueOptions): Promise<string | null>
168
+ ```
169
+
170
+ Adds a job to the queue. Returns the job ID as a string, or `null` if deduplicated by `singletonKey`.
171
+
172
+ ```typescript
173
+ interface EnqueueOptions {
174
+ priority?: number; // Default: 0. Range: -32768 to 32767. Higher runs first.
175
+ singletonKey?: string; // Dedupe key (max 191 chars). Must not start with "cron:".
176
+ retryLimit?: number; // Default: 2. Max retries after initial attempt (so 3 total runs max).
177
+ retryDelaySecs?: number; // Default: 30. Base delay between retries (seconds).
178
+ retryBackoff?: boolean; // Default: false. true = exponential backoff (delay * 2^n, capped at 24h).
179
+ runAt?: Date; // Default: now (DB clock). Schedule for future execution.
180
+ }
181
+ ```
182
+
183
+ #### Examples
184
+
185
+ **Basic enqueue:**
186
+
187
+ ```typescript
188
+ const jobId = await boss.enqueue("send-email", {
189
+ to: "user@example.com",
190
+ subject: "Hello",
191
+ });
192
+ console.log(`Enqueued job ${jobId}`);
193
+ ```
194
+
195
+ **With priority (higher = more urgent):**
196
+
197
+ ```typescript
198
+ await boss.enqueue("notifications", { userId: 42 }, { priority: 10 });
199
+ await boss.enqueue("notifications", { userId: 99 }, { priority: 0 });
200
+ // userId 42 will be processed first
201
+ ```
202
+
203
+ **Delayed / scheduled job:**
204
+
205
+ ```typescript
206
+ const inOneHour = new Date(Date.now() + 60 * 60 * 1000);
207
+ await boss.enqueue("send-reminder", { userId: 42 }, { runAt: inOneHour });
208
+ ```
209
+
210
+ **Singleton (deduplicate):**
211
+
212
+ ```typescript
213
+ // Only one "daily-report" job per queue at a time
214
+ const id = await boss.enqueue("reports", { type: "daily" }, {
215
+ singletonKey: "daily-report",
216
+ });
217
+ // Second call returns null — job already exists
218
+ const duplicate = await boss.enqueue("reports", { type: "daily" }, {
219
+ singletonKey: "daily-report",
220
+ });
221
+ console.log(duplicate); // null
222
+ ```
223
+
224
+ **With retries and exponential backoff:**
225
+
226
+ ```typescript
227
+ await boss.enqueue("call-api", { url: "https://api.example.com" }, {
228
+ retryLimit: 5, // Retry up to 5 times after the first attempt (6 total)
229
+ retryDelaySecs: 10, // Start at 10s
230
+ retryBackoff: true, // 10s, 20s, 40s, 80s, 160s (capped at 24h)
231
+ });
232
+ ```
233
+
234
+ ---
235
+
236
+ ### `boss.work(queue, handler)`
237
+
238
+ ```typescript
239
+ boss.work<T>(queue: string, handler: JobHandler<T>): void
240
+ ```
241
+
242
+ Registers a handler for a queue and starts polling. **One handler per queue** — calling `work()` twice for the same queue throws.
243
+
244
+ ```typescript
245
+ type JobHandler<T = unknown> = (
246
+ job: ActiveJob<T>,
247
+ ctx: { signal: AbortSignal },
248
+ ) => Promise<void>;
249
+
250
+ interface ActiveJob<T = unknown> {
251
+ id: string; // Job ID (BIGINT rendered as string)
252
+ queue: string; // Queue name
253
+ payload: T; // The payload you enqueued
254
+ retryCount: number; // How many retries have been consumed so far
255
+ retryLimit: number; // Max retries allowed
256
+ }
257
+ ```
258
+
259
+ **Handler outcomes:**
260
+
261
+ | Handler does | Result |
262
+ |---|---|
263
+ | Resolves (returns) | Job is **completed** and moved to the archive table |
264
+ | Rejects (throws) with retries remaining | Job returns to queue with backoff delay |
265
+ | Rejects (throws) with retries exhausted | Job moves to the dead-letter table |
266
+
267
+ #### Examples
268
+
269
+ **Simple handler:**
270
+
271
+ ```typescript
272
+ boss.work("send-email", async (job) => {
273
+ await sendEmail(job.payload.to, job.payload.subject);
274
+ });
275
+ ```
276
+
277
+ **Typed payload:**
278
+
279
+ ```typescript
280
+ interface EmailPayload {
281
+ to: string;
282
+ subject: string;
283
+ body: string;
284
+ }
285
+
286
+ boss.work<EmailPayload>("send-email", async (job) => {
287
+ // job.payload is typed as EmailPayload
288
+ await sendEmail(job.payload.to, job.payload.subject, job.payload.body);
289
+ });
290
+ ```
291
+
292
+ **Using the abort signal:**
293
+
294
+ The `signal` is aborted in two cases:
295
+ 1. **Lease lost** — another worker reclaimed the job (the handler is a zombie)
296
+ 2. **Shutdown** — `boss.stop()` was called and drain timeout expired
297
+
298
+ ```typescript
299
+ boss.work("long-task", async (job, { signal }) => {
300
+ for (const chunk of splitIntoChunks(job.payload.data)) {
301
+ if (signal.aborted) {
302
+ throw new Error("Aborted");
303
+ }
304
+ await processChunk(chunk);
305
+ }
306
+ });
307
+ ```
308
+
309
+ **With fetch (Node 20+):**
310
+
311
+ ```typescript
312
+ boss.work("call-api", async (job, { signal }) => {
313
+ const response = await fetch(job.payload.url, { signal });
314
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
315
+ const data = await response.json();
316
+ await saveResult(job.id, data);
317
+ });
318
+ ```
319
+
320
+ ---
321
+
322
+ ### `boss.schedule(name, queue, cron, options?)`
323
+
324
+ ```typescript
325
+ await boss.schedule(name: string, queue: string, cron: string, opts?: ScheduleOptions): Promise<void>
326
+ ```
327
+
328
+ Creates or updates a cron schedule. Jobs are enqueued automatically on each tick.
329
+
330
+ ```typescript
331
+ interface ScheduleOptions {
332
+ timezone?: string; // Default: "UTC". Any IANA timezone (e.g. "America/New_York").
333
+ payload?: unknown; // Payload for each spawned job.
334
+ priority?: number; // Default: 0.
335
+ retryLimit?: number; // Default: 2.
336
+ retryDelaySecs?: number; // Default: 30.
337
+ retryBackoff?: boolean; // Default: false.
338
+ }
339
+ ```
340
+
341
+ **Cron format** — standard 5-field Vixie cron:
342
+
343
+ ```
344
+ ┌───────── minute (0–59)
345
+ │ ┌─────── hour (0–23)
346
+ │ │ ┌───── day of month (1–31)
347
+ │ │ │ ┌─── month (1–12 or Jan–Dec)
348
+ │ │ │ │ ┌─ day of week (0–6 or Sun–Sat, 0 = Sunday)
349
+ │ │ │ │ │
350
+ * * * * *
351
+ ```
352
+
353
+ Supports: values, ranges (`1-5`), steps (`*/15`), lists (`1,3,5`), and month/day-of-week names.
354
+
355
+ **DOM/DOW OR-semantics:** when both day-of-month and day-of-week are specified (not `*`), the job fires if **either** matches (Vixie cron behavior).
356
+
357
+ #### Examples
358
+
359
+ ```typescript
360
+ // Every hour on the hour
361
+ await boss.schedule("hourly-cleanup", "cleanup", "0 * * * *");
362
+
363
+ // Every weekday at 9:00 AM Eastern
364
+ await boss.schedule("morning-report", "reports", "0 9 * * 1-5", {
365
+ timezone: "America/New_York",
366
+ payload: { type: "morning" },
367
+ });
368
+
369
+ // Every 15 minutes with high priority
370
+ await boss.schedule("health-check", "monitoring", "*/15 * * * *", {
371
+ priority: 10,
372
+ retryLimit: 0, // No retries for health checks
373
+ });
374
+ ```
375
+
376
+ **Catch-up policy:** if the system was down across multiple occurrences, only **one** catch-up job is created (not one per missed tick).
377
+
378
+ **Updating a schedule:** calling `schedule()` with the same `name` updates the existing schedule in place.
379
+
380
+ ---
381
+
382
+ ### `boss.unschedule(name)`
383
+
384
+ ```typescript
385
+ await boss.unschedule(name: string): Promise<void>
386
+ ```
387
+
388
+ Removes a cron schedule by name. Already-enqueued jobs from this schedule are not affected.
389
+
390
+ ```typescript
391
+ await boss.unschedule("hourly-cleanup");
392
+ ```
393
+
394
+ ---
395
+
396
+ ### `boss.listDead(query)`
397
+
398
+ ```typescript
399
+ await boss.listDead(query): Promise<DeadJob[]>
400
+ ```
401
+
402
+ Lists jobs in the dead-letter queue (jobs that exhausted all retries).
403
+
404
+ ```typescript
405
+ interface DeadJob<T = unknown> {
406
+ id: string;
407
+ queue: string;
408
+ payload: T;
409
+ priority: number;
410
+ retryCount: number;
411
+ createdAt: Date;
412
+ failedAt: Date;
413
+ lastError: { message: string; stack?: string; at: string } | null;
414
+ }
415
+ ```
416
+
417
+ ```typescript
418
+ const deadJobs = await boss.listDead({
419
+ queue: "send-email",
420
+ after: new Date("2025-01-01"), // Optional: filter by failed_at range
421
+ before: new Date(), // Optional
422
+ limit: 20, // Default: 50
423
+ offset: 0, // Default: 0
424
+ });
425
+
426
+ for (const job of deadJobs) {
427
+ console.log(`Job ${job.id} failed: ${job.lastError?.message}`);
428
+ }
429
+ ```
430
+
431
+ ---
432
+
433
+ ### `boss.replayDead(ids)`
434
+
435
+ ```typescript
436
+ await boss.replayDead(ids: string[]): Promise<number>
437
+ ```
438
+
439
+ Re-enqueues dead jobs as fresh jobs (new IDs, counters reset). Returns the count of replayed jobs.
440
+
441
+ Throws `SingletonCollisionError` if a replayed job collides with a live singleton — the dead row is preserved and nothing is replayed.
442
+
443
+ ```typescript
444
+ // Replay specific failed jobs
445
+ const replayed = await boss.replayDead(["42", "43", "44"]);
446
+ console.log(`Replayed ${replayed} jobs`);
447
+ ```
448
+
449
+ ```typescript
450
+ // Replay all dead jobs for a queue
451
+ const deadJobs = await boss.listDead({ queue: "send-email" });
452
+ if (deadJobs.length > 0) {
453
+ const replayed = await boss.replayDead(deadJobs.map((j) => j.id));
454
+ console.log(`Replayed ${replayed} of ${deadJobs.length} dead jobs`);
455
+ }
456
+ ```
457
+
458
+ ---
459
+
460
+ ### `boss.getArchivedJob(id)`
461
+
462
+ ```typescript
463
+ await boss.getArchivedJob(id: string): Promise<ArchivedJob | null>
464
+ ```
465
+
466
+ Looks up a completed job by its original ID. Returns `null` if not found (either never existed or already pruned by retention).
467
+
468
+ ```typescript
469
+ interface ArchivedJob<T = unknown> {
470
+ id: string;
471
+ queue: string;
472
+ payload: T;
473
+ priority: number;
474
+ retryCount: number;
475
+ createdAt: Date;
476
+ startedAt: Date;
477
+ completedAt: Date;
478
+ durationMs: number; // Handler execution time in milliseconds
479
+ }
480
+ ```
481
+
482
+ ```typescript
483
+ const archived = await boss.getArchivedJob("12345");
484
+ if (archived) {
485
+ console.log(`Job completed in ${archived.durationMs}ms`);
486
+ }
487
+ ```
488
+
489
+ ---
490
+
491
+ ### `boss.listArchive(query)`
492
+
493
+ ```typescript
494
+ await boss.listArchive(query): Promise<ArchivedJob[]>
495
+ ```
496
+
497
+ Lists completed jobs using keyset pagination (pass the last row's `completedAt` as `before` for the next page).
498
+
499
+ ```typescript
500
+ // First page
501
+ const page1 = await boss.listArchive({
502
+ queue: "send-email",
503
+ limit: 20,
504
+ });
505
+
506
+ // Next page (use last row's completedAt as cursor)
507
+ if (page1.length === 20) {
508
+ const page2 = await boss.listArchive({
509
+ queue: "send-email",
510
+ before: page1[page1.length - 1].completedAt,
511
+ limit: 20,
512
+ });
513
+ }
514
+ ```
515
+
516
+ ---
517
+
518
+ ### `boss.stop(options?)`
519
+
520
+ ```typescript
521
+ await boss.stop(opts?: { drainTimeoutMs?: number }): Promise<void>
522
+ ```
523
+
524
+ Gracefully shuts down all workers. **Idempotent** — safe to call multiple times. The instance is unusable after `stop()`.
525
+
526
+ **Shutdown sequence:**
527
+
528
+ 1. Stops all polling loops and the cron ticker (no new claims)
529
+ 2. Waits for in-flight handlers up to `drainTimeoutMs` (default: 30s)
530
+ 3. If handlers are still running at timeout: aborts their `AbortSignal` and releases jobs back to the queue **without incrementing retry count** (a drain is not a failure)
531
+ 4. Stops heartbeat and sweep timers
532
+
533
+ ```typescript
534
+ // Wire up to process signals
535
+ process.on("SIGTERM", async () => {
536
+ console.log("Shutting down...");
537
+ await boss.stop({ drainTimeoutMs: 10_000 });
538
+ await pool.end();
539
+ console.log("Shutdown complete");
540
+ });
541
+ ```
542
+
543
+ ---
544
+
545
+ ### Error classes
546
+
547
+ All predictable failures throw typed errors you can catch:
548
+
549
+ ```typescript
550
+ import { ConfigError, ValidationError, SingletonCollisionError } from "mysql-boss";
551
+ ```
552
+
553
+ | Error | When |
554
+ |---|---|
555
+ | `ConfigError` | Invalid constructor options (bad `batchSize`, `leaseSeconds < 3 * heartbeatSeconds`, etc.) |
556
+ | `ValidationError` | Invalid enqueue arguments (`priority` out of range, `singletonKey` too long or starts with `cron:`) |
557
+ | `SingletonCollisionError` | `replayDead()` collides with a live singleton job |
558
+
559
+ ---
560
+
561
+ ## Delivery guarantee
562
+
563
+ mysql-boss provides **at-least-once delivery**. This means:
564
+
565
+ - A job will be processed **at least once** — crash recovery ensures no job is lost
566
+ - A job **may be processed more than once** if a worker crashes after completing work but before the completion transaction commits
567
+ - **Your handlers must be idempotent** — safe to run multiple times with the same input
568
+
569
+ ### Why "at least once"?
570
+
571
+ If a worker finishes processing a job but crashes before the archive-move transaction commits, the job's lease will eventually expire. The sweep recovers it, and another worker re-processes it. The work was done twice, but the job record stays consistent.
572
+
573
+ **Making handlers idempotent:**
574
+
575
+ ```typescript
576
+ boss.work("charge-customer", async (job) => {
577
+ // Use an idempotency key to prevent double-charging
578
+ const idempotencyKey = `charge-${job.id}`;
579
+ await paymentProvider.charge(job.payload.amount, { idempotencyKey });
580
+ });
581
+ ```
582
+
583
+ ---
584
+
585
+ ## Architecture
586
+
587
+ ### Tables
588
+
589
+ | Table | Purpose | Size profile |
590
+ |---|---|---|
591
+ | `mysql_boss_jobs` | Hot table — only pending and in-flight jobs | Backlog-sized (small) |
592
+ | `mysql_boss_jobs_archive` | Completed jobs — audit/history | Grows with throughput, pruned by retention |
593
+ | `mysql_boss_jobs_dead` | Failed jobs (retries exhausted) | Small unless something is broken |
594
+ | `mysql_boss_schedules` | Cron schedule definitions | Tiny |
595
+
596
+ Completed jobs are **moved** out of the hot table into the archive table on success (and into the dead-letter table on permanent failure). This keeps the hot table small and the dequeue index fast — MySQL has no partial indexes, so terminal rows left in it would bloat every claim scan.
597
+
598
+ ### How claims work
599
+
600
+ ```
601
+ Worker A Worker B
602
+ | |
603
+ |-- BEGIN |-- BEGIN
604
+ |-- SELECT ... FOR UPDATE |-- SELECT ... FOR UPDATE
605
+ | SKIP LOCKED | SKIP LOCKED
606
+ | -> locks rows 1, 2, 3 | -> skips 1, 2, 3
607
+ | | -> locks rows 4, 5, 6
608
+ |-- UPDATE SET state='active' |-- UPDATE SET state='active'
609
+ |-- COMMIT |-- COMMIT
610
+ ```
611
+
612
+ Workers never block each other. `SKIP LOCKED` means if a row is already claimed, the next worker silently skips it and takes the next available job.
613
+
614
+ ### Crash recovery
615
+
616
+ Every claimed job has a `lease_expires_at` timestamp. If a worker crashes:
617
+
618
+ 1. Its database connections die — all InnoDB row locks are released
619
+ 2. The `lease_expires_at` data column remains
620
+ 3. A periodic **sweep** finds jobs where `lease_expires_at < NOW()` and `state = 'active'`
621
+ 4. The sweep resets them to `available` (with `retry_count` incremented) or moves them to the DLQ
622
+
623
+ ### Fencing
624
+
625
+ Every post-claim operation (`complete`, `fail`, `heartbeat`) includes `AND locked_by = UUID_TO_BIN(?)`. A zombie worker whose lease expired cannot affect a job now owned by someone else.
626
+
627
+ ---
628
+
629
+ ## Archive retention and partitioning
630
+
631
+ The archive table (`mysql_boss_jobs_archive` by default) is pruned automatically based on `archiveRetentionDays` (default: 14 days). Pruning runs in batches of 5,000 rows to bound lock time.
632
+
633
+ ### For high-throughput or long-retention setups
634
+
635
+ If you keep archives for 90+ days or process millions of jobs, consider partitioning your configured archive table by `completed_at` (monthly partitions). This turns retention cleanup from batched DELETEs into instant `ALTER TABLE ... DROP PARTITION`:
636
+
637
+ ```sql
638
+ ALTER TABLE mysql_boss_jobs_archive
639
+ PARTITION BY RANGE (TO_DAYS(completed_at)) (
640
+ PARTITION p202501 VALUES LESS THAN (TO_DAYS('2025-02-01')),
641
+ PARTITION p202502 VALUES LESS THAN (TO_DAYS('2025-03-01')),
642
+ -- add partitions monthly
643
+ PARTITION pmax VALUES LESS THAN MAXVALUE
644
+ );
645
+ ```
646
+
647
+ mysql-boss's batched `DELETE` still works against a partitioned table — partitioning is a deployment choice, not a library change.
648
+
649
+ ---
650
+
651
+ ## Connection pool sizing
652
+
653
+ mysql-boss uses short-lived connections: one per claim transaction, released immediately after COMMIT. Handlers never hold connections. A reasonable starting point:
654
+
655
+ ```
656
+ connectionLimit = concurrency + 2 (sweep + tick)
657
+ ```
658
+
659
+ For a single-process worker with `concurrency: 10`, a pool of 12–15 connections is plenty.
660
+
661
+ All connections are configured to `READ COMMITTED` isolation and `UTC` timezone automatically.
662
+
663
+ ---
664
+
665
+ ## TypeScript and JavaScript support
666
+
667
+ mysql-boss ships dual **ESM** and **CommonJS** builds with full type definitions.
668
+
669
+ **ESM (TypeScript / modern Node.js):**
670
+
671
+ ```typescript
672
+ import { MysqlBoss } from "mysql-boss";
673
+ import type { ActiveJob, EnqueueOptions } from "mysql-boss";
674
+ ```
675
+
676
+ **CommonJS (JavaScript):**
677
+
678
+ ```javascript
679
+ const { MysqlBoss } = require("mysql-boss");
680
+ ```
681
+
682
+ Both resolve automatically via the `exports` field in `package.json`.
683
+
684
+ ---
685
+
686
+ ## License
687
+
688
+ [MIT](LICENSE)