@nicnocquee/dataqueue 1.26.0 → 1.31.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/src/index.test.ts CHANGED
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { initJobQueue, JobQueueConfig } from './index.js';
3
3
  import { createTestDbAndPool, destroyTestDb } from './test-util.js';
4
4
  import { Pool } from 'pg';
5
+ import type { CronScheduleRecord } from './types.js';
5
6
 
6
7
  // Integration tests for index.ts
7
8
 
@@ -532,3 +533,363 @@ describe('index integration', () => {
532
533
  });
533
534
  });
534
535
  });
536
+
537
+ describe('cron schedules integration', () => {
538
+ let pool: Pool;
539
+ let dbName: string;
540
+ let testDbUrl: string;
541
+ let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
542
+
543
+ beforeEach(async () => {
544
+ const setup = await createTestDbAndPool();
545
+ pool = setup.pool;
546
+ dbName = setup.dbName;
547
+ testDbUrl = setup.testDbUrl;
548
+ const config: JobQueueConfig = {
549
+ databaseConfig: {
550
+ connectionString: testDbUrl,
551
+ },
552
+ };
553
+ jobQueue = initJobQueue<TestPayloadMap>(config);
554
+ });
555
+
556
+ afterEach(async () => {
557
+ vi.restoreAllMocks();
558
+ jobQueue.getPool().end();
559
+ await pool.end();
560
+ await destroyTestDb(dbName);
561
+ });
562
+
563
+ it('creates a cron schedule and retrieves it by ID', async () => {
564
+ // Act
565
+ const id = await jobQueue.addCronJob({
566
+ scheduleName: 'every-5-min-email',
567
+ cronExpression: '*/5 * * * *',
568
+ jobType: 'email',
569
+ payload: { to: 'cron@example.com' },
570
+ });
571
+
572
+ // Assert
573
+ const schedule = await jobQueue.getCronJob(id);
574
+ expect(schedule).not.toBeNull();
575
+ expect(schedule!.scheduleName).toBe('every-5-min-email');
576
+ expect(schedule!.cronExpression).toBe('*/5 * * * *');
577
+ expect(schedule!.jobType).toBe('email');
578
+ expect(schedule!.payload).toEqual({ to: 'cron@example.com' });
579
+ expect(schedule!.status).toBe('active');
580
+ expect(schedule!.allowOverlap).toBe(false);
581
+ expect(schedule!.timezone).toBe('UTC');
582
+ expect(schedule!.nextRunAt).toBeInstanceOf(Date);
583
+ });
584
+
585
+ it('retrieves a cron schedule by name', async () => {
586
+ // Setup
587
+ await jobQueue.addCronJob({
588
+ scheduleName: 'my-schedule',
589
+ cronExpression: '0 * * * *',
590
+ jobType: 'email',
591
+ payload: { to: 'test@example.com' },
592
+ });
593
+
594
+ // Act
595
+ const schedule = await jobQueue.getCronJobByName('my-schedule');
596
+
597
+ // Assert
598
+ expect(schedule).not.toBeNull();
599
+ expect(schedule!.scheduleName).toBe('my-schedule');
600
+ });
601
+
602
+ it('returns null for nonexistent schedule', async () => {
603
+ // Act
604
+ const byId = await jobQueue.getCronJob(99999);
605
+ const byName = await jobQueue.getCronJobByName('nonexistent');
606
+
607
+ // Assert
608
+ expect(byId).toBeNull();
609
+ expect(byName).toBeNull();
610
+ });
611
+
612
+ it('rejects duplicate schedule names', async () => {
613
+ // Setup
614
+ await jobQueue.addCronJob({
615
+ scheduleName: 'unique-name',
616
+ cronExpression: '* * * * *',
617
+ jobType: 'email',
618
+ payload: { to: 'a@example.com' },
619
+ });
620
+
621
+ // Act & Assert
622
+ await expect(
623
+ jobQueue.addCronJob({
624
+ scheduleName: 'unique-name',
625
+ cronExpression: '*/5 * * * *',
626
+ jobType: 'sms',
627
+ payload: { to: 'b@example.com' },
628
+ }),
629
+ ).rejects.toThrow();
630
+ });
631
+
632
+ it('rejects invalid cron expressions', async () => {
633
+ // Act & Assert
634
+ await expect(
635
+ jobQueue.addCronJob({
636
+ scheduleName: 'bad-cron',
637
+ cronExpression: 'not a cron',
638
+ jobType: 'email',
639
+ payload: { to: 'a@example.com' },
640
+ }),
641
+ ).rejects.toThrow('Invalid cron expression');
642
+ });
643
+
644
+ it('lists active and paused schedules', async () => {
645
+ // Setup
646
+ const id1 = await jobQueue.addCronJob({
647
+ scheduleName: 'schedule-1',
648
+ cronExpression: '* * * * *',
649
+ jobType: 'email',
650
+ payload: { to: 'a@example.com' },
651
+ });
652
+ await jobQueue.addCronJob({
653
+ scheduleName: 'schedule-2',
654
+ cronExpression: '*/5 * * * *',
655
+ jobType: 'sms',
656
+ payload: { to: 'b@example.com' },
657
+ });
658
+ await jobQueue.pauseCronJob(id1);
659
+
660
+ // Act
661
+ const all = await jobQueue.listCronJobs();
662
+ const active = await jobQueue.listCronJobs('active');
663
+ const paused = await jobQueue.listCronJobs('paused');
664
+
665
+ // Assert
666
+ expect(all).toHaveLength(2);
667
+ expect(active).toHaveLength(1);
668
+ expect(active[0].scheduleName).toBe('schedule-2');
669
+ expect(paused).toHaveLength(1);
670
+ expect(paused[0].scheduleName).toBe('schedule-1');
671
+ });
672
+
673
+ it('pauses and resumes a schedule', async () => {
674
+ // Setup
675
+ const id = await jobQueue.addCronJob({
676
+ scheduleName: 'pausable',
677
+ cronExpression: '* * * * *',
678
+ jobType: 'email',
679
+ payload: { to: 'a@example.com' },
680
+ });
681
+
682
+ // Act — pause
683
+ await jobQueue.pauseCronJob(id);
684
+ const paused = await jobQueue.getCronJob(id);
685
+
686
+ // Assert
687
+ expect(paused!.status).toBe('paused');
688
+
689
+ // Act — resume
690
+ await jobQueue.resumeCronJob(id);
691
+ const resumed = await jobQueue.getCronJob(id);
692
+
693
+ // Assert
694
+ expect(resumed!.status).toBe('active');
695
+ });
696
+
697
+ it('edits a schedule and recalculates nextRunAt when expression changes', async () => {
698
+ // Setup
699
+ const id = await jobQueue.addCronJob({
700
+ scheduleName: 'editable',
701
+ cronExpression: '* * * * *',
702
+ jobType: 'email',
703
+ payload: { to: 'old@example.com' },
704
+ });
705
+ const before = await jobQueue.getCronJob(id);
706
+
707
+ // Act
708
+ await jobQueue.editCronJob(id, {
709
+ cronExpression: '0 0 * * *',
710
+ payload: { to: 'new@example.com' },
711
+ });
712
+
713
+ // Assert
714
+ const after = await jobQueue.getCronJob(id);
715
+ expect(after!.cronExpression).toBe('0 0 * * *');
716
+ expect(after!.payload).toEqual({ to: 'new@example.com' });
717
+ expect(after!.nextRunAt!.getTime()).not.toBe(before!.nextRunAt!.getTime());
718
+ });
719
+
720
+ it('removes a schedule', async () => {
721
+ // Setup
722
+ const id = await jobQueue.addCronJob({
723
+ scheduleName: 'removable',
724
+ cronExpression: '* * * * *',
725
+ jobType: 'email',
726
+ payload: { to: 'a@example.com' },
727
+ });
728
+
729
+ // Act
730
+ await jobQueue.removeCronJob(id);
731
+
732
+ // Assert
733
+ const removed = await jobQueue.getCronJob(id);
734
+ expect(removed).toBeNull();
735
+ });
736
+
737
+ it('enqueueDueCronJobs enqueues a job when nextRunAt is due', async () => {
738
+ // Setup — insert a schedule with nextRunAt in the past
739
+ const id = await jobQueue.addCronJob({
740
+ scheduleName: 'due-now',
741
+ cronExpression: '* * * * *',
742
+ jobType: 'email',
743
+ payload: { to: 'due@example.com' },
744
+ });
745
+ // Force nextRunAt to be in the past
746
+ await pool.query(
747
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
748
+ [id],
749
+ );
750
+
751
+ // Act
752
+ const count = await jobQueue.enqueueDueCronJobs();
753
+
754
+ // Assert
755
+ expect(count).toBe(1);
756
+ const jobs = await jobQueue.getJobsByStatus('pending');
757
+ const cronJob = jobs.find(
758
+ (j) =>
759
+ j.jobType === 'email' && (j.payload as any).to === 'due@example.com',
760
+ );
761
+ expect(cronJob).toBeDefined();
762
+ });
763
+
764
+ it('enqueueDueCronJobs advances nextRunAt and sets lastJobId', async () => {
765
+ // Setup
766
+ const id = await jobQueue.addCronJob({
767
+ scheduleName: 'advance-test',
768
+ cronExpression: '* * * * *',
769
+ jobType: 'email',
770
+ payload: { to: 'advance@example.com' },
771
+ });
772
+ await pool.query(
773
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
774
+ [id],
775
+ );
776
+
777
+ // Act
778
+ await jobQueue.enqueueDueCronJobs();
779
+
780
+ // Assert
781
+ const schedule = await jobQueue.getCronJob(id);
782
+ expect(schedule!.lastJobId).not.toBeNull();
783
+ expect(schedule!.lastEnqueuedAt).toBeInstanceOf(Date);
784
+ expect(schedule!.nextRunAt).toBeInstanceOf(Date);
785
+ expect(schedule!.nextRunAt!.getTime()).toBeGreaterThan(Date.now() - 5000);
786
+ });
787
+
788
+ it('enqueueDueCronJobs skips paused schedules', async () => {
789
+ // Setup
790
+ const id = await jobQueue.addCronJob({
791
+ scheduleName: 'paused-skip',
792
+ cronExpression: '* * * * *',
793
+ jobType: 'email',
794
+ payload: { to: 'paused@example.com' },
795
+ });
796
+ await pool.query(
797
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
798
+ [id],
799
+ );
800
+ await jobQueue.pauseCronJob(id);
801
+
802
+ // Act
803
+ const count = await jobQueue.enqueueDueCronJobs();
804
+
805
+ // Assert
806
+ expect(count).toBe(0);
807
+ });
808
+
809
+ it('enqueueDueCronJobs skips schedules not yet due', async () => {
810
+ // Setup — nextRunAt is calculated to the future by addCronJob
811
+ await jobQueue.addCronJob({
812
+ scheduleName: 'future-schedule',
813
+ cronExpression: '0 0 1 1 *',
814
+ jobType: 'email',
815
+ payload: { to: 'future@example.com' },
816
+ });
817
+
818
+ // Act
819
+ const count = await jobQueue.enqueueDueCronJobs();
820
+
821
+ // Assert
822
+ expect(count).toBe(0);
823
+ });
824
+
825
+ it('enqueueDueCronJobs skips when allowOverlap=false and last job is still active', async () => {
826
+ // Setup
827
+ const id = await jobQueue.addCronJob({
828
+ scheduleName: 'no-overlap',
829
+ cronExpression: '* * * * *',
830
+ jobType: 'email',
831
+ payload: { to: 'overlap@example.com' },
832
+ allowOverlap: false,
833
+ });
834
+ await pool.query(
835
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
836
+ [id],
837
+ );
838
+
839
+ // First enqueue should succeed
840
+ const count1 = await jobQueue.enqueueDueCronJobs();
841
+ expect(count1).toBe(1);
842
+
843
+ // Set nextRunAt to past again (simulating next tick)
844
+ await pool.query(
845
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
846
+ [id],
847
+ );
848
+
849
+ // Act — second enqueue should be skipped because previous job is still pending
850
+ const count2 = await jobQueue.enqueueDueCronJobs();
851
+
852
+ // Assert
853
+ expect(count2).toBe(0);
854
+ });
855
+
856
+ it('enqueueDueCronJobs enqueues when allowOverlap=true even if last job is still active', async () => {
857
+ // Setup
858
+ const id = await jobQueue.addCronJob({
859
+ scheduleName: 'with-overlap',
860
+ cronExpression: '* * * * *',
861
+ jobType: 'email',
862
+ payload: { to: 'overlap@example.com' },
863
+ allowOverlap: true,
864
+ });
865
+ await pool.query(
866
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
867
+ [id],
868
+ );
869
+
870
+ // First enqueue
871
+ const count1 = await jobQueue.enqueueDueCronJobs();
872
+ expect(count1).toBe(1);
873
+
874
+ // Set nextRunAt to past again
875
+ await pool.query(
876
+ `UPDATE cron_schedules SET next_run_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
877
+ [id],
878
+ );
879
+
880
+ // Act — second enqueue should succeed because allowOverlap=true
881
+ const count2 = await jobQueue.enqueueDueCronJobs();
882
+
883
+ // Assert
884
+ expect(count2).toBe(1);
885
+
886
+ // Verify there are two pending jobs
887
+ const jobs = await jobQueue.getJobsByStatus('pending');
888
+ const cronJobs = jobs.filter(
889
+ (j) =>
890
+ j.jobType === 'email' &&
891
+ (j.payload as any).to === 'overlap@example.com',
892
+ );
893
+ expect(cronJobs).toHaveLength(2);
894
+ });
895
+ });
package/src/index.ts CHANGED
@@ -14,12 +14,16 @@ import {
14
14
  JobType,
15
15
  PostgresJobQueueConfig,
16
16
  RedisJobQueueConfig,
17
+ CronScheduleOptions,
18
+ CronScheduleStatus,
19
+ EditCronScheduleOptions,
17
20
  } from './types.js';
18
- import { QueueBackend } from './backend.js';
21
+ import { QueueBackend, CronScheduleInput } from './backend.js';
19
22
  import { setLogContext } from './log-context.js';
20
23
  import { createPool } from './db-util.js';
21
24
  import { PostgresBackend } from './backends/postgres.js';
22
25
  import { RedisBackend } from './backends/redis.js';
26
+ import { getNextCronOccurrence, validateCronExpression } from './cron.js';
23
27
 
24
28
  /**
25
29
  * Initialize the job queue system.
@@ -56,6 +60,66 @@ export const initJobQueue = <PayloadMap = any>(
56
60
  return pool;
57
61
  };
58
62
 
63
+ /**
64
+ * Enqueue due cron jobs. Shared by the public API and the processor hook.
65
+ */
66
+ const enqueueDueCronJobsImpl = async (): Promise<number> => {
67
+ const dueSchedules = await backend.getDueCronSchedules();
68
+ let count = 0;
69
+
70
+ for (const schedule of dueSchedules) {
71
+ // Overlap check: skip if allowOverlap is false and last job is still active
72
+ if (!schedule.allowOverlap && schedule.lastJobId !== null) {
73
+ const lastJob = await backend.getJob(schedule.lastJobId);
74
+ if (
75
+ lastJob &&
76
+ (lastJob.status === 'pending' ||
77
+ lastJob.status === 'processing' ||
78
+ lastJob.status === 'waiting')
79
+ ) {
80
+ // Still active — advance nextRunAt but don't enqueue
81
+ const nextRunAt = getNextCronOccurrence(
82
+ schedule.cronExpression,
83
+ schedule.timezone,
84
+ );
85
+ await backend.updateCronScheduleAfterEnqueue(
86
+ schedule.id,
87
+ new Date(),
88
+ schedule.lastJobId,
89
+ nextRunAt,
90
+ );
91
+ continue;
92
+ }
93
+ }
94
+
95
+ // Enqueue a new job instance
96
+ const jobId = await backend.addJob<any, any>({
97
+ jobType: schedule.jobType,
98
+ payload: schedule.payload,
99
+ maxAttempts: schedule.maxAttempts,
100
+ priority: schedule.priority,
101
+ timeoutMs: schedule.timeoutMs ?? undefined,
102
+ forceKillOnTimeout: schedule.forceKillOnTimeout,
103
+ tags: schedule.tags,
104
+ });
105
+
106
+ // Advance to next occurrence
107
+ const nextRunAt = getNextCronOccurrence(
108
+ schedule.cronExpression,
109
+ schedule.timezone,
110
+ );
111
+ await backend.updateCronScheduleAfterEnqueue(
112
+ schedule.id,
113
+ new Date(),
114
+ jobId,
115
+ nextRunAt,
116
+ );
117
+ count++;
118
+ }
119
+
120
+ return count;
121
+ };
122
+
59
123
  // Return the job queue API
60
124
  return {
61
125
  // Job queue operations
@@ -153,11 +217,14 @@ export const initJobQueue = <PayloadMap = any>(
153
217
  config.verbose ?? false,
154
218
  ),
155
219
 
156
- // Job processing
220
+ // Job processing — automatically enqueues due cron jobs before each batch
157
221
  createProcessor: (
158
222
  handlers: JobHandlers<PayloadMap>,
159
223
  options?: ProcessorOptions,
160
- ) => createProcessor<PayloadMap>(backend, handlers, options),
224
+ ) =>
225
+ createProcessor<PayloadMap>(backend, handlers, options, async () => {
226
+ await enqueueDueCronJobsImpl();
227
+ }),
161
228
 
162
229
  // Job events
163
230
  getJobEvents: withLogContext(
@@ -185,6 +252,91 @@ export const initJobQueue = <PayloadMap = any>(
185
252
  config.verbose ?? false,
186
253
  ),
187
254
 
255
+ // Cron schedule operations
256
+ addCronJob: withLogContext(
257
+ <T extends JobType<PayloadMap>>(
258
+ options: CronScheduleOptions<PayloadMap, T>,
259
+ ) => {
260
+ if (!validateCronExpression(options.cronExpression)) {
261
+ return Promise.reject(
262
+ new Error(`Invalid cron expression: "${options.cronExpression}"`),
263
+ );
264
+ }
265
+ const nextRunAt = getNextCronOccurrence(
266
+ options.cronExpression,
267
+ options.timezone ?? 'UTC',
268
+ );
269
+ const input: CronScheduleInput = {
270
+ scheduleName: options.scheduleName,
271
+ cronExpression: options.cronExpression,
272
+ jobType: options.jobType as string,
273
+ payload: options.payload,
274
+ maxAttempts: options.maxAttempts ?? 3,
275
+ priority: options.priority ?? 0,
276
+ timeoutMs: options.timeoutMs ?? null,
277
+ forceKillOnTimeout: options.forceKillOnTimeout ?? false,
278
+ tags: options.tags,
279
+ timezone: options.timezone ?? 'UTC',
280
+ allowOverlap: options.allowOverlap ?? false,
281
+ nextRunAt,
282
+ };
283
+ return backend.addCronSchedule(input);
284
+ },
285
+ config.verbose ?? false,
286
+ ),
287
+ getCronJob: withLogContext(
288
+ (id: number) => backend.getCronSchedule(id),
289
+ config.verbose ?? false,
290
+ ),
291
+ getCronJobByName: withLogContext(
292
+ (name: string) => backend.getCronScheduleByName(name),
293
+ config.verbose ?? false,
294
+ ),
295
+ listCronJobs: withLogContext(
296
+ (status?: CronScheduleStatus) => backend.listCronSchedules(status),
297
+ config.verbose ?? false,
298
+ ),
299
+ removeCronJob: withLogContext(
300
+ (id: number) => backend.removeCronSchedule(id),
301
+ config.verbose ?? false,
302
+ ),
303
+ pauseCronJob: withLogContext(
304
+ (id: number) => backend.pauseCronSchedule(id),
305
+ config.verbose ?? false,
306
+ ),
307
+ resumeCronJob: withLogContext(
308
+ (id: number) => backend.resumeCronSchedule(id),
309
+ config.verbose ?? false,
310
+ ),
311
+ editCronJob: withLogContext(
312
+ async (id: number, updates: EditCronScheduleOptions) => {
313
+ if (
314
+ updates.cronExpression !== undefined &&
315
+ !validateCronExpression(updates.cronExpression)
316
+ ) {
317
+ throw new Error(
318
+ `Invalid cron expression: "${updates.cronExpression}"`,
319
+ );
320
+ }
321
+ let nextRunAt: Date | null | undefined;
322
+ if (
323
+ updates.cronExpression !== undefined ||
324
+ updates.timezone !== undefined
325
+ ) {
326
+ const existing = await backend.getCronSchedule(id);
327
+ const expr = updates.cronExpression ?? existing?.cronExpression ?? '';
328
+ const tz = updates.timezone ?? existing?.timezone ?? 'UTC';
329
+ nextRunAt = getNextCronOccurrence(expr, tz);
330
+ }
331
+ await backend.editCronSchedule(id, updates, nextRunAt);
332
+ },
333
+ config.verbose ?? false,
334
+ ),
335
+ enqueueDueCronJobs: withLogContext(
336
+ () => enqueueDueCronJobsImpl(),
337
+ config.verbose ?? false,
338
+ ),
339
+
188
340
  // Advanced access
189
341
  getPool: () => {
190
342
  if (backendType !== 'postgres') {
@@ -213,9 +365,10 @@ const withLogContext =
213
365
  };
214
366
 
215
367
  export * from './types.js';
216
- export { QueueBackend } from './backend.js';
368
+ export { QueueBackend, CronScheduleInput } from './backend.js';
217
369
  export { PostgresBackend } from './backends/postgres.js';
218
370
  export {
219
371
  validateHandlerSerializable,
220
372
  testHandlerSerialization,
221
373
  } from './handler-validation.js';
374
+ export { getNextCronOccurrence, validateCronExpression } from './cron.js';
package/src/processor.ts CHANGED
@@ -818,16 +818,18 @@ export async function processBatchWithHandlers<PayloadMap>(
818
818
  }
819
819
 
820
820
  /**
821
- * Start a job processor that continuously processes jobs
822
- * @param backend - The queue backend
823
- * @param handlers - The job handlers for this processor instance
821
+ * Start a job processor that continuously processes jobs.
822
+ * @param backend - The queue backend.
823
+ * @param handlers - The job handlers for this processor instance.
824
824
  * @param options - The processor options. Leave pollInterval empty to run only once. Use jobType to filter jobs by type.
825
- * @returns {Processor} The processor instance
825
+ * @param onBeforeBatch - Optional callback invoked before each batch. Used internally to enqueue due cron jobs.
826
+ * @returns {Processor} The processor instance.
826
827
  */
827
828
  export const createProcessor = <PayloadMap = any>(
828
829
  backend: QueueBackend,
829
830
  handlers: JobHandlers<PayloadMap>,
830
831
  options: ProcessorOptions = {},
832
+ onBeforeBatch?: () => Promise<void>,
831
833
  ): Processor => {
832
834
  const {
833
835
  workerId = `worker-${Math.random().toString(36).substring(2, 9)}`,
@@ -847,6 +849,22 @@ export const createProcessor = <PayloadMap = any>(
847
849
  const processJobs = async (): Promise<number> => {
848
850
  if (!running) return 0;
849
851
 
852
+ // Run pre-batch hook (e.g. enqueue due cron jobs) before processing
853
+ if (onBeforeBatch) {
854
+ try {
855
+ await onBeforeBatch();
856
+ } catch (hookError) {
857
+ log(`onBeforeBatch hook error: ${hookError}`);
858
+ if (onError) {
859
+ onError(
860
+ hookError instanceof Error
861
+ ? hookError
862
+ : new Error(String(hookError)),
863
+ );
864
+ }
865
+ }
866
+ }
867
+
850
868
  log(
851
869
  `Processing jobs with workerId: ${workerId}${jobType ? ` and jobType: ${Array.isArray(jobType) ? jobType.join(',') : jobType}` : ''}`,
852
870
  );