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.
- package/CLAUDE.md +157 -28
- package/README.md +1 -1
- package/package.json +2 -1
- package/packages/cli/dist/bin.js +32418 -29638
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +32364 -29501
- package/packages/core/src/ai.ts +7 -1
- package/packages/core/src/auth.ts +191 -39
- package/packages/core/src/background.ts +19 -19
- package/packages/core/src/cache.ts +492 -49
- package/packages/core/src/devAdmin.ts +79 -32
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +5 -4
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +9 -13
- package/packages/core/src/metrics.ts +199 -961
- package/packages/core/src/middleware.ts +390 -123
- package/packages/core/src/queue.ts +188 -32
- package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
- package/packages/core/src/queueBackends/liteBackend.ts +13 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
- package/packages/core/src/rateLimiter.ts +10 -5
- package/packages/core/src/request.ts +6 -9
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +751 -414
- package/packages/core/src/session.ts +244 -27
- package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -147
- package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
- package/packages/core/src/testClient.ts +18 -5
- package/packages/core/src/trustedProxy.ts +249 -0
- package/packages/core/src/types.ts +29 -5
- package/packages/core/src/websocket.ts +66 -0
- package/packages/orm/dist/index.js +22367 -19504
- package/packages/orm/src/adapters/firebird.ts +183 -56
- package/packages/orm/src/adapters/mongodb.ts +25 -4
- package/packages/orm/src/adapters/mssql.ts +114 -29
- package/packages/orm/src/adapters/mysql.ts +103 -40
- package/packages/orm/src/adapters/odbc.ts +44 -21
- package/packages/orm/src/adapters/postgres.ts +118 -26
- package/packages/orm/src/adapters/sqlDialect.ts +120 -0
- package/packages/orm/src/adapters/sqlite.ts +60 -24
- package/packages/orm/src/baseModel.ts +135 -40
- package/packages/orm/src/cachedDatabase.ts +43 -19
- package/packages/orm/src/connectTimeout.ts +265 -0
- package/packages/orm/src/database.ts +237 -197
- package/packages/orm/src/databaseResult.ts +65 -13
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -3
- package/packages/orm/src/migration.ts +18 -3
- package/packages/orm/src/queryBuilder.ts +38 -4
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +15 -4
- package/types/core/src/ai.d.ts +1 -1
- package/types/core/src/auth.d.ts +28 -5
- package/types/core/src/background.d.ts +3 -3
- package/types/core/src/cache.d.ts +15 -12
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/dotenv.d.ts +38 -16
- package/types/core/src/index.d.ts +5 -6
- package/types/core/src/logger.d.ts +93 -16
- package/types/core/src/messenger.d.ts +2 -2
- package/types/core/src/metrics.d.ts +25 -61
- package/types/core/src/middleware.d.ts +134 -11
- package/types/core/src/queue.d.ts +54 -5
- package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
- package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
- package/types/core/src/router.d.ts +14 -3
- package/types/core/src/server.d.ts +15 -0
- package/types/core/src/session.d.ts +87 -2
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
- package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
- package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
- package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
- package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +28 -5
- package/types/core/src/websocket.d.ts +26 -0
- package/types/orm/src/adapters/firebird.d.ts +55 -10
- package/types/orm/src/adapters/mongodb.d.ts +2 -2
- package/types/orm/src/adapters/mssql.d.ts +18 -11
- package/types/orm/src/adapters/mysql.d.ts +11 -10
- package/types/orm/src/adapters/odbc.d.ts +9 -12
- package/types/orm/src/adapters/postgres.d.ts +11 -10
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +15 -3
- package/types/orm/src/baseModel.d.ts +45 -9
- package/types/orm/src/cachedDatabase.d.ts +18 -5
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +72 -26
- package/types/orm/src/databaseResult.d.ts +24 -0
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +102 -43
- package/types/orm/src/index.d.ts +5 -2
- package/types/orm/src/queryBuilder.d.ts +23 -3
- package/types/orm/src/sqlTranslator.d.ts +126 -2
- package/types/orm/src/types.d.ts +14 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
- 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.
|
|
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.
|
|
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
|
|
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 #
|
|
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:**
|
|
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
|
|
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(
|
|
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,
|
|
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
|
|
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;
|
|
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
|
|
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(
|
|
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(); // →
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
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
|
|
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` —
|
|
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
|
-
- **
|
|
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.
|
|
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"
|