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 +16 -2
- package/README.md +3 -4
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +233 -43
- package/packages/core/dist/index.js +233 -43
- package/packages/core/public/js/tina4js.min.js +3 -3
- package/packages/core/src/authGate.ts +61 -1
- package/packages/core/src/queue.ts +75 -18
- package/packages/core/src/queueBackends/liteBackend.ts +17 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +80 -14
- package/packages/core/src/router.ts +38 -0
- package/packages/core/src/server.ts +29 -1
- package/packages/core/src/types.ts +4 -0
- package/packages/orm/dist/index.js +233 -43
- package/packages/orm/src/baseModel.ts +18 -5
- package/packages/orm/src/migration.ts +14 -5
- package/types/core/src/authGate.d.ts +3 -0
- package/types/core/src/queue.d.ts +41 -4
- package/types/core/src/queueBackends/liteBackend.d.ts +7 -1
- package/types/core/src/queueBackends/mongoBackend.d.ts +7 -2
- package/types/core/src/router.d.ts +16 -0
- package/types/core/src/types.d.ts +4 -0
- package/types/orm/src/baseModel.d.ts +10 -5
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
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.
|
|
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">
|
|
6
|
+
<p align="center">Zero dependencies. One import, everything works.</p>
|
|
7
7
|
<p align="center">
|
|
8
|
-
<a href="https://
|
|
9
|
-
<img src="https://img.shields.io/
|
|
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
package/packages/cli/dist/bin.js
CHANGED
|
@@ -12352,7 +12352,9 @@ function buildAddColumnSql(adapter, table2, colName, def) {
|
|
|
12352
12352
|
return sql;
|
|
12353
12353
|
}
|
|
12354
12354
|
function mt(db) {
|
|
12355
|
-
|
|
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
|
|
12380
|
+
batch INTEGER DEFAULT 1 NOT NULL,
|
|
12379
12381
|
executed_at VARCHAR(50) NOT NULL,
|
|
12380
|
-
passed INTEGER NOT NULL
|
|
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]?.
|
|
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
|
|
14892
|
-
* this table is busted too
|
|
14893
|
-
* never touches this table is left intact
|
|
14894
|
-
*
|
|
14895
|
-
*
|
|
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
|
|
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 (
|
|
28119
|
-
//
|
|
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
|
-
:
|
|
28124
|
-
await col.
|
|
28125
|
-
|
|
28126
|
-
|
|
28127
|
-
|
|
28128
|
-
|
|
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:
|
|
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
|
|
28176
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
29200
|
-
|
|
29201
|
-
|
|
29202
|
-
|
|
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
|
|
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
|
-
|
|
29237
|
-
|
|
29238
|
-
|
|
29239
|
-
|
|
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 (
|
|
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");
|