tina4-nodejs 3.2.1 → 3.4.0

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 (32) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/packages/cli/src/bin.ts +13 -1
  5. package/packages/cli/src/commands/migrate.ts +19 -5
  6. package/packages/cli/src/commands/migrateCreate.ts +29 -28
  7. package/packages/cli/src/commands/migrateRollback.ts +59 -0
  8. package/packages/cli/src/commands/migrateStatus.ts +62 -0
  9. package/packages/core/public/js/tina4-dev-admin.min.js +1 -1
  10. package/packages/core/src/auth.ts +44 -10
  11. package/packages/core/src/devAdmin.ts +14 -16
  12. package/packages/core/src/index.ts +9 -2
  13. package/packages/core/src/queue.ts +127 -25
  14. package/packages/core/src/queueBackends/mongoBackend.ts +223 -0
  15. package/packages/core/src/request.ts +3 -3
  16. package/packages/core/src/router.ts +90 -51
  17. package/packages/core/src/server.ts +47 -3
  18. package/packages/core/src/session.ts +17 -1
  19. package/packages/core/src/sessionHandlers/databaseHandler.ts +134 -0
  20. package/packages/core/src/sessionHandlers/redisHandler.ts +230 -0
  21. package/packages/core/src/types.ts +12 -6
  22. package/packages/core/src/websocket.ts +11 -2
  23. package/packages/core/src/websocketConnection.ts +4 -2
  24. package/packages/frond/src/engine.ts +66 -1
  25. package/packages/orm/src/autoCrud.ts +17 -12
  26. package/packages/orm/src/baseModel.ts +99 -21
  27. package/packages/orm/src/database.ts +197 -69
  28. package/packages/orm/src/databaseResult.ts +207 -0
  29. package/packages/orm/src/index.ts +6 -3
  30. package/packages/orm/src/migration.ts +296 -71
  31. package/packages/orm/src/model.ts +1 -0
  32. package/packages/orm/src/types.ts +1 -0
@@ -7,6 +7,8 @@ interface MatchResult {
7
7
  meta?: RouteMeta;
8
8
  middlewares?: Middleware[];
9
9
  template?: string;
10
+ secure?: boolean;
11
+ cached?: boolean;
10
12
  }
11
13
 
