tina4-nodejs 3.13.104 → 3.13.105

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.
@@ -144,7 +144,13 @@ export interface QueueBackendInterface {
144
144
  // persistent-connection rewrite lands.
145
145
  complete?(queue: string, id: string): void;
146
146
  fail?(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number): void;
147
- retry?(queue: string, id: string, delaySeconds?: number): void;
147
+ /**
148
+ * Explicit manual re-queue: returns true if the id was found and revived,
149
+ * false otherwise (parity with Python's backend.retry_job()). A backend may
150
+ * legacy-return void; callers coerce a void return to true so nothing that
151
+ * used to be reported as success silently flips to failure.
152
+ */
153
+ retry?(queue: string, id: string, delaySeconds?: number): boolean | void;
148
154
  deadLetters?(queue: string, maxRetries?: number): QueueJob[];
149
155
  failed?(queue: string, maxRetries?: number): QueueJob[];
150
156
  retryFailed?(queue: string, maxRetries?: number): number;
@@ -400,7 +406,19 @@ export class Queue {
400
406
  }
401
407
 
402
408
  /**
403
- * Count jobs filtered by status. Defaults to "pending".
409
+ * Count jobs by status. Defaults to "pending".
410
+ *
411
+ * ``"pending"`` counts jobs waiting to be popped -- INCLUDES retryable-
412
+ * but-attempted ones, because they live in the pending queue under the
413
+ * auto-retry lifecycle (see failed()).
414
+ * ``"reserved"`` counts jobs a consumer has popped but not yet
415
+ * completed/failed (in-flight against the visibility timeout).
416
+ * ``"completed"`` counts jobs the consumer has finished successfully.
417
+ * ``"failed"``, ``"dead"``, ``"dead_letter"`` are ALIASES that all count
418
+ * the dead-letter store -- jobs whose attempts >= maxRetries and that
419
+ * have given up. Use deadLetters() to list them. Retryable-but-attempted
420
+ * jobs are NOT counted by size("failed"); use failed() to list them or
421
+ * size("pending") to include them in a total.
404
422
  */
405
423
  size(status: string = "pending"): number {
406
424
  const q = this.topic;
@@ -455,13 +473,21 @@ export class Queue {
455
473
  /**
456
474
  * Get jobs that failed at least once but are still being retried
457
475
  * (0 < attempts < maxRetries). These live in the pending queue under the
458
- * auto-retry lifecycle; dead-lettered jobs are returned by deadLetters().
476
+ * auto-retry lifecycle (fail() re-queues them with an incremented attempts
477
+ * count and a retryBackoff delay) so pop() picks them up again. They are
478
+ * NOT counted by size("failed") -- that alias counts the dead-letter store,
479
+ * matching deadLetters(). To include retryable-failed jobs in a total, use
480
+ * size("pending"). Terminal failures are returned by deadLetters().
459
481
  */
460
482
  failed(): QueueJob[] {
461
- if (this.externalBackend?.failed) {
462
- return this.externalBackend.failed(this.topic, this._maxRetries);
463
- }
464
- return this.liteBackend.failed(this.topic, this._maxRetries);
483
+ const raw = this.externalBackend?.failed
484
+ ? this.externalBackend.failed(this.topic, this._maxRetries)
485
+ : this.liteBackend.failed(this.topic, this._maxRetries);
486
+ // Wrap so callers get the full Job lifecycle (parity with deadLetters()
487
+ // and Python's failed()).
488
+ return raw.map((data) =>
489
+ createJob({ ...(data as JobData), topic: (data as JobData).topic ?? this.topic }, this),
490
+ );
465
491
  }
466
492
 
467
493
  /**
@@ -473,21 +499,31 @@ export class Queue {
473
499
  */
474
500
  retry(jobId?: string, delaySeconds?: number): boolean {
475
501
  if (jobId) {
476
- // Retry a specific job by ID
502
+ // Retry a specific job by ID. Honour whatever the external backend
503
+ // returns (a boolean) so an unknown id reports false; only coerce a
504
+ // legacy void return to true to preserve the pre-3.13.105 contract on
505
+ // a backend that hasn't been updated (LiteBackend already returns bool).
477
506
  if (this.externalBackend?.retry) {
478
- this.externalBackend.retry(this.topic, jobId, delaySeconds);
479
- return true;
507
+ const result = this.externalBackend.retry(this.topic, jobId, delaySeconds);
508
+ return result === undefined ? true : Boolean(result);
480
509
  }
481
510
  return this.liteBackend.retry(this.topic, jobId, delaySeconds);
482
511
  }
483
- // Retry all dead-letter jobs
512
+ // Retry ALL dead-letter jobs -- an explicit for...of iterates every
513
+ // entry rather than a reducer like .some() that would short-circuit on
514
+ // the first truthy result (PY-12-04 parity: Python's generator-inside-
515
+ // any() had exactly that bug pre-3.13.105 and left the remaining
516
+ // dead letters silently in the store).
484
517
  const deadJobs = this.deadLetters();
485
518
  if (deadJobs.length === 0) return false;
486
519
  let retried = false;
487
520
  for (const job of deadJobs) {
488
521
  if (this.externalBackend?.retry) {
489
- this.externalBackend.retry(this.topic, job.id, delaySeconds);
490
- retried = true;
522
+ const result = this.externalBackend.retry(this.topic, job.id, delaySeconds);
523
+ // A modern backend returns bool; a legacy backend returns void which
524
+ // we optimistically treat as revived (parity with the pre-3.13.105
525
+ // pathway that never had a way to know otherwise).
526
+ if (result === undefined || Boolean(result)) retried = true;
491
527
  } else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
492
528
  retried = true;
493
529
  }
@@ -496,13 +532,34 @@ export class Queue {
496
532
  }
497
533
 
498
534
  /**
499
- * Get dead letter jobs failed jobs that exceeded max retries.
535
+ * Get jobs that exceeded max_retries -- terminal failures.
536
+ *
537
+ * Same set counted by size("failed") / size("dead") / size("dead_letter")
538
+ * (three aliases for the dead-letter store). To LIST retryable-but-
539
+ * attempted jobs (attempts > 0 AND attempts < maxRetries) that are still
540
+ * being auto-retried, use failed() -- those live in the pending queue and
541
+ * are NOT dead letters.
542
+ *
543
+ * Returns Job objects with the failure reason on ``.error`` (not raw dicts)
544
+ * so callers can iterate uniformly with the rest of the queue API and, in
545
+ * particular, call ``.retry()`` on each to manually revive it:
546
+ *
547
+ * for (const job of queue.deadLetters()) {
548
+ * Log.warn(`revived ${job.id}: ${job.error}`);
549
+ * job.retry();
550
+ * }
500
551
  */
501
552
  deadLetters(maxRetries?: number): QueueJob[] {
502
- if (this.externalBackend?.deadLetters) {
503
- return this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
504
- }
505
- return this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
553
+ const raw = this.externalBackend?.deadLetters
554
+ ? this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries)
555
+ : this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
556
+ // Wrap so ``job.retry()`` / ``job.fail()`` / ``job.complete()`` work
557
+ // uniformly (parity with pop() and the Python master). Preserves the
558
+ // job's own topic so the lifecycle methods route back to THIS queue's
559
+ // backend even on a job that dead-lettered on a different topic.
560
+ return raw.map((data) =>
561
+ createJob({ ...(data as JobData), topic: (data as JobData).topic ?? this.topic }, this),
562
+ );
506
563
  }
507
564
 
508
565
  /**
@@ -677,11 +677,27 @@ export class LiteBackend {
677
677
  * Explicit re-queue requested by the caller (job.retry()).
678
678
  *
679
679
  * Always re-enqueues regardless of the retry limit — manual override,
680
- * distinct from the automatic failJob() path.
680
+ * distinct from the automatic failJob() path. Cleans up BOTH the
681
+ * reservation record AND any dead-letter file for this id, so a caller
682
+ * that iterates deadLetters() and calls .retry() on each doesn't leave
683
+ * the failed/ directory carrying duplicates (PY-12-05, 3.13.105).
684
+ * Aligns with retry(queue, jobId) which had always unlinked the
685
+ * dead-letter file -- two spellings of the same intent that previously
686
+ * diverged.
681
687
  */
682
688
  retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
683
689
  // Clear the reservation — the consumer acknowledged (with an explicit retry).
684
690
  this.clearReservation(queue, job.id);
691
+ // Drop any dead-letter file for this id BEFORE the re-queue -- if this
692
+ // job came from deadLetters() it lives in failed/ and would otherwise
693
+ // stay on disk while a fresh pending file appears in the queue dir, so
694
+ // the next deadLetters() call reports the job again and a consumer
695
+ // processes it twice.
696
+ try {
697
+ unlinkSync(join(this.ensureFailedDir(queue), `${job.id}.queue-data`));
698
+ } catch {
699
+ // ENOENT is fine (the job never dead-lettered or was already cleared).
700
+ }
685
701
  job.attempts = (job.attempts || 0) + 1;
686
702
  job.error = undefined;
687
703
  this.requeue(queue, job, delaySeconds ?? 0, undefined);
@@ -343,17 +343,67 @@ export class MongoBackend implements QueueBackend {
343
343
  process.stdout.write("__OK__");
344
344
  }
345
345
  else if (operation === "retry") {
346
- // Explicit manual re-queue (always re-enqueues). data = JSON
347
- // { id, delaySeconds }.
346
+ // Explicit manual re-queue. Serves BOTH Queue.retry(id) (revive
347
+ // a dead-letter job) AND job.retry() (manual re-queue of a live
348
+ // reserved/pending job) so the Mongo backend matches
349
+ // LiteBackend's dual behaviour.
350
+ //
351
+ // 1) DL revival (Queue.retry(id) after fail exhausted retries).
352
+ // Pre-3.13.105 this branch was BROKEN: the search filter was
353
+ // { queue: queueName, id, status: "failed" } -- three separate
354
+ // reasons it could never match. dead_letter() inserts under
355
+ // queueName + ".dead_letter" (not queueName), carries
356
+ // status "dead" (not "failed"), and the original under
357
+ // queueName was already acked to "completed" by the time the
358
+ // DL was written. Now we look up in the DL namespace by id,
359
+ // delete the DL doc first (so an interrupted retry never
360
+ // leaves both a DL and a fresh pending doc), and upsert the
361
+ // original back to pending -- re-hydrating if the original
362
+ // was purged (housekeeping) so a retry always works.
363
+ // 2) Live-doc manual re-queue (job.retry() on a job the caller
364
+ // just popped and wants back in pending). The live-doc path
365
+ // is preserved from before 3.13.105.
366
+ //
367
+ // Returns __OK__ when either path acted; __NOT_FOUND__ when
368
+ // neither the DL nor the live doc existed, so Queue.retry(id)
369
+ // can now report the pre-3.13.105 blanket-true as false for
370
+ // unknown ids. data = JSON { id, delaySeconds }.
348
371
  const info = JSON.parse(data);
372
+ const dlTopic = queueName + ".dead_letter";
373
+ const now = new Date().toISOString();
349
374
  const avail = info.delaySeconds > 0
350
375
  ? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
351
- : new Date().toISOString();
352
- await col.updateOne(
353
- { queue: queueName, id: info.id },
354
- { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
355
- );
356
- process.stdout.write("__OK__");
376
+ : now;
377
+ const dlDoc = await col.findOne({ queue: dlTopic, id: info.id });
378
+ if (dlDoc !== null) {
379
+ await col.deleteOne({ _id: dlDoc._id });
380
+ const payload = dlDoc.payload ?? {};
381
+ const priority = dlDoc.priority ?? 0;
382
+ await col.updateOne(
383
+ { queue: queueName, id: info.id },
384
+ {
385
+ $set: {
386
+ status: "pending",
387
+ availableAt: avail,
388
+ reservedAt: null,
389
+ error: null,
390
+ payload,
391
+ priority,
392
+ id: info.id,
393
+ createdAt: dlDoc.createdAt ?? now,
394
+ },
395
+ $inc: { attempts: 1 },
396
+ },
397
+ { upsert: true },
398
+ );
399
+ process.stdout.write("__OK__");
400
+ } else {
401
+ const result = await col.updateOne(
402
+ { queue: queueName, id: info.id },
403
+ { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
404
+ );
405
+ process.stdout.write(result.matchedCount > 0 ? "__OK__" : "__NOT_FOUND__");
406
+ }
357
407
  }
358
408
  else if (operation === "deadLetters") {
359
409
  const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
@@ -398,10 +448,20 @@ export class MongoBackend implements QueueBackend {
398
448
  process.stdout.write(String(revived));
399
449
  }
400
450
  else if (operation === "purge") {
401
- // Delete docs by status (default: all for the topic). data = JSON { status }.
451
+ // Delete docs by status (default: every doc for the topic).
452
+ // Pre-3.13.105 this filtered by { queue: queueName, status } for
453
+ // EVERY status -- correct for pending/reserved/completed, wrong
454
+ // for the dead-letter states (dead/failed/dead_letter) which
455
+ // live under queueName + ".dead_letter" and carry status "dead".
456
+ // A purge("dead") therefore deleted nothing and returned 0.
457
+ // data = JSON { status }.
402
458
  const info = data ? JSON.parse(data) : {};
403
- const filter = { queue: queueName };
404
- if (info.status) filter.status = info.status;
459
+ const isDead = info.status && ["dead", "failed", "dead_letter"].includes(info.status);
460
+ const filter = isDead
461
+ ? { queue: queueName + ".dead_letter" }
462
+ : (info.status
463
+ ? { queue: queueName, status: info.status }
464
+ : { queue: queueName });
405
465
  const res = await col.deleteMany(filter);
406
466
  process.stdout.write(String(res.deletedCount || 0));
407
467
  }
@@ -513,9 +573,15 @@ export class MongoBackend implements QueueBackend {
513
573
  this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
514
574
  }
515
575
 
516
- /** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
517
- retry(queue: string, id: string, delaySeconds: number = 0): void {
518
- this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
576
+ /**
577
+ * Revive a specific dead-letter job by id. Returns true if the DL was found
578
+ * and revived, false otherwise (parity with LiteBackend.retry(queue, id)
579
+ * and Python's mongo_backend.retry_job()). Pre-3.13.105 this returned void
580
+ * and Queue.retry(id) reported success for every call, even for unknown ids.
581
+ */
582
+ retry(queue: string, id: string, delaySeconds: number = 0): boolean {
583
+ const out = this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
584
+ return out.includes("__OK__");
519
585
  }
520
586
 
521
587
  /** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
@@ -17249,17 +17249,67 @@ var init_mongoBackend = __esm({
17249
17249
  process.stdout.write("__OK__");
17250
17250
  }
17251
17251
  else if (operation === "retry") {
17252
- // Explicit manual re-queue (always re-enqueues). data = JSON
17253
- // { id, delaySeconds }.
17252
+ // Explicit manual re-queue. Serves BOTH Queue.retry(id) (revive
17253
+ // a dead-letter job) AND job.retry() (manual re-queue of a live
17254
+ // reserved/pending job) so the Mongo backend matches
17255
+ // LiteBackend's dual behaviour.
17256
+ //
17257
+ // 1) DL revival (Queue.retry(id) after fail exhausted retries).
17258
+ // Pre-3.13.105 this branch was BROKEN: the search filter was
17259
+ // { queue: queueName, id, status: "failed" } -- three separate
17260
+ // reasons it could never match. dead_letter() inserts under
17261
+ // queueName + ".dead_letter" (not queueName), carries
17262
+ // status "dead" (not "failed"), and the original under
17263
+ // queueName was already acked to "completed" by the time the
17264
+ // DL was written. Now we look up in the DL namespace by id,
17265
+ // delete the DL doc first (so an interrupted retry never
17266
+ // leaves both a DL and a fresh pending doc), and upsert the
17267
+ // original back to pending -- re-hydrating if the original
17268
+ // was purged (housekeeping) so a retry always works.
17269
+ // 2) Live-doc manual re-queue (job.retry() on a job the caller
17270
+ // just popped and wants back in pending). The live-doc path
17271
+ // is preserved from before 3.13.105.
17272
+ //
17273
+ // Returns __OK__ when either path acted; __NOT_FOUND__ when
17274
+ // neither the DL nor the live doc existed, so Queue.retry(id)
17275
+ // can now report the pre-3.13.105 blanket-true as false for
17276
+ // unknown ids. data = JSON { id, delaySeconds }.
17254
17277
  const info = JSON.parse(data);
17278
+ const dlTopic = queueName + ".dead_letter";
17279
+ const now = new Date().toISOString();
17255
17280
  const avail = info.delaySeconds > 0
17256
17281
  ? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
17257
- : new Date().toISOString();
17258
- await col.updateOne(
17259
- { queue: queueName, id: info.id },
17260
- { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
17261
- );
17262
- process.stdout.write("__OK__");
17282
+ : now;
17283
+ const dlDoc = await col.findOne({ queue: dlTopic, id: info.id });
17284
+ if (dlDoc !== null) {
17285
+ await col.deleteOne({ _id: dlDoc._id });
17286
+ const payload = dlDoc.payload ?? {};
17287
+ const priority = dlDoc.priority ?? 0;
17288
+ await col.updateOne(
17289
+ { queue: queueName, id: info.id },
17290
+ {
17291
+ $set: {
17292
+ status: "pending",
17293
+ availableAt: avail,
17294
+ reservedAt: null,
17295
+ error: null,
17296
+ payload,
17297
+ priority,
17298
+ id: info.id,
17299
+ createdAt: dlDoc.createdAt ?? now,
17300
+ },
17301
+ $inc: { attempts: 1 },
17302
+ },
17303
+ { upsert: true },
17304
+ );
17305
+ process.stdout.write("__OK__");
17306
+ } else {
17307
+ const result = await col.updateOne(
17308
+ { queue: queueName, id: info.id },
17309
+ { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
17310
+ );
17311
+ process.stdout.write(result.matchedCount > 0 ? "__OK__" : "__NOT_FOUND__");
17312
+ }
17263
17313
  }
17264
17314
  else if (operation === "deadLetters") {
17265
17315
  const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
@@ -17304,10 +17354,20 @@ var init_mongoBackend = __esm({
17304
17354
  process.stdout.write(String(revived));
17305
17355
  }
17306
17356
  else if (operation === "purge") {
17307
- // Delete docs by status (default: all for the topic). data = JSON { status }.
17357
+ // Delete docs by status (default: every doc for the topic).
17358
+ // Pre-3.13.105 this filtered by { queue: queueName, status } for
17359
+ // EVERY status -- correct for pending/reserved/completed, wrong
17360
+ // for the dead-letter states (dead/failed/dead_letter) which
17361
+ // live under queueName + ".dead_letter" and carry status "dead".
17362
+ // A purge("dead") therefore deleted nothing and returned 0.
17363
+ // data = JSON { status }.
17308
17364
  const info = data ? JSON.parse(data) : {};
17309
- const filter = { queue: queueName };
17310
- if (info.status) filter.status = info.status;
17365
+ const isDead = info.status && ["dead", "failed", "dead_letter"].includes(info.status);
17366
+ const filter = isDead
17367
+ ? { queue: queueName + ".dead_letter" }
17368
+ : (info.status
17369
+ ? { queue: queueName, status: info.status }
17370
+ : { queue: queueName });
17311
17371
  const res = await col.deleteMany(filter);
17312
17372
  process.stdout.write(String(res.deletedCount || 0));
17313
17373
  }
@@ -17404,9 +17464,15 @@ var init_mongoBackend = __esm({
17404
17464
  fail(queue, id, error, maxRetries, retryBackoff = 0) {
17405
17465
  this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
17406
17466
  }
17407
- /** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
17467
+ /**
17468
+ * Revive a specific dead-letter job by id. Returns true if the DL was found
17469
+ * and revived, false otherwise (parity with LiteBackend.retry(queue, id)
17470
+ * and Python's mongo_backend.retry_job()). Pre-3.13.105 this returned void
17471
+ * and Queue.retry(id) reported success for every call, even for unknown ids.
17472
+ */
17408
17473
  retry(queue, id, delaySeconds = 0) {
17409
- this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
17474
+ const out = this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
17475
+ return out.includes("__OK__");
17410
17476
  }
17411
17477
  /** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
17412
17478
  deadLetters(queue, maxRetries) {
@@ -18075,10 +18141,20 @@ var init_liteBackend = __esm({
18075
18141
  * Explicit re-queue requested by the caller (job.retry()).
18076
18142
  *
18077
18143
  * Always re-enqueues regardless of the retry limit — manual override,
18078
- * distinct from the automatic failJob() path.
18144
+ * distinct from the automatic failJob() path. Cleans up BOTH the
18145
+ * reservation record AND any dead-letter file for this id, so a caller
18146
+ * that iterates deadLetters() and calls .retry() on each doesn't leave
18147
+ * the failed/ directory carrying duplicates (PY-12-05, 3.13.105).
18148
+ * Aligns with retry(queue, jobId) which had always unlinked the
18149
+ * dead-letter file -- two spellings of the same intent that previously
18150
+ * diverged.
18079
18151
  */
18080
18152
  retryJob(queue, job, delaySeconds) {
18081
18153
  this.clearReservation(queue, job.id);
18154
+ try {
18155
+ unlinkSync6(join17(this.ensureFailedDir(queue), `${job.id}.queue-data`));
18156
+ } catch {
18157
+ }
18082
18158
  job.attempts = (job.attempts || 0) + 1;
18083
18159
  job.error = void 0;
18084
18160
  this.requeue(queue, job, delaySeconds ?? 0, void 0);
@@ -18277,7 +18353,19 @@ var init_queue = __esm({
18277
18353
  }
18278
18354
  }
18279
18355
  /**
18280
- * Count jobs filtered by status. Defaults to "pending".
18356
+ * Count jobs by status. Defaults to "pending".
18357
+ *
18358
+ * ``"pending"`` counts jobs waiting to be popped -- INCLUDES retryable-
18359
+ * but-attempted ones, because they live in the pending queue under the
18360
+ * auto-retry lifecycle (see failed()).
18361
+ * ``"reserved"`` counts jobs a consumer has popped but not yet
18362
+ * completed/failed (in-flight against the visibility timeout).
18363
+ * ``"completed"`` counts jobs the consumer has finished successfully.
18364
+ * ``"failed"``, ``"dead"``, ``"dead_letter"`` are ALIASES that all count
18365
+ * the dead-letter store -- jobs whose attempts >= maxRetries and that
18366
+ * have given up. Use deadLetters() to list them. Retryable-but-attempted
18367
+ * jobs are NOT counted by size("failed"); use failed() to list them or
18368
+ * size("pending") to include them in a total.
18281
18369
  */
18282
18370
  size(status2 = "pending") {
18283
18371
  const q = this.topic;
@@ -18327,13 +18415,17 @@ var init_queue = __esm({
18327
18415
  /**
18328
18416
  * Get jobs that failed at least once but are still being retried
18329
18417
  * (0 < attempts < maxRetries). These live in the pending queue under the
18330
- * auto-retry lifecycle; dead-lettered jobs are returned by deadLetters().
18418
+ * auto-retry lifecycle (fail() re-queues them with an incremented attempts
18419
+ * count and a retryBackoff delay) so pop() picks them up again. They are
18420
+ * NOT counted by size("failed") -- that alias counts the dead-letter store,
18421
+ * matching deadLetters(). To include retryable-failed jobs in a total, use
18422
+ * size("pending"). Terminal failures are returned by deadLetters().
18331
18423
  */
18332
18424
  failed() {
18333
- if (this.externalBackend?.failed) {
18334
- return this.externalBackend.failed(this.topic, this._maxRetries);
18335
- }
18336
- return this.liteBackend.failed(this.topic, this._maxRetries);
18425
+ const raw = this.externalBackend?.failed ? this.externalBackend.failed(this.topic, this._maxRetries) : this.liteBackend.failed(this.topic, this._maxRetries);
18426
+ return raw.map(
18427
+ (data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
18428
+ );
18337
18429
  }
18338
18430
  /**
18339
18431
  * Retry all dead letter jobs for this queue's topic.
@@ -18345,8 +18437,8 @@ var init_queue = __esm({
18345
18437
  retry(jobId, delaySeconds) {
18346
18438
  if (jobId) {
18347
18439
  if (this.externalBackend?.retry) {
18348
- this.externalBackend.retry(this.topic, jobId, delaySeconds);
18349
- return true;
18440
+ const result = this.externalBackend.retry(this.topic, jobId, delaySeconds);
18441
+ return result === void 0 ? true : Boolean(result);
18350
18442
  }
18351
18443
  return this.liteBackend.retry(this.topic, jobId, delaySeconds);
18352
18444
  }
@@ -18355,8 +18447,8 @@ var init_queue = __esm({
18355
18447
  let retried = false;
18356
18448
  for (const job of deadJobs) {
18357
18449
  if (this.externalBackend?.retry) {
18358
- this.externalBackend.retry(this.topic, job.id, delaySeconds);
18359
- retried = true;
18450
+ const result = this.externalBackend.retry(this.topic, job.id, delaySeconds);
18451
+ if (result === void 0 || Boolean(result)) retried = true;
18360
18452
  } else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
18361
18453
  retried = true;
18362
18454
  }
@@ -18364,13 +18456,28 @@ var init_queue = __esm({
18364
18456
  return retried;
18365
18457
  }
18366
18458
  /**
18367
- * Get dead letter jobs failed jobs that exceeded max retries.
18459
+ * Get jobs that exceeded max_retries -- terminal failures.
18460
+ *
18461
+ * Same set counted by size("failed") / size("dead") / size("dead_letter")
18462
+ * (three aliases for the dead-letter store). To LIST retryable-but-
18463
+ * attempted jobs (attempts > 0 AND attempts < maxRetries) that are still
18464
+ * being auto-retried, use failed() -- those live in the pending queue and
18465
+ * are NOT dead letters.
18466
+ *
18467
+ * Returns Job objects with the failure reason on ``.error`` (not raw dicts)
18468
+ * so callers can iterate uniformly with the rest of the queue API and, in
18469
+ * particular, call ``.retry()`` on each to manually revive it:
18470
+ *
18471
+ * for (const job of queue.deadLetters()) {
18472
+ * Log.warn(`revived ${job.id}: ${job.error}`);
18473
+ * job.retry();
18474
+ * }
18368
18475
  */
18369
18476
  deadLetters(maxRetries) {
18370
- if (this.externalBackend?.deadLetters) {
18371
- return this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
18372
- }
18373
- return this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
18477
+ const raw = this.externalBackend?.deadLetters ? this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries) : this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
18478
+ return raw.map(
18479
+ (data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
18480
+ );
18374
18481
  }
18375
18482
  /**
18376
18483
  * Delete messages by status (e.g. "completed", "failed", "dead").
@@ -38322,7 +38429,9 @@ function buildAddColumnSql(adapter, table2, colName, def) {
38322
38429
  return sql;
38323
38430
  }
38324
38431
  function mt(db) {
38325
- return engineOf(db) === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
38432
+ const engine = engineOf(db);
38433
+ if (engine === "firebird") return MIGRATION_TABLE;
38434
+ return engine === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
38326
38435
  }
38327
38436
  function deriveDescription(name) {
38328
38437
  return name.replace(/^\d+_/, "").replace(/_/g, " ");
@@ -38345,9 +38454,9 @@ async function ensureMigrationTableOn(db) {
38345
38454
  id INTEGER NOT NULL PRIMARY KEY,
38346
38455
  migration_name VARCHAR(500) NOT NULL UNIQUE,
38347
38456
  description VARCHAR(500),
38348
- batch INTEGER NOT NULL DEFAULT 1,
38457
+ batch INTEGER DEFAULT 1 NOT NULL,
38349
38458
  executed_at VARCHAR(50) NOT NULL,
38350
- passed INTEGER NOT NULL DEFAULT 1
38459
+ passed INTEGER DEFAULT 1 NOT NULL
38351
38460
  )`);
38352
38461
  } else {
38353
38462
  const idCol = migrationIdColumn(db);
@@ -38444,7 +38553,7 @@ async function recordApplied(db, name, batch, passed = 1) {
38444
38553
  "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE"
38445
38554
  );
38446
38555
  insertCols.unshift("id");
38447
- values.unshift(rows[0]?.NEXT_ID ?? 1);
38556
+ values.unshift(rows[0]?.next_id ?? 1);
38448
38557
  }
38449
38558
  const placeholders = insertCols.map(() => "?").join(", ");
38450
38559
  await adapterExecute(
@@ -40858,15 +40967,25 @@ var init_baseModel = __esm({
40858
40967
  /**
40859
40968
  * Invalidate every cached query that touches this model's table.
40860
40969
  *
40861
- * Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that reads
40862
- * this table is busted too (it carries this table's tag), while a query that
40863
- * never touches this table is left intact. Called after every ORM write
40864
- * (save/delete/forceDelete/restore) so a read-after-write never serves a
40865
- * stale/deleted row (CACHE-DEC-01).
40970
+ * Tag-scoped in the ORM layer (a cached JOIN on another model that reads
40971
+ * this table is busted too because it carries this table's tag; a query
40972
+ * that never touches this table is left intact), then cascaded to the
40973
+ * DB layer on this model's bound connection so an out-of-band write /
40974
+ * deliberate refresh / race-with-another-process cannot leave stale rows
40975
+ * in db.fetch()'s persistent cache. Called after every ORM write
40976
+ * (save/delete/forceDelete/restore) so a read-after-write never serves
40977
+ * a stale/deleted row (CACHE-DEC-01). PY-06-22 (3.13.105) added the
40978
+ * DB-layer cascade -- previously the two cache layers disagreed under
40979
+ * TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
40866
40980
  */
40867
40981
  static clearCache() {
40868
40982
  const ModelClass = this;
40869
40983
  modelQueryCache.clearTag((ModelClass.tableName ?? "").toLowerCase());
40984
+ try {
40985
+ const db = ModelClass.getDb();
40986
+ if (typeof db?.cacheClear === "function") db.cacheClear();
40987
+ } catch {
40988
+ }
40870
40989
  }
40871
40990
  /**
40872
40991
  * Execute a raw SQL SELECT and return results as model instances.
@@ -1385,15 +1385,28 @@ export class BaseModel {
1385
1385
  /**
1386
1386
  * Invalidate every cached query that touches this model's table.
1387
1387
  *
1388
- * Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that reads
1389
- * this table is busted too (it carries this table's tag), while a query that
1390
- * never touches this table is left intact. Called after every ORM write
1391
- * (save/delete/forceDelete/restore) so a read-after-write never serves a
1392
- * stale/deleted row (CACHE-DEC-01).
1388
+ * Tag-scoped in the ORM layer (a cached JOIN on another model that reads
1389
+ * this table is busted too because it carries this table's tag; a query
1390
+ * that never touches this table is left intact), then cascaded to the
1391
+ * DB layer on this model's bound connection so an out-of-band write /
1392
+ * deliberate refresh / race-with-another-process cannot leave stale rows
1393
+ * in db.fetch()'s persistent cache. Called after every ORM write
1394
+ * (save/delete/forceDelete/restore) so a read-after-write never serves
1395
+ * a stale/deleted row (CACHE-DEC-01). PY-06-22 (3.13.105) added the
1396
+ * DB-layer cascade -- previously the two cache layers disagreed under
1397
+ * TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
1393
1398
  */
1394
1399
  static clearCache(): void {
1395
1400
  const ModelClass = this as unknown as typeof BaseModel;
1396
1401
  modelQueryCache.clearTag((ModelClass.tableName ?? "").toLowerCase());
1402
+ try {
1403
+ const db: any = ModelClass.getDb();
1404
+ if (typeof db?.cacheClear === "function") db.cacheClear();
1405
+ } catch {
1406
+ // A resolvable DB is not guaranteed at every clearCache() call site
1407
+ // (module-import time in odd bootstraps, tests that mutate bindings);
1408
+ // never let a cache-clear crash a save/delete.
1409
+ }
1397
1410
  }
1398
1411
 
1399
1412
  /**