tina4-nodejs 3.13.92 → 3.13.94

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 (134) hide show
  1. package/CLAUDE.md +16 -3
  2. package/README.md +1 -1
  3. package/package.json +12 -9
  4. package/packages/cli/dist/bin.js +1260 -969
  5. package/packages/core/dist/index.js +1260 -969
  6. package/packages/core/src/devMailbox.ts +20 -44
  7. package/packages/core/src/index.ts +2 -2
  8. package/packages/core/src/messenger.ts +72 -0
  9. package/packages/core/src/queueBackends/kafkaBackend.ts +108 -12
  10. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  11. package/packages/core/src/sessionHandlers/mongoClient.ts +9 -3
  12. package/packages/core/src/sessionHandlers/redisHandler.ts +18 -5
  13. package/packages/core/src/sessionHandlers/respClient.ts +5 -1
  14. package/packages/frond/dist/index.js +74 -31
  15. package/packages/frond/src/engine.ts +99 -33
  16. package/packages/orm/dist/index.js +3055 -2764
  17. package/packages/orm/src/adapters/sqlite.ts +4 -1
  18. package/packages/orm/src/database.ts +108 -8
  19. package/types/cli/src/bin.d.ts +92 -0
  20. package/types/cli/src/commands/build.d.ts +2 -0
  21. package/types/cli/src/commands/generate.d.ts +47 -0
  22. package/types/cli/src/commands/init.d.ts +1 -0
  23. package/types/cli/src/commands/metrics.d.ts +6 -0
  24. package/types/cli/src/commands/migrate.d.ts +1 -0
  25. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  26. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  27. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  28. package/types/cli/src/commands/queue.d.ts +20 -0
  29. package/types/cli/src/commands/routes.d.ts +1 -0
  30. package/types/cli/src/commands/seed.d.ts +1 -0
  31. package/types/cli/src/commands/serve.d.ts +6 -0
  32. package/types/cli/src/commands/test.d.ts +1 -0
  33. package/types/core/src/ai.d.ts +64 -0
  34. package/types/core/src/api.d.ts +262 -0
  35. package/types/core/src/auth.d.ts +154 -0
  36. package/types/core/src/authGate.d.ts +20 -0
  37. package/types/core/src/background.d.ts +34 -0
  38. package/types/core/src/cache.d.ts +160 -0
  39. package/types/core/src/constants.d.ts +38 -0
  40. package/types/core/src/container.d.ts +44 -0
  41. package/types/core/src/context/chunker.d.ts +31 -0
  42. package/types/core/src/context/index.d.ts +93 -0
  43. package/types/core/src/devAdmin.d.ts +179 -0
  44. package/types/core/src/devMailbox.d.ts +54 -0
  45. package/types/core/src/docs.d.ts +141 -0
  46. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  47. package/types/core/src/dotenv.d.ts +65 -0
  48. package/types/core/src/env.d.ts +28 -0
  49. package/types/core/src/errorOverlay.d.ts +36 -0
  50. package/types/core/src/events.d.ts +75 -0
  51. package/types/core/src/fakeData.d.ts +55 -0
  52. package/types/core/src/feedback.d.ts +90 -0
  53. package/types/core/src/graphql.d.ts +207 -0
  54. package/types/core/src/health.d.ts +22 -0
  55. package/types/core/src/htmlElement.d.ts +75 -0
  56. package/types/core/src/i18n.d.ts +37 -0
  57. package/types/core/src/index.d.ts +93 -0
  58. package/types/core/src/job.d.ts +39 -0
  59. package/types/core/src/logger.d.ts +123 -0
  60. package/types/core/src/mcp.d.ts +248 -0
  61. package/types/core/src/messenger.d.ts +191 -0
  62. package/types/core/src/metrics.d.ts +77 -0
  63. package/types/core/src/middleware.d.ts +207 -0
  64. package/types/core/src/mqtt.d.ts +257 -0
  65. package/types/core/src/mqttMessage.d.ts +67 -0
  66. package/types/core/src/plan.d.ts +96 -0
  67. package/types/core/src/projectIndex.d.ts +56 -0
  68. package/types/core/src/queue.d.ts +219 -0
  69. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  70. package/types/core/src/queueBackends/liteBackend.d.ts +119 -0
  71. package/types/core/src/queueBackends/mongoBackend.d.ts +97 -0
  72. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  73. package/types/core/src/rateLimiter.d.ts +49 -0
  74. package/types/core/src/request.d.ts +25 -0
  75. package/types/core/src/response.d.ts +28 -0
  76. package/types/core/src/routeDiscovery.d.ts +12 -0
  77. package/types/core/src/router.d.ts +355 -0
  78. package/types/core/src/scss.d.ts +19 -0
  79. package/types/core/src/server.d.ts +131 -0
  80. package/types/core/src/service.d.ts +115 -0
  81. package/types/core/src/session.d.ts +256 -0
  82. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  83. package/types/core/src/sessionHandlers/databaseHandler.d.ts +42 -0
  84. package/types/core/src/sessionHandlers/mongoClient.d.ts +24 -0
  85. package/types/core/src/sessionHandlers/mongoHandler.d.ts +61 -0
  86. package/types/core/src/sessionHandlers/redisHandler.d.ts +60 -0
  87. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  88. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  89. package/types/core/src/static.d.ts +2 -0
  90. package/types/core/src/test.d.ts +94 -0
  91. package/types/core/src/testClient.d.ts +36 -0
  92. package/types/core/src/testing.d.ts +58 -0
  93. package/types/core/src/types.d.ts +219 -0
  94. package/types/core/src/validator.d.ts +52 -0
  95. package/types/core/src/websocket.d.ts +376 -0
  96. package/types/core/src/websocketBackplane.d.ts +166 -0
  97. package/types/core/src/websocketConnection.d.ts +54 -0
  98. package/types/core/src/wsdl.d.ts +101 -0
  99. package/types/frond/src/engine.d.ts +263 -0
  100. package/types/frond/src/index.d.ts +2 -0
  101. package/types/orm/src/adapters/firebird.d.ts +138 -0
  102. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  103. package/types/orm/src/adapters/mssql.d.ts +70 -0
  104. package/types/orm/src/adapters/mysql.d.ts +66 -0
  105. package/types/orm/src/adapters/odbc.d.ts +97 -0
  106. package/types/orm/src/adapters/postgres.d.ts +85 -0
  107. package/types/orm/src/adapters/sqlite.d.ts +56 -0
  108. package/types/orm/src/autoCrud.d.ts +73 -0
  109. package/types/orm/src/baseModel.d.ts +391 -0
  110. package/types/orm/src/cachedDatabase.d.ts +177 -0
  111. package/types/orm/src/database.d.ts +609 -0
  112. package/types/orm/src/databaseResult.d.ts +85 -0
  113. package/types/orm/src/docstore.d.ts +182 -0
  114. package/types/orm/src/fakeData.d.ts +22 -0
  115. package/types/orm/src/index.d.ts +40 -0
  116. package/types/orm/src/migration.d.ts +275 -0
  117. package/types/orm/src/model.d.ts +7 -0
  118. package/types/orm/src/query.d.ts +14 -0
  119. package/types/orm/src/queryBuilder.d.ts +173 -0
  120. package/types/orm/src/realtime/index.d.ts +7 -0
  121. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  122. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  123. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  124. package/types/orm/src/realtime/models/message.d.ts +36 -0
  125. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  126. package/types/orm/src/realtime/realtime.d.ts +24 -0
  127. package/types/orm/src/realtime/storage.d.ts +61 -0
  128. package/types/orm/src/seeder.d.ts +118 -0
  129. package/types/orm/src/sqlTranslator.d.ts +134 -0
  130. package/types/orm/src/types.d.ts +138 -0
  131. package/types/orm/src/validation.d.ts +6 -0
  132. package/types/swagger/src/generator.d.ts +46 -0
  133. package/types/swagger/src/index.d.ts +2 -0
  134. package/types/swagger/src/ui.d.ts +11 -0
