tina4-nodejs 3.13.14 → 3.13.18

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.
@@ -1,8 +1,14 @@
1
- import { getAdapter, getNamedAdapter, setAdapter, parseDatabaseUrl } from "./database.js";
1
+ import {
2
+ getAdapter, getNamedAdapter, setAdapter, parseDatabaseUrl,
3
+ adapterQuery, adapterFetch, adapterExecute, adapterFetchOne,
4
+ adapterStartTransaction, adapterCommit, adapterRollback,
5
+ adapterTableExists, adapterCreateTable, extractLastInsertId,
6
+ } from "./database.js";
2
7
  import { validate as validateFields } from "./validation.js";
3
8
  import { QueryBuilder } from "./queryBuilder.js";
4
9
  import { SQLiteAdapter } from "./adapters/sqlite.js";
5
10
  import { QueryCache } from "./sqlTranslation.js";
11
+ import { Log } from "@tina4/core";
6
12
  import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
7
13
 
8
14
  /**
@@ -258,7 +264,7 @@ export class BaseModel {
258
264
  * @param id Primary key value.
259
265
  * @param include Optional array of relationship names to eager-load.
260
266
  */
261
- static findById<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown, include?: string[]): T | null {
267
+ static async findById<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown, include?: string[]): Promise<T | null> {
262
268
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
263
269
  const pkCol = ModelClass.getPkColumn();
264
270
  let sql = `SELECT * FROM "${ModelClass.tableName}" WHERE "${pkCol}" = ?`;
@@ -270,9 +276,9 @@ export class BaseModel {
270
276
  sql += ` AND ${ModelClass.tableFilter}`;
271
277
  }
272
278
 
273
- const instance = ModelClass.selectOne<T>(sql, [id]);
279
+ const instance = await ModelClass.selectOne<T>(sql, [id]);
274
280
  if (instance && include) {
275
- ModelClass._eagerLoad([instance], include);
281
+ await ModelClass._eagerLoad([instance], include);
276
282
  }
277
283
  return instance;
278
284
  }
