feat(storage): add StorageManager and cache subsystem; integrate storage into ConnectionManager and GitopsApp, migrate legacy connections, and add tests

This commit is contained in:
2026-02-24 15:22:56 +00:00
parent e8e45d5371
commit 43321c35d6
19 changed files with 706 additions and 25 deletions

68
ts/cache/classes.cache.cleaner.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
import { logger } from '../logging.ts';
import type { CacheDb } from './classes.cachedb.ts';
// deno-lint-ignore no-explicit-any
type DocumentClass = { getInstances: (filter: any) => Promise<{ delete: () => Promise<void> }[]> };
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
/**
* Periodically cleans up expired cached documents.
*/
export class CacheCleaner {
private intervalId: number | null = null;
private intervalMs: number;
private documentClasses: DocumentClass[] = [];
private cacheDb: CacheDb;
constructor(cacheDb: CacheDb, intervalMs = DEFAULT_INTERVAL_MS) {
this.cacheDb = cacheDb;
this.intervalMs = intervalMs;
}
/** Register a document class for cleanup */
registerClass(cls: DocumentClass): void {
this.documentClasses.push(cls);
}
start(): void {
if (this.intervalId !== null) return;
this.intervalId = setInterval(() => {
this.clean().catch((err) => {
logger.error(`CacheCleaner error: ${err}`);
});
}, this.intervalMs);
// Unref so the interval doesn't prevent process exit
Deno.unrefTimer(this.intervalId);
logger.debug(`CacheCleaner started (interval: ${this.intervalMs}ms)`);
}
stop(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
logger.debug('CacheCleaner stopped');
}
}
/** Run a single cleanup pass */
async clean(): Promise<number> {
const now = Date.now();
let totalDeleted = 0;
for (const cls of this.documentClasses) {
try {
const expired = await cls.getInstances({ expiresAt: { $lt: now } });
for (const doc of expired) {
await doc.delete();
totalDeleted++;
}
} catch (err) {
logger.error(`CacheCleaner: failed to clean class: ${err}`);
}
}
if (totalDeleted > 0) {
logger.debug(`CacheCleaner: deleted ${totalDeleted} expired document(s)`);
}
return totalDeleted;
}
}

57
ts/cache/classes.cached.document.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
import * as plugins from '../plugins.ts';
/** TTL duration constants in milliseconds */
export const TTL = {
MINUTES_5: 5 * 60 * 1000,
HOURS_1: 60 * 60 * 1000,
HOURS_24: 24 * 60 * 60 * 1000,
DAYS_7: 7 * 24 * 60 * 60 * 1000,
DAYS_30: 30 * 24 * 60 * 60 * 1000,
DAYS_90: 90 * 24 * 60 * 60 * 1000,
} as const;
/**
* Abstract base class for cached documents with TTL support.
* Extend this class and add @Collection decorator pointing to your CacheDb.
*/
export abstract class CachedDocument<
T extends CachedDocument<T>,
> extends plugins.smartdata.SmartDataDbDoc<T, T> {
@plugins.smartdata.svDb()
public createdAt: number = Date.now();
@plugins.smartdata.svDb()
public expiresAt: number = Date.now() + TTL.HOURS_1;
@plugins.smartdata.svDb()
public lastAccessedAt: number = Date.now();
constructor() {
super();
}
/** Set TTL in milliseconds from now */
setTTL(ms: number): void {
this.expiresAt = Date.now() + ms;
}
/** Set TTL in days from now */
setTTLDays(days: number): void {
this.setTTL(days * 24 * 60 * 60 * 1000);
}
/** Set TTL in hours from now */
setTTLHours(hours: number): void {
this.setTTL(hours * 60 * 60 * 1000);
}
/** Check if this document has expired */
isExpired(): boolean {
return Date.now() > this.expiresAt;
}
/** Update last accessed timestamp */
touch(): void {
this.lastAccessedAt = Date.now();
}
}

82
ts/cache/classes.cachedb.ts vendored Normal file
View File

@@ -0,0 +1,82 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
export interface ICacheDbOptions {
storagePath?: string;
dbName?: string;
debug?: boolean;
}
/**
* Singleton wrapper around LocalTsmDb + SmartdataDb.
* Provides a managed MongoDB-compatible cache database.
*/
export class CacheDb {
private static instance: CacheDb | null = null;
private localTsmDb: InstanceType<typeof plugins.smartmongo.LocalTsmDb> | null = null;
private smartdataDb: InstanceType<typeof plugins.smartdata.SmartdataDb> | null = null;
private options: Required<ICacheDbOptions>;
private constructor(options: ICacheDbOptions = {}) {
this.options = {
storagePath: options.storagePath ?? './.nogit/cachedb',
dbName: options.dbName ?? 'gitops_cache',
debug: options.debug ?? false,
};
}
static getInstance(options?: ICacheDbOptions): CacheDb {
if (!CacheDb.instance) {
CacheDb.instance = new CacheDb(options);
}
return CacheDb.instance;
}
static resetInstance(): void {
CacheDb.instance = null;
}
async start(): Promise<void> {
logger.info('Starting CacheDb...');
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
folderPath: this.options.storagePath,
});
const { connectionUri } = await this.localTsmDb.start();
this.smartdataDb = new plugins.smartdata.SmartdataDb({
mongoDbUrl: connectionUri,
mongoDbName: this.options.dbName,
});
await this.smartdataDb.init();
logger.success(`CacheDb started (db: ${this.options.dbName})`);
}
async stop(): Promise<void> {
logger.info('Stopping CacheDb...');
if (this.smartdataDb) {
await this.smartdataDb.close();
this.smartdataDb = null;
}
if (this.localTsmDb) {
// localDb.stop() may hang under Deno — fire-and-forget with timeout
const stopPromise = this.localTsmDb.stop().catch(() => {});
await Promise.race([
stopPromise,
new Promise<void>((resolve) => {
const id = setTimeout(resolve, 3000);
Deno.unrefTimer(id);
}),
]);
this.localTsmDb = null;
}
logger.success('CacheDb stopped');
}
getDb(): InstanceType<typeof plugins.smartdata.SmartdataDb> {
if (!this.smartdataDb) {
throw new Error('CacheDb not started. Call start() first.');
}
return this.smartdataDb;
}
}

View File

@@ -0,0 +1,32 @@
import * as plugins from '../../plugins.ts';
import { CacheDb } from '../classes.cachedb.ts';
import { CachedDocument, TTL } from '../classes.cached.document.ts';
/**
* Cached project data from git providers. TTL: 5 minutes.
*/
@plugins.smartdata.Collection(() => CacheDb.getInstance().getDb())
export class CachedProject extends CachedDocument<CachedProject> {
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
public connectionId: string = '';
@plugins.smartdata.svDb()
public projectName: string = '';
@plugins.smartdata.svDb()
public projectUrl: string = '';
@plugins.smartdata.svDb()
public description: string = '';
@plugins.smartdata.svDb()
public defaultBranch: string = '';
constructor() {
super();
this.setTTL(TTL.MINUTES_5);
}
}

1
ts/cache/documents/index.ts vendored Normal file
View File

@@ -0,0 +1 @@
export { CachedProject } from './classes.cached.project.ts';

5
ts/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
export { CacheDb } from './classes.cachedb.ts';
export type { ICacheDbOptions } from './classes.cachedb.ts';
export { CachedDocument, TTL } from './classes.cached.document.ts';
export { CacheCleaner } from './classes.cache.cleaner.ts';
export * from './documents/index.ts';