Skip to main content
Glama
joelmnz

Article Manager MCP Server

by joelmnz
databaseConstraints.ts17.3 kB
import { database } from './database.js'; import { DatabaseServiceError, DatabaseErrorType, handleDatabaseError, logDatabaseError } from './databaseErrors.js'; /** * Database constraint validation and enforcement service */ export class DatabaseConstraintService { /** * Validate article data before database operations */ async validateArticleData(data: { title?: string; slug?: string; content?: string; folder?: string; excludeId?: number; }): Promise<void> { const { title, slug, content, folder, excludeId } = data; // Title validation if (title !== undefined) { this.validateTitle(title); } // Slug validation if (slug !== undefined) { this.validateSlugFormat(slug); await this.validateSlugUniqueness(slug, excludeId); } // Content validation if (content !== undefined) { this.validateContent(content); } // Folder validation if (folder !== undefined) { this.validateFolderPath(folder); } } /** * Validate title constraints */ private validateTitle(title: string): void { if (!title || title.trim().length === 0) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Title cannot be empty', 'Article title is required and cannot be empty.' ); } if (title.length > 500) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Title exceeds maximum length of 500 characters', 'Article title is too long. Please use a shorter title (maximum 500 characters).' ); } // Check for potentially problematic characters if (title.includes('\0')) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Title contains null characters', 'Article title contains invalid characters.' ); } } /** * Validate slug format constraints */ private validateSlugFormat(slug: string): void { if (!slug || slug.trim().length === 0) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Slug cannot be empty', 'Article URL identifier cannot be empty.' ); } if (slug.length > 255) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Slug exceeds maximum length of 255 characters', 'Article URL identifier is too long. Please use a shorter title.' ); } // Validate slug format (lowercase letters, numbers, hyphens only) if (!/^[a-z0-9-]+$/.test(slug)) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Slug contains invalid characters', 'Article URL identifier can only contain lowercase letters, numbers, and hyphens.' ); } // Additional slug constraints if (slug.startsWith('-') || slug.endsWith('-')) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Slug cannot start or end with hyphens', 'Article URL identifier cannot start or end with hyphens.' ); } if (slug.includes('--')) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Slug cannot contain consecutive hyphens', 'Article URL identifier cannot contain consecutive hyphens.' ); } // Reserved slugs that might conflict with system routes const reservedSlugs = [ 'api', 'admin', 'public', 'static', 'assets', 'health', 'status', 'login', 'logout', 'auth', 'mcp', 'search', 'new', 'edit', 'delete' ]; if (reservedSlugs.includes(slug)) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, `Slug '${slug}' is reserved`, `The title '${slug}' is reserved and cannot be used. Please choose a different title.` ); } } /** * Validate slug uniqueness constraint */ private async validateSlugUniqueness(slug: string, excludeId?: number): Promise<void> { try { let query = 'SELECT id, title FROM articles WHERE slug = $1'; const params: any[] = [slug]; if (excludeId) { query += ' AND id != $2'; params.push(excludeId); } const result = await database.query(query, params); if (result.rows.length > 0) { const existingTitle = result.rows[0].title; throw new DatabaseServiceError( DatabaseErrorType.CONSTRAINT_VIOLATION, `Article with slug '${slug}' already exists`, `An article titled '${existingTitle}' already uses this URL identifier. Please choose a different title.` ); } } catch (error) { if (error instanceof DatabaseServiceError) { throw error; } const dbError = handleDatabaseError(error); logDatabaseError(dbError, 'Slug Uniqueness Check'); throw dbError; } } /** * Validate content constraints */ private validateContent(content: string): void { if (!content || content.trim().length === 0) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Content cannot be empty', 'Article content is required and cannot be empty.' ); } // Check for null characters if (content.includes('\0')) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Content contains null characters', 'Article content contains invalid characters.' ); } // Reasonable content length limit (10MB) const maxContentLength = 10 * 1024 * 1024; // 10MB if (content.length > maxContentLength) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Content exceeds maximum length', 'Article content is too large. Please reduce the content size.' ); } } /** * Validate folder path constraints */ private validateFolderPath(folder: string): void { if (folder.length > 500) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Folder path exceeds maximum length of 500 characters', 'Folder path is too long. Please use a shorter folder name.' ); } // Allow empty string for root folder if (folder === '' || folder === '/') { return; } // Validate folder path format if (!/^[a-zA-Z0-9_/-]+$/.test(folder)) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Folder path contains invalid characters', 'Folder path can only contain letters, numbers, underscores, hyphens, and forward slashes.' ); } // Ensure no double slashes, leading/trailing slashes, or invalid patterns if (folder.includes('//')) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Folder path contains consecutive slashes', 'Folder path cannot contain consecutive forward slashes.' ); } if (folder.startsWith('/') || folder.endsWith('/')) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Folder path cannot start or end with slashes', 'Folder path cannot start or end with forward slashes.' ); } // Check for invalid folder names const pathParts = folder.split('/'); for (const part of pathParts) { if (part === '' || part === '.' || part === '..') { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Folder path contains invalid directory names', 'Folder path cannot contain empty parts, "." or ".." directory names.' ); } if (part.length > 100) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Individual folder name exceeds maximum length', 'Individual folder names cannot exceed 100 characters.' ); } } // Check folder depth (reasonable limit) if (pathParts.length > 10) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Folder path exceeds maximum depth', 'Folder path is too deep. Please use fewer nested folders (maximum 10 levels).' ); } } /** * Validate version history data */ async validateVersionData(data: { articleId?: number; versionId?: number; title?: string; content?: string; folder?: string; message?: string; }): Promise<void> { const { articleId, versionId, title, content, folder, message } = data; // Article ID validation if (articleId !== undefined) { await this.validateArticleExists(articleId); } // Version ID validation if (versionId !== undefined) { this.validateVersionId(versionId); } // Title, content, and folder validation (reuse article validation) if (title !== undefined) { this.validateTitle(title); } if (content !== undefined) { this.validateContent(content); } if (folder !== undefined) { this.validateFolderPath(folder); } // Message validation if (message !== undefined && message.length > 1000) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Version message exceeds maximum length', 'Version message is too long. Please use a shorter message (maximum 1000 characters).' ); } } /** * Validate that an article exists */ private async validateArticleExists(articleId: number): Promise<void> { try { if (!Number.isInteger(articleId) || articleId <= 0) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Invalid article ID', 'Article identifier is invalid.' ); } const result = await database.query( 'SELECT id FROM articles WHERE id = $1', [articleId] ); if (result.rows.length === 0) { throw new DatabaseServiceError( DatabaseErrorType.NOT_FOUND, `Article with ID ${articleId} not found`, 'The specified article does not exist.' ); } } catch (error) { if (error instanceof DatabaseServiceError) { throw error; } const dbError = handleDatabaseError(error); logDatabaseError(dbError, 'Article Existence Check'); throw dbError; } } /** * Validate version ID format */ private validateVersionId(versionId: number): void { if (!Number.isInteger(versionId) || versionId <= 0) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Invalid version ID', 'Version identifier must be a positive integer.' ); } } /** * Validate embedding data */ async validateEmbeddingData(data: { articleId?: number; chunkId?: string; chunkIndex?: number; textContent?: string; vector?: number[]; }): Promise<void> { const { articleId, chunkId, chunkIndex, textContent, vector } = data; // Article ID validation if (articleId !== undefined) { await this.validateArticleExists(articleId); } // Chunk ID validation if (chunkId !== undefined) { if (!chunkId || chunkId.trim().length === 0) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Chunk ID cannot be empty', 'Embedding chunk identifier cannot be empty.' ); } if (chunkId.length > 255) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Chunk ID exceeds maximum length', 'Embedding chunk identifier is too long.' ); } } // Chunk index validation if (chunkIndex !== undefined) { if (!Number.isInteger(chunkIndex) || chunkIndex < 0) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Invalid chunk index', 'Embedding chunk index must be a non-negative integer.' ); } } // Text content validation if (textContent !== undefined) { if (!textContent || textContent.trim().length === 0) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Text content cannot be empty', 'Embedding text content cannot be empty.' ); } if (textContent.length > 10000) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Text content exceeds maximum length', 'Embedding text content is too long for processing.' ); } } // Vector validation if (vector !== undefined) { if (!Array.isArray(vector)) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Vector must be an array', 'Embedding vector must be a valid array of numbers.' ); } if (vector.length === 0) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Vector cannot be empty', 'Embedding vector cannot be empty.' ); } // Check vector dimensions (common embedding sizes) const validDimensions = [384, 512, 768, 1024, 1536, 3072]; if (!validDimensions.includes(vector.length)) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, `Invalid vector dimension: ${vector.length}`, 'Embedding vector has an unsupported dimension size.' ); } // Validate vector values for (let i = 0; i < vector.length; i++) { if (typeof vector[i] !== 'number' || !isFinite(vector[i])) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Vector contains invalid values', 'Embedding vector contains invalid numeric values.' ); } } } } /** * Check referential integrity for article deletion */ async validateArticleDeletion(articleId: number): Promise<void> { try { // Check if article exists await this.validateArticleExists(articleId); // Check for dependent records that would be affected const checks = await Promise.all([ database.query('SELECT COUNT(*) as count FROM article_history WHERE article_id = $1', [articleId]), database.query('SELECT COUNT(*) as count FROM embeddings WHERE article_id = $1', [articleId]) ]); const historyCount = parseInt(checks[0].rows[0].count); const embeddingCount = parseInt(checks[1].rows[0].count); // Log what will be deleted (for audit purposes) if (historyCount > 0 || embeddingCount > 0) { console.log(`Article deletion will cascade to ${historyCount} history records and ${embeddingCount} embeddings`); } } catch (error) { if (error instanceof DatabaseServiceError) { throw error; } const dbError = handleDatabaseError(error); logDatabaseError(dbError, 'Article Deletion Validation'); throw dbError; } } /** * Validate database constraints are properly enforced */ async validateConstraintEnforcement(): Promise<{ valid: boolean; issues: string[] }> { const issues: string[] = []; try { // Check unique constraints const duplicateSlugs = await database.query(` SELECT slug, COUNT(*) as count FROM articles GROUP BY slug HAVING COUNT(*) > 1 `); if (duplicateSlugs.rows.length > 0) { issues.push(`Found ${duplicateSlugs.rows.length} duplicate slugs`); } // Check foreign key constraints const orphanedHistory = await database.query(` SELECT COUNT(*) as count FROM article_history ah LEFT JOIN articles a ON ah.article_id = a.id WHERE a.id IS NULL `); if (parseInt(orphanedHistory.rows[0].count) > 0) { issues.push(`Found ${orphanedHistory.rows[0].count} orphaned history records`); } const orphanedEmbeddings = await database.query(` SELECT COUNT(*) as count FROM embeddings e LEFT JOIN articles a ON e.article_id = a.id WHERE a.id IS NULL `); if (parseInt(orphanedEmbeddings.rows[0].count) > 0) { issues.push(`Found ${orphanedEmbeddings.rows[0].count} orphaned embedding records`); } // Check not null constraints const nullTitles = await database.query(` SELECT COUNT(*) as count FROM articles WHERE title IS NULL OR title = '' `); if (parseInt(nullTitles.rows[0].count) > 0) { issues.push(`Found ${nullTitles.rows[0].count} articles with null/empty titles`); } return { valid: issues.length === 0, issues }; } catch (error) { const dbError = handleDatabaseError(error); logDatabaseError(dbError, 'Constraint Validation'); issues.push(`Constraint validation failed: ${dbError.message}`); return { valid: false, issues }; } } } // Export singleton instance export const databaseConstraintService = new DatabaseConstraintService();

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/joelmnz/mcp-markdown-manager'

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