UNPKG

@t1mmen/srtd

Version:

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

381 lines • 15.8 kB
import EventEmitter from 'node:events'; import fs from 'node:fs/promises'; import path from 'node:path'; import { glob } from 'glob'; import { applyMigration } from '../utils/applyMigration.js'; import { calculateMD5 } from '../utils/calculateMD5.js'; import { getConfig } from '../utils/config.js'; import { getNextTimestamp } from '../utils/getNextTimestamp.js'; import { isWipTemplate } from '../utils/isWipTemplate.js'; import { loadBuildLog } from '../utils/loadBuildLog.js'; import { logger } from '../utils/logger.js'; import { saveBuildLog } from '../utils/saveBuildLog.js'; if (!global.__srtd_watchers) { global.__srtd_watchers = []; } export class TemplateManager extends EventEmitter { watcher = null; baseDir; buildLog; localBuildLog; config; templateCache = new Map(); cacheTimeout = 1000; silent; processQueue = new Set(); processingTemplate = null; processing = false; // Constructor: constructor(baseDir, buildLog, localBuildLog, config, options = {}) { super(); this.silent = options.silent ?? false; this.baseDir = baseDir; this.buildLog = buildLog; this.localBuildLog = localBuildLog; this.config = config; } [Symbol.dispose]() { if (this.watcher) { void this.watcher.close(); this.watcher = null; } this.removeAllListeners(); } static async create(baseDir, options = {}) { const config = await getConfig(baseDir); const buildLog = await loadBuildLog(baseDir, 'common'); const localBuildLog = await loadBuildLog(baseDir, 'local'); return new TemplateManager(baseDir, buildLog, localBuildLog, config, options); } isCacheValid(cache) { return Date.now() - cache.lastChecked < this.cacheTimeout; } invalidateCache(templatePath) { this.templateCache.delete(templatePath); } async findTemplates() { const templatePath = path.join(this.baseDir, this.config.templateDir, this.config.filter); const matches = await glob(templatePath); return matches; } async getTemplateStatus(templatePath) { const cached = this.templateCache.get(templatePath); if (cached && this.isCacheValid(cached)) { return cached.status; } const content = await fs.readFile(templatePath, 'utf-8'); const currentHash = await calculateMD5(content); const relPath = path.relative(this.baseDir, templatePath); // Merge build and apply states const buildState = { ...this.buildLog.templates[relPath], ...this.localBuildLog.templates[relPath], }; const status = { name: path.basename(templatePath, '.sql'), path: templatePath, currentHash, migrationHash: null, buildState, wip: await isWipTemplate(templatePath), }; this.templateCache.set(templatePath, { status, lastChecked: Date.now(), }); return status; } async saveBuildLogs() { try { await Promise.all([ saveBuildLog(this.baseDir, this.buildLog, 'common'), saveBuildLog(this.baseDir, this.localBuildLog, 'local'), ]); } catch (error) { throw new Error(`Failed to save build logs: ${error}`); } } async handleTemplateChange(templatePath) { // Add to queue if not already there if (!this.processQueue.has(templatePath) && this.processingTemplate !== templatePath) { this.processQueue.add(templatePath); } if (!this.processing) { this.processing = true; await this.processNextTemplate(); } } async processTemplate(templatePath, force = false) { try { this.invalidateCache(templatePath); const template = await this.getTemplateStatus(templatePath); const relPath = path.relative(this.baseDir, templatePath); const needsProcessing = force || !this.localBuildLog.templates[relPath]?.lastAppliedHash || this.localBuildLog.templates[relPath]?.lastAppliedHash !== template.currentHash; if (needsProcessing) { this.emit('templateChanged', template); const result = await this.applyTemplate(templatePath); if (result.errors.length) { const error = result.errors[0]; const formattedError = typeof error === 'string' ? error : error?.error; this.emit('templateError', { template, error: formattedError }); } else { const updatedTemplate = await this.getTemplateStatus(templatePath); this.emit('templateApplied', updatedTemplate); } return result; } return { errors: [], applied: [], skipped: [template.name], built: [] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.log(`Error processing template ${templatePath}: ${errorMessage}`, 'error'); this.emit('templateError', { template: await this.getTemplateStatus(templatePath), error: errorMessage, }); const templateName = path.basename(templatePath, '.sql'); return { errors: [ { file: templatePath, error: errorMessage, templateName, }, ], applied: [], skipped: [], built: [], }; } } async processNextTemplate() { if (this.processQueue.size === 0) { this.processing = false; return; } const templatePath = this.processQueue.values().next().value; if (!templatePath) { this.processing = false; return; } this.processQueue.delete(templatePath); this.processingTemplate = templatePath; try { await this.processTemplate(templatePath); } finally { this.processingTemplate = null; await this.processNextTemplate(); } } async watch() { const chokidar = await import('chokidar'); const templatePath = path.join(this.baseDir, this.config.templateDir); const watcher = chokidar.watch(templatePath, { ignoreInitial: false, ignored: ['**/!(*.sql)'], persistent: true, awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 50, }, }); // Track watcher globally global.__srtd_watchers.push(watcher); // Do initial scan once const existingFiles = await glob(path.join(templatePath, this.config.filter)); for (const file of existingFiles) { await this.handleTemplateChange(file); } // Only handle future changes const handleEvent = async (filepath) => { if (path.extname(filepath) === '.sql') { await this.handleTemplateChange(filepath); } }; watcher .on('add', handleEvent) .on('change', handleEvent) .on('error', error => { this.log(`Watcher error: ${error}`, 'error'); }); // Update cleanup this.watcher = watcher; return { close: async () => { await watcher.close(); const idx = global.__srtd_watchers.indexOf(watcher); if (idx > -1) global.__srtd_watchers.splice(idx, 1); }, }; } async applyTemplate(templatePath) { const template = await this.getTemplateStatus(templatePath); const content = await fs.readFile(templatePath, 'utf-8'); const relPath = path.relative(this.baseDir, templatePath); try { const result = await applyMigration(content, template.name, this.silent); if (result === true) { // Always calculate fresh hash after successful apply const currentHash = await calculateMD5(content); if (!this.localBuildLog.templates[relPath]) { this.localBuildLog.templates[relPath] = {}; } this.localBuildLog.templates[relPath] = { ...this.localBuildLog.templates[relPath], lastAppliedHash: currentHash, lastAppliedDate: new Date().toISOString(), lastAppliedError: undefined, }; await this.saveBuildLogs(); this.invalidateCache(templatePath); return { errors: [], applied: [template.name], skipped: [], built: [] }; } // On error, don't update hash but track the error this.localBuildLog.templates[relPath] = { ...this.localBuildLog.templates[relPath], lastAppliedError: result.error, }; await this.saveBuildLogs(); return { errors: [result], applied: [], skipped: [], built: [] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (!this.localBuildLog.templates) { this.localBuildLog = { templates: {}, version: '1.0', lastTimestamp: '' }; } await this.saveBuildLogs(); throw new Error(errorMessage); } } async buildTemplate(templatePath, force = false) { const template = await this.getTemplateStatus(templatePath); const isWip = await isWipTemplate(templatePath); const relPath = path.relative(this.baseDir, templatePath); if (isWip) { this.log(`Skipping WIP template: ${template.name}`, 'skip'); return; } const content = await fs.readFile(templatePath, 'utf-8'); const currentHash = await calculateMD5(content); if (!force && this.buildLog.templates[relPath]?.lastBuildHash === currentHash) { this.log(`Skipping unchanged template: ${template.name}`, 'skip'); return; } const timestamp = await getNextTimestamp(this.buildLog); const prefix = this.config.migrationPrefix ? `${this.config.migrationPrefix}-` : ''; const migrationName = `${timestamp}_${prefix}${template.name}.sql`; const migrationPath = path.join(this.config.migrationDir, migrationName); const header = `-- Generated with srtd from template: ${this.config.templateDir}/${template.name}.sql\n`; const banner = this.config.banner ? `-- ${this.config.banner}\n` : '\n'; const lastBuildAt = this.buildLog.templates[relPath]?.lastMigrationFile; const footer = `${this.config.footer}\n-- Last built: ${lastBuildAt || 'Never'}\n-- Built with https://github.com/t1mmen/srtd\n`; const safeContent = this.config.wrapInTransaction ? `BEGIN;\n\n${content}\n\nCOMMIT;` : content; const migrationContent = `${header}${banner}\n${safeContent}\n${footer}`; try { await fs.writeFile(path.resolve(this.baseDir, migrationPath), migrationContent); this.buildLog.templates[relPath] = { ...this.buildLog.templates[relPath], lastBuildHash: currentHash, lastBuildDate: new Date().toISOString(), lastMigrationFile: migrationName, lastBuildError: undefined, }; this.invalidateCache(templatePath); await this.saveBuildLogs(); this.emit('templateBuilt', template); } catch (error) { this.buildLog.templates[relPath] = { ...this.buildLog.templates[relPath], lastBuildError: error instanceof Error ? error.message : String(error), }; await this.saveBuildLogs(); this.emit('templateError', { template, error }); } } log(msg, logLevel = 'info') { if (this.silent) return; logger[logLevel](msg); } async processTemplates(options) { const templates = await this.findTemplates(); const result = { errors: [], applied: [], built: [], skipped: [] }; this.log('\n'); if (options.apply) { const action = options.force ? 'Force applying' : 'Applying'; this.log(`${action} changed templates to local database...`, 'success'); // Process all templates for (const templatePath of templates) { try { const processResult = await this.processTemplate(templatePath, options.force); if (processResult) { result.errors.push(...(processResult.errors || [])); result.applied.push(...(processResult.applied || [])); result.skipped.push(...(processResult.skipped || [])); } else { throw new Error('No result from processing template'); } } catch (error) { result.errors.push({ file: templatePath, templateName: templatePath, error: error instanceof Error ? error.message : 'Unknown error', }); } } if (result.applied.length === 0 && result.errors.length === 0) { this.log('No changes to apply', 'skip'); } else if (result.errors.length > 0) { this.log(`${result.errors.length} template(s) failed to apply`, 'error'); for (const err of result.errors) { this.log(`${err.file}: ${err.error}`, 'error'); } } else { this.log(`Applied ${result.applied.length} template(s)`, 'success'); } } if (options.generateFiles) { let built = 0; let skipped = 0; this.log('Building migration files from templates...', 'success'); for (const templatePath of templates) { const isWip = await isWipTemplate(templatePath); if (!isWip) { const template = await this.getTemplateStatus(templatePath); if (options.force || template.currentHash !== template.buildState.lastBuildHash) { await this.buildTemplate(templatePath, options.force); result.built.push(template.name); built++; } else { result.skipped.push(template.name); skipped++; } } else { result.skipped.push(path.basename(templatePath, '.sql')); skipped++; } } if (built > 0) { this.log(`Generated ${built} migration file(s)`, 'success'); } else if (skipped > 0) { this.log('No new changes to build', 'skip'); } } return result; } } //# sourceMappingURL=templateManager.js.map