tina4-nodejs 3.10.83 → 3.10.85

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.10.83",
3
+ "version": "3.10.85",
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"],
@@ -32,31 +32,29 @@ function base64urlDecode(str: string): Buffer {
32
32
  /**
33
33
  * Create a signed JWT token.
34
34
  *
35
- * @param payload - Claims to encode (e.g. `{ userId: 1, role: "admin" }`)
36
- * @param secret - HMAC secret (HS256) or PEM private key (RS256)
35
+ * Secret is always read from `process.env.SECRET`.
36
+ * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
37
+ *
38
+ * @param payload - Claims to encode (e.g. `{ userId: 1, role: "admin" }`)
37
39
  * @param expiresIn - Lifetime in seconds (default 3600)
38
- * @param algorithm - "HS256" or "RS256" (default "HS256")
39
40
  * @returns Signed JWT string: header.payload.signature
40
41
  */
41
42
  export function getToken(
42
43
  payload: Record<string, unknown>,
43
- secret?: string,
44
- expiresIn: number = 60,
45
- algorithm: string = "HS256",
44
+ expiresIn: number = 3600,
46
45
  ): string {
46
+ const secret = process.env.SECRET ?? "";
47
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
- }
48
+ console.warn("Auth: SECRET not set in .env using blank secret (insecure)");
52
49
  }
50
+ const algorithm = process.env.TINA4_JWT_ALGORITHM ?? "HS256";
53
51
 
54
52
  const header = { alg: algorithm, typ: "JWT" };
55
53
  const now = Math.floor(Date.now() / 1000);
56
54
 
57
55
  const claims: Record<string, unknown> = { ...payload, iat: now };
58
56
  if (expiresIn !== 0) {
59
- claims.exp = now + Math.floor(expiresIn * 60);
57
+ claims.exp = now + expiresIn;
60
58
  }
61
59
 
62
60
  const h = base64urlEncode(Buffer.from(JSON.stringify(header)));
@@ -68,39 +66,37 @@ export function getToken(
68
66
  }
69
67
 
70
68
  /**
71
- * Validate a JWT token and return the decoded payload, or null if invalid/expired.
69
+ * Validate a JWT token and return the decoded payload, or false if invalid/expired.
70
+ *
71
+ * Secret is always read from `process.env.SECRET`.
72
+ * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
72
73
  */
