Bootstraps Vellum as a single Go binary with one SQLite database. - core: env-driven config, slog logging, SQLite (WAL) with pooled pragmas, embedded migration runner keyed on PRAGMA user_version - migrations/0001_core.sql: identity, media, user state, infra. Media items are global; all per-user state (progress, favorites, grants) is a join. One polymorphic progress table covers every media kind. - httpapi: request-ID, structured request logging, panic recovery, JSON endpoints for /healthz and /api/v1/version - multi-arch Dockerfile (pure-Go SQLite, CGO_ENABLED=0) with an entrypoint that honours PUID/PGID like the rest of the self-hosted stack - tests for migration idempotency, FK enforcement, WAL, config, middleware Media does not exist yet. Nothing is exposed beyond health and version.
138 lines
3.6 KiB
Go
138 lines
3.6 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Migrate applies every embedded migration whose number is greater than the
|
|
// database's current PRAGMA user_version, in ascending order, each inside its
|
|
// own transaction.
|
|
//
|
|
// A migration is recorded by its numeric prefix. This is why applied files are
|
|
// never edited: the runner has no checksum, only a version number, so changing
|
|
// an applied file would silently diverge a running database from the schema.
|
|
func Migrate(ctx context.Context, db *sql.DB, fsys fs.FS, log *slog.Logger) error {
|
|
names, err := fs.Glob(fsys, "*.sql")
|
|
if err != nil {
|
|
return fmt.Errorf("list migrations: %w", err)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
current, err := userVersion(ctx, db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Debug("schema version", "current", current, "available", len(names))
|
|
|
|
for _, name := range names {
|
|
n, err := migrationNumber(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n <= current {
|
|
continue
|
|
}
|
|
if err := applyMigration(ctx, db, fsys, name, n); err != nil {
|
|
return fmt.Errorf("migration %s: %w", name, err)
|
|
}
|
|
log.Info("applied migration", "file", name, "version", n)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyMigration(ctx context.Context, db *sql.DB, fsys fs.FS, name string, n int) error {
|
|
body, err := fs.ReadFile(fsys, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck // no-op once Commit succeeds
|
|
|
|
for _, stmt := range splitStatements(string(body)) {
|
|
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
|
return fmt.Errorf("exec: %w\nstatement:\n%s", err, stmt)
|
|
}
|
|
}
|
|
|
|
// PRAGMA does not accept a bound parameter. n is parsed from the filename
|
|
// as an integer above, so it is not attacker-controlled.
|
|
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", n)); err != nil {
|
|
return fmt.Errorf("set user_version: %w", err)
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func userVersion(ctx context.Context, db *sql.DB) (int, error) {
|
|
var v int
|
|
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v); err != nil {
|
|
return 0, fmt.Errorf("read user_version: %w", err)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// migrationNumber parses the numeric prefix of "0001_core.sql" -> 1.
|
|
func migrationNumber(name string) (int, error) {
|
|
base := name
|
|
if i := strings.IndexByte(base, '_'); i >= 0 {
|
|
base = base[:i]
|
|
} else if i := strings.IndexByte(base, '.'); i >= 0 {
|
|
base = base[:i]
|
|
}
|
|
n, err := strconv.Atoi(base)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("migration %q: filename must start with a number, e.g. 0001_core.sql", name)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// splitStatements splits a SQL file on semicolons that terminate a statement.
|
|
//
|
|
// This is not a general SQL parser. It is correct for the schema files this
|
|
// project writes: no stored procedures, no BEGIN...END blocks, no semicolons
|
|
// inside string literals. Comments are stripped first so that a ';' inside a
|
|
// comment cannot split a statement.
|
|
func splitStatements(sqlText string) []string {
|
|
var out []string
|
|
for _, stmt := range strings.Split(stripSQLComments(sqlText), ";") {
|
|
if s := strings.TrimSpace(stmt); s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// stripSQLComments removes -- line comments and /* */ block comments.
|
|
func stripSQLComments(s string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
|
|
for i := 0; i < len(s); {
|
|
switch {
|
|
case strings.HasPrefix(s[i:], "--"):
|
|
for i < len(s) && s[i] != '\n' {
|
|
i++
|
|
}
|
|
case strings.HasPrefix(s[i:], "/*"):
|
|
i += 2
|
|
for i < len(s) && !strings.HasPrefix(s[i:], "*/") {
|
|
i++
|
|
}
|
|
i += 2
|
|
default:
|
|
b.WriteByte(s[i])
|
|
i++
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|