feat: add reusable jobqueue library

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-16 00:51:54 +02:00
commit 34ca0fe17d
30 changed files with 6405 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
name: publish
on:
push:
tags:
- 'v*'
jobs:
test-and-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://git.sal.giize.com/api/packages/mozempk/npm/
- name: Install deps
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: Build
run: npm run build
- name: Publish
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITEA_NPM_TOKEN }}

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
*.db
*.db-wal
*.db-shm
.env
coverage/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Giancarmine Salucci
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

253
README.md Normal file
View File

@@ -0,0 +1,253 @@
# jobqueue
Framework-agnostic async job queue for Node.js with:
- **SQLite persistence** via `better-sqlite3` + WAL mode
- **Multi-phase pipelines** with per-phase progress and phase result passing
- **Concurrency control** with a worker pool
- **Automatic retry** with fixed, linear, or exponential backoff
- **SSE helpers** for progress/completion streaming
- **Outbound webhooks** with optional HMAC-SHA256 signing
- **Retention cleanup** for stale and expired jobs
- **ESM-only**, **Node 20+**
## Install
```bash
npm install jobqueue --registry=https://git.sal.giize.com/api/packages/mozempk/npm/
```
To make installs/publishes use Gitea by default:
```ini
registry=https://git.sal.giize.com/api/packages/mozempk/npm/
//git.sal.giize.com/api/packages/mozempk/npm/:_authToken=${NODE_AUTH_TOKEN}
```
## Quick start
```ts
import { JobQueue } from 'jobqueue';
const queue = new JobQueue<{ url: string }>({
dbPath: './data/jobs.db',
phases: ['download', 'process', 'upload'],
concurrency: 2,
retry: {
maxAttempts: 3,
strategy: 'exponential',
baseDelayMs: 1_000,
maxDelayMs: 30_000,
classifyError: async (error) => {
if (error instanceof Error && error.message.includes('ECONNREFUSED')) {
return 'recoverable';
}
return 'fatal';
},
},
webhook: {
url: 'https://example.com/hooks/jobs',
events: ['job:completed', 'job:failed'],
secret: process.env.JOBQUEUE_WEBHOOK_SECRET,
},
retention: {
staleAfterMs: 7 * 24 * 60 * 60 * 1000,
deleteAfterMs: 30 * 24 * 60 * 60 * 1000,
onDelete: async (job) => {
console.log(`Deleting ${job.id}`);
},
},
});
queue.handle('download', async (job, ctx) => {
await ctx.progress(10, 'Downloading source');
return { filePath: `/tmp/${job.id}.mp4` };
});
queue.handle('process', async (_job, ctx) => {
const download = ctx.phaseResult<{ filePath: string }>('download');
await ctx.progress(50, 'Processing file');
return { outputPath: `${download?.filePath}.json` };
});
queue.handle('upload', async (_job, ctx) => {
const processed = ctx.phaseResult<{ outputPath: string }>('process');
await ctx.progress(100, 'Uploading result');
return { uploaded: Boolean(processed?.outputPath) };
});
queue.on('job:completed', (job) => {
console.log('completed', job.id, job.phaseResults);
});
const jobId = await queue.enqueue({
url: 'https://example.com/video',
});
```
## API
### `new JobQueue(config)`
```ts
interface QueueConfig<TData> {
dbPath: string;
phases: readonly string[];
concurrency?: number;
retry?: RetryConfig<TData>;
retention?: RetentionConfig<TData>;
webhook?: WebhookConfig;
shutdownTimeoutMs?: number;
}
```
### `queue.handle(phaseName, handler)`
Register one handler per phase.
```ts
type PhaseHandler<TData> = (
job: JobRecord<TData>,
context: PhaseContext<TData>,
) => Promise<JsonValue | undefined> | JsonValue | undefined;
```
### `queue.enqueue(data, options?)`
Creates a new job and returns its ID.
```ts
await queue.enqueue(
{ url: 'https://example.com/video' },
{
scheduledAt: new Date(Date.now() + 5_000),
maxAttempts: 5,
webhookUrl: 'https://example.com/job-specific-hook',
},
);
```
### `queue.getJob(id)` / `queue.listJobs(options)`
Inspect persisted jobs.
### `queue.retry(id, options?)`
Manual retry. By default it restarts from the first phase.
```ts
await queue.retry(jobId, { fromStart: true });
```
### `queue.cancel(id)`
Cooperative cancellation. Active handlers receive an `AbortSignal` at `ctx.signal`.
### `queue.createEventStream(options?)`
Returns a web `ReadableStream<Uint8Array>` ready to plug into Express, Hono, SvelteKit, Fastify, or plain Node responses.
```ts
const stream = queue.createEventStream({
jobId,
includeSnapshot: true,
});
```
Use headers from `SseSerializer.headers()`.
### `queue.shutdown()`
Stops retention timers, waits for active jobs, then closes SQLite cleanly.
## Retry behavior
`maxAttempts` includes the first attempt.
- `maxAttempts: 1` → never auto-retry
- `maxAttempts: 3` → first run + 2 retries
Error classification decides whether a failure is retryable:
```ts
retry: {
maxAttempts: 3,
classifyError: async (error) => {
if (error instanceof Error && /timeout|econnrefused/i.test(error.message)) {
return 'recoverable';
}
return 'fatal';
},
}
```
## Webhooks
Supported outbound events:
- `job:completed`
- `job:failed`
- `job:retrying`
- `job:cancelled`
- `job:stale`
Payload shape:
```json
{
"event": "job:completed",
"emittedAt": "2026-05-15T22:00:00.000Z",
"job": {
"id": "..."
}
}
```
If `secret` is configured, jobqueue sends:
```text
X-JobQueue-Event: job:completed
X-JobQueue-Signature: <hex hmac sha256>
```
## SSE events
Stream payloads are emitted with these event names:
- `snapshot`
- `job:enqueued`
- `job:started`
- `job:progress`
- `job:phase:completed`
- `job:completed`
- `job:failed`
- `job:retrying`
- `job:cancelled`
- `job:stale`
- `job:deleted`
- `job:webhook:delivered`
- `job:webhook:failed`
- `ping`
## Development
```bash
npm install
npm run lint
npm test
npm run build
```
## Publish from Gitea Actions
Create repo secret:
- `GITEA_NPM_TOKEN` → token with package publish permissions
Publishing flow:
1. Push code to `mozempk/jobqueue`
2. Create tag: `git tag v0.1.0 && git push origin v0.1.0`
3. Gitea Actions runs tests, build, and `npm publish`

3394
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

62
package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "jobqueue",
"version": "0.1.0",
"description": "Framework-agnostic async job queue with multi-phase pipelines, SSE helpers, webhooks, retry strategies, and SQLite persistence.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "ssh://git@git.sal.giize.com:2222/mozempk/jobqueue.git"
},
"homepage": "https://git.sal.giize.com/mozempk/jobqueue",
"publishConfig": {
"registry": "https://git.sal.giize.com/api/packages/mozempk/npm/"
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "tsc --noEmit",
"prepublishOnly": "npm run lint && npm run test && npm run build"
},
"keywords": [
"job-queue",
"async",
"pipeline",
"worker",
"sse",
"webhook",
"sqlite",
"retry",
"concurrency"
],
"author": "Giancarmine Salucci <mozempk@gmail.com>",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"better-sqlite3": "^12.2.0",
"p-limit": "^6.2.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^3.1.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.0",
"tsup": "^8.4.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
}
}

721
src/JobQueue.ts Normal file
View File

