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 +1 -1
- package/packages/core/src/auth.ts +36 -43
- package/packages/core/src/middleware.ts +5 -8
- package/packages/core/src/queue.ts +19 -8
- package/packages/core/src/queueBackends/liteBackend.ts +7 -2
- package/packages/core/src/server.ts +4 -7
- package/packages/orm/src/baseModel.ts +56 -5
- package/packages/orm/src/index.ts +1 -0
- package/packages/orm/src/migration.ts +81 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.10.
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
94
|
+
return false;
|
|
99
95
|
}
|
|
100
96
|
|
|
101
|
-
return
|
|
97
|
+
return true;
|
|
102
98
|
} catch {
|
|
103
|
-
return
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
245
|
-
expiresIn: number = 60,
|
|
246
|
-
algorithm: string = "HS256",
|
|
238
|
+
expiresIn: number = 3600,
|
|
247
239
|
): string | null {
|
|
248
|
-
|
|
249
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
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():
|
|
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
|
|
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(
|
|
211
|
-
|
|
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):
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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():
|
|
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():
|
|
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():
|
|
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():
|
|
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
|
/**
|
|
@@ -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
|
+
}
|