// 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) )