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.
@@ -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 false;
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 (always re-enqueues). data = JSON
17253
- // { id, delaySeconds }.
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
- : new Date().toISOString();
17258
- await col.updateOne(
17259
- { queue: queueName, id: info.id },
17260
- { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
17261
- );
17262
- process.stdout.write("__OK__");
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: all for the topic). data = JSON { status }.
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 filter = { queue: queueName };
17310
- if (info.status) filter.status = info.status;
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
- /** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
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 filtered by status. Defaults to "pending".
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; dead-lettered jobs are returned by deadLetters().
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
- if (this.externalBackend?.failed) {
18334
- return this.externalBackend.failed(this.topic, this._maxRetries);
18335
- }
18336
- return this.liteBackend.failed(this.topic, this._maxRetries);
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 dead letter jobs failed jobs that exceeded max retries.
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
- if (this.externalBackend?.deadLetters) {
18371
- return this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
18372
- }
18373
- return this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
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 (isHtmlResponse(res)) {
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
- return engineOf(db) === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
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 DEFAULT 1,
38528
+ batch INTEGER DEFAULT 1 NOT NULL,
38349
38529
  executed_at VARCHAR(50) NOT NULL,
38350
- passed INTEGER NOT NULL DEFAULT 1
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]?.NEXT_ID ?? 1);
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, NOT a wholesale flush: a cached JOIN on another model that reads
40862
- * this table is busted too (it carries this table's tag), while a query that
40863
- * never touches this table is left intact. Called after every ORM write
40864
- * (save/delete/forceDelete/restore) so a read-after-write never serves a
40865
- * stale/deleted row (CACHE-DEC-01).
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, NOT a wholesale flush: a cached JOIN on another model that reads
1389
- * this table is busted too (it carries this table's tag), while a query that
1390
- * never touches this table is left intact. Called after every ORM write
1391
- * (save/delete/forceDelete/restore) so a read-after-write never serves a
1392
- * stale/deleted row (CACHE-DEC-01).
1388
+ * Tag-scoped in the ORM layer (a cached JOIN on another model that reads
1389
+ * this table is busted too because it carries this table's tag; a query
1390
+ * that never touches this table is left intact), then cascaded to the
1391
+ * DB layer on this model's bound connection so an out-of-band write /
1392
+ * deliberate refresh / race-with-another-process cannot leave stale rows
1393
+ * in db.fetch()'s persistent cache. Called after every ORM write
1394
+ * (save/delete/forceDelete/restore) so a read-after-write never serves
1395
+ * a stale/deleted row (CACHE-DEC-01). PY-06-22 (3.13.105) added the
1396
+ * DB-layer cascade -- previously the two cache layers disagreed under
1397
+ * TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
1393
1398
  */
1394
1399
  static clearCache(): void {
1395
1400
  const ModelClass = this as unknown as typeof BaseModel;
1396
1401
  modelQueryCache.clearTag((ModelClass.tableName ?? "").toLowerCase());
1402
+ try {
1403
+ const db: any = ModelClass.getDb();
1404
+ if (typeof db?.cacheClear === "function") db.cacheClear();
1405
+ } catch {
1406
+ // A resolvable DB is not guaranteed at every clearCache() call site
1407
+ // (module-import time in odd bootstraps, tests that mutate bindings);
1408
+ // never let a cache-clear crash a save/delete.
1409
+ }
1397
1410
  }
1398
1411
 
1399
1412
  /**
@@ -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
- return engineOf(db) === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
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 DEFAULT 1,
359
+ batch INTEGER DEFAULT 1 NOT NULL,
351
360
  executed_at VARCHAR(50) NOT NULL,
352
- passed INTEGER NOT NULL DEFAULT 1
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<{ NEXT_ID: number }>(db,
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]?.NEXT_ID ?? 1);
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
- retry?(queue: string, id: string, delaySeconds?: number): void;
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 filtered by status. Defaults to "pending".
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; dead-lettered jobs are returned by deadLetters().
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 dead letter jobs failed jobs that exceeded max retries.
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
- /** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
91
- retry(queue: string, id: string, delaySeconds?: number): void;
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). */