tina4-nodejs 3.13.104 → 3.13.108

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.108)
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.108 - 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/README.md CHANGED
@@ -3,11 +3,10 @@
3
3
  </p>
4
4
  <h1 align="center">Tina4 Node.js</h1>
5
5
  <h3 align="center">The Intelligent Native Application 4ramework</h3>
6
- <p align="center">98 built-in features. Zero dependencies. One import, everything works.</p>
6
+ <p align="center">Zero dependencies. One import, everything works.</p>
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@tina4/core"><img src="https://img.shields.io/npm/v/@tina4/core?color=7b1fa2&label=npm" alt="npm"></a>
9
- <img src="https://img.shields.io/badge/tests-2%2C897%20passing-brightgreen" alt="Tests">
10
- <img src="https://img.shields.io/badge/features-98-blue" alt="Features">
8
+ <a href="https://github.com/tina4stack/tina4-nodejs/releases"><img src="https://img.shields.io/github/v/tag/tina4stack/tina4-nodejs?color=7b1fa2&label=version&sort=semver" alt="version"></a>
9
+ <a href="https://github.com/tina4stack/tina4-nodejs/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/tina4stack/tina4-nodejs/test.yml?label=tests" alt="Tests"></a>
11
10
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero Deps">
12
11
  <a href="https://tina4.com"><img src="https://img.shields.io/badge/docs-tina4.com-7b1fa2" alt="Docs"></a>
13
12
  </p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.104",
3
+ "version": "3.13.108",
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.
@@ -19183,6 +19195,31 @@ var init_router = __esm({
19183
19195
  this.route.noAuth = true;
19184
19196
  return this;
19185
19197
  }
19198
+ /**
19199
+ * RBAC: require ONE of the named roles (OR). Reads the verified JWT `roles`
19200
+ * claim. Chain .role()/.can() for AND. Implies auth. Feature 138 / ADR-0058.
19201
+ */
19202
+ role(...names) {
19203
+ const clean = names.filter((n) => n !== "");
19204
+ if (clean.length > 0) {
19205
+ (this.route.requiredRoles ??= []).push(clean);
19206
+ this.route.secure = true;
19207
+ }
19208
+ return this;
19209
+ }
19210
+ /**
19211
+ * RBAC: require ONE of the named permissions (OR). Reads the verified JWT
19212
+ * `permissions` claim; granted-side wildcards (`posts.*`, `*`) satisfy a
19213
+ * concrete requirement. Chain for AND. Implies auth. Feature 138.
19214
+ */
19215
+ can(...permissions) {
19216
+ const clean = permissions.filter((p) => p !== "");
19217
+ if (clean.length > 0) {
19218
+ (this.route.requiredPerms ??= []).push(clean);
19219
+ this.route.secure = true;
19220
+ }
19221
+ return this;
19222
+ }
19186
19223
  /** Mark this route's response as cacheable. */
19187
19224
  cache() {
19188
19225
  this.route.cached = true;
@@ -19250,7 +19287,9 @@ var init_router = __esm({
19250
19287
  secure: secureDefault,
19251
19288
  cached: definition.cached,
19252
19289
  noAuth: definition.noAuth,
19253
- template: definition.template
19290
+ template: definition.template,
19291
+ requiredRoles: definition.requiredRoles,
19292
+ requiredPerms: definition.requiredPerms
19254
19293
  };
19255
19294
  routes.push(compiled);
19256
19295
  return new RouteRef(compiled);
@@ -19397,7 +19436,9 @@ var init_router = __esm({
19397
19436
  template: route.template,
19398
19437
  secure: route.secure,
19399
19438
  cached: route.cached,
19400
- noAuth: route.noAuth
19439
+ noAuth: route.noAuth,
19440
+ requiredRoles: route.requiredRoles,
19441
+ requiredPerms: route.requiredPerms
19401
19442
  };
19402
19443
  }
19403
19444
  }
@@ -19420,7 +19461,9 @@ var init_router = __esm({
19420
19461
  template: route.template,
19421
19462
  secure: route.secure,
19422
19463
  cached: route.cached,
19423
- noAuth: route.noAuth
19464
+ noAuth: route.noAuth,
19465
+ requiredRoles: route.requiredRoles,
19466
+ requiredPerms: route.requiredPerms
19424
19467
  });
19425
19468
  }
19426
19469
  }
