tina4-nodejs 3.13.37 → 3.13.39
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 +65 -20
- package/README.md +6 -6
- package/package.json +5 -3
- package/packages/cli/src/bin.ts +7 -0
- package/packages/cli/src/commands/init.ts +1 -0
- package/packages/cli/src/commands/metrics.ts +154 -0
- package/packages/cli/src/commands/routes.ts +3 -3
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +66 -44
- package/packages/core/src/devMailbox.ts +4 -0
- package/packages/core/src/dotenv.ts +13 -4
- package/packages/core/src/events.ts +86 -4
- package/packages/core/src/graphql.ts +182 -128
- package/packages/core/src/htmlElement.ts +62 -3
- package/packages/core/src/index.ts +21 -10
- package/packages/core/src/logger.ts +85 -28
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +557 -98
- package/packages/core/src/middleware.ts +130 -40
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
- package/packages/core/src/rateLimiter.ts +1 -1
- package/packages/core/src/response.ts +90 -6
- package/packages/core/src/router.ts +56 -8
- package/packages/core/src/server.ts +138 -23
- package/packages/core/src/session.ts +130 -18
- package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
- package/packages/core/src/testClient.ts +1 -1
- package/packages/core/src/types.ts +17 -2
- package/packages/core/src/websocket.ts +666 -42
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/core/src/wsdl.ts +55 -21
- package/packages/orm/src/adapters/pg-types.d.ts +60 -0
- package/packages/orm/src/adapters/postgres.ts +26 -4
- package/packages/orm/src/adapters/sqlite.ts +112 -13
- package/packages/orm/src/baseModel.ts +175 -25
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +6 -1
- package/packages/orm/src/migration.ts +151 -24
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- package/packages/swagger/src/ui.ts +1 -1
package/CLAUDE.md
CHANGED
|
@@ -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,18 +120,18 @@ 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. **Per-route auth (v3.13.39, Python master b5976d4):** a WS route is PUBLIC by default (mirrors GET). Mark it secured imperatively — `Router.websocket(path, fn, { secured: true })` / chain `.secure()` on the returned `WsRouteRef` / `WebSocketServer.route(path, fn, { secured: true })` — OR decorator-style via a `_secured` flag on the handler (works in either declaration order). A secured route enforces a valid JWT on the upgrade, AFTER the origin allow-list and BEFORE accept: missing/invalid → reject with HTTP 401 (never accept); public routes always pass (non-breaking). Three token transports (`wsToken(headers, query, subprotocol)`): (1) `Authorization: Bearer <jwt>` header (server/CLI/mobile); (2) the `Sec-WebSocket-Protocol` subprotocol `"bearer, <jwt>"` (browsers — `new WebSocket()` can't set headers; the server echoes `bearer` back as the accepted subprotocol); (3) `?token=<jwt>` query param. Validated via the same `validToken` (Auth) the HTTP routes use. The verified payload is exposed as `connection.auth` (`null` on public routes). `wsAuthorized(route, headers, query, subprotocol) -> [payload, ok]` is the gate. **The integrated server (`server.ts`) now dispatches user WS routes:** its `upgrade` handler routes a non-`/__dev_reload` upgrade through `serveWebSocketRoute(req, socket, head)`, which matches the WS route table, enforces per-route auth, then drives the real open/message/close lifecycle on the connection (`wsRouteManager`) — parity with Python/PHP/Ruby (previously only `/__dev_reload` was wired, so user WS routes never reached a live connection).
|
|
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
|
-
- `logger.ts` — Structured logging
|
|
127
|
+
- `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.
|
|
128
128
|
- `rateLimiter.ts` — Rate limiting middleware
|
|
129
129
|
- `dotenv.ts` — `.env` file loading
|
|
130
130
|
- `health.ts` — Health check endpoint
|
|
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`
|
|
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
|
-
|
|
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
|
-
}, {
|
|
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);
|
|
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 —
|
|
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
|
|
596
|
-
// TTL TINA4_AUTO_CACHING_TTL=5s):
|
|
597
|
-
//
|
|
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
|
|
@@ -743,6 +775,8 @@ const adults = User.query().where("age > ?", [18]).orderBy("name").get();
|
|
|
743
775
|
|
|
744
776
|
SQL-file based migrations under `migrations/`. The framework runs pending migrations on startup; the helpers here are for programmatic control (CLI, scripts, tests).
|
|
745
777
|
|
|
778
|
+
**Auto-run on startup (`TINA4_AUTO_MIGRATE`, default on).** `startServer()` calls `autoMigrateOnStartup()` (in `server.ts`) AFTER `initDatabase()`/model sync and BEFORE the server listens. When a `migrations/` folder with at least one `.sql` file exists, `TINA4_AUTO_MIGRATE` is not falsy (default `"true"`; `false`/`0`/`no`/`off` disable), and a DB adapter is resolvable, it runs the existing `migrate()` runner so the schema is current with no manual `tina4 migrate` step. It is **non-breaking**: a failure is logged via `Log.error` and the service still starts (a bad migration must never take the backend down). The runner is wrapped in try/catch and `autoMigrateOnStartup()` never rejects/throws out of the boot path. Set `TINA4_AUTO_MIGRATE=false` to disable (e.g. multi-instance production that migrates as a separate deploy step — concurrent first-apply can race). The explicit `tina4 migrate` CLI (`packages/cli/src/commands/migrate.ts`) is unaffected and stays **fail-fast** (`process.exit(1)` on a statement error) so CI keeps the non-zero exit code.
|
|
779
|
+
|
|
746
780
|
```typescript
|
|
747
781
|
import {
|
|
748
782
|
migrate, rollback, status, createMigration, syncModels,
|
|
@@ -756,7 +790,16 @@ await createMigration("add users table"); // scaffolds migrations/<ts>_add_use
|
|
|
756
790
|
syncModels(discoveredModels); // auto-create tables / add columns from `static fields`
|
|
757
791
|
```
|
|
758
792
|
|
|
759
|
-
|
|
793
|
+
### How migrations work internally
|
|
794
|
+
|
|
795
|
+
- SQL files live in `migrations/`, named `NNNNNN_description.sql` (sequential) or `YYYYMMDDHHMMSS_description.sql` (timestamp), and are split on the `;` delimiter.
|
|
796
|
+
- Files are applied in **numeric-prefix order** (`9_` before `10_` — a plain lexical sort misorders unpadded prefixes because `"10" < "9"`). A file with no numeric/timestamp prefix sorts **after** the numbered ones (lexically) and logs a `Log.warning` — its order is undefined.
|
|
797
|
+
- State is tracked by **row existence** in `tina4_migration` (id, name, batch, applied_at), auto-created per engine: a migration runs once — if a row with its `name` exists it is skipped. A FAILED migration is **never** recorded (no row is written, nothing is deleted) — fix the bad file and re-run; the `migrate()` summary's `failed[]` carries the failure.
|
|
798
|
+
- **Each migration FILE is wrapped in its own transaction.** On a failure the file rolls back and `migrate()` **STOPS** — later files are never applied on top of a missing earlier one (parity with Python/PHP/Ruby). Already-applied files stay applied. The explicit `tina4 migrate` CLI surfaces a non-empty `failed[]` as a non-zero exit; startup auto-migration logs it and the service still boots (see `TINA4_AUTO_MIGRATE` above).
|
|
799
|
+
- **Atomicity caveat:** per-file transactions are truly atomic only on engines with **transactional DDL (PostgreSQL)**. MySQL, Firebird, and SQLite auto-commit DDL, so a multi-statement migration that fails midway on those engines leaves earlier statements applied — keep one logical change per file. `CREATE TABLE` and `ALTER TABLE ... ADD` are made idempotent on Firebird/MSSQL (existence-checked via `RDB$RELATION_FIELDS` / `tableExists`) so a re-run with a raw `CREATE`/`ADD` does not error "object already exists"; SQLite/MySQL/PostgreSQL support `IF NOT EXISTS` and are left to the engine. Only a genuine already-exists is skipped — every other error still raises.
|
|
800
|
+
- The stored-proc block delimiters (`$$ … $$` / `// … //`) are extracted before splitting, but a `//` preceded by a colon is **not** treated as a delimiter, so a URL (`https://…`) or any `://` literal inside a migration is never swallowed as an opaque block.
|
|
801
|
+
|
|
802
|
+
Schema sync (`syncModels`) runs alongside SQL migrations on boot.
|
|
760
803
|
|
|
761
804
|
## Module: Frond (`packages/frond/src/engine.ts`)
|
|
762
805
|
|
|
@@ -789,6 +832,8 @@ frond.unsandbox();
|
|
|
789
832
|
|
|
790
833
|
Zero-dep HTTP client over `node:http` / `node:https`. Used by integrations, queue producers, health checks, and tests.
|
|
791
834
|
|
|
835
|
+
**Retry/backoff (opt-in, default off):** pass `maxRetries` (default `0`) and `retryBackoff` (default `0.5`s base, exponential) in the options bag — `new Api(url, { bearerToken, maxRetries: 3, retryBackoff: 0.5 })`. Retries a transport error (`http_code` null) or a retryable status (429/500/502/503/504); 4xx is never retried (a retried non-idempotent request may be re-sent, so retries are opt-in). Node's `node:http`/`node:https` doesn't auto-follow redirects, so there's no cross-host Authorization-leak surface (the redirect auth-strip is Python-only).
|
|
836
|
+
|
|
792
837
|
```typescript
|
|
793
838
|
import { Api } from "@tina4/core";
|
|
794
839
|
|
|
@@ -1125,7 +1170,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
|
|
|
1125
1170
|
- **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
1171
|
- **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
|
|
1127
1172
|
- **`npx tina4nodejs generate`**: model, route, migration, middleware scaffolding
|
|
1128
|
-
- **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **
|
|
1173
|
+
- **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
1174
|
- **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
1175
|
- **Sessions**: file backend (default). `TINA4_SESSION_SAMESITE` env var (default: Lax)
|
|
1131
1176
|
- **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
|
|
@@ -1134,11 +1179,11 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
|
|
|
1134
1179
|
- **ORM relationships**: `hasMany`, `hasOne`, `belongsTo` with eager loading (`include`)
|
|
1135
1180
|
- **Frond pre-compilation**: 2.8x template render improvement
|
|
1136
1181
|
- **QueryBuilder** with NoSQL/MongoDB support (`toMongo()`)
|
|
1137
|
-
- **WebSocket backplane** (Redis pub/sub) for horizontal scaling
|
|
1182
|
+
- **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
1183
|
- **SameSite=Lax** default on session cookies (`TINA4_SESSION_SAMESITE`)
|
|
1139
1184
|
- **`tina4 deploy docker`** generates Dockerfile and .dockerignore
|
|
1140
1185
|
- **Gallery**: 7 interactive examples with Try It deploy at `/_dev/`
|
|
1141
|
-
- **SSE/Streaming**: `response.stream()` for Server-Sent Events — pass an async generator
|
|
1186
|
+
- **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
1187
|
|
|
1143
1188
|
## Don'ts
|
|
1144
1189
|
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
## Quick Start
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
# With the Tina4 CLI (recommended
|
|
20
|
+
# With the Tina4 CLI (recommended, enables SCSS + live reload)
|
|
21
21
|
cargo install tina4 # or grab a binary from https://github.com/tina4stack/tina4/releases
|
|
22
22
|
tina4 init nodejs ./my-app
|
|
23
23
|
cd my-app && tina4 serve
|
|
@@ -57,12 +57,12 @@ export default class User {
|
|
|
57
57
|
| Category | Features |
|
|
58
58
|
|----------|----------|
|
|
59
59
|
| **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
|
|
60
|
-
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird
|
|
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
62
|
| **Auth & Security** (5) | JWT (HS256/RS256), 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
|
-
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters
|
|
65
|
+
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
|
|
66
66
|
| **Data & Storage** (4) | Session (File/Redis/Valkey/MongoDB/DB), response cache (LRU, TTL), seeder + 50+ fake data generators, messenger (SMTP/IMAP) |
|
|
67
67
|
| **Developer Tools** (7) | Dev dashboard (11 tabs), dev toolbar, error overlay (Catppuccin Mocha), dev mailbox, hot reload + CSS hot-reload, code metrics (complexity, coupling, maintainability), AI context installer (7 tools) |
|
|
68
68
|
| **Utilities** (7) | DI container (transient + singleton), HtmlElement builder, inline testing (`@tests` decorator), i18n (6 languages), Swagger/OpenAPI auto-generation, CLI scaffolding (`generate model/route/migration/middleware`), structured logging |
|
|
@@ -86,14 +86,14 @@ npx tina4nodejs generate model <name>
|
|
|
86
86
|
|
|
87
87
|
## Performance
|
|
88
88
|
|
|
89
|
-
Benchmarked with `wrk
|
|
89
|
+
Benchmarked with `wrk`: 5,000 requests, 50 concurrent, median of 3 runs:
|
|
90
90
|
|
|
91
91
|
| Framework | JSON req/s | Deps | Features |
|
|
92
92
|
|-----------|-----------|------|----------|
|
|
93
93
|
| Raw `node:http` | 91,110 | 0 | 1 |
|
|
94
94
|
| **Tina4 Node.js** | **84,771** | 0 | 55 |
|
|
95
95
|
|
|
96
|
-
Tina4 Node.js runs at **93% of raw Node.js speed** while providing 55 built-in features
|
|
96
|
+
Tina4 Node.js runs at **93% of raw Node.js speed** while providing 55 built-in features, a zero-overhead architecture.
|
|
97
97
|
|
|
98
98
|
**Across all 4 Tina4 implementations:**
|
|
99
99
|
|
|
@@ -107,7 +107,7 @@ Tina4 Node.js runs at **93% of raw Node.js speed** while providing 55 built-in f
|
|
|
107
107
|
|
|
108
108
|
## Cross-Framework Parity
|
|
109
109
|
|
|
110
|
-
Tina4 ships identical features across four languages
|
|
110
|
+
Tina4 ships identical features across four languages: same architecture, same conventions, same 55 features:
|
|
111
111
|
|
|
112
112
|
| | Python | PHP | Ruby | Node.js |
|
|
113
113
|
|---|--------|-----|------|---------|
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
"version": "3.13.
|
|
6
|
+
"version": "3.13.39",
|
|
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
|
}
|
package/packages/cli/src/bin.ts
CHANGED
|
@@ -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");
|
|
@@ -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
|
|
36
|
-
const pathCmp = a.
|
|
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.
|
|
53
|
+
const path = route.pattern.padEnd(40);
|
|
54
54
|
const summary = route.meta?.summary ?? "";
|
|
55
55
|
console.log(` ${method}${path}${summary}`);
|
|
56
56
|
}
|
package/packages/core/src/api.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface ApiResult {
|
|
|
18
18
|
error: string | null;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* HTTP statuses that warrant an automatic retry when `maxRetries` > 0:
|
|
23
|
+
* rate-limit (429) plus the transient server-side 5xx family. 4xx client
|
|
24
|
+
* errors (401, 404, …) are NOT retried — a repeat won't succeed.
|
|
25
|
+
*/
|
|
26
|
+
const RETRY_STATUSES: ReadonlySet<number> = new Set([429, 500, 502, 503, 504]);
|
|
27
|
+
|
|
21
28
|
/**
|
|
22
29
|
* Constructor options for {@link Api}. Used as the second argument to
|
|
23
30
|
* `new Api(url, { ... })` — cross-framework parity with Python
|
|
@@ -33,6 +40,16 @@ export interface ApiOptions {
|
|
|
33
40
|
username?: string;
|
|
34
41
|
password?: string;
|
|
35
42
|
headers?: Record<string, string>;
|
|
43
|
+
/**
|
|
44
|
+
* Maximum automatic retries on a transient failure (default 0 = off, so
|
|
45
|
+
* existing callers are unaffected). When > 0, a transport error or a
|
|
46
|
+
* retryable status (429/5xx) is retried up to this many times with
|
|
47
|
+
* exponential backoff. NOTE: a retried non-idempotent request (POST/…)
|
|
48
|
+
* may be re-sent — retries are opt-in for that reason.
|
|
49
|
+
*/
|
|
50
|
+
maxRetries?: number;
|
|
51
|
+
/** Base backoff in seconds, doubling each attempt (default 0.5). */
|
|
52
|
+
retryBackoff?: number;
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
export class Api {
|
|
@@ -41,6 +58,8 @@ export class Api {
|
|
|
41
58
|
private timeout: number;
|
|
42
59
|
private authHeader: string;
|
|
43
60
|
private ignoreSsl: boolean;
|
|
61
|
+
private maxRetries: number;
|
|
62
|
+
private retryBackoff: number;
|
|
44
63
|
|
|
45
64
|
/**
|
|
46
65
|
* Construct an Api client.
|
|
@@ -60,6 +79,14 @@ export class Api {
|
|
|
60
79
|
* Bearer wins over basic-auth when both passed. `verifySsl: false` is
|
|
61
80
|
* the positive form of `ignoreSsl: true`; `ignoreSsl` wins when both
|
|
62
81
|
* supplied for backward compatibility.
|
|
82
|
+
*
|
|
83
|
+
* `maxRetries` (default 0 = off) enables automatic retry with
|
|
84
|
+
* exponential backoff (`retryBackoff` seconds base, doubling each
|
|
85
|
+
* attempt) on a transport error or a retryable status (429/5xx). A
|
|
86
|
+
* retried non-idempotent request (POST/…) may be re-sent — retries are
|
|
87
|
+
* opt-in for that reason.
|
|
88
|
+
*
|
|
89
|
+
* new Api("https://api.example.com", { maxRetries: 3, retryBackoff: 0.5 });
|
|
63
90
|
*/
|
|
64
91
|
constructor(
|
|
65
92
|
baseUrl: string = "",
|
|
@@ -68,6 +95,9 @@ export class Api {
|
|
|
68
95
|
) {
|
|
69
96
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
70
97
|
this.headers = {};
|
|
98
|
+
// Retry defaults: off (0) so existing callers are unaffected.
|
|
99
|
+
this.maxRetries = 0;
|
|
100
|
+
this.retryBackoff = 0.5;
|
|
71
101
|
|
|
72
102
|
// Options-bag form — second arg is an object literal
|
|
73
103
|
if (typeof authHeaderOrOptions === "object" && authHeaderOrOptions !== null) {
|
|
@@ -75,6 +105,8 @@ export class Api {
|
|
|
75
105
|
this.authHeader = opts.authHeader ?? "";
|
|
76
106
|
this.timeout = opts.timeout ?? timeout;
|
|
77
107
|
this.ignoreSsl = (opts.ignoreSsl ?? false) || (opts.verifySsl === false);
|
|
108
|
+
this.maxRetries = Math.max(0, opts.maxRetries ?? 0);
|
|
109
|
+
this.retryBackoff = opts.retryBackoff ?? 0.5;
|
|
78
110
|
|
|
79
111
|
// Bearer wins over basic-auth when both are passed
|
|
80
112
|
if (opts.bearerToken != null) {
|
|
@@ -189,7 +221,38 @@ export class Api {
|
|
|
189
221
|
return `${this.baseUrl}/${path.replace(/^\/+/, "")}`;
|
|
190
222
|
}
|
|
191
223
|
|
|
192
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Execute the request with opt-in retry/backoff.
|
|
226
|
+
*
|
|
227
|
+
* With `maxRetries` > 0, a transport failure (`http_code` null) or a
|
|
228
|
+
* retryable status (429/5xx) is retried up to `maxRetries` times with
|
|
229
|
+
* exponential backoff; any other outcome (2xx, 3xx, other 4xx) returns
|
|
230
|
+
* at once. A retried non-idempotent request may be re-sent — retries
|
|
231
|
+
* are opt-in for that reason.
|
|
232
|
+
*/
|
|
233
|
+
private async execute(
|
|
234
|
+
method: string,
|
|
235
|
+
url: string,
|
|
236
|
+
body?: unknown,
|
|
237
|
+
contentType: string = "application/json",
|
|
238
|
+
): Promise<ApiResult> {
|
|
239
|
+
const attempts = this.maxRetries + 1;
|
|
240
|
+
let result: ApiResult = { http_code: null, body: null, headers: {}, error: null };
|
|
241
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
242
|
+
result = await this.attempt(method, url, body, contentType);
|
|
243
|
+
const code = result.http_code;
|
|
244
|
+
const retryable = code === null || RETRY_STATUSES.has(code);
|
|
245
|
+
if (!retryable || attempt === attempts - 1) {
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
const delayMs = this.retryBackoff * Math.pow(2, attempt) * 1000;
|
|
249
|
+
await new Promise<void>((r) => setTimeout(r, delayMs));
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A single HTTP attempt — returns the standardized result. */
|
|
255
|
+
private attempt(
|
|
193
256
|
method: string,
|
|
194
257
|
url: string,
|
|
195
258
|
body?: unknown,
|