@nicnocquee/dataqueue 1.35.0-beta.20260224110011 → 1.35.0-beta.20260224120854

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.
@@ -33,19 +33,19 @@
33
33
  "slug": "api/job-options",
34
34
  "title": "JobOptions",
35
35
  "description": "",
36
- "content": "The `JobOptions` interface defines the options for creating a new job in the queue.\n\n## Fields\n\n- `jobType`: _string_ — The type of the job.\n- `payload`: _any_ — The payload for the job, type-safe per job\n type.\n- `maxAttempts?`: _number_ — Maximum number of attempts for\n this job (default: 3).\n- `priority?`: _number_ — Priority of the job (higher runs\n first, default: 0).\n- `runAt?`: _Date | null_ — When to run the job (default: now).\n- `timeoutMs?`: _number_ — Timeout for this job in milliseconds.\n If not set, uses the processor default or unlimited.\n- `forceKillOnTimeout?`: _boolean_ — If true, the job will be forcefully terminated (using Worker Threads) when timeout is reached. If false (default), the job will only receive an AbortSignal and must handle the abort gracefully.\n\n **⚠️ Runtime Requirements**: This option requires **Node.js** and will **not work** in Bun or other runtimes without worker thread support. See [Force Kill on Timeout](/usage/force-kill-timeout) for details.\n\n- `tags?`: _string[]_ — Tags for this job. Used for grouping, searching, or batch operations.\n- `idempotencyKey?`: _string_ — Optional idempotency key. When provided, ensures that only one job exists for a given key. If a job with the same key already exists, `addJob` returns the existing job's ID instead of creating a duplicate. See [Idempotency](/usage/add-job#idempotency) for details.\n\n## Example\n\n```ts\nconst job = {\n jobType: 'email',\n payload: { to: 'user@example.com', subject: 'Hello' },\n maxAttempts: 5,\n priority: 10,\n runAt: new Date(Date.now() + 60000), // run in 1 minute\n timeoutMs: 30000, // 30 seconds\n forceKillOnTimeout: false, // Use graceful shutdown (default)\n tags: ['welcome', 'user'], // tags for grouping/searching\n idempotencyKey: 'welcome-email-user-123', // prevent duplicate jobs\n};\n```"
36
+ "content": "The `JobOptions` interface defines the options for creating a new job in the queue.\n\n## Fields\n\n- `jobType`: _string_ — The type of the job.\n- `payload`: _any_ — The payload for the job, type-safe per job\n type.\n- `maxAttempts?`: _number_ — Maximum number of attempts for\n this job (default: 3).\n- `priority?`: _number_ — Priority of the job (higher runs\n first, default: 0).\n- `runAt?`: _Date | null_ — When to run the job (default: now).\n- `timeoutMs?`: _number_ — Timeout for this job in milliseconds.\n If not set, uses the processor default or unlimited.\n- `forceKillOnTimeout?`: _boolean_ — If true, the job will be forcefully terminated (using Worker Threads) when timeout is reached. If false (default), the job will only receive an AbortSignal and must handle the abort gracefully.\n\n **⚠️ Runtime Requirements**: This option requires **Node.js** and will **not work** in Bun or other runtimes without worker thread support. See [Force Kill on Timeout](/usage/force-kill-timeout) for details.\n\n- `tags?`: _string[]_ — Tags for this job. Used for grouping, searching, or batch operations.\n- `idempotencyKey?`: _string_ — Optional idempotency key. When provided, ensures that only one job exists for a given key. If a job with the same key already exists, `addJob` returns the existing job's ID instead of creating a duplicate. See [Idempotency](/usage/add-job#idempotency) for details.\n- `deadLetterJobType?`: _string_ — Optional dead-letter destination job type. When the job exhausts retries, DataQueue creates a new pending job in this job type with an envelope payload containing source metadata, original payload, and failure context.\n\n## Example\n\n```ts\nconst job = {\n jobType: 'email',\n payload: { to: 'user@example.com', subject: 'Hello' },\n maxAttempts: 5,\n priority: 10,\n runAt: new Date(Date.now() + 60000), // run in 1 minute\n timeoutMs: 30000, // 30 seconds\n forceKillOnTimeout: false, // Use graceful shutdown (default)\n tags: ['welcome', 'user'], // tags for grouping/searching\n idempotencyKey: 'welcome-email-user-123', // prevent duplicate jobs\n deadLetterJobType: 'email_dead_letter', // route exhausted failures\n};\n```"
37
37
  },
