Replace scs postgresstore with a sqlc-backed SessionStore.
Keep scs for cookies and session API while sessions DDL and queries live in the same sqlc stack as the rest of Postgres.
This commit is contained in:
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,7 +114,7 @@ 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,
|
||||
ViewerID: viewerID,
|
||||
ID: id,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
+91
-30
@@ -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 {
|
||||
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
|
||||
}
|
||||
s.stopper.StopCleanup()
|
||||
s.stopper = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +52,7 @@ WHERE q.id = $2
|
||||
`
|
||||
|
||||
type GetQuestionParams struct {
|
||||
UserID string
|
||||
ViewerID string
|
||||
ID string
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user