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
|
@@ -8337,6 +8337,31 @@ var init_router = __esm({
|
|
|
8337
8337
|
this.route.noAuth = true;
|
|
8338
8338
|
return this;
|
|
8339
8339
|
}
|
|
8340
|
+
/**
|
|
8341
|
+
* RBAC: require ONE of the named roles (OR). Reads the verified JWT `roles`
|
|
8342
|
+
* claim. Chain .role()/.can() for AND. Implies auth. Feature 138 / ADR-0058.
|
|
8343
|
+
*/
|
|
8344
|
+
role(...names) {
|
|
8345
|
+
const clean = names.filter((n) => n !== "");
|
|
8346
|
+
if (clean.length > 0) {
|
|
8347
|
+
(this.route.requiredRoles ??= []).push(clean);
|
|
8348
|
+
this.route.secure = true;
|
|
8349
|
+
}
|
|
8350
|
+
return this;
|
|
8351
|
+
}
|
|
8352
|
+
/**
|
|
8353
|
+
* RBAC: require ONE of the named permissions (OR). Reads the verified JWT
|
|
8354
|
+
* `permissions` claim; granted-side wildcards (`posts.*`, `*`) satisfy a
|
|
8355
|
+
* concrete requirement. Chain for AND. Implies auth. Feature 138.
|
|
8356
|
+
*/
|
|
8357
|
+
can(...permissions) {
|
|
8358
|
+
const clean = permissions.filter((p) => p !== "");
|
|
8359
|
+
if (clean.length > 0) {
|
|
8360
|
+
(this.route.requiredPerms ??= []).push(clean);
|
|
8361
|
+
this.route.secure = true;
|
|
8362
|
+
}
|
|
8363
|
+
return this;
|
|
8364
|
+
}
|
|
8340
8365
|
/** Mark this route's response as cacheable. */
|
|
8341
8366
|
cache() {
|
|
8342
8367
|
this.route.cached = true;
|
|
@@ -8404,7 +8429,9 @@ var init_router = __esm({
|
|
|
8404
8429
|
secure: secureDefault,
|
|
8405
8430
|
cached: definition.cached,
|
|
8406
8431
|
noAuth: definition.noAuth,
|
|
8407
|
-
template: definition.template
|
|
8432
|
+
template: definition.template,
|
|
8433
|
+
requiredRoles: definition.requiredRoles,
|
|
8434
|
+
requiredPerms: definition.requiredPerms
|
|
8408
8435
|
};
|
|
8409
8436
|
routes.push(compiled);
|
|
8410
8437
|
return new RouteRef(compiled);
|
|
@@ -8551,7 +8578,9 @@ var init_router = __esm({
|
|
|
8551
8578
|
template: route.template,
|
|
8552
8579
|
secure: route.secure,
|
|
8553
8580
|
cached: route.cached,
|
|
8554
|
-
noAuth: route.noAuth
|
|
8581
|
+
noAuth: route.noAuth,
|
|
8582
|
+
requiredRoles: route.requiredRoles,
|
|
8583
|
+
requiredPerms: route.requiredPerms
|
|
8555
8584
|
};
|
|
8556
8585
|
}
|
|
8557
8586
|
}
|
|
@@ -8574,7 +8603,9 @@ var init_router = __esm({
|
|
|
8574
8603
|
template: route.template,
|
|
8575
8604
|
secure: route.secure,
|
|
8576
8605
|
cached: route.cached,
|
|
8577
|
-
noAuth: route.noAuth
|
|
8606
|
+
noAuth: route.noAuth,
|
|
8607
|
+
requiredRoles: route.requiredRoles,
|
|
8608
|
+
requiredPerms: route.requiredPerms
|
|
8578
8609
|
});
|
|
8579
8610
|
}
|
|
8580
8611
|
}
|
|
@@ -8936,7 +8967,7 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
|
|
|
8936
8967
|
const identity = sso?.identity;
|
|
8937
8968
|
if (identity?.issuer && identity?.subject) {
|
|
8938
8969
|
req2.user = identity;
|
|
8939
|
-
return
|
|
8970
|
+
return rbacForbidden(match, identity, res);
|
|
8940
8971
|
}
|
|
8941
8972
|
const sessionToken = req2.session?.get?.("token");
|
|
8942
8973
|
if (sessionToken && validToken(sessionToken)) {
|
|
@@ -8956,8 +8987,45 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
|
|
|
8956
8987
|
res.header("FreshToken", fresh);
|
|
8957
8988
|
}
|
|
8958
8989
|
}
|
|
8990
|
+
return rbacForbidden(match, req2.user, res);
|
|
8991
|
+
}
|
|
8992
|
+
function rbacClaimList(subject, key, legacy) {
|
|
8993
|
+
const coerce = (v) => {
|
|
8994
|
+
if (typeof v === "string") return v === "" ? [] : [v];
|
|
8995
|
+
if (Array.isArray(v)) return v.map((x) => String(x)).filter((x) => x !== "");
|
|
8996
|
+
return [];
|
|
8997
|
+
};
|
|
8998
|
+
let out = coerce(subject[key]);
|
|
8999
|
+
if (out.length === 0 && legacy) out = coerce(subject[legacy]);
|
|
9000
|
+
return out;
|
|
9001
|
+
}
|
|
9002
|
+
function rbacPermGranted(granted, required) {
|
|
9003
|
+
return granted.some(
|
|
9004
|
+
(g) => g === "*" || g === required || g.endsWith(".*") && required.startsWith(g.slice(0, -1))
|
|
9005
|
+
);
|
|
9006
|
+
}
|
|
9007
|
+
function rbacForbidden(match, payload, res) {
|
|
9008
|
+
const requiredRoles = match.requiredRoles ?? [];
|
|
9009
|
+
const requiredPerms = match.requiredPerms ?? [];
|
|
9010
|
+
if (requiredRoles.length === 0 && requiredPerms.length === 0) {
|
|
9011
|
+
return false;
|
|
9012
|
+
}
|
|
9013
|
+
const subject = payload && typeof payload === "object" ? payload : {};
|
|
9014
|
+
const roles = rbacClaimList(subject, "roles", "role");
|
|
9015
|
+
for (const group of requiredRoles) {
|
|
9016
|
+
if (!group.some((r) => roles.includes(r))) return writeForbidden(res);
|
|
9017
|
+
}
|
|
9018
|
+
const perms = rbacClaimList(subject, "permissions");
|
|
9019
|
+
for (const group of requiredPerms) {
|
|
9020
|
+
if (!group.some((p) => rbacPermGranted(perms, p))) return writeForbidden(res);
|
|
9021
|
+
}
|
|
8959
9022
|
return false;
|
|
8960
9023
|
}
|
|
9024
|
+
function writeForbidden(res) {
|
|
9025
|
+
res.raw.writeHead(403, { "Content-Type": "application/json" });
|
|
9026
|
+
res.raw.end(JSON.stringify({ error: "Forbidden" }));
|
|
9027
|
+
return true;
|
|
9028
|
+
}
|
|
8961
9029
|
var init_authGate = __esm({
|
|
8962
9030
|
"../core/src/authGate.ts"() {
|
|
8963
9031
|
"use strict";
|
|
@@ -17249,17 +17317,67 @@ var init_mongoBackend = __esm({
|
|
|
17249
17317
|
process.stdout.write("__OK__");
|
|
17250
17318
|
}
|
|
17251
17319
|
else if (operation === "retry") {
|
|
17252
|
-
// Explicit manual re-queue (
|
|
17253
|
-
//
|
|
17320
|
+
// Explicit manual re-queue. Serves BOTH Queue.retry(id) (revive
|
|
17321
|
+
// a dead-letter job) AND job.retry() (manual re-queue of a live
|
|
17322
|
+
// reserved/pending job) so the Mongo backend matches
|
|
17323
|
+
// LiteBackend's dual behaviour.
|
|
17324
|
+
//
|
|
17325
|
+
// 1) DL revival (Queue.retry(id) after fail exhausted retries).
|
|
17326
|
+
// Pre-3.13.105 this branch was BROKEN: the search filter was
|
|
17327
|
+
// { queue: queueName, id, status: "failed" } -- three separate
|
|
17328
|
+
// reasons it could never match. dead_letter() inserts under
|
|
17329
|
+
// queueName + ".dead_letter" (not queueName), carries
|
|
17330
|
+
// status "dead" (not "failed"), and the original under
|
|
17331
|
+
// queueName was already acked to "completed" by the time the
|
|
17332
|
+
// DL was written. Now we look up in the DL namespace by id,
|
|
17333
|
+
// delete the DL doc first (so an interrupted retry never
|
|
17334
|
+
// leaves both a DL and a fresh pending doc), and upsert the
|
|
17335
|
+
// original back to pending -- re-hydrating if the original
|
|
17336
|
+
// was purged (housekeeping) so a retry always works.
|
|
17337
|
+
// 2) Live-doc manual re-queue (job.retry() on a job the caller
|
|
17338
|
+
// just popped and wants back in pending). The live-doc path
|
|
17339
|
+
// is preserved from before 3.13.105.
|
|
17340
|
+
//
|
|
17341
|
+
// Returns __OK__ when either path acted; __NOT_FOUND__ when
|
|
17342
|
+
// neither the DL nor the live doc existed, so Queue.retry(id)
|
|
17343
|
+
// can now report the pre-3.13.105 blanket-true as false for
|
|
17344
|
+
// unknown ids. data = JSON { id, delaySeconds }.
|
|
17254
17345
|
const info = JSON.parse(data);
|
|
17346
|
+
const dlTopic = queueName + ".dead_letter";
|
|
17347
|
+
const now = new Date().toISOString();
|
|
17255
17348
|
const avail = info.delaySeconds > 0
|
|
17256
17349
|
? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
|
|
17257
|
-
:
|
|
17258
|
-
await col.
|
|
17259
|
-
|
|
17260
|
-
|
|
17261
|
-
|
|
17262
|
-
|
|
17350
|
+
: now;
|
|
17351
|
+
const dlDoc = await col.findOne({ queue: dlTopic, id: info.id });
|
|
17352
|
+
if (dlDoc !== null) {
|
|
17353
|
+
await col.deleteOne({ _id: dlDoc._id });
|
|
17354
|
+
const payload = dlDoc.payload ?? {};
|
|
17355
|
+
const priority = dlDoc.priority ?? 0;
|
|
17356
|
+
await col.updateOne(
|
|
17357
|
+
{ queue: queueName, id: info.id },
|
|
17358
|
+
{
|
|
17359
|
+
$set: {
|
|
17360
|
+
status: "pending",
|
|
17361
|
+
availableAt: avail,
|
|
17362
|
+
reservedAt: null,
|
|
17363
|
+
error: null,
|
|
17364
|
+
payload,
|
|
17365
|
+
priority,
|
|
17366
|
+
id: info.id,
|
|
17367
|
+
createdAt: dlDoc.createdAt ?? now,
|
|
17368
|
+
},
|
|
17369
|
+
$inc: { attempts: 1 },
|
|
17370
|
+
},
|
|
17371
|
+
{ upsert: true },
|
|
17372
|
+
);
|
|
17373
|
+
process.stdout.write("__OK__");
|
|
17374
|
+
} else {
|
|
17375
|
+
const result = await col.updateOne(
|
|
17376
|
+
{ queue: queueName, id: info.id },
|
|
17377
|
+
{ $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
|
|
17378
|
+
);
|
|
17379
|
+
process.stdout.write(result.matchedCount > 0 ? "__OK__" : "__NOT_FOUND__");
|
|
17380
|
+
}
|
|
17263
17381
|
}
|
|
17264
17382
|
else if (operation === "deadLetters") {
|
|
17265
17383
|
const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
|
|
@@ -17304,10 +17422,20 @@ var init_mongoBackend = __esm({
|
|
|
17304
17422
|
process.stdout.write(String(revived));
|
|
17305
17423
|
}
|
|
17306
17424
|
else if (operation === "purge") {
|
|
17307
|
-
// Delete docs by status (default:
|
|
17425
|
+
// Delete docs by status (default: every doc for the topic).
|
|
17426
|
+
// Pre-3.13.105 this filtered by { queue: queueName, status } for
|
|
17427
|
+
// EVERY status -- correct for pending/reserved/completed, wrong
|
|
17428
|
+
// for the dead-letter states (dead/failed/dead_letter) which
|
|
17429
|
+
// live under queueName + ".dead_letter" and carry status "dead".
|
|
17430
|
+
// A purge("dead") therefore deleted nothing and returned 0.
|
|
17431
|
+
// data = JSON { status }.
|
|
17308
17432
|
const info = data ? JSON.parse(data) : {};
|
|
17309
|
-
const
|
|
17310
|
-
|
|
17433
|
+
const isDead = info.status && ["dead", "failed", "dead_letter"].includes(info.status);
|
|
17434
|
+
const filter = isDead
|
|
17435
|
+
? { queue: queueName + ".dead_letter" }
|
|
17436
|
+
: (info.status
|
|
17437
|
+
? { queue: queueName, status: info.status }
|
|
17438
|
+
: { queue: queueName });
|
|
17311
17439
|
const res = await col.deleteMany(filter);
|
|
17312
17440
|
process.stdout.write(String(res.deletedCount || 0));
|
|
17313
17441
|
}
|
|
@@ -17404,9 +17532,15 @@ var init_mongoBackend = __esm({
|
|
|
17404
17532
|
fail(queue, id, error, maxRetries, retryBackoff = 0) {
|
|
17405
17533
|
this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
|
|
17406
17534
|
}
|
|
17407
|
-
/**
|
|
17535
|
+
/**
|
|
17536
|
+
* Revive a specific dead-letter job by id. Returns true if the DL was found
|
|
17537
|
+
* and revived, false otherwise (parity with LiteBackend.retry(queue, id)
|
|
17538
|
+
* and Python's mongo_backend.retry_job()). Pre-3.13.105 this returned void
|
|
17539
|
+
* and Queue.retry(id) reported success for every call, even for unknown ids.
|
|
17540
|
+
*/
|
|
17408
17541
|
retry(queue, id, delaySeconds = 0) {
|
|
17409
|
-
this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
|
|
17542
|
+
const out = this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
|
|
17543
|
+
return out.includes("__OK__");
|
|
17410
17544
|
}
|
|
17411
17545
|
/** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
|
|
17412
17546
|
deadLetters(queue, maxRetries) {
|
|
@@ -18075,10 +18209,20 @@ var init_liteBackend = __esm({
|
|
|
18075
18209
|
* Explicit re-queue requested by the caller (job.retry()).
|
|
18076
18210
|
*
|
|
18077
18211
|
* Always re-enqueues regardless of the retry limit — manual override,
|
|
18078
|
-
* distinct from the automatic failJob() path.
|
|
18212
|
+
* distinct from the automatic failJob() path. Cleans up BOTH the
|
|
18213
|
+
* reservation record AND any dead-letter file for this id, so a caller
|
|
18214
|
+
* that iterates deadLetters() and calls .retry() on each doesn't leave
|
|
18215
|
+
* the failed/ directory carrying duplicates (PY-12-05, 3.13.105).
|
|
18216
|
+
* Aligns with retry(queue, jobId) which had always unlinked the
|
|
18217
|
+
* dead-letter file -- two spellings of the same intent that previously
|
|
18218
|
+
* diverged.
|
|
18079
18219
|
*/
|
|
18080
18220
|
retryJob(queue, job, delaySeconds) {
|
|
18081
18221
|
this.clearReservation(queue, job.id);
|
|
18222
|
+
try {
|
|
18223
|
+
unlinkSync6(join17(this.ensureFailedDir(queue), `${job.id}.queue-data`));
|
|
18224
|
+
} catch {
|
|
18225
|
+
}
|
|
18082
18226
|
job.attempts = (job.attempts || 0) + 1;
|
|
18083
18227
|
job.error = void 0;
|
|
18084
18228
|
this.requeue(queue, job, delaySeconds ?? 0, void 0);
|
|
@@ -18277,7 +18421,19 @@ var init_queue = __esm({
|
|
|
18277
18421
|
}
|
|
18278
18422
|
}
|
|
18279
18423
|
/**
|
|
18280
|
-
* Count jobs
|
|
18424
|
+
* Count jobs by status. Defaults to "pending".
|
|
18425
|
+
*
|
|
18426
|
+
* ``"pending"`` counts jobs waiting to be popped -- INCLUDES retryable-
|
|
18427
|
+
* but-attempted ones, because they live in the pending queue under the
|
|
18428
|
+
* auto-retry lifecycle (see failed()).
|
|
18429
|
+
* ``"reserved"`` counts jobs a consumer has popped but not yet
|
|
18430
|
+
* completed/failed (in-flight against the visibility timeout).
|
|
18431
|
+
* ``"completed"`` counts jobs the consumer has finished successfully.
|
|
18432
|
+
* ``"failed"``, ``"dead"``, ``"dead_letter"`` are ALIASES that all count
|
|
18433
|
+
* the dead-letter store -- jobs whose attempts >= maxRetries and that
|
|
18434
|
+
* have given up. Use deadLetters() to list them. Retryable-but-attempted
|
|
18435
|
+
* jobs are NOT counted by size("failed"); use failed() to list them or
|
|
18436
|
+
* size("pending") to include them in a total.
|
|
18281
18437
|
*/
|
|
18282
18438
|
size(status2 = "pending") {
|
|
18283
18439
|
const q = this.topic;
|
|
@@ -18327,13 +18483,17 @@ var init_queue = __esm({
|
|
|
18327
18483
|
/**
|
|
18328
18484
|
* Get jobs that failed at least once but are still being retried
|
|
18329
18485
|
* (0 < attempts < maxRetries). These live in the pending queue under the
|
|
18330
|
-
* auto-retry lifecycle
|
|
18486
|
+
* auto-retry lifecycle (fail() re-queues them with an incremented attempts
|
|
18487
|
+
* count and a retryBackoff delay) so pop() picks them up again. They are
|
|
18488
|
+
* NOT counted by size("failed") -- that alias counts the dead-letter store,
|
|
18489
|
+
* matching deadLetters(). To include retryable-failed jobs in a total, use
|
|
18490
|
+
* size("pending"). Terminal failures are returned by deadLetters().
|
|
18331
18491
|
*/
|
|
18332
18492
|
failed() {
|
|
18333
|
-
|
|
18334
|
-
|
|
18335
|
-
|
|
18336
|
-
|
|
18493
|
+
const raw = this.externalBackend?.failed ? this.externalBackend.failed(this.topic, this._maxRetries) : this.liteBackend.failed(this.topic, this._maxRetries);
|
|
18494
|
+
return raw.map(
|
|
18495
|
+
(data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
|
|
18496
|
+
);
|
|
18337
18497
|
}
|
|
18338
18498
|
/**
|
|
18339
18499
|
* Retry all dead letter jobs for this queue's topic.
|
|
@@ -18345,8 +18505,8 @@ var init_queue = __esm({
|
|
|
18345
18505
|
retry(jobId, delaySeconds) {
|
|
18346
18506
|
if (jobId) {
|
|
18347
18507
|
if (this.externalBackend?.retry) {
|
|
18348
|
-
this.externalBackend.retry(this.topic, jobId, delaySeconds);
|
|
18349
|
-
return true;
|
|
18508
|
+
const result = this.externalBackend.retry(this.topic, jobId, delaySeconds);
|
|
18509
|
+
return result === void 0 ? true : Boolean(result);
|
|
18350
18510
|
}
|
|
18351
18511
|
return this.liteBackend.retry(this.topic, jobId, delaySeconds);
|
|
18352
18512
|
}
|
|
@@ -18355,8 +18515,8 @@ var init_queue = __esm({
|
|
|
18355
18515
|
let retried = false;
|
|
18356
18516
|
for (const job of deadJobs) {
|
|
18357
18517
|
if (this.externalBackend?.retry) {
|
|
18358
|
-
this.externalBackend.retry(this.topic, job.id, delaySeconds);
|
|
18359
|
-
retried = true;
|
|
18518
|
+
const result = this.externalBackend.retry(this.topic, job.id, delaySeconds);
|
|
18519
|
+
if (result === void 0 || Boolean(result)) retried = true;
|
|
18360
18520
|
} else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
|
|
18361
18521
|
retried = true;
|
|
18362
18522
|
}
|
|
@@ -18364,13 +18524,28 @@ var init_queue = __esm({
|
|
|
18364
18524
|
return retried;
|
|
18365
18525
|
}
|
|
18366
18526
|
/**
|
|
18367
|
-
* Get
|
|
18527
|
+
* Get jobs that exceeded max_retries -- terminal failures.
|
|
18528
|
+
*
|
|
18529
|
+
* Same set counted by size("failed") / size("dead") / size("dead_letter")
|
|
18530
|
+
* (three aliases for the dead-letter store). To LIST retryable-but-
|
|
18531
|
+
* attempted jobs (attempts > 0 AND attempts < maxRetries) that are still
|
|
18532
|
+
* being auto-retried, use failed() -- those live in the pending queue and
|
|
18533
|
+
* are NOT dead letters.
|
|
18534
|
+
*
|
|
18535
|
+
* Returns Job objects with the failure reason on ``.error`` (not raw dicts)
|
|
18536
|
+
* so callers can iterate uniformly with the rest of the queue API and, in
|
|
18537
|
+
* particular, call ``.retry()`` on each to manually revive it:
|
|
18538
|
+
*
|
|
18539
|
+
* for (const job of queue.deadLetters()) {
|
|
18540
|
+
* Log.warn(`revived ${job.id}: ${job.error}`);
|
|
18541
|
+
* job.retry();
|
|
18542
|
+
* }
|
|
18368
18543
|
*/
|
|
18369
18544
|
deadLetters(maxRetries) {
|
|
18370
|
-
|
|
18371
|
-
|
|
18372
|
-
|
|
18373
|
-
|
|
18545
|
+
const raw = this.externalBackend?.deadLetters ? this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries) : this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
|
|
18546
|
+
return raw.map(
|
|
18547
|
+
(data) => createJob({ ...data, topic: data.topic ?? this.topic }, this)
|
|
18548
|
+
);
|
|
18374
18549
|
}
|
|
18375
18550
|
/**
|
|
18376
18551
|
* Delete messages by status (e.g. "completed", "failed", "dead").
|
|
@@ -24927,6 +25102,9 @@ function asHtmlString(chunk) {
|
|
|
24927
25102
|
if (Buffer.isBuffer(chunk)) return chunk.toString("utf-8");
|
|
24928
25103
|
return null;
|
|
24929
25104
|
}
|
|
25105
|
+
function isInjectableHtml(res) {
|
|
25106
|
+
return isHtmlResponse(res) && !res.raw.getHeader("content-encoding");
|
|
25107
|
+
}
|
|
24930
25108
|
function injectIntoHtml(ctx, devToolbar, html) {
|
|
24931
25109
|
if (!devToolbar) return injectFeedbackWidget(ctx.req, html);
|
|
24932
25110
|
const toolbarCtx = {
|
|
@@ -24954,7 +25132,7 @@ function wrapResponseEnd(ctx) {
|
|
|
24954
25132
|
Date.now() - ctx.reqStartTime
|
|
24955
25133
|
);
|
|
24956
25134
|
}
|
|
24957
|
-
if (
|
|
25135
|
+
if (isInjectableHtml(res)) {
|
|
24958
25136
|
const html = asHtmlString(chunk);
|
|
24959
25137
|
if (html !== null) chunk = injectIntoHtml(ctx, devToolbar, html);
|
|
24960
25138
|
if (!res.raw.headersSent) res.raw.removeHeader("content-length");
|
|
@@ -38322,7 +38500,9 @@ function buildAddColumnSql(adapter, table2, colName, def) {
|
|
|
38322
38500
|
return sql;
|
|
38323
38501
|
}
|
|
38324
38502
|
function mt(db) {
|
|
38325
|
-
|
|
38503
|
+
const engine = engineOf(db);
|
|
38504
|
+
if (engine === "firebird") return MIGRATION_TABLE;
|
|
38505
|
+
return engine === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
|
|
38326
38506
|
}
|
|
38327
38507
|
function deriveDescription(name) {
|
|
38328
38508
|
return name.replace(/^\d+_/, "").replace(/_/g, " ");
|
|
@@ -38345,9 +38525,9 @@ async function ensureMigrationTableOn(db) {
|
|
|
38345
38525
|
id INTEGER NOT NULL PRIMARY KEY,
|
|
38346
38526
|
migration_name VARCHAR(500) NOT NULL UNIQUE,
|
|
38347
38527
|
description VARCHAR(500),
|
|
38348
|
-
batch INTEGER NOT NULL
|
|
38528
|
+
batch INTEGER DEFAULT 1 NOT NULL,
|
|
38349
38529
|
executed_at VARCHAR(50) NOT NULL,
|
|
38350
|
-
passed INTEGER NOT NULL
|
|
38530
|
+
passed INTEGER DEFAULT 1 NOT NULL
|
|
38351
38531
|
)`);
|
|
38352
38532
|
} else {
|
|
38353
38533
|
const idCol = migrationIdColumn(db);
|
|
@@ -38444,7 +38624,7 @@ async function recordApplied(db, name, batch, passed = 1) {
|
|
|
38444
38624
|
"SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE"
|
|
38445
38625
|
);
|
|
38446
38626
|
insertCols.unshift("id");
|
|
38447
|
-
values.unshift(rows[0]?.
|
|
38627
|
+
values.unshift(rows[0]?.next_id ?? 1);
|
|
38448
38628
|
}
|
|
38449
38629
|
const placeholders = insertCols.map(() => "?").join(", ");
|
|
38450
38630
|
await adapterExecute(
|
|
@@ -40858,15 +41038,25 @@ var init_baseModel = __esm({
|
|
|
40858
41038
|
/**
|
|
40859
41039
|
* Invalidate every cached query that touches this model's table.
|
|
40860
41040
|
*
|
|
40861
|
-
* Tag-scoped
|
|
40862
|
-
* this table is busted too
|
|
40863
|
-
* never touches this table is left intact
|
|
40864
|
-
*
|
|
40865
|
-
*
|
|
41041
|
+
* Tag-scoped in the ORM layer (a cached JOIN on another model that reads
|
|
41042
|
+
* this table is busted too because it carries this table's tag; a query
|
|
41043
|
+
* that never touches this table is left intact), then cascaded to the
|
|
41044
|
+
* DB layer on this model's bound connection so an out-of-band write /
|
|
41045
|
+
* deliberate refresh / race-with-another-process cannot leave stale rows
|
|
41046
|
+
* in db.fetch()'s persistent cache. Called after every ORM write
|
|
41047
|
+
* (save/delete/forceDelete/restore) so a read-after-write never serves
|
|
41048
|
+
* a stale/deleted row (CACHE-DEC-01). PY-06-22 (3.13.105) added the
|
|
41049
|
+
* DB-layer cascade -- previously the two cache layers disagreed under
|
|
41050
|
+
* TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
|
|
40866
41051
|
*/
|
|
40867
41052
|
static clearCache() {
|
|
40868
41053
|
const ModelClass = this;
|
|
40869
41054
|
modelQueryCache.clearTag((ModelClass.tableName ?? "").toLowerCase());
|
|
41055
|
+
try {
|
|
41056
|
+
const db = ModelClass.getDb();
|
|
41057
|
+
if (typeof db?.cacheClear === "function") db.cacheClear();
|
|
41058
|
+
} catch {
|
|
41059
|
+
}
|
|
40870
41060
|
}
|
|
40871
41061
|
/**
|
|
40872
41062
|
* 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
|
|
1389
|
-
* this table is busted too
|
|
1390
|
-
* never touches this table is left intact
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
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
|
/**
|
|
@@ -297,7 +297,16 @@ const MIGRATION_TABLE = "tina4_migration";
|
|
|
297
297
|
* sql_mode, so they are always correct there.
|
|
298
298
|
*/
|
|
299
299
|
function mt(db: DatabaseAdapter): string {
|
|
300
|
-
|
|
300
|
+
const engine = engineOf(db);
|
|
301
|
+
|
|
302
|
+
// Firebird: leave it UNQUOTED so it folds to the upper-case TINA4_MIGRATION
|
|
303
|
+
// that the PHP and Python masters create. A quoted lower-case identifier is a
|
|
304
|
+
// DIFFERENT, case-sensitive table there, so a quoted spelling cannot see a
|
|
305
|
+
// ledger written by another Tina4 language — while tableExists() matches
|
|
306
|
+
// case-insensitively and reports it present, so the INSERT fails alone.
|
|
307
|
+
if (engine === "firebird") return MIGRATION_TABLE;
|
|
308
|
+
|
|
309
|
+
return engine === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
|
|
301
310
|
}
|
|
302
311
|
|
|
303
312
|
/**
|
|
@@ -347,9 +356,9 @@ async function ensureMigrationTableOn(db: DatabaseAdapter): Promise<void> {
|
|
|
347
356
|
id INTEGER NOT NULL PRIMARY KEY,
|
|
348
357
|
migration_name VARCHAR(500) NOT NULL UNIQUE,
|
|
349
358
|
description VARCHAR(500),
|
|
350
|
-
batch INTEGER NOT NULL
|
|
359
|
+
batch INTEGER DEFAULT 1 NOT NULL,
|
|
351
360
|
executed_at VARCHAR(50) NOT NULL,
|
|
352
|
-
passed INTEGER NOT NULL
|
|
361
|
+
passed INTEGER DEFAULT 1 NOT NULL
|
|
353
362
|
)`);
|
|
354
363
|
} else {
|
|
355
364
|
// Engine-aware bookkeeping DDL (non-Firebird). Each engine spells an
|
|
@@ -517,11 +526,11 @@ async function recordApplied(
|
|
|
517
526
|
|
|
518
527
|
if (isFirebirdAdapter(db)) {
|
|
519
528
|
// Firebird: generate the id from the sequence.
|
|
520
|
-
const rows = await adapterQuery<{
|
|
529
|
+
const rows = await adapterQuery<{ next_id: number }>(db,
|
|
521
530
|
"SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE",
|
|
522
531
|
);
|
|
523
532
|
insertCols.unshift("id");
|
|
524
|
-
values.unshift(rows[0]?.
|
|
533
|
+
values.unshift(rows[0]?.next_id ?? 1);
|
|
525
534
|
}
|
|
526
535
|
|
|
527
536
|
const placeholders = insertCols.map(() => "?").join(", ");
|
|
@@ -3,6 +3,9 @@ import type { Tina4Request, Tina4Response } from "./types.js";
|
|
|
3
3
|
export interface AuthGateRoute {
|
|
4
4
|
secure?: boolean;
|
|
5
5
|
noAuth?: boolean;
|
|
6
|
+
/** RBAC guard groups (Feature 138): OR within a group, AND across groups. */
|
|
7
|
+
requiredRoles?: string[][];
|
|
8
|
+
requiredPerms?: string[][];
|
|
6
9
|
}
|
|
7
10
|
/**
|
|
8
11
|
* Enforce auth for a matched route.
|
|
@@ -74,7 +74,13 @@ export interface QueueBackendInterface {
|
|
|
74
74
|
close(): void;
|
|
75
75
|
complete?(queue: string, id: string): void;
|
|
76
76
|
fail?(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number): void;
|
|
77
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Explicit manual re-queue: returns true if the id was found and revived,
|
|
79
|
+
* false otherwise (parity with Python's backend.retry_job()). A backend may
|
|
80
|
+
* legacy-return void; callers coerce a void return to true so nothing that
|
|
81
|
+
* used to be reported as success silently flips to failure.
|
|
82
|
+
*/
|
|
83
|
+
retry?(queue: string, id: string, delaySeconds?: number): boolean | void;
|
|
78
84
|
deadLetters?(queue: string, maxRetries?: number): QueueJob[];
|
|
79
85
|
failed?(queue: string, maxRetries?: number): QueueJob[];
|
|
80
86
|
retryFailed?(queue: string, maxRetries?: number): number;
|
|
@@ -137,7 +143,19 @@ export declare class Queue {
|
|
|
137
143
|
*/
|
|
138
144
|
process(handler: (job: QueueJob | QueueJob[]) => Promise<void> | void, options?: ProcessOptions): void;
|
|
139
145
|
/**
|
|
140
|
-
* Count jobs
|
|
146
|
+
* Count jobs by status. Defaults to "pending".
|
|
147
|
+
*
|
|
148
|
+
* ``"pending"`` counts jobs waiting to be popped -- INCLUDES retryable-
|
|
149
|
+
* but-attempted ones, because they live in the pending queue under the
|
|
150
|
+
* auto-retry lifecycle (see failed()).
|
|
151
|
+
* ``"reserved"`` counts jobs a consumer has popped but not yet
|
|
152
|
+
* completed/failed (in-flight against the visibility timeout).
|
|
153
|
+
* ``"completed"`` counts jobs the consumer has finished successfully.
|
|
154
|
+
* ``"failed"``, ``"dead"``, ``"dead_letter"`` are ALIASES that all count
|
|
155
|
+
* the dead-letter store -- jobs whose attempts >= maxRetries and that
|
|
156
|
+
* have given up. Use deadLetters() to list them. Retryable-but-attempted
|
|
157
|
+
* jobs are NOT counted by size("failed"); use failed() to list them or
|
|
158
|
+
* size("pending") to include them in a total.
|
|
141
159
|
*/
|
|
142
160
|
size(status?: string): number;
|
|
143
161
|
/**
|
|
@@ -172,7 +190,11 @@ export declare class Queue {
|
|
|
172
190
|
/**
|
|
173
191
|
* Get jobs that failed at least once but are still being retried
|
|
174
192
|
* (0 < attempts < maxRetries). These live in the pending queue under the
|
|
175
|
-
* auto-retry lifecycle
|
|
193
|
+
* auto-retry lifecycle (fail() re-queues them with an incremented attempts
|
|
194
|
+
* count and a retryBackoff delay) so pop() picks them up again. They are
|
|
195
|
+
* NOT counted by size("failed") -- that alias counts the dead-letter store,
|
|
196
|
+
* matching deadLetters(). To include retryable-failed jobs in a total, use
|
|
197
|
+
* size("pending"). Terminal failures are returned by deadLetters().
|
|
176
198
|
*/
|
|
177
199
|
failed(): QueueJob[];
|
|
178
200
|
/**
|
|
@@ -184,7 +206,22 @@ export declare class Queue {
|
|
|
184
206
|
*/
|
|
185
207
|
retry(jobId?: string, delaySeconds?: number): boolean;
|
|
186
208
|
/**
|
|
187
|
-
* Get
|
|
209
|
+
* Get jobs that exceeded max_retries -- terminal failures.
|
|
210
|
+
*
|
|
211
|
+
* Same set counted by size("failed") / size("dead") / size("dead_letter")
|
|
212
|
+
* (three aliases for the dead-letter store). To LIST retryable-but-
|
|
213
|
+
* attempted jobs (attempts > 0 AND attempts < maxRetries) that are still
|
|
214
|
+
* being auto-retried, use failed() -- those live in the pending queue and
|
|
215
|
+
* are NOT dead letters.
|
|
216
|
+
*
|
|
217
|
+
* Returns Job objects with the failure reason on ``.error`` (not raw dicts)
|
|
218
|
+
* so callers can iterate uniformly with the rest of the queue API and, in
|
|
219
|
+
* particular, call ``.retry()`` on each to manually revive it:
|
|
220
|
+
*
|
|
221
|
+
* for (const job of queue.deadLetters()) {
|
|
222
|
+
* Log.warn(`revived ${job.id}: ${job.error}`);
|
|
223
|
+
* job.retry();
|
|
224
|
+
* }
|
|
188
225
|
*/
|
|
189
226
|
deadLetters(maxRetries?: number): QueueJob[];
|
|
190
227
|
/**
|
|
@@ -122,7 +122,13 @@ export declare class LiteBackend {
|
|
|
122
122
|
* Explicit re-queue requested by the caller (job.retry()).
|
|
123
123
|
*
|
|
124
124
|
* Always re-enqueues regardless of the retry limit — manual override,
|
|
125
|
-
* distinct from the automatic failJob() path.
|
|
125
|
+
* distinct from the automatic failJob() path. Cleans up BOTH the
|
|
126
|
+
* reservation record AND any dead-letter file for this id, so a caller
|
|
127
|
+
* that iterates deadLetters() and calls .retry() on each doesn't leave
|
|
128
|
+
* the failed/ directory carrying duplicates (PY-12-05, 3.13.105).
|
|
129
|
+
* Aligns with retry(queue, jobId) which had always unlinked the
|
|
130
|
+
* dead-letter file -- two spellings of the same intent that previously
|
|
131
|
+
* diverged.
|
|
126
132
|
*/
|
|
127
133
|
retryJob(queue: string, job: QueueJob, delaySeconds?: number): void;
|
|
128
134
|
}
|
|
@@ -87,8 +87,13 @@ export declare class MongoBackend implements QueueBackend {
|
|
|
87
87
|
* retries remain, else dead-letter. Mirrors the file/lite backend.
|
|
88
88
|
*/
|
|
89
89
|
fail(queue: string, id: string, error: string, maxRetries: number, retryBackoff?: number): void;
|
|
90
|
-
/**
|
|
91
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Revive a specific dead-letter job by id. Returns true if the DL was found
|
|
92
|
+
* and revived, false otherwise (parity with LiteBackend.retry(queue, id)
|
|
93
|
+
* and Python's mongo_backend.retry_job()). Pre-3.13.105 this returned void
|
|
94
|
+
* and Queue.retry(id) reported success for every call, even for unknown ids.
|
|
95
|
+
*/
|
|
96
|
+
retry(queue: string, id: string, delaySeconds?: number): boolean;
|
|
92
97
|
/** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
|
|
93
98
|
deadLetters(queue: string, maxRetries?: number): QueueJob[];
|
|
94
99
|
/** Jobs that failed but are still eligible for retry (status=failed, attempts < max). */
|