naco-api/internal/store/migrate.go
Nacorid 8cfb1291aa ops: migrator, enrichment backpressure, ollama fallback, readiness
- Add dependency-free in-process schema migrator (internal/store/migrate.go) that applies
  sql/schema/*.sql in order, tracking versions in schema_migrations. Gated by MIGRATE_ON_START
  and MIGRATIONS_DIR (default sql/schema).
- Fix migration ordering: 002 now creates the 'vector' extension before defining the
  user_style_profiles table (previously the extension was only created later in 003).
- Bound concurrent background enrichment with a 4-slot semaphore so bursts of live
  getStyleProfile calls cannot overwhelm Ollama.
- buildStyleProfile degrades gracefully when Ollama is down: stores a zero embedding and a
  keyword-based fallback summary instead of failing the whole request.
- Add a /readyz side server (READYZ_PORT, default 9090) that checks DB connectivity and
  Ollama availability.

Co-Authored-By: Claude (AI assistant) <noreply@anthropic.com>
2026-08-29 02:09:50 +02:00

106 lines
2.9 KiB
Go

package store
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Migrate applies every *.sql file in dir (in filename order). Each file is expected to
// contain a "-- +goose up" section (between that marker and "-- +goose down", or EOF) which
// is executed inside a single transaction. Applied versions are recorded in
// schema_migrations so re-running is a no-op.
func Migrate(ctx context.Context, db *sql.DB, dir string) error {
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("create schema_migrations table: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("read migrations dir %q: %w", dir, err)
}
type migration struct{ name, up string }
migrations := make([]migration, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return fmt.Errorf("read migration %q: %w", e.Name(), err)
}
migrations = append(migrations, migration{name: e.Name(), up: extractUpSQL(string(raw))})
}
sort.Slice(migrations, func(i, j int) bool { return migrations[i].name < migrations[j].name })
for _, m := range migrations {
if strings.TrimSpace(m.up) == "" {
continue
}
applied, err := versionApplied(ctx, db, m.name)
if err != nil {
return err
}
if applied {
continue
}
if err := applyMigration(ctx, db, m.name, m.up); err != nil {
return fmt.Errorf("apply migration %q: %w", m.name, err)
}
}
return nil
}
// extractUpSQL returns the SQL between the "-- +goose up" marker and the next
// "-- +goose down" marker (or end of file).
func extractUpSQL(content string) string {
var b strings.Builder
inUp := false
for _, line := range strings.Split(content, "\n") {
switch strings.TrimSpace(line) {
case "-- +goose up":
inUp = true
continue
case "-- +goose down":
inUp = false
continue
}
if inUp {
b.WriteString(line)
b.WriteString("\n")
}
}
return b.String()
}
func versionApplied(ctx context.Context, db *sql.DB, version string) (bool, error) {
var count int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = $1`, version).Scan(&count); err != nil {
return false, fmt.Errorf("check migration %q: %w", version, err)
}
return count > 0, nil
}
func applyMigration(ctx context.Context, db *sql.DB, version, up string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, up); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}