Skip to main content
Glama
file-link-repository.ts14.5 kB
/** * FileLinkRepository - File-based repository for Link entities * * Specialized repository for managing entity relationships: * - Link files stored as JSON in links/ directory * - IndexManager with LinkIndexMetadata for composite key lookups * - FileLockManager for cross-process concurrent access control * - Source/Target indexes for efficient queries * - Bulk operations with transaction semantics * - Support for RelationType filtering * * Extends BaseFileRepository to inherit common functionality: * - atomicWriteJSON() for safe file writes * - loadJSON() for file reading * - LRU cache operations */ import * as fs from 'fs/promises'; import * as path from 'path'; import { v4 as uuidv4 } from 'uuid'; import type { LinkRepository } from '../../../domain/repositories/interfaces.js'; import type { Link, RelationType } from '../../../domain/entities/types.js'; import { NotFoundError, ConflictError, ValidationError, } from '../../../domain/repositories/errors.js'; import { IndexManager } from './index-manager.js'; import { type FileLockManager } from './file-lock-manager.js'; import { BaseFileRepository } from './base-file-repository.js'; import type { LinkIndexMetadata, CacheOptions } from './types.js'; /** * File Link Repository Implementation * * Extends BaseFileRepository for common file operations */ export class FileLinkRepository extends BaseFileRepository implements LinkRepository { private readonly planId: string; private readonly linksDir: string; private readonly indexManager: IndexManager<LinkIndexMetadata>; private readonly fileLockManager: FileLockManager; private readonly linkCache = new Map<string, Link>(); constructor( baseDir: string, planId: string, fileLockManager: FileLockManager, cacheOptions?: Partial<CacheOptions> ) { super(baseDir, cacheOptions); this.planId = planId; this.fileLockManager = fileLockManager; // Setup paths const planDir = path.join(baseDir, 'plans', planId); this.linksDir = path.join(planDir, 'links'); const indexesDir = path.join(planDir, 'indexes'); const indexPath = path.join(indexesDir, 'link-index.json'); // Initialize index manager this.indexManager = new IndexManager<LinkIndexMetadata>(indexPath, cacheOptions); } /** * 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.linksDir, { recursive: true }); await fs.mkdir(indexesDir, { recursive: true }); // Initialize index manager await this.indexManager.initialize(); this.markInitialized(); } // ensureInitialized() is inherited from BaseFileRepository /** * Get FileLockManager instance (for testing) */ public getLockManager(): FileLockManager { return this.fileLockManager; } // ============================================================================ // CRUD Operations // ============================================================================ public async createLink(link: Omit<Link, 'id' | 'createdAt' | 'createdBy'>): Promise<Link> { await this.ensureInitialized(); // Validate this.validateLinkData(link); // FIX H-1: Lock on composite key to prevent race condition // The lock must be acquired BEFORE the duplicate check to avoid TOCTOU const compositeKey = `link:${link.sourceId}:${link.targetId}:${link.relationType}`; return await this.fileLockManager.withLock(compositeKey, async () => { // Check for existing link with same composite key (now inside lock) const exists = await this.linkExists(link.sourceId, link.targetId, link.relationType); if (exists) { throw new ConflictError( `Link already exists: ${link.sourceId} -> ${link.targetId} (${link.relationType})`, 'duplicate', { sourceId: link.sourceId, targetId: link.targetId, relationType: link.relationType } ); } // Create link with generated fields const id = uuidv4(); const now = new Date().toISOString(); const fullLink: Link = { ...link, id, createdAt: now, createdBy: 'system', // Default system user; context-based user tracking not yet implemented }; // Get file path const filePath = this.getLinkFilePath(id); // Atomic write link file await this.saveLinkFile(filePath, fullLink); // Update index const metadata: LinkIndexMetadata = { id, type: 'link', filePath, sourceId: fullLink.sourceId, targetId: fullLink.targetId, relationType: fullLink.relationType, createdAt: fullLink.createdAt, updatedAt: now, version: 1, }; await this.indexManager.add(metadata); // Cache with LRU eviction if (this.cacheOptions.enabled) { this.cacheLink(id, fullLink); } return fullLink; }); } public async getLinkById(id: string): Promise<Link> { await this.ensureInitialized(); const link = await this.getLinkByIdOrNull(id); if (!link) { throw new NotFoundError('link', id); } return link; } private async getLinkByIdOrNull(id: string): Promise<Link | null> { // Note: ensureInitialized() called by public methods // Check cache (delegates to base class) if (this.cacheOptions.enabled) { const cached = this.cacheGet(this.linkCache, id); if (cached) { return cached; } } // Get from index const metadata = await this.indexManager.get(id); if (!metadata) { return null; } // Load from file const link = await this.loadLinkFile(metadata.filePath); // Cache with LRU eviction if (this.cacheOptions.enabled) { this.cacheLink(id, link); } return link; } public async findLinksBySource(sourceId: string, relationType?: string): Promise<Link[]> { await this.ensureInitialized(); return this.findLinksByPredicate( (m: LinkIndexMetadata) => m.sourceId === sourceId && (relationType === undefined || relationType === '' || m.relationType === relationType) ); } public async findLinksByTarget(targetId: string, relationType?: string): Promise<Link[]> { await this.ensureInitialized(); return this.findLinksByPredicate( (m: LinkIndexMetadata) => m.targetId === targetId && (relationType === undefined || relationType === '' || m.relationType === relationType) ); } public async findLinksByEntity( entityId: string, direction: 'incoming' | 'outgoing' | 'both' = 'both' ): Promise<Link[]> { await this.ensureInitialized(); const predicates: Record<typeof direction, (m: LinkIndexMetadata) => boolean> = { outgoing: (m) => m.sourceId === entityId, incoming: (m) => m.targetId === entityId, both: (m) => m.sourceId === entityId || m.targetId === entityId, }; return this.findLinksByPredicate(predicates[direction]); } public async findAllLinks(relationType?: string): Promise<Link[]> { await this.ensureInitialized(); if (relationType !== undefined && relationType !== '') { return this.findLinksByPredicate((m: LinkIndexMetadata) => m.relationType === relationType); } // Return all links return this.findLinksByPredicate(() => true); } public async deleteLink(id: string): Promise<void> { await this.ensureInitialized(); // Use FileLockManager with withLock for atomic delete (FIX M-2, FIX H-2) await this.fileLockManager.withLock(`link:${id}`, async () => { // Re-check existence INSIDE lock to avoid TOCTOU (FIX H-2) const metadata = await this.indexManager.get(id); if (!metadata) { throw new NotFoundError('link', id); } // Delete file await fs.unlink(metadata.filePath); // Remove from index await this.indexManager.delete(id); // Invalidate cache (delegates to base class) this.cacheInvalidate(this.linkCache, id); }); } public async deleteLinksForEntity(entityId: string): Promise<number> { await this.ensureInitialized(); // FIX M-1: Consistent initialization pattern // Find all links for entity const links = await this.findLinksByEntity(entityId, 'both'); // Delete each link for (const link of links) { await this.deleteLink(link.id); } return links.length; } public async linkExists(sourceId: string, targetId: string, relationType: string): Promise<boolean> { await this.ensureInitialized(); const allMetadata = await this.indexManager.getAll(); // Check for exact match on composite key return allMetadata.some( (m: LinkIndexMetadata) => m.sourceId === sourceId && m.targetId === targetId && m.relationType === relationType ); } // ============================================================================ // Bulk Operations (FIX M2) // ============================================================================ public async createMany( links: Omit<Link, 'id' | 'createdAt' | 'createdBy'>[] ): Promise<Link[]> { const created: Link[] = []; const createdIds: string[] = []; try { for (const linkData of links) { const link = await this.createLink(linkData); created.push(link); createdIds.push(link.id); } return created; } catch (error) { // Rollback on failure - best effort for (const id of createdIds) { try { await this.deleteLink(id); } catch { // Ignore rollback errors (best effort) } } throw error; } } public async deleteMany(idsOrFilter: string[] | LinkFilter): Promise<number> { let idsToDelete: string[]; if (Array.isArray(idsOrFilter)) { // Delete by IDs idsToDelete = idsOrFilter; } else { // Delete by filter const links = await this.findLinksByFilter(idsOrFilter); idsToDelete = links.map((l) => l.id); } // Delete each link, track actual successes let successCount = 0; for (const id of idsToDelete) { try { await this.deleteLink(id); successCount++; } catch { // Continue on error (best effort) - link may not exist or already deleted } } return successCount; } // ============================================================================ // Private Helpers // ============================================================================ /** * Valid RelationType values per CLAUDE.md */ private static readonly validRelationTypes: readonly string[] = [ 'implements', 'addresses', 'depends_on', 'blocks', 'alternative_to', 'supersedes', 'references', 'derived_from', 'has_artifact' ]; private validateLinkData(link: Partial<Link>): void { const errors: { field: string; message: string; value?: unknown }[] = []; if (link.sourceId === undefined || link.sourceId === '' || link.sourceId.trim() === '') { errors.push({ field: 'sourceId', message: 'sourceId is required', value: link.sourceId }); } if (link.targetId === undefined || link.targetId === '' || link.targetId.trim() === '') { errors.push({ field: 'targetId', message: 'targetId is required', value: link.targetId }); } if (link.relationType === undefined) { errors.push({ field: 'relationType', message: 'relationType is required', value: link.relationType, }); } else if (!FileLinkRepository.validRelationTypes.includes(link.relationType)) { errors.push({ field: 'relationType', message: `relationType must be one of: ${FileLinkRepository.validRelationTypes.join(', ')}`, value: link.relationType, }); } if (errors.length > 0) { throw new ValidationError('Link validation failed', errors); } } private getLinkFilePath(id: string): string { return path.join(this.linksDir, `${id}.json`); } /** * Load link from file (delegates to base class) */ private async loadLinkFile(filePath: string): Promise<Link> { return this.loadJSON<Link>(filePath); } /** * Cache link with LRU eviction (delegates to base class) */ private cacheLink(id: string, link: Link): void { this.cacheSet(this.linkCache, id, link); } /** * Save link to file atomically (delegates to base class) */ private async saveLinkFile(filePath: string, link: Link): Promise<void> { await this.atomicWriteJSON(filePath, link); } /** * Dispose repository and release resources */ public dispose(): Promise<void> { // Clear cache (delegates to base class) this.cacheClear(this.linkCache); // Note: FileLockManager is shared and should be disposed by caller // We don't dispose it here as we don't own it return Promise.resolve(); } private async findLinksByFilter(filter: LinkFilter): Promise<Link[]> { await this.ensureInitialized(); // FIX M-1: Defensive consistency return this.findLinksByPredicate((m: LinkIndexMetadata) => { if (filter.sourceId !== undefined && filter.sourceId !== '' && m.sourceId !== filter.sourceId) return false; if (filter.targetId !== undefined && filter.targetId !== '' && m.targetId !== filter.targetId) return false; if (filter.relationType !== undefined && m.relationType !== filter.relationType) return false; return true; }); } /** * Generic helper for filtering and loading links by predicate * Eliminates code duplication across findLinksBySource/Target/Entity/Filter */ private async findLinksByPredicate( predicate: (metadata: LinkIndexMetadata) => boolean ): Promise<Link[]> { const allMetadata = await this.indexManager.getAll(); const filtered = allMetadata.filter(predicate); // Load all matching links const links: Link[] = []; for (const metadata of filtered) { const link = await this.loadLinkFile(metadata.filePath); links.push(link); } return links; } } /** * Link filter for bulk delete operations (FIX M2) */ export interface LinkFilter { sourceId?: string; targetId?: string; relationType?: RelationType; }

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