tina4-nodejs 3.13.94 → 3.13.96

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 (123) hide show
  1. package/CLAUDE.md +158 -30
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/packages/cli/dist/bin.js +30911 -28444
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +30810 -28261
  8. package/packages/core/public/css/tina4.min.css +1 -1
  9. package/packages/core/src/ai.ts +7 -1
  10. package/packages/core/src/auth.ts +191 -39
  11. package/packages/core/src/background.ts +19 -19
  12. package/packages/core/src/cache.ts +492 -49
  13. package/packages/core/src/devAdmin.ts +79 -32
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +6 -7
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +294 -106
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +34 -16
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +886 -421
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  34. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  35. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
  36. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  37. package/packages/core/src/sessionHandlers/respClient.ts +16 -147
  38. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  39. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  40. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  41. package/packages/core/src/testClient.ts +18 -5
  42. package/packages/core/src/trustedProxy.ts +249 -0
  43. package/packages/core/src/types.ts +29 -5
  44. package/packages/core/src/websocket.ts +66 -0
  45. package/packages/orm/dist/index.js +22717 -20168
  46. package/packages/orm/src/adapters/firebird.ts +183 -56
  47. package/packages/orm/src/adapters/mongodb.ts +25 -4
  48. package/packages/orm/src/adapters/mssql.ts +114 -29
  49. package/packages/orm/src/adapters/mysql.ts +103 -40
  50. package/packages/orm/src/adapters/odbc.ts +44 -21
  51. package/packages/orm/src/adapters/postgres.ts +118 -26
  52. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  53. package/packages/orm/src/adapters/sqlite.ts +60 -24
  54. package/packages/orm/src/autoCrud.ts +12 -10
  55. package/packages/orm/src/baseModel.ts +135 -40
  56. package/packages/orm/src/cachedDatabase.ts +43 -19
  57. package/packages/orm/src/connectTimeout.ts +265 -0
  58. package/packages/orm/src/database.ts +241 -197
  59. package/packages/orm/src/databaseResult.ts +51 -28
  60. package/packages/orm/src/databaseUrl.ts +484 -0
  61. package/packages/orm/src/docstore.ts +386 -145
  62. package/packages/orm/src/index.ts +13 -6
  63. package/packages/orm/src/migration.ts +44 -11
  64. package/packages/orm/src/model.ts +4 -0
  65. package/packages/orm/src/queryBuilder.ts +47 -6
  66. package/packages/orm/src/sqlTranslator.ts +310 -4
  67. package/packages/orm/src/types.ts +21 -77
  68. package/packages/swagger/dist/index.js +78 -20
  69. package/packages/swagger/src/generator.ts +172 -29
  70. package/types/core/src/ai.d.ts +1 -1
  71. package/types/core/src/auth.d.ts +28 -5
  72. package/types/core/src/background.d.ts +3 -3
  73. package/types/core/src/cache.d.ts +15 -12
  74. package/types/core/src/dispatchPipeline.d.ts +117 -0
  75. package/types/core/src/dotenv.d.ts +38 -16
  76. package/types/core/src/index.d.ts +6 -9
  77. package/types/core/src/logger.d.ts +93 -16
  78. package/types/core/src/messenger.d.ts +47 -6
  79. package/types/core/src/metrics.d.ts +25 -61
  80. package/types/core/src/middleware.d.ts +134 -11
  81. package/types/core/src/queue.d.ts +54 -5
  82. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
  83. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  84. package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
  85. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
  86. package/types/core/src/router.d.ts +14 -3
  87. package/types/core/src/server.d.ts +15 -4
  88. package/types/core/src/session.d.ts +87 -2
  89. package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
  90. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  91. package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
  92. package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
  93. package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
  94. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  95. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  96. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  97. package/types/core/src/trustedProxy.d.ts +44 -0
  98. package/types/core/src/types.d.ts +28 -5
  99. package/types/core/src/websocket.d.ts +26 -0
  100. package/types/orm/src/adapters/firebird.d.ts +55 -10
  101. package/types/orm/src/adapters/mongodb.d.ts +2 -2
  102. package/types/orm/src/adapters/mssql.d.ts +18 -11
  103. package/types/orm/src/adapters/mysql.d.ts +11 -10
  104. package/types/orm/src/adapters/odbc.d.ts +9 -12
  105. package/types/orm/src/adapters/postgres.d.ts +11 -10
  106. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  107. package/types/orm/src/adapters/sqlite.d.ts +15 -3
  108. package/types/orm/src/baseModel.d.ts +45 -9
  109. package/types/orm/src/cachedDatabase.d.ts +18 -5
  110. package/types/orm/src/connectTimeout.d.ts +100 -0
  111. package/types/orm/src/database.d.ts +78 -28
  112. package/types/orm/src/databaseResult.d.ts +29 -15
  113. package/types/orm/src/databaseUrl.d.ts +125 -0
  114. package/types/orm/src/docstore.d.ts +102 -43
  115. package/types/orm/src/index.d.ts +6 -4
  116. package/types/orm/src/migration.d.ts +4 -3
  117. package/types/orm/src/queryBuilder.d.ts +23 -3
  118. package/types/orm/src/sqlTranslator.d.ts +126 -2
  119. package/types/orm/src/types.d.ts +21 -38
  120. package/packages/core/src/scss.ts +0 -623
  121. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
  122. package/types/core/src/scss.d.ts +0 -19
  123. package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
