UNPKG

@memory-bank/mcp

Version:

Memory-enabled Co-Pilot (MCP) server for managing project documentation and context

180 lines 10.9 kB
import { MemoryDocument } from '../../domain/entities/MemoryDocument.js'; import { JsonPatchOperation } from '../../domain/jsonpatch/JsonPatchOperation.js'; import { ApplicationErrors } from '../../shared/errors/ApplicationError.js'; import { logger } from '../../shared/utils/logger.js'; /** * Service responsible for the core logic of writing/patching documents. */ export class DocumentWriterService { patchService; componentLogger = logger.withContext({ component: 'DocumentWriterService' }); constructor(patchService) { this.patchService = patchService; } /** * Writes or patches a document using the provided repository. * Handles common logic like input validation, patch application, and content update/creation. * Tag indexing is expected to be handled by the repository's saveDocument implementation. * * @param repository The specific repository (Branch or Global) to use. * @param input The details of the document to write or patch. * @returns The saved MemoryDocument. * @throws {ApplicationError} If input is invalid, patching fails, or repository interaction fails. */ async write(repository, // Assuming a common interface exists or will be created input) { this.componentLogger.info(`Writing document: ${input.path.value}`, { hasContent: input.content !== undefined && input.content !== null, hasPatches: input.patches && input.patches.length > 0, }); // 1. Input Validation const hasContent = input.content !== undefined && input.content !== null; const hasPatches = input.patches && Array.isArray(input.patches) && input.patches.length > 0; if (!hasContent && !hasPatches) { throw ApplicationErrors.invalidInput('Either document content or patches must be provided'); } if (hasContent && hasPatches) { throw ApplicationErrors.invalidInput('Cannot provide both document content and patches simultaneously'); } // 2. Get Existing Document (if needed or exists) const existingDocument = await repository.getDocument(input.path); let documentToSave; // 3. Apply Patches if provided if (hasPatches) { this.componentLogger.debug('Processing write request with patches.', { path: input.path.value }); if (!existingDocument) { throw ApplicationErrors.notFound('Document', input.path.value, { message: 'Cannot apply patches to non-existent document.' }); } try { let currentContentObject; if (typeof existingDocument.content === 'string') { try { currentContentObject = JSON.parse(existingDocument.content); } catch (parseError) { throw ApplicationErrors.executionFailed(`Failed to parse existing document content as JSON for patching: ${parseError.message}`, parseError instanceof Error ? parseError : undefined); } } else if (typeof existingDocument.content === 'object' && existingDocument.content !== null) { currentContentObject = existingDocument.content; // Already an object } else { throw ApplicationErrors.executionFailed(`Existing document content is not a string or object, cannot apply patches. Type: ${typeof existingDocument.content}`); } const patchOperations = (input.patches ?? []).map(p => JsonPatchOperation.create(p.op, p.path, p.value, p.from)); // --- Test Operation Validation --- const testOperations = patchOperations.filter(op => op.op === 'test'); if (testOperations.length > 0) { this.componentLogger.debug('Validating test operations before applying patch', { path: input.path.value, testOperations }); for (const testOp of testOperations) { if (testOp.op !== 'test' || testOp.value === undefined) { throw ApplicationErrors.invalidInput(`Invalid test operation format: ${JSON.stringify(testOp)}`); } try { const expectedValue = testOp.value; let actualValue = currentContentObject; const segments = testOp.path.segments.slice(1); // Exclude root segment for (const segment of segments) { if (actualValue && typeof actualValue === 'object' && segment in actualValue) { actualValue = actualValue[segment]; } else if (Array.isArray(actualValue) && /^\d+$/.test(segment)) { const index = parseInt(segment, 10); if (index >= 0 && index < actualValue.length) { actualValue = actualValue[index]; } else { throw new Error(`Path not found at index: ${segment}`); } } else { throw new Error(`Path not found: ${testOp.path.path}`); } } if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) { throw new Error(`Value mismatch at path ${testOp.path.path}. Expected: ${JSON.stringify(expectedValue)}, Actual: ${JSON.stringify(actualValue)}`); } this.componentLogger.debug('Test operation successful:', { testOp }); } catch (testError) { this.componentLogger.error('Patch test operation failed:', { path: input.path.value, testOp, error: testError }); const cause = testError instanceof Error ? testError : undefined; throw ApplicationErrors.invalidInput(`Patch test operation failed: ${cause?.message ?? 'Test failed'}`, { cause }); } } } // --- Apply Non-Test Patches --- const nonTestOperations = patchOperations.filter(op => op.op !== 'test'); const patchedContent = this.patchService.apply(currentContentObject, nonTestOperations); const stringifiedContent = JSON.stringify(patchedContent, null, 2); documentToSave = existingDocument.updateContent(stringifiedContent); // Update tags on the MemoryDocument object if provided in input if (input.tags) { documentToSave = documentToSave.updateTags(input.tags); this.componentLogger.debug('Tags updated on MemoryDocument along with patches.', { path: input.path.value, newTags: input.tags.map(t => t.value) }); } } catch (patchError) { this.componentLogger.error(`Failed to apply JSON patch to ${input.path.value}`, { error: patchError }); throw ApplicationErrors.executionFailed(`Failed to apply JSON patch: ${patchError.message}`, patchError instanceof Error ? patchError : undefined); } } // 4. Handle Content Update/Creation else if (hasContent) { this.componentLogger.debug('Processing write request with content.', { path: input.path.value }); const contentInput = input.content ?? ''; // Use empty string if content is null/undefined const tagsToApply = input.tags ?? []; // Use provided tags or empty array // Convert content to string if it's an object let contentToSaveAsString; if (typeof contentInput === 'string') { contentToSaveAsString = contentInput; // Optional: Validate if it's valid JSON string? try { JSON.parse(contentToSaveAsString); } catch (e) { this.componentLogger.warn(`Content provided as string is not valid JSON: ${input.path.value}`); // Decide if we should throw or allow non-JSON strings // For now, let's allow it, but log a warning. } } else if (typeof contentInput === 'object' && contentInput !== null) { contentToSaveAsString = JSON.stringify(contentInput, null, 2); // Pretty print JSON } else { // Should not happen due to earlier checks, but defensively handle throw ApplicationErrors.invalidInput(`Invalid content type: ${typeof contentInput}`); } if (existingDocument) { documentToSave = existingDocument.updateContent(contentToSaveAsString); documentToSave = documentToSave.updateTags(tagsToApply); // Always update tags based on input } else { documentToSave = MemoryDocument.create({ path: input.path, content: contentToSaveAsString, tags: tagsToApply, lastModified: new Date(), }); } } else { // Should be unreachable due to initial validation throw ApplicationErrors.executionFailed('Invalid state: No content or patches to process.'); } // 5. Save Document via Repository this.componentLogger.debug(`Saving document ${documentToSave.path.value} via repository.`); await repository.saveDocument(documentToSave); // 6. Return the saved document (repository might update lastModified or other fields) // It might be safer to re-fetch, but let's return the object we passed to save for now. // The UseCase might re-fetch if it needs the absolute latest state (e.g., for tag index updates). return documentToSave; } } // TODO: Define or import a common IDocumentRepository interface // This interface should likely include: // - getDocument(path: DocumentPath): Promise<MemoryDocument | null> // - saveDocument(document: MemoryDocument): Promise<void> // - exists?(identifier: string): Promise<boolean>; // Optional, if needed by service // - initialize?(identifier: any): Promise<void>; // Optional //# sourceMappingURL=DocumentWriterService.js.map