package cache
import (
"context"
"database/sql"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
type Confirmation struct {
Confirmed bool
}
type MigrateCacheResult struct {
FromVersion int
ToVersion int
Applied []int
BackupPath string
BackupVerified bool
IdentityPreserved bool
Compatibility VersionCompatibility
}
func InspectCacheMigration(ctx context.Context, dataSourceName string) (*MigrateCacheResult, error) {
if _, err := os.Stat(dataSourceName); err != nil {
if os.IsNotExist(err) {
return &MigrateCacheResult{FromVersion: 0, ToVersion: currentSchemaVersion}, nil
}
return nil, fmt.Errorf("cache: cannot access cache file: %w", err)
}
dsn, err := sqliteReadOnlyDSN(dataSourceName)
if err != nil {
return nil, err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
defer db.Close()
compat, err := CheckVersionCompatibility(ctx, db)
if err != nil {
return nil, err
}
return &MigrateCacheResult{FromVersion: compat.DetectedVersion, ToVersion: currentSchemaVersion, Compatibility: compat}, nil
}
func MigrateCache(ctx context.Context, dataSourceName string, forceNoFTS bool) (*MigrateCacheResult, error) {
return MigrateCacheWithConfirm(ctx, dataSourceName, forceNoFTS, Confirmation{Confirmed: true})
}
func MigrateCacheWithConfirm(ctx context.Context, dataSourceName string, forceNoFTS bool, confirm Confirmation) (*MigrateCacheResult, error) {
if _, err := os.Stat(dataSourceName); err != nil {
if os.IsNotExist(err) {
return &MigrateCacheResult{FromVersion: 0, ToVersion: currentSchemaVersion, Applied: nil}, nil
}
return nil, fmt.Errorf("cache: cannot access cache file: %w", err)
}
db, err := sql.Open("sqlite", dataSourceName)
if err != nil {
return nil, err
}
defer db.Close()
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
return nil, err
}
if _, err := db.ExecContext(ctx, "PRAGMA busy_timeout = 5000"); err != nil {
return nil, err
}
store := &SQLiteStore{db: db, forceNoFTS: forceNoFTS, cachePath: dataSourceName, lockPath: writerLockPath(dataSourceName)}
lease, err := store.AcquireWriter(ctx, WriterRequest{Operation: "migration"})
if err != nil {
return nil, err
}
defer func() {
_ = store.ReleaseWriter(context.Background(), lease)
}()
compat, err := CheckVersionCompatibility(ctx, db)
if err != nil {
return nil, err
}
beforeVersion := compat.DetectedVersion
if !compat.Compatible || beforeVersion <= 1 {
return &MigrateCacheResult{FromVersion: beforeVersion, ToVersion: currentSchemaVersion, Applied: nil, Compatibility: compat}, nil
}
if beforeVersion == currentSchemaVersion {
return &MigrateCacheResult{FromVersion: beforeVersion, ToVersion: currentSchemaVersion, Applied: nil, Compatibility: compat}, nil
}
if !confirm.Confirmed {
return &MigrateCacheResult{
FromVersion: beforeVersion,
ToVersion: currentSchemaVersion,
Applied: nil,
Compatibility: compat,
}, nil
}
if _, err := db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
return nil, fmt.Errorf("cache: failed to checkpoint WAL before migration: %w", err)
}
backupPath, err := backupCache(dataSourceName)
if err != nil {
return nil, fmt.Errorf("cache: failed to create backup before migration: %w", err)
}
backupVersion, backupIdentity, err := verifyCacheBackup(ctx, backupPath)
if err != nil {
_ = os.Remove(backupPath)
return nil, fmt.Errorf("cache: failed to verify backup before migration: %w", err)
}
if backupVersion != beforeVersion {
_ = os.Remove(backupPath)
return nil, fmt.Errorf("cache: backup schema version %d does not match source version %d", backupVersion, beforeVersion)
}
useFTS := !forceNoFTS && detectFTS5(ctx, db)
applied := make([]int, 0)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
for _, m := range migrations {
if m.version <= beforeVersion {
continue
}
if err = m.apply(ctx, tx, useFTS); err != nil {
_ = tx.Rollback()
return nil, err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM schema_version`); err != nil {
_ = tx.Rollback()
return nil, err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO schema_version (version) VALUES (?)`, m.version); err != nil {
_ = tx.Rollback()
return nil, err
}
if _, err = tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", m.version)); err != nil {
_ = tx.Rollback()
return nil, err
}
applied = append(applied, m.version)
}
identityPreserved, err := verifyMigrationTransaction(ctx, tx, backupIdentity)
if err != nil {
_ = tx.Rollback()
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return &MigrateCacheResult{
FromVersion: beforeVersion,
ToVersion: currentSchemaVersion,
Applied: applied,
BackupPath: backupPath,
BackupVerified: true,
IdentityPreserved: identityPreserved,
Compatibility: compat,
}, nil
}
func verifyCacheBackup(ctx context.Context, path string) (int, string, error) {
dsn, err := sqliteImmutableReadOnlyDSN(path)
if err != nil {
return 0, "", err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return 0, "", err
}
defer db.Close()
var integrity string
if err := db.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&integrity); err != nil {
return 0, "", err
}
if integrity != "ok" {
return 0, "", fmt.Errorf("integrity_check returned %q", integrity)
}
version, err := schemaVersion(ctx, db)
if err != nil {
return 0, "", err
}
var identity string
if version >= 17 {
if err := db.QueryRowContext(ctx, `SELECT cache_uuid FROM cache_identity WHERE identity_key = 1`).Scan(&identity); err != nil {
return 0, "", err
}
}
return version, identity, nil
}
func verifyMigrationTransaction(ctx context.Context, tx *sql.Tx, expectedIdentity string) (bool, error) {
var version int
if err := tx.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&version); err != nil {
return false, err
}
if version != currentSchemaVersion {
return false, fmt.Errorf("cache: migration verification found schema version %d, expected %d", version, currentSchemaVersion)
}
var integrity string
if err := tx.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&integrity); err != nil {
return false, err
}
if integrity != "ok" {
return false, fmt.Errorf("cache: migration integrity_check returned %q", integrity)
}
var identity string
if err := tx.QueryRowContext(ctx, `SELECT cache_uuid FROM cache_identity WHERE identity_key = 1`).Scan(&identity); err != nil {
return false, err
}
if expectedIdentity != "" && identity != expectedIdentity {
return false, fmt.Errorf("cache: migration changed cache identity")
}
return expectedIdentity == "" || identity == expectedIdentity, nil
}
func backupCache(sourcePath string) (string, error) {
timestamp := time.Now().UTC().Format("20060102T150405Z")
backupPath := sourcePath + ".backup-" + timestamp
src, err := os.Open(sourcePath)
if err != nil {
return "", fmt.Errorf("open source for backup: %w", err)
}
defer src.Close()
dir := filepath.Dir(backupPath)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("create backup directory: %w", err)
}
dst, err := os.OpenFile(backupPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)
if err != nil {
return "", fmt.Errorf("create backup file: %w", err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
os.Remove(backupPath)
return "", fmt.Errorf("copy backup: %w", err)
}
if err := dst.Sync(); err != nil {
os.Remove(backupPath)
return "", fmt.Errorf("sync backup: %w", err)
}
return backupPath, nil
}
func hasSchemaVersionTable(ctx context.Context, db *sql.DB) (bool, error) {
var count int
err := db.QueryRowContext(ctx, `SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'`).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func isEmptyDatabase(ctx context.Context, db *sql.DB) (bool, error) {
var count int
err := db.QueryRowContext(ctx, `SELECT count(*) FROM sqlite_master WHERE type IN ('table', 'view', 'index', 'trigger') AND name NOT LIKE 'sqlite_%'`).Scan(&count)
if err != nil {
return false, err
}
return count == 0, nil
}