tina4-nodejs 3.10.76 → 3.10.84

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.
@@ -2,6 +2,7 @@ import { getAdapter, getNamedAdapter, setAdapter, parseDatabaseUrl } from "./dat
2
2
  import { validate as validateFields } from "./validation.js";
3
3
  import { QueryBuilder } from "./queryBuilder.js";
4
4
  import { SQLiteAdapter } from "./adapters/sqlite.js";
5
+ import { QueryCache } from "./sqlTranslation.js";
5
6
  import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
6
7
 
7
8
  /**
@@ -54,6 +55,7 @@ export class BaseModel {
54
55
  static hasMany?: RelationshipDefinition[];
55
56
  static belongsTo?: RelationshipDefinition[];
56
57
  static _db?: string;
58
+ static _queryCache?: QueryCache;
57
59
 
58
60
  /**
59
61
  * When true, auto-generates fieldMapping entries from camelCase field names
@@ -69,6 +71,12 @@ export class BaseModel {
69
71
  */
70
72
  static fieldMapping: Record<string, string> = {};
71
73
 
74
+ /**
75
+ * When true, auto-generates CRUD routes for this model.
76
+ * Models must explicitly opt-in by setting `static autoCrud = true;`.
77
+ */
78
+ static autoCrud: boolean = false;
79
+
72
80
  /** Instance data */
73
81
  [key: string]: unknown;
74
82
 
@@ -235,17 +243,95 @@ export class BaseModel {
235
243
  return instance;
236
244
  }
237
245
 
