tina4-nodejs 3.13.36 → 3.13.38

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.
Files changed (50) hide show
  1. package/CLAUDE.md +51 -19
  2. package/package.json +5 -3
  3. package/packages/cli/src/bin.ts +7 -0
  4. package/packages/cli/src/commands/init.ts +1 -0
  5. package/packages/cli/src/commands/metrics.ts +154 -0
  6. package/packages/cli/src/commands/routes.ts +3 -3
  7. package/packages/core/public/js/tina4-dev-admin.js +212 -212
  8. package/packages/core/public/js/tina4-dev-admin.min.js +212 -212
  9. package/packages/core/src/auth.ts +112 -2
  10. package/packages/core/src/cache.ts +2 -2
  11. package/packages/core/src/devAdmin.ts +75 -26
  12. package/packages/core/src/devMailbox.ts +4 -0
  13. package/packages/core/src/dotenv.ts +13 -4
  14. package/packages/core/src/events.ts +86 -4
  15. package/packages/core/src/graphql.ts +182 -128
  16. package/packages/core/src/htmlElement.ts +62 -3
  17. package/packages/core/src/index.ts +14 -8
  18. package/packages/core/src/logger.ts +1 -1
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/messenger.ts +111 -11
  21. package/packages/core/src/metrics.ts +232 -33
  22. package/packages/core/src/middleware.ts +129 -39
  23. package/packages/core/src/plan.ts +1 -1
  24. package/packages/core/src/queue.ts +1 -1
  25. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  26. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  27. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  28. package/packages/core/src/rateLimiter.ts +1 -1
  29. package/packages/core/src/response.ts +90 -6
  30. package/packages/core/src/router.ts +2 -2
  31. package/packages/core/src/server.ts +26 -4
  32. package/packages/core/src/session.ts +130 -18
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  34. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  35. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  36. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  37. package/packages/core/src/testClient.ts +1 -1
  38. package/packages/core/src/websocket.ts +247 -33
  39. package/packages/core/src/websocketBackplane.ts +210 -10
  40. package/packages/core/src/wsdl.ts +55 -21
  41. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  42. package/packages/orm/src/adapters/postgres.ts +26 -4
  43. package/packages/orm/src/adapters/sqlite.ts +112 -13
  44. package/packages/orm/src/baseModel.ts +8 -3
  45. package/packages/orm/src/cachedDatabase.ts +15 -6
  46. package/packages/orm/src/database.ts +257 -55
  47. package/packages/orm/src/index.ts +2 -1
  48. package/packages/orm/src/migration.ts +2 -2
  49. package/packages/orm/src/seeder.ts +443 -65
  50. package/packages/swagger/src/ui.ts +1 -1
package/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.36)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.37)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
@@ -50,7 +50,7 @@ tina4-nodejs/
50
50
  firebird.ts # Firebird adapter
51
51
  baseModel.ts # Base model class
52
52
  fakeData.ts # ORM-aware fake data (extends core, field-type heuristics)
53
- seeder.ts # Database seeding (seedTable, seedOrm)
53
+ seeder.ts # Database seeding (seedTable, seedOrm, seedModels)
54
54
  sqlTranslation.ts # Cross-engine SQL translator + query cache
55
55
  swagger/ # OpenAPI spec generator, Swagger UI
56
56
  frond/ # Zero-dependency Twig-compatible template engine
@@ -120,9 +120,9 @@ The HTTP foundation. Handles request/response lifecycle, route matching, middlew
120
120
  - `auth.ts` — Authentication helpers
121
121
  - `cache.ts` — In-memory caching
122
122
  - `session.ts` — Session management with pluggable handlers. `TINA4_SESSION_SAMESITE` env var (default: Lax)
