Sessions had no server-side expiry: only the web cookie's 30-day Max-Age limited them, and a bearer token (Android) lived until revoked by hand. GetSessionByTokenHash and ListSessionsForUser now ignore sessions idle for 30 days or older than a year, and the GC worker deletes them hourly. A password change was a plain UPDATE, so a session opened with the old password survived it. Now: - self-service change signs out every other device and keeps this one; - reset by email ends every session the account has; - an admin reset ends the target's sessions (keeping the admin's own when they reset themselves). The success copy on web and Android says the other devices were signed out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
5.3 KiB
Go
143 lines
5.3 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: gc.sql
|
|
|
|
package dbq
|
|
|
|
import (
|
|
"context"
|
|
)
|
|
|
|
const gcClosePlaySessionsWithNoRecentEvents = `-- name: GcClosePlaySessionsWithNoRecentEvents :execrows
|
|
UPDATE play_sessions
|
|
SET ended_at = COALESCE(last_event_at, started_at)
|
|
WHERE ended_at IS NULL
|
|
AND (
|
|
(track_count > 0 AND last_event_at < now() - INTERVAL '6 hours')
|
|
OR
|
|
(track_count = 0 AND started_at < now() - INTERVAL '1 hour')
|
|
)
|
|
`
|
|
|
|
// #565: play_sessions.ended_at was added but never populated by any
|
|
// writer. Close sessions whose last_event_at is older than 6h —
|
|
// treating that as "user moved on" the same way audio_service does
|
|
// after grace periods. Sessions with NO events (track_count = 0)
|
|
// older than 1h are also closed (stale handshakes from clients that
|
|
// never recorded a play). ended_at is set to last_event_at so the
|
|
// session's duration reads naturally.
|
|
func (q *Queries) GcClosePlaySessionsWithNoRecentEvents(ctx context.Context) (int64, error) {
|
|
result, err := q.db.Exec(ctx, gcClosePlaySessionsWithNoRecentEvents)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const gcCloseStalePlayEvents = `-- name: GcCloseStalePlayEvents :execrows
|
|
|
|
UPDATE play_events
|
|
SET ended_at = COALESCE(
|
|
started_at + (duration_played_ms * INTERVAL '1 millisecond'),
|
|
now()
|
|
)
|
|
WHERE ended_at IS NULL
|
|
AND started_at < now() - INTERVAL '24 hours'
|
|
`
|
|
|
|
// Background garbage-collector / lifecycle queries. All five address
|
|
// drift findings from the 2026-06-02 audit (Scribe parent #552):
|
|
// #565 #566 #567 #574 #575. Sweeper runs on a long tick (default 1h)
|
|
// so per-query cost is amortised; each is idempotent (re-running on
|
|
// already-closed/-deleted rows is a no-op).
|
|
// #566: play_events rows opened more than 24h ago that never got a
|
|
// play_ended. Client crashed mid-track, network dropped, etc. We
|
|
// synthesize ended_at = started_at + duration_played_ms when present,
|
|
// otherwise leave duration_played_ms null and stamp ended_at = now()
|
|
// so the row stops looking "open" for downstream queries that filter
|
|
// ended_at IS NULL. Doesn't touch was_skipped (we genuinely don't
|
|
// know if the user skipped).
|
|
func (q *Queries) GcCloseStalePlayEvents(ctx context.Context) (int64, error) {
|
|
result, err := q.db.Exec(ctx, gcCloseStalePlayEvents)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const gcDeleteExpiredPasswordResets = `-- name: GcDeleteExpiredPasswordResets :execrows
|
|
DELETE FROM password_resets
|
|
WHERE (used_at IS NOT NULL AND used_at < now() - INTERVAL '7 days')
|
|
OR (used_at IS NULL AND expires_at < now() - INTERVAL '1 hour')
|
|
`
|
|
|
|
// #575: password_resets accumulates expired + used rows forever. The
|
|
// validation path already rejects them; this just keeps the table
|
|
// from growing unbounded. Used rows are kept for 7 days for audit;
|
|
// unused expired rows go immediately.
|
|
func (q *Queries) GcDeleteExpiredPasswordResets(ctx context.Context) (int64, error) {
|
|
result, err := q.db.Exec(ctx, gcDeleteExpiredPasswordResets)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const gcDeleteExpiredSessions = `-- name: GcDeleteExpiredSessions :execrows
|
|
DELETE FROM sessions
|
|
WHERE last_seen_at <= now() - interval '30 days'
|
|
OR created_at <= now() - interval '365 days'
|
|
`
|
|
|
|
// Sessions past their idle (30 days) or absolute (1 year) limit. They already
|
|
// fail auth through GetSessionByTokenHash's filter; this only clears the rows.
|
|
func (q *Queries) GcDeleteExpiredSessions(ctx context.Context) (int64, error) {
|
|
result, err := q.db.Exec(ctx, gcDeleteExpiredSessions)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const gcExpireScrobbleQueueFailedRows = `-- name: GcExpireScrobbleQueueFailedRows :execrows
|
|
DELETE FROM scrobble_queue
|
|
WHERE status = 'failed'
|
|
AND enqueued_at < now() - INTERVAL '14 days'
|
|
`
|
|
|
|
// #567: scrobble_queue rows that have been in status='failed' for
|
|
// more than 14 days. The worker stops retrying after maxAttempts;
|
|
// failed rows accumulate forever otherwise. CASCADE from play_events
|
|
// already drops the row when the underlying event is deleted, so this
|
|
// only handles persistent failures (token revoked, etc.).
|
|
func (q *Queries) GcExpireScrobbleQueueFailedRows(ctx context.Context) (int64, error) {
|
|
result, err := q.db.Exec(ctx, gcExpireScrobbleQueueFailedRows)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const gcResetStuckSystemPlaylistRuns = `-- name: GcResetStuckSystemPlaylistRuns :execrows
|
|
UPDATE system_playlist_runs
|
|
SET in_flight = false,
|
|
last_error = COALESCE(last_error, 'stuck-row auto-reset by gc')
|
|
WHERE in_flight = true
|
|
AND last_run_at < now() - INTERVAL '10 minutes'
|
|
`
|
|
|
|
// #574: system_playlist_runs.in_flight = true can wedge on a
|
|
// goroutine panic between SET in_flight=true and SET in_flight=false.
|
|
// The duplicate-prevention check refuses to start a fresh regen while
|
|
// in_flight, so a stuck row blocks all future regens for that user.
|
|
// Reset rows where last_run_at is older than 10 minutes (regens
|
|
// shouldn't take that long).
|
|
func (q *Queries) GcResetStuckSystemPlaylistRuns(ctx context.Context) (int64, error) {
|
|
result, err := q.db.Exec(ctx, gcResetStuckSystemPlaylistRuns)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|