140 lines
3.9 KiB
TypeScript
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));
|
|
}
|
|
}
|