package redisstate import ( "context" "errors" "fmt" "strings" "galaxy/lobby/internal/domain/application" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/ports" "github.com/redis/go-redis/v9" ) // ApplicationStore provides Redis-backed durable storage for application // records. type ApplicationStore struct { client *redis.Client keys Keyspace } // NewApplicationStore constructs one Redis-backed application store. It // returns an error when client is nil. func NewApplicationStore(client *redis.Client) (*ApplicationStore, error) { if client == nil { return nil, errors.New("new application store: nil redis client") } return &ApplicationStore{ client: client, keys: Keyspace{}, }, nil } // Save persists a new submitted application record and enforces the // single-active (non-rejected) constraint per (applicant, game) pair. func (store *ApplicationStore) Save(ctx context.Context, record application.Application) error { if store == nil || store.client == nil { return errors.New("save application: nil store") } if ctx == nil { return errors.New("save application: nil context") } if err := record.Validate(); err != nil { return fmt.Errorf("save application: %w", err) } if record.Status != application.StatusSubmitted { return fmt.Errorf( "save application: status must be %q, got %q", application.StatusSubmitted, record.Status, ) } payload, err := MarshalApplication(record) if err != nil { return fmt.Errorf("save application: %w", err) } primaryKey := store.keys.Application(record.ApplicationID) activeLookupKey := store.keys.UserGameApplication(record.ApplicantUserID, record.GameID) gameIndexKey := store.keys.ApplicationsByGame(record.GameID) userIndexKey := store.keys.ApplicationsByUser(record.ApplicantUserID) member := record.ApplicationID.String() watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error { existingPrimary, getErr := tx.Exists(ctx, primaryKey).Result() if getErr != nil { return fmt.Errorf("save application: %w", getErr) } if existingPrimary != 0 { return fmt.Errorf("save application: %w", application.ErrConflict) } existingActive, getErr := tx.Exists(ctx, activeLookupKey).Result() if getErr != nil { return fmt.Errorf("save application: %w", getErr) } if existingActive != 0 { return fmt.Errorf("save application: %w", application.ErrConflict) } _, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, primaryKey, payload, ApplicationRecordTTL) pipe.Set(ctx, activeLookupKey, member, ApplicationRecordTTL) pipe.SAdd(ctx, gameIndexKey, member) pipe.SAdd(ctx, userIndexKey, member) return nil }) return err }, primaryKey, activeLookupKey) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("save application: %w", application.ErrConflict) case watchErr != nil: return watchErr default: return nil } } // Get returns the record identified by applicationID. func (store *ApplicationStore) Get(ctx context.Context, applicationID common.ApplicationID) (application.Application, error) { if store == nil || store.client == nil { return application.Application{}, errors.New("get application: nil store") } if ctx == nil { return application.Application{}, errors.New("get application: nil context") } if err := applicationID.Validate(); err != nil { return application.Application{}, fmt.Errorf("get application: %w", err) } payload, err := store.client.Get(ctx, store.keys.Application(applicationID)).Bytes() switch { case errors.Is(err, redis.Nil): return application.Application{}, application.ErrNotFound case err != nil: return application.Application{}, fmt.Errorf("get application: %w", err) } record, err := UnmarshalApplication(payload) if err != nil { return application.Application{}, fmt.Errorf("get application: %w", err) } return record, nil } // GetByGame returns every application attached to gameID. func (store *ApplicationStore) GetByGame(ctx context.Context, gameID common.GameID) ([]application.Application, error) { if store == nil || store.client == nil { return nil, errors.New("get applications by game: nil store") } if ctx == nil { return nil, errors.New("get applications by game: nil context") } if err := gameID.Validate(); err != nil { return nil, fmt.Errorf("get applications by game: %w", err) } return store.loadApplicationsBySet(ctx, "get applications by game", store.keys.ApplicationsByGame(gameID), ) } // GetByUser returns every application submitted by applicantUserID. func (store *ApplicationStore) GetByUser(ctx context.Context, applicantUserID string) ([]application.Application, error) { if store == nil || store.client == nil { return nil, errors.New("get applications by user: nil store") } if ctx == nil { return nil, errors.New("get applications by user: nil context") } trimmed := strings.TrimSpace(applicantUserID) if trimmed == "" { return nil, fmt.Errorf("get applications by user: applicant user id must not be empty") } return store.loadApplicationsBySet(ctx, "get applications by user", store.keys.ApplicationsByUser(trimmed), ) } // loadApplicationsBySet materializes applications whose ids are stored in // setKey. Stale set members (primary key removed out-of-band) are dropped // silently, mirroring gamestore.GetByStatus. func (store *ApplicationStore) loadApplicationsBySet(ctx context.Context, operation, setKey string) ([]application.Application, error) { members, err := store.client.SMembers(ctx, setKey).Result() if err != nil { return nil, fmt.Errorf("%s: %w", operation, err) } if len(members) == 0 { return nil, nil } primaryKeys := make([]string, len(members)) for index, member := range members { primaryKeys[index] = store.keys.Application(common.ApplicationID(member)) } payloads, err := store.client.MGet(ctx, primaryKeys...).Result() if err != nil { return nil, fmt.Errorf("%s: %w", operation, err) } records := make([]application.Application, 0, len(payloads)) for _, entry := range payloads { if entry == nil { continue } raw, ok := entry.(string) if !ok { return nil, fmt.Errorf("%s: unexpected payload type %T", operation, entry) } record, err := UnmarshalApplication([]byte(raw)) if err != nil { return nil, fmt.Errorf("%s: %w", operation, err) } records = append(records, record) } return records, nil } // UpdateStatus applies one status transition in a compare-and-swap fashion. func (store *ApplicationStore) UpdateStatus(ctx context.Context, input ports.UpdateApplicationStatusInput) error { if store == nil || store.client == nil { return errors.New("update application status: nil store") } if ctx == nil { return errors.New("update application status: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("update application status: %w", err) } if err := application.Transition(input.ExpectedFrom, input.To); err != nil { return err } primaryKey := store.keys.Application(input.ApplicationID) at := input.At.UTC() watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error { payload, getErr := tx.Get(ctx, primaryKey).Bytes() switch { case errors.Is(getErr, redis.Nil): return application.ErrNotFound case getErr != nil: return fmt.Errorf("update application status: %w", getErr) } existing, err := UnmarshalApplication(payload) if err != nil { return fmt.Errorf("update application status: %w", err) } if existing.Status != input.ExpectedFrom { return fmt.Errorf("update application status: %w", application.ErrConflict) } existing.Status = input.To decidedAt := at existing.DecidedAt = &decidedAt encoded, err := MarshalApplication(existing) if err != nil { return fmt.Errorf("update application status: %w", err) } activeLookupKey := store.keys.UserGameApplication(existing.ApplicantUserID, existing.GameID) _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, primaryKey, encoded, ApplicationRecordTTL) if input.To == application.StatusRejected { pipe.Del(ctx, activeLookupKey) } return nil }) return err }, primaryKey) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("update application status: %w", application.ErrConflict) case watchErr != nil: return watchErr default: return nil } } // Ensure ApplicationStore satisfies the ports.ApplicationStore interface // at compile time. var _ ports.ApplicationStore = (*ApplicationStore)(nil)