diff --git a/db/queries/sessions.sql b/db/queries/sessions.sql new file mode 100644 index 0000000..81482a2 --- /dev/null +++ b/db/queries/sessions.sql @@ -0,0 +1,18 @@ +-- name: GetSession :one +SELECT data +FROM sessions +WHERE token = $1 AND expiry > now(); + +-- name: UpsertSession :exec +INSERT INTO sessions (token, data, expiry) +VALUES ($1, $2, $3) +ON CONFLICT (token) DO UPDATE +SET data = excluded.data, expiry = excluded.expiry; + +-- name: DeleteSession :exec +DELETE FROM sessions +WHERE token = $1; + +-- name: DeleteExpiredSessions :exec +DELETE FROM sessions +WHERE expiry <= now(); diff --git a/go.mod b/go.mod index 84dc9b5..844fc09 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module plumber go 1.25.0 require ( - github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de github.com/alexedwards/scs/v2 v2.9.0 github.com/aws/aws-sdk-go-v2 v1.43.7 github.com/aws/aws-sdk-go-v2/credentials v1.19.37 diff --git a/go.sum b/go.sum index 88aefcd..256af28 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de h1:LDrMkjj4OCCQsq9SvIPQV1l3leMxqXZTCTxDFwMrqTE= -github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de/go.mod h1:TDDdV/xnjj+/4zBQ9a2k+i2AbuAdY7SQjPUh5zoTZ3M= github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= github.com/aws/aws-sdk-go-v2 v1.43.7 h1:msCzvkeYJA9ehbV8mRRmkZLo/zJg/+yDVLNtflg83hQ= @@ -43,8 +41,6 @@ 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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/lib/pq v1.4.0 h1:TmtCFbH+Aw0AixwyttznSMQDgbR5Yed/Gg6S8Funrhc= -github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/store/postgres.go b/internal/store/postgres.go index cd5d24b..3f1af8f 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -67,10 +67,6 @@ func OpenPostgres(databaseURL, schema string) (*sql.DB, *SessionStore, error) { _ = db.Close() return nil, nil, fmt.Errorf("apply schema: %w", err) } - if err := applySessionsSchema(db); err != nil { - _ = db.Close() - return nil, nil, fmt.Errorf("apply sessions schema: %w", err) - } if err := migrateUserProfileColumns(db); err != nil { _ = db.Close() return nil, nil, fmt.Errorf("migrate profile columns: %w", err) diff --git a/internal/store/question.go b/internal/store/question.go index 8915e8f..0761c19 100644 --- a/internal/store/question.go +++ b/internal/store/question.go @@ -99,7 +99,7 @@ func rankedFrom( func ListHunt(ctx context.Context, db *sql.DB, huntDate, viewerID string) ([]RankedQuestion, error) { rows, err := sqlc.New(db).ListHunt(ctx, sqlc.ListHuntParams{ - UserID: viewerID, + ViewerID: viewerID, HuntDate: huntDate, }) if err != nil { @@ -114,8 +114,8 @@ func ListHunt(ctx context.Context, db *sql.DB, huntDate, viewerID string) ([]Ran func GetQuestion(ctx context.Context, db *sql.DB, id, viewerID string) (*RankedQuestion, error) { r, err := sqlc.New(db).GetQuestion(ctx, sqlc.GetQuestionParams{ - UserID: viewerID, - ID: id, + ViewerID: viewerID, + ID: id, }) if err != nil { return nil, err diff --git a/internal/store/sessions.go b/internal/store/sessions.go index 4d4edcf..b7fb062 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -1,53 +1,114 @@ package store import ( + "context" "database/sql" + "errors" + "sync" "time" - "github.com/alexedwards/scs/postgresstore" "github.com/alexedwards/scs/v2" + + "plumber/internal/store/sqlc" ) -const sessionsSchemaPostgres = ` -CREATE TABLE IF NOT EXISTS sessions ( - token TEXT PRIMARY KEY, - data BYTEA NOT NULL, - expiry TIMESTAMPTZ NOT NULL -); -CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions (expiry); -` - -// applySessionsSchema creates the scs sessions table if missing. -func applySessionsSchema(db *sql.DB) error { - return applySchema(db, sessionsSchemaPostgres) -} - -type sessionStopper interface { - StopCleanup() -} - -// SessionStore wraps scs Postgres session persistence and cleanup. +// SessionStore persists scs sessions in Postgres via sqlc and optionally +// deletes expired rows on an interval. type SessionStore struct { - store scs.Store - stopper sessionStopper + db *sql.DB + q *sqlc.Queries + stop chan struct{} + stopped chan struct{} + once sync.Once } -// NewSessionStore starts a postgresstore with the given cleanup interval. +// NewSessionStore creates a store backed by db. cleanupInterval > 0 starts a +// background goroutine that deletes expired sessions; 0 disables cleanup. func NewSessionStore(db *sql.DB, cleanupInterval time.Duration) *SessionStore { - ps := postgresstore.NewWithCleanupInterval(db, cleanupInterval) - return &SessionStore{store: ps, stopper: ps} + s := &SessionStore{ + db: db, + q: sqlc.New(db), + } + if cleanupInterval > 0 { + s.stop = make(chan struct{}) + s.stopped = make(chan struct{}) + go s.cleanupLoop(cleanupInterval) + } + return s } -// Store returns the scs.Store implementation. +// Store returns the scs.Store implementation (s itself). func (s *SessionStore) Store() scs.Store { - return s.store + return s +} + +// Find implements scs.Store. +func (s *SessionStore) Find(token string) ([]byte, bool, error) { + return s.FindCtx(context.Background(), token) +} + +// Commit implements scs.Store. +func (s *SessionStore) Commit(token string, data []byte, expiry time.Time) error { + return s.CommitCtx(context.Background(), token, data, expiry) +} + +// Delete implements scs.Store. +func (s *SessionStore) Delete(token string) error { + return s.DeleteCtx(context.Background(), token) +} + +// FindCtx implements scs.CtxStore. +func (s *SessionStore) FindCtx(ctx context.Context, token string) ([]byte, bool, error) { + data, err := s.q.GetSession(ctx, token) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, false, nil + } + return nil, false, err + } + return data, true, nil +} + +// CommitCtx implements scs.CtxStore. +func (s *SessionStore) CommitCtx(ctx context.Context, token string, data []byte, expiry time.Time) error { + return s.q.UpsertSession(ctx, sqlc.UpsertSessionParams{ + Token: token, + Data: data, + Expiry: expiry, + }) +} + +// DeleteCtx implements scs.CtxStore. +func (s *SessionStore) DeleteCtx(ctx context.Context, token string) error { + return s.q.DeleteSession(ctx, token) +} + +// StopCleanup stops the background expiry deleter. Safe to call multiple times. +func (s *SessionStore) StopCleanup() { + if s == nil || s.stop == nil { + return + } + s.once.Do(func() { + close(s.stop) + <-s.stopped + }) } // Close stops background session cleanup. func (s *SessionStore) Close() { - if s == nil || s.stopper == nil { - return - } - s.stopper.StopCleanup() - s.stopper = nil + s.StopCleanup() +} + +func (s *SessionStore) cleanupLoop(interval time.Duration) { + defer close(s.stopped) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + _ = s.q.DeleteExpiredSessions(context.Background()) + case <-s.stop: + return + } + } } diff --git a/internal/store/sessions_test.go b/internal/store/sessions_test.go new file mode 100644 index 0000000..b9430f7 --- /dev/null +++ b/internal/store/sessions_test.go @@ -0,0 +1,55 @@ +package store + +import ( + "os" + "testing" + "time" +) + +func TestSessionStoreCommitFindDelete(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + url = os.Getenv("DATABASE_URL") + } + if url == "" { + t.Skip("DATABASE_URL or TEST_DATABASE_URL not set") + } + schema, err := os.ReadFile("../../schema.sql") + if err != nil { + t.Fatal(err) + } + db, sessions, err := OpenPostgres(url, string(schema)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + defer sessions.Close() + + token := "test-session-" + time.Now().Format("20060102150405.000000000") + data := []byte("hello-session") + expiry := time.Now().Add(time.Hour) + + if err := sessions.Commit(token, data, expiry); err != nil { + t.Fatalf("Commit: %v", err) + } + got, found, err := sessions.Find(token) + if err != nil { + t.Fatalf("Find: %v", err) + } + if !found { + t.Fatal("expected found") + } + if string(got) != string(data) { + t.Fatalf("data = %q, want %q", got, data) + } + if err := sessions.Delete(token); err != nil { + t.Fatalf("Delete: %v", err) + } + _, found, err = sessions.Find(token) + if err != nil { + t.Fatalf("Find after delete: %v", err) + } + if found { + t.Fatal("expected not found after delete") + } +} diff --git a/internal/store/sqlc/models.go b/internal/store/sqlc/models.go index 93ac364..7de647f 100644 --- a/internal/store/sqlc/models.go +++ b/internal/store/sqlc/models.go @@ -4,6 +4,10 @@ package sqlc +import ( + "time" +) + type Answer struct { QuestionID string AuthorID string @@ -23,6 +27,12 @@ type Question struct { CreatedAt string } +type Session struct { + Token string + Data []byte + Expiry time.Time +} + type User struct { ID string Username string diff --git a/internal/store/sqlc/questions.sql.go b/internal/store/sqlc/questions.sql.go index dc8b4e1..9b69b70 100644 --- a/internal/store/sqlc/questions.sql.go +++ b/internal/store/sqlc/questions.sql.go @@ -39,9 +39,12 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) const getQuestion = `-- name: GetQuestion :one SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, - COALESCE((SELECT SUM(value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, + COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered, - COALESCE((SELECT value FROM votes WHERE votes.user_id = $1 AND votes.question_id = q.id), 0)::bigint AS user_vote + COALESCE(( + SELECT votes.value FROM votes + WHERE votes.user_id = $1 AND votes.question_id = q.id + ), 0)::bigint AS user_vote FROM questions q JOIN users u ON u.id = q.author_id LEFT JOIN answers a ON a.question_id = q.id @@ -49,8 +52,8 @@ WHERE q.id = $2 ` type GetQuestionParams struct { - UserID string - ID string + ViewerID string + ID string } type GetQuestionRow struct { @@ -69,7 +72,7 @@ type GetQuestionRow struct { } func (q *Queries) GetQuestion(ctx context.Context, arg GetQuestionParams) (GetQuestionRow, error) { - row := q.db.QueryRowContext(ctx, getQuestion, arg.UserID, arg.ID) + row := q.db.QueryRowContext(ctx, getQuestion, arg.ViewerID, arg.ID) var i GetQuestionRow err := row.Scan( &i.ID, @@ -103,7 +106,10 @@ const listHunt = `-- name: ListHunt :many SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, COALESCE(SUM(v.value), 0)::bigint AS score, CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered, - COALESCE((SELECT value FROM votes WHERE votes.user_id = $1 AND votes.question_id = q.id), 0)::bigint AS user_vote + COALESCE(( + SELECT votes.value FROM votes + WHERE votes.user_id = $1 AND votes.question_id = q.id + ), 0)::bigint AS user_vote FROM questions q JOIN users u ON u.id = q.author_id LEFT JOIN votes v ON v.question_id = q.id @@ -114,7 +120,7 @@ ORDER BY score DESC, q.created_at ASC ` type ListHuntParams struct { - UserID string + ViewerID string HuntDate string } @@ -134,7 +140,7 @@ type ListHuntRow struct { } func (q *Queries) ListHunt(ctx context.Context, arg ListHuntParams) ([]ListHuntRow, error) { - rows, err := q.db.QueryContext(ctx, listHunt, arg.UserID, arg.HuntDate) + rows, err := q.db.QueryContext(ctx, listHunt, arg.ViewerID, arg.HuntDate) if err != nil { return nil, err } @@ -171,7 +177,7 @@ func (q *Queries) ListHunt(ctx context.Context, arg ListHuntParams) ([]ListHuntR const listQuestionsAnsweredBy = `-- name: ListQuestionsAnsweredBy :many SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, - COALESCE((SELECT SUM(value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, + COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, 1::bigint AS answered, 0::bigint AS user_vote FROM answers ans @@ -196,8 +202,8 @@ type ListQuestionsAnsweredByRow struct { UserVote int64 } -func (q *Queries) ListQuestionsAnsweredBy(ctx context.Context, authorID string) ([]ListQuestionsAnsweredByRow, error) { - rows, err := q.db.QueryContext(ctx, listQuestionsAnsweredBy, authorID) +func (q *Queries) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]ListQuestionsAnsweredByRow, error) { + rows, err := q.db.QueryContext(ctx, listQuestionsAnsweredBy, adminID) if err != nil { return nil, err } @@ -234,7 +240,7 @@ func (q *Queries) ListQuestionsAnsweredBy(ctx context.Context, authorID string) const listQuestionsByAuthor = `-- name: ListQuestionsByAuthor :many SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, - COALESCE((SELECT SUM(value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, + COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score, CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered, 0::bigint AS user_vote FROM questions q diff --git a/internal/store/sqlc/sessions.sql.go b/internal/store/sqlc/sessions.sql.go new file mode 100644 index 0000000..9562dea --- /dev/null +++ b/internal/store/sqlc/sessions.sql.go @@ -0,0 +1,62 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: sessions.sql + +package sqlc + +import ( + "context" + "time" +) + +const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec +DELETE FROM sessions +WHERE expiry <= now() +` + +func (q *Queries) DeleteExpiredSessions(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteExpiredSessions) + return err +} + +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM sessions +WHERE token = $1 +` + +func (q *Queries) DeleteSession(ctx context.Context, token string) error { + _, err := q.db.ExecContext(ctx, deleteSession, token) + return err +} + +const getSession = `-- name: GetSession :one +SELECT data +FROM sessions +WHERE token = $1 AND expiry > now() +` + +func (q *Queries) GetSession(ctx context.Context, token string) ([]byte, error) { + row := q.db.QueryRowContext(ctx, getSession, token) + var data []byte + err := row.Scan(&data) + return data, err +} + +const upsertSession = `-- name: UpsertSession :exec +INSERT INTO sessions (token, data, expiry) +VALUES ($1, $2, $3) +ON CONFLICT (token) DO UPDATE +SET data = excluded.data, expiry = excluded.expiry +` + +type UpsertSessionParams struct { + Token string + Data []byte + Expiry time.Time +} + +func (q *Queries) UpsertSession(ctx context.Context, arg UpsertSessionParams) error { + _, err := q.db.ExecContext(ctx, upsertSession, arg.Token, arg.Data, arg.Expiry) + return err +} diff --git a/schema.sql b/schema.sql index e3a8e89..67136d6 100644 --- a/schema.sql +++ b/schema.sql @@ -36,3 +36,10 @@ CREATE TABLE IF NOT EXISTS answers ( created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + data BYTEA NOT NULL, + expiry TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions (expiry);