@maestro-js/recurring-jobs 1.0.0-alpha.0 → 1.0.0-alpha.10

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 (4) hide show
  1. package/README.md +138 -253
  2. package/dist/index.d.ts +127 -160
  3. package/dist/index.js +185 -115
  4. package/package.json +12 -6
package/README.md CHANGED
@@ -1,348 +1,233 @@
1
- # RecurringJobs Skill
1
+ # @maestro-js/recurring-jobs
2
2
 
3
- ## Overview
4
-
5
- RecurringJobs provides cron-based job scheduling backed by the Queue package. Each recurring job gets its own dedicated queue where every execution is a persisted message with full lifecycle tracking. The next run is enqueued before the work function executes, guaranteeing the schedule continues even if the current run fails.
3
+ Cron-based recurring job scheduling with distributed lock-based deduplication.
6
4
 
7
5
  ## Quick Setup
8
6
 
9
7
  ```ts
10
8
  import { RecurringJobs } from '@maestro-js/recurring-jobs'
11
- import { Queue } from '@maestro-js/queue'
12
- import { Log } from '@maestro-js/log'
13
-
14
- // Create a logger for recurring job events
15
- const logger = Log.create<[RecurringJobs.LogMessage]>({
16
- transports: [{ write: ({ data: [msg] }) => console.log(msg) }]
17
- })
18
9
 
19
- // Create and register the recurring jobs service with a queue service
20
- const recurringJobsService = RecurringJobs.Provider.create({
21
- logger,
22
- queueService: Queue.provider('default')
10
+ // Create and register with the local (in-memory) driver
11
+ const service = RecurringJobs.Provider.create({
12
+ driver: RecurringJobs.drivers.local()
23
13
  })
24
- RecurringJobs.Provider.register('default', recurringJobsService)
14
+ RecurringJobs.Provider.register('default', service)
25
15
 
26
16
  // Define a recurring job
27
- const dailyReport = RecurringJobs.createJob({
28
- name: 'daily-sales-report',
29
- cron: '0 6 * * *',
30
- workFn: async () => {
31
- const total = await calculateDailySales()
32
- return { total }
33
- }
17
+ RecurringJobs.create({
18
+ name: 'send-weekly-digest',
19
+ cron: '0 9 * * MON',
20
+ handle: async () => sendDigest()
34
21
  })
35
22
 
36
- // Start processing
37
- dailyReport.startProcessing()
38
- ```
39
-
40
- ## Cron Expression Format
41
-
42
- Uses the `cron-parser` library (v4.x) for standard 5-field cron expressions:
23
+ // Start polling -- fires immediately then every 60 seconds
24
+ const worker = RecurringJobs.work()
43
25
 
44
- ```
45
- ┌───────────── minute (0-59)
46
- │ ┌───────────── hour (0-23)
47
- │ │ ┌───────────── day of month (1-31)
48
- │ │ │ ┌───────────── month (1-12)
49
- │ │ │ │ ┌───────────── day of week (0-7, 0 and 7 are Sunday)
50
- │ │ │ │ │
51
- * * * * *
26
+ // Graceful shutdown
27
+ await worker.abort()
52
28
  ```
53
29
 
54
- Common examples:
55
- - `0 6 * * *` -- every day at 6:00 AM
56
- - `*/15 * * * *` -- every 15 minutes
57
- - `0 0 * * 0` -- every Sunday at midnight
58
- - `0 9 1 * *` -- first day of every month at 9:00 AM
59
- - `30 */2 * * *` -- every 2 hours at the 30-minute mark
30
+ ## Drivers
60
31
 
61
- Invalid cron expressions throw immediately when passed to `createJob()`, since `cron-parser.parseExpression()` is called at creation time.
62
-
63
- ## API Reference
32
+ ### Db
64
33
 
65
- ### Provider Setup
66
-
67
- #### `RecurringJobs.Provider.create(config)`
68
-
69
- Create a recurring jobs service instance.
34
+ MySQL-backed, for multi-process production deployments. Uses `UPDATE ... WHERE lastRunDate < ?`
35
+ and `INSERT IGNORE` for atomic distributed locking.
70
36
 
71
37
  ```ts
72
- RecurringJobs.Provider.create(config: {
73
- logger: Log.Logger<[RecurringJobs.LogMessage]>
74
- queueService: Queue.QueueService
38
+ RecurringJobs.drivers.db({
39
+ db: Db, // Db service instance
40
+ databaseTable: 'recurringJobs'
75
41
  })
76
42
  ```
77
43
 
78
- - `logger` -- a Log instance typed to `RecurringJobs.LogMessage` for structured job lifecycle events
79
- - `queueService` -- the Queue service used to back each recurring job's dedicated queue
80
-
81
- #### `RecurringJobs.Provider.register(name, service)`
44
+ Required table schema:
82
45
 
83
- Register the service instance in the named registry.
84
-
85
- ```ts
86
- RecurringJobs.Provider.register('default', recurringJobsService)
46
+ ```sql
47
+ CREATE TABLE recurringJobs (
48
+ name VARCHAR(255) NOT NULL PRIMARY KEY,
49
+ lastRunDate DATETIME DEFAULT NULL
50
+ );
87
51
  ```
88
52
 
89
- #### `RecurringJobs.provider(key)`
90
-
91
- Resolve a named instance from the registry. Supports deferred resolution via Proxy.
92
-
93
- ```ts
94
- const jobs = RecurringJobs.provider('default')
95
- ```
53
+ Source: `packages/recurring-jobs/src/db-recurring-jobs-driver.ts`
96
54
 
97
- ### Service Methods (on the facade or provider instance)
55
+ ### Cache
98
56
 
99
- #### `RecurringJobs.createJob(config)`
100
-
101
- Create a recurring job with a dedicated queue. Returns a `RecurringJob` handle.
57
+ Redis-backed via `@maestro-js/cache` with distributed locking (`cache.lock()`). Use when you
58
+ already have Redis and don't need MySQL.
102
59
 
103
60
  ```ts
104
- RecurringJobs.createJob<T>(config: {
105
- name: string
106
- cron: string
107
- workFn: () => Promise<T>
108
- logger?: Log.LogFunctions<[RecurringJobs.LogMessage]> // optional per-job logger
109
- }): RecurringJobs.RecurringJob<T>
61
+ RecurringJobs.drivers.cache({
62
+ cache: cacheService, // Cache.CacheService instance
63
+ keyPrefix: 'recurring-jobs:' // optional, default 'recurring-jobs:'
64
+ })
110
65
  ```
111
66
 
112
- Each job creates a queue named `RecurringJobs: <name>`. The cron expression is validated immediately and throws if invalid.
67
+ Source: `packages/recurring-jobs/src/cache-recurring-jobs-driver.ts`
113
68
 
114
- #### `RecurringJobs.startProcessing()`
69
+ ### Local
115
70
 
116
- Start processing on all registered recurring jobs.
71
+ In-memory, single-process only. No distributed locking -- every process will run every due job.
72
+ Good for development and testing.
117
73
 
118
74
  ```ts
