import { promises as fs } from 'fs';
import path from 'path';
import { logger } from './logger.js';
export interface StorageItem {
content: string;
metadata: {
createdAt: string;
updatedAt: string;
version: number;
};
}
export class FileStorage {
private baseDir: string;
private locks: Map<string, Promise<void>>;
constructor(baseDir: string = './data') {
this.baseDir = baseDir;
this.locks = new Map();
this.initializeStorage();
}
private async initializeStorage() {
try {
await fs.mkdir(this.baseDir, { recursive: true });
logger.info('Storage initialized', { baseDir: this.baseDir });
} catch (error) {
logger.error('Failed to initialize storage', error as Error, { baseDir: this.baseDir });
throw error;
}
}
private getFilePath(key: string): string {
// セキュリティ: パストラバーサル対策
const safeName = key.replace(/[^a-zA-Z0-9_-]/g, '_');
return path.join(this.baseDir, `${safeName}.json`);
}
// 非同期書き込み制御(ロック機構)
private async acquireLock(key: string): Promise<() => void> {
while (this.locks.has(key)) {
await this.locks.get(key);
}
let releaseLock: () => void;
const lockPromise = new Promise<void>((resolve) => {
releaseLock = resolve;
});
this.locks.set(key, lockPromise);
return () => {
this.locks.delete(key);
releaseLock!();
};
}
async read(key: string, requestId: string): Promise<string | null> {
const filePath = this.getFilePath(key);
try {
logger.debug('Reading file', { operation: 'read', key, filePath, requestId });
const data = await fs.readFile(filePath, 'utf-8');
const item: StorageItem = JSON.parse(data);
logger.info('File read successfully', {
operation: 'read',
key,
version: item.metadata.version,
requestId
});
return item.content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.warn('File not found', { operation: 'read', key, requestId });
return null;
}
logger.error('Failed to read file', error as Error, { operation: 'read', key, requestId });
throw error;
}
}
async write(key: string, content: string, requestId: string): Promise<void> {
const releaseLock = await this.acquireLock(key);
const filePath = this.getFilePath(key);
try {
logger.debug('Writing file', { operation: 'write', key, filePath, requestId });
let currentVersion = 0;
let createdAt = new Date().toISOString();
try {
const existing = await fs.readFile(filePath, 'utf-8');
const existingItem: StorageItem = JSON.parse(existing);
currentVersion = existingItem.metadata.version;
createdAt = existingItem.metadata.createdAt;
} catch {
// ファイルが存在しない場合は新規作成
}
const item: StorageItem = {
content,
metadata: {
createdAt,
updatedAt: new Date().toISOString(),
version: currentVersion + 1
}
};
await fs.writeFile(filePath, JSON.stringify(item, null, 2), 'utf-8');
logger.info('File written successfully', {
operation: 'write',
key,
version: item.metadata.version,
requestId
});
} catch (error) {
logger.error('Failed to write file', error as Error, { operation: 'write', key, requestId });
throw error;
} finally {
releaseLock();
}
}
async delete(key: string, requestId: string): Promise<boolean> {
const releaseLock = await this.acquireLock(key);
const filePath = this.getFilePath(key);
try {
logger.debug('Deleting file', { operation: 'delete', key, filePath, requestId });
await fs.unlink(filePath);
logger.info('File deleted successfully', { operation: 'delete', key, requestId });
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.warn('File not found for deletion', { operation: 'delete', key, requestId });
return false;
}
logger.error('Failed to delete file', error as Error, { operation: 'delete', key, requestId });
throw error;
} finally {
releaseLock();
}
}
async list(pattern?: string, requestId?: string): Promise<string[]> {
try {
logger.debug('Listing files', { operation: 'list', pattern, requestId });
const files = await fs.readdir(this.baseDir);
const jsonFiles = files
.filter(f => f.endsWith('.json'))
.map(f => f.replace('.json', '').replace(/_/g, '/'));
const filtered = pattern
? jsonFiles.filter(f => f.includes(pattern))
: jsonFiles;
logger.info('Files listed successfully', {
operation: 'list',
count: filtered.length,
pattern,
requestId
});
return filtered;
} catch (error) {
logger.error('Failed to list files', error as Error, { operation: 'list', requestId });
throw error;
}
}
async search(query: string, requestId: string): Promise<Array<{ key: string; content: string }>> {
try {
logger.debug('Searching files', { operation: 'search', query, requestId });
const keys = await this.list(undefined, requestId);
const results: Array<{ key: string; content: string }> = [];
for (const key of keys) {
const content = await this.read(key, requestId);
if (content && content.toLowerCase().includes(query.toLowerCase())) {
results.push({ key, content });
}
}
logger.info('Search completed', {
operation: 'search',
query,
resultsCount: results.length,
requestId
});
return results;
} catch (error) {
logger.error('Failed to search files', error as Error, { operation: 'search', query, requestId });
throw error;
}
}
}