file-storage.ts•6.45 kB
import * as fs from 'fs/promises';
import * as path from 'path';
import * as crypto from 'crypto';
import type {
PlanManifest,
Entity,
Link,
ActivePlansIndex,
EntityType,
} from '../domain/entities/types.js';
export class FileStorage {
private baseDir: string;
private plansDir: string;
private activePlansPath: string;
constructor(baseDir: string) {
this.baseDir = baseDir;
this.plansDir = path.join(baseDir, 'plans');
this.activePlansPath = path.join(baseDir, 'active-plans.json');
}
async initialize(): Promise<void> {
await fs.mkdir(this.plansDir, { recursive: true });
await fs.mkdir(path.join(this.baseDir, '.history'), { recursive: true });
}
// Plan directory operations
async createPlanDirectory(planId: string): Promise<void> {
const planDir = path.join(this.plansDir, planId);
await fs.mkdir(planDir, { recursive: true });
await fs.mkdir(path.join(planDir, 'entities'), { recursive: true });
await fs.mkdir(path.join(planDir, 'versions'), { recursive: true });
await fs.mkdir(path.join(planDir, 'exports'), { recursive: true });
}
async deletePlan(planId: string): Promise<void> {
const planDir = path.join(this.plansDir, planId);
await fs.rm(planDir, { recursive: true, force: true });
}
async listPlans(): Promise<string[]> {
try {
const entries = await fs.readdir(this.plansDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
} catch {
return [];
}
}
async planExists(planId: string): Promise<boolean> {
try {
const planDir = path.join(this.plansDir, planId);
await fs.access(planDir);
return true;
} catch {
return false;
}
}
// Manifest operations
async saveManifest(planId: string, manifest: PlanManifest): Promise<void> {
const manifestPath = path.join(this.plansDir, planId, 'manifest.json');
await this.atomicWrite(manifestPath, manifest);
}
async loadManifest(planId: string): Promise<PlanManifest> {
const manifestPath = path.join(this.plansDir, planId, 'manifest.json');
const content = await fs.readFile(manifestPath, 'utf-8');
return JSON.parse(content) as PlanManifest;
}
// Entity operations
async saveEntities<T extends Entity>(
planId: string,
entityType: string,
entities: T[]
): Promise<void> {
const entityPath = path.join(
this.plansDir,
planId,
'entities',
`${entityType}.json`
);
await this.atomicWrite(entityPath, entities);
}
async loadEntities<T extends Entity>(
planId: string,
entityType: string
): Promise<T[]> {
const entityPath = path.join(
this.plansDir,
planId,
'entities',
`${entityType}.json`
);
try {
const content = await fs.readFile(entityPath, 'utf-8');
return JSON.parse(content) as T[];
} catch {
return [];
}
}
// Link operations
async saveLinks(planId: string, links: Link[]): Promise<void> {
const linksPath = path.join(this.plansDir, planId, 'links.json');
await this.atomicWrite(linksPath, links);
}
async loadLinks(planId: string): Promise<Link[]> {
const linksPath = path.join(this.plansDir, planId, 'links.json');
try {
const content = await fs.readFile(linksPath, 'utf-8');
return JSON.parse(content) as Link[];
} catch {
return [];
}
}
// Active plans operations
async saveActivePlans(index: ActivePlansIndex): Promise<void> {
await this.atomicWrite(this.activePlansPath, index);
}
async loadActivePlans(): Promise<ActivePlansIndex> {
try {
const content = await fs.readFile(this.activePlansPath, 'utf-8');
return JSON.parse(content) as ActivePlansIndex;
} catch {
return {};
}
}
// Atomic write to prevent data corruption
async atomicWrite(filePath: string, data: unknown): Promise<void> {
const tmpPath = `${filePath}.tmp.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
try {
// Write to temp file
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
// Verify JSON is valid
const written = await fs.readFile(tmpPath, 'utf-8');
JSON.parse(written);
// Atomic rename
await fs.rename(tmpPath, filePath);
} catch (error) {
// Cleanup temp file on error
await fs.unlink(tmpPath).catch(() => {});
throw error;
}
}
// Version operations
async saveVersion(
planId: string,
version: number,
type: 'snapshot' | 'delta',
data: unknown
): Promise<void> {
const versionPath = path.join(
this.plansDir,
planId,
'versions',
`v${version}.${type}.json`
);
await this.atomicWrite(versionPath, data);
}
async loadVersion(
planId: string,
version: number,
type: 'snapshot' | 'delta'
): Promise<unknown> {
const versionPath = path.join(
this.plansDir,
planId,
'versions',
`v${version}.${type}.json`
);
const content = await fs.readFile(versionPath, 'utf-8');
return JSON.parse(content);
}
async listVersions(planId: string): Promise<{ version: number; type: 'snapshot' | 'delta' }[]> {
const versionsDir = path.join(this.plansDir, planId, 'versions');
try {
const files = await fs.readdir(versionsDir);
return files
.filter((f) => f.endsWith('.json'))
.map((f) => {
const match = f.match(/^v(\d+)\.(snapshot|delta)\.json$/);
if (match) {
return {
version: parseInt(match[1], 10),
type: match[2] as 'snapshot' | 'delta',
};
}
return null;
})
.filter((v): v is { version: number; type: 'snapshot' | 'delta' } => v !== null)
.sort((a, b) => a.version - b.version);
} catch {
return [];
}
}
// Export operations
async saveExport(planId: string, filename: string, content: string): Promise<string> {
const exportPath = path.join(this.plansDir, planId, 'exports', filename);
await fs.writeFile(exportPath, content, 'utf-8');
return exportPath;
}
// Helper to get plan directory path
getPlanDir(planId: string): string {
return path.join(this.plansDir, planId);
}
// Get base directory
getBaseDir(): string {
return this.baseDir;
}
}
export default FileStorage;