tina4-nodejs 3.13.37 → 3.13.39

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 (56) hide show
  1. package/CLAUDE.md +65 -20
  2. package/README.md +6 -6
  3. package/package.json +5 -3
  4. package/packages/cli/src/bin.ts +7 -0
  5. package/packages/cli/src/commands/init.ts +1 -0
  6. package/packages/cli/src/commands/metrics.ts +154 -0
  7. package/packages/cli/src/commands/routes.ts +3 -3
  8. package/packages/core/src/api.ts +64 -1
  9. package/packages/core/src/auth.ts +112 -2
  10. package/packages/core/src/cache.ts +2 -2
  11. package/packages/core/src/devAdmin.ts +66 -44
  12. package/packages/core/src/devMailbox.ts +4 -0
  13. package/packages/core/src/dotenv.ts +13 -4
  14. package/packages/core/src/events.ts +86 -4
  15. package/packages/core/src/graphql.ts +182 -128
  16. package/packages/core/src/htmlElement.ts +62 -3
  17. package/packages/core/src/index.ts +21 -10
  18. package/packages/core/src/logger.ts +85 -28
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/mcp.ts +25 -8
  21. package/packages/core/src/messenger.ts +111 -11
  22. package/packages/core/src/metrics.ts +557 -98
  23. package/packages/core/src/middleware.ts +130 -40
  24. package/packages/core/src/plan.ts +1 -1
  25. package/packages/core/src/queue.ts +1 -1
  26. package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
  27. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  28. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  29. package/packages/core/src/rateLimiter.ts +1 -1
  30. package/packages/core/src/response.ts +90 -6
  31. package/packages/core/src/router.ts +56 -8
  32. package/packages/core/src/server.ts +138 -23
  33. package/packages/core/src/session.ts +130 -18
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  36. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  37. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  38. package/packages/core/src/testClient.ts +1 -1
  39. package/packages/core/src/types.ts +17 -2
  40. package/packages/core/src/websocket.ts +666 -42
  41. package/packages/core/src/websocketBackplane.ts +210 -10
  42. package/packages/core/src/websocketConnection.ts +6 -0
  43. package/packages/core/src/wsdl.ts +55 -21
  44. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  45. package/packages/orm/src/adapters/postgres.ts +26 -4
  46. package/packages/orm/src/adapters/sqlite.ts +112 -13
  47. package/packages/orm/src/baseModel.ts +175 -25
  48. package/packages/orm/src/cachedDatabase.ts +15 -6
  49. package/packages/orm/src/database.ts +257 -55
  50. package/packages/orm/src/index.ts +6 -1
  51. package/packages/orm/src/migration.ts +151 -24
  52. package/packages/orm/src/queryBuilder.ts +14 -2
  53. package/packages/orm/src/seeder.ts +443 -65
  54. package/packages/orm/src/types.ts +7 -0
  55. package/packages/orm/src/validation.ts +14 -0
  56. package/packages/swagger/src/ui.ts +1 -1
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { execFileSync, exec } from "node:child_process";
7
7
  import cluster from "node:cluster";
8
8
  import os from "node:os";
9
+ import type { Socket } from "node:net";
9
10
  import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
10
11
  import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
11
12
  import { validToken, getPayload, refreshToken } from "./auth.js";
@@ -19,7 +20,7 @@ import { createHealthRoutes } from "./health.js";
19
20
  import { rateLimiter } from "./rateLimiter.js";
20
21
  import { Log } from "./logger.js";
21
22
  import { DevAdmin, RequestInspector, WsTracker } from "./devAdmin.js";
22
- import { devReloadWs } from "./websocket.js";
23
+ import { devReloadWs, serveWebSocketRoute, wsRouteManager } from "./websocket.js";
23
24
  import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js";
24
25
  import { I18n } from "./i18n.js";
25
26
  import { stopAllBackgroundTasks } from "./background.js";
@@ -33,6 +34,72 @@ const BUILTIN_ERROR_TEMPLATES_DIR = resolve(__dirname, "..", "templates");
33
34
  /** Built-in public directory for framework-bundled static assets. */
34
35
  const BUILTIN_PUBLIC_DIR = resolve(__dirname, "..", "public");
35
36
 
