tina4-nodejs 3.10.90 → 3.10.91
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/api.ts +6 -6
- package/packages/core/src/auth.ts +28 -15
- package/packages/core/src/cache.ts +9 -0
- package/packages/core/src/fakeData.ts +24 -14
- package/packages/core/src/graphql.ts +1 -1
- package/packages/core/src/i18n.ts +1 -1
- package/packages/core/src/mcp.ts +3 -0
- package/packages/core/src/middleware.ts +24 -0
- package/packages/core/src/queue.ts +103 -30
- package/packages/core/src/queueBackends/liteBackend.ts +43 -0
- package/packages/core/src/router.ts +32 -14
- package/packages/core/src/session.ts +4 -4
- package/packages/core/src/websocket.ts +22 -1
- package/packages/frond/src/engine.ts +6 -0
- package/packages/orm/src/adapters/firebird.ts +2 -2
- package/packages/orm/src/adapters/mssql.ts +2 -2
- package/packages/orm/src/adapters/mysql.ts +2 -2
- package/packages/orm/src/adapters/postgres.ts +2 -2
- package/packages/orm/src/adapters/sqlite.ts +3 -3
- package/packages/orm/src/baseModel.ts +43 -6
- package/packages/orm/src/database.ts +58 -15
- package/packages/orm/src/fakeData.ts +1 -11
- package/packages/orm/src/migration.ts +73 -3
- package/packages/orm/src/sqlTranslation.ts +20 -3
- package/packages/orm/src/types.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.91",
|
|
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"],
|
package/packages/core/src/api.ts
CHANGED
|
@@ -36,7 +36,7 @@ export class Api {
|
|
|
36
36
|
/**
|
|
37
37
|
* Add custom headers to all subsequent requests.
|
|
38
38
|
*/
|
|
39
|
-
|
|
39
|
+
addHeaders(headers: Record<string, string>): void {
|
|
40
40
|
Object.assign(this.headers, headers);
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -78,36 +78,36 @@ export class Api {
|
|
|
78
78
|
* HTTP POST request.
|
|
79
79
|
*/
|
|
80
80
|
async post(path: string, body?: unknown, contentType: string = "application/json"): Promise<ApiResult> {
|
|
81
|
-
return this.sendRequest(
|
|
81
|
+
return this.sendRequest("POST", path, body, contentType);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* HTTP PUT request.
|
|
86
86
|
*/
|
|
87
87
|
async put(path: string, body?: unknown, contentType: string = "application/json"): Promise<ApiResult> {
|
|
88
|
-
return this.sendRequest(
|
|
88
|
+
return this.sendRequest("PUT", path, body, contentType);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
92
|
* HTTP PATCH request.
|
|
93
93
|
*/
|
|
94
94
|
async patch(path: string, body?: unknown, contentType: string = "application/json"): Promise<ApiResult> {
|
|
95
|
-
return this.sendRequest(
|
|
95
|
+
return this.sendRequest("PATCH", path, body, contentType);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
/**
|
|
99
99
|
* HTTP DELETE request.
|
|
100
100
|
*/
|
|
101
101
|
async delete(path: string, body?: unknown): Promise<ApiResult> {
|
|
102
|
-
return this.sendRequest(
|
|
102
|
+
return this.sendRequest("DELETE", path, body);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
106
|
* Generic request method — public entry point for any HTTP method.
|
|
107
107
|
*/
|
|
108
108
|
async sendRequest(
|
|
109
|
-
path: string,
|
|
110
109
|
method: string,
|
|
110
|
+
path: string,
|
|
111
111
|
body?: unknown,
|
|
112
112
|
contentType: string = "application/json",
|
|
113
113
|
): Promise<ApiResult> {
|
|
@@ -35,32 +35,45 @@ function base64urlDecode(str: string): Buffer {
|
|
|
35
35
|
* Secret is always read from `process.env.SECRET`.
|
|
36
36
|
* Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
|
|
37
37
|
*
|
|
38
|
-
* @param payload
|
|
39
|
-
* @param
|
|
38
|
+
* @param payload - Claims to encode (e.g. `{ userId: 1, role: "admin" }`)
|
|
39
|
+
* @param secretOrExpiresIn - Signing secret string, OR expiresIn number (back-compat with old 2-arg form)
|
|
40
|
+
* @param expiresIn - Lifetime in seconds (default 3600). Only used when secret is a string.
|
|
40
41
|
* @returns Signed JWT string: header.payload.signature
|
|
41
42
|
*/
|
|
42
43
|
export function getToken(
|
|
43
44
|
payload: Record<string, unknown>,
|
|
45
|
+
secretOrExpiresIn?: string | number,
|
|
44
46
|
expiresIn: number = 3600,
|
|
47
|
+
algorithm?: string,
|
|
45
48
|
): string {
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
// Back-compat: if second arg is a number, treat it as expiresIn (old 2-arg form)
|
|
50
|
+
let resolvedSecret: string;
|
|
51
|
+
let resolvedExpiresIn: number;
|
|
52
|
+
if (typeof secretOrExpiresIn === "number") {
|
|
53
|
+
resolvedSecret = process.env.SECRET ?? "";
|
|
54
|
+
resolvedExpiresIn = secretOrExpiresIn;
|
|
55
|
+
} else {
|
|
56
|
+
resolvedSecret = secretOrExpiresIn ?? process.env.SECRET ?? "";
|
|
57
|
+
resolvedExpiresIn = expiresIn;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!resolvedSecret) {
|
|
48
61
|
console.warn("Auth: SECRET not set in .env — using blank secret (insecure)");
|
|
49
62
|
}
|
|
50
|
-
const
|
|
63
|
+
const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
|
|
51
64
|
|
|
52
|
-
const header = { alg:
|
|
65
|
+
const header = { alg: resolvedAlgorithm, typ: "JWT" };
|
|
53
66
|
const now = Math.floor(Date.now() / 1000);
|
|
54
67
|
|
|
55
68
|
const claims: Record<string, unknown> = { ...payload, iat: now };
|
|
56
|
-
if (
|
|
57
|
-
claims.exp = now +
|
|
69
|
+
if (resolvedExpiresIn !== 0) {
|
|
70
|
+
claims.exp = now + resolvedExpiresIn;
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
const h = base64urlEncode(Buffer.from(JSON.stringify(header)));
|
|
61
74
|
const p = base64urlEncode(Buffer.from(JSON.stringify(claims)));
|
|
62
75
|
const signingInput = `${h}.${p}`;
|
|
63
|
-
const signature = sign(signingInput,
|
|
76
|
+
const signature = sign(signingInput, resolvedSecret, resolvedAlgorithm);
|
|
64
77
|
|
|
65
78
|
return `${h}.${p}.${signature}`;
|
|
66
79
|
}
|
|
@@ -71,12 +84,12 @@ export function getToken(
|
|
|
71
84
|
* Secret is always read from `process.env.SECRET`.
|
|
72
85
|
* Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
|
|
73
86
|
*/
|
|
74
|
-
export function validToken(token: string): boolean {
|
|
75
|
-
const
|
|
76
|
-
if (!
|
|
87
|
+
export function validToken(token: string, secret?: string, algorithm?: string): boolean {
|
|
88
|
+
const resolvedSecret = secret ?? process.env.SECRET ?? "";
|
|
89
|
+
if (!resolvedSecret) {
|
|
77
90
|
console.warn("Auth: SECRET not set in .env — using blank secret (insecure)");
|
|
78
91
|
}
|
|
79
|
-
const
|
|
92
|
+
const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
|
|
80
93
|
try {
|
|
81
94
|
const parts = token.split(".");
|
|
82
95
|
if (parts.length !== 3) return false;
|
|
@@ -84,7 +97,7 @@ export function validToken(token: string): boolean {
|
|
|
84
97
|
const [h, p, sig] = parts;
|
|
85
98
|
const signingInput = `${h}.${p}`;
|
|
86
99
|
|
|
87
|
-
if (!verifySignature(signingInput, sig,
|
|
100
|
+
if (!verifySignature(signingInput, sig, resolvedSecret, resolvedAlgorithm)) {
|
|
88
101
|
return false;
|
|
89
102
|
}
|
|
90
103
|
|
|
@@ -211,7 +224,7 @@ export function authMiddleware(secret?: string, algorithm: string = "HS256"): Mi
|
|
|
211
224
|
}
|
|
212
225
|
|
|
213
226
|
const token = authHeader.slice(7);
|
|
214
|
-
if (!validToken(token)) {
|
|
227
|
+
if (!validToken(token, secret, algorithm)) {
|
|
215
228
|
res({ error: "Unauthorized" }, 401);
|
|
216
229
|
return;
|
|
217
230
|
}
|
|
@@ -501,6 +501,15 @@ export function cacheClear(): void {
|
|
|
501
501
|
_getBackend().clear();
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
/** Remove expired entries from the cache. Returns count removed. */
|
|
505
|
+
export function sweep(): number {
|
|
506
|
+
const backend = _getBackend();
|
|
507
|
+
if (typeof (backend as any).sweep === "function") {
|
|
508
|
+
return (backend as any).sweep();
|
|
509
|
+
}
|
|
510
|
+
return 0;
|
|
511
|
+
}
|
|
512
|
+
|
|
504
513
|
/** Return cache statistics from the active backend. */
|
|
505
514
|
export function cacheBackendStats(): { hits: number; misses: number; size: number; backend: string } {
|
|
506
515
|
return _getBackend().stats();
|
|
@@ -68,12 +68,6 @@ const JOB_TITLES = [
|
|
|
68
68
|
"Systems Administrator",
|
|
69
69
|
];
|
|
70
70
|
|
|
71
|
-
const COLORS = [
|
|
72
|
-
"red", "blue", "green", "yellow", "purple", "orange", "pink",
|
|
73
|
-
"cyan", "magenta", "teal", "indigo", "violet", "coral", "salmon",
|
|
74
|
-
"turquoise", "maroon", "navy", "olive", "silver", "gold",
|
|
75
|
-
];
|
|
76
|
-
|
|
77
71
|
const CURRENCIES = [
|
|
78
72
|
"USD", "EUR", "GBP", "JPY", "AUD", "CAD", "CHF", "CNY",
|
|
79
73
|
"SEK", "NZD", "MXN", "SGD", "HKD", "NOK", "ZAR", "INR",
|
|
@@ -137,7 +131,7 @@ export class FakeData {
|
|
|
137
131
|
return this.pick(LAST_NAMES);
|
|
138
132
|
}
|
|
139
133
|
|
|
140
|
-
|
|
134
|
+
name(): string {
|
|
141
135
|
return `${this.firstName()} ${this.lastName()}`;
|
|
142
136
|
}
|
|
143
137
|
|
|
@@ -209,7 +203,7 @@ export class FakeData {
|
|
|
209
203
|
return this.randInt(min, max + 1);
|
|
210
204
|
}
|
|
211
205
|
|
|
212
|
-
|
|
206
|
+
numeric(min = 0, max = 1000, decimals = 2): number {
|
|
213
207
|
const raw = min + this.rng() * (max - min);
|
|
214
208
|
return Number(raw.toFixed(decimals));
|
|
215
209
|
}
|
|
@@ -251,11 +245,7 @@ export class FakeData {
|
|
|
251
245
|
return `${this.randInt(1, 256)}.${this.randInt(0, 256)}.${this.randInt(0, 256)}.${this.randInt(1, 256)}`;
|
|
252
246
|
}
|
|
253
247
|
|
|
254
|
-
|
|
255
|
-
return this.pick(COLORS);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
hexColor(): string {
|
|
248
|
+
colorHex(): string {
|
|
259
249
|
const hex = this.randInt(0, 0x1000000).toString(16).padStart(6, "0");
|
|
260
250
|
return `#${hex}`;
|
|
261
251
|
}
|
|
@@ -270,11 +260,31 @@ export class FakeData {
|
|
|
270
260
|
return this.pick(CURRENCIES);
|
|
271
261
|
}
|
|
272
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Returns multi-paragraph text.
|
|
265
|
+
* Matches Python's text() method.
|
|
266
|
+
*/
|
|
267
|
+
text(paragraphs = 3): string {
|
|
268
|
+
const parts: string[] = [];
|
|
269
|
+
for (let i = 0; i < paragraphs; i++) {
|
|
270
|
+
parts.push(this.paragraph(4));
|
|
271
|
+
}
|
|
272
|
+
return parts.join("\n\n");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Returns a random element from the given array.
|
|
277
|
+
* Matches Python's choice() method.
|
|
278
|
+
*/
|
|
279
|
+
choice<T>(items: T[]): T {
|
|
280
|
+
return items[this.randInt(0, items.length)];
|
|
281
|
+
}
|
|
282
|
+
|
|
273
283
|
/**
|
|
274
284
|
* Run seed files from a directory. Each file should export a default async function.
|
|
275
285
|
* Returns an array of executed file paths.
|
|
276
286
|
*/
|
|
277
|
-
async
|
|
287
|
+
async seedDir(seedDir?: string): Promise<string[]> {
|
|
278
288
|
const dir = resolve(seedDir ?? "src/seeds");
|
|
279
289
|
if (!existsSync(dir)) return [];
|
|
280
290
|
const files = readdirSync(dir)
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -51,6 +51,30 @@ export class MiddlewareChain {
|
|
|
51
51
|
* the chain short-circuits and runBefore returns shouldContinue = false.
|
|
52
52
|
*/
|
|
53
53
|
export class MiddlewareRunner {
|
|
54
|
+
/** Globally registered middleware classes (parity with PHP/Ruby/Python orchestrators). */
|
|
55
|
+
private static globalMiddleware: any[] = [];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register a middleware class to run on every request.
|
|
59
|
+
* Mirrors Tina4\Middleware::use (PHP), Tina4::Middleware.use (Ruby),
|
|
60
|
+
* and Middleware.use (Python).
|
|
61
|
+
*/
|
|
62
|
+
static use(cls: any): void {
|
|
63
|
+
if (!MiddlewareRunner.globalMiddleware.includes(cls)) {
|
|
64
|
+
MiddlewareRunner.globalMiddleware.push(cls);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Return the list of globally registered middleware classes. */
|
|
69
|
+
static getGlobal(): any[] {
|
|
70
|
+
return [...MiddlewareRunner.globalMiddleware];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Clear all globally registered middleware (primarily for tests). */
|
|
74
|
+
static reset(): void {
|
|
75
|
+
MiddlewareRunner.globalMiddleware = [];
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
/**
|
|
55
79
|
* Execute every beforeX static method found on the supplied classes,
|
|
56
80
|
* in order. Returns the (possibly mutated) request and response pair and a
|
|
@@ -51,6 +51,14 @@ export interface ProcessOptions {
|
|
|
51
51
|
pollInterval?: number;
|
|
52
52
|
maxJobs?: number;
|
|
53
53
|
maxRetries?: number;
|
|
54
|
+
batchSize?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ConsumeOptions {
|
|
58
|
+
batchSize?: number;
|
|
59
|
+
pollInterval?: number;
|
|
60
|
+
iterations?: number;
|
|
61
|
+
id?: string;
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
export interface QueueBackendInterface {
|
|
@@ -139,11 +147,18 @@ export class Queue {
|
|
|
139
147
|
return this.liteBackend.pop(q, this);
|
|
140
148
|
}
|
|
141
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Pop up to count jobs at once. Returns a partial batch if fewer available.
|
|
152
|
+
*/
|
|
153
|
+
popBatch(count: number): QueueJob[] {
|
|
154
|
+
return this.liteBackend.popBatch(this.topic, this, count);
|
|
155
|
+
}
|
|
156
|
+
|
|
142
157
|
/**
|
|
143
158
|
* Process jobs from a queue with a handler function.
|
|
144
159
|
*/
|
|
145
160
|
process(
|
|
146
|
-
handler: (job: QueueJob) => Promise<void> | void,
|
|
161
|
+
handler: (job: QueueJob | QueueJob[]) => Promise<void> | void,
|
|
147
162
|
options?: ProcessOptions,
|
|
148
163
|
): void {
|
|
149
164
|
const queue = this.topic;
|
|
@@ -151,23 +166,44 @@ export class Queue {
|
|
|
151
166
|
|
|
152
167
|
const maxJobs = opts?.maxJobs ?? Infinity;
|
|
153
168
|
const maxRetries = opts?.maxRetries ?? this._maxRetries;
|
|
169
|
+
const batchSize = opts?.batchSize;
|
|
154
170
|
let processed = 0;
|
|
155
171
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
result
|
|
163
|
-
|
|
164
|
-
|
|
172
|
+
if (batchSize && batchSize > 1) {
|
|
173
|
+
while (processed < maxJobs) {
|
|
174
|
+
const remaining = maxJobs === Infinity ? batchSize : Math.min(batchSize, maxJobs - processed);
|
|
175
|
+
const jobs = this.popBatch(remaining);
|
|
176
|
+
if (jobs.length === 0) break;
|
|
177
|
+
try {
|
|
178
|
+
const result = handler(jobs);
|
|
179
|
+
if (result instanceof Promise) {
|
|
180
|
+
result.catch((err: Error) => {
|
|
181
|
+
for (const job of jobs) this._failJob(queue, job, err.message, maxRetries);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
} catch (err: unknown) {
|
|
185
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
186
|
+
for (const job of jobs) this._failJob(queue, job, message, maxRetries);
|
|
187
|
+
}
|
|
188
|
+
processed += jobs.length;
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
while (processed < maxJobs) {
|
|
192
|
+
const job = this.pop();
|
|
193
|
+
if (!job) break;
|
|
194
|
+
try {
|
|
195
|
+
const result = handler(job);
|
|
196
|
+
if (result instanceof Promise) {
|
|
197
|
+
result.catch((err: Error) => {
|
|
198
|
+
this._failJob(queue, job, err.message, maxRetries);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
processed++;
|
|
202
|
+
} catch (err: unknown) {
|
|
203
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
204
|
+
this._failJob(queue, job, message, maxRetries);
|
|
205
|
+
processed++;
|
|
165
206
|
}
|
|
166
|
-
processed++;
|
|
167
|
-
} catch (err: unknown) {
|
|
168
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
169
|
-
this._failJob(queue, job, message, maxRetries);
|
|
170
|
-
processed++;
|
|
171
207
|
}
|
|
172
208
|
}
|
|
173
209
|
}
|
|
@@ -211,7 +247,12 @@ export class Queue {
|
|
|
211
247
|
* @param delaySeconds - Optional delay before jobs become available
|
|
212
248
|
* @returns true if at least one job was re-queued, false if none found
|
|
213
249
|
*/
|
|
214
|
-
retry(delaySeconds?: number): boolean {
|
|
250
|
+
retry(jobId?: string, delaySeconds?: number): boolean {
|
|
251
|
+
if (jobId) {
|
|
252
|
+
// Retry a specific job by ID
|
|
253
|
+
return this.liteBackend.retry(this.topic, jobId, delaySeconds);
|
|
254
|
+
}
|
|
255
|
+
// Retry all dead-letter jobs
|
|
215
256
|
const deadJobs = this.deadLetters();
|
|
216
257
|
if (deadJobs.length === 0) return false;
|
|
217
258
|
let retried = false;
|
|
@@ -246,7 +287,7 @@ export class Queue {
|
|
|
246
287
|
/**
|
|
247
288
|
* Produce a message onto a topic. Convenience wrapper around push().
|
|
248
289
|
*/
|
|
249
|
-
produce(topic: string, payload: unknown,
|
|
290
|
+
produce(topic: string, payload: unknown, priority: number = 0, delay: number = 0): string {
|
|
250
291
|
if (this.externalBackend) {
|
|
251
292
|
return this.externalBackend.push(topic, payload, delay);
|
|
252
293
|
}
|
|
@@ -279,29 +320,61 @@ export class Queue {
|
|
|
279
320
|
* for await (const job of queue.consume("emails")) { ... }
|
|
280
321
|
* for await (const job of queue.consume("emails", undefined, 5000)) { ... }
|
|
281
322
|
*/
|
|
282
|
-
async *consume(
|
|
283
|
-
|
|
323
|
+
async *consume(topicOrOptions?: string | ConsumeOptions, id?: string, pollInterval: number = 1000, iterations: number = 0, batchSize: number = 1): AsyncGenerator<QueueJob | QueueJob[]> {
|
|
324
|
+
// Support options-object form: consume({ batchSize, pollInterval, iterations, id })
|
|
325
|
+
let q: string;
|
|
326
|
+
let resolvedId: string | undefined;
|
|
327
|
+
let resolvedPollInterval: number;
|
|
328
|
+
let resolvedIterations: number;
|
|
329
|
+
let resolvedBatchSize: number;
|
|
330
|
+
|
|
331
|
+
if (topicOrOptions !== null && typeof topicOrOptions === "object") {
|
|
332
|
+
const opts = topicOrOptions as ConsumeOptions;
|
|
333
|
+
q = this.topic;
|
|
334
|
+
resolvedId = opts.id;
|
|
335
|
+
resolvedPollInterval = opts.pollInterval ?? 1000;
|
|
336
|
+
resolvedIterations = opts.iterations ?? 0;
|
|
337
|
+
resolvedBatchSize = opts.batchSize ?? batchSize;
|
|
338
|
+
} else {
|
|
339
|
+
q = (topicOrOptions as string | undefined) ?? this.topic;
|
|
340
|
+
resolvedId = id;
|
|
341
|
+
resolvedPollInterval = pollInterval;
|
|
342
|
+
resolvedIterations = iterations;
|
|
343
|
+
resolvedBatchSize = batchSize;
|
|
344
|
+
}
|
|
284
345
|
|
|
285
|
-
if (
|
|
286
|
-
const raw = this.popById(
|
|
346
|
+
if (resolvedId !== undefined) {
|
|
347
|
+
const raw = this.popById(resolvedId);
|
|
287
348
|
if (raw) yield createJob(raw as any, this);
|
|
288
349
|
return;
|
|
289
350
|
}
|
|
290
351
|
|
|
291
352
|
// pollInterval=0 → single-pass drain (returns when empty)
|
|
292
353
|
// pollInterval>0 → long-running poll (sleeps when empty, never returns)
|
|
293
|
-
// iterations>0 → stop after consuming N jobs
|
|
354
|
+
// iterations>0 → stop after consuming N jobs (or N batches when batchSize>1)
|
|
294
355
|
let consumed = 0;
|
|
295
356
|
while (true) {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
|
|
357
|
+
if (resolvedBatchSize && resolvedBatchSize > 1) {
|
|
358
|
+
const jobs = this.popBatch(resolvedBatchSize);
|
|
359
|
+
if (jobs.length === 0) {
|
|
360
|
+
if (resolvedPollInterval <= 0) break;
|
|
361
|
+
await new Promise(resolve => setTimeout(resolve, resolvedPollInterval));
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
yield jobs;
|
|
365
|
+
consumed++;
|
|
366
|
+
if (resolvedIterations > 0 && consumed >= resolvedIterations) break;
|
|
367
|
+
} else {
|
|
368
|
+
const raw = this.pop() as any;
|
|
369
|
+
if (raw === null) {
|
|
370
|
+
if (resolvedPollInterval <= 0) break;
|
|
371
|
+
await new Promise(resolve => setTimeout(resolve, resolvedPollInterval));
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
yield createJob(raw, this);
|
|
375
|
+
consumed++;
|
|
376
|
+
if (resolvedIterations > 0 && consumed >= resolvedIterations) break;
|
|
301
377
|
}
|
|
302
|
-
yield createJob(raw, this);
|
|
303
|
-
consumed++;
|
|
304
|
-
if (iterations > 0 && consumed >= iterations) break;
|
|
305
378
|
}
|
|
306
379
|
}
|
|
307
380
|
|
|
@@ -90,6 +90,49 @@ export class LiteBackend {
|
|
|
90
90
|
return null;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
popBatch(queue: string, bridge: JobQueueBridge, count: number): QueueJob[] {
|
|
94
|
+
const dir = this.ensureDir(queue);
|
|
95
|
+
|
|
96
|
+
let files: string[];
|
|
97
|
+
try {
|
|
98
|
+
files = readdirSync(dir).filter(f => f.endsWith(".queue-data")).sort();
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const now = new Date().toISOString();
|
|
104
|
+
const results: QueueJob[] = [];
|
|
105
|
+
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
if (results.length >= count) break;
|
|
108
|
+
const filePath = join(dir, file);
|
|
109
|
+
let job: QueueJob;
|
|
110
|
+
try {
|
|
111
|
+
job = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
112
|
+
} catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (job.status !== "pending") continue;
|
|
117
|
+
if (job.delayUntil && job.delayUntil > now) continue;
|
|
118
|
+
|
|
119
|
+
job.status = "reserved";
|
|
120
|
+
job.topic = queue;
|
|
121
|
+
job.priority = job.priority ?? 0;
|
|
122
|
+
writeFileSync(filePath, JSON.stringify(job, null, 2));
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
unlinkSync(filePath);
|
|
126
|
+
} catch {
|
|
127
|
+
// Already consumed by another worker
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
results.push(createJob(job as any, bridge));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return results;
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
size(queue: string, status: string = "pending"): number {
|
|
94
137
|
if (status === "failed") {
|
|
95
138
|
const failedDir = this.ensureFailedDir(queue);
|
|
@@ -54,6 +54,12 @@ export class RouteRef {
|
|
|
54
54
|
this.route.cached = true;
|
|
55
55
|
return this;
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/** Append middleware class(es) to this route. */
|
|
59
|
+
middleware(...middlewareClasses: Middleware[]): this {
|
|
60
|
+
this.route.middlewares = [...(this.route.middlewares ?? []), ...middlewareClasses];
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
export interface RouteInfo {
|
|
@@ -194,7 +200,7 @@ export class Router {
|
|
|
194
200
|
/**
|
|
195
201
|
* Match a request method + pathname to a registered route.
|
|
196
202
|
*/
|
|
197
|
-
match(method: string,
|
|
203
|
+
match(method: string, path: string): MatchResult | null {
|
|
198
204
|
const upperMethod = method.toUpperCase();
|
|
199
205
|
|
|
200
206
|
// Try exact method first, then ANY routes are already registered under each method
|
|
@@ -202,7 +208,7 @@ export class Router {
|
|
|
202
208
|
if (!routes) return null;
|
|
203
209
|
|
|
204
210
|
for (const route of routes) {
|
|
205
|
-
const match = route.regex.exec(
|
|
211
|
+
const match = route.regex.exec(path);
|
|
206
212
|
if (match) {
|
|
207
213
|
const params: Record<string, string> = {};
|
|
208
214
|
for (let i = 0; i < route.paramNames.length; i++) {
|
|
@@ -310,46 +316,58 @@ export class Router {
|
|
|
310
316
|
// Router.get("/path", handler)
|
|
311
317
|
// as an alternative to importing the top-level get(), post(), etc.
|
|
312
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Register a route for a specific HTTP method.
|
|
321
|
+
* Core registration method — all convenience methods delegate here.
|
|
322
|
+
*/
|
|
323
|
+
static add(method: string, path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
324
|
+
const m = method.toUpperCase();
|
|
325
|
+
if (m === "ANY") {
|
|
326
|
+
return defaultRouter.any(path, handler, middleware, swaggerMeta);
|
|
327
|
+
}
|
|
328
|
+
return defaultRouter.addRoute({ method: m, pattern: path, handler, middlewares: middleware, meta: swaggerMeta, template });
|
|
329
|
+
}
|
|
330
|
+
|
|
313
331
|
/**
|
|
314
332
|
* Register a GET route on the default global router.
|
|
315
333
|
*/
|
|
316
|
-
static get(path: string, handler: RouteHandler,
|
|
317
|
-
return defaultRouter.get(path, handler,
|
|
334
|
+
static get(path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
335
|
+
return defaultRouter.get(path, handler, middleware, swaggerMeta);
|
|
318
336
|
}
|
|
319
337
|
|
|
320
338
|
/**
|
|
321
339
|
* Register a POST route on the default global router.
|
|
322
340
|
*/
|
|
323
|
-
static post(path: string, handler: RouteHandler,
|
|
324
|
-
return defaultRouter.post(path, handler,
|
|
341
|
+
static post(path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
342
|
+
return defaultRouter.post(path, handler, middleware, swaggerMeta);
|
|
325
343
|
}
|
|
326
344
|
|
|
327
345
|
/**
|
|
328
346
|
* Register a PUT route on the default global router.
|
|
329
347
|
*/
|
|
330
|
-
static put(path: string, handler: RouteHandler,
|
|
331
|
-
return defaultRouter.put(path, handler,
|
|
348
|
+
static put(path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
349
|
+
return defaultRouter.put(path, handler, middleware, swaggerMeta);
|
|
332
350
|
}
|
|
333
351
|
|
|
334
352
|
/**
|
|
335
353
|
* Register a PATCH route on the default global router.
|
|
336
354
|
*/
|
|
337
|
-
static patch(path: string, handler: RouteHandler,
|
|
338
|
-
return defaultRouter.patch(path, handler,
|
|
355
|
+
static patch(path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
356
|
+
return defaultRouter.patch(path, handler, middleware, swaggerMeta);
|
|
339
357
|
}
|
|
340
358
|
|
|
341
359
|
/**
|
|
342
360
|
* Register a DELETE route on the default global router.
|
|
343
361
|
*/
|
|
344
|
-
static delete(path: string, handler: RouteHandler,
|
|
345
|
-
return defaultRouter.delete(path, handler,
|
|
362
|
+
static delete(path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
363
|
+
return defaultRouter.delete(path, handler, middleware, swaggerMeta);
|
|
346
364
|
}
|
|
347
365
|
|
|
348
366
|
/**
|
|
349
367
|
* Register a route that matches ANY HTTP method on the default global router.
|
|
350
368
|
*/
|
|
351
|
-
static any(path: string, handler: RouteHandler,
|
|
352
|
-
return defaultRouter.any(path, handler,
|
|
369
|
+
static any(path: string, handler: RouteHandler, middleware?: Middleware[], swaggerMeta?: RouteMeta, template?: string): RouteRef {
|
|
370
|
+
return defaultRouter.any(path, handler, middleware, swaggerMeta);
|
|
353
371
|
}
|
|
354
372
|
|
|
355
373
|
/**
|
|
@@ -68,7 +68,7 @@ interface SessionData {
|
|
|
68
68
|
*/
|
|
69
69
|
export interface SessionHandler {
|
|
70
70
|
read(sessionId: string): SessionData | null;
|
|
71
|
-
write(sessionId: string, data: SessionData, ttl
|
|
71
|
+
write(sessionId: string, data: SessionData, ttl?: number): void;
|
|
72
72
|
destroy(sessionId: string): void;
|
|
73
73
|
/** Garbage-collect expired sessions. Optional — Redis/Valkey/Mongo handle TTL natively. */
|
|
74
74
|
gc?(maxLifetime: number): void;
|
|
@@ -112,7 +112,7 @@ export class FileSessionHandler implements SessionHandler {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
write(sessionId: string, data: SessionData, ttl: number): void {
|
|
115
|
+
write(sessionId: string, data: SessionData, ttl: number = 0): void {
|
|
116
116
|
this.ensureDir();
|
|
117
117
|
const expires = ttl > 0 ? Math.floor(Date.now() / 1000) + ttl : 0;
|
|
118
118
|
const wrapper = { _data: data, _expires: expires };
|
|
@@ -126,7 +126,7 @@ export class FileSessionHandler implements SessionHandler {
|
|
|
126
126
|
} catch { /* ignore */ }
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
gc(
|
|
129
|
+
gc(maxLifetime: number = 0): void {
|
|
130
130
|
if (!existsSync(this.storagePath)) return;
|
|
131
131
|
const now = Math.floor(Date.now() / 1000);
|
|
132
132
|
try {
|
|
@@ -298,7 +298,7 @@ export class RedisSessionHandler implements SessionHandler {
|
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
write(sessionId: string, data: SessionData, ttl: number): void {
|
|
301
|
+
write(sessionId: string, data: SessionData, ttl: number = 0): void {
|
|
302
302
|
const json = JSON.stringify(data);
|
|
303
303
|
if (ttl > 0) {
|
|
304
304
|
this.execSync(["SETEX", this.key(sessionId), String(ttl), json]);
|
|
@@ -58,7 +58,7 @@ export interface WebSocketClient {
|
|
|
58
58
|
|
|
59
59
|
type EventHandler = (...args: unknown[]) => void;
|
|
60
60
|
|
|
61
|
-
// ── Frame Utilities (
|
|
61
|
+
// ── Frame Utilities (internal) ───────────────────────────────
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
|
|
@@ -285,6 +285,27 @@ export class WebSocketServer {
|
|
|
285
285
|
return this.clients;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Close a specific client connection with an optional code and reason.
|
|
290
|
+
*/
|
|
291
|
+
close(clientId: string, code: number = 1000, reason: string = ""): void {
|
|
292
|
+
const client = this.clients.get(clientId);
|
|
293
|
+
if (!client || client.closed) return;
|
|
294
|
+
client.closed = true;
|
|
295
|
+
const reasonBytes = Buffer.from(reason, "utf-8");
|
|
296
|
+
const payload = Buffer.alloc(2 + reasonBytes.length);
|
|
297
|
+
payload.writeUInt16BE(code, 0);
|
|
298
|
+
reasonBytes.copy(payload, 2);
|
|
299
|
+
try {
|
|
300
|
+
client.socket.write(buildFrame(OP_CLOSE, payload));
|
|
301
|
+
client.socket.end();
|
|
302
|
+
} catch {
|
|
303
|
+
// already closed
|
|
304
|
+
}
|
|
305
|
+
this.clients.delete(clientId);
|
|
306
|
+
this.removeClientFromAllRooms(clientId);
|
|
307
|
+
}
|
|
308
|
+
|
|
288
309
|
// ── Rooms ──────────────────────────────────────────────────
|
|
289
310
|
|
|
290
311
|
/**
|
|
@@ -1370,6 +1370,12 @@ export class Frond {
|
|
|
1370
1370
|
this.compiledStrings.clear();
|
|
1371
1371
|
}
|
|
1372
1372
|
|
|
1373
|
+
/** Render a debug dump of a value as HTML — parity with PHP/Ruby/Python.
|
|
1374
|
+
* Gated on TINA4_DEBUG=true. Returns empty string in production. */
|
|
1375
|
+
renderDump(value: unknown): string {
|
|
1376
|
+
return renderDump(value).toString();
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1373
1379
|
private load(name: string): string {
|
|
1374
1380
|
const filePath = join(this.templateDir, name);
|
|
1375
1381
|
if (!existsSync(filePath)) {
|
|
@@ -212,7 +212,7 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown
|
|
215
|
+
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
|
|
216
216
|
throw new Error("Use updateAsync() for Firebird.");
|
|
217
217
|
}
|
|
218
218
|
|
|
@@ -231,7 +231,7 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
delete(table: string, filter: Record<string, unknown
|
|
234
|
+
delete(table: string, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
|
|
235
235
|
throw new Error("Use deleteAsync() for Firebird.");
|
|
236
236
|
}
|
|
237
237
|
|
|
@@ -257,7 +257,7 @@ export class MssqlAdapter implements DatabaseAdapter {
|
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown
|
|
260
|
+
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
|
|
261
261
|
throw new Error("Use updateAsync() for MSSQL.");
|
|
262
262
|
}
|
|
263
263
|
|
|
@@ -280,7 +280,7 @@ export class MssqlAdapter implements DatabaseAdapter {
|
|
|
280
280
|
}
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
-
delete(table: string, filter: Record<string, unknown
|
|
283
|
+
delete(table: string, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
|
|
284
284
|
throw new Error("Use deleteAsync() for MSSQL.");
|
|
285
285
|
}
|
|
286
286
|
|
|
@@ -183,7 +183,7 @@ export class MysqlAdapter implements DatabaseAdapter {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown
|
|
186
|
+
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
|
|
187
187
|
throw new Error("Use updateAsync() for MySQL.");
|
|
188
188
|
}
|
|
189
189
|
|
|
@@ -202,7 +202,7 @@ export class MysqlAdapter implements DatabaseAdapter {
|
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
delete(table: string, filter: Record<string, unknown
|
|
205
|
+
delete(table: string, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
|
|
206
206
|
throw new Error("Use deleteAsync() for MySQL.");
|
|
207
207
|
}
|
|
208
208
|
|
|
@@ -167,7 +167,7 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown
|
|
170
|
+
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
|
|
171
171
|
throw new Error("Use updateAsync() for PostgreSQL.");
|
|
172
172
|
}
|
|
173
173
|
|
|
@@ -190,7 +190,7 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
delete(table: string, filter: Record<string, unknown
|
|
193
|
+
delete(table: string, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
|
|
194
194
|
throw new Error("Use deleteAsync() for PostgreSQL.");
|
|
195
195
|
}
|
|
196
196
|
|
|
@@ -100,7 +100,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown
|
|
103
|
+
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
|
|
104
104
|
const setClauses = Object.keys(data).map((k) => `"${k}" = ?`).join(", ");
|
|
105
105
|
const whereClauses = Object.keys(filter).map((k) => `"${k}" = ?`).join(" AND ");
|
|
106
106
|
const sql = `UPDATE "${table}" SET ${setClauses} WHERE ${whereClauses}`;
|
|
@@ -114,7 +114,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[]): DatabaseResult {
|
|
117
|
+
delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): DatabaseResult {
|
|
118
118
|
if (Array.isArray(filter)) {
|
|
119
119
|
let totalAffected = 0;
|
|
120
120
|
for (const row of filter) {
|
|
@@ -127,7 +127,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
127
127
|
if (typeof filter === "string") {
|
|
128
128
|
const sql = filter ? `DELETE FROM "${table}" WHERE ${filter}` : `DELETE FROM "${table}"`;
|
|
129
129
|
try {
|
|
130
|
-
const result = this.db.prepare(sql).run();
|
|
130
|
+
const result = this.db.prepare(sql).run(...(params ?? []));
|
|
131
131
|
return { success: true, rowsAffected: result.changes };
|
|
132
132
|
} catch (e) {
|
|
133
133
|
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
@@ -285,7 +285,7 @@ export class BaseModel {
|
|
|
285
285
|
*/
|
|
286
286
|
static create<T extends BaseModel>(
|
|
287
287
|
this: new (data?: Record<string, unknown>) => T,
|
|
288
|
-
data: Record<string, unknown
|
|
288
|
+
data: Record<string, unknown> = {},
|
|
289
289
|
): T {
|
|
290
290
|
const instance = new this(data) as T;
|
|
291
291
|
instance.save();
|
|
@@ -403,6 +403,7 @@ export class BaseModel {
|
|
|
403
403
|
where?: string,
|
|
404
404
|
params?: unknown[],
|
|
405
405
|
include?: string[],
|
|
406
|
+
orderBy?: string,
|
|
406
407
|
): T[] {
|
|
407
408
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
408
409
|
const db = ModelClass.getDb();
|
|
@@ -419,7 +420,8 @@ export class BaseModel {
|
|
|
419
420
|
}
|
|
420
421
|
|
|
421
422
|
const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
|
|
422
|
-
const
|
|
423
|
+
const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
|
|
424
|
+
const sql = `SELECT * FROM "${ModelClass.tableName}"${whereClause}${orderClause}`;
|
|
423
425
|
|
|
424
426
|
const rows = db.query(sql, params);
|
|
425
427
|
const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
@@ -755,28 +757,46 @@ export class BaseModel {
|
|
|
755
757
|
/**
|
|
756
758
|
* Return true if a record with the given primary key exists.
|
|
757
759
|
*/
|
|
758
|
-
static exists(
|
|
760
|
+
static exists(pkValue: unknown): boolean {
|
|
759
761
|
const ModelClass = this as unknown as typeof BaseModel;
|
|
760
|
-
return ModelClass.findById(
|
|
762
|
+
return ModelClass.findById(pkValue) !== null;
|
|
761
763
|
}
|
|
762
764
|
|
|
763
765
|
/**
|
|
764
766
|
* Run a raw SQL query with results cached by TTL. Cache is per-model-class.
|
|
767
|
+
*
|
|
768
|
+
* @param sql SQL query string.
|
|
769
|
+
* @param params Bind parameters.
|
|
770
|
+
* @param ttl Cache TTL in seconds (default 60).
|
|
771
|
+
* @param limit Max records to return (default 20).
|
|
772
|
+
* @param offset Records to skip (default 0).
|
|
773
|
+
* @param include Relationship names to eager-load on cache miss.
|
|
765
774
|
*/
|
|
766
775
|
static cached<T extends BaseModel>(
|
|
767
776
|
this: new (data?: Record<string, unknown>) => T,
|
|
768
777
|
sql: string,
|
|
769
778
|
params?: unknown[],
|
|
770
779
|
ttl = 60,
|
|
780
|
+
limit = 20,
|
|
781
|
+
offset = 0,
|
|
782
|
+
include?: string[],
|
|
771
783
|
): T[] {
|
|
772
784
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
773
785
|
if (!ModelClass._queryCache) {
|
|
774
786
|
ModelClass._queryCache = new QueryCache({ defaultTtl: ttl, maxSize: 500 });
|
|
775
787
|
}
|
|
776
|
-
const
|
|
788
|
+
const cacheKey = `${ModelClass.tableName}:${sql}:${limit}:${offset}`;
|
|
789
|
+
const key = QueryCache.queryKey(cacheKey, params ?? []);
|
|
777
790
|
const hit = ModelClass._queryCache.get(key) as T[] | undefined;
|
|
778
791
|
if (hit !== undefined) return hit;
|
|
779
|
-
|
|
792
|
+
|
|
793
|
+
const db = ModelClass.getDb();
|
|
794
|
+
const querySql = `${sql} LIMIT ${limit} OFFSET ${offset}`;
|
|
795
|
+
const rows = db.query(querySql, params);
|
|
796
|
+
const results = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
797
|
+
if (include && results.length > 0) {
|
|
798
|
+
ModelClass._eagerLoad(results as BaseModel[], include);
|
|
799
|
+
}
|
|
780
800
|
ModelClass._queryCache.set(key, results, ttl);
|
|
781
801
|
return results;
|
|
782
802
|
}
|
|
@@ -1260,6 +1280,23 @@ export class BaseModel {
|
|
|
1260
1280
|
}
|
|
1261
1281
|
}
|
|
1262
1282
|
|
|
1283
|
+
/**
|
|
1284
|
+
* Public alias for _eagerLoad. Eagerly loads relationships for a list of instances,
|
|
1285
|
+
* preventing N+1 queries.
|
|
1286
|
+
*
|
|
1287
|
+
* Usage:
|
|
1288
|
+
* const users = User.all();
|
|
1289
|
+
* await User.eagerLoad(users, ["posts", "profile"]);
|
|
1290
|
+
*
|
|
1291
|
+
* @param instances Array of model instances to load relationships onto.
|
|
1292
|
+
* @param includeList Array of relationship names (supports dot notation for nesting).
|
|
1293
|
+
*/
|
|
1294
|
+
static eagerLoad(instances: BaseModel[], includeList: string[]): Promise<void> {
|
|
1295
|
+
const ModelClass = this as unknown as typeof BaseModel;
|
|
1296
|
+
ModelClass._eagerLoad(instances, includeList);
|
|
1297
|
+
return Promise.resolve();
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1263
1300
|
/**
|
|
1264
1301
|
* Clear the relationship cache.
|
|
1265
1302
|
*/
|
|
@@ -95,7 +95,12 @@ export function parseDatabaseUrl(url: string, username?: string, password?: stri
|
|
|
95
95
|
// Handle sqlite:// separately because URL class mangles the path
|
|
96
96
|
if (url.startsWith("sqlite:///")) {
|
|
97
97
|
// sqlite:///absolute/path — three slashes means absolute
|
|
98
|
-
|
|
98
|
+
let path = url.slice("sqlite://".length);
|
|
99
|
+
// Windows: sqlite:///C:/Users/app.db → /C:/Users/app.db after slicing.
|
|
100
|
+
// The leading / before the drive letter must be removed.
|
|
101
|
+
if (/^\/[A-Za-z]:/.test(path)) {
|
|
102
|
+
path = path.slice(1);
|
|
103
|
+
}
|
|
99
104
|
result = { type: "sqlite", path };
|
|
100
105
|
} else if (url.startsWith("sqlite://")) {
|
|
101
106
|
// sqlite://./relative or sqlite://relative
|
|
@@ -223,7 +228,7 @@ export class Database {
|
|
|
223
228
|
private pool: (DatabaseAdapter | null)[] = [];
|
|
224
229
|
|
|
225
230
|
/** Pool size (0 = single connection) */
|
|
226
|
-
private
|
|
231
|
+
private _poolSize: number = 0;
|
|
227
232
|
|
|
228
233
|
/** Round-robin index */
|
|
229
234
|
private poolIndex: number = 0;
|
|
@@ -270,7 +275,7 @@ export class Database {
|
|
|
270
275
|
setAdapter(adapters[0]);
|
|
271
276
|
|
|
272
277
|
const db = new Database(adapters[0]);
|
|
273
|
-
db.
|
|
278
|
+
db._poolSize = pool;
|
|
274
279
|
db.pool = adapters;
|
|
275
280
|
db.poolIndex = 0;
|
|
276
281
|
db.adapter = null; // Don't use single-adapter path
|
|
@@ -304,9 +309,9 @@ export class Database {
|
|
|
304
309
|
* Get the next adapter — from pool (round-robin) or single connection.
|
|
305
310
|
*/
|
|
306
311
|
private getNextAdapter(): DatabaseAdapter {
|
|
307
|
-
if (this.
|
|
312
|
+
if (this._poolSize > 0) {
|
|
308
313
|
const idx = this.poolIndex;
|
|
309
|
-
this.poolIndex = (this.poolIndex + 1) % this.
|
|
314
|
+
this.poolIndex = (this.poolIndex + 1) % this._poolSize;
|
|
310
315
|
return this.pool[idx] as DatabaseAdapter;
|
|
311
316
|
}
|
|
312
317
|
|
|
@@ -319,16 +324,46 @@ export class Database {
|
|
|
319
324
|
}
|
|
320
325
|
|
|
321
326
|
/** Get the pool size (0 = single connection mode). */
|
|
322
|
-
|
|
323
|
-
return this.
|
|
327
|
+
poolSize(): number {
|
|
328
|
+
return this._poolSize;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Alias for poolSize() — returns total pool size (0 = single connection mode). */
|
|
332
|
+
size(): number {
|
|
333
|
+
return this._poolSize;
|
|
324
334
|
}
|
|
325
335
|
|
|
326
336
|
/** Get the number of active (created) connections in the pool. */
|
|
327
|
-
|
|
328
|
-
if (this.
|
|
337
|
+
activeCount(): number {
|
|
338
|
+
if (this._poolSize === 0) return this.adapter ? 1 : 0;
|
|
329
339
|
return this.pool.filter(a => a !== null).length;
|
|
330
340
|
}
|
|
331
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Borrow a connection from the pool (or the single adapter).
|
|
344
|
+
* The caller is responsible for returning it via checkin().
|
|
345
|
+
*/
|
|
346
|
+
checkout(): DatabaseAdapter {
|
|
347
|
+
return this.getNextAdapter();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Return a borrowed connection to the pool.
|
|
352
|
+
* For round-robin pools this is a no-op (connections stay in the pool array),
|
|
353
|
+
* but the method exists for API parity and future pooling strategies.
|
|
354
|
+
*/
|
|
355
|
+
checkin(_adapter: DatabaseAdapter): void {
|
|
356
|
+
// No-op for round-robin pool — connections are not removed on checkout.
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Close all pooled connections and clear the pool.
|
|
361
|
+
* Equivalent to close() but named for explicit pool teardown.
|
|
362
|
+
*/
|
|
363
|
+
closeAll(): void {
|
|
364
|
+
this.close();
|
|
365
|
+
}
|
|
366
|
+
|
|
332
367
|
/** Query rows with optional pagination. Returns a DatabaseResult wrapper. */
|
|
333
368
|
fetch(sql: string, params?: unknown[], limit?: number, offset?: number): DatabaseResult {
|
|
334
369
|
const adapter = this.getNextAdapter();
|
|
@@ -376,9 +411,9 @@ export class Database {
|
|
|
376
411
|
}
|
|
377
412
|
|
|
378
413
|
/** Update rows in a table matching filter. */
|
|
379
|
-
update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown
|
|
414
|
+
update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown>, params?: unknown[]): DatabaseWriteResult {
|
|
380
415
|
const adapter = this.getNextAdapter();
|
|
381
|
-
const result = adapter.update(table, data, filter ?? {});
|
|
416
|
+
const result = adapter.update(table, data, filter ?? {}, params);
|
|
382
417
|
if (this.autoCommit) {
|
|
383
418
|
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
384
419
|
}
|
|
@@ -386,9 +421,9 @@ export class Database {
|
|
|
386
421
|
}
|
|
387
422
|
|
|
388
423
|
/** Delete rows from a table matching filter. */
|
|
389
|
-
delete(table: string, filter?: Record<string, unknown
|
|
424
|
+
delete(table: string, filter?: Record<string, unknown>, params?: unknown[]): DatabaseWriteResult {
|
|
390
425
|
const adapter = this.getNextAdapter();
|
|
391
|
-
const result = adapter.delete(table, filter ?? {});
|
|
426
|
+
const result = adapter.delete(table, filter ?? {}, params);
|
|
392
427
|
if (this.autoCommit) {
|
|
393
428
|
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
394
429
|
}
|
|
@@ -397,7 +432,7 @@ export class Database {
|
|
|
397
432
|
|
|
398
433
|
/** Close all database connections (pool or single). */
|
|
399
434
|
close(): void {
|
|
400
|
-
if (this.
|
|
435
|
+
if (this._poolSize > 0) {
|
|
401
436
|
for (let i = 0; i < this.pool.length; i++) {
|
|
402
437
|
if (this.pool[i] !== null) {
|
|
403
438
|
this.pool[i]!.close();
|
|
@@ -454,7 +489,7 @@ export class Database {
|
|
|
454
489
|
* @param paramSets - Array of parameter arrays, one per execution.
|
|
455
490
|
* @returns Array of results from each execution.
|
|
456
491
|
*/
|
|
457
|
-
executeMany(sql: string, paramSets: unknown[][]): unknown[] {
|
|
492
|
+
executeMany(sql: string, paramSets: unknown[][] = []): unknown[] {
|
|
458
493
|
const adapter = this.getNextAdapter();
|
|
459
494
|
const results: unknown[] = [];
|
|
460
495
|
|
|
@@ -486,6 +521,14 @@ export class Database {
|
|
|
486
521
|
};
|
|
487
522
|
}
|
|
488
523
|
|
|
524
|
+
/** Clear the query result cache. */
|
|
525
|
+
cacheClear(): void {
|
|
526
|
+
// Node database layer does not maintain an internal query cache at this
|
|
527
|
+
// level (caching lives in the SQLTranslation layer). This method exists
|
|
528
|
+
// for API parity with PHP, Python, and Ruby.
|
|
529
|
+
// To clear the SQLTranslation query cache use: QueryCache.clear()
|
|
530
|
+
}
|
|
531
|
+
|
|
489
532
|
/** Get the last auto-increment id. */
|
|
490
533
|
getLastId(): string | number {
|
|
491
534
|
const id = this.getNextAdapter().lastInsertId();
|
|
@@ -14,16 +14,6 @@ export class FakeData extends CoreFakeData {
|
|
|
14
14
|
super(seed);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** Alias for fullName() to match the Python API. */
|
|
18
|
-
name(): string {
|
|
19
|
-
return this.fullName();
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Alias for float() to match the Python API. */
|
|
23
|
-
numeric(min = 0, max = 1000, decimals = 2): number {
|
|
24
|
-
return this.float(min, max, decimals);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
17
|
/**
|
|
28
18
|
* Generate a Date object within a year range.
|
|
29
19
|
* Matches the Python API's datetime() method.
|
|
@@ -69,7 +59,7 @@ export class FakeData extends CoreFakeData {
|
|
|
69
59
|
if (col.includes("company") || col.includes("org")) return this.company();
|
|
70
60
|
if (col.includes("job") || col.includes("title") || col.includes("position")) return this.jobTitle();
|
|
71
61
|
if (col.includes("url") || col.includes("website") || col.includes("link")) return this.url();
|
|
72
|
-
if (col.includes("color") || col.includes("colour")) return this.
|
|
62
|
+
if (col.includes("color") || col.includes("colour")) return this.colorHex();
|
|
73
63
|
if (col.includes("uuid") || col === "guid") return this.uuid();
|
|
74
64
|
if (col === "ip" || col === "ip_address" || col === "ipaddress") return this.ipAddress();
|
|
75
65
|
if (col.includes("currency")) return this.currency();
|
|
@@ -191,7 +191,7 @@ export function isMigrationApplied(name: string): boolean {
|
|
|
191
191
|
/**
|
|
192
192
|
* Record a migration as applied.
|
|
193
193
|
*/
|
|
194
|
-
export function recordMigration(name: string, batch: number): void {
|
|
194
|
+
export function recordMigration(name: string, batch: number, passed: number = 1): void {
|
|
195
195
|
const adapter = getAdapter();
|
|
196
196
|
if (isFirebirdAdapter(adapter)) {
|
|
197
197
|
// Firebird: generate ID from sequence
|
|
@@ -746,6 +746,66 @@ export async function createMigration(
|
|
|
746
746
|
return { upPath, downPath };
|
|
747
747
|
}
|
|
748
748
|
|
|
749
|
+
/**
|
|
750
|
+
* Create a new TypeScript class-based migration file with a timestamp prefix.
|
|
751
|
+
*
|
|
752
|
+
* @param description - Human-readable description (used in filename and class name).
|
|
753
|
+
* @param options - Optional configuration.
|
|
754
|
+
* @returns Path to the created file.
|
|
755
|
+
*/
|
|
756
|
+
export async function createClassMigration(
|
|
757
|
+
description: string,
|
|
758
|
+
options?: { migrationsDir?: string },
|
|
759
|
+
): Promise<string> {
|
|
760
|
+
const dir = resolve(options?.migrationsDir ?? "migrations");
|
|
761
|
+
|
|
762
|
+
if (!existsSync(dir)) {
|
|
763
|
+
mkdirSync(dir, { recursive: true });
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const safeName = description
|
|
767
|
+
.toLowerCase()
|
|
768
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
769
|
+
.replace(/^_|_$/g, "");
|
|
770
|
+
|
|
771
|
+
// Derive PascalCase class name
|
|
772
|
+
const className = description
|
|
773
|
+
.replace(/[^a-zA-Z0-9 ]+/g, " ")
|
|
774
|
+
.trim()
|
|
775
|
+
.split(/\s+/)
|
|
776
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
777
|
+
.join("");
|
|
778
|
+
|
|
779
|
+
const now = new Date();
|
|
780
|
+
const timestamp = [
|
|
781
|
+
now.getFullYear(),
|
|
782
|
+
String(now.getMonth() + 1).padStart(2, "0"),
|
|
783
|
+
String(now.getDate()).padStart(2, "0"),
|
|
784
|
+
String(now.getHours()).padStart(2, "0"),
|
|
785
|
+
String(now.getMinutes()).padStart(2, "0"),
|
|
786
|
+
String(now.getSeconds()).padStart(2, "0"),
|
|
787
|
+
].join("");
|
|
788
|
+
|
|
789
|
+
const fileName = `${timestamp}_${safeName}.ts`;
|
|
790
|
+
const filePath = join(dir, fileName);
|
|
791
|
+
|
|
792
|
+
const content =
|
|
793
|
+
`// Migration: ${description}\n` +
|
|
794
|
+
`// Created: ${now.toISOString()}\n\n` +
|
|
795
|
+
`import type { DatabaseAdapter } from "@tina4/orm";\n\n` +
|
|
796
|
+
`export class ${className} {\n` +
|
|
797
|
+
` async up(db: DatabaseAdapter): Promise<void> {\n` +
|
|
798
|
+
` // db.execute("CREATE TABLE ...");\n` +
|
|
799
|
+
` }\n\n` +
|
|
800
|
+
` async down(db: DatabaseAdapter): Promise<void> {\n` +
|
|
801
|
+
` // db.execute("DROP TABLE IF EXISTS ...");\n` +
|
|
802
|
+
` }\n` +
|
|
803
|
+
`}\n`;
|
|
804
|
+
|
|
805
|
+
writeFileSync(filePath, content, "utf-8");
|
|
806
|
+
return filePath;
|
|
807
|
+
}
|
|
808
|
+
|
|
749
809
|
/**
|
|
750
810
|
* Object-oriented Migration class — canonical Tina4 Migration API.
|
|
751
811
|
*
|
|
@@ -800,8 +860,18 @@ export class Migration {
|
|
|
800
860
|
return status(this.db, { migrationsDir: this.dir });
|
|
801
861
|
}
|
|
802
862
|
|
|
803
|
-
/**
|
|
804
|
-
|
|
863
|
+
/**
|
|
864
|
+
* Scaffold a new migration file.
|
|
865
|
+
*
|
|
866
|
+
* kind="sql" — creates {timestamp}_{description}.sql + .down.sql (default)
|
|
867
|
+
* kind="class" — creates {timestamp}_{description}.ts with a TypeScript class template
|
|
868
|
+
*
|
|
869
|
+
* Returns the path to the created up file (or class file).
|
|
870
|
+
*/
|
|
871
|
+
async create(description: string, kind: "sql" | "class" = "sql"): Promise<string | { upPath: string; downPath: string }> {
|
|
872
|
+
if (kind === "class") {
|
|
873
|
+
return createClassMigration(description, { migrationsDir: this.dir });
|
|
874
|
+
}
|
|
805
875
|
return createMigration(description, { migrationsDir: this.dir });
|
|
806
876
|
}
|
|
807
877
|
|
|
@@ -163,6 +163,7 @@ export class SQLTranslator {
|
|
|
163
163
|
interface CacheEntry<T> {
|
|
164
164
|
value: T;
|
|
165
165
|
expiresAt: number;
|
|
166
|
+
tags: string[];
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
/**
|
|
@@ -201,11 +202,12 @@ export class QueryCache {
|
|
|
201
202
|
}
|
|
202
203
|
|
|
203
204
|
/**
|
|
204
|
-
* Set a cached value with optional TTL (seconds)
|
|
205
|
+
* Set a cached value with optional TTL (seconds) and tags for grouped
|
|
206
|
+
* invalidation via clearTag().
|
|
205
207
|
*/
|
|
206
|
-
set<T>(key: string, value: T, ttl?: number): void {
|
|
208
|
+
set<T>(key: string, value: T, ttl?: number, tags: string[] = []): void {
|
|
207
209
|
// Evict oldest entry if at max size
|
|
208
|
-
if (this.store.size >= this.maxSize) {
|
|
210
|
+
if (this.store.size >= this.maxSize && !this.store.has(key)) {
|
|
209
211
|
const firstKey = this.store.keys().next().value;
|
|
210
212
|
if (firstKey !== undefined) this.store.delete(firstKey);
|
|
211
213
|
}
|
|
@@ -213,9 +215,24 @@ export class QueryCache {
|
|
|
213
215
|
this.store.set(key, {
|
|
214
216
|
value,
|
|
215
217
|
expiresAt: Date.now() + (ttl ?? this.defaultTtl) * 1000,
|
|
218
|
+
tags,
|
|
216
219
|
});
|
|
217
220
|
}
|
|
218
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Remove all entries that carry the given tag. Returns the number removed.
|
|
224
|
+
*/
|
|
225
|
+
clearTag(tag: string): number {
|
|
226
|
+
let removed = 0;
|
|
227
|
+
for (const [key, entry] of this.store) {
|
|
228
|
+
if (entry.tags.includes(tag)) {
|
|
229
|
+
this.store.delete(key);
|
|
230
|
+
removed++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return removed;
|
|
234
|
+
}
|
|
235
|
+
|
|
219
236
|
/**
|
|
220
237
|
* Check if a key exists and is not expired.
|
|
221
238
|
*/
|
|
@@ -69,10 +69,10 @@ export interface DatabaseAdapter {
|
|
|
69
69
|
insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult;
|
|
70
70
|
|
|
71
71
|
/** Update rows in a table matching filter, returns affected row count. */
|
|
72
|
-
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown
|
|
72
|
+
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult;
|
|
73
73
|
|
|
74
74
|
/** Delete rows from a table matching filter (object, string WHERE, or array of objects). */
|
|
75
|
-
delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[]): DatabaseResult;
|
|
75
|
+
delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): DatabaseResult;
|
|
76
76
|
|
|
77
77
|
/** Start a transaction. */
|
|
78
78
|
startTransaction(): void;
|