tina4-nodejs 3.0.0-rc.2

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 (119) hide show
  1. package/BENCHMARK_REPORT.md +96 -0
  2. package/CARBONAH.md +140 -0
  3. package/CLAUDE.md +599 -0
  4. package/COMPARISON.md +194 -0
  5. package/README.md +595 -0
  6. package/package.json +59 -0
  7. package/packages/cli/src/bin.ts +110 -0
  8. package/packages/cli/src/commands/init.ts +194 -0
  9. package/packages/cli/src/commands/migrate.ts +96 -0
  10. package/packages/cli/src/commands/migrateCreate.ts +59 -0
  11. package/packages/cli/src/commands/routes.ts +61 -0
  12. package/packages/cli/src/commands/serve.ts +58 -0
  13. package/packages/cli/src/commands/test.ts +83 -0
  14. package/packages/core/gallery/auth/meta.json +1 -0
  15. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
  16. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
  17. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
  18. package/packages/core/gallery/database/meta.json +1 -0
  19. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
  20. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
  21. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
  22. package/packages/core/gallery/error-overlay/meta.json +1 -0
  23. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
  24. package/packages/core/gallery/orm/meta.json +1 -0
  25. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
  26. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
  27. package/packages/core/gallery/queue/meta.json +1 -0
  28. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
  29. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
  30. package/packages/core/gallery/rest-api/meta.json +1 -0
  31. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
  32. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
  33. package/packages/core/gallery/templates/meta.json +1 -0
  34. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
  35. package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
  36. package/packages/core/public/css/tina4.css +2463 -0
  37. package/packages/core/public/css/tina4.min.css +1 -0
  38. package/packages/core/public/favicon.ico +0 -0
  39. package/packages/core/public/images/logo.svg +5 -0
  40. package/packages/core/public/images/tina4-logo-icon.webp +0 -0
  41. package/packages/core/public/js/frond.min.js +420 -0
  42. package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
  43. package/packages/core/public/js/tina4.min.js +93 -0
  44. package/packages/core/public/swagger/index.html +90 -0
  45. package/packages/core/public/swagger/oauth2-redirect.html +63 -0
  46. package/packages/core/src/ai.ts +359 -0
  47. package/packages/core/src/api.ts +248 -0
  48. package/packages/core/src/auth.ts +287 -0
  49. package/packages/core/src/cache.ts +121 -0
  50. package/packages/core/src/constants.ts +48 -0
  51. package/packages/core/src/container.ts +90 -0
  52. package/packages/core/src/devAdmin.ts +2024 -0
  53. package/packages/core/src/devMailbox.ts +316 -0
  54. package/packages/core/src/dotenv.ts +172 -0
  55. package/packages/core/src/errorOverlay.test.ts +122 -0
  56. package/packages/core/src/errorOverlay.ts +278 -0
  57. package/packages/core/src/events.ts +112 -0
  58. package/packages/core/src/fakeData.ts +309 -0
  59. package/packages/core/src/graphql.ts +812 -0
  60. package/packages/core/src/health.ts +31 -0
  61. package/packages/core/src/htmlElement.ts +172 -0
  62. package/packages/core/src/i18n.ts +136 -0
  63. package/packages/core/src/index.ts +88 -0
  64. package/packages/core/src/logger.ts +226 -0
  65. package/packages/core/src/messenger.ts +822 -0
  66. package/packages/core/src/middleware.ts +138 -0
  67. package/packages/core/src/queue.ts +481 -0
  68. package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
  69. package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
  70. package/packages/core/src/rateLimiter.ts +107 -0
  71. package/packages/core/src/request.ts +189 -0
  72. package/packages/core/src/response.ts +146 -0
  73. package/packages/core/src/routeDiscovery.ts +87 -0
  74. package/packages/core/src/router.ts +398 -0
  75. package/packages/core/src/scss.ts +366 -0
  76. package/packages/core/src/server.ts +610 -0
  77. package/packages/core/src/service.ts +380 -0
  78. package/packages/core/src/session.ts +480 -0
  79. package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
  80. package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
  81. package/packages/core/src/static.ts +58 -0
  82. package/packages/core/src/testing.ts +233 -0
  83. package/packages/core/src/types.ts +98 -0
  84. package/packages/core/src/watcher.ts +37 -0
  85. package/packages/core/src/websocket.ts +408 -0
  86. package/packages/core/src/wsdl.ts +546 -0
  87. package/packages/core/templates/errors/302.twig +14 -0
  88. package/packages/core/templates/errors/401.twig +9 -0
  89. package/packages/core/templates/errors/403.twig +29 -0
  90. package/packages/core/templates/errors/404.twig +29 -0
  91. package/packages/core/templates/errors/500.twig +38 -0
  92. package/packages/core/templates/errors/502.twig +9 -0
  93. package/packages/core/templates/errors/503.twig +12 -0
  94. package/packages/core/templates/errors/base.twig +37 -0
  95. package/packages/frond/src/engine.ts +1475 -0
  96. package/packages/frond/src/index.ts +2 -0
  97. package/packages/orm/src/adapters/firebird.ts +455 -0
  98. package/packages/orm/src/adapters/mssql.ts +440 -0
  99. package/packages/orm/src/adapters/mysql.ts +355 -0
  100. package/packages/orm/src/adapters/postgres.ts +362 -0
  101. package/packages/orm/src/adapters/sqlite.ts +270 -0
  102. package/packages/orm/src/autoCrud.ts +231 -0
  103. package/packages/orm/src/baseModel.ts +536 -0
  104. package/packages/orm/src/database.ts +321 -0
  105. package/packages/orm/src/fakeData.ts +118 -0
  106. package/packages/orm/src/index.ts +49 -0
  107. package/packages/orm/src/migration.ts +392 -0
  108. package/packages/orm/src/model.ts +56 -0
  109. package/packages/orm/src/query.ts +113 -0
  110. package/packages/orm/src/seeder.ts +120 -0
  111. package/packages/orm/src/sqlTranslation.ts +272 -0
  112. package/packages/orm/src/types.ts +110 -0
  113. package/packages/orm/src/validation.ts +93 -0
  114. package/packages/swagger/src/generator.ts +189 -0
  115. package/packages/swagger/src/index.ts +2 -0
  116. package/packages/swagger/src/ui.ts +48 -0
  117. package/skills/tina4-developer.skill +0 -0
  118. package/skills/tina4-js.skill +0 -0
  119. package/skills/tina4-maintainer.skill +0 -0
