259 lines
7.5 KiB
Go
259 lines
7.5 KiB
Go
// Package database serves the System -> Database page. It exposes read-only
|
|
// database stats plus SQLite maintenance operations: VACUUM, PRAGMA optimize,
|
|
// and Factory Reset. Long-running operations are tracked via the jobs.Tracker
|
|
// so the frontend can observe progress over the event bus.
|
|
package database
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/db/dialect"
|
|
"github.com/kandev/kandev/internal/system/jobs"
|
|
)
|
|
|
|
const (
|
|
databaseDriverPostgres = "postgres"
|
|
databaseDriverSQLite = "sqlite"
|
|
)
|
|
|
|
// Stats is the read-only database-state payload returned to the frontend.
|
|
//
|
|
// LastBackupAt is a pointer so the JSON shape is `null` when no backup
|
|
// exists. Serialising a zero time.Time as "0001-01-01T00:00:00Z" would
|
|
// defeat the frontend's "Never" fallback in database-stats-card.tsx.
|
|
type Stats struct {
|
|
Driver string `json:"driver"`
|
|
Path string `json:"path"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
WALSizeBytes int64 `json:"wal_size_bytes"`
|
|
SchemaVersion string `json:"schema_version"`
|
|
LastBackupAt *time.Time `json:"last_backup_at"`
|
|
}
|
|
|
|
// ResetDirs lists the on-disk directories factory-reset wipes. The Service
|
|
// only needs paths to call os.RemoveAll on — the construction site (cmd/kandev)
|
|
// fills these in from the resolved data/home dirs.
|
|
type ResetDirs struct {
|
|
Worktrees string
|
|
Repos string
|
|
Sessions string
|
|
Tasks string
|
|
QuickChat string
|
|
}
|
|
|
|
// Service is the maintenance + stats facade for the System -> Database page.
|
|
//
|
|
// FactoryReset does not auto-restart the backend. The job result includes
|
|
// restart_required=true; the frontend dialog reads it and asks the user to
|
|
// quit and relaunch Kandev. The previous syscall.Exec approach was brittle
|
|
// under desktop launchers and `make dev` watchers.
|
|
type Service struct {
|
|
pool *db.Pool
|
|
databasePath string
|
|
dirs ResetDirs
|
|
jobs *jobs.Tracker
|
|
log *logger.Logger
|
|
|
|
// OrchestratorShutdown stops the orchestrator and active executions before
|
|
// the factory-reset job runs. Wired by cmd/kandev. Tests pass a no-op.
|
|
OrchestratorShutdown func()
|
|
}
|
|
|
|
// NewService constructs a Service for the configured SQLite database path.
|
|
// The sibling backups directory is derived from databasePath. dirs lists the
|
|
// on-disk subtrees factory-reset wipes.
|
|
func NewService(pool *db.Pool, databasePath string, dirs ResetDirs, j *jobs.Tracker, log *logger.Logger) *Service {
|
|
return &Service{
|
|
pool: pool,
|
|
databasePath: databasePath,
|
|
dirs: dirs,
|
|
jobs: j,
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
func (s *Service) backupsDir() string {
|
|
return filepath.Join(filepath.Dir(s.databasePath), "backups")
|
|
}
|
|
|
|
// Stats returns the current database stats. SQLite size is computed from
|
|
// PRAGMA page_count * page_size (cheaper than os.Stat on hot writes and more
|
|
// accurate during a VACUUM that creates a sibling file). Postgres size is
|
|
// reported from pg_database_size(current_database()).
|
|
func (s *Service) Stats() (Stats, error) {
|
|
driver := s.databaseDriver()
|
|
out := Stats{Driver: driver}
|
|
if driver == databaseDriverSQLite {
|
|
out.Path = s.databasePath
|
|
}
|
|
|
|
if s.pool != nil {
|
|
size, err := readDatabaseSize(s.pool.Reader())
|
|
if err != nil {
|
|
return Stats{}, err
|
|
}
|
|
out.SizeBytes = size
|
|
|
|
version, err := readSchemaVersion(s.pool.Reader())
|
|
if err != nil {
|
|
return Stats{}, err
|
|
}
|
|
out.SchemaVersion = version
|
|
}
|
|
|
|
if driver == databaseDriverSQLite {
|
|
if wal, err := walSize(s.databasePath); err == nil {
|
|
out.WALSizeBytes = wal
|
|
}
|
|
|
|
if last := lastBackupAt(s.backupsDir()); !last.IsZero() {
|
|
out.LastBackupAt = &last
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Service) databaseDriver() string {
|
|
if s.pool == nil || s.pool.Writer() == nil {
|
|
return databaseDriverSQLite
|
|
}
|
|
switch driver := s.pool.Writer().DriverName(); {
|
|
case dialect.IsPostgres(driver):
|
|
return databaseDriverPostgres
|
|
case driver == dialect.SQLite3:
|
|
return databaseDriverSQLite
|
|
default:
|
|
return driver
|
|
}
|
|
}
|
|
|
|
func readDatabaseSize(d *sqlx.DB) (int64, error) {
|
|
if dialect.IsPostgres(d.DriverName()) {
|
|
return readPostgresDBSize(d)
|
|
}
|
|
return readSQLiteDBSize(d)
|
|
}
|
|
|
|
// readSQLiteDBSize returns the database size in bytes via PRAGMA page_count *
|
|
// page_size. Both PRAGMAs return a single integer row.
|
|
func readSQLiteDBSize(d interface {
|
|
QueryRow(query string, args ...interface{}) *sql.Row
|
|
}) (int64, error) {
|
|
var pages, pageSize int64
|
|
if err := d.QueryRow("PRAGMA page_count").Scan(&pages); err != nil {
|
|
return 0, fmt.Errorf("pragma page_count: %w", err)
|
|
}
|
|
if err := d.QueryRow("PRAGMA page_size").Scan(&pageSize); err != nil {
|
|
return 0, fmt.Errorf("pragma page_size: %w", err)
|
|
}
|
|
return pages * pageSize, nil
|
|
}
|
|
|
|
func readPostgresDBSize(d interface {
|
|
QueryRow(query string, args ...interface{}) *sql.Row
|
|
}) (int64, error) {
|
|
var size int64
|
|
if err := d.QueryRow("SELECT pg_database_size(current_database())").Scan(&size); err != nil {
|
|
return 0, fmt.Errorf("pg_database_size: %w", err)
|
|
}
|
|
return size, nil
|
|
}
|
|
|
|
// readSchemaVersion reads the binary version recorded by
|
|
// cmd/kandev/storage.go:recordSchemaVersion. Missing key returns "" with
|
|
// no error (fresh DB on first boot).
|
|
func readSchemaVersion(d *sqlx.DB) (string, error) {
|
|
var value string
|
|
err := d.QueryRow(d.Rebind(`SELECT value FROM kandev_meta WHERE key = ?`), "kandev_version").Scan(&value)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("read kandev_version: %w", err)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// walSize stats <dbPath>-wal and returns its size in bytes; a missing WAL
|
|
// file is treated as zero (the DB may have been just checkpointed).
|
|
func walSize(dbPath string) (int64, error) {
|
|
info, err := os.Stat(dbPath + "-wal")
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return 0, nil
|
|
}
|
|
return 0, err
|
|
}
|
|
return info.Size(), nil
|
|
}
|
|
|
|
// lastBackupAt returns the mtime of the newest file in backupDir, or the
|
|
// zero time if the directory is missing/empty. A directory read error is
|
|
// treated as "no backups" — the Stats endpoint is best-effort.
|
|
func lastBackupAt(backupDir string) time.Time {
|
|
entries, err := os.ReadDir(backupDir)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
mtimes := make([]time.Time, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
fi, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
mtimes = append(mtimes, fi.ModTime())
|
|
}
|
|
if len(mtimes) == 0 {
|
|
return time.Time{}
|
|
}
|
|
sort.Slice(mtimes, func(i, j int) bool { return mtimes[i].After(mtimes[j]) })
|
|
return mtimes[0]
|
|
}
|
|
|
|
// HandleStats returns a gin handler for GET /api/v1/system/database.
|
|
func HandleStats(s *Service) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
stats, err := s.Stats()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, stats)
|
|
}
|
|
}
|
|
|
|
// startJobResponse is the canonical 202 payload for POST endpoints that
|
|
// spawn a background job.
|
|
type startJobResponse struct {
|
|
JobID string `json:"job_id"`
|
|
}
|
|
|
|
func respondAccepted(c *gin.Context, jobID string) {
|
|
c.JSON(http.StatusAccepted, startJobResponse{JobID: jobID})
|
|
}
|
|
|
|
// ctxOrBackground returns the context from the gin request when available,
|
|
// falling back to context.Background() for direct service callers.
|
|
func ctxOrBackground(c *gin.Context) context.Context {
|
|
if c == nil || c.Request == nil {
|
|
return context.Background()
|
|
}
|
|
return c.Request.Context()
|
|
}
|