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.
@@ -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">&#11088; Star</a>
320
+ <a href="https://github.com/tina4stack/tina4-nodejs/stargazers" class="btn" target="_blank"><span class="star-wiggle">&#9734;</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
- const server = createServer(async (rawReq, rawRes) => {
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+)?(?:function\s*\w*)?\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
- effectiveSql += ` LIMIT ${limit}`;
61
- if (skip !== undefined && skip > 0) {
62
- effectiveSql += ` OFFSET ${skip}`;
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 relKey = rel.model.toLowerCase() + "s";
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 key = r.model.toLowerCase() + "s";
822
- return key === relName || r.model.toLowerCase() === relName || r.model === relName;
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 key = r.model.toLowerCase() + "s";
881
- return key === relName || r.model.toLowerCase() === relName || r.model === relName;
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
  }