12
14
  interface CompiledRoute {
@@ -17,18 +19,42 @@ interface CompiledRoute {
17
19
  meta?: RouteMeta;
18
20
  filePath?: string;
19
21
  middlewares?: Middleware[];
22
+ secure?: boolean;
20
23
  cached?: boolean;
21
24
  cacheStore?: Map<string, { data: unknown; expires: number }>;
22
25
  cacheTtl?: number;
23
26
  template?: string;
24
27
  }
25
28
 
29
+ /**
30
+ * Thin reference to a registered route, enabling chained modifiers.
31
+ *
32
+ * Usage:
33
+ * router.get("/api/data", handler).secure().cache();
34
+ */
35
+ export class RouteRef {
36
+ constructor(private route: CompiledRoute) {}
37
+
38
+ /** Mark this route as requiring bearer-token authentication. */
39
+ secure(): this {
40
+ this.route.secure = true;
41
+ return this;
42
+ }
43
+
44
+ /** Mark this route's response as cacheable. */
45
+ cache(): this {
46
+ this.route.cached = true;
47
+ return this;
48
+ }
49
+ }
50
+
26
51
  export interface RouteInfo {
27
52
  method: string;
28
53
  path: string;
29
54
  handler: string;
30
55
  middlewareCount: number;
31
56
  cached: boolean;
57
+ secure: boolean;
32
58
  }
33
59
 
34
60
  export class Router {
@@ -38,7 +64,7 @@ export class Router {
38
64
  /**
39
65
  * Add a raw route definition (used internally and by file-based routing).
40
66
  */
41
- addRoute(definition: RouteDefinition): void {
67
+ addRoute(definition: RouteDefinition): RouteRef {
42
68
  const method = definition.method.toUpperCase();
43
69
  const { regex, paramNames } = this.compilePattern(definition.pattern);
44
70
 
@@ -54,7 +80,7 @@ export class Router {
54
80
  routes.splice(existingIndex, 1);
55
81
  }
56
82
 
57
- routes.push({
83
+ const compiled: CompiledRoute = {
58
84
  pattern: definition.pattern,
59
85
  regex,
60
86
  paramNames,
@@ -62,52 +88,58 @@ export class Router {
62
88
  meta: definition.meta,
63
89
  filePath: definition.filePath,
64
90
  middlewares: definition.middlewares,
91
+ secure: definition.secure,
92
+ cached: definition.cached,
65
93
  template: definition.template,
66
- });
94
+ };
95
+ routes.push(compiled);
96
+ return new RouteRef(compiled);
67
97
  }
68
98
 
69
99
  /**
70
100
  * Register a GET route programmatically.
71
101
  */
72
- get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
73
- this.addRoute({ method: "GET", pattern: path, handler, middlewares, meta });
102
+ get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
103
+ return this.addRoute({ method: "GET", pattern: path, handler, middlewares, meta });
74
104
  }
75
105
 
76
106
  /**
77
107
  * Register a POST route programmatically.
78
108
  */
79
- post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
80
- this.addRoute({ method: "POST", pattern: path, handler, middlewares, meta });
109
+ post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
110
+ return this.addRoute({ method: "POST", pattern: path, handler, middlewares, meta });
81
111
  }
82
112
 
83
113
  /**
84
114
  * Register a PUT route programmatically.
85
115
  */
86
- put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
87
- this.addRoute({ method: "PUT", pattern: path, handler, middlewares, meta });
116
+ put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
117
+ return this.addRoute({ method: "PUT", pattern: path, handler, middlewares, meta });
88
118
  }
89
119
 
90
120
  /**
91
121
  * Register a PATCH route programmatically.
92
122
  */
93
- patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
94
- this.addRoute({ method: "PATCH", pattern: path, handler, middlewares, meta });
123
+ patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
124
+ return this.addRoute({ method: "PATCH", pattern: path, handler, middlewares, meta });
95
125
  }
96
126
 
97
127
  /**
98
128
  * Register a DELETE route programmatically.
99
129
  */
100
- delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
101
- this.addRoute({ method: "DELETE", pattern: path, handler, middlewares, meta });
130
+ delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
131
+ return this.addRoute({ method: "DELETE", pattern: path, handler, middlewares, meta });
102
132
  }
103
133
 
104
134
  /**
105
135
  * Register a route that matches ANY HTTP method.
106
136
  */
107
- any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
137
+ any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
138
+ let lastRef!: RouteRef;
108
139
  for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]) {
109
- this.addRoute({ method, pattern: path, handler, middlewares, meta });
140
+ lastRef = this.addRoute({ method, pattern: path, handler, middlewares, meta });
110
141
  }
142
+ return lastRef;
111
143
  }
112
144
 
113
145
  /**
@@ -142,6 +174,8 @@ export class Router {
142
174
  meta: route.meta,
143
175
  middlewares: route.middlewares,
144
176
  template: route.template,
177
+ secure: route.secure,
178
+ cached: route.cached,
145
179
  };
146
180
  }
147
181
  }
@@ -164,6 +198,8 @@ export class Router {
164
198
  filePath: route.filePath,
165
199
  middlewares: route.middlewares,
166
200
  template: route.template,
201
+ secure: route.secure,
202
+ cached: route.cached,
167
203
  });
168
204
  }
169
205
  }
@@ -183,6 +219,7 @@ export class Router {
183
219
  handler: route.filePath ?? (route.handler.name || "(anonymous)"),
184
220
  middlewareCount: route.middlewares?.length ?? 0,
185
221
  cached: route.cached ?? false,
222
+ secure: route.secure ?? false,
186
223
  });
187
224
  }
188
225
  }
@@ -228,43 +265,43 @@ export class Router {
228
265
  /**
229
266
  * Register a GET route on the default global router.
230
267
  */
