tina4-nodejs 3.12.3 → 3.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.12.3)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.12.4)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.12.3 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.12.4 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
@@ -1094,3 +1094,32 @@ Always read and follow the instructions in .claude/skills/tina4-developer/SKILL.
1094
1094
 
1095
1095
  ## Tina4-js Frontend Skill
1096
1096
  Always read and follow the instructions in .claude/skills/tina4-js/SKILL.md when working with tina4-js frontend code. Read its referenced files in .claude/skills/tina4-js/references/ as needed.
1097
+
1098
+ ## First Principle: Documentation Matches Code Reality
1099
+
1100
+ **This rule overrides everything else in this file.**
1101
+
1102
+ Every command, env var, method, class, or feature mentioned in any
1103
+ documentation file (`*.md` in this repo, or any tina4-book chapter,
1104
+ or `tina4-documentation/docs/`) MUST exist in code. No exceptions.
1105
+ No "we'll build it later" entries. No Laravel/Rails-style commands
1106
+ that look right but don't exist. No env vars that the framework
1107
+ doesn't actually read.
1108
+
1109
+ When you add a doc reference, add the implementation in the same PR.
1110
+ When you remove a feature, remove every doc reference in the same PR.
1111
+ When you find drift, fix it both ways: build the real thing OR delete
1112
+ the doc.
1113
+
1114
+ The `tina4-documentation/scripts/audit-truth.py` script is the source
1115
+ of truth. It runs as a CI gate (`audit-truth.yml`) on every PR — the
1116
+ build fails on CLI drift. Run it locally before pushing if you've
1117
+ touched docs:
1118
+
1119
+ ```bash
1120
+ cd /path/to/tina4-documentation
1121
+ python3 scripts/audit-truth.py --strict
1122
+ ```
1123
+
1124
+ If you're unsure whether something exists, run `tina4 <command> --help`
1125
+ or grep the framework source. Don't guess.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.12.3",
6
+ "version": "3.12.4",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript — 54 built-in features, zero dependencies",
@@ -76,11 +76,18 @@ function parseEnvContent(content: string): Record<string, string> {
76
76
  * Load environment variables from a .env file into process.env.
77
77
  * Does not override existing process.env values unless they are undefined.
78
78
  *
79
- * @param path - Path to the .env file. Defaults to ".env" in the current working directory.
79
+ * Resolution order for the env file path:
80
+ * 1. Explicit `path` argument
81
+ * 2. `TINA4_ENV_FILE` env var (if set and non-empty)
82
+ * 3. `.env` in the current working directory
83
+ *
84
+ * @param path - Path to the .env file. Optional override.
80
85
  * @returns The parsed key-value pairs, or an empty object if the file doesn't exist.
81
86
  */
82
87
  export function loadEnv(path?: string): Record<string, string> {
83
- const envPath = resolve(path ?? ".env");
88
+ const fromEnv = (process.env.TINA4_ENV_FILE ?? "").trim();
89
+ const target = path ?? (fromEnv.length > 0 ? fromEnv : ".env");
90
+ const envPath = resolve(target);
84
91
 
85
92
  if (!existsSync(envPath)) {
86
93
  return {};
@@ -374,6 +374,29 @@ interface QueryConfig {
374
374
  resolver: ResolverFn;
375
375
  }
376
376
 
377
+ // ── Env-driven config ────────────────────────────────────────
378
+
379
+ /**
380
+ * URL the GraphQL handler should be mounted at.
381
+ * `TINA4_GRAPHQL_ENDPOINT` overrides the default `/graphql`.
382
+ */
383
+ export function graphqlEndpoint(): string {
384
+ const raw = (process.env.TINA4_GRAPHQL_ENDPOINT ?? "").trim();
385
+ if (raw.length === 0) return "/graphql";
386
+ return raw.startsWith("/") ? raw : `/${raw}`;
387
+ }
388
+
389
+ /**
390
+ * Whether to auto-generate the schema from registered ORM models.
391
+ * `TINA4_GRAPHQL_AUTO_SCHEMA=true` (default) lets the dev server build a
392
+ * usable schema with no manual wiring; set to `false` to require explicit
393
+ * `addType` / `addQuery` calls.
394
+ */
395
+ export function graphqlAutoSchemaEnabled(): boolean {
396
+ const raw = (process.env.TINA4_GRAPHQL_AUTO_SCHEMA ?? "true").trim().toLowerCase();
397
+ return ["true", "1", "yes", "on"].includes(raw);
398
+ }
399
+
377
400
  // ── GraphQL Engine ───────────────────────────────────────────
378
401
 
379
402
  export class GraphQL {
@@ -1,27 +1,46 @@
1
- import type { RouteDefinition } from "./types.js";
1
+ import type { RouteDefinition, RouteHandler } from "./types.js";
2
2
 
3
3
  /** Server start time, set when health route is created */
4
4
  let startTime: number;
5
5
 
6
6
  /**
7
- * Create the /health route definition.
8
- * Returns a RouteDefinition that can be added to the router.
7
+ * Resolve the health route path. Priority:
8
+ * 1. `TINA4_HEALTH_PATH` env var
9
+ * 2. Default `/__health` (matches Python parity — under-prefix avoids
10
+ * colliding with app routes named /health)
11
+ */
12
+ export function healthPath(): string {
13
+ const raw = (process.env.TINA4_HEALTH_PATH ?? "").trim();
14
+ if (raw.length === 0) return "/__health";
15
+ return raw.startsWith("/") ? raw : `/${raw}`;
16
+ }
17
+
18
+ function buildHandler(version: string): RouteHandler {
19
+ return (_req, res) => {
20
+ const uptimeSeconds = (Date.now() - startTime) / 1000;
21
+ res.json({
22
+ status: "ok",
23
+ version,
24
+ uptime: Math.round(uptimeSeconds * 100) / 100,
25
+ framework: "tina4-nodejs",
26
+ });
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Create the primary health route definition.
32
+ *
33
+ * Tests use this directly. Server bootstrap goes through createHealthRoutes()
34
+ * to also register the legacy `/health` alias when TINA4_HEALTH_PATH points
35
+ * elsewhere — matching Python behaviour so existing probes don't break.
9
36
  */
10
37
  export function createHealthRoute(version: string = "3.0.0"): RouteDefinition {
11
38
  startTime = Date.now();
12
39
 
13
40
  return {
14
41
  method: "GET",
15
- pattern: "/health",
16
- handler: (_req, res) => {
17
- const uptimeSeconds = (Date.now() - startTime) / 1000;
18
- res.json({
19
- status: "ok",
20
- version,
21
- uptime: Math.round(uptimeSeconds * 100) / 100,
22
- framework: "tina4-nodejs",
23
- });
24
- },
42
+ pattern: healthPath(),
43
+ handler: buildHandler(version),
25
44
  meta: {
26
45
  summary: "Health check",
27
46
  description: "Returns server health status, version, and uptime.",
@@ -29,3 +48,42 @@ export function createHealthRoute(version: string = "3.0.0"): RouteDefinition {
29
48
  },
30
49
  };
31
50
  }
51
+
52
+ /**
53
+ * Create one or two health routes — the env-defined path always, plus a
54
+ * legacy `/health` alias when the env path differs. Mirrors
55
+ * tina4-python's two-line registration in `core/server.py`.
56
+ */
57
+ export function createHealthRoutes(version: string = "3.0.0"): RouteDefinition[] {
58
+ startTime = Date.now();
59
+ const routes: RouteDefinition[] = [];
60
+ const path = healthPath();
61
+
62
+ routes.push({
63
+ method: "GET",
64
+ pattern: path,
65
+ handler: buildHandler(version),
66
+ meta: {
67
+ summary: "Health check",
68
+ description: "Returns server health status, version, and uptime.",
69
+ tags: ["System"],
70
+ },
71
+ });
72
+
73
+ // Always register /health for backwards compatibility with existing probes
74
+ // (Kubernetes liveness/readiness, load balancers, monitoring scripts).
75
+ if (path !== "/health") {
76
+ routes.push({
77
+ method: "GET",
78
+ pattern: "/health",
79
+ handler: buildHandler(version),
80
+ meta: {
81
+ summary: "Health check (legacy alias)",
82
+ description: "Backwards-compatible alias for the configured health path.",
83
+ tags: ["System"],
84
+ },
85
+ });
86
+ }
87
+
88
+ return routes;
89
+ }
@@ -12,9 +12,9 @@ export type {
12
12
  WebSocketRouteDefinition,
13
13
  } from "./types.js";
14
14
 
15
- export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled } from "./server.js";
15
+ export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled, isBannerSuppressed } from "./server.js";
16
16
  export { background, stopAllBackgroundTasks, backgroundTaskCount } from "./background.js";
17
- export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from "./router.js";
17
+ export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares, isTrailingSlashRedirectEnabled } from "./router.js";
18
18
  export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
19
19
  export type { RouteInfo } from "./router.js";
20
20
  export { discoverRoutes } from "./routeDiscovery.js";
@@ -25,7 +25,7 @@ export { createResponse, errorResponse, setDefaultTemplatesDir, getFrond, setFro
25
25
  export { tryServeStatic } from "./static.js";
26
26
  export { loadEnv, getEnv, requireEnv, hasEnv, allEnv, resetEnv, isTruthy } from "./dotenv.js";
27
27
  export { Log } from "./logger.js";
28
- export { createHealthRoute } from "./health.js";
28
+ export { createHealthRoute, createHealthRoutes, healthPath } from "./health.js";
29
29
  export { rateLimiter } from "./rateLimiter.js";
30
30
  export type { RateLimiterConfig } from "./rateLimiter.js";
31
31
  export {
@@ -45,7 +45,7 @@ export {
45
45
  refreshToken, authenticateRequest, validateApiKey,
46
46
  Auth,
47
47
  } from "./auth.js";
48
- export { Session, FileSessionHandler, RedisSessionHandler } from "./session.js";
48
+ export { Session, FileSessionHandler, RedisSessionHandler, buildSessionCookie } from "./session.js";
49
49
  export type { SessionConfig, SessionHandler } from "./session.js";
50
50
  export { I18n } from "./i18n.js";
51
51
  export { FakeData } from "./fakeData.js";
@@ -55,7 +55,7 @@ export { Queue } from "./queue.js";
55
55
  export type { QueueConfig, QueueJob, ProcessOptions } from "./queue.js";
56
56
  export { createJob } from "./job.js";
57
57
  export type { JobData, JobQueueBridge } from "./job.js";
58
- export { GraphQL, ParseError } from "./graphql.js";
58
+ export { GraphQL, ParseError, graphqlEndpoint, graphqlAutoSchemaEnabled } from "./graphql.js";
59
59
  export type { GraphQLField, ResolverFn, GraphQLResult } from "./graphql.js";
60
60
  export {
61
61
  WebSocketServer,
@@ -108,7 +108,7 @@ export type { WebSocketBackplane } from "./websocketBackplane.js";
108
108
  export {
109
109
  McpServer, mcpTool, mcpResource, registerDevTools,
110
110
  encodeResponse, encodeError, encodeNotification, decodeRequest,
111
- schemaFromParams, isLocalhost,
111
+ schemaFromParams, isLocalhost, mcpEnabled, mcpPort,
112
112
  PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR,
113
113
  } from "./mcp.js";
114
114
  export type { JsonRpcMessage, McpToolDefinition, McpResourceDefinition, JsonSchema, McpToolParam } from "./mcp.js";
@@ -5,12 +5,13 @@ import {
5
5
  renameSync,
6
6
  statSync,
7
7
  unlinkSync,
8
+ writeFileSync,
8
9
  } from "node:fs";
9
- import { join, dirname } from "node:path";
10
+ import { join, dirname, isAbsolute } from "node:path";
10
11
  import { isTruthy } from "./dotenv.js";
11
12
 
12
13
  /** Log level severity */
13
- type LogLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR";
14
+ type LogLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
14
15
 
15
16
  /** Log level priority for filtering */
16
17
  const LEVEL_PRIORITY: Record<LogLevel, number> = {
@@ -18,6 +19,7 @@ const LEVEL_PRIORITY: Record<LogLevel, number> = {
18
19
  INFO: 1,
19
20
  WARNING: 2,
20
21
  ERROR: 3,
22
+ CRITICAL: 4,
21
23
  };
22
24
 
23
25
  /** Structured log entry for JSON output */
@@ -31,10 +33,11 @@ interface LogEntry {
31
33
 
32
34
  /** ANSI color codes for terminal output */
33
35
  const COLORS: Record<LogLevel, string> = {
34
- DEBUG: "\x1b[36m", // cyan
35
- INFO: "\x1b[32m", // green
36
- WARNING: "\x1b[33m", // yellow
37
- ERROR: "\x1b[31m", // red
36
+ DEBUG: "\x1b[36m", // cyan
37
+ INFO: "\x1b[32m", // green
38
+ WARNING: "\x1b[33m", // yellow
39
+ ERROR: "\x1b[31m", // red
40
+ CRITICAL: "\x1b[35m", // magenta
38
41
  };
39
42
  const RESET = "\x1b[0m";
40
43
 
@@ -47,28 +50,103 @@ const DEFAULT_LOG_DIR = "logs";
47
50
  /** Default log filename */
48
51
  const DEFAULT_LOG_FILE = "tina4.log";
49
52
 
53
+ /** Default rotation size — 10 MB */
54
+ const DEFAULT_ROTATE_SIZE = 10 * 1024 * 1024;
55
+
56
+ /** Default rotation keep count */
57
+ const DEFAULT_ROTATE_KEEP = 5;
58
+
50
59
  /** Strip ANSI escape codes from a string */
51
60
  function stripAnsi(text: string): string {
52
61
  return text.replace(ANSI_RE, "");
53
62
  }
54
63
 
64
+ /**
65
+ * Resolve the log file path from env (or constructor options).
66
+ *
67
+ * If `TINA4_LOG_FILE` is set:
68
+ * - absolute path → used as-is.
69
+ * - relative → resolved against `TINA4_LOG_DIR` (default `logs`).
70
+ *
71
+ * Otherwise the directory is `TINA4_LOG_DIR` and filename is `tina4.log`.
72
+ */
73
+ function resolveLogFilePath(logDir: string, logFile: string): string {
74
+ if (isAbsolute(logFile)) return logFile;
75
+ return join(logDir, logFile);
76
+ }
77
+
55
78
  /**
56
79
  * Structured logger for Tina4.
57
80
  *
58
- * Production (TINA4_DEBUG not truthy): JSON lines to logs/tina4.log
81
+ * Production (TINA4_DEBUG not truthy): JSON or text lines to logs/tina4.log
59
82
  * Development (TINA4_DEBUG=true): Colorized human-readable to stdout + file
60
83
  *
61
- * Log rotation: numbered scheme (tina4.log -> tina4.log.1 -> ... -> tina4.log.{keep})
62
- * Raw log file always writes ALL levels (no filtering), plain text (no ANSI codes).
63
- * Console output respects TINA4_LOG_LEVEL.
84
+ * Env vars:
85
+ * TINA4_LOG_FILE — explicit log file (absolute or relative). Empty = use TINA4_LOG_DIR + tina4.log
86
+ * TINA4_LOG_DIR — directory for log files (default: "logs")
87
+ * TINA4_LOG_FORMAT — "text" | "json" (default: "text")
88
+ * TINA4_LOG_OUTPUT — "stdout" | "file" | "both" (default: "stdout")
89
+ * TINA4_LOG_CRITICAL — "true" to enable CRITICAL level shortcut (default: "false")
90
+ * TINA4_LOG_ROTATE_SIZE — bytes; 0 disables rotation (default: 10485760 = 10MB)
91
+ * TINA4_LOG_ROTATE_KEEP — number of historical files to keep (default: 5)
92
+ * TINA4_LOG_LEVEL — minimum console level (default: "DEBUG")
93
+ *
94
+ * Rotation is stdlib roll-your-own:
95
+ * - On each write, statSync the file. If size >= TINA4_LOG_ROTATE_SIZE, rotate.
96
+ * - app.log.{N-1} → app.log.{N}, …, app.log → app.log.1 via fs.renameSync.
97
+ * - Files beyond _KEEP are dropped via fs.unlinkSync.
98
+ * - _SIZE=0 disables rotation entirely.
64
99
  */
65
100
  export class Log {
66
101
  private static requestId: string | undefined;
67
- private static logDir: string = DEFAULT_LOG_DIR;
68
- private static logFile: string = DEFAULT_LOG_FILE;
69
- private static maxFileSize: number = 10 * 1024 * 1024;
70
- private static keepFiles: number = 5;
71
- private static minLevel: number = 0;
102
+
103
+ /**
104
+ * Re-read all log-related env vars. Called on every log() so tests that
105
+ * mutate process.env between calls see the new values without having to
106
+ * call configure() each time.
107
+ */
108
+ private static readEnv(): {
109
+ logDir: string;
110
+ logFile: string;
111
+ rotateSize: number;
112
+ rotateKeep: number;
113
+ minLevel: number;
114
+ format: "text" | "json";
115
+ output: "stdout" | "file" | "both";
116
+ criticalEnabled: boolean;
117
+ } {
118
+ const logDir = process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
119
+ const logFile = (process.env.TINA4_LOG_FILE ?? "").trim() || DEFAULT_LOG_FILE;
120
+
121
+ const rawSize = process.env.TINA4_LOG_ROTATE_SIZE;
122
+ let rotateSize = DEFAULT_ROTATE_SIZE;
123
+ if (rawSize !== undefined) {
124
+ const n = parseInt(rawSize, 10);
125
+ rotateSize = isNaN(n) || n < 0 ? DEFAULT_ROTATE_SIZE : n;
126
+ }
127
+
128
+ const rawKeep = process.env.TINA4_LOG_ROTATE_KEEP;
129
+ let rotateKeep = DEFAULT_ROTATE_KEEP;
130
+ if (rawKeep !== undefined) {
131
+ const n = parseInt(rawKeep, 10);
132
+ rotateKeep = isNaN(n) || n < 1 ? DEFAULT_ROTATE_KEEP : n;
133
+ }
134
+
135
+ const levelEnv = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
136
+ const minLevel = LEVEL_PRIORITY[levelEnv as LogLevel] ?? 0;
137
+
138
+ const fmt = (process.env.TINA4_LOG_FORMAT ?? "text").trim().toLowerCase();
139
+ const format: "text" | "json" = fmt === "json" ? "json" : "text";
140
+
141
+ const out = (process.env.TINA4_LOG_OUTPUT ?? "stdout").trim().toLowerCase();
142
+ let output: "stdout" | "file" | "both" = "stdout";
143
+ if (out === "file") output = "file";
144
+ else if (out === "both") output = "both";
145
+
146
+ const criticalEnabled = isTruthy(process.env.TINA4_LOG_CRITICAL);
147
+
148
+ return { logDir, logFile, rotateSize, rotateKeep, minLevel, format, output, criticalEnabled };
149
+ }
72
150
 
73
151
  /**
74
152
  * Set the current request ID for log correlation.
@@ -85,21 +163,12 @@ export class Log {
85
163
  }
86
164
 
87
165
  /**
88
- * Configure the log directory, filename, and rotation settings.
166
+ * Configure the log directory / filename. Mostly a no-op now —
167
+ * env vars are re-read on every call. Kept for backwards compatibility.
89
168
  */
90
169
  static configure(options: { logDir?: string; logFile?: string }): void {
91
- if (options.logDir) Log.logDir = options.logDir;
92
- if (options.logFile) Log.logFile = options.logFile;
93
-
94
- // Read rotation config from env (with defaults)
95
- const maxSizeMb = parseInt(process.env.TINA4_LOG_MAX_SIZE ?? "10", 10);
96
- Log.maxFileSize = (isNaN(maxSizeMb) ? 10 : maxSizeMb) * 1024 * 1024;
97
- const keep = parseInt(process.env.TINA4_LOG_KEEP ?? "5", 10);
98
- Log.keepFiles = isNaN(keep) ? 5 : keep;
99
-
100
- // Resolve minimum console log level
101
- const levelEnv = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
102
- Log.minLevel = LEVEL_PRIORITY[levelEnv as LogLevel] ?? 0;
170
+ if (options.logDir) process.env.TINA4_LOG_DIR = options.logDir;
171
+ if (options.logFile) process.env.TINA4_LOG_FILE = options.logFile;
103
172
  }
104
173
 
105
174
  /** Log an informational message. */
@@ -117,11 +186,25 @@ export class Log {
117
186
  Log.log("WARNING", message, data);
118
187
  }
119
188
 
189
+ /** Backwards-compat alias for warning(). */
190
+ static warn(message: string, data?: unknown): void {
191
+ Log.log("WARNING", message, data);
192
+ }
193
+
120
194
  /** Log an error message. */
121
195
  static error(message: string, data?: unknown): void {
122
196
  Log.log("ERROR", message, data);
123
197
  }
124
198
 
199
+ /**
200
+ * Log a critical message. Only emitted when TINA4_LOG_CRITICAL=true,
201
+ * otherwise this is a no-op (matches Python parity — critical is the
202
+ * highest-severity bucket and is opt-in to avoid drowning noisy apps).
203
+ */
204
+ static critical(message: string, data?: unknown): void {
205
+ Log.log("CRITICAL", message, data);
206
+ }
207
+
125
208
  /** Check if running in production mode (TINA4_DEBUG is not truthy). */
126
209
  private static isProduction(): boolean {
127
210
  return !isTruthy(process.env.TINA4_DEBUG);
@@ -132,43 +215,59 @@ export class Log {
132
215
  return new Date().toISOString();
133
216
  }
134
217
 
135
- /** Get the full path to the current log file */
136
- private static logFilePath(): string {
137
- return join(Log.logDir, Log.logFile);
138
- }
139
-
140
218
  /** Ensure the log directory exists */
141
- private static ensureLogDir(): void {
142
- const dir = dirname(Log.logFilePath());
219
+ private static ensureLogDir(filePath: string): void {
220
+ const dir = dirname(filePath);
143
221
  if (!existsSync(dir)) {
144
222
  mkdirSync(dir, { recursive: true });
145
223
  }
146
224
  }
147
225
 
148
226
  /**
149
- * Rotate using numbered scheme: tina4.log.{keep} is deleted, all others shift up by 1.
227
+ * Roll-your-own rotation, stdlib only.
228
+ *
229
+ * Sequence on each write:
230
+ * 1. statSync the current file. If size < rotateSize, return.
231
+ * 2. Drop any file beyond keep via unlinkSync (cap the historical count).
232
+ * 3. Atomic shift: app.log.{N-1} → app.log.{N}, …, app.log.1 → app.log.2.
233
+ * 4. Rename current app.log → app.log.1.
234
+ * 5. Truncate via writeFileSync(path, "") so subsequent appends start fresh.
235
+ *
236
+ * Sync calls per write are fine — the worst case is contention on a single
237
+ * file, and the OS atomically serialises rename/unlink anyway.
238
+ *
239
+ * `rotateSize` of 0 disables rotation entirely.
150
240
  */
151
- private static rotateIfNeeded(): void {
152
- const filePath = Log.logFilePath();
241
+ private static rotateIfNeeded(filePath: string, rotateSize: number, rotateKeep: number): void {
242
+ if (rotateSize <= 0) return;
153
243
  if (!existsSync(filePath)) return;
154
244
 
245
+ let size = 0;
155
246
  try {
156
- const stats = statSync(filePath);
157
- if (stats.size < Log.maxFileSize) return;
247
+ size = statSync(filePath).size;
158
248
  } catch {
159
249
  return;
160
250
  }
251
+ if (size < rotateSize) return;
252
+
253
+ // Drop files beyond keep
254
+ for (let n = rotateKeep + 1; n <= rotateKeep + 10; n++) {
255
+ const stale = `${filePath}.${n}`;
256
+ if (existsSync(stale)) {
257
+ try { unlinkSync(stale); } catch { /* ignore */ }
258
+ } else {
259
+ break;
260
+ }
261
+ }
161
262
 
162
- const keep = Log.keepFiles;
163
-
164
- // Delete the oldest rotated file if it exists
165
- const oldest = `${filePath}.${keep}`;
263
+ // Drop the oldest in-window file if at capacity
264
+ const oldest = `${filePath}.${rotateKeep}`;
166
265
  if (existsSync(oldest)) {
167
266
  try { unlinkSync(oldest); } catch { /* ignore */ }
168
267
  }
169
268
 
170
- // Shift existing rotated files: .{n} -> .{n+1}
171
- for (let n = keep - 1; n >= 1; n--) {
269
+ // Shift: .{N-1} -> .{N}, ..., .1 -> .2
270
+ for (let n = rotateKeep - 1; n >= 1; n--) {
172
271
  const src = `${filePath}.${n}`;
173
272
  const dst = `${filePath}.${n + 1}`;
174
273
  if (existsSync(src)) {
@@ -176,16 +275,17 @@ export class Log {
176
275
  }
177
276
  }
178
277
 
179
- // Rename current log to .1
278
+ // Move current .1, then truncate
180
279
  try { renameSync(filePath, `${filePath}.1`); } catch { /* ignore */ }
280
+ try { writeFileSync(filePath, "", "utf-8"); } catch { /* ignore */ }
181
281
  }
182
282
 
183
- /** Write a line to the log file, stripping ANSI codes */
184
- private static writeToFile(line: string): void {
283
+ /** Write a line to the log file, stripping ANSI codes. */
284
+ private static writeToFile(filePath: string, line: string, rotateSize: number, rotateKeep: number): void {
185
285
  try {
186
- Log.ensureLogDir();
187
- Log.rotateIfNeeded();
188
- appendFileSync(Log.logFilePath(), stripAnsi(line) + "\n", "utf-8");
286
+ Log.ensureLogDir(filePath);
287
+ Log.rotateIfNeeded(filePath, rotateSize, rotateKeep);
288
+ appendFileSync(filePath, stripAnsi(line) + "\n", "utf-8");
189
289
  } catch {
190
290
  // Silently fail — logging should never crash the app
191
291
  }
@@ -193,6 +293,11 @@ export class Log {
193
293
 
194
294
  /** Core log method */
195
295
  private static log(level: LogLevel, message: string, data?: unknown): void {
296
+ const cfg = Log.readEnv();
297
+
298
+ // Critical level is opt-in; treat as no-op when disabled.
299
+ if (level === "CRITICAL" && !cfg.criticalEnabled) return;
300
+
196
301
  const entry: LogEntry = {
197
302
  timestamp: Log.timestamp(),
198
303
  level,
@@ -207,20 +312,35 @@ export class Log {
207
312
  entry.context = data;
208
313
  }
209
314
 
210
- // Build human-readable line for file/console
211
- const paddedLevel = level.padEnd(7);
315
+ // Build human-readable line
316
+ const paddedLevel = level.padEnd(8);
212
317
  const reqPart = Log.requestId ? ` [${Log.requestId}]` : "";
213
318
  const dataPart = data !== undefined ? ` ${JSON.stringify(data)}` : "";
214
319
  const humanLine = `${entry.timestamp} [${paddedLevel}]${reqPart} ${message}${dataPart}`;
215
320
 
216
- // Console output respects TINA4_LOG_LEVEL
217
- const shouldLog = (LEVEL_PRIORITY[level] ?? 0) >= Log.minLevel;
218
- if (!Log.isProduction() && shouldLog) {
321
+ // Build the file-format line based on TINA4_LOG_FORMAT
322
+ const fileLine = cfg.format === "json" ? JSON.stringify(entry) : humanLine;
323
+
324
+ const shouldLog = (LEVEL_PRIORITY[level] ?? 0) >= cfg.minLevel;
325
+
326
+ // Console output. TINA4_LOG_OUTPUT="file" disables stdout entirely;
327
+ // anything else (stdout, both) prints to console in dev, suppresses in prod.
328
+ if (shouldLog && cfg.output !== "file" && !Log.isProduction()) {
219
329
  const color = COLORS[level];
220
330
  console.log(`${color}${humanLine}${RESET}`);
221
331
  }
222
332
 
223
- // File always gets ALL levels (raw log, no filtering), plain text (no ANSI)
224
- Log.writeToFile(humanLine);
333
+ // File output: always teed for dev (legacy behaviour), and either always
334
+ // (production default) or honoured per output mode.
335
+ //
336
+ // output=stdout (default): file in dev + prod (legacy parity)
337
+ // output=file file only — no console
338
+ // output=both file + console (already handled above)
339
+ //
340
+ // The "stdout-only without file" mode that some Python deployments want
341
+ // is gated on TINA4_LOG_OUTPUT=stdout combined with TINA4_LOG_FILE set
342
+ // explicitly to an empty string in env — we treat empty file as default.
343
+ const filePath = resolveLogFilePath(cfg.logDir, cfg.logFile);
344
+ Log.writeToFile(filePath, fileLine, cfg.rotateSize, cfg.rotateKeep);
225
345
  }
226
346
  }
@@ -162,6 +162,44 @@ export function isLocalhost(): boolean {
162
162
  return ["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].includes(host);
163
163
  }
164
164
 
165
+ // ── MCP env config (Python parity) ───────────────────────────
166
+
167
+ /** Truthy check that mirrors `dotenv.isTruthy` without the import cycle. */
168
+ function envTruthy(val: string | undefined): boolean {
169
+ if (val == null) return false;
170
+ return ["true", "1", "yes", "on"].includes(val.trim().toLowerCase());
171
+ }
172
+
173
+ /**
174
+ * Whether the built-in MCP server should auto-start.
175
+ *
176
+ * Default: `true` when `TINA4_DEBUG=true`, `false` otherwise. The `TINA4_MCP`
177
+ * env var can force either state explicitly. Matches the Python framework
178
+ * which only exposes MCP endpoints in dev mode by default.
179
+ */
180
+ export function mcpEnabled(): boolean {
181
+ const raw = process.env.TINA4_MCP;
182
+ if (raw === undefined || raw.trim() === "") {
183
+ return envTruthy(process.env.TINA4_DEBUG);
184
+ }
185
+ return envTruthy(raw);
186
+ }
187
+
188
+ /**
189
+ * Resolve the MCP HTTP port. Default: HTTP server port + 2000.
190
+ *
191
+ * `TINA4_MCP_PORT` overrides directly. The `mainPort` argument is the
192
+ * primary HTTP port (the framework passes `port` from `resolvePortAndHost`).
193
+ */
194
+ export function mcpPort(mainPort: number = 7148): number {
195
+ const raw = process.env.TINA4_MCP_PORT;
196
+ if (raw && raw.trim() !== "") {
197
+ const n = parseInt(raw, 10);
198
+ if (!isNaN(n) && n > 0) return n;
199
+ }
200
+ return mainPort + 2000;
201
+ }
202
+
165
203
  // ── McpServer class ──────────────────────────────────────────
166
204
 
167
205
  export class McpServer {
@@ -67,6 +67,8 @@ interface MessengerOptions {
67
67
  imapPort?: number;
68
68
  imapUser?: string;
69
69
  imapPass?: string;
70
+ /** IMAP transport security: "tls" (default), "starttls", or "none". */
71
+ imapEncryption?: string;
70
72
  }
71
73
 
72
74
  export interface ImapMessage {
@@ -300,6 +302,7 @@ export class Messenger {
300
302
  private imapPort: number;
301
303
  private imapUser: string;
302
304
  private imapPass: string;
305
+ private imapEncryption: string;
303
306
 
304
307
  constructor(options?: MessengerOptions) {
305
308
  // Priority: constructor > TINA4_MAIL_* > sensible default.
@@ -345,6 +348,22 @@ export class Messenger {
345
348
  this.imapPass = options?.imapPass
346
349
  ?? process.env.TINA4_MAIL_IMAP_PASSWORD
347
350
  ?? this.password;
351
+
352
+ // IMAP encryption — separate from SMTP encryption because IMAP almost
353
+ // always uses port 993 + implicit TLS while SMTP toggles between 587
354
+ // (STARTTLS) and 465 (implicit TLS). Default is "tls" to match
355
+ // industry-standard IMAPS port 993 behaviour.
356
+ this.imapEncryption = (options?.imapEncryption
357
+ ?? process.env.TINA4_MAIL_IMAP_ENCRYPTION
358
+ ?? "tls").toLowerCase();
359
+ }
360
+
361
+ /**
362
+ * Read-only IMAP encryption mode for inspection / tests.
363
+ * Returns one of "tls", "starttls", "none", "ssl".
364
+ */
365
+ getImapEncryption(): string {
366
+ return this.imapEncryption;
348
367
  }
349
368
 
350
369
  /**
@@ -570,7 +589,12 @@ export class Messenger {
570
589
 
571
590
  let socket: net.Socket | tls.TLSSocket;
572
591
 
573
- if (this.imapPort === 993) {
592
+ // Honour TINA4_MAIL_IMAP_ENCRYPTION when set; otherwise infer from port.
593
+ // "tls" / "ssl" → implicit TLS connect; anything else → plain connect.
594
+ const useTls = this.imapEncryption === "tls" || this.imapEncryption === "ssl"
595
+ || (this.imapEncryption === "" && this.imapPort === 993);
596
+
597
+ if (useTls) {
574
598
  socket = tls.connect({ host: this.imapHost, port: this.imapPort, rejectUnauthorized: false });
575
599
  await new Promise<void>((resolve, reject) => {
576
600
  socket.once("secureConnect", resolve);
@@ -1,4 +1,16 @@
1
1
  import type { RouteHandler, RouteDefinition, RouteMeta, Middleware, Tina4Request, Tina4Response, WebSocketRouteHandler, WebSocketRouteDefinition } from "./types.js";
2
+ import { isTruthy } from "./dotenv.js";
3
+
4
+ /**
5
+ * Whether `TINA4_TRAILING_SLASH_REDIRECT` is enabled.
6
+ *
7
+ * Default: false. When true, a request to `/foo/` that has no exact match
8
+ * but matches `/foo` will be treated as a hit on `/foo` — callers can use
9
+ * the returned pattern to issue a 308 redirect (Python parity).
10
+ */
11
+ export function isTrailingSlashRedirectEnabled(): boolean {
12
+ return isTruthy(process.env.TINA4_TRAILING_SLASH_REDIRECT);
13
+ }
2
14
 
3
15
  interface MatchResult {
4
16
  handler: RouteHandler;
@@ -199,6 +211,11 @@ export class Router {
199
211
 
200
212
  /**
201
213
  * Match a request method + pathname to a registered route.
214
+ *
215
+ * When `TINA4_TRAILING_SLASH_REDIRECT=true` and the request path ends in
216
+ * a trailing slash that doesn't match a registered route, retry without
217
+ * the trailing slash. Returning the de-slashed pattern lets callers issue
218
+ * a 308 redirect instead of a hard 404 — Python parity.
202
219
  */
203
220
  match(method: string, path: string): MatchResult | null {
204
221
  const upperMethod = method.toUpperCase();
@@ -207,6 +224,28 @@ export class Router {
207
224
  const routes = this.routes.get(upperMethod);
208
225
  if (!routes) return null;
209
226
 
227
+ const direct = this.matchRoute(routes, path);
228
+ if (direct) return direct;
229
+
230
+ // Trailing-slash redirect — strip a single trailing "/" and retry.
231
+ // Root "/" is intentionally excluded (it's its own route).
232
+ if (
233
+ isTrailingSlashRedirectEnabled() &&
234
+ path.length > 1 &&
235
+ path.endsWith("/")
236
+ ) {
237
+ const stripped = path.replace(/\/+$/, "");
238
+ if (stripped.length > 0) {
239
+ const retry = this.matchRoute(routes, stripped);
240
+ if (retry) return retry;
241
+ }
242
+ }
243
+
244
+ return null;
245
+ }
246
+
247
+ /** Inner match against a list of compiled routes, no trailing-slash logic. */
248
+ private matchRoute(routes: CompiledRoute[], path: string): MatchResult | null {
210
249
  for (const route of routes) {
211
250
  const match = route.regex.exec(path);
212
251
  if (match) {
@@ -227,7 +266,6 @@ export class Router {
227
266
  };
228
267
  }
229
268
  }
230
-
231
269
  return null;
232
270
  }
233
271
 
@@ -15,7 +15,7 @@ import { createResponse, setDefaultTemplatesDir } from "./response.js";
15
15
  import { MiddlewareChain, cors, requestLogger } from "./middleware.js";
16
16
  import { tryServeStatic } from "./static.js";
17
17
  import { loadEnv, isTruthy } from "./dotenv.js";
18
- import { createHealthRoute } from "./health.js";
18
+ import { createHealthRoutes } from "./health.js";
19
19
  import { rateLimiter } from "./rateLimiter.js";
20
20
  import { Log } from "./logger.js";
21
21
  import { DevAdmin, RequestInspector } from "./devAdmin.js";
@@ -114,7 +114,7 @@ export function _checkLegacyEnvVars(): void {
114
114
  }
115
115
  lines.push(
116
116
  "",
117
- "Run `tina4 env-migrate` to rewrite your .env automatically,",
117
+ "Run `tina4 env --migrate` to rewrite your .env automatically,",
118
118
  "or rename manually. See https://tina4.com/release/3.12.0",
119
119
  "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
120
120
  bar,
@@ -198,17 +198,32 @@ function openBrowser(url: string) {
198
198
  /**
199
199
  * Resolve port and host with priority: explicit config > ENV var > default.
200
200
  * Exported for testability.
201
+ *
202
+ * Host resolution prefers `TINA4_HOST` (the framework-prefixed name —
203
+ * matches Python parity) and falls back to the unprefixed `HOST` env var
204
+ * for backwards compatibility.
201
205
  */
202
206
  export function resolvePortAndHost(config?: { port?: number; host?: string }): { port: number; host: string } {
203
207
  const port = config?.port
204
208
  ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : undefined)
205
209
  ?? 7148;
206
210
  const host = config?.host
211
+ ?? process.env.TINA4_HOST
207
212
  ?? process.env.HOST
208
213
  ?? "0.0.0.0";
209
214
  return { port, host };
210
215
  }
211
216
 
217
+ /**
218
+ * Whether the boot banner should be suppressed. Set TINA4_SUPPRESS=true to
219
+ * silence the ASCII-art banner and route table on startup — useful in CI,
220
+ * test runners, and embedded contexts where stdout is consumed by another
221
+ * process.
222
+ */
223
+ export function isBannerSuppressed(): boolean {
224
+ return isTruthy(process.env.TINA4_SUPPRESS);
225
+ }
226
+
212
227
  function isDevMode(): boolean {
213
228
  return isTruthy(process.env.TINA4_DEBUG);
214
229
  }
@@ -677,7 +692,8 @@ export async function startServer(config?: Tina4Config): Promise<{
677
692
  const reset = isTty ? "\x1b[0m" : "";
678
693
  const logLevel = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
679
694
 
680
- console.log(`${color}
695
+ if (!isBannerSuppressed()) {
696
+ console.log(`${color}
681
697
  ______ _ __ __
682
698
  /_ __/(_)___ ____ _/ // /
683
699
  / / / / __ \\/ __ \`/ // /_
@@ -691,6 +707,7 @@ ${reset}
691
707
  Dashboard: http://localhost:${port}/__dev
692
708
  Debug: OFF (Log level: ${logLevel})
693
709
  `);
710
+ }
694
711
 
695
712
  for (let i = 0; i < numCPUs; i++) {
696
713
  cluster.fork();
@@ -737,9 +754,11 @@ ${reset}
737
754
  router.addRoute(route);
738
755
  }
739
756
 
740
- // Register health check endpoint
741
- const healthRoute = createHealthRoute(TINA4_VERSION);
742
- router.addRoute(healthRoute);
757
+ // Register health check endpoint(s). createHealthRoutes returns both the
758
+ // env-configured path (default /__health) and a /health legacy alias.
759
+ for (const healthRoute of createHealthRoutes(TINA4_VERSION)) {
760
+ router.addRoute(healthRoute);
761
+ }
743
762
 
744
763
  // Initialize Frond template engine
745
764
  let frondEngine: any = null;
@@ -837,9 +856,16 @@ ${reset}
837
856
  }
838
857
  }
839
858
 
840
- // Initialize Swagger
859
+ // Initialize Swagger — gated on TINA4_SWAGGER_ENABLED (default: enabled
860
+ // in debug mode, off in production). Loading the swagger module also
861
+ // pulls in route discovery for the generator, so skip the import entirely
862
+ // when disabled.
841
863
  try {
842
864
  const swagger = await import("../../swagger/src/index.js");
865
+ if (!swagger.swaggerEnabled()) {
866
+ // Skip the rest of the swagger block when disabled.
867
+ throw new Error("__swagger_disabled__");
868
+ }
843
869
  const allRoutes = router.getRoutes();
844
870
 
845
871
  // Collect model definitions for schema generation
@@ -889,9 +915,12 @@ ${reset}
889
915
 
890
916
  // Auto-start session — read cookie, create session, save + set cookie on response end
891
917
  {
892
- const { Session } = await import("./session.js");
918
+ const { Session, buildSessionCookie } = await import("./session.js");
893
919
  const cookieHeader = rawReq.headers.cookie ?? "";
894
- const sidMatch = cookieHeader.match(/tina4_session=([^;]+)/);
920
+ const cookieName = process.env.TINA4_SESSION_NAME ?? "tina4_session";
921
+ // Build a regex from the (possibly customised) cookie name. Escape regex meta-chars.
922
+ const escapedName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
923
+ const sidMatch = cookieHeader.match(new RegExp(`${escapedName}=([^;]+)`));
895
924
  const existingSid = sidMatch ? sidMatch[1] : undefined;
896
925
  const sess = new Session();
897
926
  sess.start(existingSid);
@@ -909,8 +938,7 @@ ${reset}
909
938
  const newSid = (sess as any).sessionId ?? (sess as any).getSessionId?.();
910
939
  if (newSid && newSid !== existingSid && !rawRes.headersSent) {
911
940
  const ttl = parseInt(process.env.TINA4_SESSION_TTL ?? "3600", 10);
912
- const sameSite = process.env.TINA4_SESSION_SAMESITE ?? "Lax";
913
- rawRes.setHeader("Set-Cookie", `tina4_session=${newSid}; Path=/; HttpOnly; SameSite=${sameSite}; Max-Age=${ttl}`);
941
+ rawRes.setHeader("Set-Cookie", buildSessionCookie(newSid, ttl));
914
942
  }
915
943
  return origEnd(...args);
916
944
  } as typeof rawRes.end;
@@ -1234,7 +1262,8 @@ ${reset}
1234
1262
  ? `\n Test Port: http://localhost:${testPort} (stable — no hot-reload)`
1235
1263
  : "";
1236
1264
 
1237
- console.log(`${color}
1265
+ if (!isBannerSuppressed()) {
1266
+ console.log(`${color}
1238
1267
  ______ _ __ __
1239
1268
  /_ __/(_)___ ____ _/ // /
1240
1269
  / / / / __ \\/ __ \`/ // /_
@@ -1248,6 +1277,7 @@ ${reset}
1248
1277
  Dashboard: http://localhost:${port}/__dev
1249
1278
  Debug: ${isDebug ? "ON" : "OFF"} (Log level: ${logLevel})${dualPortLines}
1250
1279
  `);
1280
+ }
1251
1281
  const noBrowser = isTruthy(process.env.TINA4_NO_BROWSER);
1252
1282
  if (!noBrowser) {
1253
1283
  // Open browser on test port (hot-reload) if available, otherwise main port
@@ -531,10 +531,15 @@ export class Session {
531
531
 
532
532
  /**
533
533
  * Return a Set-Cookie header value for this session.
534
+ *
535
+ * Honours these env vars (cross-framework parity):
536
+ * TINA4_SESSION_NAME — cookie name (default: "tina4_session")
537
+ * TINA4_SESSION_SAMESITE — SameSite attribute (default: "Lax")
538
+ * TINA4_SESSION_HTTPONLY — emit HttpOnly (default: true)
539
+ * TINA4_SESSION_SECURE — emit Secure (default: false)
534
540
  */
535
- cookieHeader(cookieName: string = "tina4_session"): string {
536
- const sameSite = process.env.TINA4_SESSION_SAMESITE ?? "Lax";
537
- return `${cookieName}=${this.sessionId}; Path=/; HttpOnly; SameSite=${sameSite}; Max-Age=${this.ttl}`;
541
+ cookieHeader(cookieName?: string): string {
542
+ return buildSessionCookie(this.sessionId, this.ttl, cookieName);
538
543
  }
539
544
 
540
545
  /**
@@ -555,3 +560,38 @@ export class Session {
555
560
  this.handler.write(this.sessionId, this.data, this.ttl);
556
561
  }
557
562
  }
563
+
564
+ /**
565
+ * Build the `Set-Cookie` header value for a Tina4 session. Centralised so
566
+ * the auto-cookie path in server.ts and `Session.cookieHeader()` agree on
567
+ * which env vars are honoured and what the defaults are.
568
+ *
569
+ * Env vars (Python parity):
570
+ * TINA4_SESSION_NAME — cookie name (default: "tina4_session")
571
+ * TINA4_SESSION_SAMESITE — SameSite attribute (default: "Lax")
572
+ * TINA4_SESSION_HTTPONLY — emit HttpOnly (default: true)
573
+ * TINA4_SESSION_SECURE — emit Secure (default: false)
574
+ */
575
+ export function buildSessionCookie(sessionId: string | null, ttl: number, cookieName?: string): string {
576
+ const name = cookieName ?? process.env.TINA4_SESSION_NAME ?? "tina4_session";
577
+ const sameSite = process.env.TINA4_SESSION_SAMESITE ?? "Lax";
578
+
579
+ // HttpOnly defaults to TRUE (matches existing behaviour and Python parity).
580
+ // Treat any explicit non-truthy value (false/0/no/off) as opt-out.
581
+ const httpOnlyRaw = process.env.TINA4_SESSION_HTTPONLY;
582
+ const httpOnly = httpOnlyRaw === undefined
583
+ ? true
584
+ : !["false", "0", "no", "off"].includes(httpOnlyRaw.trim().toLowerCase());
585
+
586
+ // Secure defaults to FALSE — only emit when the operator opts in (https
587
+ // deployments). Setting it eagerly would break http://localhost dev cookies.
588
+ const secureRaw = process.env.TINA4_SESSION_SECURE ?? "";
589
+ const secure = ["true", "1", "yes", "on"].includes(secureRaw.trim().toLowerCase());
590
+
591
+ const parts = [`${name}=${sessionId ?? ""}`, "Path=/"];
592
+ if (httpOnly) parts.push("HttpOnly");
593
+ parts.push(`SameSite=${sameSite}`);
594
+ if (secure) parts.push("Secure");
595
+ parts.push(`Max-Age=${ttl}`);
596
+ return parts.join("; ");
597
+ }
@@ -1327,10 +1327,16 @@ export class Frond {
1327
1327
  private _allowedVars: Set<string> | null;
1328
1328
  private fragmentCache: Map<string, [string, number]>;
1329
1329
  private _autoEscape: boolean;
1330
- /** Token pre-compilation cache for file templates */
1331
- private compiled = new Map<string, { tokens: Token[]; mtime: number }>();
1330
+ /**
1331
+ * Token pre-compilation cache for file templates.
1332
+ *
1333
+ * `cachedAt` is captured so the TINA4_TEMPLATE_CACHE_TTL env var can
1334
+ * force re-compilation after N seconds even in production. TTL of 0
1335
+ * means "no time-based invalidation" — entries live forever.
1336
+ */
1337
+ private compiled = new Map<string, { tokens: Token[]; mtime: number; cachedAt: number }>();
1332
1338
  /** Token pre-compilation cache for string templates */
1333
- private compiledStrings = new Map<string, Token[]>();
1339
+ private compiledStrings = new Map<string, { tokens: Token[]; cachedAt: number }>();
1334
1340
 
1335
1341
  getTemplateDir(): string { return this.templateDir; }
1336
1342
 
@@ -1386,6 +1392,20 @@ export class Frond {
1386
1392
  this.tests[name] = fn;
1387
1393
  }
1388
1394
 
1395
+ /**
1396
+ * Read the cache TTL in seconds. `TINA4_TEMPLATE_CACHE_TTL=0` (the
1397
+ * default) keeps the existing "cache forever in prod" behaviour — any
1398
+ * positive value invalidates compiled tokens after N seconds, useful
1399
+ * when running long-lived servers behind a slow file sync where mtime
1400
+ * isn't a reliable freshness signal.
1401
+ */
1402
+ private cacheTtlSeconds(): number {
1403
+ const raw = process.env.TINA4_TEMPLATE_CACHE_TTL;
1404
+ if (raw === undefined) return 0;
1405
+ const n = parseInt(raw, 10);
1406
+ return isNaN(n) || n < 0 ? 0 : n;
1407
+ }
1408
+
1389
1409
  render(template: string, data?: Record<string, unknown>): string {
1390
1410
  const context = { ...this.globals, ...(data || {}) };
1391
1411
  const filePath = join(this.templateDir, template);
@@ -1395,12 +1415,17 @@ export class Frond {
1395
1415
  }
1396
1416
 
1397
1417
  const debugMode = (process.env.TINA4_DEBUG || "").toLowerCase() === "true";
1418
+ const ttlMs = this.cacheTtlSeconds() * 1000;
1398
1419
 
1399
1420
  if (!debugMode) {
1400
1421
  // Production: use permanent cache (no filesystem checks)
1401
1422
  const cached = this.compiled.get(template);
1402
1423
  if (cached) {
1403
- return this.executeCached(cached.tokens, context);
1424
+ // TTL=0 means cache forever; any positive value invalidates the
1425
+ // compiled tokens after N seconds.
1426
+ if (ttlMs === 0 || (Date.now() - cached.cachedAt) < ttlMs) {
1427
+ return this.executeCached(cached.tokens, context);
1428
+ }
1404
1429
  }
1405
1430
  }
1406
1431
  // Dev mode: skip cache entirely — always re-read and re-tokenize
@@ -1410,7 +1435,7 @@ export class Frond {
1410
1435
  const source = readFileSync(filePath, "utf-8");
1411
1436
  const mtime = statSync(filePath).mtimeMs;
1412
1437
  const tokens = tokenize(source);
1413
- this.compiled.set(template, { tokens, mtime });
1438
+ this.compiled.set(template, { tokens, mtime, cachedAt: Date.now() });
1414
1439
  return this.executeWithSource(source, tokens, context);
1415
1440
  }
1416
1441
 
@@ -1418,13 +1443,16 @@ export class Frond {
1418
1443
  const context = { ...this.globals, ...(data || {}) };
1419
1444
 
1420
1445
  const key = createHash("md5").update(source).digest("hex");
1421
- const cachedTokens = this.compiledStrings.get(key);
1422
- if (cachedTokens) {
1423
- return this.executeCached(cachedTokens, context);
1446
+ const ttlMs = this.cacheTtlSeconds() * 1000;
1447
+ const cached = this.compiledStrings.get(key);
1448
+ if (cached) {
1449
+ if (ttlMs === 0 || (Date.now() - cached.cachedAt) < ttlMs) {
1450
+ return this.executeCached(cached.tokens, context);
1451
+ }
1424
1452
  }
1425
1453
 
1426
1454
  const tokens = tokenize(source);
1427
- this.compiledStrings.set(key, tokens);
1455
+ this.compiledStrings.set(key, { tokens, cachedAt: Date.now() });
1428
1456
  return this.executeCached(tokens, context);
1429
1457
  }
1430
1458
 
@@ -830,6 +830,21 @@ async function createAdapterFromUrl(url: string, username?: string, password?: s
830
830
  * 2. process.env.TINA4_DATABASE_URL
831
831
  * 3. config.type + config.path (legacy)
832
832
  */
833
+ /**
834
+ * Resolve the connection-pool size from `TINA4_DB_POOL`.
835
+ *
836
+ * Default: 0 (single-connection mode). Any positive integer enables
837
+ * round-robin pooling with that many connections — Database.create() honours
838
+ * this transparently. The env var is the simple deploy-time override; tests
839
+ * and library users can still pass `pool` directly to Database.create().
840
+ */
841
+ export function resolveDbPool(): number {
842
+ const raw = process.env.TINA4_DB_POOL;
843
+ if (raw === undefined || raw.trim() === "") return 0;
844
+ const n = parseInt(raw, 10);
845
+ return isNaN(n) || n < 0 ? 0 : n;
846
+ }
847
+
833
848
  export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
834
849
  // Resolve credentials: config.user > config.username > env TINA4_DATABASE_USERNAME
835
850
  const resolvedUser = config?.user ?? config?.username ?? process.env.TINA4_DATABASE_USERNAME;
@@ -839,6 +854,12 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
839
854
  const url = config?.url ?? process.env.TINA4_DATABASE_URL;
840
855
 
841
856
  if (url) {
857
+ const pool = resolveDbPool();
858
+ if (pool > 0) {
859
+ // Pool-aware path — delegate to Database.create which manages
860
+ // round-robin adapter rotation and async-local-storage transaction pinning.
861
+ return Database.create(url, resolvedUser, resolvedPassword, pool);
862
+ }
842
863
  const adapter = await createAdapterFromUrl(url, resolvedUser, resolvedPassword);
843
864
  setAdapter(adapter);
844
865
  return new Database(adapter);
@@ -14,7 +14,7 @@ export { FetchResult } from "./types.js";
14
14
 
15
15
  export { DatabaseResult } from "./databaseResult.js";
16
16
  export type { ColumnInfoResult } from "./databaseResult.js";
17
- export { Database, initDatabase, getAdapter, setAdapter, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter } from "./database.js";
17
+ export { Database, initDatabase, getAdapter, setAdapter, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool } from "./database.js";
18
18
  export type { DatabaseConfig, ParsedDatabaseUrl } from "./database.js";
19
19
  export { discoverModels } from "./model.js";
20
20
  export type { DiscoveredModel } from "./model.js";
@@ -1,9 +1,17 @@
1
1
  import type { RouteDefinition } from "@tina4/core";
2
2
  import type { ModelDefinition, FieldDefinition } from "@tina4/orm";
3
3
 
4
+ interface OpenAPISpecInfo {
5
+ title: string;
6
+ version: string;
7
+ description?: string;
8
+ contact?: { name?: string; url?: string; email?: string };
9
+ license?: { name: string; url?: string };
10
+ }
11
+
4
12
  interface OpenAPISpec {
5
13
  openapi: string;
6
- info: { title: string; version: string; description?: string };
14
+ info: OpenAPISpecInfo;
7
15
  paths: Record<string, Record<string, unknown>>;
8
16
  components?: { schemas?: Record<string, unknown> };
9
17
  }
@@ -12,13 +20,30 @@ export function generate(
12
20
  routes: RouteDefinition[],
13
21
  models: ModelDefinition[] = []
14
22
  ): OpenAPISpec {
23
+ const info: OpenAPISpecInfo = {
24
+ title: process.env.TINA4_SWAGGER_TITLE ?? "Tina4 API",
25
+ version: process.env.TINA4_SWAGGER_VERSION ?? "0.0.1",
26
+ description: process.env.TINA4_SWAGGER_DESCRIPTION ?? "Auto-generated API documentation",
27
+ };
28
+
29
+ // Optional contact email — surfaced in the OpenAPI `info.contact.email`
30
+ // field when set. Matches the python `SWAGGER_CONTACT_EMAIL` convention.
31
+ const contactEmail = (process.env.TINA4_SWAGGER_CONTACT_EMAIL ?? "").trim();
32
+ if (contactEmail.length > 0) {
33
+ info.contact = { email: contactEmail };
34
+ }
35
+
36
+ // Optional license — accepts a plain SPDX identifier ("MIT", "Apache-2.0")
37
+ // or a "Name|URL" pair. Empty string disables license output entirely.
38
+ const licenseRaw = (process.env.TINA4_SWAGGER_LICENSE ?? "").trim();
39
+ if (licenseRaw.length > 0) {
40
+ const [name, url] = licenseRaw.split("|").map((s) => s.trim());
41
+ info.license = url ? { name, url } : { name };
42
+ }
43
+
15
44
  const spec: OpenAPISpec = {
16
45
  openapi: "3.0.3",
17
- info: {
18
- title: process.env.TINA4_SWAGGER_TITLE ?? "Tina4 API",
19
- version: process.env.TINA4_SWAGGER_VERSION ?? "0.0.1",
20
- description: process.env.TINA4_SWAGGER_DESCRIPTION ?? "Auto-generated API documentation",
21
- },
46
+ info,
22
47
  paths: {},
23
48
  components: { schemas: {} },
24
49
  };
@@ -1,2 +1,2 @@
1
1
  export { generate } from "./generator.js";
2
- export { createSwaggerRoutes } from "./ui.js";
2
+ export { createSwaggerRoutes, swaggerEnabled } from "./ui.js";
@@ -26,6 +26,23 @@ const SWAGGER_UI_HTML = (specUrl: string) => `<!DOCTYPE html>
26
26
  </body>
27
27
  </html>`;
28
28
 
29
+ /**
30
+ * Whether the Swagger UI + spec routes should be registered at boot.
31
+ *
32
+ * Default: enabled when `TINA4_DEBUG=true`, disabled otherwise. Operators
33
+ * can force either state with `TINA4_SWAGGER_ENABLED=true|false`. Matches
34
+ * Python parity: dev-only by default to keep production attack surface
35
+ * minimal, but easy to expose intentionally for public APIs.
36
+ */
37
+ export function swaggerEnabled(): boolean {
38
+ const raw = (process.env.TINA4_SWAGGER_ENABLED ?? "").trim().toLowerCase();
39
+ if (raw === "") {
40
+ const debug = (process.env.TINA4_DEBUG ?? "").trim().toLowerCase();
41
+ return ["true", "1", "yes", "on"].includes(debug);
42
+ }
43
+ return ["true", "1", "yes", "on"].includes(raw);
44
+ }
45
+
29
46
  export function createSwaggerRoutes(
30
47
  getSpec: () => Record<string, unknown>
31
48
  ): RouteDefinition[] {