Replace Postgres with SQLite

Ten members and a handful of songs a week never needed a database server,
and the server was the last thing making this a two-container deployment.
modernc.org/sqlite is pure Go, so CGO_ENABLED=0 survives and the dependency
count is unchanged: pgx out, sqlite in.

The port stayed small because the driver matches $1-style placeholders
against argument ordinals exactly as pgx does, so no query needed rewriting
for parameters. What did change:

- timestamptz becomes timestamp holding UTC 'YYYY-MM-DD HH:MM:SS'. The
  declared type is what makes the driver return time.Time, and the
  fixed-width UTC string is what makes ordering and comparison against
  datetime('now') mean what they say.
- interval has no equivalent: sessions.idle_ttl is seconds, and the review
  edit window travels as a SQLite date modifier string.
- No stddev_pop, so the divisive and unified boards spell the population
  formula out, guarded with max(0.0, ...) because cancellation returns a
  tiny negative when every score is identical.
- foreign_keys is off by default, so the cascades only exist because the
  pragma is set on every connection.

Drops the postgres service, its healthcheck, the depends_on gate, the
startup retry loop and POSTGRES_PASSWORD. ./storage is now the whole
backup. Tests get a fresh database file per test and run everywhere
instead of skipping without TEST_DATABASE_URL.
This commit is contained in:
Esa Kataja
2026-08-02 20:47:41 +03:00
parent a9776c6dde
commit 1fe5211ae6
28 changed files with 440 additions and 389 deletions
+35 -26
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"log/slog"
@@ -10,8 +11,8 @@ import (
"strings"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
"modernc.org/sqlite"
)
const (
@@ -79,9 +80,9 @@ func (a *app) startSession(ctx context.Context, userID int64, remember bool) (st
}
tok := token()
expires := time.Now().Add(ttl)
_, err := a.pool.Exec(ctx,
_, err := a.db.ExecContext(ctx,
`insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`,
tok, userID, ttl, expires)
tok, userID, int64(ttl.Seconds()), expires)
return tok, expires, err
}
@@ -100,32 +101,32 @@ func (a *app) session(w http.ResponseWriter, r *http.Request) *member {
return nil
}
var (
m member
expires time.Time
ttl time.Duration
ttlMicros int64
m member
expires time.Time
ttl time.Duration
ttlSeconds int64
)
err := a.pool.QueryRow(r.Context(), `
select s.expires_at, extract(epoch from s.idle_ttl) * 1000000,
err := a.db.QueryRowContext(r.Context(), `
select s.expires_at, s.idle_ttl,
u.id, u.name, u.email, u.avatar, u.banned, u.created_at
from sessions s join users u on u.id = s.user_id
where s.token = $1 and s.expires_at > now()`, tok).
Scan(&expires, &ttlMicros, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt)
where s.token = $1 and s.expires_at > datetime('now')`, tok).
Scan(&expires, &ttlSeconds, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
if !errors.Is(err, sql.ErrNoRows) {
slog.Error("session lookup", "ctx", "auth", "error", err)
}
return nil
}
if m.Banned {
// Banning deletes sessions, so this is belt and braces for a row that outlived one.
a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, m.ID)
a.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, m.ID)
return nil
}
ttl = time.Duration(ttlMicros) * time.Microsecond
ttl = time.Duration(ttlSeconds) * time.Second
if time.Until(expires) < ttl-extendAfter {
newExpiry := time.Now().Add(ttl)
if _, err := a.pool.Exec(r.Context(),
if _, err := a.db.ExecContext(r.Context(),
`update sessions set expires_at = $2 where token = $1`, tok, newExpiry); err == nil {
a.setSessionCookie(w, tok, newExpiry)
}
@@ -178,7 +179,7 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
hash string
banned bool
)
err := a.pool.QueryRow(r.Context(),
err := a.db.QueryRowContext(r.Context(),
`select id, password_hash, banned from users where email = $1`, email).Scan(&id, &hash, &banned)
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil {
a.logins.fail(email)
@@ -208,7 +209,7 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
func (a *app) logout(w http.ResponseWriter, r *http.Request) {
if tok := sessionToken(r); tok != "" {
a.pool.Exec(r.Context(), `delete from sessions where token = $1`, tok)
a.db.ExecContext(r.Context(), `delete from sessions where token = $1`, tok)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
@@ -259,19 +260,19 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := a.pool.Begin(r.Context())
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
slog.Error("begin", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
defer tx.Rollback()
var inviteID int64
err = tx.QueryRow(r.Context(),
`update invites set is_valid = false where code = $1 and is_valid returning id`,
err = tx.QueryRowContext(r.Context(),
`update invites set is_valid = 0 where code = $1 and is_valid returning id`,
form.Code).Scan(&inviteID)
if errors.Is(err, pgx.ErrNoRows) {
if errors.Is(err, sql.ErrNoRows) {
form.Errors["code"] = "Kutsukoodi ei kelpaa."
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
return
@@ -282,7 +283,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
}
var userID int64
err = tx.QueryRow(r.Context(),
err = tx.QueryRowContext(r.Context(),
`insert into users (name, email, password_hash) values ($1, $2, $3) returning id`,
form.Name, form.Email, string(hash)).Scan(&userID)
if isUnique(err) {
@@ -295,7 +296,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
if err := tx.Commit(); err != nil {
slog.Error("commit registration", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
@@ -313,7 +314,15 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// SQLITE_CONSTRAINT_UNIQUE and SQLITE_CONSTRAINT_PRIMARYKEY, spelled out rather than pulled in from
// modernc.org/sqlite/lib — that package is the whole generated amalgamation, for two integers.
const (
sqliteConstraintUnique = 2067
sqliteConstraintPrimaryKey = 1555
)
func isUnique(err error) bool {
var pgErr interface{ SQLState() string }
return errors.As(err, &pgErr) && pgErr.SQLState() == "23505"
var e *sqlite.Error
return errors.As(err, &e) &&
(e.Code() == sqliteConstraintUnique || e.Code() == sqliteConstraintPrimaryKey)
}