38
38
  {
39
39
  "slug": "api/job-queue",
40
40
  "title": "JobQueue",
41
41
  "description": "",
42
- "content": "## Initialization\n\n### initJobQueue\n\n```ts\ninitJobQueue(config: JobQueueConfig): JobQueue\n```\n\nInitializes the job queue system with the provided configuration. The `JobQueueConfig` is a discriminated union -- you provide either a PostgreSQL or Redis configuration.\n\n#### PostgresJobQueueConfig\n\nProvide either `databaseConfig` (the library creates a pool) or `pool` (bring your own `pg.Pool`). At least one must be set.\n\n```ts\ninterface PostgresJobQueueConfig {\n backend?: 'postgres'; // Optional, defaults to 'postgres'\n databaseConfig?: {\n connectionString?: string;\n host?: string;\n port?: number;\n database?: string;\n user?: string;\n password?: string;\n ssl?: DatabaseSSLConfig;\n };\n pool?: import('pg').Pool; // Bring your own pool\n verbose?: boolean;\n}\n```\n\n#### RedisJobQueueConfig\n\nProvide either `redisConfig` (the library creates an ioredis client) or `client` (bring your own). At least one must be set.\n\n```ts\ninterface RedisJobQueueConfig {\n backend: 'redis'; // Required\n redisConfig?: {\n url?: string;\n host?: string;\n port?: number;\n password?: string;\n db?: number;\n tls?: RedisTLSConfig;\n keyPrefix?: string; // Default: 'dq:'\n };\n client?: unknown; // Bring your own ioredis client\n keyPrefix?: string; // Key prefix when using external client (default: 'dq:')\n verbose?: boolean;\n}\n```\n\n#### JobQueueConfig\n\n```ts\ntype JobQueueConfig = PostgresJobQueueConfig | RedisJobQueueConfig;\n```\n\n#### DatabaseSSLConfig\n\n```ts\ninterface DatabaseSSLConfig {\n ca?: string;\n cert?: string;\n key?: string;\n rejectUnauthorized?: boolean;\n}\n```\n\n- `ca` - Client certificate authority (CA) as PEM string or file path. If the value starts with 'file://', it will be loaded from file, otherwise treated as PEM string.\n- `cert` - Client certificate as PEM string or file path. If the value starts with 'file://', it will be loaded from file, otherwise treated as PEM string.\n- `key` - Client private key as PEM string or file path. If the value starts with 'file://', it will be loaded from file, otherwise treated as PEM string.\n- `rejectUnauthorized` - Whether to reject unauthorized certificates (default: true)\n\n#### RedisTLSConfig\n\n```ts\ninterface RedisTLSConfig {\n ca?: string;\n cert?: string;\n key?: string;\n rejectUnauthorized?: boolean;\n}\n```\n\n---\n\n## Adding Jobs\n\n### addJob\n\n```ts\naddJob(job: JobOptions, options?: AddJobOptions): Promise<number>\n```\n\nAdds a job to the queue. Returns the job ID.\n\n#### JobOptions\n\n```ts\ninterface JobOptions {\n jobType: string;\n payload: any;\n maxAttempts?: number;\n priority?: number;\n runAt?: Date | null;\n timeoutMs?: number;\n tags?: string[];\n idempotencyKey?: string;\n retryDelay?: number; // Base delay between retries in seconds (default: 60)\n retryBackoff?: boolean; // Use exponential backoff (default: true)\n retryDelayMax?: number; // Max delay cap in seconds (default: none)\n group?: { id: string; tier?: string }; // Optional group for global concurrency limits\n}\n```\n\n- `retryDelay` - Base delay between retries in seconds. When `retryBackoff` is true, this is the base for exponential backoff (`retryDelay * 2^attempts`). When false, retries use this fixed delay. Default: `60`.\n- `retryBackoff` - Whether to use exponential backoff. When true, delay doubles with each attempt and includes jitter. Default: `true`.\n- `retryDelayMax` - Maximum delay cap in seconds. Only meaningful when `retryBackoff` is true. No limit when omitted.\n- `group` - Optional grouping metadata. Use `group.id` to enforce global per-group limits with `ProcessorOptions.groupConcurrency`. `group.tier` is reserved for future policies.\n\n#### AddJobOptions\n\n```ts\ninterface AddJobOptions {\n db?: DatabaseClient;\n}\n```\n\n- `db` — An external database client (e.g., a `pg.PoolClient` inside a transaction). When provided, the INSERT runs on this client instead of the internal pool. **PostgreSQL only.** Throws if used with the Redis backend.\n\n### addJobs\n\n```ts\naddJobs(jobs: JobOptions[], options?: AddJobOptions): Promise<number[]>\n```\n\nAdds multiple jobs to the queue in a single operation. More efficient than calling `addJob` in a loop because it batches the INSERT into a single database round-trip (PostgreSQL) or a single atomic Lua script (Redis).\n\nReturns an array of job IDs in the same order as the input array.\n\nEach job can independently have its own `priority`, `runAt`, `tags`, `idempotencyKey`, and other options. Idempotency keys are handled per-job — duplicates resolve to the existing job's ID without creating a new row.\n\nPassing an empty array returns `[]` immediately without touching the database.\n\n```ts\nconst jobIds = await jobQueue.addJobs([\n {\n jobType: 'email',\n payload: { to: 'a@example.com', subject: 'Hi', body: '...' },\n },\n {\n jobType: 'email',\n payload: { to: 'b@example.com', subject: 'Hi', body: '...' },\n priority: 10,\n },\n {\n jobType: 'report',\n payload: { reportId: '123', userId: '456' },\n tags: ['monthly'],\n },\n]);\n// jobIds = [1, 2, 3]\n```\n\nThe `{ db }` option works the same as `addJob` — pass a transactional client to batch-insert within an existing transaction (PostgreSQL only).\n\n#### DatabaseClient\n\n```ts\ninterface DatabaseClient {\n query(\n text: string,\n values?: any[],\n ): Promise<{ rows: any[]; rowCount: number | null }>;\n}\n```\n\nAny object matching this interface works — `pg.Pool`, `pg.PoolClient`, `pg.Client`, or ORM query runners that expose a raw `query()` method.\n\n---\n\n## Retrieving Jobs\n\n### getJob\n\n```ts\ngetJob(id: number): Promise<JobRecord | null>\n```\n\nRetrieves a job by its ID.\n\n### getJobs\n\n```ts\ngetJobs(\n filters?: {\n jobType?: string;\n priority?: number;\n runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };\n tags?: { values: string[]; mode?: 'all' | 'any' | 'none' | 'exact' };\n },\n limit?: number,\n offset?: number\n): Promise<JobRecord[]>\n```\n\nRetrieves jobs matching the provided filters, with optional pagination.\n\n### getJobsByStatus\n\n```ts\ngetJobsByStatus(status: string, limit?: number, offset?: number): Promise<JobRecord[]>\n```\n\nRetrieves jobs by their status, with pagination.\n\n### getAllJobs\n\n```ts\ngetAllJobs(limit?: number, offset?: number): Promise<JobRecord[]>\n```\n\nRetrieves all jobs, with optional pagination.\n\n### getJobsByTags\n\n```ts\ngetJobsByTags(tags: string[], mode?: TagQueryMode, limit?: number, offset?: number): Promise<JobRecord[]>\n```\n\nRetrieves jobs by tag(s).\n\n---\n\n## Managing Jobs\n\n### retryJob\n\n```ts\nretryJob(jobId: number): Promise<void>\n```\n\nRetries a job given its ID.\n\n### cancelJob\n\n```ts\ncancelJob(jobId: number): Promise<void>\n```\n\nCancels a job given its ID.\n\n### editJob\n\n```ts\neditJob(jobId: number, updates: EditJobOptions): Promise<void>\n```\n\nEdits a pending job given its ID. Only works for jobs with status 'pending'. Silently fails for other statuses (processing, completed, failed, cancelled).\n\n#### EditJobOptions\n\n```ts\ninterface EditJobOptions {\n payload?: any;\n maxAttempts?: number;\n priority?: number;\n runAt?: Date | null;\n timeoutMs?: number;\n tags?: string[];\n retryDelay?: number | null;\n retryBackoff?: boolean | null;\n retryDelayMax?: number | null;\n}\n```\n\nAll fields are optional - only provided fields will be updated. Note that `jobType` cannot be changed. Set retry fields to `null` to revert to legacy default behavior.\n\n#### Example\n\n```ts\n// Edit a pending job's payload and priority\nawait jobQueue.editJob(jobId, {\n payload: { to: 'newemail@example.com', subject: 'Updated' },\n priority: 10,\n});\n\n// Edit only the scheduled run time\nawait jobQueue.editJob(jobId, {\n runAt: new Date(Date.now() + 60000), // Run in 1 minute\n});\n\n// Edit multiple fields at once\nawait jobQueue.editJob(jobId, {\n payload: { to: 'updated@example.com' },\n priority: 5,\n maxAttempts: 10,\n timeoutMs: 30000,\n tags: ['urgent', 'priority'],\n});\n```\n\n### editAllPendingJobs\n\n```ts\neditAllPendingJobs(\n filters?: {\n jobType?: string;\n priority?: number;\n runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };\n tags?: { values: string[]; mode?: 'all' | 'any' | 'none' | 'exact' };\n },\n updates: EditJobOptions\n): Promise<number>\n```\n\nEdits all pending jobs that match the filters. Only works for jobs with status 'pending'. Non-pending jobs are not affected. Returns the number of jobs that were edited.\n\n#### Parameters\n\n- `filters` (optional): Filters to select which jobs to edit. If not provided, all pending jobs are edited.\n - `jobType`: Filter by job type\n - `priority`: Filter by priority\n - `runAt`: Filter by scheduled run time (supports `gt`, `gte`, `lt`, `lte`, `eq` operators or exact Date match)\n - `tags`: Filter by tags with mode ('all', 'any', 'none', 'exact')\n- `updates`: The fields to update (same as `EditJobOptions`). All fields are optional - only provided fields will be updated.\n\n#### Returns\n\nThe number of jobs that were successfully edited.\n\n#### Examples\n\n```ts\n// Edit all pending jobs\nconst editedCount = await jobQueue.editAllPendingJobs(undefined, {\n priority: 10,\n});\n\n// Edit all pending email jobs\nconst editedCount = await jobQueue.editAllPendingJobs(\n { jobType: 'email' },\n {\n priority: 5,\n },\n);\n\n// Edit all pending jobs with 'urgent' tag\nconst editedCount = await jobQueue.editAllPendingJobs(\n { tags: { values: ['urgent'], mode: 'any' } },\n {\n priority: 10,\n maxAttempts: 5,\n },\n);\n\n// Edit all pending jobs scheduled in the future\nconst editedCount = await jobQueue.editAllPendingJobs(\n { runAt: { gte: new Date() } },\n {\n priority: 10,\n },\n);\n\n// Edit with combined filters\nconst editedCount = await jobQueue.editAllPendingJobs(\n {\n jobType: 'email',\n tags: { values: ['urgent'], mode: 'any' },\n },\n {\n priority: 10,\n maxAttempts: 5,\n },\n);\n```\n\n**Note:** Only pending jobs are edited. Jobs with other statuses (processing, completed, failed, cancelled) are not affected. Edit events are recorded for each affected job, just like single job edits.\n\n### cancelAllUpcomingJobs\n\n```ts\ncancelAllUpcomingJobs(filters?: {\n jobType?: string;\n priority?: number;\n runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };\n tags?: { values: string[]; mode?: 'all' | 'any' | 'none' | 'exact' };\n}): Promise<number>\n```\n\nCancels all upcoming jobs that match the filters. Returns the number of jobs cancelled.\n\n### cleanupOldJobs\n\n```ts\ncleanupOldJobs(daysToKeep?: number): Promise<number>\n```\n\nCleans up jobs older than the specified number of days. Returns the number of jobs removed.\n\n### reclaimStuckJobs\n\n```ts\nreclaimStuckJobs(maxProcessingTimeMinutes?: number): Promise<number>\n```\n\nReclaims jobs stuck in 'processing' for too long. Returns the number of jobs reclaimed. If a job has a `timeoutMs` that is longer than the `maxProcessingTimeMinutes` threshold, the job's own timeout is used instead, preventing premature reclamation of long-running jobs.\n\n---\n\n## Job Events\n\n### getJobEvents\n\n```ts\ngetJobEvents(jobId: number): Promise<JobEvent[]>\n```\n\nRetrieves the job events for a job.\n\n#### JobEvent\n\n```ts\ninterface JobEvent {\n id: number;\n jobId: number;\n eventType: JobEventType;\n createdAt: Date;\n metadata: any;\n}\n```\n\n#### JobEventType\n\n```ts\nenum JobEventType {\n Added = 'added',\n Processing = 'processing',\n Completed = 'completed',\n Failed = 'failed',\n Cancelled = 'cancelled',\n Retried = 'retried',\n Edited = 'edited',\n}\n```\n\n---\n\n## Event Hooks\n\nDataQueue emits real-time events for job lifecycle transitions. Register listeners using `on`, `once`, `off`, and `removeAllListeners`. Works identically with both PostgreSQL and Redis backends.\n\n### QueueEventMap\n\n```ts\ninterface QueueEventMap {\n 'job:added': { jobId: number; jobType: string };\n 'job:processing': { jobId: number; jobType: string };\n 'job:completed': { jobId: number; jobType: string };\n 'job:failed': {\n jobId: number;\n jobType: string;\n error: Error;\n willRetry: boolean;\n };\n 'job:cancelled': { jobId: number };\n 'job:retried': { jobId: number };\n 'job:waiting': { jobId: number; jobType: string };\n 'job:progress': { jobId: number; progress: number };\n error: Error;\n}\n```\n\n### on\n\n```ts\non(event: QueueEventName, listener: (data) => void): void\n```\n\nRegister a listener that fires every time the event is emitted.\n\n### once\n\n```ts\nonce(event: QueueEventName, listener: (data) => void): void\n```\n\nRegister a one-time listener that auto-removes after the first invocation.\n\n### off\n\n```ts\noff(event: QueueEventName, listener: (data) => void): void\n```\n\nRemove a previously registered listener. Pass the exact function reference used with `on` or `once`.\n\n### removeAllListeners\n\n```ts\nremoveAllListeners(event?: QueueEventName): void\n```\n\nRemove all listeners for a specific event, or all listeners for all events when called without arguments.\n\nSee [Event Hooks](/usage/event-hooks) for detailed usage examples.\n\n---\n\n## Processing Jobs\n\n### createProcessor\n\n```ts\ncreateProcessor(\n handlers: JobHandlers,\n options?: ProcessorOptions\n): Processor\n```\n\nCreates a job processor with the provided handlers and options.\n\n#### ProcessorOptions\n\n```ts\ninterface ProcessorOptions {\n workerId?: string;\n batchSize?: number;\n concurrency?: number;\n groupConcurrency?: number;\n pollInterval?: number;\n onError?: (error: Error) => void;\n verbose?: boolean;\n jobType?: string | string[];\n}\n```\n\n- `groupConcurrency` - Optional global per-group concurrency limit (positive integer). Applies only to jobs with `group.id`; ungrouped jobs are unaffected.\n\n---\n\n## Background Supervisor\n\n### createSupervisor\n\n```ts\ncreateSupervisor(options?: SupervisorOptions): Supervisor\n```\n\nCreates a background supervisor that automatically runs maintenance tasks on a configurable interval: reclaiming stuck jobs, cleaning up old completed jobs/events, and expiring timed-out waitpoint tokens.\n\n#### SupervisorOptions\n\n```ts\ninterface SupervisorOptions {\n intervalMs?: number; // default: 60000\n stuckJobsTimeoutMinutes?: number; // default: 10\n cleanupJobsDaysToKeep?: number; // default: 30 (0 to disable)\n cleanupEventsDaysToKeep?: number; // default: 30 (0 to disable)\n cleanupBatchSize?: number; // default: 1000\n reclaimStuckJobs?: boolean; // default: true\n expireTimedOutTokens?: boolean; // default: true\n onError?: (error: Error) => void; // default: console.error\n verbose?: boolean;\n}\n```\n\n#### Supervisor\n\n```ts\ninterface Supervisor {\n start(): Promise<SupervisorRunResult>;\n startInBackground(): void;\n stop(): void;\n stopAndDrain(timeoutMs?: number): Promise<void>;\n isRunning(): boolean;\n}\n```\n\n- `start()` runs all tasks once and returns the results (serverless-friendly).\n- `startInBackground()` starts a background loop that runs every `intervalMs`.\n- `stopAndDrain()` stops the loop and waits for the current run to finish.\n\n#### SupervisorRunResult\n\n```ts\ninterface SupervisorRunResult {\n reclaimedJobs: number;\n cleanedUpJobs: number;\n cleanedUpEvents: number;\n expiredTokens: number;\n}\n```\n\nSee [Long-Running Server](/usage/long-running-server#background-supervisor) for usage examples.\n\n---\n\n## Accessing the Underlying Client\n\n### getPool\n\n```ts\ngetPool(): Pool\n```\n\nReturns the PostgreSQL connection pool instance. Only available when using the PostgreSQL backend.\n\n> **Note:** Throws an error if called when using the Redis backend.\n\n### getRedisClient\n\n```ts\ngetRedisClient(): Redis\n```\n\nReturns the `ioredis` client instance. Only available when using the Redis backend.\n\n> **Note:** Throws an error if called when using the PostgreSQL backend."
42
+ "content": "## Initialization\n\n### initJobQueue\n\n```ts\ninitJobQueue(config: JobQueueConfig): JobQueue\n```\n\nInitializes the job queue system with the provided configuration. The `JobQueueConfig` is a discriminated union -- you provide either a PostgreSQL or Redis configuration.\n\n#### PostgresJobQueueConfig\n\nProvide either `databaseConfig` (the library creates a pool) or `pool` (bring your own `pg.Pool`). At least one must be set.\n\n```ts\ninterface PostgresJobQueueConfig {\n backend?: 'postgres'; // Optional, defaults to 'postgres'\n databaseConfig?: {\n connectionString?: string;\n host?: string;\n port?: number;\n database?: string;\n user?: string;\n password?: string;\n ssl?: DatabaseSSLConfig;\n };\n pool?: import('pg').Pool; // Bring your own pool\n verbose?: boolean;\n}\n```\n\n#### RedisJobQueueConfig\n\nProvide either `redisConfig` (the library creates an ioredis client) or `client` (bring your own). At least one must be set.\n\n```ts\ninterface RedisJobQueueConfig {\n backend: 'redis'; // Required\n redisConfig?: {\n url?: string;\n host?: string;\n port?: number;\n password?: string;\n db?: number;\n tls?: RedisTLSConfig;\n keyPrefix?: string; // Default: 'dq:'\n };\n client?: unknown; // Bring your own ioredis client\n keyPrefix?: string; // Key prefix when using external client (default: 'dq:')\n verbose?: boolean;\n}\n```\n\n#### JobQueueConfig\n\n```ts\ntype JobQueueConfig = PostgresJobQueueConfig | RedisJobQueueConfig;\n```\n\n#### DatabaseSSLConfig\n\n```ts\ninterface DatabaseSSLConfig {\n ca?: string;\n cert?: string;\n key?: string;\n rejectUnauthorized?: boolean;\n}\n```\n\n- `ca` - Client certificate authority (CA) as PEM string or file path. If the value starts with 'file://', it will be loaded from file, otherwise treated as PEM string.\n- `cert` - Client certificate as PEM string or file path. If the value starts with 'file://', it will be loaded from file, otherwise treated as PEM string.\n- `key` - Client private key as PEM string or file path. If the value starts with 'file://', it will be loaded from file, otherwise treated as PEM string.\n- `rejectUnauthorized` - Whether to reject unauthorized certificates (default: true)\n\n#### RedisTLSConfig\n\n```ts\ninterface RedisTLSConfig {\n ca?: string;\n cert?: string;\n key?: string;\n rejectUnauthorized?: boolean;\n}\n```\n\n---\n\n## Adding Jobs\n\n### addJob\n\n```ts\naddJob(job: JobOptions, options?: AddJobOptions): Promise<number>\n```\n\nAdds a job to the queue. Returns the job ID.\n\n#### JobOptions\n\n```ts\ninterface JobOptions {\n jobType: string;\n payload: any;\n maxAttempts?: number;\n priority?: number;\n runAt?: Date | null;\n timeoutMs?: number;\n tags?: string[];\n idempotencyKey?: string;\n retryDelay?: number; // Base delay between retries in seconds (default: 60)\n retryBackoff?: boolean; // Use exponential backoff (default: true)\n retryDelayMax?: number; // Max delay cap in seconds (default: none)\n deadLetterJobType?: string; // Route exhausted failures to this job type\n group?: { id: string; tier?: string }; // Optional group for global concurrency limits\n}\n```\n\n- `retryDelay` - Base delay between retries in seconds. When `retryBackoff` is true, this is the base for exponential backoff (`retryDelay * 2^attempts`). When false, retries use this fixed delay. Default: `60`.\n- `retryBackoff` - Whether to use exponential backoff. When true, delay doubles with each attempt and includes jitter. Default: `true`.\n- `retryDelayMax` - Maximum delay cap in seconds. Only meaningful when `retryBackoff` is true. No limit when omitted.\n- `deadLetterJobType` - Optional dead-letter destination. When retries are exhausted, a new pending job is created in this job type with an envelope payload (`originalJob`, `originalPayload`, `failure`).\n- `group` - Optional grouping metadata. Use `group.id` to enforce global per-group limits with `ProcessorOptions.groupConcurrency`. `group.tier` is reserved for future policies.\n\n#### AddJobOptions\n\n```ts\ninterface AddJobOptions {\n db?: DatabaseClient;\n}\n```\n\n- `db` — An external database client (e.g., a `pg.PoolClient` inside a transaction). When provided, the INSERT runs on this client instead of the internal pool. **PostgreSQL only.** Throws if used with the Redis backend.\n\n### addJobs\n\n```ts\naddJobs(jobs: JobOptions[], options?: AddJobOptions): Promise<number[]>\n```\n\nAdds multiple jobs to the queue in a single operation. More efficient than calling `addJob` in a loop because it batches the INSERT into a single database round-trip (PostgreSQL) or a single atomic Lua script (Redis).\n\nReturns an array of job IDs in the same order as the input array.\n\nEach job can independently have its own `priority`, `runAt`, `tags`, `idempotencyKey`, and other options. Idempotency keys are handled per-job — duplicates resolve to the existing job's ID without creating a new row.\n\nPassing an empty array returns `[]` immediately without touching the database.\n\n```ts\nconst jobIds = await jobQueue.addJobs([\n {\n jobType: 'email',\n payload: { to: 'a@example.com', subject: 'Hi', body: '...' },\n },\n {\n jobType: 'email',\n payload: { to: 'b@example.com', subject: 'Hi', body: '...' },\n priority: 10,\n },\n {\n jobType: 'report',\n payload: { reportId: '123', userId: '456' },\n tags: ['monthly'],\n },\n]);\n// jobIds = [1, 2, 3]\n```\n\nThe `{ db }` option works the same as `addJob` — pass a transactional client to batch-insert within an existing transaction (PostgreSQL only).\n\n#### DatabaseClient\n\n```ts\ninterface DatabaseClient {\n query(\n text: string,\n values?: any[],\n ): Promise<{ rows: any[]; rowCount: number | null }>;\n}\n```\n\nAny object matching this interface works — `pg.Pool`, `pg.PoolClient`, `pg.Client`, or ORM query runners that expose a raw `query()` method.\n\n---\n\n## Retrieving Jobs\n\n### getJob\n\n```ts\ngetJob(id: number): Promise<JobRecord | null>\n```\n\nRetrieves a job by its ID.\n\n### getJobs\n\n```ts\ngetJobs(\n filters?: {\n jobType?: string;\n priority?: number;\n runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };\n tags?: { values: string[]; mode?: 'all' | 'any' | 'none' | 'exact' };\n },\n limit?: number,\n offset?: number\n): Promise<JobRecord[]>\n```\n\nRetrieves jobs matching the provided filters, with optional pagination.\n\n### getJobsByStatus\n\n```ts\ngetJobsByStatus(status: string, limit?: number, offset?: number): Promise<JobRecord[]>\n```\n\nRetrieves jobs by their status, with pagination.\n\n### getAllJobs\n\n```ts\ngetAllJobs(limit?: number, offset?: number): Promise<JobRecord[]>\n```\n\nRetrieves all jobs, with optional pagination.\n\n### getJobsByTags\n\n```ts\ngetJobsByTags(tags: string[], mode?: TagQueryMode, limit?: number, offset?: number): Promise<JobRecord[]>\n```\n\nRetrieves jobs by tag(s).\n\n---\n\n## Managing Jobs\n\n### retryJob\n\n```ts\nretryJob(jobId: number): Promise<void>\n```\n\nRetries a job given its ID.\n\n### cancelJob\n\n```ts\ncancelJob(jobId: number): Promise<void>\n```\n\nCancels a job given its ID.\n\n### editJob\n\n```ts\neditJob(jobId: number, updates: EditJobOptions): Promise<void>\n```\n\nEdits a pending job given its ID. Only works for jobs with status 'pending'. Silently fails for other statuses (processing, completed, failed, cancelled).\n\n#### EditJobOptions\n\n```ts\ninterface EditJobOptions {\n payload?: any;\n maxAttempts?: number;\n priority?: number;\n runAt?: Date | null;\n timeoutMs?: number;\n tags?: string[];\n retryDelay?: number | null;\n retryBackoff?: boolean | null;\n retryDelayMax?: number | null;\n deadLetterJobType?: string | null;\n}\n```\n\nAll fields are optional - only provided fields will be updated. Note that `jobType` cannot be changed. Set retry fields to `null` to revert to legacy default behavior. Set `deadLetterJobType` to `null` to clear dead-letter routing for pending jobs.\n\n#### Example\n\n```ts\n// Edit a pending job's payload and priority\nawait jobQueue.editJob(jobId, {\n payload: { to: 'newemail@example.com', subject: 'Updated' },\n priority: 10,\n});\n\n// Edit only the scheduled run time\nawait jobQueue.editJob(jobId, {\n runAt: new Date(Date.now() + 60000), // Run in 1 minute\n});\n\n// Edit multiple fields at once\nawait jobQueue.editJob(jobId, {\n payload: { to: 'updated@example.com' },\n priority: 5,\n maxAttempts: 10,\n timeoutMs: 30000,\n tags: ['urgent', 'priority'],\n});\n```\n\n### editAllPendingJobs\n\n```ts\neditAllPendingJobs(\n filters?: {\n jobType?: string;\n priority?: number;\n runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };\n tags?: { values: string[]; mode?: 'all' | 'any' | 'none' | 'exact' };\n },\n updates: EditJobOptions\n): Promise<number>\n```\n\nEdits all pending jobs that match the filters. Only works for jobs with status 'pending'. Non-pending jobs are not affected. Returns the number of jobs that were edited.\n\n#### Parameters\n\n- `filters` (optional): Filters to select which jobs to edit. If not provided, all pending jobs are edited.\n - `jobType`: Filter by job type\n - `priority`: Filter by priority\n - `runAt`: Filter by scheduled run time (supports `gt`, `gte`, `lt`, `lte`, `eq` operators or exact Date match)\n - `tags`: Filter by tags with mode ('all', 'any', 'none', 'exact')\n- `updates`: The fields to update (same as `EditJobOptions`). All fields are optional - only provided fields will be updated.\n\n#### Returns\n\nThe number of jobs that were successfully edited.\n\n#### Examples\n\n```ts\n// Edit all pending jobs\nconst editedCount = await jobQueue.editAllPendingJobs(undefined, {\n priority: 10,\n});\n\n// Edit all pending email jobs\nconst editedCount = await jobQueue.editAllPendingJobs(\n { jobType: 'email' },\n {\n priority: 5,\n },\n);\n\n// Edit all pending jobs with 'urgent' tag\nconst editedCount = await jobQueue.editAllPendingJobs(\n { tags: { values: ['urgent'], mode: 'any' } },\n {\n priority: 10,\n maxAttempts: 5,\n },\n);\n\n// Edit all pending jobs scheduled in the future\nconst editedCount = await jobQueue.editAllPendingJobs(\n { runAt: { gte: new Date() } },\n {\n priority: 10,\n },\n);\n\n// Edit with combined filters\nconst editedCount = await jobQueue.editAllPendingJobs(\n {\n jobType: 'email',\n tags: { values: ['urgent'], mode: 'any' },\n },\n {\n priority: 10,\n maxAttempts: 5,\n },\n);\n```\n\n**Note:** Only pending jobs are edited. Jobs with other statuses (processing, completed, failed, cancelled) are not affected. Edit events are recorded for each affected job, just like single job edits.\n\n### cancelAllUpcomingJobs\n\n```ts\ncancelAllUpcomingJobs(filters?: {\n jobType?: string;\n priority?: number;\n runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };\n tags?: { values: string[]; mode?: 'all' | 'any' | 'none' | 'exact' };\n}): Promise<number>\n```\n\nCancels all upcoming jobs that match the filters. Returns the number of jobs cancelled.\n\n### cleanupOldJobs\n\n```ts\ncleanupOldJobs(daysToKeep?: number): Promise<number>\n```\n\nCleans up jobs older than the specified number of days. Returns the number of jobs removed.\n\n### reclaimStuckJobs\n\n```ts\nreclaimStuckJobs(maxProcessingTimeMinutes?: number): Promise<number>\n```\n\nReclaims jobs stuck in 'processing' for too long. Returns the number of jobs reclaimed. If a job has a `timeoutMs` that is longer than the `maxProcessingTimeMinutes` threshold, the job's own timeout is used instead, preventing premature reclamation of long-running jobs.\n\n---\n\n## Job Events\n\n### getJobEvents\n\n```ts\ngetJobEvents(jobId: number): Promise<JobEvent[]>\n```\n\nRetrieves the job events for a job.\n\n#### JobEvent\n\n```ts\ninterface JobEvent {\n id: number;\n jobId: number;\n eventType: JobEventType;\n createdAt: Date;\n metadata: any;\n}\n```\n\n#### JobEventType\n\n```ts\nenum JobEventType {\n Added = 'added',\n Processing = 'processing',\n Completed = 'completed',\n Failed = 'failed',\n Cancelled = 'cancelled',\n Retried = 'retried',\n Edited = 'edited',\n}\n```\n\n---\n\n## Event Hooks\n\nDataQueue emits real-time events for job lifecycle transitions. Register listeners using `on`, `once`, `off`, and `removeAllListeners`. Works identically with both PostgreSQL and Redis backends.\n\n### QueueEventMap\n\n```ts\ninterface QueueEventMap {\n 'job:added': { jobId: number; jobType: string };\n 'job:processing': { jobId: number; jobType: string };\n 'job:completed': { jobId: number; jobType: string };\n 'job:failed': {\n jobId: number;\n jobType: string;\n error: Error;\n willRetry: boolean;\n };\n 'job:cancelled': { jobId: number };\n 'job:retried': { jobId: number };\n 'job:waiting': { jobId: number; jobType: string };\n 'job:progress': { jobId: number; progress: number };\n error: Error;\n}\n```\n\n### on\n\n```ts\non(event: QueueEventName, listener: (data) => void): void\n```\n\nRegister a listener that fires every time the event is emitted.\n\n### once\n\n```ts\nonce(event: QueueEventName, listener: (data) => void): void\n```\n\nRegister a one-time listener that auto-removes after the first invocation.\n\n### off\n\n```ts\noff(event: QueueEventName, listener: (data) => void): void\n```\n\nRemove a previously registered listener. Pass the exact function reference used with `on` or `once`.\n\n### removeAllListeners\n\n```ts\nremoveAllListeners(event?: QueueEventName): void\n```\n\nRemove all listeners for a specific event, or all listeners for all events when called without arguments.\n\nSee [Event Hooks](/usage/event-hooks) for detailed usage examples.\n\n---\n\n## Processing Jobs\n\n### createProcessor\n\n```ts\ncreateProcessor(\n handlers: JobHandlers,\n options?: ProcessorOptions\n): Processor\n```\n\nCreates a job processor with the provided handlers and options.\n\n#### ProcessorOptions\n\n```ts\ninterface ProcessorOptions {\n workerId?: string;\n batchSize?: number;\n concurrency?: number;\n groupConcurrency?: number;\n pollInterval?: number;\n onError?: (error: Error) => void;\n verbose?: boolean;\n jobType?: string | string[];\n}\n```\n\n- `groupConcurrency` - Optional global per-group concurrency limit (positive integer). Applies only to jobs with `group.id`; ungrouped jobs are unaffected.\n\n---\n\n## Background Supervisor\n\n### createSupervisor\n\n```ts\ncreateSupervisor(options?: SupervisorOptions): Supervisor\n```\n\nCreates a background supervisor that automatically runs maintenance tasks on a configurable interval: reclaiming stuck jobs, cleaning up old completed jobs/events, and expiring timed-out waitpoint tokens.\n\n#### SupervisorOptions\n\n```ts\ninterface SupervisorOptions {\n intervalMs?: number; // default: 60000\n stuckJobsTimeoutMinutes?: number; // default: 10\n cleanupJobsDaysToKeep?: number; // default: 30 (0 to disable)\n cleanupEventsDaysToKeep?: number; // default: 30 (0 to disable)\n cleanupBatchSize?: number; // default: 1000\n reclaimStuckJobs?: boolean; // default: true\n expireTimedOutTokens?: boolean; // default: true\n onError?: (error: Error) => void; // default: console.error\n verbose?: boolean;\n}\n```\n\n#### Supervisor\n\n```ts\ninterface Supervisor {\n start(): Promise<SupervisorRunResult>;\n startInBackground(): void;\n stop(): void;\n stopAndDrain(timeoutMs?: number): Promise<void>;\n isRunning(): boolean;\n}\n```\n\n- `start()` runs all tasks once and returns the results (serverless-friendly).\n- `startInBackground()` starts a background loop that runs every `intervalMs`.\n- `stopAndDrain()` stops the loop and waits for the current run to finish.\n\n#### SupervisorRunResult\n\n```ts\ninterface SupervisorRunResult {\n reclaimedJobs: number;\n cleanedUpJobs: number;\n cleanedUpEvents: number;\n expiredTokens: number;\n}\n```\n\nSee [Long-Running Server](/usage/long-running-server#background-supervisor) for usage examples.\n\n---\n\n## Accessing the Underlying Client\n\n### getPool\n\n```ts\ngetPool(): Pool\n```\n\nReturns the PostgreSQL connection pool instance. Only available when using the PostgreSQL backend.\n\n> **Note:** Throws an error if called when using the Redis backend.\n\n### getRedisClient\n\n```ts\ngetRedisClient(): Redis\n```\n\nReturns the `ioredis` client instance. Only available when using the Redis backend.\n\n> **Note:** Throws an error if called when using the PostgreSQL backend."
43
43
  },
