tina4-nodejs 3.13.94 → 3.13.95

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 (115) hide show
  1. package/CLAUDE.md +157 -28
  2. package/README.md +1 -1
  3. package/package.json +2 -1
  4. package/packages/cli/dist/bin.js +32418 -29638
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +32364 -29501
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/dispatchPipeline.ts +285 -0
  14. package/packages/core/src/dotenv.ts +185 -40
  15. package/packages/core/src/index.ts +5 -4
  16. package/packages/core/src/logger.ts +257 -36
  17. package/packages/core/src/mcp.ts +1 -1
  18. package/packages/core/src/messenger.ts +9 -13
  19. package/packages/core/src/metrics.ts +199 -961
  20. package/packages/core/src/middleware.ts +390 -123
  21. package/packages/core/src/queue.ts +188 -32
  22. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  23. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  24. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  25. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  26. package/packages/core/src/rateLimiter.ts +10 -5
  27. package/packages/core/src/request.ts +6 -9
  28. package/packages/core/src/response.ts +46 -1
  29. package/packages/core/src/router.ts +29 -4
  30. package/packages/core/src/server.ts +751 -414
  31. package/packages/core/src/session.ts +244 -27
  32. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  33. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  34. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  36. package/packages/core/src/sessionHandlers/respClient.ts +16 -147
  37. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  38. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  39. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  40. package/packages/core/src/testClient.ts +18 -5
  41. package/packages/core/src/trustedProxy.ts +249 -0
  42. package/packages/core/src/types.ts +29 -5
  43. package/packages/core/src/websocket.ts +66 -0
  44. package/packages/orm/dist/index.js +22367 -19504
  45. package/packages/orm/src/adapters/firebird.ts +183 -56
  46. package/packages/orm/src/adapters/mongodb.ts +25 -4
  47. package/packages/orm/src/adapters/mssql.ts +114 -29
  48. package/packages/orm/src/adapters/mysql.ts +103 -40
  49. package/packages/orm/src/adapters/odbc.ts +44 -21
  50. package/packages/orm/src/adapters/postgres.ts +118 -26
  51. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  52. package/packages/orm/src/adapters/sqlite.ts +60 -24
  53. package/packages/orm/src/baseModel.ts +135 -40
  54. package/packages/orm/src/cachedDatabase.ts +43 -19
  55. package/packages/orm/src/connectTimeout.ts +265 -0
  56. package/packages/orm/src/database.ts +237 -197
  57. package/packages/orm/src/databaseResult.ts +65 -13
  58. package/packages/orm/src/databaseUrl.ts +484 -0
  59. package/packages/orm/src/docstore.ts +386 -145
  60. package/packages/orm/src/index.ts +13 -3
  61. package/packages/orm/src/migration.ts +18 -3
  62. package/packages/orm/src/queryBuilder.ts +38 -4
  63. package/packages/orm/src/sqlTranslator.ts +310 -4
  64. package/packages/orm/src/types.ts +15 -4
  65. package/types/core/src/ai.d.ts +1 -1
  66. package/types/core/src/auth.d.ts +28 -5
  67. package/types/core/src/background.d.ts +3 -3
  68. package/types/core/src/cache.d.ts +15 -12
  69. package/types/core/src/dispatchPipeline.d.ts +117 -0
  70. package/types/core/src/dotenv.d.ts +38 -16
  71. package/types/core/src/index.d.ts +5 -6
  72. package/types/core/src/logger.d.ts +93 -16
  73. package/types/core/src/messenger.d.ts +2 -2
  74. package/types/core/src/metrics.d.ts +25 -61
  75. package/types/core/src/middleware.d.ts +134 -11
  76. package/types/core/src/queue.d.ts +54 -5
  77. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
  78. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  79. package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
  80. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
  81. package/types/core/src/router.d.ts +14 -3
  82. package/types/core/src/server.d.ts +15 -0
  83. package/types/core/src/session.d.ts +87 -2
  84. package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
  85. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  86. package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
  87. package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
  88. package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
  89. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  90. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  91. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  92. package/types/core/src/trustedProxy.d.ts +44 -0
  93. package/types/core/src/types.d.ts +28 -5
  94. package/types/core/src/websocket.d.ts +26 -0
  95. package/types/orm/src/adapters/firebird.d.ts +55 -10
  96. package/types/orm/src/adapters/mongodb.d.ts +2 -2
  97. package/types/orm/src/adapters/mssql.d.ts +18 -11
  98. package/types/orm/src/adapters/mysql.d.ts +11 -10
  99. package/types/orm/src/adapters/odbc.d.ts +9 -12
  100. package/types/orm/src/adapters/postgres.d.ts +11 -10
  101. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  102. package/types/orm/src/adapters/sqlite.d.ts +15 -3
  103. package/types/orm/src/baseModel.d.ts +45 -9
  104. package/types/orm/src/cachedDatabase.d.ts +18 -5
  105. package/types/orm/src/connectTimeout.d.ts +100 -0
  106. package/types/orm/src/database.d.ts +72 -26
  107. package/types/orm/src/databaseResult.d.ts +24 -0
  108. package/types/orm/src/databaseUrl.d.ts +125 -0
  109. package/types/orm/src/docstore.d.ts +102 -43
  110. package/types/orm/src/index.d.ts +5 -2
  111. package/types/orm/src/queryBuilder.d.ts +23 -3
  112. package/types/orm/src/sqlTranslator.d.ts +126 -2
  113. package/types/orm/src/types.d.ts +14 -4
  114. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
  115. package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
