Use golang migrate for migrations

This commit is contained in:
2025-02-22 21:53:12 +01:00
parent aef42ff33c
commit d47f7d7fb0
10 changed files with 220 additions and 141 deletions

View File

@@ -2,9 +2,11 @@ package app
import (
"fmt"
"lemma/internal/db"
"lemma/internal/logging"
"lemma/internal/secrets"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -12,7 +14,8 @@ import (
// Config holds the configuration for the application
type Config struct {
DBPath string
DBURL string
DBType db.DBType
WorkDir string
StaticPath string
Port string
@@ -31,7 +34,7 @@ type Config struct {
// DefaultConfig returns a new Config instance with default values
func DefaultConfig() *Config {
return &Config{
DBPath: "./lemma.db",
DBURL: "sqlite://lemma.db",
WorkDir: "./data",
StaticPath: "../app/dist",
Port: "8080",
@@ -65,6 +68,31 @@ func (c *Config) Redact() *Config {
return &redacted
}
// ParseDBURL parses a database URL and returns the driver name and data source
func ParseDBURL(dbURL string) (db.DBType, string, error) {
if strings.HasPrefix(dbURL, "sqlite://") || strings.HasPrefix(dbURL, "sqlite3://") {
path := strings.TrimPrefix(dbURL, "sqlite://")
path = strings.TrimPrefix(path, "sqlite3://")
if path == ":memory:" {
return db.DBTypeSQLite, path, nil
}
if !filepath.IsAbs(path) {
path = filepath.Clean(path)
}
return db.DBTypeSQLite, path, nil
}
// Try to parse as postgres URL
if strings.HasPrefix(dbURL, "postgres://") || strings.HasPrefix(dbURL, "postgresql://") {
return db.DBTypePostgres, dbURL, nil
}
return "", "", fmt.Errorf("unsupported database URL format: %s", dbURL)
}
// LoadConfig creates a new Config instance with values from environment variables
func LoadConfig() (*Config, error) {
config := DefaultConfig()
@@ -73,8 +101,13 @@ func LoadConfig() (*Config, error) {
config.IsDevelopment = env == "development"
}
if dbPath := os.Getenv("LEMMA_DB_PATH"); dbPath != "" {
config.DBPath = dbPath
if dbURL := os.Getenv("LEMMA_DB_URL"); dbURL != "" {
dbType, dataSource, err := ParseDBURL(dbURL)
if err != nil {
return nil, err
}
config.DBURL = dataSource
config.DBType = dbType
}
if workDir := os.Getenv("LEMMA_WORKDIR"); workDir != "" {

View File

@@ -17,7 +17,7 @@ func TestDefaultConfig(t *testing.T) {
got interface{}
expected interface{}
}{
{"DBPath", cfg.DBPath, "./lemma.db"},
{"DBPath", cfg.DBURL, "./lemma.db"},
{"WorkDir", cfg.WorkDir, "./data"},
{"StaticPath", cfg.StaticPath, "../app/dist"},
{"Port", cfg.Port, "8080"},
@@ -81,8 +81,8 @@ func TestLoad(t *testing.T) {
t.Fatalf("Load() error = %v", err)
}
if cfg.DBPath != "./lemma.db" {
t.Errorf("default DBPath = %v, want %v", cfg.DBPath, "./lemma.db")
if cfg.DBURL != "./lemma.db" {
t.Errorf("default DBPath = %v, want %v", cfg.DBURL, "./lemma.db")
}
})
@@ -122,7 +122,7 @@ func TestLoad(t *testing.T) {
expected interface{}
}{
{"IsDevelopment", cfg.IsDevelopment, true},
{"DBPath", cfg.DBPath, "/custom/db/path.db"},
{"DBPath", cfg.DBURL, "/custom/db/path.db"},
{"WorkDir", cfg.WorkDir, "/custom/work/dir"},
{"StaticPath", cfg.StaticPath, "/custom/static/path"},
{"Port", cfg.Port, "3000"},

View File

@@ -28,9 +28,9 @@ func initSecretsService(cfg *Config) (secrets.Service, error) {
// initDatabase initializes and migrates the database
func initDatabase(cfg *Config, secretsService secrets.Service) (db.Database, error) {
logging.Debug("initializing database", "path", cfg.DBPath)
logging.Debug("initializing database", "path", cfg.DBURL)
database, err := db.Init(cfg.DBPath, secretsService)
database, err := db.Init(cfg.DBURL, secretsService)
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}

View File

@@ -12,6 +12,13 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver
)
type DBType string
const (
DBTypeSQLite DBType = "sqlite3"
DBTypePostgres DBType = "postgres"
)
// UserStore defines the methods for interacting with user data in the database
type UserStore interface {
CreateUser(user *models.User) (*models.User, error)
@@ -108,6 +115,7 @@ func getLogger() logging.Logger {
type database struct {
*sql.DB
secretsService secrets.Service
dbType DBType
}
// Init initializes the database connection

View File

@@ -1,141 +1,60 @@
package db
import (
"embed"
"fmt"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
// Migration represents a database migration
type Migration struct {
Version int
SQL string
}
var migrations = []Migration{
{
Version: 1,
SQL: `
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
display_name TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_workspace_id INTEGER
);
-- Create workspaces table with integrated settings
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_opened_file_path TEXT,
-- Settings fields
theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')),
auto_save BOOLEAN NOT NULL DEFAULT 0,
git_enabled BOOLEAN NOT NULL DEFAULT 0,
git_url TEXT,
git_user TEXT,
git_token TEXT,
git_auto_commit BOOLEAN NOT NULL DEFAULT 0,
git_commit_msg_template TEXT DEFAULT '${action} ${filename}',
git_commit_name TEXT,
git_commit_email TEXT,
show_hidden_files BOOLEAN NOT NULL DEFAULT 0,
created_by INTEGER REFERENCES users(id),
updated_by INTEGER REFERENCES users(id),
updated_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
-- Create sessions table for authentication
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
refresh_token TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-- Create system_settings table for application settings
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for performance
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX idx_sessions_refresh_token ON sessions(refresh_token);
`,
},
}
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Migrate applies all database migrations
func (db *database) Migrate() error {
log := getLogger().WithGroup("migrations")
log.Info("starting database migration")
// Create migrations table if it doesn't exist
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS migrations (
version INTEGER PRIMARY KEY
)`)
sourceInstance, err := iofs.New(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("failed to create migrations table: %w", err)
return fmt.Errorf("failed to create source instance: %w", err)
}
// Get current version
var currentVersion int
err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM migrations").Scan(&currentVersion)
if err != nil {
return fmt.Errorf("failed to get current migration version: %w", err)
}
var m *migrate.Migrate
// Apply new migrations
for _, migration := range migrations {
if migration.Version > currentVersion {
log := log.With("migration_version", migration.Version)
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction for migration %d: %w", migration.Version, err)
}
// Execute migration SQL
_, err = tx.Exec(migration.SQL)
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("migration %d failed: %v, rollback failed: %v",
migration.Version, err, rbErr)
}
return fmt.Errorf("migration %d failed: %w", migration.Version, err)
}
// Update migrations table
_, err = tx.Exec("INSERT INTO migrations (version) VALUES (?)", migration.Version)
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("failed to update migration version: %v, rollback failed: %v",
err, rbErr)
}
return fmt.Errorf("failed to update migration version: %w", err)
}
// Commit transaction
err = tx.Commit()
if err != nil {
return fmt.Errorf("failed to commit migration %d: %w", migration.Version, err)
}
currentVersion = migration.Version
log.Debug("migration applied", "new_version", currentVersion)
driverName := db.dbType
switch driverName {
case "postgres":
driver, err := postgres.WithInstance(db.DB, &postgres.Config{})
if err != nil {
return fmt.Errorf("failed to create postgres driver: %w", err)
}
m, err = migrate.NewWithInstance("iofs", sourceInstance, "postgres", driver)
if err != nil {
return fmt.Errorf("failed to create migrate instance: %w", err)
}
case "sqlite3":
driver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{})
if err != nil {
return fmt.Errorf("failed to create sqlite driver: %w", err)
}
m, err = migrate.NewWithInstance("iofs", sourceInstance, "sqlite3", driver)
if err != nil {
return fmt.Errorf("failed to create migrate instance: %w", err)
}
default:
return fmt.Errorf("unsupported database driver: %s", driverName)
}
log.Info("database migration completed", "final_version", currentVersion)
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("failed to run migrations: %w", err)
}
log.Info("database migration completed")
return nil
}

View File

@@ -0,0 +1,8 @@
-- 001_initial_schema.down.sql
DROP INDEX IF EXISTS idx_sessions_refresh_token;
DROP INDEX IF EXISTS idx_sessions_expires_at;
DROP INDEX IF EXISTS idx_sessions_user_id;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS workspaces;
DROP TABLE IF EXISTS system_settings;
DROP TABLE IF EXISTS users;

View File

@@ -0,0 +1,59 @@
-- 001_initial_schema.up.sql
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
email TEXT NOT NULL UNIQUE,
display_name TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_workspace_id INTEGER
);
-- Create workspaces table with integrated settings
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_opened_file_path TEXT,
-- Settings fields
theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')),
auto_save BOOLEAN NOT NULL DEFAULT 0,
git_enabled BOOLEAN NOT NULL DEFAULT 0,
git_url TEXT,
git_user TEXT,
git_token TEXT,
git_auto_commit BOOLEAN NOT NULL DEFAULT 0,
git_commit_msg_template TEXT DEFAULT '${action} ${filename}',
git_commit_name TEXT,
git_commit_email TEXT,
show_hidden_files BOOLEAN NOT NULL DEFAULT 0,
created_by INTEGER REFERENCES users(id),
updated_by INTEGER REFERENCES users(id),
updated_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
-- Create sessions table for authentication
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
refresh_token TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-- Create system_settings table for application settings
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for performance
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX idx_sessions_refresh_token ON sessions(refresh_token);

View File

@@ -99,7 +99,7 @@ func setupTestHarness(t *testing.T) *testHarness {
// Create test config
testConfig := &app.Config{
DBPath: ":memory:",
DBURL: ":memory:",
WorkDir: tempDir,
StaticPath: "../testdata",
Port: "8081",