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.
package/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.104)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.105)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.13.104 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.105 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
@@ -309,6 +309,20 @@ queue.consume(topic?, id?, pollInterval=1000): AsyncGenerator<QueueJob>
309
309
  // Usage: for await (const job of queue.consume("emails")) { ... }
310
310
  ```
311
311
 
312
+ **size / failed / deadLetters — three surfaces, one distinction (3.13.105 parity).**
313
+ - `queue.size(status)` — `"pending"` counts jobs waiting to be popped AND retryable-
314
+ but-attempted ones (they live in the pending queue under the auto-retry lifecycle).
315
+ `"reserved"` counts in-flight jobs against the visibility timeout. `"completed"`
316
+ counts finished jobs. `"failed"` / `"dead"` / `"dead_letter"` are ALIASES — all
317
+ three count the dead-letter store (== `queue.deadLetters().length`).
318
+ - `queue.failed()` — retryable-but-attempted jobs (0 < attempts < maxRetries) that
319
+ live in the pending queue and are still being auto-retried. They are counted under
320
+ `size("pending")`, NOT `size("failed")`. Use this to LIST them; use
321
+ `size("pending")` to include them in a total.
322
+ - `queue.deadLetters()` — terminal failures (attempts >= maxRetries). Same set that
323
+ `size("failed")` / `size("dead")` / `size("dead_letter")` count. Returns wrapped
324
+ `Job` objects so callers can iterate uniformly and call `.retry()` on each.
325
+
312
326
  ### @tina4/swagger (`packages/swagger/`)
313
327
  Auto-generates OpenAPI 3.0.3 docs.
314
328
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.104",
3
+ "version": "3.13.105",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - native TypeScript conventions and shared Tina4 contracts",
6
6
  "keywords": [
@@ -12352,7 +12352,9 @@ function buildAddColumnSql(adapter, table2, colName, def) {
12352
12352
  return sql;
12353
12353
  }
12354
12354
  function mt(db) {
12355
- return engineOf(db) === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
12355
+ const engine = engineOf(db);
12356
+ if (engine === "firebird") return MIGRATION_TABLE;
12357
+ return engine === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
12356
12358
  }
12357
12359
  function deriveDescription(name) {
12358
12360
  return name.replace(/^\d+_/, "").replace(/_/g, " ");
@@ -12375,9 +12377,9 @@ async function ensureMigrationTableOn(db) {
12375
12377
  id INTEGER NOT NULL PRIMARY KEY,
12376
12378
  migration_name VARCHAR(500) NOT NULL UNIQUE,
12377
12379
  description VARCHAR(500),
12378
- batch INTEGER NOT NULL DEFAULT 1,
12380
+ batch INTEGER DEFAULT 1 NOT NULL,
12379
12381
  executed_at VARCHAR(50) NOT NULL,
12380
- passed INTEGER NOT NULL DEFAULT 1
12382
+ passed INTEGER DEFAULT 1 NOT NULL
12381
12383
  )`);
12382
12384
  } else {
12383
12385
  const idCol = migrationIdColumn(db);
@@ -12474,7 +12476,7 @@ async function recordApplied(db, name, batch, passed = 1) {
12474
12476
  "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE"
12475
12477
  );
12476
12478
  insertCols.unshift("id");
12477
- values.unshift(rows[0]?.NEXT_ID ?? 1);
12479
+ values.unshift(rows[0]?.next_id ?? 1);
12478
12480
  }
12479
12481
  const placeholders = insertCols.map(() => "?").join(", ");
