tina4-nodejs 3.13.97 → 3.13.99

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 (96) hide show
  1. package/CLAUDE.md +60 -25
  2. package/package.json +1 -2
  3. package/packages/cli/dist/bin.js +20620 -18995
  4. package/packages/cli/src/bin.ts +28 -71
  5. package/packages/cli/src/commands/migrate.ts +36 -75
  6. package/packages/cli/src/commands/migrateRollback.ts +10 -1
  7. package/packages/cli/src/commands/test.ts +92 -21
  8. package/packages/core/dist/index.js +20459 -18815
  9. package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
  10. package/packages/core/src/ai.ts +28 -12
  11. package/packages/core/src/api.ts +13 -5
  12. package/packages/core/src/background.ts +9 -3
  13. package/packages/core/src/devAdmin.ts +135 -20
  14. package/packages/core/src/dispatchPipeline.ts +185 -1
  15. package/packages/core/src/docs.ts +33 -5
  16. package/packages/core/src/env.ts +1 -1
  17. package/packages/core/src/errorOverlay.ts +39 -48
  18. package/packages/core/src/fakeData.ts +15 -0
  19. package/packages/core/src/index.ts +17 -6
  20. package/packages/core/src/logger.ts +892 -572
  21. package/packages/core/src/mcp.ts +9 -1
  22. package/packages/core/src/messenger.ts +31 -4
  23. package/packages/core/src/middleware.ts +169 -43
  24. package/packages/core/src/portTakeover.ts +232 -0
  25. package/packages/core/src/request.ts +57 -8
  26. package/packages/core/src/response.ts +67 -0
  27. package/packages/core/src/router.ts +35 -7
  28. package/packages/core/src/server.ts +450 -190
  29. package/packages/core/src/static.ts +81 -12
  30. package/packages/core/src/testClient.ts +126 -137
  31. package/packages/core/src/testing.ts +16 -12
  32. package/packages/core/src/types.ts +21 -9
  33. package/packages/core/src/version.ts +66 -0
  34. package/packages/core/src/websocket.ts +2 -2
  35. package/packages/core/src/websocketBackplane.ts +2 -2
  36. package/packages/frond/dist/index.js +31 -13
  37. package/packages/frond/src/engine.ts +39 -7
  38. package/packages/orm/dist/index.js +10879 -9258
  39. package/packages/orm/src/adapters/firebird.ts +200 -27
  40. package/packages/orm/src/adapters/mongodb.ts +160 -10
  41. package/packages/orm/src/adapters/mssql.ts +38 -11
  42. package/packages/orm/src/adapters/mysql.ts +24 -1
  43. package/packages/orm/src/adapters/odbc.ts +127 -29
  44. package/packages/orm/src/adapters/postgres.ts +18 -0
  45. package/packages/orm/src/adapters/sqlite.ts +93 -14
  46. package/packages/orm/src/autoCrud.ts +72 -8
  47. package/packages/orm/src/baseModel.ts +323 -71
  48. package/packages/orm/src/cachedDatabase.ts +48 -1
  49. package/packages/orm/src/database.ts +162 -59
  50. package/packages/orm/src/fakeData.ts +6 -2
  51. package/packages/orm/src/index.ts +4 -1
  52. package/packages/orm/src/migration.ts +95 -52
  53. package/packages/orm/src/query.ts +16 -4
  54. package/packages/orm/src/seeder.ts +43 -25
  55. package/packages/orm/src/sqlTranslator.ts +104 -19
  56. package/packages/orm/src/types.ts +97 -21
  57. package/packages/orm/src/validation.ts +5 -1
  58. package/packages/swagger/dist/index.js +3 -2
  59. package/packages/swagger/src/generator.ts +19 -4
  60. package/packages/swagger/src/ui.ts +6 -4
  61. package/types/cli/src/bin.d.ts +0 -22
  62. package/types/core/src/api.d.ts +11 -4
  63. package/types/core/src/background.d.ts +5 -2
  64. package/types/core/src/devAdmin.d.ts +35 -0
  65. package/types/core/src/dispatchPipeline.d.ts +41 -1
  66. package/types/core/src/errorOverlay.d.ts +13 -13
  67. package/types/core/src/index.d.ts +9 -6
  68. package/types/core/src/logger.d.ts +111 -185
  69. package/types/core/src/middleware.d.ts +40 -5
  70. package/types/core/src/portTakeover.d.ts +50 -0
  71. package/types/core/src/request.d.ts +15 -0
  72. package/types/core/src/response.d.ts +29 -0
  73. package/types/core/src/server.d.ts +92 -0
  74. package/types/core/src/testClient.d.ts +29 -3
  75. package/types/core/src/testing.d.ts +16 -12
  76. package/types/core/src/types.d.ts +21 -9
  77. package/types/core/src/version.d.ts +11 -0
  78. package/types/core/src/websocketBackplane.d.ts +1 -1
  79. package/types/frond/src/engine.d.ts +10 -0
  80. package/types/orm/src/adapters/firebird.d.ts +61 -2
  81. package/types/orm/src/adapters/mongodb.d.ts +20 -0
  82. package/types/orm/src/adapters/mssql.d.ts +11 -0
  83. package/types/orm/src/adapters/mysql.d.ts +11 -0
  84. package/types/orm/src/adapters/odbc.d.ts +35 -4
  85. package/types/orm/src/adapters/postgres.d.ts +11 -0
  86. package/types/orm/src/adapters/sqlite.d.ts +23 -4
  87. package/types/orm/src/baseModel.d.ts +45 -25
  88. package/types/orm/src/cachedDatabase.d.ts +27 -1
  89. package/types/orm/src/database.d.ts +56 -6
  90. package/types/orm/src/index.d.ts +3 -2
  91. package/types/orm/src/migration.d.ts +23 -5
  92. package/types/orm/src/query.d.ts +3 -0
  93. package/types/orm/src/seeder.d.ts +15 -2
  94. package/types/orm/src/sqlTranslator.d.ts +17 -4
  95. package/types/orm/src/types.d.ts +75 -16
  96. package/packages/core/src/errorOverlay.test.ts +0 -122
@@ -1,83 +1,367 @@
1
1
  import {
2
2
  appendFileSync,
3
+ closeSync,
3
4
  existsSync,
4
5
  mkdirSync,
6
+ openSync,
5
7
  renameSync,
6
8
  statSync,
7
9
  unlinkSync,
8
- writeFileSync,
9
10
  } from "node:fs";
10
- import { join, dirname, isAbsolute } from "node:path";
11
+ import { join, dirname, isAbsolute, basename } from "node:path";
12
+ import { AsyncLocalStorage } from "node:async_hooks";
13
+ import { createHash } from "node:crypto";
11
14
  import { isTruthy } from "./dotenv.js";
12
15
 
13
- /** Log level severity */
14
- type LogLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
16
+ /**
17
+ * Structured logger for Tina4. Conformant to the shared cross-framework
18
+ * contract at plan/v3/fixtures/logger_contract.json (feature 2), decided in
19
+ * plan/v3/features/002-structured-logger.md and ADR-0041, with the
20
+ * 2026-08-10 override of decisions 8 and 20 (separate console/file levels;
21
+ * single-file, in-process lock only).
22
+ *
23
+ * Rewritten 2026-08-13 alongside the shared conformance pass. Node's OWN
24
+ * adaptations, each real and each documented at the point it applies:
25
+ *
26
+ * - Request id: AsyncLocalStorage, not a manual clear-in-finally. A
27
+ * request's id is scoped to the ALS `run()` call around its dispatch, so
28
+ * it cannot leak into a sibling request and needs no "clear" step for
29
+ * correctness (see runWithRequestId below).
30
+ * - Fork (Decision 12's fork-discard requirement): child_process.fork()
31
+ * spawns a genuinely NEW process re-executing the module from scratch —
32
+ * unlike POSIX fork() in Python/PHP/Ruby, it shares no memory with the
33
+ * parent, so there is nothing to inherit and nothing to discard. A child
34
+ * process starts with `activeSnapshot === null` by construction.
35
+ * - The in-process lock (Decision 20): synchronous fs calls on the event
36
+ * loop's single thread cannot interleave (log() never awaits), so the
37
+ * common case needs no lock at all. Where a real lock is exercised (a
38
+ * worker_threads writer, or the LOG-E05 conformance case), it is backed
39
+ * by a real SharedArrayBuffer + Atomics — Node's actual cross-thread
40
+ * synchronous primitive — not a simulation.
41
+ */
15
42
 
