Skip to main content
Glama
file-repository.ts19.2 kB
/** * FileRepository<T> - File-based repository implementation * * Implements Repository Pattern for file-based storage: * - Entity files stored as JSON in entities/ directory * - IndexManager for fast lookups with caching * - FileLockManager for cross-process concurrent access control * - Atomic writes with write-file-atomic * - Validation with Zod schemas * - Query operations with filtering, sorting, pagination * - Bulk operations with transaction semantics */ import * as fs from 'fs/promises'; import * as path from 'path'; import type { Repository, QueryOptions, QueryResult, Filter, SortSpec, } from '../../../domain/repositories/interfaces.js'; import type { Entity, EntityType } from '../../../domain/entities/types.js'; import { NotFoundError, ConflictError, ValidationError, } from '../../../domain/repositories/errors.js'; import { IndexManager } from './index-manager.js'; import { FileLockManager } from './file-lock-manager.js'; import { BaseFileRepository } from './base-file-repository.js'; import type { IndexMetadata, CacheOptions } from './types.js'; /** * File Repository Implementation * * Extends BaseFileRepository to inherit common functionality: * - atomicWriteJSON() for safe file writes * - loadJSON() for file reading * - LRU cache operations */ export class FileRepository<T extends Entity> extends BaseFileRepository implements Repository<T> { public readonly entityType: EntityType; private readonly planId: string; private readonly entitiesDir: string; private readonly indexManager: IndexManager; private readonly fileLockManager: FileLockManager; private readonly entityCache = new Map<string, T>(); private readonly ownsLockManager: boolean; constructor( baseDir: string, planId: string, entityType: EntityType, cacheOptions?: Partial<CacheOptions>, fileLockManager?: FileLockManager ) { super(baseDir, cacheOptions); this.planId = planId; this.entityType = entityType; // Setup paths const planDir = path.join(baseDir, 'plans', planId); this.entitiesDir = path.join(planDir, 'entities'); const indexesDir = path.join(planDir, 'indexes'); const indexPath = path.join(indexesDir, `${entityType}-index.json`); // Initialize managers this.indexManager = new IndexManager<IndexMetadata>(indexPath, cacheOptions); // Use shared FileLockManager if provided, otherwise create new one // Track ownership to avoid disposing shared instances this.ownsLockManager = !fileLockManager; this.fileLockManager = fileLockManager ?? new FileLockManager(planDir); } /** * Initialize repository */ public async initialize(): Promise<void> { if (this.isInitializedState()) { return; // Already initialized } // Create directories const planDir = path.join(this.baseDir, 'plans', this.planId); const indexesDir = path.join(planDir, 'indexes'); await fs.mkdir(this.entitiesDir, { recursive: true }); await fs.mkdir(indexesDir, { recursive: true }); // Initialize managers await this.indexManager.initialize(); // Only initialize FileLockManager if not already initialized (shared instance) if (!this.fileLockManager.isInitialized()) { await this.fileLockManager.initialize(); } this.markInitialized(); } // ensureInitialized() is inherited from BaseFileRepository // ============================================================================ // Read Operations // ============================================================================ public async findById(id: string): Promise<T> { await this.ensureInitialized(); // FIX H-2 const entity = await this.findByIdOrNull(id); if (!entity) { throw new NotFoundError(this.entityType, id); } return entity; } public async findByIdOrNull(id: string): Promise<T | null> { await this.ensureInitialized(); // FIX H-2 // Check cache first (delegates to base class) if (this.cacheOptions.enabled) { const cached = this.cacheGet(this.entityCache, id); if (cached) { return cached; } } // Get from index const metadata = await this.indexManager.get(id); if (!metadata) { return null; } // Load entity from file const entity = await this.loadEntityFile(metadata.filePath); // Cache result if (this.cacheOptions.enabled) { this.cacheEntity(id, entity); } return entity; } public async exists(id: string): Promise<boolean> { await this.ensureInitialized(); // FIX H-2 return await this.indexManager.has(id); } public async findByIds(ids: string[]): Promise<T[]> { await this.ensureInitialized(); // FIX H-2 const results: T[] = []; for (const id of ids) { const entity = await this.findByIdOrNull(id); if (entity) { results.push(entity); } } return results; } public async findAll(): Promise<T[]> { await this.ensureInitialized(); // FIX H-2 const allMetadata = await this.indexManager.getAll(); const entities: T[] = []; for (const metadata of allMetadata) { const entity = await this.loadEntityFile(metadata.filePath); entities.push(entity); } return entities; } public async query(options: QueryOptions<T>): Promise<QueryResult<T>> { await this.ensureInitialized(); // FIX H-2 // Load all entities let entities = await this.findAll(); // Apply filter if (options.filter) { entities = this.applyFilter(entities, options.filter); } const total = entities.length; // Apply sort if (options.sort && options.sort.length > 0) { entities = this.applySort(entities, options.sort); } // Apply pagination const offset = options.pagination?.offset ?? 0; const limit = options.pagination?.limit ?? total; const paginatedEntities = entities.slice(offset, offset + limit); const hasMore = offset + limit < total; return { items: paginatedEntities, total, offset, limit, hasMore, }; } public async count(filter?: Filter<T>): Promise<number> { await this.ensureInitialized(); // FIX H-2 if (!filter) { return await this.indexManager.size(); } const entities = await this.findAll(); const filtered = this.applyFilter(entities, filter); return filtered.length; } public async findOne(filter: Filter<T>): Promise<T | null> { await this.ensureInitialized(); // FIX H-2 const entities = await this.findAll(); const filtered = this.applyFilter(entities, filter); return filtered[0] ?? null; } // ============================================================================ // Write Operations // ============================================================================ public async create(entity: T): Promise<T> { await this.ensureInitialized(); const lockResource = `${this.entityType}:${entity.id}`; // Cross-process file lock for concurrent access control return await this.fileLockManager.withLock(lockResource, async () => { // Check if already exists if (await this.exists(entity.id)) { throw new ConflictError( `${this.entityType} with ID '${entity.id}' already exists`, 'duplicate', { entityType: this.entityType, entityId: entity.id } ); } // Validate entity this.validateEntity(entity); // Generate file path const filePath = this.getEntityFilePath(entity.id); // Save entity to file await this.saveEntityFile(filePath, entity); // Update index await this.indexManager.add({ id: entity.id, type: this.entityType, filePath, version: entity.version, updatedAt: entity.updatedAt, }); // Cache entity if (this.cacheOptions.enabled) { this.cacheEntity(entity.id, entity); } return entity; }); } public async update(id: string, updates: Partial<T>): Promise<T> { await this.ensureInitialized(); const lockResource = `${this.entityType}:${id}`; // Cross-process file lock for concurrent access control return await this.fileLockManager.withLock(lockResource, async () => { // Load existing entity const existing = await this.findById(id); // Check for version mismatch (optimistic locking) // If caller provides version, it must match current version if (updates.version !== undefined && updates.version !== existing.version) { throw new ConflictError( `Version mismatch for ${this.entityType} '${id}': expected ${String(updates.version)}, found ${String(existing.version)}`, 'version', { entityType: this.entityType, entityId: id, expectedVersion: updates.version, actualVersion: existing.version, } ); } // Apply updates (excluding version from updates, we auto-increment) const { version: providedVersion, ...otherUpdates } = updates; void providedVersion; // Excluded from updates, version is managed internally const updated: T = { ...existing, ...otherUpdates, id, // Ensure ID cannot be changed version: existing.version + 1, updatedAt: new Date().toISOString(), }; // Validate updated entity this.validateEntity(updated); // Save to file const filePath = this.getEntityFilePath(id); await this.saveEntityFile(filePath, updated); // Update index await this.indexManager.update({ id: updated.id, type: this.entityType, filePath, version: updated.version, updatedAt: updated.updatedAt, }); // Invalidate cache this.invalidateCache(id); return updated; }); } public async delete(id: string): Promise<void> { await this.ensureInitialized(); const lockResource = `${this.entityType}:${id}`; // Cross-process file lock for concurrent access control await this.fileLockManager.withLock(lockResource, async () => { // Check if exists if (!(await this.exists(id))) { throw new NotFoundError(this.entityType, id); } // Delete file const filePath = this.getEntityFilePath(id); // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally swallow cleanup errors await fs.unlink(path.join(this.entitiesDir, filePath)).catch(() => {}); // Remove from index await this.indexManager.delete(id); // Invalidate cache this.invalidateCache(id); }); } // ============================================================================ // Bulk Operations // ============================================================================ public async createMany(entities: T[]): Promise<T[]> { const created: T[] = []; const rollback: string[] = []; try { for (const entity of entities) { // Validate before creating this.validateEntity(entity); const result = await this.create(entity); created.push(result); rollback.push(entity.id); } return created; } catch (error) { // Rollback on error for (const id of rollback) { // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally swallow rollback errors await this.delete(id).catch(() => {}); } throw error; } } public async updateMany(updates: { id: string; data: Partial<T> }[]): Promise<T[]> { const results: T[] = []; for (const { id, data } of updates) { const updated = await this.update(id, data); results.push(updated); } return results; } public async deleteMany(ids: string[]): Promise<number> { let deleted = 0; for (const id of ids) { try { await this.delete(id); deleted++; } catch (error) { // Skip non-existent entities if (!(error instanceof NotFoundError)) { throw error; } } } return deleted; } public async upsertMany(entities: T[]): Promise<T[]> { const results: T[] = []; for (const entity of entities) { const exists = await this.exists(entity.id); if (exists) { // Update existing - exclude version to allow upsert regardless of version const { version: providedVersion, ...updates } = entity as T & { version?: number }; void providedVersion; // Excluded to allow upsert regardless of version const updated = await this.update(entity.id, updates as Partial<T>); results.push(updated); } else { // Create new const created = await this.create(entity); results.push(created); } } return results; } // ============================================================================ // Private Helper Methods // ============================================================================ /** * Get entity file path relative to entities directory */ private getEntityFilePath(id: string): string { return `${this.entityType}-${id}.json`; } /** * Load entity from file (delegates to base class) */ private async loadEntityFile(filePath: string): Promise<T> { const fullPath = path.join(this.entitiesDir, filePath); return this.loadJSON<T>(fullPath); } /** * Save entity to file atomically (delegates to base class) */ private async saveEntityFile(filePath: string, entity: T): Promise<void> { const fullPath = path.join(this.entitiesDir, filePath); await this.atomicWriteJSON(fullPath, entity); } /** * Validate entity */ private validateEntity(entity: T): void { const errors: { field: string; message: string; value?: unknown }[] = []; if (entity.id === '' || entity.id.trim() === '') { errors.push({ field: 'id', message: 'Entity ID cannot be empty', value: entity.id, }); } // entity.type is EntityType (not optional), so no need to check for undefined/null if (typeof entity.version !== 'number' || entity.version < 1) { errors.push({ field: 'version', message: 'Version must be a positive number', value: entity.version, }); } if (errors.length > 0) { throw new ValidationError( `Validation failed for ${this.entityType}`, errors, { entityType: this.entityType } ); } } /** * Apply filter to entities */ private applyFilter(entities: T[], filter: Filter<T>): T[] { if ('conditions' in filter && Array.isArray(filter.conditions)) { const operator = filter.operator ?? 'and'; const conditions = filter.conditions; return entities.filter((entity) => { const results = conditions.map((condition) => { const field = condition.field as keyof T; const value = entity[field]; switch (condition.operator) { case 'eq': return value === condition.value; case 'ne': return value !== condition.value; case 'gt': return typeof value === 'number' && typeof condition.value === 'number' && value > condition.value; case 'gte': return typeof value === 'number' && typeof condition.value === 'number' && value >= condition.value; case 'lt': return typeof value === 'number' && typeof condition.value === 'number' && value < condition.value; case 'lte': return typeof value === 'number' && typeof condition.value === 'number' && value <= condition.value; case 'in': return Array.isArray(condition.value) && condition.value.includes(value); case 'nin': return Array.isArray(condition.value) && !condition.value.includes(value); case 'contains': return typeof value === 'string' && value.includes(String(condition.value)); case 'startsWith': return typeof value === 'string' && value.startsWith(String(condition.value)); case 'endsWith': return typeof value === 'string' && value.endsWith(String(condition.value)); case 'exists': // Check if field exists and has a value (not undefined/null) return condition.value === true ? value !== undefined : value === undefined; case 'regex': { if (typeof value !== 'string') return false; try { // Support case-insensitive matching by default const regex = new RegExp(String(condition.value), 'i'); return regex.test(value); } catch { return false; // Invalid regex pattern } } default: return false; } }); return operator === 'and' ? results.every((r) => r) : results.some((r) => r); }); } return entities; } /** * Apply sort to entities */ private applySort(entities: T[], sort: SortSpec<T>[]): T[] { // Priority order for semantic fields const priorityOrder: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1, }; return [...entities].sort((a, b) => { for (const { field, direction } of sort) { const aVal = a[field as keyof T]; const bVal = b[field as keyof T]; let comparison = 0; // Special handling for priority field if (field === 'priority' && typeof aVal === 'string' && typeof bVal === 'string') { const aPriority = priorityOrder[aVal.toLowerCase()] ?? 0; const bPriority = priorityOrder[bVal.toLowerCase()] ?? 0; comparison = aPriority - bPriority; } else { // Standard comparison if (aVal < bVal) comparison = -1; else if (aVal > bVal) comparison = 1; } if (comparison !== 0) { return direction === 'asc' ? comparison : -comparison; } } return 0; }); } /** * Cache entity (delegates to base class with LRU eviction) */ private cacheEntity(id: string, entity: T): void { this.cacheSet(this.entityCache, id, entity); } /** * Invalidate cache (delegates to base class) */ private invalidateCache(id: string): void { this.cacheInvalidate(this.entityCache, id); } // ============================================================================ // Lifecycle // ============================================================================ /** * Dispose repository and release resources */ public async dispose(): Promise<void> { // Only dispose file lock manager if we own it (not shared) if (this.ownsLockManager) { await this.fileLockManager.dispose(); } // Clear cache (delegates to base class) this.cacheClear(this.entityCache); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cppmyjob/cpp-mcp-planner'

If you have feedback or need assistance with the MCP directory API, please join our Discord server