/** * Dev Command * * Start local development environment for postgres.do with: * - Local PostgreSQL using PGLite * - Migration watching and hot reload * - Optional wrangler integration * * @module cli/commands/dev */ import { resolve, join } from 'node:path' import { existsSync, watch, readFileSync, readdirSync, statSync } from 'node:fs' import type { DevCLIOptions } from '../types.js' import { formatSuccess, formatError, formatInfo, formatWarning, formatDim, formatMigrationResult, formatDuration, createSpinner, } from '../formatting.js' import { loadMigrationsFromDirectory } from '../utils.js' /** * Configuration for the dev server */ interface DevServerConfig { migrationsDir: string seedsDir: string port: number dataDir: string | null // null = in-memory verbose: boolean watch: boolean wrangler: boolean } /** * Dev server state */ interface DevServerState { pglite: unknown | null sql: unknown | null isRunning: boolean lastMigrationHash: string | null migrationWatcher: ReturnType | null seedWatcher: ReturnType | null } const state: DevServerState = { pglite: null, sql: null, isRunning: false, lastMigrationHash: null, migrationWatcher: null, seedWatcher: null, } /** * Start the dev server */ export async function runDev(options: DevCLIOptions): Promise { const config: DevServerConfig = { migrationsDir: resolve(process.cwd(), options.migrationsDir || './migrations'), seedsDir: resolve(process.cwd(), options.seedsDir || './seeds'), port: options.port || 5432, dataDir: options.persist ? resolve(process.cwd(), options.dataDir || './.postgres.do') : null, verbose: options.verbose || false, watch: options.watch !== false, // Default to true wrangler: options.wrangler || false, } console.log('') console.log(formatInfo('Starting postgres.do dev server...')) console.log('') // Initialize PGLite await initializePGLite(config) // Run initial migrations if (existsSync(config.migrationsDir)) { await applyMigrations(config) } else { console.log(formatDim(` No migrations directory found at ${config.migrationsDir}`)) console.log(formatDim(' Create migrations with: npx postgres.do migrate:create ')) } // Run seeds if requested if (options.seed && existsSync(config.seedsDir)) { await runSeeds(config) } // Start watchers if enabled if (config.watch) { startWatchers(config) } // Print connection info printConnectionInfo(config) // Handle shutdown setupShutdownHandlers(config) // Keep process running await keepAlive(config) } /** * Initialize PGLite instance */ async function initializePGLite(config: DevServerConfig): Promise { const spinner = createSpinner('Initializing PGLite...') spinner.start() try { // Dynamic import to handle optional dependency const { PGlite } = await import('@dotdo/pglite') const { createPGLiteSql } = await import('../../pglite/index.js') // Create PGLite instance const pgliteConfig = config.dataDir ? { dataDir: config.dataDir } : {} const pglite = new PGlite(pgliteConfig) await pglite.waitReady // Create SQL interface const sql = await createPGLiteSql(pglite) state.pglite = pglite state.sql = sql state.isRunning = true spinner.succeed(`PGLite initialized ${config.dataDir ? `(persistent: ${config.dataDir})` : '(in-memory)'}`) } catch (error) { spinner.fail('Failed to initialize PGLite') if (error instanceof Error && error.message.includes('Cannot find module')) { console.error(formatError('PGLite is not installed. Install it with:')) console.error(formatInfo(' npm install @dotdo/pglite')) } else { console.error(formatError(error instanceof Error ? error.message : String(error))) } process.exit(1) } } /** * Apply migrations from directory */ async function applyMigrations(config: DevServerConfig): Promise { console.log('') console.log(formatInfo('Running migrations...')) try { const migrations = loadMigrationsFromDirectory(config.migrationsDir) if (migrations.length === 0) { console.log(formatDim(' No migrations found')) return } // Import migration system const { createMigrationRegistry, createMigrationRunner } = await import('@dotdo/postgres/migrations') // Create registry - cast MigrationDefinition to Migration (structurally compatible) const registry = createMigrationRegistry({ migrations: migrations as import('@dotdo/postgres/migrations').Migration[] }) // Create query executor from PGLite const sql = state.sql as { unsafe: (query: string) => Promise } const executor = { async query(sqlStr: string): Promise<{ rows: T[]; fields: { name: string; dataTypeID: number }[] }> { const result = await sql.unsafe(sqlStr) return { rows: result as T[], fields: [] } }, } // Create runner - use conditional spreading for optional properties const runner = createMigrationRunner(executor, registry, { ...(config.verbose ? { debug: config.verbose } : {}), onProgress: (event) => { if (config.verbose || event.phase === 'completed' || event.phase === 'failed') { // Adapt MigrationProgressEvent to MigrationProgressDisplay const displayEvent = { migration: event.migration, index: event.index, total: event.total, phase: event.phase, ...(event.durationMs !== undefined && { durationMs: event.durationMs }), ...(event.error !== undefined && { error: event.error }), } console.log(formatMigrationResult(displayEvent)) } }, }) // Run migrations const startTime = Date.now() const result = await runner.migrate() const duration = Date.now() - startTime // Store hash for change detection state.lastMigrationHash = computeMigrationHash(config.migrationsDir) if (result.success) { if (result.migrationsRun === 0) { console.log(formatDim(' Database is up to date')) } else { console.log(formatSuccess(` Applied ${result.migrationsRun} migration(s) in ${formatDuration(duration)}`)) } } else { console.error(formatError(' Migration failed')) const failedResult = result.results.find((r: { success: boolean }) => !r.success) if (failedResult) { console.error(formatError(` Failed on: ${failedResult.id}`)) console.error(formatError(` Error: ${failedResult.error}`)) } } } catch (error) { console.error(formatError('Migration error:'), error instanceof Error ? error.message : error) if (config.verbose && error instanceof Error && error.stack) { console.error(error.stack) } } } /** * Run seed files */ async function runSeeds(config: DevServerConfig): Promise { console.log('') console.log(formatInfo('Running seeds...')) try { const seedFiles = findSeedFiles(config.seedsDir) if (seedFiles.length === 0) { console.log(formatDim(' No seed files found')) return } for (const seedFile of seedFiles) { console.log(formatDim(` Running ${seedFile.name}...`)) try { // Try to load and run the seed const seedPath = seedFile.path // For SQL seeds if (seedFile.name.endsWith('.sql')) { const sql = state.sql as { unsafe: (query: string) => Promise } const content = readFileSync(seedPath, 'utf-8') await sql.unsafe(content) console.log(formatSuccess(` Applied ${seedFile.name}`)) } // For TypeScript/JavaScript seeds else if (seedFile.name.endsWith('.ts') || seedFile.name.endsWith('.js')) { // Dynamic import const seedModule = await import(seedPath) const seedFn = seedModule.default || seedModule.seed if (typeof seedFn === 'function') { await seedFn(state.sql) console.log(formatSuccess(` Applied ${seedFile.name}`)) } else { console.log(formatWarning(` Skipped ${seedFile.name} (no default export or seed function)`)) } } } catch (error) { console.error(formatError(` Failed ${seedFile.name}:`), error instanceof Error ? error.message : error) } } } catch (error) { console.error(formatError('Seed error:'), error instanceof Error ? error.message : error) } } /** * Find seed files in directory */ function findSeedFiles(dir: string): Array<{ name: string; path: string }> { if (!existsSync(dir)) { return [] } const entries = readdirSync(dir) const seedFiles: Array<{ name: string; path: string }> = [] for (const entry of entries) { if (entry.startsWith('.') || entry.startsWith('_')) { continue } const entryPath = join(dir, entry) const stat = statSync(entryPath) if (stat.isFile() && (entry.endsWith('.sql') || entry.endsWith('.ts') || entry.endsWith('.js'))) { seedFiles.push({ name: entry, path: entryPath }) } } // Sort by name for consistent ordering return seedFiles.sort((a, b) => a.name.localeCompare(b.name)) } /** * Start file watchers for hot reload */ function startWatchers(config: DevServerConfig): void { console.log('') console.log(formatInfo('Watching for changes...')) // Watch migrations directory if (existsSync(config.migrationsDir)) { try { state.migrationWatcher = watch(config.migrationsDir, { recursive: true }, (eventType, filename) => { if (filename && (filename.endsWith('.ts') || filename.endsWith('.js') || filename.endsWith('.sql'))) { handleMigrationChange(config, eventType, filename) } }) console.log(formatDim(` Watching migrations: ${config.migrationsDir}`)) } catch (error) { console.log(formatWarning(` Could not watch migrations directory: ${error instanceof Error ? error.message : error}`)) } } // Watch seeds directory if (existsSync(config.seedsDir)) { try { state.seedWatcher = watch(config.seedsDir, { recursive: true }, (eventType, filename) => { if (filename && (filename.endsWith('.ts') || filename.endsWith('.js') || filename.endsWith('.sql'))) { handleSeedChange(config, eventType, filename) } }) console.log(formatDim(` Watching seeds: ${config.seedsDir}`)) } catch (error) { console.log(formatWarning(` Could not watch seeds directory: ${error instanceof Error ? error.message : error}`)) } } } /** * Handle migration file changes */ let migrationChangeTimeout: NodeJS.Timeout | null = null function handleMigrationChange(config: DevServerConfig, eventType: string, filename: string): void { // Debounce to avoid multiple triggers if (migrationChangeTimeout) { clearTimeout(migrationChangeTimeout) } migrationChangeTimeout = setTimeout(async () => { const newHash = computeMigrationHash(config.migrationsDir) if (newHash !== state.lastMigrationHash) { console.log('') console.log(formatInfo(`Migration ${eventType}: ${filename}`)) // Re-run migrations await applyMigrations(config) } }, 100) } /** * Handle seed file changes */ function handleSeedChange(_config: DevServerConfig, eventType: string, filename: string): void { console.log('') console.log(formatInfo(`Seed ${eventType}: ${filename}`)) console.log(formatDim(' Run `npx postgres.do dev:reset --seed` to re-apply seeds')) } /** * Compute hash of migration directory for change detection */ function computeMigrationHash(dir: string): string { if (!existsSync(dir)) { return '' } try { const migrations = loadMigrationsFromDirectory(dir) return migrations.map((m) => `${m.id}:${m.version}:${m.up.length}`).join('|') } catch { return '' } } /** * Print connection information */ function printConnectionInfo(config: DevServerConfig): void { console.log('') console.log('----------------------------------------') console.log(formatSuccess('postgres.do dev server is running!')) console.log('----------------------------------------') console.log('') console.log('Connection Info:') console.log(formatDim(' Type: PGLite (embedded PostgreSQL)')) console.log(formatDim(` Storage: ${config.dataDir || 'In-memory'}`)) console.log('') console.log('Available Commands:') console.log(formatDim(' npx postgres.do dev:reset Reset database')) console.log(formatDim(' npx postgres.do dev:reset --seed Reset and seed')) console.log(formatDim(' npx postgres.do migrate:create Create migration')) console.log('') if (config.wrangler) { console.log(formatInfo('Wrangler integration enabled')) console.log(formatDim(' Your Durable Objects will use this dev database')) console.log('') } console.log(formatDim('Press Ctrl+C to stop')) console.log('') } /** * Setup shutdown handlers */ function setupShutdownHandlers(_config: DevServerConfig): void { const shutdown = async () => { console.log('') console.log(formatInfo('Shutting down...')) // Stop watchers if (state.migrationWatcher) { state.migrationWatcher.close() } if (state.seedWatcher) { state.seedWatcher.close() } // Close PGLite if (state.sql) { try { const sql = state.sql as { end: () => Promise } await sql.end() } catch { // Ignore errors during shutdown } } state.isRunning = false console.log(formatSuccess('Goodbye!')) process.exit(0) } process.on('SIGINT', shutdown) process.on('SIGTERM', shutdown) } /** * Keep process alive */ async function keepAlive(_config: DevServerConfig): Promise { // Keep the process running by waiting indefinitely return new Promise(() => { // This promise never resolves, keeping the process alive // The shutdown handlers will call process.exit() }) }