Files
kandev/apps/backend/internal/system/database/reset.go
T
2026-08-15 19:34:52 +01:00

167 lines
5.2 KiB
Go

package database
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"time"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
)
// resetConfirmToken is the literal value the client must POST as
// {"confirm": "..."} to authorise a factory reset. Anything else is
// rejected with a 400 / typed error.
const resetConfirmToken = "RESET"
// ErrResetNotConfirmed is returned when the confirm value supplied to
// FactoryReset does not equal the resetConfirmToken sentinel.
var ErrResetNotConfirmed = errors.New("factory reset requires confirm=\"RESET\"")
// FactoryReset orchestrates a destructive wipe of the kandev install. The
// caller must pass confirm == "RESET" — anything else returns
// ErrResetNotConfirmed without starting a job. On success the job ID is
// returned immediately; the heavy work runs asynchronously via the jobs
// tracker.
//
// The job:
// 1. Calls s.OrchestratorShutdown (if set) to stop running executions.
// 2. Snapshots the live DB to the sibling backups directory.
// 3. Drops every user table from the SQLite schema (kandev_meta is kept).
// 4. os.RemoveAll on worktrees/repos/sessions/tasks/quick-chat subdirs.
//
// On success the result map exposes {"snapshot_path", "tables_dropped",
// "restart_required": true}. The frontend dialog uses restart_required to
// prompt the user to quit and relaunch Kandev — no auto re-exec.
func (s *Service) FactoryReset(ctx context.Context, confirm string) (string, error) {
if confirm != resetConfirmToken {
return "", ErrResetNotConfirmed
}
return s.jobs.Start(ctx, "factory-reset", func(jobCtx context.Context) (map[string]interface{}, error) {
return s.runFactoryReset(jobCtx)
}), nil
}
func (s *Service) runFactoryReset(_ context.Context) (map[string]interface{}, error) {
if err := s.requireSQLiteMaintenance("factory reset"); err != nil {
return nil, err
}
if s.OrchestratorShutdown != nil {
s.OrchestratorShutdown()
}
snapshotPath, err := s.createPreResetSnapshot()
if err != nil {
return nil, fmt.Errorf("pre-reset snapshot: %w", err)
}
dropped, err := dropUserTables(s.pool.Writer())
if err != nil {
return nil, fmt.Errorf("drop tables: %w", err)
}
if err := s.wipeSubdirs(); err != nil {
return nil, fmt.Errorf("wipe subdirs: %w", err)
}
// Intentionally no auto-restart. The frontend dialog reads
// restart_required from the job result and asks the user to quit and
// relaunch the app.
return map[string]interface{}{
"snapshot_path": snapshotPath,
"tables_dropped": dropped,
"restart_required": true,
}, nil
}
// createPreResetSnapshot performs VACUUM INTO into a stable, time-stamped
// path inside the backups directory. The "kandev-" prefix is kept so the
// existing backup retention regex continues to match the file.
func (s *Service) createPreResetSnapshot() (string, error) {
if s.pool == nil {
return "", fmt.Errorf("no database pool")
}
backupDir := s.backupsDir()
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return "", fmt.Errorf("mkdir backups: %w", err)
}
name := fmt.Sprintf("kandev-pre-reset-%s.db", strconv.FormatInt(time.Now().UTC().Unix(), 10))
path := filepath.Join(backupDir, name)
if _, err := s.pool.Writer().Exec(`VACUUM INTO ?`, path); err != nil {
return "", fmt.Errorf("vacuum into %s: %w", path, err)
}
if s.log != nil {
s.log.Info("pre-reset snapshot created", zap.String("path", path))
}
return path, nil
}
// dropUserTables drops every row in sqlite_master that is a non-internal
// table other than kandev_meta. Returns the count of tables dropped.
func dropUserTables(writer *sqlx.DB) (int, error) {
rows, err := writer.Query(`
SELECT name FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
AND name != 'kandev_meta'
`)
if err != nil {
return 0, fmt.Errorf("enumerate user tables: %w", err)
}
var names []string
for rows.Next() {
var n string
if scanErr := rows.Scan(&n); scanErr != nil {
_ = rows.Close()
return 0, fmt.Errorf("scan table name: %w", scanErr)
}
names = append(names, n)
}
if closeErr := rows.Close(); closeErr != nil {
return 0, fmt.Errorf("close rows: %w", closeErr)
}
// FK enforcement is on for the writer pool — defer constraint checks so
// drop order doesn't matter. SQLite ignores PRAGMA foreign_keys inside a
// transaction, so toggle it at the connection level.
if _, err := writer.Exec("PRAGMA foreign_keys = OFF"); err != nil {
return 0, fmt.Errorf("disable foreign keys: %w", err)
}
defer func() { _, _ = writer.Exec("PRAGMA foreign_keys = ON") }()
for _, n := range names {
if _, err := writer.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %q", n)); err != nil {
return 0, fmt.Errorf("drop table %s: %w", n, err)
}
}
return len(names), nil
}
// wipeSubdirs removes the worktrees/repos/sessions/tasks/quick-chat
// subdirs configured at construction. Empty paths are skipped so partial
// wiring is harmless in tests.
func (s *Service) wipeSubdirs() error {
targets := []string{
s.dirs.Worktrees,
s.dirs.Repos,
s.dirs.Sessions,
s.dirs.Tasks,
s.dirs.QuickChat,
}
for _, t := range targets {
if t == "" {
continue
}
if err := os.RemoveAll(t); err != nil {
return fmt.Errorf("remove %s: %w", t, err)
}
}
return nil
}