// Package membershipstore implements the PostgreSQL-backed adapter for // `ports.MembershipStore`. // // PG_PLAN.md §6A migrates Game Lobby Service away from Redis-backed durable // membership records. package membershipstore import ( "context" "database/sql" "errors" "fmt" "strings" "time" "galaxy/lobby/internal/adapters/postgres/internal/sqlx" pgtable "galaxy/lobby/internal/adapters/postgres/jet/lobby/table" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/ports" pg "github.com/go-jet/jet/v2/postgres" ) // Config configures one PostgreSQL-backed membership store instance. type Config struct { DB *sql.DB OperationTimeout time.Duration } // Store persists Game Lobby membership records in PostgreSQL. type Store struct { db *sql.DB operationTimeout time.Duration } // New constructs one PostgreSQL-backed membership store from cfg. func New(cfg Config) (*Store, error) { if cfg.DB == nil { return nil, errors.New("new postgres membership store: db must not be nil") } if cfg.OperationTimeout <= 0 { return nil, errors.New("new postgres membership store: operation timeout must be positive") } return &Store{ db: cfg.DB, operationTimeout: cfg.OperationTimeout, }, nil } // membershipSelectColumns is the canonical SELECT list for the memberships // table, matching scanMembership's column order. var membershipSelectColumns = pg.ColumnList{ pgtable.Memberships.MembershipID, pgtable.Memberships.GameID, pgtable.Memberships.UserID, pgtable.Memberships.RaceName, pgtable.Memberships.CanonicalKey, pgtable.Memberships.Status, pgtable.Memberships.JoinedAt, pgtable.Memberships.RemovedAt, } // Save persists a new active membership record. Save is create-only; a // second save against the same membership id maps the unique-violation to // membership.ErrConflict. func (store *Store) Save(ctx context.Context, record membership.Membership) error { if store == nil || store.db == nil { return errors.New("save membership: nil store") } if err := record.Validate(); err != nil { return fmt.Errorf("save membership: %w", err) } if record.Status != membership.StatusActive { return fmt.Errorf( "save membership: status must be %q, got %q", membership.StatusActive, record.Status, ) } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "save membership", store.operationTimeout) if err != nil { return err } defer cancel() stmt := pgtable.Memberships.INSERT( pgtable.Memberships.MembershipID, pgtable.Memberships.GameID, pgtable.Memberships.UserID, pgtable.Memberships.RaceName, pgtable.Memberships.CanonicalKey, pgtable.Memberships.Status, pgtable.Memberships.JoinedAt, pgtable.Memberships.RemovedAt, ).VALUES( record.MembershipID.String(), record.GameID.String(), record.UserID, record.RaceName, record.CanonicalKey, string(record.Status), record.JoinedAt.UTC(), sqlx.NullableTimePtr(record.RemovedAt), ) query, args := stmt.Sql() if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil { if sqlx.IsUniqueViolation(err) { return fmt.Errorf("save membership: %w", membership.ErrConflict) } return fmt.Errorf("save membership: %w", err) } return nil } // Get returns the record identified by membershipID. func (store *Store) Get(ctx context.Context, membershipID common.MembershipID) (membership.Membership, error) { if store == nil || store.db == nil { return membership.Membership{}, errors.New("get membership: nil store") } if err := membershipID.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("get membership: %w", err) } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get membership", store.operationTimeout) if err != nil { return membership.Membership{}, err } defer cancel() stmt := pg.SELECT(membershipSelectColumns). FROM(pgtable.Memberships). WHERE(pgtable.Memberships.MembershipID.EQ(pg.String(membershipID.String()))) query, args := stmt.Sql() row := store.db.QueryRowContext(operationCtx, query, args...) record, err := scanMembership(row) if sqlx.IsNoRows(err) { return membership.Membership{}, membership.ErrNotFound } if err != nil { return membership.Membership{}, fmt.Errorf("get membership: %w", err) } return record, nil } // GetByGame returns every membership attached to gameID. func (store *Store) GetByGame(ctx context.Context, gameID common.GameID) ([]membership.Membership, error) { if store == nil || store.db == nil { return nil, errors.New("get memberships by game: nil store") } if err := gameID.Validate(); err != nil { return nil, fmt.Errorf("get memberships by game: %w", err) } stmt := pg.SELECT(membershipSelectColumns). FROM(pgtable.Memberships). WHERE(pgtable.Memberships.GameID.EQ(pg.String(gameID.String()))). ORDER_BY(pgtable.Memberships.JoinedAt.ASC(), pgtable.Memberships.MembershipID.ASC()) return store.queryList(ctx, "get memberships by game", stmt) } // GetByUser returns every membership held by userID. func (store *Store) GetByUser(ctx context.Context, userID string) ([]membership.Membership, error) { if store == nil || store.db == nil { return nil, errors.New("get memberships by user: nil store") } trimmed := strings.TrimSpace(userID) if trimmed == "" { return nil, fmt.Errorf("get memberships by user: user id must not be empty") } stmt := pg.SELECT(membershipSelectColumns). FROM(pgtable.Memberships). WHERE(pgtable.Memberships.UserID.EQ(pg.String(trimmed))). ORDER_BY(pgtable.Memberships.JoinedAt.ASC(), pgtable.Memberships.MembershipID.ASC()) return store.queryList(ctx, "get memberships by user", stmt) } func (store *Store) queryList(ctx context.Context, operation string, stmt pg.SelectStatement) ([]membership.Membership, error) { operationCtx, cancel, err := sqlx.WithTimeout(ctx, operation, store.operationTimeout) if err != nil { return nil, err } defer cancel() query, args := stmt.Sql() rows, err := store.db.QueryContext(operationCtx, query, args...) if err != nil { return nil, fmt.Errorf("%s: %w", operation, err) } defer rows.Close() records := make([]membership.Membership, 0) for rows.Next() { record, err := scanMembership(rows) if err != nil { return nil, fmt.Errorf("%s: scan: %w", operation, err) } records = append(records, record) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("%s: %w", operation, err) } if len(records) == 0 { return nil, nil } return records, nil } // UpdateStatus applies one status transition with compare-and-swap on the // current status column. RemovedAt is set to input.At when transitioning out // of active. func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateMembershipStatusInput) error { if store == nil || store.db == nil { return errors.New("update membership status: nil store") } if err := input.Validate(); err != nil { return fmt.Errorf("update membership status: %w", err) } if err := membership.Transition(input.ExpectedFrom, input.To); err != nil { return err } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update membership status", store.operationTimeout) if err != nil { return err } defer cancel() at := input.At.UTC() stmt := pgtable.Memberships.UPDATE(pgtable.Memberships.Status, pgtable.Memberships.RemovedAt). SET(string(input.To), at). WHERE(pg.AND( pgtable.Memberships.MembershipID.EQ(pg.String(input.MembershipID.String())), pgtable.Memberships.Status.EQ(pg.String(string(input.ExpectedFrom))), )) query, args := stmt.Sql() result, err := store.db.ExecContext(operationCtx, query, args...) if err != nil { return fmt.Errorf("update membership status: %w", err) } affected, err := result.RowsAffected() if err != nil { return fmt.Errorf("update membership status: rows affected: %w", err) } if affected == 0 { probe := pg.SELECT(pgtable.Memberships.Status). FROM(pgtable.Memberships). WHERE(pgtable.Memberships.MembershipID.EQ(pg.String(input.MembershipID.String()))) probeQuery, probeArgs := probe.Sql() var current string row := store.db.QueryRowContext(operationCtx, probeQuery, probeArgs...) if err := row.Scan(¤t); err != nil { if sqlx.IsNoRows(err) { return membership.ErrNotFound } return fmt.Errorf("update membership status: probe: %w", err) } return fmt.Errorf("update membership status: %w", membership.ErrConflict) } return nil } // Delete removes the membership record identified by membershipID. The // pre-start removemember path uses Delete; the post-start path uses // UpdateStatus(active → removed). func (store *Store) Delete(ctx context.Context, membershipID common.MembershipID) error { if store == nil || store.db == nil { return errors.New("delete membership: nil store") } if err := membershipID.Validate(); err != nil { return fmt.Errorf("delete membership: %w", err) } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete membership", store.operationTimeout) if err != nil { return err } defer cancel() stmt := pgtable.Memberships.DELETE(). WHERE(pgtable.Memberships.MembershipID.EQ(pg.String(membershipID.String()))) query, args := stmt.Sql() result, err := store.db.ExecContext(operationCtx, query, args...) if err != nil { return fmt.Errorf("delete membership: %w", err) } affected, err := result.RowsAffected() if err != nil { return fmt.Errorf("delete membership: rows affected: %w", err) } if affected == 0 { return membership.ErrNotFound } return nil } type rowScanner interface { Scan(dest ...any) error } func scanMembership(rs rowScanner) (membership.Membership, error) { var ( membershipID string gameID string userID string raceName string canonicalKey string status string joinedAt time.Time removedAt sql.NullTime ) if err := rs.Scan( &membershipID, &gameID, &userID, &raceName, &canonicalKey, &status, &joinedAt, &removedAt, ); err != nil { return membership.Membership{}, err } return membership.Membership{ MembershipID: common.MembershipID(membershipID), GameID: common.GameID(gameID), UserID: userID, RaceName: raceName, CanonicalKey: canonicalKey, Status: membership.Status(status), JoinedAt: joinedAt.UTC(), RemovedAt: sqlx.TimePtrFromNullable(removedAt), }, nil } // Ensure Store satisfies the ports.MembershipStore interface at compile // time. var _ ports.MembershipStore = (*Store)(nil)