Skip to main content
Glama
linking-service.ts7.92 kB
import type { RepositoryFactory } from '../../infrastructure/factory/repository-factory.js'; import type { Link, RelationType, Requirement, Solution, Phase, Decision, Artifact } from '../entities/types.js'; // Entity types for validation type EntityType = 'requirement' | 'solution' | 'phase' | 'decision' | 'artifact'; // Input types export interface LinkEntitiesInput { planId: string; sourceId: string; targetId: string; relationType: RelationType; metadata?: Record<string, unknown>; } export interface GetEntityLinksInput { planId: string; entityId: string; relationType?: RelationType; direction?: 'outgoing' | 'incoming' | 'both'; } export interface UnlinkEntitiesInput { planId: string; linkId?: string; sourceId?: string; targetId?: string; relationType?: RelationType; } // Output types export interface LinkEntitiesResult { linkId: string; } export interface GetEntityLinksResult { entityId: string; links: Link[]; outgoing: Link[]; incoming: Link[]; } export interface UnlinkEntitiesResult { success: boolean; deletedLinkIds: string[]; } export class LinkingService { constructor(private readonly repositoryFactory: RepositoryFactory) {} public async linkEntities(input: LinkEntitiesInput): Promise<LinkEntitiesResult> { const linkRepo = this.repositoryFactory.createLinkRepository(input.planId); // BUG #13 FIX: Validate that sourceId and targetId reference existing entities await this.validateEntityExists(input.planId, input.sourceId, 'sourceId'); await this.validateEntityExists(input.planId, input.targetId, 'targetId'); // REQ-5: Prevent self-referencing links if (input.sourceId === input.targetId) { throw new Error('Cannot create self-referencing link'); } // Check for cycle if depends_on if (input.relationType === 'depends_on') { const links = await linkRepo.findAllLinks('depends_on'); const hasCycle = this.detectCycle(links, input.sourceId, input.targetId); if (hasCycle) { throw new Error('Circular dependency detected'); } } // Check if link already exists const exists = await linkRepo.linkExists(input.sourceId, input.targetId, input.relationType); if (exists) { throw new Error('Link already exists'); } // Create link const link = await linkRepo.createLink({ sourceId: input.sourceId, targetId: input.targetId, relationType: input.relationType, metadata: input.metadata, }); return { linkId: link.id }; } public async getEntityLinks(input: GetEntityLinksInput): Promise<GetEntityLinksResult> { const linkRepo = this.repositoryFactory.createLinkRepository(input.planId); const direction = input.direction ?? 'both'; let outgoing: Link[] = []; let incoming: Link[] = []; if (direction === 'outgoing' || direction === 'both') { outgoing = await linkRepo.findLinksBySource(input.entityId, input.relationType); } if (direction === 'incoming' || direction === 'both') { incoming = await linkRepo.findLinksByTarget(input.entityId, input.relationType); } return { entityId: input.entityId, links: [...outgoing, ...incoming], outgoing, incoming, }; } public async unlinkEntities(input: UnlinkEntitiesInput): Promise<UnlinkEntitiesResult> { const linkRepo = this.repositoryFactory.createLinkRepository(input.planId); const deletedIds: string[] = []; if (input.linkId !== undefined && input.linkId !== '') { // Delete by linkId await linkRepo.deleteLink(input.linkId); deletedIds.push(input.linkId); } else { // Delete by source/target/type - need to find matching links first const allLinks = await linkRepo.findAllLinks(input.relationType); for (const link of allLinks) { const matchSource = (input.sourceId !== undefined && input.sourceId !== '') ? link.sourceId === input.sourceId : true; const matchTarget = (input.targetId !== undefined && input.targetId !== '') ? link.targetId === input.targetId : true; if (matchSource && matchTarget) { await linkRepo.deleteLink(link.id); deletedIds.push(link.id); } } } return { success: true, deletedLinkIds: deletedIds, }; } // DFS cycle detection private detectCycle(links: Link[], sourceId: string, targetId: string): boolean { // Build adjacency list for depends_on links const graph = new Map<string, string[]>(); for (const link of links) { if (link.relationType === 'depends_on') { if (!graph.has(link.sourceId)) { graph.set(link.sourceId, []); } const sourceLinks = graph.get(link.sourceId); if (sourceLinks) { sourceLinks.push(link.targetId); } } } // Add the proposed link temporarily if (!graph.has(sourceId)) { graph.set(sourceId, []); } const proposedSourceLinks = graph.get(sourceId); if (proposedSourceLinks) { proposedSourceLinks.push(targetId); } // DFS to detect cycle const visited = new Set<string>(); const stack = new Set<string>(); const dfs = (node: string): boolean => { if (stack.has(node)) return true; // Cycle! if (visited.has(node)) return false; visited.add(node); stack.add(node); const neighbors = graph.get(node) ?? []; for (const neighbor of neighbors) { if (dfs(neighbor)) return true; } stack.delete(node); return false; }; // Check from sourceId return dfs(sourceId); } // Helper to get all links for an entity (for referential integrity check) public async getLinksForEntity(planId: string, entityId: string): Promise<Link[]> { const linkRepo = this.repositoryFactory.createLinkRepository(planId); return linkRepo.findLinksByEntity(entityId, 'both'); } // Delete all links for an entity (for cascading delete) public async deleteLinksForEntity(planId: string, entityId: string): Promise<number> { const linkRepo = this.repositoryFactory.createLinkRepository(planId); return linkRepo.deleteLinksForEntity(entityId); } /** * BUG #13 FIX: Validate that an entity ID references an existing entity * Checks all entity types (requirement, solution, phase, decision, artifact) */ private async validateEntityExists(planId: string, entityId: string, _fieldName: string): Promise<void> { const entityTypes: EntityType[] = ['requirement', 'solution', 'phase', 'decision', 'artifact']; for (const entityType of entityTypes) { try { const repo = this.getRepositoryForType(planId, entityType); await repo.findById(entityId); return; // Found - validation passed } catch { // Not found in this type - continue checking other types } } // Not found in any entity type throw new Error(`Entity '${entityId}' not found`); } /** * Get repository for a specific entity type */ private getRepositoryForType(planId: string, entityType: EntityType): ReturnType<typeof this.repositoryFactory.createRepository> { switch (entityType) { case 'requirement': return this.repositoryFactory.createRepository<Requirement>('requirement', planId); case 'solution': return this.repositoryFactory.createRepository<Solution>('solution', planId); case 'phase': return this.repositoryFactory.createRepository<Phase>('phase', planId); case 'decision': return this.repositoryFactory.createRepository<Decision>('decision', planId); case 'artifact': return this.repositoryFactory.createRepository<Artifact>('artifact', planId); default: { const exhaustiveCheck: never = entityType; throw new Error(`Unknown entity type: ${String(exhaustiveCheck)}`); } } } }

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