UNPKG

@t1mmen/srtd

Version:

Supabase Repeatable Template Definitions (srtd): 🪄 Live-reloading SQL templates for Supabase DX. Make your database changes reviewable and migrations maintainable! 🚀

449 lines • 17.6 kB
// src/commands/watch.ts import readline from 'node:readline'; import chalk from 'chalk'; import { Command } from 'commander'; import figures from 'figures'; import { ndjsonEvent } from '../output/ndjsonOutput.js'; import { Orchestrator } from '../services/Orchestrator.js'; import { displayValidationWarnings } from '../ui/displayWarnings.js'; import { createSpinner, renderBranding, renderResultRow, renderWatchFooter, } from '../ui/index.js'; import { getConfig } from '../utils/config.js'; import { findProjectRoot } from '../utils/findProjectRoot.js'; import { formatPath } from '../utils/formatPath.js'; import { getErrorMessage } from '../utils/getErrorMessage.js'; const MAX_HISTORY = 10; /** * Stack consecutive events for the same template (unless error). * When changed→applied happens, we only show "applied" (the meaningful action). */ export function stackResults(results) { const stacked = []; for (const result of results) { const last = stacked[stacked.length - 1]; const type = statusToEventType(result.status); // Stack if same template and neither is error if (last && last.result.template === result.template && type !== 'error' && !last.types.includes('error')) { // Add type if not already present if (!last.types.includes(type)) { last.types.push(type); } // Update result to latest (so we get the applied result, not changed) last.result = { ...result }; } else { stacked.push({ result: { ...result }, types: [type], }); } } // Convert back to TemplateResult // When we have both changed and applied, just show applied (the action that matters) return stacked.map(({ result, types }) => { // If we have applied, that's the important one - show it as success if (types.includes('applied')) { return { ...result, status: 'success' }; } // If only changed, keep it as changed return result; }); } /** Convert TemplateResult status to WatchEventType */ export function statusToEventType(status) { switch (status) { case 'success': return 'applied'; case 'changed': return 'changed'; case 'error': return 'error'; default: return 'changed'; } } /** * Determine if a template needs building and why. * Returns null if template is up-to-date with current build. */ export function getBuildReason(template) { if (!template.buildState.lastBuildHash) return 'never-built'; if (template.currentHash !== template.buildState.lastBuildHash) return 'outdated'; return null; } /** Renders the full watch mode screen with header, history, errors, and instructions */ export function renderScreen(options) { const { templates, recentUpdates, historicActivity, errors, showHistory, needsBuild } = options; console.clear(); // Render header renderBranding({ subtitle: 'Watch' }); // Show status line const templateCount = templates.length; const statusParts = [`${templateCount} template${templateCount !== 1 ? 's' : ''}`]; if (needsBuild.size > 0) statusParts.push(chalk.yellow(`${needsBuild.size} need build`)); if (errors.size > 0) statusParts.push(chalk.red(`${errors.size} errors`)); console.log(chalk.dim(statusParts.join(' • '))); console.log(); // History section (if toggled on) // Combine historic activity with recent updates (oldest at top, newest at bottom) const hasRecentActivity = recentUpdates.length > 0 || historicActivity.length > 0; if (showHistory && hasRecentActivity) { console.log(chalk.bold('Recent activity:')); const stacked = stackResults(recentUpdates); // First show historic activity (older entries at top) // historicActivity comes newest-first, so reverse to get oldest-first const remainingSlots = MAX_HISTORY - stacked.length; if (remainingSlots > 0) { const historicToShow = historicActivity.slice(0, remainingSlots).reverse(); for (const entry of historicToShow) { // Skip if this template is already shown in recent updates const alreadyShown = stacked.some(s => s.template === entry.template); if (alreadyShown) continue; // Convert historic entry to TemplateResult const result = { template: entry.template, status: 'success', timestamp: entry.timestamp, }; renderResultRow(result, { command: 'watch' }); } } // Then show stacked recent updates (newer entries at bottom) for (const result of stacked) { renderResultRow(result, { command: 'watch' }); } console.log(); } // Pending build section with reasons if (needsBuild.size > 0) { console.log(chalk.yellow.bold('Pending build:')); for (const [templatePath, reason] of needsBuild) { const label = reason === 'never-built' ? 'never built' : 'changed since build'; console.log(chalk.yellow(` ⚡ ${formatPath.truncatePath(templatePath)} (${label})`)); } console.log(); } // Errors section if (errors.size > 0) { console.log(chalk.red.bold('Errors:')); for (const [templatePath, error] of errors) { console.log(chalk.red(` ${formatPath.truncatePath(templatePath)}: ${error}`)); } console.log(); } // Footer with keyboard shortcuts renderWatchFooter({ shortcuts: [ { key: 'q', label: 'quit' }, { key: 'b', label: 'build' }, { key: 'u', label: showHistory ? 'hide history' : 'show history' }, ], }); } export const watchCommand = new Command('watch') .description('Watch templates for changes and auto-apply') .option('--json', 'Output events as NDJSON stream') .action(async (options) => { const jsonMode = options.json ?? false; const exitCode = 0; let orchestrator = null; try { // Render header only if not invoked from menu and not in JSON mode if (!process.env.__SRTD_FROM_MENU__ && !jsonMode) { renderBranding({ subtitle: 'Watch' }); } // Skip spinner in JSON mode const spinner = jsonMode ? null : createSpinner('').start(); // Initialize Orchestrator const projectRoot = await findProjectRoot(); const { config, warnings: configWarnings } = await getConfig(projectRoot); orchestrator = await Orchestrator.create(projectRoot, config, { silent: true, configWarnings, }); // Display validation warnings (skip in JSON mode) if (!jsonMode) { displayValidationWarnings(orchestrator.getValidationWarnings()); } // Load historic activity for initial display const historicActivity = orchestrator.getRecentActivity(MAX_HISTORY); // Load initial templates const templatePaths = await orchestrator.findTemplates(); const templates = []; const errors = new Map(); for (const templatePath of templatePaths) { try { const template = await orchestrator.getTemplateStatusExternal(templatePath); templates.push(template); } catch (error) { errors.set(templatePath, getErrorMessage(error)); } } // Complete spinner if not in JSON mode if (spinner) { spinner.succeed(`Watching ${templates.length} template(s)`); } // State for history tracking - now uses unified TemplateResult const recentUpdates = []; let showHistory = true; // Track templates that need building with reason const needsBuild = new Map(); // Populate needsBuild from initial template state for (const template of templates) { const reason = getBuildReason(template); if (reason) needsBuild.set(template.path, reason); } // In JSON mode, emit init event instead of rendering if (jsonMode) { ndjsonEvent('init', { templates: templates.map(t => t.path), needsBuild: Array.from(needsBuild.entries()).map(([template, reason]) => ({ template, reason, })), errors: Array.from(errors.entries()).map(([template, message]) => ({ template, message, })), }); } else { // Initial render renderScreen({ templates, recentUpdates, historicActivity, errors, showHistory, needsBuild, }); } // Helper to call renderScreen with all current state (no-op in JSON mode) const doRender = () => { if (jsonMode) return; renderScreen({ templates, recentUpdates, historicActivity, errors, showHistory, needsBuild, }); }; // Set up orchestrator event listeners orchestrator.on('templateChanged', (template) => { // Check if this change invalidates a previous build const hadBuild = !!template.buildState.lastBuildHash; const result = { template: template.path, status: 'changed', timestamp: new Date(), buildOutdated: hadBuild, }; recentUpdates.push(result); if (recentUpdates.length > MAX_HISTORY) recentUpdates.shift(); // Update needsBuild tracking const reason = getBuildReason(template); if (reason) needsBuild.set(template.path, reason); if (jsonMode) { ndjsonEvent('templateChanged', result); } else { doRender(); } }); orchestrator.on('templateApplied', (template) => { const result = { template: template.path, status: 'success', timestamp: new Date(), }; recentUpdates.push(result); if (recentUpdates.length > MAX_HISTORY) recentUpdates.shift(); // Track as needing build if not already built with current hash const reason = getBuildReason(template); if (reason) needsBuild.set(template.path, reason); // Clear any previous error for this template errors.delete(template.path); if (jsonMode) { ndjsonEvent('templateApplied', result); } else { doRender(); } }); orchestrator.on('templateError', ({ template, error, hint }) => { const result = { template: template.path, status: 'error', timestamp: new Date(), errorMessage: error, errorHint: hint, }; recentUpdates.push(result); if (recentUpdates.length > MAX_HISTORY) recentUpdates.shift(); errors.set(template.path, error); if (jsonMode) { ndjsonEvent('templateError', result); } else { doRender(); } }); // Start watching const watcher = await orchestrator.watch({ silent: false, initialProcess: true, }); // Cleanup function to properly dispose const cleanup = async () => { if (!jsonMode) { console.log(); console.log(chalk.dim('Stopping watch mode...')); } await watcher.close(); if (orchestrator) { await orchestrator.dispose(); } process.exit(exitCode); }; /** * Extract template name from migration filename. * Format: {timestamp}_{prefix}-{templateName}.sql * e.g., 20241228_srtd-audit.sql -> audit.sql */ const extractTemplateName = (migrationFile) => { const match = migrationFile.match(/_[^-]+-(.+)\.sql$/); return match ? `${match[1]}.sql` : migrationFile; }; /** * Refresh needsBuild map from current template state. * Clears the map and repopulates based on fresh template hashes. */ const refreshNeedsBuild = async (orch, templatePaths) => { needsBuild.clear(); for (const templatePath of templatePaths) { try { const template = await orch.getTemplateStatusExternal(templatePath); const reason = getBuildReason(template); if (reason) needsBuild.set(template.path, reason); } catch { // Template may have been deleted, skip } } }; // Build action handler for 'b' key const triggerBuild = async () => { if (!orchestrator) return; // Refresh template state before building const templatePaths = await orchestrator.findTemplates(); await refreshNeedsBuild(orchestrator, templatePaths); // Check if anything needs building if (needsBuild.size === 0) { // Show "all up to date" feedback in activity log recentUpdates.push({ template: 'all templates', status: 'unchanged', timestamp: new Date(), displayOverride: 'all up to date', }); if (recentUpdates.length > MAX_HISTORY) recentUpdates.shift(); doRender(); return; } try { const result = await orchestrator.build({ silent: true }); // Add build results to recent updates with 'built' status for (const migrationFile of result.built) { const templateName = extractTemplateName(migrationFile); recentUpdates.push({ template: templateName, status: 'built', target: migrationFile, timestamp: new Date(), }); } if (recentUpdates.length > MAX_HISTORY) { recentUpdates.splice(0, recentUpdates.length - MAX_HISTORY); } // Show any build errors for (const err of result.errors) { errors.set(err.file, err.error); } // Refresh needsBuild after building await refreshNeedsBuild(orchestrator, templatePaths); } catch (error) { console.log(chalk.red(`Build failed: ${getErrorMessage(error)}`)); } doRender(); }; // Set up keyboard input handling for quit, toggle, and build (skip in JSON mode) if (process.stdin.isTTY && !jsonMode) { // Reset stdin state in case it was modified by previous prompts process.stdin.setRawMode(false); process.stdin.removeAllListeners('keypress'); process.stdin.pause(); // Now set up fresh keypress handling readline.emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.on('keypress', (_str, key) => { if (key && (key.name === 'q' || (key.ctrl && key.name === 'c'))) { process.stdin.setRawMode(false); void cleanup(); } if (key && key.name === 'u') { showHistory = !showHistory; doRender(); } if (key && key.name === 'b') { void triggerBuild(); } }); } // Handle SIGINT process.on('SIGINT', () => { void cleanup(); }); // Keep the process alive await new Promise(() => { // This promise never resolves, keeping the process alive // The process will be terminated by the keyboard handler or SIGINT }); } catch (error) { if (jsonMode) { ndjsonEvent('error', { message: getErrorMessage(error) }); } else { console.log(); console.log(chalk.red(`${figures.cross} Error starting watch mode:`)); console.log(chalk.red(getErrorMessage(error))); } if (orchestrator) { await orchestrator.dispose(); } process.exit(1); } }); //# sourceMappingURL=watch.js.map