44
44
  {
45
45
  "slug": "api/job-record",
46
46
  "title": "JobRecord",
47
47
  "description": "",
48
- "content": "The `JobRecord` interface represents a job stored in the queue, including its status, attempts, and metadata.\n\n## Fields\n\n- `id`: _number_ — Unique job ID.\n- `jobType`: _string_ — The type of the job.\n- `payload`: _any_ — The job payload.\n- `status`:\n _'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'_ —\n Current job status.\n- `createdAt`: _Date_ — When the job was created.\n- `updated_at`: _Date_ — When the job was last updated.\n- `locked_at`: _Date | null_ — When the job was locked for\n processing.\n- `locked_by`: _string | null_ — Worker that locked the job.\n- `attempts`: _number_ — Number of attempts so far.\n- `maxAttempts`: _number_ — Maximum allowed attempts.\n- `nextAttemptAt`: _Date | null_ — When the next attempt is\n scheduled.\n- `priority`: _number_ — Job priority.\n- `runAt`: _Date_ — When the job is scheduled to run.\n- `pendingReason?`: _string | null_ — Reason for pending\n status.\n- `errorHistory?`: _\\{ message: string; timestamp: string \\}[]_ — Error history for the job.\n- `timeoutMs?`: _number | null_ — Timeout for this job in\n milliseconds.\n- `failureReason?`: _FailureReason | null_ — Reason for last\n failure, if any.\n- `completedAt`: _Date | null_ — When the job was completed.\n- `startedAt`: _Date | null_ — When the job was first picked up\n for processing.\n- `lastRetriedAt`: _Date | null_ — When the job was last\n retried.\n- `lastFailedAt`: _Date | null_ — When the job last failed.\n- `lastCancelledAt`: _Date | null_ — When the job was last\n cancelled.\n- `tags?`: _string[]_ — Tags for this job. Used for grouping, searching, or batch operations.\n- `idempotencyKey?`: _string | null_ — The idempotency key for this job, if one was provided when the job was created.\n- `progress?`: _number | null_ — Progress percentage (0–100) reported by the handler via `ctx.setProgress()`. `null` if no progress has been reported. See [Progress Tracking](/usage/progress-tracking).\n- `output?`: _unknown_ — Handler output stored via `ctx.setOutput(data)` or by returning a value from the handler. `null` if no output has been stored. See [Job Output](/usage/job-output).\n\n## Example\n\n```json\n{\n \"id\": 1,\n \"jobType\": \"email\",\n \"payload\": { \"to\": \"user@example.com\", \"subject\": \"Hello\" },\n \"status\": \"completed\",\n \"createdAt\": \"2024-06-01T12:00:00Z\",\n \"tags\": [\"welcome\", \"user\"],\n \"idempotencyKey\": \"welcome-email-user-123\",\n \"progress\": 100,\n \"output\": { \"messageId\": \"abc-123\", \"sentAt\": \"2024-06-01T12:00:05Z\" }\n}\n```"
48
+ "content": "The `JobRecord` interface represents a job stored in the queue, including its status, attempts, and metadata.\n\n## Fields\n\n- `id`: _number_ — Unique job ID.\n- `jobType`: _string_ — The type of the job.\n- `payload`: _any_ — The job payload.\n- `status`:\n _'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'waiting'_ —\n Current job status.\n- `createdAt`: _Date_ — When the job was created.\n- `updated_at`: _Date_ — When the job was last updated.\n- `locked_at`: _Date | null_ — When the job was locked for\n processing.\n- `locked_by`: _string | null_ — Worker that locked the job.\n- `attempts`: _number_ — Number of attempts so far.\n- `maxAttempts`: _number_ — Maximum allowed attempts.\n- `nextAttemptAt`: _Date | null_ — When the next attempt is\n scheduled.\n- `priority`: _number_ — Job priority.\n- `runAt`: _Date_ — When the job is scheduled to run.\n- `pendingReason?`: _string | null_ — Reason for pending\n status.\n- `errorHistory?`: _\\{ message: string; timestamp: string \\}[]_ — Error history for the job.\n- `timeoutMs?`: _number | null_ — Timeout for this job in\n milliseconds.\n- `failureReason?`: _FailureReason | null_ — Reason for last\n failure, if any.\n- `completedAt`: _Date | null_ — When the job was completed.\n- `startedAt`: _Date | null_ — When the job was first picked up\n for processing.\n- `lastRetriedAt`: _Date | null_ — When the job was last\n retried.\n- `lastFailedAt`: _Date | null_ — When the job last failed.\n- `lastCancelledAt`: _Date | null_ — When the job was last\n cancelled.\n- `tags?`: _string[]_ — Tags for this job. Used for grouping, searching, or batch operations.\n- `idempotencyKey?`: _string | null_ — The idempotency key for this job, if one was provided when the job was created.\n- `progress?`: _number | null_ — Progress percentage (0–100) reported by the handler via `ctx.setProgress()`. `null` if no progress has been reported. See [Progress Tracking](/usage/progress-tracking).\n- `output?`: _unknown_ — Handler output stored via `ctx.setOutput(data)` or by returning a value from the handler. `null` if no output has been stored. See [Job Output](/usage/job-output).\n- `deadLetterJobType?`: _string | null_ — Configured dead-letter destination job type for this job.\n- `deadLetteredAt?`: _Date | null_ — Timestamp when this job was routed to a dead-letter job.\n- `deadLetterJobId?`: _number | null_ — Linked dead-letter job ID created when retries were exhausted.\n\n## Example\n\n```json\n{\n \"id\": 1,\n \"jobType\": \"email\",\n \"payload\": { \"to\": \"user@example.com\", \"subject\": \"Hello\" },\n \"status\": \"completed\",\n \"createdAt\": \"2024-06-01T12:00:00Z\",\n \"tags\": [\"welcome\", \"user\"],\n \"idempotencyKey\": \"welcome-email-user-123\",\n \"progress\": 100,\n \"output\": { \"messageId\": \"abc-123\", \"sentAt\": \"2024-06-01T12:00:05Z\" }\n}\n```"
49
49
  },