@@ -0,0 +1,721 @@
import { CancellationError } from './util/errors.js';
import { TypedEventBus } from './events/EventBus.js';
import { SseSerializer } from './events/SseSerializer.js';
import { WorkerPool } from './processor/WorkerPool.js';
import { PhaseRunner } from './processor/PhaseRunner.js';
import { RetryStrategy } from './retry/RetryStrategy.js';
import { SqliteStorage } from './storage/SqliteStorage.js';
import { RetentionScheduler } from './retention/RetentionScheduler.js';
import { WebhookDispatcher } from './webhook/WebhookDispatcher.js';
import { createJobId } from './util/id.js';
import type {
EnqueueOptions,
JobData,
JobFailure,
JobPhaseState,
JobProgressEvent,
JobQueueEvents,
JobRecord,
ListJobsOptions,
PhaseHandler,
QueueConfig,
QueueStreamEvent,
RetryOptions,
StreamOptions,
WebhookDispatchError,
WebhookEventName,
} from './types.js';
const DEFAULT_CONCURRENCY = 1;
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
function now(): string {
return new Date().toISOString();
}
function createInitialPhases(phaseNames: readonly string[]): JobPhaseState[] {
return phaseNames.map((name) => ({
name,
status: 'pending',
progress: 0,
message: null,
startedAt: null,
completedAt: null,
error: null,
}));
}
function clonePhases(phases: JobPhaseState[]): JobPhaseState[] {
return phases.map((phase) => ({ ...phase }));
}
function normalizeQueueConfig<TData extends JobData>(config: QueueConfig<TData>): QueueConfig<TData> {
const phases = [...new Set(config.phases)];
if (phases.length === 0) {
throw new Error('QueueConfig.phases must contain at least one phase');
}
return {
...config,
phases,
concurrency: config.concurrency ?? DEFAULT_CONCURRENCY,
shutdownTimeoutMs: config.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,
};
}
export class JobQueue<TData extends JobData = JobData> {
private readonly config: QueueConfig<TData>;
private readonly storage: SqliteStorage<TData>;
private readonly events = new TypedEventBus<JobQueueEvents<TData>>();
private readonly retryStrategy: RetryStrategy<TData>;
private readonly workerPool: WorkerPool;
private readonly handlers = new Map<string, PhaseHandler<TData>>();
private readonly serializer = new SseSerializer();
private readonly controllers = new Map<string, AbortController>();
private readonly webhookDispatcher?: WebhookDispatcher<TData>;
private readonly retentionScheduler?: RetentionScheduler<TData>;
private wakeupTimer: NodeJS.Timeout | null = null;
private closed = false;
private pumping = false;
private repumpRequested = false;
public constructor(queueConfig: QueueConfig<TData>) {
this.config = normalizeQueueConfig(queueConfig);
this.storage = new SqliteStorage<TData>(this.config.dbPath);
this.retryStrategy = new RetryStrategy(this.config.retry);
this.workerPool = new WorkerPool(this.config.concurrency ?? DEFAULT_CONCURRENCY);
this.webhookDispatcher = this.config.webhook
? new WebhookDispatcher<TData>(this.config.webhook)
: undefined;
this.storage.resetActiveJobs('Interrupted by process restart');
if (this.config.retention) {
this.retentionScheduler = new RetentionScheduler(this.config.retention, {
markStale: async (cutoffIso) => this.markStaleJobs(cutoffIso),
deleteStale: async (cutoffIso) => this.deleteStaleJobs(cutoffIso),
});
this.retentionScheduler.start();
}
this.requestPump();
}
public handle(phaseName: string, handler: PhaseHandler<TData>): this {
if (!this.config.phases.includes(phaseName)) {
throw new Error(`Phase "${phaseName}" is not defined in queue config`);
}
this.handlers.set(phaseName, handler);
return this;
}
public on<TName extends keyof JobQueueEvents<TData> & string>(
event: TName,
listener: (...args: JobQueueEvents<TData>[TName]) => void,
): () => void {
return this.events.on(event, listener);
}
public once<TName extends keyof JobQueueEvents<TData> & string>(
event: TName,
listener: (...args: JobQueueEvents<TData>[TName]) => void,
): () => void {
return this.events.once(event, listener);
}
public async enqueue(data: TData, options: EnqueueOptions = {}): Promise<string> {
this.ensureOpen();
const id = options.id ?? createJobId();
const job = this.storage.createJob(
id,
data,
createInitialPhases(this.config.phases),
options,
options.maxAttempts ?? this.retryStrategy.defaultMaxAttempts(),
);
this.events.emit('job:enqueued', job);
this.requestPump();
return id;
}
public getJob(id: string): JobRecord<TData> | null {
return this.storage.getJob(id);
}
public listJobs(options: ListJobsOptions = {}): JobRecord<TData>[] {
return this.storage.listJobs(options);
}
public async retry(id: string, options: RetryOptions = {}): Promise<JobRecord<TData>> {
this.ensureOpen();
const existing = this.requireJob(id);
if (!['failed', 'cancelled', 'stale'].includes(existing.status)) {
throw new Error(`Job ${id} cannot be retried from status ${existing.status}`);
}
const phases =
options.fromStart === false
? clonePhases(existing.phases).map<JobPhaseState>((phase) =>
phase.status === 'completed'
? phase
: {
...phase,
status: 'pending',
progress: 0,
message: null,
completedAt: null,
error: null,
},
)
: createInitialPhases(this.config.phases);
const retried = this.storage.resetForRetry(
id,
phases,
existing.maxAttempts,
options.scheduledAt instanceof Date ? options.scheduledAt.toISOString() : options.scheduledAt ?? null,
);
this.events.emit('job:enqueued', retried);
this.requestPump();
return retried;
}
public async cancel(id: string): Promise<JobRecord<TData>> {
const job = this.requireJob(id);
if (!['pending', 'active'].includes(job.status)) {
return job;
}
const phases = clonePhases(job.phases);
const activePhaseIndex = phases.findIndex((phase) => phase.status === 'active');
if (activePhaseIndex >= 0) {
phases[activePhaseIndex] = {
...phases[activePhaseIndex],
status: 'cancelled',
completedAt: now(),
};
}
const controller = this.controllers.get(id);
controller?.abort(new CancellationError());
const cancelled = this.storage.cancelJob(id, phases);
this.events.emit('job:cancelled', cancelled);
void this.dispatchWebhook('job:cancelled', cancelled);
this.requestPump();
return cancelled;
}
public createEventStream(options: StreamOptions = {}): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const keepAliveMs = options.keepAliveMs ?? 30_000;
let cleanup = () => {};
return new ReadableStream<Uint8Array>({
start: (controller) => {
if (options.includeSnapshot !== false) {
if (options.jobId) {
const job = this.getJob(options.jobId);
if (job) {
this.enqueueStreamEvent(controller, encoder, 'snapshot', {
type: 'snapshot',
jobId: job.id,
job,
timestamp: now(),
});
}
} else {
for (const job of this.listJobs({ limit: 100 })) {
this.enqueueStreamEvent(controller, encoder, 'snapshot', {
type: 'snapshot',
jobId: job.id,
job,
timestamp: now(),
});
}
}
}
const unsubscribers = [
this.on('job:enqueued', (job) => this.pushJobEvent(controller, encoder, 'job:enqueued', job, options.jobId)),
this.on('job:started', (job) => this.pushJobEvent(controller, encoder, 'job:started', job, options.jobId)),
this.on('job:progress', (job, progress) => {
if (!this.matchesStreamJob(options.jobId, job.id)) {
return;
}
this.enqueueStreamEvent(controller, encoder, 'job:progress', {
type: 'job:progress',
jobId: job.id,
job,
progress,
timestamp: now(),
});
}),
this.on('job:phase:completed', (job, phase) => {
if (!this.matchesStreamJob(options.jobId, job.id)) {
return;
}
this.enqueueStreamEvent(controller, encoder, 'job:phase:completed', {
type: 'job:phase:completed',
jobId: job.id,
job,
phase,
timestamp: now(),
});
}),
this.on('job:completed', (job) => this.pushJobEvent(controller, encoder, 'job:completed', job, options.jobId)),
this.on('job:failed', (job, failure) => {
if (!this.matchesStreamJob(options.jobId, job.id)) {
return;
}
this.enqueueStreamEvent(controller, encoder, 'job:failed', {
type: 'job:failed',
jobId: job.id,
job,
failure,
timestamp: now(),
});
}),
this.on('job:retrying', (job, retry) => {
if (!this.matchesStreamJob(options.jobId, job.id)) {
return;
}
this.enqueueStreamEvent(controller, encoder, 'job:retrying', {
type: 'job:retrying',
jobId: job.id,
job,
retry,
timestamp: now(),
});
}),
this.on('job:cancelled', (job) => this.pushJobEvent(controller, encoder, 'job:cancelled', job, options.jobId)),
this.on('job:stale', (job) => this.pushJobEvent(controller, encoder, 'job:stale', job, options.jobId)),
this.on('job:deleted', (jobId) => {
if (!this.matchesStreamJob(options.jobId, jobId)) {
return;
}
this.enqueueStreamEvent(controller, encoder, 'job:deleted', {
type: 'job:deleted',
deletedJobId: jobId,
jobId,
timestamp: now(),
});
}),
this.on('job:webhook:delivered', (job, webhook) => {
if (!this.matchesStreamJob(options.jobId, job.id)) {
return;
}
this.enqueueStreamEvent(controller, encoder, 'job:webhook:delivered', {
type: 'job:webhook:delivered',
jobId: job.id,
job,
webhook,
timestamp: now(),
});
}),
this.on('job:webhook:failed', (job, webhook) => {
if (!this.matchesStreamJob(options.jobId, job.id)) {
return;
}
this.enqueueStreamEvent(controller, encoder, 'job:webhook:failed', {
type: 'job:webhook:failed',
jobId: job.id,
job,
webhook,
timestamp: now(),
});
}),
];
const keepAlive = setInterval(() => {
this.enqueueStreamEvent(controller, encoder, 'ping', {
type: 'ping',
timestamp: now(),
});
}, keepAliveMs);
keepAlive.unref?.();
cleanup = () => {
clearInterval(keepAlive);
for (const unsubscribe of unsubscribers) {
unsubscribe();
}
};
},
cancel: () => {
cleanup();
return undefined;
},
});
}
public async shutdown(timeoutMs = this.config.shutdownTimeoutMs): Promise<void> {
if (this.closed) {
return;
}
this.closed = true;
this.retentionScheduler?.stop();
if (this.wakeupTimer) {
clearTimeout(this.wakeupTimer);
this.wakeupTimer = null;
}
await this.workerPool.drain(timeoutMs);
this.events.removeAllListeners();
this.storage.close();
}
private async pump(): Promise<void> {
if (this.closed) {
return;
}
if (this.pumping) {
this.repumpRequested = true;
return;
}
this.pumping = true;
try {
do {
this.repumpRequested = false;
while (this.workerPool.hasCapacity()) {
const candidates = this.storage.listRunnableJobs(this.workerPool.availableSlots);
if (candidates.length === 0) {
break;
}
let dispatched = 0;
for (const candidate of candidates) {
if (!this.workerPool.hasCapacity()) {
break;
}
if (!this.storage.claimPendingJob(candidate.id)) {
continue;
}
const claimed = this.requireJob(candidate.id);
this.events.emit('job:started', claimed);
this.workerPool
.run(async () => this.processJob(claimed.id))
.finally(() => {
this.requestPump();
});
dispatched += 1;
}
if (dispatched === 0) {
break;
}
}
this.scheduleWakeup();
} while (this.repumpRequested);
} finally {
this.pumping = false;
}
}
private requestPump(): void {
if (this.closed) {
return;
}
void this.pump();
}
private scheduleWakeup(): void {
if (this.wakeupTimer) {
clearTimeout(this.wakeupTimer);
this.wakeupTimer = null;
}
const nextScheduledAt = this.storage.getNextScheduledAt();
if (!nextScheduledAt) {
return;
}
const delay = Math.max(new Date(nextScheduledAt).getTime() - Date.now(), 0);
this.wakeupTimer = setTimeout(() => {
this.wakeupTimer = null;
this.requestPump();
}, delay);
this.wakeupTimer.unref?.();
}
private async processJob(jobId: string): Promise<void> {
const job = this.requireJob(jobId);
const controller = new AbortController();
this.controllers.set(jobId, controller);
try {
const runner = new PhaseRunner<TData>({
handlers: this.handlers,
phases: this.config.phases,
onPhaseStarted: async (phaseName, phases) =>
this.storage.saveProgress(jobId, phaseName, phases, this.requireJob(jobId).progress, null),
onProgress: async (phaseName, phases, phaseProgress, overallProgress, message, details) => {
const updated = this.storage.saveProgress(jobId, phaseName, phases, overallProgress, message ?? null);
const progressEvent: JobProgressEvent = {
jobId,
phase: phaseName,
phaseProgress,
overallProgress,
message: message ?? null,
timestamp: now(),
...(details !== undefined ? { details } : {}),
};
this.events.emit('job:progress', updated, progressEvent);
},
onPhaseCompleted: async (phaseName, phases, phaseResults, overallProgress) => {
const updated = this.storage.savePhaseCompletion(
jobId,
phases,
phaseResults,
null,
overallProgress,
null,
);
const completedPhase = updated.phases.find((phase) => phase.name === phaseName);
if (!completedPhase) {
throw new Error(`Completed phase ${phaseName} not found`);
}
this.events.emit('job:phase:completed', updated, completedPhase);
return updated;
},
onCancelled: async (phaseName, phases) => {
const activePhases = clonePhases(phases);
if (phaseName) {
const active = activePhases.find((phase) => phase.name === phaseName);
if (active) {
active.status = 'cancelled';
active.completedAt = now();
}
}
const cancelled = this.storage.cancelJob(jobId, activePhases);
this.events.emit('job:cancelled', cancelled);
void this.dispatchWebhook('job:cancelled', cancelled);
},
});
const result = await runner.run(job, controller.signal);
const latest = this.requireJob(jobId);
if (latest.status === 'cancelled') {
return;
}
const completed = this.storage.completeJob(jobId, result.phases, result.phaseResults);
this.events.emit('job:completed', completed);
void this.dispatchWebhook('job:completed', completed);
} catch (error) {
if (error instanceof CancellationError) {
const current = this.requireJob(jobId);
if (current.status !== 'cancelled') {
const cancelled = this.storage.cancelJob(jobId, clonePhases(current.phases));
this.events.emit('job:cancelled', cancelled);
void this.dispatchWebhook('job:cancelled', cancelled);
}
return;
}
await this.handleFailure(jobId, error);
} finally {
this.controllers.delete(jobId);
}
}
private async handleFailure(jobId: string, error: unknown): Promise<void> {
const current = this.requireJob(jobId);
const recoverability = await this.retryStrategy.shouldRetry(error, current);
const phaseName = current.currentPhase;
const phases = clonePhases(current.phases);
const activePhase = phaseName ? phases.find((phase) => phase.name === phaseName) : undefined;
if (activePhase) {
activePhase.error = error instanceof Error ? error.message : String(error);
}
const failure: JobFailure = {
message: error instanceof Error ? error.message : String(error),
phase: phaseName,
recoverable: recoverability.disposition === 'recoverable',
timestamp: now(),
attempt: current.retryCount + 1,
};
if (recoverability.retry && activePhase) {
activePhase.status = 'pending';
activePhase.progress = 0;
activePhase.message = null;
activePhase.completedAt = null;
const nextRunAt = new Date(Date.now() + recoverability.delayMs).toISOString();
const pending = this.storage.scheduleRetry(
jobId,
phases,
current.progress,
failure,
current.retryCount + 1,
nextRunAt,
);
const retry = {
jobId,
phase: phaseName,
attempt: pending.retryCount,
delayMs: recoverability.delayMs,
nextRunAt,
timestamp: now(),
};
this.events.emit('job:retrying', pending, retry);
void this.dispatchWebhook('job:retrying', pending);
this.requestPump();
return;
}
if (activePhase) {
activePhase.status = 'failed';
activePhase.completedAt = now();
}
const failed = this.storage.failJob(jobId, phases, failure);
this.events.emit('job:failed', failed, failure);
void this.dispatchWebhook('job:failed', failed);
}
private async markStaleJobs(cutoffIso: string): Promise<JobRecord<TData>[]> {
const staleJobs = this.storage.markStaleJobs(cutoffIso);
for (const job of staleJobs) {
if (this.config.retention?.onStale) {
await this.config.retention.onStale(job);
}
this.events.emit('job:stale', job);
void this.dispatchWebhook('job:stale', job);
}
return staleJobs;
}
private async deleteStaleJobs(cutoffIso: string): Promise<JobRecord<TData>[]> {
const deletedJobs = this.storage.deleteStaleJobs(cutoffIso);
for (const job of deletedJobs) {
if (this.config.retention?.onDelete) {
await this.config.retention.onDelete(job);
}
this.events.emit('job:deleted', job.id);
}
return deletedJobs;
}
private async dispatchWebhook(event: WebhookEventName, job: JobRecord<TData>): Promise<void> {
if (!this.webhookDispatcher?.supports(event, job)) {
return;
}
try {
const result = await this.webhookDispatcher.dispatch(event, job);
this.storage.markWebhookSent(job.id);
const refreshed = this.requireJob(job.id);
this.events.emit('job:webhook:delivered', refreshed, result);
} catch (error) {
const dispatchError =
error instanceof Error && 'dispatchError' in error
? ((error as { dispatchError?: WebhookDispatchError }).dispatchError ?? {
event,
jobId: job.id,
message: error.message,
finalAttempt: 1,
})
: {
event,
jobId: job.id,
message: error instanceof Error ? error.message : String(error),
finalAttempt: 1,
};
const refreshed = this.requireJob(job.id);
this.events.emit('job:webhook:failed', refreshed, dispatchError);
}
}
private pushJobEvent(
controller: ReadableStreamDefaultController<Uint8Array>,
encoder: { encode: (input?: string) => Uint8Array },
type: Exclude<QueueStreamEvent['type'], 'snapshot' | 'job:progress' | 'job:phase:completed' | 'job:failed' | 'job:retrying' | 'job:deleted' | 'job:webhook:delivered' | 'job:webhook:failed' | 'ping'>,
job: JobRecord<TData>,
jobIdFilter?: string,
): void {
if (!this.matchesStreamJob(jobIdFilter, job.id)) {
return;
}
this.enqueueStreamEvent(controller, encoder, type, {
type,
jobId: job.id,
job,
timestamp: now(),
});
}
private enqueueStreamEvent(
controller: ReadableStreamDefaultController<Uint8Array>,
encoder: { encode: (input?: string) => Uint8Array },
eventName: string,
payload: QueueStreamEvent<TData>,
): void {
controller.enqueue(encoder.encode(this.serializer.event(eventName, payload)));
}
private matchesStreamJob(jobIdFilter: string | undefined, jobId: string): boolean {
return !jobIdFilter || jobIdFilter === jobId;
}
private requireJob(id: string): JobRecord<TData> {
const job = this.storage.getJob(id);
if (!job) {
throw new Error(`Job ${id} not found`);
}
return job;
}
private ensureOpen(): void {
if (this.closed) {
throw new Error('JobQueue is shut down');
}
}
}