231
- static get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
232
- defaultRouter.get(path, handler, middlewares, meta);
268
+ static get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
269
+ return defaultRouter.get(path, handler, middlewares, meta);
233
270
  }
234
271
 
235
272
  /**
236
273
  * Register a POST route on the default global router.
237
274
  */
238
- static post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
239
- defaultRouter.post(path, handler, middlewares, meta);
275
+ static post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
276
+ return defaultRouter.post(path, handler, middlewares, meta);
240
277
  }
241
278
 
242
279
  /**
243
280
  * Register a PUT route on the default global router.
244
281
  */
245
- static put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
246
- defaultRouter.put(path, handler, middlewares, meta);
282
+ static put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
283
+ return defaultRouter.put(path, handler, middlewares, meta);
247
284
  }
248
285
 
249
286
  /**
250
287
  * Register a PATCH route on the default global router.
251
288
  */
252
- static patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
253
- defaultRouter.patch(path, handler, middlewares, meta);
289
+ static patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
290
+ return defaultRouter.patch(path, handler, middlewares, meta);
254
291
  }
255
292
 
256
293
  /**
257
294
  * Register a DELETE route on the default global router.
258
295
  */
259
- static delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
260
- defaultRouter.delete(path, handler, middlewares, meta);
296
+ static delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
297
+ return defaultRouter.delete(path, handler, middlewares, meta);
261
298
  }
262
299
 
263
300
  /**
264
301
  * Register a route that matches ANY HTTP method on the default global router.
265
302
  */
266
- static any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
267
- defaultRouter.any(path, handler, middlewares, meta);
303
+ static any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
304
+ return defaultRouter.any(path, handler, middlewares, meta);
268
305
  }
269
306
 
270
307
  /**
@@ -343,8 +380,8 @@ export class RouteGroup {
343
380
  return merged.length > 0 ? merged : undefined;
344
381
  }
345
382
 
346
- get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
347
- this.router.addRoute({
383
+ get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
384
+ return this.router.addRoute({
348
385
  method: "GET",
349
386
  pattern: this.prefix + path,
350
387
  handler,
@@ -353,8 +390,8 @@ export class RouteGroup {
353
390
  });
354
391
  }
355
392
 
356
- post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
357
- this.router.addRoute({
393
+ post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
394
+ return this.router.addRoute({
358
395
  method: "POST",
359
396
  pattern: this.prefix + path,
360
397
  handler,
@@ -363,8 +400,8 @@ export class RouteGroup {
363
400
  });
364
401
  }
365
402
 
366
- put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
367
- this.router.addRoute({
403
+ put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
404
+ return this.router.addRoute({
368
405
  method: "PUT",
369
406
  pattern: this.prefix + path,
370
407
  handler,
@@ -373,8 +410,8 @@ export class RouteGroup {
373
410
  });
374
411
  }
375
412
 
376
- patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
377
- this.router.addRoute({
413
+ patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
414
+ return this.router.addRoute({
378
415
  method: "PATCH",
379
416
  pattern: this.prefix + path,
380
417
  handler,
@@ -383,8 +420,8 @@ export class RouteGroup {
383
420
  });
384
421
  }
385
422
 
386
- delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
387
- this.router.addRoute({
423
+ delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
424
+ return this.router.addRoute({
388
425
  method: "DELETE",
389
426
  pattern: this.prefix + path,
390
427
  handler,
@@ -393,9 +430,10 @@ export class RouteGroup {
393
430
  });
394
431
  }
395
432
 
396
- any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
433
+ any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
434
+ let lastRef!: RouteRef;
397
435
  for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]) {
398
- this.router.addRoute({
436
+ lastRef = this.router.addRoute({
399
437
  method,
400
438
  pattern: this.prefix + path,
401
439
  handler,
@@ -403,6 +441,7 @@ export class RouteGroup {
403
441
  meta,
404
442
  });
405
443
  }
444
+ return lastRef;
406
445
  }
407
446
 
408
447
  /**
@@ -458,29 +497,29 @@ export const defaultRouter = new Router();
458
497
  * res.json({ id: req.params.id }, 201);
459
498
  * });
460
499
  */
