@nicnocquee/dataqueue 1.33.0 → 1.35.0-beta.20260224075710

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.
Files changed (54) hide show
  1. package/ai/build-docs-content.ts +96 -0
  2. package/ai/build-llms-full.ts +42 -0
  3. package/ai/docs-content.json +290 -0
  4. package/ai/rules/advanced.md +170 -0
  5. package/ai/rules/basic.md +159 -0
  6. package/ai/rules/react-dashboard.md +87 -0
  7. package/ai/skills/dataqueue-advanced/SKILL.md +370 -0
  8. package/ai/skills/dataqueue-core/SKILL.md +235 -0
  9. package/ai/skills/dataqueue-react/SKILL.md +201 -0
  10. package/dist/cli.cjs +577 -32
  11. package/dist/cli.cjs.map +1 -1
  12. package/dist/cli.d.cts +52 -2
  13. package/dist/cli.d.ts +52 -2
  14. package/dist/cli.js +575 -32
  15. package/dist/cli.js.map +1 -1
  16. package/dist/index.cjs +937 -108
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +358 -11
  19. package/dist/index.d.ts +358 -11
  20. package/dist/index.js +937 -108
  21. package/dist/index.js.map +1 -1
  22. package/dist/mcp-server.cjs +186 -0
  23. package/dist/mcp-server.cjs.map +1 -0
  24. package/dist/mcp-server.d.cts +32 -0
  25. package/dist/mcp-server.d.ts +32 -0
  26. package/dist/mcp-server.js +175 -0
  27. package/dist/mcp-server.js.map +1 -0
  28. package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
  29. package/migrations/1781200000006_add_output_to_job_queue.sql +3 -0
  30. package/package.json +10 -4
  31. package/src/backend.ts +36 -3
  32. package/src/backends/postgres.ts +344 -42
  33. package/src/backends/redis-scripts.ts +173 -8
  34. package/src/backends/redis.test.ts +668 -0
  35. package/src/backends/redis.ts +244 -15
  36. package/src/cli.test.ts +65 -0
  37. package/src/cli.ts +56 -19
  38. package/src/db-util.ts +1 -1
  39. package/src/index.test.ts +811 -12
  40. package/src/index.ts +106 -14
  41. package/src/install-mcp-command.test.ts +216 -0
  42. package/src/install-mcp-command.ts +185 -0
  43. package/src/install-rules-command.test.ts +218 -0
  44. package/src/install-rules-command.ts +233 -0
  45. package/src/install-skills-command.test.ts +176 -0
  46. package/src/install-skills-command.ts +124 -0
  47. package/src/mcp-server.test.ts +162 -0
  48. package/src/mcp-server.ts +231 -0
  49. package/src/processor.ts +133 -49
  50. package/src/queue.test.ts +477 -0
  51. package/src/queue.ts +20 -3
  52. package/src/supervisor.test.ts +340 -0
  53. package/src/supervisor.ts +177 -0
  54. package/src/types.ts +318 -3
@@ -12,6 +12,8 @@ import {
12
12
  EditCronScheduleOptions,
13
13
  WaitpointRecord,
14
14
  CreateTokenOptions,
15
+ AddJobOptions,
16
+ DatabaseClient,
15
17
  } from '../types.js';
16
18
  import { randomUUID } from 'crypto';
