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:
+11
-11
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -47,12 +47,12 @@ func (a *app) avatarPath(userID int64) string {
|
||||
// per-song opinion is gated, whole-history aggregate is public.
|
||||
func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView, error) {
|
||||
var p profileView
|
||||
err := a.pool.QueryRow(ctx, `
|
||||
err := a.db.QueryRowContext(ctx, `
|
||||
select u.id, u.name, u.email, u.avatar, u.created_at,
|
||||
(select count(*) from songs s where s.submitted_by = u.id),
|
||||
(select count(*) from reviews r where r.reviewer_id = u.id),
|
||||
(select avg(r.score)::float from reviews r where r.reviewer_id = u.id),
|
||||
(select avg(r.score)::float from reviews r
|
||||
(select avg(r.score) from reviews r where r.reviewer_id = u.id),
|
||||
(select avg(r.score) from reviews r
|
||||
join songs s on s.id = r.song_id where s.submitted_by = u.id)
|
||||
from users u where u.id = $1`, userID).
|
||||
Scan(&p.ID, &p.Name, &p.Email, &p.Avatar, &p.CreatedAt,
|
||||
@@ -67,7 +67,7 @@ func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView
|
||||
}
|
||||
|
||||
// Their songs, with the viewer's own reveal rule applied to each average.
|
||||
rows, err := a.pool.Query(ctx, `select`+songColumns+`
|
||||
rows, err := a.db.QueryContext(ctx, `select`+songColumns+`
|
||||
from songs s join users u on u.id = s.submitted_by
|
||||
where s.submitted_by = $2
|
||||
order by s.created_at desc`, viewerID, userID)
|
||||
@@ -90,7 +90,7 @@ func (a *app) profilePage(w http.ResponseWriter, r *http.Request) {
|
||||
id = parsed
|
||||
}
|
||||
p, err := a.profile(r.Context(), me.ID, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
} else if err != nil {
|
||||
@@ -120,7 +120,7 @@ func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
if _, err := a.db.ExecContext(r.Context(),
|
||||
`update users set name = $2, email = $3 where id = $1`, me.ID, name, email); isUnique(err) {
|
||||
a.flash(w, "Sähköpostiosoite on jo käytössä.")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
@@ -153,7 +153,7 @@ func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int64, current, next string) bool {
|
||||
var hash string
|
||||
if err := a.pool.QueryRow(r.Context(),
|
||||
if err := a.db.QueryRowContext(r.Context(),
|
||||
`select password_hash from users where id = $1`, userID).Scan(&hash); err != nil {
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return false
|
||||
@@ -168,13 +168,13 @@ func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int6
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
if _, err := a.db.ExecContext(r.Context(),
|
||||
`update users set password_hash = $2 where id = $1`, userID, string(newHash)); err != nil {
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
// Every other session dies; this browser keeps its own.
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
if _, err := a.db.ExecContext(r.Context(),
|
||||
`delete from sessions where user_id = $1 and token <> $2`, userID, sessionToken(r)); err != nil {
|
||||
slog.Error("drop sessions", "ctx", "auth", "error", err, "user", userID)
|
||||
}
|
||||
@@ -202,7 +202,7 @@ func (a *app) saveAvatar(r *http.Request, userID int64, file io.Reader) error {
|
||||
if err := toAvatarJPEG(r.Context(), tmp, out); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.pool.Exec(r.Context(),
|
||||
_, err = a.db.ExecContext(r.Context(),
|
||||
`update users set avatar = $2 where id = $1`, userID, filepath.Base(out))
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user