@@ -15,8 +15,6 @@ import { join } from "node:path";
15
15
  import { randomUUID } from "node:crypto";
16
16
 
17
17
  import type { SendResult, EmailMessage } from "./messenger.js";
18
- import { Messenger } from "./messenger.js";
19
- import { isTruthy } from "./dotenv.js";
20
18
 
21
19
  // ── DevMailbox ───────────────────────────────────────────────
22
20
 
@@ -38,20 +36,35 @@ export class DevMailbox {
38
36
 
39
37
  /**
40
38
  * Capture an email to the dev mailbox instead of sending it.
39
+ *
40
+ * The parameter order MATCHES Messenger.send() on purpose. It did not before:
41
+ * send()'s 5th positional was `text` and capture()'s was `cc`, so the same call
42
+ * meant different things depending on which door it came through -- that mismatch
43
+ * IS nodejs#42.
44
+ *
45
+ * BREAKING: `text` is now the 5th positional. A caller passing cc positionally
46
+ * must move it. Aligning the two signatures is the fix; leaving them apart would
47
+ * preserve the bug.
41
48
  */
42
49
  capture(
43
50
  to: string | string[],
44
51
  subject: string,
45
52
  body: string,
46
53
  html: boolean = false,
47
- cc: string[] = [],
48
- bcc: string[] = [],
54
+ text?: string,
55
+ cc: string | string[] = [],
56
+ bcc: string | string[] = [],
49
57
  replyTo?: string,
50
58
  attachments: string[] = [],
51
59
  from?: string,
52
60
  ): SendResult {
53
61
  const id = randomUUID();
54
62
  const toList = Array.isArray(to) ? to : [to];
63
+ // Normalised HERE, at the boundary, so a message is well formed however it
64
+ // arrived. A dev mailbox that stores a malformed message and reports success
65
+ // defeats its own purpose -- it exists to show you what you WOULD have sent.
66
+ const ccList = Array.isArray(cc) ? cc : (cc ? [cc] : []);
67
+ const bccList = Array.isArray(bcc) ? bcc : (bcc ? [bcc] : []);
55
68
  const now = new Date().toISOString();
56
69
 
57
70
  const message: EmailMessage = {
@@ -59,11 +72,12 @@ export class DevMailbox {
59
72
  type: "outbox",
60
73
  from: from ?? process.env.TINA4_MAIL_FROM ?? "dev@localhost",
61
74
  to: toList,
62
- cc,
63
- bcc,
75
+ cc: ccList,
76
+ bcc: bccList,
64
77
  reply_to: replyTo,
65
78
  subject,
66
79
  body,
80
+ text,
67
81
  html,
68
82
  attachments,
69
83
  date: now,
@@ -280,41 +294,3 @@ export class DevMailbox {
280
294
  }
281
295
 
282
296
  // ── Factory ──────────────────────────────────────────────────
283
-
284
- /**
285
- * Create a Messenger or DevMailbox based on the environment.
286
- *
287
- * Returns DevMailbox when:
288
- * - TINA4_DEBUG is "true", OR
289
- * - No TINA4_MAIL_HOST is configured
290
- *
291
- * Returns a real Messenger otherwise (SMTP configured + not debug mode).
292
- *
293
- * This follows the factory pattern from PHP's MessengerFactory.
294
- */
295
- export function createMessenger(): Messenger | DevMailbox {
296
- const debug = process.env.TINA4_DEBUG;
297
- const smtpHost = process.env.TINA4_MAIL_HOST;
298
-
299
- // Production = NOT debug mode AND NODE_ENV is "production".
300
- // Derived here (was previously referenced undefined → ReferenceError).
301
- const isProd = !isTruthy(debug) && process.env.NODE_ENV === "production";
302
-
303
- // Force dev mode when TINA4_DEBUG is truthy
304
- if (isTruthy(debug)) {
305
- return new DevMailbox();
306
- }
307
-
308
- // No SMTP configured — must use dev mailbox
309
- if (!smtpHost) {
310
- return new DevMailbox();
311
- }
312
-
313
- // Non-production environment — use dev mailbox
314
- if (!isProd) {
315
- return new DevMailbox();
316
- }
317
-
318
- // Production with SMTP configured — use real Messenger
319
- return new Messenger();
320
- }
@@ -94,9 +94,9 @@ export {
94
94
  handleFeedbackWidgetJs,
95
95
  registerFeedbackRoutes,
96
96
  } from "./feedback.js";
97
- export { Messenger, MessengerConnectionError } from "./messenger.js";
97
+ export { Messenger, MessengerConnectionError, createMessenger } from "./messenger.js";
98
98
  export type { SendResult, EmailMessage } from "./messenger.js";
99
- export { DevMailbox, createMessenger } from "./devMailbox.js";
99
+ export { DevMailbox } from "./devMailbox.js";
100
100
  export { WSDLService, WSDLOperation } from "./wsdl.js";
101
101
  export type { WSDLOperationMeta } from "./wsdl.js";
102
102
  export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htmlElement.js";
@@ -29,6 +29,7 @@ import { readFileSync } from "node:fs";
29
29
  import { basename } from "node:path";
30
30
  import { randomUUID } from "node:crypto";
31
31
  import { isTruthy } from "./dotenv.js";
32
+ import { DevMailbox } from "./devMailbox.js";
32
33
  import { Log } from "./logger.js";
33
34
 
34
35
  /**
@@ -77,6 +78,10 @@ export interface EmailMessage {
77
78
  reply_to?: string;
78
79
  subject: string;
79
80
  body: string;
81
+ /** Plain-text alternative. Carried on the dev path too, so the captured message
82
+ * is the message: a mailbox that shows you something other than what you wrote
83
+ * is worse than no mailbox. */
84
+ text?: string;
80
85
  html: boolean;
81
86
  attachments: string[];
82
87
  date: string;
@@ -328,6 +333,10 @@ export class Messenger {
328
333
  private fromName: string;
329
334
  private encryption: string;
330
335
  private useTls: boolean;
336
+ /** Whether an SMTP host was actually configured (see the constructor). */
337
+ private smtpConfigured: boolean = false;
338
+ /** The local mailbox, present only when this messenger captures. */
339
+ public devMailbox: DevMailbox | null = null;
331
340
  private imapHost: string;
332
341
  private imapPort: number;
333
342
  private imapUser: string;
@@ -337,6 +346,11 @@ export class Messenger {
337
346
  constructor(options?: MessengerOptions) {
338
347
  // Priority: constructor > TINA4_MAIL_* > sensible default.
339
348
  // Legacy SMTP_*/IMAP_* env vars were removed in v3.12 — boot guard rejects them.
349
+ // Whether a host was actually CONFIGURED, which is not the same as this.host
350
+ // being set: it falls back to "localhost", so it is never empty and cannot
351
+ // answer "can this messenger send?". The capture gate needs that answer, so
352
+ // record it here while the real inputs are still in scope.
353
+ this.smtpConfigured = Boolean(options?.host ?? process.env.TINA4_MAIL_HOST);
340
354
  this.host = options?.host
341
355
  ?? process.env.TINA4_MAIL_HOST
342
356
  ?? "localhost";
@@ -399,6 +413,31 @@ export class Messenger {
399
413
  /**
400
414
  * Send an email via SMTP.
401
415
  */
416
+ /**
417
+ * Should send() capture locally instead of talking to SMTP?
418
+ *
419
+ * Availability decides, not verbosity. With no SMTP host configured sending is
420
+ * impossible, so simulate it into a folder rather than failing -- that is what
421
+ * makes a laptop with no mail server usable. TINA4_MAIL_CAPTURE forces capture
422
+ * even when a host IS configured.
423
+ *
424
+ * TINA4_DEBUG deliberately does NOT gate this, and neither does NODE_ENV. Debug
425
+ * must still be able to send, and the old `NODE_ENV !== "production"` clause
426
+ * silently swallowed every staging email.
427
+ */
428
+ private shouldCapture(): boolean {
429
+ if (isTruthy(process.env.TINA4_MAIL_CAPTURE)) return true;
430
+ return !this.smtpConfigured;
431
+ }
432
+
433
+ /** The local mailbox, created on first capture and reused after. */
434
+ private getDevMailbox(): DevMailbox {
435
+ if (this.devMailbox === null) {
436
+ this.devMailbox = new DevMailbox();
437
+ }
438
+ return this.devMailbox;
439
+ }
440
+
402
441
  async send(
403
442
  to: string | string[],
404
443
  subject: string,
@@ -416,6 +455,17 @@ export class Messenger {
416
455
  const ccList = Array.isArray(options.cc) ? options.cc : (options.cc ? [options.cc] : []);
417
456
  const bccList = Array.isArray(options.bcc) ? options.bcc : (options.bcc ? [options.bcc] : []);
418
457
  const allRecipients = [...toList, ...ccList, ...bccList];
458
+
459
+ // Dev capture is a BRANCH here, not a different object returned by the factory.
460
+ // createMessenger() used to hand back a DevMailbox, which has capture() and no
461
+ // send(), so the documented call threw TypeError (nodejs#41).
462
+ if (this.shouldCapture()) {
463
+ return this.getDevMailbox().capture(
464
+ to, subject, body, html, text, ccList, bccList, replyTo,
465
+ attachments, this.fromAddress || undefined,
466
+ );
467
+ }
468
+
419
469
  const messageId = `${randomUUID()}@${this.host}`;
420
470
 
421
471
  if (allRecipients.length === 0) {
@@ -1069,3 +1119,25 @@ function parseFullMessage(uid: string, response: string): ImapFullMessage {
1069
1119
  headers,
1070
1120
  };
1071
1121
  }
1122
+
1123
+ /**
1124
+ * Create a Messenger configured for the current environment.
1125
+ *
1126
+ * Returns ONE concrete type, always. It used to return `Messenger | DevMailbox`,
1127
+ * and those two shared NO sending method -- DevMailbox has capture(), Messenger has
1128
+ * send() -- so the documented call threw TypeError whenever the dev branch was
1129
+ * taken. That is nodejs#41. Capture is now a branch inside Messenger.send(), so the
1130
+ * object you get back has one send() with one signature either way.
1131
+ *
1132
+ * The gate is availability, not verbosity:
1133
+ * - no TINA4_MAIL_HOST -> capture (sending is impossible, so simulate it)
1134
+ * - TINA4_MAIL_CAPTURE truthy -> capture even with SMTP configured
1135
+ * - otherwise -> send, EVEN WITH TINA4_DEBUG ON
1136
+ *
1137
+ * TINA4_DEBUG no longer forces capture: debug must still be able to send real mail.
1138
+ * The `NODE_ENV !== "production"` clause is also gone -- it captured even with SMTP
1139
+ * configured and debug off, which silently ate every staging email.
1140
+ */
1141
+ export function createMessenger(): Messenger {
1142
+ return new Messenger();
1143
+ }
@@ -497,12 +497,15 @@ export class KafkaBackend implements QueueBackend {
497
497
  if (errCode === 0) {
498
498
  finish("__PUBLISHED__", 0);
499
499
  } else {
500
+ // Report the CODE, not just "it failed" — the caller decides
501
+ // whether it is retriable (3/5, the async topic-creation race)
502
+ // or fatal (e.g. 29 TOPIC_AUTHORIZATION_FAILED).
500
503
  process.stderr.write("Produce error code " + errCode);
501
- finish("__ERROR__" + errCode, 0);
504
+ finish("__PRODUCEERROR__" + errCode, 0);
502
505
  }
503
506
  } catch (e) {
504
507
  process.stderr.write("produce parse: " + e.message);
505
- finish("__ERROR__", 0);
508
+ finish("__PARSEERROR__produce: " + e.message, 0);
506
509
  }
507
510
  return;
508
511
  } else if (operation === "get") {
@@ -516,6 +519,7 @@ export class KafkaBackend implements QueueBackend {
516
519
  pos += 4; // throttleTimeMs (v1+)
517
520
  const topicCount = buffer.readInt32BE(pos); pos += 4;
518
521
  let out = "__EMPTY__";
522
+ let fatalCode = 0;
519
523
  for (let t = 0; t < topicCount; t++) {
520
524
  const tl = buffer.readInt16BE(pos); pos += 2 + tl;
521
525
  const pc = buffer.readInt32BE(pos); pos += 4;
@@ -527,6 +531,14 @@ export class KafkaBackend implements QueueBackend {
527
531
  const abortedCount = buffer.readInt32BE(pos); pos += 4;
528
532
  if (abortedCount > 0) pos += abortedCount * 16; // (-1 => none, skip)
529
533
  const recSetSize = buffer.readInt32BE(pos); pos += 4;
534
+ // 3 = UNKNOWN_TOPIC_OR_PARTITION, 5 = LEADER_NOT_AVAILABLE:
535
+ // "nothing to read here yet", which a consumer that starts
536
+ // before its producer hits on every cold start. Any OTHER code
537
+ // (29 TOPIC_AUTHORIZATION_FAILED, 13 STALE_CONTROLLER_EPOCH, …)
538
+ // is a real failure and must NOT be reported as an empty queue.
539
+ if (errCode !== 0 && errCode !== 3 && errCode !== 5) {
540
+ fatalCode = errCode;
541
+ }
530
542
  if (errCode === 0 && recSetSize > 0) {
531
543
  const val = firstRecordValue(buffer, pos, pos + recSetSize);
532
544
  if (val !== null) out = val;
@@ -534,21 +546,33 @@ export class KafkaBackend implements QueueBackend {
534
546
  pos += recSetSize > 0 ? recSetSize : 0;
535
547
  }
536
548
  }
549
+ if (fatalCode !== 0) {
550
+ process.stderr.write("Fetch error code " + fatalCode);
551
+ finish("__FETCHERROR__" + fatalCode, 0);
552
+ return;
553
+ }
537
554
  finish(out, 0);
538
555
  } catch (e) {
556
+ // A parse failure is NOT an empty queue either — say so.
539
557
  process.stderr.write("fetch parse: " + e.message);
540
- finish("__EMPTY__", 0);
558
+ finish("__PARSEERROR__fetch: " + e.message, 0);
541
559
  }
542
560
  return;
543
561
  }
544
562
  });
545
563
 
564
+ // Report the reason on STDOUT and exit 0. Writing it to stderr and
565
+ // exiting non-zero LOST it: stderr to a pipe is an async write and
566
+ // process.exit() truncates it, so the parent saw an empty stderr and fell
567
+ // back to execFileSync's message -- which embeds this entire script.
568
+ // stdout is flushed by finish()'s write callback, so it survives.
546
569
  sock.on("error", (err) => {
547
- process.stderr.write(err.message);
548
- finish("", 1);
570
+ finish("__TRANSPORTERROR__" + err.message, 0);
549
571
  });
550
572
 
551
- var timer = setTimeout(() => { finish("", 1); }, 10000);
573
+ var timer = setTimeout(() => {
574
+ finish("__TRANSPORTERROR__timed out after 10s talking to " + host + ":" + port, 0);
575
+ }, 10000);
552
576
  `;
553
577
 
554
578
  try {
@@ -558,8 +582,59 @@ export class KafkaBackend implements QueueBackend {
558
582
  stdio: ["pipe", "pipe", "pipe"],
559
583
  });
560
584
  return result;
561
- } catch {
562
- return "";
585
+ } catch (err) {
586
+ // Reached only when the child itself could not run (spawn failure, killed,
587
+ // the outer 15s timeout). The socket-level reasons come back through
588
+ // stdout as __TRANSPORTERROR__ instead. Swallowing this to "" made every
589
+ // failure indistinguishable from an empty queue.
590
+ //
591
+ // execFileSync's own message embeds the ENTIRE generated script, so it is
592
+ // truncated here -- a 20KB error that buries the cause is barely better
593
+ // than no error at all.
594
+ const e = err as { stderr?: Buffer | string; message?: string };
595
+ const reason = String(e.stderr ?? "").trim() || e.message || "unknown error";
596
+ const firstLine = reason.split("\n", 1)[0]!.slice(0, 200);
597
+ return "__TRANSPORTERROR__" + firstLine;
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Sleep synchronously between produce retries.
603
+ *
604
+ * `push()` is synchronous (the whole backend drives its socket through a child
605
+ * process), so there is no event loop to await on. `Atomics.wait` on a
606
+ * SharedArrayBuffer is the stdlib way to block a thread for a fixed time --
607
+ * no dependency, no busy-wait burning CPU.
608
+ */
609
+ private static sleepSync(ms: number): void {
610
+ const shared = new Int32Array(new SharedArrayBuffer(4));
611
+ Atomics.wait(shared, 0, 0, ms);
612
+ }
613
+
614
+ /**
615
+ * Turn a sentinel from the protocol child into a thrown error, or return.
616
+ *
617
+ * The wording matches the Python and PHP backends exactly -- the parity rule
618
+ * covers user-visible error messages, not just behaviour.
619
+ */
620
+ private static assertNoError(result: string, operation: string, topic: string): void {
621
+ const fatal = /^__(PRODUCEERROR|FETCHERROR)__(\d+)/.exec(result);
622
+ if (fatal) {
623
+ throw new Error(
624
+ `Kafka rejected the ${operation} for topic ${topic}: error code ${fatal[2]}`,
625
+ );
626
+ }
627
+ if (result.startsWith("__TRANSPORTERROR__")) {
628
+ throw new Error(
629
+ `Kafka ${operation} for topic ${topic} failed: ` +
630
+ result.slice("__TRANSPORTERROR__".length),
631
+ );
632
+ }
633
+ if (result.startsWith("__PARSEERROR__")) {
634
+ throw new Error(
635
+ `Kafka ${operation} for topic ${topic} returned an unreadable response: ` +
636
+ result.slice("__PARSEERROR__".length),
637
+ );
563
638
  }
564
639
  }
565
640
 
@@ -576,15 +651,36 @@ export class KafkaBackend implements QueueBackend {
576
651
  delayUntil: null,
577
652
  };
578
653
 
579
- const result = this.execSync("publish", queue, JSON.stringify(job));
580
- if (!result.includes("__PUBLISHED__")) {
581
- throw new Error("Kafka publish failed");
654
+ // Topic auto-creation is ASYNCHRONOUS, so a brand-new topic answers
655
+ // UNKNOWN_TOPIC_OR_PARTITION (3) or LEADER_NOT_AVAILABLE (5) on the first
656
+ // attempt while the controller is still electing a leader. Retry those
657
+ // (same 10 attempts / 200ms as the Python and PHP backends) instead of
658
+ // failing a cold-start push; every other code throws immediately.
659
+ const body = JSON.stringify(job);
660
+ let result = "";
661
+ for (let attempt = 1; attempt <= 10; attempt++) {
662
+ result = this.execSync("publish", queue, body);
663
+ if (result.includes("__PUBLISHED__")) {
664
+ return id;
665
+ }
666
+ const retriable = /^__PRODUCEERROR__(3|5)\b/.test(result);
667
+ if (!retriable || attempt === 10) {
668
+ break;
669
+ }
670
+ KafkaBackend.sleepSync(200);
582
671
  }
583
- return id;
672
+
673
+ KafkaBackend.assertNoError(result, "produce", queue);
674
+ throw new Error(`Kafka publish failed for topic ${queue}: ${result || "no response"}`);
584
675
  }
585
676
 
586
677
  pop(queue: string): QueueJob | null {
587
678
  const result = this.execSync("get", queue);
679
+
680
+ // A real failure must NOT read as an empty queue: a mis-permissioned
681
+ // consumer would otherwise poll an "idle" topic forever.
682
+ KafkaBackend.assertNoError(result, "fetch", queue);
683
+
588
684
  if (!result || result === "__EMPTY__" || result === "__UNSUPPORTED__") return null;
589
685
 
590
686
  try {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Tina4 session handlers — turn a failed `execFileSync` child into a readable cause.
3
+ *
4
+ * The session-handler interface is synchronous but every backend client is async,
5
+ * so each command runs in a short-lived `node -e` child. When that child fails,
6
+ * `execFileSync` throws an error whose `.message` begins "Command failed:" and
7
+ * then embeds THE ENTIRE GENERATED SCRIPT — kilobytes of source with the real
8
+ * reason nowhere in it. Every handler used to throw exactly that, so an
9
+ * operator debugging a Redis outage got a wall of JavaScript instead of
10
+ * "connect ECONNREFUSED 127.0.0.1:6379".
11
+ *
12
+ * The children already write the real reason to stderr; `execFileSync` captures
13
+ * it on `err.stderr`. This module is the ONE place that prefers it, so the three
14
+ * call sites (respClient, mongoClient, redisHandler's npm path) cannot drift.
15
+ */
16
+
17
+ /** Longest fallback we will pass through when there is no usable stderr. */
18
+ const MAX_FALLBACK = 200;
19
+
20
+ /**
21
+ * Extract the most useful one-line cause from a thrown `execFileSync` error.
22
+ *
23
+ * Order of preference:
24
+ * 1. the child's own stderr — what it actually reported;
25
+ * 2. a timeout, named as such (a SIGTERM kill leaves stderr empty, so without
26
+ * this the caller would see the useless generic message);
27
+ * 3. a non-zero exit code with no output at all;
28
+ * 4. the error's own message, first line only and length-capped, so the
29
+ * generated script can never be dumped into a log.
30
+ */
31
+ export function childFailureReason(err: unknown): string {
32
+ const e = (err ?? {}) as {
33
+ stderr?: Buffer | string;
34
+ message?: string;
35
+ signal?: string | null;
36
+ status?: number | null;
37
+ code?: string;
38
+ };
39
+
40
+ const stderr = String(e.stderr ?? "").trim();
41
+ if (stderr !== "") {
42
+ return firstLine(stderr);
43
+ }
44
+
45
+ // execFileSync's `timeout` option kills the child with a signal, so it exits
46
+ // with NOTHING on stderr. Say "timed out" rather than "Command failed".
47
+ if (e.code === "ETIMEDOUT" || e.signal) {
48
+ return `timed out or was killed (${e.code ?? e.signal})`;
49
+ }
50
+
51
+ if (typeof e.status === "number" && e.status !== 0) {
52
+ return `child exited with code ${e.status} and no output`;
53
+ }
54
+
55
+ return firstLine(String(e.message ?? "unknown error"));
56
+ }
57
+
58
+ /** First line of `text`, capped at MAX_FALLBACK characters. */
59
+ function firstLine(text: string): string {
60
+ const line = text.split("\n", 1)[0] ?? "";
61
+ return line.length > MAX_FALLBACK ? `${line.slice(0, MAX_FALLBACK)}...` : line;
62
+ }
63
+
64
+ /**
65
+ * Build the Error a session handler throws when its child command failed.
66
+ *
67
+ * `label` names the backend ("Redis", "Valkey", "MongoDB") so the message says
68
+ * which one broke; the wording is shared so all three read alike.
69
+ */
70
+ export function childFailureError(label: string, err: unknown): Error {
71
+ return new Error(`${label} command failed: ${childFailureReason(err)}`);
72
+ }
@@ -22,6 +22,7 @@
22
22
  * writes).
23
23
  */
24
24
  import { execFileSync } from "node:child_process";
25
+ import { childFailureError } from "./childError.js";
25
26
 
26
27
  export interface MongoTarget {
27
28
  host: string;
@@ -90,8 +91,10 @@ export function mongoCommandSync(
90
91
  process.stdout.write(out, () => process.exit(0));
91
92
  } catch (err) {
92
93
  try { if (client) await client.close(); } catch (e) {}
93
- process.stderr.write(String((err && err.message) || err));
94
- process.exit(1);
94
+ // Exit from the write CALLBACK: stderr to a pipe is an async write and
95
+ // a bare process.exit() truncates it, which left the parent with an
96
+ // empty stderr and nothing but execFileSync's script-dump message.
97
+ process.stderr.write(String((err && err.message) || err), () => process.exit(1));
95
98
  }
96
99
  })();
97
100
  } else {
@@ -233,6 +236,9 @@ export function mongoCommandSync(
233
236
  stdio: ["pipe", "pipe", "pipe"],
234
237
  });
235
238
  } catch (err) {
236
- throw new Error(`${label} command failed: ${(err as Error).message}`);
239
+ // The child's stderr carries the real reason (connection refused, auth
240
+ // failure, timeout); execFileSync's message carries the whole generated
241
+ // script. Prefer the former.
242
+ throw childFailureError(label, err);
237
243
  }
238
244
  }
@@ -17,6 +17,7 @@
17
17
  * TINA4_SESSION_REDIS_DB (default: 0)
18
18
  */
19
19
  import { execFileSync } from "node:child_process";
20
+ import { childFailureError } from "./childError.js";
20
21
  import { createRequire } from "node:module";
21
22
  import type { SessionHandler } from "../session.js";
22
23
  import { respCommandSync } from "./respClient.js";
@@ -142,9 +143,17 @@ export class RedisNpmSessionHandler implements SessionHandler {
142
143
  (async () => {
143
144
  try {
144
145
  const redis = require("redis");
146
+ // reconnectStrategy: false — this child runs ONE command and exits, so
147
+ // retrying inside it is pointless: the handler is called again on the
148
+ // next request anyway. With the driver's default strategy a refused
149
+ // connection never rejects, the child hangs until execFileSync's 5s
150
+ // timeout kills it, and the caller is told "timed out" when the truth
151
+ // is "connection refused". Off, connect() rejects in ~5ms with the real
152
+ // reason -- a better message AND no 5s stall per request when Redis is
153
+ // down.
145
154
  const clientOpts = useUrl
146
- ? { url }
147
- : { socket: { host, port }, password: password || undefined, database: db };
155
+ ? { url, socket: { reconnectStrategy: false } }
156
+ : { socket: { host, port, reconnectStrategy: false }, password: password || undefined, database: db };
148
157
  const client = redis.createClient(clientOpts);
149
158
  client.on("error", () => {});
150
159
  await client.connect();
@@ -158,8 +167,10 @@ export class RedisNpmSessionHandler implements SessionHandler {
158
167
  const out = (result === null || result === undefined) ? "__NULL__" : String(result);
159
168
  process.stdout.write(out, () => process.exit(0));
160
169
  } catch (err) {
161
- process.stderr.write(String((err && err.message) || err));
162
- process.exit(1);
170
+ // Exit from the write CALLBACK: stderr to a pipe is an async write and
171
+ // a bare process.exit() truncates it, which left the parent with an
172
+ // empty stderr and nothing but execFileSync's script-dump message.
173
+ process.stderr.write(String((err && err.message) || err), () => process.exit(1));
163
174
  }
164
175
  })();
165
176
  `;
@@ -171,7 +182,9 @@ export class RedisNpmSessionHandler implements SessionHandler {
171
182
  stdio: ["pipe", "pipe", "pipe"],
172
183
  });
173
184
  } catch (err) {
174
- throw new Error(`Redis command failed: ${(err as Error).message}`);
185
+ // The child's stderr carries the driver's real reason; execFileSync's
186
+ // message carries the whole generated script. Prefer the former.
187
+ throw childFailureError("Redis", err);
175
188
  }
176
189
  if (result === "__NULL__") return ""; // genuine key miss
177
190
  return result;
@@ -16,6 +16,7 @@
16
16
  * then returns the LAST (command) reply.
17
17
  */
18
18
  import { execFileSync } from "node:child_process";
19
+ import { childFailureError } from "./childError.js";
19
20
 
20
21
  export interface RespTarget {
21
22
  host: string;
@@ -161,7 +162,10 @@ export function respCommandSync(target: RespTarget, args: string[], label = "Red
161
162
  // Non-zero exit = socket error / timeout / closed connection: a transport
162
163
  // FAILURE, not a key miss. Surface it so the Session boundary logs + degrades
163
164
  // (or re-throws under strict mode).
164
- throw new Error(`${label} command failed: ${(err as Error).message}`);
165
+ //
166
+ // Report the CHILD's stderr, not execFileSync's message -- that message
167
+ // embeds the whole generated script and buries the actual reason.
168
+ throw childFailureError(label, err);
165
169
  }
166
170
  if (result === "__NULL__") return ""; // genuine key miss
167
171
  if (result.startsWith("__ERR__")) {