feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
@@ -0,0 +1,375 @@
package userstore
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
pgtable "galaxy/user/internal/adapters/postgres/jet/user/table"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// SQL constraint names declared in 00001_init.sql; referenced from error
// translation so we can disambiguate UNIQUE violations on (email) versus
// (user_name).
const (
accountsEmailUniqueConstraint = "accounts_email_unique"
accountsUserNameUniqueConstraint = "accounts_user_name_unique"
)
// accountSelectColumns is the canonical SELECT list for accounts, matching
// scanAccountRow's column order.
var accountSelectColumns = pg.ColumnList{
pgtable.Accounts.UserID,
pgtable.Accounts.Email,
pgtable.Accounts.UserName,
pgtable.Accounts.DisplayName,
pgtable.Accounts.PreferredLanguage,
pgtable.Accounts.TimeZone,
pgtable.Accounts.DeclaredCountry,
pgtable.Accounts.CreatedAt,
pgtable.Accounts.UpdatedAt,
pgtable.Accounts.DeletedAt,
}
// Create stores one new account record. Email and user-name uniqueness are
// enforced by the schema; conflicts on those columns surface as
// ports.ErrConflict (with ports.ErrUserNameConflict for the dedicated
// user-name index).
func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("create account in postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "create account in postgres")
if err != nil {
return err
}
defer cancel()
if err := insertAccount(operationCtx, store.db, input.Account); err != nil {
return err
}
return nil
}
// insertAccount runs one INSERT against accounts using the supplied Queryer
// (a *sql.DB or a *sql.Tx). It centralises the column list and error
// translation used by Create and EnsureByEmail.
func insertAccount(ctx context.Context, q queryer, record account.UserAccount) error {
stmt := pgtable.Accounts.INSERT(
pgtable.Accounts.UserID,
pgtable.Accounts.Email,
pgtable.Accounts.UserName,
pgtable.Accounts.DisplayName,
pgtable.Accounts.PreferredLanguage,
pgtable.Accounts.TimeZone,
pgtable.Accounts.DeclaredCountry,
pgtable.Accounts.CreatedAt,
pgtable.Accounts.UpdatedAt,
pgtable.Accounts.DeletedAt,
).VALUES(
record.UserID.String(),
record.Email.String(),
record.UserName.String(),
record.DisplayName.String(),
record.PreferredLanguage.String(),
record.TimeZone.String(),
nullableCountry(record.DeclaredCountry),
record.CreatedAt.UTC(),
record.UpdatedAt.UTC(),
nullableTime(record.DeletedAt),
)
query, args := stmt.Sql()
_, err := q.ExecContext(ctx, query, args...)
if err == nil {
return nil
}
if mapped := classifyUniqueViolation(err, accountsUserNameUniqueConstraint, ports.ErrUserNameConflict); mapped != nil {
return fmt.Errorf("create account %q in postgres: %w", record.UserID, mapped)
}
if isUniqueViolation(err) {
return fmt.Errorf("create account %q in postgres: %w", record.UserID, ports.ErrConflict)
}
return fmt.Errorf("create account %q in postgres: %w", record.UserID, err)
}
// queryer is the subset of *sql.DB / *sql.Tx used by helpers that need to
// run inside an existing transaction or against the bare pool.
type queryer interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
// GetByUserID returns the stored account identified by userID.
func (store *Store) GetByUserID(ctx context.Context, userID common.UserID) (account.UserAccount, error) {
if err := userID.Validate(); err != nil {
return account.UserAccount{}, fmt.Errorf("get account by user id from postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "get account by user id from postgres")
if err != nil {
return account.UserAccount{}, err
}
defer cancel()
record, err := scanAccountByUserID(operationCtx, store.db, userID)
switch {
case errors.Is(err, ports.ErrNotFound):
return account.UserAccount{}, fmt.Errorf("get account by user id %q from postgres: %w", userID, ports.ErrNotFound)
case err != nil:
return account.UserAccount{}, fmt.Errorf("get account by user id %q from postgres: %w", userID, err)
}
return record, nil
}
// GetByEmail returns the stored account identified by the normalized e-mail
// address.
func (store *Store) GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error) {
if err := email.Validate(); err != nil {
return account.UserAccount{}, fmt.Errorf("get account by email from postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "get account by email from postgres")
if err != nil {
return account.UserAccount{}, err
}
defer cancel()
record, err := scanAccountByEmail(operationCtx, store.db, email)
switch {
case errors.Is(err, ports.ErrNotFound):
return account.UserAccount{}, fmt.Errorf("get account by email %q from postgres: %w", email, ports.ErrNotFound)
case err != nil:
return account.UserAccount{}, fmt.Errorf("get account by email %q from postgres: %w", email, err)
}
return record, nil
}
// GetByUserName returns the stored account identified by the exact stored
// user name.
func (store *Store) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) {
if err := userName.Validate(); err != nil {
return account.UserAccount{}, fmt.Errorf("get account by user name from postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "get account by user name from postgres")
if err != nil {
return account.UserAccount{}, err
}
defer cancel()
record, err := scanAccountByUserName(operationCtx, store.db, userName)
switch {
case errors.Is(err, ports.ErrNotFound):
return account.UserAccount{}, fmt.Errorf("get account by user name %q from postgres: %w", userName, ports.ErrNotFound)
case err != nil:
return account.UserAccount{}, fmt.Errorf("get account by user name %q from postgres: %w", userName, err)
}
return record, nil
}
// ExistsByUserID reports whether userID currently identifies a stored account
// that is not soft-deleted. Soft-deleted accounts are treated as non-existing
// for external callers per Stage 22.
func (store *Store) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
if err := userID.Validate(); err != nil {
return false, fmt.Errorf("exists by user id from postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "exists by user id from postgres")
if err != nil {
return false, err
}
defer cancel()
stmt := pg.SELECT(pgtable.Accounts.DeletedAt).
FROM(pgtable.Accounts).
WHERE(pgtable.Accounts.UserID.EQ(pg.String(userID.String())))
query, args := stmt.Sql()
var deletedAt *time.Time
err = store.db.QueryRowContext(operationCtx, query, args...).Scan(&deletedAt)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, fmt.Errorf("exists by user id %q from postgres: %w", userID, err)
}
return deletedAt == nil, nil
}
// Update replaces the stored account state for record.UserID. Email and
// user_name are immutable; mutation attempts return ports.ErrConflict.
// declared_country, display_name, preferred_language, time_zone, updated_at,
// and deleted_at are the columns affected.
func (store *Store) Update(ctx context.Context, record account.UserAccount) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("update account in postgres: %w", err)
}
return store.withTx(ctx, "update account in postgres", func(ctx context.Context, tx *sql.Tx) error {
current, err := scanAccountForUpdate(ctx, tx, record.UserID)
if err != nil {
if errors.Is(err, ports.ErrNotFound) {
return fmt.Errorf("update account %q in postgres: %w", record.UserID, ports.ErrNotFound)
}
return fmt.Errorf("update account %q in postgres: %w", record.UserID, err)
}
if current.Email != record.Email || current.UserName != record.UserName {
return fmt.Errorf("update account %q in postgres: %w", record.UserID, ports.ErrConflict)
}
stmt := pgtable.Accounts.UPDATE(
pgtable.Accounts.DisplayName,
pgtable.Accounts.PreferredLanguage,
pgtable.Accounts.TimeZone,
pgtable.Accounts.DeclaredCountry,
pgtable.Accounts.UpdatedAt,
pgtable.Accounts.DeletedAt,
).SET(
record.DisplayName.String(),
record.PreferredLanguage.String(),
record.TimeZone.String(),
nullableCountry(record.DeclaredCountry),
record.UpdatedAt.UTC(),
nullableTime(record.DeletedAt),
).WHERE(pgtable.Accounts.UserID.EQ(pg.String(record.UserID.String())))
query, args := stmt.Sql()
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return fmt.Errorf("update account %q in postgres: %w", record.UserID, err)
}
return nil
})
}
// scanAccountByUserID is a thin wrapper around scanAccountWhere for the
// (user_id) column so atomic flows can reuse the same scanner with FOR
// UPDATE locking semantics.
func scanAccountByUserID(ctx context.Context, q queryer, userID common.UserID) (account.UserAccount, error) {
return scanAccountWhere(ctx, q, pgtable.Accounts.UserID.EQ(pg.String(userID.String())), false)
}
func scanAccountByEmail(ctx context.Context, q queryer, email common.Email) (account.UserAccount, error) {
return scanAccountWhere(ctx, q, pgtable.Accounts.Email.EQ(pg.String(email.String())), false)
}
func scanAccountByUserName(ctx context.Context, q queryer, userName common.UserName) (account.UserAccount, error) {
return scanAccountWhere(ctx, q, pgtable.Accounts.UserName.EQ(pg.String(userName.String())), false)
}
func scanAccountForUpdate(ctx context.Context, q queryer, userID common.UserID) (account.UserAccount, error) {
return scanAccountWhere(ctx, q, pgtable.Accounts.UserID.EQ(pg.String(userID.String())), true)
}
func scanAccountForUpdateByEmail(ctx context.Context, q queryer, email common.Email) (account.UserAccount, error) {
return scanAccountWhere(ctx, q, pgtable.Accounts.Email.EQ(pg.String(email.String())), true)
}
func scanAccountWhere(ctx context.Context, q queryer, condition pg.BoolExpression, forUpdate bool) (account.UserAccount, error) {
stmt := pg.SELECT(accountSelectColumns).
FROM(pgtable.Accounts).
WHERE(condition)
if forUpdate {
stmt = stmt.FOR(pg.UPDATE())
}
query, args := stmt.Sql()
row := q.QueryRowContext(ctx, query, args...)
return scanAccountRow(row)
}
func scanAccountRow(row *sql.Row) (account.UserAccount, error) {
var (
record account.UserAccount
userID string
email string
userName string
displayName string
preferredLang string
timeZone string
declaredCountry *string
createdAt time.Time
updatedAt time.Time
deletedAt *time.Time
)
if err := row.Scan(
&userID, &email, &userName, &displayName,
&preferredLang, &timeZone, &declaredCountry,
&createdAt, &updatedAt, &deletedAt,
); err != nil {
return account.UserAccount{}, mapNotFound(err)
}
record.UserID = common.UserID(userID)
record.Email = common.Email(email)
record.UserName = common.UserName(userName)
record.DisplayName = common.DisplayName(displayName)
record.PreferredLanguage = common.LanguageTag(preferredLang)
record.TimeZone = common.TimeZoneName(timeZone)
if declaredCountry != nil {
record.DeclaredCountry = common.CountryCode(*declaredCountry)
}
record.CreatedAt = createdAt.UTC()
record.UpdatedAt = updatedAt.UTC()
record.DeletedAt = timeFromNullable(deletedAt)
return record, nil
}
// AccountStore adapts Store to the UserAccountStore port. The wrapper is
// returned by Store.Accounts() so callers that need only the narrow port
// interface remain unaware of the broader Store surface.
type AccountStore struct {
store *Store
}
// Accounts returns one adapter that exposes the user-account store port over
// Store.
func (store *Store) Accounts() *AccountStore {
if store == nil {
return nil
}
return &AccountStore{store: store}
}
// Create stores one new account record.
func (adapter *AccountStore) Create(ctx context.Context, input ports.CreateAccountInput) error {
return adapter.store.Create(ctx, input)
}
// GetByUserID returns the stored account identified by userID.
func (adapter *AccountStore) GetByUserID(ctx context.Context, userID common.UserID) (account.UserAccount, error) {
return adapter.store.GetByUserID(ctx, userID)
}
// GetByEmail returns the stored account identified by email.
func (adapter *AccountStore) GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error) {
return adapter.store.GetByEmail(ctx, email)
}
// GetByUserName returns the stored account identified by userName.
func (adapter *AccountStore) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) {
return adapter.store.GetByUserName(ctx, userName)
}
// ExistsByUserID reports whether userID currently identifies a stored
// account.
func (adapter *AccountStore) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
return adapter.store.ExistsByUserID(ctx, userID)
}
// Update replaces the stored account state for record.UserID.
func (adapter *AccountStore) Update(ctx context.Context, record account.UserAccount) error {
return adapter.store.Update(ctx, record)
}
var _ ports.UserAccountStore = (*AccountStore)(nil)