@t1mmen/srtd
Version:
Supabase Repeatable Template Definitions (srtd): 🪄 Live-reloading SQL templates for Supabase DX. Make your database changes reviewable and migrations maintainable! 🚀
236 lines • 9.36 kB
JavaScript
/**
* MigrationBuilder Service - Generates Supabase migration files from templates
* Pure service that takes template content and metadata and produces formatted migration files
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { getNextTimestamp } from '../utils/getNextTimestamp.js';
import { interpolateMigrationFilename } from '../utils/interpolateMigrationFilename.js';
export class MigrationBuilder {
config;
constructor(config) {
this.config = {
migrationPrefix: '',
migrationFilename: '$timestamp_$prefix$migrationName.sql',
banner: '',
footer: '',
wrapInTransaction: true,
...config,
};
}
/**
* Generate a migration file from a single template.
* Note: Caller is responsible for updating the build log with newLastTimestamp.
*/
generateMigration(template, buildLog, options = {}) {
const { timestamp, newLastTimestamp } = getNextTimestamp(buildLog.lastTimestamp);
const fileName = interpolateMigrationFilename({
template: this.config.migrationFilename,
timestamp,
migrationName: template.name,
prefix: this.config.migrationPrefix,
});
const filePath = path.join(this.config.migrationDir, fileName);
// Validate path stays within migration directory (prevent path traversal)
this.validateMigrationPath(filePath);
const content = this.formatMigrationContent(template, {
isBundled: false,
...options,
});
return {
fileName,
filePath,
content,
timestamp,
newLastTimestamp,
};
}
/**
* Generate a bundled migration file from multiple templates.
* Note: Caller is responsible for updating the build log with newLastTimestamp.
*/
generateBundledMigration(templates, buildLog, options = {}) {
const { timestamp, newLastTimestamp } = getNextTimestamp(buildLog.lastTimestamp);
const fileName = interpolateMigrationFilename({
template: this.config.migrationFilename,
timestamp,
migrationName: 'bundle',
prefix: this.config.migrationPrefix,
});
const filePath = path.join(this.config.migrationDir, fileName);
// Validate path stays within migration directory (prevent path traversal)
this.validateMigrationPath(filePath);
let content = '';
const includedTemplates = [];
for (const template of templates) {
const templateContent = this.formatMigrationContent(template, {
isBundled: true,
...options,
});
content += `${templateContent}\n\n`;
includedTemplates.push(template.name);
}
return {
fileName,
filePath,
content: content.trim(),
timestamp,
newLastTimestamp,
includedTemplates,
};
}
/**
* Format migration content with headers, footers, and transaction wrapping
*/
formatMigrationContent(template, options = {}) {
const { isBundled = false, wrapInTransaction = this.config.wrapInTransaction } = options;
// Generate header
const headerPrefix = isBundled ? 'Template' : 'Generated with srtd from template';
const header = `-- ${headerPrefix}: ${this.config.templateDir}/${template.name}.sql\n`;
// Generate banner
const banner = this.config.banner ? `-- ${this.config.banner}\n` : '\n';
// Generate footer
const lastBuildInfo = template.lastBuildAt || 'Never';
const footerText = this.config.footer ? `${this.config.footer}\n` : '';
const footer = `${footerText}-- Last built: ${lastBuildInfo}\n-- Built with https://github.com/t1mmen/srtd\n`;
// Wrap content in transaction if needed
const safeContent = wrapInTransaction
? `BEGIN;\n\n${template.content}\n\nCOMMIT;`
: template.content;
// Assemble final content
return `${header}${banner}\n${safeContent}\n${footer}`;
}
/**
* Create MigrationBuilder from CLI config
*/
static fromConfig(config, baseDir) {
// Only include defined properties to allow constructor defaults to apply
return new MigrationBuilder({
baseDir,
templateDir: config.templateDir,
migrationDir: config.migrationDir,
...(config.migrationPrefix !== undefined && { migrationPrefix: config.migrationPrefix }),
...(config.migrationFilename !== undefined && {
migrationFilename: config.migrationFilename,
}),
...(config.banner !== undefined && { banner: config.banner }),
...(config.footer !== undefined && { footer: config.footer }),
...(config.wrapInTransaction !== undefined && {
wrapInTransaction: config.wrapInTransaction,
}),
});
}
/**
* Generate migration file path for a template
*/
getMigrationPath(templateName, timestamp) {
const fileName = interpolateMigrationFilename({
template: this.config.migrationFilename,
timestamp,
migrationName: templateName,
prefix: this.config.migrationPrefix,
});
return path.join(this.config.migrationDir, fileName);
}
/**
* Generate absolute migration file path for a template
*/
getAbsoluteMigrationPath(templateName, timestamp) {
const migrationPath = this.getMigrationPath(templateName, timestamp);
return path.resolve(this.config.baseDir, migrationPath);
}
/**
* Validate migration configuration
*/
validateConfig() {
const errors = [];
if (!this.config.baseDir) {
errors.push('baseDir is required');
}
if (!this.config.templateDir) {
errors.push('templateDir is required');
}
if (!this.config.migrationDir) {
errors.push('migrationDir is required');
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Validate that a migration path stays within the migration directory
* Prevents path traversal attacks via malicious template patterns
*/
validateMigrationPath(filePath) {
const resolvedPath = path.resolve(this.config.baseDir, filePath);
const resolvedMigrationDir = path.resolve(this.config.baseDir, this.config.migrationDir);
// Use path.relative() for robust cross-platform path traversal detection
const relativePath = path.relative(resolvedMigrationDir, resolvedPath);
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
throw new Error(`Invalid migration path: "${filePath}" would write outside migration directory`);
}
}
/**
* Write migration file to disk
*/
async writeMigration(migrationResult) {
const fullPath = path.resolve(this.config.baseDir, migrationResult.filePath);
const directory = path.dirname(fullPath);
// Ensure migration directory exists
await fs.mkdir(directory, { recursive: true });
// Write migration file
await fs.writeFile(fullPath, migrationResult.content, 'utf-8');
return fullPath;
}
/**
* Write bundled migration file to disk
*/
async writeBundledMigration(migrationResult) {
const fullPath = path.resolve(this.config.baseDir, migrationResult.filePath);
const directory = path.dirname(fullPath);
// Ensure migration directory exists
await fs.mkdir(directory, { recursive: true });
// Write bundled migration file
await fs.writeFile(fullPath, migrationResult.content, 'utf-8');
return fullPath;
}
/**
* Generate and write migration file in one operation.
* Note: Caller is responsible for updating the build log with result.newLastTimestamp.
*/
async generateAndWriteMigration(template, buildLog, options = {}) {
const result = this.generateMigration(template, buildLog, options);
const filePath = await this.writeMigration(result);
return { result, filePath };
}
/**
* Generate and write bundled migration file in one operation.
* Note: Caller is responsible for updating the build log with result.newLastTimestamp.
*/
async generateAndWriteBundledMigration(templates, buildLog, options = {}) {
const result = this.generateBundledMigration(templates, buildLog, options);
const filePath = await this.writeBundledMigration(result);
return { result, filePath };
}
/**
* Check if migration file already exists
*/
async migrationExists(fileName) {
const fullPath = path.resolve(this.config.baseDir, this.config.migrationDir, fileName);
try {
await fs.access(fullPath);
return true;
}
catch {
return false;
}
}
/**
* Get current configuration
*/
getConfig() {
return { ...this.config };
}
}
//# sourceMappingURL=MigrationBuilder.js.map