// Package racenamedir implements the PostgreSQL-backed adapter for // `ports.RaceNameDirectory`. // // One row in the `race_names` table backs one of three bindings on a // canonical key: a registered name (one per canonical_key, immutable // holder), a per-game reservation, or a pending_registration created by a // capable game finish. The composite primary key (canonical_key, game_id) // matches the existing two-tier semantics, where the same user may hold // reservations on the same canonical key across multiple active games. // Registered rows store game_id = '' and keep the source game in // source_game_id, so the per-canonical uniqueness rule reduces to a // partial UNIQUE index. Cross-user collisions on canonical_key are // arbitrated by serialising every write transaction with // pg_advisory_xact_lock(hashtextextended(canonical_key, 0)). // // PG_PLAN.md ยง6B introduces this adapter; see // `galaxy/lobby/docs/postgres-migration.md` for the full decision record. package racenamedir 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/racename" "galaxy/lobby/internal/ports" pg "github.com/go-jet/jet/v2/postgres" ) // Binding kind values stored verbatim in `race_names.binding_kind`. They // equal the corresponding `ports.Kind*` constants so adapter methods can // surface them at the port boundary without translation. const ( bindingRegistered = ports.KindRegistered // "registered" bindingReservation = ports.KindReservation // "reservation" bindingPending = ports.KindPendingRegistration // "pending_registration" ) // registeredGameID is the sentinel value stored in the (canonical_key, // game_id) primary key for binding_kind = 'registered' rows. The actual // source game is kept in source_game_id; the empty `game_id` keeps // registered rows distinct from per-game reservations under the same // canonical_key. const registeredGameID = "" // Config configures one PostgreSQL-backed Race Name Directory adapter. // The adapter does not own the underlying *sql.DB lifecycle: the caller // (typically the service runtime) opens, instruments, migrates, and // closes the pool. type Config struct { // DB is the connection pool the directory uses for every query. DB *sql.DB // OperationTimeout bounds one operation. Read-only methods derive a // single context from it; write methods reuse the same bound across // the BEGIN ... COMMIT transaction. OperationTimeout time.Duration // Policy supplies the canonical-key derivation and ValidateName // rules; the adapter owns no race-name policy of its own. Policy *racename.Policy // Clock supplies wall-clock time used to stamp reserved_at_ms, // registered_at_ms and the cutoff passed to // ExpirePendingRegistrations.Defaults to time.Now when nil. Clock func() time.Time } // Directory persists Race Name Directory bindings in PostgreSQL. type Directory struct { db *sql.DB operationTimeout time.Duration policy *racename.Policy nowFn func() time.Time } // New constructs one PostgreSQL-backed Race Name Directory from cfg. func New(cfg Config) (*Directory, error) { if cfg.DB == nil { return nil, errors.New("new postgres race name directory: db must not be nil") } if cfg.OperationTimeout <= 0 { return nil, errors.New("new postgres race name directory: operation timeout must be positive") } if cfg.Policy == nil { return nil, errors.New("new postgres race name directory: policy must not be nil") } nowFn := cfg.Clock if nowFn == nil { nowFn = time.Now } return &Directory{ db: cfg.DB, operationTimeout: cfg.OperationTimeout, policy: cfg.Policy, nowFn: nowFn, }, nil } // Canonicalize returns the canonical uniqueness key for raceName as a // plain string. Validation failures map to ports.ErrInvalidName. func (directory *Directory) Canonicalize(raceName string) (string, error) { if directory == nil { return "", errors.New("canonicalize race name: nil directory") } canonical, err := directory.policy.Canonicalize(raceName) if err != nil { return "", fmt.Errorf("canonicalize race name: %w", ports.ErrInvalidName) } return canonical.String(), nil } // Check reports whether raceName is taken for actorUserID. A concurrent // Reserve may race against the result; service code that needs atomicity // must rely on Reserve returning ErrNameTaken instead of pre-checking. func (directory *Directory) Check( ctx context.Context, raceName, actorUserID string, ) (ports.Availability, error) { if directory == nil { return ports.Availability{}, errors.New("check race name: nil directory") } actor, err := normalizeNonEmpty(actorUserID, "check race name", "actor user id") if err != nil { return ports.Availability{}, err } canonical, err := directory.policy.Canonicalize(raceName) if err != nil { return ports.Availability{}, fmt.Errorf("check race name: %w", ports.ErrInvalidName) } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "check race name", directory.operationTimeout) if err != nil { return ports.Availability{}, err } defer cancel() stmt := pg.SELECT( pgtable.RaceNames.HolderUserID, pgtable.RaceNames.BindingKind, ).FROM(pgtable.RaceNames). WHERE(pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String()))) query, args := stmt.Sql() rows, err := directory.db.QueryContext(operationCtx, query, args...) if err != nil { return ports.Availability{}, fmt.Errorf("check race name: %w", err) } defer rows.Close() var ( bestHolder string bestKind string bestRank int ) for rows.Next() { var holder, kind string if err := rows.Scan(&holder, &kind); err != nil { return ports.Availability{}, fmt.Errorf("check race name: scan: %w", err) } rank := bindingPriority(kind) if bestKind == "" || rank < bestRank { bestHolder = holder bestKind = kind bestRank = rank } } if err := rows.Err(); err != nil { return ports.Availability{}, fmt.Errorf("check race name: %w", err) } if bestKind == "" { return ports.Availability{}, nil } return ports.Availability{ Taken: bestHolder != actor, HolderUserID: bestHolder, Kind: bestKind, }, nil } // Reserve claims raceName for (gameID, userID). Repeating the call with // the same tuple is idempotent; cross-user collisions on the canonical // key surface ports.ErrNameTaken. func (directory *Directory) Reserve( ctx context.Context, gameID, userID, raceName string, ) error { if directory == nil { return errors.New("reserve race name: nil directory") } game, err := normalizeGameID(gameID, "reserve race name") if err != nil { return err } user, err := normalizeNonEmpty(userID, "reserve race name", "user id") if err != nil { return err } displayName, err := racename.ValidateName(raceName) if err != nil { return fmt.Errorf("reserve race name: %w", ports.ErrInvalidName) } canonical, err := directory.policy.Canonical(displayName) if err != nil { return fmt.Errorf("reserve race name: %w", ports.ErrInvalidName) } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "reserve race name", directory.operationTimeout) if err != nil { return err } defer cancel() reservedAtMs := directory.nowFn().UTC().UnixMilli() return directory.withCanonicalLock(operationCtx, canonical, "reserve race name", func(tx *sql.Tx) error { existing, err := loadByCanonicalTx(operationCtx, tx, canonical) if err != nil { return fmt.Errorf("reserve race name: %w", err) } for _, r := range existing { if r.holderUserID != user { return ports.ErrNameTaken } } // Same-user idempotency: a row already at this PK means the // holder already binds this canonical for this game (whether as // reservation or pending_registration). Skip the INSERT. for _, r := range existing { if r.gameID == game.String() { return nil } } stmt := pgtable.RaceNames.INSERT( pgtable.RaceNames.CanonicalKey, pgtable.RaceNames.GameID, pgtable.RaceNames.HolderUserID, pgtable.RaceNames.RaceName, pgtable.RaceNames.BindingKind, pgtable.RaceNames.SourceGameID, pgtable.RaceNames.ReservedAtMs, ).VALUES( canonical.String(), game.String(), user, displayName, bindingReservation, game.String(), reservedAtMs, ) query, args := stmt.Sql() if _, err := tx.ExecContext(operationCtx, query, args...); err != nil { return fmt.Errorf("reserve race name: %w", err) } return nil }) } // ReleaseReservation removes the reservation held by userID for raceName // in gameID. Missing reservation, mismatched holder, and invalid raceName // all resolve to a silent no-op per the port contract. func (directory *Directory) ReleaseReservation( ctx context.Context, gameID, userID, raceName string, ) error { if directory == nil { return errors.New("release race name reservation: nil directory") } game, err := normalizeGameID(gameID, "release race name reservation") if err != nil { return err } user, err := normalizeNonEmpty(userID, "release race name reservation", "user id") if err != nil { return err } canonical, err := directory.policy.Canonicalize(raceName) if err != nil { // Invalid name is a silent no-op per the port contract. if ctxErr := contextAlive(ctx); ctxErr != nil { return ctxErr } return nil } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "release race name reservation", directory.operationTimeout) if err != nil { return err } defer cancel() return directory.withCanonicalLock(operationCtx, canonical, "release race name reservation", func(tx *sql.Tx) error { stmt := pgtable.RaceNames.DELETE().WHERE(pg.AND( pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String())), pgtable.RaceNames.GameID.EQ(pg.String(game.String())), pgtable.RaceNames.HolderUserID.EQ(pg.String(user)), )) query, args := stmt.Sql() if _, err := tx.ExecContext(operationCtx, query, args...); err != nil { return fmt.Errorf("release race name reservation: %w", err) } return nil }) } // MarkPendingRegistration promotes the reservation for (gameID, userID) // on raceName's canonical key to pending_registration status. func (directory *Directory) MarkPendingRegistration( ctx context.Context, gameID, userID, raceName string, eligibleUntil time.Time, ) error { if directory == nil { return errors.New("mark pending race name registration: nil directory") } game, err := normalizeGameID(gameID, "mark pending race name registration") if err != nil { return err } user, err := normalizeNonEmpty(userID, "mark pending race name registration", "user id") if err != nil { return err } if eligibleUntil.IsZero() { return fmt.Errorf("mark pending race name registration: eligible until must be set") } displayName, err := racename.ValidateName(raceName) if err != nil { return fmt.Errorf("mark pending race name registration: %w", ports.ErrInvalidName) } canonical, err := directory.policy.Canonical(displayName) if err != nil { return fmt.Errorf("mark pending race name registration: %w", ports.ErrInvalidName) } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "mark pending race name registration", directory.operationTimeout) if err != nil { return err } defer cancel() eligibleUntilMs := eligibleUntil.UTC().UnixMilli() return directory.withCanonicalLock(operationCtx, canonical, "mark pending race name registration", func(tx *sql.Tx) error { existing, err := loadByCanonicalTx(operationCtx, tx, canonical) if err != nil { return fmt.Errorf("mark pending race name registration: %w", err) } var target *raceNameRow for index, candidate := range existing { if candidate.gameID == game.String() && candidate.holderUserID == user { target = &existing[index] break } } if target == nil { return fmt.Errorf("mark pending race name registration: reservation missing for game %q user %q", game, user) } switch target.bindingKind { case bindingPending: if target.eligibleUntilMs == nil || *target.eligibleUntilMs != eligibleUntilMs { return fmt.Errorf("mark pending race name registration: %w", ports.ErrInvalidName) } return nil case bindingReservation: // promote default: return fmt.Errorf("mark pending race name registration: reservation missing for game %q user %q", game, user) } stmt := pgtable.RaceNames.UPDATE( pgtable.RaceNames.BindingKind, pgtable.RaceNames.RaceName, pgtable.RaceNames.EligibleUntilMs, ).SET( bindingPending, displayName, eligibleUntilMs, ).WHERE(pg.AND( pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String())), pgtable.RaceNames.GameID.EQ(pg.String(game.String())), )) query, args := stmt.Sql() if _, err := tx.ExecContext(operationCtx, query, args...); err != nil { return fmt.Errorf("mark pending race name registration: %w", err) } return nil }) } // ExpirePendingRegistrations releases every pending registration whose // eligible_until_ms is at or before now. Each released entry is returned // so callers can emit telemetry; running the method twice over the same // state returns an empty slice the second time. func (directory *Directory) ExpirePendingRegistrations( ctx context.Context, now time.Time, ) ([]ports.ExpiredPending, error) { if directory == nil { return nil, errors.New("expire pending race name registrations: nil directory") } cutoff := now.UTC().UnixMilli() scanCtx, cancel, err := sqlx.WithTimeout(ctx, "expire pending race name registrations", directory.operationTimeout) if err != nil { return nil, err } defer cancel() stmt := pg.SELECT( pgtable.RaceNames.CanonicalKey, pgtable.RaceNames.GameID, ).FROM(pgtable.RaceNames).WHERE(pg.AND( pgtable.RaceNames.BindingKind.EQ(pg.String(bindingPending)), pgtable.RaceNames.EligibleUntilMs.LT_EQ(pg.Int(cutoff)), )) query, args := stmt.Sql() rows, err := directory.db.QueryContext(scanCtx, query, args...) if err != nil { return nil, fmt.Errorf("expire pending race name registrations: %w", err) } type candidate struct { canonical racename.CanonicalKey gameID string } var candidates []candidate for rows.Next() { var canonicalKey, gameID string if err := rows.Scan(&canonicalKey, &gameID); err != nil { rows.Close() return nil, fmt.Errorf("expire pending race name registrations: scan: %w", err) } candidates = append(candidates, candidate{ canonical: racename.CanonicalKey(canonicalKey), gameID: gameID, }) } if err := rows.Close(); err != nil { return nil, fmt.Errorf("expire pending race name registrations: %w", err) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("expire pending race name registrations: %w", err) } if len(candidates) == 0 { return nil, nil } expired := make([]ports.ExpiredPending, 0, len(candidates)) for _, cand := range candidates { entry, released, err := directory.expireOne(ctx, cand.canonical, cand.gameID, cutoff) if err != nil { return nil, fmt.Errorf("expire pending race name registrations: %w", err) } if released { expired = append(expired, entry) } } return expired, nil } // expireOne re-reads the candidate row under an advisory lock, deletes it // when still pending and at-or-before cutoff, and returns the matching // ExpiredPending entry. Concurrent transitions (Register, ReleaseReservation, // or a refreshed eligible_until_ms) cause expireOne to skip the row. func (directory *Directory) expireOne( ctx context.Context, canonical racename.CanonicalKey, gameID string, cutoff int64, ) (ports.ExpiredPending, bool, error) { operationCtx, cancel, err := sqlx.WithTimeout(ctx, "expire pending race name registrations", directory.operationTimeout) if err != nil { return ports.ExpiredPending{}, false, err } defer cancel() var ( entry ports.ExpiredPending released bool ) err = directory.withCanonicalLock(operationCtx, canonical, "expire pending race name registrations", func(tx *sql.Tx) error { row, found, err := loadOneByPKTx(operationCtx, tx, canonical, gameID) if err != nil { return err } if !found { return nil } if row.bindingKind != bindingPending { return nil } if row.eligibleUntilMs == nil || *row.eligibleUntilMs > cutoff { return nil } stmt := pgtable.RaceNames.DELETE().WHERE(pg.AND( pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String())), pgtable.RaceNames.GameID.EQ(pg.String(gameID)), )) query, args := stmt.Sql() if _, err := tx.ExecContext(operationCtx, query, args...); err != nil { return err } entry = ports.ExpiredPending{ CanonicalKey: canonical.String(), RaceName: row.raceName, GameID: gameID, UserID: row.holderUserID, EligibleUntilMs: *row.eligibleUntilMs, } released = true return nil }) if err != nil { return ports.ExpiredPending{}, false, err } return entry, released, nil } // Register converts the pending registration identified by (gameID, // userID) on raceName's canonical key into a registered race name. func (directory *Directory) Register( ctx context.Context, gameID, userID, raceName string, ) error { if directory == nil { return errors.New("register race name: nil directory") } game, err := normalizeGameID(gameID, "register race name") if err != nil { return err } user, err := normalizeNonEmpty(userID, "register race name", "user id") if err != nil { return err } displayName, err := racename.ValidateName(raceName) if err != nil { return fmt.Errorf("register race name: %w", ports.ErrInvalidName) } canonical, err := directory.policy.Canonical(displayName) if err != nil { return fmt.Errorf("register race name: %w", ports.ErrInvalidName) } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "register race name", directory.operationTimeout) if err != nil { return err } defer cancel() nowMs := directory.nowFn().UTC().UnixMilli() return directory.withCanonicalLock(operationCtx, canonical, "register race name", func(tx *sql.Tx) error { existing, err := loadByCanonicalTx(operationCtx, tx, canonical) if err != nil { return fmt.Errorf("register race name: %w", err) } // Already registered: idempotent for the same holder, ErrNameTaken // for any other user. for _, r := range existing { if r.bindingKind == bindingRegistered { if r.holderUserID == user { return nil } return ports.ErrNameTaken } } var pending *raceNameRow for index, r := range existing { if r.gameID == game.String() && r.holderUserID == user && r.bindingKind == bindingPending { pending = &existing[index] break } } if pending == nil { return ports.ErrPendingMissing } if pending.eligibleUntilMs == nil || *pending.eligibleUntilMs <= nowMs { return ports.ErrPendingExpired } del := pgtable.RaceNames.DELETE().WHERE(pg.AND( pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String())), pgtable.RaceNames.GameID.EQ(pg.String(game.String())), )) delQuery, delArgs := del.Sql() if _, err := tx.ExecContext(operationCtx, delQuery, delArgs...); err != nil { return fmt.Errorf("register race name: %w", err) } ins := pgtable.RaceNames.INSERT( pgtable.RaceNames.CanonicalKey, pgtable.RaceNames.GameID, pgtable.RaceNames.HolderUserID, pgtable.RaceNames.RaceName, pgtable.RaceNames.BindingKind, pgtable.RaceNames.SourceGameID, pgtable.RaceNames.ReservedAtMs, pgtable.RaceNames.RegisteredAtMs, ).VALUES( canonical.String(), registeredGameID, user, pending.raceName, bindingRegistered, game.String(), pending.reservedAtMs, nowMs, ) insQuery, insArgs := ins.Sql() if _, err := tx.ExecContext(operationCtx, insQuery, insArgs...); err != nil { return fmt.Errorf("register race name: %w", err) } return nil }) } // ListRegistered returns every registered race name owned by userID. func (directory *Directory) ListRegistered( ctx context.Context, userID string, ) ([]ports.RegisteredName, error) { if directory == nil { return nil, errors.New("list registered race names: nil directory") } user, err := normalizeNonEmpty(userID, "list registered race names", "user id") if err != nil { return nil, err } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list registered race names", directory.operationTimeout) if err != nil { return nil, err } defer cancel() stmt := pg.SELECT( pgtable.RaceNames.CanonicalKey, pgtable.RaceNames.RaceName, pgtable.RaceNames.SourceGameID, pgtable.RaceNames.RegisteredAtMs, ).FROM(pgtable.RaceNames).WHERE(pg.AND( pgtable.RaceNames.HolderUserID.EQ(pg.String(user)), pgtable.RaceNames.BindingKind.EQ(pg.String(bindingRegistered)), )) query, args := stmt.Sql() rows, err := directory.db.QueryContext(operationCtx, query, args...) if err != nil { return nil, fmt.Errorf("list registered race names: %w", err) } defer rows.Close() var out []ports.RegisteredName for rows.Next() { var ( canonical string raceName string sourceGameID string regAt sql.NullInt64 ) if err := rows.Scan(&canonical, &raceName, &sourceGameID, ®At); err != nil { return nil, fmt.Errorf("list registered race names: scan: %w", err) } var regAtMs int64 if regAt.Valid { regAtMs = regAt.Int64 } out = append(out, ports.RegisteredName{ CanonicalKey: canonical, RaceName: raceName, SourceGameID: sourceGameID, RegisteredAtMs: regAtMs, }) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list registered race names: %w", err) } return out, nil } // ListReservations returns every active reservation owned by userID // whose status has not yet been promoted to pending_registration. func (directory *Directory) ListReservations( ctx context.Context, userID string, ) ([]ports.Reservation, error) { if directory == nil { return nil, errors.New("list race name reservations: nil directory") } user, err := normalizeNonEmpty(userID, "list race name reservations", "user id") if err != nil { return nil, err } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list race name reservations", directory.operationTimeout) if err != nil { return nil, err } defer cancel() stmt := pg.SELECT( pgtable.RaceNames.CanonicalKey, pgtable.RaceNames.RaceName, pgtable.RaceNames.GameID, pgtable.RaceNames.ReservedAtMs, ).FROM(pgtable.RaceNames).WHERE(pg.AND( pgtable.RaceNames.HolderUserID.EQ(pg.String(user)), pgtable.RaceNames.BindingKind.EQ(pg.String(bindingReservation)), )) query, args := stmt.Sql() rows, err := directory.db.QueryContext(operationCtx, query, args...) if err != nil { return nil, fmt.Errorf("list race name reservations: %w", err) } defer rows.Close() var out []ports.Reservation for rows.Next() { var ( canonical string raceName string gameID string reservedAtMs int64 ) if err := rows.Scan(&canonical, &raceName, &gameID, &reservedAtMs); err != nil { return nil, fmt.Errorf("list race name reservations: scan: %w", err) } out = append(out, ports.Reservation{ CanonicalKey: canonical, RaceName: raceName, GameID: gameID, ReservedAtMs: reservedAtMs, }) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list race name reservations: %w", err) } return out, nil } // ListPendingRegistrations returns every pending registration owned by // userID. func (directory *Directory) ListPendingRegistrations( ctx context.Context, userID string, ) ([]ports.PendingRegistration, error) { if directory == nil { return nil, errors.New("list pending race name registrations: nil directory") } user, err := normalizeNonEmpty(userID, "list pending race name registrations", "user id") if err != nil { return nil, err } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list pending race name registrations", directory.operationTimeout) if err != nil { return nil, err } defer cancel() stmt := pg.SELECT( pgtable.RaceNames.CanonicalKey, pgtable.RaceNames.RaceName, pgtable.RaceNames.GameID, pgtable.RaceNames.ReservedAtMs, pgtable.RaceNames.EligibleUntilMs, ).FROM(pgtable.RaceNames).WHERE(pg.AND( pgtable.RaceNames.HolderUserID.EQ(pg.String(user)), pgtable.RaceNames.BindingKind.EQ(pg.String(bindingPending)), )) query, args := stmt.Sql() rows, err := directory.db.QueryContext(operationCtx, query, args...) if err != nil { return nil, fmt.Errorf("list pending race name registrations: %w", err) } defer rows.Close() var out []ports.PendingRegistration for rows.Next() { var ( canonical string raceName string gameID string reservedAtMs int64 eligibleAt sql.NullInt64 ) if err := rows.Scan(&canonical, &raceName, &gameID, &reservedAtMs, &eligibleAt); err != nil { return nil, fmt.Errorf("list pending race name registrations: scan: %w", err) } var eligibleAtMs int64 if eligibleAt.Valid { eligibleAtMs = eligibleAt.Int64 } out = append(out, ports.PendingRegistration{ CanonicalKey: canonical, RaceName: raceName, GameID: gameID, ReservedAtMs: reservedAtMs, EligibleUntilMs: eligibleAtMs, }) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list pending race name registrations: %w", err) } return out, nil } // ReleaseAllByUser atomically clears every binding owned by userID. The // user-lifecycle consumer invokes the method on permanent_blocked and // deleted events, so concurrent writes by the same user cannot race // (the user is permanently disabled by the time the cascade runs). func (directory *Directory) ReleaseAllByUser( ctx context.Context, userID string, ) error { if directory == nil { return errors.New("release all race names by user: nil directory") } user, err := normalizeNonEmpty(userID, "release all race names by user", "user id") if err != nil { return err } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "release all race names by user", directory.operationTimeout) if err != nil { return err } defer cancel() stmt := pgtable.RaceNames.DELETE(). WHERE(pgtable.RaceNames.HolderUserID.EQ(pg.String(user))) query, args := stmt.Sql() if _, err := directory.db.ExecContext(operationCtx, query, args...); err != nil { return fmt.Errorf("release all race names by user: %w", err) } return nil } // raceNameRow mirrors one race_names row in adapter-internal code so // transactional methods can read the row state under an advisory lock and // branch without re-deriving column ordering at every call site. type raceNameRow struct { canonicalKey string gameID string holderUserID string raceName string bindingKind string sourceGameID string reservedAtMs int64 eligibleUntilMs *int64 registeredAtMs *int64 } // raceNameAllColumns is the column list scanRow expects in order. var raceNameAllColumns = pg.ColumnList{ pgtable.RaceNames.CanonicalKey, pgtable.RaceNames.GameID, pgtable.RaceNames.HolderUserID, pgtable.RaceNames.RaceName, pgtable.RaceNames.BindingKind, pgtable.RaceNames.SourceGameID, pgtable.RaceNames.ReservedAtMs, pgtable.RaceNames.EligibleUntilMs, pgtable.RaceNames.RegisteredAtMs, } func scanRow(scanner interface{ Scan(...any) error }) (raceNameRow, error) { var ( row raceNameRow eligible sql.NullInt64 registered sql.NullInt64 ) if err := scanner.Scan( &row.canonicalKey, &row.gameID, &row.holderUserID, &row.raceName, &row.bindingKind, &row.sourceGameID, &row.reservedAtMs, &eligible, ®istered, ); err != nil { return raceNameRow{}, err } if eligible.Valid { v := eligible.Int64 row.eligibleUntilMs = &v } if registered.Valid { v := registered.Int64 row.registeredAtMs = &v } return row, nil } // loadByCanonicalTx returns every race_names row for canonical_key. func loadByCanonicalTx( ctx context.Context, tx *sql.Tx, canonical racename.CanonicalKey, ) ([]raceNameRow, error) { stmt := pg.SELECT(raceNameAllColumns). FROM(pgtable.RaceNames). WHERE(pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String()))) query, args := stmt.Sql() rows, err := tx.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var out []raceNameRow for rows.Next() { row, err := scanRow(rows) if err != nil { return nil, err } out = append(out, row) } if err := rows.Err(); err != nil { return nil, err } return out, nil } // loadOneByPKTx loads one row by its (canonical_key, game_id) primary // key. The returned bool is false when no row matches. func loadOneByPKTx( ctx context.Context, tx *sql.Tx, canonical racename.CanonicalKey, gameID string, ) (raceNameRow, bool, error) { stmt := pg.SELECT(raceNameAllColumns). FROM(pgtable.RaceNames). WHERE(pg.AND( pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String())), pgtable.RaceNames.GameID.EQ(pg.String(gameID)), )) query, args := stmt.Sql() row := tx.QueryRowContext(ctx, query, args...) out, err := scanRow(row) if sqlx.IsNoRows(err) { return raceNameRow{}, false, nil } if err != nil { return raceNameRow{}, false, err } return out, true, nil } // withCanonicalLock runs op inside a transaction guarded by // pg_advisory_xact_lock(hashtextextended(canonical_key, 0)). The lock is // released when the transaction terminates (commit or rollback). func (directory *Directory) withCanonicalLock( ctx context.Context, canonical racename.CanonicalKey, operation string, op func(tx *sql.Tx) error, ) error { tx, err := directory.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("%s: begin tx: %w", operation, err) } committed := false defer func() { if !committed { _ = tx.Rollback() } }() if _, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", canonical.String()); err != nil { return fmt.Errorf("%s: advisory lock: %w", operation, err) } if err := op(tx); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("%s: commit: %w", operation, err) } committed = true return nil } // bindingPriority maps a binding_kind value to a priority rank where // lower numbers mean a stronger binding (registered > pending > reservation). func bindingPriority(kind string) int { switch kind { case bindingRegistered: return 1 case bindingPending: return 2 case bindingReservation: return 3 default: return 99 } } // normalizeNonEmpty trims value and rejects empty results with an error // that mentions operation and field for traceability. func normalizeNonEmpty(value, operation, field string) (string, error) { trimmed := strings.TrimSpace(value) if trimmed == "" { return "", fmt.Errorf("%s: %s must not be empty", operation, field) } return trimmed, nil } // normalizeGameID trims value and converts it into a typed common.GameID, // rejecting empty input through normalizeNonEmpty. func normalizeGameID(value, operation string) (common.GameID, error) { trimmed, err := normalizeNonEmpty(value, operation, "game id") if err != nil { return "", err } return common.GameID(trimmed), nil } // contextAlive surfaces ctx cancellation through a stable error path even // when the calling method is a defensive no-op for invalid input. The // shared port test suite expects every method to honour cancellation // regardless of preceding validation. func contextAlive(ctx context.Context) error { if ctx == nil { return errors.New("nil context") } if err := ctx.Err(); err != nil { return err } return nil } // Ensure *Directory satisfies the ports.RaceNameDirectory interface at // compile time. var _ ports.RaceNameDirectory = (*Directory)(nil)