47
src/events/EventBus.ts Normal file
View File

@@ -0,0 +1,47 @@
import { EventEmitter } from 'node:events';
type ExtractArgs<TValue> = TValue extends unknown[] ? TValue : never;
export class TypedEventBus<TEvents extends object> {
private readonly emitter = new EventEmitter();
public on<TName extends keyof TEvents & string>(
event: TName,
listener: (...args: ExtractArgs<TEvents[TName]>) => void,
): () => void {
this.emitter.on(event, listener as (...args: unknown[]) => void);
return () => {
this.off(event, listener);
};
}
public once<TName extends keyof TEvents & string>(
event: TName,
listener: (...args: ExtractArgs<TEvents[TName]>) => void,
): () => void {
const wrapped = (...args: ExtractArgs<TEvents[TName]>) => {
this.off(event, wrapped);
listener(...args);
};
return this.on(event, wrapped);
}
public off<TName extends keyof TEvents & string>(
event: TName,
listener: (...args: ExtractArgs<TEvents[TName]>) => void,
): void {
this.emitter.off(event, listener as (...args: unknown[]) => void);
}
public emit<TName extends keyof TEvents & string>(
event: TName,
...args: ExtractArgs<TEvents[TName]>
): void {
this.emitter.emit(event, ...(args as unknown[]));
}
public removeAllListeners(): void {
this.emitter.removeAllListeners();
}
}

