tina4-nodejs 3.13.18 → 3.13.20

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 CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.18)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.20)
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.18 — 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.20 — 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
 
@@ -105,7 +105,7 @@ The HTTP foundation. Handles request/response lifecycle, route matching, middlew
105
105
  - `router.ts` — Pattern matching with `{id}` dynamic params and `{...slug}` catch-all
106
106
  - `routeDiscovery.ts` — Scans `src/routes/` recursively, maps files to endpoints (converts `[id]` dirs to `{id}` URL patterns)
107
107
  - `request.ts` — Wraps `IncomingMessage`, adds `.params`, `.query`, `.body`
108
- - `response.ts` — Wraps `ServerResponse`, adds `.json()`, `.html()`, `.status()`, `.send()`, `.redirect()`
108
+ - `response.ts` — Wraps `ServerResponse`, adds `.json()`, `.html()`, `.status()`, `.send()`, `.redirect()`. `res.json(...)` / `response(...)` auto-serialize an ORM model (→ JSON object), an array of models, or a `DatabaseResult` (→ JSON array) — no manual `toDict()`/`toJson()`. Plain objects, arrays and strings behave exactly as before (purely additive).
109
109
  - `middleware.ts` — Chain runner, built-in CORS and request logger
110
110
  - `static.ts` — Serves files from `public/` with MIME type detection
111
111
  - `types.ts` — All shared type definitions (`Tina4Request`, `Tina4Response`, `RouteHandler`, etc.)
@@ -243,6 +243,8 @@ response.xml(content, status?): Tina4Response
243
243
  response.stream(source: AsyncIterable<string | Buffer>, contentType?: string): Promise<Tina4Response> // SSE/streaming
244
244
  ```
245
245
 
246
+ `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
+
246
248
  ### Queue
247
249
 
248
250
  ```typescript
@@ -553,7 +555,7 @@ r.group("/api/v1", (g) => {
553
555
  Full Database API. The same instance covers all five drivers (sqlite, postgres, mysql, mssql, firebird) — pick the driver via `TINA4_DATABASE_URL` or pass a `DatabaseConfig` to `initDatabase()`.
554
556
 
555
557
  ```typescript
556
- import { initDatabase, Database, DatabaseResult } from "@tina4/orm";
558
+ import { initDatabase, bindDatabase, createAdapterFromUrl, Database, DatabaseResult } from "@tina4/orm";
557
559
 
558
560
  const db = await initDatabase({ url: "sqlite:///app.db" });
559
561
  // Connection pooling: pass `pool: 4` for round-robin connections.
@@ -595,6 +597,30 @@ db.cacheClear(): void
595
597
  db.pool
596
598
  ```
597
599
 
600
+ ### Binding adapters: `bindDatabase` / `createAdapterFromUrl`
601
+
602
+ There are three ways models get an adapter, in increasing order of explicitness:
603
+
604
+ ```typescript
605
+ import { initDatabase, bindDatabase, createAdapterFromUrl } from "@tina4/orm";
606
+
607
+ // (a) .env auto-default (unchanged) — initDatabase() auto-binds the default at boot.
608
+ // Most apps need nothing more than TINA4_DATABASE_URL in .env.
609
+ const db = await initDatabase({ url: "sqlite:///app.db" });
610
+
611
+ // (b) Set or override the default explicitly with bindDatabase(adapter).
612
+ bindDatabase(adapter);
613
+
614
+ // (c) Register a NAMED / secondary connection and point a model at it.
615
+ bindDatabase(await createAdapterFromUrl("postgres://localhost:5432/analytics"), "analytics");
616
+ // then a model selects it:
617
+ // class Visit extends BaseModel { static _db = "analytics"; }
618
+ ```
619
+
620
+ - `bindDatabase(adapter, name?)` — public binder. With no `name` it sets/overrides the **default** connection; with a `name` it registers a **named** connection. `initDatabase()` (auto-binds the `.env` default) and the internal `setAdapter()` are unchanged — `bindDatabase` is additive and non-breaking.
621
+ - `createAdapterFromUrl(url, user?, pass?)` — now exported. Builds a `DatabaseAdapter` from a connection URL (and optional credentials), ready to pass to `bindDatabase`.
622
+ - A model selects a named connection via `static _db = "analytics"`. A mistyped/missing named connection (e.g. `static _db = "typo"`) now **throws** a clear error instead of silently falling back to the default.
623
+
598
624
  **`tina4_sequences` table** — Auto-created by `getNextId()` on first use for SQLite, MySQL, and MSSQL. Stores the current sequence value per table. Do not modify this table manually.
599
625
 
600
626
  ## Module: ORM (`packages/orm/src/baseModel.ts`)
@@ -602,7 +628,7 @@ db.pool
602
628
  Active-Record base class. Models live in `src/models/` and are auto-discovered. Use `static fields` (not decorators) — same convention across all four frameworks.
603
629
 
604
630
  ```typescript