50
50
  {
51
51
  "slug": "api/processor",
@@ -147,7 +147,7 @@
147
147
  "slug": "usage/building-with-ai",
148
148
  "title": "Building with AI",
149
149
  "description": "Tools and resources for building DataQueue projects with AI coding assistants.",
150
- "content": "We provide multiple tools to help AI coding assistants write correct DataQueue code. Use one or all of them for the best developer experience.\n\n## Quick Setup\n\n### 1. Install Skills\n\nPortable instruction sets that teach any AI coding assistant DataQueue best practices.\n\n```bash\nnpx dataqueue-cli install-skills\n```\n\nSkills are installed as `SKILL.md` files into your AI tool's skills directory (`.cursor/skills/`, `.claude/skills/`, etc.). They cover core patterns, advanced features (waits, cron, tokens), and React/Dashboard integration.\n\n### 2. Install Agent Rules\n\nComprehensive rule sets installed directly into your AI client's config files.\n\n```bash\nnpx dataqueue-cli install-rules\n```\n\nThe installer prompts you to choose your AI client and writes rules to the appropriate location:\n\n| Client | Installs to |\n| -------------- | --------------------------------- |\n| Cursor | `.cursor/rules/dataqueue-*.mdc` |\n| Claude Code | `CLAUDE.md` |\n| AGENTS.md | `AGENTS.md` |\n| GitHub Copilot | `.github/copilot-instructions.md` |\n| Windsurf | `CONVENTIONS.md` |\n\n### 3. Install MCP Server\n\nGive your AI assistant direct access to DataQueue documentation — search docs, fetch specific pages, and list all available topics.\n\n```bash\nnpx dataqueue-cli install-mcp\n```\n\nThe installer prompts you to choose your AI client and writes the MCP config to the appropriate location. Currently supported clients:\n\n| Client | Installs to |\n| ----------------- | ------------------------------------- |\n| Cursor | `.cursor/mcp.json` |\n| Claude Code | `.mcp.json` |\n| VS Code (Copilot) | `.vscode/mcp.json` |\n| Windsurf | `~/.codeium/windsurf/mcp_config.json` |\n\nThe MCP server runs via `npx dataqueue-cli mcp` and communicates over stdio. It exposes three tools:\n\n| Tool | Description |\n| ---------------- | ---------------------------------------- |\n| `search-docs` | Full-text search across all doc pages |\n| `get-doc-page` | Fetch a specific doc page by slug |\n| `list-doc-pages` | List all available doc pages with titles |\n\n## Skills vs Agent Rules vs MCP\n\n| | **Skills** | **Agent Rules** | **MCP Server** |\n| :---------------- | :----------------------------------- | :------------------------------------- | :------------------------------------- |\n| **What it does** | Drops skill files into your project | Installs rule sets into client config | Runs a live server your AI connects to |\n| **Installs to** | `.cursor/skills/`, `.claude/skills/` | `.cursor/rules/`, `CLAUDE.md`, etc. | `.cursor/mcp.json`, `.mcp.json`, etc. |\n| **Best for** | Teaching patterns and best practices | Comprehensive code generation guidance | Live documentation search |\n| **Works offline** | Yes | Yes | Yes (runs locally) |\n\n**Recommendation:** Install all three. Skills and Agent Rules teach your AI _how_ to write code. The MCP Server lets it _look up_ the docs when it needs specifics.\n\n## llms.txt\n\nWe publish machine-readable documentation for LLM consumption:\n\n- [docs.dataqueue.dev/llms.txt](https://docs.dataqueue.dev/llms.txt) — concise overview\n- [docs.dataqueue.dev/llms-full.txt](https://docs.dataqueue.dev/llms-full.txt) — full documentation\n\nThese follow the [llms.txt standard](https://llmstxt.org) and can be fed directly into any LLM context window.\n\n## Project-Level Context Snippet\n\nIf you prefer a lightweight approach, paste this snippet into a context file at the root of your project:\n\n| File | Read by |\n| :-------------------------------- | :---------------------------- |\n| `CLAUDE.md` | Claude Code |\n| `AGENTS.md` | OpenAI Codex, Jules, OpenCode |\n| `.cursor/rules/*.md` | Cursor |\n| `.github/copilot-instructions.md` | GitHub Copilot |\n| `CONVENTIONS.md` | Windsurf, Cline, and others |\n\n```markdown\n# DataQueue rules\n\n## Imports\n\nAlways import from `@nicnocquee/dataqueue`.\n\n## PayloadMap pattern\n\nDefine a type map of job types to payload shapes for full type safety:\n\n\\`\\`\\`ts\ntype JobPayloadMap = {\nsend_email: { to: string; subject: string; body: string };\n};\n\\`\\`\\`\n\n## Initialization (singleton)\n\nNever call initJobQueue per request — use a module-level singleton:\n\n\\`\\`\\`ts\nimport { initJobQueue } from '@nicnocquee/dataqueue';\nlet queue: ReturnType<typeof initJobQueue<JobPayloadMap>> | null = null;\nexport const getJobQueue = () => {\nif (!queue) {\nqueue = initJobQueue<JobPayloadMap>({\ndatabaseConfig: { connectionString: process.env.PG_DATAQUEUE_DATABASE },\n});\n}\nreturn queue;\n};\n\\`\\`\\`\n\n## Handler pattern\n\nType handlers as `JobHandlers<PayloadMap>` — TypeScript enforces a handler for every job type.\n\n## Processing\n\n- Serverless: `processor.start()` (one-shot)\n- Long-running: `processor.startInBackground()` + `stopAndDrain()` on SIGTERM\n\n## Common mistakes\n\n1. Creating initJobQueue per request (creates a DB pool each time)\n2. Missing handler for a job type (fails with NoHandler)\n3. Not checking signal.aborted in long handlers\n4. Forgetting reclaimStuckJobs() — crashed workers leave jobs stuck\n5. Skipping migrations (PostgreSQL requires `dataqueue-cli migrate`)\n```"
150
+ "content": "We provide multiple tools to help AI coding assistants write correct DataQueue code. Use one or all of them for the best developer experience.\n\n## Quick Setup\n\n### 1. Install Skills\n\nPortable instruction sets that teach any AI coding assistant DataQueue best practices.\n\n```bash\nnpx dataqueue-cli install-skills\n```\n\nSkills are installed as `SKILL.md` files into your AI tool's skills directory (`.cursor/skills/`, `.claude/skills/`, etc.). They cover core patterns, advanced features (waits, cron, tokens), and React/Dashboard integration.\n\n### 2. Install Agent Rules\n\nComprehensive rule sets installed directly into your AI client's config files.\n\n```bash\nnpx dataqueue-cli install-rules\n```\n\nThe installer prompts you to choose your AI client and writes rules to the appropriate location:\n\n| Client | Installs to |\n| -------------- | --------------------------------- |\n| Cursor | `.cursor/rules/dataqueue-*.mdc` |\n| Claude Code | `CLAUDE.md` |\n| AGENTS.md | `AGENTS.md` |\n| GitHub Copilot | `.github/copilot-instructions.md` |\n| Windsurf | `CONVENTIONS.md` |\n\n### 3. Install MCP Server\n\nGive your AI assistant direct access to DataQueue documentation — search docs, fetch specific pages, and list all available topics.\n\n```bash\nnpx dataqueue-cli install-mcp\n```\n\nThe installer prompts you to choose your AI client and writes the MCP config to the appropriate location. Currently supported clients:\n\n| Client | Installs to |\n| ----------------- | ------------------------------------- |\n| Cursor | `.cursor/mcp.json` |\n| Claude Code | `.mcp.json` |\n| VS Code (Copilot) | `.vscode/mcp.json` |\n| Windsurf | `~/.codeium/windsurf/mcp_config.json` |\n\nThe MCP server runs via `npx dataqueue-cli mcp` and communicates over stdio. It exposes three tools:\n\n| Tool | Description |\n| ---------------- | ---------------------------------------- |\n| `search-docs` | Full-text search across all doc pages |\n| `get-doc-page` | Fetch a specific doc page by slug |\n| `list-doc-pages` | List all available doc pages with titles |\n\n## Skills vs Agent Rules vs MCP\n\n| | **Skills** | **Agent Rules** | **MCP Server** |\n| :---------------- | :----------------------------------- | :------------------------------------- | :------------------------------------- |\n| **What it does** | Drops skill files into your project | Installs rule sets into client config | Runs a live server your AI connects to |\n| **Installs to** | `.cursor/skills/`, `.claude/skills/` | `.cursor/rules/`, `CLAUDE.md`, etc. | `.cursor/mcp.json`, `.mcp.json`, etc. |\n| **Best for** | Teaching patterns and best practices | Comprehensive code generation guidance | Live documentation search |\n| **Works offline** | Yes | Yes | Yes (runs locally) |\n\n**Recommendation:** Install all three. Skills and Agent Rules teach your AI _how_ to write code. The MCP Server lets it _look up_ the docs when it needs specifics.\n\n## llms.txt\n\nWe publish machine-readable documentation for LLM consumption:\n\n- [docs.dataqueue.dev/llms.txt](https://docs.dataqueue.dev/llms.txt) — concise overview\n- [docs.dataqueue.dev/llms-full.txt](https://docs.dataqueue.dev/llms-full.txt) — full documentation\n\nThese follow the [llms.txt standard](https://llmstxt.org) and can be fed directly into any LLM context window.\n\n## Project-Level Context Snippet\n\nIf you prefer a lightweight approach, paste this snippet into a context file at the root of your project:\n\n| File | Read by |\n| :-------------------------------- | :---------------------------- |\n| `CLAUDE.md` | Claude Code |\n| `AGENTS.md` | OpenAI Codex, Jules, OpenCode |\n| `.cursor/rules/*.md` | Cursor |\n| `.github/copilot-instructions.md` | GitHub Copilot |\n| `CONVENTIONS.md` | Windsurf, Cline, and others |\n\n```markdown\n# DataQueue rules\n\n## Imports\n\nAlways import from `@nicnocquee/dataqueue`.\n\n## PayloadMap pattern\n\nDefine a type map of job types to payload shapes for full type safety:\n\n\\`\\`\\`ts\ntype JobPayloadMap = {\nsend_email: { to: string; subject: string; body: string };\n};\n\\`\\`\\`\n\n## Initialization (singleton)\n\nNever call initJobQueue per request — use a module-level singleton:\n\n\\`\\`\\`ts\nimport { initJobQueue } from '@nicnocquee/dataqueue';\nlet queue: ReturnType<typeof initJobQueue<JobPayloadMap>> | null = null;\nexport const getJobQueue = () => {\nif (!queue) {\nqueue = initJobQueue<JobPayloadMap>({\ndatabaseConfig: { connectionString: process.env.PG_DATAQUEUE_DATABASE },\n});\n}\nreturn queue;\n};\n\\`\\`\\`\n\n## Handler pattern\n\nType handlers as `JobHandlers<PayloadMap>` — TypeScript enforces a handler for every job type.\n\n## Processing\n\n- Serverless: `processor.start()` (one-shot)\n- Long-running: `processor.startInBackground()` + `stopAndDrain()` on SIGTERM\n\n## Common mistakes\n\n1. Creating initJobQueue per request (creates a DB pool each time)\n2. Missing handler for a job type (fails with NoHandler)\n3. Not checking signal.aborted in long handlers\n4. Forgetting dead-letter routing for critical jobs — set `deadLetterJobType` so exhausted failures are inspectable/replayable\n5. Forgetting reclaimStuckJobs() — crashed workers leave jobs stuck\n6. Skipping migrations (PostgreSQL requires `dataqueue-cli migrate`)\n```"
151
151
  },