View File

@@ -0,0 +1,29 @@
import type { QueueStreamEvent } from '../types.js';
export class SseSerializer {
public static headers(): Record<string, string> {
return {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
};
}
public event<TEvent extends QueueStreamEvent>(event: string, payload: TEvent, id?: string): string {
const lines: string[] = [];
if (id) {
lines.push(`id: ${id}`);
}
lines.push(`event: ${event}`);
lines.push(`data: ${JSON.stringify(payload)}`);
return `${lines.join('\n')}\n\n`;
}
public comment(comment: string): string {
return `: ${comment}\n\n`;
}
}

38
src/index.ts Normal file
View File

@@ -0,0 +1,38 @@
export { JobQueue } from './JobQueue.js';
export { TypedEventBus } from './events/EventBus.js';
export { SseSerializer } from './events/SseSerializer.js';
export { PhaseRunner } from './processor/PhaseRunner.js';
export { WorkerPool } from './processor/WorkerPool.js';
export { RetentionScheduler } from './retention/RetentionScheduler.js';
export { RetryStrategy } from './retry/RetryStrategy.js';
export { SqliteStorage } from './storage/SqliteStorage.js';
export { WebhookDispatcher } from './webhook/WebhookDispatcher.js';
export { CancellationError } from './util/errors.js';
export { createJobId } from './util/id.js';
export type {
EnqueueOptions,
ErrorDisposition,
JobData,
JobFailure,
JobPhaseState,
JobProgressEvent,
JobQueueEvents,
JobRecord,
JobStatus,
JsonValue,
ListJobsOptions,
PhaseContext,
PhaseHandler,
QueueConfig,
QueueStreamEvent,
RetentionConfig,
RetryBackoffStrategy,
RetryConfig,
RetryOptions,
StreamOptions,
WebhookConfig,
WebhookDispatchError,
WebhookDispatchResult,
WebhookEventName,
} from './types.js';

View File

@@ -0,0 +1,143 @@
import type {
JobData,
JobPhaseState,
JobRecord,
JsonValue,
PhaseContext,
PhaseHandler,
} from '../types.js';
interface PhaseRunnerOptions<TData extends JobData> {
handlers: Map<string, PhaseHandler<TData>>;
phases: readonly string[];
onProgress: (
phaseName: string,
phases: JobPhaseState[],
phaseProgress: number,
overallProgress: number,
message: string | undefined,
details: JsonValue | undefined,
) => Promise<void>;
onPhaseStarted: (phaseName: string, phases: JobPhaseState[]) => Promise<JobRecord<TData>>;
onPhaseCompleted: (
phaseName: string,
phases: JobPhaseState[],
phaseResults: Record<string, JsonValue | undefined>,
overallProgress: number,
) => Promise<JobRecord<TData>>;
onCancelled: (phaseName: string | null, phases: JobPhaseState[]) => Promise<void>;
}
export class PhaseRunner<TData extends JobData = JobData> {
public constructor(private readonly options: PhaseRunnerOptions<TData>) {}
public async run(
job: JobRecord<TData>,
signal: AbortSignal,
): Promise<{ phases: JobPhaseState[]; phaseResults: Record<string, JsonValue | undefined> }> {
const phases = job.phases.map((phase) => ({ ...phase }));
const phaseResults = { ...job.phaseResults };
let currentJob = job;
for (const [phaseIndex, phaseName] of this.options.phases.entries()) {
const phaseState = phases[phaseIndex];
if (!phaseState) {
throw new Error(`Missing phase state for ${phaseName}`);
}
if (phaseState.status === 'completed') {
continue;
}
if (signal.aborted) {
phaseState.status = 'cancelled';
phaseState.completedAt = new Date().toISOString();
await this.options.onCancelled(phaseName, phases);
return { phases, phaseResults };
}
phaseState.status = 'active';
phaseState.startedAt ??= new Date().toISOString();
phaseState.error = null;
currentJob = await this.options.onPhaseStarted(phaseName, phases);
const handler = this.options.handlers.get(phaseName);
if (!handler) {
throw new Error(`No handler registered for phase "${phaseName}"`);
}
const context: PhaseContext<TData> = {
job: currentJob,
phase: phaseName,
signal,
progress: async (percent, message, details) => {
this.ensureNotCancelled(signal);
phaseState.progress = normalizeProgress(percent);
phaseState.message = message ?? null;
const overallProgress = calculateOverallProgress(
phaseIndex,
phases.length,
phaseState.progress,
);
await this.options.onProgress(
phaseName,
phases,
phaseState.progress,
overallProgress,
message,
details,
);
return currentJob;
},
phaseResult: <TResult extends JsonValue = JsonValue>(lookupPhase: string) =>
phaseResults[lookupPhase] as TResult | undefined,
phaseResults: () => ({ ...phaseResults }),
isCancelled: () => signal.aborted,
throwIfCancelled: async () => {
this.ensureNotCancelled(signal);
},
};
const result = await handler(currentJob, context);
this.ensureNotCancelled(signal);
phaseState.status = 'completed';
phaseState.progress = 100;
phaseState.message = null;
phaseState.completedAt = new Date().toISOString();
phaseResults[phaseName] = result;
currentJob = await this.options.onPhaseCompleted(
phaseName,
phases,
phaseResults,
calculateOverallProgress(phaseIndex, phases.length, 100),
);
}
return { phases, phaseResults };
}
private ensureNotCancelled(signal: AbortSignal): void {
if (signal.aborted) {
throw signal.reason instanceof Error ? signal.reason : new Error('Job cancelled');
}
}
}
function calculateOverallProgress(
phaseIndex: number,
totalPhases: number,
phaseProgress: number,
): number {
const clamped = normalizeProgress(phaseProgress);
return Math.round(((phaseIndex + clamped / 100) / totalPhases) * 100);
}
function normalizeProgress(value: number): number {
return Math.min(100, Math.max(0, Math.round(value)));
}

View File