123
- - `websocket.ts` — WebSocket support with backplane for scaling via Redis pub/sub (`TINA4_WS_BACKPLANE`, `TINA4_WS_BACKPLANE_URL`). Rooms API: `wss.joinRoom(clientId, room)`, `wss.leaveRoom(clientId, room)`, `wss.broadcastToRoom(room, msg, excludeIds?)`, `wss.getRoomConnections(room)`, `wss.roomCount(room)`, `wss.getClientRooms(clientId)`
123
+ - `websocket.ts` — WebSocket support with backplane for scaling via Redis/NATS pub/sub (`TINA4_WS_BACKPLANE`, `TINA4_WS_BACKPLANE_URL`). The backplane is wired for real: `broadcast`/`broadcastToRoom`/`sendTo` deliver to LOCAL connections first (resiliently — a dead/slow client is pruned, never aborting the loop) then publish an envelope `{src,kind,exclude,room,path,text|b64}` to the shared channel `tina4:ws` (identical wire shape across all 4 frameworks). The subscribe callback relays directly on the event loop with an origin guard (drop our own `src` echo — no double-delivery) and never re-publishes (no cluster loop); binary messages ride as base64. Security/ops knobs (all opt-in, non-breaking): `TINA4_WS_ALLOWED_ORIGINS` (comma-separated origin allow-list enforced on upgrade — empty/unset = allow all), `TINA4_WS_IDLE_TIMEOUT` (seconds; 0/unset disables the idle-connection reaper), `TINA4_WS_MAX_BACKLOG` (bytes; a slow client whose socket write backlog exceeds this is dropped rather than buffered without bound). Rooms API: `wss.joinRoom(clientId, room)`, `wss.leaveRoom(clientId, room)`, `wss.broadcastToRoom(room, msg, excludeIds?)`, `wss.getRoomConnections(room)`, `wss.roomCount(room)`, `wss.getClientRooms(clientId)`. `originAllowed(headers)` is exported for upgrade-path checks.
124
124
  - `queue.ts` — Queue system with pluggable backends
125
- - `graphql.ts` — GraphQL engine
125
+ - `graphql.ts` — GraphQL engine. **Hardening:** selection-set nesting is bounded by `TINA4_GRAPHQL_MAX_DEPTH` (default `50`; `<= 0` disables; exposed as the public `gql.maxDepth` field + `graphqlMaxDepth()` helper). Depth increments on every recursive entry — sub-selections, fragment spreads, AND inline fragments — so an over-deep query or a circular fragment fails with `Query exceeds maximum depth of N` instead of overflowing the stack (top-level starts at depth 1). A resolver exception is logged via `Log.error` and the detail is surfaced to the client **only** under `TINA4_DEBUG` (`isDebugMode()`); otherwise it returns a generic `Internal server error` (path preserved) so internal state never leaks.
126
126
  - `i18n.ts` — Internationalization / localization
127
127
  - `logger.ts` — Structured logging
128
128
  - `rateLimiter.ts` — Rate limiting middleware
@@ -131,7 +131,7 @@ The HTTP foundation. Handles request/response lifecycle, route matching, middlew
131
131
  - `scss.ts` — SCSS compilation
132
132
  - `messenger.ts` — Messaging system
133
133
  - `service.ts` — Service layer helpers
