Select the types of activity you want to include in your feed.
feat: databse migrations now in play
Previously the database had once schema file and that was edited. Now we have migrations so changes can be made over time. The migrations support SQLite (used for local development) and MySQL compatible databses.
···4848reset-db: ## Remove sqlite database
4949 rm -f tumble.sqlite
50505151+backup: ## Backup the current database
5252+ cp tumble.sqlite tumble.sqlite.bak
5353+ @echo "Database backed up to tumble.sqlite.bak"
5454+5555+restore: ## Restore the database from backup
5656+ cp tumble.sqlite.bak tumble.sqlite
5757+ @echo "Database restored from tumble.sqlite.bak"
5858+5159load-fixtures: ## Load test fixtures
5260 ./tests/load_fixtures.sh
6161+6262+run-test: build ## Run the application with the test database
6363+ $(BUILD_DIR)/$(BINARY_NAME) conf/config-test.yaml
6464+6565+6666+6767+test-db: build ## Create a fresh test database with fixtures
6868+ ./tests/setup_test_db.sh
53695470help: ## Show this help message
5571 @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
+30-1
README.md
···12121313If you are not on EL, things should still work. Just `make install` or package it yourself.
14141515-1615## Database Support
17161817Tumble now supports both **MySQL** and **SQLite** databases. Choose the one that fits your needs:
···2019- **SQLite**: Recommended for development, testing, and small deployments. No separate database server required.
2120- **MySQL**: Recommended for production deployments with higher traffic.
22212222+## Development Workflows
2323+2424+### Database Migrations
2525+2626+Tumble uses [golang-migrate](https://github.com/golang-migrate/migrate) to manage database schemas. Migrations are automatically applied on application startup.
2727+2828+- **Location**: Migration files are stored in `sql/mysql/` and `sql/sqlite/`.
2929+- **Versioning**: Files are named sequentially (e.g., `000001_init.up.sql`).
3030+- **Adding Migrations**: To change the schema, create a new numbered `.up.sql` file in the appropriate directory.
3131+3232+### Testing Infrastructure
3333+3434+A robust test infrastructure is available for rapid development:
3535+3636+- **Run Tests**: `make test-api` runs the integration tests.
3737+- **Test Database**: `make test-db` creates a fresh, disposable SQLite database (`tumble-test.sqlite`), runs migrations, and loads sample fixtures.
3838+- **Run with Test DB**: `make run-test` starts the application using the test database.
3939+- **Backup/Restore**: `make backup` and `make restore` allow you to snapshot your current development database.
4040+4141+To customize the test environment, you can edit `conf/config-test.yaml`.
4242+2343## Quick Setup
24442545### 1. Configure Database
···2747Create or edit `config.yaml` in the htdocs directory:
28482949**For SQLite (easiest):**
5050+3051```yaml
3152driver: sqlite
3253database_file: tumble.db
···3455```
35563657**For MySQL:**
5858+3759```yaml
3860driver: mysql
3961database: tumble
···84106## Migration from MySQL to SQLite
85107861081. Export your MySQL data:
109109+87110 ```bash
88111 mysqldump -u tumble -p tumble > tumble_backup.sql
89112 ```
···911142. Update `config.yaml` to use SQLite
92115931163. Run setup script:
117117+94118 ```bash
95119 perl scripts/setup_database.pl
96120 ```
···127151```
128152129153With debug mode enabled, you'll see:
154154+130155- All SQL queries being executed
131156- Row counts for each query result
132157- Detailed query execution information
133158134159**Example:**
160160+135161```bash
136162# Enable debug mode
137163export TUMBLE_DEBUG=1
···143169### Log Output Examples
144170145171**Successful MySQL connection:**
172172+146173```
147174[MySQL] Attempting to connect to database 'tumble' on host 'localhost' as user 'tumble'
148175[MySQL] Connection SUCCESSFUL
···151178```
152179153180**SQLite with missing database file:**
181181+154182```
155183[SQLite] Attempting to connect to database file: tumble.db
156184[SQLite] Database file DOES NOT EXIST
···162190```
163191164192**Connection failure with diagnostics:**
193193+165194```
166195[MySQL] Connection FAILED: Access denied for user 'tumble'@'localhost'
167196[MySQL] Diagnostics:
+4-1
cmd/tumble/main.go
···177177 mux.HandleFunc("/api/openapi.json", h.OpenAPISpecHandler)
178178179179 // Start
180180- addr := ":8080" // Default or from config? Perl was CGI so port wasn't in config.
180180+ addr := ":" + cfg.Port
181181+ if cfg.Port == "" {
182182+ addr = ":8080"
183183+ }
181184 slog.Info("Starting tumble server", "addr", addr)
182185 if err := http.ListenAndServe(addr, loggingMiddleware(mux)); err != nil {
183186 slog.Error("Server failed", "error", err)
···11+package data
22+33+import (
44+ "database/sql"
55+ "fmt"
66+ "log/slog"
77+88+ "github.com/golang-migrate/migrate/v4"
99+ "github.com/golang-migrate/migrate/v4/database"
1010+ "github.com/golang-migrate/migrate/v4/database/mysql"
1111+ "github.com/golang-migrate/migrate/v4/source/iofs"
1212+1313+ tumblesql "tumble/sql"
1414+)
1515+1616+// RunMigrations executes pending migrations.
1717+// driverName should be "mysql" or "sqlite"
1818+func RunMigrations(db *sql.DB, driverName string, databaseName string) error {
1919+ slog.Info("Running migrations", "driver", driverName)
2020+2121+ sourceDriver, err := iofs.New(tumblesql.MigrationFS, driverName)
2222+ if err != nil {
2323+ return fmt.Errorf("failed to create iofs source: %w", err)
2424+ }
2525+2626+ var databaseDriver database.Driver
2727+2828+ switch driverName {
2929+ case "mysql":
3030+ databaseDriver, err = mysql.WithInstance(db, &mysql.Config{})
3131+ if err != nil {
3232+ return fmt.Errorf("failed to create mysql driver: %w", err)
3333+ }
3434+ case "sqlite":
3535+ // Custom driver for modernc/sqlite (no CGO)
3636+ // We implement this in sqlite_driver.go
3737+ databaseDriver, err = WithInstance(db, &Config{})
3838+ if err != nil {
3939+ return fmt.Errorf("failed to create sqlite driver: %w", err)
4040+ }
4141+ default:
4242+ return fmt.Errorf("unsupported driver: %s", driverName)
4343+ }
4444+4545+ m, err := migrate.NewWithInstance(
4646+ "iofs", sourceDriver,
4747+ driverName, databaseDriver,
4848+ )
4949+ if err != nil {
5050+ return fmt.Errorf("failed to create migrate instance: %w", err)
5151+ }
5252+5353+ if err := m.Up(); err != nil && err != migrate.ErrNoChange {
5454+ return fmt.Errorf("failed to run up migrations: %w", err)
5555+ }
5656+5757+ slog.Info("Migrations completed successfully")
5858+ return nil
5959+}
+5-29
internal/data/mysql.go
···394394}
395395396396func (s *MySQLStore) Bootstrap(ctx context.Context) error {
397397- schema, err := SchemaFS.ReadFile("schema.mysql")
398398- if err != nil {
399399- return err
400400- }
401401- // Simple split by ; might fail on complex SQL, but for this schema it's fine.
402402- // Actually, the schema has multi-line statements.
403403- // A robust solution executes the whole script if the driver supports it, or splits carefully.
404404- // MySQL driver often supports multiple statements if enabled, but better to execute one by one if split properly.
405405- // For this specific schema, splitting by `;` works because there are no semicolons inside strings/triggers.
406406- // HOWEVER, creating a new method to execute script is cleaner.
407407-408408- // Actually, just executing the whole thing might work if multiStatements=true in DSN, but let's assume not.
409409- // We'll follow a simple split approach for now, or just execute the known CREATE statements.
410410- // Since we want to use the embedded file, we should parse it.
411411-412412- // Simpler: Just execute the file content?
413413- // Drivers behave differently.
414414- // Let's rely on the file content being simple enough.
415415-416416- queries := splitSQL(string(schema))
417417- for _, q := range queries {
418418- if q == "" {
419419- continue
420420- }
421421- if _, err := s.db.ExecContext(ctx, q); err != nil {
422422- return fmt.Errorf("failed to execute query %q: %w", q, err)
423423- }
424424- }
425425- return nil
397397+ // User databaseName "tumble" or extract from config?
398398+ // DSN parsing is complex. For now we assume "mysql" driver name is sufficient.
399399+ // Actually, RunMigrations needs databaseName to keep migrate logic happy implicitly?
400400+ // The driverName check we wrote just uses "mysql".
401401+ return RunMigrations(s.db, "mysql", "mysql")
426402}
+1-11
internal/data/sqlite.go
···391391}
392392393393func (s *SQLiteStore) Bootstrap(ctx context.Context) error {
394394- schema, err := SchemaFS.ReadFile("schema.sqlite")
395395- if err != nil {
396396- return err
397397- }
398398-399399- // modernc.org/sqlite usually handles multiple statements in one Exec.
400400- // Let's try executing the whole block.
401401- if _, err := s.db.ExecContext(ctx, string(schema)); err != nil {
402402- return err
403403- }
404404- return nil
394394+ return RunMigrations(s.db, "sqlite", "sqlite")
405395}
+137
internal/data/sqlite_driver.go
···11+package data
22+33+import (
44+ "database/sql"
55+ "fmt"
66+ "io"
77+ "io/ioutil"
88+ "strings"
99+1010+ migrate "github.com/golang-migrate/migrate/v4/database"
1111+)
1212+1313+func init() {
1414+ // We don't register automatically to avoid importing this package everywhere
1515+}
1616+1717+type Config struct {
1818+ MigrationsTable string
1919+ NoTxWrap bool
2020+}
2121+2222+type SQLite struct {
2323+ db *sql.DB
2424+ config *Config
2525+}
2626+2727+func WithInstance(instance *sql.DB, config *Config) (migrate.Driver, error) {
2828+ if config == nil {
2929+ config = &Config{}
3030+ }
3131+ if config.MigrationsTable == "" {
3232+ config.MigrationsTable = "schema_migrations"
3333+ }
3434+ return &SQLite{
3535+ db: instance,
3636+ config: config,
3737+ }, nil
3838+}
3939+4040+func (s *SQLite) Open(url string) (migrate.Driver, error) {
4141+ return nil, fmt.Errorf("not implemented, use WithInstance")
4242+}
4343+4444+func (s *SQLite) Close() error {
4545+ // We don't close the DB here as it's passed in instance
4646+ return nil
4747+}
4848+4949+func (s *SQLite) Lock() error {
5050+ return nil // SQLite doesn't need explicit locking usually if single threaded migration or handled by db lock
5151+}
5252+5353+func (s *SQLite) Unlock() error {
5454+ return nil
5555+}
5656+5757+func (s *SQLite) Run(migration io.Reader) error {
5858+ migr, err := ioutil.ReadAll(migration)
5959+ if err != nil {
6060+ return err
6161+ }
6262+ query := string(migr)
6363+ if strings.TrimSpace(query) == "" {
6464+ return nil
6565+ }
6666+6767+ if s.config.NoTxWrap {
6868+ _, err := s.db.Exec(query)
6969+ return err
7070+ }
7171+7272+ tx, err := s.db.Begin()
7373+ if err != nil {
7474+ return err
7575+ }
7676+ if _, err := tx.Exec(query); err != nil {
7777+ tx.Rollback()
7878+ return err
7979+ }
8080+ return tx.Commit()
8181+}
8282+8383+func (s *SQLite) SetVersion(version int, dirty bool) error {
8484+ tx, err := s.db.Begin()
8585+ if err != nil {
8686+ return err
8787+ }
8888+ // Delete all
8989+ if _, err := tx.Exec(fmt.Sprintf("DELETE FROM %s", s.config.MigrationsTable)); err != nil {
9090+ tx.Rollback()
9191+ return err
9292+ }
9393+ // Insert new
9494+ if _, err := tx.Exec(fmt.Sprintf("INSERT INTO %s (version, dirty) VALUES (?, ?)", s.config.MigrationsTable), version, dirty); err != nil {
9595+ tx.Rollback()
9696+ return err
9797+ }
9898+ return tx.Commit()
9999+}
100100+101101+func (s *SQLite) Version() (version int, dirty bool, err error) {
102102+ query := fmt.Sprintf("SELECT version, dirty FROM %s LIMIT 1", s.config.MigrationsTable)
103103+ err = s.db.QueryRow(query).Scan(&version, &dirty)
104104+ if err != nil {
105105+ if err == sql.ErrNoRows {
106106+ return migrate.NilVersion, false, nil
107107+ }
108108+ // If table doesn't exist, create it and return nil version
109109+ // simple check: try creating
110110+ if err := s.ensureVersionTable(); err != nil {
111111+ return 0, false, err
112112+ }
113113+ // Retry
114114+ err = s.db.QueryRow(query).Scan(&version, &dirty)
115115+ if err == sql.ErrNoRows {
116116+ return migrate.NilVersion, false, nil
117117+ }
118118+ return version, dirty, err
119119+ }
120120+ return version, dirty, nil
121121+}
122122+123123+func (s *SQLite) Drop() error {
124124+ // Not implemented
125125+ return nil
126126+}
127127+128128+func (s *SQLite) ensureVersionTable() error {
129129+ query := fmt.Sprintf(`
130130+ CREATE TABLE IF NOT EXISTS %s (
131131+ version INTEGER PRIMARY KEY,
132132+ dirty BOOLEAN NOT NULL
133133+ )
134134+ `, s.config.MigrationsTable)
135135+ _, err := s.db.Exec(query)
136136+ return err
137137+}
···11+#!/bin/bash
22+set -e
33+44+# Configuration
55+BINARY="./bin/tumble"
66+CONFIG="conf/config-test.yaml"
77+DB_PATH="tumble-test.sqlite"
88+PORT="8080"
99+BASE_URL="http://localhost:$PORT"
1010+1111+echo "Setting up $DB_PATH..."
1212+rm -f "$DB_PATH"
1313+1414+# Start server
1515+echo "Starting server..."
1616+$BINARY "$CONFIG" &
1717+PID=$!
1818+echo "Server PID: $PID"
1919+2020+# Ensure cleanup
2121+trap "echo 'Stopping server...'; kill $PID || true" EXIT
2222+2323+# Wait for server to be ready
2424+echo "Waiting for server to be ready on port $PORT..."
2525+for i in {1..30}; do
2626+ if curl -s "http://localhost:$PORT" >/dev/null; then
2727+ echo "Server is up!"
2828+ break
2929+ fi
3030+ sleep 1
3131+done
3232+3333+# Run fixtures
3434+echo "Running fixtures..."
3535+export DB_PATH="$DB_PATH"
3636+export API_BASE_URL="$BASE_URL"
3737+./tests/load_fixtures.sh
3838+3939+echo "Test database created successfully."