37
+ /**
38
+ * Apply pending DB migrations on startup — NON-BREAKING.
39
+ *
40
+ * When a `migrations/` folder exists (with at least one `.sql` file, excluding
41
+ * `.down.sql`) and `TINA4_AUTO_MIGRATE` is not disabled (default "true";
42
+ * false/0/no/off disable), pending migrations are applied during boot so the
43
+ * schema is current with no manual `tina4 migrate` step. A failure here is
44
+ * logged LOUD via `Log.error` and the service STILL starts — a bad migration
45
+ * must never take the backend down. (The explicit `tina4 migrate` CLI stays
46
+ * fail-fast so CI still gets a non-zero exit. Only this startup hook swallows.)
47
+ *
48
+ * Disable with `TINA4_AUTO_MIGRATE=false` — e.g. multi-instance production that
49
+ * migrates as a separate deploy step (concurrent first-apply can race).
50
+ *
51
+ * @param migrationDir - migrations directory (default "migrations", relative to base)
52
+ * @param base - project root used to resolve the migrations directory
53
+ */
54
+ export async function autoMigrateOnStartup(
55
+ migrationDir = "migrations",
56
+ base = process.cwd(),
57
+ ): Promise<void> {
58
+ const dir = resolve(base, migrationDir);
59
+
60
+ // Gate 1: a migrations/ folder with at least one .sql (non-down) file.
61
+ if (!existsSync(dir)) return;
62
+ let hasSql = false;
63
+ try {
64
+ hasSql = readdirSync(dir).some((f) => f.endsWith(".sql") && !f.endsWith(".down.sql"));
65
+ } catch {
66
+ return; // unreadable dir → nothing to do (silent)
67
+ }
68
+ if (!hasSql) return;
69
+
70
+ // Gate 2: TINA4_AUTO_MIGRATE not falsy (default "true").
71
+ const flag = process.env.TINA4_AUTO_MIGRATE;
72
+ if (flag != null && !isTruthy(flag)) {
73
+ Log.debug("TINA4_AUTO_MIGRATE is off — skipping startup migrations");
74
+ return;
75
+ }
76
+
77
+ // Gate 3: a DB adapter must be resolvable. (initDatabase() has already run by
78
+ // the time this is called from startServer.)
79
+ let orm: typeof import("../../orm/src/index.js");
80
+ try {
81
+ orm = await import("../../orm/src/index.js");
82
+ orm.getAdapter(); // throws if no adapter configured
83
+ } catch (err) {
84
+ Log.debug(`Startup migrations skipped (no database configured): ${err instanceof Error ? err.message : String(err)}`);
85
+ return;
86
+ }
87
+
88
+ // Run the EXISTING migrate runner inside try/catch — NEVER re-raise out of
89
+ // the startup hook (non-breaking).
90
+ try {
91
+ const result = await orm.migrate(undefined, { migrationsDir: dir });
92
+ if (result.applied.length > 0) {
93
+ Log.info(`Applied ${result.applied.length} pending migration(s) on startup`);
94
+ }
95
+ } catch (err) {
96
+ Log.error(
97
+ `Startup auto-migration failed: ${err instanceof Error ? err.message : String(err)} — ` +
98
+ "the service is starting anyway. Run `tina4 migrate` to retry.",
99
+ );
100
+ }
101
+ }
102
+
36
103
  /** Read version from root package.json so the banner always matches the published version. */