134
- - `wsdl.ts` — WSDL / SOAP support
134
+ - `wsdl.ts` — WSDL / SOAP support. **Hardening:** a SOAP message containing a `<!DOCTYPE>` is rejected with a `Client` fault ("DOCTYPE declarations are not allowed in SOAP messages") BEFORE the body is parsed and the operation never runs — SOAP 1.1 forbids DTDs and this closes the XML entity-expansion (billion-laughs) / external-entity (XXE) surface (defence in depth — the hand-rolled parser is already immune). `convertValue` for an `int`/`float`/`integer`/`double`/`number` param throws on a non-numeric value (matching Python's `int()`/`float()` raise) so it becomes a `Server` fault instead of a silent `NaN`. An operation that throws is logged via `Log.error`; the real cause reaches the client **only** under `TINA4_DEBUG` (`isDebugMode()`), else a generic `Internal server error`.
135
135
 
136
136
  ### @tina4/orm (`packages/orm/`)
137
137
  Database layer with auto-CRUD generation, seeding, fake data, and SQL translation.
@@ -150,7 +150,7 @@ Database layer with auto-CRUD generation, seeding, fake data, and SQL translatio
150
150
  - `validation.ts` — Validates request bodies against model field definitions
151
151
  - `types.ts` — `FieldDefinition`, `ModelDefinition`, `DatabaseAdapter`, `QueryOptions`
152
152
  - `fakeData.ts` — ORM-aware fake data extending core (adds `forField()` with column-name heuristics)
153
- - `seeder.ts` — Database seeding (`seedTable` for raw SQL, `seedOrm` for model-based)
153
+ - `seeder.ts` — Database seeding (`seedTable` raw SQL, `seedOrm` model-based, `seedModels` FK-ordered batch). All return a `SeedSummary { seeded, failed, errors }`; per-row failures are logged + counted + skipped (`strict` re-raises). Options: `{ overrides, clear, seed, strict }`.
154
154
  - `sqlTranslation.ts` — Cross-engine SQL translator (`SQLTranslator`) and TTL query cache (`QueryCache`)
155
155
  - **Instance methods:** `save(): this|false` (fluent, false on failure), `delete()`, `forceDelete()`, `restore()`, `load(sql, params?, include?): boolean`, `validate(): string[]`, `toDict(include?)`, `toAssoc(include?)`, `toObject()`, `toArray(): unknown[]`, `toList()`, `toJson(include?)`, `hasOne(class, fk)`, `hasMany(class, fk, limit?, offset?)`, `belongsTo(class, fk)`
156
156
  - **Static methods:** `find(id, include?)`, `findById(id, include?)`, `findOrFail(id)`, `create(data)`, `all(where?, params?, include?)`, `select(sql, params?)`, `selectOne(sql, params?, include?)`, `where(conditions, params?, limit?, offset?, include?)`, `count(conditions?, params?)`, `withTrashed(conditions?, params?, limit?, offset?)`, `scope(name, filterSql, params?)` (registers reusable method), `createTable()`, `query()`, `_processForeignKeys()`, `_applyFkRegistry()`
@@ -223,10 +223,12 @@ session.gc(): void
223
223
 
224
224
  Backends: file, redis, redis-npm, valkey, mongodb, database.
225
225
 
226
+ **Backend-failure policy (all 4 frameworks): log-loud + degrade.** A backend (Redis/Valkey/Mongo/DB) that becomes unreachable mid-request is logged via `Log.error` and degraded rather than crashing the app or losing data silently. The external handlers now **throw** a transport error on an unreachable server (previously they swallowed it to an empty string — silent data loss); the `Session` boundary catches it: a read failure yields an empty session (the request still serves), and `save()` returns `false` (best-effort, dirty flag retained for a later retry). A genuine key/doc miss still returns empty **without** logging — empty is not a failure. Set `TINA4_SESSION_STRICT=true` to re-throw instead. Call `regenerate()` right after a successful login or privilege change to defeat session fixation.
227
+
226
228
  ### Database extras
227
229
 
228
230
  ```typescript
229
- db.execute(sql, params?): boolean | unknown // bool for writes, result for RETURNING/CALL/EXEC
231
+ db.execute(sql, params?): boolean | unknown // RAISES on SQL error (never returns false; cause on getError()); on success: bool for writes, result for RETURNING/CALL/EXEC. try/catch — don't test the return.
230
232
  db.getLastId(): string | number
231
233
  db.getError(): string | null
232
234
  db.cacheStats(): { enabled, size, ttl }
@@ -243,6 +245,8 @@ response.xml(content, status?): Tina4Response
243
245
  response.stream(source: AsyncIterable<string | Buffer>, contentType?: string): Promise<Tina4Response> // SSE/streaming
244
246
  ```
245
247
 
248
+ `response.stream()` is hardened: it bails cleanly when the client disconnects mid-stream (`res.writableEnded`/`res.socket.destroyed`), catches a generator/source error (logs via `Log.error`, ends the stream cleanly — never crashes the handler), applies slow-client backpressure (awaits `drain` when `res.write()` returns false rather than buffering ahead of a stalled client), and writes a periodic `:` keep-alive comment every `TINA4_SSE_HEARTBEAT` seconds (default 15; set `0` to opt out). On disconnect/error it best-effort closes the source (`.return()`/`.close()`/`.aclose()`).
249
+
246
250
  `res.json(model)`, `res.json(arrayOfModels)`, and `res.json(db.fetch(...))` auto-serialize to JSON — a single model becomes a JSON object, an array of models or a `DatabaseResult` becomes a JSON array. No manual `toDict()`/`toJson()` needed.
247
251
 
248
252
  ### Queue
@@ -423,7 +427,7 @@ reset();
423
427
  Database seeding with fake data generation. The ORM `FakeData` extends core `FakeData` (which provides names, emails, addresses, etc.) and adds `forField()` for auto-generating values based on ORM field definitions with column-name heuristics.
424
428
 
425
429
  ```typescript
426
- import { FakeData, seedTable, seedOrm } from "@tina4/orm";
430
+ import { FakeData, seedTable, seedOrm, seedModels } from "@tina4/orm";
427
431
 
428
432
  // FakeData — deterministic with optional seed
429
433
  const fake = new FakeData(42);
@@ -445,18 +449,36 @@ fake.forField({ type: "string", maxLength: 50 }, "email"); // generates email
445
449
  fake.forField({ type: "integer", min: 0, max: 100 }); // random integer
446
450
  fake.forField({ type: "boolean" }); // true/false
447
451
 
448
- // seedTable — raw SQL inserts with generator functions
449
- await seedTable(db, "users", 50, {
452
+ // seedTable — raw SQL inserts with generator functions.
453
+ // Returns a SeedSummary { seeded, failed, errors } (NOT a bare number).
454
+ const summary = await seedTable(db, "users", 50, {
450
455
  name: () => fake.name(),
451
456
  email: () => fake.email(),
452
457
  role: "user", // static values also accepted
453
- }, { active: true }); // overrides applied to every row
458
+ }, undefined, { clear: true, seed: 42, strict: false });
459
+ // summary.seeded / summary.failed / summary.errors[{ row, message }]
460
+ // (legacy positional 5th arg overrides also still works: seedTable(..., { active: true }))
454
461
 
455
462
  // seedOrm — auto-seed from model field definitions
456
463
  import User from "./src/models/User.js";
457
- await seedOrm(User, 100, { role: "user" }, 42); // optional seed for determinism
464
+ await seedOrm(User, 100, { role: "user" }, 42); // legacy positional
465
+ await seedOrm(User, 100, undefined, undefined, { clear: true, seed: 42, strict: true });
466
+
467
+ // seedModels — batch-seed FK-related models in dependency order (parents first,
468
+ // reverse-order clear, FK columns resolved to real parent PKs). Topo-sorts by the
469
+ // `references` graph so the declared order doesn't matter.
470
+ const results = await seedModels([Book, Author], 10, { clear: true, seed: 7 });
471
+ // → { Author: SeedSummary, Book: SeedSummary }
458
472
  ```
459
473
 
474
+ **Visible-but-resilient seeding (P1-P4).** Every row is wrapped: a failing row is
475
+ logged (with its index + cause) and skipped, incrementing `summary.failed` — never
476
+ silent, never a crash. `strict: true` re-raises on the first failure instead of
477
+ skipping. `clear: true` truncates the target first (idempotent re-runs). `seed`
478
+ makes a run reproducible. `seedModels()` orders by the foreignKey dependency graph.
479
+ The dev-admin seed endpoint (`POST /__dev/api/seed`) routes through `seedTable`,
480
+ accepts `seed`/`clear`/`strict`, and returns `{ seeded, failed, errors, table }`.
481
+
460
482
  Column-name heuristics in `forField()`: columns named `email`, `phone`, `name`, `address`, `city`, `country`, `company`, `url`, `uuid`, `ip`, `currency`, etc. get contextually appropriate fake data.
461
483
 
462
484
  ## Module: SQL Translation (`packages/orm/src/sqlTranslation.ts`)
@@ -564,7 +586,13 @@ const db = await initDatabase({ url: "sqlite:///app.db" });
564
586
  db.fetch(sql, params?, limit?, offset?): DatabaseResult // .records, .count, .limit, .offset
565
587
  db.fetchOne<T>(sql, params?): T | null
566
588
 
567
- // Writes — return boolean for simple writes, result for RETURNING / CALL / EXEC / SELECT
589
+ // Writes — execute() RAISES on a SQL error (bad SQL, constraint violation,
590
+ // dead connection, missing driver): it records the cause on getError() then
591
+ // re-throws — it never swallows and returns false (mirrors fetch()/fetchOne()).
592
+ // On SUCCESS it returns boolean for simple writes, the result set for
593
+ // RETURNING / CALL / EXEC / SELECT. Callers needing a bool (ORM save(),
594
+ // createTable(), migration runner, dev-admin/MCP DB tools) try/catch and
595
+ // convert — they must NOT test the return value for false.
568
596
  db.execute(sql, params?): boolean | unknown
569
597
  db.executeMany(sql, paramSets): unknown[] // wrapped in a transaction
570
598
  db.insert(table, data): DatabaseWriteResult
@@ -592,9 +620,13 @@ db.getColumns(table): { name, type, nullable?, default?, primaryKey? }[]
592
620
  // auto-creates Postgres sequences, and uses native Firebird generators.
593
621
  db.getNextId(table, pkColumn?, generatorName?): number
594
622
 
595
- // DB query cache — request-scoped auto cache is ON by default (TINA4_AUTO_CACHING=true,
596
- // TTL TINA4_AUTO_CACHING_TTL=5s): dedupes identical db.fetch()/ORM reads within a request,
597
- // flushed on any write (always in-process, fastest). Persistent cross-request cache opt-in
623
+ // DB query cache — request-scoped auto cache is OFF by default (opt-in via
624
+ // TINA4_AUTO_CACHING=true, TTL TINA4_AUTO_CACHING_TTL=5s): when enabled it dedupes
625
+ // identical db.fetch()/ORM reads within a request, flushed on any write (always
626
+ // in-process, fastest). Default OFF because a request-scoped cache defaulting ON is a
627
+ // footgun — a read-after-write in one request (e.g. SELECT MAX(id) then INSERT) returns a
628
+ // cached pre-write value (duplicate PKs / stale state); enable it for read-heavy endpoints.
629
+ // Persistent cross-request cache opt-in
598
630
  // via TINA4_DB_CACHE=true (TTL TINA4_DB_CACHE_TTL=30s), configured via TINA4_DB_CACHE_BACKEND
599
631
  // + TINA4_DB_CACHE_URL. The persistent layer routes through the SAME unified async backend
600
632
  // set (memory default = in-process; redis/valkey/memcached/mongodb/database distribute), so
@@ -1125,7 +1157,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1125
1157
  - **Frond template engine optimizations**: pre-compiled regexes, lazy loop context (copy-on-write), filter chain caching, path split caching, inline common filters (11-15% speedup)
1126
1158
  - **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
1127
1159
  - **`npx tina4nodejs generate`**: model, route, migration, middleware scaffolding
1128
- - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **on by default** (`TINA4_AUTO_CACHING=true`, TTL `TINA4_AUTO_CACHING_TTL=5`s) dedupes identical `db.fetch()`/ORM reads within a request and flushes on writes (always in-process); persistent cross-request cache opt-in via `TINA4_DB_CACHE=true` (TTL `TINA4_DB_CACHE_TTL=30`s) routed through the unified async backend set via `TINA4_DB_CACHE_BACKEND` (memory/file/redis/valkey/memcached/mongodb/database) + `TINA4_DB_CACHE_URL`, so instances share one cache with global write-invalidation (full parity with Python/PHP/Ruby). `db.cacheStats()` reports `mode` (request/persistent/off) + `backend`
1160
+ - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **off by default — opt-in via `TINA4_AUTO_CACHING=true`** (TTL `TINA4_AUTO_CACHING_TTL=5`s) which dedupes identical `db.fetch()`/ORM reads within a request and flushes on writes (always in-process); default OFF because a request-scoped cache defaulting on is a read-after-write footgun (cached pre-write `SELECT MAX(id)` → duplicate PKs); persistent cross-request cache opt-in via `TINA4_DB_CACHE=true` (TTL `TINA4_DB_CACHE_TTL=30`s) routed through the unified async backend set via `TINA4_DB_CACHE_BACKEND` (memory/file/redis/valkey/memcached/mongodb/database) + `TINA4_DB_CACHE_URL`, so instances share one cache with global write-invalidation (full parity with Python/PHP/Ruby). `db.cacheStats()` reports `mode` (request/persistent/off) + `backend`
1129
1161
  - **Cache**: unified backend set — `memory` (default), `file`, `redis`, `valkey`, `memcached`, `mongodb`, `database` — via `TINA4_CACHE_BACKEND` (+ `TINA4_CACHE_URL`/credentials); file-backend fallback if a backend is unreachable. The KV API, the `responseCache` middleware, and the persistent DB query cache all route through this async backend set (native async clients, no child processes) — a network backend distributes them cross-instance, full parity with Python/PHP/Ruby; `memory` (default) keeps them in-process. `await` the async API (`cacheGet`/`cacheSet`/`clearCache`)
1130
1162
  - **Sessions**: file backend (default). `TINA4_SESSION_SAMESITE` env var (default: Lax)
1131
1163
  - **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
@@ -1134,11 +1166,11 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1134
1166
  - **ORM relationships**: `hasMany`, `hasOne`, `belongsTo` with eager loading (`include`)
1135
1167
  - **Frond pre-compilation**: 2.8x template render improvement
1136
1168
  - **QueryBuilder** with NoSQL/MongoDB support (`toMongo()`)
1137
- - **WebSocket backplane** (Redis pub/sub) for horizontal scaling
1169
+ - **WebSocket backplane** (Redis/NATS pub/sub) for horizontal scaling — wired for real: local-first resilient delivery, then publish a `{src,kind,exclude,room,path,text|b64}` envelope to the `tina4:ws` channel (cross-framework wire shape); subscribe relays directly with an origin guard (no own-echo) and never re-publishes. Origin allow-list (`TINA4_WS_ALLOWED_ORIGINS`), idle reaper (`TINA4_WS_IDLE_TIMEOUT`), and slow-client drop (`TINA4_WS_MAX_BACKLOG`) — all opt-in/non-breaking
1138
1170
  - **SameSite=Lax** default on session cookies (`TINA4_SESSION_SAMESITE`)
1139
1171
  - **`tina4 deploy docker`** generates Dockerfile and .dockerignore
1140
1172
  - **Gallery**: 7 interactive examples with Try It deploy at `/_dev/`
1141
- - **SSE/Streaming**: `response.stream()` for Server-Sent Events — pass an async generator, framework handles chunked transfer encoding and keep-alive
1173
+ - **SSE/Streaming**: `response.stream()` for Server-Sent Events — pass an async generator; framework handles chunked transfer encoding plus a periodic keep-alive heartbeat (`TINA4_SSE_HEARTBEAT`, default 15s, `0` to disable). Hardened against client disconnect (bails when the socket is gone), a generator error mid-stream (logs + ends cleanly, never crashes the worker), and slow clients (awaits socket drain instead of unbounded buffering)
1142
1174
 
1143
1175
  ## Don'ts
1144
1176
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.36",
6
+ "version": "3.13.38",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -57,7 +57,8 @@
57
57
  "scripts": {
58
58
  "build": "npm run build --workspaces",
59
59
  "clean": "rm -rf packages/*/dist",
60
- "test": "tsx test/run-all.ts"
60
+ "test": "tsx test/run-all.ts",
61
+ "typecheck": "tsc -p tsconfig.typecheck.json"
61
62
  },
62
63
  "engines": {
63
64
  "node": ">=22.0.0"
@@ -67,6 +68,7 @@
67
68
  "typescript": "^5.7.0",
68
69
  "tsx": "^4.19.0",
69
70
  "esbuild": "^0.24.0",
70
- "mongodb": "^6.0.0"
71
+ "mongodb": "^6.0.0",
72
+ "@types/node": "^22.10.0"
71
73
  }
72
74
  }
