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.
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/packages/core/src/auth.ts +36 -54
- package/packages/core/src/index.ts +4 -1
- package/packages/core/src/job.ts +86 -0
- package/packages/core/src/middleware.ts +5 -8
- package/packages/core/src/queue.ts +73 -465
- package/packages/core/src/queueBackends/liteBackend.ts +374 -0
- package/packages/core/src/server.ts +4 -7
- package/packages/core/src/websocket.ts +81 -0
- package/packages/orm/src/baseModel.ts +140 -11
- package/packages/orm/src/index.ts +1 -0
- package/packages/orm/src/migration.ts +81 -0
|
@@ -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
|
-
/**
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
|
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():
|
|
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():
|
|
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():
|
|
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():
|
|
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
|
/**
|
|
@@ -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
|
+
}
|