tina4-nodejs 3.10.67 → 3.10.70

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/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.10.42)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.10.70)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.10.42 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.10.70 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
@@ -153,8 +153,8 @@ Database layer with auto-CRUD generation, seeding, fake data, and SQL translatio
153
153
  - `fakeData.ts` — ORM-aware fake data extending core (adds `forField()` with column-name heuristics)
154
154
  - `seeder.ts` — Database seeding (`seedTable` for raw SQL, `seedOrm` for model-based)
155
155
  - `sqlTranslation.ts` — Cross-engine SQL translator (`SQLTranslator`) and TTL query cache (`QueryCache`)
156
- - **Instance methods:** `save()`, `delete()`, `forceDelete()`, `restore()`, `load(sql, params?, include?): boolean` (selectOne into self), `validate(): string[]`, `toDict(include?): Record`, `toObject(): Record`, `toArray(): unknown[]`, `toList(): unknown[]`, `toJson(): string`, `hasOne()`, `hasMany()`, `belongsTo()`
157
- - **Static methods:** `find(id, include?)`, `findById(id, include?)`, `findAll(where?, params?, include?)`, `findOrFail(id)`, `create(data)`, `select(sql, params?)`, `selectOne(sql, params?, include?)`, `count(conditions?, params?)`, `withTrashed(conditions?, params?)`, `scope(name, filterSql, params?)`, `createTable()`, `query(): QueryBuilder`
156
+ - **Instance methods:** `save(): this|null` (fluent, null on failure), `delete()`, `forceDelete()`, `restore()`, `load(sql, params?, include?): boolean`, `validate(): string[]`, `toDict(include?)`, `toAssoc(include?)`, `toObject()`, `toArray(): unknown[]`, `toList()`, `toJson(include?)`, `hasOne(class, fk)`, `hasMany(class, fk, limit?, offset?)`, `belongsTo(class, fk)`
157
+ - **Static methods:** `find(id, include?)`, `findById(id, include?)`, `findOrFail(id)`, `create(data)`, `all(where?, params?, include?)`, `select(sql, params?)`, `selectOne(sql, params?, include?)`, `where(conditions, params?, limit?, offset?, include?)`, `count(conditions?, params?)`, `withTrashed(conditions?, params?, limit?, offset?)`, `scope(name, filterSql, params?)` (registers reusable method), `createTable()`, `query()`
158
158
  - QueryBuilder supports `toMongo()` for generating MongoDB query documents from the same fluent API
159
159
  - `getNextId(table: string, pkColumn?: string, generatorName?: string): Promise<number>` — Race-safe ID generation using atomic sequence table (`tina4_sequences`). SQLite/MySQL/MSSQL use `tina4_sequences` with atomic UPDATE+SELECT. PostgreSQL auto-creates sequences if missing. Firebird uses existing generators (unchanged).
160
160
 
@@ -162,10 +162,10 @@ Database layer with auto-CRUD generation, seeding, fake data, and SQL translatio
162
162
 
163
163
  ### File Uploads
164
164
 
165
- Multipart file uploads are available via `req.files` (array of UploadedFile objects):
165
+ Multipart file uploads via `req.files` (dict keyed by field name):
166
166
 
167
167
  ```typescript
168
- // req.files[0] =>
168
+ // req.files["avatar"] =>
169
169
  {
170
170
  fieldName: "avatar",
171
171
  filename: "photo.png",
@@ -177,15 +177,80 @@ Multipart file uploads are available via `req.files` (array of UploadedFile obje
177
177
 
178
178
  ```typescript
179
179
  post("/api/upload", (req, res) => {
180
- const file = req.files?.find(f => f.fieldName === "avatar");
180
+ const file = req.files["avatar"];
181
181
  if (!file) return res.json({ error: "No file" }, 400);
182
- fs.writeFileSync(`src/public/uploads/${file.filename}`, file.content);
182
+ fs.writeFileSync(`src/public/uploads/${(file as any).filename}`, (file as any).content);
183
183
  return res.json({ ok: true });
184
184
  });
