@chidchanun/bcp 0.2.7 → 0.2.8

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,822 @@
1
+ import {
2
+ randomUUID,
3
+ } from "node:crypto";
4
+
5
+ export type JobState =
6
+ | "queued"
7
+ | "running"
8
+ | "succeeded"
9
+ | "failed"
10
+ | "cancelled";
11
+
12
+ export interface JobRecord<
13
+ TPayload = unknown
14
+ > {
15
+ id: string;
16
+ name: string;
17
+ payload: TPayload;
18
+ state: JobState;
19
+ attempts: number;
20
+ maxAttempts: number;
21
+ createdAt: number;
22
+ availableAt: number;
23
+ startedAt?: number;
24
+ completedAt?: number;
25
+ error?: string;
26
+ }
27
+
28
+ export interface EnqueueJobOptions {
29
+ id?: string;
30
+ delayMs?: number;
31
+ maxAttempts?: number;
32
+ }
33
+
34
+ export interface JobQueueAdapter {
35
+ enqueue<TPayload = unknown>(
36
+ job: JobRecord<TPayload>
37
+ ): Promise<void>;
38
+
39
+ reserve(
40
+ now: number
41
+ ): Promise<JobRecord | null>;
42
+
43
+ complete(
44
+ id: string,
45
+ completedAt: number
46
+ ): Promise<void>;
47
+
48
+ fail(
49
+ id: string,
50
+ options: {
51
+ error: string;
52
+ failedAt: number;
53
+ retryAt?: number;
54
+ }
55
+ ): Promise<void>;
56
+
57
+ cancel(
58
+ id: string,
59
+ cancelledAt: number
60
+ ): Promise<boolean>;
61
+
62
+ get<TPayload = unknown>(
63
+ id: string
64
+ ): Promise<JobRecord<TPayload> | null>;
65
+
66
+ list(): Promise<JobRecord[]>;
67
+
68
+ close?(): Promise<void>;
69
+ }
70
+
71
+ export interface MemoryJobQueueAdapter
72
+ extends JobQueueAdapter {
73
+ clear(): void;
74
+ }
75
+
76
+ export interface JobHandlerContext<
77
+ TPayload = unknown
78
+ > {
79
+ job: Readonly<JobRecord<TPayload>>;
80
+ payload: TPayload;
81
+ signal: AbortSignal;
82
+ }
83
+
84
+ export type JobHandler<
85
+ TPayload = unknown
86
+ > = (
87
+ context: JobHandlerContext<TPayload>
88
+ ) => unknown | Promise<unknown>;
89
+
90
+ export type JobRetryDelay =
91
+ | number
92
+ | ((attempt: number) => number);
93
+
94
+ export interface JobQueueOptions {
95
+ adapter?: JobQueueAdapter;
96
+ defaultMaxAttempts?: number;
97
+ retryDelayMs?: JobRetryDelay;
98
+ now?: () => number;
99
+ idFactory?: () => string;
100
+ }
101
+
102
+ export interface StartJobWorkerOptions {
103
+ concurrency?: number;
104
+ pollIntervalMs?: number;
105
+ }
106
+
107
+ export interface JobWorker {
108
+ readonly running: boolean;
109
+ stop(): Promise<void>;
110
+ }
111
+
112
+ export interface BackgroundJobQueue {
113
+ readonly adapter: JobQueueAdapter;
114
+
115
+ register<TPayload = unknown>(
116
+ name: string,
117
+ handler: JobHandler<TPayload>
118
+ ): () => void;
119
+
120
+ enqueue<TPayload = unknown>(
121
+ name: string,
122
+ payload: TPayload,
123
+ options?: EnqueueJobOptions
124
+ ): Promise<JobRecord<TPayload>>;
125
+
126
+ get<TPayload = unknown>(
127
+ id: string
128
+ ): Promise<JobRecord<TPayload> | null>;
129
+
130
+ list(): Promise<JobRecord[]>;
131
+
132
+ cancel(id: string): Promise<boolean>;
133
+
134
+ processNext(
135
+ signal?: AbortSignal
136
+ ): Promise<boolean>;
137
+
138
+ startWorker(
139
+ options?: StartJobWorkerOptions
140
+ ): JobWorker;
141
+
142
+ close(): Promise<void>;
143
+ }
144
+
145
+ export function createMemoryJobQueueAdapter():
146
+ MemoryJobQueueAdapter {
147
+ const jobs =
148
+ new Map<string, JobRecord>();
149
+
150
+ return {
151
+ async enqueue(job) {
152
+ if (jobs.has(job.id)) {
153
+ throw new Error(
154
+ `BCP Jobs: job id "${job.id}" already exists.`
155
+ );
156
+ }
157
+
158
+ jobs.set(
159
+ job.id,
160
+ cloneJob(job)
161
+ );
162
+ },
163
+
164
+ async reserve(now) {
165
+ const candidate =
166
+ Array.from(
167
+ jobs.values()
168
+ )
169
+ .filter(
170
+ job =>
171
+ job.state === "queued" &&
172
+ job.availableAt <= now
173
+ )
174
+ .sort(
175
+ (left, right) =>
176
+ left.availableAt - right.availableAt ||
177
+ left.createdAt - right.createdAt ||
178
+ left.id.localeCompare(right.id)
179
+ )[0];
180
+
181
+ if (!candidate) {
182
+ return null;
183
+ }
184
+
185
+ candidate.state =
186
+ "running";
187
+ candidate.attempts += 1;
188
+ candidate.startedAt =
189
+ now;
190
+ candidate.error =
191
+ undefined;
192
+
193
+ return cloneJob(
194
+ candidate
195
+ );
196
+ },
197
+
198
+ async complete(
199
+ id,
200
+ completedAt
201
+ ) {
202
+ const job =
203
+ jobs.get(id);
204
+
205
+ if (
206
+ !job ||
207
+ job.state === "cancelled"
208
+ ) {
209
+ return;
210
+ }
211
+
212
+ job.state =
213
+ "succeeded";
214
+ job.completedAt =
215
+ completedAt;
216
+ job.error =
217
+ undefined;
218
+ },
219
+
220
+ async fail(
221
+ id,
222
+ options
223
+ ) {
224
+ const job =
225
+ jobs.get(id);
226
+
227
+ if (
228
+ !job ||
229
+ job.state === "cancelled"
230
+ ) {
231
+ return;
232
+ }
233
+
234
+ job.error =
235
+ options.error;
236
+
237
+ if (
238
+ options.retryAt !== undefined &&
239
+ job.attempts < job.maxAttempts
240
+ ) {
241
+ job.state =
242
+ "queued";
243
+ job.availableAt =
244
+ options.retryAt;
245
+ job.startedAt =
246
+ undefined;
247
+ return;
248
+ }
249
+
250
+ job.state =
251
+ "failed";
252
+ job.completedAt =
253
+ options.failedAt;
254
+ },
255
+
256
+ async cancel(
257
+ id,
258
+ cancelledAt
259
+ ) {
260
+ const job =
261
+ jobs.get(id);
262
+
263
+ if (
264
+ !job ||
265
+ job.state === "succeeded" ||
266
+ job.state === "failed" ||
267
+ job.state === "cancelled"
268
+ ) {
269
+ return false;
270
+ }
271
+
272
+ job.state =
273
+ "cancelled";
274
+ job.completedAt =
275
+ cancelledAt;
276
+ return true;
277
+ },
278
+
279
+ async get(id) {
280
+ const job =
281
+ jobs.get(id);
282
+
283
+ return job
284
+ ? cloneJob(job) as JobRecord<any>
285
+ : null;
286
+ },
287
+
288
+ async list() {
289
+ return Array.from(
290
+ jobs.values()
291
+ )
292
+ .map(cloneJob)
293
+ .sort(
294
+ (left, right) =>
295
+ left.createdAt - right.createdAt ||
296
+ left.id.localeCompare(right.id)
297
+ );
298
+ },
299
+
300
+ clear() {
301
+ jobs.clear();
302
+ },
303
+ };
304
+ }
305
+
306
+ export function createJobQueue(
307
+ options: JobQueueOptions = {}
308
+ ): BackgroundJobQueue {
309
+ const adapter =
310
+ options.adapter ??
311
+ createMemoryJobQueueAdapter();
312
+ const handlers =
313
+ new Map<string, JobHandler<any>>();
314
+ const workers =
315
+ new Set<InternalJobWorker>();
316
+ const now =
317
+ options.now ??
318
+ Date.now;
319
+ const idFactory =
320
+ options.idFactory ??
321
+ randomUUID;
322
+ const defaultMaxAttempts =
323
+ normalizePositiveInteger(
324
+ options.defaultMaxAttempts ?? 3,
325
+ "defaultMaxAttempts"
326
+ );
327
+ const retryDelay =
328
+ options.retryDelayMs ??
329
+ ((attempt: number) =>
330
+ Math.min(
331
+ 30_000,
332
+ 1_000 *
333
+ 2 ** Math.max(
334
+ 0,
335
+ attempt - 1
336
+ )
337
+ ));
338
+
339
+ const queue:
340
+ BackgroundJobQueue = {
341
+ adapter,
342
+
343
+ register(name, handler) {
344
+ const normalizedName =
345
+ normalizeJobName(name);
346
+
347
+ if (
348
+ typeof handler !==
349
+ "function"
350
+ ) {
351
+ throw new TypeError(
352
+ "BCP Jobs: handler must be a function."
353
+ );
354
+ }
355
+
356
+ if (
357
+ handlers.has(
358
+ normalizedName
359
+ )
360
+ ) {
361
+ throw new Error(
362
+ `BCP Jobs: handler "${normalizedName}" is already registered.`
363
+ );
364
+ }
365
+
366
+ const registeredHandler =
367
+ handler as JobHandler<any>;
368
+
369
+ handlers.set(
370
+ normalizedName,
371
+ registeredHandler
372
+ );
373
+
374
+ return () => {
375
+ if (
376
+ handlers.get(
377
+ normalizedName
378
+ ) === registeredHandler
379
+ ) {
380
+ handlers.delete(
381
+ normalizedName
382
+ );
383
+ }
384
+ };
385
+ },
386
+
387
+ async enqueue(
388
+ name,
389
+ payload,
390
+ enqueueOptions = {}
391
+ ) {
392
+ const createdAt =
393
+ now();
394
+ const delayMs =
395
+ normalizeNonNegativeNumber(
396
+ enqueueOptions.delayMs ?? 0,
397
+ "delayMs"
398
+ );
399
+ const maxAttempts =
400
+ normalizePositiveInteger(
401
+ enqueueOptions.maxAttempts ??
402
+ defaultMaxAttempts,
403
+ "maxAttempts"
404
+ );
405
+ const id =
406
+ normalizeJobId(
407
+ enqueueOptions.id ??
408
+ idFactory()
409
+ );
410
+ const job:
411
+ JobRecord<typeof payload> = {
412
+ id,
413
+ name:
414
+ normalizeJobName(
415
+ name
416
+ ),
417
+ payload,
418
+ state: "queued",
419
+ attempts: 0,
420
+ maxAttempts,
421
+ createdAt,
422
+ availableAt:
423
+ createdAt +
424
+ delayMs,
425
+ };
426
+
427
+ await adapter.enqueue(
428
+ job
429
+ );
430
+
431
+ return cloneJob(job);
432
+ },
433
+
434
+ get(id) {
435
+ return adapter.get(
436
+ normalizeJobId(id)
437
+ );
438
+ },
439
+
440
+ list() {
441
+ return adapter.list();
442
+ },
443
+
444
+ cancel(id) {
445
+ return adapter.cancel(
446
+ normalizeJobId(id),
447
+ now()
448
+ );
449
+ },
450
+
451
+ async processNext(
452
+ signal =
453
+ new AbortController()
454
+ .signal
455
+ ) {
456
+ if (signal.aborted) {
457
+ return false;
458
+ }
459
+
460
+ const job =
461
+ await adapter.reserve(
462
+ now()
463
+ );
464
+
465
+ if (!job) {
466
+ return false;
467
+ }
468
+
469
+ const handler =
470
+ handlers.get(
471
+ job.name
472
+ );
473
+
474
+ if (!handler) {
475
+ await adapter.fail(
476
+ job.id,
477
+ {
478
+ error:
479
+ `No handler registered for job "${job.name}".`,
480
+ failedAt:
481
+ now(),
482
+ }
483
+ );
484
+ return true;
485
+ }
486
+
487
+ try {
488
+ await handler({
489
+ job:
490
+ cloneJob(job),
491
+ payload:
492
+ job.payload,
493
+ signal,
494
+ });
495
+
496
+ await adapter.complete(
497
+ job.id,
498
+ now()
499
+ );
500
+ } catch (error) {
501
+ const failedAt =
502
+ now();
503
+ const shouldRetry =
504
+ job.attempts <
505
+ job.maxAttempts;
506
+ const retryAt =
507
+ shouldRetry
508
+ ? failedAt +
509
+ resolveRetryDelay(
510
+ retryDelay,
511
+ job.attempts
512
+ )
513
+ : undefined;
514
+
515
+ await adapter.fail(
516
+ job.id,
517
+ {
518
+ error:
519
+ formatJobError(
520
+ error
521
+ ),
522
+ failedAt,
523
+ retryAt,
524
+ }
525
+ );
526
+ }
527
+
528
+ return true;
529
+ },
530
+
531
+ startWorker(
532
+ workerOptions = {}
533
+ ) {
534
+ const worker =
535
+ createWorker(
536
+ queue,
537
+ workerOptions,
538
+ () =>
539
+ workers.delete(
540
+ worker
541
+ )
542
+ );
543
+
544
+ workers.add(worker);
545
+ return worker;
546
+ },
547
+
548
+ async close() {
549
+ await Promise.all(
550
+ Array.from(
551
+ workers,
552
+ worker =>
553
+ worker.stop()
554
+ )
555
+ );
556
+
557
+ if (adapter.close) {
558
+ await adapter.close();
559
+ }
560
+ },
561
+ };
562
+
563
+ return queue;
564
+ }
565
+
566
+ interface InternalJobWorker
567
+ extends JobWorker {}
568
+
569
+ function createWorker(
570
+ queue: BackgroundJobQueue,
571
+ options: StartJobWorkerOptions,
572
+ onStop: () => void
573
+ ): InternalJobWorker {
574
+ const concurrency =
575
+ normalizePositiveInteger(
576
+ options.concurrency ?? 1,
577
+ "concurrency"
578
+ );
579
+ const pollIntervalMs =
580
+ normalizeNonNegativeNumber(
581
+ options.pollIntervalMs ?? 250,
582
+ "pollIntervalMs"
583
+ );
584
+ const controller =
585
+ new AbortController();
586
+ let running =
587
+ true;
588
+ let stopPromise:
589
+ Promise<void> | null =
590
+ null;
591
+
592
+ const loops =
593
+ Array.from(
594
+ {
595
+ length:
596
+ concurrency,
597
+ },
598
+ () =>
599
+ runWorkerLoop(
600
+ queue,
601
+ controller.signal,
602
+ pollIntervalMs
603
+ )
604
+ );
605
+
606
+ return {
607
+ get running() {
608
+ return running;
609
+ },
610
+
611
+ stop() {
612
+ if (stopPromise) {
613
+ return stopPromise;
614
+ }
615
+
616
+ running =
617
+ false;
618
+ controller.abort();
619
+ stopPromise =
620
+ Promise.allSettled(
621
+ loops
622
+ )
623
+ .then(
624
+ () => {
625
+ onStop();
626
+ }
627
+ );
628
+
629
+ return stopPromise;
630
+ },
631
+ };
632
+ }
633
+
634
+ async function runWorkerLoop(
635
+ queue: BackgroundJobQueue,
636
+ signal: AbortSignal,
637
+ pollIntervalMs: number
638
+ ): Promise<void> {
639
+ while (!signal.aborted) {
640
+ const processed =
641
+ await queue.processNext(
642
+ signal
643
+ );
644
+
645
+ if (
646
+ !processed &&
647
+ !signal.aborted
648
+ ) {
649
+ await sleep(
650
+ pollIntervalMs,
651
+ signal
652
+ );
653
+ }
654
+ }
655
+ }
656
+
657
+ function sleep(
658
+ durationMs: number,
659
+ signal: AbortSignal
660
+ ): Promise<void> {
661
+ if (
662
+ durationMs === 0 ||
663
+ signal.aborted
664
+ ) {
665
+ return Promise.resolve();
666
+ }
667
+
668
+ return new Promise(
669
+ resolve => {
670
+ const timeout =
671
+ setTimeout(
672
+ finish,
673
+ durationMs
674
+ );
675
+
676
+ const onAbort =
677
+ () =>
678
+ finish();
679
+
680
+ signal.addEventListener(
681
+ "abort",
682
+ onAbort,
683
+ {
684
+ once: true,
685
+ }
686
+ );
687
+
688
+ function finish() {
689
+ clearTimeout(timeout);
690
+ signal.removeEventListener(
691
+ "abort",
692
+ onAbort
693
+ );
694
+ resolve();
695
+ }
696
+ }
697
+ );
698
+ }
699
+
700
+ function resolveRetryDelay(
701
+ value: JobRetryDelay,
702
+ attempt: number
703
+ ): number {
704
+ const delay =
705
+ typeof value === "function"
706
+ ? value(attempt)
707
+ : value;
708
+
709
+ return normalizeNonNegativeNumber(
710
+ delay,
711
+ "retryDelayMs"
712
+ );
713
+ }
714
+
715
+ function normalizeJobName(
716
+ value: string
717
+ ): string {
718
+ const name =
719
+ String(value)
720
+ .trim();
721
+
722
+ if (!name) {
723
+ throw new TypeError(
724
+ "BCP Jobs: job name must be a non-empty string."
725
+ );
726
+ }
727
+
728
+ if (name.length > 200) {
729
+ throw new TypeError(
730
+ "BCP Jobs: job name must not exceed 200 characters."
731
+ );
732
+ }
733
+
734
+ return name;
735
+ }
736
+
737
+ function normalizeJobId(
738
+ value: string
739
+ ): string {
740
+ const id =
741
+ String(value)
742
+ .trim();
743
+
744
+ if (!id) {
745
+ throw new TypeError(
746
+ "BCP Jobs: job id must be a non-empty string."
747
+ );
748
+ }
749
+
750
+ if (id.length > 200) {
751
+ throw new TypeError(
752
+ "BCP Jobs: job id must not exceed 200 characters."
753
+ );
754
+ }
755
+
756
+ return id;
757
+ }
758
+
759
+ function normalizePositiveInteger(
760
+ value: number,
761
+ field: string
762
+ ): number {
763
+ if (
764
+ !Number.isInteger(value) ||
765
+ value <= 0
766
+ ) {
767
+ throw new TypeError(
768
+ `BCP Jobs: ${field} must be a positive integer.`
769
+ );
770
+ }
771
+
772
+ return value;
773
+ }
774
+
775
+ function normalizeNonNegativeNumber(
776
+ value: number,
777
+ field: string
778
+ ): number {
779
+ if (
780
+ !Number.isFinite(value) ||
781
+ value < 0
782
+ ) {
783
+ throw new TypeError(
784
+ `BCP Jobs: ${field} must be a non-negative finite number.`
785
+ );
786
+ }
787
+
788
+ return Math.floor(value);
789
+ }
790
+
791
+ function formatJobError(
792
+ error: unknown
793
+ ): string {
794
+ if (error instanceof Error) {
795
+ return error.message ||
796
+ error.name;
797
+ }
798
+
799
+ if (typeof error === "string") {
800
+ return error;
801
+ }
802
+
803
+ try {
804
+ const serialized =
805
+ JSON.stringify(error);
806
+
807
+ return serialized ??
808
+ String(error);
809
+ } catch {
810
+ return String(error);
811
+ }
812
+ }
813
+
814
+ function cloneJob<
815
+ TPayload
816
+ >(
817
+ job: JobRecord<TPayload>
818
+ ): JobRecord<TPayload> {
819
+ return {
820
+ ...job,
821
+ };
822
+ }