@@ -8,6 +8,7 @@ import { listRoutes } from "./commands/routes.js";
8
8
  import { runTests } from "./commands/test.js";
9
9
  import { generate } from "./commands/generate.js";
10
10
  import { runSeeds } from "./commands/seed.js";
11
+ import { runMetrics } from "./commands/metrics.js";
11
12
  import { execSync } from "node:child_process";
12
13
 
13
14
  const args = process.argv.slice(2);
@@ -26,6 +27,8 @@ const HELP = `
26
27
  tina4nodejs routes List all registered routes
27
28
  tina4nodejs test [file] Run project tests
28
29
  tina4nodejs seed [file] Run database seed files from src/seeds/
30
+ tina4nodejs metrics [--top N] [--json] [--fail-on warn|error] [--path DIR]
31
+ Rank top code-quality offenders
29
32
  tina4nodejs console Open an interactive REPL with the framework loaded
30
33
  tina4nodejs ai Install AI coding assistant context files
31
34
  tina4nodejs help Show this help message
@@ -131,6 +134,10 @@ async function main(): Promise<void> {
131
134
  await runSeeds(args[1]);
132
135
  break;
133
136
  }
137
+ case "metrics": {
138
+ const code = runMetrics(args.slice(1));
139
+ process.exit(code);
140
+ }
134
141
  case "console": {
135
142
  const repl = await import("node:repl");
136
143
  const { loadEnv, Router, Log } = await import("../../core/src/index.js");
@@ -109,6 +109,7 @@ dist/
109
109
  *.db
110
110
  *.sqlite
111
111
  .env
112
+ .env.local
112
113
  .DS_Store
113
114
  data/
114
115
  `
