tina4-nodejs 3.13.42 → 3.13.44
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 +2 -1
- package/package.json +1 -1
- package/packages/core/src/graphql.ts +23 -14
- package/packages/core/src/mcp.ts +21 -2
- package/packages/core/src/queue.ts +101 -9
- package/packages/core/src/queueBackends/kafkaBackend.ts +303 -167
- package/packages/core/src/queueBackends/mongoBackend.ts +143 -0
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +97 -31
- package/packages/core/src/server.ts +12 -5
- package/packages/core/src/session.ts +11 -95
- package/packages/core/src/sessionHandlers/mongoClient.ts +238 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +25 -204
- package/packages/core/src/sessionHandlers/redisHandler.ts +69 -114
- package/packages/core/src/sessionHandlers/respClient.ts +171 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +11 -95
- package/packages/orm/src/adapters/firebird.ts +20 -2
- package/packages/orm/src/adapters/mssql.ts +24 -2
- package/packages/orm/src/adapters/mysql.ts +20 -2
- package/packages/orm/src/adapters/postgres.ts +40 -12
- package/packages/orm/src/adapters/sqlite.ts +16 -2
- package/packages/orm/src/autoCrud.ts +13 -0
- package/packages/orm/src/baseModel.ts +3 -1
- package/packages/orm/src/cachedDatabase.ts +1 -1
- package/packages/orm/src/database.ts +42 -11
- package/packages/orm/src/index.ts +1 -1
- package/packages/orm/src/migration.ts +124 -45
- package/packages/orm/src/types.ts +5 -3
|
@@ -229,12 +229,20 @@ export class MongoBackend implements QueueBackend {
|
|
|
229
229
|
if (result && result.value) {
|
|
230
230
|
// findOneAndUpdate returns { value: doc } in older drivers
|
|
231
231
|
const doc = result.value;
|
|
232
|
+
// Carry the framework topic on the job so complete()/fail()
|
|
233
|
+
// route the ack/requeue back to THIS topic's docs (the Mongo
|
|
234
|
+
// internal queue field is dropped from the returned shape).
|
|
235
|
+
doc.topic = queueName;
|
|
232
236
|
delete doc._id;
|
|
233
237
|
delete doc.queue;
|
|
234
238
|
process.stdout.write(JSON.stringify(doc));
|
|
235
239
|
} else if (result && result._id) {
|
|
236
240
|
// Some driver versions return the doc directly
|
|
237
241
|
const doc = { ...result };
|
|
242
|
+
// Carry the framework topic on the job so complete()/fail()
|
|
243
|
+
// route the ack/requeue back to THIS topic's docs (the Mongo
|
|
244
|
+
// internal queue field is dropped from the returned shape).
|
|
245
|
+
doc.topic = queueName;
|
|
238
246
|
delete doc._id;
|
|
239
247
|
delete doc.queue;
|
|
240
248
|
process.stdout.write(JSON.stringify(doc));
|
|
@@ -253,6 +261,95 @@ export class MongoBackend implements QueueBackend {
|
|
|
253
261
|
await col.deleteMany({ queue: queueName });
|
|
254
262
|
process.stdout.write("__CLEARED__");
|
|
255
263
|
}
|
|
264
|
+
else if (operation === "complete") {
|
|
265
|
+
// Ack a finished job so the reclaim never re-delivers it. data = job id.
|
|
266
|
+
// (The pop reserved this doc; without this it stays reserved and is
|
|
267
|
+
// re-delivered after the visibility window — the redelivery bug.)
|
|
268
|
+
await col.updateOne(
|
|
269
|
+
{ queue: queueName, id: data },
|
|
270
|
+
{ $set: { status: "completed", completedAt: new Date().toISOString(), reservedAt: null } },
|
|
271
|
+
);
|
|
272
|
+
process.stdout.write("__OK__");
|
|
273
|
+
}
|
|
274
|
+
else if (operation === "fail") {
|
|
275
|
+
// Requeue while retries remain (reset availableAt -> visible again),
|
|
276
|
+
// else dead-letter. Atomic decision in Mongo. data = JSON
|
|
277
|
+
// { id, error, maxRetries, retryBackoff }.
|
|
278
|
+
const info = JSON.parse(data);
|
|
279
|
+
const now = new Date().toISOString();
|
|
280
|
+
const doc = await col.findOne({ queue: queueName, id: info.id });
|
|
281
|
+
if (doc) {
|
|
282
|
+
const attempts = (doc.attempts || 0) + 1;
|
|
283
|
+
if (attempts >= info.maxRetries) {
|
|
284
|
+
await col.insertOne({
|
|
285
|
+
...doc, _id: undefined, attempts,
|
|
286
|
+
status: "dead", queue: queueName + ".dead_letter", error: info.error,
|
|
287
|
+
});
|
|
288
|
+
await col.deleteOne({ _id: doc._id, queue: queueName });
|
|
289
|
+
} else {
|
|
290
|
+
const avail = info.retryBackoff > 0
|
|
291
|
+
? new Date(Date.now() + info.retryBackoff * 1000).toISOString()
|
|
292
|
+
: now;
|
|
293
|
+
await col.updateOne(
|
|
294
|
+
{ _id: doc._id, queue: queueName },
|
|
295
|
+
{ $set: { status: "pending", availableAt: avail, reservedAt: null, error: info.error },
|
|
296
|
+
$inc: { attempts: 1 } },
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
process.stdout.write("__OK__");
|
|
301
|
+
}
|
|
302
|
+
else if (operation === "retry") {
|
|
303
|
+
// Explicit manual re-queue (always re-enqueues). data = JSON
|
|
304
|
+
// { id, delaySeconds }.
|
|
305
|
+
const info = JSON.parse(data);
|
|
306
|
+
const avail = info.delaySeconds > 0
|
|
307
|
+
? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
|
|
308
|
+
: new Date().toISOString();
|
|
309
|
+
await col.updateOne(
|
|
310
|
+
{ queue: queueName, id: info.id },
|
|
311
|
+
{ $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
|
|
312
|
+
);
|
|
313
|
+
process.stdout.write("__OK__");
|
|
314
|
+
}
|
|
315
|
+
else if (operation === "deadLetters") {
|
|
316
|
+
const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
|
|
317
|
+
const out = docs.map((d) => { delete d._id; delete d.queue; return d; });
|
|
318
|
+
process.stdout.write(JSON.stringify(out));
|
|
319
|
+
}
|
|
320
|
+
else if (operation === "failed") {
|
|
321
|
+
const docs = await col
|
|
322
|
+
.find({ queue: queueName, status: "failed", attempts: { $lt: maxRetries } })
|
|
323
|
+
.toArray();
|
|
324
|
+
const out = docs.map((d) => { delete d._id; delete d.queue; return d; });
|
|
325
|
+
process.stdout.write(JSON.stringify(out));
|
|
326
|
+
}
|
|
327
|
+
else if (operation === "retryFailed") {
|
|
328
|
+
// Revive dead-lettered jobs under the (possibly raised) limit back to
|
|
329
|
+
// the main queue as pending. data = the max-retries limit.
|
|
330
|
+
const mr = data ? Number(data) : maxRetries;
|
|
331
|
+
const now = new Date().toISOString();
|
|
332
|
+
let revived = 0;
|
|
333
|
+
while (true) {
|
|
334
|
+
const doc = await col.findOneAndUpdate(
|
|
335
|
+
{ queue: queueName + ".dead_letter", attempts: { $lt: mr } },
|
|
336
|
+
{ $set: { status: "pending", availableAt: now, reservedAt: null, queue: queueName, error: null } },
|
|
337
|
+
{ returnDocument: "after" },
|
|
338
|
+
);
|
|
339
|
+
const updated = doc && doc.value ? doc.value : (doc && doc._id ? doc : null);
|
|
340
|
+
if (!updated) break;
|
|
341
|
+
revived++;
|
|
342
|
+
}
|
|
343
|
+
process.stdout.write(String(revived));
|
|
344
|
+
}
|
|
345
|
+
else if (operation === "purge") {
|
|
346
|
+
// Delete docs by status (default: all for the topic). data = JSON { status }.
|
|
347
|
+
const info = data ? JSON.parse(data) : {};
|
|
348
|
+
const filter = { queue: queueName };
|
|
349
|
+
if (info.status) filter.status = info.status;
|
|
350
|
+
const res = await col.deleteMany(filter);
|
|
351
|
+
process.stdout.write(String(res.deletedCount || 0));
|
|
352
|
+
}
|
|
256
353
|
} catch (err) {
|
|
257
354
|
process.stderr.write(err.message || String(err));
|
|
258
355
|
process.exit(1);
|
|
@@ -328,4 +425,50 @@ export class MongoBackend implements QueueBackend {
|
|
|
328
425
|
clear(queue: string): void {
|
|
329
426
|
this.execSync("clear", queue);
|
|
330
427
|
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Acknowledge a completed job — drop its reservation so the reclaim never
|
|
431
|
+
* re-delivers it. Without this a Mongo-popped job stayed reserved and was
|
|
432
|
+
* re-delivered after the visibility window (the redelivery bug).
|
|
433
|
+
*/
|
|
434
|
+
complete(queue: string, id: string): void {
|
|
435
|
+
this.execSync("complete", queue, id);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Record a failed attempt: requeue (reset availableAt, ++attempts) while
|
|
440
|
+
* retries remain, else dead-letter. Mirrors the file/lite backend.
|
|
441
|
+
*/
|
|
442
|
+
fail(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number = 0): void {
|
|
443
|
+
this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
|
|
447
|
+
retry(queue: string, id: string, delaySeconds: number = 0): void {
|
|
448
|
+
this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
|
|
452
|
+
deadLetters(queue: string, maxRetries?: number): QueueJob[] {
|
|
453
|
+
const out = this.execSync("deadLetters", queue, String(maxRetries ?? this.maxRetries));
|
|
454
|
+
try { return JSON.parse(out) as QueueJob[]; } catch { return []; }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Jobs that failed but are still eligible for retry (status=failed, attempts < max). */
|
|
458
|
+
failed(queue: string, maxRetries?: number): QueueJob[] {
|
|
459
|
+
const out = this.execSync("failed", queue, String(maxRetries ?? this.maxRetries));
|
|
460
|
+
try { return JSON.parse(out) as QueueJob[]; } catch { return []; }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Revive dead-lettered jobs under the (possibly raised) limit. Returns count revived. */
|
|
464
|
+
retryFailed(queue: string, maxRetries?: number): number {
|
|
465
|
+
const out = this.execSync("retryFailed", queue, String(maxRetries ?? this.maxRetries));
|
|
466
|
+
return parseInt(out, 10) || 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Remove jobs by status (default: every doc for the topic). Returns count removed. */
|
|
470
|
+
purge(queue: string, status?: string): number {
|
|
471
|
+
const out = this.execSync("purge", queue, JSON.stringify({ status: status ?? "" }));
|
|
472
|
+
return parseInt(out, 10) || 0;
|
|
473
|
+
}
|
|
331
474
|
}
|
|
@@ -242,6 +242,8 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
242
242
|
let buffer = Buffer.alloc(0);
|
|
243
243
|
let step = "handshake";
|
|
244
244
|
let deliveryTag = null;
|
|
245
|
+
let expectedBody = 0;
|
|
246
|
+
let receivedBody = 0;
|
|
245
247
|
|
|
246
248
|
sock.on("data", (chunk) => {
|
|
247
249
|
buffer = Buffer.concat([buffer, chunk]);
|
|
@@ -264,11 +266,29 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
264
266
|
const methodId = payload.readUInt16BE(2);
|
|
265
267
|
handleMethod(classId, methodId, payload.subarray(4), channel);
|
|
266
268
|
} else if (frameType === 2) { // HEADER frame
|
|
267
|
-
// Content header —
|
|
269
|
+
// Content header — capture the declared body size so we know when the
|
|
270
|
+
// body is complete (a body may arrive in several frames).
|
|
271
|
+
if (operation === "get") {
|
|
272
|
+
// body-size is a 64-bit field at offset 4 of the header payload;
|
|
273
|
+
// the low 32 bits are enough for our JSON payloads.
|
|
274
|
+
expectedBody = payload.readUInt32BE(8);
|
|
275
|
+
receivedBody = 0;
|
|
276
|
+
}
|
|
268
277
|
} else if (frameType === 3) { // BODY frame
|
|
269
278
|
// Content body
|
|
270
279
|
const body = payload.toString("utf-8");
|
|
271
280
|
process.stdout.write(body);
|
|
281
|
+
if (operation === "get") {
|
|
282
|
+
receivedBody += payload.length;
|
|
283
|
+
// Once the whole body has arrived, cleanly close the connection so
|
|
284
|
+
// the child exits 0 with the body on stdout. Without this the get
|
|
285
|
+
// handler fell through to the 10s watchdog (exit 1), so pop()
|
|
286
|
+
// always saw a failed child and returned null even though the
|
|
287
|
+
// message body had been written to stdout.
|
|
288
|
+
if (receivedBody >= expectedBody) {
|
|
289
|
+
closeConnection();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
272
292
|
}
|
|
273
293
|
}
|
|
274
294
|
}
|
|
@@ -310,11 +330,29 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
310
330
|
sendMethod(0, 10, 11, payload.subarray(0, offset));
|
|
311
331
|
}
|
|
312
332
|
else if (classId === 10 && methodId === 30) {
|
|
313
|
-
// Connection.Tune → send Connection.Tune-Ok + Connection.Open
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
333
|
+
// Connection.Tune → send Connection.Tune-Ok + Connection.Open.
|
|
334
|
+
// AMQP 0-9-1 requires TuneOk values to NOT exceed the server's proposal.
|
|
335
|
+
// The broker's Tune args are channel-max:short, frame-max:long,
|
|
336
|
+
// heartbeat:short. Hardcoding channel-max=0 means "no limit", which
|
|
337
|
+
// RabbitMQ treats as exceeding its proposed channel-max (e.g. 2047) and
|
|
338
|
+
// it aborts the connection right after Open — so negotiate instead.
|
|
339
|
+
const desiredFrameMax = 131072;
|
|
340
|
+
const desiredHeartbeat = 60;
|
|
341
|
+
const serverChannelMax = args.length >= 2 ? args.readUInt16BE(0) : 0;
|
|
342
|
+
const serverFrameMax = args.length >= 6 ? args.readUInt32BE(2) : 0;
|
|
343
|
+
const serverHeartbeat = args.length >= 8 ? args.readUInt16BE(6) : 0;
|
|
344
|
+
|
|
345
|
+
// channel-max: echo the server's value (its cap); 0 = unlimited.
|
|
346
|
+
const channelMax = serverChannelMax;
|
|
347
|
+
// frame-max: min(desired, server), treating 0 as unlimited on either side.
|
|
348
|
+
const frameMax = serverFrameMax === 0 ? desiredFrameMax : Math.min(desiredFrameMax, serverFrameMax);
|
|
349
|
+
// heartbeat: our choice, clamped to the server's if it proposed a non-zero one.
|
|
350
|
+
const heartbeat = serverHeartbeat === 0 ? desiredHeartbeat : Math.min(desiredHeartbeat, serverHeartbeat);
|
|
351
|
+
|
|
352
|
+
const tuneOk = Buffer.alloc(8);
|
|
353
|
+
tuneOk.writeUInt16BE(channelMax, 0); // channel-max (negotiated)
|
|
354
|
+
tuneOk.writeUInt32BE(frameMax, 2); // frame-max (negotiated)
|
|
355
|
+
tuneOk.writeUInt16BE(heartbeat, 6); // heartbeat (negotiated)
|
|
318
356
|
sendMethod(0, 10, 31, tuneOk);
|
|
319
357
|
|
|
320
358
|
// Connection.Open
|
|
@@ -334,8 +372,13 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
334
372
|
}
|
|
335
373
|
else if (classId === 20 && methodId === 11) {
|
|
336
374
|
// Channel.Open-Ok → declare queue
|
|
375
|
+
// Payload: reserved(2) + queue short-string(1 + len) + flags(1) +
|
|
376
|
+
// arguments empty-table(4) = 8 + len bytes. (Was 7 + len, which
|
|
377
|
+
// overflowed the 4-byte empty-table write at offset 4 + len and threw
|
|
378
|
+
// ERR_OUT_OF_RANGE — swallowed by the catch, so publish/get/size all
|
|
379
|
+
// silently failed even once the handshake succeeded.)
|
|
337
380
|
const qBuf = Buffer.from(queueName, "utf-8");
|
|
338
|
-
const declPayload = Buffer.alloc(
|
|
381
|
+
const declPayload = Buffer.alloc(8 + qBuf.length);
|
|
339
382
|
declPayload.writeUInt16BE(0, 0); // reserved
|
|
340
383
|
declPayload.writeUInt8(qBuf.length, 2);
|
|
341
384
|
qBuf.copy(declPayload, 3);
|
|
@@ -357,25 +400,21 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
357
400
|
|
|
358
401
|
sendMethod(1, 60, 40, pubPayload);
|
|
359
402
|
|
|
360
|
-
// Content header
|
|
403
|
+
// Content header frame. AMQP 0-9-1 content header body is:
|
|
404
|
+
// class-id(2) + weight(2) + body-size(8) + property-flags(2), then
|
|
405
|
+
// one entry per set property flag. With property-flags = 0 (no
|
|
406
|
+
// properties) the header is EXACTLY 14 bytes. The previous code
|
|
407
|
+
// allocated 14 + 1 + ct.length + 1 = 32 bytes but only wrote the
|
|
408
|
+
// first 14, leaving 18 trailing zero bytes that the broker parsed as
|
|
409
|
+
// bogus property data — it replied INTERNAL_ERROR (541) and closed
|
|
410
|
+
// the connection, so no message was ever stored. Allocate exactly 14.
|
|
361
411
|
const bodyBuf = Buffer.from(data, "utf-8");
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// body
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
header.writeUInt16BE(0x6000, 12); // property flags: delivery-mode + content-type
|
|
369
|
-
// content-type
|
|
370
|
-
const ct = Buffer.from("application/json");
|
|
371
|
-
header.writeUInt8(ct.length, 14);
|
|
372
|
-
|
|
373
|
-
const fullHeader = Buffer.alloc(14 + 1 + ct.length + 1);
|
|
374
|
-
fullHeader.writeUInt16BE(60, 0);
|
|
375
|
-
fullHeader.writeUInt16BE(0, 2);
|
|
376
|
-
fullHeader.writeUInt32BE(0, 4);
|
|
377
|
-
fullHeader.writeUInt32BE(bodyBuf.length, 8);
|
|
378
|
-
fullHeader.writeUInt16BE(0x0000, 12); // no properties for simplicity
|
|
412
|
+
const fullHeader = Buffer.alloc(14);
|
|
413
|
+
fullHeader.writeUInt16BE(60, 0); // class = basic
|
|
414
|
+
fullHeader.writeUInt16BE(0, 2); // weight
|
|
415
|
+
fullHeader.writeUInt32BE(0, 4); // body-size high 32 bits
|
|
416
|
+
fullHeader.writeUInt32BE(bodyBuf.length, 8); // body-size low 32 bits
|
|
417
|
+
fullHeader.writeUInt16BE(0x0000, 12); // property flags: none set
|
|
379
418
|
|
|
380
419
|
// Send header frame
|
|
381
420
|
const hFrame = Buffer.alloc(7 + fullHeader.length + 1);
|
|
@@ -440,9 +479,21 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
440
479
|
closeConnection();
|
|
441
480
|
}
|
|
442
481
|
else if (classId === 10 && methodId === 50) {
|
|
443
|
-
// Connection.Close
|
|
482
|
+
// Connection.Close (server-initiated, e.g. a channel/protocol error)
|
|
483
|
+
// → send Connection.Close-Ok and exit non-zero so the caller sees the
|
|
484
|
+
// failure rather than a half-completed operation.
|
|
444
485
|
sendMethod(0, 10, 51, Buffer.alloc(0));
|
|
445
486
|
sock.destroy();
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|
|
489
|
+
else if (classId === 10 && methodId === 51) {
|
|
490
|
+
// Connection.Close-Ok — the broker has acknowledged our Close, which
|
|
491
|
+
// means it has fully processed everything we sent (incl. a Basic.Publish
|
|
492
|
+
// and its content frames). Only now is it safe to drop the socket. The
|
|
493
|
+
// previous code destroyed the socket on a blind 200ms timer right after
|
|
494
|
+
// writing the body frame, racing the broker and dropping the message.
|
|
495
|
+
sock.destroy();
|
|
496
|
+
process.exit(0);
|
|
446
497
|
}
|
|
447
498
|
}
|
|
448
499
|
|
|
@@ -482,14 +533,26 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
482
533
|
}
|
|
483
534
|
|
|
484
535
|
function closeConnection() {
|
|
485
|
-
// Send Connection.Close
|
|
486
|
-
|
|
536
|
+
// Send Connection.Close — payload is reply-code(2) + reply-text
|
|
537
|
+
// short-string(1 + 0) + class-id(2) + method-id(2) = 7 bytes. (Was
|
|
538
|
+
// Buffer.alloc(6), so the method-id write at offset 5 overflowed and
|
|
539
|
+
// threw ERR_OUT_OF_RANGE right after "__PUBLISHED__" was written —
|
|
540
|
+
// swallowed by the parent catch, so push() reported "publish failed"
|
|
541
|
+
// even though the message had already reached the broker.)
|
|
542
|
+
const closePayload = Buffer.alloc(7);
|
|
487
543
|
closePayload.writeUInt16BE(200, 0); // reply code
|
|
488
|
-
closePayload.writeUInt8(0, 2); // reply text (empty)
|
|
544
|
+
closePayload.writeUInt8(0, 2); // reply text (empty short-string)
|
|
489
545
|
closePayload.writeUInt16BE(0, 3); // class
|
|
490
546
|
closePayload.writeUInt16BE(0, 5); // method
|
|
491
547
|
sendMethod(0, 10, 50, closePayload);
|
|
492
|
-
|
|
548
|
+
// Do NOT destroy the socket here — wait for the broker's
|
|
549
|
+
// Connection.Close-Ok (10/51), which confirms it has fully processed
|
|
550
|
+
// everything we sent (the Basic.Publish + content frames in particular).
|
|
551
|
+
// The 10/51 handler exits 0. This safety net only fires if the broker
|
|
552
|
+
// never replies, and still exits 0 because stdout already carries the
|
|
553
|
+
// operation's result (the message was flushed to the socket before
|
|
554
|
+
// Close was sent).
|
|
555
|
+
setTimeout(() => { sock.destroy(); process.exit(0); }, 3000).unref();
|
|
493
556
|
}
|
|
494
557
|
|
|
495
558
|
sock.on("error", (err) => {
|
|
@@ -497,7 +560,10 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
497
560
|
process.exit(1);
|
|
498
561
|
});
|
|
499
562
|
|
|
500
|
-
|
|
563
|
+
// Watchdog: only fires if an operation never completes (handshake hangs).
|
|
564
|
+
// unref() so a completed operation's pending timer can't keep the event
|
|
565
|
+
// loop alive and delay/override the clean exit above.
|
|
566
|
+
setTimeout(() => { sock.destroy(); process.exit(1); }, 10000).unref();
|
|
501
567
|
`;
|
|
502
568
|
|
|
503
569
|
try {
|
|
@@ -937,8 +937,13 @@ ${reset}
|
|
|
937
937
|
console.log(` \x1b[35m${definition.tableName}\x1b[0m (${Object.keys(definition.fields).length} fields)`);
|
|
938
938
|
}
|
|
939
939
|
|
|
940
|
-
// Generate auto-CRUD routes for
|
|
941
|
-
|
|
940
|
+
// Generate auto-CRUD routes ONLY for models that explicitly opted in via
|
|
941
|
+
// `static autoCrud = true` (the documented opt-in gate; default false). The
|
|
942
|
+
// server previously generated the 5 CRUD endpoints for every discovered model
|
|
943
|
+
// regardless of the flag, contradicting the documented contract. (Python's
|
|
944
|
+
// AutoCrud is opt-in too — via an explicit AutoCrud.register/discover call.)
|
|
945
|
+
const crudModels = orm.crudEligibleModels(models);
|
|
946
|
+
const crudRoutes = crudModels.length > 0 ? orm.generateCrudRoutes(crudModels) : [];
|
|
942
947
|
for (const route of crudRoutes) {
|
|
943
948
|
// Only add if no file-based route already handles this
|
|
944
949
|
const existing = router.match(route.method, route.pattern.replace(/\{(\w+)\}/g, "test").replace(/\[(\w+)\]/g, "test"));
|
|
@@ -947,9 +952,11 @@ ${reset}
|
|
|
947
952
|
}
|
|
948
953
|
}
|
|
949
954
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
955
|
+
if (crudRoutes.length > 0) {
|
|
956
|
+
console.log(`\n Auto-CRUD endpoints:`);
|
|
957
|
+
for (const route of crudRoutes) {
|
|
958
|
+
console.log(` \x1b[33m${route.method.padEnd(7)}\x1b[0m ${route.pattern}`);
|
|
959
|
+
}
|
|
953
960
|
}
|
|
954
961
|
}
|
|
955
962
|
} catch (err) {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
import { randomBytes } from "node:crypto";
|
|
28
28
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
|
|
29
29
|
import { join } from "node:path";
|
|
30
|
-
import { execFileSync } from "node:child_process";
|
|
31
30
|
import { Log } from "./logger.js";
|
|
32
31
|
import { isTruthy } from "./dotenv.js";
|
|
32
|
+
import { respCommandSync } from "./sessionHandlers/respClient.js";
|
|
33
33
|
import { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
|
|
34
34
|
import { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
|
|
35
35
|
import { MongoSessionHandler } from "./sessionHandlers/mongoHandler.js";
|
|
@@ -196,103 +196,19 @@ export class RedisSessionHandler implements SessionHandler {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
/**
|
|
199
|
-
* Execute a Redis command synchronously
|
|
199
|
+
* Execute a Redis command synchronously against the live server.
|
|
200
200
|
*
|
|
201
|
-
*
|
|
202
|
-
* transport/connection FAILURE (server unreachable,
|
|
203
|
-
* THROWS so the Session boundary can distinguish "not found"
|
|
204
|
-
* "backend failed" (log-loud + degrade). Backend-failure
|
|
201
|
+
* Delegates to the shared {@link respCommandSync} transport: a genuine key miss
|
|
202
|
+
* yields `""`, and a transport/connection FAILURE (server unreachable, rejected
|
|
203
|
+
* AUTH, timeout) THROWS so the Session boundary can distinguish "not found"
|
|
204
|
+
* (silent) from "backend failed" (log-loud + degrade). Backend-failure parity.
|
|
205
205
|
*/
|
|
206
206
|
private execSync(args: string[]): string {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const db = ${this.db};
|
|
213
|
-
const args = ${JSON.stringify(args)};
|
|
214
|
-
|
|
215
|
-
function buildCommand(a) {
|
|
216
|
-
let cmd = "*" + a.length + "\\r\\n";
|
|
217
|
-
for (const s of a) cmd += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
|
|
218
|
-
return cmd;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function parseResp(buf) {
|
|
222
|
-
const str = buf.toString("utf-8");
|
|
223
|
-
if (str.startsWith("+")) return str.slice(1).split("\\r\\n")[0];
|
|
224
|
-
if (str.startsWith("-")) return "ERR:" + str.slice(1).split("\\r\\n")[0];
|
|
225
|
-
if (str.startsWith(":")) return str.slice(1).split("\\r\\n")[0];
|
|
226
|
-
if (str.startsWith("$-1")) return null;
|
|
227
|
-
if (str.startsWith("$")) {
|
|
228
|
-
const nl = str.indexOf("\\r\\n");
|
|
229
|
-
const len = parseInt(str.slice(1, nl), 10);
|
|
230
|
-
const start = nl + 2;
|
|
231
|
-
return str.slice(start, start + len);
|
|
232
|
-
}
|
|
233
|
-
return str;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const sock = net.createConnection({ host, port }, () => {
|
|
237
|
-
let commands = "";
|
|
238
|
-
if (password) commands += buildCommand(["AUTH", password]);
|
|
239
|
-
if (db !== 0) commands += buildCommand(["SELECT", String(db)]);
|
|
240
|
-
commands += buildCommand(args);
|
|
241
|
-
sock.write(commands);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
let buffer = Buffer.alloc(0);
|
|
245
|
-
sock.on("data", (chunk) => {
|
|
246
|
-
buffer = Buffer.concat([buffer, chunk]);
|
|
247
|
-
});
|
|
248
|
-
sock.on("end", () => {
|
|
249
|
-
// Parse last response (skip AUTH/SELECT responses)
|
|
250
|
-
const lines = buffer.toString("utf-8").split("\\r\\n");
|
|
251
|
-
let responses = [];
|
|
252
|
-
let i = 0;
|
|
253
|
-
while (i < lines.length) {
|
|
254
|
-
const line = lines[i];
|
|
255
|
-
if (!line) { i++; continue; }
|
|
256
|
-
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(":")) {
|
|
257
|
-
responses.push(line);
|
|
258
|
-
i++;
|
|
259
|
-
} else if (line.startsWith("$")) {
|
|
260
|
-
const len = parseInt(line.slice(1), 10);
|
|
261
|
-
if (len === -1) { responses.push(null); i++; }
|
|
262
|
-
else { responses.push(lines[i+1] || ""); i += 2; }
|
|
263
|
-
} else { i++; }
|
|
264
|
-
}
|
|
265
|
-
// The last response is our actual command result
|
|
266
|
-
const result = responses[responses.length - 1];
|
|
267
|
-
if (result === null) process.stdout.write("__NULL__");
|
|
268
|
-
else if (typeof result === "string" && result.startsWith("-")) process.stdout.write("__ERR__" + result);
|
|
269
|
-
else process.stdout.write(String(result ?? "__NULL__"));
|
|
270
|
-
});
|
|
271
|
-
sock.on("error", (err) => {
|
|
272
|
-
process.stderr.write(err.message);
|
|
273
|
-
process.exit(1);
|
|
274
|
-
});
|
|
275
|
-
setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
|
|
276
|
-
`;
|
|
277
|
-
|
|
278
|
-
let result: string;
|
|
279
|
-
try {
|
|
280
|
-
result = execFileSync(process.execPath, ["-e", script], {
|
|
281
|
-
encoding: "utf-8",
|
|
282
|
-
timeout: 5000,
|
|
283
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
284
|
-
});
|
|
285
|
-
} catch (err) {
|
|
286
|
-
// Non-zero exit = the child hit a socket error / AUTH failure / timeout.
|
|
287
|
-
// That is a transport FAILURE, not a key miss — surface it so the
|
|
288
|
-
// Session boundary logs + degrades (or re-throws under strict mode).
|
|
289
|
-
throw new Error(`Redis command failed: ${(err as Error).message}`);
|
|
290
|
-
}
|
|
291
|
-
if (result === "__NULL__") return ""; // genuine key miss
|
|
292
|
-
if (result.startsWith("__ERR__")) {
|
|
293
|
-
throw new Error(`Redis error: ${result.slice("__ERR__".length)}`);
|
|
294
|
-
}
|
|
295
|
-
return result;
|
|
207
|
+
return respCommandSync(
|
|
208
|
+
{ host: this.host, port: this.port, password: this.password, db: this.db },
|
|
209
|
+
args,
|
|
210
|
+
"Redis",
|
|
211
|
+
);
|
|
296
212
|
}
|
|
297
213
|
|
|
298
214
|
private key(sessionId: string): string {
|