UNPKG

adonis-odm

Version:

A comprehensive MongoDB ODM for AdonisJS with Lucid-style API, type-safe relationships, embedded documents, and transaction support

502 lines (501 loc) 19 kB
import { readdir, stat } from 'node:fs/promises'; import { join, resolve, extname, basename } from 'node:path'; import { pathToFileURL } from 'node:url'; import { getSeederConfig } from '../config/odm_config.js'; import { BaseSeeder } from './base_seeder.js'; /** * Seeder Manager * * Manages the discovery, loading, and execution of database seeders. * Provides functionality to run seeders individually or in batches, * with support for environment filtering and connection-specific execution. */ export class SeederManager { client; seederConfig; config; constructor(config, client) { this.config = config; this.client = client; this.seederConfig = getSeederConfig(config); } /** * Run seeders based on the provided options * * @param options - Options for running seeders * @returns Array of seeder execution results */ async run(options = {}) { const environment = options.environment || process.env.NODE_ENV || 'development'; const connection = options.connection || this.seederConfig.defaultConnection; // Validate connection if specified if (options.connection) { await this.validateConnection(options.connection); } let seederFiles; if (options.files && options.files.length > 0) { // Run specific files seederFiles = options.files; } else { // Discover all seeder files seederFiles = await this.getSeederFiles(); } const results = []; for (const filePath of seederFiles) { const result = await this.runFile(filePath, connection, environment); results.push(result); } return results; } /** * Run a specific seeder file * * @param filePath - Path to the seeder file * @param connection - Connection name to use * @param environment - Environment to check against * @returns Seeder execution result */ async runFile(filePath, connection, environment) { const seederName = this.getSeederNameFromPath(filePath); const actualConnection = connection || this.seederConfig.defaultConnection; const actualEnvironment = environment || process.env.NODE_ENV || 'development'; const result = { name: seederName, filePath, executed: false, }; try { // Validate connection before proceeding await this.validateConnection(actualConnection); // Load the seeder class const SeederClass = await this.loadSeeder(filePath); // Check environment restrictions if (!this.shouldRunInEnvironment(SeederClass, actualEnvironment)) { const seederEnvironments = SeederClass.environment; result.skipReason = `Environment restriction: seeder only runs in [${seederEnvironments?.join(', ')}], current: ${actualEnvironment}`; return result; } // Verify connection is available before creating seeder instance await this.verifyConnectionAvailability(actualConnection); // Create seeder instance const seeder = new SeederClass(this.client, actualConnection); // Execute the seeder const startTime = Date.now(); await seeder.run(); const endTime = Date.now(); result.executed = true; result.executionTime = endTime - startTime; } catch (error) { // Enhance error message with connection context const enhancedError = this.enhanceConnectionError(error, actualConnection); result.error = enhancedError instanceof Error ? enhancedError : new Error(String(enhancedError)); } return result; } /** * Get all seeder files from configured paths * * @returns Array of seeder file paths ordered by execution order and dependencies */ async getSeederFiles() { const seederFiles = []; for (const searchPath of this.seederConfig.paths) { const resolvedPath = resolve(searchPath); try { const files = await this.discoverSeederFiles(resolvedPath); seederFiles.push(...files); } catch (error) { // Path doesn't exist or is not accessible, skip it continue; } } // Get ordered seeders based on order and dependencies return await this.getOrderedSeeders(seederFiles); } /** * Load a seeder class from a file path * * @param filePath - Path to the seeder file * @returns The seeder class constructor */ async loadSeeder(filePath) { const resolvedPath = resolve(filePath); const fileUrl = pathToFileURL(resolvedPath).href; try { const module = await import(fileUrl); const SeederClass = module.default || module[Object.keys(module)[0]]; if (!SeederClass || typeof SeederClass !== 'function') { throw new Error(`No valid seeder class found in ${filePath}`); } // Verify it extends BaseSeeder if (!(SeederClass.prototype instanceof BaseSeeder) && SeederClass !== BaseSeeder) { throw new Error(`Seeder class in ${filePath} must extend BaseSeeder`); } return SeederClass; } catch (error) { throw new Error(`Failed to load seeder from ${filePath}: ${error instanceof Error ? error.message : String(error)}`); } } /** * Check if a seeder should run in the current environment * * @param SeederClass - The seeder class * @param environment - Current environment * @returns True if the seeder should run */ shouldRunInEnvironment(SeederClass, environment) { return SeederClass.shouldRun(environment); } /** * Discover seeder files in a directory recursively * * @param dirPath - Directory path to search * @returns Array of seeder file paths */ async discoverSeederFiles(dirPath) { const files = []; try { const entries = await readdir(dirPath); for (const entry of entries) { const fullPath = join(dirPath, entry); const stats = await stat(fullPath); if (stats.isDirectory()) { // Recursively search subdirectories const subFiles = await this.discoverSeederFiles(fullPath); files.push(...subFiles); } else if (stats.isFile() && this.isSeederFile(fullPath)) { files.push(fullPath); } } } catch (error) { // Directory doesn't exist or is not accessible throw error; } return files; } /** * Check if a file is a valid seeder file * * @param filePath - File path to check * @returns True if the file is a seeder file */ isSeederFile(filePath) { const ext = extname(filePath); const validExtensions = ['.ts', '.js', '.mts', '.mjs']; // Must have a valid extension if (!validExtensions.includes(ext)) { return false; } // Skip test files and other non-seeder files const fileName = basename(filePath).toLowerCase(); const skipPatterns = ['.test.', '.spec.', '.d.ts']; // Allow main seeder files (index.ts, main.ts) if (this.isMainSeederFile(filePath)) { return true; } return !skipPatterns.some((pattern) => fileName.includes(pattern)); } /** * Extract seeder name from file path * * @param filePath - File path * @returns Seeder name */ getSeederNameFromPath(filePath) { const fileName = basename(filePath, extname(filePath)); // Convert from snake_case or kebab-case to PascalCase return fileName .split(/[-_]/) .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) .join(''); } /** * Validate that a connection name exists in the configuration * * @param connectionName - Name of the connection to validate * @throws Error if connection is not found */ async validateConnection(connectionName) { const availableConnections = Object.keys(this.config.connections || {}); if (!availableConnections.includes(connectionName)) { throw new Error(`Invalid connection "${connectionName}". Available connections: [${availableConnections.join(', ')}]`); } } /** * Verify that a connection is available and established * * @param connectionName - Name of the connection to verify * @throws Error if connection is not available */ async verifyConnectionAvailability(connectionName) { try { // Check if the connection exists in the database manager if (!this.client.hasConnection(connectionName)) { throw new Error(`Connection "${connectionName}" is not established`); } // Try to get the connection to ensure it's working const connection = this.client.connection(connectionName); // Perform a simple ping to verify the connection is alive await connection.db().admin().ping(); } catch (error) { throw new Error(`Connection "${connectionName}" is not available: ${error instanceof Error ? error.message : String(error)}`); } } /** * Enhance error messages with connection context * * @param error - Original error * @param connectionName - Connection name being used * @returns Enhanced error with connection context */ enhanceConnectionError(error, connectionName) { const originalMessage = error instanceof Error ? error.message : String(error); // Check if error is connection-related if (originalMessage.toLowerCase().includes('connection') || originalMessage.toLowerCase().includes('connect') || originalMessage.toLowerCase().includes('network') || originalMessage.toLowerCase().includes('timeout')) { return new Error(`Connection error on "${connectionName}": ${originalMessage}`); } // For other errors, just add connection context return new Error(`Error on connection "${connectionName}": ${originalMessage}`); } /** * Get all available connection names * * @returns Array of connection names */ getAvailableConnections() { return Object.keys(this.config.connections || {}); } /** * Check if a connection name is valid * * @param connectionName - Connection name to check * @returns True if connection exists in configuration */ isValidConnection(connectionName) { return Object.keys(this.config.connections || {}).includes(connectionName); } /** * Get seeder configuration * * @returns Current seeder configuration */ getConfig() { return { ...this.seederConfig }; } /** * Get ordered seeders based on execution order and dependencies * * @param files - Array of seeder file paths * @returns Array of ordered seeder file paths */ async getOrderedSeeders(files) { if (files.length === 0) { return []; } // Load metadata for all seeders const seedersWithMetadata = []; for (const filePath of files) { const metadata = await this.getSeederMetadata(filePath); const className = this.getSeederNameFromPath(filePath); seedersWithMetadata.push({ filePath, className, metadata, }); } // Resolve dependency order using topological sort return this.resolveDependencyOrder(seedersWithMetadata); } /** * Get metadata from a seeder class * * @param filePath - Path to the seeder file * @returns Seeder metadata */ async getSeederMetadata(filePath) { try { const SeederClass = await this.loadSeeder(filePath); const isMainSeeder = this.isMainSeederFile(filePath); return { order: SeederClass.order ?? (isMainSeeder ? 0 : 999), dependencies: SeederClass.dependencies ?? [], isMainSeeder, }; } catch (error) { // Only catch loading errors (syntax, import errors, etc.) // Let validation errors bubble up if (this.isLoadingError(error)) { const isMainSeeder = this.isMainSeederFile(filePath); return { order: isMainSeeder ? 0 : 999, dependencies: [], isMainSeeder, }; } // Re-throw validation errors throw error; } } /** * Check if an error is a loading error (syntax, import, etc.) vs validation error * * @param error - The error to check * @returns True if this is a loading error that should be handled gracefully */ isLoadingError(error) { if (!(error instanceof Error)) { return false; } const message = error.message.toLowerCase(); // Common loading error patterns return (message.includes('syntax') || message.includes('import') || message.includes('module') || message.includes('cannot find') || message.includes('failed to load seeder') || message.includes('no valid seeder class') || message.includes('must extend baseseeder')); } /** * Check if a file is a main seeder (index.ts or main.ts) * * @param filePath - Path to the seeder file * @returns True if this is a main seeder file */ isMainSeederFile(filePath) { const fileName = basename(filePath, extname(filePath)).toLowerCase(); return fileName === 'index' || fileName === 'main'; } /** * Resolve dependency order using topological sorting * * @param seeders - Array of seeders with metadata * @returns Array of ordered seeder file paths */ resolveDependencyOrder(seeders) { // Create a map of class names to seeders for quick lookup const seederMap = new Map(); seeders.forEach((seeder) => { seederMap.set(seeder.className, seeder); }); // Validate dependencies this.validateDependencies(seeders, seederMap); // Use global topological sort instead of order groups // This ensures dependencies are respected across all order levels return this.globalTopologicalSort(seeders, seederMap); } /** * Validate seeder dependencies * * @param seeders - Array of seeders with metadata * @param seederMap - Map of class names to seeders */ validateDependencies(seeders, seederMap) { for (const seeder of seeders) { const dependencies = seeder.metadata.dependencies ?? []; for (const dependency of dependencies) { if (!seederMap.has(dependency)) { throw new Error(`Seeder "${seeder.className}" depends on "${dependency}" which was not found`); } } } // Check for circular dependencies this.detectCircularDependencies(seeders, seederMap); } /** * Detect circular dependencies in seeders * * @param seeders - Array of seeders with metadata * @param seederMap - Map of class names to seeders */ detectCircularDependencies(seeders, seederMap) { const visited = new Set(); const recursionStack = new Set(); const hasCycle = (className) => { if (recursionStack.has(className)) { return true; } if (visited.has(className)) { return false; } visited.add(className); recursionStack.add(className); const seeder = seederMap.get(className); if (seeder) { const dependencies = seeder.metadata.dependencies ?? []; for (const dependency of dependencies) { if (hasCycle(dependency)) { return true; } } } recursionStack.delete(className); return false; }; for (const seeder of seeders) { if (hasCycle(seeder.className)) { throw new Error(`Circular dependency detected involving seeder "${seeder.className}"`); } } } /** * Perform global topological sort considering both order and dependencies * * @param seeders - Array of all seeders to sort * @param seederMap - Map of all seeders for dependency lookup * @returns Array of ordered seeder file paths */ globalTopologicalSort(seeders, seederMap) { const result = []; const visited = new Set(); const tempMark = new Set(); const visit = (className) => { if (tempMark.has(className)) { throw new Error(`Circular dependency detected involving "${className}"`); } if (visited.has(className)) { return; } const seeder = seederMap.get(className); if (!seeder) { return; } tempMark.add(className); // Visit dependencies first const dependencies = seeder.metadata.dependencies ?? []; for (const dependency of dependencies) { visit(dependency); } tempMark.delete(className); visited.add(className); result.push(seeder.filePath); }; // Sort seeders by order first, then by name for consistent ordering const sortedSeeders = [...seeders].sort((a, b) => { const orderA = a.metadata.order ?? 999; const orderB = b.metadata.order ?? 999; if (orderA !== orderB) { return orderA - orderB; } // If same order, sort by class name for consistency return a.className.localeCompare(b.className); }); // Visit all seeders in sorted order for (const seeder of sortedSeeders) { visit(seeder.className); } return result; } }