@@ -0,0 +1,58 @@
import pLimit from 'p-limit';
export class WorkerPool {
private readonly limit: ReturnType<typeof pLimit>;
private readonly running = new Set<Promise<unknown>>();
public constructor(private readonly concurrency: number) {
if (!Number.isInteger(concurrency) || concurrency <= 0) {
throw new Error('concurrency must be a positive integer');
}
this.limit = pLimit(concurrency);
}
public get activeCount(): number {
return this.limit.activeCount;
}
public get pendingCount(): number {
return this.limit.pendingCount;
}
public get availableSlots(): number {
return Math.max(this.concurrency - this.activeCount - this.pendingCount, 0);
}
public hasCapacity(): boolean {
return this.availableSlots > 0;
}
public run<T>(task: () => Promise<T>): Promise<T> {
const scheduled = this.limit(async () => task());
const tracked = scheduled.finally(() => {
this.running.delete(tracked);
});
this.running.add(tracked);
return scheduled;
}
public async drain(timeoutMs?: number): Promise<void> {
const active = Promise.allSettled([...this.running]);
if (timeoutMs === undefined) {
await active;
return;
}
await Promise.race([
active.then(() => undefined),
new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Timed out waiting for workers to drain after ${timeoutMs}ms`));
}, timeoutMs);
}),
]);
}
}

View File

@@ -0,0 +1,43 @@
import type { JobData, JobRecord, RetentionConfig } from '../types.js';
export class RetentionScheduler<TData extends JobData = JobData> {
private interval: NodeJS.Timeout | null = null;
public constructor(
private readonly config: RetentionConfig<TData>,
private readonly handlers: {
markStale: (cutoffIso: string) => Promise<JobRecord<TData>[]>;
deleteStale: (cutoffIso: string) => Promise<JobRecord<TData>[]>;
},
) {}
public start(): void {
if (this.interval) {
return;
}
const intervalMs = this.config.intervalMs ?? 60_000;
this.interval = setInterval(() => {
void this.runCycle();
}, intervalMs);
this.interval.unref?.();
}
public async runCycle(): Promise<void> {
const now = Date.now();
const staleCutoff = new Date(now - this.config.staleAfterMs).toISOString();
const deleteCutoff = new Date(now - this.config.deleteAfterMs).toISOString();
await this.handlers.markStale(staleCutoff);
await this.handlers.deleteStale(deleteCutoff);
}
public stop(): void {
if (!this.interval) {
return;
}
clearInterval(this.interval);
this.interval = null;
}
}

View File

@@ -0,0 +1,82 @@
import type {
ErrorDisposition,
JobData,
JobRecord,
RetryBackoffStrategy,
RetryConfig,
} from '../types.js';
const DEFAULT_MAX_ATTEMPTS = 1;
const DEFAULT_BASE_DELAY_MS = 1_000;
const DEFAULT_MAX_DELAY_MS = 30_000;
export class RetryStrategy<TData extends JobData = JobData> {
private readonly config: Required<
Pick<RetryConfig<TData>, 'maxAttempts' | 'strategy' | 'baseDelayMs' | 'maxDelayMs'>
> &
Pick<RetryConfig<TData>, 'classifyError'>;
public constructor(config: RetryConfig<TData> = {}) {
this.config = {
maxAttempts: config.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
strategy: config.strategy ?? 'exponential',
baseDelayMs: config.baseDelayMs ?? DEFAULT_BASE_DELAY_MS,
maxDelayMs: config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS,
classifyError: config.classifyError,
};
}
public defaultMaxAttempts(): number {
return this.config.maxAttempts;
}
public async classify(error: unknown, job: JobRecord<TData>): Promise<ErrorDisposition> {
if (this.config.classifyError) {
return this.config.classifyError(error, job);
}
return 'fatal';
}
public getDelayMs(retryCount: number): number {
const multiplier = this.getMultiplier(retryCount);
return Math.min(this.config.baseDelayMs * multiplier, this.config.maxDelayMs);
}
public async shouldRetry(
error: unknown,
job: JobRecord<TData>,
): Promise<{ retry: boolean; delayMs: number; disposition: ErrorDisposition }> {
const disposition = await this.classify(error, job);
const nextRetryCount = job.retryCount + 1;
if (disposition !== 'recoverable' || nextRetryCount >= job.maxAttempts) {
return {
retry: false,
delayMs: 0,
disposition,
};
}
return {
retry: true,
delayMs: this.getDelayMs(nextRetryCount),
disposition,
};
}
private getMultiplier(retryCount: number): number {
const normalizedRetryCount = Math.max(retryCount, 1);
const strategy: RetryBackoffStrategy = this.config.strategy;
if (strategy === 'fixed') {
return 1;
}
if (strategy === 'linear') {
return normalizedRetryCount;
}
return 2 ** (normalizedRetryCount - 1);
}
}

View File

@@ -0,0 +1,580 @@
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import Database from 'better-sqlite3';
import type {
EnqueueOptions,
JobData,
JobFailure,
JobPhaseState,
JobRecord,
JobStatus,
ListJobsOptions,
} from '../types.js';
interface JobRow {
id: string;
status: JobStatus;
data: string;
current_phase: string | null;
phases_json: string;
phase_results: string;
progress: number;
progress_message: string | null;
error_json: string | null;
retry_count: number;
max_attempts: number;
webhook_url: string | null;
webhook_sent: number;
created_at: string;
started_at: string | null;
completed_at: string | null;
updated_at: string;
scheduled_at: string | null;
cancelled_at: string | null;
}
function toIsoString(value: Date | string | undefined): string | null {
if (!value) {
return null;
}
return value instanceof Date ? value.toISOString() : value;
}
function parseJson<T>(value: string | null, fallback: T): T {
if (!value) {
return fallback;
}
return JSON.parse(value) as T;
}
export class SqliteStorage<TData extends JobData = JobData> {
private readonly db: Database.Database;
public constructor(dbPath: string) {
mkdirSync(dirname(dbPath), { recursive: true });
this.db = new Database(dbPath);
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
this.db.pragma('busy_timeout = 5000');
this.init();
}
public createJob(
id: string,
data: TData,
phases: JobPhaseState[],
options: EnqueueOptions,
maxAttempts: number,
): JobRecord<TData> {
const now = new Date().toISOString();
const row: JobRow = {
id,
status: 'pending',
data: JSON.stringify(data),
current_phase: null,
phases_json: JSON.stringify(phases),
phase_results: JSON.stringify({}),
progress: 0,
progress_message: null,
error_json: null,
retry_count: 0,
max_attempts: maxAttempts,
webhook_url: options.webhookUrl ?? null,
webhook_sent: 0,
created_at: now,
started_at: null,
completed_at: null,
updated_at: now,
scheduled_at: toIsoString(options.scheduledAt),
cancelled_at: null,
};
this.db
.prepare(
`
INSERT INTO jobs (
id, status, data, current_phase, phases_json, phase_results, progress,
progress_message, error_json, retry_count, max_attempts, webhook_url,
webhook_sent, created_at, started_at, completed_at, updated_at,
scheduled_at, cancelled_at
)
VALUES (
@id, @status, @data, @current_phase, @phases_json, @phase_results, @progress,
@progress_message, @error_json, @retry_count, @max_attempts, @webhook_url,
@webhook_sent, @created_at, @started_at, @completed_at, @updated_at,
@scheduled_at, @cancelled_at
)
`,
)
.run(row);
return this.parseRow(row);
}
public getJob(id: string): JobRecord<TData> | null {
const row = this.db.prepare('SELECT * FROM jobs WHERE id = ?').get(id) as JobRow | undefined;
return row ? this.parseRow(row) : null;
}
public listJobs(options: ListJobsOptions = {}): JobRecord<TData>[] {
const where: string[] = [];
const params: Array<string | number> = [];
if (options.statuses && options.statuses.length > 0) {
where.push(`status IN (${options.statuses.map(() => '?').join(', ')})`);
params.push(...options.statuses);
}
const limit = options.limit ?? 100;
const offset = options.offset ?? 0;
params.push(limit, offset);
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
const rows = this.db
.prepare(
`
SELECT * FROM jobs
${whereClause}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`,
)
.all(...params) as JobRow[];
return rows.map((row) => this.parseRow(row));
}
public listRunnableJobs(limit: number): JobRecord<TData>[] {
const now = new Date().toISOString();
const rows = this.db
.prepare(
`
SELECT * FROM jobs
WHERE status = 'pending'
AND (scheduled_at IS NULL OR scheduled_at <= ?)
ORDER BY COALESCE(scheduled_at, created_at) ASC, created_at ASC
LIMIT ?
`,
)
.all(now, limit) as JobRow[];
return rows.map((row) => this.parseRow(row));
}
public claimPendingJob(id: string): boolean {
const now = new Date().toISOString();
const result = this.db
.prepare(
`
UPDATE jobs
SET status = 'active',
started_at = COALESCE(started_at, ?),
updated_at = ?,
cancelled_at = NULL
WHERE id = ?
AND status = 'pending'
AND (scheduled_at IS NULL OR scheduled_at <= ?)
`,
)
.run(now, now, id, now);
return result.changes > 0;
}
public saveProgress(
id: string,
currentPhase: string,
phases: JobPhaseState[],
progress: number,
message: string | null,
): JobRecord<TData> {
const now = new Date().toISOString();
this.db
.prepare(
`
UPDATE jobs
SET current_phase = ?,
phases_json = ?,
progress = ?,
progress_message = ?,
updated_at = ?
WHERE id = ?
`,
)
.run(currentPhase, JSON.stringify(phases), progress, message, now, id);
return this.mustGetJob(id);
}
public savePhaseCompletion(
id: string,
phases: JobPhaseState[],
phaseResults: Record<string, unknown>,
currentPhase: string | null,
progress: number,
message: string | null,
): JobRecord<TData> {
const now = new Date().toISOString();
this.db
.prepare(
`
UPDATE jobs
SET phases_json = ?,
phase_results = ?,
current_phase = ?,
progress = ?,
progress_message = ?,
error_json = NULL,
updated_at = ?
WHERE id = ?
`,
)
.run(
JSON.stringify(phases),
JSON.stringify(phaseResults),
currentPhase,
progress,
message,
now,
id,
);
return this.mustGetJob(id);
}
public completeJob(
id: string,
phases: JobPhaseState[],
phaseResults: Record<string, unknown>,
): JobRecord<TData> {
const now = new Date().toISOString();
this.db
.prepare(
`
UPDATE jobs
SET status = 'completed',
phases_json = ?,
phase_results = ?,
current_phase = NULL,
progress = 100,
progress_message = NULL,
error_json = NULL,
completed_at = ?,
updated_at = ?
WHERE id = ?
`,
)
.run(JSON.stringify(phases), JSON.stringify(phaseResults), now, now, id);
return this.mustGetJob(id);
}
public failJob(id: string, phases: JobPhaseState[], failure: JobFailure): JobRecord<TData> {
const now = new Date().toISOString();
this.db
.prepare(
`
UPDATE jobs
SET status = 'failed',
phases_json = ?,
current_phase = ?,
error_json = ?,
completed_at = ?,
updated_at = ?
WHERE id = ?
`,
)
.run(JSON.stringify(phases), failure.phase, JSON.stringify(failure), now, now, id);
return this.mustGetJob(id);
}
public scheduleRetry(
id: string,
phases: JobPhaseState[],
progress: number,
failure: JobFailure,
retryCount: number,
scheduledAt: string,
): JobRecord<TData> {
const now = new Date().toISOString();
this.db
.prepare(
`
UPDATE jobs
SET status = 'pending',
phases_json = ?,
current_phase = ?,
progress = ?,
progress_message = ?,
error_json = ?,
retry_count = ?,
scheduled_at = ?,
updated_at = ?
WHERE id = ?
`,
)
.run(
JSON.stringify(phases),
failure.phase,
progress,
`Retry scheduled in ${Math.max(new Date(scheduledAt).getTime() - Date.now(), 0)}ms`,
JSON.stringify(failure),
retryCount,
scheduledAt,
now,
id,
);
return this.mustGetJob(id);
}
public resetForRetry(
id: string,
phases: JobPhaseState[],
maxAttempts: number,
scheduledAt: string | null,
): JobRecord<TData> {
const now = new Date().toISOString();
this.db
.prepare(
`
UPDATE jobs
SET status = 'pending',
current_phase = NULL,
phases_json = ?,
phase_results = '{}',
progress = 0,
progress_message = NULL,
error_json = NULL,
retry_count = 0,
max_attempts = ?,
webhook_sent = 0,
completed_at = NULL,
updated_at = ?,
scheduled_at = ?,
cancelled_at = NULL
WHERE id = ?
`,
)
.run(JSON.stringify(phases), maxAttempts, now, scheduledAt, id);
return this.mustGetJob(id);
}
public cancelJob(id: string, phases: JobPhaseState[]): JobRecord<TData> {
const now = new Date().toISOString();
this.db
.prepare(
`
UPDATE jobs
SET status = 'cancelled',
phases_json = ?,
current_phase = NULL,
completed_at = COALESCE(completed_at, ?),
cancelled_at = ?,
updated_at = ?
WHERE id = ?
AND status IN ('pending', 'active')
`,
)
.run(JSON.stringify(phases), now, now, now, id);
return this.mustGetJob(id);
}
public markWebhookSent(id: string): void {
this.db
.prepare('UPDATE jobs SET webhook_sent = 1, updated_at = ? WHERE id = ?')
.run(new Date().toISOString(), id);
}
public resetActiveJobs(message: string): JobRecord<TData>[] {
const activeRows = this.db
.prepare(`SELECT * FROM jobs WHERE status = 'active'`)
.all() as JobRow[];
if (activeRows.length === 0) {
return [];
}
const now = new Date().toISOString();
const failure: JobFailure = {
message,
phase: null,
recoverable: false,
timestamp: now,
attempt: 0,
};
const statement = this.db.prepare(
`
UPDATE jobs
SET status = 'failed',
error_json = ?,
completed_at = ?,
updated_at = ?
WHERE id = ?
`,
);
const transaction = this.db.transaction((rows: JobRow[]) => {
for (const row of rows) {
statement.run(JSON.stringify(failure), now, now, row.id);
}
});
transaction(activeRows);
return activeRows.map((row) => this.mustGetJob(row.id));
}
public markStaleJobs(cutoffIso: string): JobRecord<TData>[] {
const rows = this.db
.prepare(
`
SELECT * FROM jobs
WHERE status IN ('completed', 'failed', 'cancelled')
AND COALESCE(completed_at, updated_at) <= ?
`,
)
.all(cutoffIso) as JobRow[];
if (rows.length === 0) {
return [];
}
const now = new Date().toISOString();
const update = this.db.prepare(
`
UPDATE jobs
SET status = 'stale',
updated_at = ?
WHERE id = ?
`,
);
const transaction = this.db.transaction((staleRows: JobRow[]) => {
for (const row of staleRows) {
update.run(now, row.id);
}
});
transaction(rows);
return rows.map((row) => this.mustGetJob(row.id));
}
public deleteStaleJobs(cutoffIso: string): JobRecord<TData>[] {
const rows = this.db
.prepare(
`
SELECT * FROM jobs
WHERE status = 'stale'
AND COALESCE(completed_at, updated_at) <= ?
`,
)
.all(cutoffIso) as JobRow[];
if (rows.length === 0) {
return [];
}
const remove = this.db.prepare('DELETE FROM jobs WHERE id = ?');
const transaction = this.db.transaction((deleteRows: JobRow[]) => {
for (const row of deleteRows) {
remove.run(row.id);
}
});
transaction(rows);
return rows.map((row) => this.parseRow(row));
}
public getNextScheduledAt(): string | null {
const row = this.db
.prepare(
`
SELECT MIN(scheduled_at) AS scheduled_at
FROM jobs
WHERE status = 'pending'
AND scheduled_at IS NOT NULL
`,
)
.get() as { scheduled_at: string | null };
return row.scheduled_at;
}
public close(): void {
this.db.close();
}
private init(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
data TEXT NOT NULL,
current_phase TEXT,
phases_json TEXT NOT NULL,
phase_results TEXT NOT NULL DEFAULT '{}',
progress INTEGER NOT NULL DEFAULT 0,
progress_message TEXT,
error_json TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 1,
webhook_url TEXT,
webhook_sent INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL,
scheduled_at TEXT,
cancelled_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_jobs_scheduled_at ON jobs(scheduled_at);
CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at);
`);
}
private mustGetJob(id: string): JobRecord<TData> {
const job = this.getJob(id);
if (!job) {
throw new Error(`Job ${id} not found`);
}
return job;
}
private parseRow(row: JobRow): JobRecord<TData> {
return {
id: row.id,
status: row.status,
data: parseJson<TData>(row.data, {} as TData),
currentPhase: row.current_phase,
phases: parseJson<JobPhaseState[]>(row.phases_json, []),
phaseResults: parseJson<Record<string, unknown>>(row.phase_results, {}) as Record<
string,
TData[keyof TData] | undefined
>,
progress: row.progress,
progressMessage: row.progress_message,
error: parseJson<JobFailure | null>(row.error_json, null),
retryCount: row.retry_count,
maxAttempts: row.max_attempts,
webhookUrl: row.webhook_url,
webhookSent: Boolean(row.webhook_sent),
createdAt: row.created_at,
startedAt: row.started_at,
completedAt: row.completed_at,
updatedAt: row.updated_at,
scheduledAt: row.scheduled_at,
cancelledAt: row.cancelled_at,
};
}
}

216
src/types.ts Normal file
View File

@@ -0,0 +1,216 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export type Awaitable<T> = T | Promise<T>;
export type JobData = Record<string, JsonValue>;
export type JobStatus =
| 'pending'
| 'active'
| 'completed'
| 'failed'
| 'cancelled'
| 'stale';
export type JobPhaseStatus = 'pending' | 'active' | 'completed' | 'failed' | 'cancelled';
export type RetryBackoffStrategy = 'fixed' | 'linear' | 'exponential';
export type ErrorDisposition = 'recoverable' | 'fatal';
export interface JobPhaseState {
name: string;
status: JobPhaseStatus;
progress: number;
message: string | null;
startedAt: string | null;
completedAt: string | null;
error: string | null;
}
export interface JobFailure {
message: string;
phase: string | null;
recoverable: boolean;
timestamp: string;
attempt: number;
}
export interface JobRetryEvent {
jobId: string;
phase: string | null;
attempt: number;
delayMs: number;
nextRunAt: string;
timestamp: string;
}
export interface JobProgressEvent {
jobId: string;
phase: string;
phaseProgress: number;
overallProgress: number;
message: string | null;
timestamp: string;
details?: JsonValue;
}
export interface JobRecord<TData extends JobData = JobData> {
id: string;
status: JobStatus;
data: TData;
currentPhase: string | null;
phases: JobPhaseState[];
phaseResults: Record<string, JsonValue | undefined>;
progress: number;
progressMessage: string | null;
error: JobFailure | null;
retryCount: number;
maxAttempts: number;
webhookUrl: string | null;
webhookSent: boolean;
createdAt: string;
startedAt: string | null;
completedAt: string | null;
updatedAt: string;
scheduledAt: string | null;
cancelledAt: string | null;
}
export interface EnqueueOptions {
id?: string;
scheduledAt?: string | Date;
maxAttempts?: number;
webhookUrl?: string;
}
export interface RetryOptions {
fromStart?: boolean;
scheduledAt?: string | Date;
}
export interface ListJobsOptions {
statuses?: JobStatus[];
limit?: number;
offset?: number;
}
export interface StreamOptions {
jobId?: string;
includeSnapshot?: boolean;
keepAliveMs?: number;
}
export interface RetryConfig<TData extends JobData = JobData> {
maxAttempts?: number;
strategy?: RetryBackoffStrategy;
baseDelayMs?: number;
maxDelayMs?: number;
classifyError?: (
error: unknown,
job: JobRecord<TData>,
) => Awaitable<ErrorDisposition>;
}
export interface RetentionConfig<TData extends JobData = JobData> {
staleAfterMs: number;
deleteAfterMs: number;
intervalMs?: number;
onStale?: (job: JobRecord<TData>) => Awaitable<void>;
onDelete?: (job: JobRecord<TData>) => Awaitable<void>;
}
export type WebhookEventName =
| 'job:completed'
| 'job:failed'
| 'job:retrying'
| 'job:cancelled'
| 'job:stale';
export interface WebhookConfig {
url?: string;
events?: WebhookEventName[];
secret?: string;
timeoutMs?: number;
headers?: Record<string, string>;
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
}
export interface QueueConfig<TData extends JobData = JobData> {
dbPath: string;
phases: readonly string[];
concurrency?: number;
retry?: RetryConfig<TData>;
retention?: RetentionConfig<TData>;
webhook?: WebhookConfig;
shutdownTimeoutMs?: number;
}
export interface PhaseContext<TData extends JobData = JobData> {
readonly job: JobRecord<TData>;
readonly phase: string;
readonly signal: AbortSignal;
progress: (percent: number, message?: string, details?: JsonValue) => Promise<JobRecord<TData>>;
phaseResult: <TResult extends JsonValue = JsonValue>(phaseName: string) => TResult | undefined;
phaseResults: () => Record<string, JsonValue | undefined>;
isCancelled: () => boolean;
throwIfCancelled: () => Promise<void>;
}
export type PhaseHandler<TData extends JobData = JobData> = (
job: JobRecord<TData>,
context: PhaseContext<TData>,
) => Awaitable<JsonValue | undefined>;
export interface WebhookDispatchResult {
event: WebhookEventName;
jobId: string;
status: number;
deliveredAt: string;
}
export interface WebhookDispatchError {
event: WebhookEventName;
jobId: string;
message: string;
finalAttempt: number;
}
export interface QueueStreamEvent<TData extends JobData = JobData> {
type:
| 'snapshot'
| 'job:enqueued'
| 'job:started'
| 'job:progress'
| 'job:phase:completed'
| 'job:completed'
| 'job:failed'
| 'job:retrying'
| 'job:cancelled'
| 'job:stale'
| 'job:deleted'
| 'job:webhook:delivered'
| 'job:webhook:failed'
| 'ping';
jobId?: string;
job?: JobRecord<TData>;
progress?: JobProgressEvent;
phase?: JobPhaseState;
failure?: JobFailure;
retry?: JobRetryEvent;
webhook?: WebhookDispatchResult | WebhookDispatchError;
deletedJobId?: string;
timestamp: string;
}
export interface JobQueueEvents<TData extends JobData = JobData> {
'job:enqueued': [job: JobRecord<TData>];
'job:started': [job: JobRecord<TData>];
'job:progress': [job: JobRecord<TData>, progress: JobProgressEvent];
'job:phase:completed': [job: JobRecord<TData>, phase: JobPhaseState];
'job:completed': [job: JobRecord<TData>];
'job:failed': [job: JobRecord<TData>, failure: JobFailure];
'job:retrying': [job: JobRecord<TData>, retry: JobRetryEvent];
'job:cancelled': [job: JobRecord<TData>];
'job:stale': [job: JobRecord<TData>];
'job:deleted': [jobId: string];
'job:webhook:delivered': [job: JobRecord<TData>, result: WebhookDispatchResult];
'job:webhook:failed': [job: JobRecord<TData>, error: WebhookDispatchError];
}

6
src/util/errors.ts Normal file
View File

@@ -0,0 +1,6 @@
export class CancellationError extends Error {
public constructor(message = 'Job cancelled') {
super(message);
this.name = 'CancellationError';
}
}

5
src/util/id.ts Normal file
View File

@@ -0,0 +1,5 @@
import { randomUUID } from 'node:crypto';
export function createJobId(): string {
return randomUUID();
}

View File

@@ -0,0 +1,143 @@
import { createHmac } from 'node:crypto';
import type {
JobData,
JobRecord,
WebhookConfig,
WebhookDispatchError,
WebhookDispatchResult,
WebhookEventName,
} from '../types.js';
const DEFAULT_TIMEOUT_MS = 5_000;
const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_BASE_DELAY_MS = 500;
const DEFAULT_MAX_DELAY_MS = 5_000;
function sleep(delayMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
}
export class WebhookDispatcher<TData extends JobData = JobData> {
private readonly config: Required<
Pick<WebhookConfig, 'events' | 'timeoutMs' | 'maxAttempts' | 'baseDelayMs' | 'maxDelayMs'>
> &
Pick<WebhookConfig, 'headers' | 'secret' | 'url'>;
public constructor(config: WebhookConfig = {}) {
this.config = {
url: config.url,
events: config.events ?? ['job:completed', 'job:failed'],
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
headers: config.headers,
secret: config.secret,
maxAttempts: config.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
baseDelayMs: config.baseDelayMs ?? DEFAULT_BASE_DELAY_MS,
maxDelayMs: config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS,
};
}
public supports(event: WebhookEventName, job: JobRecord<TData>): boolean {
return Boolean(job.webhookUrl ?? this.config.url) && this.config.events.includes(event);
}
public async dispatch(
event: WebhookEventName,
job: JobRecord<TData>,
): Promise<WebhookDispatchResult> {
const url = job.webhookUrl ?? this.config.url;
if (!url) {
throw new Error(`No webhook URL configured for ${event}`);
}
const payload = JSON.stringify({
event,
emittedAt: new Date().toISOString(),
job,
});
const signature = this.sign(payload);
let attempt = 0;
let lastError: Error | null = null;
while (attempt < this.config.maxAttempts) {
attempt += 1;
try {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, this.config.timeoutMs);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-JobQueue-Event': event,
...(signature ? { 'X-JobQueue-Signature': signature } : {}),
...(this.config.headers ?? {}),
},
body: payload,
signal: controller.signal,
});
clearTimeout(timeout);
if (response.ok) {
return {
event,
jobId: job.id,
status: response.status,
deliveredAt: new Date().toISOString(),
};
}
if (response.status < 500) {
throw Object.assign(new Error(`Webhook returned non-retryable status ${response.status}`), {
retryable: false,
});
}
throw new Error(`Webhook returned retryable status ${response.status}`);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
const retryable =
typeof error === 'object' &&
error !== null &&
'retryable' in error &&
(error as { retryable?: boolean }).retryable === false
? false
: true;
if (!retryable || attempt >= this.config.maxAttempts) {
break;
}
await sleep(this.nextDelay(attempt));
}
}
const dispatchError: WebhookDispatchError = {
event,
jobId: job.id,
message: lastError?.message ?? 'Unknown webhook delivery error',
finalAttempt: attempt,
};
throw Object.assign(new Error(dispatchError.message), { dispatchError });
}
private sign(payload: string): string | undefined {
if (!this.config.secret) {
return undefined;
}
return createHmac('sha256', this.config.secret).update(payload).digest('hex');
}
private nextDelay(attempt: number): number {
return Math.min(this.config.baseDelayMs * 2 ** (attempt - 1), this.config.maxDelayMs);
}
}