119
- RecurringJobs.startProcessing()
75
+ RecurringJobs.drivers.local()
120
76
  ```
121
77
 
122
- #### `RecurringJobs.stopProcessing()`
123
-
124
- Stop processing on all registered recurring jobs.
125
-
126
- ```ts
127
- RecurringJobs.stopProcessing()
128
- ```
129
-
130
- #### `RecurringJobs.list()`
131
-
132
- Return the names of all registered recurring jobs.
133
-
134
- ```ts
135
- RecurringJobs.list() // ['daily-sales-report', 'session-cleanup']
136
- ```
78
+ Source: `packages/recurring-jobs/src/local-recurring-jobs-driver.ts`
137
79
 
138
- #### `RecurringJobs.get(name)`
80
+ ### Choosing a driver
139
81
 
140
- Return the recurring job with the given name, or `undefined` if not found.
82
+ | Driver | Distributed locking | Persistence | Best for |
83
+ |---------|---------------------|-------------|---------------------------------------|
84
+ | **Db** | Yes (MySQL row) | Yes | Multi-process production |
85
+ | **Cache** | Yes (Redis lock) | Yes* | Production when you have Redis, no MySQL |
86
+ | **Local** | No | No | Development and testing |
141
87
 
142
- ```ts
143
- const job = RecurringJobs.get<{ total: number }>('daily-sales-report')
144
- ```
88
+ \* Cache persistence depends on Redis persistence settings.
145
89
 
146
- ### RecurringJob Instance Methods
90
+ ## API Reference
147
91
 
148
- #### `job.startProcessing()`
92
+ ### `RecurringJobs.create(config)`
149
93
 
150
- Enqueue the next scheduled run and begin polling. If a waiting job already exists, it is not duplicated (deduplication).
94
+ Register a recurring job. Throws immediately on invalid cron expression or duplicate job name.
151
95
 
152
96
  ```ts
153
- dailyReport.startProcessing()
97
+ RecurringJobs.create({
98
+ name: string, // unique job identifier
99
+ cron: string, // cron expression (e.g. '0 9 * * MON')
100
+ handle: () => Promise<Result>, // async handler
101
+ middleware?: Middleware<Result>[] // optional middleware chain
102
+ })
154
103
  ```
155
104
 
156
- #### `job.stopProcessing()`
105
+ ### `RecurringJobs.work()`
157
106
 
158
- Stop polling for this job.
107
+ Start a 60-second polling loop that checks for due jobs, acquires distributed locks via the
108
+ driver, and executes handlers. Returns an object with `abort()` to stop the loop and wait for
109
+ in-flight ticks.
159
110
 
160
111
  ```ts
161
- dailyReport.stopProcessing()
162
- ```
163
-
164
- #### `job.getNextUp()`
165
-
166
- Return the first message in `waiting` status, or `null` if none exists.
112
+ const worker = RecurringJobs.work()
167
113
 
168
- ```ts
169
- const next = await dailyReport.getNextUp()
170
- // { id: 42, status: 'waiting', scheduledDate: '2026-02-22T06:00:00.000Z', cron: '0 6 * * *', ... }
114
+ // Later, to stop:
115
+ await worker.abort()
171
116
  ```
172
117
 
173
- #### `job.list({ pageSize, pageNumber })`
118
+ ### `RecurringJobs.stopAllWork()`
174
119
 
175
- Return a paginated list of all messages (past runs and pending) for this job.
120
+ Abort all active work loops and wait for in-flight ticks to finish.
176
121
 
177
122
  ```ts
178
- const history = await dailyReport.list({ pageSize: 20, pageNumber: 0 })
179
- // [{ id: 41, status: 'success', result: { total: 15420 }, ... }, ...]
123
+ await RecurringJobs.stopAllWork()
180
124
  ```
181
125
 
182
- #### `job.rescheduleJob({ jobId, scheduledDate })`
126
+ ## Common Patterns
183
127
 
184
- Change the scheduled time of a waiting job. Only the next waiting job can be rescheduled; throws if `jobId` does not match the current waiting job.
128
+ ### Basic cron job
185
129
 
186
130
  ```ts
