tina4-nodejs 3.13.42 → 3.13.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.42)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.43)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.42",
6
+ "version": "3.13.43",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -81,9 +81,17 @@ export interface ProcessOptions {
81
81
  maxJobs?: number;
82
82
  maxRetries?: number;
83
83
  batchSize?: number;
84
+ /**
85
+ * Override the queue's topic for this drain (parity with Python's
86
+ * process(handler, topic=...)). When set, process() retargets the queue so
87
+ * pop() reads the requested topic instead of the construction-time one.
88
+ */
89
+ topic?: string;
84
90
  }
85
91
 
86
92
  export interface ConsumeOptions {
93
+ /** Topic to consume (defaults to the constructor topic). */
94
+ topic?: string;
87
95
  batchSize?: number;
88
96
  pollInterval?: number;
89
97
  iterations?: number;
@@ -95,6 +103,18 @@ export interface QueueBackendInterface {
95
103
  pop(queue: string): QueueJob | null;
96
104
  size(queue: string): number;
97
105
  clear(queue: string): void;
106
+ // Optional full lifecycle. Reservation-based backends (MongoDB) implement
107
+ // these so complete()/fail() ack the ACTIVE store — without complete(), a
108
+ // reserved Mongo job is re-delivered after the visibility window. Backends
109
+ // that auto-ack on pop (RabbitMQ no-ack) or delegate to the broker (Kafka
110
+ // offsets) omit them, and the Queue keeps its prior behaviour for them.
111
+ complete?(queue: string, id: string): void;
112
+ fail?(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number): void;
113
+ retry?(queue: string, id: string, delaySeconds?: number): void;
114
+ deadLetters?(queue: string, maxRetries?: number): QueueJob[];
115
+ failed?(queue: string, maxRetries?: number): QueueJob[];
116
+ retryFailed?(queue: string, maxRetries?: number): number;
117
+ purge?(queue: string, status?: string): number;
98
118
  }
99
119
 
100
120
  // ── Queue ────────────────────────────────────────────────────
@@ -152,6 +172,22 @@ export class Queue {
152
172
  }
153
173
  }
154
174
 
175
+ /**
176
+ * Point this queue at ``topic`` in place.
177
+ *
178
+ * produce()/consume()/process() call this so a topic argument actually
179
+ * changes which topic is read or written. Without it the argument was
180
+ * accepted but ignored on the read path — pop() always used the
181
+ * construction-time topic, so consume("other") silently drained the wrong
182
+ * queue. The lite + external backends are topic-per-call (every push/pop/size
183
+ * takes the queue name), so changing this.topic retargets all of them; the
184
+ * job lifecycle (complete()/fail()/retry()) routes by the job's own .topic, so
185
+ * it is unaffected. Mirrors Python's Queue._retarget().
186
+ */
187
+ private retarget(topic: string): void {
188
+ this.topic = topic;
189
+ }
190
+
155
191
  // ── Unified API (topic-aware) ────────────────────────────────
156
192
 
157
193
  /**
@@ -197,6 +233,12 @@ export class Queue {
197
233
  handler: (job: QueueJob | QueueJob[]) => Promise<void> | void,
198
234
  options?: ProcessOptions,
199
235
  ): void {
236
+ // Honour an explicit topic: retarget so pop() drains the requested topic
237
+ // (parity with Python's process(handler, topic=...)). Without this the
238
+ // argument was accepted but ignored on the read path.
239
+ if (options?.topic !== undefined) {
240
+ this.retarget(options.topic);
241
+ }
200
242
  const queue = this.topic;
201
243
  const opts = options;
202
244
 
@@ -275,6 +317,9 @@ export class Queue {
275
317
  * auto-retry lifecycle; dead-lettered jobs are returned by deadLetters().
276
318
  */
277
319
  failed(): QueueJob[] {
320
+ if (this.externalBackend?.failed) {
321
+ return this.externalBackend.failed(this.topic, this._maxRetries);
322
+ }
278
323
  return this.liteBackend.failed(this.topic, this._maxRetries);
279
324
  }
280
325
 
