@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
JavaScript
// 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