73
- export function validToken(
74
- token: string,
75
- secret?: string,
76
- algorithm: string = "HS256",
77
- ): Record<string, unknown> | null {
74
+ export function validToken(token: string): boolean {
75
+ const secret = process.env.SECRET ?? "";
78
76
  if (!secret) {
79
- secret = process.env.SECRET ?? "";
80
- if (!secret) {
81
- console.warn("Auth: SECRET not set in .env — using blank secret (insecure)");
82
- }
77
+ console.warn("Auth: SECRET not set in .env using blank secret (insecure)");
83
78
  }
79
+ const algorithm = process.env.TINA4_JWT_ALGORITHM ?? "HS256";
84
80
  try {
85
81
  const parts = token.split(".");
86
- if (parts.length !== 3) return null;
82
+ if (parts.length !== 3) return false;
87
83
 
88
84
  const [h, p, sig] = parts;
89
85
  const signingInput = `${h}.${p}`;
90
86
 
91
- if (!verifySignature(signingInput, sig, secret, algorithm)) {
92
- return null;
87
+ if (!verifySignature(signingInput, sig, secret as string, algorithm)) {
88
+ return false;
93
89
  }
94
90
 
95
91
  const payload = JSON.parse(base64urlDecode(p).toString()) as Record<string, unknown>;
96
92
 
97
93
  if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) {
98
- return null;
94
+ return false;
99
95
  }
100
96
 
101
- return payload;
97
+ return true;
102
98
  } catch {
103
- return null;
99
+ return false;
104
100
  }
105
101
  }
106
102
 
@@ -205,7 +201,7 @@ export function checkPassword(password: string, hash: string): boolean {
205
201
  * Authorization header. On success, attaches the decoded payload to
206
202
  * `(request as any).auth`. On failure, sends a 401 JSON response.
207
203
  */
208
- export function authMiddleware(secret: string, algorithm: string = "HS256"): Middleware {
204
+ export function authMiddleware(secret?: string, algorithm: string = "HS256"): Middleware {
209
205
  return (req: Tina4Request, res: Tina4Response, next: () => void): void => {
210
206
  const authHeader = req.headers.authorization ?? "";
211
207
 
@@ -215,14 +211,12 @@ export function authMiddleware(secret: string, algorithm: string = "HS256"): Mid
215
211
  }
216
212
 
217
213
  const token = authHeader.slice(7);
218
- const payload = validToken(token, secret, algorithm);
219
-
220
- if (payload === null) {
214
+ if (!validToken(token)) {
221
215
  res({ error: "Unauthorized" }, 401);
222
216
  return;
223
217
  }
224
218
 
225
- (req as any).auth = payload;
219
+ (req as any).auth = getPayload(token);
226
220
  next();
227
221
  };
228
222
  }
@@ -233,24 +227,24 @@ export function authMiddleware(secret: string, algorithm: string = "HS256"): Mid
233
227
  * Refresh a JWT token — validate the existing token then re-sign
234
228
  * with a fresh expiry.
235
229
  *
230
+ * Secret is always read from `process.env.SECRET`.
231
+ *
236
232
  * @param token - Existing JWT to refresh
237
- * @param secret - HMAC secret or PEM key
238
233
  * @param expiresIn - New lifetime in seconds (default 3600)
239
- * @param algorithm - "HS256" or "RS256" (default "HS256")
240
234
  * @returns New signed JWT string, or null if the input token is invalid/expired
241
235
  */
242
236
  export function refreshToken(
243
237
  token: string,
244
- secret?: string,
245
- expiresIn: number = 60,
246
- algorithm: string = "HS256",
238
+ expiresIn: number = 3600,
247
239
  ): string | null {
248
- const payload = validToken(token, secret, algorithm);
249
- if (payload === null) return null;
240
+ if (!validToken(token)) return null;
241
+
242
+ const payload = getPayload(token);
243
+ if (!payload) return null;
250
244
 
251
245
  // Strip standard timing claims so getToken sets fresh ones
252
246
  const { iat: _iat, exp: _exp, ...claims } = payload;
253
- return getToken(claims, secret, expiresIn, algorithm);
247
+ return getToken(claims, expiresIn);
254
248
  }
255
249
 
256
250
  // ── Request Authentication ───────────────────────────────────────
@@ -275,9 +269,8 @@ export function authenticateRequest(
275
269
 
276
270
  const token = authHeader.slice(7);
277
271
 
278
- // Try JWT first
279
- const payload = validToken(token, secret, algorithm);
280
- if (payload !== null) return payload;
272
+ // Try JWT first (secret/algorithm params kept for backward compat but validToken reads from env)
273
+ if (validToken(token)) return getPayload(token);
281
274
 
282
275
  // Fallback: treat Bearer value as API key
283
276
  if (validateApiKey(token)) {
@@ -1,5 +1,5 @@
1
1
  import type { Tina4Request, Tina4Response, Middleware } from "./types.js";
2
- import { validToken } from "./auth.js";
2
+ import { validToken, getPayload } from "./auth.js";
3
3
 
4
4
  export class MiddlewareChain {
5
5
  private middlewares: Middleware[] = [];
@@ -455,9 +455,7 @@ export class CsrfMiddleware {
455
455
  if (authHeader.startsWith("Bearer ")) {
456
456
  const bearerToken = authHeader.slice(7).trim();
457
457
  if (bearerToken) {
458
- const secret = process.env.SECRET || "tina4-default-secret";
459
- const payload = validToken(bearerToken, secret);
460
- if (payload !== null) {
458
+ if (validToken(bearerToken)) {
461
459
  return [req, res];
462
460
  }
463
461
  }
@@ -494,10 +492,7 @@ export class CsrfMiddleware {
494
492
  }
495
493
 
496
494
  // Validate the token
497
- const secret = process.env.SECRET || "tina4-default-secret";
498
- const payload = validToken(token, secret);
499
-
500
- if (payload === null) {
495
+ if (!validToken(token)) {
501
496
  res({
502
497
  error: "CSRF_INVALID",
503
498
  message: "Invalid or missing form token",
@@ -505,6 +500,8 @@ export class CsrfMiddleware {
505
500
  return [req, res];
506
501
  }
507
502
 
503
+ const payload = getPayload(token) ?? {};
504
+
508
505
  // Session binding — if token has session_id, verify it matches
509
506
  const tokenSessionId = payload.session_id as string | undefined;
510
507
  if (tokenSessionId) {
@@ -57,7 +57,7 @@ export interface QueueBackendInterface {
57
57
  push(queue: string, payload: unknown, delay?: number): string;
58
58
  pop(queue: string): QueueJob | null;
59
59
  size(queue: string): number;
60
- clear(queue: string): void;
60
+ clear(queue: string): number;
61
61
  }
62
62
 
63
63
  // ── Queue ────────────────────────────────────────────────────
@@ -185,16 +185,16 @@ export class Queue {
185
185
  }
186
186
 
187
187
  /**
188
- * Remove all jobs from this queue's topic.
188
+ * Remove all jobs from this queue's topic. Returns the number cleared.
189
189
  */
190
- clear(): void {
190
+ clear(): number {
191
191
  const q = this.topic;
192
192
 
193
193
  if (this.externalBackend) {
194
194
  this.externalBackend.clear(q);
195
- return;
195
+ return 0;
196
196
  }
197
- this.liteBackend.clear(q);
197
+ return this.liteBackend.clear(q);
198
198
  }
199
199
 
200
200
  /**
@@ -205,10 +205,21 @@ export class Queue {
205
205
  }
206
206
 
207
207
  /**
208
- * Retry a failed job by moving it back to the queue.
208
+ * Retry all dead letter jobs for this queue's topic.
209
+ * Moves failed jobs that exceeded max retries back to pending.
210
+ *
211
+ * @param delaySeconds - Optional delay before jobs become available
212
+ * @returns true if at least one job was re-queued, false if none found
209
213
  */
210
- retry(jobId: string, delaySeconds?: number): boolean {
211
- return this.liteBackend.retry(this.topic, jobId, delaySeconds);
214
+ retry(delaySeconds?: number): boolean {
215
+ const deadJobs = this.deadLetters();
216
+ if (deadJobs.length === 0) return false;
217
+ let retried = false;
218
+ for (const job of deadJobs) {
219
+ const ok = this.liteBackend.retry(this.topic, job.id, delaySeconds);
220
+ if (ok) retried = true;
221
+ }
222
+ return retried;
212
223
  }
213
224
 
214
225
  /**
@@ -122,12 +122,14 @@ export class LiteBackend {
122
122
  return count;
123
123
  }
124
124
 
125
- clear(queue: string): void {
125
+ clear(queue: string): number {
126
126
  const dir = this.ensureDir(queue);
127
+ let count = 0;
127
128
  try {
128
129
  const files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
129
130
  for (const file of files) {
130
131
  unlinkSync(join(dir, file));
132
+ count++;
131
133
  }
132
134
  } catch {
133
135
  // directory might not exist
@@ -140,11 +142,13 @@ export class LiteBackend {
140
142
  const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
141
143
  for (const file of files) {
142
144
  unlinkSync(join(failedDir, file));
145
+ count++;
143
146
  }
144
147
  }
145
148
  } catch {
146
149
  // ignore
147
150
  }
151
+ return count;
148
152
  }
149
153
 
150
154
  failed(queue: string): QueueJob[] {
@@ -168,7 +172,7 @@ export class LiteBackend {
168
172
  return results;
169
173
  }
170
174
 
171
- retry(queue: string, jobId: string): boolean {
175
+ retry(queue: string, jobId: string, delaySeconds?: number): boolean {
172
176
  try {
173
177
  const queues = readdirSync(this.basePath);
174
178
  for (const q of queues) {
@@ -180,6 +184,7 @@ export class LiteBackend {
180
184
  job.status = "pending";
181
185
  job.attempts = (job.attempts || 0) + 1;
182
186
  job.error = undefined;
187
+ job.delayUntil = delaySeconds ? new Date(Date.now() + delaySeconds * 1000).toISOString() : null;
183
188
 
184
189
  this.seq++;
185
190
  const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
@@ -8,7 +8,7 @@ import cluster from "node:cluster";
8
8
  import os from "node:os";
9
9
  import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
10
10
  import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
11
- import { validToken } from "./auth.js";
11
+ import { validToken, getPayload } from "./auth.js";
12
12
  import { discoverRoutes } from "./routeDiscovery.js";
13
13
  import { createRequest, parseBody } from "./request.js";
14
14
  import { createResponse, setDefaultTemplatesDir } from "./response.js";
@@ -596,7 +596,7 @@ ${reset}
596
596
  console.log(` \x1b[35m${definition.tableName}\x1b[0m (${Object.keys(definition.fields).length} fields)`);
597
597
  }
598
598
 
599
- // Generate auto-CRUD routes (file-based routes take precedence)
599
+ // Generate auto-CRUD routes for all discovered models
600
600
  const crudRoutes = orm.generateCrudRoutes(models);
601
601
  for (const route of crudRoutes) {
602
602
  // Only add if no file-based route already handles this
@@ -791,15 +791,12 @@ ${reset}
791
791
  if (match.secure === true && match.noAuth !== true && !isDevAdmin) {
792
792
  const authHeader = req.headers.authorization ?? "";
793
793
  const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
794
- const secret = process.env.SECRET || "";
795
- const payload = token ? validToken(token, secret) : null;
796
-
797
- if (!payload) {
794
+ if (!token || !validToken(token)) {
798
795
  res.raw.writeHead(401, { "Content-Type": "application/json" });
799
796
  res.raw.end(JSON.stringify({ error: "Unauthorized" }));
800
797
  return;
801
798
  }
802
- req.user = payload;
799
+ req.user = getPayload(token) ?? {};
803
800
  }
804
801
 
805
802
  // Inject path params by name into handler arguments, then request/response
@@ -2,6 +2,7 @@ import { getAdapter, getNamedAdapter, setAdapter, parseDatabaseUrl } from "./dat
2
2
  import { validate as validateFields } from "./validation.js";
3
3
  import { QueryBuilder } from "./queryBuilder.js";
4
4
  import { SQLiteAdapter } from "./adapters/sqlite.js";
5
+ import { QueryCache } from "./sqlTranslation.js";
5
6
  import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
6
7
 
7
8
  /**
@@ -54,6 +55,7 @@ export class BaseModel {
54
55
  static hasMany?: RelationshipDefinition[];
55
56
  static belongsTo?: RelationshipDefinition[];
56
57
  static _db?: string;
58
+ static _queryCache?: QueryCache;
57
59
 
58
60
  /**
59
61
  * When true, auto-generates fieldMapping entries from camelCase field names
@@ -69,6 +71,12 @@ export class BaseModel {
69
71
  */
70
72
  static fieldMapping: Record<string, string> = {};
71
73
 
74
+ /**
75
+ * When true, auto-generates CRUD routes for this model.
76
+ * Models must explicitly opt-in by setting `static autoCrud = true;`.
77
+ */
78
+ static autoCrud: boolean = false;
79
+
72
80
  /** Instance data */
73
81
  [key: string]: unknown;
74
82
 
@@ -468,7 +476,7 @@ export class BaseModel {
468
476
  /**
469
477
  * Delete this instance. Uses soft delete if configured.
470
478
  */
471
- delete(): void {
479
+ delete(): boolean {
472
480
  const ModelClass = this.constructor as typeof BaseModel;
473
481
  const db = ModelClass.getDb();
474
482
  const pk = ModelClass.getPkField();
@@ -498,6 +506,7 @@ export class BaseModel {
498
506
  db.rollback();
499
507
  throw e;
500
508
  }
509
+ return true;
501
510
  }
502
511
 
503
512
  /**
@@ -630,9 +639,9 @@ export class BaseModel {
630
639
  * Generate and execute CREATE TABLE DDL from the model's field definitions.
631
640
  * Uses the adapter's createTable method if available, otherwise builds SQL directly.
632
641
  */
633
- static createTable(): void {
642
+ static createTable(): boolean {
634
643
  const db = this.getDb();
635
- if (db.tableExists(this.tableName)) return;
644
+ if (db.tableExists(this.tableName)) return true;
636
645
 
637
646
  if (typeof db.createTable === "function") {
638
647
  // Remap field keys to DB column names if fieldMapping is defined
@@ -679,6 +688,7 @@ export class BaseModel {
679
688
  throw e;
680
689
  }
681
690
  }
691
+ return true;
682
692
  }
683
693
 
684
694
  /**
@@ -693,6 +703,45 @@ export class BaseModel {
693
703
  return result;
694
704
  }
695
705
 
706
+ /**
707
+ * Return true if a record with the given primary key exists.
708
+ */
709
+ static exists(id: unknown): boolean {
710
+ const ModelClass = this as unknown as typeof BaseModel;
711
+ return ModelClass.findById(id) !== null;
712
+ }
713
+
714
+ /**
715
+ * Run a raw SQL query with results cached by TTL. Cache is per-model-class.
716
+ */
717
+ static cached<T extends BaseModel>(
718
+ this: new (data?: Record<string, unknown>) => T,
719
+ sql: string,
720
+ params?: unknown[],
721
+ ttl = 60,
722
+ ): T[] {
723
+ const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
724
+ if (!ModelClass._queryCache) {
725
+ ModelClass._queryCache = new QueryCache({ defaultTtl: ttl, maxSize: 500 });
726
+ }
727
+ const key = QueryCache.queryKey(`${ModelClass.tableName}:${sql}`, params ?? []);
728
+ const hit = ModelClass._queryCache.get(key) as T[] | undefined;
729
+ if (hit !== undefined) return hit;
730
+ const results = ModelClass.select<T>(sql, params);
731
+ ModelClass._queryCache.set(key, results, ttl);
732
+ return results;
733
+ }
734
+
735
+ /**
736
+ * Clear the per-model query cache.
737
+ */
738
+ static clearCache(): void {
739
+ const ModelClass = this as unknown as typeof BaseModel;
740
+ if (ModelClass._queryCache) {
741
+ ModelClass._queryCache.clear();
742
+ }
743
+ }
744
+
696
745
  /**
697
746
  * Execute a raw SQL SELECT and return results as model instances.
698
747
  */
@@ -725,7 +774,7 @@ export class BaseModel {
725
774
  /**
726
775
  * Permanently delete this instance, bypassing soft delete.
727
776
  */
728
- forceDelete(): void {
777
+ forceDelete(): boolean {
729
778
  const ModelClass = this.constructor as typeof BaseModel;
730
779
  const db = ModelClass.getDb();
731
780
  const pk = ModelClass.getPkField();
@@ -747,12 +796,13 @@ export class BaseModel {
747
796
  db.rollback();
748
797
  throw e;
749
798
  }
799
+ return true;
750
800
  }
751
801
 
752
802
  /**
753
803
  * Restore a soft-deleted record.
754
804
  */
755
- restore(): void {
805
+ restore(): boolean {
756
806
  const ModelClass = this.constructor as typeof BaseModel;
757
807
  if (!ModelClass.softDelete) {
758
808
  throw new Error("restore() is only available on models with softDelete enabled");
@@ -779,6 +829,7 @@ export class BaseModel {
779
829
  throw e;
780
830
  }
781
831
  this.is_deleted = 0;
832
+ return true;
782
833
  }
783
834
 
784
835
  /**
@@ -32,6 +32,7 @@ export {
32
32
  migrate,
33
33
  createMigration,
34
34
  status,
35
+ Migration,
35
36
  } from "./migration.js";
36
37
  export type { MigrationResult, MigrationStatus } from "./migration.js";
37
38
  export { generateCrudRoutes } from "./autoCrud.js";
@@ -745,3 +745,84 @@ export async function createMigration(
745
745
 
746
746
  return { upPath, downPath };
747
747
  }
748
+
749
+ /**
750
+ * Object-oriented Migration class — canonical Tina4 Migration API.
751
+ *
752
+ * Provides parity with Python, PHP, and Ruby:
753
+ * - migrate() Run all pending migrations
754
+ * - rollback(steps=1) Roll back last N batches
755
+ * - status() Show completed/pending
756
+ * - create(description) Scaffold new .sql + .down.sql files
757
+ * - getApplied() List applied migrations
758
+ * - getPending() List pending migration filenames
759
+ * - getFiles() List all migration files on disk
760
+ *
761
+ * @example
762
+ * const m = new Migration(db, { migrationsDir: "migrations" });
763
+ * await m.migrate();
764
+ * await m.rollback(2);
765
+ * await m.status();
766
+ * await m.create("add users table");
767
+ */
768
+ export class Migration {
769
+ private db?: DatabaseAdapter;
770
+ private dir: string;
771
+ private delimiter: string;
772
+
773
+ constructor(db?: DatabaseAdapter, options?: { migrationsDir?: string; delimiter?: string }) {
774
+ this.db = db;
775
+ this.dir = options?.migrationsDir ?? "migrations";
776
+ this.delimiter = options?.delimiter ?? ";";
777
+ }
778
+
779
+ /** Run all pending migrations. Returns applied/skipped/failed summary. */
780
+ async migrate(): Promise<MigrationResult> {
781
+ return migrate(this.db, { migrationsDir: this.dir, delimiter: this.delimiter });
782
+ }
783
+
784
+ /** Roll back the last N batches. Returns list of rolled-back migration names. */
785
+ async rollback(steps = 1): Promise<string[]> {
786
+ const db = this.db ?? (await import("./database.js")).getAdapter();
787
+ // If tracking table doesn't exist yet there's nothing to roll back
788
+ if (!db.tableExists(MIGRATION_TABLE)) return [];
789
+ const rolled: string[] = [];
790
+ for (let i = 0; i < steps; i++) {
791
+ const batch = rollback(this.dir, this.delimiter);
792
+ if (batch.length === 0) break;
793
+ rolled.push(...batch);
794
+ }
795
+ return rolled;
796
+ }
797
+
798
+ /** Get migration status: which are completed and which are pending. */
799
+ async status(): Promise<MigrationStatus> {
800
+ return status(this.db, { migrationsDir: this.dir });
801
+ }
802
+
803
+ /** Scaffold a new .sql + .down.sql migration file. Returns created paths. */
804
+ async create(description: string): Promise<{ upPath: string; downPath: string }> {
805
+ return createMigration(description, { migrationsDir: this.dir });
806
+ }
807
+
808
+ /** Return list of completed (applied) migration filenames. */
809
+ async getApplied(): Promise<string[]> {
810
+ const s = await this.status();
811
+ return s.completed;
812
+ }
813
+
814
+ /** Return list of pending migration filenames. */
815
+ async getPending(): Promise<string[]> {
816
+ const s = await this.status();
817
+ return s.pending;
818
+ }
819
+
820
+ /** Return sorted list of all migration files on disk (excludes .down.sql). */
821
+ getFiles(): string[] {
822
+ const dir = resolve(this.dir);
823
+ if (!existsSync(dir)) return [];
824
+ return sortMigrationFiles(
825
+ readdirSync(dir).filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")),
826
+ );
827
+ }
828
+ }