@@ -288,6 +333,10 @@ export class Queue {
288
333
  retry(jobId?: string, delaySeconds?: number): boolean {
289
334
  if (jobId) {
290
335
  // Retry a specific job by ID
336
+ if (this.externalBackend?.retry) {
337
+ this.externalBackend.retry(this.topic, jobId, delaySeconds);
338
+ return true;
339
+ }
291
340
  return this.liteBackend.retry(this.topic, jobId, delaySeconds);
292
341
  }
293
342
  // Retry all dead-letter jobs
@@ -295,8 +344,12 @@ export class Queue {
295
344
  if (deadJobs.length === 0) return false;
296
345
  let retried = false;
297
346
  for (const job of deadJobs) {
298
- const ok = this.liteBackend.retry(this.topic, job.id, delaySeconds);
299
- if (ok) retried = true;
347
+ if (this.externalBackend?.retry) {
348
+ this.externalBackend.retry(this.topic, job.id, delaySeconds);
349
+ retried = true;
350
+ } else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
351
+ retried = true;
352
+ }
300
353
  }
301
354
  return retried;
302
355
  }
@@ -305,6 +358,9 @@ export class Queue {
305
358
  * Get dead letter jobs — failed jobs that exceeded max retries.
306
359
  */
307
360
  deadLetters(maxRetries?: number): QueueJob[] {
361
+ if (this.externalBackend?.deadLetters) {
362
+ return this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
363
+ }
308
364
  return this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
309
365
  }
310
366
 
@@ -312,6 +368,9 @@ export class Queue {
312
368
  * Delete messages by status (e.g. "completed", "failed", "dead").
313
369
  */
314
370
  purge(status: string, maxRetries?: number): number {
371
+ if (this.externalBackend?.purge) {
372
+ return this.externalBackend.purge(this.topic, status);
373
+ }
315
374
  return this.liteBackend.purge(this.topic, status, maxRetries ?? this._maxRetries);
316
375
  }
317
376
 
@@ -319,17 +378,27 @@ export class Queue {
319
378
  * Re-queue failed jobs that haven't exceeded max retries back to pending.
320
379
  */
321
380
  retryFailed(maxRetries?: number): number {
381
+ if (this.externalBackend?.retryFailed) {
382
+ return this.externalBackend.retryFailed(this.topic, maxRetries ?? this._maxRetries);
383
+ }
322
384
  return this.liteBackend.retryFailed(this.topic, maxRetries ?? this._maxRetries);
323
385
  }
324
386
 
325
387
  /**
326
388
  * Produce a message onto a topic. Convenience wrapper around push().
389
+ *
390
+ * Retargets to the requested topic (restoring the prior one afterwards) so it
391
+ * shares the same retarget path consume()/process() use — keeping produce and
392
+ * consume symmetric on the same topic argument.
327
393
  */
328
394
  produce(topic: string, payload: unknown, priority: number = 0, delay: number = 0): string {
329
- if (this.externalBackend) {
330
- return this.externalBackend.push(topic, payload, delay);
395
+ const previous = this.topic;
396
+ this.retarget(topic);
397
+ try {
398
+ return this.push(payload, delay, priority);
399
+ } finally {
400
+ this.retarget(previous);
331
401
  }
332
- return this.liteBackend.push(topic, payload, delay, priority);
333
402
  }
334
403
 
335
404
  /**
@@ -368,7 +437,9 @@ export class Queue {
368
437
 
369
438
  if (topicOrOptions !== null && typeof topicOrOptions === "object") {
370
439
  const opts = topicOrOptions as ConsumeOptions;
371
- q = this.topic;
440
+ // Honour opts.topic (parity with the string-arg form) — previously the
441
+ // options-object form ignored it and always drained the constructor topic.
442
+ q = opts.topic ?? this.topic;
372
443
  resolvedId = opts.id;
373
444
  resolvedPollInterval = opts.pollInterval ?? 1000;
374
445
  resolvedIterations = opts.iterations ?? 0;
@@ -381,6 +452,12 @@ export class Queue {
381
452
  resolvedBatchSize = batchSize;
382
453
  }
383
454
 
455
+ // Honour the topic argument: point the queue (and the backend pop()/
456
+ // popById() route through) at it. Previously the resolved topic was
457
+ // computed but never used — pop()/popById() read this.topic, so
458
+ // consume("other") silently drained the construction-time topic.
459
+ this.retarget(q);
460
+
384
461
  if (resolvedId !== undefined) {
385
462
  const raw = this.popById(resolvedId);
386
463
  if (raw) yield createJob(raw as any, this);
@@ -453,6 +530,10 @@ export class Queue {
453
530
  * after retryBackoff seconds) or dead-letter (attempts >= maxRetries).
454
531
  */
455
532
  _failJob(queue: string, job: QueueJob, error: string, maxRetries: number): void {
533
+ if (this.externalBackend?.fail) {
534
+ this.externalBackend.fail(queue, job.id, error, maxRetries, this._retryBackoff);
535
+ return;
536
+ }
456
537
  this.liteBackend.failJob(queue, job, error, maxRetries, this._retryBackoff);
457
538
  }
458
539
 
@@ -460,15 +541,26 @@ export class Queue {
460
541
  * Re-queue a job back to the main queue directory with incremented attempts.
461
542
  */
462
543
  _retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
544
+ if (this.externalBackend?.retry) {
545
+ this.externalBackend.retry(queue, job.id, delaySeconds);
546
+ return;
547
+ }
463
548
  this.liteBackend.retryJob(queue, job, delaySeconds);
464
549
  }
465
550
 
466
551
  /**
467
- * Acknowledge a completed job — drop its reservation record so the visibility
468
- * reclaim never re-delivers it. No-op for external backends (they ack their
469
- * own way; lite-backend reservations are file records).
552
+ * Acknowledge a completed job — drop its reservation so the visibility reclaim
553
+ * never re-delivers it. Routes to the active backend: a reservation-based
554
+ * external backend (MongoDB) acks there (without this its reserved doc would
555
+ * be re-delivered after the visibility window); RabbitMQ (no-ack on get) and
556
+ * Kafka (offset-based) expose no complete(), so the lite path is used and is a
557
+ * harmless no-op for them since they already acked/own redelivery.
470
558
  */
471
559
  _completeJob(queue: string, job: QueueJob): void {
560
+ if (this.externalBackend?.complete) {
561
+ this.externalBackend.complete(queue, job.id);
562
+ return;
563
+ }
472
564
  this.liteBackend.completeJob(queue, job);
473
565
  }
474
566
  }
@@ -229,12 +229,20 @@ export class MongoBackend implements QueueBackend {
229
229
  if (result && result.value) {
230
230
  // findOneAndUpdate returns { value: doc } in older drivers
231
231
  const doc = result.value;
232
+ // Carry the framework topic on the job so complete()/fail()
233
+ // route the ack/requeue back to THIS topic's docs (the Mongo
234
+ // internal queue field is dropped from the returned shape).
235
+ doc.topic = queueName;
232
236
  delete doc._id;
233
237
  delete doc.queue;
234
238
  process.stdout.write(JSON.stringify(doc));
235
239
  } else if (result && result._id) {
236
240
  // Some driver versions return the doc directly
237
241
  const doc = { ...result };
242
+ // Carry the framework topic on the job so complete()/fail()
243
+ // route the ack/requeue back to THIS topic's docs (the Mongo
244
+ // internal queue field is dropped from the returned shape).
245
+ doc.topic = queueName;
238
246
  delete doc._id;
239
247
  delete doc.queue;
240
248
  process.stdout.write(JSON.stringify(doc));
@@ -253,6 +261,95 @@ export class MongoBackend implements QueueBackend {
253
261
  await col.deleteMany({ queue: queueName });
254
262
  process.stdout.write("__CLEARED__");
255
263
  }
264
+ else if (operation === "complete") {
265
+ // Ack a finished job so the reclaim never re-delivers it. data = job id.
266
+ // (The pop reserved this doc; without this it stays reserved and is
267
+ // re-delivered after the visibility window — the redelivery bug.)
268
+ await col.updateOne(
269
+ { queue: queueName, id: data },
270
+ { $set: { status: "completed", completedAt: new Date().toISOString(), reservedAt: null } },
271
+ );
272
+ process.stdout.write("__OK__");
273
+ }
274
+ else if (operation === "fail") {
275
+ // Requeue while retries remain (reset availableAt -> visible again),
276
+ // else dead-letter. Atomic decision in Mongo. data = JSON
277
+ // { id, error, maxRetries, retryBackoff }.
278
+ const info = JSON.parse(data);
279
+ const now = new Date().toISOString();
280
+ const doc = await col.findOne({ queue: queueName, id: info.id });
281
+ if (doc) {
282
+ const attempts = (doc.attempts || 0) + 1;
283
+ if (attempts >= info.maxRetries) {
284
+ await col.insertOne({
285
+ ...doc, _id: undefined, attempts,
286
+ status: "dead", queue: queueName + ".dead_letter", error: info.error,
287
+ });
288
+ await col.deleteOne({ _id: doc._id, queue: queueName });
289
+ } else {
290
+ const avail = info.retryBackoff > 0
291
+ ? new Date(Date.now() + info.retryBackoff * 1000).toISOString()
292
+ : now;
293
+ await col.updateOne(
294
+ { _id: doc._id, queue: queueName },
295
+ { $set: { status: "pending", availableAt: avail, reservedAt: null, error: info.error },
296
+ $inc: { attempts: 1 } },
297
+ );
298
+ }
299
+ }
300
+ process.stdout.write("__OK__");
301
+ }
302
+ else if (operation === "retry") {
303
+ // Explicit manual re-queue (always re-enqueues). data = JSON
304
+ // { id, delaySeconds }.
305
+ const info = JSON.parse(data);
306
+ const avail = info.delaySeconds > 0
307
+ ? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
308
+ : new Date().toISOString();
309
+ await col.updateOne(
310
+ { queue: queueName, id: info.id },
311
+ { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
312
+ );
313
+ process.stdout.write("__OK__");
314
+ }
315
+ else if (operation === "deadLetters") {
316
+ const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
317
+ const out = docs.map((d) => { delete d._id; delete d.queue; return d; });
318
+ process.stdout.write(JSON.stringify(out));
319
+ }
320
+ else if (operation === "failed") {
321
+ const docs = await col
322
+ .find({ queue: queueName, status: "failed", attempts: { $lt: maxRetries } })
323
+ .toArray();
324
+ const out = docs.map((d) => { delete d._id; delete d.queue; return d; });
325
+ process.stdout.write(JSON.stringify(out));
326
+ }
327
+ else if (operation === "retryFailed") {
328
+ // Revive dead-lettered jobs under the (possibly raised) limit back to
329
+ // the main queue as pending. data = the max-retries limit.
330
+ const mr = data ? Number(data) : maxRetries;
331
+ const now = new Date().toISOString();
332
+ let revived = 0;
333
+ while (true) {
334
+ const doc = await col.findOneAndUpdate(
335
+ { queue: queueName + ".dead_letter", attempts: { $lt: mr } },
336
+ { $set: { status: "pending", availableAt: now, reservedAt: null, queue: queueName, error: null } },
337
+ { returnDocument: "after" },
338
+ );
339
+ const updated = doc && doc.value ? doc.value : (doc && doc._id ? doc : null);
340
+ if (!updated) break;
341
+ revived++;
342
+ }
343
+ process.stdout.write(String(revived));
344
+ }
345
+ else if (operation === "purge") {
346
+ // Delete docs by status (default: all for the topic). data = JSON { status }.
347
+ const info = data ? JSON.parse(data) : {};
348
+ const filter = { queue: queueName };
349
+ if (info.status) filter.status = info.status;
350
+ const res = await col.deleteMany(filter);
351
+ process.stdout.write(String(res.deletedCount || 0));
352
+ }
256
353
  } catch (err) {
257
354
  process.stderr.write(err.message || String(err));
258
355
  process.exit(1);
@@ -328,4 +425,50 @@ export class MongoBackend implements QueueBackend {
328
425
  clear(queue: string): void {
329
426
  this.execSync("clear", queue);
330
427
  }
428
+
429
+ /**
430
+ * Acknowledge a completed job — drop its reservation so the reclaim never
431
+ * re-delivers it. Without this a Mongo-popped job stayed reserved and was
432
+ * re-delivered after the visibility window (the redelivery bug).
433
+ */
434
+ complete(queue: string, id: string): void {
435
+ this.execSync("complete", queue, id);
436
+ }
437
+
438
+ /**
439
+ * Record a failed attempt: requeue (reset availableAt, ++attempts) while
440
+ * retries remain, else dead-letter. Mirrors the file/lite backend.
441
+ */
442
+ fail(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number = 0): void {
443
+ this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
444
+ }
445
+
446
+ /** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
447
+ retry(queue: string, id: string, delaySeconds: number = 0): void {
448
+ this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
449
+ }
450
+
451
+ /** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
452
+ deadLetters(queue: string, maxRetries?: number): QueueJob[] {
453
+ const out = this.execSync("deadLetters", queue, String(maxRetries ?? this.maxRetries));
454
+ try { return JSON.parse(out) as QueueJob[]; } catch { return []; }
455
+ }
456
+
457
+ /** Jobs that failed but are still eligible for retry (status=failed, attempts < max). */
458
+ failed(queue: string, maxRetries?: number): QueueJob[] {
459
+ const out = this.execSync("failed", queue, String(maxRetries ?? this.maxRetries));
460
+ try { return JSON.parse(out) as QueueJob[]; } catch { return []; }
461
+ }
462
+
463
+ /** Revive dead-lettered jobs under the (possibly raised) limit. Returns count revived. */
464
+ retryFailed(queue: string, maxRetries?: number): number {
465
+ const out = this.execSync("retryFailed", queue, String(maxRetries ?? this.maxRetries));
466
+ return parseInt(out, 10) || 0;
467
+ }
468
+
469
+ /** Remove jobs by status (default: every doc for the topic). Returns count removed. */
470
+ purge(queue: string, status?: string): number {
471
+ const out = this.execSync("purge", queue, JSON.stringify({ status: status ?? "" }));
472
+ return parseInt(out, 10) || 0;
473
+ }
331
474
  }