@@ -19782,7 +19825,7 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
19782
19825
  const identity = sso?.identity;
19783
19826
  if (identity?.issuer && identity?.subject) {
19784
19827
  req2.user = identity;
19785
- return false;
19828
+ return rbacForbidden(match, identity, res);
19786
19829
  }
19787
19830
  const sessionToken = req2.session?.get?.("token");
19788
19831
  if (sessionToken && validToken(sessionToken)) {
@@ -19802,8 +19845,45 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
19802
19845
  res.header("FreshToken", fresh);
19803
19846
  }
19804
19847
  }
19848
+ return rbacForbidden(match, req2.user, res);
19849
+ }
19850
+ function rbacClaimList(subject, key, legacy) {
19851
+ const coerce = (v) => {
19852
+ if (typeof v === "string") return v === "" ? [] : [v];
19853
+ if (Array.isArray(v)) return v.map((x) => String(x)).filter((x) => x !== "");
19854
+ return [];
19855
+ };
19856
+ let out = coerce(subject[key]);
19857
+ if (out.length === 0 && legacy) out = coerce(subject[legacy]);
19858
+ return out;
19859
+ }
19860
+ function rbacPermGranted(granted, required) {
19861
+ return granted.some(
19862
+ (g) => g === "*" || g === required || g.endsWith(".*") && required.startsWith(g.slice(0, -1))
19863
+ );
19864
+ }
19865
+ function rbacForbidden(match, payload, res) {
19866
+ const requiredRoles = match.requiredRoles ?? [];
19867
+ const requiredPerms = match.requiredPerms ?? [];
19868
+ if (requiredRoles.length === 0 && requiredPerms.length === 0) {
19869
+ return false;
19870
+ }
19871
+ const subject = payload && typeof payload === "object" ? payload : {};
19872
+ const roles = rbacClaimList(subject, "roles", "role");
19873
+ for (const group of requiredRoles) {
19874
+ if (!group.some((r) => roles.includes(r))) return writeForbidden(res);
19875
+ }
19876
+ const perms = rbacClaimList(subject, "permissions");
19877
+ for (const group of requiredPerms) {
19878
+ if (!group.some((p) => rbacPermGranted(perms, p))) return writeForbidden(res);
19879
+ }
19805
19880
  return false;
19806
19881
  }