605
- import { BaseModel, initDatabase, setAdapter } from "@tina4/orm";
631
+ import { BaseModel, initDatabase, bindDatabase, createAdapterFromUrl } from "@tina4/orm";
606
632
 
607
633
  export default class User extends BaseModel {
608
634
  static tableName = "users";
@@ -612,10 +638,15 @@ export default class User extends BaseModel {
612
638
  author_id: { type: "foreignKey" as const, references: "Author" }, // auto-wires belongsTo + hasMany
613
639
  };
614
640
  static softDelete = true; // optional — toggles is_deleted column
641
+ // static _db = "analytics"; // optional — bind this model to a named connection
615
642
  }
616
643
 
644
+ // Constructor accepts an object OR a JSON object string. Passing an array throws TypeError.
645
+ const user = new User({ email: "alice@example.com" });
646
+ const user2 = new User('{"email":"bob@example.com"}'); // JSON object string -> one record
647
+ // new User([{ ... }]); // throws TypeError — map over the list to build many records
648
+
617
649
  // Instance methods (chainable where it makes sense)
618
- const user = new User({ email: "alice@example.com" });
619
650
  user.save(); // returns this on success, false on failure
620
651
  user.delete(); // soft-delete if enabled, otherwise hard
621
652
  user.forceDelete(); // bypasses soft-delete
@@ -645,11 +676,16 @@ User.createTable();
645
676
  User.query(): QueryBuilder;
646
677
  BaseModel.registerModel(name, class); // for foreignKey name resolution
647
678
 
