tina4-nodejs 3.13.16 → 3.13.19

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.16)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.19)
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.16 — 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.19 — 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,644 tests** passing across all modules
1101
+ - **3,679 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.16",
6
+ "version": "3.13.19",
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
 
@@ -10,12 +10,14 @@ import { SQLTranslator } from "../sqlTranslation.js";
10
10
  import { createRequire } from "node:module";
11
11
 
12
12
  let pg: typeof import("pg") | null = null;
13
+ let typeParsersRegistered = false;
13
14
 
14
15
  function requirePg(): typeof import("pg") {
15
16
  if (pg) return pg;
16
17
  try {
17
18
  const req = createRequire(import.meta.url);
18
19
  pg = req("pg");
20
+ registerTypeParsers(pg!);
19
21
  return pg!;
20
22
  } catch {
21
23
  throw new Error(
@@ -28,6 +30,33 @@ function requirePg(): typeof import("pg") {
28
30
  }
29
31
  }
30
32
 
33
+ /**
34
+ * Register global pg type parsers so int8 and numeric/decimal columns decode to
35
+ * JS numbers instead of strings. node-postgres returns int8 (OID 20) and
36
+ * numeric (OID 1700) as strings by default to preserve full precision; Python,
37
+ * Ruby and PHP all return native numerics for aggregates (SUM, AVG, COUNT, …),
38
+ * so this brings Node to cross-framework parity. count() and getNextId() already
39
+ * coerce via Number(), so this is purely additive for them.
40
+ *
41
+ * PRECISION CAVEAT: values beyond Number.MAX_SAFE_INTEGER (2^53 - 1) lose
42
+ * precision when coerced to a JS double. That is the accepted trade-off for
43
+ * parity — Python and Ruby return native numerics (and lose precision the same
44
+ * way for floats) too. Applications that need exact 64-bit/arbitrary-precision
45
+ * values should select the column with an explicit ::text cast.
46
+ *
47
+ * Idempotent — registration runs once per process.
48
+ */
49
+ function registerTypeParsers(pgModule: typeof import("pg")): void {
50
+ if (typeParsersRegistered) return;
51
+ const types = pgModule.types ?? (pgModule as any).default?.types;
52
+ if (!types?.setTypeParser) return;
53
+ // OID 20 = int8 (bigint) → Number (NULL passes through untouched by pg)
54
+ types.setTypeParser(20, (v: string) => Number(v));
55
+ // OID 1700 = numeric / decimal → parseFloat
56
+ types.setTypeParser(1700, (v: string) => parseFloat(v));
57
+ typeParsersRegistered = true;
58
+ }
59
+
31
60
  export interface PostgresConfig {
32
61
  host?: string;
33
62
  port?: number;
@@ -8,6 +8,7 @@ import { validate as validateFields } from "./validation.js";
8
8
  import { QueryBuilder } from "./queryBuilder.js";
9
9
  import { SQLiteAdapter } from "./adapters/sqlite.js";
10
10
  import { QueryCache } from "./sqlTranslation.js";
11
+ import { Log } from "@tina4/core";
11
12
  import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
12
13
 
13
14
  /**
@@ -94,7 +95,21 @@ export class BaseModel {
94
95
  /** Relationship cache for lazy loading */
95
96
  private _relCache: Record<string, unknown> = {};
96
97
 
97
- 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
+ }
98
113
  if (data) {
99
114
  const ModelClass = this.constructor as typeof BaseModel;
100
115
  // If autoMap is on, auto-generate fieldMapping from camelCase fields
@@ -1149,6 +1164,28 @@ export class BaseModel {
1149
1164
 
1150
1165
  static registerModel(name: string, modelClass: typeof BaseModel): void {
1151
1166
  BaseModel._modelRegistry[name] = modelClass;
1167
+ // Eagerly process this model's foreignKey fields so the cross-model
1168
+ // _fkRegistry is populated as soon as the model is known — not only when
1169
+ // the *declaring* model happens to be touched first. This is what makes
1170
+ // eager loading work standalone (without server-boot auto-discovery):
1171
+ // a parent's hasMany is registered by the child's _processForeignKeys(),
1172
+ // so we must run it for every registered model proactively.
1173
+ modelClass._processForeignKeys();
1174
+ }
1175
+
1176
+ /**
1177
+ * Process foreignKey fields on every registered model so the cross-model
1178
+ * _fkRegistry (and each model's belongsTo/hasMany) is fully wired regardless
1179
+ * of which model was used first. Idempotent — _processForeignKeys() and
1180
+ * _applyFkRegistry() both guard against duplicates.
1181
+ */
1182
+ private static _processAllForeignKeys(): void {
1183
+ for (const modelClass of Object.values(BaseModel._modelRegistry)) {
1184
+ modelClass._processForeignKeys();
1185
+ }
1186
+ for (const modelClass of Object.values(BaseModel._modelRegistry)) {
1187
+ modelClass._applyFkRegistry();
1188
+ }
1152
1189
  }
1153
1190
 
1154
1191
  /**
@@ -1168,9 +1205,13 @@ export class BaseModel {
1168
1205
 
1169
1206
  const ModelClass = instances[0].constructor as typeof BaseModel;
1170
1207
 
1171
- // Apply FK registry so foreignKey fields auto-wire hasMany on referenced models
1172
- ModelClass._processForeignKeys();
1173
- ModelClass._applyFkRegistry();
1208
+ // Wire FK relationships across ALL registered models, not just the parent.
1209
+ // A parent's hasMany is declared by the CHILD's foreignKey field, so we must
1210
+ // process every registered model's FKs before resolving includes — otherwise
1211
+ // a standalone Author.findById(id, ["posts"]) silently finds nothing because
1212
+ // Post._processForeignKeys() never ran. _applyFkRegistry() then merges the
1213
+ // registered hasMany entries onto each model.
1214
+ BaseModel._processAllForeignKeys();
1174
1215
 
1175
1216
  // Group includes: top-level and nested
1176
1217
  const topLevel: Record<string, string[]> = {};
@@ -1186,28 +1227,54 @@ export class BaseModel {
1186
1227
  }
1187
1228
 
1188
1229
  for (const [relName, nested] of Object.entries(topLevel)) {
1189
- // Find the relationship definition
1230
+ // Find the relationship definition.
1231
+ //
1232
+ // Include names are resolved case-insensitively against each candidate
1233
+ // relation. Accepted forms (for a relation to model "Post" on table "posts"):
1234
+ // - the model name → "Post" / "post"
1235
+ // - the auto/related key → "post" (singular) or "posts" (when
1236
+ // TINA4_ORM_PLURAL_TABLE_NAMES is enabled)
1237
+ // - the table name → "posts"
1238
+ // All matching is lower-cased so "Post", "post" and "posts" all resolve.
1239
+ const want = relName.toLowerCase();
1190
1240
  let relDef: RelationshipDefinition | undefined;
1191
1241
  let relType: "hasOne" | "hasMany" | "belongsTo" | null = null;
1192
1242
 
1243
+ const matchesModel = (r: RelationshipDefinition): boolean => {
1244
+ const base = r.model.toLowerCase();
1245
+ const related = BaseModel._modelRegistry[r.model];
1246
+ const table = related?.tableName?.toLowerCase();
1247
+ return (
1248
+ base === want ||
1249
+ base + "s" === want ||
1250
+ (table !== undefined && table === want)
1251
+ );
1252
+ };
1253
+
1193
1254
  if (ModelClass.hasOne) {
1194
- relDef = ModelClass.hasOne.find((r) => r.model.toLowerCase() === relName || r.model === relName);
1255
+ relDef = ModelClass.hasOne.find(matchesModel);
1195
1256
  if (relDef) relType = "hasOne";
1196
1257
  }
1197
1258
  if (!relDef && ModelClass.hasMany) {
1198
- relDef = ModelClass.hasMany.find((r) => {
1199
- const base = r.model.toLowerCase();
1200
- const key = _pluralRelKeys() ? base + "s" : base;
1201
- return key === relName || base === relName || r.model === relName;
1202
- });
1259
+ relDef = ModelClass.hasMany.find(matchesModel);
1203
1260
  if (relDef) relType = "hasMany";
1204
1261
  }
1205
1262
  if (!relDef && ModelClass.belongsTo) {
1206
- relDef = ModelClass.belongsTo.find((r) => r.model.toLowerCase() === relName || r.model === relName);
1263
+ relDef = ModelClass.belongsTo.find(matchesModel);
1207
1264
  if (relDef) relType = "belongsTo";
1208
1265
  }
1209
1266
 
1210
- if (!relDef || !relType) continue;
1267
+ if (!relDef || !relType) {
1268
+ // Don't silently skip — a typo'd or unknown include name is almost
1269
+ // always a developer mistake. Surface it so it's visible.
1270
+ Log.warn(
1271
+ `eager-load: include "${relName}" did not match any relationship on ` +
1272
+ `${ModelClass.name} (table "${ModelClass.tableName}"). ` +
1273
+ `Accepted forms are the related model name, its singular/plural ` +
1274
+ `key, or the related table name (case-insensitive).`,
1275
+ );
1276
+ continue;
1277
+ }
1211
1278
 
1212
1279
  const relatedClass = BaseModel._modelRegistry[relDef.model];
1213
1280
  if (!relatedClass) continue;
@@ -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,