185
185
  ```
186
186
 
187
187
  Max upload size: `TINA4_MAX_UPLOAD_SIZE` env var (default 10MB).
188
188
 
189
+ ### Auth
190
+
191
+ ```typescript
192
+ // expires_in is in MINUTES (default 60). Reads SECRET from env if not passed.
193
+ getToken(payload, secret?, expiresIn=60): string
194
+ validToken(token, secret?): Record | null
195
+ getPayload(token): Record | null
196
+ refreshToken(token, secret?, expiresIn=60): string | null
197
+ hashPassword(password, salt?, iterations=260000): string // PBKDF2-SHA256, $ delimiter
198
+ checkPassword(password, hash): boolean // timing-safe
199
+ validateApiKey(provided, expected?): boolean // reads TINA4_API_KEY from env
200
+ authenticateRequest(headers, secret?): Record | null // Bearer JWT, falls back to API key
201
+ // Also available as Auth.getToken(), Auth.validToken(), etc.
202
+ ```
203
+
204
+ ### Session
205
+
206
+ ```typescript
207
+ session.start(sessionId?): string
208
+ session.get(key, defaultValue?): unknown
209
+ session.set(key, value): void
210
+ session.delete(key): void
211
+ session.has(key): boolean
212
+ session.all(): Record
213
+ session.clear(): void
214
+ session.destroy(): void
215
+ session.regenerate(): string
216
+ session.flash(key, value?): unknown // Dual-mode: set with value, get+remove without
217
+ session.getFlash(key, defaultValue?): unknown
218
+ session.save(): void // Public — persist to backend
219
+ session.cookieHeader(name?): string // Set-Cookie header value
220
+ session.getSessionId(): string | null
221
+ session.gc(): void
222
+ ```
223
+
224
+ Backends: file, redis, redis-npm, valkey, mongodb, database.
225
+
226
+ ### Database extras
227
+
228
+ ```typescript
229
+ db.execute(sql, params?): boolean | unknown // bool for writes, result for RETURNING/CALL/EXEC
230
+ db.getLastId(): string | number
231
+ db.getError(): string | null
232
+ db.cacheStats(): { enabled, size, ttl }
233
+ ```
234
+
235
+ ### Request extras
236
+
237
+ ```typescript
238
+ req.files: Record<string, UploadedFile> // dict keyed by field name (not array)
239
+ req.cookies: Record<string, string> // parsed from Cookie header
240
+ req.contentType: string // from content-type header
241
+ req.query: Record<string, string> // query string params
242
+ response.xml(content, status?): Tina4Response
243
+ response.stream(generator, contentType?: string, status?: number): void // SSE/streaming
244
+ ```
245
+
246
+ ### Queue
247
+
248
+ ```typescript
249
+ queue.consume(topic?, id?, pollInterval=1000): AsyncGenerator<QueueJob>
250
+ // Long-running async generator. Sleeps when empty. pollInterval=0 for single-pass.
251
+ // Usage: for await (const job of queue.consume("emails")) { ... }
252
+ ```
253
+
189
254
  ### @tina4/swagger (`packages/swagger/`)
190
255
  Auto-generates OpenAPI 3.0 docs.
191
256
 
@@ -613,7 +678,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
613
678
 
614
679
  ## v3 Features Summary
615
680
 
616
- - **44 built-in features**, zero third-party dependencies
681
+ - **45 built-in features**, zero third-party dependencies
617
682
  - **1,812 tests** passing across all modules
618
683
  - **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
619
684
  - **Frond template engine optimizations**: pre-compiled regexes, lazy loop context (copy-on-write), filter chain caching, path split caching, inline common filters (11-15% speedup)
@@ -631,6 +696,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
631
696
  - **SameSite=Lax** default on session cookies (`TINA4_SESSION_SAMESITE`)
632
697
  - **`tina4 init`** generates Dockerfile and .dockerignore
633
698
  - **Gallery**: 7 interactive examples with Try It deploy at `/_dev/`
699
+ - **SSE/Streaming**: `response.stream()` for Server-Sent Events — pass an async generator, framework handles chunked transfer encoding and keep-alive
634
700
 
635
701
  ## Don'ts
636
702
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.10.67",
3
+ "version": "3.10.70",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript — 54 built-in features, zero dependencies",
6
6
  "keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
@@ -40,16 +40,23 @@ function base64urlDecode(str: string): Buffer {
40
40
  */
41
41
  export function getToken(
42
42
  payload: Record<string, unknown>,
43
- secret: string,
44
- expiresIn: number = 3600,
43
+ secret?: string,
44
+ expiresIn: number = 60,
45
45
  algorithm: string = "HS256",
46
46
  ): string {
47
+ if (!secret) {
48
+ secret = process.env.SECRET ?? "";
49
+ if (!secret) {
50
+ console.warn("Auth: SECRET not set in .env — using blank secret (insecure)");
51
+ }
52
+ }
53
+
47
54
  const header = { alg: algorithm, typ: "JWT" };
48
55
  const now = Math.floor(Date.now() / 1000);
49
56
 
50
57
  const claims: Record<string, unknown> = { ...payload, iat: now };
51
58
  if (expiresIn !== 0) {
52
- claims.exp = now + expiresIn;
59
+ claims.exp = now + Math.floor(expiresIn * 60);
53
60
  }
54
61
 
55
62
  const h = base64urlEncode(Buffer.from(JSON.stringify(header)));
@@ -65,9 +72,15 @@ export function getToken(
65
72
  */
66
73
  export function validToken(
67
74
  token: string,
68
- secret: string,
75
+ secret?: string,
69
76
  algorithm: string = "HS256",
70
77
  ): Record<string, unknown> | null {
78
+ if (!secret) {
79
+ secret = process.env.SECRET ?? "";
80
+ if (!secret) {
81
+ console.warn("Auth: SECRET not set in .env — using blank secret (insecure)");
82
+ }
83
+ }
71
84
  try {
72
85
  const parts = token.split(".");
73
86
  if (parts.length !== 3) return null;
@@ -145,24 +158,27 @@ function verifySignature(input: string, sig: string, secret: string, algorithm:
145
158
  * @param password - Plaintext password
146
159
  * @param salt - Hex-encoded salt (auto-generated if omitted)
147
160
  * @param iterations - PBKDF2 iterations (default 100000)
148
- * @returns Format: `pbkdf2_sha256:iterations:salt:hash` (all hex-encoded)
161
+ * @returns Format: `pbkdf2_sha256$iterations$salt$hash` (all hex-encoded)
149
162
  */
150
163
  export function hashPassword(
151
164
  password: string,
152
165
  salt?: string,
153
- iterations: number = 100000,
166
+ iterations: number = 260000,
154
167
  ): string {
155
168
  const actualSalt = salt ?? randomBytes(16).toString("hex");
156
169
  const dk = pbkdf2Sync(password, actualSalt, iterations, 32, "sha256");
157
- return `pbkdf2_sha256:${iterations}:${actualSalt}:${dk.toString("hex")}`;
170
+ return `pbkdf2_sha256$${iterations}$${actualSalt}$${dk.toString("hex")}`;
158
171
  }
159
172
 
160
173
  /**
161
174
  * Check a password against a PBKDF2 hash string.
175
+ * Supports both $ and : delimiters for backward compatibility.
162
176
  */
163
177
  export function checkPassword(password: string, hash: string): boolean {
164
178
  try {
165
- const parts = hash.split(":");
179
+ // Support both $ (standard) and : (legacy) delimiters
180
+ const delimiter = hash.includes("$") ? "$" : ":";
181
+ const parts = hash.split(delimiter);
166
182
  if (parts.length !== 4 || parts[0] !== "pbkdf2_sha256") return false;
167
183
 
168
184
  const iterations = parseInt(parts[1], 10);
@@ -225,8 +241,8 @@ export function authMiddleware(secret: string, algorithm: string = "HS256"): Mid
225
241
  */
226
242
  export function refreshToken(
227
243
  token: string,
228
- secret: string,
229
- expiresIn: number = 3600,
244
+ secret?: string,
245
+ expiresIn: number = 60,
230
246
  algorithm: string = "HS256",
231
247
  ): string | null {
232
248
  const payload = validToken(token, secret, algorithm);
@@ -249,7 +265,7 @@ export function refreshToken(
249
265
  */
250
266
  export function authenticateRequest(
251
267
  headers: Record<string, string | string[] | undefined>,
252
- secret: string,
268
+ secret?: string,
253
269
  algorithm: string = "HS256",
254
270
  ): Record<string, unknown> | null {
255
271
  const authHeader =
@@ -258,7 +274,17 @@ export function authenticateRequest(
258
274
  if (!authHeader.startsWith("Bearer ")) return null;
259
275
 
260
276
  const token = authHeader.slice(7);
261
- return validToken(token, secret, algorithm);
277
+
278
+ // Try JWT first
279
+ const payload = validToken(token, secret, algorithm);
280
+ if (payload !== null) return payload;
281
+
282
+ // Fallback: treat Bearer value as API key
283
+ if (validateApiKey(token)) {
284
+ return { _auth: "api_key" };
285
+ }
286
+
287
+ return null;
262
288
  }
263
289
 
264
290
  // ── Backward-Compatible Aliases ──────────────────────────────────
@@ -87,6 +87,28 @@ export class Events {
87
87
  return results;
88
88
  }
89
89
 
90
+ /**
91
+ * Emit an event and await all async listeners.
92
+ * Returns array of resolved results from each listener.
93
+ */
94
+ static async emitAsync(event: string, ...args: unknown[]): Promise<unknown[]> {
95
+ const entries = _listeners.get(event);
96
+ if (!entries) return [];
97
+
98
+ const snapshot = [...entries];
99
+ const results: unknown[] = [];
100
+
101
+ for (const entry of snapshot) {
102
+ if (entry.once) {
103
+ const idx = entries.indexOf(entry);
104
+ if (idx !== -1) entries.splice(idx, 1);
105
+ }
106
+ results.push(await entry.callback(...args));
107
+ }
108
+
109
+ return results;
110
+ }
111
+
90
112
  /**
91
113
  * Get all listener callbacks for an event (in priority order).
92
114
  */
@@ -383,6 +383,21 @@ export class GraphQL {
383
383
 
384
384
  constructor() {}
385
385
 
386
+ /**
387
+ * Return schema metadata for debugging.
388
+ */
389
+ introspect(): Record<string, unknown> {
390
+ const queries: Record<string, unknown> = {};
391
+ for (const [name, config] of Object.entries(this.queries)) {
392
+ queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
393
+ }
394
+ const mutations: Record<string, unknown> = {};
395
+ for (const [name, config] of Object.entries(this.mutations)) {
396
+ mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
397
+ }
398
+ return { types: Object.keys(this.types), queries, mutations };
399
+ }
400
+
386
401
  /**
387
402
  * Register a named type with its fields.
388
403
  */
@@ -391,6 +406,21 @@ export class GraphQL {
391
406
  return this;
392
407
  }
393
408
 
409
+ /**
410
+ * Return schema metadata for debugging.
411
+ */
412
+ introspect(): Record<string, unknown> {
413
+ const queries: Record<string, unknown> = {};
414
+ for (const [name, config] of Object.entries(this.queries)) {
415
+ queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
416
+ }
417
+ const mutations: Record<string, unknown> = {};
418
+ for (const [name, config] of Object.entries(this.mutations)) {
419
+ mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
420
+ }
421
+ return { types: Object.keys(this.types), queries, mutations };
422
+ }
423
+
394
424
  /**
395
425
  * Register a query resolver.
396
426
  */
@@ -404,6 +434,21 @@ export class GraphQL {
404
434
  return this;
405
435
  }
406
436
 
437
+ /**
438
+ * Return schema metadata for debugging.
439
+ */
440
+ introspect(): Record<string, unknown> {
441
+ const queries: Record<string, unknown> = {};
442
+ for (const [name, config] of Object.entries(this.queries)) {
443
+ queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
444
+ }
445
+ const mutations: Record<string, unknown> = {};
446
+ for (const [name, config] of Object.entries(this.mutations)) {
447
+ mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
448
+ }
449
+ return { types: Object.keys(this.types), queries, mutations };
450
+ }
451
+
407
452
  /**
408
453
  * Register a mutation resolver.
409
454
  */
@@ -417,6 +462,21 @@ export class GraphQL {
417
462
  return this;
418
463
  }
419
464
 
465
+ /**
466
+ * Return schema metadata for debugging.
467
+ */
468
+ introspect(): Record<string, unknown> {
469
+ const queries: Record<string, unknown> = {};
470
+ for (const [name, config] of Object.entries(this.queries)) {
471
+ queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
472
+ }
473
+ const mutations: Record<string, unknown> = {};
474
+ for (const [name, config] of Object.entries(this.mutations)) {
475
+ mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
476
+ }
477
+ return { types: Object.keys(this.types), queries, mutations };
478
+ }
479
+
420
480
  /**
421
481
  * Execute a GraphQL query string.
422
482
  */
@@ -464,10 +524,57 @@ export class GraphQL {
464
524
  return result;
465
525
  }
466
526
 
527
+ /**
528
+ * Return schema metadata for debugging.
529
+ */
530
+ introspect(): Record<string, unknown> {
531
+ const queries: Record<string, unknown> = {};
532
+ for (const [name, config] of Object.entries(this.queries)) {
533
+ queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
534
+ }
535
+ const mutations: Record<string, unknown> = {};
536
+ for (const [name, config] of Object.entries(this.mutations)) {
537
+ mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
538
+ }
539
+ return { types: Object.keys(this.types), queries, mutations };
540
+ }
541
+
542
+
543
+ /**
544
+ * Return schema metadata for debugging.
545
+ */
546
+ introspect(): Record<string, unknown> {
547
+ const queries: Record<string, unknown> = {};
548
+ for (const [name, config] of Object.entries(this.queries)) {
549
+ queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
550
+ }
551
+ const mutations: Record<string, unknown> = {};
552
+ for (const [name, config] of Object.entries(this.mutations)) {
553
+ mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
554
+ }
555
+ return { types: Object.keys(this.types), queries, mutations };
556
+ }
557
+
467
558
  /**
468
559
  * Generate SDL schema string.
469
560
  */
470
- schema(): string {
561
+
562
+ /**
563
+ * Return schema metadata for debugging.
564
+ */
565
+ introspect(): Record<string, unknown> {
566
+ const queries: Record<string, unknown> = {};
567
+ for (const [name, config] of Object.entries(this.queries)) {
568
+ queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
569
+ }
570
+ const mutations: Record<string, unknown> = {};
571
+ for (const [name, config] of Object.entries(this.mutations)) {
572
+ mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
573
+ }
574
+ return { types: Object.keys(this.types), queries, mutations };
575
+ }
576
+
577
+ schemaSdl(): string {
471
578
  const lines: string[] = [];
472
579
 
473
580
  // Types
@@ -505,6 +612,21 @@ export class GraphQL {
505
612
  return lines.join("\n");
506
613
  }
507
614
 
615
+ /**
616
+ * Return schema metadata for debugging.
617
+ */
618
+ introspect(): Record<string, unknown> {
619
+ const queries: Record<string, unknown> = {};
620
+ for (const [name, config] of Object.entries(this.queries)) {
621
+ queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
622
+ }
623
+ const mutations: Record<string, unknown> = {};
624
+ for (const [name, config] of Object.entries(this.mutations)) {
625
+ mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
626
+ }
627
+ return { types: Object.keys(this.types), queries, mutations };
628
+ }
629
+
508
630
  /**
509
631
  * Auto-generate type, queries, and CRUD mutations from an ORM model class.
510
632
  *
@@ -92,8 +92,8 @@ export class I18n {
92
92
  try {
93
93
  const files = readdirSync(this._localeDir);
94
94
  const locales = files
95
- .filter((f) => f.endsWith(".json"))
96
- .map((f) => f.replace(/\.json$/, ""))
95
+ .filter((f) => f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml"))
96
+ .map((f) => f.replace(/\.(json|ya?ml)$/, ""))
97
97
  .sort();
98
98
  return locales.length > 0 ? locales : [this._defaultLocale];
99
99
  } catch {
@@ -112,15 +112,58 @@ export class I18n {
112
112
  const raw = readFileSync(filePath, "utf-8");
113
113
  const data = JSON.parse(raw);
114
114
  this._translations.set(locale, I18n._flatten(data));
115
+ return;
115
116
  } catch {
116
117
  this._translations.set(locale, {});
118
+ return;
119
+ }
120
+ }
121
+ // Try YAML (.yml or .yaml) — zero-dep parser
122
+ for (const ext of [".yml", ".yaml"]) {
123
+ const yamlPath = join(this._localeDir, `${locale}${ext}`);
124
+ if (existsSync(yamlPath)) {
125
+ try {
126
+ const raw = readFileSync(yamlPath, "utf-8");
127
+ const data = I18n._parseSimpleYaml(raw);
128
+ this._translations.set(locale, I18n._flatten(data));
129
+ return;
130
+ } catch { /* skip */ }
117
131
  }
118
- } else {
119
- this._translations.set(locale, {});
120
132
  }
133
+ this._translations.set(locale, {});
121
134
  }
122
135
 
123
136
  /** Flatten nested objects: {"a": {"b": "c"}} → {"a.b": "c"} */
137
+
138
+ /** Zero-dep YAML parser for simple key: value locale files. */
139
+ private static _parseSimpleYaml(text: string): Record<string, unknown> {
140
+ const result: Record<string, unknown> = {};
141
+ let currentParent: string | null = null;
142
+ for (const line of text.split("\n")) {
143
+ const stripped = line.trim();
144
+ if (!stripped || stripped.startsWith("#")) continue;
145
+ const indent = line.length - line.trimStart().length;
146
+ const colonIdx = stripped.indexOf(":");
147
+ if (colonIdx === -1) continue;
148
+ const key = stripped.substring(0, colonIdx).trim();
149
+ let value = stripped.substring(colonIdx + 1).trim();
150
+ // Strip quotes
151
+ if (value && (value[0] === '"' || value[0] === "'") && value[value.length - 1] === value[0]) {
152
+ value = value.substring(1, value.length - 1);
153
+ }
154
+ if (!value) {
155
+ currentParent = key;
156
+ result[key] = {};
157
+ } else if (indent > 0 && currentParent && typeof result[currentParent] === "object") {
158
+ (result[currentParent] as Record<string, string>)[key] = value;
159
+ } else {
160
+ currentParent = null;
161
+ result[key] = value;
162
+ }
163
+ }
164
+ return result;
165
+ }
166
+
124
167
  private static _flatten(data: Record<string, unknown>, prefix = ""): Record<string, string> {
125
168
  const result: Record<string, string> = {};
126
169
  for (const [key, value] of Object.entries(data)) {
@@ -623,7 +623,20 @@ export class Queue {
623
623
  * processEmail(job);
624
624
  * }
625
625
  */
626
- *consume(topic?: string, id?: string): Generator<QueueJob> {
626
+ /**
627
+ * Long-running async generator that polls the queue continuously.
628
+ * When empty, sleeps for pollInterval ms before polling again.
629
+ * No external while-loop or sleep needed.
630
+ *
631
+ * @param topic Queue topic (defaults to constructor topic)
632
+ * @param id Optional job ID — single yield, no polling
633
+ * @param pollInterval Milliseconds to sleep when queue is empty (default 1000)
634
+ *
635
+ * Usage:
636
+ * for await (const job of queue.consume("emails")) { ... }
637
+ * for await (const job of queue.consume("emails", undefined, 5000)) { ... }
638
+ */
639
+ async *consume(topic?: string, id?: string, pollInterval: number = 1000): AsyncGenerator<QueueJob> {
627
640
  const q = topic ?? this.topic;
628
641
 
629
642
  if (id !== undefined) {
@@ -632,8 +645,15 @@ export class Queue {
632
645
  return;
633
646
  }
634
647
 
635
- let raw: any;
636
- while ((raw = this.pop(q)) !== null) {
648
+ // pollInterval=0 → single-pass drain (returns when empty)
649
+ // pollInterval>0 long-running poll (sleeps when empty, never returns)
650
+ while (true) {
651
+ const raw = this.pop(q) as any;
652
+ if (raw === null) {
653
+ if (pollInterval <= 0) break;
654
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
655
+ continue;
656
+ }
637
657
  yield createJob(raw, this, q);
638
658
  }
639
659
  }
@@ -12,7 +12,19 @@ export function createRequest(req: IncomingMessage): Tina4Request {
12
12
  tReq.params = {};
13
13
  tReq.query = query;
14
14
  tReq.body = undefined;
15
- tReq.files = [];
15
+ tReq.files = {};
16
+ tReq.contentType = (req.headers["content-type"] ?? "") as string;
17
+
18
+ // Parse cookies from Cookie header
19
+ const cookieHeader = (req.headers.cookie ?? "") as string;
20
+ const cookies: Record<string, string> = {};
21
+ if (cookieHeader) {
22
+ for (const pair of cookieHeader.split(";")) {
23
+ const [k, ...v] = pair.trim().split("=");
24
+ if (k) cookies[k.trim()] = v.join("=").trim();
25
+ }
26
+ }
27
+ tReq.cookies = cookies;
16
28
 
17
29
  // Determine client IP with X-Forwarded-For support
18
30
  const forwarded = req.headers["x-forwarded-for"];
@@ -109,9 +121,9 @@ function extractBoundary(contentType: string): string | null {
109
121
  export function parseMultipart(
110
122
  body: Buffer,
111
123
  boundary: string,
112
- ): { fields: Record<string, string>; files: UploadedFile[] } {
124
+ ): { fields: Record<string, string>; files: Record<string, UploadedFile | UploadedFile[]> } {
113
125
  const fields: Record<string, string> = {};
114
- const files: UploadedFile[] = [];
126
+ const files: Record<string, UploadedFile | UploadedFile[]> = {};
115
127
 
116
128
  const delimiter = Buffer.from(`--${boundary}`);
117
129
  const closeDelimiter = Buffer.from(`--${boundary}--`);
@@ -152,13 +164,20 @@ export function parseMultipart(
152
164
 
153
165
  if (disposition.filename) {
154
166
  // File upload — standardised format: filename, type, content (raw bytes), size
155
- files.push({
167
+ const file: UploadedFile = {
156
168
  fieldName: disposition.name,
157
169
  filename: disposition.filename,
158
170
  type: partContentType ?? "application/octet-stream",
159
171
  content: Buffer.from(content),
160
172
  size: content.length,
161
- });
173
+ };
174
+ // Dict keyed by field name — multiple files under same name become array
175
+ if (files[disposition.name]) {
176
+ const existing = files[disposition.name];
177
+ files[disposition.name] = Array.isArray(existing) ? [...existing, file] : [existing, file];
178
+ } else {
179
+ files[disposition.name] = file;
180
+ }
162
181
  } else if (disposition.name) {
163
182
  // Regular field
164
183
  fields[disposition.name] = content.toString("utf-8");
@@ -110,6 +110,14 @@ export function createResponse(res: ServerResponse): Tina4Response {
110
110
  return response;
111
111
  };
112
112
 
113
+ response.xml = function (content: string, status?: number): Tina4Response {
114
+ if (res.headersSent) return response;
115
+ if (status !== undefined) res.statusCode = status;
116
+ safeSetHeader("Content-Type", "application/xml; charset=utf-8");
117
+ safeEnd(content);
118
+ return response;
119
+ };
120
+
113
121
  response.send = function (data: unknown, statusCode?: number, contentType?: string): Tina4Response {
114
122
  return response(data, statusCode, contentType);
115
123
  };
@@ -234,6 +242,40 @@ export function createResponse(res: ServerResponse): Tina4Response {
234
242
  return response.render(name, data, status, templateDir);
235
243
  };
236
244
 
245
+ /**
246
+ * Stream response from an async generator for Server-Sent Events (SSE).
247
+ *
248
+ * Usage:
249
+ * export default async function (req, res) {
250
+ * res.stream(async function* () {
251
+ * for (let i = 0; i < 10; i++) {
252
+ * yield `data: message ${i}\n\n`;
253
+ * await new Promise(r => setTimeout(r, 1000));
254
+ * }
255
+ * }());
256
+ * }
257
+ */
258
+ (response as any).stream = async function (
259
+ source: AsyncIterable<string | Buffer>,
260
+ contentType: string = "text/event-stream",
261
+ ): Promise<Tina4Response> {
262
+ if (res.headersSent) return response;
263
+ res.writeHead(200, {
264
+ "Content-Type": contentType,
265
+ "Cache-Control": "no-cache",
266
+ "Connection": "keep-alive",
267
+ "X-Accel-Buffering": "no",
268
+ });
269
+
270
+ for await (const chunk of source) {
271
+ const data = typeof chunk === "string" ? chunk : chunk.toString();
272
+ res.write(data);
273
+ }
274
+
275
+ res.end();
276
+ return response;
277
+ };
278
+
237
279
  return response;
238
280
  }
239
281
 
@@ -430,18 +430,6 @@ export class Session {
430
430
  this.save();
431
431
  }
432
432
 
433
- /**
434
- * Clear all session data without destroying the session.
435
- * The session ID and cookie remain — only the data is wiped.
436
- */
437
- clear(): void {
438
- if (!this.data) return;
439
- for (const key of Object.keys(this.data)) {
440
- delete this.data[key];
441
- }
442
- this.save();
443
- }
444
-
445
433
  /**
446
434
  * Destroy the entire session.
447
435
  */
@@ -506,31 +494,49 @@ export class Session {
506
494
  }
507
495
 
508
496
  /**
509
- * Set flash data (auto-deleted after first read).
497
+ * Dual-mode flash: set with value, get+remove without.
498
+ *
499
+ * session.flash("message", "Saved!") // set
500
+ * session.flash("message") // get + auto-remove → "Saved!"
510
501
  */
511
- flash(key: string, value: unknown): void {
512
- this.set(`${FLASH_PREFIX}${key}`, value);
502
+ flash(key: string, value?: unknown): unknown {
503
+ const flashKey = `${FLASH_PREFIX}${key}`;
504
+ if (value !== undefined) {
505
+ // Set mode
506
+ this.set(flashKey, value);
507
+ return undefined;
508
+ }
509
+ // Get mode — read and remove
510
+ if (!this.data || !(flashKey in this.data)) return undefined;
511
+ const stored = this.data[flashKey];
512
+ delete this.data[flashKey];
513
+ this.save();
514
+ return stored;
513
515
  }
514
516
 
515
517
  /**
516
- * Get flash data (auto-deleted after read).
518
+ * Get flash data by key (alias for flash(key) without value).
517
519
  */
518
520
  getFlash(key: string, defaultValue?: unknown): unknown {
519
- const flashKey = `${FLASH_PREFIX}${key}`;
520
- if (!this.data || !(flashKey in this.data)) return defaultValue;
521
- const value = this.data[flashKey];
522
- delete this.data[flashKey];
523
- this.save();
524
- return value ?? defaultValue;
521
+ const result = this.flash(key);
522
+ return result !== undefined ? result : defaultValue;
525
523
  }
526
524
 
527
525
  /**
528
526
  * Get the current session ID.
529
527
  */
530
- getId(): string | null {
528
+ getSessionId(): string | null {
531
529
  return this.sessionId;
532
530
  }
533
531
 
532
+ /**
533
+ * Return a Set-Cookie header value for this session.
534
+ */
535
+ cookieHeader(cookieName: string = "tina4_session"): string {
536
+ const sameSite = process.env.TINA4_SESSION_SAMESITE ?? "Lax";
537
+ return `${cookieName}=${this.sessionId}; Path=/; HttpOnly; SameSite=${sameSite}; Max-Age=${this.ttl}`;
538
+ }
539
+
534
540
  /**
535
541
  * Run garbage collection on the session backend.
536
542
  * Removes expired file/database sessions. Redis/Valkey/Mongo handle TTL natively.
@@ -541,9 +547,10 @@ export class Session {
541
547
  }
542
548
  }
543
549
 
544
- // ── Private ───────────────────────────────────────────────────
545
-
546
- private save(): void {
550
+ /**
551
+ * Persist session data to the backend.
552
+ */
553
+ save(): void {
547
554
  if (!this.sessionId || !this.data) return;
548
555
  this.handler.write(this.sessionId, this.data, this.ttl);
549
556
  }
@@ -22,7 +22,9 @@ export interface Tina4Request extends IncomingMessage {
22
22
  query: Record<string, string>;
23
23
  body: unknown;
24
24
  ip: string;
25
- files: UploadedFile[];
25
+ files: Record<string, UploadedFile | UploadedFile[]>;
26
+ cookies: Record<string, string>;
27
+ contentType: string;
26
28
  session: Tina4Session;
27
29
  user?: Record<string, unknown>;
28
30
  }
@@ -41,6 +43,7 @@ export interface Tina4ResponseMethods {
41
43
  json(data: unknown, status?: number): Tina4Response;
42
44
  html(content: string, status?: number): Tina4Response;
43
45
  text(content: string, status?: number): Tina4Response;
46
+ xml(content: string, status?: number): Tina4Response;
44
47
  status(code: number): Tina4Response;
45
48
  header(name: string, value: string | number | readonly string[]): Tina4Response;
46
49
  send(data: unknown, statusCode?: number, contentType?: string): Tina4Response;
@@ -51,6 +54,8 @@ export interface Tina4ResponseMethods {
51
54
  error(code: string, message: string, status?: number): Tina4Response;
52
55
  render(template: string, data?: Record<string, unknown>, status?: number, templateDir?: string): Promise<Tina4Response>;
53
56
  template(name: string, data?: Record<string, unknown>, status?: number, templateDir?: string): Promise<Tina4Response>;
57
+ /** Stream response from an async generator (SSE or chunked). */
58
+ stream(source: AsyncIterable<string | Buffer>, contentType?: string): Promise<Tina4Response>;
54
59
  /** The underlying ServerResponse for advanced use */
55
60
  raw: ServerResponse;
56
61
  }
@@ -6,7 +6,7 @@
6
6
  * import { WebSocketServer } from "@tina4/core";
7
7
  *
8
8
  * const wss = new WebSocketServer({ port: 8080 });
9
- * wss.on("connection", (client) => {
9
+ * wss.on("open", (client) => {
10
10
  * console.log("Connected:", client.id);
11
11
  * });
12
12
  * wss.on("message", (client, message) => {
@@ -330,7 +330,7 @@ export class WebSocketServer {
330
330
  };
331
331
 
332
332
  this.clients.set(clientId, client);
333
- this.emit("connection", client);
333
+ this.emit("open", client);
334
334
 
335
335
  // Handle incoming data
336
336
  let buffer = Buffer.alloc(0);
@@ -5,14 +5,18 @@
5
5
  export interface WebSocketConnection {
6
6
  /** Unique connection identifier */
7
7
  id: string;
8
+ /** The WebSocket route path this connection is on */
9
+ path: string;
10
+ /** Client IP address */
11
+ ip: string;
12
+ /** HTTP headers from the upgrade request */
13
+ headers: Record<string, string>;
14
+ /** Route parameters extracted from `{param}` segments in the path */
15
+ params: Record<string, string>;
8
16
  /** Send a message to this connection only */
9
17
  send(message: string): void;
10
18
  /** Broadcast a message to all connections on the same path (path-scoped) */
11
19
  broadcast(message: string): void;
12
20
  /** Close this connection */
13
21
  close(): void;
14
- /** The WebSocket route path this connection is on */
15
- path: string;
16
- /** Route parameters extracted from `{param}` segments in the path */
17
- params: Record<string, string>;
18
22
  }
@@ -68,60 +68,95 @@ function escapeXml(value: string): string {
68
68
  .replace(/'/g, "&apos;");
69
69
  }
70
70
 
71
- /**
72
- * Extract text content from an XML element by tag name (simple string parser).
73
- * Returns the text content of the first matching element, or null.
74
- */
75
- function extractElement(xml: string, tagName: string): string | null {
76
- // Try with namespace prefix variations
77
- const patterns = [
78
- new RegExp(`<(?:[a-zA-Z0-9]+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:[a-zA-Z0-9]+:)?${tagName}>`, "i"),
79
- new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)</${tagName}>`, "i"),
80
- ];
81
-
82
- for (const pattern of patterns) {
83
- const match = xml.match(pattern);
84
- if (match) return match[1];
71
+ // ── Minimal zero-dep XML DOM parser ────────────────────────────
72
+ // Stack-based parser that builds a tree. Handles namespaces by
73
+ // stripping prefixes. No external dependencies.
74
+
75
+ interface XmlNode {
76
+ tag: string;
77
+ children: XmlNode[];
78
+ text: string;
79
+ }
80
+
81
+ function parseXml(xml: string): XmlNode {
82
+ const root: XmlNode = { tag: "", children: [], text: "" };
83
+ const stack: XmlNode[] = [root];
84
+ let i = 0;
85
+
86
+ while (i < xml.length) {
87
+ if (xml[i] === "<") {
88
+ const closeIdx = xml.indexOf(">", i);
89
+ if (closeIdx === -1) break;
90
+ const tagContent = xml.substring(i + 1, closeIdx).trim();
91
+
92
+ if (tagContent.startsWith("/")) {
93
+ stack.pop();
94
+ } else if (tagContent.startsWith("?") || tagContent.startsWith("!")) {
95
+ // PI or comment — skip
96
+ } else {
97
+ const selfClosing = tagContent.endsWith("/");
98
+ const raw = selfClosing ? tagContent.slice(0, -1).trim() : tagContent;
99
+ const spaceIdx = raw.indexOf(" ");
100
+ const fullTag = spaceIdx === -1 ? raw : raw.substring(0, spaceIdx);
101
+ const colonIdx = fullTag.indexOf(":");
102
+ const localTag = colonIdx === -1 ? fullTag : fullTag.substring(colonIdx + 1);
103
+
104
+ const node: XmlNode = { tag: localTag, children: [], text: "" };
105
+ stack[stack.length - 1].children.push(node);
106
+ if (!selfClosing) stack.push(node);
107
+ }
108
+ i = closeIdx + 1;
109
+ } else {
110
+ const nextTag = xml.indexOf("<", i);
111
+ const text = (nextTag === -1 ? xml.substring(i) : xml.substring(i, nextTag)).trim();
112
+ if (text && stack.length > 1) {
113
+ stack[stack.length - 1].text += text;
114
+ }
115
+ i = nextTag === -1 ? xml.length : nextTag;
116
+ }
85
117
  }
86
118
 
87
- return null;
119
+ return root;
88
120
  }
89
121
 
90
- /**
91
- * Extract all direct child elements with their tag names and text content.
92
- * Returns an array of { name, value } pairs.
93
- */
94
- function extractChildren(xml: string): Array<{ name: string; value: string }> {
95
- const results: Array<{ name: string; value: string }> = [];
96
- // Match opening tags, capturing name (strip namespace prefix) and content
97
- const pattern = /<(?:[a-zA-Z0-9]+:)?([a-zA-Z0-9_]+)[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?(\1)[^>]*>/g;
98
-
99
- let match: RegExpExecArray | null;
100
- while ((match = pattern.exec(xml)) !== null) {
101
- results.push({ name: match[1], value: match[2].trim() });
122
+ function findNode(node: XmlNode, tagName: string): XmlNode | null {
123
+ if (node.tag === tagName) return node;
124
+ for (const child of node.children) {
125
+ const found = findNode(child, tagName);
126
+ if (found) return found;
102
127
  }
128
+ return null;
129
+ }
103
130
 
104
- return results;
131
+ function extractElement(xml: string, tagName: string): string | null {
132
+ const tree = parseXml(xml);
133
+ const node = findNode(tree, tagName);
134
+ if (!node) return null;
135
+ // Rebuild inner content from children
136
+ if (node.children.length === 0) return node.text || null;
137
+ // For complex content, fall back to regex on the original XML
138
+ const pattern = new RegExp(`<(?:[a-zA-Z0-9]+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:[a-zA-Z0-9]+:)?${tagName}>`, "i");
139
+ const match = xml.match(pattern);
140
+ return match ? match[1] : node.text || null;
141
+ }
142
+
143
+ function extractChildren(xml: string): Array<{ name: string; value: string }> {
144
+ const tree = parseXml(xml);
145
+ const target = tree.children.length === 1 ? tree.children[0] : tree;
146
+ return target.children.map(c => ({ name: c.tag, value: c.text }));
105
147
  }
106
148
 
107
- /**
108
- * Extract the SOAP Body content from a SOAP envelope.
109
- */
110
149
  function extractSoapBody(xml: string): string | null {
111
150
  return extractElement(xml, "Body");
112
151
  }
113
152
 
114
- /**
115
- * Extract the operation element from the SOAP body.
116
- * Returns { name, content } or null.
117
- */
118
153
  function extractOperation(bodyXml: string): { name: string; content: string } | null {
119
- // The first child element of Body is the operation
120
- const match = bodyXml.match(/<(?:[a-zA-Z0-9]+:)?([a-zA-Z0-9_]+)[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?\1[^>]*>/i);
121
- if (match) {
122
- return { name: match[1], content: match[2] };
123
- }
124
- return null;
154
+ const tree = parseXml(bodyXml);
155
+ const firstChild = tree.children[0];
156
+ if (!firstChild) return null;
157
+ const pattern = new RegExp(`<(?:[a-zA-Z0-9]+:)?${firstChild.tag}[^>]*>([\\s\\S]*?)</(?:[a-zA-Z0-9]+:)?${firstChild.tag}>`, "i");
158
+ const match = bodyXml.match(pattern);
159
+ return { name: firstChild.tag, content: match ? match[1] : "" };
125
160
  }
126
161
 
127
162
  // ── Metadata storage ─────────────────────────────────────────
@@ -258,11 +258,12 @@ export class BaseModel {
258
258
 
259
259
  /**
260
260
  * Find all records, optionally with a where clause.
261
+ * Alias: all()
261
262
  * @param where Optional WHERE clause.
262
263
  * @param params Optional query parameters.
263
264
  * @param include Optional array of relationship names to eager-load.
264
265
  */
265
- static findAll<T extends BaseModel>(
266
+ static all<T extends BaseModel>(
266
267
  this: new (data?: Record<string, unknown>) => T,
267
268
  where?: string,
268
269
  params?: unknown[],
@@ -293,10 +294,51 @@ export class BaseModel {
293
294
  return instances;
294
295
  }
295
296
 
297
+ /**
298
+ * Query records with a WHERE clause.
299
+ * Matches Python/PHP/Ruby where() API.
300
+ *
301
+ * @param conditions WHERE clause (e.g. "age > ? AND active = ?")
302
+ * @param params Bind parameters
303
+ * @param limit Max records (default 20)
304
+ * @param offset Skip records (default 0)
305
+ * @param include Relationship names to eager-load
306
+ */
307
+ static where<T extends BaseModel>(
308
+ this: new (data?: Record<string, unknown>) => T,
309
+ conditions: string,
310
+ params?: unknown[],
311
+ limit: number = 20,
312
+ offset: number = 0,
313
+ include?: string[],
314
+ ): T[] {
315
+ const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
316
+ const db = ModelClass.getDb();
317
+
318
+ const parts: string[] = [];
319
+ if (ModelClass.softDelete) {
320
+ parts.push("is_deleted = 0");
321
+ }
322
+ if (ModelClass.tableFilter) {
323
+ parts.push(ModelClass.tableFilter);
324
+ }
325
+ parts.push(`(${conditions})`);
326
+
327
+ const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${parts.join(" AND ")} LIMIT ${limit} OFFSET ${offset}`;
328
+
329
+ const rows = db.query(sql, params);
330
+ const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
331
+ if (include) {
332
+ ModelClass._eagerLoad(instances, include);
333
+ }
334
+ return instances;
335
+ }
336
+
296
337
  /**
297
338
  * Save this instance (insert or update).
339
+ * Returns this on success (fluent), null on failure.
298
340
  */
299
- save(): void {
341
+ save(): this | null {
300
342
  const ModelClass = this.constructor as typeof BaseModel;
301
343
  const db = ModelClass.getDb();
302
344
  const pk = ModelClass.getPkField();
@@ -339,8 +381,10 @@ export class BaseModel {
339
381
  db.commit();
340
382
  } catch (e) {
341
383
  db.rollback();
342
- throw e;
384
+ return null;
343
385
  }
386
+ (this as any)._exists = true;
387
+ return this;
344
388
  }
345
389
 
346
390
  /**
@@ -454,6 +498,13 @@ export class BaseModel {
454
498
  return result;
455
499
  }
456
500
 
501
+ /**
502
+ * Convert to an associative object (alias for toDict).
503
+ */
504
+ toAssoc(include?: string[]): Record<string, unknown> {
505
+ return this.toDict(include);
506
+ }
507
+
457
508
  /**
458
509
  * Convert to a plain object (alias for toDict).
459
510
  */
@@ -477,9 +528,10 @@ export class BaseModel {
477
528
 
478
529
  /**
479
530
  * Convert to JSON string.
531
+ * @param include Optional relationship names to include.
480
532
  */
481
- toJson(): string {
482
- return JSON.stringify(this.toDict());
533
+ toJson(include?: string[]): string {
534
+ return JSON.stringify(this.toDict(include));
483
535
  }
484
536
 
485
537
  /**
@@ -659,7 +711,7 @@ export class BaseModel {
659
711
  conditions?: string,
660
712
  params?: unknown[],
661
713
  limit?: number,
662
- skip?: number,
714
+ offset?: number,
663
715
  ): T[] {
664
716
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
665
717
  const db = ModelClass.getDb();
@@ -679,8 +731,8 @@ export class BaseModel {
679
731
  if (limit !== undefined) {
680
732
  sql += ` LIMIT ${limit}`;
681
733
  }
682
- if (skip !== undefined) {
683
- sql += ` OFFSET ${skip}`;
734
+ if (offset !== undefined) {
735
+ sql += ` OFFSET ${offset}`;
684
736
  }
685
737
 
686
738
  const rows = db.query(sql, params);
@@ -709,29 +761,22 @@ export class BaseModel {
709
761
  }
710
762
 
711
763
  /**
712
- * Apply a named scope (reusable query filter).
764
+ * Register a reusable query scope on the class.
765
+ *
766
+ * Usage:
767
+ * User.scope("active", "active = ?", [1]);
768
+ * const users = (User as any).active(); // calls where("active = ?", [1])
769
+ * const users = (User as any).active(10, 5); // with limit/offset
713
770
  */
714
- static scope<T extends BaseModel>(
715
- this: new (data?: Record<string, unknown>) => T,
771
+ static scope(
716
772
  name: string,
717
773
  filterSql: string,
718
774
  params?: unknown[],
719
- ): T[] {
720
- const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
721
- const db = ModelClass.getDb();
722
-
723
- const conditions: string[] = [];
724
- if (ModelClass.softDelete) {
725
- conditions.push("is_deleted = 0");
726
- }
727
- if (ModelClass.tableFilter) {
728
- conditions.push(ModelClass.tableFilter);
729
- }
730
- conditions.push(filterSql);
731
-
732
- const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${conditions.join(" AND ")}`;
733
- const rows = db.query(sql, params);
734
- return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
775
+ ): void {
776
+ const ModelClass = this as unknown as typeof BaseModel;
777
+ (ModelClass as any)[name] = (limit: number = 20, offset: number = 0) => {
778
+ return ModelClass.where.call(ModelClass as any, filterSql, params, limit, offset);
779
+ };
735
780
  }
736
781
 
737
782
  /**
@@ -773,6 +818,8 @@ export class BaseModel {
773
818
  this: T,
774
819
  relatedClass: typeof BaseModel & (new (data?: Record<string, unknown>) => R),
775
820
  foreignKey: string,
821
+ limit: number = 100,
822
+ offset: number = 0,
776
823
  ): R[] {
777
824
  const ModelClass = this.constructor as typeof BaseModel;
778
825
  const pk = ModelClass.getPkField();
@@ -787,6 +834,7 @@ export class BaseModel {
787
834
  if (relatedClass.softDelete) {
788
835
  sql += ` AND is_deleted = 0`;
789
836
  }
837
+ sql += ` LIMIT ${limit} OFFSET ${offset}`;
790
838
 
791
839
  const rows = db.query(sql, [pkValue]);
792
840
  const related = rows.map((row) => new relatedClass(row as Record<string, unknown>) as R);
@@ -233,6 +233,7 @@ export class Database {
233
233
 
234
234
  /** Whether to automatically commit after each write operation */
235
235
  private autoCommit: boolean = process.env.TINA4_AUTOCOMMIT === "true";
236
+ private lastError: string | null = null;
236
237
 
237
238
  /** Database engine type (sqlite, postgres, mysql, mssql, firebird) */
238
239
  private dbType: string = "sqlite";
@@ -340,14 +341,28 @@ export class Database {
340
341
  return this.getNextAdapter().fetchOne<T>(sql, params);
341
342
  }
342
343
 
343
- /** Execute a statement (INSERT, UPDATE, DELETE, DDL). */
344
- execute(sql: string, params?: unknown[]): unknown {
345
- const adapter = this.getNextAdapter();
346
- const result = adapter.execute(sql, params);
347
- if (this.autoCommit) {
348
- try { adapter.commit(); } catch { /* no active transaction */ }
344
+ /**
345
+ * Execute a write statement. Returns true/false for simple writes.
346
+ * If SQL contains RETURNING, CALL, EXEC, or SELECT, returns the result set.
347
+ */
348
+ execute(sql: string, params?: unknown[]): boolean | unknown {
349
+ try {
350
+ const adapter = this.getNextAdapter();
351
+ const result = adapter.execute(sql, params);
352
+ if (this.autoCommit) {
353
+ try { adapter.commit(); } catch { /* no active transaction */ }
354
+ }
355
+ this.lastError = null;
356
+ const upper = sql.trim().toUpperCase();
357
+ if (upper.includes("RETURNING") || upper.startsWith("CALL ") ||
358
+ upper.startsWith("EXEC ") || upper.startsWith("SELECT ")) {
359
+ return result;
360
+ }
361
+ return true;
362
+ } catch (e: any) {
363
+ this.lastError = e?.message ?? String(e);
364
+ return false;
349
365
  }
350
- return result;
351
366
  }
352
367
 
353
368
  /** Insert a row into a table. */
@@ -457,6 +472,20 @@ export class Database {
457
472
  return results;
458
473
  }
459
474
 
475
+ /** Return the last execute() error message, or null. */
476
+ getError(): string | null {
477
+ return this.lastError ?? null;
478
+ }
479
+
480
+ /** Return query cache statistics. */
481
+ cacheStats(): { enabled: boolean; size: number; ttl: number } {
482
+ return {
483
+ enabled: process.env.TINA4_DB_CACHE === "true",
484
+ size: 0,
485
+ ttl: parseInt(process.env.TINA4_DB_CACHE_TTL ?? "30", 10),
486
+ };
487
+ }
488
+
460
489
  /** Get the last auto-increment id. */
461
490
  getLastId(): string | number {
462
491
  const id = this.getNextAdapter().lastInsertId();