Skip to content

Commit bed03d4

Browse files
authored
Merge pull request #358 from AppSprout-dev/fix/backup-retention
fix: skip pre-migration backup when schema is current, add retention
2 parents 5ef4bfc + 75a74c3 commit bed03d4

4 files changed

Lines changed: 193 additions & 10 deletions

File tree

cmd/mnemonic/main.go

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,18 +1230,30 @@ func serveCommand(configPath string) {
12301230
die(exitPermission, fmt.Sprintf("creating data directory: %v", err), "check permissions on ~/.mnemonic/")
12311231
}
12321232

1233-
// Pre-migration safety backup (only if DB already exists)
1233+
// Pre-migration safety backup (only if DB exists AND schema is outdated)
12341234
if _, statErr := os.Stat(cfg.Store.DBPath); statErr == nil {
1235-
backupDir, bdErr := backup.EnsureBackupDir()
1236-
if bdErr != nil {
1237-
log.Warn("could not create backup directory for pre-migration backup", "error", bdErr)
1238-
} else {
1239-
bkPath, bkErr := backup.BackupSQLiteFile(cfg.Store.DBPath, backupDir)
1240-
if bkErr != nil {
1241-
log.Warn("pre-migration backup failed", "error", bkErr)
1242-
} else if bkPath != "" {
1243-
log.Info("pre-migration backup created", "path", bkPath)
1235+
currentVer, verErr := backup.ReadSchemaVersion(cfg.Store.DBPath)
1236+
if verErr != nil {
1237+
log.Warn("could not read schema version, will back up defensively", "error", verErr)
1238+
currentVer = -1 // force backup
1239+
}
1240+
if currentVer < sqlite.SchemaVersion {
1241+
backupDir, bdErr := backup.EnsureBackupDir()
1242+
if bdErr != nil {
1243+
log.Warn("could not create backup directory for pre-migration backup", "error", bdErr)
1244+
} else {
1245+
bkPath, bkErr := backup.BackupSQLiteFile(cfg.Store.DBPath, backupDir)
1246+
if bkErr != nil {
1247+
log.Warn("pre-migration backup failed", "error", bkErr)
1248+
} else if bkPath != "" {
1249+
log.Info("pre-migration backup created", "path", bkPath)
1250+
}
1251+
if pruneErr := backup.PruneOldBackups(backupDir, 3); pruneErr != nil {
1252+
log.Warn("failed to prune old backups", "error", pruneErr)
1253+
}
12441254
}
1255+
} else {
1256+
log.Debug("schema is current, skipping pre-migration backup")
12451257
}
12461258
}
12471259

internal/backup/export.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@ package backup
22

33
import (
44
"context"
5+
"database/sql"
56
"encoding/json"
67
"fmt"
78
"io"
89
"os"
910
"path/filepath"
11+
"sort"
12+
"strings"
1013
"time"
1114

1215
"github.com/appsprout-dev/mnemonic/internal/store"
16+
17+
_ "modernc.org/sqlite" // pure-Go SQLite driver
1318
)
1419

1520
type ExportFormat string
@@ -165,6 +170,52 @@ func BackupSQLiteFile(dbPath string, backupDir string) (string, error) {
165170
return backupPath, nil
166171
}
167172

173+
// ReadSchemaVersion opens the database read-only and returns PRAGMA user_version.
174+
// Returns 0 for databases that have never had the version set (pre-existing DBs).
175+
func ReadSchemaVersion(dbPath string) (int, error) {
176+
db, err := sql.Open("sqlite", dbPath+"?mode=ro")
177+
if err != nil {
178+
return 0, fmt.Errorf("opening database for version check: %w", err)
179+
}
180+
defer func() { _ = db.Close() }()
181+
182+
var version int
183+
if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
184+
return 0, fmt.Errorf("reading user_version: %w", err)
185+
}
186+
return version, nil
187+
}
188+
189+
// PruneOldBackups keeps the most recent `keep` pre-migration backup files in dir
190+
// and removes older ones. Only targets files matching the "pre_migrate_*.db" pattern.
191+
func PruneOldBackups(dir string, keep int) error {
192+
entries, err := os.ReadDir(dir)
193+
if err != nil {
194+
return fmt.Errorf("reading backup directory: %w", err)
195+
}
196+
197+
var backups []string
198+
for _, e := range entries {
199+
if !e.IsDir() && strings.HasPrefix(e.Name(), "pre_migrate_") && strings.HasSuffix(e.Name(), ".db") {
200+
backups = append(backups, e.Name())
201+
}
202+
}
203+
204+
// Filenames contain timestamps, so lexicographic sort = chronological order.
205+
sort.Strings(backups)
206+
207+
if len(backups) <= keep {
208+
return nil
209+
}
210+
211+
for _, name := range backups[:len(backups)-keep] {
212+
if err := os.Remove(filepath.Join(dir, name)); err != nil {
213+
return fmt.Errorf("removing old backup %s: %w", name, err)
214+
}
215+
}
216+
return nil
217+
}
218+
168219
func EnsureBackupDir() (string, error) {
169220
homeDir, err := os.UserHomeDir()
170221
if err != nil {

internal/backup/export_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,115 @@ func TestEnsureBackupDir(t *testing.T) {
324324
}
325325
}
326326

327+
func TestReadSchemaVersion_AfterInitSchema(t *testing.T) {
328+
_, dbPath, cleanup := setupTestStore(t)
329+
defer cleanup()
330+
331+
ver, err := ReadSchemaVersion(dbPath)
332+
if err != nil {
333+
t.Fatalf("ReadSchemaVersion failed: %v", err)
334+
}
335+
if ver != sqlite.SchemaVersion {
336+
t.Errorf("expected schema version %d, got %d", sqlite.SchemaVersion, ver)
337+
}
338+
}
339+
340+
func TestReadSchemaVersion_FreshDB(t *testing.T) {
341+
// A fresh SQLite DB with no PRAGMA user_version set returns 0.
342+
dir := t.TempDir()
343+
dbPath := filepath.Join(dir, "fresh.db")
344+
345+
db, err := sql.Open("sqlite", dbPath)
346+
if err != nil {
347+
t.Fatalf("failed to open fresh db: %v", err)
348+
}
349+
// Create at least one table so the file exists on disk.
350+
if _, err := db.Exec("CREATE TABLE t(id INTEGER)"); err != nil {
351+
t.Fatalf("failed to create table: %v", err)
352+
}
353+
_ = db.Close()
354+
355+
ver, err := ReadSchemaVersion(dbPath)
356+
if err != nil {
357+
t.Fatalf("ReadSchemaVersion failed: %v", err)
358+
}
359+
if ver != 0 {
360+
t.Errorf("expected version 0 for fresh DB, got %d", ver)
361+
}
362+
}
363+
364+
func TestPruneOldBackups(t *testing.T) {
365+
dir := t.TempDir()
366+
367+
// Create 5 fake pre-migration backups with distinct timestamps.
368+
names := []string{
369+
"pre_migrate_2026-01-01_100000.db",
370+
"pre_migrate_2026-01-02_100000.db",
371+
"pre_migrate_2026-01-03_100000.db",
372+
"pre_migrate_2026-01-04_100000.db",
373+
"pre_migrate_2026-01-05_100000.db",
374+
}
375+
for _, n := range names {
376+
if err := os.WriteFile(filepath.Join(dir, n), []byte("fake"), 0644); err != nil {
377+
t.Fatalf("failed to create %s: %v", n, err)
378+
}
379+
}
380+
381+
// Also create a non-matching file that should NOT be pruned.
382+
if err := os.WriteFile(filepath.Join(dir, "backup_2026-01-01.json"), []byte("keep"), 0644); err != nil {
383+
t.Fatal(err)
384+
}
385+
386+
if err := PruneOldBackups(dir, 3); err != nil {
387+
t.Fatalf("PruneOldBackups failed: %v", err)
388+
}
389+
390+
entries, err := os.ReadDir(dir)
391+
if err != nil {
392+
t.Fatal(err)
393+
}
394+
395+
var remaining []string
396+
for _, e := range entries {
397+
remaining = append(remaining, e.Name())
398+
}
399+
400+
// Should have 3 pre_migrate files + 1 json file = 4 total
401+
if len(remaining) != 4 {
402+
t.Errorf("expected 4 files remaining, got %d: %v", len(remaining), remaining)
403+
}
404+
405+
// The oldest two should be gone.
406+
for _, name := range remaining {
407+
if name == names[0] || name == names[1] {
408+
t.Errorf("old backup %s should have been pruned", name)
409+
}
410+
}
411+
}
412+
413+
func TestPruneOldBackups_FewFiles(t *testing.T) {
414+
dir := t.TempDir()
415+
416+
// Only 2 backups, keep=3 — nothing should be deleted.
417+
for _, n := range []string{"pre_migrate_2026-01-01_100000.db", "pre_migrate_2026-01-02_100000.db"} {
418+
if err := os.WriteFile(filepath.Join(dir, n), []byte("fake"), 0644); err != nil {
419+
t.Fatal(err)
420+
}
421+
}
422+
423+
if err := PruneOldBackups(dir, 3); err != nil {
424+
t.Fatalf("PruneOldBackups failed: %v", err)
425+
}
426+
427+
entries, err := os.ReadDir(dir)
428+
if err != nil {
429+
t.Fatal(err)
430+
}
431+
if len(entries) != 2 {
432+
t.Errorf("expected 2 files, got %d", len(entries))
433+
}
434+
}
435+
327436
// jsonContains checks if a JSON string contains a given substring.
328437
func jsonContains(jsonStr, substr string) bool {
329438
return len(jsonStr) > 0 && contains(jsonStr, substr)

internal/store/sqlite/schema.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import (
66
"strings"
77
)
88

9+
// SchemaVersion is the current target schema version. Bump this whenever a new
10+
// migration is added. It is written to PRAGMA user_version after InitSchema
11+
// completes, and read by the pre-migration backup logic to skip backups when
12+
// the schema is already current.
13+
const SchemaVersion = 15
14+
915
const schema = `
1016
-- Raw observations before encoding
1117
CREATE TABLE IF NOT EXISTS raw_memories (
@@ -483,6 +489,11 @@ CREATE INDEX IF NOT EXISTS idx_amendments_memory ON memory_amendments(memory_id)
483489
return fmt.Errorf("failed to add tool_usage.suggested_ids column: %w", err)
484490
}
485491

492+
// Record the schema version so pre-migration backups can skip when current.
493+
if _, err := db.Exec(fmt.Sprintf("PRAGMA user_version = %d", SchemaVersion)); err != nil {
494+
return fmt.Errorf("failed to set user_version: %w", err)
495+
}
496+
486497
return nil
487498
}
488499

0 commit comments

Comments
 (0)