152
152
  {
153
153
  "slug": "usage/cancel-jobs",
@@ -195,7 +195,7 @@
195
195
  "slug": "usage/failed-jobs",
196
196
  "title": "Failed Jobs",
197
197
  "description": "",
198
- "content": "A job handler can fail for many reasons, such as a bug in the code or running out of resources.\n\nWhen a job fails, it is marked as `failed` and retried up to `maxAttempts` times (default: 3). You can view the error history for a job in its `errorHistory` field.\n\n## Retry configuration\n\nYou can control the retry behavior per-job using three options:\n\n| Option | Type | Default | Description |\n| --------------- | --------- | ------- | ---------------------------------------------- |\n| `retryDelay` | `number` | `60` | Base delay between retries in **seconds** |\n| `retryBackoff` | `boolean` | `true` | Use exponential backoff (doubles each attempt) |\n| `retryDelayMax` | `number` | _none_ | Maximum cap for the delay in **seconds** |\n\n### Fixed delay\n\nSet `retryBackoff: false` to use a constant delay between retries:\n\n```ts\nawait jobQueue.addJob({\n jobType: 'email',\n payload: { to: 'user@example.com' },\n maxAttempts: 5,\n retryDelay: 30, // 30 seconds between each retry\n retryBackoff: false,\n});\n```\n\nEvery retry will wait exactly 30 seconds.\n\n### Exponential backoff (default)\n\nWhen `retryBackoff` is `true` (the default), the delay doubles with each attempt. A small amount of random jitter is added to prevent thundering herd problems:\n\n```ts\nawait jobQueue.addJob({\n jobType: 'email',\n payload: { to: 'user@example.com' },\n maxAttempts: 5,\n retryDelay: 10, // base: 10 seconds\n retryBackoff: true, // enabled by default\n});\n```\n\nThis produces approximate delays of 10s, 20s, 40s, 80s, ... (with jitter).\n\n### Capping the delay\n\nUse `retryDelayMax` to prevent the delay from growing unbounded:\n\n```ts\nawait jobQueue.addJob({\n jobType: 'email',\n payload: { to: 'user@example.com' },\n maxAttempts: 10,\n retryDelay: 5,\n retryBackoff: true,\n retryDelayMax: 300, // never wait more than 5 minutes\n});\n```\n\nDelays: ~5s, ~10s, ~20s, ~40s, ~80s, ~160s, ~300s, ~300s, ...\n\n### Default behavior\n\nIf none of the retry options are set, the legacy formula `2^attempts * 1 minute` is used. This means the first retry is after ~2 minutes, then ~4 minutes, then ~8 minutes, and so on.\n\n## Jitter\n\nWhen exponential backoff is enabled, each computed delay is multiplied by a random factor between 0.5 and 1.0. This prevents multiple failed jobs from retrying at exactly the same time, which could overload downstream services.\n\n## Cron schedules\n\nRetry configuration can also be set on cron schedules. Every job enqueued by the schedule inherits the retry settings:\n\n```ts\nawait jobQueue.addCronJob({\n scheduleName: 'daily-report',\n cronExpression: '0 9 * * *',\n jobType: 'report',\n payload: { type: 'daily' },\n retryDelay: 60,\n retryBackoff: true,\n retryDelayMax: 600,\n});\n```\n\n## Editing retry config\n\nYou can update the retry configuration of a pending job:\n\n```ts\nawait jobQueue.editJob(jobId, {\n retryDelay: 15,\n retryBackoff: false,\n});\n```"
198
+ "content": "A job handler can fail for many reasons, such as a bug in the code or running out of resources.\n\nWhen a job fails, it is marked as `failed` and retried up to `maxAttempts` times (default: 3). You can view the error history for a job in its `errorHistory` field.\n\n## Dead-letter queues\n\nYou can route permanently failed jobs to a dead-letter job type using `deadLetterJobType`.\n\nWhen a job exhausts retries (`attempts >= maxAttempts`), DataQueue:\n\n1. Keeps the source job as `failed`.\n2. Creates a new pending dead-letter job in `deadLetterJobType`.\n3. Stores linkage metadata on the source job (`deadLetteredAt`, `deadLetterJobId`).\n\n```ts\nawait jobQueue.addJob({\n jobType: 'email',\n payload: { to: 'user@example.com' },\n maxAttempts: 3,\n deadLetterJobType: 'email_dead_letter',\n});\n```\n\nThe dead-letter job payload is an envelope:\n\n```ts\n{\n originalJob: { id, jobType, attempts, maxAttempts },\n originalPayload: { ... }, // original job payload\n failure: { message, reason, failedAt },\n}\n```\n\nIf `deadLetterJobType` is not set, behavior is unchanged: exhausted jobs remain failed without creating a dead-letter job.\n\n## Retry configuration\n\nYou can control the retry behavior per-job using three options:\n\n| Option | Type | Default | Description |\n| --------------- | --------- | ------- | ---------------------------------------------- |\n| `retryDelay` | `number` | `60` | Base delay between retries in **seconds** |\n| `retryBackoff` | `boolean` | `true` | Use exponential backoff (doubles each attempt) |\n| `retryDelayMax` | `number` | _none_ | Maximum cap for the delay in **seconds** |\n\n### Fixed delay\n\nSet `retryBackoff: false` to use a constant delay between retries:\n\n```ts\nawait jobQueue.addJob({\n jobType: 'email',\n payload: { to: 'user@example.com' },\n maxAttempts: 5,\n retryDelay: 30, // 30 seconds between each retry\n retryBackoff: false,\n});\n```\n\nEvery retry will wait exactly 30 seconds.\n\n### Exponential backoff (default)\n\nWhen `retryBackoff` is `true` (the default), the delay doubles with each attempt. A small amount of random jitter is added to prevent thundering herd problems:\n\n```ts\nawait jobQueue.addJob({\n jobType: 'email',\n payload: { to: 'user@example.com' },\n maxAttempts: 5,\n retryDelay: 10, // base: 10 seconds\n retryBackoff: true, // enabled by default\n});\n```\n\nThis produces approximate delays of 10s, 20s, 40s, 80s, ... (with jitter).\n\n### Capping the delay\n\nUse `retryDelayMax` to prevent the delay from growing unbounded:\n\n```ts\nawait jobQueue.addJob({\n jobType: 'email',\n payload: { to: 'user@example.com' },\n maxAttempts: 10,\n retryDelay: 5,\n retryBackoff: true,\n retryDelayMax: 300, // never wait more than 5 minutes\n});\n```\n\nDelays: ~5s, ~10s, ~20s, ~40s, ~80s, ~160s, ~300s, ~300s, ...\n\n### Default behavior\n\nIf none of the retry options are set, the legacy formula `2^attempts * 1 minute` is used. This means the first retry is after ~2 minutes, then ~4 minutes, then ~8 minutes, and so on.\n\n## Jitter\n\nWhen exponential backoff is enabled, each computed delay is multiplied by a random factor between 0.5 and 1.0. This prevents multiple failed jobs from retrying at exactly the same time, which could overload downstream services.\n\n## Cron schedules\n\nRetry configuration can also be set on cron schedules. Every job enqueued by the schedule inherits the retry settings:\n\n```ts\nawait jobQueue.addCronJob({\n scheduleName: 'daily-report',\n cronExpression: '0 9 * * *',\n jobType: 'report',\n payload: { type: 'daily' },\n retryDelay: 60,\n retryBackoff: true,\n retryDelayMax: 600,\n});\n```\n\n## Editing retry config\n\nYou can update the retry configuration of a pending job:\n\n```ts\nawait jobQueue.editJob(jobId, {\n retryDelay: 15,\n retryBackoff: false,\n});\n```"
199
199
  },