37
104
  function readPackageVersion(): string {
38
105
  try {
@@ -675,9 +742,23 @@ export async function startServer(config?: Tina4Config): Promise<{
675
742
  router: Router;
676
743
  port: number;
677
744
  }> {
678
- // Load .env early so TINA4_DEBUG is available for cluster decision
745
+ // Load env early so TINA4_DEBUG is available for the cluster decision.
746
+ // Precedence MUST be: real environment (set before boot) > .env.local > .env.
747
+ // loadEnv(override=false) is first-wins (only sets a key not already present),
748
+ // so load .env.local BEFORE .env — both with override=false. A real env var
749
+ // set before boot is already present and wins over both; .env.local fills
750
+ // local-only keys; .env fills whatever neither set. Loading .env.local with
751
+ // override=true would let a stray gitignored .env.local clobber an explicitly
752
+ // set real env var (e.g. a production TINA4_SECRET) — never do that.
753
+ loadEnv(".env.local");
679
754
  loadEnv();
680
755
 
756
+ // Auto-generate a per-machine dev secret to a gitignored .env.local when one
757
+ // is missing (dev only, never CI/prod). Must run after env load and before
758
+ // any auth use. Local import avoids a load-time cycle through auth.
759
+ const { ensureDevSecret } = await import("./auth.js");
760
+ ensureDevSecret();
761
+
681
762
  // Refuse to boot with pre-3.12 un-prefixed env vars set.
682
763
  _checkLegacyEnvVars();
683
764
 
@@ -869,6 +950,12 @@ ${reset}
869
950
  } catch (err) {
870
951
  console.warn(`\n ORM not available (install @tina4/orm to enable):`, err);
871
952
  }
953
+
954
+ // Auto-run pending migrations on startup — AFTER initDatabase()/model sync,
955
+ // BEFORE the server listens. Non-breaking: a failure is logged and boot
956
+ // continues (the helper never throws). Gated on a migrations/ dir + the
957
+ // TINA4_AUTO_MIGRATE flag (default on) + a resolvable DB adapter.
958
+ await autoMigrateOnStartup("migrations", base);
872
959
  }
873
960
 
874
961
  // Initialize Swagger — gated on TINA4_SWAGGER_ENABLED (default: enabled
@@ -972,8 +1059,8 @@ ${reset}
972
1059
  rawRes.setHeader("Content-Length", String(accumulated));
973
1060
  }
974
1061
  const realCb = typeof chunk === "function" ? chunk : cb;
975
- return origEnd(undefined, undefined, realCb);
976
1062
  void origWrite; // referenced to keep tsc happy
1063
+ return typeof realCb === "function" ? origEnd(realCb) : origEnd();
977
1064
  }) as typeof rawRes.end;
978
1065
  }
979
1066
 
@@ -1151,7 +1238,14 @@ ${reset}
1151
1238
  ];
1152
1239
  if (globalMiddleware.length > 0) {
1153
1240
  const [, , proceed] = await MiddlewareRunner.runBefore(globalMiddleware, req, res);
1154
- if (!proceed || res.raw.writableEnded) return;
1241
+ if (!proceed || res.raw.writableEnded) {
1242
+ // AFTER-ON-4xx RULE (M2): after_* ALWAYS run even when a before_*
1243
+ // short-circuited (4xx / clean 500 / response ended), so they can
1244
+ // still add headers / logging. Run them, then stop the handler.
1245
+ await MiddlewareRunner.runAfter(globalMiddleware, req, res);
1246
+ if (!res.raw.writableEnded) res.raw.end();
1247
+ return;
1248
+ }
1155
1249
  }
1156
1250
 
1157
1251
  // Run per-route middlewares if any
@@ -1393,31 +1487,52 @@ ${reset}
1393
1487
  // posts /__dev/api/reload to the MAIN port. Matches Python (master).
1394
1488
  const server = createServer(dispatch);
1395
1489
 
1396
- // WebSocket-primary DevReload: accept and hold /__dev_reload upgrades on the
1397
- // MAIN port (debug only) so POST /__dev/api/reload can push an instant reload.
1398
- // Mirrors Python's _register_dev_reload_ws + _ws_manager.broadcast(path=…).
1399
- // Without this the handshake 404s and the whole stack silently falls back to
1400
- // polling. Track connections in WsTracker so they appear in the dev-admin list.
1490
+ // WebSocket upgrade handling on the MAIN port. Two responsibilities:
1491
+ //
1492
+ // 1. WebSocket-primary DevReload (debug only): accept and hold /__dev_reload
1493
+ // upgrades so POST /__dev/api/reload can push an instant reload. Mirrors
1494
+ // Python's _register_dev_reload_ws + _ws_manager.broadcast(path=…).
1495
+ //
1496
+ // 2. USER WS ROUTES (always): a route registered via Router.websocket() /
1497
+ // WebSocketServer.route() is dispatched to a live open/message/close
1498
+ // lifecycle on the real connection — parity with Python/PHP/Ruby. Per-route
1499
+ // auth is enforced here on the upgrade (serveWebSocketRoute): a @secured /
1500
+ // .secure() route rejects a missing/invalid JWT before accepting; public
1501
+ // routes pass. Previously the integrated server only handled /__dev_reload,
1502
+ // so user WS routes never reached a live connection.
1503
+ //
1504
+ // Tracking goes through WsTracker so connections show in the dev-admin list.
1401
1505
  if (isDevMode()) {
1402
1506
  devReloadWs.setTracker(
1403
1507
  (remoteAddress, p) => WsTracker.add(remoteAddress, p),
1404
1508
  (id) => { WsTracker.remove(id); },
1405
1509
  );
1406
- server.on("upgrade", (req: IncomingMessage, socket, head) => {
1407
- const upPath = (req.url ?? "/").split("?")[0];
1408
- if (upPath === "/__dev_reload") {
1409
- devReloadWs.handleUpgrade(req, socket, head);
1410
- return;
1411
- }
1412
- // Not a dev-reload upgrade — refuse cleanly rather than leaving it hanging.
1413
- try {
1414
- socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
1415
- socket.destroy();
1416
- } catch {
1417
- /* socket already gone */
1418
- }
1419
- });
1510
+ wsRouteManager.setTracker(
1511
+ (remoteAddress, p) => WsTracker.add(remoteAddress, p),
1512
+ (id) => { WsTracker.remove(id); },
1513
+ );
1420
1514
  }
