@bitclaw/jobs 1.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Chavez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # @bitclaw/jobs
2
+
3
+ SQLite-backed background job queue for Bun. Features priority ordering, retries with backoff, cron scheduling, job dependencies, batch processing, rate limiting, and a dead-letter table.
4
+
5
+ ## Features
6
+
7
+ - **Typed** Generic `JobQueue<TMap>` with per-type payload validation
8
+ - **Priority** Jobs ordered by priority DESC then created_at ASC
9
+ - **Retries** Configurable `maxRetries` with automatic dead-letter after exhaustion
10
+ - **Dependencies** Blocked jobs auto-unblock when all dependencies complete
11
+ - **Batches** Group jobs, track progress, fire `then`/`finally` callbacks on completion
12
+ - **Cron** 5-field cron parser with `nextCronOccurrence` and overlap control
13
+ - **Scheduler** Persistent `schedules` table with upsert semantics and cleanup
14
+ - **Rate Limiter** In-memory sliding window per-worker throttling
15
+ - **Worker** `setTimeout`-based poll loop with graceful shutdown and per-job timeout
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ bun add @bitclaw/jobs
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ```typescript
26
+ import { JobQueue } from '@bitclaw/jobs'
27
+
28
+ type AppJobs = {
29
+ 'email:send': { to: string; subject: string }
30
+ }
31
+
32
+ const queue = new JobQueue<AppJobs>('./jobs.db')
33
+ queue.add('email:send', { to: 'user@test.com', subject: 'Hello' })
34
+ ```
35
+
36
+ ## Worker
37
+
38
+ ```typescript
39
+ const worker = queue.createWorker({
40
+ type: 'email:send',
41
+ handler: async (job, ctx) => {
42
+ ctx.reportProgress(50)
43
+ await sendEmail(job.data.to, job.data.subject)
44
+ },
45
+ pollIntervalMs: 1000,
46
+ maxRate: { count: 10, windowMs: 1000 }
47
+ })
48
+
49
+ worker.start()
50
+ // ... later
51
+ await worker.stop()
52
+ ```
53
+
54
+ ## Cron Scheduler
55
+
56
+ ```typescript
57
+ import { Scheduler } from '@bitclaw/jobs'
58
+
59
+ const scheduler = new Scheduler(queue)
60
+ scheduler.register('daily-report', 'report:generate', '0 2 * * *', {
61
+ data: { type: 'daily' }
62
+ })
63
+ scheduler.start() // ticks every 60s by default
64
+ ```
65
+
66
+ ## Subpath Exports
67
+
68
+ ```typescript
69
+ import { JobQueue } from '@bitclaw/jobs'
70
+ import { JobWorker } from '@bitclaw/jobs/worker'
71
+ import { JobQueue } from '@bitclaw/jobs/queue'
72
+ import { Scheduler } from '@bitclaw/jobs/scheduler'
73
+ import { parseCron } from '@bitclaw/jobs/cron'
74
+ import { initializeSchema } from '@bitclaw/jobs/schema'
75
+ import { SlidingWindowRateLimiter } from '@bitclaw/jobs/rate-limiter'
76
+ ```
77
+
78
+ ## Testing
79
+
80
+ ```bash
81
+ bun test
82
+ ```
83
+
84
+ 118 tests across 7 files.
package/dist/cron.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type ParsedCron = {
2
+ minutes: Set<number>;
3
+ hours: Set<number>;
4
+ daysOfMonth: Set<number>;
5
+ months: Set<number>;
6
+ daysOfWeek: Set<number>;
7
+ };
8
+ export declare function parseCron(expression: string): ParsedCron;
9
+ export declare function cronMatches(parsed: ParsedCron, date: Date): boolean;
10
+ export declare function nextCronOccurrence(parsed: ParsedCron, after: Date): Date;
11
+ //# sourceMappingURL=cron.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cron.d.ts","sourceRoot":"","sources":["../src/cron.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,UAAU,GAAG;IACvB,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACrB,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACnB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACzB,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpB,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB,CAAC;AAqDF,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,UAAU,CAmBxD;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,IAAI,GAAG,OAAO,CAQnE;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,IAAI,GAAG,IAAI,CAiBxE"}
package/dist/cron.js ADDED
@@ -0,0 +1,86 @@
1
+ // packages/jobs/src/cron.ts
2
+ // Minimal 5-field cron parser (minute, hour, day-of-month, month, day-of-week)
3
+ const FIELD_RANGES = [
4
+ [0, 59], // minute
5
+ [0, 23], // hour
6
+ [1, 31], // day of month
7
+ [1, 12], // month
8
+ [0, 6] // day of week (0=Sun)
9
+ ];
10
+ function parseField(field, min, max) {
11
+ const values = new Set();
12
+ for (const part of field.split(',')) {
13
+ if (part === '*') {
14
+ for (let i = min; i <= max; i++)
15
+ values.add(i);
16
+ continue;
17
+ }
18
+ // */N step from min
19
+ const fullStepMatch = part.match(/^\*\/(\d+)$/);
20
+ if (fullStepMatch) {
21
+ const step = Number(fullStepMatch[1]);
22
+ if (step === 0)
23
+ throw new Error(`Invalid step value: ${part}`);
24
+ for (let i = min; i <= max; i += step)
25
+ values.add(i);
26
+ continue;
27
+ }
28
+ // N-M or N-M/S range with optional step
29
+ const rangeMatch = part.match(/^(\d+)-(\d+)(?:\/(\d+))?$/);
30
+ if (rangeMatch) {
31
+ const start = Number(rangeMatch[1]);
32
+ const end = Number(rangeMatch[2]);
33
+ const step = rangeMatch[3] ? Number(rangeMatch[3]) : 1;
34
+ if (start < min || end > max || start > end) {
35
+ throw new Error(`Invalid range: ${part} (valid: ${min}-${max})`);
36
+ }
37
+ if (step === 0)
38
+ throw new Error(`Invalid step value: ${part}`);
39
+ for (let i = start; i <= end; i += step)
40
+ values.add(i);
41
+ continue;
42
+ }
43
+ // Plain number
44
+ const num = Number(part);
45
+ if (Number.isNaN(num) || num < min || num > max) {
46
+ throw new Error(`Invalid value: ${part} (valid: ${min}-${max})`);
47
+ }
48
+ values.add(num);
49
+ }
50
+ return values;
51
+ }
52
+ export function parseCron(expression) {
53
+ const fields = expression.trim().split(/\s+/);
54
+ if (fields.length !== 5) {
55
+ throw new Error(`Invalid cron expression: expected 5 fields, got ${fields.length}`);
56
+ }
57
+ return {
58
+ minutes: parseField(fields[0], FIELD_RANGES[0][0], FIELD_RANGES[0][1]),
59
+ hours: parseField(fields[1], FIELD_RANGES[1][0], FIELD_RANGES[1][1]),
60
+ daysOfMonth: parseField(fields[2], FIELD_RANGES[2][0], FIELD_RANGES[2][1]),
61
+ months: parseField(fields[3], FIELD_RANGES[3][0], FIELD_RANGES[3][1]),
62
+ daysOfWeek: parseField(fields[4], FIELD_RANGES[4][0], FIELD_RANGES[4][1])
63
+ };
64
+ }
65
+ export function cronMatches(parsed, date) {
66
+ return (parsed.minutes.has(date.getUTCMinutes()) &&
67
+ parsed.hours.has(date.getUTCHours()) &&
68
+ parsed.daysOfMonth.has(date.getUTCDate()) &&
69
+ parsed.months.has(date.getUTCMonth() + 1) &&
70
+ parsed.daysOfWeek.has(date.getUTCDay()));
71
+ }
72
+ export function nextCronOccurrence(parsed, after) {
73
+ // Start from the next minute
74
+ const candidate = new Date(after.getTime());
75
+ candidate.setUTCSeconds(0, 0);
76
+ candidate.setUTCMinutes(candidate.getUTCMinutes() + 1);
77
+ // Cap at 2 years to avoid infinite loops
78
+ const limit = after.getTime() + 2 * 365 * 24 * 60 * 60 * 1000;
79
+ while (candidate.getTime() <= limit) {
80
+ if (cronMatches(parsed, candidate)) {
81
+ return candidate;
82
+ }
83
+ candidate.setUTCMinutes(candidate.getUTCMinutes() + 1);
84
+ }
85
+ throw new Error('No matching cron occurrence found within 2 years');
86
+ }
@@ -0,0 +1,10 @@
1
+ export type { ParsedCron } from './cron';
2
+ export { cronMatches, nextCronOccurrence, parseCron } from './cron';
3
+ export { JobQueue } from './queue';
4
+ export { SlidingWindowRateLimiter } from './rate-limiter';
5
+ export { Scheduler } from './scheduler';
6
+ export { applyPragmas, initializeSchema } from './schema';
7
+ export type { AddJobOptions, AddScheduleOptions, BatchOptions, FailedJob, Job, JobBatch, JobContext, JobMap, JobStats, JobStatus, ListJobsOptions, PaginatedResult, PurgeOptions, RateLimit, Schedule, WorkerOptions } from './types';
8
+ export { NonRetryableError } from './types';
9
+ export { JobWorker } from './worker';
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,YAAY,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AACpE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,wBAAwB,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAC1D,YAAY,EACV,aAAa,EACb,kBAAkB,EAClB,YAAY,EACZ,SAAS,EACT,GAAG,EACH,QAAQ,EACR,UAAU,EACV,MAAM,EACN,QAAQ,EACR,SAAS,EACT,eAAe,EACf,eAAe,EACf,YAAY,EACZ,SAAS,EACT,QAAQ,EACR,aAAa,EACd,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAC5C,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // packages/jobs/src/index.ts
2
+ // Barrel export for @bitclaw/jobs
3
+ export { cronMatches, nextCronOccurrence, parseCron } from './cron';
4
+ export { JobQueue } from './queue';
5
+ export { SlidingWindowRateLimiter } from './rate-limiter';
6
+ export { Scheduler } from './scheduler';
7
+ export { applyPragmas, initializeSchema } from './schema';
8
+ export { NonRetryableError } from './types';
9
+ export { JobWorker } from './worker';
@@ -0,0 +1,63 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import type { AddJobOptions, BatchOptions, FailedJob, Job, JobBatch, JobMap, JobStats, ListJobsOptions, PaginatedResult, PurgeOptions, WorkerOptions } from './types';
3
+ import { JobWorker } from './worker';
4
+ export declare class JobQueue<TMap extends JobMap = Record<string, unknown>> {
5
+ readonly db: Database;
6
+ private readonly insertJobStmt;
7
+ private readonly insertDepStmt;
8
+ private readonly selectJobStmt;
9
+ private readonly selectPendingStmt;
10
+ private readonly markProcessingStmt;
11
+ private readonly markDoneStmt;
12
+ private readonly markFailedStmt;
13
+ private readonly updateProgressStmt;
14
+ private readonly selectStatsStmt;
15
+ private readonly countFailedStmt;
16
+ private readonly insertFailedJobStmt;
17
+ private readonly deleteJobStmt;
18
+ private readonly selectDependentsStmt;
19
+ private readonly countUnmetDepsStmt;
20
+ private readonly unblockJobStmt;
21
+ private readonly lastInsertRowIdStmt;
22
+ private readonly insertBatchStmt;
23
+ private readonly selectBatchStmt;
24
+ private readonly decrementBatchPendingStmt;
25
+ private readonly incrementBatchFailedStmt;
26
+ private readonly finishBatchStmt;
27
+ private readonly cancelBatchStmt;
28
+ private readonly cancelBatchJobsStmt;
29
+ constructor(dbPath: string);
30
+ add<K extends string & keyof TMap>(type: K, data: TMap[K], options?: AddJobOptions): number;
31
+ getJob(id: number): Job | null;
32
+ getStats(): JobStats;
33
+ getFailedJobs(options?: {
34
+ type?: string;
35
+ limit?: number;
36
+ offset?: number;
37
+ }): PaginatedResult<FailedJob>;
38
+ listJobs(options?: ListJobsOptions): PaginatedResult<Job>;
39
+ cancelJob(id: number): boolean;
40
+ forceRetryJob(id: number): boolean;
41
+ setJobHttpLog(id: number, requestLog: string, responseLog: string): void;
42
+ getJobTypes(): string[];
43
+ retryFailedJob(failedJobId: number): number;
44
+ purgeFailedJobs(olderThanMs: number): number;
45
+ purge(options: PurgeOptions): number;
46
+ pollAndClaim(type: string): Job | null;
47
+ markJobDone(id: number): void;
48
+ markJobDead(id: number, error: string): void;
49
+ markJobFailed(id: number, error: string): void;
50
+ updateProgress(id: number, progress: number): void;
51
+ createBatch(name: string, options?: BatchOptions): string;
52
+ addToBatch<K extends string & keyof TMap>(batchId: string, type: K, data: TMap[K], options?: AddJobOptions): number;
53
+ getBatch(batchId: string): JobBatch | null;
54
+ cancelBatch(batchId: string): void;
55
+ createWorker<K extends string & keyof TMap>(options: WorkerOptions<TMap[K]> & {
56
+ type: K;
57
+ }): JobWorker<TMap, K>;
58
+ private insertJob;
59
+ close(): void;
60
+ private unblockDependents;
61
+ private handleBatchJobComplete;
62
+ }
63
+ //# sourceMappingURL=queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue.d.ts","sourceRoot":"","sources":["../src/queue.ts"],"names":[],"mappings":"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"}