Files
galaxy-game/user/internal/adapters/postgres/userstore/store.go
T
2026-04-26 20:34:39 +02:00

139 lines
5.1 KiB
Go

// Package userstore implements the PostgreSQL-backed source-of-truth
// persistence used by User Service.
//
// The package owns the on-disk shape of the `user` schema (defined in
// `galaxy/user/internal/adapters/postgres/migrations`) and translates the
// schema-agnostic ports defined under `galaxy/user/internal/ports` into
// concrete `database/sql` operations driven by the pgx driver. Atomic
// composite operations (auth-directory, entitlement-lifecycle, policy-
// lifecycle) execute inside explicit `BEGIN … COMMIT` transactions with
// `SELECT … FOR UPDATE` locks on the rows they mutate.
//
// Stage 3 of `PG_PLAN.md` migrates User Service away from Redis-backed
// durable state. Two Redis Streams (`user:domain_events`,
// `user:lifecycle_events`) remain on Redis for event publication; the
// store is no longer aware of them.
package userstore
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"galaxy/user/internal/ports"
)
// Config configures one PostgreSQL-backed user store instance. The store does
// not own the underlying *sql.DB lifecycle: the caller (typically the
// service runtime) opens, instruments, migrates, and closes the pool. The
// store only borrows the pool and bounds individual round trips with
// OperationTimeout.
type Config struct {
// DB stores the connection pool the store uses for every query.
DB *sql.DB
// OperationTimeout bounds one round trip. The store creates a derived
// context for each operation so callers cannot starve the pool with an
// unbounded ctx. Multi-statement transactions inherit this bound for the
// whole BEGIN … COMMIT span.
OperationTimeout time.Duration
}
// Store persists auth-facing user state in PostgreSQL and exposes the narrow
// atomic auth-facing mutation boundary plus selected entity-store interfaces
// through the same accessor methods (`Accounts`, `BlockedEmails`,
// `EntitlementSnapshots`, `EntitlementHistory`, `EntitlementLifecycle`,
// `Sanctions`, `Limits`, `PolicyLifecycle`) that the previous Redis-backed
// store provided. This keeps the runtime wiring identical between the two
// implementations.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed user store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres user store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres user store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// Close is a no-op for the PostgreSQL-backed store: the connection pool is
// owned by the caller (the runtime) and closed once the runtime shuts down.
// The accessor remains so the Redis-store contract can be preserved
// transparently in the runtime wiring.
func (store *Store) Close() error {
return nil
}
// Ping verifies that the configured PostgreSQL backend is reachable. It runs
// `db.PingContext` under the configured operation timeout.
func (store *Store) Ping(ctx context.Context) error {
operationCtx, cancel, err := withTimeout(ctx, "ping postgres user store", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
if err := store.db.PingContext(operationCtx); err != nil {
return fmt.Errorf("ping postgres user store: %w", err)
}
return nil
}
// withTx runs fn inside a BEGIN … COMMIT transaction bounded by the store's
// operation timeout. It rolls back on any error or panic and returns whatever
// fn returned. The transaction uses the default isolation level
// (`READ COMMITTED`); per-row locking is achieved through `SELECT … FOR
// UPDATE` issued inside fn.
func (store *Store) withTx(ctx context.Context, operation string, fn func(ctx context.Context, tx *sql.Tx) error) error {
operationCtx, cancel, err := withTimeout(ctx, operation, store.operationTimeout)
if err != nil {
return err
}
defer cancel()
tx, err := store.db.BeginTx(operationCtx, nil)
if err != nil {
return fmt.Errorf("%s: begin: %w", operation, err)
}
if err := fn(operationCtx, tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("%s: commit: %w", operation, err)
}
return nil
}
// operationContext bounds one read or write that does not need a transaction
// envelope (single statement). It mirrors store.withTx for non-transactional
// callers.
func (store *Store) operationContext(ctx context.Context, operation string) (context.Context, context.CancelFunc, error) {
return withTimeout(ctx, operation, store.operationTimeout)
}
// Store directly satisfies the user-account port (its primary entity) and the
// composite auth-directory port. The remaining ports
// (BlockedEmailStore, entitlement-*, sanction-*, limit-*, user-list) are
// implemented by adapter types declared in their respective files; those
// adapters are obtained through Accounts(), BlockedEmails(),
// EntitlementSnapshots(), EntitlementHistory(), EntitlementLifecycle(),
// Sanctions(), Limits(), PolicyLifecycle(), and UserList() accessors.
var (
_ ports.AuthDirectoryStore = (*Store)(nil)
_ ports.UserAccountStore = (*Store)(nil)
)