@@ -283,12 +289,12 @@ export class BaseModel {
283
289
  * Usage:
284
290
  * const user = User.create({ name: "Alice", email: "alice@example.com" });
285
291
  */
286
- static create<T extends BaseModel>(
292
+ static async create<T extends BaseModel>(
287
293
  this: new (data?: Record<string, unknown>) => T,
288
294
  data: Record<string, unknown> = {},
289
- ): T {
295
+ ): Promise<T> {
290
296
  const instance = new this(data) as T;
291
- instance.save();
297
+ await instance.save();
292
298
  return instance;
293
299
  }
294
300
 
@@ -303,14 +309,14 @@ export class BaseModel {
303
309
  *
304
310
  * Use findById(id) for single-record primary key lookup.
305
311
  */
306
- static find<T extends BaseModel>(
312
+ static async find<T extends BaseModel>(
307
313
  this: new (data?: Record<string, unknown>) => T,
308
314
  filter?: Record<string, unknown>,
309
315
  limit = 100,
310
316
  offset = 0,
311
317
  orderBy?: string,
312
318
  include?: string[],
313
- ): T[] {
319
+ ): Promise<T[]> {
314
320
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
315
321
  const db = ModelClass.getDb();
316
322
  const conditions: string[] = [];
@@ -336,7 +342,7 @@ export class BaseModel {
336
342
  sql += ` ORDER BY ${orderBy}`;
337
343
  }
338
344
 
339
- const rows = db.fetch(sql, params, limit, offset);
345
+ const rows = await adapterFetch(db, sql, params, limit, offset);
340
346
  const data = (rows as any)?.data ?? rows;
341
347
  const instances = (Array.isArray(data) ? data : []).map((row: Record<string, unknown>) => {
342
348
  const inst = new this(row) as T;
@@ -345,7 +351,7 @@ export class BaseModel {
345
351
  });
346
352
 
347
353
  if (include) {
348
- ModelClass._eagerLoad(instances as BaseModel[], include);
354
+ await ModelClass._eagerLoad(instances as BaseModel[], include);
349
355
  }
350
356
 
351
357
  return instances;
@@ -365,7 +371,7 @@ export class BaseModel {
365
371
  *
366
372
  * Returns true if found, false otherwise.
367
373
  */
368
- load(filter?: string, params?: unknown[], include?: string[]): boolean {
374
+ async load(filter?: string, params?: unknown[], include?: string[]): Promise<boolean> {
369
375
  const ModelClass = this.constructor as typeof BaseModel & (new (data?: Record<string, unknown>) => BaseModel);
370
376
  const table = (ModelClass as any).tableName ?? (this as any).tableName;
371
377
 
@@ -381,7 +387,7 @@ export class BaseModel {
381
387
  sql = `SELECT * FROM ${table} WHERE ${filter}`;
382
388
  }
383
389
 
384
- const result = ModelClass.selectOne(sql, params, include);
390
+ const result = await ModelClass.selectOne(sql, params, include);
385
391
  if (!result) return false;
386
392
  const data = (result as any).toJSON ? (result as any).toJSON() : result;
387
393
  for (const [key, value] of Object.entries(data)) {
@@ -398,13 +404,13 @@ export class BaseModel {
398
404
  * @param params Optional query parameters.
399
405
  * @param include Optional array of relationship names to eager-load.
400
406
  */
401
- static all<T extends BaseModel>(
407
+ static async all<T extends BaseModel>(
402
408
  this: new (data?: Record<string, unknown>) => T,
403
409
  where?: string,
404
410
  params?: unknown[],
405
411
  include?: string[],
406
412
  orderBy?: string,
407
- ): T[] {
413
+ ): Promise<T[]> {
408
414
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
409
415
  const db = ModelClass.getDb();
410
416
 
@@ -423,10 +429,10 @@ export class BaseModel {
423
429
  const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
424
430
  const sql = `SELECT * FROM "${ModelClass.tableName}"${whereClause}${orderClause}`;
425
431
 
426
- const rows = db.query(sql, params);
432
+ const rows = await adapterQuery(db, sql, params);
427
433
  const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
428
434
  if (include) {
429
- ModelClass._eagerLoad(instances, include);
435
+ await ModelClass._eagerLoad(instances, include);
430
436
  }
431
437
  return instances;
432
438
  }
@@ -441,14 +447,14 @@ export class BaseModel {
441
447
  * @param offset Skip records (default 0)
442
448
  * @param include Relationship names to eager-load
443
449
  */
444
- static where<T extends BaseModel>(
450
+ static async where<T extends BaseModel>(
445
451
  this: new (data?: Record<string, unknown>) => T,
446
452
  conditions: string,
447
453
  params?: unknown[],
448
454
  limit: number = 20,
449
455
  offset: number = 0,
450
456
  include?: string[],
451
- ): T[] {
457
+ ): Promise<T[]> {
452
458
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
453
459
  const db = ModelClass.getDb();
454
460
 
@@ -463,10 +469,10 @@ export class BaseModel {
463
469
 
464
470
  const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${parts.join(" AND ")} LIMIT ${limit} OFFSET ${offset}`;
465
471
 
466
- const rows = db.query(sql, params);
472
+ const rows = await adapterQuery(db, sql, params);
467
473
  const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
468
474
  if (include) {
469
- ModelClass._eagerLoad(instances, include);
475
+ await ModelClass._eagerLoad(instances, include);
470
476
  }
471
477
  return instances;
472
478
  }
@@ -475,7 +481,7 @@ export class BaseModel {
475
481
  * Save this instance (insert or update).
476
482
  * Returns this on success (fluent), null on failure.
477
483
  */
478
- save(): this | false {
484
+ async save(): Promise<this | false> {
479
485
  const ModelClass = this.constructor as typeof BaseModel;
480
486
  const db = ModelClass.getDb();
481
487
  const pk = ModelClass.getPkField();
@@ -498,7 +504,7 @@ export class BaseModel {
498
504
  isUpdate = true;
499
505
  } else {
500
506
  try {
501
- isUpdate = ModelClass.exists(pkValue);
507
+ isUpdate = await ModelClass.exists(pkValue);
502
508
  } catch {
503
509
  // If we can't tell (e.g. table doesn't exist yet), fall back
504
510
  // to INSERT so the user sees the real driver error rather
@@ -508,19 +514,19 @@ export class BaseModel {
508
514
  }
509
515
  }
510
516
 
511
- db.startTransaction();
517
+ await adapterStartTransaction(db);
512
518
  try {
513
519
  if (isUpdate) {
514
520
  // Update
515
521
  const updateFields = Object.entries(ModelClass.fields).filter(
516
522
  ([name, def]) => !def.primaryKey && this[name] !== undefined,
517
523
  );
518
- if (updateFields.length === 0) { db.commit(); return; }
524
+ if (updateFields.length === 0) { await adapterCommit(db); return this; }
519
525
 
520
526
  const setClause = updateFields.map(([k]) => `"${ModelClass.getDbColumn(k)}" = ?`).join(", ");
521
527
  const values = [...updateFields.map(([k]) => this[k]), pkValue];
522
528
 
523
- db.execute(`UPDATE "${ModelClass.tableName}" SET ${setClause} WHERE "${pkCol}" = ?`, values);
529
+ await adapterExecute(db, `UPDATE "${ModelClass.tableName}" SET ${setClause} WHERE "${pkCol}" = ?`, values);
524
530
  } else {
525
531
  // Insert
526
532
  const insertFields = Object.entries(ModelClass.fields).filter(
@@ -531,23 +537,42 @@ export class BaseModel {
531
537
  const placeholders = insertFields.map(() => "?").join(", ");
532
538
  const values = insertFields.map(([k]) => this[k]);
533
539
 
534
- const result = db.execute(
535
- `INSERT INTO "${ModelClass.tableName}" (${columns}) VALUES (${placeholders})`,
536
- values,
537
- ) as { lastInsertRowid?: number };
540
+ // For auto-increment PKs on engines that need it (PostgreSQL),
541
+ // RETURNING the PK column lets us read the engine-assigned id back.
542
+ // SQLite ignores the RETURNING clause harmlessly (it supports it
543
+ // since 3.35) and we still prefer its lastInsertRowid; for other
544
+ // engines extractLastInsertId() reads rows[0].id.
545
+ const wantReturning = pkField?.autoIncrement && db.constructor.name !== "SQLiteAdapter";
546
+ const insertSql =
547
+ `INSERT INTO "${ModelClass.tableName}" (${columns}) VALUES (${placeholders})` +
548
+ (wantReturning ? ` RETURNING "${pkCol}"` : "");
549
+
550
+ const result = await adapterExecute(db, insertSql, values);
538
551
 
539
552
  // v3.13.11 (issue #50.2): only adopt the engine-assigned ID
540
553
  // for auto-increment PKs. A natural-key PK was already set by
541
- // the caller; don't overwrite it with the driver's last_id
542
- // (which on PG would be a sequence value that doesn't apply
543
- // to this row).
544
- if (result.lastInsertRowid && pkField?.autoIncrement) {
545
- this[pk] = result.lastInsertRowid;
554
+ // the caller; don't overwrite it with the driver's last_id.
555
+ if (pkField?.autoIncrement) {
556
+ // RETURNING result: pg puts it in rows[0][pkCol]; normalise here.
557
+ let newId = extractLastInsertId(result);
558
+ if (newId === null && result && typeof result === "object") {
559
+ const rows = (result as any).rows;
560
+ if (Array.isArray(rows) && rows[0]) {
561
+ newId = rows[0][pkCol] ?? rows[0].id ?? null;
562
+ }
563
+ }
564
+ if (newId === null) {
565
+ // Fall back to the adapter's tracked last id (MySQL/MSSQL).
566
+ newId = db.lastInsertId();
567
+ }
568
+ if (newId !== null && newId !== undefined) {
569
+ this[pk] = newId;
570
+ }
546
571
  }
547
572
  }
548
- db.commit();
573
+ await adapterCommit(db);
549
574
  } catch (e) {
550
- db.rollback();
575
+ await adapterRollback(db);
551
576
  return false;
552
577
  }
553
578
  (this as any)._exists = true;
@@ -557,7 +582,7 @@ export class BaseModel {
557
582
  /**
558
583
  * Delete this instance. Uses soft delete if configured.
559
584
  */
560
- delete(): boolean {
585
+ async delete(): Promise<boolean> {
561
586
  const ModelClass = this.constructor as typeof BaseModel;
562
587
  const db = ModelClass.getDb();
563
588
  const pk = ModelClass.getPkField();
@@ -568,23 +593,23 @@ export class BaseModel {
568
593
  throw new Error("Cannot delete a model without a primary key value");
569
594
  }
570
595
 
571
- db.startTransaction();
596
+ await adapterStartTransaction(db);
572
597
  try {
573
598
  if (ModelClass.softDelete) {
574
- db.execute(
599
+ await adapterExecute(db,
575
600
  `UPDATE "${ModelClass.tableName}" SET is_deleted = 1 WHERE "${pkCol}" = ?`,
576
601
  [pkValue],
577
602
  );
578
603
  this.is_deleted = 1;
579
604
  } else {
580
- db.execute(
605
+ await adapterExecute(db,
581
606
  `DELETE FROM "${ModelClass.tableName}" WHERE "${pkCol}" = ?`,
582
607
  [pkValue],
583
608
  );
584
609
  }
585
- db.commit();
610
+ await adapterCommit(db);
586
611
  } catch (e) {
587
- db.rollback();
612
+ await adapterRollback(db);
588
613
  throw e;
589
614
  }
590
615
  return true;
@@ -624,14 +649,13 @@ export class BaseModel {
624
649
  }
625
650
 
626
651
  for (const [relName, nested] of Object.entries(topLevel)) {
627
- const cached = this._relCache[relName];
628
- if (cached === undefined) {
629
- // Try lazy load via instance methods
630
- const related = this._lazyLoadRelationship(relName);
631
- if (related === undefined) continue;
632
- this._relCache[relName] = related;
633
- }
652
+ // toDict stays synchronous (used in routes, templates, serialization).
653
+ // Relationships must be eager-loaded first (await Model._eagerLoad / the
654
+ // include arg on find/all/where) which fills _relCache. A relation that
655
+ // isn't cached is simply skipped here — async lazy-load on a sync
656
+ // serializer is not possible after the Option A async refactor.
634
657
  const data = this._relCache[relName];
658
+ if (data === undefined) continue;
635
659
  if (data === null || data === undefined) {
636
660
  result[relName] = null;
637
661
  } else if (Array.isArray(data)) {
@@ -722,54 +746,59 @@ export class BaseModel {
722
746
  * Generate and execute CREATE TABLE DDL from the model's field definitions.
723
747
  * Uses the adapter's createTable method if available, otherwise builds SQL directly.
724
748
  */
725
- static createTable(): boolean {
749
+ static async createTable(): Promise<boolean> {
726
750
  const db = this.getDb();
727
- if (db.tableExists(this.tableName)) return true;
751
+ if (await adapterTableExists(db, this.tableName)) return true;
728
752
 
729
- if (typeof db.createTable === "function") {
730
- // Remap field keys to DB column names if fieldMapping is defined
753
+ // Prefer the adapter's createTable every adapter implements it and the
754
+ // async variants (PostgreSQL/MySQL/MSSQL/Firebird) emit engine-aware DDL
755
+ // (datetime → TIMESTAMP, boolean → native BOOLEAN, auto-increment → SERIAL
756
+ // etc. on PG). Remap field keys to DB column names if fieldMapping exists.
757
+ if (typeof db.createTable === "function" || typeof (db as any).createTableAsync === "function") {
731
758
  const mappedFields: Record<string, FieldDefinition> = {};
732
759
  for (const [fieldName, def] of Object.entries(this.fields)) {
733
760
  const dbCol = this.getDbColumn(fieldName);
734
761
  mappedFields[dbCol] = def;
735
762
  }
736
- db.createTable(this.tableName, mappedFields);
737
- } else {
738
- // Fallback: build SQL manually
739
- const typeMap: Record<string, string> = {
740
- integer: "INTEGER",
741
- string: "TEXT",
742
- text: "TEXT",
743
- number: "REAL",
744
- numeric: "REAL",
745
- boolean: "INTEGER",
746
- datetime: "TEXT",
747
- };
763
+ await adapterCreateTable(db, this.tableName, mappedFields);
764
+ return true;
765
+ }
748
766
 
749
- const colDefs: string[] = [];
750
- for (const [fieldName, def] of Object.entries(this.fields)) {
751
- const dbCol = this.getDbColumn(fieldName);
752
- const sqlType = typeMap[def.type] || "TEXT";
753
- const parts = [`"${dbCol}" ${sqlType}`];
754
- if (def.primaryKey) parts.push("PRIMARY KEY");
755
- if (def.autoIncrement) parts.push("AUTOINCREMENT");
756
- if (def.required && !def.primaryKey) parts.push("NOT NULL");
757
- if (def.default !== undefined) {
758
- const dv = typeof def.default === "string" ? `'${def.default}'` : String(def.default);
759
- parts.push(`DEFAULT ${dv}`);
760
- }
761
- colDefs.push(parts.join(" "));
762
- }
767
+ // Fallback: build SQL manually (SQLite-only dialect — used only when an
768
+ // adapter lacks createTable, which none currently do).
769
+ const typeMap: Record<string, string> = {
770
+ integer: "INTEGER",
771
+ string: "TEXT",
772
+ text: "TEXT",
773
+ number: "REAL",
774
+ numeric: "REAL",
775
+ boolean: "INTEGER",
776
+ datetime: "TEXT",
777
+ };
763
778
 
764
- const sql = `CREATE TABLE IF NOT EXISTS "${this.tableName}" (${colDefs.join(", ")})`;
765
- db.startTransaction();
766
- try {
767
- db.execute(sql);
768
- db.commit();
769
- } catch (e) {
770
- db.rollback();
771
- throw e;
779
+ const colDefs: string[] = [];
780
+ for (const [fieldName, def] of Object.entries(this.fields)) {
781
+ const dbCol = this.getDbColumn(fieldName);
782
+ const sqlType = typeMap[def.type] || "TEXT";
783
+ const parts = [`"${dbCol}" ${sqlType}`];
784
+ if (def.primaryKey) parts.push("PRIMARY KEY");
785
+ if (def.autoIncrement) parts.push("AUTOINCREMENT");
786
+ if (def.required && !def.primaryKey) parts.push("NOT NULL");
787
+ if (def.default !== undefined) {
788
+ const dv = typeof def.default === "string" ? `'${def.default}'` : String(def.default);
789
+ parts.push(`DEFAULT ${dv}`);
772
790
  }
791
+ colDefs.push(parts.join(" "));
792
+ }
793
+
794
+ const sql = `CREATE TABLE IF NOT EXISTS "${this.tableName}" (${colDefs.join(", ")})`;
795
+ await adapterStartTransaction(db);
796
+ try {
797
+ await adapterExecute(db, sql);
798
+ await adapterCommit(db);
799
+ } catch (e) {
800
+ await adapterRollback(db);
801
+ throw e;
773
802
  }
774
803
  return true;
775
804
  }
@@ -777,9 +806,9 @@ export class BaseModel {
777
806
  /**
778
807
  * Find a record by primary key or throw an error if not found.
779
808
  */
780
- static findOrFail<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown): T {
809
+ static async findOrFail<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown): Promise<T> {
781
810
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
782
- const result = ModelClass.findById(id) as T | null;
811
+ const result = (await ModelClass.findById(id)) as T | null;
783
812
  if (result === null) {
784
813
  throw new Error(`${ModelClass.tableName}: record with id ${id} not found`);
785
814
  }
@@ -789,9 +818,9 @@ export class BaseModel {
789
818
  /**
790
819
  * Return true if a record with the given primary key exists.
791
820
  */
792
- static exists(pkValue: unknown): boolean {
821
+ static async exists(pkValue: unknown): Promise<boolean> {
793
822
  const ModelClass = this as unknown as typeof BaseModel;
794
- return ModelClass.findById(pkValue) !== null;
823
+ return (await ModelClass.findById(pkValue)) !== null;
795
824
  }
796
825
 
797
826
  /**
@@ -804,7 +833,7 @@ export class BaseModel {
804
833
  * @param offset Records to skip (default 0).
805
834
  * @param include Relationship names to eager-load on cache miss.
806
835
  */
807
- static cached<T extends BaseModel>(
836
+ static async cached<T extends BaseModel>(
808
837
  this: new (data?: Record<string, unknown>) => T,
809
838
  sql: string,
810
839
  params?: unknown[],
@@ -812,7 +841,7 @@ export class BaseModel {
812
841
  limit = 20,
813
842
  offset = 0,
814
843
  include?: string[],
815
- ): T[] {
844
+ ): Promise<T[]> {
816
845
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
817
846
  if (!ModelClass._queryCache) {
818
847
  ModelClass._queryCache = new QueryCache({ defaultTtl: ttl, maxSize: 500 });
@@ -824,10 +853,10 @@ export class BaseModel {
824
853
 
825
854
  const db = ModelClass.getDb();
826
855
  const querySql = `${sql} LIMIT ${limit} OFFSET ${offset}`;
827
- const rows = db.query(querySql, params);
856
+ const rows = await adapterQuery(db, querySql, params);
828
857
  const results = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
829
858
  if (include && results.length > 0) {
830
- ModelClass._eagerLoad(results as BaseModel[], include);
859
+ await ModelClass._eagerLoad(results as BaseModel[], include);
831
860
  }
832
861
  ModelClass._queryCache.set(key, results, ttl);
833
862
  return results;
@@ -846,28 +875,28 @@ export class BaseModel {
846
875
  /**
847
876
  * Execute a raw SQL SELECT and return results as model instances.
848
877
  */
849
- static select<T extends BaseModel>(
878
+ static async select<T extends BaseModel>(
850
879
  this: new (data?: Record<string, unknown>) => T,
851
880
  sql: string,
852
881
  params?: unknown[],
853
- ): T[] {
882
+ ): Promise<T[]> {
854
883
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
855
884
  const db = ModelClass.getDb();
856
- const rows = db.query(sql, params);
885
+ const rows = await adapterQuery(db, sql, params);
857
886
  return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
858
887
  }
859
888
 
860
- static selectOne<T extends BaseModel>(
889
+ static async selectOne<T extends BaseModel>(
861
890
  this: new (data?: Record<string, unknown>) => T,
862
891
  sql: string,
863
892
  params?: unknown[],
864
893
  include?: string[],
865
- ): T | null {
894
+ ): Promise<T | null> {
866
895
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
867
- const results = ModelClass.select<T>(sql, params);
896
+ const results = await ModelClass.select<T>(sql, params);
868
897
  const instance = results[0] ?? null;
869
898
  if (instance && include) {
870
- ModelClass._eagerLoad([instance], include);
899
+ await ModelClass._eagerLoad([instance], include);
871
900
  }
872
901
  return instance;
873
902
  }
@@ -875,7 +904,7 @@ export class BaseModel {
875
904
  /**
876
905
  * Permanently delete this instance, bypassing soft delete.
877
906
  */
878
- forceDelete(): boolean {
907
+ async forceDelete(): Promise<boolean> {
879
908
  const ModelClass = this.constructor as typeof BaseModel;
880
909
  const db = ModelClass.getDb();
881
910
  const pk = ModelClass.getPkField();
@@ -886,15 +915,15 @@ export class BaseModel {
886
915
  throw new Error("Cannot delete a model without a primary key value");
887
916
  }
888
917
 
889
- db.startTransaction();
918
+ await adapterStartTransaction(db);
890
919
  try {
891
- db.execute(
920
+ await adapterExecute(db,
892
921
  `DELETE FROM "${ModelClass.tableName}" WHERE "${pkCol}" = ?`,
893
922
  [pkValue],
894
923
  );
895
- db.commit();
924
+ await adapterCommit(db);
896
925
  } catch (e) {
897
- db.rollback();
926
+ await adapterRollback(db);
898
927
  throw e;
899
928
  }
900
929
  return true;
@@ -903,7 +932,7 @@ export class BaseModel {
903
932
  /**
904
933
  * Restore a soft-deleted record.
905
934
  */
906
- restore(): boolean {
935
+ async restore(): Promise<boolean> {
907
936
  const ModelClass = this.constructor as typeof BaseModel;
908
937
  if (!ModelClass.softDelete) {
909
938
  throw new Error("restore() is only available on models with softDelete enabled");
@@ -918,15 +947,15 @@ export class BaseModel {
918
947
  throw new Error("Cannot restore a model without a primary key value");
919
948
  }
920
949
 
921
- db.startTransaction();
950
+ await adapterStartTransaction(db);
922
951
  try {
923
- db.execute(
952
+ await adapterExecute(db,
924
953
  `UPDATE "${ModelClass.tableName}" SET is_deleted = 0 WHERE "${pkCol}" = ?`,
925
954
  [pkValue],
926
955
  );
927
- db.commit();
956
+ await adapterCommit(db);
928
957
  } catch (e) {
929
- db.rollback();
958
+ await adapterRollback(db);
930
959
  throw e;
931
960
  }
932
961
  this.is_deleted = 0;
@@ -936,13 +965,13 @@ export class BaseModel {
936
965
  /**
937
966
  * Find records including soft-deleted ones.
938
967
  */
939
- static withTrashed<T extends BaseModel>(
968
+ static async withTrashed<T extends BaseModel>(
940
969
  this: new (data?: Record<string, unknown>) => T,
941
970
  conditions?: string,
942
971
  params?: unknown[],
943
972
  limit?: number,
944
973
  offset?: number,
945
- ): T[] {
974
+ ): Promise<T[]> {
946
975
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
947
976
  const db = ModelClass.getDb();
948
977
 
@@ -965,14 +994,14 @@ export class BaseModel {
965
994
  sql += ` OFFSET ${offset}`;
966
995
  }
967
996
 
968
- const rows = db.query(sql, params);
997
+ const rows = await adapterQuery(db, sql, params);
969
998
  return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
970
999
  }
971
1000
 
972
1001
  /**
973
1002
  * Count records matching conditions (respects soft delete and table filter).
974
1003
  */
975
- static count(conditions?: string, params?: unknown[]): number {
1004
+ static async count(conditions?: string, params?: unknown[]): Promise<number> {
976
1005
  const db = this.getDb();
977
1006
  const parts: string[] = [];
978
1007
  if (this.softDelete) {
@@ -986,8 +1015,14 @@ export class BaseModel {
986
1015
  }
987
1016
  const whereClause = parts.length > 0 ? ` WHERE ${parts.join(" AND ")}` : "";
988
1017
  const sql = `SELECT COUNT(*) as cnt FROM "${this.tableName}"${whereClause}`;
989
- const rows = db.query(sql, params);
990
- return rows.length > 0 ? (rows[0] as any).cnt : 0;
1018
+ const rows = await adapterQuery(db, sql, params);
1019
+ if (rows.length === 0) return 0;
1020
+ // PostgreSQL returns COUNT(*) as a bigint, which the `pg` driver hands
1021
+ // back as a string ("2"); Firebird upper-cases the alias. Coerce to a
1022
+ // Number and tolerate case so count() returns a real number on every engine.
1023
+ const row = rows[0] as Record<string, unknown>;
1024
+ const cnt = row.cnt ?? row.CNT ?? 0;
1025
+ return Number(cnt);
991
1026
  }
992
1027
 
993
1028
  /**
@@ -1012,11 +1047,11 @@ export class BaseModel {
1012
1047
  /**
1013
1048
  * Load a has-one related model instance.
1014
1049
  */
1015
- hasOne<T extends BaseModel, R extends BaseModel>(
1050
+ async hasOne<T extends BaseModel, R extends BaseModel>(
1016
1051
  this: T,
1017
1052
  relatedClass: typeof BaseModel & (new (data?: Record<string, unknown>) => R),
1018
1053
  foreignKey: string,
1019
- ): R | null {
1054
+ ): Promise<R | null> {
1020
1055
  const ModelClass = this.constructor as typeof BaseModel;
1021
1056
  const pk = ModelClass.getPkField();
1022
1057
  const pkValue = this[pk];
@@ -1032,7 +1067,7 @@ export class BaseModel {
1032
1067
  }
1033
1068
  sql += ` LIMIT 1`;
1034
1069
 
1035
- const rows = db.query(sql, [pkValue]);
1070
+ const rows = await adapterQuery(db, sql, [pkValue]);
1036
1071
  if (rows.length === 0) return null;
1037
1072
 
1038
1073
  const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
@@ -1044,13 +1079,13 @@ export class BaseModel {
1044
1079
  /**
1045
1080
  * Load has-many related model instances.
1046
1081
  */
1047
- hasMany<T extends BaseModel, R extends BaseModel>(
1082
+ async hasMany<T extends BaseModel, R extends BaseModel>(
1048
1083
  this: T,
1049
1084
  relatedClass: typeof BaseModel & (new (data?: Record<string, unknown>) => R),
1050
1085
  foreignKey: string,
1051
1086
  limit: number = 100,
1052
1087
  offset: number = 0,
1053
- ): R[] {
1088
+ ): Promise<R[]> {
1054
1089
  const ModelClass = this.constructor as typeof BaseModel;
1055
1090
  const pk = ModelClass.getPkField();
1056
1091
  const pkValue = this[pk];
@@ -1066,7 +1101,7 @@ export class BaseModel {
1066
1101
  }
1067
1102
  sql += ` LIMIT ${limit} OFFSET ${offset}`;
1068
1103
 
1069
- const rows = db.query(sql, [pkValue]);
1104
+ const rows = await adapterQuery(db, sql, [pkValue]);
1070
1105
  const related = rows.map((row) => new relatedClass(row as Record<string, unknown>) as R);
1071
1106
  const relKey = relatedClass.tableName.toLowerCase();
1072
1107
  this[relKey] = related;
@@ -1076,11 +1111,11 @@ export class BaseModel {
1076
1111
  /**
1077
1112
  * Load the parent model this instance belongs to.
1078
1113
  */
1079
- belongsTo<T extends BaseModel, R extends BaseModel>(
1114
+ async belongsTo<T extends BaseModel, R extends BaseModel>(
1080
1115
  this: T,
1081
1116
  relatedClass: typeof BaseModel & (new (data?: Record<string, unknown>) => R),
1082
1117
  foreignKey: string,
1083
- ): R | null {
1118
+ ): Promise<R | null> {
1084
1119
  // foreignKey is a DB column name — resolve to JS property name on this model
1085
1120
  const ModelClass = this.constructor as typeof BaseModel;
1086
1121
  const reverseMap = ModelClass.getReverseMapping();
@@ -1099,7 +1134,7 @@ export class BaseModel {
1099
1134
  }
1100
1135
  sql += ` LIMIT 1`;
1101
1136
 
1102
- const rows = db.query(sql, [fkValue]);
1137
+ const rows = await adapterQuery(db, sql, [fkValue]);
1103
1138
  if (rows.length === 0) return null;
1104
1139
 
1105
1140
  const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
@@ -1115,63 +1150,35 @@ export class BaseModel {
1115
1150
 
1116
1151
  static registerModel(name: string, modelClass: typeof BaseModel): void {
1117
1152
  BaseModel._modelRegistry[name] = modelClass;
1153
+ // Eagerly process this model's foreignKey fields so the cross-model
1154
+ // _fkRegistry is populated as soon as the model is known — not only when
1155
+ // the *declaring* model happens to be touched first. This is what makes
1156
+ // eager loading work standalone (without server-boot auto-discovery):
1157
+ // a parent's hasMany is registered by the child's _processForeignKeys(),
1158
+ // so we must run it for every registered model proactively.
1159
+ modelClass._processForeignKeys();
1118
1160
  }
1119
1161
 
1120
1162
  /**
1121
- * Resolve a model class by name from the registry.
1163
+ * Process foreignKey fields on every registered model so the cross-model
1164
+ * _fkRegistry (and each model's belongsTo/hasMany) is fully wired regardless
1165
+ * of which model was used first. Idempotent — _processForeignKeys() and
1166
+ * _applyFkRegistry() both guard against duplicates.
1122
1167
  */
1123
- private static _resolveModel(name: string): (typeof BaseModel) | null {
1124
- return BaseModel._modelRegistry[name] ?? null;
1168
+ private static _processAllForeignKeys(): void {
1169
+ for (const modelClass of Object.values(BaseModel._modelRegistry)) {
1170
+ modelClass._processForeignKeys();
1171
+ }
1172
+ for (const modelClass of Object.values(BaseModel._modelRegistry)) {
1173
+ modelClass._applyFkRegistry();
1174
+ }
1125
1175
  }
1126
1176
 
1127
1177
  /**
1128
- * Lazy-load a single relationship by name (used by toDict with include).
1178
+ * Resolve a model class by name from the registry.
1129
1179
  */
1130
- private _lazyLoadRelationship(relName: string): unknown {
1131
- const ModelClass = this.constructor as typeof BaseModel;
1132
-
1133
- // Apply FK registry so foreignKey fields auto-wire relationships
1134
- ModelClass._processForeignKeys();
1135
- ModelClass._applyFkRegistry();
1136
-
1137
- // Check hasOne
1138
- if (ModelClass.hasOne) {
1139
- const rel = ModelClass.hasOne.find((r) => r.model.toLowerCase() === relName || r.model === relName);
1140
- if (rel) {
1141
- const relatedClass = BaseModel._modelRegistry[rel.model];
1142
- if (relatedClass) {
1143
- return this.hasOne(relatedClass as any, rel.foreignKey);
1144
- }
1145
- }
1146
- }
1147
-
1148
- // Check hasMany
1149
- if (ModelClass.hasMany) {
1150
- const rel = ModelClass.hasMany.find((r) => {
1151
- const base = r.model.toLowerCase();
1152
- const key = _pluralRelKeys() ? base + "s" : base;
1153
- return key === relName || base === relName || r.model === relName;
1154
- });
1155
- if (rel) {
1156
- const relatedClass = BaseModel._modelRegistry[rel.model];
1157
- if (relatedClass) {
1158
- return this.hasMany(relatedClass as any, rel.foreignKey);
1159
- }
1160
- }
1161
- }
1162
-
1163
- // Check belongsTo
1164
- if (ModelClass.belongsTo) {
1165
- const rel = ModelClass.belongsTo.find((r) => r.model.toLowerCase() === relName || r.model === relName);
1166
- if (rel) {
1167
- const relatedClass = BaseModel._modelRegistry[rel.model];
1168
- if (relatedClass) {
1169
- return this.belongsTo(relatedClass as any, rel.foreignKey);
1170
- }
1171
- }
1172
- }
1173
-
1174
- return undefined;
1180
+ private static _resolveModel(name: string): (typeof BaseModel) | null {
1181
+ return BaseModel._modelRegistry[name] ?? null;
1175
1182
  }
1176
1183
 
1177
1184
  /**
@@ -1179,14 +1186,18 @@ export class BaseModel {
1179
1186
  * @param instances Array of model instances.
1180
1187
  * @param include Array of relationship names (supports dot notation for nesting).
1181
1188
  */
1182
- static _eagerLoad(instances: BaseModel[], include: string[]): void {
1189
+ static async _eagerLoad(instances: BaseModel[], include: string[]): Promise<void> {
1183
1190
  if (instances.length === 0) return;
1184
1191
 
1185
1192
  const ModelClass = instances[0].constructor as typeof BaseModel;
1186
1193
 
1187
- // Apply FK registry so foreignKey fields auto-wire hasMany on referenced models
1188
- ModelClass._processForeignKeys();
1189
- ModelClass._applyFkRegistry();
1194
+ // Wire FK relationships across ALL registered models, not just the parent.
1195
+ // A parent's hasMany is declared by the CHILD's foreignKey field, so we must
1196
+ // process every registered model's FKs before resolving includes — otherwise
1197
+ // a standalone Author.findById(id, ["posts"]) silently finds nothing because
1198
+ // Post._processForeignKeys() never ran. _applyFkRegistry() then merges the
1199
+ // registered hasMany entries onto each model.
1200
+ BaseModel._processAllForeignKeys();
1190
1201
 
1191
1202
  // Group includes: top-level and nested
1192
1203
  const topLevel: Record<string, string[]> = {};
@@ -1202,28 +1213,54 @@ export class BaseModel {
1202
1213
  }
1203
1214
 
1204
1215
  for (const [relName, nested] of Object.entries(topLevel)) {
1205
- // Find the relationship definition
1216
+ // Find the relationship definition.
1217
+ //
1218
+ // Include names are resolved case-insensitively against each candidate
1219
+ // relation. Accepted forms (for a relation to model "Post" on table "posts"):
1220
+ // - the model name → "Post" / "post"
1221
+ // - the auto/related key → "post" (singular) or "posts" (when
1222
+ // TINA4_ORM_PLURAL_TABLE_NAMES is enabled)
1223
+ // - the table name → "posts"
1224
+ // All matching is lower-cased so "Post", "post" and "posts" all resolve.
1225
+ const want = relName.toLowerCase();
1206
1226
  let relDef: RelationshipDefinition | undefined;
1207
1227
  let relType: "hasOne" | "hasMany" | "belongsTo" | null = null;
1208
1228
 
1229
+ const matchesModel = (r: RelationshipDefinition): boolean => {
1230
+ const base = r.model.toLowerCase();
1231
+ const related = BaseModel._modelRegistry[r.model];
1232
+ const table = related?.tableName?.toLowerCase();
1233
+ return (
1234
+ base === want ||
1235
+ base + "s" === want ||
1236
+ (table !== undefined && table === want)
1237
+ );
1238
+ };
1239
+
1209
1240
  if (ModelClass.hasOne) {
1210
- relDef = ModelClass.hasOne.find((r) => r.model.toLowerCase() === relName || r.model === relName);
1241
+ relDef = ModelClass.hasOne.find(matchesModel);
1211
1242
  if (relDef) relType = "hasOne";
1212
1243
  }
1213
1244
  if (!relDef && ModelClass.hasMany) {
1214
- relDef = ModelClass.hasMany.find((r) => {
1215
- const base = r.model.toLowerCase();
1216
- const key = _pluralRelKeys() ? base + "s" : base;
1217
- return key === relName || base === relName || r.model === relName;
1218
- });
1245
+ relDef = ModelClass.hasMany.find(matchesModel);
1219
1246
  if (relDef) relType = "hasMany";
1220
1247
  }
1221
1248
  if (!relDef && ModelClass.belongsTo) {
1222
- relDef = ModelClass.belongsTo.find((r) => r.model.toLowerCase() === relName || r.model === relName);
1249
+ relDef = ModelClass.belongsTo.find(matchesModel);
1223
1250
  if (relDef) relType = "belongsTo";
1224
1251
  }
1225
1252
 
1226
- if (!relDef || !relType) continue;
1253
+ if (!relDef || !relType) {
1254
+ // Don't silently skip — a typo'd or unknown include name is almost
1255
+ // always a developer mistake. Surface it so it's visible.
1256
+ Log.warn(
1257
+ `eager-load: include "${relName}" did not match any relationship on ` +
1258
+ `${ModelClass.name} (table "${ModelClass.tableName}"). ` +
1259
+ `Accepted forms are the related model name, its singular/plural ` +
1260
+ `key, or the related table name (case-insensitive).`,
1261
+ );
1262
+ continue;
1263
+ }
1227
1264
 
1228
1265
  const relatedClass = BaseModel._modelRegistry[relDef.model];
1229
1266
  if (!relatedClass) continue;
@@ -1244,12 +1281,12 @@ export class BaseModel {
1244
1281
  sql += ` AND is_deleted = 0`;
1245
1282
  }
1246
1283
 
1247
- const rows = db.query(sql, pkValues);
1284
+ const rows = await adapterQuery(db, sql, pkValues);
1248
1285
  const related = rows.map((row) => new relatedClass(row as Record<string, unknown>));
1249
1286
 
1250
1287
  // Eager load nested
1251
1288
  if (nested.length > 0 && related.length > 0) {
1252
- relatedClass._eagerLoad(related, nested);
1289
+ await relatedClass._eagerLoad(related, nested);
1253
1290
  }
1254
1291
 
1255
1292
  // Group by FK — fk is a DB column name, resolve to JS property name on the related model
@@ -1290,11 +1327,11 @@ export class BaseModel {
1290
1327
  sql += ` AND is_deleted = 0`;
1291
1328
  }
1292
1329
 
1293
- const rows = db.query(sql, fkValues);
1330
+ const rows = await adapterQuery(db, sql, fkValues);
1294
1331
  const related = rows.map((row) => new relatedClass(row as Record<string, unknown>));
1295
1332
 
1296
1333
  if (nested.length > 0 && related.length > 0) {
1297
- relatedClass._eagerLoad(related, nested);
1334
+ await relatedClass._eagerLoad(related, nested);
1298
1335
  }
1299
1336
 
1300
1337
  const lookup: Record<string, BaseModel> = {};
@@ -1323,10 +1360,9 @@ export class BaseModel {
1323
1360
  * @param instances Array of model instances to load relationships onto.
1324
1361
  * @param includeList Array of relationship names (supports dot notation for nesting).
1325
1362
  */
1326
- static eagerLoad(instances: BaseModel[], includeList: string[]): Promise<void> {
1363
+ static async eagerLoad(instances: BaseModel[], includeList: string[]): Promise<void> {
1327
1364
  const ModelClass = this as unknown as typeof BaseModel;
1328
- ModelClass._eagerLoad(instances, includeList);
1329
- return Promise.resolve();
1365
+ await ModelClass._eagerLoad(instances, includeList);
1330
1366
  }
1331
1367
 
1332
1368
  /**