@onebun/core 0.2.12 → 0.2.13

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.
@@ -239,6 +239,286 @@ describe('QueueScheduler', () => {
239
239
  });
240
240
  });
241
241
 
242
+ describe('addJob', () => {
243
+ it('should add a cron job visible via getJob', () => {
244
+ scheduler.addJob({
245
+ type: 'cron',
246
+ name: 'my-cron',
247
+ expression: '0 0 * * * *',
248
+ pattern: 'test.cron',
249
+ });
250
+
251
+ const job = scheduler.getJob('my-cron');
252
+ expect(job).toBeDefined();
253
+ expect(job!.type).toBe('cron');
254
+ expect(job!.paused).toBe(false);
255
+ expect(job!.declarative).toBe(false);
256
+ expect(job!.schedule.cron).toBe('0 0 * * * *');
257
+ });
258
+
259
+ it('should add an interval job visible via getJob', () => {
260
+ scheduler.addJob({
261
+ type: 'interval',
262
+ name: 'my-interval',
263
+ intervalMs: 5000,
264
+ pattern: 'test.interval',
265
+ });
266
+
267
+ const job = scheduler.getJob('my-interval');
268
+ expect(job).toBeDefined();
269
+ expect(job!.type).toBe('interval');
270
+ expect(job!.paused).toBe(false);
271
+ expect(job!.declarative).toBe(false);
272
+ expect(job!.schedule.every).toBe(5000);
273
+ });
274
+
275
+ it('should add a timeout job visible via getJob', () => {
276
+ scheduler.addJob({
277
+ type: 'timeout',
278
+ name: 'my-timeout',
279
+ timeoutMs: 3000,
280
+ pattern: 'test.timeout',
281
+ });
282
+
283
+ const job = scheduler.getJob('my-timeout');
284
+ expect(job).toBeDefined();
285
+ expect(job!.type).toBe('timeout');
286
+ expect(job!.paused).toBe(false);
287
+ expect(job!.declarative).toBe(false);
288
+ expect(job!.schedule.timeout).toBe(3000);
289
+ });
290
+ });
291
+
292
+ describe('getJobs returns type, paused, and timeout', () => {
293
+ it('should return correct type, paused, and schedule for all job types', () => {
294
+ scheduler.addJob({
295
+ type: 'cron', name: 'c1', expression: '* * * * *', pattern: 'p',
296
+ });
297
+ scheduler.addJob({
298
+ type: 'interval', name: 'i1', intervalMs: 1000, pattern: 'p',
299
+ });
300
+ scheduler.addJob({
301
+ type: 'timeout', name: 't1', timeoutMs: 2000, pattern: 'p',
302
+ });
303
+
304
+ const jobs = scheduler.getJobs();
305
+ expect(jobs.length).toBe(3);
306
+
307
+ const cronJob = jobs.find((j) => j.name === 'c1');
308
+ expect(cronJob!.type).toBe('cron');
309
+ expect(cronJob!.paused).toBe(false);
310
+
311
+ const intervalJob = jobs.find((j) => j.name === 'i1');
312
+ expect(intervalJob!.type).toBe('interval');
313
+ expect(intervalJob!.paused).toBe(false);
314
+ expect(intervalJob!.schedule.every).toBe(1000);
315
+
316
+ const timeoutJob = jobs.find((j) => j.name === 't1');
317
+ expect(timeoutJob!.type).toBe('timeout');
318
+ expect(timeoutJob!.paused).toBe(false);
319
+ expect(timeoutJob!.schedule.timeout).toBe(2000);
320
+ });
321
+ });
322
+
323
+ describe('declarative field', () => {
324
+ it('should mark jobs added via addCronJob with declarative option as declarative', () => {
325
+ scheduler.addCronJob('dec-cron', '* * * * *', 'p', undefined, { declarative: true });
326
+
327
+ const job = scheduler.getJob('dec-cron');
328
+ expect(job).toBeDefined();
329
+ expect(job!.declarative).toBe(true);
330
+ });
331
+
332
+ it('should mark jobs added via addIntervalJob with declarative option as declarative', () => {
333
+ scheduler.addIntervalJob('dec-interval', 1000, 'p', undefined, { declarative: true });
334
+
335
+ const job = scheduler.getJob('dec-interval');
336
+ expect(job).toBeDefined();
337
+ expect(job!.declarative).toBe(true);
338
+ });
339
+
340
+ it('should mark jobs added via addTimeoutJob with declarative option as declarative', () => {
341
+ scheduler.addTimeoutJob('dec-timeout', 1000, 'p', undefined, { declarative: true });
342
+
343
+ const job = scheduler.getJob('dec-timeout');
344
+ expect(job).toBeDefined();
345
+ expect(job!.declarative).toBe(true);
346
+ });
347
+
348
+ it('should mark jobs added via addJob as not declarative', () => {
349
+ scheduler.addJob({
350
+ type: 'cron', name: 'dyn-cron', expression: '* * * * *', pattern: 'p',
351
+ });
352
+
353
+ const job = scheduler.getJob('dyn-cron');
354
+ expect(job).toBeDefined();
355
+ expect(job!.declarative).toBe(false);
356
+ });
357
+ });
358
+
359
+ describe('pauseJob', () => {
360
+ it('should pause an existing job and return true', () => {
361
+ scheduler.addJob({
362
+ type: 'interval', name: 'j1', intervalMs: 1000, pattern: 'p',
363
+ });
364
+
365
+ const result = scheduler.pauseJob('j1');
366
+ expect(result).toBe(true);
367
+
368
+ const job = scheduler.getJob('j1');
369
+ expect(job!.paused).toBe(true);
370
+ });
371
+
372
+ it('should return false for nonexistent job', () => {
373
+ const result = scheduler.pauseJob('nonexistent');
374
+ expect(result).toBe(false);
375
+ });
376
+ });
377
+
378
+ describe('resumeJob', () => {
379
+ it('should resume a paused job and return true', () => {
380
+ scheduler.addJob({
381
+ type: 'interval', name: 'j1', intervalMs: 1000, pattern: 'p',
382
+ });
383
+ scheduler.pauseJob('j1');
384
+
385
+ const result = scheduler.resumeJob('j1');
386
+ expect(result).toBe(true);
387
+
388
+ const job = scheduler.getJob('j1');
389
+ expect(job!.paused).toBe(false);
390
+ });
391
+
392
+ it('should return false for nonexistent job', () => {
393
+ const result = scheduler.resumeJob('nonexistent');
394
+ expect(result).toBe(false);
395
+ });
396
+
397
+ it('should return false for non-paused job', () => {
398
+ scheduler.addJob({
399
+ type: 'interval', name: 'j1', intervalMs: 1000, pattern: 'p',
400
+ });
401
+
402
+ const result = scheduler.resumeJob('j1');
403
+ expect(result).toBe(false);
404
+ });
405
+ });
406
+
407
+ describe('updateJob', () => {
408
+ it('should update cron expression', () => {
409
+ scheduler.addJob({
410
+ type: 'cron',
411
+ name: 'c1',
412
+ expression: '0 0 * * * *',
413
+ pattern: 'p',
414
+ });
415
+
416
+ const result = scheduler.updateJob({ type: 'cron', name: 'c1', expression: '*/5 * * * *' });
417
+ expect(result).toBe(true);
418
+
419
+ const job = scheduler.getJob('c1');
420
+ expect(job!.schedule.cron).toBe('*/5 * * * *');
421
+ });
422
+
423
+ it('should update interval', () => {
424
+ scheduler.addJob({
425
+ type: 'interval', name: 'i1', intervalMs: 1000, pattern: 'p',
426
+ });
427
+
428
+ const result = scheduler.updateJob({ type: 'interval', name: 'i1', intervalMs: 5000 });
429
+ expect(result).toBe(true);
430
+
431
+ const job = scheduler.getJob('i1');
432
+ expect(job!.schedule.every).toBe(5000);
433
+ });
434
+
435
+ it('should update timeout', () => {
436
+ scheduler.addJob({
437
+ type: 'timeout', name: 't1', timeoutMs: 1000, pattern: 'p',
438
+ });
439
+
440
+ const result = scheduler.updateJob({ type: 'timeout', name: 't1', timeoutMs: 9000 });
441
+ expect(result).toBe(true);
442
+
443
+ const job = scheduler.getJob('t1');
444
+ expect(job!.schedule.timeout).toBe(9000);
445
+ });
446
+
447
+ it('should return false when type does not match', () => {
448
+ scheduler.addJob({
449
+ type: 'interval', name: 'i1', intervalMs: 1000, pattern: 'p',
450
+ });
451
+
452
+ const result = scheduler.updateJob({ type: 'cron', name: 'i1', expression: '* * * * *' });
453
+ expect(result).toBe(false);
454
+ });
455
+
456
+ it('should return false for nonexistent job', () => {
457
+ const result = scheduler.updateJob({ type: 'cron', name: 'nope', expression: '* * * * *' });
458
+ expect(result).toBe(false);
459
+ });
460
+ });
461
+
462
+ describe('paused jobs do not fire', () => {
463
+ it('should not fire paused cron job', async () => {
464
+ const received: Message[] = [];
465
+ await adapter.subscribe('test.cron', async (message) => {
466
+ received.push(message);
467
+ });
468
+
469
+ // Every second cron
470
+ scheduler.addJob({
471
+ type: 'cron',
472
+ name: 'cron-paused',
473
+ expression: '* * * * * *',
474
+ pattern: 'test.cron',
475
+ });
476
+ scheduler.pauseJob('cron-paused');
477
+ scheduler.start();
478
+
479
+ // Advance time well past when cron would fire
480
+ advanceTime(5000);
481
+ await Promise.resolve();
482
+
483
+ expect(received.length).toBe(0);
484
+ });
485
+
486
+ it('should not fire paused interval job, and resume restarts it', async () => {
487
+ const received: Message[] = [];
488
+ await adapter.subscribe('test.interval', async (message) => {
489
+ received.push(message);
490
+ });
491
+
492
+ scheduler.addJob({
493
+ type: 'interval',
494
+ name: 'int-paused',
495
+ intervalMs: 100,
496
+ pattern: 'test.interval',
497
+ });
498
+ scheduler.start();
499
+
500
+ // Should fire immediately on start
501
+ advanceTime(10);
502
+ await Promise.resolve();
503
+ expect(received.length).toBe(1);
504
+
505
+ // Pause the job
506
+ scheduler.pauseJob('int-paused');
507
+
508
+ // Advance time — no new messages
509
+ advanceTime(500);
510
+ await Promise.resolve();
511
+ expect(received.length).toBe(1);
512
+
513
+ // Resume — should restart interval and fire immediately
514
+ scheduler.resumeJob('int-paused');
515
+
516
+ advanceTime(10);
517
+ await Promise.resolve();
518
+ expect(received.length).toBe(2);
519
+ });
520
+ });
521
+
242
522
  describe('createQueueScheduler', () => {
243
523
  it('should create scheduler instance', () => {
244
524
  const created = createQueueScheduler(adapter);
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  import type {
9
+ AddJobOptions,
10
+ UpdateJobOptions,
9
11
  QueueAdapter,
10
12
  ScheduledJobInfo,
11
13
  OverlapStrategy,
@@ -42,6 +44,12 @@ interface ScheduledJob {
42
44
  // Timeout-specific
43
45
  timeoutMs?: number;
44
46
 
47
+ // Pause state
48
+ paused?: boolean;
49
+
50
+ // Whether created via decorator
51
+ declarative?: boolean;
52
+
45
53
  // Runtime state
46
54
  timer?: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>;
47
55
  isRunning?: boolean;
@@ -90,8 +98,12 @@ export class QueueScheduler {
90
98
  this.checkCronJobs();
91
99
  }, this.cronCheckIntervalMs);
92
100
 
93
- // Start all interval and timeout jobs
101
+ // Start all interval and timeout jobs (skip paused)
94
102
  for (const job of this.jobs.values()) {
103
+ if (job.paused) {
104
+ continue;
105
+ }
106
+
95
107
  if (job.type === 'interval' && job.intervalMs) {
96
108
  this.startIntervalJob(job);
97
109
  } else if (job.type === 'timeout' && job.timeoutMs) {
@@ -137,6 +149,7 @@ export class QueueScheduler {
137
149
  options?: {
138
150
  metadata?: Partial<MessageMetadata>;
139
151
  overlapStrategy?: OverlapStrategy;
152
+ declarative?: boolean;
140
153
  },
141
154
  ): void {
142
155
  const schedule = parseCronExpression(expression);
@@ -152,6 +165,7 @@ export class QueueScheduler {
152
165
  getDataFn,
153
166
  metadata: options?.metadata,
154
167
  overlapStrategy: options?.overlapStrategy ?? 'skip',
168
+ declarative: options?.declarative,
155
169
  };
156
170
 
157
171
  this.jobs.set(name, job);
@@ -167,6 +181,7 @@ export class QueueScheduler {
167
181
  getDataFn?: () => unknown | Promise<unknown>,
168
182
  options?: {
169
183
  metadata?: Partial<MessageMetadata>;
184
+ declarative?: boolean;
170
185
  },
171
186
  ): void {
172
187
  const job: ScheduledJob = {
@@ -176,6 +191,7 @@ export class QueueScheduler {
176
191
  intervalMs,
177
192
  getDataFn,
178
193
  metadata: options?.metadata,
194
+ declarative: options?.declarative,
179
195
  };
180
196
 
181
197
  this.jobs.set(name, job);
@@ -196,6 +212,7 @@ export class QueueScheduler {
196
212
  getDataFn?: () => unknown | Promise<unknown>,
197
213
  options?: {
198
214
  metadata?: Partial<MessageMetadata>;
215
+ declarative?: boolean;
199
216
  },
200
217
  ): void {
201
218
  const job: ScheduledJob = {
@@ -205,6 +222,7 @@ export class QueueScheduler {
205
222
  timeoutMs,
206
223
  getDataFn,
207
224
  metadata: options?.metadata,
225
+ declarative: options?.declarative,
208
226
  };
209
227
 
210
228
  this.jobs.set(name, job);
@@ -215,6 +233,118 @@ export class QueueScheduler {
215
233
  }
216
234
  }
217
235
 
236
+ /**
237
+ * Add a job using the unified API
238
+ */
239
+ addJob(options: AddJobOptions): void {
240
+ switch (options.type) {
241
+ case 'cron':
242
+ this.addCronJob(options.name, options.expression, options.pattern, options.getDataFn, {
243
+ metadata: options.metadata,
244
+ overlapStrategy: options.overlapStrategy,
245
+ });
246
+ break;
247
+ case 'interval':
248
+ this.addIntervalJob(options.name, options.intervalMs, options.pattern, options.getDataFn, {
249
+ metadata: options.metadata,
250
+ });
251
+ break;
252
+ case 'timeout':
253
+ this.addTimeoutJob(options.name, options.timeoutMs, options.pattern, options.getDataFn, {
254
+ metadata: options.metadata,
255
+ });
256
+ break;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Pause a scheduled job
262
+ */
263
+ pauseJob(name: string): boolean {
264
+ const job = this.jobs.get(name);
265
+ if (!job) {
266
+ return false;
267
+ }
268
+
269
+ job.paused = true;
270
+
271
+ if (job.timer) {
272
+ clearTimeout(job.timer);
273
+ clearInterval(job.timer);
274
+ job.timer = undefined;
275
+ }
276
+
277
+ return true;
278
+ }
279
+
280
+ /**
281
+ * Resume a paused job
282
+ */
283
+ resumeJob(name: string): boolean {
284
+ const job = this.jobs.get(name);
285
+ if (!job || !job.paused) {
286
+ return false;
287
+ }
288
+
289
+ job.paused = false;
290
+
291
+ if (this.running) {
292
+ if (job.type === 'interval' && job.intervalMs) {
293
+ this.startIntervalJob(job);
294
+ } else if (job.type === 'timeout' && job.timeoutMs) {
295
+ this.startTimeoutJob(job);
296
+ } else if (job.type === 'cron' && job.cronSchedule) {
297
+ job.nextRun = getNextRun(job.cronSchedule) ?? undefined;
298
+ }
299
+ }
300
+
301
+ return true;
302
+ }
303
+
304
+ /**
305
+ * Update a scheduled job's timing configuration
306
+ */
307
+ updateJob(options: UpdateJobOptions): boolean {
308
+ const job = this.jobs.get(options.name);
309
+ if (!job || job.type !== options.type) {
310
+ return false;
311
+ }
312
+
313
+ switch (options.type) {
314
+ case 'cron': {
315
+ const schedule = parseCronExpression(options.expression);
316
+ job.cronExpression = options.expression;
317
+ job.cronSchedule = schedule;
318
+ job.nextRun = getNextRun(schedule) ?? undefined;
319
+ break;
320
+ }
321
+ case 'interval': {
322
+ if (job.timer) {
323
+ clearInterval(job.timer);
324
+ job.timer = undefined;
325
+ }
326
+ job.intervalMs = options.intervalMs;
327
+ if (this.running && !job.paused) {
328
+ this.startIntervalJob(job);
329
+ }
330
+ break;
331
+ }
332
+ case 'timeout': {
333
+ if (job.timer) {
334
+ clearTimeout(job.timer);
335
+ job.timer = undefined;
336
+ }
337
+ job.timeoutMs = options.timeoutMs;
338
+ if (this.running && !job.paused) {
339
+ this.startTimeoutJob(job);
340
+ }
341
+ break;
342
+ }
343
+ }
344
+
345
+ return true;
346
+ }
347
+
218
348
  /**
219
349
  * Remove a job
220
350
  */
@@ -244,10 +374,14 @@ export class QueueScheduler {
244
374
  for (const job of this.jobs.values()) {
245
375
  result.push({
246
376
  name: job.name,
377
+ type: job.type,
378
+ paused: job.paused ?? false,
379
+ declarative: job.declarative ?? false,
247
380
  pattern: job.pattern,
248
381
  schedule: {
249
382
  cron: job.cronExpression,
250
383
  every: job.intervalMs,
384
+ timeout: job.timeoutMs,
251
385
  },
252
386
  nextRun: job.nextRun,
253
387
  lastRun: job.lastRun,
@@ -269,10 +403,14 @@ export class QueueScheduler {
269
403
 
270
404
  return {
271
405
  name: job.name,
406
+ type: job.type,
407
+ paused: job.paused ?? false,
408
+ declarative: job.declarative ?? false,
272
409
  pattern: job.pattern,
273
410
  schedule: {
274
411
  cron: job.cronExpression,
275
412
  every: job.intervalMs,
413
+ timeout: job.timeoutMs,
276
414
  },
277
415
  nextRun: job.nextRun,
278
416
  lastRun: job.lastRun,
@@ -302,6 +440,10 @@ export class QueueScheduler {
302
440
  continue;
303
441
  }
304
442
 
443
+ if (job.paused) {
444
+ continue;
445
+ }
446
+
305
447
  // Check if it's time to run
306
448
  if (job.nextRun && now >= job.nextRun) {
307
449
  // Handle overlap strategy
@@ -224,30 +224,79 @@ export interface Subscription {
224
224
  export type OverlapStrategy = 'skip' | 'queue';
225
225
 
226
226
  /**
227
- * Options for scheduled jobs
227
+ * Add a cron job
228
228
  */
229
- export interface ScheduledJobOptions {
230
- /** Pattern to publish to */
229
+ export interface AddCronJob {
230
+ type: 'cron';
231
+ name: string;
232
+ expression: string;
231
233
  pattern: string;
234
+ getDataFn?: () => unknown | Promise<unknown>;
235
+ metadata?: Partial<MessageMetadata>;
236
+ overlapStrategy?: OverlapStrategy;
237
+ }
232
238
 
233
- /** Data to include in the message */
234
- data?: unknown;
235
-
236
- /** Schedule configuration */
237
- schedule: {
238
- /** Cron expression */
239
- cron?: string;
240
- /** Interval in milliseconds */
241
- every?: number;
242
- };
239
+ /**
240
+ * Add an interval job
241
+ */
242
+ export interface AddIntervalJob {
243
+ type: 'interval';
244
+ name: string;
245
+ intervalMs: number;
246
+ pattern: string;
247
+ getDataFn?: () => unknown | Promise<unknown>;
248
+ metadata?: Partial<MessageMetadata>;
249
+ }
243
250
 
244
- /** Metadata to include in messages */
251
+ /**
252
+ * Add a timeout job
253
+ */
254
+ export interface AddTimeoutJob {
255
+ type: 'timeout';
256
+ name: string;
257
+ timeoutMs: number;
258
+ pattern: string;
259
+ getDataFn?: () => unknown | Promise<unknown>;
245
260
  metadata?: Partial<MessageMetadata>;
261
+ }
246
262
 
247
- /** What to do if previous job is still running */
248
- overlapStrategy?: OverlapStrategy;
263
+ /**
264
+ * Discriminated union for adding any scheduled job type
265
+ */
266
+ export type AddJobOptions = AddCronJob | AddIntervalJob | AddTimeoutJob;
267
+
268
+ /**
269
+ * Update a cron job's expression
270
+ */
271
+ export interface UpdateCronJob {
272
+ type: 'cron';
273
+ name: string;
274
+ expression: string;
249
275
  }
250
276
 
277
+ /**
278
+ * Update an interval job's interval
279
+ */
280
+ export interface UpdateIntervalJob {
281
+ type: 'interval';
282
+ name: string;
283
+ intervalMs: number;
284
+ }
285
+
286
+ /**
287
+ * Update a timeout job's delay
288
+ */
289
+ export interface UpdateTimeoutJob {
290
+ type: 'timeout';
291
+ name: string;
292
+ timeoutMs: number;
293
+ }
294
+
295
+ /**
296
+ * Discriminated union for updating any scheduled job type
297
+ */
298
+ export type UpdateJobOptions = UpdateCronJob | UpdateIntervalJob | UpdateTimeoutJob;
299
+
251
300
  /**
252
301
  * Information about a scheduled job
253
302
  */
@@ -255,13 +304,23 @@ export interface ScheduledJobInfo {
255
304
  /** Job name */
256
305
  name: string;
257
306
 
307
+ /** Job type */
308
+ type: 'cron' | 'interval' | 'timeout';
309
+
258
310
  /** Pattern to publish to */
259
311
  pattern: string;
260
312
 
313
+ /** Whether the job is paused */
314
+ paused: boolean;
315
+
316
+ /** Whether the job was created via decorator (@Cron, @Interval, @Timeout) */
317
+ declarative: boolean;
318
+
261
319
  /** Schedule configuration */
262
320
  schedule: {
263
321
  cron?: string;
264
322
  every?: number;
323
+ timeout?: number;
265
324
  };
266
325
 
267
326
  /** Next scheduled run time */
@@ -286,7 +345,6 @@ export type QueueFeature =
286
345
  | 'priority'
287
346
  | 'dead-letter-queue'
288
347
  | 'retry'
289
- | 'scheduled-jobs'
290
348
  | 'consumer-groups'
291
349
  | 'pattern-subscriptions';
292
350
 
@@ -353,16 +411,6 @@ export interface QueueAdapter {
353
411
  options?: SubscribeOptions
354
412
  ): Promise<Subscription>;
355
413
 
356
- // Scheduled Jobs
357
- /** Add a scheduled job */
358
- addScheduledJob(name: string, options: ScheduledJobOptions): Promise<void>;
359
-
360
- /** Remove a scheduled job */
361
- removeScheduledJob(name: string): Promise<boolean>;
362
-
363
- /** Get all scheduled jobs */
364
- getScheduledJobs(): Promise<ScheduledJobInfo[]>;
365
-
366
414
  // Feature Support
367
415
  /** Check if a feature is supported by this adapter */
368
416
  supports(feature: QueueFeature): boolean;