next -> main #149

Open
clawbot wants to merge 11 commits from next into main
6 changed files with 91 additions and 17 deletions
Showing only changes of commit 46a55ec15d - Show all commits
+7
View File
@@ -30,6 +30,13 @@ exhaustion
# Completed Steps # Completed Steps
- 2026-09-29 migrations at the path `REPO_POLICIES.md` sets (closes #96): the
migration files moved, contents unchanged, from `internal/database/schema/`
to `internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the
`internal/db/migrations` package embeds them and `internal/database` reads
them through its `FS()`; the `internal/database` package itself stays; the
version still comes from the filename prefix, so a database that has recorded
versions 0 and 1 runs neither again.
- 2026-09-29 `trusted_proxies` advice and signature padding in `README.md` - 2026-09-29 `trusted_proxies` advice and signature padding in `README.md`
(closes #150): the login-limit paragraph, the `trusted_proxies` entry and (closes #150): the login-limit paragraph, the `trusted_proxies` entry and
`config.example.yml` say to set `trusted_proxies` to the address pixa sees for `config.example.yml` say to set `trusted_proxies` to the address pixa sees for
+15 -17
View File
@@ -4,9 +4,9 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"embed"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -15,14 +15,12 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/db/migrations"
"sneak.berlin/go/pixa/internal/logger" "sneak.berlin/go/pixa/internal/logger"
_ "modernc.org/sqlite" // SQLite driver registration _ "modernc.org/sqlite" // SQLite driver registration
) )
//go:embed schema/*.sql
var schemaFS embed.FS
// bootstrapVersion is the migration that creates the schema_migrations // bootstrapVersion is the migration that creates the schema_migrations
// table itself. It is applied before the normal migration loop. // table itself. It is applied before the normal migration loop.
const bootstrapVersion = 0 const bootstrapVersion = 0
@@ -113,29 +111,29 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
return s, nil return s, nil
} }
// collectMigrations reads the embedded schema directory and returns // collectMigrations reads the embedded migrations directory and returns
// migration filenames sorted lexicographically. // migration filenames sorted lexicographically.
func collectMigrations() ([]string, error) { func collectMigrations() ([]string, error) {
entries, err := schemaFS.ReadDir("schema") entries, err := fs.ReadDir(migrations.FS(), ".")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read schema directory: %w", err) return nil, fmt.Errorf("failed to read migrations directory: %w", err)
} }
var migrations []string var filenames []string
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name()) filenames = append(filenames, entry.Name())
} }
} }
sort.Strings(migrations) sort.Strings(filenames)
return migrations, nil return filenames, nil
} }
// bootstrapMigrationsTable ensures the schema_migrations table exists // bootstrapMigrationsTable ensures the schema_migrations table exists
// by applying 000.sql if the table is missing. // by applying 000_migration.sql if the table is missing.
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error { func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
var tableExists int var tableExists int
@@ -150,9 +148,9 @@ func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger)
return nil return nil
} }
content, err := schemaFS.ReadFile("schema/000.sql") content, err := fs.ReadFile(migrations.FS(), "000_migration.sql")
if err != nil { if err != nil {
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err) return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err)
} }
if log != nil { if log != nil {
@@ -177,12 +175,12 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
return err return err
} }
migrations, err := collectMigrations() filenames, err := collectMigrations()
if err != nil { if err != nil {
return err return err
} }
for _, migration := range migrations { for _, migration := range filenames {
version, parseErr := ParseMigrationVersion(migration) version, parseErr := ParseMigrationVersion(migration)
if parseErr != nil { if parseErr != nil {
return parseErr return parseErr
@@ -208,7 +206,7 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
} }
// Read and apply migration. // Read and apply migration.
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration)) content, readErr := fs.ReadFile(migrations.FS(), migration)
if readErr != nil { if readErr != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, readErr) return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
} }
@@ -0,0 +1,54 @@
package database
import (
"bytes"
"database/sql"
"log/slog"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite" // SQLite driver registration
)
// TestApplyMigrations_SecondRunAppliesNothing applies the migrations twice
// to one database file, as happens when pixad starts again on the database
// it created, and checks that the second run applies none of them.
// ApplyMigrations logs a message starting with "applying" before it runs
// any migration, the bootstrap one included.
func TestApplyMigrations_SecondRunAppliesNothing(t *testing.T) {
t.Parallel()
ctx := t.Context()
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "state.sqlite3"))
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
var firstLog bytes.Buffer
err = ApplyMigrations(ctx, db, slog.New(slog.NewTextHandler(&firstLog, nil)))
if err != nil {
t.Fatalf("first ApplyMigrations failed: %v", err)
}
if !strings.Contains(firstLog.String(), "applying") {
t.Fatalf("first ApplyMigrations logged no applied migration:\n%s",
firstLog.String())
}
var secondLog bytes.Buffer
err = ApplyMigrations(ctx, db, slog.New(slog.NewTextHandler(&secondLog, nil)))
if err != nil {
t.Fatalf("second ApplyMigrations failed: %v", err)
}
if strings.Contains(secondLog.String(), "applying") {
t.Errorf("second ApplyMigrations ran a migration again:\n%s",
secondLog.String())
}
}
+15
View File
@@ -0,0 +1,15 @@
// Package migrations provides the embedded SQL migration files.
package migrations
import (
"embed"
"io/fs"
)
//go:embed *.sql
var files embed.FS
// FS returns the embedded filesystem containing the migration files.
func FS() fs.FS {
return files
}