tina4-nodejs 3.0.0-rc.2 → 3.1.0
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/BENCHMARK_REPORT.md +248 -86
- package/CARBONAH.md +4 -4
- package/CLAUDE.md +16 -1
- package/COMPARISON.md +58 -46
- package/README.md +60 -6
- package/package.json +2 -1
- package/packages/cli/src/bin.ts +8 -0
- package/packages/cli/src/commands/generate.ts +237 -0
- package/packages/core/gallery/queue/meta.json +1 -1
- package/packages/core/gallery/queue/src/lib/queueDb.ts +32 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +28 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +28 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +20 -10
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +25 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +36 -6
- package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +160 -0
- package/packages/core/src/cache.ts +402 -10
- package/packages/core/src/index.ts +5 -2
- package/packages/core/src/messenger.ts +118 -36
- package/packages/core/src/queue.ts +172 -92
- package/packages/core/src/response.ts +46 -0
- package/packages/core/src/router.ts +94 -1
- package/packages/core/src/server.ts +66 -7
- package/packages/core/src/types.ts +20 -1
- package/packages/core/src/websocketConnection.ts +16 -0
- package/packages/frond/src/engine.ts +184 -6
- package/packages/orm/src/baseModel.ts +274 -20
- package/packages/orm/src/cachedDatabase.ts +180 -0
- package/packages/orm/src/index.ts +4 -0
- package/packages/orm/src/model.ts +1 -0
- package/packages/orm/src/types.ts +75 -0
|
@@ -24,11 +24,15 @@ export class BaseModel {
|
|
|
24
24
|
static tableFilter?: string;
|
|
25
25
|
static hasOne?: RelationshipDefinition[];
|
|
26
26
|
static hasMany?: RelationshipDefinition[];
|
|
27
|
+
static belongsTo?: RelationshipDefinition[];
|
|
27
28
|
static _db?: string;
|
|
28
29
|
|
|
29
30
|
/** Instance data */
|
|
30
31
|
[key: string]: unknown;
|
|
31
32
|
|
|
33
|
+
/** Relationship cache for lazy loading */
|
|
34
|
+
private _relCache: Record<string, unknown> = {};
|
|
35
|
+
|
|
32
36
|
constructor(data?: Record<string, unknown>) {
|
|
33
37
|
if (data) {
|
|
34
38
|
for (const [key, value] of Object.entries(data)) {
|
|
@@ -56,8 +60,10 @@ export class BaseModel {
|
|
|
56
60
|
|
|
57
61
|
/**
|
|
58
62
|
* Find a record by primary key.
|
|
63
|
+
* @param id Primary key value.
|
|
64
|
+
* @param include Optional array of relationship names to eager-load.
|
|
59
65
|
*/
|
|
60
|
-
static findById<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown): T | null {
|
|
66
|
+
static findById<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown, include?: string[]): T | null {
|
|
61
67
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
62
68
|
const db = ModelClass.getDb();
|
|
63
69
|
const pk = ModelClass.getPkField();
|
|
@@ -72,16 +78,24 @@ export class BaseModel {
|
|
|
72
78
|
|
|
73
79
|
const rows = db.query(sql, [id]);
|
|
74
80
|
if (rows.length === 0) return null;
|
|
75
|
-
|
|
81
|
+
const instance = new ModelClass(rows[0] as Record<string, unknown>) as T;
|
|
82
|
+
if (include) {
|
|
83
|
+
ModelClass._eagerLoad([instance], include);
|
|
84
|
+
}
|
|
85
|
+
return instance;
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
/**
|
|
79
89
|
* Find all records, optionally with a where clause.
|
|
90
|
+
* @param where Optional WHERE clause.
|
|
91
|
+
* @param params Optional query parameters.
|
|
92
|
+
* @param include Optional array of relationship names to eager-load.
|
|
80
93
|
*/
|
|
81
94
|
static findAll<T extends BaseModel>(
|
|
82
95
|
this: new (data?: Record<string, unknown>) => T,
|
|
83
96
|
where?: string,
|
|
84
97
|
params?: unknown[],
|
|
98
|
+
include?: string[],
|
|
85
99
|
): T[] {
|
|
86
100
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
87
101
|
const db = ModelClass.getDb();
|
|
@@ -101,7 +115,11 @@ export class BaseModel {
|
|
|
101
115
|
const sql = `SELECT * FROM "${ModelClass.tableName}"${whereClause}`;
|
|
102
116
|
|
|
103
117
|
const rows = db.query(sql, params);
|
|
104
|
-
|
|
118
|
+
const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
119
|
+
if (include) {
|
|
120
|
+
ModelClass._eagerLoad(instances, include);
|
|
121
|
+
}
|
|
122
|
+
return instances;
|
|
105
123
|
}
|
|
106
124
|
|
|
107
125
|
/**
|
|
@@ -112,6 +130,7 @@ export class BaseModel {
|
|
|
112
130
|
const db = ModelClass.getDb();
|
|
113
131
|
const pk = ModelClass.getPkField();
|
|
114
132
|
const pkValue = this[pk];
|
|
133
|
+
this._relCache = {}; // Clear relationship cache on save
|
|
115
134
|
|
|
116
135
|
if (pkValue !== undefined && pkValue !== null) {
|
|
117
136
|
// Update
|
|
@@ -174,8 +193,9 @@ export class BaseModel {
|
|
|
174
193
|
|
|
175
194
|
/**
|
|
176
195
|
* Convert to plain object (dictionary).
|
|
196
|
+
* @param include Optional array of relationship names to include (supports dot notation for nesting).
|
|
177
197
|
*/
|
|
178
|
-
toDict(): Record<string, unknown> {
|
|
198
|
+
toDict(include?: string[]): Record<string, unknown> {
|
|
179
199
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
180
200
|
const result: Record<string, unknown> = {};
|
|
181
201
|
for (const key of Object.keys(ModelClass.fields)) {
|
|
@@ -183,27 +203,66 @@ export class BaseModel {
|
|
|
183
203
|
result[key] = this[key];
|
|
184
204
|
}
|
|
185
205
|
}
|
|
186
|
-
// Include
|
|
187
|
-
if (ModelClass.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
206
|
+
// Include soft delete field
|
|
207
|
+
if (ModelClass.softDelete && this.is_deleted !== undefined) {
|
|
208
|
+
result.is_deleted = this.is_deleted;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (include) {
|
|
212
|
+
// Group includes: top-level and nested
|
|
213
|
+
const topLevel: Record<string, string[]> = {};
|
|
214
|
+
for (const inc of include) {
|
|
215
|
+
const parts = inc.split(".", 2);
|
|
216
|
+
const relName = parts[0];
|
|
217
|
+
if (!topLevel[relName]) {
|
|
218
|
+
topLevel[relName] = [];
|
|
219
|
+
}
|
|
220
|
+
if (parts.length > 1) {
|
|
221
|
+
topLevel[relName].push(parts[1]);
|
|
192
222
|
}
|
|
193
223
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
224
|
+
|
|
225
|
+
for (const [relName, nested] of Object.entries(topLevel)) {
|
|
226
|
+
const cached = this._relCache[relName];
|
|
227
|
+
if (cached === undefined) {
|
|
228
|
+
// Try lazy load via instance methods
|
|
229
|
+
const related = this._lazyLoadRelationship(relName);
|
|
230
|
+
if (related === undefined) continue;
|
|
231
|
+
this._relCache[relName] = related;
|
|
232
|
+
}
|
|
233
|
+
const data = this._relCache[relName];
|
|
234
|
+
if (data === null || data === undefined) {
|
|
235
|
+
result[relName] = null;
|
|
236
|
+
} else if (Array.isArray(data)) {
|
|
237
|
+
result[relName] = (data as BaseModel[]).map((r) =>
|
|
238
|
+
r.toDict(nested.length > 0 ? nested : undefined),
|
|
239
|
+
);
|
|
240
|
+
} else if (typeof (data as BaseModel).toDict === "function") {
|
|
241
|
+
result[relName] = (data as BaseModel).toDict(
|
|
242
|
+
nested.length > 0 ? nested : undefined,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
// Legacy: include any relationship data already loaded on instance
|
|
248
|
+
if (ModelClass.hasOne) {
|
|
249
|
+
for (const rel of ModelClass.hasOne) {
|
|
250
|
+
const relKey = rel.model.toLowerCase();
|
|
251
|
+
if (this[relKey] !== undefined) {
|
|
252
|
+
result[relKey] = this[relKey];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (ModelClass.hasMany) {
|
|
257
|
+
for (const rel of ModelClass.hasMany) {
|
|
258
|
+
const relKey = rel.model.toLowerCase() + "s";
|
|
259
|
+
if (this[relKey] !== undefined) {
|
|
260
|
+
result[relKey] = this[relKey];
|
|
261
|
+
}
|
|
200
262
|
}
|
|
201
263
|
}
|
|
202
264
|
}
|
|
203
|
-
|
|
204
|
-
if (ModelClass.softDelete && this.is_deleted !== undefined) {
|
|
205
|
-
result.is_deleted = this.is_deleted;
|
|
206
|
-
}
|
|
265
|
+
|
|
207
266
|
return result;
|
|
208
267
|
}
|
|
209
268
|
|
|
@@ -533,4 +592,199 @@ export class BaseModel {
|
|
|
533
592
|
this[relKey] = related;
|
|
534
593
|
return related;
|
|
535
594
|
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Register a model class for lookup by name (used by eager loading).
|
|
598
|
+
*/
|
|
599
|
+
static _modelRegistry: Record<string, typeof BaseModel> = {};
|
|
600
|
+
|
|
601
|
+
static registerModel(name: string, modelClass: typeof BaseModel): void {
|
|
602
|
+
BaseModel._modelRegistry[name] = modelClass;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Resolve a model class by name from the registry.
|
|
607
|
+
*/
|
|
608
|
+
private static _resolveModel(name: string): (typeof BaseModel) | null {
|
|
609
|
+
return BaseModel._modelRegistry[name] ?? null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Lazy-load a single relationship by name (used by toDict with include).
|
|
614
|
+
*/
|
|
615
|
+
private _lazyLoadRelationship(relName: string): unknown {
|
|
616
|
+
const ModelClass = this.constructor as typeof BaseModel;
|
|
617
|
+
|
|
618
|
+
// Check hasOne
|
|
619
|
+
if (ModelClass.hasOne) {
|
|
620
|
+
const rel = ModelClass.hasOne.find((r) => r.model.toLowerCase() === relName || r.model === relName);
|
|
621
|
+
if (rel) {
|
|
622
|
+
const relatedClass = BaseModel._modelRegistry[rel.model];
|
|
623
|
+
if (relatedClass) {
|
|
624
|
+
return this.hasOne(relatedClass as any, rel.foreignKey);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Check hasMany
|
|
630
|
+
if (ModelClass.hasMany) {
|
|
631
|
+
const rel = ModelClass.hasMany.find((r) => {
|
|
632
|
+
const key = r.model.toLowerCase() + "s";
|
|
633
|
+
return key === relName || r.model.toLowerCase() === relName || r.model === relName;
|
|
634
|
+
});
|
|
635
|
+
if (rel) {
|
|
636
|
+
const relatedClass = BaseModel._modelRegistry[rel.model];
|
|
637
|
+
if (relatedClass) {
|
|
638
|
+
return this.hasMany(relatedClass as any, rel.foreignKey);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Check belongsTo
|
|
644
|
+
if (ModelClass.belongsTo) {
|
|
645
|
+
const rel = ModelClass.belongsTo.find((r) => r.model.toLowerCase() === relName || r.model === relName);
|
|
646
|
+
if (rel) {
|
|
647
|
+
const relatedClass = BaseModel._modelRegistry[rel.model];
|
|
648
|
+
if (relatedClass) {
|
|
649
|
+
return this.belongsTo(relatedClass as any, rel.foreignKey);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Eager load relationships for a collection of instances (prevents N+1).
|
|
659
|
+
* @param instances Array of model instances.
|
|
660
|
+
* @param include Array of relationship names (supports dot notation for nesting).
|
|
661
|
+
*/
|
|
662
|
+
static _eagerLoad(instances: BaseModel[], include: string[]): void {
|
|
663
|
+
if (instances.length === 0) return;
|
|
664
|
+
|
|
665
|
+
const ModelClass = instances[0].constructor as typeof BaseModel;
|
|
666
|
+
|
|
667
|
+
// Group includes: top-level and nested
|
|
668
|
+
const topLevel: Record<string, string[]> = {};
|
|
669
|
+
for (const inc of include) {
|
|
670
|
+
const parts = inc.split(".", 2);
|
|
671
|
+
const relName = parts[0];
|
|
672
|
+
if (!topLevel[relName]) {
|
|
673
|
+
topLevel[relName] = [];
|
|
674
|
+
}
|
|
675
|
+
if (parts.length > 1) {
|
|
676
|
+
topLevel[relName].push(parts[1]);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
for (const [relName, nested] of Object.entries(topLevel)) {
|
|
681
|
+
// Find the relationship definition
|
|
682
|
+
let relDef: RelationshipDefinition | undefined;
|
|
683
|
+
let relType: "hasOne" | "hasMany" | "belongsTo" | null = null;
|
|
684
|
+
|
|
685
|
+
if (ModelClass.hasOne) {
|
|
686
|
+
relDef = ModelClass.hasOne.find((r) => r.model.toLowerCase() === relName || r.model === relName);
|
|
687
|
+
if (relDef) relType = "hasOne";
|
|
688
|
+
}
|
|
689
|
+
if (!relDef && ModelClass.hasMany) {
|
|
690
|
+
relDef = ModelClass.hasMany.find((r) => {
|
|
691
|
+
const key = r.model.toLowerCase() + "s";
|
|
692
|
+
return key === relName || r.model.toLowerCase() === relName || r.model === relName;
|
|
693
|
+
});
|
|
694
|
+
if (relDef) relType = "hasMany";
|
|
695
|
+
}
|
|
696
|
+
if (!relDef && ModelClass.belongsTo) {
|
|
697
|
+
relDef = ModelClass.belongsTo.find((r) => r.model.toLowerCase() === relName || r.model === relName);
|
|
698
|
+
if (relDef) relType = "belongsTo";
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (!relDef || !relType) continue;
|
|
702
|
+
|
|
703
|
+
const relatedClass = BaseModel._modelRegistry[relDef.model];
|
|
704
|
+
if (!relatedClass) continue;
|
|
705
|
+
|
|
706
|
+
const db = relatedClass.getDb();
|
|
707
|
+
const fk = relDef.foreignKey;
|
|
708
|
+
|
|
709
|
+
if (relType === "hasOne" || relType === "hasMany") {
|
|
710
|
+
const pk = ModelClass.getPkField();
|
|
711
|
+
const pkValues = instances
|
|
712
|
+
.map((inst) => inst[pk])
|
|
713
|
+
.filter((v) => v !== undefined && v !== null);
|
|
714
|
+
if (pkValues.length === 0) continue;
|
|
715
|
+
|
|
716
|
+
const placeholders = pkValues.map(() => "?").join(",");
|
|
717
|
+
let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${fk}" IN (${placeholders})`;
|
|
718
|
+
if (relatedClass.softDelete) {
|
|
719
|
+
sql += ` AND is_deleted = 0`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const rows = db.query(sql, pkValues);
|
|
723
|
+
const related = rows.map((row) => new relatedClass(row as Record<string, unknown>));
|
|
724
|
+
|
|
725
|
+
// Eager load nested
|
|
726
|
+
if (nested.length > 0 && related.length > 0) {
|
|
727
|
+
relatedClass._eagerLoad(related, nested);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Group by FK
|
|
731
|
+
const grouped: Record<string, BaseModel[]> = {};
|
|
732
|
+
for (const record of related) {
|
|
733
|
+
const fkVal = String(record[fk]);
|
|
734
|
+
if (!grouped[fkVal]) grouped[fkVal] = [];
|
|
735
|
+
grouped[fkVal].push(record);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
for (const inst of instances) {
|
|
739
|
+
const pkVal = String(inst[pk]);
|
|
740
|
+
const records = grouped[pkVal] || [];
|
|
741
|
+
if (relType === "hasOne") {
|
|
742
|
+
inst._relCache[relName] = records[0] ?? null;
|
|
743
|
+
} else {
|
|
744
|
+
inst._relCache[relName] = records;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
} else if (relType === "belongsTo") {
|
|
748
|
+
const fkValues = [...new Set(
|
|
749
|
+
instances
|
|
750
|
+
.map((inst) => inst[fk])
|
|
751
|
+
.filter((v) => v !== undefined && v !== null),
|
|
752
|
+
)];
|
|
753
|
+
if (fkValues.length === 0) continue;
|
|
754
|
+
|
|
755
|
+
const relatedPk = relatedClass.getPkField();
|
|
756
|
+
const placeholders = fkValues.map(() => "?").join(",");
|
|
757
|
+
let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${relatedPk}" IN (${placeholders})`;
|
|
758
|
+
if (relatedClass.softDelete) {
|
|
759
|
+
sql += ` AND is_deleted = 0`;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const rows = db.query(sql, fkValues);
|
|
763
|
+
const related = rows.map((row) => new relatedClass(row as Record<string, unknown>));
|
|
764
|
+
|
|
765
|
+
if (nested.length > 0 && related.length > 0) {
|
|
766
|
+
relatedClass._eagerLoad(related, nested);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const lookup: Record<string, BaseModel> = {};
|
|
770
|
+
for (const record of related) {
|
|
771
|
+
lookup[String(record[relatedPk])] = record;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
for (const inst of instances) {
|
|
775
|
+
const fkVal = inst[fk];
|
|
776
|
+
inst._relCache[relName] = fkVal !== undefined && fkVal !== null
|
|
777
|
+
? lookup[String(fkVal)] ?? null
|
|
778
|
+
: null;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Clear the relationship cache.
|
|
786
|
+
*/
|
|
787
|
+
clearRelCache(): void {
|
|
788
|
+
this._relCache = {};
|
|
789
|
+
}
|
|
536
790
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 Cached Database — Transparent query cache decorator for DatabaseAdapter.
|
|
3
|
+
*
|
|
4
|
+
* Wraps any DatabaseAdapter and caches SELECT results from fetch() and fetchOne().
|
|
5
|
+
* Write operations (insert, update, delete, execute) invalidate the entire cache.
|
|
6
|
+
*
|
|
7
|
+
* Opt-in via .env:
|
|
8
|
+
* TINA4_DB_CACHE=true # enable (default: false)
|
|
9
|
+
* TINA4_DB_CACHE_TTL=30 # TTL in seconds (default: 30)
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* import { CachedDatabaseAdapter } from "@tina4/orm";
|
|
13
|
+
* import { SQLiteAdapter } from "./adapters/sqlite.js";
|
|
14
|
+
*
|
|
15
|
+
* const raw = new SQLiteAdapter("./data/app.db");
|
|
16
|
+
* const db = new CachedDatabaseAdapter(raw);
|
|
17
|
+
* db.fetch("SELECT * FROM users"); // cached on second call
|
|
18
|
+
* db.cacheStats(); // { enabled: true, hits: 1, ... }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { QueryCache } from "./sqlTranslation.js";
|
|
22
|
+
import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "./types.js";
|
|
23
|
+
|
|
24
|
+
function isTruthy(val: string | undefined): boolean {
|
|
25
|
+
return ["true", "1", "yes", "on"].includes((val ?? "").trim().toLowerCase());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class CachedDatabaseAdapter implements DatabaseAdapter {
|
|
29
|
+
private adapter: DatabaseAdapter;
|
|
30
|
+
private cache: QueryCache;
|
|
31
|
+
private enabled: boolean;
|
|
32
|
+
private ttl: number;
|
|
33
|
+
private hits: number = 0;
|
|
34
|
+
private misses: number = 0;
|
|
35
|
+
|
|
36
|
+
constructor(adapter: DatabaseAdapter, enabled?: boolean, ttl?: number) {
|
|
37
|
+
this.adapter = adapter;
|
|
38
|
+
this.enabled = enabled ?? isTruthy(process.env.TINA4_DB_CACHE);
|
|
39
|
+
this.ttl = ttl ?? parseInt(process.env.TINA4_DB_CACHE_TTL ?? "30", 10);
|
|
40
|
+
this.cache = new QueryCache({ defaultTtl: this.ttl, maxSize: 10000 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Cache helpers ─────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
cacheStats(): { enabled: boolean; hits: number; misses: number; size: number; ttl: number } {
|
|
46
|
+
return {
|
|
47
|
+
enabled: this.enabled,
|
|
48
|
+
hits: this.hits,
|
|
49
|
+
misses: this.misses,
|
|
50
|
+
size: this.cache.size(),
|
|
51
|
+
ttl: this.ttl,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
cacheClear(): void {
|
|
56
|
+
this.cache.clear();
|
|
57
|
+
this.hits = 0;
|
|
58
|
+
this.misses = 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private invalidate(): void {
|
|
62
|
+
this.cache.clear();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── DatabaseAdapter interface ─────────────────────────────
|
|
66
|
+
|
|
67
|
+
execute(sql: string, params?: unknown[]): unknown {
|
|
68
|
+
if (this.enabled) this.invalidate();
|
|
69
|
+
return this.adapter.execute(sql, params);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
executeMany(sql: string, paramsList: unknown[][]): { totalAffected: number; lastInsertId?: number | bigint } {
|
|
73
|
+
if (this.enabled) this.invalidate();
|
|
74
|
+
return this.adapter.executeMany(sql, paramsList);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[] {
|
|
78
|
+
return this.adapter.query(sql, params);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fetch<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): T[] {
|
|
82
|
+
if (this.enabled) {
|
|
83
|
+
const key = QueryCache.queryKey(sql + `:L${limit}:S${skip}`, params as unknown[] | undefined);
|
|
84
|
+
const cached = this.cache.get<T[]>(key);
|
|
85
|
+
if (cached !== undefined) {
|
|
86
|
+
this.hits++;
|
|
87
|
+
return cached;
|
|
88
|
+
}
|
|
89
|
+
const result = this.adapter.fetch<T>(sql, params, limit, skip);
|
|
90
|
+
this.cache.set(key, result, this.ttl);
|
|
91
|
+
this.misses++;
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
return this.adapter.fetch(sql, params, limit, skip);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | null {
|
|
98
|
+
if (this.enabled) {
|
|
99
|
+
const key = QueryCache.queryKey(sql + ":ONE", params as unknown[] | undefined);
|
|
100
|
+
const cached = this.cache.get<T | null>(key);
|
|
101
|
+
if (cached !== undefined) {
|
|
102
|
+
this.hits++;
|
|
103
|
+
return cached;
|
|
104
|
+
}
|
|
105
|
+
const result = this.adapter.fetchOne<T>(sql, params);
|
|
106
|
+
this.cache.set(key, result, this.ttl);
|
|
107
|
+
this.misses++;
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
return this.adapter.fetchOne(sql, params);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult {
|
|
114
|
+
if (this.enabled) this.invalidate();
|
|
115
|
+
return this.adapter.insert(table, data);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): DatabaseResult {
|
|
119
|
+
if (this.enabled) this.invalidate();
|
|
120
|
+
return this.adapter.update(table, data, filter);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[]): DatabaseResult {
|
|
124
|
+
if (this.enabled) this.invalidate();
|
|
125
|
+
return this.adapter.delete(table, filter);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
startTransaction(): void {
|
|
129
|
+
this.adapter.startTransaction();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
commit(): void {
|
|
133
|
+
this.adapter.commit();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
rollback(): void {
|
|
137
|
+
this.adapter.rollback();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
tables(): string[] {
|
|
141
|
+
return this.adapter.tables();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
columns(table: string): ColumnInfo[] {
|
|
145
|
+
return this.adapter.columns(table);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
lastInsertId(): number | bigint | null {
|
|
149
|
+
return this.adapter.lastInsertId();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
close(): void {
|
|
153
|
+
this.adapter.close();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
tableExists(name: string): boolean {
|
|
157
|
+
return this.adapter.tableExists(name);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
createTable(name: string, columns: Record<string, FieldDefinition>): void {
|
|
161
|
+
if (this.enabled) this.invalidate();
|
|
162
|
+
this.adapter.createTable(name, columns);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
getTableColumns?(name: string): Array<{ name: string; type: string }> {
|
|
166
|
+
return this.adapter.getTableColumns?.(name) ?? [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
addColumn?(table: string, colName: string, def: FieldDefinition): void {
|
|
170
|
+
if (this.enabled) this.invalidate();
|
|
171
|
+
this.adapter.addColumn?.(table, colName, def);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Access the underlying adapter directly.
|
|
176
|
+
*/
|
|
177
|
+
getAdapter(): DatabaseAdapter {
|
|
178
|
+
return this.adapter;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -7,8 +7,11 @@ export type {
|
|
|
7
7
|
ColumnInfo,
|
|
8
8
|
QueryOptions,
|
|
9
9
|
RelationshipDefinition,
|
|
10
|
+
PaginatedResult,
|
|
10
11
|
} from "./types.js";
|
|
11
12
|
|
|
13
|
+
export { FetchResult } from "./types.js";
|
|
14
|
+
|
|
12
15
|
export { initDatabase, getAdapter, setAdapter, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter } from "./database.js";
|
|
13
16
|
export type { DatabaseConfig, ParsedDatabaseUrl } from "./database.js";
|
|
14
17
|
export { discoverModels } from "./model.js";
|
|
@@ -34,6 +37,7 @@ export { validate } from "./validation.js";
|
|
|
34
37
|
export type { ValidationError } from "./validation.js";
|
|
35
38
|
export { BaseModel } from "./baseModel.js";
|
|
36
39
|
export { SQLTranslator, QueryCache } from "./sqlTranslation.js";
|
|
40
|
+
export { CachedDatabaseAdapter } from "./cachedDatabase.js";
|
|
37
41
|
export { FakeData } from "./fakeData.js";
|
|
38
42
|
export { seedTable, seedOrm } from "./seeder.js";
|
|
39
43
|
|
|
@@ -43,6 +43,7 @@ export async function discoverModels(modelsDir: string): Promise<DiscoveredModel
|
|
|
43
43
|
tableFilter: ModelClass.tableFilter,
|
|
44
44
|
hasOne: ModelClass.hasOne as RelationshipDefinition[] | undefined,
|
|
45
45
|
hasMany: ModelClass.hasMany as RelationshipDefinition[] | undefined,
|
|
46
|
+
belongsTo: ModelClass.belongsTo as RelationshipDefinition[] | undefined,
|
|
46
47
|
dbName: ModelClass._db,
|
|
47
48
|
};
|
|
48
49
|
|
|
@@ -25,6 +25,7 @@ export interface ModelDefinition {
|
|
|
25
25
|
tableFilter?: string;
|
|
26
26
|
hasOne?: RelationshipDefinition[];
|
|
27
27
|
hasMany?: RelationshipDefinition[];
|
|
28
|
+
belongsTo?: RelationshipDefinition[];
|
|
28
29
|
dbName?: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -102,6 +103,80 @@ export interface DatabaseAdapter {
|
|
|
102
103
|
addColumn?(table: string, colName: string, def: FieldDefinition): void;
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
export interface PaginatedResult<T = Record<string, unknown>> {
|
|
107
|
+
data: T[];
|
|
108
|
+
page: number;
|
|
109
|
+
perPage: number;
|
|
110
|
+
total: number;
|
|
111
|
+
totalPages: number;
|
|
112
|
+
hasNext: boolean;
|
|
113
|
+
hasPrev: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Wraps an array of fetched rows with convenience methods.
|
|
118
|
+
*
|
|
119
|
+
* Mirrors Python's `DatabaseResult` and Ruby's `Tina4::DatabaseResult`.
|
|
120
|
+
*/
|
|
121
|
+
export class FetchResult<T = Record<string, unknown>> {
|
|
122
|
+
readonly records: T[];
|
|
123
|
+
readonly count: number;
|
|
124
|
+
readonly sql: string;
|
|
125
|
+
|
|
126
|
+
constructor(records: T[], sql = "") {
|
|
127
|
+
this.records = records;
|
|
128
|
+
this.count = records.length;
|
|
129
|
+
this.sql = sql;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Paginate the in-memory result set. */
|
|
133
|
+
toPaginate(page = 1, perPage = 20): PaginatedResult<T> {
|
|
134
|
+
const total = this.count;
|
|
135
|
+
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
|
136
|
+
const offset = (page - 1) * perPage;
|
|
137
|
+
const data = this.records.slice(offset, offset + perPage);
|
|
138
|
+
return {
|
|
139
|
+
data,
|
|
140
|
+
page,
|
|
141
|
+
perPage,
|
|
142
|
+
total,
|
|
143
|
+
totalPages,
|
|
144
|
+
hasNext: page < totalPages,
|
|
145
|
+
hasPrev: page > 1,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Return the first record or null. */
|
|
150
|
+
first(): T | null {
|
|
151
|
+
return this.records[0] ?? null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Return the last record or null. */
|
|
155
|
+
last(): T | null {
|
|
156
|
+
return this.records[this.records.length - 1] ?? null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Check if result is empty. */
|
|
160
|
+
isEmpty(): boolean {
|
|
161
|
+
return this.records.length === 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Convert to plain array. */
|
|
165
|
+
toArray(): T[] {
|
|
166
|
+
return [...this.records];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Convert to JSON string. */
|
|
170
|
+
toJSON(): string {
|
|
171
|
+
return JSON.stringify(this.records);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Iterate over records. */
|
|
175
|
+
[Symbol.iterator](): Iterator<T> {
|
|
176
|
+
return this.records[Symbol.iterator]();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
105
180
|
export interface QueryOptions {
|
|
106
181
|
filter?: Record<string, unknown>;
|
|
107
182
|
sort?: string;
|