tina4-nodejs 3.10.41 → 3.10.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -3
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/cli/src/bin.ts +48 -4
- package/packages/cli/src/commands/generate.ts +800 -103
- package/packages/cli/src/commands/serve.ts +1 -0
- package/packages/core/src/ai.ts +488 -108
- package/packages/core/src/devAdmin.ts +643 -98
- package/packages/core/src/index.ts +3 -2
- package/packages/core/src/metrics.ts +52 -0
- package/packages/core/src/server.ts +40 -5
- package/packages/core/src/testClient.ts +187 -0
- package/packages/orm/src/adapters/sqlite.ts +7 -3
- package/packages/orm/src/baseModel.ts +17 -5
|
@@ -12,7 +12,7 @@ export type {
|
|
|
12
12
|
WebSocketRouteDefinition,
|
|
13
13
|
} from "./types.js";
|
|
14
14
|
|
|
15
|
-
export { startServer, resolvePortAndHost } from "./server.js";
|
|
15
|
+
export { startServer, resolvePortAndHost, handle } from "./server.js";
|
|
16
16
|
export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
17
17
|
export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
|
|
18
18
|
export type { RouteInfo } from "./router.js";
|
|
@@ -68,7 +68,7 @@ export type { ResponseCacheConfig } from "./cache.js";
|
|
|
68
68
|
export { Api } from "./api.js";
|
|
69
69
|
export type { ApiResult } from "./api.js";
|
|
70
70
|
export { Events } from "./events.js";
|
|
71
|
-
export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker } from "./devAdmin.js";
|
|
71
|
+
export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, renderDashboard } from "./devAdmin.js";
|
|
72
72
|
export { Messenger } from "./messenger.js";
|
|
73
73
|
export type { SendResult, EmailMessage } from "./messenger.js";
|
|
74
74
|
export { DevMailbox, createMessenger } from "./devMailbox.js";
|
|
@@ -94,6 +94,7 @@ export type { ValkeySessionConfig } from "./sessionHandlers/valkeyHandler.js";
|
|
|
94
94
|
export { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
|
|
95
95
|
export type { RedisNpmSessionConfig } from "./sessionHandlers/redisHandler.js";
|
|
96
96
|
export { tests, assertEqual, assertThrows, assertTrue, assertFalse, runAllTests, resetTests } from "./testing.js";
|
|
97
|
+
export { TestClient, TestResponse } from "./testClient.js";
|
|
97
98
|
export { Container, container } from "./container.js";
|
|
98
99
|
export { Validator } from "./validator.js";
|
|
99
100
|
export type { ValidationError } from "./validator.js";
|
|
@@ -54,6 +54,18 @@ function relativePath(filePath: string): string {
|
|
|
54
54
|
return path.relative(".", filePath);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// ── Test file detection ─────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function hasMatchingTest(relPath: string): boolean {
|
|
60
|
+
const name = relPath.split('/').pop()?.replace('.ts', '').replace('.js', '') || '';
|
|
61
|
+
const patterns = [
|
|
62
|
+
`test/${name}.test.ts`,
|
|
63
|
+
`${relPath.replace('.ts', '.test.ts').replace('.js', '.test.js')}`,
|
|
64
|
+
`tests/${name}.test.ts`,
|
|
65
|
+
];
|
|
66
|
+
return patterns.some(p => fs.existsSync(p));
|
|
67
|
+
}
|
|
68
|
+
|
|
57
69
|
// ── Line counting ────────────────────────────────────────────
|
|
58
70
|
|
|
59
71
|
interface LineCounts {
|
|
@@ -517,9 +529,27 @@ function detectViolations(
|
|
|
517
529
|
return violations;
|
|
518
530
|
}
|
|
519
531
|
|
|
532
|
+
// ── Root Resolution ──────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Pick the right directory to scan.
|
|
536
|
+
*
|
|
537
|
+
* If the root dir has .ts files, scan the user's project code.
|
|
538
|
+
* Otherwise, scan the framework itself — so the bubble chart is never empty.
|
|
539
|
+
*/
|
|
540
|
+
function resolveRoot(root: string = "src"): string {
|
|
541
|
+
const rootPath = path.resolve(root);
|
|
542
|
+
if (fs.existsSync(rootPath) && walkFiles(rootPath, [".ts", ".js"]).length > 0) {
|
|
543
|
+
return root;
|
|
544
|
+
}
|
|
545
|
+
// Fallback: scan the framework package itself
|
|
546
|
+
return path.resolve(path.dirname(new URL(import.meta.url).pathname));
|
|
547
|
+
}
|
|
548
|
+
|
|
520
549
|
// ── Quick Metrics ────────────────────────────────────────────
|
|
521
550
|
|
|
522
551
|
export function quickMetrics(root: string = "src"): Record<string, any> {
|
|
552
|
+
root = resolveRoot(root);
|
|
523
553
|
const rootPath = path.resolve(root);
|
|
524
554
|
if (!fs.existsSync(rootPath)) {
|
|
525
555
|
return { error: `Directory not found: ${root}` };
|
|
@@ -644,6 +674,7 @@ function filesHash(root: string = "src"): string {
|
|
|
644
674
|
}
|
|
645
675
|
|
|
646
676
|
export function fullAnalysis(root: string = "src"): Record<string, any> {
|
|
677
|
+
root = resolveRoot(root);
|
|
647
678
|
const currentHash = filesHash(root);
|
|
648
679
|
const now = Date.now() / 1000;
|
|
649
680
|
|
|
@@ -730,6 +761,8 @@ export function fullAnalysis(root: string = "src"): Record<string, any> {
|
|
|
730
761
|
coupling_afferent: ca,
|
|
731
762
|
coupling_efferent: ce,
|
|
732
763
|
instability: Math.round(instability * 1000) / 1000,
|
|
764
|
+
has_tests: hasMatchingTest(relPath),
|
|
765
|
+
dep_count: ce,
|
|
733
766
|
});
|
|
734
767
|
}
|
|
735
768
|
|
|
@@ -746,6 +779,10 @@ export function fullAnalysis(root: string = "src"): Record<string, any> {
|
|
|
746
779
|
const totalMI = fileMetrics.reduce((sum, f) => sum + f.maintainability, 0);
|
|
747
780
|
const avgMI = fileMetrics.length > 0 ? totalMI / fileMetrics.length : 0;
|
|
748
781
|
|
|
782
|
+
// Detect if we're scanning framework or project
|
|
783
|
+
const frameworkDir = path.resolve(path.dirname(new URL(import.meta.url).pathname));
|
|
784
|
+
const scanningFramework = rootPath === frameworkDir || rootPath.startsWith(frameworkDir + path.sep);
|
|
785
|
+
|
|
749
786
|
const result: Record<string, any> = {
|
|
750
787
|
files_analyzed: fileMetrics.length,
|
|
751
788
|
total_functions: allFunctions.length,
|
|
@@ -755,6 +792,8 @@ export function fullAnalysis(root: string = "src"): Record<string, any> {
|
|
|
755
792
|
file_metrics: fileMetrics,
|
|
756
793
|
violations,
|
|
757
794
|
dependency_graph: importGraph,
|
|
795
|
+
scan_mode: scanningFramework ? "framework" : "project",
|
|
796
|
+
scan_root: rootPath,
|
|
758
797
|
};
|
|
759
798
|
|
|
760
799
|
_fullCache = { hash: currentHash, data: result, time: now };
|
|
@@ -789,6 +828,18 @@ export function fileDetail(filePath: string): Record<string, any> {
|
|
|
789
828
|
// Remove file field from function info for single-file detail
|
|
790
829
|
const cleanFunctions = functions.map(({ file, ...rest }) => rest);
|
|
791
830
|
|
|
831
|
+
// Detect empty methods/functions (loc <= 1 means only a brace or pass-through)
|
|
832
|
+
const warnings: { type: string; message: string; line: number }[] = [];
|
|
833
|
+
for (const fn of cleanFunctions) {
|
|
834
|
+
if (fn.loc <= 1) {
|
|
835
|
+
warnings.push({
|
|
836
|
+
type: "empty_method",
|
|
837
|
+
message: `Method '${fn.name}' appears to be empty`,
|
|
838
|
+
line: fn.line,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
792
843
|
return {
|
|
793
844
|
path: filePath,
|
|
794
845
|
loc,
|
|
@@ -796,5 +847,6 @@ export function fileDetail(filePath: string): Record<string, any> {
|
|
|
796
847
|
classes,
|
|
797
848
|
functions: cleanFunctions,
|
|
798
849
|
imports,
|
|
850
|
+
warnings,
|
|
799
851
|
};
|
|
800
852
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
1
|
+
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { resolve, dirname, join, relative } from "node:path";
|
|
3
3
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { isatty } from "node:tty";
|
|
@@ -302,6 +302,8 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
|
|
|
302
302
|
.gbtn-deploy{background:#3b82f6;color:#fff}
|
|
303
303
|
.gbtn-deploy:hover{background:#2563eb}
|
|
304
304
|
.gbtn-deployed{background:transparent;border:1px solid #22c55e;color:#22c55e;cursor:default;font-size:0.7rem}
|
|
305
|
+
@keyframes wiggle{0%{transform:rotate(0deg)}15%{transform:rotate(14deg)}30%{transform:rotate(-10deg)}45%{transform:rotate(8deg)}60%{transform:rotate(-4deg)}75%{transform:rotate(2deg)}100%{transform:rotate(0deg)}}
|
|
306
|
+
.star-wiggle{display:inline-block;transform-origin:center}
|
|
305
307
|
</style>
|
|
306
308
|
</head>
|
|
307
309
|
<body>
|
|
@@ -315,7 +317,7 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
|
|
|
315
317
|
<a href="/__dev" class="btn">Dev Admin</a>
|
|
316
318
|
<a href="#gallery" class="btn">Gallery</a>
|
|
317
319
|
<a href="https://github.com/tina4stack/tina4-nodejs" class="btn" target="_blank">GitHub</a>
|
|
318
|
-
<a href="https://github.com/tina4stack/tina4-nodejs/stargazers" class="btn" target="_blank">&#
|
|
320
|
+
<a href="https://github.com/tina4stack/tina4-nodejs/stargazers" class="btn" target="_blank"><span class="star-wiggle">☆</span> Star</a>
|
|
319
321
|
</div>
|
|
320
322
|
<div class="status">
|
|
321
323
|
<span><span class="dot"></span>Server running</span>
|
|
@@ -364,11 +366,39 @@ function deployGallery(name) {
|
|
|
364
366
|
})
|
|
365
367
|
.catch(function(e) { alert('Deploy error: ' + e.message); });
|
|
366
368
|
}
|
|
369
|
+
(function(){
|
|
370
|
+
var star=document.querySelector('.star-wiggle');
|
|
371
|
+
if(!star)return;
|
|
372
|
+
function doWiggle(){
|
|
373
|
+
star.style.animation='wiggle 1.2s ease-in-out';
|
|
374
|
+
star.addEventListener('animationend',function onEnd(){
|
|
375
|
+
star.removeEventListener('animationend',onEnd);
|
|
376
|
+
star.style.animation='none';
|
|
377
|
+
var delay=3000+Math.random()*15000;
|
|
378
|
+
setTimeout(doWiggle,delay);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
setTimeout(doWiggle,3000);
|
|
382
|
+
})();
|
|
367
383
|
</script>
|
|
368
384
|
</body>
|
|
369
385
|
</html>`;
|
|
370
386
|
}
|
|
371
387
|
|
|
388
|
+
// Module-level dispatch function — assigned when startServer() is called.
|
|
389
|
+
// Allows handle() to route requests without requiring a reference to the server.
|
|
390
|
+
let _dispatchFn: ((rawReq: IncomingMessage, rawRes: ServerResponse) => Promise<void>) | null = null;
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Dispatch a raw Node.js request through the Tina4 router and write the response.
|
|
394
|
+
* Requires startServer() to have been called first.
|
|
395
|
+
* Useful for testing and embedding.
|
|
396
|
+
*/
|
|
397
|
+
export async function handle(rawReq: IncomingMessage, rawRes: ServerResponse): Promise<void> {
|
|
398
|
+
if (!_dispatchFn) throw new Error("Tina4 server not started — call startServer() first");
|
|
399
|
+
return _dispatchFn(rawReq, rawRes);
|
|
400
|
+
}
|
|
401
|
+
|
|
372
402
|
export async function startServer(config?: Tina4Config): Promise<{
|
|
373
403
|
close: () => void;
|
|
374
404
|
router: Router;
|
|
@@ -578,7 +608,7 @@ ${reset}
|
|
|
578
608
|
console.log(` Dev dashboard at \x1b[36mhttp://localhost:${port}/__dev\x1b[0m`);
|
|
579
609
|
}
|
|
580
610
|
|
|
581
|
-
|
|
611
|
+
async function dispatch(rawReq: IncomingMessage, rawRes: ServerResponse): Promise<void> {
|
|
582
612
|
const req = createRequest(rawReq);
|
|
583
613
|
const res = createResponse(rawRes);
|
|
584
614
|
|
|
@@ -717,7 +747,7 @@ ${reset}
|
|
|
717
747
|
let result: unknown;
|
|
718
748
|
const routeParams = req.params || {};
|
|
719
749
|
const fnStr = match.handler.toString();
|
|
720
|
-
const argMatch = fnStr.match(/^(?:async\s
|
|
750
|
+
const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
|
|
721
751
|
const argNames = argMatch?.[1]?.split(",").map((s: string) => s.trim().replace(/[:=].*/,"")) ?? [];
|
|
722
752
|
const filteredArgs = argNames.filter((n: string) => n.length > 0);
|
|
723
753
|
|
|
@@ -808,7 +838,12 @@ ${reset}
|
|
|
808
838
|
}
|
|
809
839
|
}
|
|
810
840
|
}
|
|
811
|
-
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Assign to module-level so handle() can dispatch without a server reference
|
|
844
|
+
_dispatchFn = dispatch;
|
|
845
|
+
|
|
846
|
+
const server = createServer(dispatch);
|
|
812
847
|
|
|
813
848
|
return new Promise((resolvePromise) => {
|
|
814
849
|
server.listen(port, host, () => {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 Test Client — Test routes without starting a server.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
*
|
|
6
|
+
* import { TestClient } from "@tina4/core";
|
|
7
|
+
*
|
|
8
|
+
* const client = new TestClient(router);
|
|
9
|
+
*
|
|
10
|
+
* const response = await client.get("/api/users");
|
|
11
|
+
* assert(response.status === 200);
|
|
12
|
+
* assert(response.json().users);
|
|
13
|
+
*
|
|
14
|
+
* const response = await client.post("/api/users", { json: { name: "Alice" } });
|
|
15
|
+
* assert(response.status === 201);
|
|
16
|
+
*/
|
|
17
|
+
import { IncomingMessage, ServerResponse } from "node:http";
|
|
18
|
+
import { Socket } from "node:net";
|
|
19
|
+
import { createRequest, parseBody } from "./request.js";
|
|
20
|
+
import { createResponse } from "./response.js";
|
|
21
|
+
import { defaultRouter, type Router } from "./router.js";
|
|
22
|
+
|
|
23
|
+
export class TestResponse {
|
|
24
|
+
public readonly status: number;
|
|
25
|
+
public readonly body: string;
|
|
26
|
+
public readonly headers: Record<string, string>;
|
|
27
|
+
public readonly contentType: string;
|
|
28
|
+
|
|
29
|
+
constructor(statusCode: number, headers: Record<string, string>, body: string) {
|
|
30
|
+
this.status = statusCode;
|
|
31
|
+
this.body = body;
|
|
32
|
+
this.headers = headers;
|
|
33
|
+
this.contentType = headers["content-type"] ?? "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Parse body as JSON. */
|
|
37
|
+
json(): unknown {
|
|
38
|
+
if (!this.body) return null;
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(this.body);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Return body as a string. */
|
|
47
|
+
text(): string {
|
|
48
|
+
return this.body;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
toString(): string {
|
|
52
|
+
return `<TestResponse status=${this.status} contentType="${this.contentType}">`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface RequestOptions {
|
|
57
|
+
json?: Record<string, unknown> | unknown[];
|
|
58
|
+
body?: string;
|
|
59
|
+
headers?: Record<string, string>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class TestClient {
|
|
63
|
+
private router: Router;
|
|
64
|
+
|
|
65
|
+
constructor(router?: Router) {
|
|
66
|
+
this.router = router ?? defaultRouter;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Send a GET request. */
|
|
70
|
+
async get(path: string, options?: RequestOptions): Promise<TestResponse> {
|
|
71
|
+
return this._request("GET", path, options);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Send a POST request. */
|
|
75
|
+
async post(path: string, options?: RequestOptions): Promise<TestResponse> {
|
|
76
|
+
return this._request("POST", path, options);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Send a PUT request. */
|
|
80
|
+
async put(path: string, options?: RequestOptions): Promise<TestResponse> {
|
|
81
|
+
return this._request("PUT", path, options);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Send a PATCH request. */
|
|
85
|
+
async patch(path: string, options?: RequestOptions): Promise<TestResponse> {
|
|
86
|
+
return this._request("PATCH", path, options);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Send a DELETE request. */
|
|
90
|
+
async delete(path: string, options?: RequestOptions): Promise<TestResponse> {
|
|
91
|
+
return this._request("DELETE", path, options);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Build a mock request, match the route, execute the handler. */
|
|
95
|
+
private async _request(method: string, path: string, options?: RequestOptions): Promise<TestResponse> {
|
|
96
|
+
const { json, body, headers } = options ?? {};
|
|
97
|
+
|
|
98
|
+
// Build raw body
|
|
99
|
+
let rawBody = "";
|
|
100
|
+
let contentType = "";
|
|
101
|
+
if (json !== undefined) {
|
|
102
|
+
rawBody = JSON.stringify(json);
|
|
103
|
+
contentType = "application/json";
|
|
104
|
+
} else if (body !== undefined) {
|
|
105
|
+
rawBody = body;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Build headers
|
|
109
|
+
const reqHeaders: Record<string, string> = {};
|
|
110
|
+
if (headers) {
|
|
111
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
112
|
+
reqHeaders[k.toLowerCase()] = v;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (contentType && !reqHeaders["content-type"]) {
|
|
116
|
+
reqHeaders["content-type"] = contentType;
|
|
117
|
+
}
|
|
118
|
+
if (rawBody && !reqHeaders["content-length"]) {
|
|
119
|
+
reqHeaders["content-length"] = String(Buffer.byteLength(rawBody));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Create a mock IncomingMessage
|
|
123
|
+
const socket = new Socket();
|
|
124
|
+
const rawReq = new IncomingMessage(socket);
|
|
125
|
+
rawReq.method = method.toUpperCase();
|
|
126
|
+
rawReq.url = path;
|
|
127
|
+
rawReq.headers = { ...reqHeaders, host: "localhost:7145" };
|
|
128
|
+
|
|
129
|
+
// Push body data into the readable stream
|
|
130
|
+
if (rawBody) {
|
|
131
|
+
rawReq.push(Buffer.from(rawBody));
|
|
132
|
+
}
|
|
133
|
+
rawReq.push(null); // signal end of stream
|
|
134
|
+
|
|
135
|
+
// Create a mock ServerResponse that captures output
|
|
136
|
+
const rawRes = new ServerResponse(rawReq);
|
|
137
|
+
const chunks: Buffer[] = [];
|
|
138
|
+
const originalWrite = rawRes.write.bind(rawRes);
|
|
139
|
+
const originalEnd = rawRes.end.bind(rawRes);
|
|
140
|
+
|
|
141
|
+
rawRes.write = function (chunk: any, ...args: any[]): boolean {
|
|
142
|
+
if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
143
|
+
return true;
|
|
144
|
+
} as typeof rawRes.write;
|
|
145
|
+
|
|
146
|
+
rawRes.end = function (chunk?: any, ...args: any[]): ServerResponse {
|
|
147
|
+
if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
148
|
+
return rawRes;
|
|
149
|
+
} as typeof rawRes.end;
|
|
150
|
+
|
|
151
|
+
// Create Tina4 request/response wrappers
|
|
152
|
+
const req = createRequest(rawReq);
|
|
153
|
+
const res = createResponse(rawRes);
|
|
154
|
+
|
|
155
|
+
// Parse body (populates req.body)
|
|
156
|
+
await parseBody(req);
|
|
157
|
+
|
|
158
|
+
// Split path for route matching
|
|
159
|
+
const cleanPath = path.includes("?") ? path.split("?")[0] : path;
|
|
160
|
+
|
|
161
|
+
// Match route
|
|
162
|
+
const match = this.router.match(method.toUpperCase(), cleanPath);
|
|
163
|
+
if (!match) {
|
|
164
|
+
return new TestResponse(404, { "content-type": "application/json" }, '{"error":"Not found"}');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Inject route params
|
|
168
|
+
req.params = match.params;
|
|
169
|
+
|
|
170
|
+
// Execute handler
|
|
171
|
+
await match.handler(req, res);
|
|
172
|
+
|
|
173
|
+
// Collect response
|
|
174
|
+
const responseBody = Buffer.concat(chunks).toString();
|
|
175
|
+
const responseHeaders: Record<string, string> = {};
|
|
176
|
+
for (const [name, value] of Object.entries(rawRes.getHeaders())) {
|
|
177
|
+
if (value !== undefined) {
|
|
178
|
+
responseHeaders[name] = Array.isArray(value) ? value.join(", ") : String(value);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Clean up the socket
|
|
183
|
+
socket.destroy();
|
|
184
|
+
|
|
185
|
+
return new TestResponse(rawRes.statusCode, responseHeaders, responseBody);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -57,9 +57,13 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
57
57
|
fetch<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): T[] {
|
|
58
58
|
let effectiveSql = sql;
|
|
59
59
|
if (limit !== undefined) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// Skip appending LIMIT when the SQL already contains one (dedup)
|
|
61
|
+
const sqlBeforeComment = sql.toUpperCase().split("--")[0];
|
|
62
|
+
if (!sqlBeforeComment.includes("LIMIT")) {
|
|
63
|
+
effectiveSql += ` LIMIT ${limit}`;
|
|
64
|
+
if (skip !== undefined && skip > 0) {
|
|
65
|
+
effectiveSql += ` OFFSET ${skip}`;
|
|
66
|
+
}
|
|
63
67
|
}
|
|
64
68
|
}
|
|
65
69
|
return this.query<T>(effectiveSql, params);
|
|
@@ -17,6 +17,15 @@ export function camelToSnake(name: string): string {
|
|
|
17
17
|
return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Check whether ORM_PLURAL_TABLE_NAMES is enabled in .env.
|
|
22
|
+
* When true, hasMany relationship keys get an "s" suffix (e.g. "posts" instead of "post").
|
|
23
|
+
*/
|
|
24
|
+
function _pluralRelKeys(): boolean {
|
|
25
|
+
const v = process.env.ORM_PLURAL_TABLE_NAMES ?? "";
|
|
26
|
+
return /^(true|1|yes)$/i.test(v);
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
/**
|
|
21
30
|
* BaseModel provides instance methods for ORM models.
|
|
22
31
|
* Models extend this class and define static properties.
|
|
@@ -395,7 +404,8 @@ export class BaseModel {
|
|
|
395
404
|
}
|
|
396
405
|
if (ModelClass.hasMany) {
|
|
397
406
|
for (const rel of ModelClass.hasMany) {
|
|
398
|
-
const
|
|
407
|
+
const base = rel.model.toLowerCase();
|
|
408
|
+
const relKey = _pluralRelKeys() ? base + "s" : base;
|
|
399
409
|
if (this[relKey] !== undefined) {
|
|
400
410
|
result[relKey] = this[relKey];
|
|
401
411
|
}
|
|
@@ -818,8 +828,9 @@ export class BaseModel {
|
|
|
818
828
|
// Check hasMany
|
|
819
829
|
if (ModelClass.hasMany) {
|
|
820
830
|
const rel = ModelClass.hasMany.find((r) => {
|
|
821
|
-
const
|
|
822
|
-
|
|
831
|
+
const base = r.model.toLowerCase();
|
|
832
|
+
const key = _pluralRelKeys() ? base + "s" : base;
|
|
833
|
+
return key === relName || base === relName || r.model === relName;
|
|
823
834
|
});
|
|
824
835
|
if (rel) {
|
|
825
836
|
const relatedClass = BaseModel._modelRegistry[rel.model];
|
|
@@ -877,8 +888,9 @@ export class BaseModel {
|
|
|
877
888
|
}
|
|
878
889
|
if (!relDef && ModelClass.hasMany) {
|
|
879
890
|
relDef = ModelClass.hasMany.find((r) => {
|
|
880
|
-
const
|
|
881
|
-
|
|
891
|
+
const base = r.model.toLowerCase();
|
|
892
|
+
const key = _pluralRelKeys() ? base + "s" : base;
|
|
893
|
+
return key === relName || base === relName || r.model === relName;
|
|
882
894
|
});
|
|
883
895
|
if (relDef) relType = "hasMany";
|
|
884
896
|
}
|