import * as fs from 'fs/promises';
import * as path from 'path';
import logger from './logger';
import { MetadataInfo } from '../salesforce/metadataClient';
export interface MetadataSnapshot {
type: string;
fullName: string;
lastModifiedDate: string;
checksum?: string;
}
export interface IncrementalState {
lastSyncTime: string;
snapshots: MetadataSnapshot[];
orgId: string;
}
export class IncrementalTracker {
private stateFilePath: string;
private state: IncrementalState | null = null;
constructor(orgId: string, baseDir: string = './.cache') {
this.stateFilePath = path.join(baseDir, `${orgId}-metadata-state.json`);
}
async loadState(): Promise<IncrementalState> {
try {
await fs.mkdir(path.dirname(this.stateFilePath), { recursive: true });
const data = await fs.readFile(this.stateFilePath, 'utf-8');
this.state = JSON.parse(data);
logger.debug('Loaded incremental state', {
snapshots: this.state!.snapshots.length,
lastSync: this.state!.lastSyncTime
});
return this.state!;
} catch (error: any) {
if (error.code === 'ENOENT') {
// First run - create initial state
this.state = {
lastSyncTime: new Date(0).toISOString(),
snapshots: [],
orgId: ''
};
logger.info('Creating initial incremental state');
return this.state;
}
throw error;
}
}
async saveState(): Promise<void> {
if (!this.state) {
throw new Error('No state to save. Call loadState() first.');
}
try {
await fs.mkdir(path.dirname(this.stateFilePath), { recursive: true });
await fs.writeFile(this.stateFilePath, JSON.stringify(this.state, null, 2));
logger.debug('Saved incremental state', {
snapshots: this.state.snapshots.length
});
} catch (error) {
logger.error('Failed to save incremental state', { error });
throw error;
}
}
detectChanges(currentMetadata: MetadataInfo[]): {
added: MetadataInfo[];
modified: MetadataInfo[];
deleted: MetadataSnapshot[];
unchanged: MetadataInfo[];
} {
if (!this.state) {
throw new Error('State not loaded. Call loadState() first.');
}
const previousMap = new Map<string, MetadataSnapshot>();
this.state.snapshots.forEach(snapshot => {
const key = `${snapshot.type}:${snapshot.fullName}`;
previousMap.set(key, snapshot);
});
const currentMap = new Map<string, MetadataInfo>();
currentMetadata.forEach(item => {
const key = `${item.type}:${item.fullName}`;
currentMap.set(key, item);
});
const added: MetadataInfo[] = [];
const modified: MetadataInfo[] = [];
const unchanged: MetadataInfo[] = [];
// Check for additions and modifications
currentMetadata.forEach(current => {
const key = `${current.type}:${current.fullName}`;
const previous = previousMap.get(key);
if (!previous) {
added.push(current);
} else if (previous.lastModifiedDate !== current.lastModifiedDate) {
modified.push(current);
} else {
unchanged.push(current);
}
});
// Check for deletions
const deleted: MetadataSnapshot[] = [];
this.state.snapshots.forEach(previous => {
const key = `${previous.type}:${previous.fullName}`;
if (!currentMap.has(key)) {
deleted.push(previous);
}
});
logger.info('Change detection completed', {
added: added.length,
modified: modified.length,
deleted: deleted.length,
unchanged: unchanged.length
});
return { added, modified, deleted, unchanged };
}
updateSnapshots(metadata: MetadataInfo[]): void {
if (!this.state) {
throw new Error('State not loaded. Call loadState() first.');
}
// Create new snapshots from current metadata
const newSnapshots: MetadataSnapshot[] = metadata.map(item => ({
type: item.type,
fullName: item.fullName,
lastModifiedDate: item.lastModifiedDate
}));
this.state.snapshots = newSnapshots;
this.state.lastSyncTime = new Date().toISOString();
logger.debug('Updated metadata snapshots', {
count: newSnapshots.length,
lastSync: this.state.lastSyncTime
});
}
getChangedSince(sinceDate: Date): MetadataSnapshot[] {
if (!this.state) {
throw new Error('State not loaded. Call loadState() first.');
}
return this.state.snapshots.filter(snapshot =>
new Date(snapshot.lastModifiedDate) > sinceDate
);
}
async cleanup(retentionDays: number = 30): Promise<void> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
// Clean up old state files
try {
const stateDir = path.dirname(this.stateFilePath);
const files = await fs.readdir(stateDir);
for (const file of files) {
if (file.endsWith('-metadata-state.json')) {
const filePath = path.join(stateDir, file);
const stats = await fs.stat(filePath);
if (stats.mtime < cutoffDate) {
await fs.unlink(filePath);
logger.info(`Cleaned up old state file: ${file}`);
}
}
}
} catch (error) {
logger.warn('Failed to cleanup old state files', { error });
}
}
}