@@ -23,8 +23,32 @@ import { SysMetadataObject, SysMetadataHistoryObject } from '@objectstack/platfo
2323import type { IDataDriver , IDataEngine } from '@objectstack/spec/contracts' ;
2424import type { MetadataLoader } from './loader-interface.js' ;
2525import { calculateChecksum } from '../utils/metadata-history-utils.js' ;
26+ import { LRUCache } from '../utils/lru-cache.js' ;
2627import { MetadataProjector } from '../projection/metadata-projector.js' ;
2728
29+ /**
30+ * Cache configuration for `DatabaseLoader`.
31+ *
32+ * The cache sits in front of `load()`, `loadMany()`, `exists()`, `stat()`,
33+ * and `list()` so that hot read paths (REST `/meta/*`, ObjectQL plan
34+ * resolution, runtime overlay merges) do not hit the database on every
35+ * request. All write paths (`save`, `delete`, `registerRollback`) invalidate
36+ * the relevant entries.
37+ *
38+ * Defaults are conservative: 500 entries, 60s TTL — chosen so that single-
39+ * tenant Studio usage does not burn memory and so that an external write
40+ * (out-of-band SQL update) becomes visible within a minute even without
41+ * realtime invalidation.
42+ */
43+ export interface DatabaseLoaderCacheOptions {
44+ /** Whether the cache is active. Default: `true`. */
45+ enabled ?: boolean ;
46+ /** Max number of cached `(type, name)` entries. Default: `500`. */
47+ maxSize ?: number ;
48+ /** TTL in milliseconds. Set to `0` to disable expiry. Default: `60_000`. */
49+ ttl ?: number ;
50+ }
51+
2852/**
2953 * Configuration for the DatabaseLoader.
3054 *
@@ -57,6 +81,13 @@ export interface DatabaseLoaderOptions {
5781
5882 /** Enable metadata projection to type-specific tables (default: true) */
5983 enableProjection ?: boolean ;
84+
85+ /**
86+ * Read-through cache configuration. Pass `{ enabled: false }` to disable
87+ * caching outright (useful in tests or when the caller wants the loader to
88+ * always read fresh from the database).
89+ */
90+ cache ?: DatabaseLoaderCacheOptions ;
6091}
6192
6293/**
@@ -90,6 +121,15 @@ export class DatabaseLoader implements MetadataLoader {
90121 private enableProjection : boolean ;
91122 private projector ?: MetadataProjector ;
92123
124+ /** (type, name) → metadata payload — primes `load()` */
125+ private readonly loadCache ?: LRUCache < string , Record < string , unknown > | null > ;
126+ /** type → array of payloads — primes `loadMany()` */
127+ private readonly loadManyCache ?: LRUCache < string , unknown [ ] > ;
128+ /** type → list of names — primes `list()` */
129+ private readonly listCache ?: LRUCache < string , string [ ] > ;
130+ /** (type, name) → MetadataStats — primes `stat()` */
131+ private readonly statCache ?: LRUCache < string , MetadataStats | null > ;
132+
93133 constructor ( options : DatabaseLoaderOptions ) {
94134 if ( ! options . driver && ! options . engine ) {
95135 throw new Error ( 'DatabaseLoader requires either a driver or engine' ) ;
@@ -112,6 +152,67 @@ export class DatabaseLoader implements MetadataLoader {
112152 projectId : this . projectId ,
113153 } ) ;
114154 }
155+
156+ // Wire cache. Default: enabled with 500 entries / 60s TTL.
157+ const cacheOpts = options . cache ;
158+ const cacheEnabled = cacheOpts ?. enabled !== false ;
159+ if ( cacheEnabled ) {
160+ const lruOpts = {
161+ maxSize : cacheOpts ?. maxSize ?? 500 ,
162+ ttl : cacheOpts ?. ttl ?? 60_000 ,
163+ } ;
164+ this . loadCache = new LRUCache ( lruOpts ) ;
165+ this . loadManyCache = new LRUCache ( lruOpts ) ;
166+ this . listCache = new LRUCache ( lruOpts ) ;
167+ this . statCache = new LRUCache ( lruOpts ) ;
168+ }
169+ }
170+
171+ // ==========================================
172+ // Cache helpers
173+ // ==========================================
174+
175+ private cacheKey ( type : string , name : string ) : string {
176+ return `${ type } ::${ name } ` ;
177+ }
178+
179+ /**
180+ * Invalidate all cached entries for a specific (type, name) pair plus
181+ * the type-level aggregates (`loadMany`, `list`). Called from every write
182+ * path (`save`, `delete`, `registerRollback`).
183+ */
184+ private invalidate ( type : string , name : string ) : void {
185+ if ( ! this . loadCache ) return ;
186+ const key = this . cacheKey ( type , name ) ;
187+ this . loadCache . delete ( key ) ;
188+ this . statCache ?. delete ( key ) ;
189+ this . loadManyCache ?. delete ( type ) ;
190+ this . listCache ?. delete ( type ) ;
191+ }
192+
193+ /** Drop the entire cache — useful after bulk imports or schema changes. */
194+ invalidateAll ( ) : void {
195+ this . loadCache ?. clear ( ) ;
196+ this . loadManyCache ?. clear ( ) ;
197+ this . listCache ?. clear ( ) ;
198+ this . statCache ?. clear ( ) ;
199+ }
200+
201+ /** Diagnostic: aggregated cache statistics for `metrics` endpoints. */
202+ getCacheStats ( ) : {
203+ enabled : boolean ;
204+ load : ReturnType < LRUCache < string , unknown > [ 'stats' ] > | null ;
205+ loadMany : ReturnType < LRUCache < string , unknown > [ 'stats' ] > | null ;
206+ list : ReturnType < LRUCache < string , unknown > [ 'stats' ] > | null ;
207+ stat : ReturnType < LRUCache < string , unknown > [ 'stats' ] > | null ;
208+ } {
209+ return {
210+ enabled : this . loadCache !== undefined ,
211+ load : this . loadCache ?. stats ( ) ?? null ,
212+ loadMany : this . loadManyCache ?. stats ( ) ?? null ,
213+ list : this . listCache ?. stats ( ) ?? null ,
214+ stat : this . statCache ?. stats ( ) ?? null ,
215+ } ;
115216 }
116217
117218 // ==========================================
@@ -366,12 +467,29 @@ export class DatabaseLoader implements MetadataLoader {
366467
367468 await this . ensureSchema ( ) ;
368469
470+ // Read-through cache. We cache `null` (not-found) results too so a barrage
471+ // of misses does not hammer the database; invalidation on `save` upgrades
472+ // the entry once the row exists.
473+ const key = this . cacheKey ( type , name ) ;
474+ if ( this . loadCache ) {
475+ const cached = this . loadCache . get ( key ) ;
476+ if ( cached !== undefined ) {
477+ return {
478+ data : cached ,
479+ source : 'database' ,
480+ format : 'json' ,
481+ loadTime : Date . now ( ) - startTime ,
482+ } ;
483+ }
484+ }
485+
369486 try {
370487 const row = await this . _findOne ( this . tableName , {
371488 where : this . baseFilter ( type , name ) ,
372489 } ) ;
373490
374491 if ( ! row ) {
492+ this . loadCache ?. set ( key , null ) ;
375493 return {
376494 data : null ,
377495 loadTime : Date . now ( ) - startTime ,
@@ -381,6 +499,8 @@ export class DatabaseLoader implements MetadataLoader {
381499 const data = this . rowToData ( row ) ;
382500 const record = this . rowToRecord ( row ) ;
383501
502+ this . loadCache ?. set ( key , data ) ;
503+
384504 return {
385505 data,
386506 source : 'database' ,
@@ -402,14 +522,22 @@ export class DatabaseLoader implements MetadataLoader {
402522 ) : Promise < T [ ] > {
403523 await this . ensureSchema ( ) ;
404524
525+ if ( this . loadManyCache ) {
526+ const cached = this . loadManyCache . get ( type ) ;
527+ if ( cached !== undefined ) return cached as T [ ] ;
528+ }
529+
405530 try {
406531 const rows = await this . _find ( this . tableName , {
407532 where : this . baseFilter ( type ) ,
408533 } ) ;
409534
410- return rows
535+ const result = rows
411536 . map ( row => this . rowToData ( row ) )
412537 . filter ( ( data ) : data is Record < string , unknown > => data !== null ) as T [ ] ;
538+
539+ this . loadManyCache ?. set ( type , result ) ;
540+ return result ;
413541 } catch {
414542 return [ ] ;
415543 }
@@ -418,6 +546,12 @@ export class DatabaseLoader implements MetadataLoader {
418546 async exists ( type : string , name : string ) : Promise < boolean > {
419547 await this . ensureSchema ( ) ;
420548
549+ // Honor cache: a cached non-null payload implies existence.
550+ if ( this . loadCache ) {
551+ const cached = this . loadCache . get ( this . cacheKey ( type , name ) ) ;
552+ if ( cached !== undefined ) return cached !== null ;
553+ }
554+
421555 try {
422556 const count = await this . _count ( this . tableName , {
423557 where : this . baseFilter ( type , name ) ,
@@ -432,24 +566,35 @@ export class DatabaseLoader implements MetadataLoader {
432566 async stat ( type : string , name : string ) : Promise < MetadataStats | null > {
433567 await this . ensureSchema ( ) ;
434568
569+ const key = this . cacheKey ( type , name ) ;
570+ if ( this . statCache ) {
571+ const cached = this . statCache . get ( key ) ;
572+ if ( cached !== undefined ) return cached ;
573+ }
574+
435575 try {
436576 const row = await this . _findOne ( this . tableName , {
437577 where : this . baseFilter ( type , name ) ,
438578 } ) ;
439579
440- if ( ! row ) return null ;
580+ if ( ! row ) {
581+ this . statCache ?. set ( key , null ) ;
582+ return null ;
583+ }
441584
442585 const record = this . rowToRecord ( row ) ;
443586 const metadataStr = typeof row . metadata === 'string'
444587 ? row . metadata as string
445588 : JSON . stringify ( row . metadata ) ;
446589
447- return {
590+ const stats : MetadataStats = {
448591 size : metadataStr . length ,
449592 mtime : record . updatedAt ?? record . createdAt ?? new Date ( ) . toISOString ( ) ,
450593 format : 'json' ,
451594 etag : record . checksum ,
452595 } ;
596+ this . statCache ?. set ( key , stats ) ;
597+ return stats ;
453598 } catch {
454599 return null ;
455600 }
@@ -458,15 +603,23 @@ export class DatabaseLoader implements MetadataLoader {
458603 async list ( type : string ) : Promise < string [ ] > {
459604 await this . ensureSchema ( ) ;
460605
606+ if ( this . listCache ) {
607+ const cached = this . listCache . get ( type ) ;
608+ if ( cached !== undefined ) return cached ;
609+ }
610+
461611 try {
462612 const rows = await this . _find ( this . tableName , {
463613 where : this . baseFilter ( type ) ,
464614 fields : [ 'name' ] ,
465615 } ) ;
466616
467- return rows
617+ const names = rows
468618 . map ( row => row . name as string )
469619 . filter ( name => typeof name === 'string' ) ;
620+
621+ this . listCache ?. set ( type , names ) ;
622+ return names ;
470623 } catch {
471624 return [ ] ;
472625 }
@@ -658,6 +811,8 @@ export class DatabaseLoader implements MetadataLoader {
658811 state : 'active' ,
659812 } ) ;
660813
814+ this . invalidate ( type , name ) ;
815+
661816 // Write exactly one 'revert' history entry (not an 'update' entry)
662817 await this . createHistoryRecord (
663818 existing . id as string ,
@@ -695,6 +850,10 @@ export class DatabaseLoader implements MetadataLoader {
695850 // Skip update if the content is identical (prevents phantom version bumps)
696851 const previousChecksum = existing . checksum as string | undefined ;
697852 if ( newChecksum === previousChecksum ) {
853+ // No DB write, but make sure the cached payload reflects the latest
854+ // call (prior cached `null` would otherwise mask a freshly-saved
855+ // record).
856+ this . loadCache ?. set ( this . cacheKey ( type , name ) , data as Record < string , unknown > ) ;
698857 return {
699858 success : true ,
700859 path : `datasource://${ this . tableName } /${ type } /${ name } ` ,
@@ -714,6 +873,8 @@ export class DatabaseLoader implements MetadataLoader {
714873 state : 'active' ,
715874 } ) ;
716875
876+ this . invalidate ( type , name ) ;
877+
717878 // Create history record for update
718879 await this . createHistoryRecord (
719880 existing . id as string ,
@@ -757,6 +918,8 @@ export class DatabaseLoader implements MetadataLoader {
757918 updated_at : now ,
758919 } ) ;
759920
921+ this . invalidate ( type , name ) ;
922+
760923 // Create history record for creation
761924 await this . createHistoryRecord (
762925 id ,
@@ -807,6 +970,8 @@ export class DatabaseLoader implements MetadataLoader {
807970 // Delete from the main metadata table using the record's ID
808971 await this . _delete ( this . tableName , existing . id as string ) ;
809972
973+ this . invalidate ( type , name ) ;
974+
810975 // Delete projection from type-specific table
811976 if ( this . projector ) {
812977 await this . projector . deleteProjection ( type , name ) ;
0 commit comments