16
- /** Log level priority for filtering */
17
- const LEVEL_PRIORITY: Record<LogLevel, number> = {
18
- DEBUG: 0,
19
- INFO: 1,
20
- WARNING: 2,
21
- ERROR: 3,
22
- CRITICAL: 4,
43
+ // ============================================================================
44
+ // Error taxonomy — three categories, matching every other Tina4 language.
45
+ // ============================================================================
46
+
47
+ export class LogConfigurationError extends Error {
48
+ setting?: string;
49
+ value?: unknown;
50
+ accepted?: string[];
51
+ sink?: string;
52
+ operation?: string;
53
+ constructor(
54
+ message: string,
55
+ opts: { setting?: string; value?: unknown; accepted?: string[]; sink?: string; operation?: string } = {},
56
+ ) {
57
+ super(message);
58
+ this.name = "LogConfigurationError";
59
+ Object.assign(this, opts);
60
+ }
61
+ }
62
+
63
+ export class LogArgumentError extends Error {
64
+ argument?: string;
65
+ accepted?: string[];
66
+ constructor(message: string, opts: { argument?: string; accepted?: string[] } = {}) {
67
+ super(message);
68
+ this.name = "LogArgumentError";
69
+ Object.assign(this, opts);
70
+ }
71
+ }
72
+
73
+ export class LogWriteError extends Error {
74
+ sink?: string;
75
+ operation?: string;
76
+ constructor(message: string, opts: { sink?: string; operation?: string } = {}) {
77
+ super(message);
78
+ this.name = "LogWriteError";
79
+ Object.assign(this, opts);
80
+ }
81
+ }
82
+
83
+ // ============================================================================
84
+ // Constants — identical names and values across all four frameworks.
85
+ // ============================================================================
86
+
87
+ export type LogLevel = "ALL" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL" | "NONE";
88
+ type EventLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
89
+
90
+ const LEVELS: Record<LogLevel, number> = {
91
+ ALL: 0,
92
+ DEBUG: 1,
93
+ INFO: 2,
94
+ WARNING: 3,
95
+ ERROR: 4,
96
+ CRITICAL: 5,
97
+ NONE: 6,
23
98
  };
99
+ const EVENT_LEVELS: EventLevel[] = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"];
100
+
101
+ const DEFAULT_LEVEL: LogLevel = "INFO";
102
+ const DEFAULT_FILE_LEVEL: LogLevel = "ALL";
103
+ const DEFAULT_ROTATE_SIZE = 10 * 1024 * 1024;
104
+ const MIN_ROTATE_SIZE = 1024;
105
+ const DEFAULT_ROTATE_KEEP = 5;
106
+ const STDOUT_MAX_BYTES = 8192;
107
+ const OVERFLOW_MESSAGE = "Log event omitted: encoded size exceeds sink limit";
108
+ const DEFAULT_LOG_DIR = "logs";
109
+ const DEFAULT_LOG_FILE_NAME = "tina4.log";
110
+ const LOCK_TIMEOUT_MS = 2000;
111
+
112
+ // Settings that used to exist and now hard-fail configuration (Decision 19).
113
+ const REMOVED_SETTINGS: Record<string, string> = {
114
+ TINA4_LOG_MAX_SIZE: "removed; use TINA4_LOG_ROTATE_SIZE",
115
+ TINA4_LOG_KEEP: "removed; use TINA4_LOG_ROTATE_KEEP",
116
+ TINA4_LOG_APPEND: "removed; the logger always appends",
117
+ TINA4_DEBUG_LEVEL: "removed; use TINA4_LOG_LEVEL",
118
+ TINA4_LOG_CRITICAL: "removed; critical() always emits, subject only to the threshold",
119
+ };
120
+
121
+ const COLORS: Record<EventLevel, string> = {
122
+ DEBUG: "\x1b[36m",
123
+ INFO: "\x1b[32m",
124
+ WARNING: "\x1b[33m",
125
+ ERROR: "\x1b[31m",
126
+ CRITICAL: "\x1b[35m",
127
+ };
128
+ const RESET = "\x1b[0m";
129
+ // eslint-disable-next-line no-control-regex
130
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
131
+
132
+ function parseLevel(raw: string, setting: string): LogLevel {
133
+ const key = raw.trim().toUpperCase() as LogLevel;
134
+ if (!(key in LEVELS)) {
135
+ throw new LogConfigurationError(`${setting}=${JSON.stringify(raw)} is not a valid level`, {
136
+ setting,
137
+ value: raw,
138
+ accepted: Object.keys(LEVELS),
139
+ });
140
+ }
141
+ return key;
142
+ }
143
+
144
+ // ============================================================================
145
+ // Request id — AsyncLocalStorage (Decision 12, Node adaptation above).
146
+ // ============================================================================
147
+
148
+ const requestIdStore = new AsyncLocalStorage<{ id: string | undefined }>();
149
+ let requestIdFallback: string | undefined;
150
+
151
+ // ============================================================================
152
+ // Native value normalization (Decision 14).
153
+ // ============================================================================
154
+
155
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
156
+ if (typeof value !== "object" || value === null) return false;
157
+ if (Array.isArray(value)) return false;
158
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) return false;
159
+ const proto = Object.getPrototypeOf(value);
160
+ return proto === Object.prototype || proto === null;
161
+ }
162
+
163
+ /** Strict UTF-8 decode; invalid bytes become a sha256-addressed marker
164
+ * rather than a silent U+FFFD replacement, which would hide exactly the
165
+ * bytes an operator needs to diagnose (LOG-F09). */
166
+ function decodeMaybeBinary(buf: Buffer): string {
167
+ try {
168
+ return new TextDecoder("utf-8", { fatal: true }).decode(buf);
169
+ } catch {
170
+ const hash = createHash("sha256").update(buf).digest("hex");
171
+ return `<binary ${buf.length} bytes sha256=${hash}>`;
172
+ }
173
+ }
24
174
 