461
- export function get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
462
- defaultRouter.get(path, handler, middlewares, meta);
500
+ export function get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
501
+ return defaultRouter.get(path, handler, middlewares, meta);
463
502
  }
464
503
 
465
- export function post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
466
- defaultRouter.post(path, handler, middlewares, meta);
504
+ export function post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
505
+ return defaultRouter.post(path, handler, middlewares, meta);
467
506
  }
468
507
 
469
- export function put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
470
- defaultRouter.put(path, handler, middlewares, meta);
508
+ export function put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
509
+ return defaultRouter.put(path, handler, middlewares, meta);
471
510
  }
472
511
 
473
- export function patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
474
- defaultRouter.patch(path, handler, middlewares, meta);
512
+ export function patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
513
+ return defaultRouter.patch(path, handler, middlewares, meta);
475
514
  }
476
515
 
477
516
  // Named "del" to avoid conflict with the "delete" keyword; also exported as "delete" alias below.
478
- export function del(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
479
- defaultRouter.delete(path, handler, middlewares, meta);
517
+ export function del(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
518
+ return defaultRouter.delete(path, handler, middlewares, meta);
480
519
  }
481
520
 
482
- export function any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
483
- defaultRouter.any(path, handler, middlewares, meta);
521
+ export function any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
522
+ return defaultRouter.any(path, handler, middlewares, meta);
484
523
  }
485
524
 
