tina4-nodejs 3.10.50 → 3.10.55

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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Tina4 ODBC Adapter — uses the `odbc` package (optional peer dependency).
3
+ *
4
+ * Install: npm install odbc
5
+ * URL format: odbc:///DSN=MyDSN
6
+ * odbc:///DRIVER={driver};SERVER=host;DATABASE=db
7
+ *
8
+ * The connection string after stripping the "odbc:///" prefix is passed
9
+ * directly to odbc.connect(), so any valid ODBC connection string works.
10
+ */
11
+ import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
12
+ import { createRequire } from "node:module";
13
+
14
+ let odbcModule: any = null;
15
+
16
+ function requireOdbc(): any {
17
+ if (odbcModule) return odbcModule;
18
+ try {
19
+ const req = createRequire(import.meta.url);
20
+ odbcModule = req("odbc");
21
+ return odbcModule;
22
+ } catch {
23
+ throw new Error(
24
+ "The 'odbc' package is required for ODBC connections. Install it with: npm install odbc",
25
+ );
26
+ }
27
+ }
28
+
29
+ export interface OdbcConfig {
30
+ /** Full ODBC connection string, e.g. "DSN=MyDSN" or "DRIVER={SQL Server};SERVER=host;DATABASE=db" */
31
+ connectionString: string;
32
+ }
33
+
34
+ export class OdbcAdapter implements DatabaseAdapter {
35
+ private connection: any = null;
36
+ private _lastInsertId: number | bigint | null = null;
37
+ private _inTransaction: boolean = false;
38
+
39
+ /**
40
+ * Accepts either an OdbcConfig object or a raw connection string.
41
+ * When created via Database.create("odbc:///DSN=MyDSN"), the "odbc:///"
42
+ * prefix is stripped by parseDatabaseUrl and the remainder is passed here.
43
+ */
44
+ constructor(private config: OdbcConfig | string) {}
45
+
46
+ /** Extract the raw ODBC connection string from config. */
47
+ private getConnectionString(): string {
48
+ if (typeof this.config === "string") return this.config;
49
+ return this.config.connectionString;
50
+ }
51
+
52
+ /** Connect to the ODBC data source. Must be called before using the adapter. */
53
+ async connect(): Promise<void> {
54
+ const odbc = requireOdbc();
55
+ const connStr = this.getConnectionString();
56
+ // odbc package may expose connect as default export or named export
57
+ const connectFn = odbc.connect ?? odbc.default?.connect;
58
+ if (!connectFn) {
59
+ throw new Error("odbc module does not export a connect() function. Check your odbc package version.");
60
+ }
61
+ this.connection = await connectFn(connStr);
62
+ }
63
+
64
+ private ensureConnected(): void {
65
+ if (!this.connection) {
66
+ throw new Error("ODBC adapter not connected. Call connect() first.");
67
+ }
68
+ }
69
+
70
+ // -------------------------------------------------------------------------
71
+ // Synchronous interface stubs — ODBC is async; use the *Async variants
72
+ // -------------------------------------------------------------------------
73
+
74
+ execute(sql: string, params?: unknown[]): unknown {
75
+ throw new Error("Use executeAsync() for ODBC — async adapter requires async methods.");
76
+ }
77
+
78
+ executeMany(sql: string, paramsList: unknown[][]): { totalAffected: number; lastInsertId?: number | bigint } {
79
+ throw new Error("Use executeManyAsync() for ODBC — async adapter requires async methods.");
80
+ }
81
+
82
+ query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[] {
83
+ throw new Error("Use queryAsync() for ODBC — async adapter requires async methods.");
84
+ }
85
+
86
+ fetch<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): T[] {
87
+ throw new Error("Use fetchAsync() for ODBC — async adapter requires async methods.");
88
+ }
89
+
90
+ fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | null {
91
+ throw new Error("Use fetchOneAsync() for ODBC — async adapter requires async methods.");
92
+ }
93
+
94
+ insert(table: string, data: Record<string, unknown>): DatabaseResult {
95
+ throw new Error("Use insertAsync() for ODBC — async adapter requires async methods.");
96
+ }
97
+
98
+ update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): DatabaseResult {
99
+ throw new Error("Use updateAsync() for ODBC — async adapter requires async methods.");
100
+ }
101
+
102
+ delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[]): DatabaseResult {
103
+ throw new Error("Use deleteAsync() for ODBC — async adapter requires async methods.");
104
+ }
105
+
106
+ startTransaction(): void {
107
+ throw new Error("Use startTransactionAsync() for ODBC — async adapter requires async methods.");
108
+ }
109
+
110
+ commit(): void {
111
+ throw new Error("Use commitAsync() for ODBC — async adapter requires async methods.");
112
+ }
113
+
114
+ rollback(): void {
115
+ throw new Error("Use rollbackAsync() for ODBC — async adapter requires async methods.");
116
+ }
117
+
118
+ tables(): string[] {
119
+ throw new Error("Use tablesAsync() for ODBC — async adapter requires async methods.");
120
+ }
121
+
122
+ columns(table: string): ColumnInfo[] {
123
+ throw new Error("Use columnsAsync() for ODBC — async adapter requires async methods.");
124
+ }
125
+
126
+ tableExists(name: string): boolean {
127
+ throw new Error("Use tableExistsAsync() for ODBC — async adapter requires async methods.");
128
+ }
129
+
130
+ createTable(name: string, columns: Record<string, FieldDefinition>): void {
131
+ throw new Error("Use createTableAsync() for ODBC — async adapter requires async methods.");
132
+ }
133
+
134
+ getTableColumns(name: string): Array<{ name: string; type: string }> {
135
+ throw new Error("Use getTableColumnsAsync() for ODBC — async adapter requires async methods.");
136
+ }
137
+
138
+ addColumn(table: string, colName: string, def: FieldDefinition): void {
139
+ throw new Error("Use addColumnAsync() for ODBC — async adapter requires async methods.");
140
+ }
141
+
142
+ // -------------------------------------------------------------------------
143
+ // Async methods — primary API for ODBC
144
+ // -------------------------------------------------------------------------
145
+
146
+ /** Execute a write statement (INSERT, UPDATE, DELETE, DDL). */
147
+ async executeAsync(sql: string, params?: unknown[]): Promise<unknown> {
148
+ this.ensureConnected();
149
+ const result = await this.connection.query(sql, params ?? []);
150
+ // Try to capture last insert id from result metadata if present
151
+ if (result && typeof result === "object" && "lastInsertId" in result) {
152
+ this._lastInsertId = (result as any).lastInsertId;
153
+ }
154
+ return result;
155
+ }
156
+
157
+ /** Execute a statement with multiple parameter sets inside a single transaction. */
158
+ async executeManyAsync(sql: string, paramsList: unknown[][]): Promise<{ totalAffected: number; lastInsertId?: number | bigint }> {
159
+ this.ensureConnected();
160
+ let totalAffected = 0;
161
+ let lastId: number | bigint | undefined;
162
+
163
+ await this.startTransactionAsync();
164
+ try {
165
+ for (const params of paramsList) {
166
+ await this.connection.query(sql, params);
167
+ totalAffected++;
168
+ }
169
+ await this.commitAsync();
170
+ } catch (e) {
171
+ await this.rollbackAsync();
172
+ throw e;
173
+ }
174
+
175
+ if (lastId !== undefined) this._lastInsertId = lastId;
176
+ return { totalAffected, lastInsertId: lastId };
177
+ }
178
+
179
+ /** Run a SELECT and return all matching rows. */
180
+ async queryAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
181
+ this.ensureConnected();
182
+ const result = await this.connection.query(sql, params ?? []);
183
+ // odbc returns an array-like result object; spread into a plain array
184
+ return Array.from(result) as T[];
185
+ }
186
+
187
+ /** Run a SELECT with optional LIMIT/OFFSET pagination. */
188
+ async fetchAsync<T = Record<string, unknown>>(
189
+ sql: string,
190
+ params?: unknown[],
191
+ limit?: number,
192
+ skip?: number,
193
+ ): Promise<T[]> {
194
+ let effectiveSql = sql;
195
+ if (limit !== undefined) {
196
+ const sqlUpper = sql.toUpperCase().split("--")[0];
197
+ if (!sqlUpper.includes("LIMIT")) {
198
+ effectiveSql += ` LIMIT ${limit}`;
199
+ if (skip !== undefined && skip > 0) {
200
+ effectiveSql += ` OFFSET ${skip}`;
201
+ }
202
+ }
203
+ }
204
+ return this.queryAsync<T>(effectiveSql, params);
205
+ }
206
+
207
+ /** Run a SELECT and return the first row or null. */
208
+ async fetchOneAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T | null> {
209
+ const rows = await this.queryAsync<T>(sql, params);
210
+ return rows[0] ?? null;
211
+ }
212
+
213
+ /** Insert a single row into a table. */
214
+ async insertAsync(table: string, data: Record<string, unknown>): Promise<DatabaseResult> {
215
+ this.ensureConnected();
216
+ const keys = Object.keys(data);
217
+ const placeholders = keys.map(() => "?").join(", ");
218
+ const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders})`;
219
+ const values = Object.values(data);
220
+
221
+ try {
222
+ await this.connection.query(sql, values);
223
+ return { success: true, rowsAffected: 1, lastInsertId: this._lastInsertId ?? undefined };
224
+ } catch (e) {
225
+ return { success: false, rowsAffected: 0, error: (e as Error).message };
226
+ }
227
+ }
228
+
229
+ /** Update rows in a table matching filter. */
230
+ async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): Promise<DatabaseResult> {
231
+ this.ensureConnected();
232
+ const setClauses = Object.keys(data).map((k) => `"${k}" = ?`).join(", ");
233
+ const whereClauses = Object.keys(filter).map((k) => `"${k}" = ?`).join(" AND ");
234
+ const sql = `UPDATE "${table}" SET ${setClauses} WHERE ${whereClauses}`;
235
+ const values = [...Object.values(data), ...Object.values(filter)];
236
+
237
+ try {
238
+ await this.connection.query(sql, values);
239
+ return { success: true, rowsAffected: 1 };
240
+ } catch (e) {
241
+ return { success: false, rowsAffected: 0, error: (e as Error).message };
242
+ }
243
+ }
244
+
245
+ /** Delete rows from a table. */
246
+ async deleteAsync(
247
+ table: string,
248
+ filter: Record<string, unknown> | string | Record<string, unknown>[],
249
+ ): Promise<DatabaseResult> {
250
+ this.ensureConnected();
251
+
252
+ if (Array.isArray(filter)) {
253
+ let totalAffected = 0;
254
+ for (const row of filter) {
255
+ const result = await this.deleteAsync(table, row);
256
+ totalAffected += result.rowsAffected;
257
+ }
258
+ return { success: true, rowsAffected: totalAffected };
259
+ }
260
+
261
+ if (typeof filter === "string") {
262
+ const sql = filter
263
+ ? `DELETE FROM "${table}" WHERE ${filter}`
264
+ : `DELETE FROM "${table}"`;
265
+ try {
266
+ await this.connection.query(sql, []);
267
+ return { success: true, rowsAffected: 1 };
268
+ } catch (e) {
269
+ return { success: false, rowsAffected: 0, error: (e as Error).message };
270
+ }
271
+ }
272
+
273
+ const whereClauses = Object.keys(filter).map((k) => `"${k}" = ?`).join(" AND ");
274
+ const sql = `DELETE FROM "${table}" WHERE ${whereClauses}`;
275
+ const values = Object.values(filter);
276
+
277
+ try {
278
+ await this.connection.query(sql, values);
279
+ return { success: true, rowsAffected: 1 };
280
+ } catch (e) {
281
+ return { success: false, rowsAffected: 0, error: (e as Error).message };
282
+ }
283
+ }
284
+
285
+ /** Begin a transaction. */
286
+ async startTransactionAsync(): Promise<void> {
287
+ if (this._inTransaction) return;
288
+ this.ensureConnected();
289
+ // odbc connections have beginTransaction() method
290
+ await this.connection.beginTransaction();
291
+ this._inTransaction = true;
292
+ }
293
+
294
+ /** Commit the current transaction. */
295
+ async commitAsync(): Promise<void> {
296
+ if (!this._inTransaction) return;
297
+ this.ensureConnected();
298
+ await this.connection.commit();
299
+ this._inTransaction = false;
300
+ }
301
+
302
+ /** Rollback the current transaction. */
303
+ async rollbackAsync(): Promise<void> {
304
+ if (!this._inTransaction) return;
305
+ this.ensureConnected();
306
+ try {
307
+ await this.connection.rollback();
308
+ } catch {
309
+ // Rollback may fail if transaction already ended
310
+ }
311
+ this._inTransaction = false;
312
+ }
313
+
314
+ /** List all user tables using ODBC catalog functions. */
315
+ async tablesAsync(): Promise<string[]> {
316
+ this.ensureConnected();
317
+ // odbc.Connection.tables(catalog, schema, table, type) returns catalog rows
318
+ const rows: any[] = await this.connection.tables(null, null, null, "TABLE");
319
+ return rows.map((r: any) => r.TABLE_NAME ?? r.table_name ?? r.name).filter(Boolean);
320
+ }
321
+
322
+ /** Get column metadata for a table using ODBC catalog functions. */
323
+ async columnsAsync(table: string): Promise<ColumnInfo[]> {
324
+ this.ensureConnected();
325
+ // odbc.Connection.columns(catalog, schema, table, column)
326
+ const rows: any[] = await this.connection.columns(null, null, table, null);
327
+ return rows.map((r: any) => ({
328
+ name: r.COLUMN_NAME ?? r.column_name,
329
+ type: r.TYPE_NAME ?? r.type_name ?? r.DATA_TYPE ?? "",
330
+ nullable: (r.NULLABLE ?? r.nullable) === 1,
331
+ default: r.COLUMN_DEF ?? r.column_def ?? null,
332
+ primaryKey: false, // ODBC catalog doesn't easily expose PK; requires separate primaryKeys() call
333
+ }));
334
+ }
335
+
336
+ /** Check whether a table exists. */
337
+ async tableExistsAsync(name: string): Promise<boolean> {
338
+ this.ensureConnected();
339
+ const rows: any[] = await this.connection.tables(null, null, name, "TABLE");
340
+ return rows.length > 0;
341
+ }
342
+
343
+ /** Create a table from a FieldDefinition map. Uses generic SQL — works with most ODBC sources. */
344
+ async createTableAsync(name: string, columns: Record<string, FieldDefinition>): Promise<void> {
345
+ const colDefs: string[] = [];
346
+ for (const [colName, def] of Object.entries(columns)) {
347
+ const sqlType = fieldTypeToOdbc(def.type);
348
+ const parts = [`"${colName}" ${sqlType}`];
349
+ if (def.primaryKey) parts.push("PRIMARY KEY");
350
+ if (def.autoIncrement) parts.push("GENERATED ALWAYS AS IDENTITY"); // ANSI SQL
351
+ if (def.required && !def.primaryKey) parts.push("NOT NULL");
352
+ if (def.default !== undefined && def.default !== "now") {
353
+ parts.push(`DEFAULT ${sqlDefault(def.default)}`);
354
+ }
355
+ if (def.default === "now") parts.push("DEFAULT CURRENT_TIMESTAMP");
356
+ colDefs.push(parts.join(" "));
357
+ }
358
+ await this.connection.query(`CREATE TABLE IF NOT EXISTS "${name}" (${colDefs.join(", ")})`);
359
+ }
360
+
361
+ /** Get raw column name+type list for a table. */
362
+ async getTableColumnsAsync(name: string): Promise<Array<{ name: string; type: string }>> {
363
+ const cols = await this.columnsAsync(name);
364
+ return cols.map((c) => ({ name: c.name, type: c.type }));
365
+ }
366
+
367
+ /** Add a column to an existing table. */
368
+ async addColumnAsync(table: string, colName: string, def: FieldDefinition): Promise<void> {
369
+ const sqlType = fieldTypeToOdbc(def.type);
370
+ let sql = `ALTER TABLE "${table}" ADD COLUMN "${colName}" ${sqlType}`;
371
+ if (def.default !== undefined && def.default !== "now") {
372
+ sql += ` DEFAULT ${sqlDefault(def.default)}`;
373
+ } else if (def.default === "now") {
374
+ sql += " DEFAULT CURRENT_TIMESTAMP";
375
+ }
376
+ await this.connection.query(sql);
377
+ }
378
+
379
+ lastInsertId(): number | bigint | null {
380
+ return this._lastInsertId;
381
+ }
382
+
383
+ close(): void {
384
+ if (this.connection) {
385
+ // odbc close is async but we keep the sync interface — fire and forget
386
+ this.connection.close().catch(() => {});
387
+ this.connection = null;
388
+ }
389
+ }
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Helpers
394
+ // ---------------------------------------------------------------------------
395
+
396
+ function fieldTypeToOdbc(type: string): string {
397
+ switch (type) {
398
+ case "integer": return "INTEGER";
399
+ case "number":
400
+ case "numeric": return "DOUBLE PRECISION";
401
+ case "boolean": return "SMALLINT";
402
+ case "datetime": return "TIMESTAMP";
403
+ case "text": return "CLOB";
404
+ case "string":
405
+ default: return "VARCHAR(255)";
406
+ }
407
+ }
408
+
409
+ function sqlDefault(value: unknown): string {
410
+ if (typeof value === "string") return `'${value}'`;
411
+ if (typeof value === "boolean") return value ? "1" : "0";
412
+ return String(value);
413
+ }
@@ -146,9 +146,29 @@ export class SQLiteAdapter implements DatabaseAdapter {
146
146
  }
147
147
  }
148
148
 
149
- startTransaction(): void { this.db.exec("BEGIN TRANSACTION"); }
150
- commit(): void { this.db.exec("COMMIT"); }
151
- rollback(): void { this.db.exec("ROLLBACK"); }
149
+ private _inTransaction = false;
150
+
151
+ startTransaction(): void {
152
+ if (this._inTransaction) return;
153
+ this.db.exec("BEGIN TRANSACTION");
154
+ this._inTransaction = true;
155
+ }
156
+
157
+ commit(): void {
158
+ if (!this._inTransaction) return;
159
+ this.db.exec("COMMIT");
160
+ this._inTransaction = false;
161
+ }
162
+
163
+ rollback(): void {
164
+ if (!this._inTransaction) return;
165
+ try {
166
+ this.db.exec("ROLLBACK");
167
+ } catch {
168
+ // Rollback may fail if transaction already ended
169
+ }
170
+ this._inTransaction = false;
171
+ }
152
172
 
153
173
  tables(): string[] {
154
174
  const rows = this.query<{ name: string }>(
@@ -47,15 +47,24 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
47
47
 
48
48
  const limit = options.limit ?? 100;
49
49
  const page = options.page ?? 1;
50
+ const offset = (page - 1) * limit;
51
+ const totalPages = Math.ceil(total / limit);
50
52
 
51
53
  res.json({
54
+ // Primary keys
55
+ records: items,
52
56
  data: items,
53
- meta: {
54
- total,
55
- page,
56
- limit,
57
- totalPages: Math.ceil(total / limit),
58
- },
57
+ count: total,
58
+ total,
59
+ limit,
60
+ offset,
61
+ page,
62
+ per_page: limit,
63
+ perPage: limit,
64
+ totalPages,
65
+ total_pages: totalPages,
66
+ // Legacy nested meta (kept for any clients that use it)
67
+ meta: { total, page, limit, totalPages },
59
68
  });
60
69
  },
61
70
  });
@@ -1,13 +1,15 @@
1
- import { getAdapter, getNamedAdapter } from "./database.js";
1
+ import { getAdapter, getNamedAdapter, setAdapter, parseDatabaseUrl } from "./database.js";
2
2
  import { validate as validateFields } from "./validation.js";
3
3
  import { QueryBuilder } from "./queryBuilder.js";
4
+ import { SQLiteAdapter } from "./adapters/sqlite.js";
4
5
  import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
5
6
 
6
7
  /**
7
8
  * Convert a snake_case name to camelCase.
9
+ * Lowercases the input first so UPPERCASE DB column names (Firebird/Oracle) map correctly.
8
10
  */
9
11
  export function snakeToCamel(name: string): string {
10
- return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
12
+ return name.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
11
13
  }
12
14
 
13
15
  /**
@@ -90,8 +92,8 @@ export class BaseModel {
90
92
  }
91
93
  const reverseMapping = ModelClass.getReverseMapping();
92
94
  for (const [key, value] of Object.entries(data)) {
93
- // If this DB column has a mapping, use the JS property name instead
94
- const jsProp = reverseMapping[key] ?? key;
95
+ // Lowercase the DB column key so UPPERCASE columns (Firebird/Oracle) match the mapping
96
+ const jsProp = reverseMapping[key] ?? reverseMapping[key.toLowerCase()] ?? key;
95
97
  this[jsProp] = value;
96
98
  }
97
99
  }
@@ -147,12 +149,37 @@ export class BaseModel {
147
149
 
148
150
  /**
149
151
  * Get the database adapter for this model.
152
+ * If no adapter is registered, attempts auto-discovery from DATABASE_URL.
153
+ * SQLite URLs are initialised synchronously. Other engines require initDatabase()
154
+ * to be called before first use.
150
155
  */
151
156
  protected static getDb(): DatabaseAdapter {
152
157
  if (this._db) {
153
158
  return getNamedAdapter(this._db);
154
159
  }
155
- return getAdapter();
160
+ try {
161
+ return getAdapter();
162
+ } catch {
163
+ // No adapter registered — try DATABASE_URL auto-discovery
164
+ const url = process.env.DATABASE_URL;
165
+ if (url) {
166
+ const parsed = parseDatabaseUrl(url);
167
+ if (parsed.type === "sqlite") {
168
+ // SQLite adapter is synchronous — create it inline and register as default
169
+ const dbPath = parsed.path ?? "./data/tina4.db";
170
+ const adapter = new SQLiteAdapter(dbPath);
171
+ setAdapter(adapter);
172
+ return adapter;
173
+ }
174
+ throw new Error(
175
+ `DATABASE_URL is set to a non-SQLite engine ("${parsed.type}"). ` +
176
+ `Call await initDatabase() at startup before using ORM models.`,
177
+ );
178
+ }
179
+ throw new Error(
180
+ "No database adapter configured. Call initDatabase() or set DATABASE_URL in .env.",
181
+ );
182
+ }
156
183
  }
157
184
 
158
185
  /**
@@ -45,7 +45,7 @@ export function closeDatabase(): void {
45
45
  }
46
46
 
47
47
  export interface DatabaseConfig {
48
- type?: "sqlite" | "postgres" | "mysql" | "mssql" | "sqlserver" | "firebird";
48
+ type?: "sqlite" | "postgres" | "mysql" | "mssql" | "sqlserver" | "firebird" | "mongodb" | "odbc";
49
49
  path?: string;
50
50
  url?: string;
51
51
  host?: string;
@@ -54,19 +54,23 @@ export interface DatabaseConfig {
54
54
  username?: string;
55
55
  password?: string;
56
56
  database?: string;
57
+ /** ODBC-specific: full connection string, e.g. "DSN=MyDSN" or "DRIVER={SQL Server};SERVER=host;DATABASE=db" */
58
+ connectionString?: string;
57
59
  }
58
60
 
59
61
  /**
60
62
  * Parsed result from a DATABASE_URL connection string.
61
63
  */
62
64
  export interface ParsedDatabaseUrl {
63
- type: "sqlite" | "postgres" | "mysql" | "mssql" | "firebird";
65
+ type: "sqlite" | "postgres" | "mysql" | "mssql" | "firebird" | "mongodb" | "odbc";
64
66
  path?: string;
65
67
  host?: string;
66
68
  port?: number;
67
69
  user?: string;
68
70
  password?: string;
69
71
  database?: string;
72
+ /** ODBC-specific: raw connection string passed to odbc.connect() */
73
+ connectionString?: string;
70
74
  }
71
75
 
72
76
  /**
@@ -120,6 +124,28 @@ export function parseDatabaseUrl(url: string, username?: string, password?: stri
120
124
  port: match[4] ? parseInt(match[4], 10) : undefined,
121
125
  database: "/" + match[5],
122
126
  };
127
+ } else if (url.startsWith("odbc:///")) {
128
+ // odbc:///DSN=MyDSN or odbc:///DRIVER={driver};SERVER=host;DATABASE=db
129
+ // Strip the "odbc:///" prefix and pass the rest directly as the connection string
130
+ const connectionString = url.slice("odbc:///".length);
131
+ result = { type: "odbc", connectionString };
132
+ } else if (url.startsWith("mongodb://") || url.startsWith("mongodb+srv://")) {
133
+ // Pass through as-is; MongodbAdapter handles the full connection string
134
+ let parsed: URL;
135
+ try {
136
+ parsed = new URL(url);
137
+ } catch {
138
+ throw new Error(`Invalid MongoDB URL: ${url}`);
139
+ }
140
+ const database = parsed.pathname.replace(/^\//, "") || "tina4";
141
+ result = {
142
+ type: "mongodb",
143
+ host: parsed.hostname || undefined,
144
+ port: parsed.port ? parseInt(parsed.port, 10) : undefined,
145
+ user: parsed.username ? decodeURIComponent(parsed.username) : undefined,
146
+ password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
147
+ database,
148
+ };
123
149
  } else {
124
150
  // Normalize postgres:// to postgresql:// for URL parsing
125
151
  const normalizedUrl = url.startsWith("postgres://")
@@ -531,6 +557,17 @@ export class Database {
531
557
  getNextId(table: string, pkColumn = "id", generatorName?: string): number {
532
558
  const adapter = this.getNextAdapter();
533
559
 
560
+ // MongoDB — getNextId() is not supported synchronously.
561
+ // MongoDB uses ObjectId for primary keys by default.
562
+ // For integer sequences, use getNextIdAsync() instead.
563
+ if (this.dbType === "mongodb") {
564
+ throw new Error(
565
+ "getNextId() is not supported for MongoDB (async adapter). " +
566
+ "MongoDB uses ObjectId for _id by default. " +
567
+ "For integer sequences, use getNextIdAsync() or let MongoDB generate _id automatically.",
568
+ );
569
+ }
570
+
534
571
  // Firebird — use generators (atomic)
535
572
  if (this.dbType === "firebird") {
536
573
  const genName = generatorName ?? `GEN_${table.toUpperCase()}_ID`;
@@ -641,6 +678,18 @@ async function createAdapterFromUrl(url: string, username?: string, password?: s
641
678
  await adapter.connect();
642
679
  return adapter;
643
680
  }
681
+ case "mongodb": {
682
+ const { MongodbAdapter } = await import("./adapters/mongodb.js");
683
+ const adapter = new MongodbAdapter(url);
684
+ await adapter.connect();
685
+ return adapter;
686
+ }
687
+ case "odbc": {
688
+ const { OdbcAdapter } = await import("./adapters/odbc.js");
689
+ const adapter = new OdbcAdapter({ connectionString: parsed.connectionString ?? "" });
690
+ await adapter.connect();
691
+ return adapter;
692
+ }
644
693
  }
645
694
  }
646
695
 
@@ -730,6 +779,28 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
730
779
  setAdapter(adapter);
731
780
  return new Database(adapter);
732
781
  }
782
+ case "mongodb": {
783
+ const { MongodbAdapter } = await import("./adapters/mongodb.js");
784
+ const creds = resolvedUser && resolvedPassword
785
+ ? `${encodeURIComponent(resolvedUser)}:${encodeURIComponent(resolvedPassword)}@`
786
+ : "";
787
+ const host = config?.host ?? "localhost";
788
+ const port = config?.port ?? 27017;
789
+ const database = config?.database ?? "tina4";
790
+ const connectionString = `mongodb://${creds}${host}:${port}/${database}`;
791
+ const adapter = new MongodbAdapter(connectionString);
792
+ await adapter.connect();
793
+ setAdapter(adapter);
794
+ return new Database(adapter);
795
+ }
796
+ case "odbc": {
797
+ const { OdbcAdapter } = await import("./adapters/odbc.js");
798
+ const connStr = config?.connectionString ?? config?.url?.replace(/^odbc:\/\/\//, "") ?? "";
799
+ const adapter = new OdbcAdapter({ connectionString: connStr });
800
+ await adapter.connect();
801
+ setAdapter(adapter);
802
+ return new Database(adapter);
803
+ }
733
804
  default:
734
805
  throw new Error(`Unknown database type: ${type}`);
735
806
  }