feat: 实现 SQLite 迁移备份、完整表结构与简单写入队列
This commit is contained in:
+84
-27
@@ -5,6 +5,8 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -14,8 +16,11 @@ import (
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// Migrate 应用尚未执行的迁移。T0.1 仅为空执行器加 0001 占位;完整备份与表结构见 T0.3。
|
||||
func Migrate(db *sql.DB) error {
|
||||
// Migrate 应用尚未执行的嵌入迁移。
|
||||
// 若 dbExisted 为 true(调用 Open/Migrate 前已有 nixmsg.db)且存在未应用版本,
|
||||
// 先 VACUUM INTO <data_dir>/backup/pre-migrate-<时间>.db,再迁移。
|
||||
// 任一版本失败则返回错误,调用方不得继续带半新半旧库提供服务。
|
||||
func Migrate(db *sql.DB, dataDir string, dbExisted bool) error {
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
@@ -24,9 +29,38 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
return fmt.Errorf("ensure schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
pending, err := pendingMigrations(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if dbExisted {
|
||||
if err := backupBeforeMigrate(db, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range pending {
|
||||
if err := applyMigration(db, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type migrationFile struct {
|
||||
version int
|
||||
name string
|
||||
body string
|
||||
}
|
||||
|
||||
func pendingMigrations(db *sql.DB) ([]migrationFile, error) {
|
||||
entries, err := fs.ReadDir(migrationFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations: %w", err)
|
||||
return nil, fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
@@ -37,10 +71,11 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
var pending []migrationFile
|
||||
for _, name := range names {
|
||||
version, err := parseMigrationVersion(name)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
var exists int
|
||||
err = db.QueryRow(`SELECT 1 FROM schema_migrations WHERE version = ?`, version).Scan(&exists)
|
||||
@@ -48,36 +83,58 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
continue
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return fmt.Errorf("check migration %d: %w", version, err)
|
||||
return nil, fmt.Errorf("check migration %d: %w", version, err)
|
||||
}
|
||||
|
||||
body, err := migrationFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
return nil, fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
sqlText := strings.TrimSpace(string(body))
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %d: %w", version, err)
|
||||
}
|
||||
if sqlText != "" {
|
||||
if _, err := tx.Exec(sqlText); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %d: %w", version, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`,
|
||||
version,
|
||||
time.Now().UnixMilli(),
|
||||
); err != nil {
|
||||
pending = append(pending, migrationFile{
|
||||
version: version,
|
||||
name: name,
|
||||
body: strings.TrimSpace(string(body)),
|
||||
})
|
||||
}
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
func backupBeforeMigrate(db *sql.DB, dataDir string) error {
|
||||
backupDir := filepath.Join(dataDir, "backup")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir backup: %w", err)
|
||||
}
|
||||
stamp := time.Now().UTC().Format("20060102T150405")
|
||||
backupPath := filepath.Join(backupDir, "pre-migrate-"+stamp+".db")
|
||||
// SQLite VACUUM INTO 需要字面量路径;统一用斜杠,并对单引号转义。
|
||||
quoted := strings.ReplaceAll(filepath.ToSlash(backupPath), "'", "''")
|
||||
if _, err := db.Exec("VACUUM INTO '" + quoted + "'"); err != nil {
|
||||
return fmt.Errorf("vacuum into backup %s: %w", backupPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMigration(db *sql.DB, m migrationFile) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %d: %w", m.version, err)
|
||||
}
|
||||
if m.body != "" {
|
||||
if _, err := tx.Exec(m.body); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("record migration %d: %w", version, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %d: %w", version, err)
|
||||
return fmt.Errorf("apply migration %d (%s): %w", m.version, m.name, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`,
|
||||
m.version,
|
||||
time.Now().UnixMilli(),
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("record migration %d: %w", m.version, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %d: %w", m.version, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user