tina4-nodejs 3.1.2 → 3.4.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 +30 -2
- 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/src/auth.ts +44 -10
- package/packages/core/src/devAdmin.ts +14 -16
- package/packages/core/src/errorOverlay.ts +17 -15
- package/packages/core/src/index.ts +9 -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/routeDiscovery.ts +2 -1
- package/packages/core/src/router.ts +90 -51
- package/packages/core/src/server.ts +62 -4
- package/packages/core/src/session.ts +17 -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
|
@@ -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
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 MongoDB Queue Backend — uses `mongodb` npm package via dynamic import.
|
|
3
|
+
*
|
|
4
|
+
* Implements the same interface as the file-based queue but uses MongoDB
|
|
5
|
+
* for message storage and delivery. Atomic pop via findOneAndUpdate.
|
|
6
|
+
*
|
|
7
|
+
* Configure via environment variables:
|
|
8
|
+
* TINA4_MONGO_HOST (default: "localhost")
|
|
9
|
+
* TINA4_MONGO_PORT (default: 27017)
|
|
10
|
+
* TINA4_MONGO_URI (overrides host/port/username/password)
|
|
11
|
+
* TINA4_MONGO_USERNAME (optional)
|
|
12
|
+
* TINA4_MONGO_PASSWORD (optional)
|
|
13
|
+
* TINA4_MONGO_DB (default: "tina4")
|
|
14
|
+
* TINA4_MONGO_COLLECTION (default: "tina4_queue")
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import type { QueueJob } from "../queue.js";
|
|
19
|
+
|
|
20
|
+
// ── Types ────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface MongoConfig {
|
|
23
|
+
host?: string;
|
|
24
|
+
port?: number;
|
|
25
|
+
uri?: string;
|
|
26
|
+
username?: string;
|
|
27
|
+
password?: string;
|
|
28
|
+
database?: string;
|
|
29
|
+
collection?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface QueueBackend {
|
|
33
|
+
push(queue: string, payload: unknown, delay?: number): string;
|
|
34
|
+
pop(queue: string): QueueJob | null;
|
|
35
|
+
size(queue: string): number;
|
|
36
|
+
clear(queue: string): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── MongoDB Backend ──────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* MongoDB queue backend using the `mongodb` npm package.
|
|
43
|
+
*
|
|
44
|
+
* Uses synchronous-style communication by spawning a child process
|
|
45
|
+
* for each operation, similar to the RabbitMQ and Redis patterns.
|
|
46
|
+
* This keeps the interface synchronous as required by the Queue class.
|
|
47
|
+
*/
|
|
48
|
+
export class MongoBackend implements QueueBackend {
|
|
49
|
+
private host: string;
|
|
50
|
+
private port: number;
|
|
51
|
+
private uri: string;
|
|
52
|
+
private username: string;
|
|
53
|
+
private password: string;
|
|
54
|
+
private database: string;
|
|
55
|
+
private collection: string;
|
|
56
|
+
|
|
57
|
+
constructor(config?: MongoConfig) {
|
|
58
|
+
this.host = config?.host ?? process.env.TINA4_MONGO_HOST ?? "localhost";
|
|
59
|
+
this.port = config?.port
|
|
60
|
+
?? (process.env.TINA4_MONGO_PORT ? parseInt(process.env.TINA4_MONGO_PORT, 10) : 27017);
|
|
61
|
+
this.username = config?.username ?? process.env.TINA4_MONGO_USERNAME ?? "";
|
|
62
|
+
this.password = config?.password ?? process.env.TINA4_MONGO_PASSWORD ?? "";
|
|
63
|
+
this.database = config?.database ?? process.env.TINA4_MONGO_DB ?? "tina4";
|
|
64
|
+
this.collection = config?.collection ?? process.env.TINA4_MONGO_COLLECTION ?? "tina4_queue";
|
|
65
|
+
|
|
66
|
+
// URI overrides individual host/port/auth settings
|
|
67
|
+
if (config?.uri ?? process.env.TINA4_MONGO_URI) {
|
|
68
|
+
this.uri = config?.uri ?? process.env.TINA4_MONGO_URI!;
|
|
69
|
+
} else {
|
|
70
|
+
const auth = this.username
|
|
71
|
+
? `${encodeURIComponent(this.username)}:${encodeURIComponent(this.password)}@`
|
|
72
|
+
: "";
|
|
73
|
+
this.uri = `mongodb://${auth}${this.host}:${this.port}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Execute a MongoDB operation synchronously via a child process.
|
|
79
|
+
*/
|
|
80
|
+
private execSync(operation: string, queue: string, data?: string): string {
|
|
81
|
+
const script = `
|
|
82
|
+
async function main() {
|
|
83
|
+
let mongodb;
|
|
84
|
+
try {
|
|
85
|
+
mongodb = await import("mongodb");
|
|
86
|
+
} catch {
|
|
87
|
+
process.stderr.write("mongodb package not installed — run: npm install mongodb");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { MongoClient } = mongodb;
|
|
92
|
+
const uri = ${JSON.stringify(this.uri)};
|
|
93
|
+
const dbName = ${JSON.stringify(this.database)};
|
|
94
|
+
const collName = ${JSON.stringify(this.collection)};
|
|
95
|
+
const operation = ${JSON.stringify(operation)};
|
|
96
|
+
const queueName = ${JSON.stringify(queue)};
|
|
97
|
+
const data = ${JSON.stringify(data ?? "")};
|
|
98
|
+
|
|
99
|
+
const client = new MongoClient(uri, {
|
|
100
|
+
connectTimeoutMS: 5000,
|
|
101
|
+
serverSelectionTimeoutMS: 5000,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await client.connect();
|
|
106
|
+
const db = client.db(dbName);
|
|
107
|
+
const col = db.collection(collName);
|
|
108
|
+
|
|
109
|
+
// Ensure indexes on first use
|
|
110
|
+
await col.createIndex({ queue: 1, status: 1, delayUntil: 1 });
|
|
111
|
+
await col.createIndex({ queue: 1, createdAt: 1 });
|
|
112
|
+
|
|
113
|
+
if (operation === "push") {
|
|
114
|
+
const job = JSON.parse(data);
|
|
115
|
+
await col.insertOne({ ...job, queue: queueName });
|
|
116
|
+
process.stdout.write("__PUSHED__");
|
|
117
|
+
}
|
|
118
|
+
else if (operation === "pop") {
|
|
119
|
+
const now = new Date().toISOString();
|
|
120
|
+
const result = await col.findOneAndUpdate(
|
|
121
|
+
{
|
|
122
|
+
queue: queueName,
|
|
123
|
+
status: "pending",
|
|
124
|
+
$or: [
|
|
125
|
+
{ delayUntil: null },
|
|
126
|
+
{ delayUntil: { $lte: now } },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
{ $set: { status: "reserved" } },
|
|
130
|
+
{ sort: { createdAt: 1 }, returnDocument: "before" },
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
if (result && result.value) {
|
|
134
|
+
// findOneAndUpdate returns { value: doc } in older drivers
|
|
135
|
+
const doc = result.value;
|
|
136
|
+
delete doc._id;
|
|
137
|
+
delete doc.queue;
|
|
138
|
+
process.stdout.write(JSON.stringify(doc));
|
|
139
|
+
} else if (result && result._id) {
|
|
140
|
+
// Some driver versions return the doc directly
|
|
141
|
+
const doc = { ...result };
|
|
142
|
+
delete doc._id;
|
|
143
|
+
delete doc.queue;
|
|
144
|
+
process.stdout.write(JSON.stringify(doc));
|
|
145
|
+
} else {
|
|
146
|
+
process.stdout.write("__EMPTY__");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if (operation === "size") {
|
|
150
|
+
const count = await col.countDocuments({
|
|
151
|
+
queue: queueName,
|
|
152
|
+
status: "pending",
|
|
153
|
+
});
|
|
154
|
+
process.stdout.write(String(count));
|
|
155
|
+
}
|
|
156
|
+
else if (operation === "clear") {
|
|
157
|
+
await col.deleteMany({ queue: queueName });
|
|
158
|
+
process.stdout.write("__CLEARED__");
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
process.stderr.write(err.message || String(err));
|
|
162
|
+
process.exit(1);
|
|
163
|
+
} finally {
|
|
164
|
+
await client.close();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
main();
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const result = execFileSync(process.execPath, ["-e", script], {
|
|
173
|
+
encoding: "utf-8",
|
|
174
|
+
timeout: 15000,
|
|
175
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
176
|
+
});
|
|
177
|
+
return result;
|
|
178
|
+
} catch {
|
|
179
|
+
return "";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
push(queue: string, payload: unknown, delay?: number): string {
|
|
184
|
+
const id = randomUUID();
|
|
185
|
+
const now = new Date().toISOString();
|
|
186
|
+
|
|
187
|
+
const job: QueueJob = {
|
|
188
|
+
id,
|
|
189
|
+
payload,
|
|
190
|
+
status: "pending",
|
|
191
|
+
createdAt: now,
|
|
192
|
+
attempts: 0,
|
|
193
|
+
delayUntil: delay ? new Date(Date.now() + delay * 1000).toISOString() : null,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const result = this.execSync("push", queue, JSON.stringify(job));
|
|
197
|
+
if (!result.includes("__PUSHED__")) {
|
|
198
|
+
throw new Error("MongoDB push failed");
|
|
199
|
+
}
|
|
200
|
+
return id;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
pop(queue: string): QueueJob | null {
|
|
204
|
+
const result = this.execSync("pop", queue);
|
|
205
|
+
if (!result || result === "__EMPTY__") return null;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(result) as QueueJob;
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
size(queue: string): number {
|
|
215
|
+
const result = this.execSync("size", queue);
|
|
216
|
+
const num = parseInt(result, 10);
|
|
217
|
+
return isNaN(num) ? 0 : num;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
clear(queue: string): void {
|
|
221
|
+
this.execSync("clear", queue);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -129,12 +129,12 @@ function parseMultipart(
|
|
|
129
129
|
const partContentType = parsePartContentType(headersStr);
|
|
130
130
|
|
|
131
131
|
if (disposition.filename) {
|
|
132
|
-
// File upload
|
|
132
|
+
// File upload — standardised format: filename, type, content (raw bytes), size
|
|
133
133
|
files.push({
|
|
134
134
|
fieldName: disposition.name,
|
|
135
135
|
filename: disposition.filename,
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
type: partContentType ?? "application/octet-stream",
|
|
137
|
+
content: Buffer.from(content),
|
|
138
138
|
size: content.length,
|
|
139
139
|
});
|
|
140
140
|
} else if (disposition.name) {
|
|
@@ -44,7 +44,8 @@ export async function discoverRoutes(routesDir: string): Promise<RouteDefinition
|
|
|
44
44
|
|
|
45
45
|
function filePathToPattern(relativePath: string): string {
|
|
46
46
|
// Remove the filename (get.ts, post.ts, etc.) to get the directory path
|
|
47
|
-
|
|
47
|
+
// Normalise backslashes for Windows compatibility
|
|
48
|
+
const parts = relativePath.replace(/\\/g, "/").split("/").slice(0, -1);
|
|
48
49
|
|
|
49
50
|
// Convert directory segments to URL pattern
|
|
50
51
|
// File system uses [id] notation, but URL patterns use {id} to match Python
|