tina4-nodejs 3.9.0 → 3.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.9.0",
3
+ "version": "3.9.1",
4
4
  "type": "module",
5
5
  "description": "This is not a framework. Tina4 for Node.js/TypeScript — zero deps, 38 built-in features.",
6
6
  "keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
File without changes
@@ -91,6 +91,45 @@ data/
91
91
  `
92
92
  );
93
93
 
94
+ // Dockerfile
95
+ if (!existsSync(join(targetDir, "Dockerfile"))) {
96
+ writeFileSync(
97
+ join(targetDir, "Dockerfile"),
98
+ `# Build stage
99
+ FROM node:22-alpine AS build
100
+ WORKDIR /app
101
+ COPY package*.json ./
102
+ RUN npm ci --production
103
+ COPY . .
104
+
105
+ # Runtime stage
106
+ FROM node:22-alpine
107
+ WORKDIR /app
108
+ COPY --from=build /app .
109
+ ENV HOST=0.0.0.0
110
+ ENV PORT=7148
111
+ EXPOSE 7148
112
+ CMD ["npx", "tsx", "app.ts"]
113
+ `
114
+ );
115
+ }
116
+
117
+ // .dockerignore
118
+ if (!existsSync(join(targetDir, ".dockerignore"))) {
119
+ writeFileSync(
120
+ join(targetDir, ".dockerignore"),
121
+ `node_modules
122
+ dist
123
+ .git
124
+ .claude
125
+ .env
126
+ *.log
127
+ test
128
+ tmp
129
+ `
130
+ );
131
+ }
132
+
94
133
  // Sample route: GET /api/hello
95
134
  writeFileSync(
96
135
  join(targetDir, "src/routes/api/hello/get.ts"),
@@ -17,7 +17,7 @@ export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from
17
17
  export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
18
18
  export type { RouteInfo } from "./router.js";
19
19
  export { discoverRoutes } from "./routeDiscovery.js";
20
- export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger, SecurityHeadersMiddleware } from "./middleware.js";
20
+ export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger, SecurityHeadersMiddleware, CsrfMiddleware } from "./middleware.js";
21
21
  export type { CorsConfig } from "./middleware.js";
22
22
  export { createRequest, parseBody } from "./request.js";
23
23
  export { createResponse, errorResponse } from "./response.js";
@@ -1,4 +1,5 @@
1
1
  import type { Tina4Request, Tina4Response, Middleware } from "./types.js";
2
+ import { validToken } from "./auth.js";
2
3
 
3
4
  export class MiddlewareChain {
4
5
  private middlewares: Middleware[] = [];
@@ -146,7 +147,7 @@ export function cors(config?: CorsConfig): Middleware {
146
147
 
147
148
  const headersRaw = config?.headers
148
149
  ?? process.env.TINA4_CORS_HEADERS
149
- ?? "Content-Type, Authorization";
150
+ ?? "Content-Type,Authorization,X-Request-ID";
150
151
  const allowedHeaders = Array.isArray(headersRaw)
151
152
  ? headersRaw.join(", ")
152
153
  : headersRaw;
@@ -205,7 +206,9 @@ export class CorsMiddleware {
205
206
  ?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
206
207
 
207
208
  const allowedHeaders = process.env.TINA4_CORS_HEADERS
208
- ?? "Content-Type, Authorization";
209
+ ?? "Content-Type,Authorization,X-Request-ID";
210
+
211
+ const credentials = process.env.TINA4_CORS_CREDENTIALS ?? "true";
209
212
 
210
213
  const maxAge = process.env.TINA4_CORS_MAX_AGE
211
214
  ? parseInt(process.env.TINA4_CORS_MAX_AGE, 10)
@@ -226,6 +229,11 @@ export class CorsMiddleware {
226
229
  res.header("Access-Control-Allow-Methods", allowedMethods);
227
230
  res.header("Access-Control-Allow-Headers", allowedHeaders);
228
231
 
232
+ // Add credentials header when enabled and origin is not wildcard
233
+ if (credentials === "true" && originHeader !== "*") {
234
+ res.header("Access-Control-Allow-Credentials", "true");
235
+ }
236
+
229
237
  if (req.method === "OPTIONS") {
230
238
  res.header("Access-Control-Max-Age", String(maxAge));
231
239
  res(null, 204);
@@ -402,6 +410,126 @@ export class SecurityHeadersMiddleware {
402
410
  }
403
411
  }
404
412
 
413
+ /**
414
+ * Class-based CSRF middleware using the before/after convention.
415
+ * Validates form tokens on state-changing requests (POST, PUT, PATCH, DELETE).
416
+ *
417
+ * Off by default — only active when TINA4_CSRF=true in .env or when
418
+ * registered explicitly via Router.use(CsrfMiddleware).
419
+ *
420
+ * Behaviour:
421
+ * - Skips GET, HEAD, OPTIONS requests.
422
+ * - Skips routes marked .noAuth().
423
+ * - Skips requests with a valid Authorization: Bearer header (API clients).
424
+ * - Checks request body formToken then X-Form-Token header.
425
+ * - Rejects if token found in query string formToken (log warning, 403).
426
+ * - Validates token with validToken using SECRET env var.
427
+ * - If token payload has session_id, verifies it matches request session.
428
+ * - Returns 403 on failure.
429
+ *
430
+ * Usage:
431
+ * Router.use(CsrfMiddleware);
432
+ */
433
+ export class CsrfMiddleware {
434
+ static beforeCsrf(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
435
+ // Skip CSRF validation entirely if disabled via env
436
+ const csrfEnv = process.env.TINA4_CSRF;
437
+ if (csrfEnv === "false" || csrfEnv === "0" || csrfEnv === "no") {
438
+ return [req, res];
439
+ }
440
+
441
+ // Skip safe HTTP methods
442
+ const method = (req.method ?? "GET").toUpperCase();
443
+ if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
444
+ return [req, res];
445
+ }
446
+
447
+ // Skip routes marked noAuth
448
+ const route = (req as any)._route ?? (req as any).route;
449
+ if (route?.noAuth) {
450
+ return [req, res];
451
+ }
452
+
453
+ // Skip requests with valid Bearer token (API clients)
454
+ const authHeader = req.headers.authorization ?? "";
455
+ if (authHeader.startsWith("Bearer ")) {
456
+ const bearerToken = authHeader.slice(7).trim();
457
+ if (bearerToken) {
458
+ const secret = process.env.SECRET || "tina4-default-secret";
459
+ const payload = validToken(bearerToken, secret);
460
+ if (payload !== null) {
461
+ return [req, res];
462
+ }
463
+ }
464
+ }
465
+
466
+ // Reject if token is in query string (security risk)
467
+ const query = (req as any).query ?? {};
468
+ if (query.formToken) {
469
+ console.warn("[Tina4 CSRF] Token found in query string — rejected for security");
470
+ res({
471
+ error: "CSRF_INVALID",
472
+ message: "Form token must not be sent in the URL query string",
473
+ }, 403);
474
+ return [req, res];
475
+ }
476
+
477
+ // Extract token: body first, then header
478
+ let token: string | undefined;
479
+ const body = (req as any).body;
480
+ if (body && typeof body === "object" && body.formToken) {
481
+ token = String(body.formToken);
482
+ }
483
+
484
+ if (!token) {
485
+ token = (req.headers["x-form-token"] as string) ?? "";
486
+ }
487
+
488
+ if (!token) {
489
+ res({
490
+ error: "CSRF_INVALID",
491
+ message: "Invalid or missing form token",
492
+ }, 403);
493
+ return [req, res];
494
+ }
495
+
496
+ // Validate the token
497
+ const secret = process.env.SECRET || "tina4-default-secret";
498
+ const payload = validToken(token, secret);
499
+
500
+ if (payload === null) {
501
+ res({
502
+ error: "CSRF_INVALID",
503
+ message: "Invalid or missing form token",
504
+ }, 403);
505
+ return [req, res];
506
+ }
507
+
508
+ // Session binding — if token has session_id, verify it matches
509
+ const tokenSessionId = payload.session_id as string | undefined;
510
+ if (tokenSessionId) {
511
+ const session = (req as any).session;
512
+ let currentSessionId: string | undefined;
513
+ if (session) {
514
+ currentSessionId = session.session_id ?? session.sessionId ?? session.id;
515
+ if (typeof currentSessionId === "function") {
516
+ currentSessionId = undefined;
517
+ }
518
+ }
519
+
520
+ if (currentSessionId && tokenSessionId !== currentSessionId) {
521
+ res({
522
+ error: "CSRF_INVALID",
523
+ message: "Invalid or missing form token",
524
+ }, 403);
525
+ return [req, res];
526
+ }
527
+ }
528
+
529
+ return [req, res];
530
+ }
531
+ }
532
+
405
533
  // Built-in request logger middleware (function form — kept for backwards compat)
406
534
  export function requestLogger(): Middleware {
407
535
  return (req, res, next) => {
@@ -48,6 +48,8 @@ export interface QueueJob {
48
48
  createdAt: string;
49
49
  attempts: number;
50
50
  delayUntil: string | null;
51
+ priority: number;
52
+ topic: string;
51
53
  error?: string;
52
54
  /** Mark this job as completed. */
53
55
  complete(): void;
@@ -55,12 +57,15 @@ export interface QueueJob {
55
57
  fail(reason?: string): void;
56
58
  /** Reject this job with a reason. Alias for fail(). */
57
59
  reject(reason?: string): void;
60
+ /** Re-queue this job with incremented attempts and optional delay. */
61
+ retry(delaySeconds?: number): void;
58
62
  }
59
63
 
60
64
  /** Create a QueueJob with lifecycle methods bound to a Queue instance. */
61
- function createJob(data: Omit<QueueJob, "complete" | "fail" | "reject">, queue: Queue, topic: string): QueueJob {
65
+ function createJob(data: Omit<QueueJob, "complete" | "fail" | "reject" | "retry">, queue: Queue, topic: string): QueueJob {
62
66
  const job: QueueJob = {
63
67
  ...data,
68
+ topic,
64
69
  complete() {
65
70
  job.status = "completed";
66
71
  },
@@ -73,6 +78,9 @@ function createJob(data: Omit<QueueJob, "complete" | "fail" | "reject">, queue:
73
78
  reject(reason = "") {
74
79
  job.fail(reason);
75
80
  },
81
+ retry(delaySeconds?: number) {
82
+ queue._retryJob(topic, job, delaySeconds);
83
+ },
76
84
  };
77
85
  return job;
78
86
  }
@@ -160,25 +168,32 @@ export class Queue {
160
168
  * Add a job to the queue. Returns job ID.
161
169
  *
162
170
  * Can be called as:
163
- * queue.push(payload) — uses constructor topic
164
- * queue.push(payload, delay) — uses constructor topic with delay
165
- * queue.push("queueName", payload) legacy: explicit queue name
171
+ * queue.push(payload) — uses constructor topic
172
+ * queue.push(payload, delay) — uses constructor topic with delay
173
+ * queue.push(payload, delay, priority) uses constructor topic with delay and priority
174
+ * queue.push("queueName", payload) — legacy: explicit queue name
175
+ * queue.push("queueName", payload, delay) — legacy with delay
176
+ *
177
+ * @param priority — Higher value = higher priority. Default 0.
166
178
  */
167
- push(queueOrPayload: string | unknown, payloadOrDelay?: unknown, delay?: number): string {
179
+ push(queueOrPayload: string | unknown, payloadOrDelay?: unknown, delay?: number, priority?: number): string {
168
180
  let queue: string;
169
181
  let payload: unknown;
170
182
  let actualDelay: number | undefined;
183
+ let actualPriority: number;
171
184
 
172
185
  if (typeof queueOrPayload === "string" && payloadOrDelay !== undefined && typeof payloadOrDelay !== "number") {
173
- // Legacy: push("queueName", payload, delay?)
186
+ // Legacy: push("queueName", payload, delay?, priority?)
174
187
  queue = queueOrPayload;
175
188
  payload = payloadOrDelay;
176
189
  actualDelay = delay;
190
+ actualPriority = priority ?? 0;
177
191
  } else {
178
- // Unified: push(payload) or push(payload, delay)
192
+ // Unified: push(payload) or push(payload, delay) or push(payload, delay, priority)
179
193
  queue = this.topic;
180
194
  payload = queueOrPayload;
181
195
  actualDelay = typeof payloadOrDelay === "number" ? payloadOrDelay : delay;
196
+ actualPriority = typeof payloadOrDelay === "number" ? (delay ?? 0) : (priority ?? 0);
182
197
  }
183
198
 
184
199
  if (this.externalBackend) {
@@ -189,13 +204,14 @@ export class Queue {
189
204
  const now = new Date().toISOString();
190
205
  this.seq++;
191
206
 
192
- const job: QueueJob = {
207
+ const job = {
193
208
  id,
194
209
  payload,
195
- status: "pending",
210
+ status: "pending" as const,
196
211
  createdAt: now,
197
212
  attempts: 0,
198
213
  delayUntil: actualDelay ? new Date(Date.now() + actualDelay * 1000).toISOString() : null,
214
+ priority: actualPriority,
199
215
  };
200
216
 
201
217
  const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
@@ -240,6 +256,8 @@ export class Queue {
240
256
  if (job.delayUntil && job.delayUntil > now) continue;
241
257
 
242
258
  job.status = "reserved";
259
+ job.topic = q;
260
+ job.priority = job.priority ?? 0;
243
261
  writeFileSync(filePath, JSON.stringify(job, null, 2));
244
262
 
245
263
  try {
@@ -248,7 +266,7 @@ export class Queue {
248
266
  // Already consumed by another worker
249
267
  }
250
268
 
251
- return job;
269
+ return createJob(job as any, this, q);
252
270
  }
253
271
 
254
272
  return null;
@@ -302,14 +320,29 @@ export class Queue {
302
320
  }
303
321
 
304
322
  /**
305
- * Count pending jobs in a queue.
323
+ * Count jobs in a queue filtered by status.
324
+ *
325
+ * @param queue — Queue/topic name (defaults to constructor topic)
326
+ * @param status — Job status to count: "pending" (default) or "failed"
306
327
  */
307
- size(queue?: string): number {
328
+ size(queue?: string, status: string = "pending"): number {
308
329
  const q = queue ?? this.topic;
309
330
 
310
331
  if (this.externalBackend) {
311
332
  return this.externalBackend.size(q);
312
333
  }
334
+
335
+ if (status === "failed") {
336
+ const failedDir = this.ensureFailedDir(q);
337
+ let files: string[];
338
+ try {
339
+ files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
340
+ } catch {
341
+ return 0;
342
+ }
343
+ return files.length;
344
+ }
345
+
313
346
  const dir = this.ensureDir(q);
314
347
  let files: string[];
315
348
  try {
@@ -321,8 +354,8 @@ export class Queue {
321
354
  let count = 0;
322
355
  for (const file of files) {
323
356
  try {
324
- const job: QueueJob = JSON.parse(readFileSync(join(dir, file), "utf-8"));
325
- if (job.status === "pending") count++;
357
+ const job = JSON.parse(readFileSync(join(dir, file), "utf-8"));
358
+ if (job.status === status) count++;
326
359
  } catch {
327
360
  // skip corrupt files
328
361
  }
@@ -660,4 +693,19 @@ export class Queue {
660
693
 
661
694
  writeFileSync(join(failedDir, `${job.id}.queue-data`), JSON.stringify(job, null, 2));
662
695
  }
696
+
697
+ /**
698
+ * Re-queue a job back to the main queue directory with incremented attempts.
699
+ */
700
+ _retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
701
+ const dir = this.ensureDir(queue);
702
+ job.status = "pending";
703
+ job.attempts = (job.attempts || 0) + 1;
704
+ job.error = undefined;
705
+ job.delayUntil = delaySeconds ? new Date(Date.now() + delaySeconds * 1000).toISOString() : null;
706
+
707
+ this.seq++;
708
+ const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
709
+ writeFileSync(join(dir, `${prefix}_${job.id}.queue-data`), JSON.stringify(job, null, 2));
710
+ }
663
711
  }
@@ -9,6 +9,7 @@ interface MatchResult {
9
9
  template?: string;
10
10
  secure?: boolean;
11
11
  cached?: boolean;
12
+ noAuth?: boolean;
12
13
  }
13
14
 
14
15
  interface CompiledRoute {
@@ -21,6 +22,7 @@ interface CompiledRoute {
21
22
  middlewares?: Middleware[];
22
23
  secure?: boolean;
23
24
  cached?: boolean;
25
+ noAuth?: boolean;
24
26
  cacheStore?: Map<string, { data: unknown; expires: number }>;
25
27
  cacheTtl?: number;
26
28
  template?: string;
@@ -41,6 +43,12 @@ export class RouteRef {
41
43
  return this;
42
44
  }
43
45
 
46
+ /** Opt out of secure-by-default auth (for public write routes). */
47
+ noAuth(): this {
48
+ this.route.noAuth = true;
49
+ return this;
50
+ }
51
+
44
52
  /** Mark this route's response as cacheable. */
45
53
  cache(): this {
46
54
  this.route.cached = true;
@@ -105,6 +113,11 @@ export class Router {
105
113
  routes.splice(existingIndex, 1);
106
114
  }
107
115
 
116
+ // Write methods (POST/PUT/PATCH/DELETE) are secure by default
117
+ const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
118
+ const isWrite = WRITE_METHODS.has(method);
119
+ const secureDefault = isWrite ? (definition.secure ?? true) : definition.secure;
120
+
108
121
  const compiled: CompiledRoute = {
109
122
  pattern: definition.pattern,
110
123
  regex,
@@ -113,8 +126,9 @@ export class Router {
113
126
  meta: definition.meta,
114
127
  filePath: definition.filePath,
115
128
  middlewares: definition.middlewares,
116
- secure: definition.secure,
129
+ secure: secureDefault,
117
130
  cached: definition.cached,
131
+ noAuth: definition.noAuth,
118
132
  template: definition.template,
119
133
  };
120
134
  routes.push(compiled);
@@ -201,6 +215,7 @@ export class Router {
201
215
  template: route.template,
202
216
  secure: route.secure,
203
217
  cached: route.cached,
218
+ noAuth: route.noAuth,
204
219
  };
205
220
  }
206
221
  }
@@ -225,6 +240,7 @@ export class Router {
225
240
  template: route.template,
226
241
  secure: route.secure,
227
242
  cached: route.cached,
243
+ noAuth: route.noAuth,
228
244
  });
229
245
  }
230
246
  }
@@ -7,6 +7,7 @@ import cluster from "node:cluster";
7
7
  import os from "node:os";
8
8
  import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
9
9
  import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
10
+ import { validToken } from "./auth.js";
10
11
  import { discoverRoutes } from "./routeDiscovery.js";
11
12
  import { createRequest, parseBody } from "./request.js";
12
13
  import { createResponse } from "./response.js";
@@ -683,6 +684,21 @@ ${reset}
683
684
  if (!proceed || res.raw.writableEnded) return;
684
685
  }
685
686
 
687
+ // Auth enforcement: secure routes require a valid Bearer token
688
+ if (match.secure === true && match.noAuth !== true) {
689
+ const authHeader = req.headers.authorization ?? "";
690
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
691
+ const secret = process.env.SECRET || "";
692
+ const payload = token ? validToken(token, secret) : null;
693
+
694
+ if (!payload) {
695
+ res.raw.writeHead(401, { "Content-Type": "application/json" });
696
+ res.raw.end(JSON.stringify({ error: "Unauthorized" }));
697
+ return;
698
+ }
699
+ req.user = payload;
700
+ }
701
+
686
702
  // Inject path params by name into handler arguments, then request/response
687
703
  let result: unknown;
688
704
  const routeParams = req.params || {};
@@ -128,10 +128,11 @@ async function executeHandler(svc: RegisteredService): Promise<void> {
128
128
  }
129
129
 
130
130
  function startCronService(svc: RegisteredService): void {
131
- const checkIntervalMs = parseInt(
132
- process.env.TINA4_SERVICE_INTERVAL ?? "1000",
131
+ const sleepSeconds = parseInt(
132
+ process.env.TINA4_SERVICE_SLEEP ?? "5",
133
133
  10,
134
134
  );
135
+ const checkIntervalMs = sleepSeconds * 1000;
135
136
  let lastMinuteRun = -1;
136
137
 
137
138
  svc.timerId = setInterval(() => {
@@ -24,6 +24,7 @@ export interface Tina4Request extends IncomingMessage {
24
24
  ip: string;
25
25
  files: UploadedFile[];
26
26
  session: Tina4Session;
27
+ user?: Record<string, unknown>;
27
28
  }
28
29
 
29
30
  export interface CookieOptions {
@@ -84,6 +85,8 @@ export interface RouteDefinition {
84
85
  secure?: boolean;
85
86
  /** Whether this route's response should be cached */
86
87
  cached?: boolean;
88
+ /** Opt out of secure-by-default auth on write routes */
89
+ noAuth?: boolean;
87
90
  }
88
91
 
89
92
  export interface RouteMeta {
@@ -802,9 +802,22 @@ function _b64url(data: Buffer): string {
802
802
  *
803
803
  * @returns `<input type="hidden" name="formToken" value="TOKEN">`
804
804
  */
805
+ /**
806
+ * Module-level session ID holder — set by the server before rendering
807
+ * templates so that form_token() can bind tokens to the current session.
808
+ */
809
+ let _formTokenSessionId: string = "";
810
+
811
+ /**
812
+ * Set the session ID used by formToken() / form_token() for CSRF session binding.
813
+ */
814
+ export function setFormTokenSessionId(sessionId: string): void {
815
+ _formTokenSessionId = sessionId || "";
816
+ }
817
+
805
818
  function _generateFormToken(descriptor: string = ""): SafeString {
806
819
  const secret = process.env.SECRET || "tina4-default-secret";
807
- const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "30", 10);
820
+ const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
808
821
 
809
822
  const header = { alg: "HS256", typ: "JWT" };
810
823
  const now = Math.floor(Date.now() / 1000);
@@ -820,6 +833,11 @@ function _generateFormToken(descriptor: string = ""): SafeString {
820
833
  }
821
834
  }
822
835
 
836
+ // Include session_id for CSRF session binding
837
+ if (_formTokenSessionId) {
838
+ payload.session_id = _formTokenSessionId;
839
+ }
840
+
823
841
  const h = _b64url(Buffer.from(JSON.stringify(header)));
824
842
  const p = _b64url(Buffer.from(JSON.stringify(payload)));
825
843
  const sigInput = `${h}.${p}`;
@@ -205,6 +205,9 @@ export class Database {
205
205
  /** Factory for creating new adapters (used by pool) */
206
206
  private adapterFactory: (() => Promise<DatabaseAdapter>) | null = null;
207
207
 
208
+ /** Whether to automatically commit after each write operation */
209
+ private autoCommit: boolean = process.env.TINA4_AUTOCOMMIT === "true";
210
+
208
211
  /**
209
212
  * Create a Database wrapping an existing adapter.
210
213
  * For creating a Database from a URL, use the async static factories:
@@ -305,22 +308,42 @@ export class Database {
305
308
 
306
309
  /** Execute a statement (INSERT, UPDATE, DELETE, DDL). */
307
310
  execute(sql: string, params?: unknown[]): unknown {
308
- return this.getNextAdapter().execute(sql, params);
311
+ const adapter = this.getNextAdapter();
312
+ const result = adapter.execute(sql, params);
313
+ if (this.autoCommit) {
314
+ try { adapter.commit(); } catch { /* no active transaction */ }
315
+ }
316
+ return result;
309
317
  }
310
318
 
311
319
  /** Insert a row into a table. */
312
320
  insert(table: string, data: Record<string, unknown>): DatabaseWriteResult {
313
- return this.getNextAdapter().insert(table, data);
321
+ const adapter = this.getNextAdapter();
322
+ const result = adapter.insert(table, data);
323
+ if (this.autoCommit) {
324
+ try { adapter.commit(); } catch { /* no active transaction */ }
325
+ }
326
+ return result;
314
327
  }
315
328
 
316
329
  /** Update rows in a table matching filter. */
317
330
  update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown>): DatabaseWriteResult {
318
- return this.getNextAdapter().update(table, data, filter ?? {});
331
+ const adapter = this.getNextAdapter();
332
+ const result = adapter.update(table, data, filter ?? {});
333
+ if (this.autoCommit) {
334
+ try { adapter.commit(); } catch { /* no active transaction */ }
335
+ }
336
+ return result;
319
337
  }
320
338
 
321
339
  /** Delete rows from a table matching filter. */
322
340
  delete(table: string, filter?: Record<string, unknown>): DatabaseWriteResult {
323
- return this.getNextAdapter().delete(table, filter ?? {});
341
+ const adapter = this.getNextAdapter();
342
+ const result = adapter.delete(table, filter ?? {});
343
+ if (this.autoCommit) {
344
+ try { adapter.commit(); } catch { /* no active transaction */ }
345
+ }
346
+ return result;
324
347
  }
325
348
 
326
349
  /** Close all database connections (pool or single). */
@@ -15,7 +15,7 @@ export function generateOpenAPISpec(
15
15
  const spec: OpenAPISpec = {
16
16
  openapi: "3.0.3",
17
17
  info: {
18
- title: "Tina4 API",
18
+ title: process.env.SWAGGER_TITLE ?? "Tina4 API",
19
19
  version: "0.0.1",
20
20
  description: "Auto-generated API documentation",
21
21
  },