200
200
  {
201
201
  "slug": "usage/force-kill-timeout",
@@ -116,6 +116,21 @@ await queue.addJob({
116
116
  - No config — legacy `2^attempts * 60s` formula (backward compatible).
117
117
  - Cron schedules propagate retry config to enqueued jobs.
118
118
 
119
+ ## Dead-Letter Routing
120
+
121
+ Configure dead-letter capture with `deadLetterJobType`:
122
+
123
+ ```typescript
124
+ await queue.addJob({
125
+ jobType: 'email',
126
+ payload,
127
+ maxAttempts: 3,
128
+ deadLetterJobType: 'email_dead_letter',
129
+ });
130
+ ```
131
+
132
+ When retries are exhausted, DataQueue creates a pending dead-letter job with envelope payload containing `originalJob`, `originalPayload`, and `failure`. Source jobs remain `failed` and store linkage metadata (`deadLetteredAt`, `deadLetterJobId`).
133
+
119
134
  ## Event Hooks
120
135
 
121
136
  Subscribe to real-time lifecycle events via `on`, `once`, `off`, `removeAllListeners`. Works with both Postgres and Redis.
package/ai/rules/basic.md CHANGED
@@ -150,6 +150,21 @@ Control retry behavior per-job with optional fields on `addJob`:
150
150
 
151
151
  When none are set, the legacy `2^attempts * 60s` formula is used.
152
152
 
153
+ ## Dead-Letter Queue
154
+
155
+ Use `deadLetterJobType` for jobs that must be captured after exhausting retries:
156
+
157
+ ```typescript
158
+ await queue.addJob({
159
+ jobType: 'email',
160
+ payload: { to: 'user@example.com', subject: 'Hi', body: '...' },
161
+ maxAttempts: 3,
162
+ deadLetterJobType: 'email_dead_letter',
163
+ });
164
+ ```
165
+
166
+ On exhaustion, the source job stays `failed` and a new pending dead-letter job is created with envelope payload: `{ originalJob, originalPayload, failure }`.
167
+
153
168
  ## Common Mistakes
154
169
 
155
170
  1. Creating `initJobQueue` per request — use a singleton.
@@ -158,3 +173,4 @@ When none are set, the legacy `2^attempts * 60s` formula is used.
158
173
  4. Skipping maintenance — use `createSupervisor()` to automate reclaim, cleanup, and token expiry. Without it, stuck jobs and old data accumulate.
159
174
  5. Skipping migrations (PostgreSQL) — run `dataqueue-cli migrate` first. Redis needs none.
160
175
  6. Using `stop()` instead of `stopAndDrain()` — leaves in-flight jobs stuck.
176
+ 7. Expecting dead-letter routing without setting `deadLetterJobType` — DLQ is opt-in.
@@ -364,6 +364,29 @@ await queue.addCronJob({
364
364
 
365
365
  Every job enqueued by the schedule inherits the retry settings.
366
366
 
367
+ ### Dead-letter routing
368
+
369
+ Set `deadLetterJobType` on jobs (or cron schedules) to route exhausted failures:
370
+
371
+ ```typescript
372
+ await queue.addJob({
373
+ jobType: 'email',
374
+ payload: { to: 'user@example.com' },
375
+ maxAttempts: 3,
376
+ deadLetterJobType: 'email_dead_letter',
377
+ });
378
+ ```
379
+
380
+ Dead-letter jobs receive envelope payload:
381
+
382
+ ```typescript
383
+ {
384
+ originalJob: { id, jobType, attempts, maxAttempts },
385
+ originalPayload: {...},
386
+ failure: { message, reason, failedAt }
387
+ }
388
+ ```
389
+
367
390
  ### Default behavior
368
391
 
369
392
  When no retry options are set, the legacy formula `2^attempts * 60 seconds` is used. This is fully backward compatible.
@@ -183,6 +183,21 @@ await queue.addJob({
183
183
  - **Exponential backoff** (default): delay doubles each attempt with jitter.
184
184
  - **Default**: when no retry options are set, legacy `2^attempts * 60s` is used.
185
185
 
186
+ ### Dead-letter queues
187
+
188
+ Route exhausted failures into a dedicated job type with `deadLetterJobType`:
189
+
190
+ ```typescript
191
+ await queue.addJob({
192
+ jobType: 'send_email',
193
+ payload: { to: 'user@example.com', subject: 'Hi', body: 'Hello' },
194
+ maxAttempts: 3,
195
+ deadLetterJobType: 'email_dead_letter',
196
+ });
197
+ ```
198
+
199
+ When retries are exhausted, DataQueue keeps the source job as `failed` and creates a new pending dead-letter job with envelope payload: `{ originalJob, originalPayload, failure }`.
200
+
186
201
  ## Step 5: Process Jobs
187
202
 
188
203
  ### Serverless (one-shot)
@@ -235,3 +250,4 @@ process.on('SIGTERM', async () => {
235
250
  6. **Not calling `stopAndDrain` on shutdown** — use `stopAndDrain()` (not `stop()`) for graceful shutdown to avoid stuck jobs.
236
251
  7. **Forgetting to commit/rollback when using `db` option** — the `addJob` INSERT sits in an open transaction. If you never `COMMIT` or `ROLLBACK`, the connection leaks and the job is invisible to other sessions.
237
252
  8. **Using `db` option with Redis** — transactional job creation is PostgreSQL only. The Redis backend throws if `db` is provided.
253
+ 9. **Expecting dead-letter routing without configuration** — DLQ is opt-in. Set `deadLetterJobType` on jobs (or cron schedules) that require dead-letter capture.