mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 23:44:22 +00:00
Use golang migrate for migrations
This commit is contained in:
@@ -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 != "" {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¤tVersion)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
59
server/internal/db/migrations/001_initial_schema.up.sql
Normal file
59
server/internal/db/migrations/001_initial_schema.up.sql
Normal 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);
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user