package db import ( "database/sql" "fmt" "os" "path/filepath" ) // Backup writes a consistent copy of the database at srcPath to destPath using // SQLite's `VACUUM INTO`. // // Why not copy the file: Petal runs in WAL mode, so at any instant the newest // committed pages may live in petal.db-wal rather than petal.db. Copying the // three files separately can capture them mid-checkpoint and produce a backup // that is subtly torn. `VACUUM INTO` runs inside a read transaction, so it sees // one coherent snapshot including the WAL, and emits a single defragmented file // with no -wal/-shm companions — exactly what you want to ship off-box. // // It takes no write lock, so this is safe to run against the live database // while someone is writing. // // destPath must not already exist: SQLite refuses to overwrite, which keeps a // failed run from destroying the previous good backup. func Backup(srcPath, destPath string) error { if _, err := os.Stat(srcPath); err != nil { return fmt.Errorf("source database: %w", err) } if _, err := os.Stat(destPath); err == nil { return fmt.Errorf("destination %s already exists", destPath) } if dir := filepath.Dir(destPath); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create backup dir: %w", err) } } // Opened directly rather than through Open: a backup must never migrate or // seed the database it is copying. conn, err := sql.Open("sqlite", dsn(srcPath)) if err != nil { return fmt.Errorf("open source: %w", err) } defer conn.Close() conn.SetMaxOpenConns(1) if err := conn.Ping(); err != nil { return fmt.Errorf("ping source: %w", err) } // The path is interpolated because VACUUM INTO takes a literal, not a bound // parameter. Quotes are doubled so a path containing one can't break out. quoted := "'" + escapeSQLiteString(destPath) + "'" if _, err := conn.Exec("VACUUM INTO " + quoted); err != nil { return fmt.Errorf("vacuum into %s: %w", destPath, err) } // A zero-byte result would mean the vacuum silently produced nothing; catch // it here rather than discovering it during a restore. info, err := os.Stat(destPath) if err != nil { return fmt.Errorf("stat backup: %w", err) } if info.Size() == 0 { return fmt.Errorf("backup %s is empty", destPath) } return nil } func escapeSQLiteString(s string) string { out := make([]byte, 0, len(s)) for i := 0; i < len(s); i++ { if s[i] == '\'' { out = append(out, '\'') } out = append(out, s[i]) } return string(out) }