Skip to content

Commit 2eccafe

Browse files
committed
feat: add read-through caching and bootstrap modes to metadata layer
1 parent 3521a1b commit 2eccafe

8 files changed

Lines changed: 418 additions & 78 deletions

File tree

packages/metadata/src/loaders/database-loader.ts

Lines changed: 169 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,32 @@ import { SysMetadataObject, SysMetadataHistoryObject } from '@objectstack/platfo
2323
import type { IDataDriver, IDataEngine } from '@objectstack/spec/contracts';
2424
import type { MetadataLoader } from './loader-interface.js';
2525
import { calculateChecksum } from '../utils/metadata-history-utils.js';
26+
import { LRUCache } from '../utils/lru-cache.js';
2627
import { 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);

packages/metadata/src/metadata-manager.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export class MetadataManager implements IMetadataService {
150150
tableName,
151151
organizationId,
152152
projectId,
153+
cache: this.config.cache?.databaseLoader,
153154
});
154155
this.registerLoader(dbLoader);
155156
this.logger.info('DatabaseLoader configured', { datasource: this.config.datasource, tableName });
@@ -179,6 +180,7 @@ export class MetadataManager implements IMetadataService {
179180
tableName,
180181
organizationId,
181182
projectId,
183+
cache: this.config.cache?.databaseLoader,
182184
});
183185
this.registerLoader(dbLoader);
184186
this.logger.info('DatabaseLoader configured via DataEngine', { tableName });
@@ -214,6 +216,18 @@ export class MetadataManager implements IMetadataService {
214216
* should not be written to during runtime registration.
215217
*/
216218
async register(type: string, name: string, data: unknown): Promise<void> {
219+
// Persistence write gate: when `persistence.writable` is explicitly false
220+
// we treat register() as read-only. Default `true` (or omitted) preserves
221+
// historical behavior.
222+
if (this.config.persistence?.writable === false) {
223+
const msg = `MetadataManager is read-only (persistence.writable=false); refusing to register ${type}/${name}`;
224+
if (this.config.validation?.throwOnError) {
225+
throw new Error(msg);
226+
}
227+
this.logger.warn(msg);
228+
return;
229+
}
230+
217231
if (!this.registry.has(type)) {
218232
this.registry.set(type, new Map());
219233
}
@@ -835,6 +849,16 @@ export class MetadataManager implements IMetadataService {
835849
* Save/update an overlay for a metadata item
836850
*/
837851
async saveOverlay(overlay: MetadataOverlay): Promise<void> {
852+
// Overlay write gate — independent from base writability so deployments
853+
// can freeze Studio overlays while still permitting base register().
854+
if (this.config.persistence?.overlayWritable === false) {
855+
const msg = `MetadataManager overlays are read-only (persistence.overlayWritable=false); refusing to save overlay for ${overlay.baseType}/${overlay.baseName}`;
856+
if (this.config.validation?.throwOnError) {
857+
throw new Error(msg);
858+
}
859+
this.logger.warn(msg);
860+
return;
861+
}
838862
const key = this.overlayKey(overlay.baseType, overlay.baseName, overlay.scope);
839863
this.overlays.set(key, overlay);
840864
}

0 commit comments

Comments
 (0)