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.
- package/CLAUDE.md +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/cli/src/bin.ts +13 -1
- package/packages/cli/src/commands/migrate.ts +19 -5
- package/packages/cli/src/commands/migrateCreate.ts +29 -28
- package/packages/cli/src/commands/migrateRollback.ts +59 -0
- package/packages/cli/src/commands/migrateStatus.ts +62 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +1 -1
- package/packages/core/public/js/tina4js.min.js +47 -0
- package/packages/core/src/auth.ts +44 -10
- package/packages/core/src/devAdmin.ts +14 -16
- package/packages/core/src/index.ts +10 -3
- package/packages/core/src/middleware.ts +232 -2
- package/packages/core/src/queue.ts +127 -25
- package/packages/core/src/queueBackends/mongoBackend.ts +223 -0
- package/packages/core/src/request.ts +3 -3
- package/packages/core/src/router.ts +115 -51
- package/packages/core/src/server.ts +47 -3
- package/packages/core/src/session.ts +29 -1
- package/packages/core/src/sessionHandlers/databaseHandler.ts +134 -0
- package/packages/core/src/sessionHandlers/redisHandler.ts +230 -0
- package/packages/core/src/types.ts +12 -6
- package/packages/core/src/websocket.ts +11 -2
- package/packages/core/src/websocketConnection.ts +4 -2
- package/packages/frond/src/engine.ts +66 -1
- package/packages/orm/src/autoCrud.ts +17 -12
- package/packages/orm/src/baseModel.ts +99 -21
- package/packages/orm/src/database.ts +197 -69
- package/packages/orm/src/databaseResult.ts +207 -0
- package/packages/orm/src/index.ts +6 -3
- package/packages/orm/src/migration.ts +296 -71
- package/packages/orm/src/model.ts +1 -0
- 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
|
-
|
|
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 '
|
|
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
|
|
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.
|
|
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}.
|
|
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(".
|
|
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.
|
|
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(".
|
|
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(".
|
|
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(".
|
|
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(".
|
|
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}.
|
|
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}.
|
|
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.
|
|
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(".
|
|
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.
|
|
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.
|
|
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(".
|
|
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(".
|
|
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(".
|
|
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.
|
|
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(".
|
|
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}.
|
|
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
|
-
|
|
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}.
|
|
661
|
+
writeFileSync(join(failedDir, `${job.id}.queue-data`), JSON.stringify(job, null, 2));
|
|
560
662
|
}
|
|
561
663
|
}
|