package db import ( "fmt" "os" "path/filepath" "strings" "time" ) // Snapshot creates a consistent backup of the database at destPath using // SQLite's VACUUM INTO statement. VACUUM INTO acquires a read lock for the // duration of the copy, which is safe while the server is running in WAL mode. // The destination file is created by SQLite; the caller must ensure the parent // directory exists. func (db *DB) Snapshot(destPath string) error { // VACUUM INTO is not supported on in-memory databases. if strings.Contains(db.path, "mode=memory") { return fmt.Errorf("db: snapshot not supported on in-memory databases") } if _, err := db.sql.Exec("VACUUM INTO ?", destPath); err != nil { return fmt.Errorf("db: snapshot VACUUM INTO %q: %w", destPath, err) } return nil } // SnapshotDir creates a timestamped backup in dir and prunes backups older // than retainDays days. dir is created with mode 0750 if it does not exist. // The backup filename format is mcias-20060102-150405.db. func (db *DB) SnapshotDir(dir string, retainDays int) (string, error) { if err := os.MkdirAll(dir, 0750); err != nil { return "", fmt.Errorf("db: create backup dir %q: %w", dir, err) } ts := time.Now().UTC().Format("20060102-150405") dest := filepath.Join(dir, fmt.Sprintf("mcias-%s.db", ts)) if err := db.Snapshot(dest); err != nil { return "", err } // Prune backups older than retainDays. if retainDays > 0 { cutoff := time.Now().UTC().AddDate(0, 0, -retainDays) entries, err := os.ReadDir(dir) if err != nil { // Non-fatal: the backup was written; log pruning failure separately. return dest, fmt.Errorf("db: list backup dir for pruning: %w", err) } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") { continue } // Skip the file we just wrote. if e.Name() == filepath.Base(dest) { continue } info, err := e.Info() if err != nil { continue } if info.ModTime().Before(cutoff) { _ = os.Remove(filepath.Join(dir, e.Name())) } } } return dest, nil }