Skip to content

Commit b2ffafb

Browse files
fix: Backup database automatically on migration for data safety (#162)
2 parents 9f5da36 + 9bf50a8 commit b2ffafb

5 files changed

Lines changed: 440 additions & 32 deletions

File tree

internal/storage/backup.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ func GetBackupDir() (string, error) {
4242

4343
// CreateBackup uses SQLite's VACUUM INTO to produce a clean, consistent snapshot of the live database.
4444
func (d *Database) CreateBackup() (*BackupInfo, error) {
45+
return d.createBackup("")
46+
}
47+
48+
// createBackup writes a VACUUM INTO snapshot to the backups directory.
49+
func (d *Database) createBackup(suffix string) (*BackupInfo, error) {
4550
backupDir, err := GetBackupDir()
4651
if err != nil {
4752
return nil, err
@@ -52,7 +57,7 @@ func (d *Database) CreateBackup() (*BackupInfo, error) {
5257
}
5358

5459
now := time.Now()
55-
filename := fmt.Sprintf("tmpo-%s.db", now.Format("20060102-150405"))
60+
filename := fmt.Sprintf("tmpo-%s%s.db", now.Format("20060102-150405"), suffix)
5661
destPath := filepath.Join(backupDir, filename)
5762

5863
escapedPath := strings.ReplaceAll(destPath, "'", "''")

internal/storage/backup_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,138 @@ func TestRestoreBackup_SucceedsWithoutSidecars(t *testing.T) {
328328
assert.NoError(t, RestoreBackup(backup.Path))
329329
}
330330

331+
// writeLegacyDBFile creates a tmpo.db on disk with a pre-migration schema (no
332+
// hourly_rate/milestone_name columns and no migration keys marked complete),
333+
// simulating a database from before the migration system existed.
334+
func writeLegacyDBFile(t *testing.T, path string) {
335+
t.Helper()
336+
db, err := sql.Open("sqlite", path)
337+
assert.NoError(t, err)
338+
defer db.Close()
339+
340+
_, err = db.Exec(`
341+
CREATE TABLE time_entries (
342+
id INTEGER PRIMARY KEY AUTOINCREMENT,
343+
project_name TEXT NOT NULL,
344+
start_time DATETIME NOT NULL,
345+
end_time DATETIME,
346+
description TEXT
347+
)
348+
`)
349+
assert.NoError(t, err)
350+
351+
_, err = db.Exec(`
352+
CREATE TABLE milestones (
353+
id INTEGER PRIMARY KEY AUTOINCREMENT,
354+
project_name TEXT NOT NULL,
355+
name TEXT NOT NULL,
356+
start_time DATETIME NOT NULL,
357+
end_time DATETIME,
358+
UNIQUE(project_name, name)
359+
)
360+
`)
361+
assert.NoError(t, err)
362+
}
363+
364+
func TestInitialize_RemovesPreMigrationBackupOnSuccess(t *testing.T) {
365+
tmpHome := t.TempDir()
366+
t.Setenv("HOME", tmpHome)
367+
t.Setenv("USERPROFILE", tmpHome)
368+
t.Setenv("TMPO_DEV", "")
369+
370+
// Seed a legacy database file with pending migrations
371+
tmpoDir := filepath.Join(tmpHome, ".tmpo")
372+
assert.NoError(t, os.MkdirAll(tmpoDir, 0700))
373+
writeLegacyDBFile(t, filepath.Join(tmpoDir, "tmpo.db"))
374+
375+
db, err := Initialize()
376+
assert.NoError(t, err)
377+
defer db.Close()
378+
379+
// Migrations succeeded, so the temporary pre-migration snapshot must be gone.
380+
// The user should see no backups they did not create themselves.
381+
backups, err := ListBackups()
382+
assert.NoError(t, err)
383+
assert.Empty(t, backups, "successful migration should leave no pre-migration backup behind")
384+
385+
// Migrations should have completed and upgraded the schema
386+
pending, err := db.hasPendingMigrations()
387+
assert.NoError(t, err)
388+
assert.False(t, pending)
389+
assert.True(t, hasColumn(t, db, "time_entries", "milestone_name"))
390+
}
391+
392+
func TestInitialize_RetainsPreMigrationBackupOnFailure(t *testing.T) {
393+
tmpHome := t.TempDir()
394+
t.Setenv("HOME", tmpHome)
395+
t.Setenv("USERPROFILE", tmpHome)
396+
t.Setenv("TMPO_DEV", "")
397+
398+
// Seed a legacy database whose start_time cannot be scanned as a timestamp,
399+
// forcing the UTC migration to fail partway through.
400+
tmpoDir := filepath.Join(tmpHome, ".tmpo")
401+
assert.NoError(t, os.MkdirAll(tmpoDir, 0700))
402+
dbPath := filepath.Join(tmpoDir, "tmpo.db")
403+
writeLegacyDBFile(t, dbPath)
404+
405+
seed, err := sql.Open("sqlite", dbPath)
406+
assert.NoError(t, err)
407+
_, err = seed.Exec(
408+
"INSERT INTO time_entries (project_name, start_time, description) VALUES (?, ?, ?)",
409+
"broken-project", "not-a-timestamp", "corrupt row",
410+
)
411+
assert.NoError(t, err)
412+
assert.NoError(t, seed.Close())
413+
414+
// Initialize must fail, and the error must point at the retained snapshot
415+
_, err = Initialize()
416+
assert.Error(t, err)
417+
assert.Contains(t, err.Error(), "pre-migration backup was preserved")
418+
419+
// The snapshot must still be present for the user to restore from
420+
backups, err := ListBackups()
421+
assert.NoError(t, err)
422+
assert.Len(t, backups, 1)
423+
assert.True(t, strings.HasSuffix(backups[0].Filename, "-premigration.db"))
424+
}
425+
426+
func TestInitialize_NoBackupForFreshDB(t *testing.T) {
427+
tmpHome := t.TempDir()
428+
t.Setenv("HOME", tmpHome)
429+
t.Setenv("USERPROFILE", tmpHome)
430+
t.Setenv("TMPO_DEV", "")
431+
432+
// No pre-existing database file: this is a brand-new install
433+
db, err := Initialize()
434+
assert.NoError(t, err)
435+
defer db.Close()
436+
437+
backups, err := ListBackups()
438+
assert.NoError(t, err)
439+
assert.Empty(t, backups, "fresh database should not trigger a pre-migration backup")
440+
}
441+
442+
func TestInitialize_NoBackupWhenAlreadyMigrated(t *testing.T) {
443+
tmpHome := t.TempDir()
444+
t.Setenv("HOME", tmpHome)
445+
t.Setenv("USERPROFILE", tmpHome)
446+
t.Setenv("TMPO_DEV", "")
447+
448+
// First run creates and fully migrates the database
449+
db, err := Initialize()
450+
assert.NoError(t, err)
451+
assert.NoError(t, db.Close())
452+
453+
// Second run against the up-to-date database must not create a backup
454+
db2, err := Initialize()
455+
assert.NoError(t, err)
456+
defer db2.Close()
457+
458+
backups, err := ListBackups()
459+
assert.NoError(t, err)
460+
assert.Empty(t, backups, "already-migrated database should not trigger a pre-migration backup")
461+
}
462+
331463
func TestCreateBackup_SetsPrivatePermissions(t *testing.T) {
332464
if runtime.GOOS == "windows" {
333465
t.Skip("POSIX permissions are not enforced on Windows")

internal/storage/db.go

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ package storage
33
import (
44
"database/sql"
55
"fmt"
6+
"os"
67
"path/filepath"
7-
"strings"
88
"time"
99

1010
"github.com/DylanDevelops/tmpo/internal/fsperm"
@@ -31,20 +31,32 @@ func Initialize() (*Database, error) {
3131
}
3232

3333
dbPath := filepath.Join(tmpoDir, "tmpo.db")
34+
35+
_, statErr := os.Stat(dbPath)
36+
dbExisted := statErr == nil
37+
3438
db, err := sql.Open("sqlite", dbPath)
3539

3640
if err != nil {
3741
return nil, fmt.Errorf("failed to open database: %w", err)
3842
}
3943

44+
success := false
45+
defer func() {
46+
if !success {
47+
db.Close()
48+
}
49+
}()
50+
4051
_, err = db.Exec(`
4152
CREATE TABLE IF NOT EXISTS time_entries (
4253
id INTEGER PRIMARY KEY AUTOINCREMENT,
4354
project_name TEXT NOT NULL,
4455
start_time DATETIME NOT NULL,
4556
end_time DATETIME,
4657
description TEXT,
47-
hourly_rate REAL
58+
hourly_rate REAL,
59+
milestone_name TEXT
4860
)
4961
`)
5062

@@ -67,26 +79,6 @@ func Initialize() (*Database, error) {
6779
return nil, fmt.Errorf("failed to create milestones table: %w", err)
6880
}
6981

70-
_, err = db.Exec(`ALTER TABLE time_entries ADD COLUMN hourly_rate REAL`)
71-
if err != nil && !isColumnExistsError(err) {
72-
return nil, fmt.Errorf("failed to add hourly_rate column: %w", err)
73-
}
74-
75-
_, err = db.Exec(`ALTER TABLE time_entries ADD COLUMN milestone_name TEXT`)
76-
if err != nil && !isColumnExistsError(err) {
77-
return nil, fmt.Errorf("failed to add milestone_name column: %w", err)
78-
}
79-
80-
_, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_time_entries_milestone ON time_entries(milestone_name)`)
81-
if err != nil {
82-
return nil, fmt.Errorf("failed to create index: %w", err)
83-
}
84-
85-
_, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_milestones_project_active ON milestones(project_name, end_time)`)
86-
if err != nil {
87-
return nil, fmt.Errorf("failed to create index: %w", err)
88-
}
89-
9082
// settings table for tracking migrations and other metadata
9183
_, err = db.Exec(`
9284
CREATE TABLE IF NOT EXISTS settings (
@@ -101,10 +93,34 @@ func Initialize() (*Database, error) {
10193

10294
database := &Database{db: db}
10395

96+
var preMigrationBackupPath string
97+
if dbExisted {
98+
pending, err := database.hasPendingMigrations()
99+
if err != nil {
100+
return nil, fmt.Errorf("failed to check for pending migrations: %w", err)
101+
}
102+
if pending {
103+
backup, err := database.createBackup("-premigration")
104+
if err != nil {
105+
return nil, fmt.Errorf("failed to create pre-migration backup: %w", err)
106+
}
107+
preMigrationBackupPath = backup.Path
108+
}
109+
}
110+
104111
if err := database.runMigrations(); err != nil {
112+
if preMigrationBackupPath != "" {
113+
return nil, fmt.Errorf("failed to run migrations (a pre-migration backup was preserved at %s): %w", preMigrationBackupPath, err)
114+
}
105115
return nil, fmt.Errorf("failed to run migrations: %w", err)
106116
}
107117

118+
if preMigrationBackupPath != "" {
119+
if err := os.Remove(preMigrationBackupPath); err != nil && !os.IsNotExist(err) {
120+
return nil, fmt.Errorf("failed to remove temporary pre-migration backup: %w", err)
121+
}
122+
}
123+
108124
if err := fsperm.SecureFile(dbPath); err != nil {
109125
return nil, err
110126
}
@@ -114,18 +130,10 @@ func Initialize() (*Database, error) {
114130
}
115131
}
116132

133+
success = true
117134
return database, nil
118135
}
119136

120-
func isColumnExistsError(err error) bool {
121-
if err == nil {
122-
return false
123-
}
124-
errMsg := err.Error()
125-
return strings.Contains(errMsg, "duplicate column name") ||
126-
strings.Contains(errMsg, "duplicate column")
127-
}
128-
129137
func (d *Database) CreateEntry(projectName, description string, hourlyRate *float64, milestoneName *string) (*TimeEntry, error) {
130138
var rate sql.NullFloat64
131139
if hourlyRate != nil {

0 commit comments

Comments
 (0)