tina4-nodejs 3.13.36 → 3.13.38

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 (50) hide show
  1. package/CLAUDE.md +51 -19
  2. package/package.json +5 -3
  3. package/packages/cli/src/bin.ts +7 -0
  4. package/packages/cli/src/commands/init.ts +1 -0
  5. package/packages/cli/src/commands/metrics.ts +154 -0
  6. package/packages/cli/src/commands/routes.ts +3 -3
  7. package/packages/core/public/js/tina4-dev-admin.js +212 -212
  8. package/packages/core/public/js/tina4-dev-admin.min.js +212 -212
  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 +75 -26
  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 +14 -8
  18. package/packages/core/src/logger.ts +1 -1
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/messenger.ts +111 -11
  21. package/packages/core/src/metrics.ts +232 -33
  22. package/packages/core/src/middleware.ts +129 -39
  23. package/packages/core/src/plan.ts +1 -1
  24. package/packages/core/src/queue.ts +1 -1
  25. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  26. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  27. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  28. package/packages/core/src/rateLimiter.ts +1 -1
  29. package/packages/core/src/response.ts +90 -6
  30. package/packages/core/src/router.ts +2 -2
  31. package/packages/core/src/server.ts +26 -4
  32. package/packages/core/src/session.ts +130 -18
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  34. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  35. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  36. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  37. package/packages/core/src/testClient.ts +1 -1
  38. package/packages/core/src/websocket.ts +247 -33
  39. package/packages/core/src/websocketBackplane.ts +210 -10
  40. package/packages/core/src/wsdl.ts +55 -21
  41. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  42. package/packages/orm/src/adapters/postgres.ts +26 -4
  43. package/packages/orm/src/adapters/sqlite.ts +112 -13
  44. package/packages/orm/src/baseModel.ts +8 -3
  45. package/packages/orm/src/cachedDatabase.ts +15 -6
  46. package/packages/orm/src/database.ts +257 -55
  47. package/packages/orm/src/index.ts +2 -1
  48. package/packages/orm/src/migration.ts +2 -2
  49. package/packages/orm/src/seeder.ts +443 -65
  50. package/packages/swagger/src/ui.ts +1 -1
@@ -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 {
@@ -26,6 +26,16 @@ export interface ValkeySessionConfig {
26
26
  password?: string;
27
27
  prefix?: string;
28
28
  db?: number;
29
+ // Unified SessionConfig fields are tolerated (and ignored) so the central
30
+ // Session can forward its config object without a structural mismatch.
31
+ backend?: string;
32
+ path?: string;
33
+ ttl?: number;
34
+ redisHost?: string;
35
+ redisPort?: number;
36
+ redisPassword?: string;
37
+ redisPrefix?: string;
38
+ redisDb?: number;
29
39
  }
30
40
 
31
41
  /**
@@ -67,7 +77,10 @@ export class ValkeySessionHandler implements SessionHandler {
67
77
  /**
68
78
  * Execute a RESP command synchronously via a short-lived TCP connection.
69
79
  *
70
- * Returns the raw RESP response string.
80
+ * Returns the command result string. A genuine key miss yields `""`. A
81
+ * transport/connection FAILURE (server unreachable, AUTH error, timeout)
82
+ * THROWS so the Session boundary can distinguish "not found" (silent) from
83
+ * "backend failed" (log-loud + degrade). Backend-failure policy parity.
71
84
  */
72
85
  private execSync(args: string[]): string {
73
86
  const script = `
@@ -141,18 +154,24 @@ export class ValkeySessionHandler implements SessionHandler {
141
154
  setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
142
155
  `;
143
156
 
157
+ let result: string;
144
158
  try {
145
- const result = execFileSync(process.execPath, ["-e", script], {
159
+ result = execFileSync(process.execPath, ["-e", script], {
146
160
  encoding: "utf-8",
147
161
  timeout: 5000,
148
162
  stdio: ["pipe", "pipe", "pipe"],
149
163
  });
150
- if (result === "__NULL__") return "";
151
- if (result.startsWith("__ERR__")) return "";
152
- return result;
153
- } catch {
154
- return "";
164
+ } catch (err) {
165
+ // Non-zero exit = the child hit a socket error / AUTH failure / timeout.
166
+ // That is a transport FAILURE, not a key miss — surface it so the
167
+ // Session boundary logs + degrades (or re-throws under strict mode).
168
+ throw new Error(`Valkey command failed: ${(err as Error).message}`);
169
+ }
170
+ if (result === "__NULL__") return ""; // genuine key miss
171
+ if (result.startsWith("__ERR__")) {
172
+ throw new Error(`Valkey error: ${result.slice("__ERR__".length)}`);
155
173
  }
174
+ return result;
156
175
  }
157
176
 
158
177
  private key(sessionId: string): string {
@@ -161,7 +180,7 @@ export class ValkeySessionHandler implements SessionHandler {
161
180
 
162
181
  read(sessionId: string): SessionData | null {
163
182
  const raw = this.execSync(["GET", this.key(sessionId)]);
164
- if (!raw) return null;
183
+ if (!raw) return null; // key miss — normal "no session yet", NOT an error
165
184
  try {
166
185
  return JSON.parse(raw) as SessionData;
167
186
  } catch {
@@ -53,7 +53,7 @@ export class TestResponse {
53
53
  }
54
54
  }
55
55
 
56
- interface RequestOptions {
56
+ export interface RequestOptions {
57
57
  json?: Record<string, unknown> | unknown[];
58
58
  body?: string;
59
59
  headers?: Record<string, string>;