feat: runtime manager
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
// Package membershipinmem provides an in-memory ports.MembershipStore
|
||||
// implementation for service-level tests. The stub mirrors the
|
||||
// behavioural contract of the Redis adapter in redisstate: Save is
|
||||
// create-only, UpdateStatus enforces membership.Transition and the
|
||||
// ExpectedFrom CAS guard, and the index reads honour the same
|
||||
// adapter-defined ordering rules.
|
||||
//
|
||||
// Production code never wires this stub; it is test-only but exposed as
|
||||
// a regular (non _test.go) package so other service test packages can
|
||||
// import it.
|
||||
package membershipinmem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// Store is a concurrency-safe in-memory implementation of
|
||||
// ports.MembershipStore. The zero value is not usable; call NewStore
|
||||
// to construct.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
records map[common.MembershipID]membership.Membership
|
||||
}
|
||||
|
||||
// NewStore constructs one empty Store ready for use.
|
||||
func NewStore() *Store {
|
||||
return &Store{records: make(map[common.MembershipID]membership.Membership)}
|
||||
}
|
||||
|
||||
// Save persists a new active membership record. Create-only.
|
||||
func (store *Store) Save(ctx context.Context, record membership.Membership) error {
|
||||
if store == nil {
|
||||
return errors.New("save membership: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save membership: nil context")
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
if _, exists := store.records[record.MembershipID]; exists {
|
||||
return fmt.Errorf("save membership: %w", membership.ErrConflict)
|
||||
}
|
||||
store.records[record.MembershipID] = record
|
||||
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 {
|
||||
return membership.Membership{}, errors.New("get membership: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return membership.Membership{}, errors.New("get membership: nil context")
|
||||
}
|
||||
if err := membershipID.Validate(); err != nil {
|
||||
return membership.Membership{}, fmt.Errorf("get membership: %w", err)
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
record, ok := store.records[membershipID]
|
||||
if !ok {
|
||||
return membership.Membership{}, membership.ErrNotFound
|
||||
}
|
||||
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 {
|
||||
return nil, errors.New("get memberships by game: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get memberships by game: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("get memberships by game: %w", err)
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
matching := make([]membership.Membership, 0, len(store.records))
|
||||
for _, record := range store.records {
|
||||
if record.GameID == gameID {
|
||||
matching = append(matching, record)
|
||||
}
|
||||
}
|
||||
sort.Slice(matching, func(i, j int) bool {
|
||||
return matching[i].JoinedAt.Before(matching[j].JoinedAt)
|
||||
})
|
||||
return matching, nil
|
||||
}
|
||||
|
||||
// GetByUser returns every membership held by userID.
|
||||
func (store *Store) GetByUser(ctx context.Context, userID string) ([]membership.Membership, error) {
|
||||
if store == nil {
|
||||
return nil, errors.New("get memberships by user: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get memberships by user: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(userID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get memberships by user: user id must not be empty")
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
matching := make([]membership.Membership, 0, len(store.records))
|
||||
for _, record := range store.records {
|
||||
if record.UserID == trimmed {
|
||||
matching = append(matching, record)
|
||||
}
|
||||
}
|
||||
sort.Slice(matching, func(i, j int) bool {
|
||||
return matching[i].JoinedAt.Before(matching[j].JoinedAt)
|
||||
})
|
||||
return matching, nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap fashion.
|
||||
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateMembershipStatusInput) error {
|
||||
if store == nil {
|
||||
return errors.New("update membership status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update membership status: nil context")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
record, ok := store.records[input.MembershipID]
|
||||
if !ok {
|
||||
return membership.ErrNotFound
|
||||
}
|
||||
if record.Status != input.ExpectedFrom {
|
||||
return fmt.Errorf("update membership status: %w", membership.ErrConflict)
|
||||
}
|
||||
|
||||
at := input.At.UTC()
|
||||
record.Status = input.To
|
||||
record.RemovedAt = &at
|
||||
store.records[input.MembershipID] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the membership record identified by membershipID. It
|
||||
// returns membership.ErrNotFound when no record exists for the id.
|
||||
func (store *Store) Delete(ctx context.Context, membershipID common.MembershipID) error {
|
||||
if store == nil {
|
||||
return errors.New("delete membership: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("delete membership: nil context")
|
||||
}
|
||||
if err := membershipID.Validate(); err != nil {
|
||||
return fmt.Errorf("delete membership: %w", err)
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
if _, ok := store.records[membershipID]; !ok {
|
||||
return membership.ErrNotFound
|
||||
}
|
||||
delete(store.records, membershipID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.MembershipStore = (*Store)(nil)
|
||||
Reference in New Issue
Block a user