17
19
  import {
@@ -103,18 +105,34 @@ export class PostgresBackend implements QueueBackend {
103
105
 
104
106
  // ── Job CRUD ──────────────────────────────────────────────────────────
105
107
 
106
- async addJob<PayloadMap, T extends JobType<PayloadMap>>({
107
- jobType,
108
- payload,
109
- maxAttempts = 3,
110
- priority = 0,
111
- runAt = null,
112
- timeoutMs = undefined,
113
- forceKillOnTimeout = false,
114
- tags = undefined,
115
- idempotencyKey = undefined,
116
- }: JobOptions<PayloadMap, T>): Promise<number> {
117
- const client = await this.pool.connect();
108
+ /**
109
+ * Add a job and return its numeric ID.
110
+ *
111
+ * @param job - Job configuration.
112
+ * @param options - Optional. Pass `{ db }` to run the INSERT on an external
113
+ * client (e.g., inside a transaction) so the job is part of the caller's
114
+ * transaction. The event INSERT also uses the same client.
115
+ */
116
+ async addJob<PayloadMap, T extends JobType<PayloadMap>>(
117
+ {
118
+ jobType,
119
+ payload,
120
+ maxAttempts = 3,
121
+ priority = 0,
122
+ runAt = null,
123
+ timeoutMs = undefined,
124
+ forceKillOnTimeout = false,
125
+ tags = undefined,
126
+ idempotencyKey = undefined,
127
+ retryDelay = undefined,
128
+ retryBackoff = undefined,
129
+ retryDelayMax = undefined,
130
+ }: JobOptions<PayloadMap, T>,
131
+ options?: AddJobOptions,
132
+ ): Promise<number> {
133
+ const externalClient = options?.db;
134
+ const client: DatabaseClient =
135
+ externalClient ?? (await this.pool.connect());
118
136
  try {
119
137
  let result;
120
138
  const onConflict = idempotencyKey
@@ -124,8 +142,8 @@ export class PostgresBackend implements QueueBackend {
124
142
  if (runAt) {
125
143
  result = await client.query(
126
144
  `INSERT INTO job_queue
127
- (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key)
128
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
145
+ (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max)
146
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
129
147
  ${onConflict}
130
148
  RETURNING id`,
131
149
  [
@@ -138,13 +156,16 @@ export class PostgresBackend implements QueueBackend {
138
156
  forceKillOnTimeout ?? false,
139
157
  tags ?? null,
140
158
  idempotencyKey ?? null,
159
+ retryDelay ?? null,
160
+ retryBackoff ?? null,
161
+ retryDelayMax ?? null,
141
162
  ],
142
163
  );
143
164
  } else {
144
165
  result = await client.query(
145
166
  `INSERT INTO job_queue
146
- (job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags, idempotency_key)
147
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
167
+ (job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max)
168
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
148
169
  ${onConflict}
149
170
  RETURNING id`,
150
171
  [
@@ -156,11 +177,13 @@ export class PostgresBackend implements QueueBackend {
156
177
  forceKillOnTimeout ?? false,
157
178
  tags ?? null,
158
179
  idempotencyKey ?? null,
180
+ retryDelay ?? null,
181
+ retryBackoff ?? null,
182
+ retryDelayMax ?? null,
159
183
  ],
160
184
  );
161
185
  }
162
186
 
163
- // If ON CONFLICT DO NOTHING was triggered, no rows are returned.
164
187
  if (result.rows.length === 0 && idempotencyKey) {
165
188
  const existing = await client.query(
166
189
  `SELECT id FROM job_queue WHERE idempotency_key = $1`,
@@ -181,18 +204,217 @@ export class PostgresBackend implements QueueBackend {
181
204
  log(
182
205
  `Added job ${jobId}: payload ${JSON.stringify(payload)}, ${runAt ? `runAt ${runAt.toISOString()}, ` : ''}priority ${priority}, maxAttempts ${maxAttempts}, jobType ${jobType}, tags ${JSON.stringify(tags)}${idempotencyKey ? `, idempotencyKey "${idempotencyKey}"` : ''}`,
183
206
  );
184
- await this.recordJobEvent(jobId, JobEventType.Added, {
185
- jobType,
186
- payload,
187
- tags,
188
- idempotencyKey,
189
- });
207
+
208
+ if (externalClient) {
209
+ try {
210
+ await client.query(
211
+ `INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
212
+ [
213
+ jobId,
214
+ JobEventType.Added,
215
+ JSON.stringify({ jobType, payload, tags, idempotencyKey }),
216
+ ],
217
+ );
218
+ } catch (error) {
219
+ log(`Error recording job event for job ${jobId}: ${error}`);
220
+ }
221
+ } else {
222
+ await this.recordJobEvent(jobId, JobEventType.Added, {
223
+ jobType,
224
+ payload,
225
+ tags,
226
+ idempotencyKey,
227
+ });
228
+ }
190
229
  return jobId;
191
230
  } catch (error) {
192
231
  log(`Error adding job: ${error}`);
193
232
  throw error;
194
233
  } finally {
195
- client.release();
234
+ if (!externalClient) (client as any).release();
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Insert multiple jobs in a single database round-trip.
240
+ *
241
+ * Uses a multi-row INSERT with ON CONFLICT handling for idempotency keys.
242
+ * Returns IDs in the same order as the input array.
243
+ */
244
+ async addJobs<PayloadMap, T extends JobType<PayloadMap>>(
245
+ jobs: JobOptions<PayloadMap, T>[],
246
+ options?: AddJobOptions,
247
+ ): Promise<number[]> {
248
+ if (jobs.length === 0) return [];
249
+
250
+ const externalClient = options?.db;
251
+ const client: DatabaseClient =
252
+ externalClient ?? (await this.pool.connect());
253
+ try {
254
+ const COLS_PER_JOB = 12;
255
+ const valueClauses: string[] = [];
256
+ const params: any[] = [];
257
+
258
+ const hasAnyIdempotencyKey = jobs.some((j) => j.idempotencyKey);
259
+
260
+ for (let i = 0; i < jobs.length; i++) {
261
+ const {
262
+ jobType,
263
+ payload,
264
+ maxAttempts = 3,
265
+ priority = 0,
266
+ runAt = null,
267
+ timeoutMs = undefined,
268
+ forceKillOnTimeout = false,
269
+ tags = undefined,
270
+ idempotencyKey = undefined,
271
+ retryDelay = undefined,
272
+ retryBackoff = undefined,
273
+ retryDelayMax = undefined,
274
+ } = jobs[i];
275
+
276
+ const base = i * COLS_PER_JOB;
277
+ valueClauses.push(
278
+ `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, ` +
279
+ `COALESCE($${base + 5}::timestamptz, CURRENT_TIMESTAMP), ` +
280
+ `$${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, ` +
281
+ `$${base + 10}, $${base + 11}, $${base + 12})`,
282
+ );
283
+ params.push(
284
+ jobType,
285
+ payload,
286
+ maxAttempts,
287
+ priority,
288
+ runAt,
289
+ timeoutMs ?? null,
290
+ forceKillOnTimeout ?? false,
291
+ tags ?? null,
292
+ idempotencyKey ?? null,
293
+ retryDelay ?? null,
294
+ retryBackoff ?? null,
295
+ retryDelayMax ?? null,
296
+ );
297
+ }
298
+
299
+ const onConflict = hasAnyIdempotencyKey
300
+ ? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING`
301
+ : '';
302
+
303
+ const result = await client.query(
304
+ `INSERT INTO job_queue
305
+ (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max)
306
+ VALUES ${valueClauses.join(', ')}
307
+ ${onConflict}
308
+ RETURNING id, idempotency_key`,
309
+ params,
310
+ );
311
+
312
+ // Build a map of idempotency_key -> id from returned rows
313
+ const returnedKeyToId = new Map<string, number>();
314
+ const returnedNullKeyIds: number[] = [];
315
+ for (const row of result.rows) {
316
+ if (row.idempotency_key != null) {
317
+ returnedKeyToId.set(row.idempotency_key, row.id);
318
+ } else {
319
+ returnedNullKeyIds.push(row.id);
320
+ }
321
+ }
322
+
323
+ // Identify idempotency keys that conflicted (not in RETURNING)
324
+ const missingKeys: string[] = [];
325
+ for (const job of jobs) {
326
+ if (job.idempotencyKey && !returnedKeyToId.has(job.idempotencyKey)) {
327
+ missingKeys.push(job.idempotencyKey);
328
+ }
329
+ }
330
+
331
+ // Batch-fetch existing IDs for conflicted keys
332
+ if (missingKeys.length > 0) {
333
+ const existing = await client.query(
334
+ `SELECT id, idempotency_key FROM job_queue WHERE idempotency_key = ANY($1)`,
335
+ [missingKeys],
336
+ );
337
+ for (const row of existing.rows) {
338
+ returnedKeyToId.set(row.idempotency_key, row.id);
339
+ }
340
+ }
341
+
342
+ // Assemble result array in input order
343
+ let nullKeyIdx = 0;
344
+ const ids: number[] = [];
345
+ for (const job of jobs) {
346
+ if (job.idempotencyKey) {
347
+ const id = returnedKeyToId.get(job.idempotencyKey);
348
+ if (id === undefined) {
349
+ throw new Error(
350
+ `Failed to resolve job ID for idempotency key "${job.idempotencyKey}"`,
351
+ );
352
+ }
353
+ ids.push(id);
354
+ } else {
355
+ ids.push(returnedNullKeyIds[nullKeyIdx++]);
356
+ }
357
+ }
358
+
359
+ log(`Batch-inserted ${jobs.length} jobs, IDs: [${ids.join(', ')}]`);
360
+
361
+ // Record 'added' events — only for newly inserted jobs
362
+ const newJobEvents: {
363
+ jobId: number;
364
+ eventType: JobEventType;
365
+ metadata?: any;
366
+ }[] = [];
367
+ for (let i = 0; i < jobs.length; i++) {
368
+ const job = jobs[i];
369
+ const wasInserted =
370
+ !job.idempotencyKey || !missingKeys.includes(job.idempotencyKey);
371
+ if (wasInserted) {
372
+ newJobEvents.push({
373
+ jobId: ids[i],
374
+ eventType: JobEventType.Added,
375
+ metadata: {
376
+ jobType: job.jobType,
377
+ payload: job.payload,
378
+ tags: job.tags,
379
+ idempotencyKey: job.idempotencyKey,
380
+ },
381
+ });
382
+ }
383
+ }
384
+
385
+ if (newJobEvents.length > 0) {
386
+ if (externalClient) {
387
+ // Record events on the same transaction client
388
+ const evtValues: string[] = [];
389
+ const evtParams: any[] = [];
390
+ let evtIdx = 1;
391
+ for (const evt of newJobEvents) {
392
+ evtValues.push(`($${evtIdx++}, $${evtIdx++}, $${evtIdx++})`);
393
+ evtParams.push(
394
+ evt.jobId,
395
+ evt.eventType,
396
+ evt.metadata ? JSON.stringify(evt.metadata) : null,
397
+ );
398
+ }
399
+ try {
400
+ await client.query(
401
+ `INSERT INTO job_events (job_id, event_type, metadata) VALUES ${evtValues.join(', ')}`,
402
+ evtParams,
403
+ );
404
+ } catch (error) {
405
+ log(`Error recording batch job events: ${error}`);
406
+ }
407
+ } else {
408
+ await this.recordJobEventsBatch(newJobEvents);
409
+ }
410
+ }
411
+
412
+ return ids;
413
+ } catch (error) {
414
+ log(`Error batch-inserting jobs: ${error}`);
415
+ throw error;
416
+ } finally {
417
+ if (!externalClient) (client as any).release();
196
418
  }
197
419
  }
198
420
 
@@ -202,7 +424,7 @@ export class PostgresBackend implements QueueBackend {
202
424
  const client = await this.pool.connect();
203
425
  try {
204
426
  const result = await client.query(
205
- `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue WHERE id = $1`,
427
+ `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", output FROM job_queue WHERE id = $1`,
206
428
  [id],
207
429
  );
208
430
 
@@ -236,7 +458,7 @@ export class PostgresBackend implements QueueBackend {
236
458
  const client = await this.pool.connect();
237
459
  try {
238
460
  const result = await client.query(
239
- `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
461
+ `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", output FROM job_queue WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
240
462
  [status, limit, offset],
241
463
  );
242
464
  log(`Found ${result.rows.length} jobs by status ${status}`);
@@ -262,7 +484,7 @@ export class PostgresBackend implements QueueBackend {
262
484
  const client = await this.pool.connect();
263
485
  try {
264
486
  const result = await client.query(
265
- `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
487
+ `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", output FROM job_queue ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
266
488
  [limit, offset],
267
489
  );
268
490
  log(`Found ${result.rows.length} jobs (all)`);
@@ -287,7 +509,7 @@ export class PostgresBackend implements QueueBackend {
287
509
  ): Promise<JobRecord<PayloadMap, T>[]> {
288
510
  const client = await this.pool.connect();
289
511
  try {
290
- let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue`;
512
+ let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", output FROM job_queue`;
291
513
  const params: any[] = [];
292
514
  const where: string[] = [];
293
515
  let paramIdx = 1;
@@ -414,7 +636,7 @@ export class PostgresBackend implements QueueBackend {
414
636
  ): Promise<JobRecord<PayloadMap, T>[]> {
415
637
  const client = await this.pool.connect();
416
638
  try {
417
- let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress
639
+ let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", output
418
640
  FROM job_queue`;
419
641
  let params: any[] = [];
420
642
  switch (mode) {
@@ -516,7 +738,7 @@ export class PostgresBackend implements QueueBackend {
516
738
  LIMIT $2
517
739
  FOR UPDATE SKIP LOCKED
518
740
  )
519
- RETURNING id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress
741
+ RETURNING id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", output
520
742
  `,
521
743
  params,
522
744
  );
@@ -549,17 +771,19 @@ export class PostgresBackend implements QueueBackend {
549
771
  }
550
772
  }
551
773
 
552
- async completeJob(jobId: number): Promise<void> {
774
+ async completeJob(jobId: number, output?: unknown): Promise<void> {
553
775
  const client = await this.pool.connect();
554
776
  try {
777
+ const outputJson = output !== undefined ? JSON.stringify(output) : null;
555
778
  const result = await client.query(
556
779
  `
557
780
  UPDATE job_queue
558
781
  SET status = 'completed', updated_at = NOW(), completed_at = NOW(),
559
- step_data = NULL, wait_until = NULL, wait_token_id = NULL
782
+ step_data = NULL, wait_until = NULL, wait_token_id = NULL,
783
+ output = COALESCE($2::jsonb, output)
560
784
  WHERE id = $1 AND status = 'processing'
561
785
  `,
562
- [jobId],
786
+ [jobId, outputJson],
563
787
  );
564
788
  if (result.rowCount === 0) {
565
789
  log(
@@ -588,9 +812,17 @@ export class PostgresBackend implements QueueBackend {
588
812
  UPDATE job_queue
589
813
  SET status = 'failed',
590
814
  updated_at = NOW(),
591
- next_attempt_at = CASE
592
- WHEN attempts < max_attempts THEN NOW() + (POWER(2, attempts) * INTERVAL '1 minute')
593
- ELSE NULL
815
+ next_attempt_at = CASE
816
+ WHEN attempts >= max_attempts THEN NULL
817
+ WHEN retry_delay IS NULL AND retry_backoff IS NULL AND retry_delay_max IS NULL
818
+ THEN NOW() + (POWER(2, attempts) * INTERVAL '1 minute')
819
+ WHEN COALESCE(retry_backoff, true) = true
820
+ THEN NOW() + (LEAST(
821
+ COALESCE(retry_delay_max, 2147483647),
822
+ COALESCE(retry_delay, 60) * POWER(2, attempts)
823
+ ) * (0.5 + 0.5 * random()) * INTERVAL '1 second')
824
+ ELSE
825
+ NOW() + (COALESCE(retry_delay, 60) * INTERVAL '1 second')
594
826
  END,
595
827
  error_history = COALESCE(error_history, '[]'::jsonb) || $2::jsonb,
596
828
  failure_reason = $3,
@@ -665,6 +897,23 @@ export class PostgresBackend implements QueueBackend {
665
897
  }
666
898
  }
667
899
 
900
+ // ── Output ────────────────────────────────────────────────────────────
901
+
902
+ async updateOutput(jobId: number, output: unknown): Promise<void> {
903
+ const client = await this.pool.connect();
904
+ try {
905
+ await client.query(
906
+ `UPDATE job_queue SET output = $2::jsonb, updated_at = NOW() WHERE id = $1`,
907
+ [jobId, JSON.stringify(output)],
908
+ );
909
+ log(`Updated output for job ${jobId}`);
910
+ } catch (error) {
911
+ log(`Error updating output for job ${jobId}: ${error}`);
912
+ } finally {
913
+ client.release();
914
+ }
915
+ }
916
+
668
917
  // ── Job management ────────────────────────────────────────────────────
669
918
 
670
919
  async retryJob(jobId: number): Promise<void> {
@@ -843,6 +1092,18 @@ export class PostgresBackend implements QueueBackend {
843
1092
  updateFields.push(`tags = $${paramIdx++}`);
844
1093
  params.push(updates.tags ?? null);
845
1094
  }
1095
+ if (updates.retryDelay !== undefined) {
1096
+ updateFields.push(`retry_delay = $${paramIdx++}`);
1097
+ params.push(updates.retryDelay ?? null);
1098
+ }
1099
+ if (updates.retryBackoff !== undefined) {
1100
+ updateFields.push(`retry_backoff = $${paramIdx++}`);
1101
+ params.push(updates.retryBackoff ?? null);
1102
+ }
1103
+ if (updates.retryDelayMax !== undefined) {
1104
+ updateFields.push(`retry_delay_max = $${paramIdx++}`);
1105
+ params.push(updates.retryDelayMax ?? null);
1106
+ }
846
1107
 
847
1108
  if (updateFields.length === 0) {
848
1109
  log(`No fields to update for job ${jobId}`);
@@ -869,6 +1130,12 @@ export class PostgresBackend implements QueueBackend {
869
1130
  if (updates.timeoutMs !== undefined)
870
1131
  metadata.timeoutMs = updates.timeoutMs;
871
1132
  if (updates.tags !== undefined) metadata.tags = updates.tags;
1133
+ if (updates.retryDelay !== undefined)
1134
+ metadata.retryDelay = updates.retryDelay;
1135
+ if (updates.retryBackoff !== undefined)
1136
+ metadata.retryBackoff = updates.retryBackoff;
1137
+ if (updates.retryDelayMax !== undefined)
1138
+ metadata.retryDelayMax = updates.retryDelayMax;
872
1139
 
873
1140
  await this.recordJobEvent(jobId, JobEventType.Edited, metadata);
874
1141
  log(`Edited job ${jobId}: ${JSON.stringify(metadata)}`);
@@ -918,6 +1185,18 @@ export class PostgresBackend implements QueueBackend {
918
1185
  updateFields.push(`tags = $${paramIdx++}`);
919
1186
  params.push(updates.tags ?? null);
920
1187
  }
1188
+ if (updates.retryDelay !== undefined) {
1189
+ updateFields.push(`retry_delay = $${paramIdx++}`);
1190
+ params.push(updates.retryDelay ?? null);
1191
+ }
1192
+ if (updates.retryBackoff !== undefined) {
1193
+ updateFields.push(`retry_backoff = $${paramIdx++}`);
1194
+ params.push(updates.retryBackoff ?? null);
1195
+ }
1196
+ if (updates.retryDelayMax !== undefined) {
1197
+ updateFields.push(`retry_delay_max = $${paramIdx++}`);
1198
+ params.push(updates.retryDelayMax ?? null);
1199
+ }
921
1200
 
922
1201
  if (updateFields.length === 0) {
923
1202
  log(`No fields to update for batch edit`);
@@ -1188,8 +1467,8 @@ export class PostgresBackend implements QueueBackend {
1188
1467
  `INSERT INTO cron_schedules
1189
1468
  (schedule_name, cron_expression, job_type, payload, max_attempts,
1190
1469
  priority, timeout_ms, force_kill_on_timeout, tags, timezone,
1191
- allow_overlap, next_run_at)
1192
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
1470
+ allow_overlap, next_run_at, retry_delay, retry_backoff, retry_delay_max)
1471
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
1193
1472
  RETURNING id`,
1194
1473
  [
1195
1474
  input.scheduleName,
@@ -1204,6 +1483,9 @@ export class PostgresBackend implements QueueBackend {
1204
1483
  input.timezone,
1205
1484
  input.allowOverlap,
1206
1485
  input.nextRunAt,
1486
+ input.retryDelay,
1487
+ input.retryBackoff,
1488
+ input.retryDelayMax,
1207
1489
  ],
1208
1490
  );
1209
1491
  const id = result.rows[0].id;
@@ -1235,7 +1517,9 @@ export class PostgresBackend implements QueueBackend {
1235
1517
  timezone, allow_overlap AS "allowOverlap", status,
1236
1518
  last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1237
1519
  next_run_at AS "nextRunAt",
1238
- created_at AS "createdAt", updated_at AS "updatedAt"
1520
+ created_at AS "createdAt", updated_at AS "updatedAt",
1521
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
1522
+ retry_delay_max AS "retryDelayMax"
1239
1523
  FROM cron_schedules WHERE id = $1`,
1240
1524
  [id],
1241
1525
  );
@@ -1263,7 +1547,9 @@ export class PostgresBackend implements QueueBackend {
1263
1547
  timezone, allow_overlap AS "allowOverlap", status,
1264
1548
  last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1265
1549
  next_run_at AS "nextRunAt",
1266
- created_at AS "createdAt", updated_at AS "updatedAt"
1550
+ created_at AS "createdAt", updated_at AS "updatedAt",
1551
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
1552
+ retry_delay_max AS "retryDelayMax"
1267
1553
  FROM cron_schedules WHERE schedule_name = $1`,
1268
1554
  [name],
1269
1555
  );
@@ -1290,7 +1576,9 @@ export class PostgresBackend implements QueueBackend {
1290
1576
  timezone, allow_overlap AS "allowOverlap", status,
1291
1577
  last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1292
1578
  next_run_at AS "nextRunAt",
1293
- created_at AS "createdAt", updated_at AS "updatedAt"
1579
+ created_at AS "createdAt", updated_at AS "updatedAt",
1580
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
1581
+ retry_delay_max AS "retryDelayMax"
1294
1582
  FROM cron_schedules`;
1295
1583
  const params: any[] = [];
1296
1584
  if (status) {
@@ -1404,6 +1692,18 @@ export class PostgresBackend implements QueueBackend {
1404
1692
  updateFields.push(`allow_overlap = $${paramIdx++}`);
1405
1693
  params.push(updates.allowOverlap);
1406
1694
  }
1695
+ if (updates.retryDelay !== undefined) {
1696
+ updateFields.push(`retry_delay = $${paramIdx++}`);
1697
+ params.push(updates.retryDelay);
1698
+ }
1699
+ if (updates.retryBackoff !== undefined) {
1700
+ updateFields.push(`retry_backoff = $${paramIdx++}`);
1701
+ params.push(updates.retryBackoff);
1702
+ }
1703
+ if (updates.retryDelayMax !== undefined) {
1704
+ updateFields.push(`retry_delay_max = $${paramIdx++}`);
1705
+ params.push(updates.retryDelayMax);
1706
+ }
1407
1707
  if (nextRunAt !== undefined) {
1408
1708
  updateFields.push(`next_run_at = $${paramIdx++}`);
1409
1709
  params.push(nextRunAt);
@@ -1443,7 +1743,9 @@ export class PostgresBackend implements QueueBackend {
1443
1743
  timezone, allow_overlap AS "allowOverlap", status,
1444
1744
  last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1445
1745
  next_run_at AS "nextRunAt",
1446
- created_at AS "createdAt", updated_at AS "updatedAt"
1746
+ created_at AS "createdAt", updated_at AS "updatedAt",
1747
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
1748
+ retry_delay_max AS "retryDelayMax"
1447
1749
  FROM cron_schedules
1448
1750
  WHERE status = 'active'
1449
1751
  AND next_run_at IS NOT NULL