@t1mmen/srtd
Version:
Supabase Repeatable Template Definitions (srtd): 🪄 Live-reloading SQL templates for Supabase DX. Make your database changes reviewable and migrations maintainable! 🚀
235 lines • 7.54 kB
JavaScript
/**
* FileSystemService - Handles all file system operations for templates
* Decoupled from business logic, only handles raw file operations
*/
import crypto from 'node:crypto';
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import path from 'node:path';
import { glob } from 'glob';
import { fileExists as fileExistsUtil } from '../utils/fileExists.js';
export class FileSystemService extends EventEmitter {
config;
watcher = null;
debouncedHandlers = new Map();
constructor(config) {
super();
this.config = config;
}
/**
* Find all template files matching the configured pattern
*/
async findTemplates() {
const templatePath = path.join(this.config.baseDir, this.config.templateDir, this.config.filter);
const matches = await glob(templatePath);
return matches.sort(); // Ensure consistent ordering
}
/**
* Read a template file and return its content with metadata
*/
async readTemplate(templatePath) {
try {
const content = await fs.readFile(templatePath, 'utf-8');
const hash = this.calculateHash(content);
const name = path.basename(templatePath, '.sql');
const relativePath = path.relative(this.config.baseDir, templatePath);
return {
path: templatePath,
name,
content,
hash,
relativePath,
};
}
catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
throw new Error(`Template file not found: ${templatePath}`);
}
throw error;
}
}
/**
* Check if a file exists
*/
async fileExists(filePath) {
return fileExistsUtil(filePath);
}
/**
* Write content to a file
*/
async writeFile(filePath, content) {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
}
/**
* Delete a file
*/
async deleteFile(filePath) {
try {
await fs.unlink(filePath);
}
catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
// File doesn't exist, ignore
return;
}
throw error;
}
}
/**
* Rename a file
*/
async renameFile(oldPath, newPath) {
await fs.rename(oldPath, newPath);
}
/**
* Get file stats
*/
async getFileStats(filePath) {
return fs.stat(filePath);
}
/**
* Calculate MD5 hash of content
* Normalizes line endings to LF for cross-platform consistency
* (Fix contributed by @louisandred - https://github.com/t1mmen/srtd/pull/42)
*/
calculateHash(content) {
const normalized = content.replace(/\r\n/g, '\n');
return crypto.createHash('md5').update(normalized).digest('hex');
}
/**
* Watch templates for changes
*/
async watchTemplates() {
if (this.watcher) {
throw new Error('Already watching templates');
}
const chokidar = await import('chokidar');
const templatePath = path.join(this.config.baseDir, this.config.templateDir);
const watchOptions = {
ignoreInitial: this.config.watchOptions?.ignoreInitial ?? false,
ignored: ['**/!(*.sql)'],
persistent: true,
awaitWriteFinish: {
stabilityThreshold: this.config.watchOptions?.stabilityThreshold ?? 200,
pollInterval: this.config.watchOptions?.pollInterval ?? 100,
},
};
this.watcher = chokidar.watch(templatePath, watchOptions);
// Handle initial files if not ignoring
if (!watchOptions.ignoreInitial) {
const existingFiles = await this.findTemplates();
for (const file of existingFiles) {
this.emitWatchEvent('added', file);
}
}
// Set up event handlers with debouncing
this.watcher
.on('add', (filepath) => {
if (path.extname(filepath) === '.sql') {
this.debouncedEmit('added', filepath);
}
})
.on('change', (filepath) => {
if (path.extname(filepath) === '.sql') {
this.debouncedEmit('changed', filepath);
}
})
.on('unlink', (filepath) => {
if (path.extname(filepath) === '.sql') {
this.debouncedEmit('removed', filepath);
}
})
.on('error', (error) => {
this.emit('error', error instanceof Error ? error : new Error(String(error)));
});
}
/**
* Stop watching templates
*/
async stopWatching() {
// Clear any pending debounced handlers
for (const timer of this.debouncedHandlers.values()) {
clearTimeout(timer);
}
this.debouncedHandlers.clear();
if (this.watcher) {
await this.watcher.close();
this.watcher = null;
}
}
/**
* Emit watch event with debouncing
*/
debouncedEmit(type, filepath) {
const key = `${type}:${filepath}`;
// Clear existing timer for this event
const existingTimer = this.debouncedHandlers.get(key);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Set new timer
const timer = setTimeout(() => {
this.emitWatchEvent(type, filepath);
this.debouncedHandlers.delete(key);
}, 100);
this.debouncedHandlers.set(key, timer);
}
/**
* Emit a watch event
*/
emitWatchEvent(type, filepath) {
const relativePath = path.relative(this.config.baseDir, filepath);
const name = path.basename(filepath, '.sql');
const event = {
type,
path: filepath,
relativePath,
name,
};
// Emit specific event types
switch (type) {
case 'added':
this.emit('template:added', event);
break;
case 'changed':
this.emit('template:changed', event);
break;
case 'removed':
this.emit('template:removed', event);
break;
}
// Also emit generic event
this.emit('template:event', event);
}
/**
* Clean up resources
*/
async dispose() {
await this.stopWatching();
this.removeAllListeners();
}
/**
* Get migration file path for a template
*/
getMigrationPath(templateName, timestamp) {
const migrationName = `${timestamp}_${templateName}.sql`;
return path.join(this.config.baseDir, this.config.migrationDir, migrationName);
}
/**
* List all migration files
*/
async listMigrations() {
const migrationPath = path.join(this.config.baseDir, this.config.migrationDir, '*.sql');
const matches = await glob(migrationPath);
return matches.sort(); // Ensure chronological order
}
/**
* Read a migration file
*/
async readMigration(migrationPath) {
return fs.readFile(migrationPath, 'utf-8');
}
}
//# sourceMappingURL=FileSystemService.js.map