Files
2026-08-27 14:00:20 +00:00

314 lines
10 KiB
Go

package persistence
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
)
type sqliteSelectionOutcome string
const (
sqliteSelectionFresh sqliteSelectionOutcome = "fresh"
sqliteSelectionExisting sqliteSelectionOutcome = "existing"
sqliteSelectionLegacyAdopted sqliteSelectionOutcome = "legacy_adopted"
sqliteSelectionConflict sqliteSelectionOutcome = "conflict"
)
type sqliteSelection struct {
path string
existedBeforeOpen bool
outcome sqliteSelectionOutcome
currentPath string
legacyPath string
currentTaskCount int64
legacyTaskCount int64
}
type sqliteCandidate struct {
path string
exists bool
tasksTable bool
hasTaskHistory bool
taskCount int64
}
// SQLiteDatabaseSelectionConflictError identifies the two default candidates
// that cannot be selected safely without operator direction.
type SQLiteDatabaseSelectionConflictError struct {
CurrentPath string
LegacyPath string
CurrentTaskCount int64
LegacyTaskCount int64
}
func (e *SQLiteDatabaseSelectionConflictError) Error() string {
return fmt.Sprintf(
"sqlite database selection conflict: current default %q has %d tasks, but legacy default %q has %d tasks; startup stopped without modifying either database, preserve both and select one explicitly with database.path or KANDEV_DATABASE_PATH",
e.CurrentPath,
e.CurrentTaskCount,
e.LegacyPath,
e.LegacyTaskCount,
)
}
// selectSQLiteDatabase runs after backend runtime-state ownership is acquired.
// The lock isolates selection and adoption from other Kandev instances.
func selectSQLiteDatabase(cfg *config.Config, log *logger.Logger) (sqliteSelection, error) {
if databasePathIsExplicit(cfg) {
path := strings.TrimSpace(cfg.Database.Path)
selection := sqliteSelection{
path: path,
outcome: sqliteSelectionExisting,
}
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
selection.outcome = sqliteSelectionFresh
} else if err != nil {
return sqliteSelection{}, fmt.Errorf("inspect explicit sqlite path %q: %w", path, err)
} else {
selection.existedBeforeOpen = true
}
logSQLiteSelection(log, cfg.SourceFor("database.path"), selection)
return selection, nil
}
currentPath := filepath.Join(cfg.ResolvedDataDir(), "kandev.db")
legacyPath := filepath.Join(cfg.ResolvedHomeDir(), "kandev.db")
current, err := inspectSQLiteCandidateLightweight(currentPath)
if err != nil {
return sqliteSelection{}, err
}
if current.exists && current.hasTaskHistory {
selection := sqliteSelection{
path: currentPath,
existedBeforeOpen: true,
outcome: sqliteSelectionExisting,
}
logSQLiteSelection(log, cfg.SourceFor("database.path"), selection)
return selection, nil
}
legacy := sqliteCandidate{}
if filepath.Clean(currentPath) != filepath.Clean(legacyPath) {
legacy, err = inspectSQLiteCandidate(legacyPath)
if err != nil {
return sqliteSelection{}, err
}
}
if current.exists {
if !current.hasTaskHistory && legacy.hasTaskHistory {
conflict := &SQLiteDatabaseSelectionConflictError{
CurrentPath: currentPath,
LegacyPath: legacyPath,
CurrentTaskCount: current.taskCount,
LegacyTaskCount: legacy.taskCount,
}
logSQLiteConflict(log, cfg.SourceFor("database.path"), conflict)
return sqliteSelection{}, conflict
}
selection := sqliteSelection{
path: currentPath,
existedBeforeOpen: true,
outcome: sqliteSelectionExisting,
}
logSQLiteSelection(log, cfg.SourceFor("database.path"), selection)
return selection, nil
}
if !legacy.exists {
selection := sqliteSelection{path: currentPath, outcome: sqliteSelectionFresh}
logSQLiteSelection(log, cfg.SourceFor("database.path"), selection)
return selection, nil
}
if err := adoptLegacySQLite(legacyPath, currentPath, legacy); err != nil {
return sqliteSelection{}, fmt.Errorf("adopt legacy sqlite database %q into %q: %w", legacyPath, currentPath, err)
}
selection := sqliteSelection{
path: currentPath,
outcome: sqliteSelectionLegacyAdopted,
currentPath: currentPath,
legacyPath: legacyPath,
currentTaskCount: current.taskCount,
legacyTaskCount: legacy.taskCount,
}
logSQLiteSelection(log, cfg.SourceFor("database.path"), selection)
return selection, nil
}
func databasePathIsExplicit(cfg *config.Config) bool {
if cfg == nil || strings.TrimSpace(cfg.Database.Path) == "" {
return false
}
// Configuration-file paths are explicit too; they cross the managed
// boundary through the internal config-file handoff.
switch cfg.SourceFor("database.path") {
case config.SourceConfiguration, config.SourceEnvironment:
return true
default:
return false
}
}
func inspectSQLiteCandidate(path string) (sqliteCandidate, error) {
return inspectSQLiteCandidateWithMode(path, true)
}
func inspectSQLiteCandidateLightweight(path string) (sqliteCandidate, error) {
return inspectSQLiteCandidateWithMode(path, false)
}
func inspectSQLiteCandidateWithMode(path string, checkIntegrity bool) (sqliteCandidate, error) {
candidate := sqliteCandidate{path: path}
info, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
return candidate, nil
}
if err != nil {
return candidate, fmt.Errorf("inspect sqlite candidate %q: %w", path, err)
}
if !info.Mode().IsRegular() {
return candidate, fmt.Errorf("inspect sqlite candidate %q: not a regular file", path)
}
candidate.exists = true
reader, err := openSQLiteReadOnly(path)
if err != nil {
return candidate, fmt.Errorf("open sqlite candidate %q: %w", path, err)
}
defer func() { _ = reader.Close() }()
if checkIntegrity {
var integrity string
if err := reader.Get(&integrity, `PRAGMA integrity_check`); err != nil {
return candidate, fmt.Errorf("check sqlite candidate %q integrity: %w", path, err)
}
if integrity != "ok" {
return candidate, fmt.Errorf("check sqlite candidate %q integrity: %s", path, integrity)
}
}
if err := reader.Get(&candidate.tasksTable, `SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tasks')`); err != nil {
return candidate, fmt.Errorf("inspect sqlite candidate %q task table: %w", path, err)
}
if candidate.tasksTable {
if checkIntegrity {
if err := reader.Get(&candidate.taskCount, `SELECT COUNT(*) FROM tasks`); err != nil {
return candidate, fmt.Errorf("count sqlite candidate %q tasks: %w", path, err)
}
candidate.hasTaskHistory = candidate.taskCount > 0
} else if err := reader.Get(&candidate.hasTaskHistory, `SELECT EXISTS (SELECT 1 FROM tasks LIMIT 1)`); err != nil {
return candidate, fmt.Errorf("inspect sqlite candidate %q task history: %w", path, err)
}
}
return candidate, nil
}
func openSQLiteReadOnly(path string) (*sqlx.DB, error) {
connection, err := db.OpenSQLiteReader(path)
if err != nil {
return nil, err
}
reader := sqlx.NewDb(connection, "sqlite3")
if err := reader.Ping(); err != nil {
_ = reader.Close()
return nil, err
}
return reader, nil
}
func adoptLegacySQLite(legacyPath, currentPath string, legacy sqliteCandidate) error {
// The caller holds runtime-state ownership for the legacy and current paths.
if err := os.MkdirAll(filepath.Dir(currentPath), 0o755); err != nil {
return fmt.Errorf("create data directory %q: %w", filepath.Dir(currentPath), err)
}
stagingDir, err := os.MkdirTemp(filepath.Dir(currentPath), ".kandev-legacy-*")
if err != nil {
return fmt.Errorf("create private sqlite staging directory: %w", err)
}
defer func() { _ = os.RemoveAll(stagingDir) }()
stagedPath := filepath.Join(stagingDir, "kandev.db")
source, err := openSQLiteReadOnly(legacyPath)
if err != nil {
return fmt.Errorf("open legacy sqlite database: %w", err)
}
defer func() { _ = source.Close() }()
if _, err := snapshotSQLite(source, stagedPath); err != nil {
return fmt.Errorf("snapshot legacy sqlite database: %w", err)
}
if err := os.Chmod(stagedPath, 0o600); err != nil {
return fmt.Errorf("protect staged sqlite database: %w", err)
}
staged, err := inspectSQLiteCandidate(stagedPath)
if err != nil {
return fmt.Errorf("validate staged sqlite database: %w", err)
}
if staged.taskCount != legacy.taskCount {
return fmt.Errorf("staged sqlite database task count = %d, want %d", staged.taskCount, legacy.taskCount)
}
if err := installStagedSQLite(stagedPath, currentPath); err != nil {
return err
}
return nil
}
// installStagedSQLite creates the target without replacing a file that may
// have appeared after candidate inspection. Both paths are on the same
// filesystem, so the hard-link creation is atomic and the staged name can be
// removed after installation.
func installStagedSQLite(stagedPath, currentPath string) error {
if err := os.Link(stagedPath, currentPath); err != nil {
return fmt.Errorf("install staged sqlite database without replacing existing target: %w", err)
}
if err := os.Remove(stagedPath); err != nil {
return fmt.Errorf("remove staged sqlite path after installation: %w", err)
}
return nil
}
func logSQLiteSelection(log *logger.Logger, source config.SettingSource, selection sqliteSelection) {
if log == nil {
return
}
fields := []zap.Field{
zap.String("db_path", selection.path),
zap.String("source", string(source)),
zap.Bool("existed_before_open", selection.existedBeforeOpen),
zap.String("outcome", string(selection.outcome)),
}
if selection.outcome == sqliteSelectionLegacyAdopted {
fields = append(fields,
zap.String("current_path", selection.currentPath),
zap.String("legacy_path", selection.legacyPath),
zap.Int64("current_task_count", selection.currentTaskCount),
zap.Int64("legacy_task_count", selection.legacyTaskCount),
)
}
log.Info("SQLite database selected", fields...)
}
func logSQLiteConflict(log *logger.Logger, source config.SettingSource, conflict *SQLiteDatabaseSelectionConflictError) {
if log == nil {
return
}
log.Warn("SQLite database selection conflict",
zap.String("source", string(source)),
zap.String("outcome", string(sqliteSelectionConflict)),
zap.String("current_path", conflict.CurrentPath),
zap.String("legacy_path", conflict.LegacyPath),
zap.Int64("current_task_count", conflict.CurrentTaskCount),
zap.Int64("legacy_task_count", conflict.LegacyTaskCount),
)
}