feat: 实现 version/serve 骨架、迁移占位与健康检查
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// OpenWriter 按 DEVELOPMENT 7.7 打开写连接(带 _txlock=immediate)。
|
||||
func OpenWriter(dataDir, synchronous string) (*sql.DB, error) {
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("mkdir data_dir: %w", err)
|
||||
}
|
||||
dsn, err := writeDSN(dataDir, synchronous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
if err := db.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func writeDSN(dataDir, synchronous string) (string, error) {
|
||||
sync := strings.ToUpper(strings.TrimSpace(synchronous))
|
||||
if sync == "" {
|
||||
sync = "FULL"
|
||||
}
|
||||
if sync != "FULL" && sync != "NORMAL" {
|
||||
return "", fmt.Errorf("invalid sqlite_synchronous: %s", synchronous)
|
||||
}
|
||||
dbPath := filepath.ToSlash(filepath.Join(dataDir, "nixmsg.db"))
|
||||
return fmt.Sprintf(
|
||||
"file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(%s)&_pragma=foreign_keys(ON)&_pragma=secure_delete(ON)&_txlock=immediate",
|
||||
dbPath,
|
||||
sync,
|
||||
), nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAndMigrate(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
db, err := OpenWriter(dir, "FULL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if migErr := Migrate(db); migErr != nil {
|
||||
t.Fatal(migErr)
|
||||
}
|
||||
if migErr := Migrate(db); migErr != nil {
|
||||
t.Fatal(migErr)
|
||||
}
|
||||
|
||||
var n int
|
||||
if scanErr := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&n); scanErr != nil {
|
||||
t.Fatal(scanErr)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("want 1 migration row, got %d", n)
|
||||
}
|
||||
nested, openErr := OpenWriter(filepath.Join(dir, "nested"), "NORMAL")
|
||||
if openErr != nil {
|
||||
t.Fatal(openErr)
|
||||
}
|
||||
_ = nested.Close()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// Migrate 应用尚未执行的迁移。T0.1 仅为空执行器加 0001 占位;完整备份与表结构见 T0.3。
|
||||
func Migrate(db *sql.DB) error {
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("ensure schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
entries, err := fs.ReadDir(migrationFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
version, err := parseMigrationVersion(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var exists int
|
||||
err = db.QueryRow(`SELECT 1 FROM schema_migrations WHERE version = ?`, version).Scan(&exists)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return 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)
|
||||
}
|
||||
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 {
|
||||
_ = 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 nil
|
||||
}
|
||||
|
||||
func parseMigrationVersion(name string) (int, error) {
|
||||
base := strings.TrimSuffix(name, ".sql")
|
||||
parts := strings.SplitN(base, "_", 2)
|
||||
if len(parts) < 1 {
|
||||
return 0, fmt.Errorf("invalid migration name: %s", name)
|
||||
}
|
||||
v, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid migration version in %s: %w", name, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
Reference in New Issue
Block a user