@maestro-js/recurring-jobs 1.0.0-alpha.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/README.md +348 -0
- package/dist/index.d.ts +200 -0
- package/dist/index.js +153 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# RecurringJobs Skill
|
|
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.
|
|
6
|
+
|
|
7
|
+
## Quick Setup
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
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
|
+
|
|
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')
|
|
23
|
+
})
|
|
24
|
+
RecurringJobs.Provider.register('default', recurringJobsService)
|
|
25
|
+
|
|
26
|
+
// 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
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
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:
|
|
43
|
+
|
|
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
|
+
* * * * *
|
|
52
|
+
```
|
|
53
|
+
|
|
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
|
|
60
|
+
|
|
61
|
+
Invalid cron expressions throw immediately when passed to `createJob()`, since `cron-parser.parseExpression()` is called at creation time.
|
|
62
|
+
|
|
63
|
+
## API Reference
|
|
64
|
+
|
|
65
|
+
### Provider Setup
|
|
66
|
+
|
|
67
|
+
#### `RecurringJobs.Provider.create(config)`
|
|
68
|
+
|
|
69
|
+
Create a recurring jobs service instance.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
RecurringJobs.Provider.create(config: {
|
|
73
|
+
logger: Log.Logger<[RecurringJobs.LogMessage]>
|
|
74
|
+
queueService: Queue.QueueService
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
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)`
|
|
82
|
+
|
|
83
|
+
Register the service instance in the named registry.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
RecurringJobs.Provider.register('default', recurringJobsService)
|
|
87
|
+
```
|
|
88
|
+
|
|
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
|
+
```
|
|
96
|
+
|
|
97
|
+
### Service Methods (on the facade or provider instance)
|
|
98
|
+
|
|
99
|
+
#### `RecurringJobs.createJob(config)`
|
|
100
|
+
|
|
101
|
+
Create a recurring job with a dedicated queue. Returns a `RecurringJob` handle.
|
|
102
|
+
|
|
103
|
+
```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>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Each job creates a queue named `RecurringJobs: <name>`. The cron expression is validated immediately and throws if invalid.
|
|
113
|
+
|
|
114
|
+
#### `RecurringJobs.startProcessing()`
|
|
115
|
+
|
|
116
|
+
Start processing on all registered recurring jobs.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
RecurringJobs.startProcessing()
|
|
120
|
+
```
|
|
121
|
+
|
|
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
|
+
```
|
|
137
|
+
|
|
138
|
+
#### `RecurringJobs.get(name)`
|
|
139
|
+
|
|
140
|
+
Return the recurring job with the given name, or `undefined` if not found.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const job = RecurringJobs.get<{ total: number }>('daily-sales-report')
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### RecurringJob Instance Methods
|
|
147
|
+
|
|
148
|
+
#### `job.startProcessing()`
|
|
149
|
+
|
|
150
|
+
Enqueue the next scheduled run and begin polling. If a waiting job already exists, it is not duplicated (deduplication).
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
dailyReport.startProcessing()
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
#### `job.stopProcessing()`
|
|
157
|
+
|
|
158
|
+
Stop polling for this job.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
dailyReport.stopProcessing()
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
#### `job.getNextUp()`
|
|
165
|
+
|
|
166
|
+
Return the first message in `waiting` status, or `null` if none exists.
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const next = await dailyReport.getNextUp()
|
|
170
|
+
// { id: 42, status: 'waiting', scheduledDate: '2026-02-22T06:00:00.000Z', cron: '0 6 * * *', ... }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
#### `job.list({ pageSize, pageNumber })`
|
|
174
|
+
|
|
175
|
+
Return a paginated list of all messages (past runs and pending) for this job.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const history = await dailyReport.list({ pageSize: 20, pageNumber: 0 })
|
|
179
|
+
// [{ id: 41, status: 'success', result: { total: 15420 }, ... }, ...]
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
#### `job.rescheduleJob({ jobId, scheduledDate })`
|
|
183
|
+
|
|
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.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
await dailyReport.rescheduleJob({
|
|
188
|
+
jobId: 42,
|
|
189
|
+
scheduledDate: '2026-02-22T08:00:00.000Z' as Iso.Instant
|
|
190
|
+
})
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
#### `job.name` / `job.cron`
|
|
194
|
+
|
|
195
|
+
Read-only properties exposing the job's name and cron expression.
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
dailyReport.name // 'daily-sales-report'
|
|
199
|
+
dailyReport.cron // '0 6 * * *'
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### JobMessage Interface
|
|
203
|
+
|
|
204
|
+
Each queue message returned by `getNextUp()` and `list()` has these fields:
|
|
205
|
+
|
|
206
|
+
```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
|
+
```
|
|
223
|
+
|
|
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()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const cleanup = RecurringJobs.createJob({
|
|
236
|
+
name: 'session-cleanup',
|
|
237
|
+
cron: '0 0 * * *',
|
|
238
|
+
workFn: async () => cleanStaleSessions()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// Start all jobs at once
|
|
242
|
+
RecurringJobs.startProcessing()
|
|
243
|
+
|
|
244
|
+
// Or start individually
|
|
245
|
+
report.startProcessing()
|
|
246
|
+
cleanup.startProcessing()
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Inspecting and Rescheduling
|
|
250
|
+
|
|
251
|
+
```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
|
|
284
|
+
})
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Execution History
|
|
288
|
+
|
|
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
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Log Events
|
|
301
|
+
|
|
302
|
+
The service emits structured log events of type `RecurringJobs.LogMessage`:
|
|
303
|
+
|
|
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 |
|
|
312
|
+
|
|
313
|
+
## Cross-Package Integration
|
|
314
|
+
|
|
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
|
|
322
|
+
|
|
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.
|
|
324
|
+
|
|
325
|
+
Queue itself depends on Db (MySQL) for message persistence, so the full dependency chain is:
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
RecurringJobs -> Queue -> Db (MySQL)
|
|
329
|
+
-> Log
|
|
330
|
+
-> ServiceRegistry
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Provider Pattern
|
|
334
|
+
|
|
335
|
+
This package follows the standard maestro Provider pattern:
|
|
336
|
+
|
|
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
|
|
341
|
+
|
|
342
|
+
## Notes
|
|
343
|
+
|
|
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.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Iso } from 'iso-fns2';
|
|
2
|
+
import { Queue } from '@maestro-js/queue';
|
|
3
|
+
import { Log } from '@maestro-js/log';
|
|
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>;
|
|
11
|
+
};
|
|
12
|
+
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>;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Cron-based job scheduling backed by the Queue package.
|
|
21
|
+
*
|
|
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.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```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
|
+
* })
|
|
39
|
+
*
|
|
40
|
+
* // Start processing and inspect upcoming runs
|
|
41
|
+
* salesReport.startProcessing()
|
|
42
|
+
* const next = await salesReport.getNextUp() // { status: 'waiting', scheduledDate: '...' }
|
|
43
|
+
*
|
|
44
|
+
* // List all registered jobs
|
|
45
|
+
* RecurringJobs.list() // ['daily-sales-report']
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
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;
|
|
64
|
+
}
|
|
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 */
|
|
92
|
+
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>;
|
|
141
|
+
name: string;
|
|
142
|
+
logger?: Log.LogFunctions<[LogMessage]>;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
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>`.
|
|
150
|
+
*
|
|
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
|
+
* ```
|
|
157
|
+
*/
|
|
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;
|
|
169
|
+
}
|
|
170
|
+
namespace Provider {
|
|
171
|
+
/** Service-level configuration passed to `RecurringJobs.Provider.create()` */
|
|
172
|
+
interface RecurringJobsServiceConfig {
|
|
173
|
+
logger: Log.Logger<[RecurringJobs.LogMessage]>;
|
|
174
|
+
queueService: Queue.QueueService;
|
|
175
|
+
}
|
|
176
|
+
type Key = keyof Keys;
|
|
177
|
+
interface Keys {
|
|
178
|
+
default: unknown;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
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.
|
|
185
|
+
*/
|
|
186
|
+
declare const RecurringJobs: {
|
|
187
|
+
Provider: {
|
|
188
|
+
create: typeof create;
|
|
189
|
+
register: (name: RecurringJobs.Provider.Key, item: RecurringJobs.RecurringJobsService) => void;
|
|
190
|
+
list: () => "default"[];
|
|
191
|
+
};
|
|
192
|
+
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>;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export { RecurringJobs };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import "iso-fns2";
|
|
3
|
+
import cronParser from "cron-parser";
|
|
4
|
+
import { Log } from "@maestro-js/log";
|
|
5
|
+
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();
|
|
56
|
+
}
|
|
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;
|
|
61
|
+
}
|
|
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 }));
|
|
68
|
+
}
|
|
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
|
+
);
|
|
75
|
+
}
|
|
76
|
+
await queue.updateMessage({ messageId: jobId, scheduledDate, body: {} });
|
|
77
|
+
}
|
|
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;
|
|
83
|
+
}
|
|
84
|
+
await queue.addMessage({}, { scheduledDate: nextRun, tracingContext: null });
|
|
85
|
+
logger.info({ event: "jobScheduled", job: jobConfig.name, nextRun });
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
function startProcessing2() {
|
|
89
|
+
addJobToQueue(getNextScheduledDate()).catch((error) => {
|
|
90
|
+
logger.error({ event: "jobSchedulingFailed", job: jobConfig.name, error });
|
|
91
|
+
});
|
|
92
|
+
queue.startProcessing();
|
|
93
|
+
}
|
|
94
|
+
function stopProcessing2() {
|
|
95
|
+
queue.stopProcessing();
|
|
96
|
+
}
|
|
97
|
+
const jobFunctions = {
|
|
98
|
+
getNextUp,
|
|
99
|
+
list: list2,
|
|
100
|
+
startProcessing: startProcessing2,
|
|
101
|
+
stopProcessing: stopProcessing2,
|
|
102
|
+
rescheduleJob,
|
|
103
|
+
name: jobConfig.name,
|
|
104
|
+
cron: jobConfig.cron
|
|
105
|
+
};
|
|
106
|
+
jobs.push(jobFunctions);
|
|
107
|
+
return jobFunctions;
|
|
108
|
+
}
|
|
109
|
+
function startProcessing() {
|
|
110
|
+
jobs.forEach((job) => job.startProcessing());
|
|
111
|
+
}
|
|
112
|
+
function stopProcessing() {
|
|
113
|
+
jobs.forEach((job) => job.stopProcessing());
|
|
114
|
+
}
|
|
115
|
+
function list() {
|
|
116
|
+
return jobs.map((job) => job.name);
|
|
117
|
+
}
|
|
118
|
+
function get(name) {
|
|
119
|
+
return jobs.find((job) => job.name === name);
|
|
120
|
+
}
|
|
121
|
+
return { startProcessing, stopProcessing, list, get, createJob };
|
|
122
|
+
}
|
|
123
|
+
var registry = ServiceRegistry.createRegistry(
|
|
124
|
+
ServiceRegistry.proxyFunctionsForObject
|
|
125
|
+
);
|
|
126
|
+
var Provider = {
|
|
127
|
+
create,
|
|
128
|
+
register: registry.register,
|
|
129
|
+
list: registry.list
|
|
130
|
+
};
|
|
131
|
+
function provider(key) {
|
|
132
|
+
const service = registry.resolve(key);
|
|
133
|
+
return {
|
|
134
|
+
startProcessing: service.startProcessing,
|
|
135
|
+
stopProcessing: service.stopProcessing,
|
|
136
|
+
list: service.list,
|
|
137
|
+
get: service.get,
|
|
138
|
+
createJob: service.createJob
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
var facade = provider("default");
|
|
142
|
+
var RecurringJobs = {
|
|
143
|
+
Provider,
|
|
144
|
+
provider,
|
|
145
|
+
startProcessing: facade.startProcessing,
|
|
146
|
+
stopProcessing: facade.stopProcessing,
|
|
147
|
+
list: facade.list,
|
|
148
|
+
get: facade.get,
|
|
149
|
+
createJob: facade.createJob
|
|
150
|
+
};
|
|
151
|
+
export {
|
|
152
|
+
RecurringJobs
|
|
153
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"cron-parser": "^4.9.0",
|
|
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"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.19.11",
|
|
19
|
+
"@maestro-js/queue": "1.0.0-alpha.0"
|
|
20
|
+
},
|
|
21
|
+
"version": "1.0.0-alpha.0",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "restricted"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"license": "UNLICENSED",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22.18.0"
|
|
31
|
+
},
|
|
32
|
+
"repository": "https://github.com/Marcato-Partners/maestro-js",
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsup --config ../../tsup.config.ts",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"test": "echo 'No tests yet'",
|
|
37
|
+
"format": "prettier --write src/",
|
|
38
|
+
"lint": "prettier --check src/"
|
|
39
|
+
}
|
|
40
|
+
}
|