Datenbank von SQLite auf Postgres umgestellt

Verbindung über KVER_DSN; Tabelle user heißt jetzt account (reserviertes
Wort in Postgres). kver import-sqlite übernimmt die alte Datei einmalig in
einer Transaktion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-26 22:22:07 +02:00
co-authored by Claude Opus 5.5
parent c6ab082f8c
commit 2714fdb1a5
15 changed files with 412 additions and 301 deletions
+38 -108
View File
@@ -45,7 +45,7 @@ const entrySelect = `
e.reply_to, e.reply_count, e.last_activity, e.deleted,
e.bump_count, e.last_bump,
COALESCE(u.username, ''), COALESCE(u.avatar, '')
FROM entry e LEFT JOIN user u ON u.uid = e.uid`
FROM entry e LEFT JOIN account u ON u.uid = e.uid`
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
func scanEntries(rows *sql.Rows) ([]Entry, error) {
@@ -66,7 +66,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
func entryByPID(pid int64) (Entry, error) {
var e Entry
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
err := db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid).Scan(
&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar)
@@ -75,8 +75,8 @@ func entryByPID(pid int64) (Entry, error) {
const (
feedPageSize = 20
// maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen SQLite
// sonst dazu, entsprechend viele Zeilen zu überspringen.
// maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen die
// Datenbank sonst dazu, entsprechend viele Zeilen zu überspringen.
maxFeedPage = 100
)
@@ -85,8 +85,8 @@ func feedWhere(uid int64, rootsOnly bool) (string, []any) {
args := []any{}
where := []string{}
if uid != 0 {
where = append(where, "e.uid = ?")
args = append(args, uid)
where = append(where, fmt.Sprintf("e.uid = $%d", len(args)))
}
if rootsOnly {
where = append(where, "e.reply_to = 0")
@@ -115,7 +115,7 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
} else {
query += ` ORDER BY e.created_at DESC`
}
query += ` LIMIT ? OFFSET ?`
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, feedPageSize, page*feedPageSize)
rows, err := db.Query(query, args...)
@@ -138,7 +138,7 @@ func handleFeed(w http.ResponseWriter, r *http.Request) error {
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
var uid int64
if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
return errUserNotFound
}
@@ -216,11 +216,11 @@ func threadData(pid int64) (thread, error) {
if entry.ReplyTo != 0 {
rows, err := db.Query(
`WITH RECURSIVE chain(pid, depth) AS (
SELECT ?, 1
SELECT $1::bigint, 1
UNION ALL
SELECT e.reply_to, c.depth + 1
FROM entry e JOIN chain c ON e.pid = c.pid
WHERE e.reply_to != 0 AND c.depth < 50
WHERE e.reply_to <> 0 AND c.depth < 50
)`+entrySelect+`
JOIN chain c ON c.pid = e.pid
ORDER BY c.depth DESC`, entry.ReplyTo)
@@ -232,7 +232,7 @@ func threadData(pid int64) (thread, error) {
}
}
rows, err := db.Query(entrySelect+` WHERE e.reply_to = ? ORDER BY e.last_activity DESC LIMIT 100`, pid)
rows, err := db.Query(entrySelect+` WHERE e.reply_to = $1 ORDER BY e.last_activity DESC LIMIT 100`, pid)
if err != nil {
return thread{}, Internal(err)
}
@@ -286,22 +286,28 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
return Internal(err)
}
// pid würfeln; +1, weil 0 der Sentinel für "kein Elternbeitrag"/"gelöschter
// Autor" ist. Bei der seltenen UNIQUE-Kollision neu würfeln statt 500.
// Autor" ist. Kollisionen über ON CONFLICT statt über den Fehler: ein
// fehlgeschlagenes Statement bricht in Postgres die ganze Transaktion ab.
var pid int64
for try := 0; ; try++ {
pid = int64(rand.IntN(999999999999)) + 1
_, err := tx.Exec(
res, err := tx.Exec(
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity)
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`,
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
ON CONFLICT (pid) DO NOTHING`,
pid, uid, now, content, filepath, replyTo, now,
)
if err == nil {
break
}
if try >= 4 || !strings.Contains(err.Error(), "UNIQUE") {
if err != nil {
tx.Rollback()
return Internal(err)
}
if n, _ := res.RowsAffected(); n == 1 {
break
}
if try >= 4 {
tx.Rollback()
return Internal(errors.New("pid-Kollision nach 5 Versuchen"))
}
}
// Antwort: reply_count und last_activity aller Vorfahren bis zum Root
// anheben -- per rekursiver CTE in einem Statement statt bis zu 50
@@ -310,14 +316,14 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
if replyTo != 0 {
if _, err := tx.Exec(
`WITH RECURSIVE chain(pid, depth) AS (
SELECT ?, 0
SELECT $1::bigint, 0
UNION ALL
SELECT e.reply_to, c.depth + 1
FROM entry e JOIN chain c ON e.pid = c.pid
WHERE e.reply_to != 0 AND c.depth < 50
WHERE e.reply_to <> 0 AND c.depth < 50
)
UPDATE entry SET reply_count = reply_count + 1,
last_activity = MAX(last_activity, ?)
last_activity = GREATEST(last_activity, $2)
WHERE pid IN (SELECT pid FROM chain)`,
replyTo, now,
); err != nil {
@@ -355,7 +361,7 @@ func handleEditEntry(w http.ResponseWriter, r *http.Request) error {
return Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content")
}
if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil {
if _, err := db.Exec(`UPDATE entry SET content = $1 WHERE pid = $2`, content, pid); err != nil {
return Internal(err)
}
@@ -395,7 +401,7 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
}
if _, err := db.Exec(
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = ?`, pid,
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = $1`, pid,
); err != nil {
return Internal(err)
}
@@ -430,16 +436,16 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
// gleichzeitige Bumps den Cooldown nicht umgehen können.
now := time.Now().Unix()
res, err := db.Exec(
`UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ?
WHERE pid = ? AND deleted = 0 AND last_bump + bump_count * ? <= ?`,
now, now, pid, bumpStep, now,
`UPDATE entry SET last_activity = $1, bump_count = bump_count + 1, last_bump = $1
WHERE pid = $2 AND deleted = 0 AND last_bump + bump_count * $3 <= $1`,
now, pid, bumpStep,
)
if err != nil {
return Internal(err)
}
var bumpCount, lastBump, deleted int64
if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = ?`, pid).
if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = $1`, pid).
Scan(&bumpCount, &lastBump, &deleted); err != nil || deleted != 0 {
return errEntryNotFound
}
@@ -480,12 +486,12 @@ func voteTally(pid, uid int64) (left, right int, selected string) {
selected = "none"
if uid != 0 {
var cur string
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil {
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = $1 AND pid = $2`, uid, pid).Scan(&cur); err == nil {
selected = cur
}
}
// Beide Zähler in einem Query statt zwei COUNTs.
rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = ? GROUP BY mode`, pid)
rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = $1 GROUP BY mode`, pid)
if err != nil {
log.Printf("voteTally pid=%d: %v", pid, err)
return
@@ -508,82 +514,6 @@ func voteTally(pid, uid int64) (left, right int, selected string) {
return
}
// tally ist der Abstimmungsstand eines Beitrags aus Sicht eines Betrachters.
type tallyState struct {
Left int
Right int
Selected string // "left", "right" oder "none"
}
// voteTallies holt die Stände für eine ganze Feed-Seite in zwei Abfragen statt
// in zwei pro Beitrag. Bei 20 Beiträgen wären das sonst 40 Roundtrips für eine
// einzige Seitenansicht.
func voteTallies(pids []int64, uid int64) (map[int64]tallyState, error) {
out := make(map[int64]tallyState, len(pids))
for _, pid := range pids {
out[pid] = tallyState{Selected: "none"}
}
if len(pids) == 0 {
return out, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(pids)), ",")
args := make([]any, 0, len(pids)+1)
for _, pid := range pids {
args = append(args, pid)
}
rows, err := db.Query(
`SELECT pid, mode, COUNT(*) FROM vote WHERE pid IN (`+placeholders+`) GROUP BY pid, mode`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var pid int64
var mode string
var n int
if err := rows.Scan(&pid, &mode, &n); err != nil {
rows.Close()
return nil, err
}
t := out[pid]
switch mode {
case "left":
t.Left = n
case "right":
t.Right = n
}
out[pid] = t
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
// Eigene Stimmen nur, wenn überhaupt jemand angemeldet ist.
if uid == 0 {
return out, nil
}
rows, err = db.Query(
`SELECT pid, mode FROM vote WHERE uid = ? AND pid IN (`+placeholders+`)`,
append([]any{uid}, args...)...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var pid int64
var mode string
if err := rows.Scan(&pid, &mode); err != nil {
return nil, err
}
t := out[pid]
t.Selected = mode
out[pid] = t
}
return out, rows.Err()
}
func writeTally(w http.ResponseWriter, pid, uid int64) {
left, right, selected := voteTally(pid, uid)
writeJSON(w, http.StatusOK, map[string]any{
@@ -625,7 +555,7 @@ func handleReport(w http.ResponseWriter, r *http.Request) error {
}
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
if _, err := db.Exec(
`INSERT INTO report (pid, uid, reason, created_at, handled) VALUES (?, 0, ?, ?, 0)`,
`INSERT INTO report (pid, uid, reason, created_at, handled) VALUES ($1, 0, $2, $3, 0)`,
pid, reason, time.Now().Unix(),
); err != nil {
return Internal(err)
@@ -660,14 +590,14 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
// (parallele Requests konnten den Toggle sonst doppelt anwenden):
// 1. Gleiche Stimme löschen -> hat das getroffen, war es ein Toggle-off.
// 2. Sonst Upsert: neue Stimme anlegen bzw. bestehende ummodeln.
res, err := db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ? AND mode = ?`, uid, pid, mode)
res, err := db.Exec(`DELETE FROM vote WHERE uid = $1 AND pid = $2 AND mode = $3`, uid, pid, mode)
if err != nil {
return Internal(err)
}
if n, _ := res.RowsAffected(); n == 0 {
if _, err := db.Exec(
`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)
ON CONFLICT(uid, pid) DO UPDATE SET mode = excluded.mode`,
`INSERT INTO vote (uid, pid, mode) VALUES ($1, $2, $3)
ON CONFLICT (uid, pid) DO UPDATE SET mode = excluded.mode`,
uid, pid, mode,
); err != nil {
return Internal(err)