@chidchanun/bcp 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1144 @@
1
+ import {
2
+ randomUUID,
3
+ } from "node:crypto";
4
+
5
+ import type {
6
+ BackgroundJobQueue,
7
+ } from "./jobs.js";
8
+
9
+ export type JobSchedule =
10
+ | {
11
+ kind: "interval";
12
+ everyMs: number;
13
+ }
14
+ | {
15
+ kind: "cron";
16
+ expression: string;
17
+ };
18
+
19
+ export interface JobScheduleRecord<
20
+ TPayload = unknown
21
+ > {
22
+ id: string;
23
+ jobName: string;
24
+ payload: TPayload;
25
+ schedule: JobSchedule;
26
+ createdAt: number;
27
+ updatedAt: number;
28
+ nextRunAt: number;
29
+ lastRunAt?: number;
30
+ maxAttempts?: number;
31
+ leaseOwner?: string;
32
+ leaseUntil?: number;
33
+ }
34
+
35
+ export interface ScheduleJobOptions {
36
+ id?: string;
37
+ everyMs?: number;
38
+ cron?: string;
39
+ startAt?: number | Date;
40
+ maxAttempts?: number;
41
+ }
42
+
43
+ export interface JobScheduleStore {
44
+ upsert<TPayload = unknown>(
45
+ schedule: JobScheduleRecord<TPayload>
46
+ ): Promise<void>;
47
+
48
+ get<TPayload = unknown>(
49
+ id: string
50
+ ): Promise<JobScheduleRecord<TPayload> | null>;
51
+
52
+ list(): Promise<JobScheduleRecord[]>;
53
+
54
+ remove(id: string): Promise<boolean>;
55
+
56
+ acquireDue(
57
+ now: number,
58
+ options: {
59
+ ownerId: string;
60
+ leaseMs: number;
61
+ limit: number;
62
+ }
63
+ ): Promise<JobScheduleRecord[]>;
64
+
65
+ complete(
66
+ id: string,
67
+ options: {
68
+ ownerId: string;
69
+ lastRunAt: number;
70
+ nextRunAt: number;
71
+ updatedAt: number;
72
+ }
73
+ ): Promise<void>;
74
+
75
+ release(
76
+ id: string,
77
+ ownerId: string
78
+ ): Promise<void>;
79
+
80
+ close?(): Promise<void>;
81
+ }
82
+
83
+ export interface MemoryJobScheduleStore
84
+ extends JobScheduleStore {
85
+ clear(): void;
86
+ }
87
+
88
+ export interface JobSchedulerOptions {
89
+ queue: BackgroundJobQueue;
90
+ store?: JobScheduleStore;
91
+ now?: () => number;
92
+ idFactory?: () => string;
93
+ ownerId?: string;
94
+ }
95
+
96
+ export interface RunDueSchedulesOptions {
97
+ limit?: number;
98
+ leaseMs?: number;
99
+ }
100
+
101
+ export interface StartJobSchedulerOptions
102
+ extends RunDueSchedulesOptions {
103
+ pollIntervalMs?: number;
104
+ onError?: (
105
+ error: unknown
106
+ ) => void | Promise<void>;
107
+ }
108
+
109
+ export interface JobSchedulerRunner {
110
+ readonly running: boolean;
111
+ stop(): Promise<void>;
112
+ }
113
+
114
+ export interface JobScheduler {
115
+ readonly queue: BackgroundJobQueue;
116
+ readonly store: JobScheduleStore;
117
+ readonly ownerId: string;
118
+
119
+ schedule<TPayload = unknown>(
120
+ jobName: string,
121
+ payload: TPayload,
122
+ options: ScheduleJobOptions
123
+ ): Promise<JobScheduleRecord<TPayload>>;
124
+
125
+ get<TPayload = unknown>(
126
+ id: string
127
+ ): Promise<JobScheduleRecord<TPayload> | null>;
128
+
129
+ list(): Promise<JobScheduleRecord[]>;
130
+
131
+ remove(id: string): Promise<boolean>;
132
+
133
+ runDue(
134
+ options?: RunDueSchedulesOptions
135
+ ): Promise<number>;
136
+
137
+ start(
138
+ options?: StartJobSchedulerOptions
139
+ ): JobSchedulerRunner;
140
+
141
+ close(): Promise<void>;
142
+ }
143
+
144
+ export function createMemoryJobScheduleStore():
145
+ MemoryJobScheduleStore {
146
+ const schedules =
147
+ new Map<
148
+ string,
149
+ JobScheduleRecord
150
+ >();
151
+
152
+ return {
153
+ async upsert(schedule) {
154
+ schedules.set(
155
+ schedule.id,
156
+ cloneSchedule(
157
+ schedule
158
+ )
159
+ );
160
+ },
161
+
162
+ async get(id) {
163
+ const schedule =
164
+ schedules.get(id);
165
+
166
+ return schedule
167
+ ? cloneSchedule(
168
+ schedule
169
+ ) as JobScheduleRecord<any>
170
+ : null;
171
+ },
172
+
173
+ async list() {
174
+ return Array.from(
175
+ schedules.values()
176
+ )
177
+ .map(
178
+ cloneSchedule
179
+ )
180
+ .sort(
181
+ (left, right) =>
182
+ left.nextRunAt -
183
+ right.nextRunAt ||
184
+ left.id.localeCompare(
185
+ right.id
186
+ )
187
+ );
188
+ },
189
+
190
+ async remove(id) {
191
+ return schedules.delete(id);
192
+ },
193
+
194
+ async acquireDue(
195
+ now,
196
+ options
197
+ ) {
198
+ const due =
199
+ Array.from(
200
+ schedules.values()
201
+ )
202
+ .filter(
203
+ schedule =>
204
+ schedule.nextRunAt <= now &&
205
+ (
206
+ schedule.leaseUntil === undefined ||
207
+ schedule.leaseUntil <= now
208
+ )
209
+ )
210
+ .sort(
211
+ (left, right) =>
212
+ left.nextRunAt -
213
+ right.nextRunAt ||
214
+ left.id.localeCompare(
215
+ right.id
216
+ )
217
+ )
218
+ .slice(
219
+ 0,
220
+ options.limit
221
+ );
222
+
223
+ for (const schedule of due) {
224
+ schedule.leaseOwner =
225
+ options.ownerId;
226
+ schedule.leaseUntil =
227
+ now + options.leaseMs;
228
+ }
229
+
230
+ return due.map(
231
+ cloneSchedule
232
+ );
233
+ },
234
+
235
+ async complete(
236
+ id,
237
+ options
238
+ ) {
239
+ const schedule =
240
+ schedules.get(id);
241
+
242
+ if (
243
+ !schedule ||
244
+ schedule.leaseOwner !==
245
+ options.ownerId
246
+ ) {
247
+ return;
248
+ }
249
+
250
+ schedule.lastRunAt =
251
+ options.lastRunAt;
252
+ schedule.nextRunAt =
253
+ options.nextRunAt;
254
+ schedule.updatedAt =
255
+ options.updatedAt;
256
+ schedule.leaseOwner =
257
+ undefined;
258
+ schedule.leaseUntil =
259
+ undefined;
260
+ },
261
+
262
+ async release(
263
+ id,
264
+ ownerId
265
+ ) {
266
+ const schedule =
267
+ schedules.get(id);
268
+
269
+ if (
270
+ !schedule ||
271
+ schedule.leaseOwner !==
272
+ ownerId
273
+ ) {
274
+ return;
275
+ }
276
+
277
+ schedule.leaseOwner =
278
+ undefined;
279
+ schedule.leaseUntil =
280
+ undefined;
281
+ },
282
+
283
+ clear() {
284
+ schedules.clear();
285
+ },
286
+ };
287
+ }
288
+
289
+ export function createJobScheduler(
290
+ options: JobSchedulerOptions
291
+ ): JobScheduler {
292
+ if (
293
+ !options ||
294
+ !options.queue
295
+ ) {
296
+ throw new TypeError(
297
+ "BCP Jobs: createJobScheduler requires a queue."
298
+ );
299
+ }
300
+
301
+ const queue =
302
+ options.queue;
303
+ const store =
304
+ options.store ??
305
+ createMemoryJobScheduleStore();
306
+ const now =
307
+ options.now ??
308
+ Date.now;
309
+ const idFactory =
310
+ options.idFactory ??
311
+ randomUUID;
312
+ const ownerId =
313
+ normalizeIdentifier(
314
+ options.ownerId ??
315
+ `scheduler-${randomUUID()}`,
316
+ "scheduler owner id"
317
+ );
318
+ const runners =
319
+ new Set<InternalSchedulerRunner>();
320
+
321
+ const scheduler:
322
+ JobScheduler = {
323
+ queue,
324
+ store,
325
+ ownerId,
326
+
327
+ async schedule(
328
+ jobName,
329
+ payload,
330
+ scheduleOptions
331
+ ) {
332
+ const currentTime =
333
+ normalizeTimestamp(
334
+ now(),
335
+ "current time"
336
+ );
337
+ const schedule =
338
+ normalizeSchedule(
339
+ scheduleOptions
340
+ );
341
+ const id =
342
+ normalizeIdentifier(
343
+ scheduleOptions.id ??
344
+ idFactory(),
345
+ "schedule id"
346
+ );
347
+ const startAt =
348
+ scheduleOptions.startAt ===
349
+ undefined
350
+ ? undefined
351
+ : normalizeTimestamp(
352
+ scheduleOptions.startAt instanceof Date
353
+ ? scheduleOptions.startAt.getTime()
354
+ : scheduleOptions.startAt,
355
+ "startAt"
356
+ );
357
+ const maxAttempts =
358
+ scheduleOptions.maxAttempts ===
359
+ undefined
360
+ ? undefined
361
+ : normalizePositiveInteger(
362
+ scheduleOptions.maxAttempts,
363
+ "maxAttempts"
364
+ );
365
+ const nextRunAt =
366
+ startAt ??
367
+ nextScheduleTime(
368
+ schedule,
369
+ currentTime
370
+ );
371
+ const record:
372
+ JobScheduleRecord<
373
+ typeof payload
374
+ > = {
375
+ id,
376
+ jobName:
377
+ normalizeIdentifier(
378
+ jobName,
379
+ "job name"
380
+ ),
381
+ payload,
382
+ schedule,
383
+ createdAt:
384
+ currentTime,
385
+ updatedAt:
386
+ currentTime,
387
+ nextRunAt,
388
+ maxAttempts,
389
+ };
390
+
391
+ await store.upsert(
392
+ record
393
+ );
394
+
395
+ return cloneSchedule(
396
+ record
397
+ );
398
+ },
399
+
400
+ get(id) {
401
+ return store.get(
402
+ normalizeIdentifier(
403
+ id,
404
+ "schedule id"
405
+ )
406
+ );
407
+ },
408
+
409
+ list() {
410
+ return store.list();
411
+ },
412
+
413
+ remove(id) {
414
+ return store.remove(
415
+ normalizeIdentifier(
416
+ id,
417
+ "schedule id"
418
+ )
419
+ );
420
+ },
421
+
422
+ async runDue(
423
+ runOptions = {}
424
+ ) {
425
+ const currentTime =
426
+ normalizeTimestamp(
427
+ now(),
428
+ "current time"
429
+ );
430
+ const limit =
431
+ normalizePositiveInteger(
432
+ runOptions.limit ?? 100,
433
+ "limit"
434
+ );
435
+ const leaseMs =
436
+ normalizePositiveInteger(
437
+ runOptions.leaseMs ??
438
+ 30_000,
439
+ "leaseMs"
440
+ );
441
+ const schedules =
442
+ await store.acquireDue(
443
+ currentTime,
444
+ {
445
+ ownerId,
446
+ leaseMs,
447
+ limit,
448
+ }
449
+ );
450
+ let enqueued = 0;
451
+
452
+ for (
453
+ const scheduleRecord
454
+ of schedules
455
+ ) {
456
+ const scheduledFor =
457
+ scheduleRecord.nextRunAt;
458
+ const runId =
459
+ createScheduledRunId(
460
+ scheduleRecord.id,
461
+ scheduledFor
462
+ );
463
+
464
+ try {
465
+ await queue.enqueue(
466
+ scheduleRecord.jobName,
467
+ scheduleRecord.payload,
468
+ {
469
+ id: runId,
470
+ maxAttempts:
471
+ scheduleRecord.maxAttempts,
472
+ }
473
+ );
474
+ enqueued += 1;
475
+ } catch (error) {
476
+ if (
477
+ !isDuplicateJobIdError(
478
+ error
479
+ )
480
+ ) {
481
+ await store.release(
482
+ scheduleRecord.id,
483
+ ownerId
484
+ );
485
+ throw error;
486
+ }
487
+ }
488
+
489
+ const nextRunAt =
490
+ nextScheduleTime(
491
+ scheduleRecord.schedule,
492
+ scheduledFor
493
+ );
494
+
495
+ await store.complete(
496
+ scheduleRecord.id,
497
+ {
498
+ ownerId,
499
+ lastRunAt:
500
+ scheduledFor,
501
+ nextRunAt,
502
+ updatedAt:
503
+ currentTime,
504
+ }
505
+ );
506
+ }
507
+
508
+ return enqueued;
509
+ },
510
+
511
+ start(
512
+ startOptions = {}
513
+ ) {
514
+ const runner =
515
+ createSchedulerRunner(
516
+ scheduler,
517
+ startOptions,
518
+ () =>
519
+ runners.delete(
520
+ runner
521
+ )
522
+ );
523
+
524
+ runners.add(runner);
525
+ return runner;
526
+ },
527
+
528
+ async close() {
529
+ await Promise.all(
530
+ Array.from(
531
+ runners,
532
+ runner =>
533
+ runner.stop()
534
+ )
535
+ );
536
+
537
+ if (store.close) {
538
+ await store.close();
539
+ }
540
+ },
541
+ };
542
+
543
+ return scheduler;
544
+ }
545
+
546
+ export function nextScheduleTime(
547
+ schedule: JobSchedule,
548
+ after: number
549
+ ): number {
550
+ const timestamp =
551
+ normalizeTimestamp(
552
+ after,
553
+ "schedule reference time"
554
+ );
555
+
556
+ if (schedule.kind === "interval") {
557
+ return timestamp +
558
+ normalizePositiveInteger(
559
+ schedule.everyMs,
560
+ "everyMs"
561
+ );
562
+ }
563
+
564
+ return nextCronTime(
565
+ schedule.expression,
566
+ timestamp
567
+ );
568
+ }
569
+
570
+ export function nextCronTime(
571
+ expression: string,
572
+ after: number
573
+ ): number {
574
+ const cron =
575
+ parseCronExpression(
576
+ expression
577
+ );
578
+ const afterTimestamp =
579
+ normalizeTimestamp(
580
+ after,
581
+ "cron reference time"
582
+ );
583
+ const minute =
584
+ 60_000;
585
+ let candidate =
586
+ Math.floor(
587
+ afterTimestamp /
588
+ minute
589
+ ) * minute + minute;
590
+ const maxIterations =
591
+ 366 * 24 * 60 * 8;
592
+
593
+ for (
594
+ let iteration = 0;
595
+ iteration < maxIterations;
596
+ iteration += 1
597
+ ) {
598
+ const date =
599
+ new Date(candidate);
600
+
601
+ if (
602
+ cron.month.has(
603
+ date.getUTCMonth() + 1
604
+ ) &&
605
+ cron.hour.has(
606
+ date.getUTCHours()
607
+ ) &&
608
+ cron.minute.has(
609
+ date.getUTCMinutes()
610
+ ) &&
611
+ matchesCronDay(
612
+ cron,
613
+ date
614
+ )
615
+ ) {
616
+ return candidate;
617
+ }
618
+
619
+ candidate += minute;
620
+ }
621
+
622
+ throw new RangeError(
623
+ `BCP Jobs: cron expression "${expression}" did not produce a run time within 8 years.`
624
+ );
625
+ }
626
+
627
+ interface InternalSchedulerRunner
628
+ extends JobSchedulerRunner {}
629
+
630
+ function createSchedulerRunner(
631
+ scheduler: JobScheduler,
632
+ options: StartJobSchedulerOptions,
633
+ onStop: () => void
634
+ ): InternalSchedulerRunner {
635
+ const pollIntervalMs =
636
+ normalizePositiveInteger(
637
+ options.pollIntervalMs ??
638
+ 1_000,
639
+ "pollIntervalMs"
640
+ );
641
+ const runOptions:
642
+ RunDueSchedulesOptions = {
643
+ limit:
644
+ options.limit,
645
+ leaseMs:
646
+ options.leaseMs,
647
+ };
648
+ const controller =
649
+ new AbortController();
650
+ let running = true;
651
+ let stopPromise:
652
+ Promise<void> | null =
653
+ null;
654
+
655
+ const loop =
656
+ runSchedulerLoop(
657
+ scheduler,
658
+ controller.signal,
659
+ pollIntervalMs,
660
+ runOptions,
661
+ options.onError
662
+ );
663
+
664
+ return {
665
+ get running() {
666
+ return running;
667
+ },
668
+
669
+ stop() {
670
+ if (stopPromise) {
671
+ return stopPromise;
672
+ }
673
+
674
+ running = false;
675
+ controller.abort();
676
+ stopPromise =
677
+ loop.finally(
678
+ onStop
679
+ );
680
+
681
+ return stopPromise;
682
+ },
683
+ };
684
+ }
685
+
686
+ async function runSchedulerLoop(
687
+ scheduler: JobScheduler,
688
+ signal: AbortSignal,
689
+ pollIntervalMs: number,
690
+ runOptions: RunDueSchedulesOptions,
691
+ onError:
692
+ StartJobSchedulerOptions["onError"]
693
+ ): Promise<void> {
694
+ while (!signal.aborted) {
695
+ try {
696
+ await scheduler.runDue(
697
+ runOptions
698
+ );
699
+ } catch (error) {
700
+ if (onError) {
701
+ await onError(error);
702
+ }
703
+ }
704
+
705
+ if (!signal.aborted) {
706
+ await sleep(
707
+ pollIntervalMs,
708
+ signal
709
+ );
710
+ }
711
+ }
712
+ }
713
+
714
+ function normalizeSchedule(
715
+ options: ScheduleJobOptions
716
+ ): JobSchedule {
717
+ if (!options) {
718
+ throw new TypeError(
719
+ "BCP Jobs: schedule options are required."
720
+ );
721
+ }
722
+
723
+ const hasInterval =
724
+ options.everyMs !== undefined;
725
+ const hasCron =
726
+ options.cron !== undefined;
727
+
728
+ if (
729
+ hasInterval === hasCron
730
+ ) {
731
+ throw new TypeError(
732
+ "BCP Jobs: schedule requires exactly one of everyMs or cron."
733
+ );
734
+ }
735
+
736
+ if (hasInterval) {
737
+ return {
738
+ kind: "interval",
739
+ everyMs:
740
+ normalizePositiveInteger(
741
+ options.everyMs as number,
742
+ "everyMs"
743
+ ),
744
+ };
745
+ }
746
+
747
+ const expression =
748
+ String(options.cron)
749
+ .trim();
750
+
751
+ parseCronExpression(
752
+ expression
753
+ );
754
+
755
+ return {
756
+ kind: "cron",
757
+ expression,
758
+ };
759
+ }
760
+
761
+ interface ParsedCronExpression {
762
+ minute: Set<number>;
763
+ hour: Set<number>;
764
+ dayOfMonth: Set<number>;
765
+ month: Set<number>;
766
+ dayOfWeek: Set<number>;
767
+ dayOfMonthWildcard: boolean;
768
+ dayOfWeekWildcard: boolean;
769
+ }
770
+
771
+ function parseCronExpression(
772
+ expression: string
773
+ ): ParsedCronExpression {
774
+ const parts =
775
+ String(expression)
776
+ .trim()
777
+ .split(/\s+/);
778
+
779
+ if (parts.length !== 5) {
780
+ throw new TypeError(
781
+ "BCP Jobs: cron must contain 5 fields: minute hour day-of-month month day-of-week."
782
+ );
783
+ }
784
+
785
+ return {
786
+ minute:
787
+ parseCronField(
788
+ parts[0],
789
+ 0,
790
+ 59,
791
+ "minute"
792
+ ).values,
793
+ hour:
794
+ parseCronField(
795
+ parts[1],
796
+ 0,
797
+ 23,
798
+ "hour"
799
+ ).values,
800
+ dayOfMonth:
801
+ parseCronField(
802
+ parts[2],
803
+ 1,
804
+ 31,
805
+ "day-of-month"
806
+ ).values,
807
+ month:
808
+ parseCronField(
809
+ parts[3],
810
+ 1,
811
+ 12,
812
+ "month"
813
+ ).values,
814
+ dayOfWeek:
815
+ parseCronField(
816
+ parts[4],
817
+ 0,
818
+ 7,
819
+ "day-of-week",
820
+ true
821
+ ).values,
822
+ dayOfMonthWildcard:
823
+ parts[2] === "*",
824
+ dayOfWeekWildcard:
825
+ parts[4] === "*",
826
+ };
827
+ }
828
+
829
+ function parseCronField(
830
+ source: string,
831
+ minimum: number,
832
+ maximum: number,
833
+ field: string,
834
+ normalizeSunday = false
835
+ ): {
836
+ values: Set<number>;
837
+ } {
838
+ const values =
839
+ new Set<number>();
840
+
841
+ for (
842
+ const segment
843
+ of source.split(",")
844
+ ) {
845
+ const [base, stepSource] =
846
+ segment.split("/");
847
+
848
+ if (
849
+ segment.split("/").length > 2
850
+ ) {
851
+ throwCronField(
852
+ field,
853
+ source
854
+ );
855
+ }
856
+
857
+ const step =
858
+ stepSource === undefined
859
+ ? 1
860
+ : parseCronInteger(
861
+ stepSource,
862
+ 1,
863
+ maximum - minimum + 1,
864
+ field,
865
+ source
866
+ );
867
+ let rangeStart:
868
+ number;
869
+ let rangeEnd:
870
+ number;
871
+
872
+ if (base === "*") {
873
+ rangeStart = minimum;
874
+ rangeEnd = maximum;
875
+ } else if (
876
+ base.includes("-")
877
+ ) {
878
+ const bounds =
879
+ base.split("-");
880
+
881
+ if (bounds.length !== 2) {
882
+ throwCronField(
883
+ field,
884
+ source
885
+ );
886
+ }
887
+
888
+ rangeStart =
889
+ parseCronInteger(
890
+ bounds[0],
891
+ minimum,
892
+ maximum,
893
+ field,
894
+ source
895
+ );
896
+ rangeEnd =
897
+ parseCronInteger(
898
+ bounds[1],
899
+ minimum,
900
+ maximum,
901
+ field,
902
+ source
903
+ );
904
+
905
+ if (
906
+ rangeEnd < rangeStart
907
+ ) {
908
+ throwCronField(
909
+ field,
910
+ source
911
+ );
912
+ }
913
+ } else {
914
+ rangeStart =
915
+ parseCronInteger(
916
+ base,
917
+ minimum,
918
+ maximum,
919
+ field,
920
+ source
921
+ );
922
+ rangeEnd =
923
+ rangeStart;
924
+ }
925
+
926
+ for (
927
+ let value = rangeStart;
928
+ value <= rangeEnd;
929
+ value += step
930
+ ) {
931
+ values.add(
932
+ normalizeSunday &&
933
+ value === 7
934
+ ? 0
935
+ : value
936
+ );
937
+ }
938
+ }
939
+
940
+ if (values.size === 0) {
941
+ throwCronField(
942
+ field,
943
+ source
944
+ );
945
+ }
946
+
947
+ return {
948
+ values,
949
+ };
950
+ }
951
+
952
+ function parseCronInteger(
953
+ source: string,
954
+ minimum: number,
955
+ maximum: number,
956
+ field: string,
957
+ fullSource: string
958
+ ): number {
959
+ if (!/^\d+$/.test(source)) {
960
+ throwCronField(
961
+ field,
962
+ fullSource
963
+ );
964
+ }
965
+
966
+ const value =
967
+ Number(source);
968
+
969
+ if (
970
+ !Number.isInteger(value) ||
971
+ value < minimum ||
972
+ value > maximum
973
+ ) {
974
+ throwCronField(
975
+ field,
976
+ fullSource
977
+ );
978
+ }
979
+
980
+ return value;
981
+ }
982
+
983
+ function throwCronField(
984
+ field: string,
985
+ source: string
986
+ ): never {
987
+ throw new TypeError(
988
+ `BCP Jobs: invalid cron ${field} field "${source}".`
989
+ );
990
+ }
991
+
992
+ function matchesCronDay(
993
+ cron: ParsedCronExpression,
994
+ date: Date
995
+ ): boolean {
996
+ const dayOfMonthMatches =
997
+ cron.dayOfMonth.has(
998
+ date.getUTCDate()
999
+ );
1000
+ const dayOfWeekMatches =
1001
+ cron.dayOfWeek.has(
1002
+ date.getUTCDay()
1003
+ );
1004
+
1005
+ if (
1006
+ cron.dayOfMonthWildcard &&
1007
+ cron.dayOfWeekWildcard
1008
+ ) {
1009
+ return true;
1010
+ }
1011
+
1012
+ if (cron.dayOfMonthWildcard) {
1013
+ return dayOfWeekMatches;
1014
+ }
1015
+
1016
+ if (cron.dayOfWeekWildcard) {
1017
+ return dayOfMonthMatches;
1018
+ }
1019
+
1020
+ return dayOfMonthMatches ||
1021
+ dayOfWeekMatches;
1022
+ }
1023
+
1024
+ function createScheduledRunId(
1025
+ scheduleId: string,
1026
+ scheduledFor: number
1027
+ ): string {
1028
+ return `schedule:${scheduleId}:${scheduledFor}`;
1029
+ }
1030
+
1031
+ function isDuplicateJobIdError(
1032
+ error: unknown
1033
+ ): boolean {
1034
+ return error instanceof Error &&
1035
+ /job id .* already exists/i.test(
1036
+ error.message
1037
+ );
1038
+ }
1039
+
1040
+ function normalizeIdentifier(
1041
+ value: string,
1042
+ field: string
1043
+ ): string {
1044
+ const normalized =
1045
+ String(value)
1046
+ .trim();
1047
+
1048
+ if (!normalized) {
1049
+ throw new TypeError(
1050
+ `BCP Jobs: ${field} must be a non-empty string.`
1051
+ );
1052
+ }
1053
+
1054
+ if (normalized.length > 200) {
1055
+ throw new TypeError(
1056
+ `BCP Jobs: ${field} must not exceed 200 characters.`
1057
+ );
1058
+ }
1059
+
1060
+ return normalized;
1061
+ }
1062
+
1063
+ function normalizeTimestamp(
1064
+ value: number,
1065
+ field: string
1066
+ ): number {
1067
+ if (
1068
+ !Number.isFinite(value) ||
1069
+ value < 0
1070
+ ) {
1071
+ throw new TypeError(
1072
+ `BCP Jobs: ${field} must be a non-negative finite timestamp.`
1073
+ );
1074
+ }
1075
+
1076
+ return Math.floor(value);
1077
+ }
1078
+
1079
+ function normalizePositiveInteger(
1080
+ value: number,
1081
+ field: string
1082
+ ): number {
1083
+ if (
1084
+ !Number.isInteger(value) ||
1085
+ value <= 0
1086
+ ) {
1087
+ throw new TypeError(
1088
+ `BCP Jobs: ${field} must be a positive integer.`
1089
+ );
1090
+ }
1091
+
1092
+ return value;
1093
+ }
1094
+
1095
+ function sleep(
1096
+ durationMs: number,
1097
+ signal: AbortSignal
1098
+ ): Promise<void> {
1099
+ if (signal.aborted) {
1100
+ return Promise.resolve();
1101
+ }
1102
+
1103
+ return new Promise(
1104
+ resolve => {
1105
+ const timeout =
1106
+ setTimeout(
1107
+ finish,
1108
+ durationMs
1109
+ );
1110
+ const onAbort =
1111
+ () => finish();
1112
+
1113
+ signal.addEventListener(
1114
+ "abort",
1115
+ onAbort,
1116
+ {
1117
+ once: true,
1118
+ }
1119
+ );
1120
+
1121
+ function finish() {
1122
+ clearTimeout(timeout);
1123
+ signal.removeEventListener(
1124
+ "abort",
1125
+ onAbort
1126
+ );
1127
+ resolve();
1128
+ }
1129
+ }
1130
+ );
1131
+ }
1132
+
1133
+ function cloneSchedule<
1134
+ TPayload
1135
+ >(
1136
+ schedule: JobScheduleRecord<TPayload>
1137
+ ): JobScheduleRecord<TPayload> {
1138
+ return {
1139
+ ...schedule,
1140
+ schedule: {
1141
+ ...schedule.schedule,
1142
+ },
1143
+ };
1144
+ }