UNPKG

supa-seed

Version:

A constraint-aware, framework-agnostic database seeding framework with deep PostgreSQL business logic discovery and MakerKit integration support

437 lines 17.3 kB
"use strict"; /** * Advanced Slug Management System for Epic 1: Universal MakerKit Core System * Comprehensive slug generation, validation, and collision handling for team accounts * Part of Task 1.5.3: Implement advanced slug management system for team accounts */ Object.defineProperty(exports, "__esModule", { value: true }); exports.SlugUtils = exports.AdvancedSlugManager = void 0; const logger_1 = require("../core/utils/logger"); /** * Advanced Slug Manager for MakerKit team accounts */ class AdvancedSlugManager { constructor(client) { this.defaultOptions = { maxLength: 50, allowNumbers: true, allowHyphens: true, forceLowercase: true, addTimestamp: false, customSeparator: '-', reservedSlugs: [ 'admin', 'api', 'app', 'auth', 'billing', 'dashboard', 'docs', 'help', 'home', 'login', 'logout', 'profile', 'settings', 'signup', 'support', 'team', 'teams', 'user', 'users', 'www', 'mail', 'email', 'ftp', 'blog', 'news', 'test', 'demo', 'staging', 'prod', 'production', 'dev', 'development' ], collisionStrategy: 'increment' }; this.client = client; } /** * Generate a unique slug for team accounts with comprehensive validation */ async generateUniqueSlug(baseName, tableName = 'accounts', options = {}) { const mergedOptions = { ...this.defaultOptions, ...options }; // Step 1: Normalize the base name let slug = this.normalizeSlug(baseName, mergedOptions); // Step 2: Validate against reserved slugs if (this.isReservedSlug(slug, mergedOptions.reservedSlugs)) { slug = await this.resolveReservedSlugCollision(slug, mergedOptions); } // Step 3: Check for database collisions if (this.client) { const collisionResult = await this.resolveSlugCollision(slug, tableName, mergedOptions); slug = collisionResult.resolvedSlug; } logger_1.Logger.debug(`Generated unique slug: ${slug} from base: ${baseName}`); return slug; } /** * Validate an existing slug and provide suggestions for improvement */ async validateSlug(slug, tableName = 'accounts', options = {}) { const mergedOptions = { ...this.defaultOptions, ...options }; const result = { isValid: true, normalizedSlug: slug, issues: [], suggestions: [], collisionDetected: false, alternativeSlugs: [] }; // Basic format validation const formatIssues = this.validateSlugFormat(slug, mergedOptions); result.issues.push(...formatIssues); // Normalize the slug const normalizedSlug = this.normalizeSlug(slug, mergedOptions); if (normalizedSlug !== slug) { result.normalizedSlug = normalizedSlug; result.suggestions.push(`Consider using normalized version: ${normalizedSlug}`); } // Check for reserved slug conflicts if (this.isReservedSlug(normalizedSlug, mergedOptions.reservedSlugs)) { result.issues.push('Slug conflicts with reserved system slug'); result.isValid = false; result.alternativeSlugs = this.generateAlternativeReservedSlugs(normalizedSlug); } // Check for database collisions if (this.client) { const hasCollision = await this.checkSlugCollision(normalizedSlug, tableName); if (hasCollision) { result.collisionDetected = true; result.isValid = false; result.issues.push('Slug already exists in database'); // Generate alternatives result.alternativeSlugs = await this.generateAlternativeSlugs(normalizedSlug, tableName, mergedOptions); } } // Generate suggestions for improvement if (result.issues.length === 0) { result.suggestions = this.generateSlugImprovementSuggestions(normalizedSlug); } return result; } /** * Normalize a slug according to MakerKit standards */ normalizeSlug(input, options) { let slug = input.toString().trim(); // Force lowercase if required if (options.forceLowercase) { slug = slug.toLowerCase(); } // Remove accents and special characters slug = slug.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); // Replace spaces and underscores with separator const separator = options.customSeparator || '-'; slug = slug.replace(/[\s_]+/g, separator); // Remove disallowed characters if (!options.allowNumbers) { slug = slug.replace(/[0-9]/g, ''); } if (!options.allowHyphens && separator !== '-') { slug = slug.replace(/-/g, ''); } // Remove special characters (keep only alphanumeric and allowed separators) const allowedChars = options.allowNumbers ? 'a-z0-9' : 'a-z'; const separatorRegex = options.allowHyphens ? '-' : ''; const pattern = new RegExp(`[^${allowedChars}${separatorRegex}${separator}]`, 'g'); slug = slug.replace(pattern, ''); // Clean up multiple separators const multiSeparatorRegex = new RegExp(`${separator}+`, 'g'); slug = slug.replace(multiSeparatorRegex, separator); // Remove leading/trailing separators const leadingTrailingRegex = new RegExp(`^${separator}|${separator}$`, 'g'); slug = slug.replace(leadingTrailingRegex, ''); // Enforce maximum length if (options.maxLength && slug.length > options.maxLength) { slug = slug.substring(0, options.maxLength); // Ensure we don't end with a separator after truncation slug = slug.replace(new RegExp(`${separator}$`), ''); } // Add timestamp if requested if (options.addTimestamp) { const timestamp = Date.now().toString().slice(-6); const maxBaseLength = (options.maxLength || 50) - timestamp.length - 1; if (slug.length > maxBaseLength) { slug = slug.substring(0, maxBaseLength); } slug = `${slug}${separator}${timestamp}`; } return slug || 'team'; // Fallback if slug becomes empty } /** * Check if a slug collides with existing records in the database */ async checkSlugCollision(slug, tableName) { if (!this.client) return false; try { const { data, error } = await this.client .from(tableName) .select('slug') .eq('slug', slug) .limit(1); if (error) { logger_1.Logger.warn(`Error checking slug collision: ${error.message}`); return false; } return data && data.length > 0; } catch (error) { logger_1.Logger.warn(`Error checking slug collision: ${error.message}`); return false; } } /** * Resolve slug collision using specified strategy */ async resolveSlugCollision(slug, tableName, options) { const result = { originalSlug: slug, resolvedSlug: slug, strategy: options.collisionStrategy || 'increment', attempts: 0, alternativesGenerated: [] }; let currentSlug = slug; let hasCollision = await this.checkSlugCollision(currentSlug, tableName); while (hasCollision && result.attempts < 50) { result.attempts++; switch (options.collisionStrategy) { case 'increment': currentSlug = `${slug}-${result.attempts}`; break; case 'timestamp': const timestamp = Date.now().toString().slice(-6); currentSlug = `${slug}-${timestamp}`; break; case 'random': const randomSuffix = Math.random().toString(36).substring(2, 8); currentSlug = `${slug}-${randomSuffix}`; break; case 'uuid': const uuidSuffix = this.generateShortUuid(); currentSlug = `${slug}-${uuidSuffix}`; break; default: currentSlug = `${slug}-${result.attempts}`; } // Ensure new slug doesn't exceed length limits if (options.maxLength && currentSlug.length > options.maxLength) { const excess = currentSlug.length - options.maxLength; const baseSlug = slug.substring(0, slug.length - excess); currentSlug = currentSlug.replace(slug, baseSlug); } result.alternativesGenerated.push(currentSlug); hasCollision = await this.checkSlugCollision(currentSlug, tableName); } result.resolvedSlug = currentSlug; return result; } /** * Validate slug format against rules */ validateSlugFormat(slug, options) { const issues = []; // Check length if (options.maxLength && slug.length > options.maxLength) { issues.push(`Slug exceeds maximum length of ${options.maxLength} characters`); } if (slug.length === 0) { issues.push('Slug cannot be empty'); } // Check for invalid characters const allowedChars = options.allowNumbers ? 'a-z0-9' : 'a-z'; const separatorChar = options.customSeparator || '-'; const hyphensAllowed = options.allowHyphens ? '-' : ''; const pattern = new RegExp(`^[${allowedChars}${hyphensAllowed}${separatorChar}]+$`, 'i'); if (!pattern.test(slug)) { issues.push('Slug contains invalid characters'); } // Check for leading/trailing separators if (slug.startsWith(separatorChar) || slug.endsWith(separatorChar)) { issues.push('Slug cannot start or end with separator'); } // Check for consecutive separators const consecutivePattern = new RegExp(`${separatorChar}{2,}`); if (consecutivePattern.test(slug)) { issues.push('Slug cannot contain consecutive separators'); } return issues; } /** * Check if slug is reserved */ isReservedSlug(slug, reservedSlugs) { const reserved = reservedSlugs || this.defaultOptions.reservedSlugs || []; return reserved.includes(slug.toLowerCase()); } /** * Resolve reserved slug collision */ async resolveReservedSlugCollision(slug, options) { const strategies = ['team', 'org', 'group', 'company']; for (const strategy of strategies) { const candidate = `${strategy}-${slug}`; if (!this.isReservedSlug(candidate, options.reservedSlugs)) { return candidate; } } // Fallback to timestamp const timestamp = Date.now().toString().slice(-6); return `${slug}-${timestamp}`; } /** * Generate alternative slugs for reserved conflicts */ generateAlternativeReservedSlugs(slug) { const prefixes = ['team', 'org', 'group', 'my', 'the']; const suffixes = ['team', 'org', 'group', 'inc', 'co']; const alternatives = []; prefixes.forEach(prefix => { alternatives.push(`${prefix}-${slug}`); }); suffixes.forEach(suffix => { alternatives.push(`${slug}-${suffix}`); }); return alternatives.slice(0, 5); // Return top 5 alternatives } /** * Generate alternative slugs for database collisions */ async generateAlternativeSlugs(slug, tableName, options) { const alternatives = []; // Strategy 1: Increment numbers for (let i = 1; i <= 5; i++) { const candidate = `${slug}-${i}`; const hasCollision = await this.checkSlugCollision(candidate, tableName); if (!hasCollision) { alternatives.push(candidate); } } // Strategy 2: Add descriptive suffixes const suffixes = ['team', 'org', 'group', 'co', 'inc']; for (const suffix of suffixes) { const candidate = `${slug}-${suffix}`; const hasCollision = await this.checkSlugCollision(candidate, tableName); if (!hasCollision) { alternatives.push(candidate); } } return alternatives.slice(0, 5); } /** * Generate suggestions for slug improvement */ generateSlugImprovementSuggestions(slug) { const suggestions = []; // Check for common issues and suggest improvements if (slug.length < 3) { suggestions.push('Consider using a longer, more descriptive slug'); } if (slug.length > 30) { suggestions.push('Consider shortening the slug for better readability'); } if (!/[a-z]/.test(slug)) { suggestions.push('Include letters for better readability'); } if (slug.split('-').length > 4) { suggestions.push('Consider reducing the number of segments for simplicity'); } return suggestions; } /** * Generate a short UUID-like string */ generateShortUuid() { return Math.random().toString(36).substring(2, 10); } /** * Batch validate multiple slugs */ async batchValidateSlugs(slugs, tableName = 'accounts', options = {}) { const results = new Map(); for (const slug of slugs) { const result = await this.validateSlug(slug, tableName, options); results.set(slug, result); } return results; } /** * Get slug generation statistics */ getSlugStatistics(slugs) { const lengths = slugs.map(s => s.length); const averageLength = lengths.reduce((a, b) => a + b, 0) / lengths.length; // Find most common pattern (simplified) const patterns = slugs.map(s => s.replace(/[0-9]/g, '#').replace(/[a-z]/g, 'a')); const patternCounts = patterns.reduce((acc, pattern) => { acc[pattern] = (acc[pattern] || 0) + 1; return acc; }, {}); const mostCommonPattern = Object.entries(patternCounts) .sort(([, a], [, b]) => b - a)[0]?.[0] || 'unknown'; const reservedConflicts = slugs.filter(s => this.isReservedSlug(s, this.defaultOptions.reservedSlugs)).length; const formatIssues = slugs.filter(s => this.validateSlugFormat(s, this.defaultOptions).length > 0).length; return { averageLength, mostCommonPattern, reservedConflicts, formatIssues }; } } exports.AdvancedSlugManager = AdvancedSlugManager; /** * Utility functions for slug management */ class SlugUtils { /** * Extract potential slug base from various data sources */ static extractSlugBase(data) { const candidates = [ data.name, data.organization_name, data.account_name, data.company_name, data.team_name, data.title, data.display_name ].filter(Boolean); return candidates[0] || 'team'; } /** * Suggest slug based on business context */ static suggestContextualSlug(data) { const base = SlugUtils.extractSlugBase(data); // Add context based on account type if (data.account_type === 'enterprise') { return `${base}-enterprise`; } if (data.industry) { return `${base}-${data.industry}`; } if (data.location) { return `${base}-${data.location}`; } return base; } /** * Check if slug follows MakerKit naming conventions */ static followsMakerKitConventions(slug) { // MakerKit conventions: lowercase, hyphens, no special chars return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug) && slug.length >= 3 && slug.length <= 50; } /** * Convert various naming formats to slug format */ static convertToSlugFormat(input, format) { let converted = input; switch (format) { case 'camelCase': converted = input.replace(/([A-Z])/g, '-$1').toLowerCase(); break; case 'snake_case': converted = input.replace(/_/g, '-'); break; case 'Title Case': converted = input.replace(/\s+/g, '-').toLowerCase(); break; case 'UPPER': converted = input.toLowerCase().replace(/\s+/g, '-'); break; } return converted.replace(/-+/g, '-').replace(/^-|-$/g, ''); } } exports.SlugUtils = SlugUtils; exports.default = AdvancedSlugManager; //# sourceMappingURL=slug-management-system.js.map