135
tests/JobQueue.test.ts Normal file
View File

@@ -0,0 +1,135 @@
import { JobQueue } from '../src/index.js';
import { cleanupDir, createDbPath, createTempDir, waitFor } from './helpers.js';
describe('JobQueue', () => {
it('runs multi-phase jobs to completion', async () => {
const dir = createTempDir();
const queue = new JobQueue<{ url: string }>({
dbPath: createDbPath(dir),
phases: ['download', 'process'],
concurrency: 1,
});
const events: string[] = [];
queue.on('job:started', () => events.push('started'));
queue.on('job:phase:completed', (_, phase) => events.push(`phase:${phase.name}`));
queue.on('job:completed', () => events.push('completed'));
queue.handle('download', async (_job, ctx) => {
await ctx.progress(50, 'downloading');
return { filePath: '/tmp/video.mp4' };
});
queue.handle('process', async (_job, ctx) => {
expect(ctx.phaseResult<{ filePath: string }>('download')?.filePath).toBe('/tmp/video.mp4');
await ctx.progress(25, 'processing');
return { outputPath: '/tmp/video.txt' };
});
try {
const jobId = await queue.enqueue({ url: 'https://example.com/video' });
await waitFor(() => queue.getJob(jobId)?.status === 'completed');
const job = queue.getJob(jobId);
expect(job?.status).toBe('completed');
expect(job?.phaseResults.download).toEqual({ filePath: '/tmp/video.mp4' });
expect(job?.phaseResults.process).toEqual({ outputPath: '/tmp/video.txt' });
expect(events).toEqual(['started', 'phase:download', 'phase:process', 'completed']);
} finally {
await queue.shutdown();
cleanupDir(dir);
}
});
it('retries recoverable failures and eventually completes', async () => {
const dir = createTempDir();
const queue = new JobQueue<{ url: string }>({
dbPath: createDbPath(dir),
phases: ['run'],
concurrency: 1,
retry: {
maxAttempts: 3,
baseDelayMs: 10,
classifyError: async (error) =>
error instanceof Error && error.message === 'recoverable' ? 'recoverable' : 'fatal',
},
});
let attempts = 0;
let retries = 0;
queue.on('job:retrying', () => {
retries += 1;
});
queue.handle('run', async () => {
attempts += 1;
if (attempts === 1) {
throw new Error('recoverable');
}
return { ok: true };
});
try {
const jobId = await queue.enqueue({ url: 'https://example.com/video' });
await waitFor(() => queue.getJob(jobId)?.status === 'completed', { timeoutMs: 4_000 });
const job = queue.getJob(jobId);
expect(job?.status).toBe('completed');
expect(job?.retryCount).toBe(1);
expect(retries).toBe(1);
} finally {
await queue.shutdown();
cleanupDir(dir);
}
});
it('streams queue events as SSE', async () => {
const dir = createTempDir();
const queue = new JobQueue<{ url: string }>({
dbPath: createDbPath(dir),
phases: ['run'],
concurrency: 1,
});
const stream = queue.createEventStream({ includeSnapshot: false });
const reader = stream.getReader();
const chunks: string[] = [];
const readPromise = (async () => {
while (true) {
const { value, done } = await reader.read();
if (done) {
return;
}
if (value) {
chunks.push(new TextDecoder().decode(value));
if (chunks.some((chunk) => chunk.includes('event: job:completed'))) {
return;
}
}
}
})();
queue.handle('run', async (_job, ctx) => {
await ctx.progress(100, 'done');
return { ok: true };
});
try {
await queue.enqueue({ url: 'https://example.com/video' });
await Promise.race([
readPromise,
waitFor(() => chunks.some((chunk) => chunk.includes('event: job:completed')), {
timeoutMs: 4_000,
}),
]);
} finally {
await reader.cancel();
await queue.shutdown();
cleanupDir(dir);
}
expect(chunks.join('\n')).toContain('event: job:completed');
expect(chunks.join('\n')).toContain('event: job:progress');
});
});

