tina4-nodejs 3.2.1 → 3.5.0

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.
Files changed (34) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/packages/cli/src/bin.ts +13 -1
  5. package/packages/cli/src/commands/migrate.ts +19 -5
  6. package/packages/cli/src/commands/migrateCreate.ts +29 -28
  7. package/packages/cli/src/commands/migrateRollback.ts +59 -0
  8. package/packages/cli/src/commands/migrateStatus.ts +62 -0
  9. package/packages/core/public/js/tina4-dev-admin.min.js +1 -1
  10. package/packages/core/public/js/tina4js.min.js +47 -0
  11. package/packages/core/src/auth.ts +44 -10
  12. package/packages/core/src/devAdmin.ts +14 -16
  13. package/packages/core/src/index.ts +10 -3
  14. package/packages/core/src/middleware.ts +232 -2
  15. package/packages/core/src/queue.ts +127 -25
  16. package/packages/core/src/queueBackends/mongoBackend.ts +223 -0
  17. package/packages/core/src/request.ts +3 -3
  18. package/packages/core/src/router.ts +115 -51
  19. package/packages/core/src/server.ts +47 -3
  20. package/packages/core/src/session.ts +29 -1
  21. package/packages/core/src/sessionHandlers/databaseHandler.ts +134 -0
  22. package/packages/core/src/sessionHandlers/redisHandler.ts +230 -0
  23. package/packages/core/src/types.ts +12 -6
  24. package/packages/core/src/websocket.ts +11 -2
  25. package/packages/core/src/websocketConnection.ts +4 -2
  26. package/packages/frond/src/engine.ts +66 -1
  27. package/packages/orm/src/autoCrud.ts +17 -12
  28. package/packages/orm/src/baseModel.ts +99 -21
  29. package/packages/orm/src/database.ts +197 -69
  30. package/packages/orm/src/databaseResult.ts +207 -0
  31. package/packages/orm/src/index.ts +6 -3
  32. package/packages/orm/src/migration.ts +296 -71
  33. package/packages/orm/src/model.ts +1 -0
  34. package/packages/orm/src/types.ts +1 -0
@@ -36,6 +36,76 @@ export class MiddlewareChain {
36
36
  }
37
37
  }
38
38
 
39
+ // ── Class-based middleware runner ────────────────────────────────
40
+
41
+ /**
42
+ * Runs class-based middleware that follows the beforeX / afterX naming convention.
43
+ *
44
+ * Static methods whose names start with "before" are executed by runBefore
45
+ * (prior to the route handler). Static methods starting with "after" are
46
+ * executed by runAfter (after the handler).
47
+ *
48
+ * Each static method receives (req, res) and returns [req, res].
49
+ * If a "before" method returns a response whose status code is >= 400
50
+ * the chain short-circuits and runBefore returns shouldContinue = false.
51
+ */
52
+ export class MiddlewareRunner {
53
+ /**
54
+ * Execute every beforeX static method found on the supplied classes,
55
+ * in order. Returns the (possibly mutated) request and response pair and a
56
+ * boolean indicating whether the route handler should still run.
57
+ *
58
+ * Short-circuits when a before method sets a status >= 400.
59
+ */
60
+ static runBefore(
61
+ classes: any[],
62
+ req: Tina4Request,
63
+ res: Tina4Response,
64
+ ): [Tina4Request, Tina4Response, boolean] {
65
+ for (const cls of classes) {
66
+ const methods = Object.getOwnPropertyNames(cls).filter(
67
+ (name) => typeof cls[name] === "function" && name.startsWith("before"),
68
+ );
69
+ for (const method of methods) {
70
+ const result = cls[method](req, res);
71
+ if (Array.isArray(result)) {
72
+ [req, res] = result as [Tina4Request, Tina4Response];
73
+ }
74
+ // Short-circuit if the middleware set an error status
75
+ if (res.raw.statusCode >= 400 || res.raw.writableEnded) {
76
+ return [req, res, false];
77
+ }
78
+ }
79
+ }
80
+ return [req, res, true];
81
+ }
82
+
83
+ /**
84
+ * Execute every afterX static method found on the supplied classes,
85
+ * in order. Returns the (possibly mutated) request and response pair.
86
+ */
87
+ static runAfter(
88
+ classes: any[],
89
+ req: Tina4Request,
90
+ res: Tina4Response,
91
+ ): [Tina4Request, Tina4Response] {
92
+ for (const cls of classes) {
93
+ const methods = Object.getOwnPropertyNames(cls).filter(
94
+ (name) => typeof cls[name] === "function" && name.startsWith("after"),
95
+ );
96
+ for (const method of methods) {
97
+ const result = cls[method](req, res);
98
+ if (Array.isArray(result)) {
99
+ [req, res] = result as [Tina4Request, Tina4Response];
100
+ }
101
+ }
102
+ }
103
+ return [req, res];
104
+ }
105
+ }
106
+
107
+ // ── Built-in class-based middleware ─────────────────────────────
108
+
39
109
  /** Configuration for the CORS middleware */