@@ -0,0 +1,154 @@
1
+ /**
2
+ * CLI command: metrics — Rank top code-quality offenders.
3
+ *
4
+ * Exposes the existing static analyzer (cyclomatic complexity, maintainability,
5
+ * large-file, too-many-functions, and the now-precise has_tests) on the CLI as
6
+ * a self-monitoring tool.
7
+ *
8
+ * tina4nodejs metrics # human report, scans src/ (or framework)
9
+ * tina4nodejs metrics --top 10 # only the worst 10
10
+ * tina4nodejs metrics --path packages/core/src # scan a specific directory
11
+ * tina4nodejs metrics --json # machine-readable for CI (ONLY json printed)
12
+ * tina4nodejs metrics --fail-on warn # exit 1 if any warn/error offender
13
+ * tina4nodejs metrics --fail-on error # exit 1 only on error-severity
14
+ *
15
+ * Returns the intended process exit code (the caller exits). Splitting "compute
16
+ * exit code" from "exit the process" keeps the handler unit-testable.
17
+ */
18
+ import { offenders } from "../../../core/src/metrics.js";
19
+
20
+ type Flags = {
21
+ top: number;
22
+ json: boolean;
23
+ path: string;
24
+ failOn: "warn" | "error" | null;
25
+ };
26
+
27
+ /** Parse `--top N`, `--json`, `--fail-on warn|error`, `--path DIR` out of argv. */
28
+ function parseFlags(args: string[]): Flags | { error: string } {
29
+ const flags: Flags = { top: 20, json: false, path: "src", failOn: null };
30
+
31
+ for (let i = 0; i < args.length; i++) {
32
+ const a = args[i];
33
+ switch (a) {
34
+ case "--json":
35
+ flags.json = true;
36
+ break;
37
+ case "--top": {
38
+ const v = args[++i];
39
+ if (v === undefined || !/^\d+$/.test(v)) {
40
+ return { error: `--top expects a number (got '${v ?? ""}')` };
41
+ }
42
+ flags.top = parseInt(v, 10);
43
+ break;
44
+ }
45
+ case "--path": {
46
+ const v = args[++i];
47
+ if (v === undefined) return { error: "--path expects a directory" };
48
+ flags.path = v;
49
+ break;
50
+ }
51
+ case "--fail-on": {
52
+ const v = args[++i];
53
+ if (v !== "warn" && v !== "error") {
54
+ return { error: `invalid --fail-on '${v ?? ""}' (use warn or error)` };
55
+ }
56
+ flags.failOn = v;
57
+ break;
58
+ }
59
+ default:
60
+ return { error: `unknown option '${a}'` };
61
+ }
62
+ }
63
+
64
+ return flags;
65
+ }
66
+
67
+ /**
68
+ * Run the metrics report. Returns the process exit code; does NOT call
69
+ * process.exit (the bin wrapper does). 0 = ok / below threshold, 1 = gated
70
+ * failure, 2 = bad arguments / analysis error.
71
+ */
72
+ export function runMetrics(args: string[] = []): number {
73
+ const parsed = parseFlags(args);
74
+ if ("error" in parsed) {
75
+ console.log(` ${parsed.error}`);
76
+ return 2;
77
+ }
78
+ const { top, json, path, failOn } = parsed;
79
+
80
+ const result = offenders(path, top);
81
+ const summary = result.summary;
82
+ const found = result.offenders;
83
+
84
+ if (summary.error) {
85
+ console.log(` metrics error: ${summary.error}`);
86
+ return 2;
87
+ }
88
+
89
+ // Decide exit code from the FULL offender set, not just the printed top-N.
90
+ // offenders()/fullAnalysis() is mtime-cached, so this reuses the same analysis.
91
+ const allOffenders = offenders(path, summary.total_offenders || 1).offenders;
92
+ const severities = new Set(allOffenders.map((o) => o.severity));
93
+ let exitCode = 0;
94
+ if (failOn === "warn" && (severities.has("warn") || severities.has("error"))) {
95
+ exitCode = 1;
96
+ } else if (failOn === "error" && severities.has("error")) {
97
+ exitCode = 1;
98
+ }
99
+
100
+ if (json) {
101
+ // Print ONLY the JSON — machine-readable for CI / tooling.
102
+ console.log(JSON.stringify({ summary, offenders: found }, null, 2));
103
+ return exitCode;
104
+ }
105
+
106
+ // ── Human report ──────────────────────────────────────────────────
107
+ const useColor = Boolean(process.stdout.isTTY);
108
+ const c = (text: string, code: string): string =>
109
+ useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
110
+ const sevColor: Record<string, string> = { error: "31", warn: "33", info: "2" }; // red / yellow / dim
111
+
112
+ console.log("");
113
+ console.log(` Tina4 Metrics — ${summary.scan_mode} scan (${summary.scan_root})`);
114
+ console.log(
115
+ ` files: ${summary.files_analyzed} ` +
116
+ `functions: ${summary.total_functions} ` +
117
+ `avg complexity: ${summary.avg_complexity} ` +
118
+ `avg maintainability: ${summary.avg_maintainability}`
119
+ );
120
+ console.log(
121
+ ` offenders: ${summary.total_offenders} total` +
122
+ (found.length ? ` (showing top ${found.length})` : "")
123
+ );
124
+ console.log("");
125
+
126
+ if (found.length === 0) {
127
+ console.log(" " + c("✓ no offenders — clean", "32"));
128
+ console.log("");
129
+ return exitCode;
130
+ }
131
+
132
+ // Column widths so the table lines up.
133
+ const locs = found.map((o) => `${o.file}:${o.line}`);
134
+ const locW = Math.max("FILE:LINE".length, ...locs.map((s) => s.length));
135
+ const kindW = Math.max("KIND".length, ...found.map((o) => o.kind.length));
136
+
137
+ const pad = (s: string, w: number) => s.padEnd(w);
138
+ const header = ` ${pad("#", 3)} ${pad("SEVERITY", 8)} ${pad("KIND", kindW)} ${pad(
139
+ "FILE:LINE",
140
+ locW
141
+ )} DETAIL`;
142
+ console.log(c(header, "1"));
143
+ console.log(" " + "-".repeat(header.length - 2));
144
+ found.forEach((o, idx) => {
145
+ const i = idx + 1;
146
+ const sevCell = c(pad(o.severity, 8), sevColor[o.severity]);
147
+ console.log(
148
+ ` ${String(i).padStart(3)} ${sevCell} ${pad(o.kind, kindW)} ` +
149
+ `${pad(locs[idx], locW)} ${o.detail}`
150
+ );
151
+ });
152
+ console.log("");
153
+ return exitCode;
154
+ }
@@ -32,8 +32,8 @@ export async function listRoutes(): Promise<void> {
32
32
  }
33
33
 
34
34
  // Sort by path then method
35
- routes.sort((a: { path: string; method: string }, b: { path: string; method: string }) => {
36
- const pathCmp = a.path.localeCompare(b.path);
35
+ routes.sort((a, b) => {
36
+ const pathCmp = a.pattern.localeCompare(b.pattern);
37
37
  return pathCmp !== 0 ? pathCmp : a.method.localeCompare(b.method);
38
38
  });
39
39
 
@@ -50,7 +50,7 @@ export async function listRoutes(): Promise<void> {
50
50
 
51
51
  for (const route of routes) {
52
52
  const method = route.method.toUpperCase().padEnd(10);
53
- const path = route.path.padEnd(40);
53
+ const path = route.pattern.padEnd(40);
54
54
  const summary = route.meta?.summary ?? "";
55
55
  console.log(` ${method}${path}${summary}`);
56
56
  }