@t1mmen/srtd
Version:
Supabase Repeatable Template Definitions (srtd): 🪄 Live-reloading SQL templates for Supabase DX. Make your database changes reviewable and migrations maintainable! 🚀
604 lines • 22.4 kB
JavaScript
/**
* StateService - Centralized state management for template status
* Single source of truth for all template states and transitions
*/
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import path from 'node:path';
import { validateBuildLog } from '../utils/schemas.js';
/**
* Template states in the state machine
*/
export var TemplateState;
(function (TemplateState) {
TemplateState["UNSEEN"] = "unseen";
TemplateState["SYNCED"] = "synced";
TemplateState["CHANGED"] = "changed";
TemplateState["APPLIED"] = "applied";
TemplateState["BUILT"] = "built";
TemplateState["ERROR"] = "error";
})(TemplateState || (TemplateState = {}));
/**
* Valid state transitions matrix
*/
const VALID_TRANSITIONS = {
[TemplateState.UNSEEN]: [
TemplateState.UNSEEN, // Allow self-transition for updates
TemplateState.SYNCED,
TemplateState.CHANGED,
TemplateState.APPLIED, // Allow direct apply for new templates
TemplateState.BUILT, // Allow direct build for new templates
TemplateState.ERROR,
],
[TemplateState.SYNCED]: [
TemplateState.SYNCED, // Allow self-transition for updates
TemplateState.CHANGED,
TemplateState.APPLIED,
TemplateState.BUILT,
TemplateState.ERROR,
],
[TemplateState.CHANGED]: [
TemplateState.CHANGED, // Allow self-transition for updates
TemplateState.SYNCED,
TemplateState.APPLIED,
TemplateState.BUILT,
TemplateState.ERROR,
],
[TemplateState.APPLIED]: [
TemplateState.APPLIED, // Allow self-transition for force apply
TemplateState.SYNCED,
TemplateState.CHANGED,
TemplateState.BUILT,
TemplateState.ERROR,
],
[TemplateState.BUILT]: [
TemplateState.BUILT, // Allow self-transition for force rebuild
TemplateState.SYNCED,
TemplateState.CHANGED,
TemplateState.APPLIED,
TemplateState.ERROR,
],
[TemplateState.ERROR]: [
TemplateState.ERROR, // Allow self-transition for updating error details
TemplateState.UNSEEN,
TemplateState.SYNCED,
TemplateState.CHANGED,
TemplateState.APPLIED,
TemplateState.BUILT,
],
};
export class StateService extends EventEmitter {
config;
templateStates = new Map();
buildLog;
localBuildLog;
saveTimer = null;
validationWarnings = [];
constructor(config) {
super();
this.config = config;
this.buildLog = this.createEmptyBuildLog();
this.localBuildLog = this.createEmptyBuildLog();
}
/**
* Initialize the service by loading existing build logs
*/
async initialize() {
await this.loadBuildLogs();
this.syncStatesToMemory();
}
/**
* Create an empty build log
*/
createEmptyBuildLog() {
return {
version: '1.0',
lastTimestamp: '',
templates: {},
};
}
/**
* Load build logs from disk
*/
async loadBuildLogs() {
const buildLogPath = this.config.buildLogPath || path.join(this.config.baseDir, '.buildlog.json');
const localBuildLogPath = this.config.localBuildLogPath || path.join(this.config.baseDir, '.buildlog.local.json');
// Reset validation warnings
this.validationWarnings = [];
// Load shared build log
try {
const content = await fs.readFile(buildLogPath, 'utf-8');
const result = validateBuildLog(content);
if (result.success && result.data) {
this.buildLog = result.data;
}
else {
const warning = {
source: 'buildLog',
type: result.errorType ?? 'validation',
message: result.error ?? 'Validation failed',
path: buildLogPath,
};
this.validationWarnings.push(warning);
this.emit('validation:warning', warning);
// Keep empty build log (current behavior)
}
}
catch (error) {
if (error.code !== 'ENOENT') {
this.emit('error', new Error(`Failed to load build log: ${error}`));
}
}
// Load local build log
try {
const content = await fs.readFile(localBuildLogPath, 'utf-8');
const result = validateBuildLog(content);
if (result.success && result.data) {
this.localBuildLog = result.data;
}
else {
const warning = {
source: 'localBuildLog',
type: result.errorType ?? 'validation',
message: result.error ?? 'Validation failed',
path: localBuildLogPath,
};
this.validationWarnings.push(warning);
this.emit('validation:warning', warning);
// Keep empty local build log (current behavior)
}
}
catch (error) {
if (error.code !== 'ENOENT') {
this.emit('error', new Error(`Failed to load local build log: ${error}`));
}
}
}
/**
* Save build logs to disk
*/
async saveBuildLogs() {
const buildLogPath = this.config.buildLogPath || path.join(this.config.baseDir, '.buildlog.json');
const localBuildLogPath = this.config.localBuildLogPath || path.join(this.config.baseDir, '.buildlog.local.json');
try {
await fs.writeFile(buildLogPath, JSON.stringify(this.buildLog, null, 2), 'utf-8');
await fs.writeFile(localBuildLogPath, JSON.stringify(this.localBuildLog, null, 2), 'utf-8');
}
catch (error) {
this.emit('error', new Error(`Failed to save build logs: ${error}`));
throw error;
}
}
/**
* Schedule auto-save if enabled
*/
scheduleAutoSave() {
if (!this.config.autoSave)
return;
if (this.saveTimer) {
clearTimeout(this.saveTimer);
}
this.saveTimer = setTimeout(() => {
// Catch errors to prevent unhandled promise rejection
// saveBuildLogs() already emits 'error' event, so we just need to handle the rejection
this.saveBuildLogs().catch(() => {
// Error already emitted in saveBuildLogs - just prevent unhandled rejection
});
}, 1000); // Save after 1 second of inactivity
}
/**
* Sync build log states to in-memory map
*/
syncStatesToMemory() {
// Merge templates from both build logs
const allTemplates = new Set([
...Object.keys(this.buildLog.templates),
...Object.keys(this.localBuildLog.templates),
]);
for (const templatePath of allTemplates) {
const remoteMeta = this.buildLog.templates[templatePath];
const localMeta = this.localBuildLog.templates[templatePath];
// Merge metadata with local taking precedence
const metadata = { ...remoteMeta, ...localMeta };
// Determine state based on metadata
const state = this.determineStateFromMetadata(metadata);
// Convert relative path to absolute path for storage key
const absolutePath = path.resolve(this.config.baseDir, templatePath);
this.templateStates.set(absolutePath, {
state,
templatePath: absolutePath,
lastAppliedHash: metadata.lastAppliedHash,
lastBuiltHash: metadata.lastBuildHash,
lastAppliedDate: metadata.lastAppliedDate,
lastBuiltDate: metadata.lastBuildDate,
lastError: metadata.lastAppliedError || metadata.lastBuildError,
metadata,
});
}
}
/**
* Determine template state from metadata
*/
determineStateFromMetadata(metadata) {
if (metadata.lastAppliedError || metadata.lastBuildError) {
return TemplateState.ERROR;
}
if (metadata.lastBuildHash) {
return TemplateState.BUILT;
}
if (metadata.lastAppliedHash) {
return TemplateState.APPLIED;
}
return TemplateState.UNSEEN;
}
/**
* Validate if a state transition is allowed
*/
validateTransition(fromState, toState) {
const allowedTransitions = VALID_TRANSITIONS[fromState];
return allowedTransitions.includes(toState);
}
/**
* Perform a state transition
*/
async transitionState(templatePath, toState, updates) {
const currentInfo = this.templateStates.get(templatePath) || {
state: TemplateState.UNSEEN,
templatePath,
};
if (!this.validateTransition(currentInfo.state, toState)) {
throw new Error(`Invalid state transition for ${templatePath}: ${currentInfo.state} -> ${toState}`);
}
const fromState = currentInfo.state;
// Update in-memory state
const newInfo = {
...currentInfo,
...updates,
state: toState,
templatePath,
};
this.templateStates.set(templatePath, newInfo);
// Update build logs
const relPath = path.relative(this.config.baseDir, templatePath);
if (toState === TemplateState.APPLIED || updates?.lastAppliedHash) {
if (!this.localBuildLog.templates[relPath]) {
this.localBuildLog.templates[relPath] = {};
}
this.localBuildLog.templates[relPath] = {
...this.localBuildLog.templates[relPath],
lastAppliedHash: updates?.lastAppliedHash || newInfo.lastAppliedHash,
lastAppliedDate: updates?.lastAppliedDate || new Date().toISOString(),
lastAppliedError: updates?.lastError,
};
}
if (toState === TemplateState.BUILT || updates?.lastBuiltHash) {
if (!this.buildLog.templates[relPath]) {
this.buildLog.templates[relPath] = {};
}
this.buildLog.templates[relPath] = {
...this.buildLog.templates[relPath],
lastBuildHash: updates?.lastBuiltHash || newInfo.lastBuiltHash,
lastBuildDate: updates?.lastBuiltDate || new Date().toISOString(),
lastBuildError: updates?.lastError,
};
}
// Emit transition event
const event = {
templatePath,
fromState,
toState,
timestamp: new Date().toISOString(),
};
this.emit('state:transition', event);
this.scheduleAutoSave();
}
/**
* Get the current state of a template
*/
getTemplateStatus(templatePath) {
return this.templateStates.get(templatePath);
}
/**
* Get all template statuses
*/
getAllTemplateStatuses() {
return new Map(this.templateStates);
}
/**
* Get validation warnings from initialization
* Returns any warnings about corrupted or invalid build log files
*/
getValidationWarnings() {
return [...this.validationWarnings];
}
/**
* Get recently applied templates sorted by date (most recent first)
* Returns templates from local build log that have lastAppliedDate
*/
getRecentlyApplied(limit = 5) {
const entries = [];
for (const [template, state] of Object.entries(this.localBuildLog.templates)) {
if (state.lastAppliedDate) {
entries.push({ template, appliedDate: state.lastAppliedDate });
}
}
// Sort by date descending (most recent first)
entries.sort((a, b) => new Date(b.appliedDate).getTime() - new Date(a.appliedDate).getTime());
return entries.slice(0, limit);
}
/**
* Get recent activity for watch mode history display.
* Returns the most recent builds and applies sorted by date.
*/
getRecentActivity(limit = 10) {
const entries = [];
// Collect builds from buildLog
for (const [template, state] of Object.entries(this.buildLog.templates)) {
if (state.lastBuildDate) {
entries.push({
template,
action: 'built',
timestamp: new Date(state.lastBuildDate),
target: state.lastMigrationFile,
});
}
}
// Collect applies from localBuildLog
for (const [template, state] of Object.entries(this.localBuildLog.templates)) {
if (state.lastAppliedDate) {
entries.push({
template,
action: 'applied',
timestamp: new Date(state.lastAppliedDate),
});
}
}
// Sort by timestamp descending (most recent first)
entries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
return entries.slice(0, limit);
}
/**
* Get template info including migration file and last date
* Used for displaying arrow format: template.sql → migration_file.sql
* Accepts either a full path, relative path, or just the template name
*/
getTemplateInfo(templatePath) {
// Try direct lookup first (relative path from project root)
const relativePath = path.relative(this.config.baseDir, templatePath);
let buildState = this.buildLog.templates[relativePath];
let localState = this.localBuildLog.templates[relativePath];
let matchedPath = relativePath;
// If not found, search by template name (for when just "test" is passed)
if (!buildState && !localState) {
const searchName = templatePath.endsWith('.sql') ? templatePath : `${templatePath}.sql`;
for (const [storedPath, state] of Object.entries(this.buildLog.templates)) {
if (storedPath.endsWith(searchName) || storedPath.endsWith(`/${searchName}`)) {
buildState = state;
matchedPath = storedPath;
localState = this.localBuildLog.templates[storedPath];
break;
}
}
// Also check local build log if nothing found in main build log
if (!buildState && !localState) {
for (const [storedPath, state] of Object.entries(this.localBuildLog.templates)) {
if (storedPath.endsWith(searchName) || storedPath.endsWith(`/${searchName}`)) {
localState = state;
matchedPath = storedPath;
break;
}
}
}
}
// Prefer build state for migration file (that's where builds write it)
// Use either build or apply date, whichever is more recent
const migrationFile = buildState?.lastMigrationFile;
const buildDate = buildState?.lastBuildDate;
const applyDate = localState?.lastAppliedDate;
// Use the most recent date
let lastDate;
if (buildDate && applyDate) {
lastDate = new Date(buildDate) > new Date(applyDate) ? buildDate : applyDate;
}
else {
lastDate = buildDate || applyDate;
}
return {
template: matchedPath,
migrationFile,
lastDate,
};
}
/**
* Check if template has changed based on hash comparison
*/
hasTemplateChanged(templatePath, currentHash) {
const info = this.templateStates.get(templatePath);
if (!info)
return true;
return currentHash !== info.lastAppliedHash && currentHash !== info.lastBuiltHash;
}
/**
* Mark template as unseen (new template)
*/
async markAsUnseen(templatePath, currentHash) {
await this.transitionState(templatePath, TemplateState.UNSEEN, {
currentHash,
});
}
/**
* Mark template as synced
*/
async markAsSynced(templatePath, currentHash) {
await this.transitionState(templatePath, TemplateState.SYNCED, {
currentHash,
});
}
/**
* Mark template as changed
*/
async markAsChanged(templatePath, currentHash) {
await this.transitionState(templatePath, TemplateState.CHANGED, {
currentHash,
});
}
/**
* Mark template as applied
*/
async markAsApplied(templatePath, hash) {
await this.transitionState(templatePath, TemplateState.APPLIED, {
lastAppliedHash: hash,
lastAppliedDate: new Date().toISOString(),
currentHash: hash,
lastError: undefined, // Clear any previous errors on successful apply
});
}
/**
* Mark template as built
*/
async markAsBuilt(templatePath, hash, migrationFile) {
const relPath = path.relative(this.config.baseDir, templatePath);
await this.transitionState(templatePath, TemplateState.BUILT, {
lastBuiltHash: hash,
lastBuiltDate: new Date().toISOString(),
currentHash: hash,
lastError: undefined, // Clear any previous errors on successful build
});
// Update migration file reference
if (migrationFile) {
this.buildLog.templates[relPath] = {
...this.buildLog.templates[relPath],
lastMigrationFile: migrationFile,
};
}
}
/**
* Mark template as having an error
*/
async markAsError(templatePath, error, type = 'apply') {
const updates = {
lastError: error,
};
const relPath = path.relative(this.config.baseDir, templatePath);
if (type === 'apply') {
this.localBuildLog.templates[relPath] = {
...this.localBuildLog.templates[relPath],
lastAppliedError: error,
};
}
else {
this.buildLog.templates[relPath] = {
...this.buildLog.templates[relPath],
lastBuildError: error,
};
}
await this.transitionState(templatePath, TemplateState.ERROR, updates);
}
/**
* Clear all states (reset)
*/
async clearAllStates() {
this.templateStates.clear();
this.buildLog = this.createEmptyBuildLog();
this.localBuildLog = this.createEmptyBuildLog();
await this.saveBuildLogs();
this.emit('state:cleared');
}
/**
* Clear build logs selectively
* @param type - 'local' clears local only, 'shared' clears shared only, 'both' clears all
*/
async clearBuildLogs(type) {
if (type === 'local' || type === 'both') {
this.localBuildLog = this.createEmptyBuildLog();
}
if (type === 'shared' || type === 'both') {
this.buildLog = this.createEmptyBuildLog();
}
// Clear in-memory state when clearing both
if (type === 'both') {
this.templateStates.clear();
}
await this.saveBuildLogs();
this.emit('state:cleared');
}
/**
* Update template timestamp
*/
updateTimestamp(timestamp) {
this.buildLog.lastTimestamp = timestamp;
this.localBuildLog.lastTimestamp = timestamp;
this.scheduleAutoSave();
}
/**
* Get the last timestamp for generating migration filenames
*/
getLastTimestamp() {
return this.buildLog.lastTimestamp;
}
/**
* Get combined build state for a template (merges common and local logs)
* @see Orchestrator.getTemplateStatus - primary consumer
*/
getTemplateBuildState(templatePath) {
const relPath = this.toRelativePath(templatePath);
const common = this.buildLog.templates[relPath];
const local = this.localBuildLog.templates[relPath];
if (!common && !local)
return undefined;
return { ...common, ...local };
}
/**
* Get build log reference for MigrationBuilder (read-only access)
* @see MigrationBuilder.generateAndWriteBundledMigration
*/
getBuildLogForMigration() {
return this.buildLog;
}
/**
* Rename template entry in build logs (used when promoting WIP templates)
* Moves the build state from old path to new path in both common and local logs
*/
async renameTemplate(oldPath, newPath) {
const oldRelPath = this.toRelativePath(oldPath);
const newRelPath = this.toRelativePath(newPath);
// Move entry in common build log
if (this.buildLog.templates[oldRelPath]) {
this.buildLog.templates[newRelPath] = this.buildLog.templates[oldRelPath];
delete this.buildLog.templates[oldRelPath];
}
// Move entry in local build log
if (this.localBuildLog.templates[oldRelPath]) {
this.localBuildLog.templates[newRelPath] = this.localBuildLog.templates[oldRelPath];
delete this.localBuildLog.templates[oldRelPath];
}
// Update in-memory state map
const oldAbsPath = path.resolve(this.config.baseDir, oldRelPath);
const newAbsPath = path.resolve(this.config.baseDir, newRelPath);
const state = this.templateStates.get(oldAbsPath);
if (state) {
this.templateStates.delete(oldAbsPath);
this.templateStates.set(newAbsPath, { ...state, templatePath: newAbsPath });
}
this.scheduleAutoSave();
}
/**
* Convert template path to relative path for build log keys
*/
toRelativePath(templatePath) {
return path.isAbsolute(templatePath)
? path.relative(this.config.baseDir, templatePath)
: templatePath;
}
/**
* Dispose of the service
*/
async dispose() {
if (this.saveTimer) {
clearTimeout(this.saveTimer);
await this.saveBuildLogs();
}
this.removeAllListeners();
}
}
//# sourceMappingURL=StateService.js.map