View File

@@ -0,0 +1,28 @@
import { RetentionScheduler } from '../src/index.js';
describe('RetentionScheduler', () => {
it('runs stale and delete handlers', async () => {
const calls: string[] = [];
const scheduler = new RetentionScheduler(
{
staleAfterMs: 1_000,
deleteAfterMs: 2_000,
},
{
markStale: async () => {
calls.push('stale');
return [];
},
deleteStale: async () => {
calls.push('delete');
return [];
},
},
);
await scheduler.runCycle();
scheduler.stop();
expect(calls).toEqual(['stale', 'delete']);
});
});

View File

@@ -0,0 +1,72 @@
import { RetryStrategy } from '../src/index.js';
describe('RetryStrategy', () => {
it('uses exponential backoff for recoverable errors', async () => {
const strategy = new RetryStrategy({
maxAttempts: 4,
baseDelayMs: 100,
classifyError: async () => 'recoverable',
});
const decision = await strategy.shouldRetry(new Error('boom'), {
id: 'job-1',
status: 'active',
data: {},
currentPhase: 'run',
phases: [],
phaseResults: {},
progress: 0,
progressMessage: null,
error: null,
retryCount: 1,
maxAttempts: 4,
webhookUrl: null,
webhookSent: false,
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
updatedAt: new Date().toISOString(),
scheduledAt: null,
cancelledAt: null,
});
expect(decision.retry).toBe(true);
expect(decision.delayMs).toBe(200);
expect(decision.disposition).toBe('recoverable');
});
it('does not retry fatal errors', async () => {
const strategy = new RetryStrategy({
maxAttempts: 4,
classifyError: async () => 'fatal',
});
const decision = await strategy.shouldRetry(new Error('fatal'), {
id: 'job-1',
status: 'active',
data: {},
currentPhase: 'run',
phases: [],
phaseResults: {},
progress: 0,
progressMessage: null,
error: null,
retryCount: 0,
maxAttempts: 4,
webhookUrl: null,
webhookSent: false,
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
updatedAt: new Date().toISOString(),
scheduledAt: null,
cancelledAt: null,
});
expect(decision).toEqual({
retry: false,
delayMs: 0,
disposition: 'fatal',
});
});
});