25
- /** Structured log entry for JSON output */
26
- interface LogEntry {
175
+ /**
176
+ * Recursively normalize a native value into the logger's safe domain:
177
+ * string (a Buffer/Uint8Array is treated as string-shaped: valid UTF-8
178
+ * decodes, invalid becomes the binary marker above), null, boolean, finite
179
+ * number, array, or plain object (string keys, own enumerable properties
180
+ * only). Anything outside that domain becomes the literal "[Unsupported]"
181
+ * WITHOUT EVER invoking the value's own toString/toJSON/valueOf (LOG-F10) —
182
+ * the domain check is entirely structural (typeof / Array.isArray /
183
+ * prototype identity), so a hostile object with a throwing stringifier is
184
+ * never given the chance to run it. A circular array/object is caught via a
185
+ * real ancestor identity chain (===) and becomes "[Circular]" (LOG-F08).
186
+ */
187
+ function normalize(value: unknown, ancestors: unknown[] = []): unknown {
188
+ if (value === null || value === undefined) return null;
189
+ if (typeof value === "boolean") return value;
190
+ if (typeof value === "string") return value;
191
+ if (typeof value === "number") return Number.isFinite(value) ? value : "[Unsupported]";
192
+ if (Buffer.isBuffer(value)) return decodeMaybeBinary(value);
193
+ if (value instanceof Uint8Array) return decodeMaybeBinary(Buffer.from(value));
194
+ if (Array.isArray(value)) {
195
+ if (ancestors.includes(value)) return "[Circular]";
196
+ const nxt = [...ancestors, value];
197
+ return value.map((v) => normalize(v, nxt));
198
+ }
199
+ if (isPlainObject(value)) {
200
+ if (ancestors.includes(value)) return "[Circular]";
201
+ const nxt = [...ancestors, value];
202
+ const out: Record<string, unknown> = {};
203
+ for (const k of Object.keys(value)) {
204
+ out[k] = normalize((value as Record<string, unknown>)[k], nxt);
205
+ }
206
+ return out;
207
+ }
208
+ return "[Unsupported]";
209
+ }
210
+
211
+ function sortKeysRecursive(value: unknown): unknown {
212
+ if (Array.isArray(value)) return value.map(sortKeysRecursive);
213
+ if (value !== null && typeof value === "object") {
214
+ const src = value as Record<string, unknown>;
215
+ const out: Record<string, unknown> = {};
216
+ for (const k of Object.keys(src).sort()) out[k] = sortKeysRecursive(src[k]);
217
+ return out;
218
+ }
219
+ return value;
220
+ }
221
+
222
+ /** Compact, sorted-key JSON — the ONE spelling used for a native (non-string)
223
+ * message AND for the context sub-object, in BOTH text and json format. */
224
+ function compactJson(value: unknown): string {
225
+ return JSON.stringify(sortKeysRecursive(value));
226
+ }
227
+
228
+ /**
229
+ * The message field's string spelling (LOG-F04/F05). A raw string message is
230
+ * used verbatim. A Buffer/Uint8Array message goes through the same
231
+ * decode-or-describe binary handling. Everything else is normalized then
232
+ * rendered as compact sorted-key JSON — even in TEXT format, so an object
233
+ * message is never "[object Object]".
234
+ */
235
+ function messageToString(rawMessage: unknown): string {
236
+ if (typeof rawMessage === "string") return rawMessage;
237
+ if (Buffer.isBuffer(rawMessage)) return decodeMaybeBinary(rawMessage);
238
+ if (rawMessage instanceof Uint8Array) return decodeMaybeBinary(Buffer.from(rawMessage));
239
+ const normalized = normalize(rawMessage);
240
+ // A non-string/non-buffer raw value can only normalize to a STRING via one
241
+ // of normalize()'s own marker paths ("[Circular]", "[Unsupported]", or a
242
+ // binary-decode result) -- numbers/booleans/null/arrays/objects never
243
+ // become strings through ordinary normalization. Use that marker directly;
244
+ // routing it through compactJson would re-encode it as a JSON string
245
+ // literal (wrapping it in quotes), which is wrong for LOG-F10.
246
+ if (typeof normalized === "string") return normalized;
247
+ return compactJson(normalized);
248
+ }
249
+
250
+ /**
251
+ * Single combined-pass escape - backslash, CR, LF - so one log call is
252
+ * exactly one physical LF-terminated line (LOG-F06). A regex with a replacer
253
+ * function makes one pass over the ORIGINAL string and cannot re-scan its own
254
+ * output, unlike sequential string-replace calls (fix backslashes, THEN fix
255
+ * newlines), which would double-escape a backslash a newline fix just wrote.
256
+ */
257
+ function escapeText(str: string): string {
258
+ return str.replace(/[\\\r\n]/g, (c) => (c === "\\" ? "\\\\" : c === "\r" ? "\\r" : "\\n"));
259
+ }
260
+
261
+ // ============================================================================
262
+ // Canonical event (Decision 15/23) — json_key_order: timestamp, level,
263
+ // message, request_id, function, context.
264
+ // ============================================================================
265
+
266
+ interface LogEvent {
27
267
  timestamp: string;
28
- level: LogLevel;
268
+ level: EventLevel;
29
269
  message: string;
30
270
  request_id?: string;
31
271
  function?: string;
32
272
  context?: unknown;
33
273
  }
34
274
 
275
+ function buildEvent(
276
+ level: EventLevel,
277
+ rawMessage: unknown,
278
+ requestId: string | undefined,
279
+ callerName: string | undefined,
280
+ rawContext: unknown,
281
+ ): LogEvent {
282
+ const event: LogEvent = {
283
+ timestamp: new Date().toISOString(),
284
+ level,
285
+ message: messageToString(rawMessage),
286
+ };
287
+ if (requestId) event.request_id = requestId;
288
+ if (callerName) event.function = callerName;
289
+ // Sort recursively at BUILD time (not just when rendering text) so the
290
+ // JSON encoder -- which stringifies the event object directly rather than
291
+ // routing through compactJson -- also gets sorted keys (LOG-F03).
292
+ if (rawContext !== undefined) event.context = sortKeysRecursive(normalize(rawContext));
293
+ return event;
294
+ }
295
+
296
+ function encodeJson(event: LogEvent): string {
297
+ return `${JSON.stringify(event)}\n`;
298
+ }
299
+
300
+ function encodeText(event: LogEvent): string {
301
+ const paddedLevel = event.level.padEnd(8);
302
+ const reqPart = event.request_id ? ` [${event.request_id}]` : "";
303
+ const fnPart = event.function ? ` [${event.function}]` : "";
304
+ const ctxPart = event.context !== undefined ? ` ${compactJson(event.context)}` : "";
305
+ return `${event.timestamp} [${paddedLevel}]${reqPart}${fnPart} ${escapeText(event.message)}${ctxPart}\n`;
306
+ }
307
+
308
+ function encode(event: LogEvent, format: "text" | "json"): string {
309
+ return format === "json" ? encodeJson(event) : encodeText(event);
310
+ }
311
+
312
+ function sha256Hex(text: string): string {
313
+ return createHash("sha256").update(Buffer.from(text, "utf-8")).digest("hex");
314
+ }
315
+
35
316
  /**
36
- * Log frames we walk past when looking for the real caller of a Log.* call.
37
- * Mirrors Python's `_OWN_FRAMES`. Anything matching is treated as internal.
317
+ * Replace an oversized encoded record with a small, valid, self-describing
318
+ * replacement carrying the ORIGINAL byte length and its sha256 (LOG-F12) — a
319
+ * witness an operator can use to prove nothing silently vanished, without
320
+ * ever storing the too-large payload itself.
38
321
  */
322
+ function overflowRecord(original: LogEvent, encoded: string, format: "text" | "json"): string {
323
+ const replacement: LogEvent = {
324
+ timestamp: original.timestamp,
325
+ level: original.level,
326
+ message: OVERFLOW_MESSAGE,
327
+ };
328
+ if (original.request_id) replacement.request_id = original.request_id;
329
+ if (original.function) replacement.function = original.function;
330
+ replacement.context = {
331
+ truncated: true,
332
+ original_bytes: Buffer.byteLength(encoded, "utf-8"),
333
+ sha256: sha256Hex(encoded),
334
+ };
335
+ return encode(replacement, format);
336
+ }
337
+
338
+ function boundedForSink(event: LogEvent, format: "text" | "json", maxBytes: number): string {
339
+ const encoded = encode(event, format);
340
+ if (Buffer.byteLength(encoded, "utf-8") <= maxBytes) return encoded;
341
+ return overflowRecord(event, encoded, format);
342
+ }
343
+
344
+ // ============================================================================
345
+ // Real caller-name capture (Decision 16) — TINA4_LOG_FUNC opt-in.
346
+ // ============================================================================
347
+
39
348
  const OWN_FRAMES = new Set<string>([
40
- "log", "Log.log",
41
- "callerName", "Log.callerName",
42
- "info", "debug", "warning", "warn", "error", "critical",
43
- "Log.info", "Log.debug", "Log.warning", "Log.warn", "Log.error", "Log.critical",
349
+ "log", "Log.log", "emit", "Log.emit",
350
+ "callerName", "Log.callerName", "resolveCallerName", "Log.resolveCallerName",
351
+ "info", "debug", "warning", "error", "critical",
352
+ "Log.info", "Log.debug", "Log.warning", "Log.error", "Log.critical",
44
353
  ]);
45
-
46
- /** V8 stack-trace markers that mean "no real function name". */
47
354
  const ANON_NAMES = new Set<string>(["", "anonymous", "<anonymous>"]);
48
355
 
49
- /**
50
- * Return the function name that called Log.{info,debug,warning,error}.
51
- *
52
- * Active only when `TINA4_LOG_FUNC=true` — captures `new Error().stack`,
53
- * walks past Log's own frames (info / warn / error / debug / log /
54
- * callerName) and returns the first user function name. Anonymous frames
55
- * (`anonymous`, `<anonymous>`, bare file paths) are filtered out as noise.
56
- * Returns undefined on any error — never throws. Parity feature #41 across
57
- * all four Tina4 frameworks.
58
- */
59
- function callerName(): string | undefined {
60
- // Read the env directly (not via Env.bool) to keep the logger ↔ env cycle
61
- // one-way: env helpers depend on Log, not the other way around.
62
- const raw = (process.env.TINA4_LOG_FUNC ?? "").trim().toLowerCase();
63
- if (raw !== "1" && raw !== "true" && raw !== "on" && raw !== "yes" && raw !== "y" && raw !== "t") {
64
- return undefined;
65
- }
356
+ function resolveCallerName(): string | undefined {
66
357
  try {
67
358
  const stack = new Error().stack;
68
359
  if (!stack) return undefined;
69
360
  const lines = stack.split("\n");
70
- // Skip line 0 ("Error") and walk frames. Cap at 32 to defend against
71
- // pathological recursion / wrapper stacks.
72
361
  for (let i = 1; i < lines.length && i < 32; i++) {
73
- const line = lines[i];
74
- // V8 frame: " at functionName (file:line:col)"
75
- // or " at file:line:col" (anonymous)
76
- // or " at async functionName (...)"
77
- const m = line.match(/^\s+at\s+(?:async\s+)?([^\s(]+)\s*\(/);
362
+ const m = lines[i].match(/^\s+at\s+(?:async\s+)?([^\s(]+)\s*\(/);
78
363
  if (!m) continue;
79
364
  const name = m[1];
80
- // Strip the leading `Object.` / class prefix some V8s emit, then check.
81
365
  const bare = name.includes(".") ? name.split(".").pop()! : name;
82
366
  if (OWN_FRAMES.has(name) || OWN_FRAMES.has(bare)) continue;
83
367
  if (ANON_NAMES.has(bare)) continue;
@@ -89,616 +373,652 @@ function callerName(): string | undefined {
89
373
  }
90
374
  }
91
375
 
92
- /** ANSI color codes for terminal output */
93
- const COLORS: Record<LogLevel, string> = {
94
- DEBUG: "\x1b[36m", // cyan
95
- INFO: "\x1b[32m", // green
96
- WARNING: "\x1b[33m", // yellow
97
- ERROR: "\x1b[31m", // red
98
- CRITICAL: "\x1b[35m", // magenta
99
- };
100
- const RESET = "\x1b[0m";
376
+ // ============================================================================
377
+ // A real, working lock (Decision 20) — SharedArrayBuffer + Atomics, Node's
378
+ // actual synchronous cross-thread blocking primitive. On the ordinary main
379
+ // thread there is no contention to protect against (log() never awaits, so
380
+ // nothing can preempt a write mid-flight) but the SAME primitive is what
381
+ // makes the lock genuinely acquirable/contendable from a real
382
+ // worker_threads.Worker when one is handed this buffer — see
383
+ // logger_fixture_contract.test.ts's LOG-R07 and LOG-E05 cases, which do
384
+ // exactly that instead of simulating contention.
385
+ // ============================================================================
386
+
387
+ const LOCK_UNLOCKED = 0;
388
+ const LOCK_LOCKED = 1;
389
+
390
+ class SinkLock {
391
+ readonly buffer: SharedArrayBuffer;
392
+ private readonly view: Int32Array;
393
+
394
+ constructor(buffer?: SharedArrayBuffer) {
395
+ this.buffer = buffer ?? new SharedArrayBuffer(4);
396
+ this.view = new Int32Array(this.buffer);
397
+ }
101
398
 
102
- /** Regex to strip ANSI escape codes */
103
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
399
+ /** Blocks THIS thread (Atomics.wait) until acquired or the timeout elapses. */
400
+ tryAcquire(timeoutMs: number): boolean {
401
+ const deadline = Date.now() + timeoutMs;
402
+ for (;;) {
403
+ if (Atomics.compareExchange(this.view, 0, LOCK_UNLOCKED, LOCK_LOCKED) === LOCK_UNLOCKED) {
404
+ return true;
405
+ }
406
+ const remaining = deadline - Date.now();
407
+ if (remaining <= 0) return false;
408
+ // Atomics.wait blocks the calling thread for real (main thread or a
409
+ // worker) until the value changes or the timeout elapses — this is
410
+ // what makes the wait bounded rather than a busy spin.
411
+ Atomics.wait(this.view, 0, LOCK_LOCKED, Math.min(remaining, 25));
412
+ }
413
+ }
104
414
 
105
- /** Default log directory */
106
- const DEFAULT_LOG_DIR = "logs";
415
+ release(): void {
416
+ Atomics.store(this.view, 0, LOCK_UNLOCKED);
417
+ Atomics.notify(this.view, 0);
418
+ }
419
+ }
107
420
 
108
- /** Default log filename */
109
- const DEFAULT_LOG_FILE = "tina4.log";
421
+ // ============================================================================
422
+ // File sink — predictive rotation (check BEFORE append: current + next >
423
+ // rotate_size; exact equality does NOT rotate), bounded lock acquisition.
424
+ // ============================================================================
425
+
426
+ class LogFileSink {
427
+ readonly path: string;
428
+ private rotateSize: number;
429
+ private rotateKeep: number;
430
+ private lock: SinkLock;
431
+
432
+ constructor(path: string, rotateSize: number, rotateKeep: number, lockBuffer?: SharedArrayBuffer) {
433
+ this.path = path;
434
+ this.rotateSize = rotateSize;
435
+ this.rotateKeep = rotateKeep;
436
+ this.lock = new SinkLock(lockBuffer);
437
+ }
110
438
 
111
- /** Default rotation size 10 MB */
112
- const DEFAULT_ROTATE_SIZE = 10 * 1024 * 1024;
439
+ /** Exposed only so a conformance test can hand the SAME lock to a real
440
+ * worker_threads.Worker and prove real contention/timeout (LOG-E05). */
441
+ get lockBuffer(): SharedArrayBuffer {
442
+ return this.lock.buffer;
443
+ }
113
444
 
114
- /** Default rotation keep count */
115
- const DEFAULT_ROTATE_KEEP = 5;
445
+ /** Create the directory and prove the file is writable (LOG-E01: configure()
446
+ * itself proves the sink opens, before any snapshot is committed). */
447
+ open(): void {
448
+ try {
449
+ mkdirSync(dirname(this.path), { recursive: true });
450
+ const fd = openSync(this.path, "a");
451
+ closeSync(fd);
452
+ } catch (err) {
453
+ throw new LogConfigurationError(`cannot open log sink ${this.path}: ${(err as Error).message}`, {
454
+ sink: this.path,
455
+ operation: "open",
456
+ });
457
+ }
458
+ }
116
459
 
117
- /** Strip ANSI escape codes from a string */
118
- function stripAnsi(text: string): string {
119
- return text.replace(ANSI_RE, "");
120
- }
460
+ private rotateIfNeeded(nextRecordBytes: number): void {
461
+ let currentSize = 0;
462
+ try {
463
+ currentSize = existsSync(this.path) ? statSync(this.path).size : 0;
464
+ } catch {
465
+ currentSize = 0;
466
+ }
467
+ if (currentSize === 0) return;
468
+ if (currentSize + nextRecordBytes <= this.rotateSize) return;
121
469
 
122
- /**
123
- * Resolve the log file path from env (or constructor options).
124
- *
125
- * If `TINA4_LOG_FILE` is set:
126
- * - absolute path → used as-is.
127
- * - relative → resolved against `TINA4_LOG_DIR` (default `logs`).
128
- *
129
- * Otherwise the directory is `TINA4_LOG_DIR` and filename is `tina4.log`.
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;
470
+ if (this.rotateKeep <= 0) {
471
+ try {
472
+ unlinkSync(this.path);
473
+ } catch {
474
+ /* nothing to discard */
475
+ }
476
+ return;
477
+ }
136
478
 
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") {
479
+ const oldest = `${this.path}.${this.rotateKeep}`;
480
+ if (existsSync(oldest)) {
481
+ try {
482
+ unlinkSync(oldest);
483
+ } catch {
484
+ /* best effort */
485
+ }
486
+ }
487
+ for (let n = this.rotateKeep - 1; n >= 1; n--) {
488
+ const src = `${this.path}.${n}`;
489
+ const dst = `${this.path}.${n + 1}`;
490
+ if (existsSync(src)) {
491
+ try {
492
+ renameSync(src, dst);
493
+ } catch {
494
+ /* best effort */
495
+ }
496
+ }
497
+ }
157
498
  try {
158
- text = JSON.stringify(message) ?? String(message);
499
+ renameSync(this.path, `${this.path}.1`);
159
500
  } catch {
160
- text = String(message);
501
+ /* best effort */
502
+ }
503
+ }
504
+
505
+ /** Append one complete encoded record, rotating first if it would cross
506
+ * the threshold. Raises LogWriteError (timeout/write) to the caller, which
507
+ * applies the sink failure policy (strict/non-strict). */
508
+ write(encodedLine: string): void {
509
+ const acquired = this.lock.tryAcquire(LOCK_TIMEOUT_MS);
510
+ if (!acquired) {
511
+ throw new LogWriteError(`timed out acquiring the log sink lock for ${this.path}`, {
512
+ sink: this.path,
513
+ operation: "lock",
514
+ });
515
+ }
516
+ try {
517
+ const payload = Buffer.from(encodedLine.replace(ANSI_RE, ""), "utf-8");
518
+ this.rotateIfNeeded(payload.byteLength);
519
+ appendFileSync(this.path, payload);
520
+ } catch (err) {
521
+ if (err instanceof LogWriteError) throw err;
522
+ throw new LogWriteError(`cannot write log sink ${this.path}: ${(err as Error).message}`, {
523
+ sink: this.path,
524
+ operation: "write",
525
+ });
526
+ } finally {
527
+ this.lock.release();
161
528
  }
162
- } else {
163
- text = String(message);
164
529
  }
165
- return text.replace(CONTROL_CHARS, "");
166
530
  }
167
531
 
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)`;
532
+ // ============================================================================
533
+ // Configuration resolution
534
+ // ============================================================================
535
+
536
+ interface ConfigureOptions {
537
+ logDir?: string;
538
+ logFile?: string;
539
+ level?: string;
540
+ fileLevel?: string;
541
+ format?: string;
542
+ output?: string;
543
+ rotateSize?: number;
544
+ rotateKeep?: number;
545
+ strict?: boolean;
546
+ caller?: boolean;
172
547
  }
173
548
 
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
- */
549
+ interface Snapshot {
550
+ level: LogLevel;
551
+ fileLevel: LogLevel;
552
+ format: "text" | "json";
553
+ stdoutEnabled: boolean;
554
+ fileEnabled: boolean;
555
+ outputSelector: "stdout" | "file" | "both";
556
+ logDir: string;
557
+ logFile: string | null;
558
+ layout: "directory" | "single";
559
+ rotateSize: number;
560
+ rotateKeep: number;
561
+ strict: boolean;
562
+ callerCapture: boolean;
563
+ mainSink: LogFileSink | null;
564
+ errorSink: LogFileSink | null;
565
+ }
566
+
567
+ /** Is this target a FILE PATH or a DIRECTORY? Identical rule in all four
568
+ * frameworks: an existing directory is always a directory; otherwise a
569
+ * basename with an extension is a file. */
183
570
  function targetIsFile(path: string): boolean {
184
571
  try {
185
572
  if (existsSync(path) && statSync(path).isDirectory()) return false;
186
- } catch { /* fall through to the extension test */ }
187
- const base = path.split(/[\\/]/).pop() ?? "";
573
+ } catch {
574
+ /* fall through to the extension test */
575
+ }
576
+ const base = basename(path);
188
577
  return base.includes(".") && !base.startsWith(".");
189
578
  }
190
579
 
191
- function resolveLogFilePath(logDir: string, logFile: string): string {
192
- if (isAbsolute(logFile)) return logFile;
193
- return join(logDir, logFile);
580
+ function resolveBool(explicit: boolean | undefined, envName: string, fallback: boolean): boolean {
581
+ if (explicit !== undefined) {
582
+ if (typeof explicit !== "boolean") {
583
+ throw new LogConfigurationError(`${envName.replace("TINA4_LOG_", "").toLowerCase()} must be a boolean`, {
584
+ setting: envName,
585
+ value: explicit,
586
+ });
587
+ }
588
+ return explicit;
589
+ }
590
+ const raw = process.env[envName];
591
+ if (raw === undefined) return fallback;
592
+ const key = raw.trim().toLowerCase();
593
+ const truthy = ["1", "true", "on", "yes", "y", "t"];
594
+ const falsy = ["0", "false", "off", "no", "n", "f", ""];
595
+ if (truthy.includes(key)) return true;
596
+ if (falsy.includes(key)) return false;
597
+ throw new LogConfigurationError(`${envName}=${JSON.stringify(raw)} is not a valid boolean`, {
598
+ setting: envName,
599
+ value: raw,
600
+ accepted: [...truthy, ...falsy],
601
+ });
194
602
  }
195
603
 
196
- /**
197
- * Structured logger for Tina4.
198
- *
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.
212
- *
213
- * Default file-output rule (TINA4_LOG_OUTPUT unset): the log FILE is written
214
- * only in development. An explicit TINA4_LOG_OUTPUT=file/both, OR an explicit
215
- * TINA4_LOG_FILE path, always forces a file (explicit wins). stdout is ALWAYS on.
216
- *
217
- * Env vars:
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
219
- * TINA4_LOG_DIR — directory for log files (default: "logs")
220
- * TINA4_LOG_FORMAT — "text" | "json" (default: "text") — the ONLY format switch
221
- * TINA4_LOG_OUTPUT — "stdout" | "file" | "both" (default: "stdout" → file only in dev)
222
- * TINA4_LOG_ROTATE_SIZE — bytes; 0 disables rotation (default: 10485760 = 10MB)
223
- * TINA4_LOG_ROTATE_KEEP — number of historical files to keep (default: 5)
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.
229
- *
230
- * Rotation is stdlib roll-your-own:
231
- * - On each write, statSync the file. If size >= TINA4_LOG_ROTATE_SIZE, rotate.
232
- * - app.log.{N-1} → app.log.{N}, …, app.log → app.log.1 via fs.renameSync.
233
- * - Files beyond _KEEP are dropped via fs.unlinkSync.
234
- * - _SIZE=0 disables rotation entirely.
235
- */
236
- export class Log {
237
- private static requestId: string | undefined;
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
-
255
- /**
256
- * Re-read all log-related env vars. Called on every log() so tests that
257
- * mutate process.env between calls see the new values without having to
258
- * call configure() each time.
259
- */
260
- private static readEnv(): {
261
- logDir: string;
262
- logFile: string;
263
- rotateSize: number;
264
- rotateKeep: number;
265
- minLevel: number;
266
- format: "text" | "json";
267
- output: "stdout" | "file" | "both";
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;
273
- } {
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();
277
- const logFile = explicitFile || DEFAULT_LOG_FILE;
278
-
279
- const rawSize = process.env.TINA4_LOG_ROTATE_SIZE;
280
- let rotateSize = DEFAULT_ROTATE_SIZE;
281
- if (rawSize !== undefined) {
282
- const n = parseInt(rawSize, 10);
283
- rotateSize = isNaN(n) || n < 0 ? DEFAULT_ROTATE_SIZE : n;
604
+ function resolveInt(
605
+ explicit: number | undefined,
606
+ envName: string,
607
+ fallback: number,
608
+ minimum: number,
609
+ ): number {
610
+ if (explicit !== undefined) {
611
+ if (!Number.isInteger(explicit) || explicit < minimum) {
612
+ throw new LogConfigurationError(`${envName} must be an integer >= ${minimum}`, {
613
+ setting: envName,
614
+ value: explicit,
615
+ });
284
616
  }
617
+ return explicit;
618
+ }
619
+ const raw = process.env[envName];
620
+ if (raw === undefined) return fallback;
621
+ if (!/^-?\d+$/.test(raw.trim())) {
622
+ throw new LogConfigurationError(`${envName}=${JSON.stringify(raw)} is not an integer`, {
623
+ setting: envName,
624
+ value: raw,
625
+ });
626
+ }
627
+ const n = parseInt(raw, 10);
628
+ if (n < minimum) {
629
+ throw new LogConfigurationError(`${envName}=${n} must be >= ${minimum}`, { setting: envName, value: n });
630
+ }
631
+ return n;
632
+ }
285
633
 
286
- const rawKeep = process.env.TINA4_LOG_ROTATE_KEEP;
287
- let rotateKeep = DEFAULT_ROTATE_KEEP;
288
- if (rawKeep !== undefined) {
289
- const n = parseInt(rawKeep, 10);
290
- rotateKeep = isNaN(n) || n < 1 ? DEFAULT_ROTATE_KEEP : n;
634
+ /** rotateKeep allows 0 (Decision: rotate_keep=0 discards the old current
635
+ * entirely rather than renaming it to .1) but never negative or fractional. */
636
+ function resolveRotateKeep(explicit: number | undefined, fallback: number): number {
637
+ if (explicit !== undefined) {
638
+ if (!Number.isInteger(explicit) || explicit < 0) {
639
+ throw new LogConfigurationError("rotateKeep must be a non-negative integer", {
640
+ setting: "TINA4_LOG_ROTATE_KEEP",
641
+ value: explicit,
642
+ });
291
643
  }
644
+ return explicit;
645
+ }
646
+ const raw = process.env.TINA4_LOG_ROTATE_KEEP;
647
+ if (raw === undefined) return fallback;
648
+ if (!/^-?\d+$/.test(raw.trim())) {
649
+ throw new LogConfigurationError(`TINA4_LOG_ROTATE_KEEP=${JSON.stringify(raw)} is not an integer`, {
650
+ setting: "TINA4_LOG_ROTATE_KEEP",
651
+ value: raw,
652
+ });
653
+ }
654
+ const n = parseInt(raw, 10);
655
+ if (n < 0) {
656
+ throw new LogConfigurationError(`TINA4_LOG_ROTATE_KEEP=${n} must not be negative`, {
657
+ setting: "TINA4_LOG_ROTATE_KEEP",
658
+ value: n,
659
+ });
660
+ }
661
+ return n;
662
+ }
292
663
 
293
- // v3.13.14: default level INFO (was DEBUG) parity with Python/PHP/Ruby;
294
- // surfaces request/startup/warn/error without debug noise in deploys.
295
- const levelEnv = (process.env.TINA4_LOG_LEVEL ?? "INFO").toUpperCase();
296
- const minLevel = LEVEL_PRIORITY[levelEnv as LogLevel] ?? 0;
297
-
298
- const fmt = (process.env.TINA4_LOG_FORMAT ?? "text").trim().toLowerCase();
299
- const format: "text" | "json" = fmt === "json" ? "json" : "text";
300
-
301
- const out = (process.env.TINA4_LOG_OUTPUT ?? "stdout").trim().toLowerCase();
302
- let output: "stdout" | "file" | "both" = "stdout";
303
- if (out === "file") output = "file";
304
- else if (out === "both") output = "both";
305
-
306
- // v3.13.39: dev/prod-aware default file output (Python master, 4c6d881).
307
- // When TINA4_LOG_OUTPUT is unset (default "stdout"), the log FILE is written
308
- // only in development (TINA4_DEBUG truthy). In production / containers the
309
- // logger is stdout-only — writing logs/tina4.log inside a container just
310
- // bloats the writable layer + disk, and 12-factor wants logs on stdout for
311
- // the platform to capture. Explicit TINA4_LOG_OUTPUT=file/both OR an explicit
312
- // TINA4_LOG_FILE path always wins (explicit forces a file regardless of env).
313
- let fileEnabled: boolean;
314
- if (output === "file" || output === "both") {
315
- fileEnabled = true;
316
- } else if (explicitFile !== "") {
317
- fileEnabled = true;
318
- } else {
319
- fileEnabled = !Log.isProduction();
664
+ function checkRemovedSettings(): void {
665
+ for (const [name, detail] of Object.entries(REMOVED_SETTINGS)) {
666
+ if (process.env[name] !== undefined) {
667
+ throw new LogConfigurationError(`${name} is a removed setting (${detail})`, {
668
+ setting: name,
669
+ value: process.env[name],
670
+ });
320
671
  }
672
+ }
673
+ }
321
674
 
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);
675
+ function isProduction(): boolean {
676
+ return !isTruthy(process.env.TINA4_DEBUG);
677
+ }
327
678
 
328
- return {
329
- logDir, logFile, rotateSize, rotateKeep, minLevel, format, output, fileEnabled,
330
- explicitFile: explicitFile !== "",
331
- strict,
332
- };
679
+ /**
680
+ * Resolve one fully-validated configuration snapshot from explicit options,
681
+ * then environment, then default (ADR-0041) — WITHOUT touching the
682
+ * filesystem. Every invalid setting throws LogConfigurationError before this
683
+ * function returns, so a caller (configure() or the lazy first-use path)
684
+ * commits nothing on a validation failure (LOG-C07, LOG-V01..V05).
685
+ */
686
+ function resolveSnapshot(options: ConfigureOptions): Omit<Snapshot, "mainSink" | "errorSink"> {
687
+ checkRemovedSettings();
688
+
689
+ const level = options.level !== undefined
690
+ ? parseLevel(options.level, "level")
691
+ : process.env.TINA4_LOG_LEVEL !== undefined
692
+ ? parseLevel(process.env.TINA4_LOG_LEVEL, "TINA4_LOG_LEVEL")
693
+ : DEFAULT_LEVEL;
694
+
695
+ const fileLevel = options.fileLevel !== undefined
696
+ ? parseLevel(options.fileLevel, "fileLevel")
697
+ : process.env.TINA4_LOG_FILE_LEVEL !== undefined
698
+ ? parseLevel(process.env.TINA4_LOG_FILE_LEVEL, "TINA4_LOG_FILE_LEVEL")
699
+ : DEFAULT_FILE_LEVEL;
700
+
701
+ // Format is DEBUG-DERIVED (Decision 3): explicit TINA4_LOG_FORMAT wins;
702
+ // otherwise truthy TINA4_DEBUG selects text, else json.
703
+ let format: "text" | "json";
704
+ const explicitFormat = options.format ?? process.env.TINA4_LOG_FORMAT;
705
+ if (explicitFormat !== undefined) {
706
+ const f = explicitFormat.trim().toLowerCase();
707
+ if (f !== "text" && f !== "json") {
708
+ throw new LogConfigurationError(`TINA4_LOG_FORMAT=${JSON.stringify(explicitFormat)} is not valid`, {
709
+ setting: "TINA4_LOG_FORMAT",
710
+ value: explicitFormat,
711
+ accepted: ["text", "json"],
712
+ });
713
+ }
714
+ format = f;
715
+ } else {
716
+ format = isTruthy(process.env.TINA4_DEBUG) ? "text" : "json";
333
717
  }
334
718
 
335
- /**
336
- * The single console-threshold predicate: does a message at `level` clear
337
- * the configured minimum console level? This is the ONE place level
338
- * comparison lives both the live log() gate and the public isEnabled()
339
- * predicate call it, so they can never disagree about what actually prints.
340
- */
341
- private static passesThreshold(level: LogLevel, minLevel: number): boolean {
342
- return (LEVEL_PRIORITY[level] ?? 0) >= minLevel;
719
+ // Output: an explicit value (stdout/file/both) ALWAYS wins, full stop —
720
+ // naming a file (below) never itself enables the file sink (LOG-C08).
721
+ let outputSelector: "stdout" | "file" | "both";
722
+ const explicitOutput = options.output ?? process.env.TINA4_LOG_OUTPUT;
723
+ if (explicitOutput !== undefined) {
724
+ const o = explicitOutput.trim().toLowerCase();
725
+ if (o !== "stdout" && o !== "file" && o !== "both") {
726
+ throw new LogConfigurationError(`TINA4_LOG_OUTPUT=${JSON.stringify(explicitOutput)} is not valid`, {
727
+ setting: "TINA4_LOG_OUTPUT",
728
+ value: explicitOutput,
729
+ accepted: ["stdout", "file", "both"],
730
+ });
731
+ }
732
+ outputSelector = o;
733
+ } else {
734
+ // Unset: dev/prod-aware default — file only in development.
735
+ outputSelector = isProduction() ? "stdout" : "both";
343
736
  }
737
+ const stdoutEnabled = outputSelector !== "file";
738
+ const fileEnabled = outputSelector !== "stdout";
344
739
 
345
- /**
346
- * Return true if a message at `level` would pass the configured minimum
347
- * console level (TINA4_LOG_LEVEL) the same threshold that gates stdout.
348
- *
349
- * This reflects CONSOLE (stdout) visibility only. The log file always
350
- * records every level regardless of this threshold, so don't use it to
351
- * decide whether something gets persisted — use it to skip building an
352
- * expensive payload that would not be shown:
353
- *
354
- * if (Log.isEnabled("debug")) {
355
- * Log.debug("state", expensiveSnapshot());
356
- * }
357
- *
358
- * `level` is case-insensitive. "critical" is the highest severity (priority
359
- * 4 > error 3) and flows through the ordinary threshold check like every
360
- * other level — there is no toggle. It reuses the same passesThreshold()
361
- * check the logger itself uses, so it never drifts from what print does.
362
- */
363
- static isEnabled(level: string): boolean {
364
- const cfg = Log.readEnv();
365
- const lvl = (level ?? "").toUpperCase() as LogLevel;
366
- return Log.passesThreshold(lvl, cfg.minLevel);
740
+ const rotateSize = resolveInt(options.rotateSize, "TINA4_LOG_ROTATE_SIZE", DEFAULT_ROTATE_SIZE, MIN_ROTATE_SIZE);
741
+ const rotateKeep = resolveRotateKeep(options.rotateKeep, DEFAULT_ROTATE_KEEP);
742
+ const strict = resolveBool(options.strict, "TINA4_LOG_STRICT", false);
743
+ const callerCapture = resolveBool(options.caller, "TINA4_LOG_FUNC", false);
744
+
745
+ const dirRaw = options.logDir ?? process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
746
+ if (dirRaw === "") {
747
+ throw new LogConfigurationError("logDir must not be empty", { setting: "TINA4_LOG_DIR", value: dirRaw });
748
+ }
749
+ const fileRaw = options.logFile ?? process.env.TINA4_LOG_FILE ?? "";
750
+ // A NUL byte can never be part of a real path (the underlying syscalls
751
+ // reject it), but that would otherwise only surface at OPEN time -- and
752
+ // only when the file sink ends up enabled. Reject it here, unconditionally,
753
+ // so a malformed path fails CONFIGURATION regardless of whether output
754
+ // happens to resolve to a sink that would ever touch the filesystem.
755
+ if (dirRaw.includes("\0") || fileRaw.includes("\0")) {
756
+ throw new LogConfigurationError("log path must not contain a NUL byte", {
757
+ setting: dirRaw.includes("\0") ? "TINA4_LOG_DIR" : "TINA4_LOG_FILE",
758
+ });
367
759
  }
368
760
 
369
- /**
370
- * Set the current request ID for log correlation.
371
- */
372
- static setRequestId(id: string | undefined): void {
373
- Log.requestId = id;
761
+ const projectRoot = process.cwd();
762
+ let dirCandidate = dirRaw;
763
+ let fileCandidate = fileRaw;
764
+ if (!fileCandidate && targetIsFile(dirCandidate)) {
765
+ fileCandidate = basename(dirCandidate);
766
+ dirCandidate = dirname(dirCandidate);
374
767
  }
375
768
 
376
- /**
377
- * Get the current request ID.
378
- */
379
- static getRequestId(): string | undefined {
380
- return Log.requestId;
769
+ const resolvedLogDir = (isAbsolute(dirCandidate) ? dirCandidate : join(projectRoot, dirCandidate)).replace(/\/$/, "");
770
+
771
+ let resolvedLogFile: string | null;
772
+ let layout: "directory" | "single";
773
+ if (fileCandidate) {
774
+ resolvedLogFile = isAbsolute(fileCandidate) ? fileCandidate : join(resolvedLogDir, fileCandidate);
775
+ layout = "single";
776
+ } else {
777
+ resolvedLogFile = null;
778
+ layout = "directory";
381
779
  }
382
780
 
383
- /**
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;
781
+ return {
782
+ level,
783
+ fileLevel,
784
+ format,
785
+ stdoutEnabled,
786
+ fileEnabled,
787
+ outputSelector,
788
+ logDir: resolvedLogDir,
789
+ logFile: resolvedLogFile,
790
+ layout,
791
+ rotateSize,
792
+ rotateKeep,
793
+ strict,
794
+ callerCapture,
795
+ };
796
+ }
797
+
798
+ // ============================================================================
799
+ // Log the public class.
800
+ // ============================================================================
801
+
802
+ let activeSnapshot: Snapshot | null = null;
803
+
804
+ /** Lazily resolve (and CACHE) the effective configuration on first use
805
+ * (LOG-C01/L2). Once resolved the snapshot is STABLE: a later environment
806
+ * mutation does not retroactively change it (LOG-C05) until reset() (LOG-C06). */
807
+ function ensureSnapshot(): Snapshot {
808
+ if (activeSnapshot) return activeSnapshot;
809
+ const resolved = resolveSnapshot({});
810
+ activeSnapshot = openSinks(resolved);
811
+ return activeSnapshot;
812
+ }
813
+
814
+ function openSinks(resolved: Omit<Snapshot, "mainSink" | "errorSink">): Snapshot {
815
+ let mainSink: LogFileSink | null = null;
816
+ let errorSink: LogFileSink | null = null;
817
+ if (resolved.fileEnabled) {
818
+ if (resolved.layout === "single") {
819
+ mainSink = new LogFileSink(resolved.logFile!, resolved.rotateSize, resolved.rotateKeep);
820
+ mainSink.open();
821
+ } else {
822
+ mainSink = new LogFileSink(join(resolved.logDir, DEFAULT_LOG_FILE_NAME), resolved.rotateSize, resolved.rotateKeep);
823
+ mainSink.open();
824
+ errorSink = new LogFileSink(join(resolved.logDir, "error.log"), resolved.rotateSize, resolved.rotateKeep);
825
+ errorSink.open();
415
826
  }
416
- Log.applyAppendMode();
417
827
  }
828
+ return { ...resolved, mainSink, errorSink };
829
+ }
830
+
831
+ function stdoutIsTty(): boolean {
832
+ return process.stdout.isTTY === true;
833
+ }
418
834
 
835
+ export class Log {
419
836
  /**
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().
837
+ * Configure the logger. Two-phase and transactional: the WHOLE candidate
838
+ * configuration is resolved and validated first (LOG-C07 a bad setting
839
+ * never touches the filesystem and never disturbs the prior snapshot),
840
+ * then its sinks are opened (LOG-E01 — an inaccessible sink fails
841
+ * configuration, still without replacing the prior snapshot), and only on
842
+ * full success does it become the active configuration.
422
843
  *
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.
844
+ * Log.configure() -> logs/tina4.log + logs/error.log
845
+ * Log.configure({ logDir: "/var/log/myapp" }) -> /var/log/myapp/tina4.log + error.log
846
+ * Log.configure({ logFile: "/var/log/myapp/app.log" }) -> that exact file, no error.log sibling
847
+ * Log.configure({ level: "debug", format: "text", output: "both", strict: true, caller: true })
429
848
  *
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.
438
- */
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.
849
+ * A plain string is also accepted as shorthand for `{ logDir: string }` —
850
+ * or `{ logFile: string }` when it looks like a file (has an extension and
851
+ * is not an existing directory), via the same targetIsFile heuristic
852
+ * configure() itself uses to split a bare TINA4_LOG_DIR that names a file.
446
853
  *
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.
854
+ * Log.configure("/var/log/myapp") -> same as { logDir: "/var/log/myapp" }
855
+ * Log.configure("/var/log/myapp/app.log") -> same as { logFile: "/var/log/myapp/app.log" }
451
856
  */
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
- }
857
+ static configure(options: string | ConfigureOptions = {}): void {
858
+ const normalized: ConfigureOptions = typeof options === "string"
859
+ ? (targetIsFile(options) ? { logFile: options } : { logDir: options })
860
+ : options;
861
+ const resolved = resolveSnapshot(normalized);
862
+ activeSnapshot = openSinks(resolved);
470
863
  }
471
864
 
472
- /** Log an informational message. */
473
- static info(message: unknown, data?: unknown): void {
474
- Log.log("INFO", message, data);
865
+ /** Forget the active configuration; the next use resolves fresh from the
866
+ * environment. Idempotent. */
867
+ static reset(): void {
868
+ activeSnapshot = null;
869
+ requestIdStore.disable?.();
870
+ requestIdFallback = undefined;
475
871
  }
476
872
 
477
- /** Log a debug message. */
478
- static debug(message: unknown, data?: unknown): void {
479
- Log.log("DEBUG", message, data);
873
+ /** A defensive copy of the effective, stable configuration (LOG-C10). */
874
+ static configuration(): Record<string, unknown> {
875
+ const s = ensureSnapshot();
876
+ return {
877
+ level: s.level,
878
+ file_level: s.fileLevel,
879
+ format: s.format,
880
+ output: s.outputSelector,
881
+ log_dir: s.logDir,
882
+ log_file: s.logFile,
883
+ layout: s.layout,
884
+ rotate_size: s.rotateSize,
885
+ rotate_keep: s.rotateKeep,
886
+ strict: s.strict,
887
+ caller: s.callerCapture,
888
+ stdout_enabled: s.stdoutEnabled,
889
+ file_enabled: s.fileEnabled,
890
+ };
480
891
  }
481
892
 
482
- /** Log a warning message. */
483
- static warning(message: unknown, data?: unknown): void {
484
- Log.log("WARNING", message, data);
485
- }
893
+ // ── request id (Decision 12 — AsyncLocalStorage) ─────────────────────
486
894
 
487
- /** Backwards-compat alias for warning(). */
488
- static warn(message: unknown, data?: unknown): void {
489
- Log.log("WARNING", message, data);
895
+ /** Run `fn` with `id` established as the request-scoped correlation id.
896
+ * Every log line inside `fn` — across every await — carries `id`, and two
897
+ * concurrent requests each keep their own; the id needs no explicit clear
898
+ * because it is scoped to this call, not to shared mutable state. */
899
+ static runWithRequestId<T>(id: string | undefined, fn: () => T): T {
900
+ return requestIdStore.run({ id }, fn);
490
901
  }
491
902
 
492
- /** Log an error message. */
493
- static error(message: unknown, data?: unknown): void {
494
- Log.log("ERROR", message, data);
903
+ static setRequestId(id: string | undefined): void {
904
+ const store = requestIdStore.getStore();
905
+ if (store) store.id = id;
906
+ else requestIdFallback = id;
495
907
  }
496
908
 
497
- /**
498
- * Log a critical message. CRITICAL is the highest severity (priority 4 >
499
- * error 3) and ALWAYS emits like every other level — subject only to the
500
- * console threshold, which it always passes at normal levels — and is always
501
- * persisted to the log file (Node tees every level to a single tina4.log;
502
- * critical 4 >= warning 2 so it would be in error.log on a split-file model).
503
- * Matches Python master parity — there is no enable toggle.
504
- */
505
- static critical(message: unknown, data?: unknown): void {
506
- Log.log("CRITICAL", message, data);
909
+ static getRequestId(): string | undefined {
910
+ const store = requestIdStore.getStore();
911
+ return store ? store.id : requestIdFallback;
507
912
  }
508
913
 
509
- /** Check if running in production mode (TINA4_DEBUG is not truthy). */
510
- private static isProduction(): boolean {
511
- return !isTruthy(process.env.TINA4_DEBUG);
914
+ /** Explicitly end a request's correlation scope (LOG-Q01/A01 public
915
+ * surface). Equivalent to setRequestId(undefined) but named for parity
916
+ * with the other three frameworks' clear_request_id. */
917
+ static clearRequestId(): void {
918
+ Log.setRequestId(undefined);
512
919
  }
513
920
 
514
- /** Get current ISO timestamp */
515
- private static timestamp(): string {
516
- return new Date().toISOString();
921
+ static sanitizeRequestId(value: string | string[] | undefined | null): string | undefined {
922
+ if (value === undefined || value === null) return undefined;
923
+ const raw = Array.isArray(value) ? value.join(",") : value;
924
+ if (raw.length === 0 || raw.length > 128) return undefined;
925
+ if (/[^A-Za-z0-9._-]/.test(raw)) return undefined;
926
+ return raw;
517
927
  }
518
928
 
519
- /** Ensure the log directory exists */
520
- private static ensureLogDir(filePath: string): void {
521
- const dir = dirname(filePath);
522
- if (!existsSync(dir)) {
523
- mkdirSync(dir, { recursive: true });
524
- }
525
- }
929
+ // ── threshold ──────────────────────────────────────────────────────
526
930
 
527
931
  /**
528
- * Roll-your-own rotation, stdlib only.
529
- *
530
- * Sequence on each write:
531
- * 1. statSync the current file. If size < rotateSize, return.
532
- * 2. Drop any file beyond keep via unlinkSync (cap the historical count).
533
- * 3. Atomic shift: app.log.{N-1} → app.log.{N}, …, app.log.1 → app.log.2.
534
- * 4. Rename current app.log → app.log.1.
535
- * 5. Truncate via writeFileSync(path, "") so subsequent appends start fresh.
536
- *
537
- * Sync calls per write are fine — the worst case is contention on a single
538
- * file, and the OS atomically serialises rename/unlink anyway.
539
- *
540
- * `rotateSize` of 0 disables rotation entirely.
932
+ * True when `level` passes the queried sink's threshold and that sink is
933
+ * active. `sink` is undefined/"console"/"stdout" (console — the historical
934
+ * meaning) or "file" (Decision 8: the file sink has its own independent
935
+ * TINA4_LOG_FILE_LEVEL threshold).
541
936
  */
542
- private static rotateIfNeeded(filePath: string, rotateSize: number, rotateKeep: number): void {
543
- if (rotateSize <= 0) return;
544
- if (!existsSync(filePath)) return;
545
-
546
- let size = 0;
547
- try {
548
- size = statSync(filePath).size;
549
- } catch {
550
- return;
937
+ static isEnabled(level: string, sink?: "console" | "stdout" | "file"): boolean {
938
+ if (level === undefined || level === null) {
939
+ throw new LogArgumentError("isEnabled requires a level", { argument: "level" });
551
940
  }
552
- if (size < rotateSize) return;
553
-
554
- // Drop files beyond keep
555
- for (let n = rotateKeep + 1; n <= rotateKeep + 10; n++) {
556
- const stale = `${filePath}.${n}`;
557
- if (existsSync(stale)) {
558
- try { unlinkSync(stale); } catch { /* ignore */ }
559
- } else {
560
- break;
561
- }
941
+ const key = String(level).trim().toUpperCase() as LogLevel;
942
+ if (!(key in LEVELS)) {
943
+ throw new LogArgumentError(`${JSON.stringify(level)} is not a valid level`, {
944
+ argument: "level",
945
+ accepted: Object.keys(LEVELS),
946
+ });
562
947
  }
563
-
564
- // Drop the oldest in-window file if at capacity
565
- const oldest = `${filePath}.${rotateKeep}`;
566
- if (existsSync(oldest)) {
567
- try { unlinkSync(oldest); } catch { /* ignore */ }
948
+ const s = ensureSnapshot();
949
+ if (sink === undefined || sink === "console" || sink === "stdout") {
950
+ return s.stdoutEnabled && LEVELS[key] >= LEVELS[s.level];
568
951
  }
569
-
570
- // Shift: .{N-1} -> .{N}, ..., .1 -> .2
571
- for (let n = rotateKeep - 1; n >= 1; n--) {
572
- const src = `${filePath}.${n}`;
573
- const dst = `${filePath}.${n + 1}`;
574
- if (existsSync(src)) {
575
- try { renameSync(src, dst); } catch { /* ignore */ }
576
- }
952
+ if (sink === "file") {
953
+ return s.fileEnabled && LEVELS[key] >= LEVELS[s.fileLevel];
577
954
  }
578
-
579
- // Move current → .1, then truncate
580
- try { renameSync(filePath, `${filePath}.1`); } catch { /* ignore */ }
581
- try { writeFileSync(filePath, "", "utf-8"); } catch { /* ignore */ }
955
+ throw new LogArgumentError(`${JSON.stringify(sink)} is not a valid sink`, {
956
+ argument: "sink",
957
+ accepted: ["console", "file"],
958
+ });
582
959
  }
583
960
 
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 {
599
- try {
600
- Log.ensureLogDir(filePath);
601
- Log.rotateIfNeeded(filePath, rotateSize, rotateKeep);
602
- appendFileSync(filePath, stripAnsi(line) + "\n", "utf-8");
603
- } catch (err) {
604
- if (strict) throw err;
605
- // Silently fail — logging should never crash the app
606
- }
607
- }
961
+ // ── event methods (Decision 23) ──────────────────────────────────────
608
962
 
609
- /** Core log method */
610
- private static log(level: LogLevel, rawMessage: unknown, data?: unknown): void {
611
- const cfg = Log.readEnv();
963
+ static debug(message: unknown, context?: unknown): void {
964
+ Log.emit("DEBUG", message, context);
965
+ }
612
966
 
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);
967
+ static info(message: unknown, context?: unknown): void {
968
+ Log.emit("INFO", message, context);
969
+ }
617
970
 
618
- const entry: LogEntry = {
619
- timestamp: Log.timestamp(),
620
- level,
621
- message,
622
- };
971
+ static warning(message: unknown, context?: unknown): void {
972
+ Log.emit("WARNING", message, context);
973
+ }
623
974
 
624
- if (Log.requestId) {
625
- entry.request_id = Log.requestId;
626
- }
975
+ static error(message: unknown, context?: unknown): void {
976
+ Log.emit("ERROR", message, context);
977
+ }
627
978
 
628
- // Caller-name injection opt-in via TINA4_LOG_FUNC=true. Off by default
629
- // so existing log output stays byte-identical for users who haven't asked
630
- // for it. Parity feature across all four Tina4 frameworks.
631
- const fnName = callerName();
632
- if (fnName) {
633
- entry.function = fnName;
634
- }
979
+ /** Highest severity. Always emits, subject only to the configured
980
+ * threshold there is no separate enable toggle. */
981
+ static critical(message: unknown, context?: unknown): void {
982
+ Log.emit("CRITICAL", message, context);
983
+ }
635
984
 
636
- if (data !== undefined) {
637
- entry.context = data;
985
+ private static emit(level: EventLevel, rawMessage: unknown, rawContext: unknown): void {
986
+ const s = ensureSnapshot();
987
+ const consoleOk = s.stdoutEnabled && LEVELS[level] >= LEVELS[s.level];
988
+ const fileOk = s.fileEnabled && LEVELS[level] >= LEVELS[s.fileLevel];
989
+ if (!consoleOk && !fileOk) return;
990
+
991
+ const requestId = Log.getRequestId();
992
+ const callerName = s.callerCapture ? resolveCallerName() : undefined;
993
+ const event = buildEvent(level, rawMessage, requestId, callerName, rawContext);
994
+
995
+ if (consoleOk) {
996
+ const line = boundedForSink(event, s.format, STDOUT_MAX_BYTES).replace(/\n$/, "");
997
+ const plain = s.format === "json" || !stdoutIsTty();
998
+ const text = plain ? line : `${COLORS[level]}${line}${RESET}`;
999
+ // console.log (not a direct process.stdout.write) — interceptable by
1000
+ // reassigning console.log, which is how both this codebase's own tests
1001
+ // and application code conventionally capture/redirect log output; it
1002
+ // still ultimately writes to stdout when not overridden.
1003
+ console.log(text);
638
1004
  }
639
1005
 
640
- // Build human-readable line
641
- const paddedLevel = level.padEnd(8);
642
- const reqPart = Log.requestId ? ` [${Log.requestId}]` : "";
643
- const fnPart = fnName ? ` [${fnName}]` : "";
644
- const dataPart = data !== undefined ? ` ${JSON.stringify(data)}` : "";
645
- const humanLine = `${entry.timestamp} [${paddedLevel}]${reqPart}${fnPart} ${message}${dataPart}`;
646
-
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;
655
-
656
- const shouldLog = Log.passesThreshold(level, cfg.minLevel);
657
-
658
- // Console output. v3.13.14: stdout is NOT suppressed in production —
659
- // containers read PID 1 stdout (docker logs / k8s) and the old
660
- // `!isProduction()` gate meant deployed apps logged nothing.
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.
668
- if (shouldLog && cfg.output !== "file") {
669
- const consoleLine = truncateForStdout(formattedLine);
670
- if (Log.isProduction()) {
671
- console.log(consoleLine);
672
- } else {
673
- console.log(`${COLORS[level]}${consoleLine}${RESET}`);
1006
+ if (fileOk && s.mainSink) {
1007
+ const mainLine = boundedForSink(event, s.format, s.rotateSize);
1008
+ writeSink(s.mainSink, mainLine, s.strict);
1009
+ if (s.layout === "directory" && s.errorSink && LEVELS[level] >= LEVELS.WARNING) {
1010
+ writeSink(s.errorSink, mainLine, s.strict);
674
1011
  }
675
1012
  }
1013
+ }
1014
+ }
676
1015
 
677
- // File output (v3.13.39 Python master, 4c6d881): gated on cfg.fileEnabled.
678
- //
679
- // output=stdout (default): file ONLY in dev (TINA4_DEBUG truthy);
680
- // production / containers are stdout-only — no
681
- // file to bloat the writable layer / disk.
682
- // output=file file only no console (always writes a file)
683
- // output=both file + console (always writes a file)
684
- // explicit TINA4_LOG_FILE always writes a file (explicit wins)
685
- //
686
- // readEnv() resolves all of the above into cfg.fileEnabled, so flipping
687
- // that one flag gates the whole file writer (the only persisted sink).
688
- if (cfg.fileEnabled) {
689
- const filePath = resolveLogFilePath(cfg.logDir, cfg.logFile);
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
- }
702
- }
1016
+ function writeSink(sink: LogFileSink, line: string, strict: boolean): void {
1017
+ try {
1018
+ sink.write(line);
1019
+ } catch (err) {
1020
+ if (strict) throw err;
1021
+ const msg = err instanceof Error ? err.message : String(err);
1022
+ console.log(`tina4: log sink ${sink.path} failed: ${msg}`);
703
1023
  }
704
1024
  }