@@ -128,6 +128,66 @@ function stripAnsi(text: string): string {
128
128
  *
129
129
  * Otherwise the directory is `TINA4_LOG_DIR` and filename is `tina4.log`.
130
130
  */
131
+ // The logger must never be surprised by what it is handed, and must never be
132
+ // the reason a request dies. Same numbers in all four frameworks (feature 2).
133
+ const STDOUT_MAX_CHARS = 2000;
134
+ // eslint-disable-next-line no-control-regex
135
+ const CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
136
+
137
+ /**
138
+ * Turn anything into a single safe line of text.
139
+ *
140
+ * A string passes through. A Buffer is decoded when it is valid UTF-8 and
141
+ * described when it is not: raw bytes at a terminal garble it and can emit
142
+ * escape sequences. Anything else becomes JSON, because an object rendered as
143
+ * text is the whole reason the caller logged it, falling back to String() for a
144
+ * value JSON cannot represent (a circular reference, a BigInt). Without this an
145
+ * object logged as a message reached the output as "[object Object]".
146
+ */
147
+ function coerceMessage(message: unknown): string {
148
+ let text: string;
149
+ if (typeof message === "string") {
150
+ text = message;
151
+ } else if (message instanceof Uint8Array) {
152
+ const decoded = new TextDecoder("utf-8", { fatal: false }).decode(message);
153
+ text = decoded.includes("�") ? `<binary ${message.byteLength} bytes>` : decoded;
154
+ } else if (message === null || message === undefined) {
155
+ text = "";
156
+ } else if (typeof message === "object") {
157
+ try {
158
+ text = JSON.stringify(message) ?? String(message);
159
+ } catch {
160
+ text = String(message);
161
+ }
162
+ } else {
163
+ text = String(message);
164
+ }
165
+ return text.replace(CONTROL_CHARS, "");
166
+ }
167
+
168
+ /** Cap a console line. The file keeps the whole thing; a terminal does not. */
169
+ function truncateForStdout(line: string): string {
170
+ if (line.length <= STDOUT_MAX_CHARS) return line;
171
+ return `${line.slice(0, STDOUT_MAX_CHARS)}... (truncated, ${line.length} chars)`;
172
+ }
173
+
174
+ /**
175
+ * Is this target a FILE PATH or a DIRECTORY?
176
+ *
177
+ * An existing directory is always a directory, extension or not. Otherwise a
178
+ * basename with an extension (app.log, app.txt) is a file and anything else is
179
+ * a directory to create. That keeps `configure("/var/log/myapp")` a directory
180
+ * and `configure("/var/log/myapp/app.log")` a file without the path needing to
181
+ * exist yet. Identical rule in all four frameworks.
182
+ */
183
+ function targetIsFile(path: string): boolean {
184
+ try {
185
+ if (existsSync(path) && statSync(path).isDirectory()) return false;
186
+ } catch { /* fall through to the extension test */ }
187
+ const base = path.split(/[\\/]/).pop() ?? "";
188
+ return base.includes(".") && !base.startsWith(".");
189
+ }
190
+
131
191
  function resolveLogFilePath(logDir: string, logFile: string): string {
132
192
  if (isAbsolute(logFile)) return logFile;
133
193
  return join(logDir, logFile);
@@ -136,23 +196,36 @@ function resolveLogFilePath(logDir: string, logFile: string): string {
136
196
  /**
137
197
  * Structured logger for Tina4.
138
198
  *
139
- * Development (TINA4_DEBUG=true): colorized human-readable to stdout + file.
140
- * Production (TINA4_DEBUG not truthy): clean structured JSON to stdout ONLY
141
- * no log file by default (writing logs/tina4.log inside a container bloats the
142
- * writable layer + disk; 12-factor wants logs on stdout). stdout is ALWAYS on.
199
+ * FORMAT IS TEXT BY DEFAULT, and TINA4_LOG_FORMAT=json is the ONLY thing that
200
+ * selects JSON. Nothing else may. Until 3.13.95 an unset TINA4_DEBUG silently
201
+ * flipped BOTH sinks to JSON here, and "production" meant four different things
202
+ * across the four frameworks (Node: !TINA4_DEBUG; Ruby: TINA4_ENV/RACK_ENV/
203
+ * RUBY_ENV == "production"; Python: only configure(production=True); PHP: no
204
+ * switch at all, JSON always) — same machine, same .env, four log formats. That
205
+ * implicit switch is deleted; an object passed as the message is still
206
+ * JSON-encoded INLINE inside the text line, which is the only JSON a default
207
+ * install emits.
208
+ *
209
+ * TINA4_DEBUG still decides COLOUR — a terminal concern, not a format one — so
210
+ * a production pipe gets clean uncoloured bytes and a dev terminal stays
211
+ * readable.
143
212
  *
144
213
  * Default file-output rule (TINA4_LOG_OUTPUT unset): the log FILE is written
145
214
  * only in development. An explicit TINA4_LOG_OUTPUT=file/both, OR an explicit
146
- * TINA4_LOG_FILE path, always forces a file (explicit wins).
215
+ * TINA4_LOG_FILE path, always forces a file (explicit wins). stdout is ALWAYS on.
147
216
  *
148
217
  * Env vars:
149
218
  * TINA4_LOG_FILE — explicit log file (absolute or relative). Setting it forces a file even in production. Empty = use TINA4_LOG_DIR + tina4.log
150
219
  * TINA4_LOG_DIR — directory for log files (default: "logs")
151
- * TINA4_LOG_FORMAT — "text" | "json" (default: "text")
220
+ * TINA4_LOG_FORMAT — "text" | "json" (default: "text") — the ONLY format switch
152
221
  * TINA4_LOG_OUTPUT — "stdout" | "file" | "both" (default: "stdout" → file only in dev)
153
222
  * TINA4_LOG_ROTATE_SIZE — bytes; 0 disables rotation (default: 10485760 = 10MB)
154
223
  * TINA4_LOG_ROTATE_KEEP — number of historical files to keep (default: 5)
155
224
  * TINA4_LOG_LEVEL — minimum console level: DEBUG | INFO | WARNING | ERROR | CRITICAL (default: "INFO")
225
+ * TINA4_LOG_STRICT — truthy: a log-write failure THROWS instead of being swallowed (default: off)
226
+ *
227
+ * Every one of these is read LAZILY, on each log() call — a script, worker, CLI
228
+ * tool or test that never boots a server still gets the operator's configuration.
156
229
  *
157
230
  * Rotation is stdlib roll-your-own:
158
231
  * - On each write, statSync the file. If size >= TINA4_LOG_ROTATE_SIZE, rotate.
@@ -163,6 +236,22 @@ function resolveLogFilePath(logDir: string, logFile: string): string {
163
236
  export class Log {
164
237
  private static requestId: string | undefined;
165
238
 
239
+ /**
240
+ * What configure() was explicitly told, held HERE rather than written back
241
+ * into process.env (ADR-0041).
242
+ *
243
+ * configure() used to assign to process.env.TINA4_LOG_DIR / _LOG_FILE. That
244
+ * reached the right answer -- the argument won -- through a mechanism no
245
+ * other framework has: it DESTROYED the operator's value for the rest of the
246
+ * process, and every child process spawned afterwards inherited the
247
+ * argument instead of what the operator set. Reading configuration must not
248
+ * write it. Keeping the explicit values in their own slot means resolution
249
+ * is explicit > env > default with the environment left intact and still
250
+ * readable.
251
+ */
252
+ private static explicitLogDir: string | undefined;
253
+ private static explicitLogFile: string | undefined;
254
+
166
255
  /**
167
256
  * Re-read all log-related env vars. Called on every log() so tests that
168
257
  * mutate process.env between calls see the new values without having to
@@ -177,9 +266,14 @@ export class Log {
177
266
  format: "text" | "json";
178
267
  output: "stdout" | "file" | "both";
179
268
  fileEnabled: boolean;
269
+ /** True when the operator named ONE file, so no error.log sibling appears. */
270
+ explicitFile: boolean;
271
+ /** TINA4_LOG_STRICT — a failed log write throws instead of being swallowed. */
272
+ strict: boolean;
180
273
  } {
181
- const logDir = process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
182
- const explicitFile = (process.env.TINA4_LOG_FILE ?? "").trim();
274
+ // explicit argument > environment > default (ADR-0041).
275
+ const logDir = Log.explicitLogDir ?? process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
276
+ const explicitFile = (Log.explicitLogFile ?? process.env.TINA4_LOG_FILE ?? "").trim();
183
277
  const logFile = explicitFile || DEFAULT_LOG_FILE;
184
278
 
185
279
  const rawSize = process.env.TINA4_LOG_ROTATE_SIZE;
@@ -225,7 +319,17 @@ export class Log {
225
319
  fileEnabled = !Log.isProduction();
226
320
  }
227
321
 
228
- return { logDir, logFile, rotateSize, rotateKeep, minLevel, format, output, fileEnabled };
322
+ // TINA4_LOG_STRICT documented on all four env-var pages, implemented only
323
+ // in Ruby until 3.13.95: a documented no-op in three frameworks. When truthy
324
+ // a log-write failure THROWS instead of being swallowed, so a deploy whose
325
+ // log directory is read-only fails loudly instead of running blind.
326
+ const strict = isTruthy(process.env.TINA4_LOG_STRICT);
327
+
328
+ return {
329
+ logDir, logFile, rotateSize, rotateKeep, minLevel, format, output, fileEnabled,
330
+ explicitFile: explicitFile !== "",
331
+ strict,
332
+ };
229
333
  }
230
334
 
231
335
  /**
@@ -277,36 +381,116 @@ export class Log {
277
381
  }
278
382
 
279
383
  /**
280
- * Configure the log directory / filename. Mostly a no-op now —
281
- * env vars are re-read on every call. Kept for backwards compatibility.
384
+ * Configure where logs are written.
385
+ *
386
+ * Logs land in a `logs/` folder by default. The argument OVERRIDES that, and
387
+ * it accepts a DIRECTORY or a FILE PATH:
388
+ *
389
+ * configure() -> ./logs/tina4.log + ./logs/error.log
390
+ * configure("/var/log/myapp") -> /var/log/myapp/tina4.log + error.log
391
+ * configure("/var/log/myapp/app.log") -> that exact file (no error.log sibling)
392
+ * configure({ logDir, logFile }) -> the explicit object form still works
393
+ *
394
+ * A plain string used to be accepted and silently ignored, because only the
395
+ * object form was read - so the call that works in the other three
396
+ * frameworks produced no log file here and said nothing (feature 2 of the
397
+ * audit, D4). Both forms now work.
398
+ */
399
+ static configure(options?: string | { logDir?: string; logFile?: string }): void {
400
+ // Record what the caller asked for; never write it back into process.env
401
+ // (ADR-0041 -- reading configuration must not write it).
402
+ if (typeof options === "string") {
403
+ if (targetIsFile(options)) {
404
+ Log.explicitLogDir = dirname(options);
405
+ Log.explicitLogFile = options;
406
+ } else {
407
+ Log.explicitLogDir = options;
408
+ }
409
+ Log.applyAppendMode();
410
+ return;
411
+ }
412
+ if (options) {
413
+ if (options.logDir) Log.explicitLogDir = options.logDir;
414
+ if (options.logFile) Log.explicitLogFile = options.logFile;
415
+ }
416
+ Log.applyAppendMode();
417
+ }
418
+
419
+ /**
420
+ * Forget what configure() was told, so resolution falls back to the
421
+ * environment and then the built-in defaults. Parity with PHP's Log::reset().
422
+ *
423
+ * This exists because the explicit values are now HELD here rather than
424
+ * written back into process.env, and that makes them STICKY for the life of
425
+ * the process -- which is right for an application (configure() at boot is
426
+ * the operator's instruction and a later stray env write should not silently
427
+ * re-point the logs) and wrong for a long-lived test process that wants to
428
+ * drive the logger purely from the environment afterwards.
429
+ *
430
+ * I removed this method once for having no callers. That was correct about
431
+ * the grep and wrong about the code: the full suite is the caller. Before the
432
+ * explicit slots existed, configure() ASSIGNED to process.env, so a later
433
+ * direct assignment simply overwrote it and env-driven cases kept working by
434
+ * accident. test/logger.test.ts depends on exactly that -- it configures a
435
+ * file early, then runs the whole default-output block off the environment
436
+ * (see its own note: "these cases must NOT route through Log.configure").
437
+ * Three of those cases failed on the lab until this came back.
282
438
  */
283
- static configure(options: { logDir?: string; logFile?: string }): void {
284
- if (options.logDir) process.env.TINA4_LOG_DIR = options.logDir;
285
- if (options.logFile) process.env.TINA4_LOG_FILE = options.logFile;
439
+ static reset(): void {
440
+ Log.explicitLogDir = undefined;
441
+ Log.explicitLogFile = undefined;
442
+ }
443
+
444
+ /**
445
+ * TINA4_LOG_APPEND — append (default) or overwrite on startup.
446
+ *
447
+ * APPEND IS THE DEFAULT: a log you can lose by restarting the process is not
448
+ * a log. Set it false for one file per run (a short CLI, a test fixture, a
449
+ * container shipping logs elsewhere); the files are truncated once here at
450
+ * configure time, never per line.
451
+ */
452
+ private static applyAppendMode(): void {
453
+ const raw = process.env.TINA4_LOG_APPEND;
454
+ const append = raw === undefined || isTruthy(raw);
455
+ if (append) return;
456
+ const cfg = Log.readEnv();
457
+ const targets = cfg.explicitFile
458
+ ? [resolveLogFilePath(cfg.logDir, cfg.logFile)]
459
+ : [resolveLogFilePath(cfg.logDir, cfg.logFile), join(cfg.logDir, "error.log")];
460
+ for (const path of targets) {
461
+ try {
462
+ if (existsSync(path)) writeFileSync(path, "", "utf-8");
463
+ } catch (err) {
464
+ // Same TINA4_LOG_STRICT contract as writeToFile: this IS a write to the
465
+ // log file, so under strict it must not be swallowed either.
466
+ if (cfg.strict) throw err;
467
+ /* logging must never crash the app */
468
+ }
469
+ }
286
470
  }
287
471
 
288
472
  /** Log an informational message. */
289
- static info(message: string, data?: unknown): void {
473
+ static info(message: unknown, data?: unknown): void {
290
474
  Log.log("INFO", message, data);
291
475
  }
292
476
 
293
477
  /** Log a debug message. */
294
- static debug(message: string, data?: unknown): void {
478
+ static debug(message: unknown, data?: unknown): void {
295
479
  Log.log("DEBUG", message, data);
296
480
  }
297
481
 
298
482
  /** Log a warning message. */
299
- static warning(message: string, data?: unknown): void {
483
+ static warning(message: unknown, data?: unknown): void {
300
484
  Log.log("WARNING", message, data);
301
485
  }
302
486
 
303
487
  /** Backwards-compat alias for warning(). */
304
- static warn(message: string, data?: unknown): void {
488
+ static warn(message: unknown, data?: unknown): void {
305
489
  Log.log("WARNING", message, data);
306
490
  }
307
491
 
308
492
  /** Log an error message. */
309
- static error(message: string, data?: unknown): void {
493
+ static error(message: unknown, data?: unknown): void {
310
494
  Log.log("ERROR", message, data);
311
495
  }
312
496
 
@@ -318,7 +502,7 @@ export class Log {
318
502
  * critical 4 >= warning 2 so it would be in error.log on a split-file model).
319
503
  * Matches Python master parity — there is no enable toggle.
320
504
  */
321
- static critical(message: string, data?: unknown): void {
505
+ static critical(message: unknown, data?: unknown): void {
322
506
  Log.log("CRITICAL", message, data);
323
507
  }
324
508
 
@@ -397,21 +581,40 @@ export class Log {
397
581
  try { writeFileSync(filePath, "", "utf-8"); } catch { /* ignore */ }
398
582
  }
399
583
 
400
- /** Write a line to the log file, stripping ANSI codes. */
401
- private static writeToFile(filePath: string, line: string, rotateSize: number, rotateKeep: number): void {
584
+ /**
585
+ * Write a line to the log file, stripping ANSI codes.
586
+ *
587
+ * A failure is swallowed by default — logging must never crash the app. With
588
+ * TINA4_LOG_STRICT truthy it is RE-THROWN instead: an app that believes it is
589
+ * writing an audit trail into a read-only directory, and is not, is worse off
590
+ * than one that dies at the first line. Same contract in all four frameworks.
591
+ */
592
+ private static writeToFile(
593
+ filePath: string,
594
+ line: string,
595
+ rotateSize: number,
596
+ rotateKeep: number,
597
+ strict: boolean,
598
+ ): void {
402
599
  try {
403
600
  Log.ensureLogDir(filePath);
404
601
  Log.rotateIfNeeded(filePath, rotateSize, rotateKeep);
405
602
  appendFileSync(filePath, stripAnsi(line) + "\n", "utf-8");
406
- } catch {
603
+ } catch (err) {
604
+ if (strict) throw err;
407
605
  // Silently fail — logging should never crash the app
408
606
  }
409
607
  }
410
608
 
411
609
  /** Core log method */
412
- private static log(level: LogLevel, message: string, data?: unknown): void {
610
+ private static log(level: LogLevel, rawMessage: unknown, data?: unknown): void {
413
611
  const cfg = Log.readEnv();
414
612
 
613
+ // Coerce FIRST, into the local the human line is built from further down.
614
+ // Anything can arrive as a message: an object from a handler, a Buffer off
615
+ // a socket, a 10MB string. See coerceMessage.
616
+ const message = coerceMessage(rawMessage);
617
+
415
618
  const entry: LogEntry = {
416
619
  timestamp: Log.timestamp(),
417
620
  level,
@@ -441,26 +644,33 @@ export class Log {
441
644
  const dataPart = data !== undefined ? ` ${JSON.stringify(data)}` : "";
442
645
  const humanLine = `${entry.timestamp} [${paddedLevel}]${reqPart}${fnPart} ${message}${dataPart}`;
443
646
 
444
- // Build the file-format line. v3.13.14: production always emits JSON
445
- // (parity with Python/Ruby) so log aggregators get structured lines;
446
- // TINA4_LOG_FORMAT=json forces it in dev too.
447
- const fileLine =
448
- cfg.format === "json" || Log.isProduction() ? JSON.stringify(entry) : humanLine;
647
+ // ONE format decision, both sinks, one input: TINA4_LOG_FORMAT. Text is the
648
+ // default; only an explicit `json` selects JSON. The `|| Log.isProduction()`
649
+ // that used to live here made an UNSET TINA4_DEBUG silently reformat every
650
+ // line — the owner's measured defect (four frameworks, four meanings of
651
+ // "production", four different formats off one .env). An object passed as
652
+ // the message is still JSON-encoded INLINE by coerceMessage, which is the
653
+ // only structure a default install emits.
654
+ const formattedLine = cfg.format === "json" ? JSON.stringify(entry) : humanLine;
449
655
 
450
656
  const shouldLog = Log.passesThreshold(level, cfg.minLevel);
451
657
 
452
658
  // Console output. v3.13.14: stdout is NOT suppressed in production —
453
659
  // containers read PID 1 stdout (docker logs / k8s) and the old
454
- // `!isProduction()` gate meant deployed apps logged nothing. In
455
- // production we print the clean structured line (JSON, no ANSI) so it
456
- // stays parseable; in dev we keep the coloured human-readable line.
660
+ // `!isProduction()` gate meant deployed apps logged nothing.
457
661
  // TINA4_LOG_OUTPUT="file" still opts out of stdout entirely.
662
+ //
663
+ // stdout carries the SAME formattedLine as the file — the format env is the
664
+ // only thing that picks text vs JSON. TINA4_DEBUG decides COLOUR only: ANSI
665
+ // is for a human at a terminal, and a production pipe / log shipper must get
666
+ // clean bytes. Truncate on the CONSOLE only; the file keeps the full line so
667
+ // a consumer parsing it loses nothing, and a terminal does not need 10MB.
458
668
  if (shouldLog && cfg.output !== "file") {
669
+ const consoleLine = truncateForStdout(formattedLine);
459
670
  if (Log.isProduction()) {
460
- console.log(fileLine);
671
+ console.log(consoleLine);
461
672
  } else {
462
- const color = COLORS[level];
463
- console.log(`${color}${humanLine}${RESET}`);
673
+ console.log(`${COLORS[level]}${consoleLine}${RESET}`);
464
674
  }
465
675
  }
466
676
 
@@ -477,7 +687,18 @@ export class Log {
477
687
  // that one flag gates the whole file writer (the only persisted sink).
478
688
  if (cfg.fileEnabled) {
479
689
  const filePath = resolveLogFilePath(cfg.logDir, cfg.logFile);
480
- Log.writeToFile(filePath, fileLine, cfg.rotateSize, cfg.rotateKeep);
690
+ Log.writeToFile(filePath, formattedLine, cfg.rotateSize, cfg.rotateKeep, cfg.strict);
691
+
692
+ // Mirror WARNING and above into a dedicated error.log so
693
+ // `tail -f logs/error.log` gives just the stuff worth looking at. Node
694
+ // wrote ONE file where Python and PHP wrote two, so anyone whose
695
+ // alerting tails error.log got silence here (feature 2 of the audit, D3).
696
+ // Skipped when the operator named ONE file explicitly: they asked for a
697
+ // single path, so a sibling error.log appearing beside it is a surprise.
698
+ if (!cfg.explicitFile && LEVEL_PRIORITY[level] >= LEVEL_PRIORITY.WARNING) {
699
+ const errorPath = join(cfg.logDir, "error.log");
700
+ Log.writeToFile(errorPath, formattedLine, cfg.rotateSize, cfg.rotateKeep, cfg.strict);
701
+ }
481
702
  }
482
703
  }
483
704
  }
@@ -1650,7 +1650,7 @@ export function registerDevTools(server: McpServer): void {
1650
1650
  return { error: "Seeder API not available (install @tina4/orm)" };
1651
1651
  }
1652
1652
  // RAW adapter, NOT globalThis.__tina4_db: autoFieldMap introspects via
1653
- // adapter.columns() and seedTable inserts via adapter.execute() — the
1653
+ // adapter.getColumns() and seedTable inserts via adapter.execute() — the
1654
1654
  // Database WRAPPER has neither (its introspection method is getColumns).
1655
1655
  // getAdapter() is the same accessor migration_* use above.
1656
1656
  let adapter;