tina4-nodejs 3.12.2 → 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.
@@ -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[] {