Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
192 lines
6.5 KiB
Go
192 lines
6.5 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
var (
|
|
mu sync.RWMutex
|
|
globalDB *sql.DB
|
|
)
|
|
|
|
// Init opens (or creates) the SQLite database and runs migrations.
|
|
func Init(dbPath string) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if globalDB != nil {
|
|
return nil
|
|
}
|
|
|
|
dir := filepath.Dir(dbPath)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("create data dir: %w", err)
|
|
}
|
|
|
|
d, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)")
|
|
if err != nil {
|
|
return fmt.Errorf("open database: %w", err)
|
|
}
|
|
|
|
d.SetMaxOpenConns(1)
|
|
|
|
if err := runMigrations(d); err != nil {
|
|
return fmt.Errorf("run migrations: %w", err)
|
|
}
|
|
|
|
globalDB = d
|
|
slog.Info("database initialized", "path", dbPath)
|
|
return nil
|
|
}
|
|
|
|
// Get returns the global database handle. Panics if Init was not called.
|
|
func Get() *sql.DB {
|
|
mu.RLock()
|
|
db := globalDB
|
|
mu.RUnlock()
|
|
if db == nil {
|
|
panic("storage.Get() called before storage.Init()")
|
|
}
|
|
return db
|
|
}
|
|
|
|
// Close closes the global database handle.
|
|
func Close() error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if globalDB != nil {
|
|
err := globalDB.Close()
|
|
globalDB = nil
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runMigrations(d *sql.DB) error {
|
|
if _, err := d.Exec(schema); err != nil {
|
|
return fmt.Errorf("create schema: %w", err)
|
|
}
|
|
|
|
// Idempotent column adds for DBs created before the dedup columns existed.
|
|
// SQLite errors with "duplicate column name" when the column is already there;
|
|
// we swallow that specifically.
|
|
addColumnIfMissing(d, "stories", "url_canonical", "TEXT")
|
|
addColumnIfMissing(d, "stories", "headline_norm", "TEXT")
|
|
addColumnIfMissing(d, "stories", "paywalled", "INTEGER NOT NULL DEFAULT 0")
|
|
// content holds the full article text (feed content:encoded when present,
|
|
// else the body scraped during paywall detection) for reader mode. Stories
|
|
// ingested before this column existed simply have NULL and fall back to lede.
|
|
addColumnIfMissing(d, "stories", "content", "TEXT")
|
|
// content_chars caches the character count of content so the "N min read"
|
|
// chip never has to LENGTH() the full body on the hot listing path. Filled at
|
|
// insert time; the backfill below populates rows that predate the column.
|
|
addColumnIfMissing(d, "stories", "content_chars", "INTEGER NOT NULL DEFAULT 0")
|
|
backfillContentChars(d)
|
|
addColumnIfMissing(d, "stories", "published_at", "INTEGER")
|
|
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
|
|
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
|
|
addColumnIfMissing(d, "round_robin_state", "last_channel", "TEXT")
|
|
// Occupancy of a shared table. Rows written before the casino went multiplayer
|
|
// are solo games and read as NULL, which is exactly what they are.
|
|
addColumnIfMissing(d, "game_live_hands", "table_id", "TEXT")
|
|
// The public detail sheet (stats + equipped gear) for an adventurer's
|
|
// click-through page. Rides the roster snapshot; NULL on rows pushed by a
|
|
// gogobee build that predates the detail page.
|
|
addColumnIfMissing(d, "adventure_roster", "detail_json", "TEXT")
|
|
|
|
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
|
// Check sqlite_master before creating.
|
|
var ftsExists int
|
|
if err := d.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='stories_fts'`).Scan(&ftsExists); err != nil {
|
|
return fmt.Errorf("probe FTS5 table: %w", err)
|
|
}
|
|
if ftsExists == 0 {
|
|
if _, err := d.Exec(ftsSchema); err != nil {
|
|
return fmt.Errorf("create FTS5 table: %w", err)
|
|
}
|
|
if _, err := d.Exec(ftsTriggers); err != nil {
|
|
return fmt.Errorf("create FTS5 triggers: %w", err)
|
|
}
|
|
slog.Info("created FTS5 search index")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RunMaintenance prunes stale data. Called periodically.
|
|
func RunMaintenance() {
|
|
// Prune old stories (30 days) and their post logs / reactions
|
|
storyCutoff := nowUnix() - int64(30*86400)
|
|
exec("prune old stories",
|
|
`DELETE FROM stories WHERE seen_at < ? AND classified = 1`, storyCutoff)
|
|
exec("prune old post_log",
|
|
`DELETE FROM post_log WHERE posted_at < ?`, storyCutoff)
|
|
exec("prune old reactions",
|
|
`DELETE FROM reactions WHERE reacted_at < ?`, storyCutoff)
|
|
|
|
// Drop per-user read/bookmark rows whose story has been pruned above, so the
|
|
// table can't accumulate dangling references as stories age out.
|
|
exec("prune orphan user_story_state",
|
|
`DELETE FROM user_story_state WHERE story_id NOT IN (SELECT id FROM stories)`)
|
|
|
|
// Same for per-story view counts once their story has aged out.
|
|
exec("prune orphan story_views",
|
|
`DELETE FROM story_views WHERE story_id NOT IN (SELECT id FROM stories)`)
|
|
|
|
// Daily unique tokens are only useful for the recent window; their salts are
|
|
// long gone. page_views is kept forever (tiny aggregate, all-time totals).
|
|
exec("prune old daily_visitors",
|
|
`DELETE FROM daily_visitors WHERE day < ?`, unixDay()-30)
|
|
|
|
exec("wal checkpoint", "PRAGMA wal_checkpoint(TRUNCATE)")
|
|
exec("optimize", "PRAGMA optimize")
|
|
}
|
|
|
|
// exec is a fire-and-forget helper that logs errors. Several callers run it from
|
|
// background goroutines (metrics, view counts), which can outlive a Close() — so
|
|
// unlike Get() it must not panic on a nil handle: it simply skips the write.
|
|
func exec(label, query string, args ...any) {
|
|
mu.RLock()
|
|
db := globalDB
|
|
mu.RUnlock()
|
|
if db == nil {
|
|
slog.Warn("db exec skipped: no database", "op", label)
|
|
return
|
|
}
|
|
if _, err := db.Exec(query, args...); err != nil {
|
|
slog.Error("db exec failed", "op", label, "err", err)
|
|
}
|
|
}
|
|
|
|
// backfillContentChars populates content_chars for rows carrying a body but a
|
|
// zero count — i.e. stories ingested before the column existed. LENGTH() counts
|
|
// characters (code points) for TEXT, matching the utf8.RuneCountInString done at
|
|
// insert. After the first run this matches no rows (bodied stories are set,
|
|
// bodyless ones stay 0 and are filtered by content IS NOT NULL), so it's a cheap
|
|
// startup no-op thereafter.
|
|
func backfillContentChars(d *sql.DB) {
|
|
if _, err := d.Exec(
|
|
`UPDATE stories SET content_chars = LENGTH(content)
|
|
WHERE content_chars = 0 AND content IS NOT NULL AND content <> ''`); err != nil {
|
|
slog.Error("backfill content_chars failed", "err", err)
|
|
}
|
|
}
|
|
|
|
func addColumnIfMissing(d *sql.DB, table, column, columnType string) {
|
|
q := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType)
|
|
if _, err := d.Exec(q); err != nil {
|
|
// SQLite returns "duplicate column name" when the column already exists.
|
|
if !strings.Contains(err.Error(), "duplicate column name") {
|
|
slog.Error("alter table failed", "table", table, "column", column, "err", err)
|
|
}
|
|
}
|
|
}
|