// Package invitestore implements the PostgreSQL-backed adapter for // `ports.InviteStore`. // // PG_PLAN.md ยง6A migrates Game Lobby Service away from Redis-backed durable // invite records. package invitestore 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/invite" "galaxy/lobby/internal/ports" pg "github.com/go-jet/jet/v2/postgres" ) // Config configures one PostgreSQL-backed invite store instance. type Config struct { DB *sql.DB OperationTimeout time.Duration } // Store persists Game Lobby invite records in PostgreSQL. type Store struct { db *sql.DB operationTimeout time.Duration } // New constructs one PostgreSQL-backed invite store from cfg. func New(cfg Config) (*Store, error) { if cfg.DB == nil { return nil, errors.New("new postgres invite store: db must not be nil") } if cfg.OperationTimeout <= 0 { return nil, errors.New("new postgres invite store: operation timeout must be positive") } return &Store{ db: cfg.DB, operationTimeout: cfg.OperationTimeout, }, nil } // inviteSelectColumns is the canonical SELECT list for the invites table, // matching scanInvite's column order. var inviteSelectColumns = pg.ColumnList{ pgtable.Invites.InviteID, pgtable.Invites.GameID, pgtable.Invites.InviterUserID, pgtable.Invites.InviteeUserID, pgtable.Invites.RaceName, pgtable.Invites.Status, pgtable.Invites.CreatedAt, pgtable.Invites.ExpiresAt, pgtable.Invites.DecidedAt, } // Save persists a new created invite record. Save is create-only; a second // save against the same invite id maps the unique-violation to // invite.ErrConflict. func (store *Store) Save(ctx context.Context, record invite.Invite) error { if store == nil || store.db == nil { return errors.New("save invite: nil store") } if err := record.Validate(); err != nil { return fmt.Errorf("save invite: %w", err) } if record.Status != invite.StatusCreated { return fmt.Errorf( "save invite: status must be %q, got %q", invite.StatusCreated, record.Status, ) } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "save invite", store.operationTimeout) if err != nil { return err } defer cancel() stmt := pgtable.Invites.INSERT( pgtable.Invites.InviteID, pgtable.Invites.GameID, pgtable.Invites.InviterUserID, pgtable.Invites.InviteeUserID, pgtable.Invites.RaceName, pgtable.Invites.Status, pgtable.Invites.CreatedAt, pgtable.Invites.ExpiresAt, pgtable.Invites.DecidedAt, ).VALUES( record.InviteID.String(), record.GameID.String(), record.InviterUserID, record.InviteeUserID, record.RaceName, string(record.Status), record.CreatedAt.UTC(), record.ExpiresAt.UTC(), sqlx.NullableTimePtr(record.DecidedAt), ) query, args := stmt.Sql() if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil { if sqlx.IsUniqueViolation(err) { return fmt.Errorf("save invite: %w", invite.ErrConflict) } return fmt.Errorf("save invite: %w", err) } return nil } // Get returns the record identified by inviteID. func (store *Store) Get(ctx context.Context, inviteID common.InviteID) (invite.Invite, error) { if store == nil || store.db == nil { return invite.Invite{}, errors.New("get invite: nil store") } if err := inviteID.Validate(); err != nil { return invite.Invite{}, fmt.Errorf("get invite: %w", err) } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get invite", store.operationTimeout) if err != nil { return invite.Invite{}, err } defer cancel() stmt := pg.SELECT(inviteSelectColumns). FROM(pgtable.Invites). WHERE(pgtable.Invites.InviteID.EQ(pg.String(inviteID.String()))) query, args := stmt.Sql() row := store.db.QueryRowContext(operationCtx, query, args...) record, err := scanInvite(row) if sqlx.IsNoRows(err) { return invite.Invite{}, invite.ErrNotFound } if err != nil { return invite.Invite{}, fmt.Errorf("get invite: %w", err) } return record, nil } // GetByGame returns every invite attached to gameID. func (store *Store) GetByGame(ctx context.Context, gameID common.GameID) ([]invite.Invite, error) { if store == nil || store.db == nil { return nil, errors.New("get invites by game: nil store") } if err := gameID.Validate(); err != nil { return nil, fmt.Errorf("get invites by game: %w", err) } stmt := pg.SELECT(inviteSelectColumns). FROM(pgtable.Invites). WHERE(pgtable.Invites.GameID.EQ(pg.String(gameID.String()))). ORDER_BY(pgtable.Invites.CreatedAt.ASC(), pgtable.Invites.InviteID.ASC()) return store.queryList(ctx, "get invites by game", stmt) } // GetByUser returns every invite addressed to inviteeUserID. func (store *Store) GetByUser(ctx context.Context, inviteeUserID string) ([]invite.Invite, error) { if store == nil || store.db == nil { return nil, errors.New("get invites by user: nil store") } trimmed := strings.TrimSpace(inviteeUserID) if trimmed == "" { return nil, fmt.Errorf("get invites by user: invitee user id must not be empty") } stmt := pg.SELECT(inviteSelectColumns). FROM(pgtable.Invites). WHERE(pgtable.Invites.InviteeUserID.EQ(pg.String(trimmed))). ORDER_BY(pgtable.Invites.CreatedAt.ASC(), pgtable.Invites.InviteID.ASC()) return store.queryList(ctx, "get invites by user", stmt) } // GetByInviter returns every invite created by inviterUserID. func (store *Store) GetByInviter(ctx context.Context, inviterUserID string) ([]invite.Invite, error) { if store == nil || store.db == nil { return nil, errors.New("get invites by inviter: nil store") } trimmed := strings.TrimSpace(inviterUserID) if trimmed == "" { return nil, fmt.Errorf("get invites by inviter: inviter user id must not be empty") } stmt := pg.SELECT(inviteSelectColumns). FROM(pgtable.Invites). WHERE(pgtable.Invites.InviterUserID.EQ(pg.String(trimmed))). ORDER_BY(pgtable.Invites.CreatedAt.ASC(), pgtable.Invites.InviteID.ASC()) return store.queryList(ctx, "get invites by inviter", stmt) } func (store *Store) queryList(ctx context.Context, operation string, stmt pg.SelectStatement) ([]invite.Invite, 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([]invite.Invite, 0) for rows.Next() { record, err := scanInvite(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. When transitioning to redeemed the row's race_name // column is replaced with the trimmed input value. func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateInviteStatusInput) error { if store == nil || store.db == nil { return errors.New("update invite status: nil store") } if err := input.Validate(); err != nil { return fmt.Errorf("update invite status: %w", err) } if err := invite.Transition(input.ExpectedFrom, input.To); err != nil { return err } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update invite status", store.operationTimeout) if err != nil { return err } defer cancel() at := input.At.UTC() raceName := strings.TrimSpace(input.RaceName) // race_name is replaced only when the caller supplies a non-empty value; // otherwise the existing value is preserved (CASE WHEN '' THEN race_name). raceExpr := pg.CASE(). WHEN(pg.String(raceName).EQ(pg.String(""))).THEN(pgtable.Invites.RaceName). ELSE(pg.String(raceName)) stmt := pgtable.Invites.UPDATE( pgtable.Invites.Status, pgtable.Invites.DecidedAt, pgtable.Invites.RaceName, ).SET( pg.String(string(input.To)), pg.TimestampzT(at), raceExpr, ).WHERE(pg.AND( pgtable.Invites.InviteID.EQ(pg.String(input.InviteID.String())), pgtable.Invites.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 invite status: %w", err) } affected, err := result.RowsAffected() if err != nil { return fmt.Errorf("update invite status: rows affected: %w", err) } if affected == 0 { probe := pg.SELECT(pgtable.Invites.Status). FROM(pgtable.Invites). WHERE(pgtable.Invites.InviteID.EQ(pg.String(input.InviteID.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 invite.ErrNotFound } return fmt.Errorf("update invite status: probe: %w", err) } return fmt.Errorf("update invite status: %w", invite.ErrConflict) } return nil } type rowScanner interface { Scan(dest ...any) error } func scanInvite(rs rowScanner) (invite.Invite, error) { var ( inviteID string gameID string inviterUserID string inviteeUserID string raceName string status string createdAt time.Time expiresAt time.Time decidedAt sql.NullTime ) if err := rs.Scan( &inviteID, &gameID, &inviterUserID, &inviteeUserID, &raceName, &status, &createdAt, &expiresAt, &decidedAt, ); err != nil { return invite.Invite{}, err } return invite.Invite{ InviteID: common.InviteID(inviteID), GameID: common.GameID(gameID), InviterUserID: inviterUserID, InviteeUserID: inviteeUserID, RaceName: raceName, Status: invite.Status(status), CreatedAt: createdAt.UTC(), ExpiresAt: expiresAt.UTC(), DecidedAt: sqlx.TimePtrFromNullable(decidedAt), }, nil } // Ensure Store satisfies the ports.InviteStore interface at compile time. var _ ports.InviteStore = (*Store)(nil)