648
- // Models bind to the active adapter, not a Database wrapper. initDatabase() sets it
649
- // automatically; setAdapter() lets you bind one explicitly. Models read it via getAdapter().
650
- await initDatabase({ url: "sqlite:///app.db" }); // sets the active adapter for all models
651
- // or, with an adapter you constructed yourself:
652
- setAdapter(adapter);
679
+ // Models bind to the active adapter, not a Database wrapper. There are three ways:
680
+ // (a) .env auto-default (unchanged) initDatabase() auto-binds the default at boot:
681
+ await initDatabase({ url: "sqlite:///app.db" }); // sets the default adapter for all models
682
+ // (b) set/override the default explicitly:
683
+ bindDatabase(adapter);
684
+ // (c) register a NAMED/secondary connection, then point a model at it with `static _db`:
685
+ bindDatabase(await createAdapterFromUrl("postgres://localhost:5432/analytics"), "analytics");
686
+ // class Visit extends BaseModel { static _db = "analytics"; }
687
+ // A mistyped/missing named connection (e.g. static _db = "typo") now throws instead of
688
+ // silently falling back to the default. (initDatabase / the internal setAdapter are unchanged.)
653
689
  ```
654
690
 
655
691
  **Soft delete:** set `static softDelete = true`. Adds an `is_deleted` INTEGER column (0/1). `delete()` flips the flag, `forceDelete()` removes the row, `restore()` clears it.
@@ -1062,7 +1098,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1062
1098
  ## v3 Features Summary
1063
1099
 
1064
1100
  - **45 built-in features**, zero third-party dependencies
1065
- - **3,653 tests** passing across all modules
1101
+ - **3,684 tests** passing across all modules
1066
1102
  - **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
1067
1103
  - **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)
1068
1104
  - **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.18",
6
+ "version": "3.13.20",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -81,6 +81,38 @@ export function setFrond(engine: InstanceType<any>): void {
81
81
  * return response.json(data, 201); // Method
82
82
  * return response.redirect("/login"); // Special
83
83
  */
84
+ /**
85
+ * Normalise domain objects into JSON-serialisable values so handlers can
86
+ * `return response(model)` / `res.json(model)` without calling .toDict() by hand:
87
+ *
88
+ * return response(user); // ORM model -> object
89
+ * return response(await User.all()); // model[] -> object[]
90
+ * return response(await db.fetch(sql));// DatabaseResult -> object[]
91
+ *
92
+ * Duck-typed (no @tina4/orm import — avoids a package cycle): a callable
93
+ * `toDict` marks a model; a `records` array plus a `toArray` method marks a
94
+ * query result. Plain objects / arrays / scalars pass through unchanged.
95
+ */
96
+ function toJsonable(data: unknown): unknown {
97
+ if (data === null || typeof data !== "object" || Buffer.isBuffer(data)) {
98
+ return data;
99
+ }
100
+ const obj = data as Record<string, unknown>;
101
+ // Query result (DatabaseResult-like): records array + toArray method.
102
+ if (Array.isArray(obj.records) && typeof obj.toArray === "function") {
103
+ return obj.records;
104
+ }
105
+ // ORM model: callable toDict().
106
+ if (typeof obj.toDict === "function") {
107
+ return (obj.toDict as () => unknown)();
108
+ }
109
+ // Collections: normalise each element (array of models -> array of objects).
110
+ if (Array.isArray(data)) {
111
+ return data.map((item) => toJsonable(item));
112
+ }
113
+ return data;
114
+ }
115
+
84
116
  export function createResponse(res: ServerResponse): Tina4Response {
85
117
 
86
118
  // ── Guard: prevent writing after headers are sent ──
@@ -95,6 +127,10 @@ export function createResponse(res: ServerResponse): Tina4Response {
95
127
  const response = function (data?: unknown, statusCode?: number, contentType?: string): Tina4Response {
96
128
  if (res.headersSent) return response;
97
129
 
130
+ // Normalise ORM models / collections / query results so handlers can
131
+ // `return response(model)` without serialising by hand.
132
+ data = toJsonable(data);
133
+
98
134
  if (statusCode !== undefined) {
99
135
  res.statusCode = statusCode;
100
136
  }
@@ -143,7 +179,7 @@ export function createResponse(res: ServerResponse): Tina4Response {
143
179
  if (res.headersSent) return response;
144
180
  if (status !== undefined) res.statusCode = status;
145
181
  safeSetHeader("Content-Type", "application/json");
146
- safeEnd(JSON.stringify(data));
182
+ safeEnd(JSON.stringify(toJsonable(data)));
147
183
  return response;
148
184
  };
149
185
 
@@ -12,7 +12,7 @@ import { validToken, getPayload, refreshToken } from "./auth.js";
12
12
  import { discoverRoutes } from "./routeDiscovery.js";
13
13
  import { createRequest } from "./request.js";
14
14
  import { createResponse, setDefaultTemplatesDir } from "./response.js";
15
- import { MiddlewareChain, cors, requestLogger } from "./middleware.js";
15
+ import { MiddlewareChain, MiddlewareRunner, cors, requestLogger } from "./middleware.js";
16
16
  import { tryServeStatic } from "./static.js";
17
17
  import { loadEnv, isTruthy } from "./dotenv.js";
18
18
  import { createHealthRoutes } from "./health.js";
@@ -1108,6 +1108,19 @@ ${reset}
1108
1108
  req.params = match.params;
1109
1109
  matchedPattern = match.pattern;
1110
1110
 
1111
+ // Global class-based middleware registered via Router.use(...) /
1112
+ // MiddlewareRunner.use(...) — run the beforeX hooks before the handler.
1113
+ // beforeX may set response headers (they persist through the handler's
1114
+ // write), mutate the request, or short-circuit on a >= 400 status.
1115
+ // (Parity with Python/PHP/Ruby, whose Router.use class middleware runs.)
1116
+ const globalMiddleware = [
1117
+ ...new Set([...Router.getClassMiddlewares(), ...MiddlewareRunner.getGlobal()]),
1118
+ ];
1119
+ if (globalMiddleware.length > 0) {
1120
+ const [, , proceed] = MiddlewareRunner.runBefore(globalMiddleware, req, res);
1121
+ if (!proceed || res.raw.writableEnded) return;
1122
+ }
1123
+
1111
1124
  // Run per-route middlewares if any
1112
1125
  if (match.middlewares && match.middlewares.length > 0) {
1113
1126
  const proceed = await runRouteMiddlewares(match.middlewares, req, res);
@@ -1198,6 +1211,13 @@ ${reset}
1198
1211
  await res.render(match.template, result as Record<string, unknown>);
1199
1212
  }
1200
1213
 
1214
+ // Global class-based middleware afterX hooks (logging / post-processing).
1215
+ // Header mutations here are no-ops once the response is flushed (Node
1216
+ // sends headers with the body) — set response headers in beforeX.
1217
+ if (globalMiddleware.length > 0) {
1218
+ MiddlewareRunner.runAfter(globalMiddleware, req, res);
1219
+ }
1220
+
1201
1221
  if (!res.raw.writableEnded) {
1202
1222
  res.raw.end();
1203
1223
  }
@@ -95,7 +95,21 @@ export class BaseModel {
95
95
  /** Relationship cache for lazy loading */
96
96
  private _relCache: Record<string, unknown> = {};
97
97
 
98
- constructor(data?: Record<string, unknown>) {
98
+ constructor(data?: Record<string, unknown> | string) {
99
+ // Accept a JSON object string (parity with Python/PHP/Ruby):
100
+ // new Widget('{"id":1,"name":"alpha"}')
101
+ if (typeof data === "string") {
102
+ data = JSON.parse(data) as Record<string, unknown>;
103
+ }
104
+ // A single model is one record — reject an array with a clear message
105
+ // (previously an array silently produced an empty model).
106
+ if (Array.isArray(data)) {
107
+ throw new TypeError(
108
+ `${(this.constructor as typeof BaseModel).name} expects an object, keyword data, ` +
109
+ `or a JSON object string for one record — got an array. ` +
110
+ `Map over the list to build many records.`,
111
+ );
112
+ }
99
113
  if (data) {
100
114
  const ModelClass = this.constructor as typeof BaseModel;
101
115
  // If autoMap is on, auto-generate fieldMapping from camelCase fields
@@ -132,6 +132,39 @@ export function setAdapter(adapter: DatabaseAdapter): void {
132
132
  activeAdapter = adapter;
133
133
  }
134
134
 
135
+ /**
136
+ * Public, user-facing API to bind a database connection.
137
+ *
138
+ * - No `name` → registers `adapter` as the global default connection
139
+ * (what `getAdapter()` returns and what every model resolves to unless it
140
+ * declares `static _db`). This is the manual equivalent of the auto-binding
141
+ * that `initDatabase()` performs from `.env`/`TINA4_DATABASE_URL`.
142
+ * - With `name` → registers `adapter` in the named registry. A model with
143
+ * `static _db = name` resolves to it via `getNamedAdapter(name)`.
144
+ *
145
+ * Mirrors the Python master `bind_database(db, name=None)`.
146
+ *
147
+ * import { bindDatabase, createAdapterFromUrl } from "@tina4/orm";
148
+ *
149
+ * // Default connection
150
+ * bindDatabase(adapter);
151
+ *
152
+ * // Named secondary connection built from a URL (kept synchronous —
153
+ * // build the adapter first, then bind it)
154
+ * bindDatabase(await createAdapterFromUrl(url, user, pass), "analytics");
155
+ *
156
+ * `bindDatabase` itself is synchronous: it takes an already-constructed
157
+ * adapter. Use `createAdapterFromUrl()` to build a named secondary adapter
158
+ * from a URL without making it the default.
159
+ */
160
+ export function bindDatabase(adapter: DatabaseAdapter, name?: string): void {
161
+ if (name === undefined) {
162
+ setAdapter(adapter);
163
+ } else {
164
+ namedAdapters.set(name, adapter);
165
+ }
166
+ }
167
+
135
168
  export function getAdapter(): DatabaseAdapter {
136
169
  if (!activeAdapter) {
137
170
  throw new Error("No database adapter configured. Call setAdapter() first.");
@@ -148,13 +181,24 @@ export function setNamedAdapter(name: string, adapter: DatabaseAdapter): void {
148
181
  }
149
182
 
150
183
  /**
151
- * Get a named adapter. Falls back to the default adapter if name not found.
184
+ * Get a named adapter previously registered via `bindDatabase(adapter, name)`
185
+ * (or the lower-level `setNamedAdapter(name, adapter)`).
186
+ *
187
+ * Throws a clear error if the name isn't registered — a model that declares
188
+ * `static _db = "name"` resolves through here, so a missing name means the
189
+ * connection was never bound. The message tells the developer exactly how to
190
+ * fix it rather than silently falling back to the default connection (which
191
+ * would hide the mistake and write to the wrong database).
152
192
  */
153
193
  export function getNamedAdapter(name: string): DatabaseAdapter {
154
194
  const adapter = namedAdapters.get(name);
155
195
  if (adapter) return adapter;
156
- // Fall back to default
157
- return getAdapter();
196
+ throw new Error(
197
+ `No database adapter registered under the name "${name}". ` +
198
+ `Call bindDatabase(adapter, "${name}") before using a model with ` +
199
+ `static _db = "${name}" (build a secondary adapter with ` +
200
+ `createAdapterFromUrl(url, user, pass) if you need one from a URL).`,
201
+ );
158
202
  }
159
203
 
160
204
  export function closeDatabase(): void {
@@ -908,10 +952,19 @@ export class Database {
908
952
  }
909
953
 
910
954
  /**
911
- * Internal helper: create a DatabaseAdapter from a parsed URL.
912
- * Extracted from initDatabase so Database.create() can reuse it.
955
+ * Build a connected `DatabaseAdapter` from a connection URL.
956
+ *
957
+ * Used internally by `initDatabase()` and `Database.create()`, and exported so
958
+ * users can construct a NAMED secondary adapter without making it the default:
959
+ *
960
+ * bindDatabase(await createAdapterFromUrl(url, user, pass), "analytics");
961
+ *
962
+ * Unlike `initDatabase()`, this does NOT call `setAdapter()` — it returns a
963
+ * standalone adapter that the caller decides what to do with. For async engines
964
+ * (Postgres/MySQL/MSSQL/Firebird/Mongo) the returned adapter is already
965
+ * connected; SQLite connects lazily.
913
966
  */
914
- async function createAdapterFromUrl(url: string, username?: string, password?: string): Promise<DatabaseAdapter> {
967
+ export async function createAdapterFromUrl(url: string, username?: string, password?: string): Promise<DatabaseAdapter> {
915
968
  const parsed = parseDatabaseUrl(url, username, password);
916
969
 
917
970
  switch (parsed.type) {
@@ -14,7 +14,7 @@ export { FetchResult } from "./types.js";
14
14
 
15
15
  export { DatabaseResult } from "./databaseResult.js";
16
16
  export type { ColumnInfoResult } from "./databaseResult.js";
17
- export { Database, initDatabase, getAdapter, setAdapter, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool, stripTrailingSemicolons } from "./database.js";
17
+ export { Database, initDatabase, getAdapter, setAdapter, bindDatabase, createAdapterFromUrl, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool, stripTrailingSemicolons } from "./database.js";
18
18
  export {
19
19
  adapterFetch, adapterQuery, adapterFetchOne, adapterExecute,
20
20
  adapterStartTransaction, adapterCommit, adapterRollback,