View File

@@ -0,0 +1,76 @@
import { SqliteStorage } from '../src/index.js';
import { cleanupDir, createDbPath, createTempDir } from './helpers.js';
describe('SqliteStorage', () => {
it('creates, updates, and completes jobs', () => {
const dir = createTempDir();
const storage = new SqliteStorage<{ url: string }>(createDbPath(dir));
try {
const job = storage.createJob(
'job-1',
{ url: 'https://example.com' },
[
{
name: 'download',
status: 'pending',
progress: 0,
message: null,
startedAt: null,
completedAt: null,
error: null,
},
],
{},
3,
);
expect(job.status).toBe('pending');
expect(storage.claimPendingJob(job.id)).toBe(true);
const inProgress = storage.saveProgress(
job.id,
'download',
[
{
name: 'download',
status: 'active',
progress: 50,
message: 'halfway',
startedAt: new Date().toISOString(),
completedAt: null,
error: null,
},
],
50,
'halfway',
);
expect(inProgress.status).toBe('active');
expect(inProgress.progress).toBe(50);
const completed = storage.completeJob(
job.id,
[
{
name: 'download',
status: 'completed',
progress: 100,
message: null,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
error: null,
},
],
{ download: { filePath: '/tmp/file' } },
);
expect(completed.status).toBe('completed');
expect(completed.progress).toBe(100);
expect(completed.phaseResults.download).toEqual({ filePath: '/tmp/file' });
} finally {
storage.close();
cleanupDir(dir);
}
});
});

View File

@@ -0,0 +1,25 @@
import { SseSerializer } from '../src/index.js';
describe('SseSerializer', () => {
it('formats SSE events', () => {
const serializer = new SseSerializer();
const event = serializer.event('job:completed', {
type: 'job:completed',
jobId: 'job-1',
timestamp: '2026-01-01T00:00:00.000Z',
});
expect(event).toContain('event: job:completed');
expect(event).toContain('"jobId":"job-1"');
expect(event.endsWith('\n\n')).toBe(true);
});
it('returns SSE headers', () => {
expect(SseSerializer.headers()).toEqual({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
});
});

View File

@@ -0,0 +1,75 @@
import { createServer } from 'node:http';
import { WebhookDispatcher } from '../src/index.js';
describe('WebhookDispatcher', () => {
it('delivers signed webhook payloads', async () => {
const payloads: string[] = [];
const headers: string[] = [];
const server = createServer((request, response) => {
let body = '';
request.on('data', (chunk) => {
body += chunk.toString();
});
request.on('end', () => {
payloads.push(body);
headers.push(String(request.headers['x-jobqueue-signature']));
response.writeHead(202).end();
});
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Server address unavailable');
}
const dispatcher = new WebhookDispatcher({
url: `http://127.0.0.1:${address.port}/hook`,
secret: 'top-secret',
events: ['job:completed'],
});
try {
const result = await dispatcher.dispatch('job:completed', {
id: 'job-1',
status: 'completed',
data: { url: 'https://example.com' },
currentPhase: null,
phases: [],
phaseResults: {},
progress: 100,
progressMessage: null,
error: null,
retryCount: 0,
maxAttempts: 1,
webhookUrl: null,
webhookSent: false,
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scheduledAt: null,
cancelledAt: null,
});
expect(result.status).toBe(202);
expect(payloads).toHaveLength(1);
expect(payloads[0]).toContain('"event":"job:completed"');
expect(headers[0]).toMatch(/^[a-f0-9]{64}$/);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
});

26
tests/WorkerPool.test.ts Normal file
View File

@@ -0,0 +1,26 @@
import { WorkerPool } from '../src/index.js';
import { waitFor } from './helpers.js';
describe('WorkerPool', () => {
it('respects concurrency limits', async () => {
const pool = new WorkerPool(2);
let active = 0;
let maxActive = 0;
const tasks = Array.from({ length: 5 }, (_, index) =>
pool.run(async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => {
setTimeout(resolve, 20 + index);
});
active -= 1;
}),
);
await Promise.all(tasks);
await waitFor(() => pool.activeCount === 0 && pool.pendingCount === 0);
expect(maxActive).toBe(2);
});
});

34
tests/helpers.ts Normal file
View File

@@ -0,0 +1,34 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export function createTempDir(prefix = 'jobqueue-'): string {
return mkdtempSync(join(tmpdir(), prefix));
}
export function createDbPath(dir: string): string {
return join(dir, 'jobs.db');
}
export function cleanupDir(dir: string): void {
rmSync(dir, { recursive: true, force: true });
}
export async function waitFor(
predicate: () => boolean,
options: { timeoutMs?: number; intervalMs?: number } = {},
): Promise<void> {
const timeoutMs = options.timeoutMs ?? 2_000;
const intervalMs = options.intervalMs ?? 10;
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
}
await new Promise((resolve) => {
setTimeout(resolve, intervalMs);
});
}
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": false
},
"include": ["src"],
"exclude": ["node_modules", "dist", "tests"]
}

11
tsup.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
sourcemap: true,
clean: true,
target: 'node20',
splitting: false,
});

14
vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/index.ts'],
},
},
});