187
- await dailyReport.rescheduleJob({
188
- jobId: 42,
189
- scheduledDate: '2026-02-22T08:00:00.000Z' as Iso.Instant
131
+ RecurringJobs.create({
132
+ name: 'cleanup-expired-tokens',
133
+ cron: '0 3 * * *', // daily at 3am
134
+ handle: async () => {
135
+ await db.update('DELETE FROM tokens WHERE expiresAt < NOW()')
136
+ }
190
137
  })
191
138
  ```
192
139
 
193
- #### `job.name` / `job.cron`
194
-
195
- Read-only properties exposing the job's name and cron expression.
140
+ ### Middleware wrapping
196
141
 
197
142
  ```ts
198
- dailyReport.name // 'daily-sales-report'
199
- dailyReport.cron // '0 6 * * *'
143
+ RecurringJobs.create({
144
+ name: 'generate-report',
145
+ cron: '0 6 * * MON',
146
+ middleware: [
147
+ async (next) => {
148
+ try {
149
+ return await next()
150
+ } catch (e) {
151
+ await errorReporter.capture(e)
152
+ throw e
153
+ }
154
+ }
155
+ ],
156
+ handle: async () => generateWeeklyReport()
157
+ })
200
158
  ```
201
159
 
202
- ### JobMessage Interface
203
-
204
- Each queue message returned by `getNextUp()` and `list()` has these fields:
160
+ ### Graceful shutdown
205
161
 
206
162
  ```ts
207
- interface JobMessage<Result = any> {
208
- id: number
209
- batchId: number | null
210
- queue: string
211
- scheduledDate: Iso.Instant
212
- enqueuedDate: Iso.Instant
213
- startedDate: Iso.Instant | null
214
- status: 'waiting' | 'processing' | 'success' | 'error' | 'stuck'
215
- result: Result
216
- recovered: number
217
- runningTime: number | null
218
- uniqueKey: number | null
219
- priority: number | null
220
- cron: string
221
- }
222
- ```
163
+ const worker = RecurringJobs.work()
223
164
 
224
- ## Common Patterns
225
-
226
- ### Multiple Recurring Jobs
227
-
228
- ```ts
229
- const report = RecurringJobs.createJob({
230
- name: 'daily-report',
231
- cron: '0 6 * * *',
232
- workFn: async () => generateReport()
165
+ process.on('SIGTERM', async () => {
166
+ await worker.abort() // waits for in-flight tick to finish
167
+ process.exit(0)
233
168
  })
234
169
 
235
- const cleanup = RecurringJobs.createJob({
236
- name: 'session-cleanup',
237
- cron: '0 0 * * *',
238
- workFn: async () => cleanStaleSessions()
170
+ // Or abort all workers at once:
171
+ process.on('SIGTERM', async () => {
172
+ await RecurringJobs.stopAllWork()
173
+ process.exit(0)
239
174
  })
240
-
241
- // Start all jobs at once
242
- RecurringJobs.startProcessing()
243
-
244
- // Or start individually
245
- report.startProcessing()
246
- cleanup.startProcessing()
247
175
  ```
248
176
 
249
- ### Inspecting and Rescheduling
177
+ ### Named provider instances
250
178
 
251
179
  ```ts
252
- const job = RecurringJobs.get('daily-report')
253
- if (job) {
254
- const next = await job.getNextUp()
255
- if (next) {
256
- // Delay the next run by 2 hours
257
- const delayed = new Date(new Date(next.scheduledDate).getTime() + 2 * 60 * 60 * 1000)
258
- await job.rescheduleJob({
259
- jobId: next.id,
260
- scheduledDate: delayed.toISOString() as Iso.Instant
261
- })
262
- }
263
- }
264
- ```
265
-
266
- ### Per-Job Logger
267
-
268
- Add a dedicated logger to a specific job for fine-grained observability:
269
-
270
- ```ts
271
- const jobLogger: Log.LogFunctions<[RecurringJobs.LogMessage]> = {
272
- write({ data: [msg] }) {
273
- if (msg.event === 'jobFailed') {
274
- alertOpsTeam(msg.job, msg.error)
275
- }
276
- }
277
- }
278
-
279
- const criticalJob = RecurringJobs.createJob({
280
- name: 'payment-reconciliation',
281
- cron: '0 */4 * * *',
282
- workFn: async () => reconcilePayments(),
283
- logger: jobLogger
180
+ const primary = RecurringJobs.Provider.create({
181
+ driver: RecurringJobs.drivers.db({ db: Db, databaseTable: 'recurringJobs' })
284
182
  })
285
- ```
183
+ RecurringJobs.Provider.register('default', primary)
286
184
 
287
- ### Execution History
185
+ const secondary = RecurringJobs.Provider.create({
186
+ driver: RecurringJobs.drivers.local()
187
+ })
188
+ RecurringJobs.Provider.register('dev', secondary)
288
189
 
289
- ```ts
290
- const job = RecurringJobs.get('daily-report')
291
- if (job) {
292
- const runs = await job.list({ pageSize: 50, pageNumber: 0 })
293
- const failures = runs.filter(r => r.status === 'error')
294
- const avgRuntime = runs
295
- .filter(r => r.runningTime !== null)
296
- .reduce((sum, r) => sum + r.runningTime!, 0) / runs.length
297
- }
190
+ RecurringJobs.provider('dev').create({ ... })
298
191
  ```
299
192
 
300
- ## Log Events
301
-
302
- The service emits structured log events of type `RecurringJobs.LogMessage`:
193
+ ## Warnings and Gotchas
303
194
 
304
- | Event | Level | Description |
305
- |---|---|---|
306
- | `jobProcessing` | info | A job message was dequeued and is about to execute |
307
- | `jobSucceeded` | info | The work function completed successfully |
308
- | `jobFailed` | error | The work function threw an error |
309
- | `jobScheduled` | info | The next run was enqueued with its scheduled date |
310
- | `jobAlreadyScheduled` | info | A waiting job already exists; skip enqueueing |
311
- | `jobSchedulingFailed` | error | Failed to enqueue the next run |
195
+ - **60-second polling**: Jobs won't fire more precisely than ~1 minute.
196
+ - **Unique job names**: Duplicate names within a service instance throw at `create()` time.
197
+ - **Eager cron validation**: Invalid cron expressions throw at `create()` time, not at poll time.
198
+ - **First-time jobs**: A never-before-run job doesn't fire immediately on `work()` -- it waits
199
+ until its cron expression's next occurrence from now.
200
+ - **Handler errors don't crash the loop**: Failed handlers are logged as `jobFailed` and other
201
+ jobs continue normally.
202
+ - **Db driver requires a table**: The `recurringJobs` table must exist with the schema shown above.
203
+ - **Local driver has no distributed locking**: All processes will independently run the same job.
312
204
 
313
205
  ## Cross-Package Integration
314
206
 
315
- ### Dependencies
316
-
317
- - **@maestro-js/service-registry** -- foundation for the Provider pattern (registry, deferred Proxy resolution)
318
- - **@maestro-js/queue** -- each recurring job creates a dedicated queue via `queueService.create()`. Queue handles message persistence, polling, dequeuing, and lifecycle transitions
319
- - **@maestro-js/log** -- structured logging with pluggable transports for job lifecycle events
320
-
321
- ### How It Connects
207
+ - **Depends on**: `@maestro-js/service-registry` (Provider pattern), `@maestro-js/log` (structured
208
+ logging), `cron-parser` (cron expression parsing)
209
+ - **Optional drivers depend on**: `@maestro-js/db` (Db driver), `@maestro-js/cache` (Cache driver)
322
210
 
323
- RecurringJobs wraps Queue to add cron scheduling semantics. When `startProcessing()` is called, the next cron-derived date is computed and a queue message is enqueued for that date. When the Queue dequeues and runs the message, the work function fires and the next run is enqueued before the work executes. This ensures schedule continuity even on failure.
211
+ ## Driver Interface
324
212
 
325
- Queue itself depends on Db (MySQL) for message persistence, so the full dependency chain is:
213
+ Implement `RecurringJobs.Driver` to create a custom backend:
326
214
 
215
+ ```ts
216
+ interface Driver {
217
+ listLastRunDates(jobNames: string[]): Promise<{ jobName: string; date: Iso.Instant }[]>
218
+ pullJob(options: { jobName: string; date: Iso.Instant }): Promise<boolean>
219
+ }
327
220
  ```
328
- RecurringJobs -> Queue -> Db (MySQL)
329
- -> Log
330
- -> ServiceRegistry
331
- ```
332
-
333
- ### Provider Pattern
334
221
 
335
- This package follows the standard maestro Provider pattern:
222
+ `listLastRunDates` returns the last run date for each known job name. `pullJob` attempts to
223
+ acquire a lock for the given job and date -- returns `true` if the lock was acquired (this process
224
+ should run the job), `false` otherwise.
336
225
 
337
- 1. `RecurringJobs.Provider.create(config)` -- factory that instantiates the service
338
- 2. `RecurringJobs.Provider.register(name, service)` -- registers the instance by name
339
- 3. `RecurringJobs.provider(key)` -- resolves a named instance (deferred via Proxy)
340
- 4. `RecurringJobs.*` -- facade that spreads `provider('default')` for direct method access
226
+ Source: `packages/recurring-jobs/src/index.ts`
341
227
 
342
- ## Notes
228
+ ## Key Source Files
343
229
 
344
- - No tests exist for this package yet (`pnpm --filter @maestro-js/recurring-jobs test` prints "No tests yet").
345
- - The `cron-parser` library validates cron expressions at job creation time; invalid expressions throw immediately.
346
- - Deduplication: `startProcessing()` checks for an existing waiting message before enqueueing, preventing double-fires across process restarts.
347
- - The next run is enqueued inside the queue's `workFn` (before the user's `workFn` runs), so the schedule continues even if the current execution fails.
348
- - Only the next waiting job can be rescheduled via `rescheduleJob()`. Attempting to reschedule a non-waiting or non-next job throws an error.
230
+ - `packages/recurring-jobs/src/index.ts` -- main export, Provider pattern, polling loop, middleware
231
+ - `packages/recurring-jobs/src/db-recurring-jobs-driver.ts` -- MySQL driver with row-level locking
232
+ - `packages/recurring-jobs/src/cache-recurring-jobs-driver.ts` -- Redis driver via @maestro-js/cache
233
+ - `packages/recurring-jobs/src/local-recurring-jobs-driver.ts` -- in-memory driver for dev/testing
package/dist/index.d.ts CHANGED
@@ -1,200 +1,167 @@
1
1
  import { Iso } from 'iso-fns2';
2
- import { Queue } from '@maestro-js/queue';
3
2
  import { Log } from '@maestro-js/log';
3
+ import { Cache } from '@maestro-js/cache';
4
4
 
5
- declare function create(serviceConfig: RecurringJobs.Provider.RecurringJobsServiceConfig): {
6
- startProcessing: () => void;
7
- stopProcessing: () => void;
8
- list: () => string[];
9
- get: <Result>(name: string) => RecurringJobs.RecurringJob<Result> | undefined;
10
- createJob: <T>(jobConfig: RecurringJobs.JobConfig<T>) => RecurringJobs.RecurringJob<T>;
5
+ interface DbRecurringJobsDriverConfig {
6
+ db: {
7
+ update(query: string, params?: any[]): Promise<{
8
+ affectedRows: number;
9
+ changedRows: number;
10
+ }>;
11
+ select(query: string, params?: any[]): Promise<Array<Record<string, any>>>;
12
+ insert(query: string, params?: any[]): Promise<{
13
+ insertId: number;
14
+ }>;
15
+ };
16
+ databaseTable: string;
17
+ }
18
+ declare function DbRecurringJobsDriver({ db, databaseTable }: DbRecurringJobsDriverConfig): RecurringJobs.Driver;
19
+
20
+ declare function localRecurringJobsDriver(): RecurringJobs.Driver;
21
+
22
+ interface CacheRecurringJobsDriverConfig {
23
+ cache: Cache.CacheService;
24
+ keyPrefix?: string;
25
+ }
26
+ declare function cacheRecurringJobsDriver({ cache, keyPrefix }: CacheRecurringJobsDriverConfig): RecurringJobs.Driver;
27
+
28
+ declare function createService({ driver, logger: serviceLogger }: RecurringJobs.Provider.ServiceConfig): RecurringJobs.Service;
29
+ type RecurringJobsLogMessage = {
30
+ event: 'jobDue';
31
+ jobName: string;
32
+ nextRun: string;
33
+ } | {
34
+ event: 'jobLockAcquired';
35
+ jobName: string;
36
+ nextRun: string;
37
+ } | {
38
+ event: 'jobLockFailed';
39
+ jobName: string;
40
+ nextRun: string;
41
+ error: unknown;
42
+ } | {
43
+ event: 'jobProcessed';
44
+ jobName: string;
45
+ } | {
46
+ event: 'jobFailed';
47
+ jobName: string;
48
+ error: unknown;
11
49
  };
50
+ type KeysWithFallback = keyof RecurringJobs.Provider.Keys extends never ? {
51
+ default: unknown;
52
+ } : RecurringJobs.Provider.Keys;
12
53
  declare function provider(key: RecurringJobs.Provider.Key): {
13
- startProcessing: () => void;
14
- stopProcessing: () => void;
15
- list: () => string[];
16
- get: <Result>(name: string) => RecurringJobs.RecurringJob<Result> | undefined;
17
- createJob: <T>(jobConfig: RecurringJobs.JobConfig<T>) => RecurringJobs.RecurringJob<T>;
54
+ work: () => {
55
+ abort(): Promise<void>;
56
+ };
57
+ stopAllWork: () => Promise<void>;
58
+ create: <Result = any>(config: RecurringJobs.JobConfig<Result>) => void;
18
59
  };
19
60
  /**
20
- * Cron-based job scheduling backed by the Queue package.
61
+ * Cron-based recurring job scheduling with distributed lock-based deduplication.
62
+ *
63
+ * Register a service with `RecurringJobs.Provider.register('default', RecurringJobs.Provider.create({ driver }))`,
64
+ * then define jobs with `RecurringJobs.create({ name, cron, handle })`. Call `RecurringJobs.work()` to start
65
+ * a 60-second polling loop that checks for due jobs, acquires distributed locks via the driver, and executes
66
+ * handlers with optional middleware.
21
67
  *
22
- * Each recurring job gets its own dedicated queue where every execution is a
23
- * persisted message with full lifecycle tracking. The next run is enqueued
24
- * *before* the work function executes, guaranteeing the schedule continues
25
- * even if the current run fails. Deduplication prevents double-fires across
26
- * process restarts.
68
+ * Three built-in drivers: `db` (MySQL row-level locking), `cache` (Redis via @maestro-js/cache),
69
+ * `local` (in-memory, no distributed locking).
27
70
  *
28
71
  * @example
29
72
  * ```ts
30
- * // Create a daily sales report job
31
- * const salesReport = RecurringJobs.createJob({
32
- * name: 'daily-sales-report',
33
- * cron: '0 6 * * *',
34
- * workFn: async () => {
35
- * const total = await calculateDailySales()
36
- * return { total }
37
- * }
38
- * })
73
+ * RecurringJobs.Provider.register('default', RecurringJobs.Provider.create({
74
+ * driver: RecurringJobs.drivers.db({ db: Db, databaseTable: 'recurringJobs' })
75
+ * }))
39
76
  *
40
- * // Start processing and inspect upcoming runs
41
- * salesReport.startProcessing()
42
- * const next = await salesReport.getNextUp() // { status: 'waiting', scheduledDate: '...' }
77
+ * RecurringJobs.create({
78
+ * name: 'send-weekly-digest',
79
+ * cron: '0 9 * * MON',
80
+ * handle: async () => sendDigest()
81
+ * })
43
82
  *
44
- * // List all registered jobs
45
- * RecurringJobs.list() // ['daily-sales-report']
83
+ * const worker = RecurringJobs.work()
84
+ * // Later: await worker.abort()
46
85
  * ```
47
86
  */
48
87
  declare namespace RecurringJobs {
49
- /** A single queue message representing one scheduled or completed job execution */
50
- interface JobMessage<Result = any> {
51
- id: number;
52
- batchId: number | null;
53
- queue: string;
54
- scheduledDate: Iso.Instant;
55
- enqueuedDate: Iso.Instant;
56
- startedDate: Iso.Instant | null;
57
- status: 'waiting' | 'processing' | 'success' | 'error' | 'stuck';
58
- result: Result;
59
- recovered: number;
60
- runningTimeSeconds: number | null;
61
- uniqueKey: number | null;
62
- priority: number | null;
63
- cron: string;
88
+ /** Union of all recurring jobs lifecycle log event shapes */
89
+ type LogMessage = RecurringJobsLogMessage;
90
+ /** Middleware function that wraps recurring job execution */
91
+ type Middleware<Result = any> = (next: () => Promise<Result>) => Promise<Result>;
92
+ /** Pluggable storage backend for tracking job run dates and acquiring distributed locks */
93
+ interface Driver {
94
+ /** Returns the last run date for each of the given job names */
95
+ listLastRunDates(jobNames: string[]): Promise<{
96
+ jobName: string;
97
+ date: Iso.Instant;
98
+ }[]>;
99
+ /** Attempts to acquire a lock for the given job and date — returns true if the lock was acquired */
100
+ pullJob(options: {
101
+ jobName: string;
102
+ date: Iso.Instant;
103
+ }): Promise<boolean>;
64
104
  }
65
- /**
66
- * A single recurring job instance with its own dedicated queue.
67
- *
68
- * Each job has independent start/stop lifecycle, execution history, and
69
- * rescheduling capability. The queue stores one message per scheduled run —
70
- * call {@link list} to page through past and pending executions, or
71
- * {@link getNextUp} to peek at the next waiting run.
72
- *
73
- * @example
74
- * ```ts
75
- * const job = RecurringJobs.createJob({
76
- * name: 'stale-session-cleanup',
77
- * cron: '0 0 * * *',
78
- * workFn: async () => { await cleanStaleSessions() }
79
- * })
80
- * job.startProcessing()
81
- * const next = await job.getNextUp() // { status: 'waiting', ... }
82
- * ```
83
- */
84
- interface RecurringJob<T = unknown> {
85
- /** Enqueues the next scheduled run and begins polling */
86
- startProcessing(): void;
87
- /** Stops polling for this job */
88
- stopProcessing(): void;
89
- /** The name of the recurring job as specified in the config */
90
- name: string;
91
- /** The cron expression defining this job's schedule */
105
+ /** Per-job configuration passed to {@link Service.create} */
106
+ interface JobConfig<Result = any> {
107
+ /** Cron expression defining the schedule (e.g. '0 9 * * MON') */
92
108
  cron: string;
93
- /** Changes the scheduled time of a waiting job */
94
- rescheduleJob(job: {
95
- jobId: number;
96
- scheduledDate: Iso.Instant;
97
- }): Promise<void>;
98
- /** Returns the first message in `waiting` status, or `null` if none exists */
99
- getNextUp(): Promise<JobMessage<T> | null>;
100
- /** Returns a paginated list of all messages (past runs and pending) for this job */
101
- list(options: {
102
- pageSize: number;
103
- pageNumber: number;
104
- }): Promise<JobMessage<unknown>[]>;
105
- }
106
- /** Union of all recurring job lifecycle log event shapes */
107
- type LogMessage = {
108
- event: 'jobProcessing';
109
- job: string;
110
- driver: Queue.Driver;
111
- messageId: number;
112
- } | {
113
- event: 'jobSucceeded';
114
- job: string;
115
- driver: Queue.Driver;
116
- result: unknown;
117
- messageId: number;
118
- } | {
119
- event: 'jobFailed';
120
- job: string;
121
- driver: Queue.Driver;
122
- error: unknown;
123
- messageId: number;
124
- } | {
125
- event: 'jobScheduled';
126
- job: string;
127
- nextRun: Iso.Instant;
128
- } | {
129
- event: 'jobAlreadyScheduled';
130
- job: string;
131
- existingJobId: number;
132
- } | {
133
- event: 'jobSchedulingFailed';
134
- job: string;
135
- error: unknown;
136
- };
137
- /** Configuration for a single recurring job passed to {@link RecurringJobsService.createJob} */
138
- interface JobConfig<T = unknown> {
139
- cron: string;
140
- workFn(): Promise<T>;
109
+ /** Async handler invoked when the job is due */
110
+ handle(): Promise<Result>;
111
+ /** Unique name identifying this job across processes */
141
112
  name: string;
142
- logger?: Log.LogFunctions<[LogMessage]>;
113
+ /** Optional middleware chain wrapping the handler */
114
+ middleware?: Middleware<Result>[];
143
115
  }
144
116
  /**
145
- * Container that manages multiple recurring jobs sharing the same queue service.
146
- *
147
- * Create individual jobs with {@link createJob}, then start or stop processing
148
- * across all jobs at once or individually. Each job gets its own dedicated queue
149
- * named `RecurringJobs: <jobName>`.
117
+ * Core recurring jobs service returned by {@link RecurringJobs.Provider.create}.
150
118
  *
151
- * @example
152
- * ```ts
153
- * const report = RecurringJobs.createJob({ name: 'daily-report', cron: '0 6 * * *', workFn: generateReport })
154
- * const cleanup = RecurringJobs.createJob({ name: 'session-cleanup', cron: '0 0 * * *', workFn: cleanSessions })
155
- * RecurringJobs.startProcessing() // starts both jobs
156
- * ```
119
+ * Use {@link Service.create create} to register jobs, then call {@link Service.work work}
120
+ * to start polling. The service checks every 60 seconds for due jobs, acquires distributed
121
+ * locks via the driver, and executes handlers with middleware.
157
122
  */
158
- interface RecurringJobsService {
159
- /** Creates a recurring job with its own dedicated queue */
160
- createJob<T>(jobConfig: RecurringJobs.JobConfig<T>): RecurringJobs.RecurringJob<T>;
161
- /** Starts processing on all registered recurring jobs */
162
- startProcessing: () => void;
163
- /** Stops processing on all registered recurring jobs */
164
- stopProcessing: () => void;
165
- /** Returns the names of all registered recurring jobs */
166
- list: () => string[];
167
- /** Returns the recurring job with the given name, or `undefined` if not found */
168
- get: <Result>(name: string) => RecurringJobs.RecurringJob<Result> | undefined;
123
+ interface Service {
124
+ /** Starts a polling loop that checks for and executes due jobs every 60 seconds — returns an object with an `abort()` method to stop */
125
+ work(): {
126
+ abort(): Promise<void>;
127
+ };
128
+ /** Aborts all active work loops and waits for in-flight ticks to finish */
129
+ stopAllWork(): Promise<void>;
130
+ /** Registers a new recurring job with the given cron schedule and handler */
131
+ create<Result = any>(config: JobConfig<Result>): void;
169
132
  }
170
133
  namespace Provider {
171
- /** Service-level configuration passed to `RecurringJobs.Provider.create()` */
172
- interface RecurringJobsServiceConfig {
173
- logger: Log.Logger<[RecurringJobs.LogMessage]>;
174
- queueService: Queue.QueueService;
134
+ interface ServiceConfig {
135
+ driver: Driver;
136
+ logger?: Log.Logger<[LogMessage]>;
175
137
  }
176
- type Key = keyof Keys;
138
+ type Key = keyof KeysWithFallback;
177
139
  interface Keys {
178
- default: unknown;
179
140
  }
180
141
  }
181
142
  }
182
143
  /**
183
- * Cron-based job scheduling backed by the Queue package.
184
- * Call `RecurringJobs.Provider.create()` with a queue service, register it, then use `RecurringJobs.createJob()` to define jobs.
144
+ * Cron-based recurring job scheduling with distributed lock-based deduplication.
145
+ * Call `RecurringJobs.Provider.create()` with a driver, register it, then use `RecurringJobs.create()` to define jobs
146
+ * and `RecurringJobs.work()` to start the 60-second polling loop. Drivers: `db`, `cache`, `local`.
185
147
  */
186
148
  declare const RecurringJobs: {
187
149
  Provider: {
188
- create: typeof create;
189
- register: (name: RecurringJobs.Provider.Key, item: RecurringJobs.RecurringJobsService) => void;
150
+ create: typeof createService;
151
+ register: (name: RecurringJobs.Provider.Key, item: RecurringJobs.Service) => void;
190
152
  list: () => "default"[];
191
153
  };
192
154
  provider: typeof provider;
193
- startProcessing: () => void;
194
- stopProcessing: () => void;
195
- list: () => string[];
196
- get: <Result>(name: string) => RecurringJobs.RecurringJob<Result> | undefined;
197
- createJob: <T>(jobConfig: RecurringJobs.JobConfig<T>) => RecurringJobs.RecurringJob<T>;
155
+ work: () => {
156
+ abort(): Promise<void>;
157
+ };
158
+ stopAllWork: () => Promise<void>;
159
+ create: <Result = any>(config: RecurringJobs.JobConfig<Result>) => void;
160
+ drivers: {
161
+ db: typeof DbRecurringJobsDriver;
162
+ local: typeof localRecurringJobsDriver;
163
+ cache: typeof cacheRecurringJobsDriver;
164
+ };
198
165
  };
199
166
 
200
167
  export { RecurringJobs };
package/dist/index.js CHANGED
@@ -1,152 +1,222 @@
1
1
  // src/index.ts
2
- import "iso-fns2";
2
+ import { instantFns as instantFns2 } from "iso-fns2";
3
3
  import cronParser from "cron-parser";
4
- import { Log } from "@maestro-js/log";
5
4
  import { ServiceRegistry } from "@maestro-js/service-registry";
6
- function create(serviceConfig) {
7
- const queueService = serviceConfig.queueService;
8
- const jobs = [];
9
- function createJob(jobConfig) {
10
- cronParser.parseExpression(jobConfig.cron);
11
- const logger = Log.create({
12
- transports: [serviceConfig.logger, ...jobConfig.logger ? [jobConfig.logger] : []]
13
- });
14
- const queue = queueService.create({
15
- name: `RecurringJobs: ${jobConfig.name}`,
16
- workFn: async () => {
17
- await addJobToQueue(getNextScheduledDate());
18
- return jobConfig.workFn();
19
- },
20
- logger: Log.create({
21
- transports: [
22
- {
23
- write({ data: [message] }) {
24
- if (message.event === "messageDequeued") {
25
- logger.info({
26
- event: "jobProcessing",
27
- job: jobConfig.name,
28
- driver: message.driver,
29
- messageId: message.messageId
30
- });
31
- } else if (message.event === "messageProcessed") {
32
- logger.info({
33
- event: "jobSucceeded",
34
- job: jobConfig.name,
35
- driver: message.driver,
36
- result: message.result,
37
- messageId: message.messageId
38
- });
39
- } else if (message.event === "messageFailed") {
40
- logger.error({
41
- event: "jobFailed",
42
- job: jobConfig.name,
43
- driver: message.driver,
44
- error: message.error,
45
- messageId: message.messageId
46
- });
47
- }
48
- }
49
- }
50
- ]
51
- })
52
- });
53
- function getNextScheduledDate() {
54
- const interval = cronParser.parseExpression(jobConfig.cron);
55
- return interval.next().toDate().toISOString();
5
+
6
+ // src/db-recurring-jobs-driver.ts
7
+ import { instantFns } from "iso-fns2";
8
+ function DbRecurringJobsDriver({ db, databaseTable }) {
9
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(databaseTable)) {
10
+ throw new Error(`Invalid databaseTable name: ${databaseTable}`);
11
+ }
12
+ async function listLastRunDates(jobNames) {
13
+ if (jobNames.length === 0) return [];
14
+ const placeholders = jobNames.map(() => "?").join(", ");
15
+ const rows = await db.select(`SELECT name, lastRunDate FROM ${databaseTable} WHERE name IN (${placeholders})`, jobNames);
16
+ return rows.filter((row) => row["lastRunDate"] != null).map((row) => ({
17
+ jobName: row["name"],
18
+ date: row["lastRunDate"]
19
+ }));
20
+ }
21
+ async function pullJob({ jobName, date }) {
22
+ const formattedDate = instantFns.formatISO9075(date);
23
+ const { changedRows } = await db.update(
24
+ `UPDATE ${databaseTable} SET lastRunDate = ? WHERE name = ? AND (lastRunDate IS NULL OR lastRunDate < ?)`,
25
+ [formattedDate, jobName, formattedDate]
26
+ );
27
+ if (changedRows > 0) return true;
28
+ const insert = await db.update(`INSERT IGNORE INTO ${databaseTable} (name, lastRunDate) VALUES (?, ?)`, [
29
+ jobName,
30
+ formattedDate
31
+ ]);
32
+ return insert.affectedRows > 0;
33
+ }
34
+ return { listLastRunDates, pullJob };
35
+ }
36
+
37
+ // src/local-recurring-jobs-driver.ts
38
+ function localRecurringJobsDriver() {
39
+ const lastRunDates = /* @__PURE__ */ new Map();
40
+ async function listLastRunDates(jobNames) {
41
+ if (jobNames.length === 0) return [];
42
+ return jobNames.filter((name) => lastRunDates.has(name)).map((name) => ({
43
+ jobName: name,
44
+ date: lastRunDates.get(name)
45
+ }));
46
+ }
47
+ async function pullJob({ jobName, date }) {
48
+ const current = lastRunDates.get(jobName);
49
+ if (current == null || current < date) {
50
+ lastRunDates.set(jobName, date);
51
+ return true;
56
52
  }
57
- async function getNextUp() {
58
- const messages = await queue.listMessages({ pageNumber: 0, pageSize: 100 });
59
- const result = messages.find((m) => m.status === "waiting");
60
- return result ? { ...result, cron: jobConfig.cron } : null;
53
+ return false;
54
+ }
55
+ return { listLastRunDates, pullJob };
56
+ }
57
+
58
+ // src/cache-recurring-jobs-driver.ts
59
+ function cacheRecurringJobsDriver({
60
+ cache,
61
+ keyPrefix = "recurring-jobs:"
62
+ }) {
63
+ async function listLastRunDates(jobNames) {
64
+ if (jobNames.length === 0) return [];
65
+ const results = await Promise.all(
66
+ jobNames.map(async (name) => {
67
+ const date = await cache.get(keyPrefix + name);
68
+ return date != null ? { jobName: name, date } : null;
69
+ })
70
+ );
71
+ return results.filter((r) => r != null);
72
+ }
73
+ async function pullJob({ jobName, date }) {
74
+ const lock = cache.lock(keyPrefix + "lock:" + jobName, 10);
75
+ const acquired = await lock.get();
76
+ if (!acquired) return false;
77
+ try {
78
+ const current = await cache.get(keyPrefix + jobName);
79
+ if (current == null || current < date) {
80
+ await cache.set(keyPrefix + jobName, date, null);
81
+ return true;
82
+ }
83
+ return false;
84
+ } finally {
85
+ await lock.release();
61
86
  }
62
- async function list2({
63
- pageNumber,
64
- pageSize
65
- }) {
66
- const messages = await queue.listMessages({ pageNumber, pageSize });
67
- return messages.map((m) => ({ ...m, cron: jobConfig.cron }));
87
+ }
88
+ return { listLastRunDates, pullJob };
89
+ }
90
+
91
+ // src/index.ts
92
+ function createService({ driver, logger: serviceLogger }) {
93
+ const allJobs = /* @__PURE__ */ new Map();
94
+ const logger = serviceLogger;
95
+ let lastRunDatesCached = null;
96
+ async function refreshLastRunDates() {
97
+ const rows = await driver.listLastRunDates([...allJobs.keys()]);
98
+ const map = /* @__PURE__ */ new Map();
99
+ for (const row of rows) map.set(row.jobName, row.date);
100
+ lastRunDatesCached = map;
101
+ return map;
102
+ }
103
+ async function executeJob(job) {
104
+ const middleware = job.middleware ?? [];
105
+ const executeFn = middleware.reduceRight(
106
+ (acc, cur) => {
107
+ return () => cur(acc);
108
+ },
109
+ () => job.handle()
110
+ );
111
+ return executeFn();
112
+ }
113
+ async function tick() {
114
+ const now = instantFns2.now();
115
+ const lastRunDates = lastRunDatesCached ?? await refreshLastRunDates();
116
+ const jobsToRun = [...allJobs.values()].map((jobConfig) => {
117
+ const lastRun = lastRunDates.get(jobConfig.name);
118
+ const cron = cronParser.parseExpression(jobConfig.cron);
119
+ if (lastRun) {
120
+ cron.reset(lastRun);
121
+ }
122
+ const nextRun = cron.next().toDate().toISOString();
123
+ return { ...jobConfig, nextRun };
124
+ }).filter((c) => c.nextRun <= now);
125
+ for (const job of jobsToRun) {
126
+ logger?.info({ event: "jobDue", jobName: job.name, nextRun: job.nextRun });
68
127
  }
69
- async function rescheduleJob({ jobId, scheduledDate }) {
70
- const nextUp = await getNextUp();
71
- if (!nextUp || nextUp.id !== jobId) {
72
- throw new Error(
73
- `Cannot reschedule job ${jobId}: only the next waiting job can be rescheduled.` + (nextUp ? ` The current waiting job is ${nextUp.id}.` : " No waiting job found.")
74
- );
128
+ const lockedJobs = [];
129
+ for (const job of jobsToRun) {
130
+ try {
131
+ const haveLock = await driver.pullJob({ jobName: job.name, date: job.nextRun });
132
+ if (haveLock) {
133
+ logger?.info({ event: "jobLockAcquired", jobName: job.name, nextRun: job.nextRun });
134
+ lockedJobs.push(job);
135
+ }
136
+ } catch (e) {
137
+ logger?.error({ event: "jobLockFailed", jobName: job.name, nextRun: job.nextRun, error: e });
75
138
  }
76
- await queue.updateMessage({ messageId: jobId, scheduledDate, body: {} });
77
139
  }
78
- async function addJobToQueue(nextRun) {
79
- const data = await getNextUp();
80
- if (data) {
81
- logger.info({ event: "jobAlreadyScheduled", job: jobConfig.name, existingJobId: data.id });
82
- return false;
140
+ if (jobsToRun.length) {
141
+ lastRunDatesCached = null;
142
+ }
143
+ const results = await Promise.allSettled(lockedJobs.map(executeJob));
144
+ for (let i = 0; i < results.length; i++) {
145
+ if (results[i].status === "rejected") {
146
+ logger?.error({
147
+ event: "jobFailed",
148
+ jobName: lockedJobs[i].name,
149
+ error: results[i].reason
150
+ });
151
+ } else {
152
+ logger?.info({ event: "jobProcessed", jobName: lockedJobs[i].name });
83
153
  }
84
- await queue.addMessage({}, { scheduledDate: nextRun, tracingContext: null });
85
- logger.info({ event: "jobScheduled", job: jobConfig.name, nextRun });
86
- return true;
87
154
  }
88
- function startProcessing2() {
89
- addJobToQueue(getNextScheduledDate()).catch((error) => {
90
- logger.error({ event: "jobSchedulingFailed", job: jobConfig.name, error });
155
+ }
156
+ let abortWorkFunctions = [];
157
+ function work() {
158
+ let ticks = [];
159
+ function doTick() {
160
+ const promise = tick();
161
+ ticks.push(promise);
162
+ promise.finally(() => {
163
+ ticks = ticks.filter((t) => t !== promise);
164
+ });
165
+ return promise.catch((e) => {
91
166
  });
92
- queue.startProcessing();
93
167
  }
94
- function stopProcessing2() {
95
- queue.stopProcessing();
168
+ doTick();
169
+ const interval = setInterval(doTick, 6e4);
170
+ async function abort() {
171
+ clearInterval(interval);
172
+ await Promise.all(ticks);
173
+ abortWorkFunctions = abortWorkFunctions.filter((a) => a !== abort);
96
174
  }
97
- const jobFunctions = {
98
- getNextUp,
99
- list: list2,
100
- startProcessing: startProcessing2,
101
- stopProcessing: stopProcessing2,
102
- rescheduleJob,
103
- name: jobConfig.name,
104
- cron: jobConfig.cron
175
+ abortWorkFunctions.push(abort);
176
+ return {
177
+ abort
105
178
  };
106
- jobs.push(jobFunctions);
107
- return jobFunctions;
108
- }
109
- function startProcessing() {
110
- jobs.forEach((job) => job.startProcessing());
111
179
  }
112
- function stopProcessing() {
113
- jobs.forEach((job) => job.stopProcessing());
180
+ async function stopAllWork() {
181
+ await Promise.all(abortWorkFunctions.map((w) => w()));
114
182
  }
115
- function list() {
116
- return jobs.map((job) => job.name);
117
- }
118
- function get(name) {
119
- return jobs.find((job) => job.name === name);
183
+ function create(config) {
184
+ cronParser.parseExpression(config.cron);
185
+ if (allJobs.has(config.name)) {
186
+ throw new Error(`Cannot have multiple recurring jobs with name "${config.name}"`);
187
+ }
188
+ allJobs.set(config.name, config);
120
189
  }
121
- return { startProcessing, stopProcessing, list, get, createJob };
190
+ return { work, create, stopAllWork };
122
191
  }
123
192
  var registry = ServiceRegistry.createRegistry(
124
193
  ServiceRegistry.proxyFunctionsForObject
125
194
  );
126
195
  var Provider = {
127
- create,
196
+ create: createService,
128
197
  register: registry.register,
129
198
  list: registry.list
130
199
  };
131
200
  function provider(key) {
132
201
  const service = registry.resolve(key);
133
202
  return {
134
- startProcessing: service.startProcessing,
135
- stopProcessing: service.stopProcessing,
136
- list: service.list,
137
- get: service.get,
138
- createJob: service.createJob
203
+ work: service.work,
204
+ stopAllWork: service.stopAllWork,
205
+ create: service.create
139
206
  };
140
207
  }
141
208
  var facade = provider("default");
142
209
  var RecurringJobs = {
143
210
  Provider,
144
211
  provider,
145
- startProcessing: facade.startProcessing,
146
- stopProcessing: facade.stopProcessing,
147
- list: facade.list,
148
- get: facade.get,
149
- createJob: facade.createJob
212
+ work: facade.work,
213
+ stopAllWork: facade.stopAllWork,
214
+ create: facade.create,
215
+ drivers: {
216
+ db: DbRecurringJobsDriver,
217
+ local: localRecurringJobsDriver,
218
+ cache: cacheRecurringJobsDriver
219
+ }
150
220
  };
151
221
  export {
152
222
  RecurringJobs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maestro-js/recurring-jobs",
3
- "description": "Use when working with @maestro-js/recurring-jobs. Cron-based job scheduling backed by the Queue package. Covers creating recurring jobs, managing schedules, start/stop processing, rescheduling, listing jobs, and inspecting upcoming runs. Follows the Provider pattern with ServiceRegistry. Key capabilities include cron expression parsing, deduplication across process restarts, per-job dedicated queues, structured log events, and paginated execution history.",
3
+ "description": "Use when working with @maestro-js/recurring-jobs. Cron-based recurring job scheduling with distributed lock-based deduplication. Use for cron scheduling, periodic background tasks, and recurring jobs that must run exactly once across processes. Drivers: Db (MySQL), Cache (Redis via @maestro-js/cache), Local (in-memory). Follows the Provider pattern with ServiceRegistry. Key capabilities include cron expression parsing, 60-second polling loop, per-job distributed locking, optional middleware wrapping, eager cron validation, structured lifecycle log events (jobDue, jobLockAcquired, jobFailed, jobProcessed), and graceful shutdown via abort/stopAllWork.",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -11,14 +11,20 @@
11
11
  "dependencies": {
12
12
  "cron-parser": "^4.9.0",
13
13
  "iso-fns2": "npm:iso-fns@2.0.0-alpha.26",
14
- "@maestro-js/service-registry": "1.0.0-alpha.0",
15
- "@maestro-js/log": "1.0.0-alpha.0"
14
+ "@maestro-js/service-registry": "1.0.0-alpha.10"
15
+ },
16
+ "peerDependencies": {
17
+ "@maestro-js/cache": "1.0.0-alpha.10",
18
+ "@maestro-js/log": "1.0.0-alpha.10"
16
19
  },
17
20
  "devDependencies": {
18
21
  "@types/node": "^22.19.11",
19
- "@maestro-js/queue": "1.0.0-alpha.0"
22
+ "@maestro-js/cache": "1.0.0-alpha.10",
23
+ "@maestro-js/db": "1.0.0-alpha.10",
24
+ "@maestro-js/log": "1.0.0-alpha.10",
25
+ "@maestro-js/queue": "1.0.0-alpha.10"
20
26
  },
21
- "version": "1.0.0-alpha.0",
27
+ "version": "1.0.0-alpha.10",
22
28
  "publishConfig": {
23
29
  "access": "restricted"
24
30
  },
@@ -33,7 +39,7 @@
33
39
  "scripts": {
34
40
  "build": "tsup --config ../../tsup.config.ts",
35
41
  "typecheck": "tsc --noEmit",
36
- "test": "echo 'No tests yet'",
42
+ "test": "beartest ./tests/**/*",
37
43
  "format": "prettier --write src/",
38
44
  "lint": "prettier --check src/"
39
45
  }