import * as path from '@std/path'; export type TStorageBackend = 'filesystem' | 'memory'; export interface IStorageConfig { backend?: TStorageBackend; fsPath?: string; } /** * Key-value storage abstraction with filesystem and memory backends. * Keys must start with '/' and are normalized (no '..', no double slashes). */ export class StorageManager { private backend: TStorageBackend; private fsPath: string; private memoryStore: Map; constructor(config: IStorageConfig = {}) { this.backend = config.backend ?? 'filesystem'; this.fsPath = config.fsPath ?? './storage'; this.memoryStore = new Map(); } /** * Normalize and validate a storage key. */ private normalizeKey(key: string): string { if (!key.startsWith('/')) { throw new Error(`Storage key must start with '/': ${key}`); } // Strip '..' segments and normalize double slashes const segments = key.split('/').filter((s) => s !== '' && s !== '..'); return '/' + segments.join('/'); } /** * Resolve a key to a filesystem path. */ private keyToPath(key: string): string { const normalized = this.normalizeKey(key); return path.join(this.fsPath, ...normalized.split('/').filter(Boolean)); } async get(key: string): Promise { const normalized = this.normalizeKey(key); if (this.backend === 'memory') { return this.memoryStore.get(normalized) ?? null; } try { return await Deno.readTextFile(this.keyToPath(normalized)); } catch (err) { if (err instanceof Deno.errors.NotFound) return null; throw err; } } async set(key: string, value: string): Promise { const normalized = this.normalizeKey(key); if (this.backend === 'memory') { this.memoryStore.set(normalized, value); return; } const filePath = this.keyToPath(normalized); const dir = path.dirname(filePath); await Deno.mkdir(dir, { recursive: true }); // Atomic write: write to temp then rename const tmpPath = filePath + '.tmp'; await Deno.writeTextFile(tmpPath, value); await Deno.rename(tmpPath, filePath); } async delete(key: string): Promise { const normalized = this.normalizeKey(key); if (this.backend === 'memory') { return this.memoryStore.delete(normalized); } try { await Deno.remove(this.keyToPath(normalized)); return true; } catch (err) { if (err instanceof Deno.errors.NotFound) return false; throw err; } } async exists(key: string): Promise { const normalized = this.normalizeKey(key); if (this.backend === 'memory') { return this.memoryStore.has(normalized); } try { await Deno.stat(this.keyToPath(normalized)); return true; } catch (err) { if (err instanceof Deno.errors.NotFound) return false; throw err; } } /** * List keys under a given prefix. */ async list(prefix: string): Promise { const normalized = this.normalizeKey(prefix); if (this.backend === 'memory') { const keys: string[] = []; for (const key of this.memoryStore.keys()) { if (key.startsWith(normalized)) { keys.push(key); } } return keys.sort(); } const dirPath = this.keyToPath(normalized); const keys: string[] = []; try { for await (const entry of Deno.readDir(dirPath)) { if (entry.isFile) { keys.push(normalized.replace(/\/$/, '') + '/' + entry.name); } } } catch (err) { if (err instanceof Deno.errors.NotFound) return []; throw err; } return keys.sort(); } async getJSON(key: string): Promise { const raw = await this.get(key); if (raw === null) return null; return JSON.parse(raw) as T; } async setJSON(key: string, value: unknown): Promise { await this.set(key, JSON.stringify(value, null, 2)); } }