19882
+ function writeForbidden(res) {
19883
+ res.raw.writeHead(403, { "Content-Type": "application/json" });
19884
+ res.raw.end(JSON.stringify({ error: "Forbidden" }));
19885
+ return true;
19886
+ }
19807
19887
  var init_authGate = __esm({
19808
19888
  "../core/src/authGate.ts"() {
19809
19889
  "use strict";
@@ -28115,17 +28195,67 @@ var init_mongoBackend = __esm({
28115
28195
  process.stdout.write("__OK__");
28116
28196
  }
28117
28197
  else if (operation === "retry") {
28118
- // Explicit manual re-queue (always re-enqueues). data = JSON
28119
- // { id, delaySeconds }.
28198
+ // Explicit manual re-queue. Serves BOTH Queue.retry(id) (revive
28199
+ // a dead-letter job) AND job.retry() (manual re-queue of a live
28200
+ // reserved/pending job) so the Mongo backend matches
28201
+ // LiteBackend's dual behaviour.
28202
+ //
28203
+ // 1) DL revival (Queue.retry(id) after fail exhausted retries).
28204
+ // Pre-3.13.105 this branch was BROKEN: the search filter was
28205
+ // { queue: queueName, id, status: "failed" } -- three separate
28206
+ // reasons it could never match. dead_letter() inserts under
28207
+ // queueName + ".dead_letter" (not queueName), carries
28208
+ // status "dead" (not "failed"), and the original under
28209
+ // queueName was already acked to "completed" by the time the
28210
+ // DL was written. Now we look up in the DL namespace by id,
28211
+ // delete the DL doc first (so an interrupted retry never
28212
+ // leaves both a DL and a fresh pending doc), and upsert the
28213
+ // original back to pending -- re-hydrating if the original
28214
+ // was purged (housekeeping) so a retry always works.
28215
+ // 2) Live-doc manual re-queue (job.retry() on a job the caller
28216
+ // just popped and wants back in pending). The live-doc path
28217
+ // is preserved from before 3.13.105.
28218
+ //
28219
+ // Returns __OK__ when either path acted; __NOT_FOUND__ when
28220
+ // neither the DL nor the live doc existed, so Queue.retry(id)
28221
+ // can now report the pre-3.13.105 blanket-true as false for
28222
+ // unknown ids. data = JSON { id, delaySeconds }.
28120
28223
  const info = JSON.parse(data);
28224
+ const dlTopic = queueName + ".dead_letter";
28225
+ const now = new Date().toISOString();
28121
28226
  const avail = info.delaySeconds > 0
28122
28227
  ? 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__");
28228
+ : now;
28229
+ const dlDoc = await col.findOne({ queue: dlTopic, id: info.id });
28230
+ if (dlDoc !== null) {
28231
+ await col.deleteOne({ _id: dlDoc._id });
28232
+ const payload = dlDoc.payload ?? {};
28233
+ const priority = dlDoc.priority ?? 0;
28234
+ await col.updateOne(
28235
+ { queue: queueName, id: info.id },
28236
+ {
28237
+ $set: {
28238
+ status: "pending",
28239
+ availableAt: avail,
28240
+ reservedAt: null,
28241
+ error: null,
28242
+ payload,
28243
+ priority,
28244
+ id: info.id,
28245
+ createdAt: dlDoc.createdAt ?? now,
28246
+ },
28247
+ $inc: { attempts: 1 },
28248
+ },
28249
+ { upsert: true },
28250
+ );
28251
+ process.stdout.write("__OK__");
28252
+ } else {
28253
+ const result = await col.updateOne(
28254
+ { queue: queueName, id: info.id },
28255
+ { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
28256
+ );
28257
+ process.stdout.write(result.matchedCount > 0 ? "__OK__" : "__NOT_FOUND__");
28258
+ }
28129
28259
  }
28130
28260
  else if (operation === "deadLetters") {
28131
28261
  const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
@@ -28170,10 +28300,20 @@ var init_mongoBackend = __esm({
28170
28300
  process.stdout.write(String(revived));
28171
28301
  }
28172
28302
  else if (operation === "purge") {
28173
- // Delete docs by status (default: all for the topic). data = JSON { status }.
28303
+ // Delete docs by status (default: every doc for the topic).
28304
+ // Pre-3.13.105 this filtered by { queue: queueName, status } for
28305
+ // EVERY status -- correct for pending/reserved/completed, wrong
28306
+ // for the dead-letter states (dead/failed/dead_letter) which
28307
+ // live under queueName + ".dead_letter" and carry status "dead".
28308
+ // A purge("dead") therefore deleted nothing and returned 0.
28309
+ // data = JSON { status }.
28174
28310
  const info = data ? JSON.parse(data) : {};
28175
- const filter = { queue: queueName };
28176
- if (info.status) filter.status = info.status;
28311
+ const isDead = info.status && ["dead", "failed", "dead_letter"].includes(info.status);
28312
+ const filter = isDead
28313
+ ? { queue: queueName + ".dead_letter" }
28314
+ : (info.status
28315
+ ? { queue: queueName, status: info.status }
28316
+ : { queue: queueName });
28177
28317
  const res = await col.deleteMany(filter);
28178
28318
  process.stdout.write(String(res.deletedCount || 0));
28179
28319
  }
@@ -28270,9 +28410,15 @@ var init_mongoBackend = __esm({
28270
28410
  fail(queue, id, error, maxRetries, retryBackoff = 0) {
28271
28411
  this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
28272
28412
  }
28273
- /** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
28413
+ /**
28414
+ * Revive a specific dead-letter job by id. Returns true if the DL was found
28415
+ * and revived, false otherwise (parity with LiteBackend.retry(queue, id)
28416
+ * and Python's mongo_backend.retry_job()). Pre-3.13.105 this returned void
28417
+ * and Queue.retry(id) reported success for every call, even for unknown ids.
28418
+ */
28274
28419
  retry(queue, id, delaySeconds = 0) {
28275
- this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
28420
+ const out = this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
28421
+ return out.includes("__OK__");
28276
28422
  }
28277
28423
  /** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
28278
28424
  deadLetters(queue, maxRetries) {
@@ -28941,10 +29087,20 @@ var init_liteBackend = __esm({
28941
29087
  * Explicit re-queue requested by the caller (job.retry()).
28942
29088
  *
28943
29089
  * Always re-enqueues regardless of the retry limit — manual override,
28944
- * distinct from the automatic failJob() path.
29090
+ * distinct from the automatic failJob() path. Cleans up BOTH the
29091
+ * reservation record AND any dead-letter file for this id, so a caller
29092
+ * that iterates deadLetters() and calls .retry() on each doesn't leave
29093
+ * the failed/ directory carrying duplicates (PY-12-05, 3.13.105).
29094
+ * Aligns with retry(queue, jobId) which had always unlinked the
29095
+ * dead-letter file -- two spellings of the same intent that previously
29096
+ * diverged.
28945
29097
  */
28946
29098
  retryJob(queue, job, delaySeconds) {
28947
29099
  this.clearReservation(queue, job.id);
29100
+ try {
29101
+ unlinkSync7(join23(this.ensureFailedDir(queue), `${job.id}.queue-data`));
29102
+ } catch {
29103
+ }
28948
29104
  job.attempts = (job.attempts || 0) + 1;
28949
29105
  job.error = void 0;
28950
29106
  this.requeue(queue, job, delaySeconds ?? 0, void 0);
@@ -29143,7 +29299,19 @@ var init_queue = __esm({
29143
29299
  }
29144
29300
  }
29145
29301
  /**
29146
- * Count jobs filtered by status. Defaults to "pending".
29302
+ * Count jobs by status. Defaults to "pending".
29303
+ *
29304
+ * ``"pending"`` counts jobs waiting to be popped -- INCLUDES retryable-
29305
+ * but-attempted ones, because they live in the pending queue under the
29306
+ * auto-retry lifecycle (see failed()).
29307
+ * ``"reserved"`` counts jobs a consumer has popped but not yet
29308
+ * completed/failed (in-flight against the visibility timeout).
29309
+ * ``"completed"`` counts jobs the consumer has finished successfully.
29310
+ * ``"failed"``, ``"dead"``, ``"dead_letter"`` are ALIASES that all count
29311
+ * the dead-letter store -- jobs whose attempts >= maxRetries and that
29312
+ * have given up. Use deadLetters() to list them. Retryable-but-attempted
29313
+ * jobs are NOT counted by size("failed"); use failed() to list them or
29314
+ * size("pending") to include them in a total.
29147
29315
  */
29148
29316
  size(status2 = "pending") {
29149
29317
  const q = this.topic;
@@ -29193,13 +29361,17 @@ var init_queue = __esm({
29193
29361
  /**
29194
29362
  * Get jobs that failed at least once but are still being retried
29195
29363
  * (0 < attempts < maxRetries). These live in the pending queue under the
29196
- * auto-retry lifecycle; dead-lettered jobs are returned by deadLetters().
29364
+ * auto-retry lifecycle (fail() re-queues them with an incremented attempts
29365
+ * count and a retryBackoff delay) so pop() picks them up again. They are
29366
+ * NOT counted by size("failed") -- that alias counts the dead-letter store,
29367
+ * matching deadLetters(). To include retryable-failed jobs in a total, use
29368
+ * size("pending"). Terminal failures are returned by deadLetters().
29197
29369
  */
29198
29370
  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);
29371
+ const raw = this.externalBackend?.failed ? this.externalBackend.failed(this.topic, this._maxRetries) : this.liteBackend.failed(this.topic, this._maxRetries);
29372
+ return raw.map(
29373
+ (data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
29374
+ );
29203
29375
  }
29204
29376
  /**
29205
29377
  * Retry all dead letter jobs for this queue's topic.
@@ -29211,8 +29383,8 @@ var init_queue = __esm({
29211
29383
  retry(jobId, delaySeconds) {
29212
29384
  if (jobId) {
29213
29385
  if (this.externalBackend?.retry) {
29214
- this.externalBackend.retry(this.topic, jobId, delaySeconds);
29215
- return true;
29386
+ const result = this.externalBackend.retry(this.topic, jobId, delaySeconds);
29387
+ return result === void 0 ? true : Boolean(result);
29216
29388
  }
29217
29389
  return this.liteBackend.retry(this.topic, jobId, delaySeconds);
29218
29390
  }
@@ -29221,8 +29393,8 @@ var init_queue = __esm({
29221
29393
  let retried = false;
29222
29394
  for (const job of deadJobs) {
29223
29395
  if (this.externalBackend?.retry) {
29224
- this.externalBackend.retry(this.topic, job.id, delaySeconds);
29225
- retried = true;
29396
+ const result = this.externalBackend.retry(this.topic, job.id, delaySeconds);
29397
+ if (result === void 0 || Boolean(result)) retried = true;
29226
29398
  } else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
29227
29399
  retried = true;
29228
29400
  }
@@ -29230,13 +29402,28 @@ var init_queue = __esm({
29230
29402
  return retried;
29231
29403
  }
29232
29404
  /**
29233
- * Get dead letter jobs failed jobs that exceeded max retries.
29405
+ * Get jobs that exceeded max_retries -- terminal failures.
29406
+ *
29407
+ * Same set counted by size("failed") / size("dead") / size("dead_letter")
29408
+ * (three aliases for the dead-letter store). To LIST retryable-but-
29409
+ * attempted jobs (attempts > 0 AND attempts < maxRetries) that are still
29410
+ * being auto-retried, use failed() -- those live in the pending queue and
29411
+ * are NOT dead letters.
29412
+ *
29413
+ * Returns Job objects with the failure reason on ``.error`` (not raw dicts)
29414
+ * so callers can iterate uniformly with the rest of the queue API and, in
29415
+ * particular, call ``.retry()`` on each to manually revive it:
29416
+ *
29417
+ * for (const job of queue.deadLetters()) {
29418
+ * Log.warn(`revived ${job.id}: ${job.error}`);
29419
+ * job.retry();
29420
+ * }
29234
29421
  */
29235
29422
  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);
29423
+ const raw = this.externalBackend?.deadLetters ? this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries) : this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
29424
+ return raw.map(
29425
+ (data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
29426
+ );
29240
29427
  }
29241
29428
  /**
29242
29429
  * Delete messages by status (e.g. "completed", "failed", "dead").
@@ -35793,6 +35980,9 @@ function asHtmlString(chunk) {
35793
35980
  if (Buffer.isBuffer(chunk)) return chunk.toString("utf-8");
35794
35981
  return null;
35795
35982
  }
35983
+ function isInjectableHtml(res) {
35984
+ return isHtmlResponse(res) && !res.raw.getHeader("content-encoding");
35985
+ }
35796
35986
  function injectIntoHtml(ctx, devToolbar, html) {
35797
35987
  if (!devToolbar) return injectFeedbackWidget(ctx.req, html);
35798
35988
  const toolbarCtx = {
@@ -35820,7 +36010,7 @@ function wrapResponseEnd(ctx) {
35820
36010
  Date.now() - ctx.reqStartTime
35821
36011
  );
35822
36012
  }
35823
- if (isHtmlResponse(res)) {
36013
+ if (isInjectableHtml(res)) {
35824
36014
  const html = asHtmlString(chunk);
35825
36015
  if (html !== null) chunk = injectIntoHtml(ctx, devToolbar, html);
35826
36016
  if (!res.raw.headersSent) res.raw.removeHeader("content-length");