@@ -0,0 +1,138 @@
1
+ import type { Tina4Request, Tina4Response, Middleware } from "./types.js";
2
+
3
+ export class MiddlewareChain {
4
+ private middlewares: Middleware[] = [];
5
+
6
+ use(fn: Middleware): void {
7
+ this.middlewares.push(fn);
8
+ }
9
+
10
+ async run(req: Tina4Request, res: Tina4Response): Promise<boolean> {
11
+ let index = 0;
12
+ let completed = true;
13
+
14
+ const next = (): void => {
15
+ index++;
16
+ };
17
+
18
+ for (index = 0; index < this.middlewares.length; index++) {
19
+ const prevIndex = index;
20
+ await this.middlewares[index](req, res, next);
21
+
22
+ // If response was already sent, stop the chain
23
+ if (res.raw.writableEnded) {
24
+ completed = false;
25
+ break;
26
+ }
27
+
28
+ // If next() wasn't called, stop the chain
29
+ if (index === prevIndex) {
30
+ // next() increments index, so if it wasn't called, index stays the same
31
+ // But we increment in the for loop, so we need to check differently
32
+ }
33
+ }
34
+
35
+ return completed;
36
+ }
37
+ }
38
+
39
+ /** Configuration for the CORS middleware */
40
+ export interface CorsConfig {
41
+ /** Allowed origins. Default: "*" (or TINA4_CORS_ORIGINS env, comma-separated) */
42
+ origins?: string | string[];
43
+ /** Allowed methods. Default: standard REST methods (or TINA4_CORS_METHODS env) */
44
+ methods?: string | string[];
45
+ /** Allowed headers. Default: Content-Type, Authorization (or TINA4_CORS_HEADERS env) */
46
+ headers?: string | string[];
47
+ /** Access-Control-Max-Age in seconds. Default: 86400 (or TINA4_CORS_MAX_AGE env) */
48
+ maxAge?: number;
49
+ }
50
+
51
+ /**
52
+ * Built-in CORS middleware.
53
+ * Reads configuration from env vars if not provided:
54
+ * TINA4_CORS_ORIGINS — comma-separated list of allowed origins, or "*"
55
+ * TINA4_CORS_METHODS — comma-separated list of allowed methods
56
+ * TINA4_CORS_HEADERS — comma-separated list of allowed headers
57
+ * TINA4_CORS_MAX_AGE — preflight cache duration in seconds
58
+ *
59
+ * Preflight (OPTIONS) returns 204 with appropriate headers.
60
+ * Supports wildcard ("*") and specific origin matching.
61
+ */
62
+ export function cors(config?: CorsConfig): Middleware {
63
+ const originsRaw = config?.origins
64
+ ?? process.env.TINA4_CORS_ORIGINS
65
+ ?? "*";
66
+ const allowedOrigins = Array.isArray(originsRaw)
67
+ ? originsRaw
68
+ : originsRaw.split(",").map((o) => o.trim());
69
+
70
+ const methodsRaw = config?.methods
71
+ ?? process.env.TINA4_CORS_METHODS
72
+ ?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
73
+ const allowedMethods = Array.isArray(methodsRaw)
74
+ ? methodsRaw.join(", ")
75
+ : methodsRaw;
76
+
77
+ const headersRaw = config?.headers
78
+ ?? process.env.TINA4_CORS_HEADERS
79
+ ?? "Content-Type, Authorization";
80
+ const allowedHeaders = Array.isArray(headersRaw)
81
+ ? headersRaw.join(", ")
82
+ : headersRaw;
83
+
84
+ const maxAge = config?.maxAge
85
+ ?? (process.env.TINA4_CORS_MAX_AGE ? parseInt(process.env.TINA4_CORS_MAX_AGE, 10) : 86400);
86
+
87
+ return (req, res, next) => {
88
+ const requestOrigin = req.headers.origin ?? "";
89
+
90
+ // Determine the correct origin header value
91
+ let originHeader: string;
92
+ if (allowedOrigins.includes("*")) {
93
+ originHeader = "*";
94
+ } else if (allowedOrigins.includes(requestOrigin)) {
95
+ originHeader = requestOrigin;
96
+ // When responding with a specific origin, add Vary: Origin
97
+ res.header("Vary", "Origin");
98
+ } else {
99
+ // Origin not allowed — still call next() but don't set CORS headers
100
+ if (req.method === "OPTIONS") {
101
+ res(null, 204);
102
+ return;
103
+ }
104
+ next();
105
+ return;
106
+ }
107
+
108
+ res.header("Access-Control-Allow-Origin", originHeader);
109
+ res.header("Access-Control-Allow-Methods", allowedMethods);
110
+ res.header("Access-Control-Allow-Headers", allowedHeaders);
111
+
112
+ if (req.method === "OPTIONS") {
113
+ res.header("Access-Control-Max-Age", String(maxAge));
114
+ res(null, 204);
115
+ return;
116
+ }
117
+
118
+ next();
119
+ };
120
+ }
121
+
122
+ // Built-in request logger middleware
123
+ export function requestLogger(): Middleware {
124
+ return (req, res, next) => {
125
+ const start = Date.now();
126
+
127
+ res.raw.on("finish", () => {
128
+ const duration = Date.now() - start;
129
+ const status = res.raw.statusCode;
130
+ const method = req.method ?? "?";
131
+ const url = req.url ?? "/";
132
+ const color = status >= 400 ? "\x1b[31m" : status >= 300 ? "\x1b[33m" : "\x1b[32m";
133
+ console.log(` ${color}${status}\x1b[0m ${method} ${url} \x1b[90m${duration}ms\x1b[0m`);
134
+ });
135
+
136
+ next();
137
+ };
138
+ }
@@ -0,0 +1,481 @@
1
+ /**
2
+ * Tina4 Queue — File-backed job queue, zero dependencies.
3
+ *
4
+ * Production-grade queue using the file system as storage.
5
+ * No Redis, no RabbitMQ, no external dependencies needed.
6
+ *
7
+ * import { Queue } from "@tina4/core";
8
+ *
9
+ * const queue = new Queue();
10
+ * queue.push("emails", { to: "alice@test.com", subject: "Hello" });
11
+ *
12
+ * const job = queue.pop("emails");
13
+ * if (job) {
14
+ * await processJob(job);
15
+ * }
16
+ */
17
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, renameSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { randomUUID } from "node:crypto";
20
+
21
+ // ── Types ────────────────────────────────────────────────────
22
+
23
+ export interface QueueConfig {
24
+ backend?: string;
25
+ path?: string;
26
+ }
27
+
28
+ export interface QueueJob {
29
+ id: string;
30
+ payload: unknown;
31
+ status: "pending" | "reserved" | "failed" | "dead";
32
+ createdAt: string;
33
+ attempts: number;
34
+ delayUntil: string | null;
35
+ error?: string;
36
+ }
37
+
38
+ export interface ProcessOptions {
39
+ pollInterval?: number;
40
+ maxJobs?: number;
41
+ maxRetries?: number;
42
+ }
43
+
44
+ // ── Queue ────────────────────────────────────────────────────
45
+
46
+ export class Queue {
47
+ private backend: string;
48
+ private basePath: string;
49
+ private seq: number = 0;
50
+ private externalBackend: { push: (q: string, p: unknown, d?: number) => string; pop: (q: string) => QueueJob | null; size: (q: string) => number; clear: (q: string) => void } | null = null;
51
+
52
+ constructor(backend?: string, config?: QueueConfig) {
53
+ this.backend = backend ?? config?.backend ?? process.env.TINA4_QUEUE_BACKEND ?? "file";
54
+ this.basePath = config?.path ?? process.env.TINA4_QUEUE_PATH ?? "data/queue";
55
+
56
+ // Initialize external backends
57
+ if (this.backend === "rabbitmq") {
58
+ // Dynamic import to avoid loading when not needed
59
+ const { RabbitMQBackend } = require("./queueBackends/rabbitmqBackend.js");
60
+ this.externalBackend = new RabbitMQBackend();
61
+ } else if (this.backend === "kafka") {
62
+ const { KafkaBackend } = require("./queueBackends/kafkaBackend.js");
63
+ this.externalBackend = new KafkaBackend();
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Ensure directory exists for a queue.
69
+ */
70
+ private ensureDir(queue: string): string {
71
+ const dir = join(this.basePath, queue);
72
+ mkdirSync(dir, { recursive: true });
73
+ return dir;
74
+ }
75
+
76
+ /**
77
+ * Ensure the failed subdirectory exists for a queue.
78
+ */
79
+ private ensureFailedDir(queue: string): string {
80
+ const dir = join(this.basePath, queue, "failed");
81
+ mkdirSync(dir, { recursive: true });
82
+ return dir;
83
+ }
84
+
85
+ /**
86
+ * Add a job to the queue. Returns job ID.
87
+ */
88
+ push(queue: string, payload: unknown, delay?: number): string {
89
+ if (this.externalBackend) {
90
+ return this.externalBackend.push(queue, payload, delay);
91
+ }
92
+ const dir = this.ensureDir(queue);
93
+ const id = randomUUID();
94
+ const now = new Date().toISOString();
95
+ this.seq++;
96
+
97
+ const job: QueueJob = {
98
+ id,
99
+ payload,
100
+ status: "pending",
101
+ createdAt: now,
102
+ attempts: 0,
103
+ delayUntil: delay ? new Date(Date.now() + delay * 1000).toISOString() : null,
104
+ };
105
+
106
+ // Use timestamp + sequence prefix for FIFO ordering
107
+ const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
108
+ writeFileSync(join(dir, `${prefix}_${id}.json`), JSON.stringify(job, null, 2));
109
+ return id;
110
+ }
111
+
112
+ /**
113
+ * Atomically claim the next available job. Returns null if empty.
114
+ */
115
+ pop(queue: string): QueueJob | null {
116
+ if (this.externalBackend) {
117
+ return this.externalBackend.pop(queue);
118
+ }
119
+ const dir = this.ensureDir(queue);
120
+
121
+ let files: string[];
122
+ try {
123
+ files = readdirSync(dir).filter(f => f.endsWith(".json")).sort();
124
+ } catch {
125
+ return null;
126
+ }
127
+
128
+ const now = new Date().toISOString();
129
+
130
+ for (const file of files) {
131
+ const filePath = join(dir, file);
132
+ let job: QueueJob;
133
+ try {
134
+ job = JSON.parse(readFileSync(filePath, "utf-8"));
135
+ } catch {
136
+ continue;
137
+ }
138
+
139
+ if (job.status !== "pending") continue;
140
+ if (job.delayUntil && job.delayUntil > now) continue;
141
+
142
+ // Reserve the job
143
+ job.status = "reserved";
144
+ writeFileSync(filePath, JSON.stringify(job, null, 2));
145
+
146
+ // Remove the file (job is consumed)
147
+ try {
148
+ unlinkSync(filePath);
149
+ } catch {
150
+ // Already consumed by another worker
151
+ }
152
+
153
+ return job;
154
+ }
155
+
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Process jobs from a queue with a handler function.
161
+ */
162
+ process(
163
+ queue: string,
164
+ handler: (job: QueueJob) => Promise<void> | void,
165
+ options?: ProcessOptions,
166
+ ): void {
167
+ const maxJobs = options?.maxJobs ?? Infinity;
168
+ const maxRetries = options?.maxRetries ?? 3;
169
+ let processed = 0;
170
+
171
+ const tick = () => {
172
+ if (processed >= maxJobs) return;
173
+
174
+ const job = this.pop(queue);
175
+ if (!job) return;
176
+
177
+ try {
178
+ const result = handler(job);
179
+ if (result instanceof Promise) {
180
+ result
181
+ .then(() => { processed++; })
182
+ .catch((err: Error) => {
183
+ this._failJob(queue, job, err.message, maxRetries);
184
+ processed++;
185
+ });
186
+ } else {
187
+ processed++;
188
+ }
189
+ } catch (err: unknown) {
190
+ const message = err instanceof Error ? err.message : String(err);
191
+ this._failJob(queue, job, message, maxRetries);
192
+ processed++;
193
+ }
194
+ };
195
+
196
+ // Process all currently available jobs synchronously
197
+ while (processed < maxJobs) {
198
+ const job = this.pop(queue);
199
+ if (!job) break;
200
+ try {
201
+ const result = handler(job);
202
+ if (result instanceof Promise) {
203
+ // For async handlers in sync process loop, we still increment
204
+ result.catch((err: Error) => {
205
+ this._failJob(queue, job, err.message, maxRetries);
206
+ });
207
+ }
208
+ processed++;
209
+ } catch (err: unknown) {
210
+ const message = err instanceof Error ? err.message : String(err);
211
+ this._failJob(queue, job, message, maxRetries);
212
+ processed++;
213
+ }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Count pending jobs in a queue.
219
+ */
220
+ size(queue: string): number {
221
+ if (this.externalBackend) {
222
+ return this.externalBackend.size(queue);
223
+ }
224
+ const dir = this.ensureDir(queue);
225
+ let files: string[];
226
+ try {
227
+ files = readdirSync(dir).filter(f => f.endsWith(".json"));
228
+ } catch {
229
+ return 0;
230
+ }
231
+
232
+ let count = 0;
233
+ for (const file of files) {
234
+ try {
235
+ const job: QueueJob = JSON.parse(readFileSync(join(dir, file), "utf-8"));
236
+ if (job.status === "pending") count++;
237
+ } catch {
238
+ // skip corrupt files
239
+ }
240
+ }
241
+ return count;
242
+ }
243
+
244
+ /**
245
+ * Remove all jobs from a queue.
246
+ */
247
+ clear(queue: string): void {
248
+ if (this.externalBackend) {
249
+ this.externalBackend.clear(queue);
250
+ return;
251
+ }
252
+ const dir = this.ensureDir(queue);
253
+ try {
254
+ const files = readdirSync(dir).filter(f => f.endsWith(".json"));
255
+ for (const file of files) {
256
+ unlinkSync(join(dir, file));
257
+ }
258
+ } catch {
259
+ // directory might not exist
260
+ }
261
+
262
+ // Also clear failed jobs
263
+ const failedDir = join(dir, "failed");
264
+ try {
265
+ if (existsSync(failedDir)) {
266
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".json"));
267
+ for (const file of files) {
268
+ unlinkSync(join(failedDir, file));
269
+ }
270
+ }
271
+ } catch {
272
+ // ignore
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Get all failed jobs for a queue.
278
+ */
279
+ failed(queue: string): QueueJob[] {
280
+ const failedDir = this.ensureFailedDir(queue);
281
+ const results: QueueJob[] = [];
282
+
283
+ try {
284
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".json")).sort();
285
+ for (const file of files) {
286
+ try {
287
+ const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
288
+ results.push(job);
289
+ } catch {
290
+ // skip corrupt files
291
+ }
292
+ }
293
+ } catch {
294
+ // directory might not exist
295
+ }
296
+
297
+ return results;
298
+ }
299
+
300
+ /**
301
+ * Retry a failed job by moving it back to the queue.
302
+ */
303
+ retry(jobId: string): boolean {
304
+ // Search for the job in all queue failed directories
305
+ try {
306
+ const queues = readdirSync(this.basePath);
307
+ for (const queue of queues) {
308
+ const failedDir = join(this.basePath, queue, "failed");
309
+ const filePath = join(failedDir, `${jobId}.json`);
310
+
311
+ if (existsSync(filePath)) {
312
+ const job: QueueJob = JSON.parse(readFileSync(filePath, "utf-8"));
313
+ job.status = "pending";
314
+ job.attempts = (job.attempts || 0) + 1;
315
+ job.error = undefined;
316
+
317
+ // Move back to queue directory with sortable prefix
318
+ this.seq++;
319
+ const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
320
+ const queueDir = join(this.basePath, queue);
321
+ writeFileSync(join(queueDir, `${prefix}_${jobId}.json`), JSON.stringify(job, null, 2));
322
+ unlinkSync(filePath);
323
+ return true;
324
+ }
325
+ }
326
+ } catch {
327
+ // ignore
328
+ }
329
+
330
+ return false;
331
+ }
332
+
333
+ /**
334
+ * Get dead letter jobs — failed jobs that exceeded max retries.
335
+ */
336
+ deadLetters(queue: string, maxRetries: number = 3): QueueJob[] {
337
+ const failedDir = this.ensureFailedDir(queue);
338
+ const results: QueueJob[] = [];
339
+
340
+ try {
341
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".json")).sort();
342
+ for (const file of files) {
343
+ try {
344
+ const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
345
+ if ((job.attempts || 0) >= maxRetries) {
346
+ job.status = "dead";
347
+ results.push(job);
348
+ }
349
+ } catch {
350
+ // skip corrupt files
351
+ }
352
+ }
353
+ } catch {
354
+ // directory might not exist
355
+ }
356
+
357
+ return results;
358
+ }
359
+
360
+ /**
361
+ * Delete messages by status (completed, failed, dead).
362
+ * For 'dead', removes failed jobs that exceeded maxRetries.
363
+ * For 'failed', removes failed jobs under maxRetries.
364
+ * For other statuses, removes matching jobs from the main queue directory.
365
+ */
366
+ purge(queue: string, status: string, maxRetries: number = 3): number {
367
+ let count = 0;
368
+
369
+ if (status === "dead") {
370
+ const failedDir = this.ensureFailedDir(queue);
371
+ try {
372
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".json"));
373
+ for (const file of files) {
374
+ try {
375
+ const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
376
+ if ((job.attempts || 0) >= maxRetries) {
377
+ unlinkSync(join(failedDir, file));
378
+ count++;
379
+ }
380
+ } catch {
381
+ // skip corrupt files
382
+ }
383
+ }
384
+ } catch {
385
+ // directory might not exist
386
+ }
387
+ } else if (status === "failed") {
388
+ const failedDir = this.ensureFailedDir(queue);
389
+ try {
390
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".json"));
391
+ for (const file of files) {
392
+ try {
393
+ const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
394
+ if ((job.attempts || 0) < maxRetries) {
395
+ unlinkSync(join(failedDir, file));
396
+ count++;
397
+ }
398
+ } catch {
399
+ // skip corrupt files
400
+ }
401
+ }
402
+ } catch {
403
+ // directory might not exist
404
+ }
405
+ } else {
406
+ // completed, pending, or other — scan main queue directory
407
+ const dir = this.ensureDir(queue);
408
+ try {
409
+ const files = readdirSync(dir).filter(f => f.endsWith(".json"));
410
+ for (const file of files) {
411
+ try {
412
+ const job: QueueJob = JSON.parse(readFileSync(join(dir, file), "utf-8"));
413
+ if (job.status === status) {
414
+ unlinkSync(join(dir, file));
415
+ count++;
416
+ }
417
+ } catch {
418
+ // skip corrupt files
419
+ }
420
+ }
421
+ } catch {
422
+ // directory might not exist
423
+ }
424
+ }
425
+
426
+ return count;
427
+ }
428
+
429
+ /**
430
+ * Re-queue failed jobs that haven't exceeded max retries back to pending.
431
+ * Returns the number of jobs re-queued.
432
+ */
433
+ retryFailed(queue: string, maxRetries: number = 3): number {
434
+ const failedDir = this.ensureFailedDir(queue);
435
+ const queueDir = this.ensureDir(queue);
436
+ let count = 0;
437
+
438
+ try {
439
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".json"));
440
+ for (const file of files) {
441
+ try {
442
+ const filePath = join(failedDir, file);
443
+ const job: QueueJob = JSON.parse(readFileSync(filePath, "utf-8"));
444
+
445
+ // Only retry if under max retries (not dead)
446
+ if ((job.attempts || 0) >= maxRetries) {
447
+ continue;
448
+ }
449
+
450
+ job.status = "pending";
451
+ job.error = undefined;
452
+
453
+ // Move back to queue directory with sortable prefix
454
+ this.seq++;
455
+ const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
456
+ writeFileSync(join(queueDir, `${prefix}_${job.id}.json`), JSON.stringify(job, null, 2));
457
+ unlinkSync(filePath);
458
+ count++;
459
+ } catch {
460
+ // skip corrupt files
461
+ }
462
+ }
463
+ } catch {
464
+ // directory might not exist
465
+ }
466
+
467
+ return count;
468
+ }
469
+
470
+ /**
471
+ * Move a job to the failed directory.
472
+ */
473
+ private _failJob(queue: string, job: QueueJob, error: string, maxRetries: number): void {
474
+ const failedDir = this.ensureFailedDir(queue);
475
+ job.status = "failed";
476
+ job.attempts = (job.attempts || 0) + 1;
477
+ job.error = error;
478
+
479
+ writeFileSync(join(failedDir, `${job.id}.json`), JSON.stringify(job, null, 2));
480
+ }
481
+ }