fix: preserve legacy sqlite database continuity (#3089)
This commit is contained in:
@@ -114,6 +114,56 @@ func TestLauncherBackendEnvironmentHandsOffSelectedConfigFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendEnvDoesNotSynthesizeDefaultDatabasePath(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
unsetEnvForTest(t, "KANDEV_DATABASE_PATH")
|
||||
|
||||
cfg := &config.Config{
|
||||
HomeDir: t.TempDir(),
|
||||
Source: config.ConfigSource{Values: map[string]config.SettingSource{
|
||||
"database.path": config.SourceDefault,
|
||||
}},
|
||||
}
|
||||
env := backendEnvForConfig(
|
||||
portConfig{BackendPort: 4321, AgentctlPort: 4322},
|
||||
"info",
|
||||
"warn",
|
||||
false,
|
||||
"health-token",
|
||||
nil,
|
||||
cfg,
|
||||
)
|
||||
for _, item := range env {
|
||||
if strings.HasPrefix(item, "KANDEV_DATABASE_PATH=") {
|
||||
t.Fatalf("default database path was synthesized into child environment: %q", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendEnvPreservesExplicitEnvironmentDatabasePath(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
explicitPath := filepath.Join(t.TempDir(), "custom.db")
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{Path: explicitPath},
|
||||
Source: config.ConfigSource{Values: map[string]config.SettingSource{
|
||||
"database.path": config.SourceEnvironment,
|
||||
}},
|
||||
}
|
||||
|
||||
env := backendEnvForConfig(
|
||||
portConfig{BackendPort: 4321, AgentctlPort: 4322},
|
||||
"info",
|
||||
"warn",
|
||||
false,
|
||||
"health-token",
|
||||
nil,
|
||||
cfg,
|
||||
)
|
||||
if got := envValue(env, "KANDEV_DATABASE_PATH"); got != explicitPath {
|
||||
t.Fatalf("explicit database path = %q, want %q", got, explicitPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherSelectedConfigHandoffSurvivesWorkingDirectoryChange(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
sourceDir := t.TempDir()
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package launcher
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/persistence"
|
||||
)
|
||||
|
||||
func TestLauncherDefaultDatabaseHandoffPreservesLegacyTask(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
unsetEnvForTest(t, "KANDEV_DATABASE_PATH")
|
||||
unsetEnvForTest(t, "KANDEV_DATABASE_DRIVER")
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("KANDEV_HOME_DIR", homeDir)
|
||||
legacyPath := filepath.Join(homeDir, "kandev.db")
|
||||
seedLauncherLegacyTaskDatabase(t, legacyPath)
|
||||
|
||||
startupConfig, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load startup configuration: %v", err)
|
||||
}
|
||||
if got := startupConfig.SourceFor("database.path"); got != config.SourceDefault {
|
||||
t.Fatalf("database.path source = %q, want %q", got, config.SourceDefault)
|
||||
}
|
||||
|
||||
childEnv := backendEnvForConfig(
|
||||
portConfig{BackendPort: 4321, AgentctlPort: 4322},
|
||||
"info",
|
||||
"warn",
|
||||
false,
|
||||
"health-token",
|
||||
nil,
|
||||
startupConfig,
|
||||
)
|
||||
for _, item := range childEnv {
|
||||
if strings.HasPrefix(item, "KANDEV_DATABASE_PATH=") {
|
||||
t.Fatalf("default database path was handed to child as an explicit override: %q", item)
|
||||
}
|
||||
}
|
||||
|
||||
childConfig, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load child configuration: %v", err)
|
||||
}
|
||||
if childConfig.Database.Path != "" {
|
||||
t.Fatalf("child database.path = %q, want a default-derived path", childConfig.Database.Path)
|
||||
}
|
||||
|
||||
pool, cleanup, err := persistence.Provide(childConfig, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("provide child database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
|
||||
var title string
|
||||
if err := pool.Reader().Get(&title, `SELECT title FROM tasks WHERE id = 'handoff-task'`); err != nil {
|
||||
t.Fatalf("read handed-off legacy task: %v", err)
|
||||
}
|
||||
if title != "handoff task" {
|
||||
t.Fatalf("handed-off task title = %q, want %q", title, "handoff task")
|
||||
}
|
||||
}
|
||||
|
||||
func seedLauncherLegacyTaskDatabase(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
database, err := sqlx.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if _, err := database.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL)`); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("create legacy tasks: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, title) VALUES ('handoff-task', 'handoff task')`); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("insert legacy task: %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("close legacy database: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func backendEnvForConfig(ports portConfig, logLevel, consoleLogLevel string, deb
|
||||
env := stripAppliedProfileEnvironment(os.Environ())
|
||||
env = upsertEnv(env, "KANDEV_SERVER_PORT", fmt.Sprint(ports.BackendPort))
|
||||
env = upsertEnv(env, "KANDEV_AGENT_STANDALONE_PORT", fmt.Sprint(ports.AgentctlPort))
|
||||
if cfg == nil || cfg.SourceFor("database.path") != config.SourceConfiguration || strings.TrimSpace(cfg.Database.Path) == "" {
|
||||
if cfg != nil && cfg.SourceFor("database.path") == config.SourceEnvironment && strings.TrimSpace(cfg.Database.Path) != "" {
|
||||
env = upsertEnv(env, "KANDEV_DATABASE_PATH", resolveDatabasePathForConfig(cfg))
|
||||
}
|
||||
if configPath := configFileForChild(cfg); configPath != "" {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -41,13 +39,11 @@ func Provide(cfg *config.Config, log *logger.Logger, version string) (*db.Pool,
|
||||
}
|
||||
|
||||
func provideSQLite(cfg *config.Config, log *logger.Logger, version string) (*db.Pool, func() error, error) {
|
||||
dbPath := cfg.Database.Path
|
||||
if dbPath == "" {
|
||||
dbPath = filepath.Join(cfg.ResolvedDataDir(), "kandev.db")
|
||||
if err := migrateLegacyDBPath(cfg, dbPath, log); err != nil {
|
||||
return nil, nil, fmt.Errorf("migrate legacy DB: %w", err)
|
||||
}
|
||||
selection, err := selectSQLiteDatabase(cfg, log)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("select sqlite database: %w", err)
|
||||
}
|
||||
dbPath := selection.path
|
||||
|
||||
// Writer: single connection, owns WAL/journal_mode setup.
|
||||
writerConn, err := db.OpenSQLite(dbPath)
|
||||
@@ -132,50 +128,6 @@ func provideSQLite(cfg *config.Config, log *logger.Logger, version string) (*db.
|
||||
return pool, cleanup, nil
|
||||
}
|
||||
|
||||
// migrateLegacyDBPath moves a pre-KANDEV_HOME_DIR SQLite DB from
|
||||
// <HomeDir>/kandev.db into the new derived location at <HomeDir>/data/kandev.db
|
||||
// on first boot after an upgrade, so `docker pull && docker restart` doesn't
|
||||
// silently start against an empty DB.
|
||||
//
|
||||
// Runs only when:
|
||||
// - KANDEV_DATABASE_PATH is not explicitly set (caller checks this).
|
||||
// - The new derived path does not exist yet.
|
||||
// - A legacy file exists at <HomeDir>/kandev.db.
|
||||
// - The legacy path differs from the new path (skip when HomeDir == DataDir).
|
||||
//
|
||||
// Only the main .db file is moved - SQLite recreates -wal/-shm on open, and a
|
||||
// cleanly-shut-down DB (the expected state on container restart) has empty WAL.
|
||||
func migrateLegacyDBPath(cfg *config.Config, newPath string, log *logger.Logger) error {
|
||||
if _, err := os.Stat(newPath); !errors.Is(err, fs.ErrNotExist) {
|
||||
return nil // new path exists (or stat failed) - nothing to migrate
|
||||
}
|
||||
legacyPath := filepath.Join(cfg.ResolvedHomeDir(), "kandev.db")
|
||||
if legacyPath == newPath {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(legacyPath); err != nil {
|
||||
return nil // no legacy file to migrate
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create data dir %s: %w", filepath.Dir(newPath), err)
|
||||
}
|
||||
if err := os.Rename(legacyPath, newPath); err != nil {
|
||||
return fmt.Errorf("move %s -> %s: %w", legacyPath, newPath, err)
|
||||
}
|
||||
// Also move -wal / -shm if present. These are transient (SQLite recreates
|
||||
// them on open) but moving them avoids orphaned files on the volume.
|
||||
for _, suffix := range []string{"-wal", "-shm"} {
|
||||
_ = os.Rename(legacyPath+suffix, newPath+suffix)
|
||||
}
|
||||
if log != nil {
|
||||
log.Info("Migrated SQLite database from pre-KANDEV_HOME_DIR location",
|
||||
zap.String("legacy_path", legacyPath),
|
||||
zap.String("new_path", newPath),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func providePostgres(cfg *config.Config, log *logger.Logger) (*db.Pool, func() error, error) {
|
||||
dsn := cfg.Database.DSN()
|
||||
dbConn, err := db.OpenPostgres(dsn, cfg.Database.MaxConns, cfg.Database.MinConns)
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
func seedLegacyTaskDatabase(t *testing.T, path string) []byte {
|
||||
return seedTaskDatabase(t, path, "task-1", "legacy task")
|
||||
}
|
||||
|
||||
func seedTaskDatabase(t *testing.T, path, id, title string) []byte {
|
||||
t.Helper()
|
||||
database, err := sqlx.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if _, err := database.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL)`); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("create tasks: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, title) VALUES (?, ?)`, id, title); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("close legacy database: %v", err)
|
||||
}
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read legacy database: %v", err)
|
||||
}
|
||||
return contents
|
||||
}
|
||||
|
||||
func TestProvideAdoptsLegacySQLiteWithoutDeletingSource(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
legacyPath := filepath.Join(homeDir, "kandev.db")
|
||||
newPath := filepath.Join(homeDir, "data", "kandev.db")
|
||||
legacyContents := seedLegacyTaskDatabase(t, legacyPath)
|
||||
|
||||
cfg := &config.Config{HomeDir: homeDir}
|
||||
pool, cleanup, err := Provide(cfg, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Provide: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
|
||||
var title string
|
||||
if err := pool.Reader().Get(&title, `SELECT title FROM tasks WHERE id = 'task-1'`); err != nil {
|
||||
t.Fatalf("read adopted task: %v", err)
|
||||
}
|
||||
if title != "legacy task" {
|
||||
t.Fatalf("adopted task title = %q, want %q", title, "legacy task")
|
||||
}
|
||||
|
||||
gotLegacy, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read retained legacy database: %v", err)
|
||||
}
|
||||
if string(gotLegacy) != string(legacyContents) {
|
||||
t.Fatalf("legacy database changed during adoption")
|
||||
}
|
||||
if _, err := os.Stat(newPath); err != nil {
|
||||
t.Fatalf("adopted database missing at %s: %v", newPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideAdoptsCommittedWALDataAndRetainsLegacySidecars(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
legacyPath := filepath.Join(homeDir, "kandev.db")
|
||||
newPath := filepath.Join(homeDir, "data", "kandev.db")
|
||||
openWALLegacyTaskDatabase(t, legacyPath)
|
||||
|
||||
legacyContents := readFileForTest(t, legacyPath)
|
||||
walContents := readFileForTest(t, legacyPath+"-wal")
|
||||
if _, err := os.Stat(legacyPath + "-shm"); err != nil {
|
||||
t.Fatalf("WAL legacy SHM sidecar missing before adoption: %v", err)
|
||||
}
|
||||
cfg := &config.Config{HomeDir: homeDir}
|
||||
pool, cleanup, err := Provide(cfg, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Provide: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
|
||||
var title string
|
||||
if err := pool.Reader().Get(&title, `SELECT title FROM tasks WHERE id = 'task-wal'`); err != nil {
|
||||
t.Fatalf("read adopted WAL task: %v", err)
|
||||
}
|
||||
if title != "wal task" {
|
||||
t.Fatalf("adopted WAL task title = %q, want %q", title, "wal task")
|
||||
}
|
||||
|
||||
if got := readFileForTest(t, legacyPath); string(got) != string(legacyContents) {
|
||||
t.Fatalf("legacy database changed during WAL adoption")
|
||||
}
|
||||
if got := readFileForTest(t, legacyPath+"-wal"); string(got) != string(walContents) {
|
||||
t.Fatalf("legacy WAL sidecar changed during adoption")
|
||||
}
|
||||
if _, err := os.Stat(legacyPath + "-shm"); err != nil {
|
||||
t.Fatalf("legacy SHM sidecar was removed during adoption: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(newPath); err != nil {
|
||||
t.Fatalf("adopted database missing at %s: %v", newPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideRejectsInvalidDefaultLegacyBeforeCreatingCurrent(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
legacyPath := filepath.Join(homeDir, "kandev.db")
|
||||
currentPath := filepath.Join(homeDir, "data", "kandev.db")
|
||||
legacyContents := []byte("not a sqlite database")
|
||||
if err := os.WriteFile(legacyPath, legacyContents, 0o600); err != nil {
|
||||
t.Fatalf("seed invalid legacy database: %v", err)
|
||||
}
|
||||
|
||||
_, _, err := Provide(&config.Config{HomeDir: homeDir}, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("Provide succeeded for an invalid default legacy database")
|
||||
}
|
||||
if !strings.Contains(err.Error(), legacyPath) {
|
||||
t.Fatalf("Provide error = %v, want legacy path %q", err, legacyPath)
|
||||
}
|
||||
if _, statErr := os.Stat(currentPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("current database was created after invalid legacy inspection: %v", statErr)
|
||||
}
|
||||
if got := readFileForTest(t, legacyPath); string(got) != string(legacyContents) {
|
||||
t.Fatalf("invalid legacy database changed during inspection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideEstablishedCurrentDatabaseIgnoresInvalidLegacy(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
currentPath := filepath.Join(homeDir, "data", "kandev.db")
|
||||
legacyPath := filepath.Join(homeDir, "kandev.db")
|
||||
if err := os.MkdirAll(filepath.Dir(currentPath), 0o755); err != nil {
|
||||
t.Fatalf("create current database directory: %v", err)
|
||||
}
|
||||
seedTaskDatabase(t, currentPath, "current-task", "current task")
|
||||
legacyContents := []byte("stale invalid legacy database")
|
||||
if err := os.WriteFile(legacyPath, legacyContents, 0o600); err != nil {
|
||||
t.Fatalf("seed invalid legacy database: %v", err)
|
||||
}
|
||||
|
||||
pool, cleanup, err := Provide(&config.Config{HomeDir: homeDir}, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Provide: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
|
||||
var title string
|
||||
if err := pool.Reader().Get(&title, `SELECT title FROM tasks WHERE id = 'current-task'`); err != nil {
|
||||
t.Fatalf("read established current task: %v", err)
|
||||
}
|
||||
if title != "current task" {
|
||||
t.Fatalf("selected task title = %q, want %q", title, "current task")
|
||||
}
|
||||
if got := readFileForTest(t, legacyPath); string(got) != string(legacyContents) {
|
||||
t.Fatalf("stale legacy database changed during current startup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideFailsClosedWhenEmptyCurrentConflictsWithLegacyHistory(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
legacyPath := filepath.Join(homeDir, "kandev.db")
|
||||
currentPath := filepath.Join(homeDir, "data", "kandev.db")
|
||||
if err := os.MkdirAll(filepath.Dir(currentPath), 0o755); err != nil {
|
||||
t.Fatalf("create current database directory: %v", err)
|
||||
}
|
||||
currentContents := seedEmptySQLiteDatabase(t, currentPath)
|
||||
legacyContents := seedLegacyTaskDatabase(t, legacyPath)
|
||||
|
||||
cfg := &config.Config{HomeDir: homeDir}
|
||||
pool, cleanup, err := Provide(cfg, nil, "")
|
||||
if err == nil {
|
||||
_ = cleanup()
|
||||
_ = pool
|
||||
t.Fatal("Provide succeeded for an ambiguous SQLite database selection")
|
||||
}
|
||||
var conflict *SQLiteDatabaseSelectionConflictError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("Provide error = %v, want SQLiteDatabaseSelectionConflictError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), currentPath) || !strings.Contains(err.Error(), legacyPath) {
|
||||
t.Fatalf("Provide error = %v, want both candidate paths", err)
|
||||
}
|
||||
|
||||
if got := readFileForTest(t, currentPath); string(got) != string(currentContents) {
|
||||
t.Fatalf("current database changed after conflict")
|
||||
}
|
||||
if got := readFileForTest(t, legacyPath); string(got) != string(legacyContents) {
|
||||
t.Fatalf("legacy database changed after conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideExplicitDatabasePathBypassesLegacyDiscovery(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
legacyPath := filepath.Join(homeDir, "kandev.db")
|
||||
legacyContents := []byte("not a sqlite database")
|
||||
if err := os.WriteFile(legacyPath, legacyContents, 0o600); err != nil {
|
||||
t.Fatalf("seed invalid legacy database: %v", err)
|
||||
}
|
||||
explicitPath := filepath.Join(homeDir, "custom", "kandev.db")
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{Path: explicitPath},
|
||||
Source: config.ConfigSource{Values: map[string]config.SettingSource{
|
||||
"database.path": config.SourceConfiguration,
|
||||
}},
|
||||
HomeDir: homeDir,
|
||||
}
|
||||
|
||||
pool, cleanup, err := Provide(cfg, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Provide with explicit database path: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
if pool.Writer() == nil {
|
||||
t.Fatal("Provide returned a nil writer for explicit database path")
|
||||
}
|
||||
if _, err := os.Stat(explicitPath); err != nil {
|
||||
t.Fatalf("explicit database was not created: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(homeDir, "data", "kandev.db")); !os.IsNotExist(err) {
|
||||
t.Fatalf("default database was unexpectedly selected: %v", err)
|
||||
}
|
||||
if got := readFileForTest(t, legacyPath); string(got) != string(legacyContents) {
|
||||
t.Fatalf("legacy candidate changed during explicit-path startup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideRetainsCurrentDatabaseWhenBothCandidatesHaveHistory(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
currentPath := filepath.Join(homeDir, "data", "kandev.db")
|
||||
legacyPath := filepath.Join(homeDir, "kandev.db")
|
||||
if err := os.MkdirAll(filepath.Dir(currentPath), 0o755); err != nil {
|
||||
t.Fatalf("create current database directory: %v", err)
|
||||
}
|
||||
seedTaskDatabase(t, currentPath, "current-task", "current task")
|
||||
legacyContents := seedLegacyTaskDatabase(t, legacyPath)
|
||||
|
||||
cfg := &config.Config{HomeDir: homeDir}
|
||||
pool, cleanup, err := Provide(cfg, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Provide: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
|
||||
var title string
|
||||
if err := pool.Reader().Get(&title, `SELECT title FROM tasks WHERE id = 'current-task'`); err != nil {
|
||||
t.Fatalf("read current task: %v", err)
|
||||
}
|
||||
if title != "current task" {
|
||||
t.Fatalf("selected current task title = %q, want %q", title, "current task")
|
||||
}
|
||||
if got := readFileForTest(t, legacyPath); string(got) != string(legacyContents) {
|
||||
t.Fatalf("legacy database changed while retaining current history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideLogsLegacyAdoptionCandidates(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
currentPath := filepath.Join(homeDir, "data", "kandev.db")
|
||||
legacyPath := filepath.Join(homeDir, "kandev.db")
|
||||
seedLegacyTaskDatabase(t, legacyPath)
|
||||
|
||||
core, logs := observer.New(zapcore.InfoLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core))
|
||||
if err != nil {
|
||||
t.Fatalf("NewFromZap: %v", err)
|
||||
}
|
||||
poll, cleanup, err := Provide(&config.Config{HomeDir: homeDir}, log, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Provide: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
_ = poll
|
||||
|
||||
entries := logs.FilterMessage("SQLite database selected").All()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("selection log entries = %d, want 1", len(entries))
|
||||
}
|
||||
fields := entries[0].ContextMap()
|
||||
for field, want := range map[string]any{
|
||||
"outcome": string(sqliteSelectionLegacyAdopted),
|
||||
"current_path": currentPath,
|
||||
"legacy_path": legacyPath,
|
||||
"current_task_count": int64(0),
|
||||
"legacy_task_count": int64(1),
|
||||
} {
|
||||
if got := fields[field]; got != want {
|
||||
t.Fatalf("selection log %s = %#v, want %#v", field, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdoptLegacySQLiteRetainsSourceWhenInstallationFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
legacyPath := filepath.Join(dir, "legacy.db")
|
||||
currentPath := filepath.Join(dir, "current.db")
|
||||
legacyContents := seedLegacyTaskDatabase(t, legacyPath)
|
||||
if err := os.Mkdir(currentPath, 0o700); err != nil {
|
||||
t.Fatalf("create installation blocker: %v", err)
|
||||
}
|
||||
|
||||
err := adoptLegacySQLite(legacyPath, currentPath, sqliteCandidate{
|
||||
path: legacyPath,
|
||||
exists: true,
|
||||
tasksTable: true,
|
||||
taskCount: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("adoptLegacySQLite succeeded despite an occupied target directory")
|
||||
}
|
||||
if got := readFileForTest(t, legacyPath); string(got) != string(legacyContents) {
|
||||
t.Fatalf("legacy database changed after failed installation")
|
||||
}
|
||||
entries, readErr := os.ReadDir(dir)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read adoption directory: %v", readErr)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".kandev-legacy-") {
|
||||
t.Fatalf("staged database leaked after failed installation: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallStagedSQLiteDoesNotReplaceExistingTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stagedPath := filepath.Join(dir, "staged.db")
|
||||
currentPath := filepath.Join(dir, "current.db")
|
||||
if err := os.WriteFile(stagedPath, []byte("staged database"), 0o600); err != nil {
|
||||
t.Fatalf("seed staged database: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(currentPath, []byte("late target"), 0o600); err != nil {
|
||||
t.Fatalf("seed late target: %v", err)
|
||||
}
|
||||
|
||||
err := installStagedSQLite(stagedPath, currentPath)
|
||||
if err == nil {
|
||||
t.Fatal("installStagedSQLite replaced an existing target")
|
||||
}
|
||||
if got := readFileForTest(t, currentPath); string(got) != "late target" {
|
||||
t.Fatalf("late target changed after failed installation: %q", got)
|
||||
}
|
||||
if got := readFileForTest(t, stagedPath); string(got) != "staged database" {
|
||||
t.Fatalf("staged database changed after rejected installation: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func seedEmptySQLiteDatabase(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
database, err := sqlx.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open empty database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := ensureMetaTable(database); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("create metadata table: %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("close empty database: %v", err)
|
||||
}
|
||||
return readFileForTest(t, path)
|
||||
}
|
||||
|
||||
func openWALLegacyTaskDatabase(t *testing.T, path string) *sqlx.DB {
|
||||
t.Helper()
|
||||
database, err := sqlx.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open WAL legacy database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
database.SetMaxOpenConns(1)
|
||||
if _, err := database.Exec(`PRAGMA journal_mode=WAL`); err != nil {
|
||||
t.Fatalf("enable WAL: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`PRAGMA wal_autocheckpoint=0`); err != nil {
|
||||
t.Fatalf("disable WAL autocheckpoint: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL)`); err != nil {
|
||||
t.Fatalf("create WAL tasks: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, title) VALUES ('task-wal', 'wal task')`); err != nil {
|
||||
t.Fatalf("insert WAL task: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
func readFileForTest(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return contents
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
func TestMigrateLegacyDBPath_MovesLegacyDB(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
legacyPath := filepath.Join(homeDir, "kandev.db")
|
||||
newPath := filepath.Join(homeDir, "data", "kandev.db")
|
||||
|
||||
if err := os.WriteFile(legacyPath, []byte("legacy-db-content"), 0o600); err != nil {
|
||||
t.Fatalf("seed legacy db: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{HomeDir: homeDir}
|
||||
if err := migrateLegacyDBPath(cfg, newPath, nil); err != nil {
|
||||
t.Fatalf("migrateLegacyDBPath: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Errorf("legacy file still exists: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(newPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read migrated db: %v", err)
|
||||
}
|
||||
if string(data) != "legacy-db-content" {
|
||||
t.Errorf("migrated content = %q, want %q", data, "legacy-db-content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLegacyDBPath_MovesWalAndShm(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
legacyPath := filepath.Join(homeDir, "kandev.db")
|
||||
newPath := filepath.Join(homeDir, "data", "kandev.db")
|
||||
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
if err := os.WriteFile(legacyPath+suffix, []byte("x"+suffix), 0o600); err != nil {
|
||||
t.Fatalf("seed %s: %v", legacyPath+suffix, err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &config.Config{HomeDir: homeDir}
|
||||
if err := migrateLegacyDBPath(cfg, newPath, nil); err != nil {
|
||||
t.Fatalf("migrateLegacyDBPath: %v", err)
|
||||
}
|
||||
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
if _, err := os.Stat(newPath + suffix); err != nil {
|
||||
t.Errorf("missing new file %s: %v", newPath+suffix, err)
|
||||
}
|
||||
if _, err := os.Stat(legacyPath + suffix); !os.IsNotExist(err) {
|
||||
t.Errorf("legacy file %s still exists", legacyPath+suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLegacyDBPath_NoLegacyFile(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
newPath := filepath.Join(homeDir, "data", "kandev.db")
|
||||
|
||||
cfg := &config.Config{HomeDir: homeDir}
|
||||
if err := migrateLegacyDBPath(cfg, newPath, nil); err != nil {
|
||||
t.Fatalf("migrateLegacyDBPath: %v", err)
|
||||
}
|
||||
|
||||
// Should be a no-op; the data dir should NOT be created if nothing to migrate.
|
||||
if _, err := os.Stat(filepath.Dir(newPath)); !os.IsNotExist(err) {
|
||||
t.Errorf("data dir was created unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLegacyDBPath_NewPathExists_SkipsMigration(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
legacyPath := filepath.Join(homeDir, "kandev.db")
|
||||
newPath := filepath.Join(homeDir, "data", "kandev.db")
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(legacyPath, []byte("legacy"), 0o600); err != nil {
|
||||
t.Fatalf("seed legacy: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(newPath, []byte("new"), 0o600); err != nil {
|
||||
t.Fatalf("seed new: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{HomeDir: homeDir}
|
||||
if err := migrateLegacyDBPath(cfg, newPath, nil); err != nil {
|
||||
t.Fatalf("migrateLegacyDBPath: %v", err)
|
||||
}
|
||||
|
||||
// New path untouched.
|
||||
got, err := os.ReadFile(newPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read new: %v", err)
|
||||
}
|
||||
if string(got) != "new" {
|
||||
t.Errorf("new path was overwritten: got %q", got)
|
||||
}
|
||||
// Legacy untouched.
|
||||
if _, err := os.Stat(legacyPath); err != nil {
|
||||
t.Errorf("legacy file removed despite new existing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLegacyDBPath_LegacyEqualsNew_NoOp(t *testing.T) {
|
||||
// If HomeDir somehow resolves so that legacy == new (shouldn't happen with
|
||||
// the current resolver, but guard against it), we must not rename a file
|
||||
// onto itself.
|
||||
tmp := t.TempDir()
|
||||
sameFile := filepath.Join(tmp, "kandev.db")
|
||||
if err := os.WriteFile(sameFile, []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{HomeDir: tmp}
|
||||
// Pretend the derived path is the same as the legacy path.
|
||||
if err := migrateLegacyDBPath(cfg, sameFile, nil); err != nil {
|
||||
t.Fatalf("migrateLegacyDBPath: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(sameFile); err != nil {
|
||||
t.Errorf("file disappeared: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR-2026-08-27-preserve-legacy-sqlite-before-default-initialization: Preserve Legacy SQLite Data Before Default Initialization
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-08-27
|
||||
**Area:** backend, cli, operations
|
||||
|
||||
## Context
|
||||
|
||||
The Go launcher resolves the current default SQLite path before starting the backend. When it publishes that derived value as `KANDEV_DATABASE_PATH`, the backend classifies the path as explicit and skips its legacy-location migration. `db.OpenSQLite` then creates an empty database at the current default path. The application appears to have lost data even though the previous database can remain at the legacy path.
|
||||
|
||||
The same installation can later contain both the new initialized database and the legacy database. Automatically switching between them based only on timestamp or file size can hide valid data from the other.
|
||||
|
||||
## Decision
|
||||
|
||||
Database path provenance must survive the launcher-to-backend boundary. A derived default is not an explicit override.
|
||||
|
||||
Before a writable SQLite open can create the default target, persistence checks the known legacy default location. When the target is absent and the legacy database is valid, Kandev adopts it through a validated staged SQLite snapshot and retains the source. It does not rename or delete the source database or its sidecars.
|
||||
|
||||
When the current default database has no task history and the legacy candidate has task history, Kandev fails startup without modifying either candidate. Explicit operator-selected paths bypass legacy discovery. Kandev does not automatically switch away from an established current database that contains task history or merge divergent histories.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Default upgrades preserve existing data instead of silently initializing an empty database.
|
||||
- Adoption requires additional startup I/O and enough disk space for a second database file.
|
||||
- The legacy source remains on disk until the operator removes it after verification.
|
||||
- Some installations with a deliberately empty current database and retained legacy task history must resolve the conflict explicitly before startup.
|
||||
- Startup diagnostics must identify path source and candidate state without exposing stored content.
|
||||
- Cross-process tests must cover launcher environment construction through persistence selection.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Continue initializing the current default path
|
||||
|
||||
Rejected because the application starts successfully against an empty database and presents the incident as data loss.
|
||||
|
||||
### Rename the legacy database into place
|
||||
|
||||
Rejected because a partial sidecar move or later validation failure can make the only known source harder to recover.
|
||||
|
||||
### Always prefer the legacy database
|
||||
|
||||
Rejected because the current target can contain newer valid data and explicit operator intent must remain authoritative.
|
||||
|
||||
### Choose by modification time or file size
|
||||
|
||||
Rejected because neither value proves which SQLite history is authoritative, especially with WAL files and copied backups.
|
||||
@@ -209,3 +209,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-08-24-agentctl-local-managed-runtime-cache-repair | [Run cache repair where npm runs](2026-08-24-agentctl-local-managed-runtime-cache-repair.md) | accepted | backend, agentctl, protocol, security | 2026-08-24 |
|
||||
| 2026-08-24-unified-fork-approval-label | [Use One Maintainer Approval Label for Contributor PR Automation](2026-08-24-unified-fork-approval-label.md) | accepted | infra, workflow, security | 2026-08-24 |
|
||||
| 2026-08-25-required-worktree-refresh-fails-closed | [Required Worktree Refresh Fails Closed](2026-08-25-required-worktree-refresh-fails-closed.md) | accepted | backend, security, operations | 2026-08-25 |
|
||||
| 2026-08-27-preserve-legacy-sqlite-before-default-initialization | [Preserve Legacy SQLite Data Before Default Initialization](2026-08-27-preserve-legacy-sqlite-before-default-initialization.md) | accepted | backend, cli, operations | 2026-08-27 |
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ See [`configuration.md`](configuration.md) for the full reference (including the
|
||||
|
||||
> **File-mode note:** when `KANDEV_LOGGING_OUTPUTPATH` is a file path, the active log file is created with mode `0600` (owner read/write only). Run any log shipper or sidecar as the same user, or use `stdout`/`stderr` and let the container runtime collect logs.
|
||||
|
||||
> **Upgrading from a pre-`KANDEV_HOME_DIR` image?** The SQLite DB path moved from `/data/kandev.db` to `/data/data/kandev.db`. The backend auto-migrates the legacy `kandev.db` (plus any `-wal`/`-shm` files) on first boot — look for `Migrated SQLite database from pre-KANDEV_HOME_DIR location` in the logs. If you prefer to pin the old location instead, set `-e KANDEV_DATABASE_PATH=/data/kandev.db`. If you previously set `KANDEV_DATA_DIR`, replace it with `KANDEV_HOME_DIR`.
|
||||
> **Upgrading from a pre-`KANDEV_HOME_DIR` image?** The SQLite DB path moved from `/data/kandev.db` to `/data/data/kandev.db`. When no explicit database path is set, the backend checks the legacy `kandev.db` and, when valid, installs a validated snapshot at the current path while retaining the legacy database and its `-wal`/`-shm` files. Look for the `SQLite database selected` diagnostic with outcome `legacy_adopted`. If both candidates need operator review, startup stops and names both paths; preserve them and select the intended file with `KANDEV_DATABASE_PATH`. To keep the old location explicitly, set `-e KANDEV_DATABASE_PATH=/data/kandev.db`. If you previously set `KANDEV_DATA_DIR`, replace it with `KANDEV_HOME_DIR`.
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ See [`configuration.md`](configuration.md) for the full reference (every backend
|
||||
|
||||
> **Logging in K8s:** prefer the default `stdout` so kubelet collects logs. If you set `KANDEV_LOGGING_OUTPUTPATH` to a file, the active log is created with mode `0600` (owner read/write only); any sidecar reading it must run as the same user.
|
||||
|
||||
> **Upgrading from a pre-`KANDEV_HOME_DIR` deployment?** The SQLite DB path moved from `/data/kandev.db` to `/data/data/kandev.db`, and `KANDEV_DATA_DIR` is gone — point `KANDEV_HOME_DIR` at the same volume mount (`/data`) instead. (`KANDEV_WORKTREE_BASEPATH` still works as an explicit override if you want to keep worktrees outside the home dir.) The backend auto-migrates the legacy `kandev.db` (plus any `-wal`/`-shm` files) on first boot — look for `Migrated SQLite database from pre-KANDEV_HOME_DIR location` in the pod logs. If you'd rather pin the old path, set `KANDEV_DATABASE_PATH=/data/kandev.db` in the ConfigMap.
|
||||
> **Upgrading from a pre-`KANDEV_HOME_DIR` deployment?** The SQLite DB path moved from `/data/kandev.db` to `/data/data/kandev.db`, and `KANDEV_DATA_DIR` is gone. Point `KANDEV_HOME_DIR` at the same volume mount (`/data`) instead. (`KANDEV_WORKTREE_BASEPATH` still works as an explicit override if you want to keep worktrees outside the home dir.) With no explicit database path, the backend checks the legacy `kandev.db` and, when valid, installs a validated snapshot at the current path while retaining the legacy database and its `-wal`/`-shm` files. Look for the `SQLite database selected` diagnostic with outcome `legacy_adopted`. If both candidates need operator review, startup stops and names both paths; preserve them and select the intended file with `KANDEV_DATABASE_PATH`. To keep the old path explicitly, set `KANDEV_DATABASE_PATH=/data/kandev.db` in the ConfigMap.
|
||||
|
||||
### PostgreSQL Settings (when `KANDEV_DATABASE_DRIVER=postgres`)
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
created: 2026-08-27
|
||||
status: implemented
|
||||
requirements:
|
||||
- REQ-PLATFORM-STARTUP-CONFIGURATION-PARITY-002
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/startup-database-selection-continuity.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Database Selection Continuity
|
||||
|
||||
## Overview
|
||||
|
||||
Preserve default SQLite database continuity across the launcher-to-backend boundary, then replace destructive legacy migration with validated staged adoption and conflict detection. Provenance must be corrected first because persistence cannot distinguish an operator override from a launcher-derived default while the launcher synthesizes `KANDEV_DATABASE_PATH`.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Preserve the typed source of the default SQLite path across managed child startup.
|
||||
- Adopt a sole legacy default database without deleting the source.
|
||||
- Stop startup when an empty-history current default conflicts with legacy task history.
|
||||
- Preserve explicit environment, YAML, and dev-isolated database selection.
|
||||
- Add bounded startup diagnostics and public recovery guidance.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- PostgreSQL changes.
|
||||
- Automatic switching or merging when both databases contain task history.
|
||||
- A new storage-doctor command or recovery UI.
|
||||
- Self-update snapshots, backup retention changes, or restore redesign.
|
||||
- Scanning arbitrary filesystem locations for custom databases.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Launcher provenance
|
||||
|
||||
Update `internal/launcher.backendEnvForConfig` so a default-derived database path is not emitted as `KANDEV_DATABASE_PATH`. Keep ambient explicit environment values, typed YAML handoff, and dev-mode managed overrides. Add focused source-preservation tests and retain startup display of the resolved path.
|
||||
|
||||
### Persistence selection and adoption
|
||||
|
||||
Extract default SQLite selection from `provideSQLite` into a pre-open helper that uses `Config.SourceFor("database.path")`. Inspect only the current and known legacy default candidates. Adopt a sole legacy candidate through `SnapshotSQLite` into a unique staged file, validate it, atomically install it, and retain the source and sidecars. Fail before repository initialization on inspection, validation, installation, or empty-history conflict errors.
|
||||
|
||||
Add a cross-boundary regression test that constructs the launcher's child environment, reloads typed child configuration, invokes persistence, and proves a seeded legacy task remains visible. Keep lower-level persistence tests for explicit selection, WAL-backed adoption, source retention, conflict immutability, and same-database replay.
|
||||
|
||||
### Diagnostics and documentation
|
||||
|
||||
Log database source and selection outcome without content. Update the public configuration reference and operations recovery guide to describe default legacy adoption, conflict errors, and the rule that explicit paths bypass discovery.
|
||||
|
||||
## Tests
|
||||
|
||||
- `AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.1`: launcher-to-persistence regression plus WAL-backed legacy adoption test.
|
||||
- `AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.2`: two-candidate conflict test that proves both files remain byte-identical.
|
||||
- `AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.3`: environment, YAML, and dev-managed explicit-path tests.
|
||||
- `AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.4`: staged-adoption success and injected-failure tests that prove source retention.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Preserve database path provenance](task-01-preserve-database-path-provenance.md)
|
||||
- [x] [Task 02: Adopt legacy databases safely](task-02-adopt-legacy-databases-safely.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
Task 01 and Task 02 complete. Launcher provenance, staged WAL-safe adoption,
|
||||
conflict handling, explicit-path isolation, diagnostics, tests, and public
|
||||
recovery guidance are implemented and verified. Review follow-up hardening
|
||||
also covers no-replace installation, lightweight warm-start inspection, and
|
||||
deployment documentation.
|
||||
|
||||
## Risks
|
||||
|
||||
- Existing service environments can carry an intentional `KANDEV_DATABASE_PATH`; tests must distinguish inherited operator values from launcher-generated defaults.
|
||||
- SQLite WAL contents must be included without moving or deleting sidecars.
|
||||
- A deliberately reset current database can conflict with retained legacy task history and require explicit operator cleanup.
|
||||
- Candidate inspection must not create files or read user content beyond bounded row counts.
|
||||
- Adoption needs free disk space for the staged copy and must leave the source recoverable on every error path.
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: "01-preserve-database-path-provenance"
|
||||
title: "Preserve database path provenance"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-PLATFORM-STARTUP-CONFIGURATION-PARITY-002
|
||||
acceptance_criteria:
|
||||
- AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.1
|
||||
- AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.3
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/startup-database-selection-continuity.md
|
||||
---
|
||||
|
||||
# Task 01: Preserve Database Path Provenance
|
||||
|
||||
## Summary
|
||||
|
||||
Keep a launcher-derived default database path classified as a default when the backend reloads configuration. Preserve explicit environment, YAML, and dev-isolated paths and add the regression seam needed by persistence selection.
|
||||
|
||||
## In scope
|
||||
|
||||
- Stop synthesizing a public database-path override for the normal derived default.
|
||||
- Preserve ambient explicit environment values, selected YAML configuration, and dev managed overrides.
|
||||
- Add launcher tests for child source provenance and resolved-path display behavior.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Legacy database inspection or copying.
|
||||
- SQLite schema and backup behavior.
|
||||
- Public documentation.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A default launch child resolves `<home>/data/kandev.db` with `SourceDefault` and no generated `KANDEV_DATABASE_PATH`.
|
||||
- Explicit environment and YAML paths retain their existing precedence and source.
|
||||
- Dev-mode repository isolation still supplies its managed database path.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/launcher -run '^(Test.*DatabasePath|TestBackendEnv.*Database)' -count=1
|
||||
cd apps/backend && go test ./internal/launcher -count=1
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/launcher/env.go`
|
||||
- `apps/backend/internal/launcher/bootstrap_test.go`
|
||||
- `apps/backend/internal/launcher/database_path_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- Removing the generated override must not remove an inherited operator override or the dev launcher's later managed override.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-PLATFORM-STARTUP-CONFIGURATION-PARITY-002`
|
||||
- `docs/specs/platform/system-design/startup-database-selection-continuity.md`
|
||||
- ADR `2026-08-20-startup-configuration-source-parity`
|
||||
|
||||
## Results
|
||||
|
||||
- Added `TestBackendEnvDoesNotSynthesizeDefaultDatabasePath` and
|
||||
`TestBackendEnvPreservesExplicitEnvironmentDatabasePath`.
|
||||
- Updated `backendEnvForConfig` so only an explicit environment database source
|
||||
is emitted as `KANDEV_DATABASE_PATH`; default and YAML sources remain typed.
|
||||
- Focused check passed: `go test ./internal/launcher -run
|
||||
'^(Test.*DatabasePath|TestBackendEnv.*Database)' -count=1`.
|
||||
- Package check passed with inherited harness variables cleared:
|
||||
`unset KANDEV_INTERNAL_CONFIG_FILE KANDEV_DATABASE_PATH; go test
|
||||
./internal/launcher -count=1`.
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
id: "02-adopt-legacy-databases-safely"
|
||||
title: "Adopt legacy databases safely"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 01-preserve-database-path-provenance
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-PLATFORM-STARTUP-CONFIGURATION-PARITY-002
|
||||
acceptance_criteria:
|
||||
- AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.1
|
||||
- AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.2
|
||||
- AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.3
|
||||
- AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.4
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/startup-database-selection-continuity.md
|
||||
---
|
||||
|
||||
# Task 02: Adopt Legacy Databases Safely
|
||||
|
||||
## Summary
|
||||
|
||||
Resolve default SQLite candidates before writable open, adopt a sole legacy database through a validated staged snapshot, and fail without mutation on an empty-history conflict. Prove the complete launcher-to-persistence regression and document recovery behavior.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add source-aware default SQLite target selection and read-only candidate inspection.
|
||||
- Replace legacy rename with staged snapshot, validation, atomic installation, and source retention.
|
||||
- Detect the current-zero-task versus legacy-task-history conflict before repository startup.
|
||||
- Add selection diagnostics, integration tests, failure-path tests, and public recovery guidance.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Switching or merging when both databases contain task history.
|
||||
- Arbitrary custom-path discovery.
|
||||
- PostgreSQL, backup retention, restore, UI, and self-update changes.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A default launch with only a WAL-backed legacy database exposes its task data from the current target and leaves the legacy files intact.
|
||||
- A current default with no tasks and a legacy default with tasks causes a non-destructive startup error naming both candidates.
|
||||
- Explicit paths bypass legacy discovery, and every staged-adoption failure leaves the source and prior target state unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/persistence ./internal/launcher -run '^(Test.*(Legacy|DatabaseContinuity|DatabaseConflict|DatabasePath))' -count=1
|
||||
cd apps/backend && go test ./internal/persistence ./internal/launcher -count=1
|
||||
cd apps/backend && go build ./cmd/kandev
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/persistence/provider.go`
|
||||
- `apps/backend/internal/persistence/sqlite_selection.go`
|
||||
- `apps/backend/internal/persistence/provider_legacy_test.go`
|
||||
- `apps/backend/internal/launcher/database_handoff_integration_test.go`
|
||||
- `docs/public/configuration.md`
|
||||
- `docs/public/operations.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 01 must preserve default source provenance across child startup.
|
||||
|
||||
## Risks
|
||||
|
||||
- Snapshot validation and atomic installation must work across macOS, Linux, and Windows filesystem semantics.
|
||||
- Candidate inspection must tolerate historical schemas that have `tasks` but no `kandev_meta` table.
|
||||
- Tests must prove committed WAL data is present in the adopted snapshot.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-PLATFORM-STARTUP-CONFIGURATION-PARITY-002`
|
||||
- `docs/specs/platform/system-design/startup-database-selection-continuity.md`
|
||||
- ADR `2026-08-27-preserve-legacy-sqlite-before-default-initialization`
|
||||
- Existing `persistence.SnapshotSQLite` and backup/restore validation patterns
|
||||
|
||||
## Results
|
||||
|
||||
- Added source-aware SQLite selection before writable database open.
|
||||
- Replaced destructive legacy rename with a WAL-safe `VACUUM INTO` snapshot,
|
||||
integrity and task-count validation, owner-only staged permissions, atomic
|
||||
installation, and source plus sidecar retention.
|
||||
- Added launcher-to-persistence, WAL adoption, invalid-candidate,
|
||||
empty-current conflict, explicit-path, established-current, and failed
|
||||
installation coverage.
|
||||
- Hardened installation with an atomic no-replace target operation and made
|
||||
established-current inspection use a lightweight task-history probe.
|
||||
- Updated Docker and Kubernetes upgrade guidance and marked the implemented
|
||||
system design as current.
|
||||
- Updated the public configuration and operations guides with continuity and
|
||||
recovery behavior.
|
||||
- Verification passed:
|
||||
- `go test ./internal/persistence ./internal/launcher -run '^(Test.*(Legacy|DatabaseContinuity|DatabaseConflict|DatabasePath))' -count=1`
|
||||
- sanitized full package tests with `KANDEV_INTERNAL_CONFIG_FILE`,
|
||||
`KANDEV_DATABASE_PATH`, and `KANDEV_DATABASE_DRIVER` unset
|
||||
- targeted race tests for the persistence and launcher packages
|
||||
- `go build ./cmd/kandev`
|
||||
- `make -C apps/backend lint`
|
||||
- `node --test scripts/validate-public-docs.test.mjs`
|
||||
- `node scripts/validate-public-docs.mjs`
|
||||
@@ -93,6 +93,21 @@ SQLite is the supported default and enables WAL mode. PostgreSQL deployments mus
|
||||
|
||||
`database.path` is an advanced SQLite file-path override. The **Settings → System → Database** and **Backups** pages use that exact file, its WAL files, and the sibling `backups/` directory. Restore stages `<configured-database-path>.new`, quiesces scheduling and active workers, validates the checkpoint result, closes the SQLite pool, and uses rollback-capable quarantine replacement for the configured file and WAL sidecars. Restart Kandev immediately after a successful restore. When the override is empty, the default path remains `<home>/data/kandev.db` and the backup directory remains `<home>/data/backups/`. Kandev does not move snapshots from another directory automatically. The System restore endpoint is SQLite-only; use PostgreSQL recovery tools for PostgreSQL.
|
||||
|
||||
### Default SQLite continuity
|
||||
|
||||
When `database.path` is empty, startup checks both `<home>/data/kandev.db` and
|
||||
the legacy default `<home>/kandev.db` before it opens a writable database. If
|
||||
only the legacy database exists and is valid, Kandev copies it to the current
|
||||
default with a validated SQLite snapshot. The legacy database and its `-wal`
|
||||
and `-shm` files remain available for recovery.
|
||||
|
||||
If the current default has no task history but the legacy default has task
|
||||
history, startup stops and names both paths. Kandev does not modify either
|
||||
database. Preserve both files, then select the intended database explicitly
|
||||
with `database.path` or `KANDEV_DATABASE_PATH` before restarting. If both
|
||||
defaults contain task history, Kandev keeps the current default and does not
|
||||
merge the databases. An explicit database path bypasses legacy discovery.
|
||||
|
||||
One backend owns a Kandev home at a time. When SQLite uses a custom path outside that home, the backend also owns that database path, so separate homes alone do not permit concurrent backends against one SQLite file. Use a separate home and database for an intentional second instance. Ownership is released when the backend exits.
|
||||
|
||||
Database-only snapshots also omit `<home>/data/master.key`, the AES-256 key used to decrypt stored secrets. Preserve that owner-only key with an independently secured home/data backup; restoring the database without its matching key leaves encrypted credentials unreadable. See [Operations](operations.md).
|
||||
|
||||
@@ -112,6 +112,7 @@ To recover capacity in one session, expand its queue chip in the task workbench.
|
||||
| Path under Kandev home | Contents and recovery significance |
|
||||
| --- | --- |
|
||||
| `data/kandev.db`, `-wal`, `-shm` | Default SQLite database and transient WAL files |
|
||||
| `kandev.db`, `-wal`, `-shm` | Legacy default SQLite database checked during startup; Kandev retains it after adoption until you verify the current database |
|
||||
| `data/master.key` | Owner-only AES-256 key used to decrypt secrets stored in the database; a database copy without the matching key cannot recover those secret values |
|
||||
| `data/backups/` | SQLite snapshots when the database uses the default path |
|
||||
| `tasks/` and legacy `worktrees/` | Managed Git worktrees and per-task files; may contain uncommitted or untracked work |
|
||||
@@ -237,6 +238,25 @@ the Kandev service first provides the clearest maintenance boundary.
|
||||
|
||||
SQLite is the default and is appropriate for a desktop, CLI, service, or single-replica container installation. Kandev uses one writer connection and a read pool in WAL mode. Only one Kandev backend should own the file.
|
||||
|
||||
#### Default database continuity and recovery
|
||||
|
||||
When no explicit `database.path` or `KANDEV_DATABASE_PATH` is set, startup
|
||||
checks `<home>/data/kandev.db` and the legacy `<home>/kandev.db` before it
|
||||
creates the current default. If only the legacy database is valid, Kandev
|
||||
creates a validated snapshot at the current path. It retains the legacy
|
||||
database and its `-wal` and `-shm` sidecars. Do not remove the legacy files
|
||||
until you verify tasks and other required data in the current database.
|
||||
|
||||
If the current default has no task history and the legacy default has task
|
||||
history, Kandev stops before modifying either database and reports both paths.
|
||||
If inspection or snapshot validation fails, startup also stops before creating
|
||||
the current database. To recover, stop other Kandev writers, preserve both
|
||||
candidate databases and all sidecars, then set `database.path` or
|
||||
`KANDEV_DATABASE_PATH` to the database you intend to use and restart. An
|
||||
explicit path skips legacy discovery. When both defaults contain task history,
|
||||
Kandev keeps the current default and does not merge the databases. Use a
|
||||
verified snapshot or a later deliberate recovery procedure to reconcile them.
|
||||
|
||||
Open **Settings > System > Database** to see database size, WAL size, schema version, path, and the newest modification time among regular entries in the sibling `backups/` directory. That timestamp is a filesystem hint, not proof of a valid snapshot: an unrelated or temporary file in the directory can affect it. SQLite exposes three maintenance actions:
|
||||
|
||||
- **Optimize** runs `PRAGMA optimize`. It is quick and updates planner statistics.
|
||||
|
||||
@@ -86,6 +86,7 @@ localization, feature toggles, health, and shared session recovery services.
|
||||
- [Provider Error Recovery](system-design/provider-error-recovery.md)
|
||||
- [Workspace Git Status](system-design/workspace-git-status.md)
|
||||
- [Go dev launcher and startup version](system-design/go-dev-launcher.md)
|
||||
- [Startup database selection continuity](system-design/startup-database-selection-continuity.md)
|
||||
|
||||
## Migration record
|
||||
|
||||
|
||||
@@ -28,6 +28,17 @@ Kandev has two startup configuration paths. Some settings use typed YAML and env
|
||||
- **AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-001.7:** **GIVEN** `database.path` is set in the selected file, **WHEN** the launcher starts the backend, **THEN** the backend uses that path.
|
||||
- **AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-001.8:** **GIVEN** a stable startup secret is set in YAML, **WHEN** Kandev starts, **THEN** the owning component receives it and logs do not expose it.
|
||||
|
||||
### REQ-PLATFORM-STARTUP-CONFIGURATION-PARITY-002: Database selection continuity
|
||||
|
||||
**Intent:** Preserve an installation's existing SQLite data when startup moves from a legacy default location to the current default location, without overriding an operator's explicit database selection.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.1:** **GIVEN** no database path is explicitly configured, the current default database is absent, and a valid database exists at the legacy default location, **WHEN** Kandev starts, **THEN** it starts with the legacy database's data instead of initializing an empty database.
|
||||
- **AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.2:** **GIVEN** the current default database has no task history and the legacy default database has task history, **WHEN** Kandev cannot prove which database is authoritative, **THEN** startup stops before modifying either database and identifies both candidates for recovery.
|
||||
- **AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.3:** **GIVEN** an operator explicitly configures `database.path` or `KANDEV_DATABASE_PATH`, **WHEN** Kandev starts, **THEN** it uses only that database and does not substitute a default-location candidate.
|
||||
- **AC-PLATFORM-STARTUP-CONFIGURATION-PARITY-002.4:** **GIVEN** Kandev adopts a legacy default database, **WHEN** adoption completes, **THEN** the original database remains recoverable until the replacement has been validated and installed successfully.
|
||||
|
||||
## Migrated source detail
|
||||
|
||||
## Why
|
||||
@@ -207,6 +218,8 @@ users receive the same source and lock behavior.
|
||||
- YAML keys for credentials managed in the product UI or database.
|
||||
- Replacing standard platform detection when an existing typed setting already
|
||||
controls the result, such as `DOCKER_HOST` with `docker.host`.
|
||||
- Automatically switching to or merging a legacy database when the current
|
||||
default database already contains task history.
|
||||
|
||||
Decision:
|
||||
[Startup configuration uses one typed source model](../../../decisions/2026-08-20-startup-configuration-source-parity.md).
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
status: current
|
||||
system: platform
|
||||
requirements:
|
||||
- REQ-PLATFORM-STARTUP-CONFIGURATION-PARITY-002
|
||||
---
|
||||
|
||||
# Startup Database Selection Continuity System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
The Platform system owns database selection because it owns startup configuration, launcher-to-backend handoff, and shared operational safety. This design covers SQLite path provenance, legacy default discovery, safe adoption, conflict handling, and startup diagnostics. It does not change PostgreSQL selection, SQLite schema migration, backup retention, database restore, or deliberate custom-path behavior.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design sections |
|
||||
| --- | --- |
|
||||
| `REQ-PLATFORM-STARTUP-CONFIGURATION-PARITY-002` | [Path provenance](#path-provenance), [Selection flow](#selection-flow), [Legacy adoption](#legacy-adoption), [Failure and recovery](#failure-and-recovery) |
|
||||
|
||||
## Path provenance
|
||||
|
||||
`internal/common/config` remains the authority for the effective `database.path` value and its `SettingSource`. The launcher can resolve the default path for display and locking, but it must not turn that derived value into a public `KANDEV_DATABASE_PATH` environment override. An explicit environment value remains inherited. A YAML value remains in the selected typed configuration file and reaches the backend through `KANDEV_INTERNAL_CONFIG_FILE`. Dev-mode isolation continues to pass its repository-local database path as managed child wiring.
|
||||
|
||||
The backend classifies a SQLite target as explicit only when `database.path` came from configuration or environment, or when a managed launch mode deliberately supplied an isolated path. A nonempty resolved string alone does not prove operator intent.
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
- `internal/launcher.backendEnvForConfig` preserves database-path provenance across the child-process boundary.
|
||||
- `internal/common/config.ConfigSource` remains the typed source record used by startup consumers.
|
||||
- `internal/persistence` owns default target discovery, legacy candidate inspection, adoption, and conflict errors before `db.OpenSQLite` can create a file.
|
||||
- `internal/persistence.SnapshotSQLite` provides a transaction-consistent copy that includes committed WAL contents.
|
||||
- The existing runtime-state lock protects the Kandev home while selection and adoption run.
|
||||
|
||||
## Selection flow
|
||||
|
||||
SQLite selection runs before opening the writable target:
|
||||
|
||||
1. Resolve the effective target and its source.
|
||||
2. If the source is explicit, open only that target. Do not inspect or adopt a default-location candidate.
|
||||
3. For a default-derived target, inspect the current path and `<home>/kandev.db` without creating either file.
|
||||
4. If neither exists, continue with normal first-install initialization.
|
||||
5. If only the legacy candidate exists and it is a readable SQLite database, adopt it through the staged-copy flow.
|
||||
6. If the current target exists, it remains authoritative unless it has no task history while the legacy candidate has task history. That condition is ambiguous and stops startup.
|
||||
7. If both candidates contain task history, do not switch away from the established current target or merge histories. The current target remains selected and diagnostics identify the retained legacy candidate; operator-directed recovery remains separate.
|
||||
|
||||
Candidate inspection is read-only. It reports whether the file exists, whether it is readable as SQLite, whether the `tasks` table exists, and its task count. It does not read task titles, messages, secrets, or other content.
|
||||
|
||||
## Legacy adoption
|
||||
|
||||
Adoption never renames or deletes the legacy source. Kandev opens it under the already-held runtime-state lock, creates a transaction-consistent snapshot in a private `0700` staging directory beside the current default target, and validates the staged database with SQLite integrity checking. It applies owner-only file permissions where supported and atomically installs the staged file at the absent target without replacing a file that appears late.
|
||||
|
||||
Any error removes only the private staging directory and its contents. The legacy database and its WAL and SHM sidecars remain untouched. The normal pre-migration backup then evaluates the adopted target before repository schema initialization.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
Startup fails before repository initialization when candidate inspection fails, adoption cannot complete, staged validation fails, or the current target has no task history while the legacy candidate does. The error identifies the selected target, the legacy candidate, and the non-destructive recovery action. It never recommends deleting either file.
|
||||
|
||||
An explicit database path bypasses legacy discovery. A user who intentionally selects a new database therefore does not receive a false conflict with the default home.
|
||||
|
||||
Recovery tooling for reconciling two databases that both contain task history is out of scope. Operators preserve both files and use the existing backup/restore workflow or a later dedicated storage-doctor command.
|
||||
|
||||
## Observability
|
||||
|
||||
Startup logs the canonical selected path, its source, whether it existed before opening, and the outcome `fresh`, `existing`, `legacy_adopted`, or `conflict`. Conflict and adoption logs include candidate paths and task counts but no record content. The diagnostic bundle receives these messages through the existing bounded backend log source.
|
||||
|
||||
## Verification strategy
|
||||
|
||||
Launcher tests prove that default-derived paths do not become environment overrides and that explicit environment, YAML, and dev-mode paths retain their behavior. Persistence tests use real file-backed SQLite databases, including WAL-backed legacy data, to prove staged adoption, source preservation, explicit-path isolation, conflict failure, and replay. A launcher-to-persistence regression test exercises the child environment and typed configuration boundary that the incident exposed.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [Startup Configuration Uses One Typed Source Model](../../../decisions/2026-08-20-startup-configuration-source-parity.md)
|
||||
- [The Go Launcher Owns Every Entrypoint](../../../decisions/2026-08-08-go-launcher-owns-all-launch-modes.md)
|
||||
- [Lock Runtime State Before Backend Startup](../../../decisions/2026-08-09-exclusive-runtime-state-ownership.md)
|
||||
- [Preserve Legacy SQLite Data Before Default Initialization](../../../decisions/2026-08-27-preserve-legacy-sqlite-before-default-initialization.md)
|
||||
Reference in New Issue
Block a user