12480
12482
  await adapterExecute(
@@ -14888,15 +14890,25 @@ var init_baseModel = __esm({
14888
14890
  /**
14889
14891
  * Invalidate every cached query that touches this model's table.
14890
14892
  *
14891
- * Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that reads
14892
- * this table is busted too (it carries this table's tag), while a query that
14893
- * never touches this table is left intact. Called after every ORM write
14894
- * (save/delete/forceDelete/restore) so a read-after-write never serves a
14895
- * stale/deleted row (CACHE-DEC-01).
14893
+ * Tag-scoped in the ORM layer (a cached JOIN on another model that reads
14894
+ * this table is busted too because it carries this table's tag; a query
14895
+ * that never touches this table is left intact), then cascaded to the
14896
+ * DB layer on this model's bound connection so an out-of-band write /
14897
+ * deliberate refresh / race-with-another-process cannot leave stale rows
14898
+ * in db.fetch()'s persistent cache. Called after every ORM write
14899
+ * (save/delete/forceDelete/restore) so a read-after-write never serves
14900
+ * a stale/deleted row (CACHE-DEC-01). PY-06-22 (3.13.105) added the
14901
+ * DB-layer cascade -- previously the two cache layers disagreed under
14902
+ * TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
14896
14903
  */
14897
14904
  static clearCache() {
14898
14905
  const ModelClass = this;
14899
14906
  modelQueryCache.clearTag((ModelClass.tableName ?? "").toLowerCase());
14907
+ try {
14908
+ const db = ModelClass.getDb();
14909
+ if (typeof db?.cacheClear === "function") db.cacheClear();
14910
+ } catch {
14911
+ }
14900
14912
  }
14901
14913
  /**
14902
14914
  * Execute a raw SQL SELECT and return results as model instances.
@@ -28115,17 +28127,67 @@ var init_mongoBackend = __esm({
28115
28127
  process.stdout.write("__OK__");
28116
28128
  }
28117
28129
  else if (operation === "retry") {
28118
- // Explicit manual re-queue (always re-enqueues). data = JSON
28119
- // { id, delaySeconds }.
28130
+ // Explicit manual re-queue. Serves BOTH Queue.retry(id) (revive
28131
+ // a dead-letter job) AND job.retry() (manual re-queue of a live
28132
+ // reserved/pending job) so the Mongo backend matches
28133
+ // LiteBackend's dual behaviour.
28134
+ //
28135
+ // 1) DL revival (Queue.retry(id) after fail exhausted retries).
28136
+ // Pre-3.13.105 this branch was BROKEN: the search filter was
28137
+ // { queue: queueName, id, status: "failed" } -- three separate
28138
+ // reasons it could never match. dead_letter() inserts under
28139
+ // queueName + ".dead_letter" (not queueName), carries
28140
+ // status "dead" (not "failed"), and the original under
28141
+ // queueName was already acked to "completed" by the time the
28142
+ // DL was written. Now we look up in the DL namespace by id,
28143
+ // delete the DL doc first (so an interrupted retry never
28144
+ // leaves both a DL and a fresh pending doc), and upsert the
28145
+ // original back to pending -- re-hydrating if the original
28146
+ // was purged (housekeeping) so a retry always works.
28147
+ // 2) Live-doc manual re-queue (job.retry() on a job the caller
28148
+ // just popped and wants back in pending). The live-doc path
28149
+ // is preserved from before 3.13.105.
28150
+ //
28151
+ // Returns __OK__ when either path acted; __NOT_FOUND__ when
28152
+ // neither the DL nor the live doc existed, so Queue.retry(id)
28153
+ // can now report the pre-3.13.105 blanket-true as false for
28154
+ // unknown ids. data = JSON { id, delaySeconds }.
28120
28155
  const info = JSON.parse(data);
28156
+ const dlTopic = queueName + ".dead_letter";
28157
+ const now = new Date().toISOString();
28121
28158
  const avail = info.delaySeconds > 0
28122
28159
  ? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
28123
- : new Date().toISOString();
28124
- await col.updateOne(
28125
- { queue: queueName, id: info.id },
28126
- { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
28127
- );
28128
- process.stdout.write("__OK__");
28160
+ : now;
28161
+ const dlDoc = await col.findOne({ queue: dlTopic, id: info.id });
28162
+ if (dlDoc !== null) {
28163
+ await col.deleteOne({ _id: dlDoc._id });
28164
+ const payload = dlDoc.payload ?? {};
28165
+ const priority = dlDoc.priority ?? 0;
28166
+ await col.updateOne(
28167
+ { queue: queueName, id: info.id },
28168
+ {
28169
+ $set: {
28170
+ status: "pending",
28171
+ availableAt: avail,
28172
+ reservedAt: null,
28173
+ error: null,
28174
+ payload,
28175
+ priority,
28176
+ id: info.id,
28177
+ createdAt: dlDoc.createdAt ?? now,
28178
+ },
28179
+ $inc: { attempts: 1 },
28180
+ },
28181
+ { upsert: true },
28182
+ );
28183
+ process.stdout.write("__OK__");
28184
+ } else {
28185
+ const result = await col.updateOne(
28186
+ { queue: queueName, id: info.id },
28187
+ { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
28188
+ );
28189
+ process.stdout.write(result.matchedCount > 0 ? "__OK__" : "__NOT_FOUND__");
28190
+ }
28129
28191
  }
28130
28192
  else if (operation === "deadLetters") {
28131
28193
  const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
@@ -28170,10 +28232,20 @@ var init_mongoBackend = __esm({
28170
28232
  process.stdout.write(String(revived));
28171
28233
  }
28172
28234
  else if (operation === "purge") {
28173
- // Delete docs by status (default: all for the topic). data = JSON { status }.
28235
+ // Delete docs by status (default: every doc for the topic).
28236
+ // Pre-3.13.105 this filtered by { queue: queueName, status } for
28237
+ // EVERY status -- correct for pending/reserved/completed, wrong
28238
+ // for the dead-letter states (dead/failed/dead_letter) which
28239
+ // live under queueName + ".dead_letter" and carry status "dead".
28240
+ // A purge("dead") therefore deleted nothing and returned 0.
28241
+ // data = JSON { status }.
28174
28242
  const info = data ? JSON.parse(data) : {};
28175
- const filter = { queue: queueName };
28176
- if (info.status) filter.status = info.status;
28243
+ const isDead = info.status && ["dead", "failed", "dead_letter"].includes(info.status);
28244
+ const filter = isDead
28245
+ ? { queue: queueName + ".dead_letter" }
28246
+ : (info.status
28247
+ ? { queue: queueName, status: info.status }
28248
+ : { queue: queueName });
28177
28249
  const res = await col.deleteMany(filter);
28178
28250
  process.stdout.write(String(res.deletedCount || 0));
28179
28251
  }
@@ -28270,9 +28342,15 @@ var init_mongoBackend = __esm({
28270
28342
  fail(queue, id, error, maxRetries, retryBackoff = 0) {
28271
28343
  this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
28272
28344
  }
28273
- /** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
28345
+ /**
28346
+ * Revive a specific dead-letter job by id. Returns true if the DL was found
28347
+ * and revived, false otherwise (parity with LiteBackend.retry(queue, id)
28348
+ * and Python's mongo_backend.retry_job()). Pre-3.13.105 this returned void
28349
+ * and Queue.retry(id) reported success for every call, even for unknown ids.
28350
+ */
28274
28351
  retry(queue, id, delaySeconds = 0) {
28275
- this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
28352
+ const out = this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
28353
+ return out.includes("__OK__");
28276
28354
  }
28277
28355
  /** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
28278
28356
  deadLetters(queue, maxRetries) {
@@ -28941,10 +29019,20 @@ var init_liteBackend = __esm({
28941
29019
  * Explicit re-queue requested by the caller (job.retry()).
28942
29020
  *
28943
29021
  * Always re-enqueues regardless of the retry limit — manual override,
28944
- * distinct from the automatic failJob() path.
29022
+ * distinct from the automatic failJob() path. Cleans up BOTH the
29023
+ * reservation record AND any dead-letter file for this id, so a caller
29024
+ * that iterates deadLetters() and calls .retry() on each doesn't leave
29025
+ * the failed/ directory carrying duplicates (PY-12-05, 3.13.105).
29026
+ * Aligns with retry(queue, jobId) which had always unlinked the
29027
+ * dead-letter file -- two spellings of the same intent that previously
29028
+ * diverged.
28945
29029
  */
28946
29030
  retryJob(queue, job, delaySeconds) {
28947
29031
  this.clearReservation(queue, job.id);
29032
+ try {
29033
+ unlinkSync7(join23(this.ensureFailedDir(queue), `${job.id}.queue-data`));
29034
+ } catch {
29035
+ }
28948
29036
  job.attempts = (job.attempts || 0) + 1;
28949
29037
  job.error = void 0;
28950
29038
  this.requeue(queue, job, delaySeconds ?? 0, void 0);
@@ -29143,7 +29231,19 @@ var init_queue = __esm({
29143
29231
  }
29144
29232
  }
29145
29233
  /**
29146
- * Count jobs filtered by status. Defaults to "pending".
29234
+ * Count jobs by status. Defaults to "pending".
29235
+ *
29236
+ * ``"pending"`` counts jobs waiting to be popped -- INCLUDES retryable-
29237
+ * but-attempted ones, because they live in the pending queue under the
29238
+ * auto-retry lifecycle (see failed()).
29239
+ * ``"reserved"`` counts jobs a consumer has popped but not yet
29240
+ * completed/failed (in-flight against the visibility timeout).
29241
+ * ``"completed"`` counts jobs the consumer has finished successfully.
29242
+ * ``"failed"``, ``"dead"``, ``"dead_letter"`` are ALIASES that all count
29243
+ * the dead-letter store -- jobs whose attempts >= maxRetries and that
29244
+ * have given up. Use deadLetters() to list them. Retryable-but-attempted
29245
+ * jobs are NOT counted by size("failed"); use failed() to list them or
29246
+ * size("pending") to include them in a total.
29147
29247
  */
29148
29248
  size(status2 = "pending") {
29149
29249
  const q = this.topic;
@@ -29193,13 +29293,17 @@ var init_queue = __esm({
29193
29293
  /**
29194
29294
  * Get jobs that failed at least once but are still being retried
29195
29295
  * (0 < attempts < maxRetries). These live in the pending queue under the
29196
- * auto-retry lifecycle; dead-lettered jobs are returned by deadLetters().
29296
+ * auto-retry lifecycle (fail() re-queues them with an incremented attempts
29297
+ * count and a retryBackoff delay) so pop() picks them up again. They are
29298
+ * NOT counted by size("failed") -- that alias counts the dead-letter store,
29299
+ * matching deadLetters(). To include retryable-failed jobs in a total, use
29300
+ * size("pending"). Terminal failures are returned by deadLetters().
29197
29301
  */
29198
29302
  failed() {
29199
- if (this.externalBackend?.failed) {
29200
- return this.externalBackend.failed(this.topic, this._maxRetries);
29201
- }
29202
- return this.liteBackend.failed(this.topic, this._maxRetries);
29303
+ const raw = this.externalBackend?.failed ? this.externalBackend.failed(this.topic, this._maxRetries) : this.liteBackend.failed(this.topic, this._maxRetries);
29304
+ return raw.map(
29305
+ (data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
29306
+ );
29203
29307
  }
29204
29308
  /**
29205
29309
  * Retry all dead letter jobs for this queue's topic.
@@ -29211,8 +29315,8 @@ var init_queue = __esm({
29211
29315
  retry(jobId, delaySeconds) {
29212
29316
  if (jobId) {
29213
29317
  if (this.externalBackend?.retry) {
29214
- this.externalBackend.retry(this.topic, jobId, delaySeconds);
29215
- return true;
29318
+ const result = this.externalBackend.retry(this.topic, jobId, delaySeconds);
29319
+ return result === void 0 ? true : Boolean(result);
29216
29320
  }
29217
29321
  return this.liteBackend.retry(this.topic, jobId, delaySeconds);
29218
29322
  }
@@ -29221,8 +29325,8 @@ var init_queue = __esm({
29221
29325
  let retried = false;
29222
29326
  for (const job of deadJobs) {
29223
29327
  if (this.externalBackend?.retry) {
29224
- this.externalBackend.retry(this.topic, job.id, delaySeconds);
29225
- retried = true;
29328
+ const result = this.externalBackend.retry(this.topic, job.id, delaySeconds);
29329
+ if (result === void 0 || Boolean(result)) retried = true;
29226
29330
  } else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
29227
29331
  retried = true;
29228
29332
  }
@@ -29230,13 +29334,28 @@ var init_queue = __esm({
29230
29334
  return retried;
29231
29335
  }
29232
29336
  /**
29233
- * Get dead letter jobs failed jobs that exceeded max retries.
29337
+ * Get jobs that exceeded max_retries -- terminal failures.
29338
+ *
29339
+ * Same set counted by size("failed") / size("dead") / size("dead_letter")
29340
+ * (three aliases for the dead-letter store). To LIST retryable-but-
29341
+ * attempted jobs (attempts > 0 AND attempts < maxRetries) that are still
29342
+ * being auto-retried, use failed() -- those live in the pending queue and
29343
+ * are NOT dead letters.
29344
+ *
29345
+ * Returns Job objects with the failure reason on ``.error`` (not raw dicts)
29346
+ * so callers can iterate uniformly with the rest of the queue API and, in
29347
+ * particular, call ``.retry()`` on each to manually revive it:
29348
+ *
29349
+ * for (const job of queue.deadLetters()) {
29350
+ * Log.warn(`revived ${job.id}: ${job.error}`);
29351
+ * job.retry();
29352
+ * }
29234
29353
  */
29235
29354
  deadLetters(maxRetries) {
29236
- if (this.externalBackend?.deadLetters) {
29237
- return this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
29238
- }
29239
- return this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
29355
+ const raw = this.externalBackend?.deadLetters ? this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries) : this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
29356
+ return raw.map(
29357
+ (data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
29358
+ );
29240
29359
  }
29241
29360
  /**
29242
29361
  * Delete messages by status (e.g. "completed", "failed", "dead").
@@ -12351,7 +12351,9 @@ function buildAddColumnSql(adapter, table2, colName, def) {
12351
12351
  return sql;
12352
12352
  }
12353
12353
  function mt(db) {
12354
- return engineOf(db) === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
12354
+ const engine = engineOf(db);
12355
+ if (engine === "firebird") return MIGRATION_TABLE;
12356
+ return engine === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
12355
12357
  }
12356
12358
  function deriveDescription(name) {
12357
12359
  return name.replace(/^\d+_/, "").replace(/_/g, " ");
@@ -12374,9 +12376,9 @@ async function ensureMigrationTableOn(db) {
12374
12376
  id INTEGER NOT NULL PRIMARY KEY,
12375
12377
  migration_name VARCHAR(500) NOT NULL UNIQUE,
12376
12378
  description VARCHAR(500),
12377
- batch INTEGER NOT NULL DEFAULT 1,
12379
+ batch INTEGER DEFAULT 1 NOT NULL,
12378
12380
  executed_at VARCHAR(50) NOT NULL,
12379
- passed INTEGER NOT NULL DEFAULT 1
12381
+ passed INTEGER DEFAULT 1 NOT NULL
12380
12382
  )`);
12381
12383
  } else {
12382
12384
  const idCol = migrationIdColumn(db);
@@ -12473,7 +12475,7 @@ async function recordApplied(db, name, batch, passed = 1) {
12473
12475
  "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE"
12474
12476
  );
12475
12477
  insertCols.unshift("id");
12476
- values.unshift(rows[0]?.NEXT_ID ?? 1);
12478
+ values.unshift(rows[0]?.next_id ?? 1);
12477
12479
  }
12478
12480
  const placeholders = insertCols.map(() => "?").join(", ");
12479
12481
  await adapterExecute(
@@ -14887,15 +14889,25 @@ var init_baseModel = __esm({
14887
14889
  /**
14888
14890
  * Invalidate every cached query that touches this model's table.
14889
14891
  *
14890
- * Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that reads
14891
- * this table is busted too (it carries this table's tag), while a query that
14892
- * never touches this table is left intact. Called after every ORM write
14893
- * (save/delete/forceDelete/restore) so a read-after-write never serves a
14894
- * stale/deleted row (CACHE-DEC-01).
14892
+ * Tag-scoped in the ORM layer (a cached JOIN on another model that reads
14893
+ * this table is busted too because it carries this table's tag; a query
14894
+ * that never touches this table is left intact), then cascaded to the
14895
+ * DB layer on this model's bound connection so an out-of-band write /
14896
+ * deliberate refresh / race-with-another-process cannot leave stale rows
14897
+ * in db.fetch()'s persistent cache. Called after every ORM write
14898
+ * (save/delete/forceDelete/restore) so a read-after-write never serves
14899
+ * a stale/deleted row (CACHE-DEC-01). PY-06-22 (3.13.105) added the
14900
+ * DB-layer cascade -- previously the two cache layers disagreed under
14901
+ * TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
14895
14902
  */
14896
14903
  static clearCache() {
14897
14904
  const ModelClass = this;
14898
14905
  modelQueryCache.clearTag((ModelClass.tableName ?? "").toLowerCase());
14906
+ try {
14907
+ const db = ModelClass.getDb();
14908
+ if (typeof db?.cacheClear === "function") db.cacheClear();
14909
+ } catch {
14910
+ }
14899
14911
  }
14900
14912
  /**
14901
14913
  * Execute a raw SQL SELECT and return results as model instances.
@@ -28094,17 +28106,67 @@ var init_mongoBackend = __esm({
28094
28106
  process.stdout.write("__OK__");
28095
28107
  }
28096
28108
  else if (operation === "retry") {
28097
- // Explicit manual re-queue (always re-enqueues). data = JSON
28098
- // { id, delaySeconds }.
28109
+ // Explicit manual re-queue. Serves BOTH Queue.retry(id) (revive
28110
+ // a dead-letter job) AND job.retry() (manual re-queue of a live
28111
+ // reserved/pending job) so the Mongo backend matches
28112
+ // LiteBackend's dual behaviour.
28113
+ //
28114
+ // 1) DL revival (Queue.retry(id) after fail exhausted retries).
28115
+ // Pre-3.13.105 this branch was BROKEN: the search filter was
28116
+ // { queue: queueName, id, status: "failed" } -- three separate
28117
+ // reasons it could never match. dead_letter() inserts under
28118
+ // queueName + ".dead_letter" (not queueName), carries
28119
+ // status "dead" (not "failed"), and the original under
28120
+ // queueName was already acked to "completed" by the time the
28121
+ // DL was written. Now we look up in the DL namespace by id,
28122
+ // delete the DL doc first (so an interrupted retry never
28123
+ // leaves both a DL and a fresh pending doc), and upsert the
28124
+ // original back to pending -- re-hydrating if the original
28125
+ // was purged (housekeeping) so a retry always works.
28126
+ // 2) Live-doc manual re-queue (job.retry() on a job the caller
28127
+ // just popped and wants back in pending). The live-doc path
28128
+ // is preserved from before 3.13.105.
28129
+ //
28130
+ // Returns __OK__ when either path acted; __NOT_FOUND__ when
28131
+ // neither the DL nor the live doc existed, so Queue.retry(id)
28132
+ // can now report the pre-3.13.105 blanket-true as false for
28133
+ // unknown ids. data = JSON { id, delaySeconds }.
28099
28134
  const info = JSON.parse(data);
28135
+ const dlTopic = queueName + ".dead_letter";
28136
+ const now = new Date().toISOString();
28100
28137
  const avail = info.delaySeconds > 0
28101
28138
  ? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
28102
- : new Date().toISOString();
28103
- await col.updateOne(
28104
- { queue: queueName, id: info.id },
28105
- { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
28106
- );
28107
- process.stdout.write("__OK__");
28139
+ : now;
28140
+ const dlDoc = await col.findOne({ queue: dlTopic, id: info.id });
28141
+ if (dlDoc !== null) {
28142
+ await col.deleteOne({ _id: dlDoc._id });
28143
+ const payload = dlDoc.payload ?? {};
28144
+ const priority = dlDoc.priority ?? 0;
28145
+ await col.updateOne(
28146
+ { queue: queueName, id: info.id },
28147
+ {
28148
+ $set: {
28149
+ status: "pending",
28150
+ availableAt: avail,
28151
+ reservedAt: null,
28152
+ error: null,
28153
+ payload,
28154
+ priority,
28155
+ id: info.id,
28156
+ createdAt: dlDoc.createdAt ?? now,
28157
+ },
28158
+ $inc: { attempts: 1 },
28159
+ },
28160
+ { upsert: true },
28161
+ );
28162
+ process.stdout.write("__OK__");
28163
+ } else {
28164
+ const result = await col.updateOne(
28165
+ { queue: queueName, id: info.id },
28166
+ { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
28167
+ );
28168
+ process.stdout.write(result.matchedCount > 0 ? "__OK__" : "__NOT_FOUND__");
28169
+ }
28108
28170
  }
28109
28171
  else if (operation === "deadLetters") {
28110
28172
  const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
@@ -28149,10 +28211,20 @@ var init_mongoBackend = __esm({
28149
28211
  process.stdout.write(String(revived));
28150
28212
  }
28151
28213
  else if (operation === "purge") {
28152
- // Delete docs by status (default: all for the topic). data = JSON { status }.
28214
+ // Delete docs by status (default: every doc for the topic).
28215
+ // Pre-3.13.105 this filtered by { queue: queueName, status } for
28216
+ // EVERY status -- correct for pending/reserved/completed, wrong
28217
+ // for the dead-letter states (dead/failed/dead_letter) which
28218
+ // live under queueName + ".dead_letter" and carry status "dead".
28219
+ // A purge("dead") therefore deleted nothing and returned 0.
28220
+ // data = JSON { status }.
28153
28221
  const info = data ? JSON.parse(data) : {};
28154
- const filter = { queue: queueName };
28155
- if (info.status) filter.status = info.status;
28222
+ const isDead = info.status && ["dead", "failed", "dead_letter"].includes(info.status);
28223
+ const filter = isDead
28224
+ ? { queue: queueName + ".dead_letter" }
28225
+ : (info.status
28226
+ ? { queue: queueName, status: info.status }
28227
+ : { queue: queueName });
28156
28228
  const res = await col.deleteMany(filter);
28157
28229
  process.stdout.write(String(res.deletedCount || 0));
28158
28230
  }
@@ -28249,9 +28321,15 @@ var init_mongoBackend = __esm({
28249
28321
  fail(queue, id, error, maxRetries, retryBackoff = 0) {
28250
28322
  this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
28251
28323
  }
28252
- /** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
28324
+ /**
28325
+ * Revive a specific dead-letter job by id. Returns true if the DL was found
28326
+ * and revived, false otherwise (parity with LiteBackend.retry(queue, id)
28327
+ * and Python's mongo_backend.retry_job()). Pre-3.13.105 this returned void
28328
+ * and Queue.retry(id) reported success for every call, even for unknown ids.
28329
+ */
28253
28330
  retry(queue, id, delaySeconds = 0) {
28254
- this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
28331
+ const out = this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
28332
+ return out.includes("__OK__");
28255
28333
  }
28256
28334
  /** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
28257
28335
  deadLetters(queue, maxRetries) {
@@ -28920,10 +28998,20 @@ var init_liteBackend = __esm({
28920
28998
  * Explicit re-queue requested by the caller (job.retry()).
28921
28999
  *
28922
29000
  * Always re-enqueues regardless of the retry limit — manual override,
28923
- * distinct from the automatic failJob() path.
29001
+ * distinct from the automatic failJob() path. Cleans up BOTH the
29002
+ * reservation record AND any dead-letter file for this id, so a caller
29003
+ * that iterates deadLetters() and calls .retry() on each doesn't leave
29004
+ * the failed/ directory carrying duplicates (PY-12-05, 3.13.105).
29005
+ * Aligns with retry(queue, jobId) which had always unlinked the
29006
+ * dead-letter file -- two spellings of the same intent that previously
29007
+ * diverged.
28924
29008
  */
28925
29009
  retryJob(queue, job, delaySeconds) {
28926
29010
  this.clearReservation(queue, job.id);
29011
+ try {
29012
+ unlinkSync7(join22(this.ensureFailedDir(queue), `${job.id}.queue-data`));
29013
+ } catch {
29014
+ }
28927
29015
  job.attempts = (job.attempts || 0) + 1;
28928
29016
  job.error = void 0;
28929
29017
  this.requeue(queue, job, delaySeconds ?? 0, void 0);
@@ -29122,7 +29210,19 @@ var init_queue = __esm({
29122
29210
  }
29123
29211
  }
29124
29212
  /**
29125
- * Count jobs filtered by status. Defaults to "pending".
29213
+ * Count jobs by status. Defaults to "pending".
29214
+ *
29215
+ * ``"pending"`` counts jobs waiting to be popped -- INCLUDES retryable-
29216
+ * but-attempted ones, because they live in the pending queue under the
29217
+ * auto-retry lifecycle (see failed()).
29218
+ * ``"reserved"`` counts jobs a consumer has popped but not yet
29219
+ * completed/failed (in-flight against the visibility timeout).
29220
+ * ``"completed"`` counts jobs the consumer has finished successfully.
29221
+ * ``"failed"``, ``"dead"``, ``"dead_letter"`` are ALIASES that all count
29222
+ * the dead-letter store -- jobs whose attempts >= maxRetries and that
29223
+ * have given up. Use deadLetters() to list them. Retryable-but-attempted
29224
+ * jobs are NOT counted by size("failed"); use failed() to list them or
29225
+ * size("pending") to include them in a total.
29126
29226
  */
29127
29227
  size(status2 = "pending") {
29128
29228
  const q = this.topic;
@@ -29172,13 +29272,17 @@ var init_queue = __esm({
29172
29272
  /**
29173
29273
  * Get jobs that failed at least once but are still being retried
29174
29274
  * (0 < attempts < maxRetries). These live in the pending queue under the
29175
- * auto-retry lifecycle; dead-lettered jobs are returned by deadLetters().
29275
+ * auto-retry lifecycle (fail() re-queues them with an incremented attempts
29276
+ * count and a retryBackoff delay) so pop() picks them up again. They are
29277
+ * NOT counted by size("failed") -- that alias counts the dead-letter store,
29278
+ * matching deadLetters(). To include retryable-failed jobs in a total, use
29279
+ * size("pending"). Terminal failures are returned by deadLetters().
29176
29280
  */
29177
29281
  failed() {
29178
- if (this.externalBackend?.failed) {
29179
- return this.externalBackend.failed(this.topic, this._maxRetries);
29180
- }
29181
- return this.liteBackend.failed(this.topic, this._maxRetries);
29282
+ const raw = this.externalBackend?.failed ? this.externalBackend.failed(this.topic, this._maxRetries) : this.liteBackend.failed(this.topic, this._maxRetries);
29283
+ return raw.map(
29284
+ (data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
29285
+ );
29182
29286
  }
29183
29287
  /**
29184
29288
  * Retry all dead letter jobs for this queue's topic.
@@ -29190,8 +29294,8 @@ var init_queue = __esm({
29190
29294
  retry(jobId, delaySeconds) {
29191
29295
  if (jobId) {
29192
29296
  if (this.externalBackend?.retry) {
29193
- this.externalBackend.retry(this.topic, jobId, delaySeconds);
29194
- return true;
29297
+ const result = this.externalBackend.retry(this.topic, jobId, delaySeconds);
29298
+ return result === void 0 ? true : Boolean(result);
29195
29299
  }
29196
29300
  return this.liteBackend.retry(this.topic, jobId, delaySeconds);
29197
29301
  }
@@ -29200,8 +29304,8 @@ var init_queue = __esm({
29200
29304
  let retried = false;
29201
29305
  for (const job of deadJobs) {
29202
29306
  if (this.externalBackend?.retry) {
29203
- this.externalBackend.retry(this.topic, job.id, delaySeconds);
29204
- retried = true;
29307
+ const result = this.externalBackend.retry(this.topic, job.id, delaySeconds);
29308
+ if (result === void 0 || Boolean(result)) retried = true;
29205
29309
  } else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
29206
29310
  retried = true;
29207
29311
  }
@@ -29209,13 +29313,28 @@ var init_queue = __esm({
29209
29313
  return retried;
29210
29314
  }
29211
29315
  /**
29212
- * Get dead letter jobs failed jobs that exceeded max retries.
29316
+ * Get jobs that exceeded max_retries -- terminal failures.
29317
+ *
29318
+ * Same set counted by size("failed") / size("dead") / size("dead_letter")
29319
+ * (three aliases for the dead-letter store). To LIST retryable-but-
29320
+ * attempted jobs (attempts > 0 AND attempts < maxRetries) that are still
29321
+ * being auto-retried, use failed() -- those live in the pending queue and
29322
+ * are NOT dead letters.
29323
+ *
29324
+ * Returns Job objects with the failure reason on ``.error`` (not raw dicts)
29325
+ * so callers can iterate uniformly with the rest of the queue API and, in
29326
+ * particular, call ``.retry()`` on each to manually revive it:
29327
+ *
29328
+ * for (const job of queue.deadLetters()) {
29329
+ * Log.warn(`revived ${job.id}: ${job.error}`);
29330
+ * job.retry();
29331
+ * }
29213
29332
  */
29214
29333
  deadLetters(maxRetries) {
29215
- if (this.externalBackend?.deadLetters) {
29216
- return this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
29217
- }
29218
- return this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
29334
+ const raw = this.externalBackend?.deadLetters ? this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries) : this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
29335
+ return raw.map(
29336
+ (data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
29337
+ );
29219
29338
  }
29220
29339
  /**
29221
29340
  * Delete messages by status (e.g. "completed", "failed", "dead").