486
525
  export function websocket(path: string, handler: WebSocketRouteHandler): void {
@@ -29,6 +29,38 @@ const BUILTIN_PUBLIC_DIR = resolve(__dirname, "..", "public");
29
29
 
30
30
  const TINA4_VERSION = "3.0.0";
31
31
 
32
+ /**
33
+ * Test-bind each port in a subprocess to find one that is available.
34
+ * Falls back to `start` if none of the candidates work.
35
+ */
36
+ function findAvailablePort(start: number, maxTries = 10): number {
37
+ const { execFileSync } = require("node:child_process");
38
+ for (let offset = 0; offset < maxTries; offset++) {
39
+ const port = start + offset;
40
+ try {
41
+ execFileSync(process.execPath, ["-e", `
42
+ const s = require("net").createServer();
43
+ s.listen(${port}, "127.0.0.1", () => { s.close(); process.exit(0); });
44
+ s.on("error", () => process.exit(1));
45
+ `], { timeout: 1000 });
46
+ return port;
47
+ } catch { continue; }
48
+ }
49
+ return start;
50
+ }
51
+
52
+ /**
53
+ * Open the user's default browser after a short delay so the server is ready.
54
+ */
55
+ function openBrowser(url: string) {
56
+ const { exec } = require("node:child_process");
57
+ setTimeout(() => {
58
+ if (process.platform === "darwin") exec(`open ${url}`);
59
+ else if (process.platform === "win32") exec(`start "" "${url}"`);
60
+ else exec(`xdg-open ${url}`);
61
+ }, 2000);
62
+ }
63
+
32
64
  /**
33
65
  * Resolve port and host with priority: explicit config > ENV var > default.
34
66
  * Exported for testability.
@@ -266,8 +298,10 @@ function deployGallery(name) {
266
298
  if (d.error) {
267
299
  alert('Deploy failed: ' + d.error);
268
300
  } else {
269
- alert('Deployed "' + d.deployed + '" (' + d.files.length + ' files). Reloading...');
270
- window.location.reload();
301
+ // Brief delay to allow newly deployed routes to register before reloading
302
+ setTimeout(function() {
303
+ window.location.reload();
304
+ }, 500);
271
305
  }
272
306
  })
273
307
  .catch(function(e) { alert('Deploy error: ' + e.message); });
@@ -285,7 +319,16 @@ export async function startServer(config?: Tina4Config): Promise<{
285
319
  // Load .env early so TINA4_DEBUG is available for cluster decision
286
320
  loadEnv();
287
321
 
288
- const { port, host } = resolvePortAndHost(config);
322
+ const resolved = resolvePortAndHost(config);
323
+ const host = resolved.host;
324
+ let port = resolved.port;
325
+
326
+ // Auto-increment port if the requested one is already in use
327
+ const availablePort = findAvailablePort(port);
328
+ if (availablePort !== port) {
329
+ console.log(` Port ${port} in use, using ${availablePort} instead`);
330
+ port = availablePort;
331
+ }
289
332
 
290
333
  // Cluster mode for production: fork workers based on CPU count
291
334
  // Only when not in dev mode and running as primary process
@@ -669,6 +712,7 @@ ${reset}
669
712
  Dashboard: http://localhost:${port}/__dev
670
713
  Debug: ${isDebug ? "ON" : "OFF"} (Log level: ${logLevel})
671
714
  `);
715
+ openBrowser(`http://${displayHost}:${port}`);
672
716
  resolvePromise({
673
717
  close: () => {
674
718
  server.close();
@@ -2,6 +2,7 @@
2
2
  * Tina4 Session — Pluggable session backends, zero core dependencies.
3
3
  *
4
4
  * File-based sessions by default. Redis backend available via raw TCP (no ioredis needed).
5
+ * Database (SQLite) backend available via better-sqlite3.
5
6
  *
6
7
  * import { Session, RedisSessionHandler } from "@tina4/core";
7
8
  *
@@ -14,6 +15,10 @@
14
15
  * redisPort: 6379,
15
16
  * });
16
17
  *
18
+ * // Database backend (SQLite via better-sqlite3)
19
+ * const session = new Session("database");
20
+ * // or: new Session("db");
21
+ *
17
22
  * const id = session.start();
18
23
  * session.set("user", { name: "Alice" });
19
24
  * session.get("user"); // { name: "Alice" }
@@ -27,7 +32,7 @@ import { execFileSync } from "node:child_process";
27
32
  // ── Types ─────────────────────────────────────────────────────────
28
33
 
29
34
  export interface SessionConfig {
30
- /** Session backend type: "file" or "redis" */
35
+ /** Session backend type: "file", "redis", "valkey", "mongo", "database" (or "db") */
31
36
  backend?: string;
32
37
  /** File storage path (default: "data/sessions") */
33
38
  path?: string;
@@ -297,6 +302,11 @@ export class Session {
297
302
  case "redis":
298
303
  this.handler = new RedisSessionHandler(config);
299
304
  break;
305
+ case "redis-npm": {
306
+ const { RedisNpmSessionHandler } = require("./sessionHandlers/redisHandler.js");
307
+ this.handler = new RedisNpmSessionHandler(config);
308
+ break;
309
+ }
300
310
  case "valkey": {
301
311
  const { ValkeySessionHandler } = require("./sessionHandlers/valkeyHandler.js");
302
312
  this.handler = new ValkeySessionHandler(config);
@@ -308,6 +318,12 @@ export class Session {
308
318
  this.handler = new MongoSessionHandler(config);
309
319
  break;
310
320
  }
321
+ case "database":
322
+ case "db": {
323
+ const { DatabaseSessionHandler } = require("./sessionHandlers/databaseHandler.js");
324
+ this.handler = new DatabaseSessionHandler(config);
325
+ break;
326
+ }
311
327
  case "file":
312
328
  default:
313
329
  this.handler = new FileSessionHandler(config?.path);
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Tina4 Database Session Handler — SQLite via better-sqlite3, zero extra dependencies.
3
+ *
4
+ * Uses the same `better-sqlite3` library the ORM already depends on.
5
+ * Stores sessions in a `tina4_session` table with JSON data and expiry.
6
+ *
7
+ * Configure via environment variables:
8
+ * DATABASE_URL (default: "sqlite:///data/tina4_sessions.db")
9
+ *
10
+ * The handler dynamically imports `better-sqlite3` and throws a clear
11
+ * error if the package is not installed.
12
+ */
13
+ import { createRequire } from "node:module";
14
+ import type { SessionHandler } from "../session.js";
15
+
16
+ const _require = createRequire(import.meta.url);
17
+
18
+ interface SessionData {
19
+ _created: number;
20
+ _accessed: number;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export interface DatabaseSessionConfig {
25
+ /** SQLite database file path (default: extracted from DATABASE_URL or "data/tina4_sessions.db") */
26
+ dbPath?: string;
27
+ }
28
+
29
+ /**
30
+ * Database session handler using better-sqlite3 (synchronous SQLite).
31
+ *
32
+ * Stores session data as JSON in a `tina4_session` table.
33
+ * Expiry is checked on read; expired rows are cleaned up lazily.
34
+ */
35
+ export class DatabaseSessionHandler implements SessionHandler {
36
+ private db: any;
37
+ private initialized = false;
38
+
39
+ constructor(config?: DatabaseSessionConfig) {
40
+ const dbPath = config?.dbPath ?? this.resolveDbPath();
41
+
42
+ let Database: any;
43
+ try {
44
+ Database = _require("better-sqlite3");
45
+ } catch {
46
+ throw new Error(
47
+ "DatabaseSessionHandler requires 'better-sqlite3'. " +
48
+ "Install it with: npm install better-sqlite3"
49
+ );
50
+ }
51
+
52
+ this.db = new Database(dbPath);
53
+ this.db.pragma("journal_mode = WAL");
54
+ }
55
+
56
+ /**
57
+ * Resolve the database file path from DATABASE_URL or use the default.
58
+ */
59
+ private resolveDbPath(): string {
60
+ const url = process.env.DATABASE_URL;
61
+ if (url && url.startsWith("sqlite://")) {
62
+ // sqlite:///path/to/db or sqlite://./relative/path
63
+ return url.replace(/^sqlite:\/\//, "");
64
+ }
65
+ return "data/tina4_sessions.db";
66
+ }
67
+
68
+ /**
69
+ * Ensure the session table exists (called once on first use).
70
+ */
71
+ private ensureTable(): void {
72
+ if (this.initialized) return;
73
+ this.db.exec(`
74
+ CREATE TABLE IF NOT EXISTS tina4_session (
75
+ session_id TEXT PRIMARY KEY,
76
+ data TEXT NOT NULL,
77
+ expires_at REAL NOT NULL
78
+ )
79
+ `);
80
+ this.initialized = true;
81
+ }
82
+
83
+ read(sessionId: string): SessionData | null {
84
+ this.ensureTable();
85
+
86
+ const row = this.db
87
+ .prepare("SELECT data, expires_at FROM tina4_session WHERE session_id = ?")
88
+ .get(sessionId) as { data: string; expires_at: number } | undefined;
89
+
90
+ if (!row) return null;
91
+
92
+ // Check expiry
93
+ const now = Date.now() / 1000;
94
+ if (row.expires_at < now) {
95
+ // Expired — clean up and return null
96
+ this.destroy(sessionId);
97
+ return null;
98
+ }
99
+
100
+ try {
101
+ return JSON.parse(row.data) as SessionData;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ write(sessionId: string, data: SessionData, ttl: number): void {
108
+ this.ensureTable();
109
+
110
+ const json = JSON.stringify(data);
111
+ const expiresAt = (Date.now() / 1000) + (ttl > 0 ? ttl : 3600);
112
+
113
+ const existing = this.db
114
+ .prepare("SELECT 1 FROM tina4_session WHERE session_id = ?")
115
+ .get(sessionId);
116
+
117
+ if (existing) {
118
+ this.db
119
+ .prepare("UPDATE tina4_session SET data = ?, expires_at = ? WHERE session_id = ?")
120
+ .run(json, expiresAt, sessionId);
121
+ } else {
122
+ this.db
123
+ .prepare("INSERT INTO tina4_session (session_id, data, expires_at) VALUES (?, ?, ?)")
124
+ .run(sessionId, json, expiresAt);
125
+ }
126
+ }
127
+
128
+ destroy(sessionId: string): void {
129
+ this.ensureTable();
130
+ this.db
131
+ .prepare("DELETE FROM tina4_session WHERE session_id = ?")
132
+ .run(sessionId);
133
+ }
134
+ }