238
- /** Alias for findById(). */
239
- static find<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown, include?: string[]): T | null {
240
- return (this as unknown as typeof BaseModel).findById.call(this, id, include) as T | null;
246
+ /**
247
+ * Find records by filter dict. Always returns an array.
248
+ *
249
+ * Usage:
250
+ * User.find({ name: "Alice" }) → [User, ...]
251
+ * User.find({ age: 18 }, 10) → [User, ...] (limit 10)
252
+ * User.find({}, 100, 0, "name ASC") → [User, ...] (with orderBy)
253
+ * User.find() → all records
254
+ *
255
+ * Use findById(id) for single-record primary key lookup.
256
+ */
257
+ static find<T extends BaseModel>(
258
+ this: new (data?: Record<string, unknown>) => T,
259
+ filter?: Record<string, unknown>,
260
+ limit = 100,
261
+ offset = 0,
262
+ orderBy?: string,
263
+ include?: string[],
264
+ ): T[] {
265
+ const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
266
+ const db = ModelClass.getDb();
267
+ const conditions: string[] = [];
268
+ const params: unknown[] = [];
269
+
270
+ if (filter) {
271
+ for (const [key, value] of Object.entries(filter)) {
272
+ const col = ModelClass.getDbColumn(key) ?? key;
273
+ conditions.push(`"${col}" = ?`);
274
+ params.push(value);
275
+ }
276
+ }
277
+
278
+ if (ModelClass.softDelete) {
279
+ conditions.push("is_deleted = 0");
280
+ }
281
+
282
+ let sql = `SELECT * FROM "${ModelClass.tableName}"`;
283
+ if (conditions.length > 0) {
284
+ sql += ` WHERE ${conditions.join(" AND ")}`;
285
+ }
286
+ if (orderBy) {
287
+ sql += ` ORDER BY ${orderBy}`;
288
+ }
289
+
290
+ const rows = db.fetch(sql, params, limit, offset);
291
+ const data = (rows as any)?.data ?? rows;
292
+ const instances = (Array.isArray(data) ? data : []).map((row: Record<string, unknown>) => {
293
+ const inst = new this(row) as T;
294
+ (inst as any)._exists = true;
295
+ return inst;
296
+ });
297
+
298
+ if (include) {
299
+ ModelClass._eagerLoad(instances as BaseModel[], include);
300
+ }
301
+
302
+ return instances;
241
303
  }
242
304
 
243
305
  /**
244
306
  * Load a record into this instance via selectOne.
245
307
  * Returns true if found and loaded, false otherwise.
246
308
  */
247
- load(sql: string, params?: unknown[], include?: string[]): boolean {
309
+ /**
310
+ * Load a record into this instance.
311
+ *
312
+ * Usage:
313
+ * orm.id = 1; orm.load() — uses PK already set
314
+ * orm.load("id = ?", [1]) — filter with params
315
+ * orm.load("id = 1") — filter string
316
+ *
317
+ * Returns true if found, false otherwise.
318
+ */
319
+ load(filter?: string, params?: unknown[], include?: string[]): boolean {
248
320
  const ModelClass = this.constructor as typeof BaseModel & (new (data?: Record<string, unknown>) => BaseModel);
321
+ const table = (ModelClass as any).tableName ?? (this as any).tableName;
322
+
323
+ let sql: string;
324
+ if (filter === undefined || filter === null) {
325
+ // No args — use PK already set
326
+ const pk = (ModelClass as any).primaryKey ?? (this as any).primaryKey ?? "id";
327
+ const pkValue = (this as any)[pk];
328
+ if (pkValue === undefined || pkValue === null) return false;
329
+ sql = `SELECT * FROM ${table} WHERE ${pk} = ?`;
330
+ params = [pkValue];
331
+ } else {
332
+ sql = `SELECT * FROM ${table} WHERE ${filter}`;
333
+ }
334
+
249
335
  const result = ModelClass.selectOne(sql, params, include);
250
336
  if (!result) return false;
251
337
  const data = (result as any).toJSON ? (result as any).toJSON() : result;
@@ -338,7 +424,7 @@ export class BaseModel {
338
424
  * Save this instance (insert or update).
339
425
  * Returns this on success (fluent), null on failure.
340
426
  */
341
- save(): this | null {
427
+ save(): this | false {
342
428
  const ModelClass = this.constructor as typeof BaseModel;
343
429
  const db = ModelClass.getDb();
344
430
  const pk = ModelClass.getPkField();
@@ -381,7 +467,7 @@ export class BaseModel {
381
467
  db.commit();
382
468
  } catch (e) {
383
469
  db.rollback();
384
- return null;
470
+ return false;
385
471
  }
386
472
  (this as any)._exists = true;
387
473
  return this;
@@ -390,7 +476,7 @@ export class BaseModel {
390
476
  /**
391
477
  * Delete this instance. Uses soft delete if configured.
392
478
  */
393
- delete(): void {
479
+ delete(): boolean {
394
480
  const ModelClass = this.constructor as typeof BaseModel;
395
481
  const db = ModelClass.getDb();
396
482
  const pk = ModelClass.getPkField();
@@ -420,6 +506,7 @@ export class BaseModel {
420
506
  db.rollback();
421
507
  throw e;
422
508
  }
509
+ return true;
423
510
  }
424
511
 
425
512
  /**
@@ -552,9 +639,9 @@ export class BaseModel {
552
639
  * Generate and execute CREATE TABLE DDL from the model's field definitions.
553
640
  * Uses the adapter's createTable method if available, otherwise builds SQL directly.
554
641
  */
555
- static createTable(): void {
642
+ static createTable(): boolean {
556
643
  const db = this.getDb();
557
- if (db.tableExists(this.tableName)) return;
644
+ if (db.tableExists(this.tableName)) return true;
558
645
 
559
646
  if (typeof db.createTable === "function") {
560
647
  // Remap field keys to DB column names if fieldMapping is defined
@@ -601,6 +688,7 @@ export class BaseModel {
601
688
  throw e;
602
689
  }
603
690
  }
691
+ return true;
604
692
  }
605
693
 
606
694
  /**
@@ -615,6 +703,45 @@ export class BaseModel {
615
703
  return result;
616
704
  }
617
705
 
706
+ /**
707
+ * Return true if a record with the given primary key exists.
708
+ */
709
+ static exists(id: unknown): boolean {
710
+ const ModelClass = this as unknown as typeof BaseModel;
711
+ return ModelClass.findById(id) !== null;
712
+ }
713
+
714
+ /**
715
+ * Run a raw SQL query with results cached by TTL. Cache is per-model-class.
716
+ */
717
+ static cached<T extends BaseModel>(
718
+ this: new (data?: Record<string, unknown>) => T,
719
+ sql: string,
720
+ params?: unknown[],
721
+ ttl = 60,
722
+ ): T[] {
723
+ const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
724
+ if (!ModelClass._queryCache) {
725
+ ModelClass._queryCache = new QueryCache({ defaultTtl: ttl, maxSize: 500 });
726
+ }
727
+ const key = QueryCache.queryKey(`${ModelClass.tableName}:${sql}`, params ?? []);
728
+ const hit = ModelClass._queryCache.get(key) as T[] | undefined;
729
+ if (hit !== undefined) return hit;
730
+ const results = ModelClass.select<T>(sql, params);
731
+ ModelClass._queryCache.set(key, results, ttl);
732
+ return results;
733
+ }
734
+
735
+ /**
736
+ * Clear the per-model query cache.
737
+ */
738
+ static clearCache(): void {
739
+ const ModelClass = this as unknown as typeof BaseModel;
740
+ if (ModelClass._queryCache) {
741
+ ModelClass._queryCache.clear();
742
+ }
743
+ }
744
+
618
745
  /**
619
746
  * Execute a raw SQL SELECT and return results as model instances.
620
747
  */
@@ -647,7 +774,7 @@ export class BaseModel {
647
774
  /**
648
775
  * Permanently delete this instance, bypassing soft delete.
649
776
  */
650
- forceDelete(): void {
777
+ forceDelete(): boolean {
651
778
  const ModelClass = this.constructor as typeof BaseModel;
652
779
  const db = ModelClass.getDb();
653
780
  const pk = ModelClass.getPkField();
@@ -669,12 +796,13 @@ export class BaseModel {
669
796
  db.rollback();
670
797
  throw e;
671
798
  }
799
+ return true;
672
800
  }
673
801
 
674
802
  /**
675
803
  * Restore a soft-deleted record.
676
804
  */
677
- restore(): void {
805
+ restore(): boolean {
678
806
  const ModelClass = this.constructor as typeof BaseModel;
679
807
  if (!ModelClass.softDelete) {
680
808
  throw new Error("restore() is only available on models with softDelete enabled");
@@ -701,6 +829,7 @@ export class BaseModel {
701
829
  throw e;
702
830
  }
703
831
  this.is_deleted = 0;
832
+ return true;
704
833
  }
705
834
 
706
835
  /**
@@ -32,6 +32,7 @@ export {
32
32
  migrate,
33
33
  createMigration,
34
34
  status,
35
+ Migration,
35
36
  } from "./migration.js";
36
37
  export type { MigrationResult, MigrationStatus } from "./migration.js";
37
38
  export { generateCrudRoutes } from "./autoCrud.js";
@@ -745,3 +745,84 @@ export async function createMigration(
745
745
 
746
746
  return { upPath, downPath };
747
747
  }
748
+
749
+ /**
750
+ * Object-oriented Migration class — canonical Tina4 Migration API.
751
+ *
752
+ * Provides parity with Python, PHP, and Ruby:
753
+ * - migrate() Run all pending migrations
754
+ * - rollback(steps=1) Roll back last N batches
755
+ * - status() Show completed/pending
756
+ * - create(description) Scaffold new .sql + .down.sql files
757
+ * - getApplied() List applied migrations
758
+ * - getPending() List pending migration filenames
759
+ * - getFiles() List all migration files on disk
760
+ *
761
+ * @example
762
+ * const m = new Migration(db, { migrationsDir: "migrations" });
763
+ * await m.migrate();
764
+ * await m.rollback(2);
765
+ * await m.status();
766
+ * await m.create("add users table");
767
+ */
768
+ export class Migration {
769
+ private db?: DatabaseAdapter;
770
+ private dir: string;
771
+ private delimiter: string;
772
+
773
+ constructor(db?: DatabaseAdapter, options?: { migrationsDir?: string; delimiter?: string }) {
774
+ this.db = db;
775
+ this.dir = options?.migrationsDir ?? "migrations";
776
+ this.delimiter = options?.delimiter ?? ";";
777
+ }
778
+
779
+ /** Run all pending migrations. Returns applied/skipped/failed summary. */
780
+ async migrate(): Promise<MigrationResult> {
781
+ return migrate(this.db, { migrationsDir: this.dir, delimiter: this.delimiter });
782
+ }
783
+
784
+ /** Roll back the last N batches. Returns list of rolled-back migration names. */
785
+ async rollback(steps = 1): Promise<string[]> {
786
+ const db = this.db ?? (await import("./database.js")).getAdapter();
787
+ // If tracking table doesn't exist yet there's nothing to roll back
788
+ if (!db.tableExists(MIGRATION_TABLE)) return [];
789
+ const rolled: string[] = [];
790
+ for (let i = 0; i < steps; i++) {
791
+ const batch = rollback(this.dir, this.delimiter);
792
+ if (batch.length === 0) break;
793
+ rolled.push(...batch);
794
+ }
795
+ return rolled;
796
+ }
797
+
798
+ /** Get migration status: which are completed and which are pending. */
799
+ async status(): Promise<MigrationStatus> {
800
+ return status(this.db, { migrationsDir: this.dir });
801
+ }
802
+
803
+ /** Scaffold a new .sql + .down.sql migration file. Returns created paths. */
804
+ async create(description: string): Promise<{ upPath: string; downPath: string }> {
805
+ return createMigration(description, { migrationsDir: this.dir });
806
+ }
807
+
808
+ /** Return list of completed (applied) migration filenames. */
809
+ async getApplied(): Promise<string[]> {
810
+ const s = await this.status();
811
+ return s.completed;
812
+ }
813
+
814
+ /** Return list of pending migration filenames. */
815
+ async getPending(): Promise<string[]> {
816
+ const s = await this.status();
817
+ return s.pending;
818
+ }
819
+
820
+ /** Return sorted list of all migration files on disk (excludes .down.sql). */
821
+ getFiles(): string[] {
822
+ const dir = resolve(this.dir);
823
+ if (!existsSync(dir)) return [];
824
+ return sortMigrationFiles(
825
+ readdirSync(dir).filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")),
826
+ );
827
+ }
828
+ }