package/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.94)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.95)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.13.94 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.95 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
@@ -55,9 +55,9 @@ tina4-nodejs/
55
55
  swagger/ # OpenAPI spec generator, Swagger UI
56
56
  frond/ # Zero-dependency Twig-compatible template engine
57
57
  test/
58
- run-all.ts # Test runner — executes all 43 test files
58
+ run-all.ts # Test runner — executes every test file (262 in the last lab run)
59
59
  integration.ts # Full integration test
60
- *.test.ts # 42 individual test files covering all subsystems
60
+ *.test.ts # 267 individual test files covering all subsystems
61
61
  plan/
62
62
  FEATURES.md # Feature tracking and roadmap
63
63
  ```
@@ -72,13 +72,13 @@ This is an **npm workspaces monorepo**. All packages are in `packages/*`.
72
72
  - **Database:** SQLite via `node:sqlite` (default), with adapters for Postgres, MySQL, MSSQL/SQL Server, and Firebird
73
73
  - **Templates:** Frond — built-in zero-dependency Twig-compatible engine (`@tina4/frond`)
74
74
  - **Dev tooling:** `tsx` for runtime TS execution, `esbuild` for builds
75
- - **Testing:** 43 test files via `tsx test/run-all.ts`
75
+ - **Testing:** 267 `*.test.ts` files via `tsx test/run-all.ts`
76
76
 
77
77
  ## Key Commands
78
78
 
79
79
  ```bash
80
80
  npm install # Install all workspace dependencies
81
- npm test # Run all 43 test files via test/run-all.ts
81
+ npm test # Run every test file via test/run-all.ts
82
82
  npm run build # Build all packages to dist/
83
83
  npm run clean # Remove all dist/ directories
84
84
  ```
@@ -128,7 +128,7 @@ The HTTP foundation. Handles request/response lifecycle, route matching, middlew
128
128
  - `queue.ts` — Queue system with pluggable backends
129
129
  - `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.
130
130
  - `i18n.ts` — Internationalization / localization
131
- - `logger.ts` — Structured logging. Five first-class severity levels: `debug`(0) < `info`(1) < `warning`(2) < `error`(3) < `critical`(4). `critical` is the HIGHEST level, NOT a relabelled `error`, and renders magenta. `Log.critical()` ALWAYS emits like every other level — subject only to `TINA4_LOG_LEVEL` (which it always clears) and teed to the log file whenever a file is being written. There is NO enable toggle: the old `TINA4_LOG_CRITICAL` opt-in was retired in v3.13.39 (the env var is no longer read), so a critical log is never a silent no-op. `Log.isEnabled("critical")` is ordinary threshold logic (`4 >= configured min`). **Dev/prod-aware default file output (v3.13.39, Python master 4c6d881):** stdout is ALWAYS on. When `TINA4_LOG_OUTPUT` is unset (default), the log FILE (`logs/tina4.log`) is written ONLY in development (`TINA4_DEBUG` truthy); in production / containers (`TINA4_DEBUG` falsy) the logger is stdout-only — no file to bloat the writable layer / disk (12-factor: logs on stdout for the platform to capture). Explicit `TINA4_LOG_OUTPUT=file`/`both`, OR an explicit `TINA4_LOG_FILE` path, always forces a file (explicit wins). `readEnv()` resolves all of this into a single `fileEnabled` flag that gates the file writer. Full parity with Python master.
131
+ - `logger.ts` — Structured logging. Five first-class severity levels: `debug`(0) < `info`(1) < `warning`(2) < `error`(3) < `critical`(4). `critical` is the HIGHEST level, NOT a relabelled `error`, and renders magenta. `Log.critical()` ALWAYS emits like every other level — subject only to `TINA4_LOG_LEVEL` (which it always clears) and teed to the log file whenever a file is being written. There is NO enable toggle: the old `TINA4_LOG_CRITICAL` opt-in was retired in v3.13.39 (the env var is no longer read), so a critical log is never a silent no-op. `Log.isEnabled("critical")` is ordinary threshold logic (`4 >= configured min`). **Dev/prod-aware default file output (v3.13.39, Python master 4c6d881):** stdout is ALWAYS on. When `TINA4_LOG_OUTPUT` is unset (default), the log FILE (`logs/tina4.log`) is written ONLY in development (`TINA4_DEBUG` truthy); in production / containers (`TINA4_DEBUG` falsy) the logger is stdout-only — no file to bloat the writable layer / disk (12-factor: logs on stdout for the platform to capture). Explicit `TINA4_LOG_OUTPUT=file`/`both`, OR an explicit `TINA4_LOG_FILE` path, always forces a file (explicit wins). `readEnv()` resolves all of this into a single `fileEnabled` flag that gates the file writer. Full parity with Python master. **Format is TEXT by default (settled logger contract, 2026-08-01):** `TINA4_LOG_FORMAT=json` is the ONLY thing that selects JSON, and it applies to BOTH sinks (stdout and file). The implicit "production means JSON" switch (an unset `TINA4_DEBUG`) is DELETED — it made the same `.env` produce four different formats across the four frameworks. `TINA4_DEBUG` now decides COLOUR only: ANSI on a dev terminal, clean bytes on a production pipe. An object/array passed as the MESSAGE is still JSON-encoded INLINE inside the text line (never `[object Object]`). **`TINA4_LOG_STRICT`:** truthy makes a log-write failure THROW instead of being swallowed (default off — logging must never crash an app that did not ask for it). **Every `TINA4_LOG_*` var is read LAZILY on each call**, so a script, worker, CLI tool or test that logs without booting a server still gets the operator's configuration; `Log.configure()` remains an explicit override.
132
132
  - `rateLimiter.ts` — Rate limiting middleware
133
133
  - `dotenv.ts` — `.env` file loading
134
134
  - `health.ts` — Health check endpoint
@@ -157,7 +157,7 @@ Database layer with auto-CRUD generation, seeding, fake data, and SQL translatio
157
157
  - `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 }`.
158
158
  - `sqlTranslator.ts` — Cross-engine SQL translator (`SQLTranslator`) and TTL query cache (`QueryCache`)
159
159
  - **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)`
160
- - **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()`
160
+ - **Static methods:** `find(id, include?)`, `findById(id, include?)`, `findOrFail(id)`, `create(data)`, `all(limit=100, offset=0, include?, orderBy?)`, `select(sql, params?, limit=100, offset=0)`, `selectOne(sql, params?, include?)`, `where(conditions, params?, limit=100, offset=0, include?, orderBy?)`, `count(conditions?, params?)`, `withTrashed(conditions?, params?, limit=100, offset=0)`, `scope(name, filterSql, params?)` (registers reusable method), `createTable()`, `query()`, `_processForeignKeys()`, `_applyFkRegistry()`
161
161
  - **Foreign key auto-wire:** Declare a field with `type: "foreignKey"` and `references: "ModelName"` to auto-wire both `belongsTo` on the declaring model and `hasMany` on the referenced model. Optional `relatedName` overrides the has-many key. Models must be registered via `BaseModel.registerModel(name, class)` for name-based resolution. Example: `user_id: { type: "foreignKey", references: "User" }` → `post.belongsTo(User, "user_id")` and `user.hasMany(Post, "user_id")` both resolve without extra wiring.
162
162
  - QueryBuilder supports `toMongo()` for generating MongoDB query documents from the same fluent API
163
163
  - `getNextId(table: string, pkColumn?: string, generatorName?: string): Promise<number>` — Race-safe ID generation using atomic sequence table (`tina4_sequences`). SQLite/MySQL/MSSQL use `tina4_sequences` with atomic UPDATE+SELECT. PostgreSQL auto-creates sequences if missing. Firebird uses existing generators (unchanged).
@@ -225,7 +225,27 @@ session.getSessionId(): string | null
225
225
  session.gc(): void
226
226
  ```
227
227
 
228
- Backends: file, redis, redis-npm, valkey, mongodb, database.
228
+ Backends: file, redis, valkey, mongodb, database, memcached.
229
+
230
+ **Breaking (3.13.95): the mongodb backend's default database is now `tina4`.** With
231
+ `TINA4_SESSION_MONGO_DB` unset it was `tina4_sessions` in Node and `tina4` in
232
+ tina4-python, tina4-php and tina4-ruby, so the same `.env` put Node's sessions in a
233
+ different database from the other three — identical configuration, different
234
+ observable outcome. What an operator sees on upgrade: current sessions are not
235
+ found, so users log in once more. Set `TINA4_SESSION_MONGO_DB=tina4_sessions` to
236
+ keep the old database. There is deliberately no fallback read: a session store is
237
+ ephemeral (everything in it carries a TTL, default 3600s), so the impact self-heals
238
+ within one session lifetime and a fallback would double a read path forever.
239
+
240
+ **`redis-npm` was removed on 2026-07-31.** It was a Node-only backend name that drove
241
+ Redis through the optional `redis` npm package. Python and Ruby also prefer that
242
+ driver when installed, but they choose it inside their single `redis` handler; only
243
+ Node exposed it as a selectable backend, and only Node's copy still ran
244
+ `execFileSync` per command instead of the persistent worker connection every other
245
+ handler moved to. Use `redis` — same backend, same `TINA4_SESSION_REDIS_*`
246
+ settings, faster transport. Setting `TINA4_SESSION_BACKEND=redis-npm` now **throws**
247
+ rather than falling through to the `file` default, because a silent demotion to disk
248
+ would log every user out on deploy and look like an outage.
229
249
 
230
250
  **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.
231
251
 
@@ -240,20 +260,23 @@ db.cacheStats(): { enabled, size, ttl } // synchronous
240
260
 
241
261
  ### DocStore — pymongo-style document store (zero-config SQLite fallback)
242
262
 
243
- `getCollection(name)` (from `@tina4/orm`) returns a Mongo-style collection. When a Mongo URI is configured it is a real Mongo collection (resolved lazily, returns a Promise); otherwise it is a `SqliteCollection` backed by a local SQLite file (`node:sqlite`, JSON1) and is synchronous. The call sites are identical either way — only the backend differs — so you develop against a zero-dependency local store and switch to MongoDB in production by setting one env var. Because `node:sqlite` is synchronous, `getCollection` is sync in the serverless path and returns a Promise only on the real-Mongo path.
263
+ `getCollection(name)` (from `@tina4/orm`) returns a Mongo-style collection. When a Mongo URI is configured it is a real Mongo collection; otherwise it is a `SqliteCollection` backed by a local SQLite file (`node:sqlite`, JSON1). The call sites are identical either way — only the backend differs — so you develop against a zero-dependency local store and switch to MongoDB in production by setting one env var.
264
+
265
+ **The API is ASYNC on both providers (ADR-0025).** `getCollection` and every collection method return a Promise; `find()` is sync and returns a cursor whose `toArray()` is async, exactly matching the MongoDB driver. `node:sqlite` is synchronous underneath, but the SHAPE never changes with the provider — before 3.13.95 the fallback was fully sync, so identical source changed TYPE when `TINA4_MONGO_URI` was set, and because a Promise is always truthy, `if (doc)` succeeded for a document that did not exist.
244
266
 
245
267
  ```typescript
246
268
  import { getCollection, isServerless, ObjectId } from "@tina4/orm";
247
269
 
248
- const orders = getCollection("orders") as any; // SqliteCollection in serverless mode
249
- const res = orders.insertOne({ customer_id: 1, total: 9.99, status: "new" });
250
- orders.findOne({ _id: res.insertedId });
251
- orders.updateOne({ _id: res.insertedId }, { $set: { status: "shipped" } });
252
- for (const doc of orders.find({ total: { $gt: 5 } }).sort("total", -1).limit(10)) {
270
+ const orders = (await getCollection("orders")) as any;
271
+ const res = await orders.insertOne({ customer_id: 1, total: 9.99, status: "new" });
272
+ await orders.findOne({ _id: res.insertedId });
273
+ await orders.updateOne({ _id: res.insertedId }, { $set: { status: "shipped" } });
274
+ // for await a real FindCursor has Symbol.asyncIterator only, never Symbol.iterator
275
+ for await (const doc of orders.find({ total: { $gt: 5 } }).sort("total", -1).limit(10)) {
253
276
  // ...
254
277
  }
255
- orders.countDocuments({ status: "shipped" });
256
- isServerless(); // true when running on the SQLite fallback
278
+ await orders.countDocuments({ status: "shipped" });
279
+ isServerless(); // true when running on the SQLite fallback (sync — reads config only)
257
280
  ```
258
281
 
259
282
  Filter operators: equality, `$in`, `$nin`, `$gt`, `$gte`, `$lt`, `$lte`, `$ne`, `$exists`, `$regex`, implicit AND, `$or`, `$and`, and dotted nested keys (`addr.city`). Updates: `$set`, `$unset`, `$inc`, replace, upsert. Cursors: `sort`, `limit`, `skip`, projection. Values round-trip (Date to/from ISO-8601, `ObjectId` to/from 24-hex) and stay queryable via `json_extract`. Non-goals: aggregation pipelines, `$elemMatch`, geo queries.
@@ -262,6 +285,8 @@ Selection and configuration:
262
285
  - `TINA4_MONGO_URI` — app-wide Mongo URI. Falls back to `TINA4_SESSION_MONGO_URI`, then the legacy `TINA4_SESSION_MONGO_URL`. When one is set, `getCollection` returns a real Mongo collection.
263
286
  - `TINA4_DOC_STORE_PATH` — SQLite file for the fallback store (default `data/tina4_docstore.db`).
264
287
 
288
+ **One client per (uri, database), and a way to close it.** `getCollection` caches the connected Mongo client rather than building a new one per call — before 3.13.95 it constructed a `new MongoClient` on EVERY call and never closed it, so 20 calls left 40 server connections open and the count grew without bound (invisible locally, because the SQLite fallback opens no connections at all). `await closeDocStore()` closes every Mongo client and the SQLite store; a pooled client keeps the event loop alive, so a script or test that touched the real provider needs it to exit.
289
+
265
290
  ### Request extras
266
291
 
267
292
  ```typescript
@@ -627,6 +652,9 @@ import { initDatabase, bindDatabase, createAdapterFromUrl, Database, DatabaseRes
627
652
  const db = await initDatabase({ url: "sqlite:///app.db" });
628
653
  // Connection pooling: pass `pool: 4` for round-robin connections.
629
654
 
655
+ // db.fetch() caps at 100 rows when no limit is passed (one number across all
656
+ // four frameworks). db.fetchAll() deliberately does NOT inherit the cap -- its
657
+ // name is the request for every row -- so it routes around fetch()'s default.
630
658
  // EVERY db method that touches the database is ASYNC on the Database wrapper --
631
659
  // it returns a Promise, so `await` it. (The node:sqlite ADAPTER underneath is
632
660
  // synchronous, but the wrapper is async so the query cache and the pg/mysql/
@@ -634,7 +662,7 @@ const db = await initDatabase({ url: "sqlite:///app.db" });
634
662
  // and close() are synchronous.
635
663
 
636
664
  // Reads — async, await them
637
- await db.fetch(sql, params?, limit?, offset?): Promise<DatabaseResult> // .records, .count, .limit, .offset
665
+ await db.fetch(sql, params?, limit?, offset?): Promise<DatabaseResult> // limit defaults to DEFAULT_ROW_CAP (100) // .records, .count, .limit, .offset
638
666
  await db.fetchOne<T>(sql, params?): Promise<T | null>
639
667
 
640
668
  // Writes — execute() RAISES on a SQL error (bad SQL, constraint violation,
@@ -773,8 +801,8 @@ User.find(id, include?);
773
801
  User.findById(id, include?);
774
802
  User.findOrFail(id); // throws if missing
775
803
  User.create(data); // construct + save
776
- User.all(where?, params?, include?);
777
- User.select(sql, params?);
804
+ User.all(limit?, offset?, include?, orderBy?); // limit defaults to 100; NO filter -- use where()
805
+ User.select(sql, params?, limit?, offset?); // limit defaults to 100
778
806
  User.selectOne(sql, params?, include?);
779
807
  User.where(conditions, params?, limit?, offset?, include?, orderBy?);
780
808
  User.count(conditions?, params?);
@@ -812,7 +840,7 @@ const orders = QueryBuilder.fromTable("orders o")
812
840
  .where("o.status = ?", ["pending"])
813
841
  .orderBy("o.created_at DESC")
814
842
  .limit(20)
815
- .get(); // → row[]
843
+ .get(); // → DatabaseResult (.records, .count, .limit, .offset)
816
844
 
817
845
  // LEFT JOIN
818
846
  QueryBuilder.fromTable("products p")
@@ -965,6 +993,10 @@ queue.purge("completed");
965
993
  queue.retryFailed();
966
994
  queue.deadLetters();
967
995
  queue.produce("notifications", payload, 0, 0);
996
+ // Release the backend connection. Idempotent; discard the queue afterwards.
997
+ // Node caveat: neither reachable backend holds a connection between calls today
998
+ // (ADR-0022 child-process design), so this releases nothing YET - it is the contract.
999
+ queue.close();
968
1000
 
969
1001
  // Job methods
970
1002
  job?.complete();
@@ -984,6 +1016,37 @@ for await (const job of queue.consume("emails")) {
984
1016
  // pollInterval=0 for single-pass drain (tests).
985
1017
  ```
986
1018
 
1019
+ ## Graceful shutdown (`packages/core/src/server.ts`)
1020
+
1021
+ `startServer()` owns signal handling. It is the ONLY place that registers
1022
+ `process.on("SIGTERM"/"SIGINT")` — `background.ts` and the CLI's `serve.ts`
1023
+ deliberately register none.
1024
+
1025
+ On SIGTERM or SIGINT it: stops background tasks, sends RFC 6455 close code
1026
+ **1001 ("going away")** to every live WebSocket, closes the listeners so new
1027
+ connections get a clean refusal, waits for in-flight requests to finish (up to
1028
+ `TINA4_SHUTDOWN_TIMEOUT`), closes the database, and exits **0**.
1029
+
1030
+ | Env var | Default | Purpose |
1031
+ | --- | --- | --- |
1032
+ | `TINA4_SHUTDOWN_TIMEOUT` | `30` | Seconds to wait for in-flight requests before force-closing them. Matches Kubernetes' default `terminationGracePeriodSeconds` and Gunicorn's `graceful_timeout`, and is the same env var and default as tina4-python / tina4-php / tina4-ruby. A non-numeric or negative value warns and falls back to 30. |
1033
+ | `TINA4_DEFAULT_WEBSERVER` | unset | **Accepted and ignored in Node.** `TRUE` pins the built-in server. Node has only one server (`node:http`), so there is nothing to switch and this is a genuine no-op. It exists here so the env surface is identical across all four frameworks: in tina4-python it forces the built-in asyncio server instead of uvicorn/hypercorn/granian, and in tina4-ruby it forces WEBrick instead of Puma. Setting it must never be an error. |
1034
+
1035
+ **SIGHUP is deliberately NOT trapped** — the default disposition terminates the
1036
+ process. The Rust CLI owns file watching and production logs go to stdout, so
1037
+ neither Puma's log-reopen nor gunicorn's config-reload use for SIGHUP applies.
1038
+ `test/gracefulShutdown.test.ts` pins this so it is not restored by accident.
1039
+
1040
+ **Never register a signal handler that does not exit.** Adding any listener for
1041
+ SIGTERM REPLACES Node's default disposition, so a handler that only cleans up
1042
+ does not "add" to the default, it CANCELS it — the process then ignores SIGTERM
1043
+ and runs until SIGKILL. `background.ts` shipped exactly that bug: a server with
1044
+ one registered `background()` task hung forever on SIGTERM, burning the whole
1045
+ Kubernetes grace period on every rolling deploy.
1046
+
1047
+ Set `terminationGracePeriodSeconds` ABOVE `TINA4_SHUTDOWN_TIMEOUT` in your pod
1048
+ spec so the drain finishes before SIGKILL.
1049
+
987
1050
  ## Module: Background Tasks (`packages/core/src/background.ts`)
988
1051
 
989
1052
  Periodic callbacks that run alongside the HTTP server. Use this instead of bare `setInterval` so timers integrate with the server lifecycle and clear on graceful shutdown.
@@ -1001,11 +1064,11 @@ background(async () => {
1001
1064
  }, 30);
1002
1065
 
1003
1066
  task.stop(); // stop just this one
1004
- stopAllBackgroundTasks(); // stop everything (also runs on SIGTERM/SIGINT)
1067
+ stopAllBackgroundTasks(); // stop everything (the server's shutdown calls this on SIGTERM/SIGINT)
1005
1068
  backgroundTaskCount(); // test helper
1006
1069
  ```
1007
1070
 
1008
- **Never use bare `setInterval` for periodic work in a Tina4 app.** `background()` catches errors, integrates with shutdown signals, calls `timer.unref()` so it doesn't block process exit, and matches Python's `background()` API exactly.
1071
+ **Never use bare `setInterval` for periodic work in a Tina4 app.** `background()` catches errors, is cleared by the server's graceful shutdown (which owns the signal handlers), calls `timer.unref()` so it doesn't block process exit, and matches Python's `background()` API exactly.
1009
1072
 
1010
1073
  ## Module: DI Container (`packages/core/src/container.ts`)
1011
1074
 
@@ -1161,8 +1224,15 @@ Set `TINA4_DATABASE_URL` in your `.env` file using `driver://host:port/database`
1161
1224
 
1162
1225
  ```bash
1163
1226
  # SQLite (default if nothing configured)
1164
- TINA4_DATABASE_URL=sqlite:///path/to/db.sqlite
1227
+ # Slash count decides relative vs absolute (the SQLAlchemy convention,
1228
+ # identical in all four frameworks). THREE slashes is RELATIVE to the working
1229
+ # directory; an absolute path needs FOUR.
1230
+ TINA4_DATABASE_URL=sqlite:///app.db # relative: ./app.db
1231
+ TINA4_DATABASE_URL=sqlite:////var/data/app.db # absolute: /var/data/app.db
1232
+ TINA4_DATABASE_URL=sqlite:/var/data/app.db # absolute (one slash) too
1165
1233
  TINA4_DATABASE_URL=sqlite://./data/tina4.db
1234
+ # FOOTGUN: "sqlite://" + an absolute path yields THREE slashes, so the file is
1235
+ # created UNDER the working directory and a stray ./var/data/ tree appears.
1166
1236
 
1167
1237
  # PostgreSQL
1168
1238
  TINA4_DATABASE_URL=postgres://localhost:5432/mydb
@@ -1194,6 +1264,64 @@ TINA4_DATABASE_PASSWORD=mypass
1194
1264
 
1195
1265
  Credential priority: `config.user` > `config.username` > `TINA4_DATABASE_USERNAME` env var.
1196
1266
 
1267
+ ### Connect timeout
1268
+
1269
+ `TINA4_DATABASE_CONNECT_TIMEOUT` bounds every database connect attempt. Same name,
1270
+ unit, default and semantics in all four frameworks.
1271
+
1272
+ | | |
1273
+ |---|---|
1274
+ | unit | **seconds** |
1275
+ | default | `10` |
1276
+ | `<= 0` | disables the bound (unbounded — each driver keeps exactly its old behaviour) |
1277
+ | garbage | warns and uses `10` |
1278
+ | on expiry | throws, naming the host, the port, the elapsed seconds, and this variable |
1279
+
1280
+ Without it a driver that never calls back hangs the app on connect with no log, no
1281
+ error and no signal — measured on the Firebird adapter at 16 minutes, 0.0% CPU. It
1282
+ is applied in two layers: the driver's own knob where it has one (so one variable
1283
+ really governs, rather than tedious's 15s or Mongo's 30s quietly winning), and an
1284
+ outer bound around the whole attempt (so it exists at all for `node-firebird`,
1285
+ which has no knob, and so a knob that covers only part of the handshake cannot
1286
+ leave a gap).
1287
+
1288
+ **The driver's timer is meant to WIN, and Tina4 translates what it raises.** The
1289
+ knob is set to the bound EXACTLY (rounded up to whole milliseconds, floored at 1 —
1290
+ three of the four knobs read `0` as *wait forever*), Tina4's own clock starts
1291
+ before the driver arms its timer, and a driver failure that took at least that long
1292
+ is re-thrown in the framework's words with the driver's error preserved as `cause`:
1293
+
1294
+ ```
1295
+ Database connect to 127.0.0.1:34467 timed out after 2.0s
1296
+ (TINA4_DATABASE_CONNECT_TIMEOUT=2 seconds; set it to 0 to wait indefinitely).
1297
+ Driver reported: timeout expired
1298
+ ```
1299
+
1300
+ That is real output, captured against a server that accepts and never replies.
1301
+ Each driver contributes its own words — pg `timeout expired`, mysql2 `connect
1302
+ ETIMEDOUT`, tedious `Failed to connect to 127.0.0.1:34467 in 2000ms`, Mongo
1303
+ `Server selection timed out after 2000 ms` — and the last two quote the budget
1304
+ they were GIVEN, which is how you can see that `2` means 2000ms and not 3000ms.
1305
+ Firebird and ODBC have no knob, so the outer bound fires instead and there is no
1306
+ `Driver reported:` clause to add.
1307
+
1308
+ `N` means `N`. Whether a failure was the bound expiring is decided by **elapsed
1309
+ time**, never by matching the driver's error text — the four clients word it four
1310
+ different ways and a marker table would drift and miss. A 50ms tolerance absorbs
1311
+ libuv's timer granularity without inflating `N`. The outer bound remains as the
1312
+ backstop for the phases a knob does not cover and the adapters that have none;
1313
+ being armed after the driver's, it only fires when the driver's did not.
1314
+
1315
+ | Adapter | Can connect block? | Bounded now | How |
1316
+ |---------|--------------------|-------------|-----|
1317
+ | Firebird | yes — **the measured 16-minute hang** | ✅ | outer bound only (`node-firebird` has no timeout option) |
1318
+ | PostgreSQL | yes — pg's `connectionTimeoutMillis` defaults to `0`, no timeout | ✅ | `connectionTimeoutMillis` + outer bound |
1319
+ | MySQL | bounded at mysql2's own 10s default | ✅ | `connectTimeout` + outer bound |
1320
+ | MSSQL | bounded at tedious's own 15s default | ✅ | `connectTimeout` + outer bound |
1321
+ | MongoDB | bounded at the driver's own 30s default | ✅ | `serverSelectionTimeoutMS` + `connectTimeoutMS` + outer bound |
1322
+ | ODBC | yes — a raw driver string carries no timeout | ⚠️ caller only | outer bound; the blocked native thread cannot be cancelled from JS |
1323
+ | SQLite | no network; opens in a **synchronous** constructor | ❌ N/A | a sync call cannot be interrupted by a timer on the same thread, and there is no host/port to name |
1324
+
1197
1325
  ### Programmatic configuration
1198
1326
  ```typescript
1199
1327
  import { initDatabase } from "@tina4/orm";
@@ -1219,9 +1347,10 @@ Run tests with:
1219
1347
  npm test
1220
1348
  ```
1221
1349
 
1222
- This executes `test/run-all.ts` which runs all 43 test files:
1350
+ This executes `test/run-all.ts`, which ran **262 files** in the last lab verification:
1223
1351
  - `test/integration.ts` — Full integration test (creates a temp project, starts a real server, runs assertions)
1224
- - `test/*.test.ts` — 42 individual test files covering all subsystems (ORM, routing, middleware, database drivers, sessions, queues, WebSocket, GraphQL, i18n, etc.)
1352
+ - `test/*.test.ts` — 267 files on disk covering all subsystems (ORM, routing, middleware, database drivers, sessions, queues, WebSocket, GraphQL, i18n, etc.). 259 are spawned by the runner under `tsx`, plus `integration.ts`; the 2 i18n suites are vitest and the runner drives them itself (so no invoker can miss them); the 6 `metrics*` files need the Rust CLI's native engine and are opted in with `TINA4_SKIP_METRICS=0`.
1353
+ - `test/_*.ts` are helpers, not suites, so the runner never collects them: `_serviceGate.ts` (the require-services gate), `_testSummary.ts`, and `_driverlessTree.ts` (builds a tree where a bare specifier genuinely cannot resolve).
1225
1354
 
1226
1355
  **Always run tests after making changes.** All tests must pass.
1227
1356
 
@@ -1257,7 +1386,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1257
1386
  ## v3 Features Summary
1258
1387
 
1259
1388
  - **98 built-in features**, zero third-party dependencies
1260
- - **6,230 tests** passing, 0 failed, across 198 files (build + typecheck green) - measured 2026-07-29 on Ubuntu 24.04.4 LTS x86_64, Node 24.18.0, live services, TINA4_REQUIRE_SERVICES=1; Firebird excluded by design
1389
+ - **7,537 tests** passing, 0 failed, **0 skipped** across 262 files (typecheck exit 0) - measured 2026-08-06 on the lab host (Ubuntu 24.04.4 LTS x86_64, Node v24.18.0) against live services with TINA4_REQUIRE_SERVICES=1. **Firebird is NOT excluded** - `test/firebird*.test.ts` runs against a REAL Firebird 5. The last six skips are closed, each by giving it the environment its condition needs rather than by relaxing what it asserts: the four missing-driver cases run in a child process that genuinely cannot resolve the driver (the source is copied out of the repo to a tree with no `node_modules` above it and run with plain `node` - `test/_driverlessTree.ts`, no resolver shim); the strict-mode write failure drops the effective uid so a 0400 file really denies root; and the RabbitMQ default-config round-trip clears the per-framework isolation overrides for that one case so "default" means the default. Every one was proven able to FAIL by mutating the code it guards.
1261
1390
  - **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
1262
1391
  - **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)
1263
1392
  - **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
@@ -1267,7 +1396,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1267
1396
  - **Sessions**: file backend (default). `TINA4_SESSION_SAMESITE` env var (default: Lax)
1268
1397
  - **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars. **Reservation/visibility timeout** (file + MongoDB): a popped job is reserved for `TINA4_QUEUE_VISIBILITY_TIMEOUT` seconds (default 300; `visibilityTimeout` Queue option; `<= 0` disables) — if the consumer dies before `complete()`/`fail()`, the next `pop()` reclaims it (incrementing `attempts`, dead-lettering past `maxRetries`), so a crashed/evicted consumer never strands a job. RabbitMQ/Kafka delegate redelivery to the broker.
1269
1398
  - **Cache**: memory/Redis/file backends
1270
- - **Messenger**: .env driven SMTP/IMAP
1399
+ - **Messenger**: .env driven SMTP/IMAP. **Cross-framework contract:** `inbox(folder = "INBOX", limit = 20, offset = 0)` takes the folder FIRST (same order in all four frameworks — Node was the outlier at `(limit, offset, folder)` before 3.13.95); `uid` is a **string** everywhere; `read(uid, folder?)` returns `ImapFullMessage | null` and a non-existent UID reads as **`null`** — falsy, so `if (!message)` is the portable missing-message check, matching Python's `{}`, PHP's `null` and Ruby's `nil`. A missing UID is NOT an error and never throws. IMAP reads DO fail loud on a connection/auth/protocol failure: `inbox`/`read`/`unread`/`search`/`folders` all raise `MessengerConnectionError` rather than swallowing it into an empty result. Real SMTP + IMAP round-trips are covered against a live GreenMail in `test/messengerGreenMail.test.ts` (ports 3025/3143; `TINA4_TEST_SMTP_*` / `TINA4_TEST_IMAP_*` to relocate)
1271
1400
  - **ORM relationships**: `hasMany`, `hasOne`, `belongsTo` with eager loading (`include`)
1272
1401
  - **Frond pre-compilation**: 2.8x template render improvement
1273
1402
  - **QueryBuilder** with NoSQL/MongoDB support (`toMongo()`)
package/README.md CHANGED
@@ -59,7 +59,7 @@ export default class User {
59
59
  | **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
60
60
  | **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
61
61
  | **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
62
- | **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
62
+ | **Auth & Security** (5) | JWT (HS256/HS384/HS512 standard, RS256 opt-in, all from builtin `node:crypto`), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
63
63
  | **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8× faster), SCSS auto-compilation, built-in CSS (~24 KB) |
64
64
  | **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
65
65
  | **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.94",
3
+ "version": "3.13.95",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -98,6 +98,7 @@
98
98
  "@types/pg": "^8.20.0",
99
99
  "esbuild": "^0.24.0",
100
100
  "mongodb": "^6.0.0",
101
+ "node-firebird": "^2.14.3",
101
102
  "tsx": "^4.19.0",
102
103
  "typescript": "^5.7.0",
103
104
  "vitest": "^4.1.9"