From 0f15ae0bfc33a0e8504aa354acacce158010505c Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sun, 2 Aug 2026 20:58:52 +0300 Subject: [PATCH] Release 2026.08.02-1 SQLite replaces Postgres, and two fixes from using the thing. - The database is a file under ./storage instead of a second container. Ten members never needed a database server, and the driver is pure Go, so the build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind mount is now the whole backup: no pgdata, no healthcheck-gated depends_on, no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the divisive/unified boards carry their own stddev, and foreign keys are on by pragma. Tests get a database file each and run without any setup - Invites are copied, not clicked. An invite is something to send, and the anchor opened the join form in the admin's own browser - Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita? Palautetta?" and the page behind it invites ideas rather than only bugs - Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it --- .env.example | 3 +- .gitignore | 1 - Dockerfile | 4 +- README.md | 36 ++++++++-------- admin.go | 22 +++++----- auth.go | 61 +++++++++++++++------------ auth_test.go | 46 +++++++++------------ docker-compose.yml | 22 +--------- docs/decisions.md | 27 ++++++++++++ docs/later.md | 2 +- docs/spec.md | 49 +++++++++++++--------- go.mod | 24 +++++++---- go.sum | 22 ++++++++++ lyrics.go | 4 +- main.go | 90 +++++++++++++++++++++++----------------- main_test.go | 27 +++--------- migrate.go | 39 ++++++++--------- migrations/001_init.sql | 92 +++++++++++++++++++++-------------------- profile.go | 22 +++++----- render.go | 4 +- reports.go | 19 +++++---- reviews.go | 37 +++++++++-------- songs.go | 35 ++++++++-------- songs_test.go | 32 +++++++------- static/style.css | 4 ++ stats.go | 30 +++++++------- stats_test.go | 2 +- submit.go | 47 +++++++++++---------- submit_test.go | 30 +++++++------- templates/admin.html | 32 +++++++++++++- templates/layout.html | 2 +- templates/report.html | 6 +-- templates/song.html | 3 +- 33 files changed, 480 insertions(+), 396 deletions(-) diff --git a/.env.example b/.env.example index d0af2b7..9f07709 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,4 @@ -# Copy to .env and edit. Neither password has a default. -POSTGRES_PASSWORD= +# Copy to .env and edit. The admin password has no default. ADMIN_USER=admin ADMIN_PASSWORD= diff --git a/.gitignore b/.gitignore index ff8cb42..f1d483c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /levyraati /levyraati26-go /storage/ -/pgdata/ .env diff --git a/Dockerfile b/Dockerfile index d34ab57..cb35ab2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,9 @@ RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /levyraati . FROM alpine:3.24 # yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current # release), so a rebuild is the update — and this avoids python3 + pip in the image entirely. -RUN apk add --no-cache ffmpeg yt-dlp ca-certificates +# sqlite is the CLI only — the app links its own pure-Go copy. It is here so `.backup` and a shell +# are reachable with docker compose exec, which is the whole of database operations now. +RUN apk add --no-cache ffmpeg yt-dlp ca-certificates sqlite COPY --from=build /levyraati /usr/local/bin/levyraati ENV STORAGE_DIR=/storage EXPOSE 8080 diff --git a/README.md b/README.md index eaa1dee..370a0be 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,12 @@ Members have an address because it is their login and because mail is a planned ## Stack -Go, Postgres, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with -yt-dlp. One binary, one origin — there is no separate frontend to deploy. +Go, SQLite, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with +yt-dlp. One binary, one origin, one container — there is no separate frontend and no database server +to deploy. -Go dependencies: `pgx/v5` and `golang.org/x/crypto`. No Node, no npm, no bundler. +Go dependencies: `modernc.org/sqlite` and `golang.org/x/crypto`. The SQLite driver is pure Go, so the +build stays `CGO_ENABLED=0`. No Node, no npm, no bundler. ## Running it @@ -64,8 +66,7 @@ creates no users: log into the admin panel and mint an invite. | Variable | Default | Notes | |---|---|---| -| `POSTGRES_PASSWORD` | — | **Required by Compose.** Used to build `DATABASE_URL` for the app | -| `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` | +| `DB_PATH` | `$STORAGE_DIR/levyraati.db` | The SQLite file. Created on first start | | `ADMIN_USER` | `admin` | Admin panel username | | `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it | | `ADDR` | `:8080` | Member-facing listener | @@ -77,16 +78,14 @@ creates no users: log into the admin panel and mint an invite. ### Local development ```sh -docker compose up -d postgres -export DATABASE_URL="postgres://levyraati:$POSTGRES_PASSWORD@localhost:5432/levyraati" export ADMIN_PASSWORD=dev SECURE_COOKIES=false go run . ``` -Requires Go 1.24+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. +Requires Go 1.25+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. There is nothing to start first: +the database is a file under `./storage`, created on the first run. -Tests that need a database are skipped unless `TEST_DATABASE_URL` points at a throwaway one — the -migration test drops and recreates the `public` schema, so never point it at anything you care about. +Tests get a fresh database file in a temp directory each, so they need no setup and touch nothing: ```sh go test ./... @@ -136,23 +135,22 @@ JSON to stdout, nothing else. There is no log table and no log viewer in the app ### Backups -Two paths hold everything: +`./storage` holds everything: audio files, avatars, and `levyraati.db`. It is a bind mount, so a copy +of that one directory is the whole backup. `storage/tmp/` is in-flight conversions and is safe to +skip; it's cleared on startup anyway. -- `./pgdata` — the database. Postgres 18 stores it under a version subdirectory (`18/docker`), so - the mount is `/var/lib/postgresql`, not `/var/lib/postgresql/data` -- `./storage` — audio files and avatars - -Both are bind mounts. `storage/tmp/` is in-flight conversions and is safe to skip; it's cleared on -startup anyway. +Copying the file while the app is running is not a backup — WAL means the latest writes live in a +sidecar file. Ask SQLite for a consistent snapshot instead: ```sh -docker compose exec postgres pg_dump -U levyraati levyraati | gzip > backup-$(date +%F).sql.gz +docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'" +gzip -c storage/tmp/backup.db > backup-$(date +%F).db.gz && rm storage/tmp/backup.db ``` ### Database shell ```sh -docker compose exec postgres psql -U levyraati levyraati +docker compose exec app sqlite3 /storage/levyraati.db ``` ## Layout diff --git a/admin.go b/admin.go index 408be4f..3bd8c54 100644 --- a/admin.go +++ b/admin.go @@ -41,12 +41,12 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) { // Unused invites are the ones with a job to do; spent ones are counted, not listed. Truncating // a list silently reads as "that's all of them". - if err := a.pool.QueryRow(r.Context(), - `select count(*)::int from invites where not is_valid`).Scan(&d.SpentCount); err != nil { + if err := a.db.QueryRowContext(r.Context(), + `select count(*) from invites where not is_valid`).Scan(&d.SpentCount); err != nil { adminError(w, "invites", err) return } - rows, err := a.pool.Query(r.Context(), + rows, err := a.db.QueryContext(r.Context(), `select id, code, is_valid, created_at from invites where is_valid order by created_at desc`) if err != nil { adminError(w, "invites", err) @@ -67,7 +67,7 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) { return } - rows, err = a.pool.Query(r.Context(), + rows, err = a.db.QueryContext(r.Context(), `select id, name, email, banned, created_at from users order by created_at`) if err != nil { adminError(w, "users", err) @@ -91,8 +91,8 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) { adminError(w, "songs", err) return } - if err := a.pool.QueryRow(r.Context(), - `select count(*)::int from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil { + if err := a.db.QueryRowContext(r.Context(), + `select count(*) from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil { adminError(w, "reports", err) return } @@ -116,7 +116,7 @@ func (a *app) inviteLink(code string) string { func (a *app) createInvite(w http.ResponseWriter, r *http.Request) { code := inviteCode() - if _, err := a.pool.Exec(r.Context(), `insert into invites (code) values ($1)`, code); err != nil { + if _, err := a.db.ExecContext(r.Context(), `insert into invites (code) values ($1)`, code); err != nil { adminError(w, "invites", err) return } @@ -136,14 +136,14 @@ func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) { return } var banned bool - err = a.pool.QueryRow(r.Context(), + err = a.db.QueryRowContext(r.Context(), `update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned) if err != nil { adminError(w, "users", err) return } if banned { - if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil { + if _, err := a.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, id); err != nil { adminError(w, "users", err) return } @@ -173,12 +173,12 @@ func (a *app) resetPassword(w http.ResponseWriter, r *http.Request) { adminError(w, "auth", err) return } - if _, err := a.pool.Exec(r.Context(), + if _, err := a.db.ExecContext(r.Context(), `update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil { adminError(w, "auth", err) return } - if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil { + if _, err := a.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, id); err != nil { adminError(w, "auth", err) return } diff --git a/auth.go b/auth.go index 01d582c..23b841b 100644 --- a/auth.go +++ b/auth.go @@ -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) } diff --git a/auth_test.go b/auth_test.go index e987f8c..f601fc4 100644 --- a/auth_test.go +++ b/auth_test.go @@ -6,34 +6,26 @@ import ( "net/http" "net/http/httptest" "net/url" - "os" + "path/filepath" "strings" "testing" "time" - - "github.com/jackc/pgx/v5/pgxpool" ) -// Set TEST_DATABASE_URL to a throwaway database — these drop and recreate the public schema. +// A fresh database file per test, thrown away with the temp dir. No server to point at, so these +// run everywhere rather than only where someone remembered to set an env var. func testApp(t *testing.T) *app { t.Helper() - dbURL := os.Getenv("TEST_DATABASE_URL") - if dbURL == "" { - t.Skip("TEST_DATABASE_URL not set") - } ctx := context.Background() - pool, err := pgxpool.New(ctx, dbURL) + db, err := openDB(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } - t.Cleanup(pool.Close) - if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil { + t.Cleanup(func() { db.Close() }) + if err := migrate(ctx, db); err != nil { t.Fatal(err) } - if err := migrate(ctx, pool); err != nil { - t.Fatal(err) - } - return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, pool: pool} + return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, db: db} } func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder { @@ -48,7 +40,7 @@ func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest. func (a *app) inviteValid(t *testing.T, code string) bool { t.Helper() var valid bool - if err := a.pool.QueryRow(context.Background(), + if err := a.db.QueryRowContext(context.Background(), `select is_valid from invites where code = $1`, code).Scan(&valid); err != nil { t.Fatal(err) } @@ -61,10 +53,10 @@ func TestInviteIsSpentOnlyBySuccess(t *testing.T) { ctx := context.Background() mux := a.withMember(a.memberMux()) - if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu1')`); err != nil { + if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu1')`); err != nil { t.Fatal(err) } - if _, err := a.pool.Exec(ctx, + if _, err := a.db.ExecContext(ctx, `insert into users (name, email, password_hash) values ('Esa', 'esa@example.com', 'x')`); err != nil { t.Fatal(err) } @@ -131,7 +123,7 @@ func TestLoginHandlerRefusesAfterTooManyFailures(t *testing.T) { func (a *app) seedMember(t *testing.T, email string) int64 { t.Helper() var id int64 - err := a.pool.QueryRow(context.Background(), + err := a.db.QueryRowContext(context.Background(), `insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`, email).Scan(&id) if err != nil { @@ -161,8 +153,8 @@ func TestSessionIdleTimeout(t *testing.T) { } // Age it past the idle window: the timeout is what expiry means, so this is the whole rule. - if _, err := a.pool.Exec(ctx, - `update sessions set expires_at = now() - interval '1 second' where token = $1`, live); err != nil { + if _, err := a.db.ExecContext(ctx, + `update sessions set expires_at = datetime('now', '-1 second') where token = $1`, live); err != nil { t.Fatal(err) } if m := a.sessionFor(t, live); m != nil { @@ -174,15 +166,15 @@ func TestSessionIdleTimeout(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := a.pool.Exec(ctx, - `update sessions set expires_at = now() + interval '1 hour' where token = $1`, fresh); err != nil { + if _, err := a.db.ExecContext(ctx, + `update sessions set expires_at = datetime('now', '+1 hour') where token = $1`, fresh); err != nil { t.Fatal(err) } if m := a.sessionFor(t, fresh); m == nil { t.Fatal("session inside the window did not resolve") } var expires time.Time - if err := a.pool.QueryRow(ctx, + if err := a.db.QueryRowContext(ctx, `select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil { t.Fatal(err) } @@ -196,7 +188,7 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) { ctx := context.Background() mux := a.withMember(a.memberMux()) - if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu2')`); err != nil { + if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu2')`); err != nil { t.Fatal(err) } w := post(t, mux, "/register", url.Values{ @@ -206,7 +198,7 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) { t.Fatalf("registration: status = %d, want 303", w.Code) } var id int64 - if err := a.pool.QueryRow(ctx, `select id from users where email = 'esa@example.com'`).Scan(&id); err != nil { + if err := a.db.QueryRowContext(ctx, `select id from users where email = 'esa@example.com'`).Scan(&id); err != nil { t.Fatal(err) } @@ -216,7 +208,7 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) { } var sessions int - if err := a.pool.QueryRow(ctx, + if err := a.db.QueryRowContext(ctx, `select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil { t.Fatal(err) } diff --git a/docker-compose.yml b/docker-compose.yml index ce4a21a..1234149 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,10 @@ services: - postgres: - image: postgres:18-alpine - environment: - POSTGRES_USER: levyraati - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} - POSTGRES_DB: levyraati - volumes: - # Postgres 18 keeps its data in /var/lib/postgresql//docker, so the mount is the - # parent directory, not the old /var/lib/postgresql/data. - - ./pgdata:/var/lib/postgresql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U levyraati"] - interval: 5s - timeout: 3s - retries: 10 - restart: unless-stopped - app: build: context: . args: VERSION: ${VERSION:-dev} environment: - DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati ADMIN_USER: ${ADMIN_USER:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env} ADDR: ":8080" @@ -31,12 +13,10 @@ services: ADMIN_ADDR: ":8081" SECURE_COOKIES: ${SECURE_COOKIES:-true} PUBLIC_URL: ${PUBLIC_URL:-} + # The SQLite file sits in here beside the audio, so this one mount is the whole backup. volumes: - ./storage:/storage ports: - "8080:8080" - "8081:8081" - depends_on: - postgres: - condition: service_healthy restart: unless-stopped diff --git a/docs/decisions.md b/docs/decisions.md index 70000b6..caa3eff 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -209,3 +209,30 @@ says so. The coverage assumption that shaped the earlier sketch was wrong and is corrected in `later.md`: LRCLIB has synced lyrics for a good share of Finnish rock, not almost none. +46. **SQLite instead of Postgres — this reverses entries 1 and 3** (2026-08-02). 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 does not change: `pgx` out, `sqlite` in. What it buys: one container, one + bind mount that is the entire backup, no `pgdata`, no healthcheck-gated `depends_on`, no startup + retry loop, and tests that run anywhere instead of skipping without `TEST_DATABASE_URL`. + + The port was smaller than expected, 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` → `timestamp` holding UTC `YYYY-MM-DD HH:MM:SS`.** The declared type is what + makes the driver return `time.Time`; the fixed-width UTC string is what makes `order by + created_at` and `expires_at > datetime('now')` mean what they say. `_time_format=datetime` and + `_timezone=UTC` on the DSN make Go write exactly the shape `datetime('now')` produces, so the + two sources of a timestamp are comparable. + - **`interval` has no equivalent.** `sessions.idle_ttl` is seconds as an integer, and the review + edit window travels as a SQLite date modifier string (`-1800 seconds`). + - **No `stddev_pop`.** The divisive and unified boards use the population formula written out, + guarded with `max(0.0, …)` because floating-point cancellation returns a tiny negative when + every score is identical, and `sqrt` of that is null. + - **`foreign_keys` is off by default in SQLite**, so every `on delete cascade` in the schema is + decoration without the pragma. It is set on the DSN alongside WAL, `busy_timeout` and + `_txlock=immediate`. + + Taken while there was still no data: the tables were recreated rather than converted, same as + entry 45. What would reverse this: enough concurrent writers that one writer is a real limit, or + wanting the database on a different box from the audio files. diff --git a/docs/later.md b/docs/later.md index b40f5ca..10418e0 100644 --- a/docs/later.md +++ b/docs/later.md @@ -84,7 +84,7 @@ The constraint that shapes every idea: **E2B-class multimodal models are speech encoder targets ASR and spoken-audio QA. Music is out of distribution — genre calls are near coin-flips, "describe this track" produces beige copy, and singing over instrumentation is a worst case for ASR. Encoders also work in ~30 s windows, so a four-minute song is a chunk loop, and on CPU -beside Postgres and ffmpeg that is minutes per submission. +beside ffmpeg that is minutes per submission. So the ideas that use the model to be *correct* are the weak ones, and the idea that uses it to be *entertaining* is the strong one: diff --git a/docs/spec.md b/docs/spec.md index 027bbc4..b0baad4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -327,12 +327,13 @@ and the average. Always public, ignores the reveal rule. Minimum **3 reviews** for a song to qualify for any ranking; `min_reviews` is published, not hardcoded in a client. -- Songs: Top 10 all-time, Bottom 10, Most Divisive (highest `stddev_pop`), Most Unified (lowest), +- Songs: Top 10 all-time, Bottom 10, Most Divisive (highest score spread), Most Unified (lowest), Most Reviewed. - Reviewers: Harshest Critic (lowest average given), Most Generous, Most Active, Most Prolific Submitter. -- Postgres does all of it: `avg()`, `count()`, `stddev_pop()`, `HAVING count(*) >= 3`. Order and - limit in SQL, never in Go. +- SQL does all of it: `avg()`, `count()`, `HAVING count(*) >= 3`. Order and limit in SQL, never in + Go. SQLite has no `stddev_pop`, so the divisive/unified boards spell the population formula out — + see `stddevPop` in `stats.go`. - **Every leaderboard needs a deterministic tie-break** — `ORDER BY value DESC, review_count DESC, id ASC`. Ties are common in a ten-person club, and without one the list reshuffles between reloads for no reason. @@ -385,7 +386,7 @@ func requireAdmin(next http.Handler) http.Handler { ``` No bcrypt here: hashing protects *stored* passwords against a database leak, and this one lives in -the env file next to the Postgres password already. The constant-time compare is the part that +the env file already. The constant-time compare is the part that matters. **Fatal at startup if `ADMIN_PASSWORD` is unset** — an admin panel that silently opens is worse than one that will not boot. @@ -519,7 +520,7 @@ the first endpoint is one line over a data function that already exists. - `snake_case` field names, matching the SQL columns. - Timestamps are RFC 3339 UTC strings (`2026-08-01T10:00:00Z`). Never preformatted, never a locale string, never a unix int. -- Ids are JSON numbers (`bigserial`, safely under 2⁵³). +- Ids are JSON numbers (SQLite rowids, safely under 2⁵³). - Nullable fields are present and `null`. **No `omitempty`** — a stable key set is worth more than a few bytes, and "missing" versus "null" is a distinction clients get wrong. - Scores are integers, averages are floats. @@ -642,33 +643,41 @@ reason the count-based lists stay until they are proven useless. ## 9. Data model -Ids are `bigserial`. Session tokens stay random — those are secrets, ids are not, and enumerable ids -are not a threat model for a login-walled app for ten friends. +Ids are `integer primary key autoincrement` — never reused, because `storage/audio/.ogg` is +named after one. Session tokens stay random — those are secrets, ids are not, and enumerable ids are +not a threat model for a login-walled app for ten friends. + +Timestamps are declared `timestamp` and hold 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. Booleans are `integer`, 0 or 1. ```sql -users (id bigserial pk, name, email unique, password_hash, avatar, banned, created_at) -sessions (token pk, user_id fk not null, idle_ttl interval not null, +users (id pk, name, email unique, password_hash, avatar, banned, created_at) +sessions (token pk, user_id fk not null, idle_ttl integer not null, -- seconds expires_at, created_at) -- token: 32 random bytes, hex -songs (id bigserial pk, title, artist, genre, description, audio_file, - duration_seconds int, +songs (id pk, title, artist, genre, description, lyrics, audio_file, + duration_seconds integer, source_url, -- nullable, for YouTube submissions submitted_by fk users, created_at) -submissions (id bigserial pk, user_id fk not null, +submissions (id pk, user_id fk not null, status text not null default 'queued', -- queued|downloading|converting|ready|failed status_msg text, source_url, tmp_path, - title, artist, genre, description, + title, artist, genre, description, lyrics, created_at) -reviews (id bigserial pk, song_id fk on delete cascade, reviewer_id fk users, - score int, text, created_at, updated_at, +reviews (id pk, song_id fk on delete cascade, reviewer_id fk users, + score integer, text, created_at, updated_at, unique (song_id, reviewer_id)) -invites (id bigserial pk, code unique, is_valid bool, created_at) -reports (id bigserial pk, user_id fk not null, body text not null, +invites (id pk, code unique, is_valid integer, created_at) +reports (id pk, user_id fk not null, body text not null, page text, user_agent text, - resolved_at timestamptz, -- null = open + resolved_at timestamp, -- null = open created_at) ``` +`foreign_keys` is off by default in SQLite, so the cascades above only exist because the pragma is +set on every connection — see `openDB` in `main.go`. + No `role` column (§6). No `status` on `songs` (§4). Also: `CHECK (score BETWEEN 1 AND 100)`, `NOT NULL` on everything required, an index on @@ -719,8 +728,8 @@ Everything else is forms and `INSERT`s. No framework, no fixtures beyond a test Each step leaves something runnable. Registration needs an invite and invites come from the admin panel, so the admin surface comes first — before a single member can exist. -1. **Skeleton** — `main.go`, embedded migrations at startup, pgxpool, slog, Docker Compose, the two - listeners. +1. **Skeleton** — `main.go`, embedded migrations at startup, `database/sql`, slog, Docker Compose, + the two listeners. 2. **Admin, invites, auth** — Basic Auth listener, mint an invite, register, log in, sessions, ban. 3. **Submission pipeline, upload path only** — submit, convert, waiting page, publish. No yt-dlp yet, so the hard parts (worker, publish transaction, restart recovery) are proven without a network diff --git a/go.mod b/go.mod index 1b1feb8..8e2f015 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,20 @@ module git.kessinen.com/kessinen/levyraati26-go -go 1.24 - -require github.com/jackc/pgx/v5 v5.7.2 +go 1.25.0 require ( - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/crypto v0.32.0 + modernc.org/sqlite v1.54.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.46.0 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index b7fc8c6..e2391e7 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -9,8 +13,14 @@ github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -20,9 +30,21 @@ golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= diff --git a/lyrics.go b/lyrics.go index d5ad22c..1061c94 100644 --- a/lyrics.go +++ b/lyrics.go @@ -241,14 +241,14 @@ func (a *app) autoFetchLyrics(ctx context.Context, subID int64, title, artist st if lyrics == "" { return } - tag, err := a.pool.Exec(ctx, + res, err := a.db.ExecContext(ctx, `update submissions set lyrics = $2 where id = $1 and lyrics is null`, subID, cleanLyrics(lyrics)) if err != nil { slog.Error("save lyrics", "ctx", "submissions", "error", err, "submission", subID) return } - if tag.RowsAffected() > 0 { + if affected(res) > 0 { slog.Info("lyrics found", "ctx", "submissions", "submission", subID) } } diff --git a/main.go b/main.go index 4b123c5..00b7087 100644 --- a/main.go +++ b/main.go @@ -3,22 +3,22 @@ package main import ( "context" "crypto/subtle" + "database/sql" "fmt" "log/slog" "net/http" "os" "path/filepath" "strings" - "time" - "github.com/jackc/pgx/v5/pgxpool" + _ "modernc.org/sqlite" ) // Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev. var version = "dev" type config struct { - databaseURL string + dbPath string adminUser string adminPass string addr string @@ -32,7 +32,6 @@ type config struct { func loadConfig() config { c := config{ - databaseURL: os.Getenv("DATABASE_URL"), adminUser: env("ADMIN_USER", "admin"), adminPass: os.Getenv("ADMIN_PASSWORD"), addr: env("ADDR", ":8080"), @@ -41,9 +40,8 @@ func loadConfig() config { secureCookies: env("SECURE_COOKIES", "true") != "false", publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"), } - if c.databaseURL == "" { - fatal("DATABASE_URL is not set") - } + // The database lives beside the audio, so one volume is the whole backup. + c.dbPath = env("DB_PATH", filepath.Join(c.storageDir, "levyraati.db")) // An admin panel that silently opens is worse than one that won't boot. if c.adminPass == "" { fatal("ADMIN_PASSWORD is not set") @@ -65,49 +63,67 @@ func fatal(msg string, args ...any) { type app struct { cfg config - pool *pgxpool.Pool + db *sql.DB logins limiter // zero value is ready to use } +// openDB opens the file with the pragmas the schema assumes. foreign_keys is off by default in +// SQLite, so without it every `on delete cascade` is decoration; WAL plus busy_timeout is what lets +// a conversion goroutine write while a request reads; _txlock=immediate takes the write lock at +// BEGIN rather than failing partway through a transaction that started out reading. +// +// _time_format and _timezone make Go write timestamps in exactly the shape datetime('now') +// produces, so the two sources of a timestamp sort and compare against each other. +func openDB(path string) (*sql.DB, error) { + return sql.Open("sqlite", "file:"+path+"?"+strings.Join([]string{ + "_pragma=busy_timeout(5000)", + "_pragma=journal_mode(WAL)", + "_pragma=foreign_keys(1)", + "_pragma=synchronous(NORMAL)", + "_time_format=datetime", + "_timezone=UTC", + "_txlock=immediate", + }, "&")) +} + +// database/sql splits the row count off into a second return value. Every caller here only asks +// whether the statement matched anything, and a driver that could not report a count would already +// have failed at Exec. +func affected(res sql.Result) int64 { + n, _ := res.RowsAffected() + return n +} + func main() { slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) slog.Info("starting", "ctx", "startup", "version", version) cfg := loadConfig() ctx := context.Background() - pool, err := pgxpool.New(ctx, cfg.databaseURL) - if err != nil { - fatal("database connect", "error", err) - } - defer pool.Close() - - // Wait for Postgres rather than crash-looping past a healthcheck that hasn't gone green yet. - for i := 0; ; i++ { - pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second) - err = pool.Ping(pingCtx) - cancel() - if err == nil { - break - } - if i == 10 { - fatal("database unreachable", "error", err) - } - time.Sleep(time.Second) - } - - if err := migrate(ctx, pool); err != nil { - fatal("migrations", "error", err) - } - if err := sweep(ctx, pool); err != nil { - fatal("startup sweep", "error", err) - } + // The storage directories come first: the database file lives in one of them. for _, dir := range []string{"audio", "tmp", "avatars"} { if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil { fatal("storage dir", "error", err, "dir", dir) } } - a := &app{cfg: cfg, pool: pool} + db, err := openDB(cfg.dbPath) + if err != nil { + fatal("database open", "error", err) + } + defer db.Close() + if err := db.PingContext(ctx); err != nil { + fatal("database unreachable", "error", err, "path", cfg.dbPath) + } + + if err := migrate(ctx, db); err != nil { + fatal("migrations", "error", err) + } + if err := sweep(ctx, db); err != nil { + fatal("startup sweep", "error", err) + } + + a := &app{cfg: cfg, db: db} // ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel // or the reverse proxy. A separate binary would need its own deploy and would race the @@ -127,7 +143,7 @@ func (a *app) memberMux() *http.ServeMux { mux.Handle("GET /static/", http.FileServerFS(assetFS)) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { - if err := a.pool.Ping(r.Context()); err != nil { + if err := a.db.PingContext(r.Context()); err != nil { http.Error(w, "db down", http.StatusServiceUnavailable) return } @@ -195,7 +211,7 @@ func (a *app) adminMux() *http.ServeMux { // (close the browser). Add a cookie session if a second admin ever needs one. // // No bcrypt: hashing protects stored passwords against a database leak, and this one lives in the -// env file next to the Postgres password already. The constant-time compare is the part that matters. +// env file already. The constant-time compare is the part that matters. func (a *app) requireAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, p, ok := r.BasicAuth() diff --git a/main_test.go b/main_test.go index c8efbd1..b1afe9e 100644 --- a/main_test.go +++ b/main_test.go @@ -4,10 +4,7 @@ import ( "context" "net/http" "net/http/httptest" - "os" "testing" - - "github.com/jackc/pgx/v5/pgxpool" ) func TestRequireAdmin(t *testing.T) { @@ -40,33 +37,19 @@ func TestRequireAdmin(t *testing.T) { } } -// Set TEST_DATABASE_URL to run this against a throwaway database. func TestMigrateIsIdempotent(t *testing.T) { - url := os.Getenv("TEST_DATABASE_URL") - if url == "" { - t.Skip("TEST_DATABASE_URL not set") - } ctx := context.Background() - pool, err := pgxpool.New(ctx, url) - if err != nil { - t.Fatal(err) - } - defer pool.Close() + a := testApp(t) // already migrated once - if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil { - t.Fatal(err) + if err := migrate(ctx, a.db); err != nil { + t.Fatalf("second migrate: %v", err) } - for i := range 2 { - if err := migrate(ctx, pool); err != nil { - t.Fatalf("migrate run %d: %v", i+1, err) - } - } - if err := sweep(ctx, pool); err != nil { + if err := sweep(ctx, a.db); err != nil { t.Fatalf("sweep: %v", err) } var n int - if err := pool.QueryRow(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil { + if err := a.db.QueryRowContext(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil { t.Fatal(err) } if n != 1 { diff --git a/migrate.go b/migrate.go index 62f108b..9839d93 100644 --- a/migrate.go +++ b/migrate.go @@ -2,12 +2,11 @@ package main import ( "context" + "database/sql" "embed" "fmt" "log/slog" "sort" - - "github.com/jackc/pgx/v5/pgxpool" ) //go:embed migrations/*.sql @@ -15,17 +14,17 @@ var migrationFS embed.FS // migrate applies every migrations/*.sql not yet recorded, in filename order, each in its own // transaction. Applied names are the record — a file that changes after it ran is not re-applied. -func migrate(ctx context.Context, pool *pgxpool.Pool) error { - _, err := pool.Exec(ctx, `create table if not exists schema_migrations ( +func migrate(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, `create table if not exists schema_migrations ( name text primary key, - applied_at timestamptz not null default now() + applied_at timestamp not null default (datetime('now')) )`) if err != nil { return fmt.Errorf("create schema_migrations: %w", err) } applied := map[string]bool{} - rows, err := pool.Query(ctx, `select name from schema_migrations`) + rows, err := db.QueryContext(ctx, `select name from schema_migrations`) if err != nil { return fmt.Errorf("read schema_migrations: %w", err) } @@ -60,19 +59,19 @@ func migrate(ctx context.Context, pool *pgxpool.Pool) error { if err != nil { return err } - tx, err := pool.Begin(ctx) + tx, err := db.BeginTx(ctx, nil) if err != nil { return err } - if _, err := tx.Exec(ctx, string(sql)); err != nil { - tx.Rollback(ctx) + if _, err := tx.ExecContext(ctx, string(sql)); err != nil { + tx.Rollback() return fmt.Errorf("migration %s: %w", name, err) } - if _, err := tx.Exec(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil { - tx.Rollback(ctx) + if _, err := tx.ExecContext(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil { + tx.Rollback() return err } - if err := tx.Commit(ctx); err != nil { + if err := tx.Commit(); err != nil { return fmt.Errorf("migration %s: %w", name, err) } slog.Info("migration applied", "ctx", "startup", "name", name) @@ -82,29 +81,31 @@ func migrate(ctx context.Context, pool *pgxpool.Pool) error { // sweep runs the startup cleanup from docs/spec.md §4.6. An in-process conversion goroutine dies // with the process, so without this those rows say "converting" forever. -func sweep(ctx context.Context, pool *pgxpool.Pool) error { - tag, err := pool.Exec(ctx, `update submissions +func sweep(ctx context.Context, db *sql.DB) error { + res, err := db.ExecContext(ctx, `update submissions set status = 'failed', status_msg = 'interrupted by restart' where status in ('queued', 'downloading', 'converting')`) if err != nil { return err } - if n := tag.RowsAffected(); n > 0 { + if n := affected(res); n > 0 { slog.Warn("submissions interrupted by restart", "ctx", "startup", "count", n) } // ponytail: temp files of swept submissions are unlinked with the row in step 3, once the // pipeline exists and there is something to unlink. - if _, err := pool.Exec(ctx, - `delete from submissions where created_at < now() - interval '7 days'`); err != nil { + if _, err := db.ExecContext(ctx, + `delete from submissions where created_at < datetime('now', '-7 days')`); err != nil { return err } - if _, err := pool.Exec(ctx, `delete from sessions where expires_at < now()`); err != nil { + if _, err := db.ExecContext(ctx, + `delete from sessions where expires_at < datetime('now')`); err != nil { return err } var failed int - err = pool.QueryRow(ctx, `select count(*) from submissions where status = 'failed'`).Scan(&failed) + err = db.QueryRowContext(ctx, + `select count(*) from submissions where status = 'failed'`).Scan(&failed) if err != nil { return err } diff --git a/migrations/001_init.sql b/migrations/001_init.sql index 68cf52a..6b05f0e 100644 --- a/migrations/001_init.sql +++ b/migrations/001_init.sql @@ -1,52 +1,56 @@ +-- Timestamps are declared `timestamp` and hold UTC 'YYYY-MM-DD HH:MM:SS': the declared type is what +-- makes the driver hand them back as time.Time, and a fixed-width UTC string is what makes +-- `order by created_at` and `expires_at > datetime('now')` mean what they say. + create table users ( - id bigserial primary key, - name text not null, - email text not null unique, - password_hash text not null, + id integer primary key autoincrement, + name text not null, + email text not null unique, + password_hash text not null, avatar text, - banned boolean not null default false, - created_at timestamptz not null default now() + banned integer not null default 0, + created_at timestamp not null default (datetime('now')) ); create table sessions ( - token text primary key, - user_id bigint not null references users (id) on delete cascade, - idle_ttl interval not null, - expires_at timestamptz not null, - created_at timestamptz not null default now() + token text primary key, + user_id integer not null references users (id) on delete cascade, + idle_ttl integer not null, -- seconds; SQLite has no interval type + expires_at timestamp not null, + created_at timestamp not null default (datetime('now')) ); -create index on sessions (user_id); +create index sessions_user on sessions (user_id); create table invites ( - id bigserial primary key, - code text not null unique, - is_valid boolean not null default true, - created_at timestamptz not null default now() + id integer primary key autoincrement, + code text not null unique, + is_valid integer not null default 1, + created_at timestamp not null default (datetime('now')) ); create table songs ( - id bigserial primary key, - title text not null, - artist text not null, - genre text not null, + id integer primary key autoincrement, + title text not null, + artist text not null, + genre text not null, description text, -- LRC or plain text, told apart by whether the first line starts with '['. Not covered by the -- lock: nobody reviewed the lyrics. lyrics text, - audio_file text not null, - duration_seconds integer not null, + audio_file text not null, + duration_seconds integer not null, source_url text, - submitted_by bigint not null references users (id), - created_at timestamptz not null default now() + submitted_by integer not null references users (id), + created_at timestamp not null default (datetime('now')) ); -create index on songs (created_at desc); +create index songs_created_at on songs (created_at desc); create table submissions ( - id bigserial primary key, - user_id bigint not null references users (id) on delete cascade, - status text not null default 'queued', + id integer primary key autoincrement, + user_id integer not null references users (id) on delete cascade, + status text not null default 'queued', status_msg text, source_url text, tmp_path text, @@ -55,37 +59,37 @@ create table submissions ( genre text, description text, lyrics text, - created_at timestamptz not null default now(), + created_at timestamp not null default (datetime('now')), constraint submissions_status check ( status in ('queued', 'downloading', 'converting', 'ready', 'failed') ) ); -- The submission quota (5 per rolling 24h, failures excluded) reads this. -create index on submissions (user_id, created_at desc); +create index submissions_user_created on submissions (user_id, created_at desc); create table reviews ( - id bigserial primary key, - song_id bigint not null references songs (id) on delete cascade, - reviewer_id bigint not null references users (id), - score integer not null check (score between 1 and 100), - text text not null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), + id integer primary key autoincrement, + song_id integer not null references songs (id) on delete cascade, + reviewer_id integer not null references users (id), + score integer not null check (score between 1 and 100), + text text not null, + created_at timestamp not null default (datetime('now')), + updated_at timestamp not null default (datetime('now')), unique (song_id, reviewer_id) ); -create index on reviews (song_id); +create index reviews_song on reviews (song_id); -- The queue asks "songs this member has not reviewed" — that lookup is by reviewer. -create index on reviews (reviewer_id, song_id); +create index reviews_reviewer_song on reviews (reviewer_id, song_id); create table reports ( - id bigserial primary key, - user_id bigint not null references users (id) on delete cascade, - body text not null, + id integer primary key autoincrement, + user_id integer not null references users (id) on delete cascade, + body text not null, page text, user_agent text, - resolved_at timestamptz, - created_at timestamptz not null default now() + resolved_at timestamp, + created_at timestamp not null default (datetime('now')) ); diff --git a/profile.go b/profile.go index 5cbf0ee..234800c 100644 --- a/profile.go +++ b/profile.go @@ -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 } diff --git a/render.go b/render.go index 84c6f43..012535d 100644 --- a/render.go +++ b/render.go @@ -78,8 +78,8 @@ func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name st p.Version = version if p.Member != nil { // The queue is a worklist, so its size belongs in the nav. - a.pool.QueryRow(r.Context(), ` - select count(*)::int from songs s + a.db.QueryRowContext(r.Context(), ` + select count(*) from songs s where s.submitted_by <> $1 and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)`, diff --git a/reports.go b/reports.go index 416434d..d7454c8 100644 --- a/reports.go +++ b/reports.go @@ -47,7 +47,7 @@ func (a *app) reportPage(w http.ResponseWriter, r *http.Request) { // Seeing your own past reports is what stops the same bug arriving four times. func (a *app) myReports(ctx context.Context, userID int64) ([]*report, error) { - rows, err := a.pool.Query(ctx, ` + rows, err := a.db.QueryContext(ctx, ` select id, body, coalesce(page, ''), resolved_at, created_at from reports where user_id = $1 order by created_at desc`, userID) if err != nil { @@ -79,7 +79,7 @@ func (a *app) createReport(w http.ResponseWriter, r *http.Request) { } // "Only on my phone" is the most common bug report and this answers it without asking. - _, err := a.pool.Exec(r.Context(), ` + _, err := a.db.ExecContext(r.Context(), ` insert into reports (user_id, body, page, user_agent) values ($1, $2, nullif($3, ''), $4)`, me.ID, body, from, clean(r.Header.Get("User-Agent"), 300)) if err != nil { @@ -95,7 +95,7 @@ func (a *app) createReport(w http.ResponseWriter, r *http.Request) { // --- admin --- func (a *app) adminReports(w http.ResponseWriter, r *http.Request) { - rows, err := a.pool.Query(r.Context(), ` + rows, err := a.db.QueryContext(r.Context(), ` select rep.id, rep.body, coalesce(rep.page, ''), coalesce(rep.user_agent, ''), u.name, rep.resolved_at, rep.created_at from reports rep join users u on u.id = rep.user_id @@ -130,8 +130,9 @@ func (a *app) resolveReport(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - if _, err := a.pool.Exec(r.Context(), - `update reports set resolved_at = case when resolved_at is null then now() end where id = $1`, + if _, err := a.db.ExecContext(r.Context(), + `update reports set resolved_at = case when resolved_at is null then datetime('now') end + where id = $1`, id); err != nil { adminError(w, "reports", err) return @@ -147,12 +148,12 @@ func (a *app) adminDeleteSong(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - tag, err := a.pool.Exec(r.Context(), `delete from songs where id = $1`, id) + res, err := a.db.ExecContext(r.Context(), `delete from songs where id = $1`, id) if err != nil { adminError(w, "songs", err) return } - if tag.RowsAffected() > 0 { + if affected(res) > 0 { removeFile(a.audioPath(id)) slog.Info("song deleted by admin", "ctx", "songs", "song", id) a.flash(w, "Kappale poistettu.") @@ -170,9 +171,9 @@ type adminSong struct { } func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) { - rows, err := a.pool.Query(ctx, ` + rows, err := a.db.QueryContext(ctx, ` select s.id, s.title, s.artist, u.name, - (select count(*) from reviews r where r.song_id = s.id)::int, s.created_at + (select count(*) from reviews r where r.song_id = s.id), s.created_at from songs s join users u on u.id = s.submitted_by order by s.created_at desc`) if err != nil { diff --git a/reviews.go b/reviews.go index 1bd1c36..accca6d 100644 --- a/reviews.go +++ b/reviews.go @@ -2,14 +2,13 @@ package main import ( "context" + "database/sql" "errors" "fmt" "log/slog" "net/http" "strconv" "time" - - "github.com/jackc/pgx/v5" ) const ( @@ -17,6 +16,10 @@ const ( maxReview = 5000 ) +// The same window as a SQLite date modifier, for the two statements that enforce it. SQLite has no +// interval type to bind, so the unit travels in the string. +var editWindowAgo = fmt.Sprintf("-%d seconds", int(editWindow.Seconds())) + type review struct { ID int64 SongID int64 @@ -40,7 +43,7 @@ func (r *review) Initials() string { } func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review, error) { - rows, err := a.pool.Query(ctx, ` + rows, err := a.db.QueryContext(ctx, ` select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at, r.reviewer_id = $2 from reviews r join users u on u.id = r.reviewer_id @@ -64,13 +67,13 @@ func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review func (a *app) viewerReview(ctx context.Context, songID, viewerID int64) (*review, error) { var v review - err := a.pool.QueryRow(ctx, ` + err := a.db.QueryRowContext(ctx, ` select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at, true from reviews r join users u on u.id = r.reviewer_id where r.song_id = $1 and r.reviewer_id = $2`, songID, viewerID). Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text, &v.CreatedAt, &v.UpdatedAt, &v.Own) - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(err, sql.ErrNoRows) { return nil, nil } return &v, err @@ -105,8 +108,8 @@ func (a *app) createReview(w http.ResponseWriter, r *http.Request) { // You cannot review your own song, and the unique constraint is what stops a second review — // no read-then-write race to lose. var submitter int64 - err = a.pool.QueryRow(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter) - if errors.Is(err, pgx.ErrNoRows) { + err = a.db.QueryRowContext(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter) + if errors.Is(err, sql.ErrNoRows) { http.NotFound(w, r) return } else if err != nil { @@ -119,7 +122,7 @@ func (a *app) createReview(w http.ResponseWriter, r *http.Request) { return } - _, err = a.pool.Exec(r.Context(), + _, err = a.db.ExecContext(r.Context(), `insert into reviews (song_id, reviewer_id, score, text) values ($1, $2, $3, $4)`, songID, me.ID, score, text) if isUnique(err) { @@ -153,12 +156,12 @@ func (a *app) editReview(w http.ResponseWriter, r *http.Request) { } var songID int64 - err = a.pool.QueryRow(r.Context(), ` - update reviews set score = $3, text = $4, updated_at = now() - where id = $1 and reviewer_id = $2 and updated_at > now() - $5::interval + err = a.db.QueryRowContext(r.Context(), ` + update reviews set score = $3, text = $4, updated_at = datetime('now') + where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $5) returning song_id`, - id, memberFrom(r.Context()).ID, score, text, editWindow.String()).Scan(&songID) - if errors.Is(err, pgx.ErrNoRows) { + id, memberFrom(r.Context()).ID, score, text, editWindowAgo).Scan(&songID) + if errors.Is(err, sql.ErrNoRows) { a.flash(w, "Muokkausaika on umpeutunut.") http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther) return @@ -180,12 +183,12 @@ func (a *app) deleteReview(w http.ResponseWriter, r *http.Request) { return } var songID int64 - err = a.pool.QueryRow(r.Context(), ` + err = a.db.QueryRowContext(r.Context(), ` delete from reviews - where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval + where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3) returning song_id`, - id, memberFrom(r.Context()).ID, editWindow.String()).Scan(&songID) - if errors.Is(err, pgx.ErrNoRows) { + id, memberFrom(r.Context()).ID, editWindowAgo).Scan(&songID) + if errors.Is(err, sql.ErrNoRows) { a.flash(w, "Muokkausaika on umpeutunut.") http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther) return diff --git a/songs.go b/songs.go index c7bd8eb..f0d5016 100644 --- a/songs.go +++ b/songs.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "errors" "fmt" "log/slog" @@ -9,8 +10,6 @@ import ( "os" "strconv" "time" - - "github.com/jackc/pgx/v5" ) const pageSize = 20 @@ -52,12 +51,12 @@ const songColumns = ` (select count(*) from reviews r where r.song_id = s.id), case when s.submitted_by = $1 or exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1) - then (select avg(r.score)::float from reviews r where r.song_id = s.id) + then (select avg(r.score) from reviews r where r.song_id = s.id) end, s.submitted_by = $1, exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)` -func scanSongs(rows pgx.Rows) ([]*songSummary, error) { +func scanSongs(rows *sql.Rows) ([]*songSummary, error) { defer rows.Close() var out []*songSummary for rows.Next() { @@ -74,7 +73,7 @@ func scanSongs(rows pgx.Rows) ([]*songSummary, error) { // The queue is a worklist: songs you can still review, oldest first, and never your own — you can // never act on those, so they would sit at the front forever. func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, error) { - 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 <> $1 and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1) @@ -93,7 +92,7 @@ func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, err // Everything, newest first. This is where a song lives once it has left the queue. func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, error) { - 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 ($2 = 0 or s.id < $2) order by s.created_at desc, s.id desc @@ -166,7 +165,7 @@ func (s *songDetail) LyricLines() []lyricLine { return parseLRC(s.Lyrics) } func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) { var d songDetail - err := a.pool.QueryRow(ctx, `select`+songColumns+`, + err := a.db.QueryRowContext(ctx, `select`+songColumns+`, coalesce(s.description, ''), coalesce(s.lyrics, ''), s.source_url from songs s join users u on u.id = s.submitted_by where s.id = $2`, viewerID, songID). @@ -207,13 +206,13 @@ func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, er // draining the queue never means navigating back to it. func (a *app) nextInQueue(ctx context.Context, viewerID, exceptID int64) (int64, error) { var id int64 - err := a.pool.QueryRow(ctx, ` + err := a.db.QueryRowContext(ctx, ` select s.id from songs s where s.submitted_by <> $1 and s.id <> $2 and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1) order by s.created_at, s.id limit 1`, viewerID, exceptID).Scan(&id) - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(err, sql.ErrNoRows) { return 0, nil } return id, err @@ -226,7 +225,7 @@ func (a *app) songPage(w http.ResponseWriter, r *http.Request) { return } d, err := a.song(r.Context(), memberFrom(r.Context()).ID, id) - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(err, sql.ErrNoRows) { http.NotFound(w, r) return } else if err != nil { @@ -246,7 +245,7 @@ func (a *app) editLyrics(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - tag, err := a.pool.Exec(r.Context(), + res, err := a.db.ExecContext(r.Context(), `update songs set lyrics = nullif($3, '') where id = $1 and submitted_by = $2`, id, memberFrom(r.Context()).ID, cleanLyrics(r.FormValue("lyrics"))) if err != nil { @@ -254,7 +253,7 @@ func (a *app) editLyrics(w http.ResponseWriter, r *http.Request) { http.Error(w, "virhe", http.StatusInternalServerError) return } - if tag.RowsAffected() == 0 { + if affected(res) == 0 { http.NotFound(w, r) return } @@ -284,7 +283,7 @@ func (a *app) editSong(w http.ResponseWriter, r *http.Request) { return } - tag, err := a.pool.Exec(r.Context(), ` + res, err := a.db.ExecContext(r.Context(), ` update songs set title = $3, artist = $4, genre = $5, description = nullif($6, '') where id = $1 and submitted_by = $2 and not exists (select 1 from reviews r where r.song_id = songs.id)`, @@ -295,7 +294,7 @@ func (a *app) editSong(w http.ResponseWriter, r *http.Request) { http.Error(w, "virhe", http.StatusInternalServerError) return } - if tag.RowsAffected() == 0 { + if affected(res) == 0 { a.flash(w, "Kappaletta ei voi enää muokata — sitä on jo arvosteltu.") } else { a.flash(w, "Tiedot tallennettu.") @@ -310,7 +309,7 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - tag, err := a.pool.Exec(r.Context(), ` + res, err := a.db.ExecContext(r.Context(), ` delete from songs where id = $1 and submitted_by = $2 and not exists (select 1 from reviews r where r.song_id = songs.id)`, id, memberFrom(r.Context()).ID) @@ -319,7 +318,7 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) { http.Error(w, "virhe", http.StatusInternalServerError) return } - if tag.RowsAffected() == 0 { + if affected(res) == 0 { a.flash(w, "Kappaletta ei voi enää poistaa — sitä on jo arvosteltu.") http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther) return @@ -348,8 +347,8 @@ func (a *app) audio(w http.ResponseWriter, r *http.Request) { return } var name string - err = a.pool.QueryRow(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name) - if errors.Is(err, pgx.ErrNoRows) { + err = a.db.QueryRowContext(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name) + if errors.Is(err, sql.ErrNoRows) { http.NotFound(w, r) return } else if err != nil { diff --git a/songs_test.go b/songs_test.go index ce074b1..2f00e4e 100644 --- a/songs_test.go +++ b/songs_test.go @@ -8,7 +8,7 @@ import ( func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 { t.Helper() var id int64 - err := a.pool.QueryRow(context.Background(), ` + err := a.db.QueryRowContext(context.Background(), ` insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by) values ($1, 'Testiartisti', 'Metal', 'x.ogg', 120, $2) returning id`, title, submitter).Scan(&id) @@ -21,7 +21,7 @@ func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 { func (a *app) seedReview(t *testing.T, songID, reviewerID int64, score int) int64 { t.Helper() var id int64 - err := a.pool.QueryRow(context.Background(), ` + err := a.db.QueryRowContext(context.Background(), ` insert into reviews (song_id, reviewer_id, score, text) values ($1, $2, $3, 'sanat') returning id`, songID, reviewerID, score).Scan(&id) if err != nil { @@ -133,8 +133,8 @@ func TestQueueContents(t *testing.T) { // Oldest first: a second unreviewed song comes after the first. older := a.seedSong(t, bertta, "Vanhempi") - if _, err := a.pool.Exec(ctx, - `update songs set created_at = now() - interval '2 days' where id = $1`, older); err != nil { + if _, err := a.db.ExecContext(ctx, + `update songs set created_at = datetime('now', '-2 days') where id = $1`, older); err != nil { t.Fatal(err) } list, err = a.queue(ctx, aino, 0) @@ -165,7 +165,7 @@ func TestSongUnlocksWhenTheLastReviewGoes(t *testing.T) { t.Fatal("a reviewed song is still editable") } - if _, err := a.pool.Exec(ctx, `delete from reviews where id = $1`, reviewID); err != nil { + if _, err := a.db.ExecContext(ctx, `delete from reviews where id = $1`, reviewID); err != nil { t.Fatal(err) } d, _ = a.song(ctx, aino, songID) @@ -192,8 +192,8 @@ func TestEditWindow(t *testing.T) { } // Just inside the window. - if _, err := a.pool.Exec(ctx, - `update reviews set updated_at = now() - interval '29 minutes' where id = $1`, + if _, err := a.db.ExecContext(ctx, + `update reviews set updated_at = datetime('now', '-29 minutes') where id = $1`, reviewID); err != nil { t.Fatal(err) } @@ -203,8 +203,8 @@ func TestEditWindow(t *testing.T) { } // Past it. - if _, err := a.pool.Exec(ctx, - `update reviews set updated_at = now() - interval '31 minutes' where id = $1`, + if _, err := a.db.ExecContext(ctx, + `update reviews set updated_at = datetime('now', '-31 minutes') where id = $1`, reviewID); err != nil { t.Fatal(err) } @@ -215,17 +215,17 @@ func TestEditWindow(t *testing.T) { // The database is the authority, not the Go clock: the update and the delete both refuse. var n int64 - err = a.pool.QueryRow(ctx, ` - update reviews set score = 1, updated_at = now() - where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval - returning id`, reviewID, bertta, editWindow.String()).Scan(&n) + err = a.db.QueryRowContext(ctx, ` + update reviews set score = 1, updated_at = datetime('now') + where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3) + returning id`, reviewID, bertta, editWindowAgo).Scan(&n) if err == nil { t.Fatal("an expired review was edited") } - err = a.pool.QueryRow(ctx, ` + err = a.db.QueryRowContext(ctx, ` delete from reviews - where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval - returning id`, reviewID, bertta, editWindow.String()).Scan(&n) + where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3) + returning id`, reviewID, bertta, editWindowAgo).Scan(&n) if err == nil { t.Fatal("an expired review was deleted") } diff --git a/static/style.css b/static/style.css index 108e4fc..b3a9b03 100644 --- a/static/style.css +++ b/static/style.css @@ -743,6 +743,10 @@ td.break { word-break: break-all; font-size: 0.8rem; } code { background: var(--surface-raised); padding: 0.1rem var(--space-1); border-radius: var(--radius); font-size: 0.85rem; } +/* The link is long and the button must stay reachable next to it on a narrow admin window. */ +.invitecell { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; } +.invitecell code { word-break: break-all; } + /* --- toasts --- */ .toasts { position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 1000; diff --git a/stats.go b/stats.go index 0700010..ad4a7c2 100644 --- a/stats.go +++ b/stats.go @@ -55,13 +55,18 @@ type stats struct { MostProlific []userStat } +// SQLite has no stddev aggregate. This is the population formula written out; max() absorbs the +// tiny negative that floating-point cancellation produces when every score is identical, which +// would otherwise make sqrt() return null and fail the scan. +const stddevPop = `sqrt(max(0.0, avg(r.score * r.score) - avg(r.score) * avg(r.score)))` + // Every leaderboard is ordered and limited in SQL, and every one carries a deterministic tie-break: -// ties are common in a ten-person club, and without one Postgres may return a different ten each -// time, so the page visibly reshuffles between reloads for no reason. +// ties are common in a ten-person club, and without one the database may return a different ten +// each time, so the page visibly reshuffles between reloads for no reason. func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) { - rows, err := a.pool.Query(ctx, ` - select s.id, s.title, s.artist, `+valueExpr+`::float as value, count(r.id)::int as reviews, - min(r.score)::int, max(r.score)::int + rows, err := a.db.QueryContext(ctx, ` + select s.id, s.title, s.artist, cast(`+valueExpr+` as real) as value, + count(r.id) as reviews, min(r.score), max(r.score) from songs s join reviews r on r.song_id = s.id group by s.id having count(r.id) >= $1 @@ -86,8 +91,8 @@ func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) // Reviewer boards need a minimum too, or one enthusiastic 100 makes someone the most generous // member in the club forever. func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction string) ([]userStat, error) { - rows, err := a.pool.Query(ctx, ` - select u.id, u.name, u.avatar, `+valueExpr+`::float as value, count(r.id)::int as n + rows, err := a.db.QueryContext(ctx, ` + select u.id, u.name, u.avatar, cast(`+valueExpr+` as real) as value, count(r.id) as n from users u join reviews r on r.reviewer_id = u.id group by u.id having count(r.id) >= $1 @@ -109,8 +114,8 @@ func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction stri } func (a *app) mostProlific(ctx context.Context) ([]userStat, error) { - rows, err := a.pool.Query(ctx, ` - select u.id, u.name, u.avatar, count(s.id)::float, count(s.id)::int + rows, err := a.db.QueryContext(ctx, ` + select u.id, u.name, u.avatar, cast(count(s.id) as real), count(s.id) from users u join songs s on s.submitted_by = u.id group by u.id order by count(s.id) desc, u.id asc @@ -140,11 +145,8 @@ func (a *app) statsPage(w http.ResponseWriter, r *http.Request) { for _, load := range []func() error{ func() (err error) { s.TopSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "desc"); return }, func() (err error) { s.BottomSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "asc"); return }, - func() (err error) { - s.MostDivisive, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "desc") - return - }, - func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc"); return }, + func() (err error) { s.MostDivisive, err = a.songLeaderboard(ctx, stddevPop, "desc"); return }, + func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, stddevPop, "asc"); return }, func() (err error) { s.MostReviewed, err = a.songLeaderboard(ctx, "count(r.id)", "desc"); return }, func() (err error) { s.Harshest, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "asc"); return }, func() (err error) { s.MostGenerous, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "desc"); return }, diff --git a/stats_test.go b/stats_test.go index d945c3f..b703fc8 100644 --- a/stats_test.go +++ b/stats_test.go @@ -51,7 +51,7 @@ func TestLeaderboardThresholdAndOrder(t *testing.T) { } // Identical scores everywhere means stddev 0, so unified beats divisive on the same data. - unified, err := a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc") + unified, err := a.songLeaderboard(ctx, stddevPop, "asc") if err != nil { t.Fatal(err) } diff --git a/submit.go b/submit.go index 9d60605..081317f 100644 --- a/submit.go +++ b/submit.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "errors" "fmt" "io" @@ -12,8 +13,6 @@ import ( "strconv" "strings" "time" - - "github.com/jackc/pgx/v5" ) const ( @@ -147,12 +146,12 @@ func (a *app) submitError(w http.ResponseWriter, r *http.Request, status int, ms // from `submissions` is why this also looks at `songs`. func (a *app) overQuota(ctx context.Context, userID int64) (bool, error) { var n int - err := a.pool.QueryRow(ctx, ` + err := a.db.QueryRowContext(ctx, ` select (select count(*) from submissions where user_id = $1 and status <> 'failed' - and created_at > now() - interval '24 hours') + and created_at > datetime('now', '-24 hours')) + (select count(*) from songs - where submitted_by = $1 and created_at > now() - interval '24 hours')`, + where submitted_by = $1 and created_at > datetime('now', '-24 hours'))`, userID).Scan(&n) return n >= maxPerDay, err } @@ -196,7 +195,7 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) { defer file.Close() var subID int64 - err = a.pool.QueryRow(r.Context(), + err = a.db.QueryRowContext(r.Context(), `insert into submissions (user_id, status) values ($1, 'queued') returning id`, m.ID).Scan(&subID) if err != nil { @@ -236,7 +235,7 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) { return } - if _, err := a.pool.Exec(r.Context(), + if _, err := a.db.ExecContext(r.Context(), `update submissions set tmp_path = $2, title = nullif($3, ''), artist = nullif($4, '') where id = $1`, subID, src, meta.Title, meta.Artist); err != nil { slog.Error("save metadata", "ctx", "submissions", "error", err, "submission", subID) @@ -268,7 +267,7 @@ func (a *app) submitURL(w http.ResponseWriter, r *http.Request, userID int64, ra } var subID int64 - err = a.pool.QueryRow(r.Context(), ` + err = a.db.QueryRowContext(r.Context(), ` insert into submissions (user_id, status, source_url, title, artist) values ($1, 'queued', $2, nullif($3, ''), nullif($4, '')) returning id`, userID, link, meta.Title, meta.Artist).Scan(&subID) @@ -294,7 +293,7 @@ func (a *app) retry(w http.ResponseWriter, r *http.Request) { http.Error(w, "ei uudelleenyritettävissä", http.StatusConflict) return } - if _, err := a.pool.Exec(r.Context(), + if _, err := a.db.ExecContext(r.Context(), `update submissions set status = 'queued', status_msg = null where id = $1`, s.ID); err != nil { slog.Error("retry", "ctx", "submissions", "error", err, "submission", s.ID) http.Error(w, "virhe", http.StatusInternalServerError) @@ -309,7 +308,7 @@ func (a *app) discardSubmission(ctx context.Context, subID int64, path string) { if path != "" { os.Remove(path) } - if _, err := a.pool.Exec(ctx, `delete from submissions where id = $1`, subID); err != nil { + if _, err := a.db.ExecContext(ctx, `delete from submissions where id = $1`, subID); err != nil { slog.Error("discard submission", "ctx", "submissions", "error", err, "submission", subID) } } @@ -348,7 +347,7 @@ func (a *app) process(subID int64, sourceURL, src string) { a.setStatus(ctx, subID, "failed", "lataus ei tuottanut tiedostoa") return } - if _, err := a.pool.Exec(ctx, + if _, err := a.db.ExecContext(ctx, `update submissions set tmp_path = $2 where id = $1`, subID, src); err != nil { slog.Error("save tmp path", "ctx", "submissions", "error", err, "submission", subID) } @@ -370,7 +369,7 @@ func (a *app) process(subID int64, sourceURL, src string) { // The original is discarded as soon as the Opus exists. os.Remove(src) - if _, err := a.pool.Exec(ctx, + if _, err := a.db.ExecContext(ctx, `update submissions set status = 'ready', status_msg = null, tmp_path = $2 where id = $1`, subID, out); err != nil { slog.Error("mark ready", "ctx", "submissions", "error", err, "submission", subID) @@ -382,7 +381,7 @@ func (a *app) process(subID int64, sourceURL, src string) { // so it covers well-tagged music; the Hae sanoitukset button on the waiting page is what // covers everything else, once the submitter has fixed the title and artist. var title, artist string - if err := a.pool.QueryRow(ctx, + if err := a.db.QueryRowContext(ctx, `select coalesce(title, ''), coalesce(artist, '') from submissions where id = $1`, subID).Scan(&title, &artist); err != nil { return @@ -395,7 +394,7 @@ func (a *app) process(subID int64, sourceURL, src string) { } func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) { - if _, err := a.pool.Exec(ctx, + if _, err := a.db.ExecContext(ctx, `update submissions set status = $2, status_msg = nullif($3, '') where id = $1`, subID, status, msg); err != nil { slog.Error("set status", "ctx", "submissions", "error", err, "submission", subID) @@ -412,14 +411,14 @@ func (a *app) loadSubmission(w http.ResponseWriter, r *http.Request) *submission return nil } var s submission - err = a.pool.QueryRow(r.Context(), ` + err = a.db.QueryRowContext(r.Context(), ` select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''), coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''), coalesce(description, ''), coalesce(lyrics, ''), created_at from submissions where id = $1`, id). Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath, &s.Title, &s.Artist, &s.Genre, &s.Description, &s.Lyrics, &s.CreatedAt) - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(err, sql.ErrNoRows) { http.NotFound(w, r) return nil } else if err != nil { @@ -473,7 +472,7 @@ func (a *app) saveMetadata(ctx context.Context, subID int64, r *http.Request) er // worker had just fetched. has := func(field string) bool { _, ok := r.Form[field]; return ok } - _, err := a.pool.Exec(ctx, ` + _, err := a.db.ExecContext(ctx, ` update submissions set title = case when $2 then nullif($3, '') else title end, artist = case when $4 then nullif($5, '') else artist end, @@ -557,16 +556,16 @@ func (a *app) publish(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 publish", "ctx", "submissions", "error", err) http.Error(w, "virhe", http.StatusInternalServerError) return } - defer tx.Rollback(r.Context()) + defer tx.Rollback() var songID int64 - err = tx.QueryRow(r.Context(), ` + err = tx.QueryRowContext(r.Context(), ` insert into songs (title, artist, genre, description, lyrics, audio_file, duration_seconds, source_url, submitted_by) values ($1, $2, $3, $4, $5, '', $6, $7, $8) returning id`, @@ -587,7 +586,7 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) { http.Error(w, "virhe", http.StatusInternalServerError) return } - if _, err := tx.Exec(r.Context(), + if _, err := tx.ExecContext(r.Context(), `update songs set audio_file = $2 where id = $1`, songID, filepath.Base(dst)); err != nil { os.Rename(dst, src) @@ -595,13 +594,13 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) { http.Error(w, "virhe", http.StatusInternalServerError) return } - if _, err := tx.Exec(r.Context(), `delete from submissions where id = $1`, s.ID); err != nil { + if _, err := tx.ExecContext(r.Context(), `delete from submissions where id = $1`, s.ID); err != nil { os.Rename(dst, src) slog.Error("delete submission", "ctx", "submissions", "error", err, "submission", s.ID) http.Error(w, "virhe", http.StatusInternalServerError) return } - if err := tx.Commit(r.Context()); err != nil { + if err := tx.Commit(); err != nil { os.Rename(dst, src) slog.Error("commit publish", "ctx", "songs", "error", err, "submission", s.ID) http.Error(w, "virhe", http.StatusInternalServerError) @@ -635,7 +634,7 @@ func nilIfEmpty(s string) *string { // Own in-flight submissions, for the home page — otherwise a submission is only reachable by URL. func (a *app) mySubmissions(ctx context.Context, userID int64) ([]*submission, error) { - rows, err := a.pool.Query(ctx, ` + rows, err := a.db.QueryContext(ctx, ` select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''), coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''), coalesce(description, ''), created_at diff --git a/submit_test.go b/submit_test.go index 7212d04..617981b 100644 --- a/submit_test.go +++ b/submit_test.go @@ -49,7 +49,7 @@ func makeAudio(t *testing.T, path string) { func (a *app) readySubmission(t *testing.T, userID int64) *submission { t.Helper() var id int64 - err := a.pool.QueryRow(context.Background(), ` + err := a.db.QueryRowContext(context.Background(), ` insert into submissions (user_id, status, title, artist, genre) values ($1, 'ready', 'Testikappale', 'Testiartisti', 'Metal') returning id`, userID).Scan(&id) @@ -58,7 +58,7 @@ func (a *app) readySubmission(t *testing.T, userID int64) *submission { } path := a.tmpPath(id, ".ogg") makeAudio(t, path) - if _, err := a.pool.Exec(context.Background(), + if _, err := a.db.ExecContext(context.Background(), `update submissions set tmp_path = $2 where id = $1`, id, path); err != nil { t.Fatal(err) } @@ -96,13 +96,13 @@ func TestPublishIsAllOrNothing(t *testing.T) { t.Fatalf("publish with an unwritable audio dir: status = %d, want 500", w.Code) } var songs, submissions int - if err := a.pool.QueryRow(ctx, `select count(*) from songs`).Scan(&songs); err != nil { + if err := a.db.QueryRowContext(ctx, `select count(*) from songs`).Scan(&songs); err != nil { t.Fatal(err) } if songs != 0 { t.Fatalf("orphan song row: %d rows with no audio file", songs) } - if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil { + if err := a.db.QueryRowContext(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil { t.Fatal(err) } if submissions != 1 { @@ -120,13 +120,13 @@ func TestPublishIsAllOrNothing(t *testing.T) { t.Fatalf("publish: status = %d, want 303", w.Code) } var songID int64 - if err := a.pool.QueryRow(ctx, `select id from songs`).Scan(&songID); err != nil { + if err := a.db.QueryRowContext(ctx, `select id from songs`).Scan(&songID); err != nil { t.Fatal(err) } if _, err := os.Stat(a.audioPath(songID)); err != nil { t.Fatalf("published song has no audio file: %v", err) } - if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil { + if err := a.db.QueryRowContext(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil { t.Fatal(err) } if submissions != 0 { @@ -154,7 +154,7 @@ func TestSubmissionQuota(t *testing.T) { check(false, "no submissions") for range 4 { - if _, err := a.pool.Exec(ctx, + if _, err := a.db.ExecContext(ctx, `insert into submissions (user_id, status) values ($1, 'ready')`, id); err != nil { t.Fatal(err) } @@ -163,7 +163,7 @@ func TestSubmissionQuota(t *testing.T) { // Failures never count — yt-dlp rot and bad files are not the submitter's fault. for range 10 { - if _, err := a.pool.Exec(ctx, + if _, err := a.db.ExecContext(ctx, `insert into submissions (user_id, status) values ($1, 'failed')`, id); err != nil { t.Fatal(err) } @@ -171,7 +171,7 @@ func TestSubmissionQuota(t *testing.T) { check(false, "failures do not count") // A published song still occupies a slot, even though its submission row is gone. - if _, err := a.pool.Exec(ctx, ` + if _, err := a.db.ExecContext(ctx, ` insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by) values ('T', 'A', 'Metal', '1.ogg', 60, $1)`, id); err != nil { t.Fatal(err) @@ -179,8 +179,8 @@ func TestSubmissionQuota(t *testing.T) { check(true, "four in flight plus one published") // Yesterday's submissions are outside the window. - if _, err := a.pool.Exec(ctx, - `update submissions set created_at = now() - interval '25 hours' where user_id = $1`, + if _, err := a.db.ExecContext(ctx, + `update submissions set created_at = datetime('now', '-25 hours') where user_id = $1`, id); err != nil { t.Fatal(err) } @@ -194,17 +194,17 @@ func TestRestartRecovery(t *testing.T) { id := a.seedMember(t, "esa@example.com") for _, status := range []string{"queued", "downloading", "converting"} { - if _, err := a.pool.Exec(ctx, + if _, err := a.db.ExecContext(ctx, `insert into submissions (user_id, status) values ($1, $2)`, id, status); err != nil { t.Fatal(err) } } - if err := sweep(ctx, a.pool); err != nil { + if err := sweep(ctx, a.db); err != nil { t.Fatal(err) } var stuck int - if err := a.pool.QueryRow(ctx, + if err := a.db.QueryRowContext(ctx, `select count(*) from submissions where status <> 'failed'`).Scan(&stuck); err != nil { t.Fatal(err) } @@ -212,7 +212,7 @@ func TestRestartRecovery(t *testing.T) { t.Fatalf("%d submissions survived the sweep still in flight", stuck) } var msg string - if err := a.pool.QueryRow(ctx, + if err := a.db.QueryRowContext(ctx, `select status_msg from submissions limit 1`).Scan(&msg); err != nil { t.Fatal(err) } diff --git a/templates/admin.html b/templates/admin.html index 47da7ec..d7eee55 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -13,8 +13,11 @@ {{range .Data.Invites}} - - {{.Link}} + + + {{.Link}} + käyttämätön {{fidate .CreatedAt}} @@ -87,4 +90,29 @@ + {{end}} diff --git a/templates/layout.html b/templates/layout.html index 4d3220f..9a21ebf 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -62,7 +62,7 @@