Skip to main content
Glama
artifact-service.ts18.1 kB
import { v4 as uuidv4 } from 'uuid'; import type { RepositoryFactory } from '../repositories/interfaces.js'; import type { PlanService } from './plan-service.js'; import type { VersionHistoryService } from './version-history-service.js'; import type { LinkingService } from './linking-service.js'; import type { Artifact, ArtifactType, ArtifactStatus, ArtifactTarget, Tag, VersionHistory, VersionDiff, Phase, Requirement, Solution, } from '../entities/types.js'; import { validateTags, validateTargets, validateCodeRefs, validateRequiredString, validateRequiredEnum, validateSlug, validateOptionalString } from './validators.js'; import { filterArtifact } from '../utils/field-filter.js'; // Constants const MAX_SLUG_LENGTH = 100; const DEFAULT_ARTIFACTS_PAGE_LIMIT = 50; /** * Converts a title string into a URL-friendly slug * @param title - The artifact title to slugify * @param artifactId - Fallback ID if slug generation produces empty string * @returns A URL-friendly slug (lowercase, alphanumeric + dashes, max 100 chars) */ function slugify(title: string, artifactId: string): string { let slug = title .toLowerCase() .normalize('NFD') // Decompose Unicode characters .replace(/[\u0300-\u036f]/g, '') // Remove diacritics (accents) .replace(/[^a-z0-9\s-]/g, '') // Keep only alphanumeric, spaces, dashes .trim() // Remove leading/trailing whitespace .replace(/\s+/g, '-') // Replace spaces with dashes .replace(/-+/g, '-') // Collapse multiple dashes into one .replace(/^-|-$/g, ''); // Trim leading/trailing dashes // Fallback for empty slugs: use artifact ID for guaranteed uniqueness if (slug === '') { slug = `artifact-${artifactId}`; } // Enforce max length return slug.substring(0, MAX_SLUG_LENGTH); } // Input types export interface AddArtifactInput { planId: string; artifact: { title: string; // REQUIRED artifactType: ArtifactType; // REQUIRED description?: string; // Optional - default: '' slug?: string; // Optional - auto-generated from title content?: { // Optional - default: undefined language?: string; sourceCode?: string; filename?: string; }; targets?: ArtifactTarget[]; // Optional - undefined OK relatedPhaseId?: string; // Optional - undefined OK relatedSolutionId?: string; // Optional - undefined OK relatedRequirementIds?: string[]; // Optional - undefined OK codeRefs?: string[]; // Optional - undefined OK tags?: Tag[]; // Optional - default: [] }; } export interface GetArtifactInput { planId: string; artifactId: string; fields?: string[]; // Fields to include: summary (default), ['*'] (all), or custom list excludeMetadata?: boolean; // Exclude metadata fields (createdAt, updatedAt, version, metadata) includeContent?: boolean; // Include heavy sourceCode field (default: false for Lazy-Load) } export interface UpdateArtifactInput { planId: string; artifactId: string; updates: Partial<{ title: string; description: string; slug: string; status: ArtifactStatus; content: { language?: string; sourceCode?: string; filename?: string; }; targets: ArtifactTarget[]; relatedPhaseId: string; relatedSolutionId: string; relatedRequirementIds: string[]; codeRefs: string[]; tags: Tag[]; }>; } export interface ListArtifactsInput { planId: string; filters?: { artifactType?: ArtifactType; status?: ArtifactStatus; relatedPhaseId?: string; }; limit?: number; offset?: number; fields?: string[]; // Fields to include: summary (default), ['*'] (all), or custom list excludeMetadata?: boolean; // Exclude metadata fields (createdAt, updatedAt, version, metadata) includeContent?: boolean; // IGNORED in list operations (sourceCode never returned in lists for security) } export interface DeleteArtifactInput { planId: string; artifactId: string; } // Output types export interface AddArtifactResult { artifactId: string; } export interface GetArtifactResult { artifact: Artifact; } export interface UpdateArtifactResult { success: boolean; artifactId: string; } export interface ListArtifactsResult { artifacts: Artifact[]; total: number; hasMore: boolean; } export interface DeleteArtifactResult { success: boolean; message: string; } export class ArtifactService { constructor( private readonly repositoryFactory: RepositoryFactory, private readonly planService: PlanService, private readonly versionHistoryService?: VersionHistoryService, private readonly linkingService?: LinkingService // BUG-015 FIX: Optional for cascading link deletion ) {} private async ensurePlanExists(planId: string): Promise<void> { const planRepo = this.repositoryFactory.createPlanRepository(); const exists = await planRepo.planExists(planId); if (!exists) { throw new Error('Plan not found'); } } /** * Helper to validate that an entity reference exists (BUG #11, #12) * @param planId - Plan ID containing the entity * @param entityType - Type of entity to validate ('phase', 'requirement', 'solution') * @param entityId - ID of the entity to validate * @throws Error if entity not found */ private async validateEntityReference( planId: string, entityType: 'phase' | 'requirement' | 'solution', entityId: string ): Promise<void> { const repo = this.repositoryFactory.createRepository<Phase | Requirement | Solution>(entityType, planId); try { await repo.findById(entityId); } catch { const entityName = entityType.charAt(0).toUpperCase() + entityType.slice(1); throw new Error(`${entityName} '${entityId}' not found`); } } /** * Validates that relatedPhaseId references an existing phase */ private async validatePhaseReference(planId: string, phaseId: string): Promise<void> { await this.validateEntityReference(planId, 'phase', phaseId); } /** * Validates that relatedRequirementIds reference existing requirements */ private async validateRequirementReferences(planId: string, requirementIds: string[]): Promise<void> { for (const reqId of requirementIds) { await this.validateEntityReference(planId, 'requirement', reqId); } } /** * Validates that relatedSolutionId references an existing solution */ private async validateSolutionReference(planId: string, solutionId: string): Promise<void> { await this.validateEntityReference(planId, 'solution', solutionId); } /** * Validates that a slug is unique within the plan * @param artifacts - Existing artifacts in the plan * @param slug - The slug to validate * @param excludeId - Optional artifact ID to exclude from uniqueness check (for updates) * @throws Error if slug already exists */ private validateSlugUniqueness( artifacts: Artifact[], slug: string, excludeId?: string ): void { const existing = artifacts.find((a) => a.slug === slug && a.id !== excludeId); if (existing) { throw new Error( `Artifact with slug "${slug}" already exists in this plan (ID: ${existing.id})` ); } } public async addArtifact(input: AddArtifactInput): Promise<AddArtifactResult> { await this.ensurePlanExists(input.planId); // Validate REQUIRED fields validateRequiredString(input.artifact.title, 'title'); validateRequiredEnum( input.artifact.artifactType, 'artifactType', ['code', 'config', 'migration', 'documentation', 'test', 'script', 'other'] ); // Validate optional fields validateTags(input.artifact.tags ?? []); validateOptionalString(input.artifact.description, 'description'); if (input.artifact.targets) { validateTargets(input.artifact.targets); } // Validate codeRefs format validateCodeRefs(input.artifact.codeRefs ?? []); // Sprint 6 BUG #13: Validate slug format if provided if (input.artifact.slug !== undefined) { validateSlug(input.artifact.slug); } // Foreign key validation: ensure referenced entities exist (BUG #11, #12) if (input.artifact.relatedPhaseId !== undefined && input.artifact.relatedPhaseId !== '') { await this.validatePhaseReference(input.planId, input.artifact.relatedPhaseId); } if (input.artifact.relatedRequirementIds !== undefined && input.artifact.relatedRequirementIds.length > 0) { await this.validateRequirementReferences(input.planId, input.artifact.relatedRequirementIds); } if (input.artifact.relatedSolutionId !== undefined && input.artifact.relatedSolutionId !== '') { await this.validateSolutionReference(input.planId, input.artifact.relatedSolutionId); } const repo = this.repositoryFactory.createRepository<Artifact>('artifact', input.planId); const artifacts = await repo.findAll(); const artifactId = uuidv4(); const slug = input.artifact.slug ?? slugify(input.artifact.title, artifactId); // Validate slug uniqueness this.validateSlugUniqueness(artifacts, slug); const now = new Date().toISOString(); const artifact: Artifact = { id: artifactId, type: 'artifact', createdAt: now, updatedAt: now, version: 1, metadata: { createdBy: 'claude-code', tags: input.artifact.tags ?? [], annotations: [], }, title: input.artifact.title, // REQUIRED description: input.artifact.description ?? '', // DEFAULT: empty string slug, artifactType: input.artifact.artifactType, // REQUIRED status: 'draft', content: input.artifact.content ? { language: input.artifact.content.language, sourceCode: input.artifact.content.sourceCode, filename: input.artifact.content.filename, } : {}, targets: input.artifact.targets, relatedPhaseId: input.artifact.relatedPhaseId, relatedSolutionId: input.artifact.relatedSolutionId, relatedRequirementIds: input.artifact.relatedRequirementIds, codeRefs: input.artifact.codeRefs, }; await repo.create(artifact); await this.planService.updateStatistics(input.planId); return { artifactId }; } public async getArtifact(input: GetArtifactInput): Promise<GetArtifactResult> { await this.ensurePlanExists(input.planId); const repo = this.repositoryFactory.createRepository<Artifact>('artifact', input.planId); const artifact = await repo.findById(input.artifactId); // Apply field filtering with Lazy-Load support const filtered = filterArtifact( artifact, input.fields, false, // isListOperation input.excludeMetadata, input.includeContent ?? false // default: false (Lazy-Load) ) as Artifact; return { artifact: filtered }; } public async updateArtifact(input: UpdateArtifactInput): Promise<UpdateArtifactResult> { await this.ensurePlanExists(input.planId); // Validate inputs if provided if (input.updates.tags !== undefined) { validateTags(input.updates.tags); } if (input.updates.targets !== undefined) { validateTargets(input.updates.targets); } if (input.updates.codeRefs !== undefined) { validateCodeRefs(input.updates.codeRefs); } // Foreign key validation on update (BUG #11) if (input.updates.relatedRequirementIds !== undefined && input.updates.relatedRequirementIds.length > 0) { await this.validateRequirementReferences(input.planId, input.updates.relatedRequirementIds); } if (input.updates.relatedSolutionId !== undefined && input.updates.relatedSolutionId !== '') { await this.validateSolutionReference(input.planId, input.updates.relatedSolutionId); } const repo = this.repositoryFactory.createRepository<Artifact>('artifact', input.planId); const artifact = await repo.findById(input.artifactId); // Sprint 7: Save current version to history BEFORE updating if (this.versionHistoryService) { const currentSnapshot = JSON.parse(JSON.stringify(artifact)) as Artifact; await this.versionHistoryService.saveVersion( input.planId, input.artifactId, 'artifact', currentSnapshot, artifact.version, 'claude-code', 'Auto-saved before update' ); } // BUG #18: Validate title if provided in updates if (input.updates.title !== undefined) { validateRequiredString(input.updates.title, 'title'); artifact.title = input.updates.title; } if (input.updates.description !== undefined) artifact.description = input.updates.description; if (input.updates.slug !== undefined) { // Validate slug uniqueness (exclude current artifact) - need all artifacts for this const allArtifacts = await repo.findAll(); this.validateSlugUniqueness(allArtifacts, input.updates.slug, input.artifactId); artifact.slug = input.updates.slug; } if (input.updates.status !== undefined) artifact.status = input.updates.status; if (input.updates.content !== undefined) { artifact.content = { ...artifact.content, ...input.updates.content }; } if (input.updates.targets !== undefined) artifact.targets = input.updates.targets; if (input.updates.relatedPhaseId !== undefined) artifact.relatedPhaseId = input.updates.relatedPhaseId; if (input.updates.relatedSolutionId !== undefined) artifact.relatedSolutionId = input.updates.relatedSolutionId; if (input.updates.relatedRequirementIds !== undefined) artifact.relatedRequirementIds = input.updates.relatedRequirementIds; if (input.updates.codeRefs !== undefined) artifact.codeRefs = input.updates.codeRefs; if (input.updates.tags !== undefined) artifact.metadata.tags = input.updates.tags; // repo.update() will auto-increment version and set updatedAt await repo.update(artifact.id, artifact); return { success: true, artifactId: input.artifactId }; } public async listArtifacts(input: ListArtifactsInput): Promise<ListArtifactsResult> { await this.ensurePlanExists(input.planId); const repo = this.repositoryFactory.createRepository<Artifact>('artifact', input.planId); let artifacts = await repo.findAll(); // Apply filters if (input.filters) { const filters = input.filters; if (filters.artifactType !== undefined) { artifacts = artifacts.filter((a) => a.artifactType === filters.artifactType); } if (filters.status !== undefined) { artifacts = artifacts.filter((a) => a.status === filters.status); } if (filters.relatedPhaseId !== undefined && filters.relatedPhaseId !== '') { artifacts = artifacts.filter((a) => a.relatedPhaseId === filters.relatedPhaseId); } } const total = artifacts.length; const limit = input.limit ?? DEFAULT_ARTIFACTS_PAGE_LIMIT; const offset = input.offset ?? 0; artifacts = artifacts.slice(offset, offset + limit); // Apply field filtering with Lazy-Load (list operations NEVER return sourceCode) const filtered = artifacts.map((artifact) => filterArtifact( artifact, input.fields, true, // isListOperation input.excludeMetadata, false // includeContent IGNORED for list operations (security) ) ) as Artifact[]; return { artifacts: filtered, total, hasMore: offset + artifacts.length < total, }; } public async deleteArtifact(input: DeleteArtifactInput): Promise<DeleteArtifactResult> { await this.ensurePlanExists(input.planId); const repo = this.repositoryFactory.createRepository<Artifact>('artifact', input.planId); // BUG-015 FIX: Cascade delete all links for this artifact if (this.linkingService) { await this.linkingService.deleteLinksForEntity(input.planId, input.artifactId); } await repo.delete(input.artifactId); await this.planService.updateStatistics(input.planId); return { success: true, message: 'Artifact deleted successfully', }; } /** * Sprint 7: Get version history for an artifact * Note: Can retrieve history even for deleted artifacts */ public async getHistory(input: { planId: string; artifactId: string; limit?: number; offset?: number }): Promise<VersionHistory<Artifact>> { if (!this.versionHistoryService) { throw new Error('Version history service not available'); } const planRepo = this.repositoryFactory.createPlanRepository(); const exists = await planRepo.planExists(input.planId); if (!exists) { throw new Error('Plan not found'); } const history = await this.versionHistoryService.getHistory({ planId: input.planId, entityId: input.artifactId, entityType: 'artifact', limit: input.limit, offset: input.offset, }); return history as VersionHistory<Artifact>; } /** * Sprint 7: Compare two versions of an artifact */ public async diff(input: { planId: string; artifactId: string; version1: number; version2: number }): Promise<VersionDiff> { if (!this.versionHistoryService) { throw new Error('Version history service not available'); } const planRepo = this.repositoryFactory.createPlanRepository(); const exists = await planRepo.planExists(input.planId); if (!exists) { throw new Error('Plan not found'); } // Load current artifact to support diffing with current version const repo = this.repositoryFactory.createRepository<Artifact>('artifact', input.planId); const currentArtifact = await repo.findById(input.artifactId); return this.versionHistoryService.diff({ planId: input.planId, entityId: input.artifactId, entityType: 'artifact', version1: input.version1, version2: input.version2, currentEntityData: currentArtifact, currentVersion: currentArtifact.version, }); } }

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