@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 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.
@@ -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
+ }