Files
gitops/ts/storage/classes.storagemanager.ts

140 lines
3.9 KiB
TypeScript

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<string, string>;
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<string | null> {
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<void> {
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<boolean> {
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<boolean> {
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<string[]> {
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<T>(key: string): Promise<T | null> {
const raw = await this.get(key);
if (raw === null) return null;
return JSON.parse(raw) as T;
}
async setJSON(key: string, value: unknown): Promise<void> {
await this.set(key, JSON.stringify(value, null, 2));
}
}