1515
+ server.on("upgrade", (req: IncomingMessage, socket: Socket, head: Buffer) => {
1516
+ const upPath = (req.url ?? "/").split("?")[0];
1517
+ // Dev-reload channel (debug only).
1518
+ if (isDevMode() && upPath === "/__dev_reload") {
1519
+ devReloadWs.handleUpgrade(req, socket, head);
1520
+ return;
1521
+ }
1522
+ // User-registered WS route — enforces per-route auth, then drives the
1523
+ // open/message/close lifecycle on this connection. Returns false only when
1524
+ // no WS route matches this path.
1525
+ if (serveWebSocketRoute(req, socket, head)) {
1526
+ return;
1527
+ }
1528
+ // No dev-reload and no matching user route — refuse cleanly.
1529
+ try {
1530
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
1531
+ socket.destroy();
1532
+ } catch {
1533
+ /* socket already gone */
1534
+ }
1535
+ });
1421
1536
 
1422
1537
  return new Promise((resolvePromise) => {
1423
1538
  server.listen(port, host, () => {
@@ -28,6 +28,8 @@ import { randomBytes } from "node:crypto";
28
28
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
29
29
  import { join } from "node:path";
30
30
  import { execFileSync } from "node:child_process";
31
+ import { Log } from "./logger.js";
32
+ import { isTruthy } from "./dotenv.js";
31
33
  import { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
32
34
  import { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
33
35
  import { MongoSessionHandler } from "./sessionHandlers/mongoHandler.js";
@@ -196,7 +198,10 @@ export class RedisSessionHandler implements SessionHandler {
196
198
  /**
197
199
  * Execute a Redis command synchronously via a short-lived TCP connection.
198
200
  *
199
- * Returns the raw RESP response string.
201
+ * Returns the command result string. A genuine key miss yields `""`. A
202
+ * transport/connection FAILURE (server unreachable, AUTH error, timeout)
203
+ * THROWS so the Session boundary can distinguish "not found" (silent) from
204
+ * "backend failed" (log-loud + degrade). Backend-failure policy parity.
200
205
  */
201
206
  private execSync(args: string[]): string {
202
207
  const script = `
@@ -270,18 +275,24 @@ export class RedisSessionHandler implements SessionHandler {
270
275
  setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
271
276
  `;
272
277
 
278
+ let result: string;
273
279
  try {
274
- const result = execFileSync(process.execPath, ["-e", script], {
280
+ result = execFileSync(process.execPath, ["-e", script], {
275
281
  encoding: "utf-8",
276
282
  timeout: 5000,
277
283
  stdio: ["pipe", "pipe", "pipe"],
278
284
  });
279
- if (result === "__NULL__") return "";
280
- if (result.startsWith("__ERR__")) return "";
281
- return result;
282
- } catch {
283
- return "";
285
+ } catch (err) {
286
+ // Non-zero exit = the child hit a socket error / AUTH failure / timeout.
287
+ // That is a transport FAILURE, not a key miss — surface it so the
288
+ // Session boundary logs + degrades (or re-throws under strict mode).
289
+ throw new Error(`Redis command failed: ${(err as Error).message}`);
290
+ }
291
+ if (result === "__NULL__") return ""; // genuine key miss
292
+ if (result.startsWith("__ERR__")) {
293
+ throw new Error(`Redis error: ${result.slice("__ERR__".length)}`);
284
294
  }
295
+ return result;
285
296
  }
286
297
 
287
298
  private key(sessionId: string): string {
@@ -290,7 +301,7 @@ export class RedisSessionHandler implements SessionHandler {
290
301
 
291
302
  read(sessionId: string): SessionData | null {
292
303
  const raw = this.execSync(["GET", this.key(sessionId)]);
293
- if (!raw) return null;
304
+ if (!raw) return null; // key miss — normal "no session yet", NOT an error
294
305
  try {
295
306
  return JSON.parse(raw) as SessionData;
296
307
  } catch {
@@ -323,6 +334,20 @@ export class Session {
323
334
  private ttl: number;
324
335
  private sessionId: string | null = null;
325
336
  private data: SessionData | null = null;
337
+ /**
338
+ * Dirty flag — set when data changes, cleared only on a successful write.
339
+ * Retained on a failed write so a later save() retries once the backend
340
+ * recovers (mirrors the Python `_dirty` semantics).
341
+ */
342
+ private dirty = false;
343
+ /**
344
+ * Backend-failure policy: log-loud + degrade (default), or re-raise when
345
+ * TINA4_SESSION_STRICT is truthy. A read failure logs + yields an empty
346
+ * session, a write failure logs + returns false (best-effort, dirty
347
+ * retained), destroy/gc failures log + swallow. Parity across all four
348
+ * frameworks. Strict mode is the escape hatch (same as events/seeding).
349
+ */
350
+ private strict: boolean;
326
351
 
327
352
  constructor(backend?: string, config?: SessionConfig) {
328
353
  const backendType = backend
@@ -333,6 +358,8 @@ export class Session {
333
358
  this.ttl = config?.ttl
334
359
  ?? (process.env.TINA4_SESSION_TTL ? parseInt(process.env.TINA4_SESSION_TTL, 10) : 3600);
335
360
 
361
+ this.strict = isTruthy(process.env.TINA4_SESSION_STRICT);
362
+
336
363
  // Select handler based on backend type
337
364
  switch (backendType) {
338
365
  case "redis":
@@ -370,6 +397,57 @@ export class Session {
370
397
  this.handler = handler;
371
398
  }
372
399
 
400
+ // ── Backend-failure policy: log-loud + degrade ─────────────────────
401
+ //
402
+ // The handlers themselves stay honest — they raise when the backend
403
+ // (Redis/Valkey/Mongo/DB) is unreachable, and return null/empty WITHOUT
404
+ // raising for a genuine "no session yet" miss. The Session layer is the
405
+ // single place that decides the resilience policy so every backend behaves
406
+ // the same: a transient outage logs + degrades rather than 500-ing every
407
+ // request (cascade outage) or vanishing silently (data loss). A genuinely
408
+ // empty result is NOT an error and never reaches these logs.
409
+
410
+ private logBackendError(op: string, err: unknown): void {
411
+ const handlerName = (this.handler as object)?.constructor?.name ?? "SessionHandler";
412
+ const message = err instanceof Error ? err.message : String(err);
413
+ Log.error(`Session backend ${op} failed (${handlerName}): ${message}`);
414
+ }
415
+
416
+ /** Read through the backend; on FAILURE log + degrade to empty (or re-throw under strict). */
417
+ private safeRead(sessionId: string): SessionData | null {
418
+ try {
419
+ return this.handler.read(sessionId);
420
+ } catch (err) {
421
+ this.logBackendError("read", err);
422
+ if (this.strict) throw err;
423
+ return null;
424
+ }
425
+ }
426
+
427
+ /** Write through the backend; on FAILURE log + return false (or re-throw under strict). */
428
+ private safeWrite(sessionId: string, data: SessionData, ttl: number): boolean {
429
+ try {
430
+ this.handler.write(sessionId, data, ttl);
431
+ return true;
432
+ } catch (err) {
433
+ this.logBackendError("write", err);
434
+ if (this.strict) throw err;
435
+ return false;
436
+ }
437
+ }
438
+
439
+ /** Destroy through the backend; on FAILURE log + swallow (or re-throw under strict). */
440
+ private safeDestroy(sessionId: string): boolean {
441
+ try {
442
+ this.handler.destroy(sessionId);
443
+ return true;
444
+ } catch (err) {
445
+ this.logBackendError("destroy", err);
446
+ if (this.strict) throw err;
447
+ return false;
448
+ }
449
+ }
450
+
373
451
  /**
374
452
  * Start or resume a session.
375
453
  * @param sessionId - Existing session ID to resume (optional)
@@ -377,17 +455,20 @@ export class Session {
377
455
  */
378
456
  start(sessionId?: string): string {
379
457
  if (sessionId) {
380
- const loaded = this.handler.read(sessionId);
458
+ const loaded = this.safeRead(sessionId);
381
459
  if (loaded) {
382
460
  // Check TTL for file backend (Redis handles TTL natively)
383
461
  const now = Math.floor(Date.now() / 1000);
384
462
  if (loaded._accessed && (now - loaded._accessed) > this.ttl) {
385
- this.handler.destroy(sessionId);
463
+ this.safeDestroy(sessionId);
386
464
  } else {
387
465
  this.sessionId = sessionId;
388
466
  this.data = loaded;
389
467
  this.data._accessed = now;
390
- this.handler.write(this.sessionId, this.data, this.ttl);
468
+ this.dirty = false;
469
+ // Refresh the accessed timestamp; a write failure here is logged but
470
+ // must not abort the resume — the request still serves.
471
+ this.safeWrite(this.sessionId, this.data, this.ttl);
391
472
  return sessionId;
392
473
  }
393
474
  }
@@ -397,7 +478,8 @@ export class Session {
397
478
  this.sessionId = randomBytes(16).toString("hex");
398
479
  const now = Math.floor(Date.now() / 1000);
399
480
  this.data = { _created: now, _accessed: now };
400
- this.handler.write(this.sessionId, this.data, this.ttl);
481
+ this.dirty = false;
482
+ this.safeWrite(this.sessionId, this.data, this.ttl);
401
483
  return this.sessionId;
402
484
  }
403
485
 
@@ -418,6 +500,7 @@ export class Session {
418
500
  set(key: string, value: unknown): void {
419
501
  if (!this.data) return;
420
502
  this.data[key] = value;
503
+ this.dirty = true;
421
504
  this.save();
422
505
  }
423
506
 
@@ -427,18 +510,23 @@ export class Session {
427
510
  delete(key: string): void {
428
511
  if (!this.data) return;
429
512
  delete this.data[key];
513
+ this.dirty = true;
430
514
  this.save();
431
515
  }
432
516
 
433
517
  /**
434
518
  * Destroy the entire session.
519
+ *
520
+ * A backend failure is logged (never silent) but does not throw under the
521
+ * default policy — local state is cleared regardless so the request proceeds.
435
522
  */
436
523
  destroy(): void {
437
524
  if (this.sessionId) {
438
- this.handler.destroy(this.sessionId);
525
+ this.safeDestroy(this.sessionId);
439
526
  }
440
527
  this.sessionId = null;
441
528
  this.data = null;
529
+ this.dirty = false;
442
530
  }
443
531
 
444
532
  /**
@@ -462,6 +550,7 @@ export class Session {
462
550
  if (!this.data) return;
463
551
  const now = Math.floor(Date.now() / 1000);
464
552
  this.data = { _created: this.data._created, _accessed: now };
553
+ this.dirty = true;
465
554
  this.save();
466
555
  }
467
556
 
@@ -475,6 +564,11 @@ export class Session {
475
564
 
476
565
  /**
477
566
  * Regenerate the session ID (keeps data, new ID).
567
+ *
568
+ * Call this right after a successful login or any privilege change to defeat
569
+ * session fixation — the pre-auth ID is destroyed and the data is carried
570
+ * onto a fresh, unguessable ID. A backend destroy/write failure is logged
571
+ * (never silent) but does not throw under the default policy.
478
572
  */
479
573
  regenerate(): string {
480
574
  const oldId = this.sessionId;
@@ -482,13 +576,14 @@ export class Session {
482
576
 
483
577
  // Remove old session
484
578
  if (oldId) {
485
- this.handler.destroy(oldId);
579
+ this.safeDestroy(oldId);
486
580
  }
487
581
 
488
582
  // New ID, keep data
489
583
  this.sessionId = randomBytes(16).toString("hex");
490
584
  this.data = oldData ?? { _created: Math.floor(Date.now() / 1000), _accessed: Math.floor(Date.now() / 1000) };
491
585
  this.data._accessed = Math.floor(Date.now() / 1000);
586
+ this.dirty = true;
492
587
  this.save();
493
588
  return this.sessionId;
494
589
  }
@@ -510,6 +605,7 @@ export class Session {
510
605
  if (!this.data || !(flashKey in this.data)) return undefined;
511
606
  const stored = this.data[flashKey];
512
607
  delete this.data[flashKey];
608
+ this.dirty = true;
513
609
  this.save();
514
610
  return stored;
515
611
  }
@@ -545,19 +641,35 @@ export class Session {
545
641
  /**
546
642
  * Run garbage collection on the session backend.
547
643
  * Removes expired file/database sessions. Redis/Valkey/Mongo handle TTL natively.
644
+ *
645
+ * A backend failure is logged (never silent) but does not throw under the
646
+ * default policy (re-raises under TINA4_SESSION_STRICT=true).
548
647
  */
549
648
  gc(): void {
550
- if (this.handler.gc) {
649
+ if (!this.handler.gc) return;
650
+ try {
551
651
  this.handler.gc(this.ttl);
652
+ } catch (err) {
653
+ this.logBackendError("gc", err);
654
+ if (this.strict) throw err;
552
655
  }
553
656
  }
554
657
 
555
658
  /**
556
659
  * Persist session data to the backend.
660
+ *
661
+ * Returns true on a successful persist, false if the backend was unreachable
662
+ * (logged). The dirty flag is cleared only on success so a later save()
663
+ * retries once the backend recovers. A nothing-to-persist call returns true.
557
664
  */
558
- save(): void {
559
- if (!this.sessionId || !this.data) return;
560
- this.handler.write(this.sessionId, this.data, this.ttl);
665
+ save(): boolean {
666
+ if (!this.sessionId || !this.data) return true;
667
+ if (!this.dirty) return true;
668
+ if (this.safeWrite(this.sessionId, this.data, this.ttl)) {
669
+ this.dirty = false;
670
+ return true;
671
+ }
672
+ return false; // write failed (logged); dirty RETAINED for retry
561
673
  }
562
674
  }
563
675
 
@@ -22,6 +22,16 @@ interface SessionData {
22
22
  export interface DatabaseSessionConfig {
23
23
  /** SQLite database file path (default: extracted from TINA4_DATABASE_URL or "data/tina4_sessions.db") */
24
24
  dbPath?: string;
25
+ // Unified SessionConfig fields are tolerated (and ignored) so the central
26
+ // Session can forward its config object without a structural mismatch.
27
+ backend?: string;
28
+ path?: string;
29
+ ttl?: number;
30
+ redisHost?: string;
31
+ redisPort?: number;
32
+ redisPassword?: string;
33
+ redisPrefix?: string;
34
+ redisDb?: number;
25
35
  }
26
36
 
27
37
  /**
@@ -30,6 +30,16 @@ export interface MongoSessionConfig {
30
30
  password?: string;
31
31
  database?: string;
32
32
  collection?: string;
33
+ // Unified SessionConfig fields are tolerated (and ignored) so the central
34
+ // Session can forward its config object without a structural mismatch.
35
+ backend?: string;
36
+ path?: string;
37
+ ttl?: number;
38
+ redisHost?: string;
39
+ redisPort?: number;
40
+ redisPassword?: string;
41
+ redisPrefix?: string;
42
+ redisDb?: number;
33
43
  }
34
44
 
35
45
  /**
@@ -79,6 +89,11 @@ export class MongoSessionHandler implements SessionHandler {
79
89
  * Uses the MongoDB wire protocol (OP_MSG) to communicate with the server.
80
90
  * For simplicity, we use the `runCommand` approach with JSON serialization
81
91
  * of BSON documents using a minimal BSON encoder.
92
+ *
93
+ * Returns the command response string (or `__EMPTY__` for an absent doc).
94
+ * A transport/connection FAILURE (server unreachable, socket error, timeout)
95
+ * THROWS so the Session boundary can distinguish "not found" (silent) from
96
+ * "backend failed" (log-loud + degrade). Backend-failure policy parity.
82
97
  */
83
98
  private execSync(command: string, args: string): string {
84
99
  const script = `
@@ -226,14 +241,16 @@ export class MongoSessionHandler implements SessionHandler {
226
241
  `;
227
242
 
228
243
  try {
229
- const result = execFileSync(process.execPath, ["-e", script], {
244
+ return execFileSync(process.execPath, ["-e", script], {
230
245
  encoding: "utf-8",
231
246
  timeout: 8000,
232
247
  stdio: ["pipe", "pipe", "pipe"],
233
248
  });
234
- return result;
235
- } catch {
236
- return "";
249
+ } catch (err) {
250
+ // Non-zero exit = the child hit a socket error / timeout. That is a
251
+ // transport FAILURE, not an absent document — surface it so the Session
252
+ // boundary logs + degrades (or re-throws under strict mode).
253
+ throw new Error(`MongoDB command failed: ${(err as Error).message}`);
237
254
  }
238
255
  }
239
256
 
@@ -32,6 +32,16 @@ export interface RedisNpmSessionConfig {
32
32
  password?: string;
33
33
  prefix?: string;
34
34
  db?: number;
35
+ // Unified SessionConfig fields are tolerated (and ignored) so the central
36
+ // Session can forward its config object without a structural mismatch.
37
+ backend?: string;
38
+ path?: string;
39
+ ttl?: number;
40
+ redisHost?: string;
41
+ redisPort?: number;
42
+ redisPassword?: string;
43
+ redisPrefix?: string;
44
+ redisDb?: number;
35
45
  }
36
46
 
37
47
  /**
@@ -78,6 +88,11 @@ export class RedisNpmSessionHandler implements SessionHandler {
78
88
  *
79
89
  * Attempts to use the `redis` npm package first. If unavailable, falls
80
90
  * back to raw TCP (RESP protocol) — same as valkeyHandler.ts.
91
+ *
92
+ * Returns the command result string. A genuine key miss yields `""`. A
93
+ * transport/connection FAILURE (server unreachable, AUTH error, timeout)
94
+ * THROWS so the Session boundary can distinguish "not found" (silent) from
95
+ * "backend failed" (log-loud + degrade). Backend-failure policy parity.
81
96
  */
82
97
  private execSync(args: string[]): string {
83
98
  const script = `
@@ -187,18 +202,24 @@ export class RedisNpmSessionHandler implements SessionHandler {
187
202
  }
188
203
  `;
189
204
 
205
+ let result: string;
190
206
  try {
191
- const result = execFileSync(process.execPath, ["-e", script], {
207
+ result = execFileSync(process.execPath, ["-e", script], {
192
208
  encoding: "utf-8",
193
209
  timeout: 5000,
194
210
  stdio: ["pipe", "pipe", "pipe"],
195
211
  });
196
- if (result === "__NULL__") return "";
197
- if (result.startsWith("__ERR__")) return "";
198
- return result;
199
- } catch {
200
- return "";
212
+ } catch (err) {
213
+ // Non-zero exit = the child hit a socket error / AUTH failure / timeout.
214
+ // That is a transport FAILURE, not a key miss — surface it so the
215
+ // Session boundary logs + degrades (or re-throws under strict mode).
216
+ throw new Error(`Redis command failed: ${(err as Error).message}`);
217
+ }
218
+ if (result === "__NULL__") return ""; // genuine key miss
219
+ if (result.startsWith("__ERR__")) {
220
+ throw new Error(`Redis error: ${result.slice("__ERR__".length)}`);
201
221
  }
222
+ return result;
202
223
  }
203
224
 
204
225
  private key(sessionId: string): string {
@@ -207,7 +228,7 @@ export class RedisNpmSessionHandler implements SessionHandler {
207
228
 
208
229
  read(sessionId: string): SessionData | null {
209
230
  const raw = this.execSync(["GET", this.key(sessionId)]);
210
- if (!raw) return null;
231
+ if (!raw) return null; // key miss — normal "no session yet", NOT an error
211
232
  try {
212
233
  return JSON.parse(raw) as SessionData;
213
234
  } catch {