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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user