JavaScript 56.7%
TypeScript 42.6%
1import { cp, rm, mkdir, stat, readdir } from "fs/promises";2import { existsSync, mkdirSync } from "fs";3import path from "path";4import { prisma } from "./prisma";56const SNAPSHOT_DIR = process.env.SNAPSHOT_DIR || "./snapshots";7const UPLOAD_DIR = process.env.UPLOAD_DIR || "./uploads";8const FILES_DIR = path.join(UPLOAD_DIR, "files");9const MAX_SNAPSHOTS = 7;1011if (!existsSync(SNAPSHOT_DIR)) mkdirSync(SNAPSHOT_DIR, { recursive: true });1213function getTodayKey(): string {14 return new Date().toISOString().split("T")[0]; // YYYY-MM-DD15}1617function buildLabel(dateStr: string): string {18 // dateStr = YYYY-MM-DD, parse in local noon to avoid timezone shifts19 const [y, m, d] = dateStr.split("-").map(Number);20 const date = new Date(y, m - 1, d, 12);21 return date.toLocaleDateString("fr-FR", {22 weekday: "long",23 day: "numeric",24 month: "long",25 year: "numeric",26 });27}2829async function getDirSize(dir: string): Promise<number> {30 if (!existsSync(dir)) return 0;31 let total = 0;32 const entries = await readdir(dir, { withFileTypes: true });33 for (const entry of entries) {34 const full = path.join(dir, entry.name);35 if (entry.isDirectory()) {36 total += await getDirSize(full);37 } else {38 const s = await stat(full);39 total += s.size;40 }41 }42 return total;43}4445export async function createSnapshot(): Promise<{ id: string; snapshotDate: string }> {46 const snapshotDate = getTodayKey();47 const snapshotPath = path.resolve(path.join(SNAPSHOT_DIR, snapshotDate));48 const label = buildLabel(snapshotDate);4950 // Upsert the DB record (one per day — re-snapshot replaces the old one)51 let record = await prisma.snapshot.findUnique({ where: { snapshotDate } });5253 // Remove existing snapshot dir if present54 if (existsSync(snapshotPath)) {55 await rm(snapshotPath, { recursive: true, force: true });56 }57 mkdirSync(snapshotPath, { recursive: true });5859 if (record) {60 record = await prisma.snapshot.update({61 where: { id: record.id },62 data: { status: "pending", error: null, label, path: snapshotPath, createdAt: new Date() },63 });64 } else {65 record = await prisma.snapshot.create({66 data: { label, snapshotDate, path: snapshotPath, status: "pending", size: BigInt(0) },67 });68 }6970 try {71 // Copy uploads/files72 const filesSnapshot = path.join(snapshotPath, "files");73 mkdirSync(filesSnapshot, { recursive: true });74 if (existsSync(FILES_DIR)) {75 await cp(FILES_DIR, filesSnapshot, { recursive: true });76 }7778 // Copy SQLite DB79 const dbPath = path.resolve("./prisma/dev.db");80 const dbSnapshotDir = path.join(snapshotPath, "database");81 mkdirSync(dbSnapshotDir, { recursive: true });82 if (existsSync(dbPath)) {83 await cp(dbPath, path.join(dbSnapshotDir, "dev.db"));84 }8586 const size = await getDirSize(snapshotPath);8788 await prisma.snapshot.update({89 where: { id: record.id },90 data: { status: "completed", size: BigInt(size) },91 });9293 // Keep only the last MAX_SNAPSHOTS days94 await pruneOldSnapshots();9596 return { id: record.id, snapshotDate };97 } catch (error) {98 await prisma.snapshot.update({99 where: { id: record.id },100 data: { status: "failed", error: (error as Error).message },101 });102 throw error;103 }104}105106export async function listSnapshots() {107 const snapshots = await prisma.snapshot.findMany({108 orderBy: { snapshotDate: "desc" },109 take: MAX_SNAPSHOTS,110 });111 return snapshots.map((s) => ({ ...s, size: Number(s.size) }));112}113114export async function deleteSnapshot(id: string): Promise<boolean> {115 const snapshot = await prisma.snapshot.findUnique({ where: { id } });116 if (!snapshot) return false;117 if (existsSync(snapshot.path)) {118 await rm(snapshot.path, { recursive: true, force: true });119 }120 await prisma.snapshot.delete({ where: { id } });121 return true;122}123124export async function restoreSnapshot(id: string): Promise<void> {125 const snapshot = await prisma.snapshot.findUnique({ where: { id } });126 if (!snapshot || snapshot.status !== "completed") {127 throw new Error("Snapshot introuvable ou incomplet");128 }129 if (!existsSync(snapshot.path)) {130 throw new Error("Fichiers du snapshot manquants sur le disque");131 }132133 const filesSnapshot = path.join(snapshot.path, "files");134 const dbSnapshot = path.join(snapshot.path, "database", "dev.db");135136 // Restore files dir137 if (existsSync(filesSnapshot)) {138 if (existsSync(FILES_DIR)) {139 await rm(FILES_DIR, { recursive: true, force: true });140 }141 await mkdir(FILES_DIR, { recursive: true });142 await cp(filesSnapshot, FILES_DIR, { recursive: true });143 }144145 // Restore SQLite DB146 if (existsSync(dbSnapshot)) {147 const dbPath = path.resolve("./prisma/dev.db");148 await prisma.$disconnect();149 await cp(dbSnapshot, dbPath);150 // Prisma reconnects automatically on next query151 }152}153154async function pruneOldSnapshots(): Promise<void> {155 const all = await prisma.snapshot.findMany({ orderBy: { snapshotDate: "desc" } });156 const toDelete = all.slice(MAX_SNAPSHOTS);157 for (const snapshot of toDelete) {158 if (existsSync(snapshot.path)) {159 await rm(snapshot.path, { recursive: true, force: true });160 }161 await prisma.snapshot.delete({ where: { id: snapshot.id } });162 }163}164165/**166 * Called by the server on an hourly tick.167 * Creates a snapshot only if none exists for today yet.168 * Returns true if a snapshot was created.169 */170export async function createDailySnapshotIfNeeded(): Promise<boolean> {171 const today = getTodayKey();172 const existing = await prisma.snapshot.findUnique({173 where: { snapshotDate: today },174 });175 if (existing?.status === "completed") return false;176 await createSnapshot();177 return true;178}179