Files
kandev/apps/backend/internal/system/database/reset.go
T

201 lines
6.7 KiB
Go

package database
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"time"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/persistence"
"github.com/kandev/kandev/internal/system/maintenance"
)
// 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. Accepted jobs outlive the caller context.
//
// The job:
// 1. Calls s.OrchestratorShutdown (if set) to stop running executions.
// 2. Calls s.DatabaseQuiesce (if set) to stop database-backed workers.
// 3. Snapshots the live DB to the sibling backups directory.
// 4. Drops every user table from the SQLite schema (kandev_meta is kept).
// 5. 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(context.WithoutCancel(ctx), "factory-reset", func(jobCtx context.Context) (map[string]interface{}, error) {
return s.runFactoryReset(jobCtx)
}), nil
}
func (s *Service) runFactoryReset(ctx context.Context) (map[string]interface{}, error) {
release, err := maintenance.ForPool(s.pool).Acquire(ctx)
if err != nil {
return nil, err
}
defer release()
if err := s.requireSQLiteMaintenance("factory reset"); err != nil {
return nil, err
}
if s.PersistenceUnavailable != nil {
s.PersistenceUnavailable()
}
if s.OrchestratorShutdown != nil {
s.OrchestratorShutdown()
}
if s.DatabaseQuiesce != nil {
if err := s.DatabaseQuiesce(); err != nil {
return nil, fmt.Errorf("quiesce database workers: %w", err)
}
}
snapshotPath, err := s.createPreResetSnapshot(ctx)
if err != nil {
return nil, fmt.Errorf("pre-reset snapshot: %w", err)
}
// Delete the delivery-ledger and run-outcome activation keys BEFORE
// dropping any user table (docs/specs/task-delivery-ledger/spec.md,
// "Reset parity"). kandev_meta itself survives the drop loop below, so
// a stale activation key left behind would point at a boot instant
// before data that no longer exists — a post-reset task with no
// ledger row would then read as "observed as nothing" rather than
// "not observed". Ordering matters because the drop loop is not
// transactional: if key deletion fails, the reset fails here and no
// table is dropped, which is the fail-safe order — the alternative
// (drop first, delete keys second) can leave a present key pointing
// at data a later step just removed.
if err := persistence.DeleteKeys(s.pool.Writer(),
"telemetry.delivery_ledger.activated_at", "telemetry.run_outcome.activated_at",
); err != nil {
return nil, fmt.Errorf("delete activation keys: %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(ctx context.Context) (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 := persistence.SnapshotSQLiteContext(ctx, s.pool.Writer(), 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
}