40
110
  export interface CorsConfig {
41
111
  /** Allowed origins. Default: "*" (or TINA4_CORS_ORIGINS env, comma-separated) */
@@ -49,7 +119,7 @@ export interface CorsConfig {
49
119
  }
50
120
 
51
121
  /**
52
- * Built-in CORS middleware.
122
+ * Built-in CORS middleware (function form).
53
123
  * Reads configuration from env vars if not provided:
54
124
  * TINA4_CORS_ORIGINS — comma-separated list of allowed origins, or "*"
55
125
  * TINA4_CORS_METHODS — comma-separated list of allowed methods
@@ -119,7 +189,167 @@ export function cors(config?: CorsConfig): Middleware {
119
189
  };
120
190
  }
121
191
 
122
- // Built-in request logger middleware
192
+ /**
193
+ * Class-based CORS middleware using the before/after convention.
194
+ * Wraps the same CORS logic as the `cors()` function middleware.
195
+ *
196
+ * Usage:
197
+ * Router.use(CorsMiddleware);
198
+ */
199
+ export class CorsMiddleware {
200
+ static beforeCors(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
201
+ const originsRaw = process.env.TINA4_CORS_ORIGINS ?? "*";
202
+ const allowedOrigins = originsRaw.split(",").map((o) => o.trim());
203
+
204
+ const allowedMethods = process.env.TINA4_CORS_METHODS
205
+ ?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
206
+
207
+ const allowedHeaders = process.env.TINA4_CORS_HEADERS
208
+ ?? "Content-Type, Authorization";
209
+
210
+ const maxAge = process.env.TINA4_CORS_MAX_AGE
211
+ ? parseInt(process.env.TINA4_CORS_MAX_AGE, 10)
212
+ : 86400;
213
+
214
+ const requestOrigin = req.headers.origin ?? "";
215
+
216
+ let originHeader: string | undefined;
217
+ if (allowedOrigins.includes("*")) {
218
+ originHeader = "*";
219
+ } else if (allowedOrigins.includes(requestOrigin)) {
220
+ originHeader = requestOrigin;
221
+ res.header("Vary", "Origin");
222
+ }
223
+
224
+ if (originHeader) {
225
+ res.header("Access-Control-Allow-Origin", originHeader);
226
+ res.header("Access-Control-Allow-Methods", allowedMethods);
227
+ res.header("Access-Control-Allow-Headers", allowedHeaders);
228
+
229
+ if (req.method === "OPTIONS") {
230
+ res.header("Access-Control-Max-Age", String(maxAge));
231
+ res(null, 204);
232
+ }
233
+ } else if (req.method === "OPTIONS") {
234
+ res(null, 204);
235
+ }
236
+
237
+ return [req, res];
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Class-based rate limiter middleware using the before/after convention.
243
+ * Uses the same sliding-window algorithm as the `rateLimiter()` function.
244
+ *
245
+ * Reads configuration from env vars:
246
+ * TINA4_RATE_LIMIT — max requests per window (default 100)
247
+ * TINA4_RATE_WINDOW — window duration in seconds (default 60)
248
+ *
249
+ * Usage:
250
+ * Router.use(RateLimiterMiddleware);
251
+ */
252
+ export class RateLimiterMiddleware {
253
+ private static store = new Map<string, { timestamps: number[] }>();
254
+ private static cleanupTimer: ReturnType<typeof setInterval> | null = null;
255
+
256
+ private static ensureCleanup(windowMs: number): void {
257
+ if (RateLimiterMiddleware.cleanupTimer) return;
258
+ RateLimiterMiddleware.cleanupTimer = setInterval(() => {
259
+ const now = Date.now();
260
+ const cutoff = now - windowMs;
261
+ for (const [ip, entry] of RateLimiterMiddleware.store) {
262
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
263
+ if (entry.timestamps.length === 0) {
264
+ RateLimiterMiddleware.store.delete(ip);
265
+ }
266
+ }
267
+ }, 60_000);
268
+ if (RateLimiterMiddleware.cleanupTimer.unref) {
269
+ RateLimiterMiddleware.cleanupTimer.unref();
270
+ }
271
+ }
272
+
273
+ static beforeRateLimit(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
274
+ const limit = process.env.TINA4_RATE_LIMIT
275
+ ? parseInt(process.env.TINA4_RATE_LIMIT, 10)
276
+ : 100;
277
+ const windowSeconds = process.env.TINA4_RATE_WINDOW
278
+ ? parseInt(process.env.TINA4_RATE_WINDOW, 10)
279
+ : 60;
280
+ const windowMs = windowSeconds * 1000;
281
+
282
+ RateLimiterMiddleware.ensureCleanup(windowMs);
283
+
284
+ const now = Date.now();
285
+ const cutoff = now - windowMs;
286
+
287
+ const forwarded = req.headers["x-forwarded-for"];
288
+ const ip = (typeof forwarded === "string" ? forwarded.split(",")[0].trim() : undefined)
289
+ ?? req.socket?.remoteAddress
290
+ ?? "unknown";
291
+
292
+ let entry = RateLimiterMiddleware.store.get(ip);
293
+ if (!entry) {
294
+ entry = { timestamps: [] };
295
+ RateLimiterMiddleware.store.set(ip, entry);
296
+ }
297
+
298
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
299
+
300
+ const resetTimestamp = entry.timestamps.length > 0
301
+ ? Math.ceil((entry.timestamps[0] + windowMs) / 1000)
302
+ : Math.ceil((now + windowMs) / 1000);
303
+
304
+ const remaining = Math.max(0, limit - entry.timestamps.length);
305
+
306
+ res.header("X-RateLimit-Limit", String(limit));
307
+ res.header("X-RateLimit-Remaining", String(Math.max(0, remaining - 1)));
308
+ res.header("X-RateLimit-Reset", String(resetTimestamp));
309
+
310
+ if (entry.timestamps.length >= limit) {
311
+ const retryAfter = Math.max(1, resetTimestamp - Math.ceil(now / 1000));
312
+ res.header("Retry-After", String(retryAfter));
313
+ res.header("X-RateLimit-Remaining", "0");
314
+ res({
315
+ error: "Too Many Requests",
316
+ statusCode: 429,
317
+ message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
318
+ }, 429);
319
+ return [req, res];
320
+ }
321
+
322
+ entry.timestamps.push(now);
323
+ return [req, res];
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Class-based request logger middleware using the before/after convention.
329
+ * `beforeLog` stamps the request start time.
330
+ * `afterLog` prints the coloured status line.
331
+ *
332
+ * Usage:
333
+ * Router.use(RequestLogger);
334
+ */
335
+ export class RequestLogger {
336
+ static beforeLog(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
337
+ (req as any).startTime = Date.now();
338
+ return [req, res];
339
+ }
340
+
341
+ static afterLog(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
342
+ const duration = Date.now() - ((req as any).startTime ?? Date.now());
343
+ const status = res.raw.statusCode;
344
+ const method = req.method ?? "?";
345
+ const url = req.url ?? "/";
346
+ const color = status >= 400 ? "\x1b[31m" : status >= 300 ? "\x1b[33m" : "\x1b[32m";
347
+ console.log(` ${color}${status}\x1b[0m ${method} ${url} \x1b[90m${duration}ms\x1b[0m`);
348
+ return [req, res];
349
+ }
350
+ }
351
+
352
+ // Built-in request logger middleware (function form — kept for backwards compat)
123
353
  export function requestLogger(): Middleware {
124
354
  return (req, res, next) => {
125
355
  const start = Date.now();
@@ -7,9 +7,10 @@
7
7
  * - 'file' — JSON files on disk (default)
8
8
  * - 'rabbitmq' — RabbitMQ via raw TCP (AMQP 0-9-1)
9
9
  * - 'kafka' — Kafka via raw TCP
10
+ * - 'mongodb' — MongoDB via `mongodb` npm package (also 'mongo')
10
11
  *
11
12
  * Environment variables:
12
- * TINA4_QUEUE_BACKEND — 'file', 'rabbitmq', or 'kafka'
13
+ * TINA4_QUEUE_BACKEND — 'file', 'rabbitmq', 'kafka', or 'mongodb'
13
14
  * TINA4_QUEUE_URL — connection URL for rabbitmq/kafka
14
15
  * TINA4_QUEUE_PATH — file backend storage path (default: data/queue)
15
16
  *
@@ -43,11 +44,37 @@ export interface QueueConfig {
43
44
  export interface QueueJob {
44
45
  id: string;
45
46
  payload: unknown;
46
- status: "pending" | "reserved" | "failed" | "dead";
47
+ status: "pending" | "reserved" | "failed" | "dead" | "completed";
47
48
  createdAt: string;
48
49
  attempts: number;
49
50
  delayUntil: string | null;
50
51
  error?: string;
52
+ /** Mark this job as completed. */
53
+ complete(): void;
54
+ /** Mark this job as failed with a reason. */
55
+ fail(reason?: string): void;
56
+ /** Reject this job with a reason. Alias for fail(). */
57
+ reject(reason?: string): void;
58
+ }
59
+
60
+ /** Create a QueueJob with lifecycle methods bound to a Queue instance. */
61
+ function createJob(data: Omit<QueueJob, "complete" | "fail" | "reject">, queue: Queue, topic: string): QueueJob {
62
+ const job: QueueJob = {
63
+ ...data,
64
+ complete() {
65
+ job.status = "completed";
66
+ },
67
+ fail(reason = "") {
68
+ job.status = "failed";
69
+ job.error = reason;
70
+ job.attempts = (job.attempts || 0) + 1;
71
+ queue._failJob(topic, job, reason, queue.getMaxRetries());
72
+ },
73
+ reject(reason = "") {
74
+ job.fail(reason);
75
+ },
76
+ };
77
+ return job;
51
78
  }
52
79
 
53
80
  export interface ProcessOptions {
@@ -69,7 +96,7 @@ export class Queue {
69
96
  private backendName: string;
70
97
  private basePath: string;
71
98
  private topic: string;
72
- private maxRetries: number;
99
+ private _maxRetries: number;
73
100
  private seq: number = 0;
74
101
  private externalBackend: QueueBackendInterface | null = null;
75
102
 
@@ -98,7 +125,7 @@ export class Queue {
98
125
  ?? process.env.TINA4_QUEUE_PATH
99
126
  ?? "data/queue";
100
127
  this.topic = resolvedConfig.topic ?? "default";
101
- this.maxRetries = resolvedConfig.maxRetries ?? 3;
128
+ this._maxRetries = resolvedConfig.maxRetries ?? 3;
102
129
 
103
130
  // Initialize external backends
104
131
  if (this.backendName === "rabbitmq") {
@@ -107,6 +134,9 @@ export class Queue {
107
134
  } else if (this.backendName === "kafka") {
108
135
  const { KafkaBackend } = require("./queueBackends/kafkaBackend.js");
109
136
  this.externalBackend = new KafkaBackend();
137
+ } else if (this.backendName === "mongodb" || this.backendName === "mongo") {
138
+ const { MongoBackend } = require("./queueBackends/mongoBackend.js");
139
+ this.externalBackend = new MongoBackend();
110
140
  }
111
141
  }
112
142
 
@@ -169,7 +199,7 @@ export class Queue {
169
199
  };
170
200
 
171
201
  const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
172
- writeFileSync(join(dir, `${prefix}_${id}.json`), JSON.stringify(job, null, 2));
202
+ writeFileSync(join(dir, `${prefix}_${id}.queue-data`), JSON.stringify(job, null, 2));
173
203
  return id;
174
204
  }
175
205
 
@@ -190,7 +220,7 @@ export class Queue {
190
220
 
191
221
  let files: string[];
192
222
  try {
193
- files = readdirSync(dir).filter(f => f.endsWith(".json")).sort();
223
+ files = readdirSync(dir).filter(f => f.endsWith(".queue-data")).sort();
194
224
  } catch {
195
225
  return null;
196
226
  }
@@ -249,7 +279,7 @@ export class Queue {
249
279
  }
250
280
 
251
281
  const maxJobs = opts?.maxJobs ?? Infinity;
252
- const maxRetries = opts?.maxRetries ?? this.maxRetries;
282
+ const maxRetries = opts?.maxRetries ?? this._maxRetries;
253
283
  let processed = 0;
254
284
 
255
285
  while (processed < maxJobs) {
@@ -283,7 +313,7 @@ export class Queue {
283
313
  const dir = this.ensureDir(q);
284
314
  let files: string[];
285
315
  try {
286
- files = readdirSync(dir).filter(f => f.endsWith(".json"));
316
+ files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
287
317
  } catch {
288
318
  return 0;
289
319
  }
@@ -312,7 +342,7 @@ export class Queue {
312
342
  }
313
343
  const dir = this.ensureDir(q);
314
344
  try {
315
- const files = readdirSync(dir).filter(f => f.endsWith(".json"));
345
+ const files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
316
346
  for (const file of files) {
317
347
  unlinkSync(join(dir, file));
318
348
  }
@@ -324,7 +354,7 @@ export class Queue {
324
354
  const failedDir = join(dir, "failed");
325
355
  try {
326
356
  if (existsSync(failedDir)) {
327
- const files = readdirSync(failedDir).filter(f => f.endsWith(".json"));
357
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
328
358
  for (const file of files) {
329
359
  unlinkSync(join(failedDir, file));
330
360
  }
@@ -343,7 +373,7 @@ export class Queue {
343
373
  const results: QueueJob[] = [];
344
374
 
345
375
  try {
346
- const files = readdirSync(failedDir).filter(f => f.endsWith(".json")).sort();
376
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data")).sort();
347
377
  for (const file of files) {
348
378
  try {
349
379
  const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
@@ -367,7 +397,7 @@ export class Queue {
367
397
  const queues = readdirSync(this.basePath);
368
398
  for (const queue of queues) {
369
399
  const failedDir = join(this.basePath, queue, "failed");
370
- const filePath = join(failedDir, `${jobId}.json`);
400
+ const filePath = join(failedDir, `${jobId}.queue-data`);
371
401
 
372
402
  if (existsSync(filePath)) {
373
403
  const job: QueueJob = JSON.parse(readFileSync(filePath, "utf-8"));
@@ -378,7 +408,7 @@ export class Queue {
378
408
  this.seq++;
379
409
  const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
380
410
  const queueDir = join(this.basePath, queue);
381
- writeFileSync(join(queueDir, `${prefix}_${jobId}.json`), JSON.stringify(job, null, 2));
411
+ writeFileSync(join(queueDir, `${prefix}_${jobId}.queue-data`), JSON.stringify(job, null, 2));
382
412
  unlinkSync(filePath);
383
413
  return true;
384
414
  }
@@ -395,12 +425,12 @@ export class Queue {
395
425
  */
396
426
  deadLetters(queue?: string, maxRetries?: number): QueueJob[] {
397
427
  const q = queue ?? this.topic;
398
- const mr = maxRetries ?? this.maxRetries;
428
+ const mr = maxRetries ?? this._maxRetries;
399
429
  const failedDir = this.ensureFailedDir(q);
400
430
  const results: QueueJob[] = [];
401
431
 
402
432
  try {
403
- const files = readdirSync(failedDir).filter(f => f.endsWith(".json")).sort();
433
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data")).sort();
404
434
  for (const file of files) {
405
435
  try {
406
436
  const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
@@ -431,12 +461,12 @@ export class Queue {
431
461
  // Legacy: purge("queueName", "status", maxRetries?)
432
462
  queue = statusOrQueue;
433
463
  status = statusOrMaxRetries;
434
- mr = maxRetries ?? this.maxRetries;
464
+ mr = maxRetries ?? this._maxRetries;
435
465
  } else {
436
466
  // Unified: purge("status") or purge("status", maxRetries)
437
467
  queue = this.topic;
438
468
  status = statusOrQueue;
439
- mr = typeof statusOrMaxRetries === "number" ? statusOrMaxRetries : (maxRetries ?? this.maxRetries);
469
+ mr = typeof statusOrMaxRetries === "number" ? statusOrMaxRetries : (maxRetries ?? this._maxRetries);
440
470
  }
441
471
 
442
472
  let count = 0;
@@ -444,7 +474,7 @@ export class Queue {
444
474
  if (status === "dead") {
445
475
  const failedDir = this.ensureFailedDir(queue);
446
476
  try {
447
- const files = readdirSync(failedDir).filter(f => f.endsWith(".json"));
477
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
448
478
  for (const file of files) {
449
479
  try {
450
480
  const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
@@ -462,7 +492,7 @@ export class Queue {
462
492
  } else if (status === "failed") {
463
493
  const failedDir = this.ensureFailedDir(queue);
464
494
  try {
465
- const files = readdirSync(failedDir).filter(f => f.endsWith(".json"));
495
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
466
496
  for (const file of files) {
467
497
  try {
468
498
  const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
@@ -480,7 +510,7 @@ export class Queue {
480
510
  } else {
481
511
  const dir = this.ensureDir(queue);
482
512
  try {
483
- const files = readdirSync(dir).filter(f => f.endsWith(".json"));
513
+ const files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
484
514
  for (const file of files) {
485
515
  try {
486
516
  const job: QueueJob = JSON.parse(readFileSync(join(dir, file), "utf-8"));
@@ -505,13 +535,13 @@ export class Queue {
505
535
  */
506
536
  retryFailed(queue?: string, maxRetries?: number): number {
507
537
  const q = queue ?? this.topic;
508
- const mr = maxRetries ?? this.maxRetries;
538
+ const mr = maxRetries ?? this._maxRetries;
509
539
  const failedDir = this.ensureFailedDir(q);
510
540
  const queueDir = this.ensureDir(q);
511
541
  let count = 0;
512
542
 
513
543
  try {
514
- const files = readdirSync(failedDir).filter(f => f.endsWith(".json"));
544
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
515
545
  for (const file of files) {
516
546
  try {
517
547
  const filePath = join(failedDir, file);
@@ -526,7 +556,7 @@ export class Queue {
526
556
 
527
557
  this.seq++;
528
558
  const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
529
- writeFileSync(join(queueDir, `${prefix}_${job.id}.json`), JSON.stringify(job, null, 2));
559
+ writeFileSync(join(queueDir, `${prefix}_${job.id}.queue-data`), JSON.stringify(job, null, 2));
530
560
  unlinkSync(filePath);
531
561
  count++;
532
562
  } catch {
@@ -540,6 +570,74 @@ export class Queue {
540
570
  return count;
541
571
  }
542
572
 
573
+ /**
574
+ * Produce a message onto a topic. Convenience wrapper around push().
575
+ */
576
+ produce(topic: string, payload: unknown, delay?: number): string {
577
+ return this.push(topic, payload, delay);
578
+ }
579
+
580
+ /**
581
+ * Consume jobs from a topic using a generator (yield pattern).
582
+ *
583
+ * Usage:
584
+ * for (const job of queue.consume("emails")) {
585
+ * processEmail(job);
586
+ * }
587
+ *
588
+ * // Consume a specific job by ID:
589
+ * for (const job of queue.consume("emails", "job-id-123")) {
590
+ * processEmail(job);
591
+ * }
592
+ */
593
+ *consume(topic?: string, id?: string): Generator<QueueJob> {
594
+ const q = topic ?? this.topic;
595
+
596
+ if (id !== undefined) {
597
+ const raw = this.popById(q, id);
598
+ if (raw) yield createJob(raw as any, this, q);
599
+ return;
600
+ }
601
+
602
+ let raw: any;
603
+ while ((raw = this.pop(q)) !== null) {
604
+ yield createJob(raw, this, q);
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Pop a specific job by ID from the queue.
610
+ */
611
+ popById(queue: string, id: string): QueueJob | null {
612
+ const q = queue ?? this.topic;
613
+ const dir = this.ensureDir(q);
614
+
615
+ let files: string[];
616
+ try {
617
+ files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
618
+ } catch {
619
+ return null;
620
+ }
621
+
622
+ for (const file of files) {
623
+ const filePath = join(dir, file);
624
+ let job: QueueJob;
625
+ try {
626
+ job = JSON.parse(readFileSync(filePath, "utf-8"));
627
+ } catch {
628
+ continue;
629
+ }
630
+
631
+ if (job.status !== "pending") continue;
632
+ if (job.id === id) {
633
+ try { unlinkSync(filePath); } catch { /* already consumed */ }
634
+ return job;
635
+ }
636
+ }
637
+
638
+ return null;
639
+ }
640
+
543
641
  /**
544
642
  * Get the configured topic name.
545
643
  */
@@ -547,15 +645,19 @@ export class Queue {
547
645
  return this.topic;
548
646
  }
549
647
 
648
+ getMaxRetries(): number {
649
+ return this._maxRetries;
650
+ }
651
+
550
652
  /**
551
653
  * Move a job to the failed directory.
552
654
  */
553
- private _failJob(queue: string, job: QueueJob, error: string, maxRetries: number): void {
655
+ _failJob(queue: string, job: QueueJob, error: string, maxRetries: number): void {
554
656
  const failedDir = this.ensureFailedDir(queue);
555
657
  job.status = "failed";
556
658
  job.attempts = (job.attempts || 0) + 1;
557
659
  job.error = error;
558
660
 
559
- writeFileSync(join(failedDir, `${job.id}.json`), JSON.stringify(job, null, 2));
661
+ writeFileSync(join(failedDir, `${job.id}.queue-data`), JSON.stringify(job, null, 2));
560
662
  }
561
663
  }