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.
@@ -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 — skip for basic.get
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
- const tuneOk = Buffer.alloc(12);
315
- tuneOk.writeUInt16BE(0, 0); // channel-max
316
- tuneOk.writeUInt32BE(131072, 2); // frame-max
317
- tuneOk.writeUInt16BE(60, 6); // heartbeat
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(7 + qBuf.length);
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 header = Buffer.alloc(18);
363
- header.writeUInt16BE(60, 0); // class = basic
364
- header.writeUInt16BE(0, 2); // weight
365
- // body size (64-bit, we only use lower 32)
366
- header.writeUInt32BE(0, 4);
367
- header.writeUInt32BE(bodyBuf.length, 8);
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 send Connection.Close-Ok
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
- const closePayload = Buffer.alloc(6);
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
- setTimeout(() => sock.destroy(), 500);
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
- setTimeout(() => { sock.destroy(); process.exit(1); }, 10000);
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 all discovered models
941
- const crudRoutes = orm.generateCrudRoutes(models);
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
- console.log(`\n Auto-CRUD endpoints:`);
951
- for (const route of crudRoutes) {
952
- console.log(` \x1b[33m${route.method.padEnd(7)}\x1b[0m ${route.pattern}`);
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 via a short-lived TCP connection.
199
+ * Execute a Redis command synchronously against the live server.
200
200
  *
201
- * Returns the command result string. A genuine key miss yields `""`. A
202
- * transport/connection FAILURE (server unreachable, AUTH error, timeout)
203
- * THROWS so the Session boundary can distinguish "not found" (silent) from
204
- * "backend failed" (log-loud + degrade). Backend-failure policy parity.
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
- const script = `
208
- const net = require("node:net");
209
- const host = ${JSON.stringify(this.host)};
210
- const port = ${this.port};
211
- const password = ${JSON.stringify(this.password)};
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 {