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)
@@ -0,0 +1,280 @@
package userstore
import (
"context"
"database/sql"
"errors"
"fmt"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/authblock"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
)
// deletedAccountBlockReasonCode is returned to auth callers when the lookup
// resolves to a soft-deleted account. Auth/Session treats this exactly like
// a regular block: it refuses to mint a session for the subject. The code is
// not a real sanction record; it lives only on the wire.
const deletedAccountBlockReasonCode common.ReasonCode = "account_deleted"
// ResolveByEmail returns the current coarse auth-facing resolution state for
// email. The decision tree, in order:
//
// 1. blocked_emails has a row for this address → blocked.
// 2. accounts has a non-soft-deleted row for this address → existing.
// 3. accounts has a soft-deleted row for this address → blocked
// (account_deleted).
// 4. otherwise → creatable.
//
// The whole sequence is a read-only path; no transaction is required.
func (store *Store) ResolveByEmail(ctx context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
if err := email.Validate(); err != nil {
return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email in postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "resolve by email in postgres")
if err != nil {
return ports.ResolveByEmailResult{}, err
}
defer cancel()
blocked, err := scanBlockedEmail(operationCtx, store.db, email, false)
switch {
case err == nil:
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindBlocked,
BlockReasonCode: blocked.ReasonCode,
}, nil
case !errors.Is(err, ports.ErrNotFound):
return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email %q in postgres: %w", email, err)
}
record, err := scanAccountByEmail(operationCtx, store.db, email)
switch {
case errors.Is(err, ports.ErrNotFound):
return ports.ResolveByEmailResult{Kind: ports.AuthResolutionKindCreatable}, nil
case err != nil:
return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email %q in postgres: %w", email, err)
}
if record.IsDeleted() {
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindBlocked,
BlockReasonCode: deletedAccountBlockReasonCode,
}, nil
}
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindExisting,
UserID: record.UserID,
}, nil
}
// EnsureByEmail atomically returns an existing user, creates a new one, or
// reports a blocked outcome. The whole flow runs in one transaction with
// row-level locks on `blocked_emails(email)` and `accounts(email)` so we
// observe a consistent snapshot of the auth-facing state.
//
// On the create branch the transaction also INSERTs the initial
// entitlement_records row and the entitlement_snapshots row. UNIQUE
// violations on user_id or user_name surface as ports.ErrConflict (with
// ports.ErrUserNameConflict for the user-name index).
func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
if err := input.Validate(); err != nil {
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email in postgres: %w", err)
}
var (
result ports.EnsureByEmailResult
handled bool
)
if err := store.withTx(ctx, "ensure by email in postgres", func(ctx context.Context, tx *sql.Tx) error {
blocked, err := scanBlockedEmail(ctx, tx, input.Email, true)
switch {
case err == nil:
result = ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeBlocked,
BlockReasonCode: blocked.ReasonCode,
}
handled = true
return nil
case !errors.Is(err, ports.ErrNotFound):
return fmt.Errorf("ensure by email %q in postgres: %w", input.Email, err)
}
existing, err := scanAccountForUpdateByEmail(ctx, tx, input.Email)
switch {
case err == nil:
if existing.IsDeleted() {
result = ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeBlocked,
BlockReasonCode: deletedAccountBlockReasonCode,
}
handled = true
return nil
}
result = ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeExisting,
UserID: existing.UserID,
}
handled = true
return nil
case !errors.Is(err, ports.ErrNotFound):
return fmt.Errorf("ensure by email %q in postgres: %w", input.Email, err)
}
if err := insertAccount(ctx, tx, input.Account); err != nil {
return err
}
if err := insertEntitlementPeriod(ctx, tx, input.EntitlementRecord); err != nil {
return err
}
if err := upsertEntitlementSnapshot(ctx, tx, input.Entitlement); err != nil {
return err
}
result = ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}
handled = true
return nil
}); err != nil {
return ports.EnsureByEmailResult{}, err
}
if !handled {
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email %q in postgres: unhandled transaction outcome", input.Email)
}
return result, nil
}
// BlockByUserID applies a block to the account identified by userID. The
// block is stored as a row in blocked_emails keyed on the user's e-mail with
// resolved_user_id pointing back to the account.
func (store *Store) BlockByUserID(ctx context.Context, input ports.BlockByUserIDInput) (ports.BlockResult, error) {
if err := input.Validate(); err != nil {
return ports.BlockResult{}, fmt.Errorf("block by user id in postgres: %w", err)
}
var (
result ports.BlockResult
handled bool
)
if err := store.withTx(ctx, "block by user id in postgres", func(ctx context.Context, tx *sql.Tx) error {
acc, err := scanAccountForUpdate(ctx, tx, input.UserID)
switch {
case errors.Is(err, ports.ErrNotFound):
return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, ports.ErrNotFound)
case err != nil:
return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, err)
}
if acc.IsDeleted() {
return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, ports.ErrNotFound)
}
blocked, err := scanBlockedEmail(ctx, tx, acc.Email, true)
switch {
case err == nil:
result = ports.BlockResult{
Outcome: ports.AuthBlockOutcomeAlreadyBlocked,
UserID: input.UserID,
}
if !blocked.ResolvedUserID.IsZero() {
result.UserID = blocked.ResolvedUserID
}
handled = true
return nil
case !errors.Is(err, ports.ErrNotFound):
return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, err)
}
record := authblock.BlockedEmailSubject{
Email: acc.Email,
ReasonCode: input.ReasonCode,
BlockedAt: input.BlockedAt.UTC(),
ResolvedUserID: input.UserID,
}
if err := upsertBlockedEmail(ctx, tx, record); err != nil {
return fmt.Errorf("block by user id %q in postgres: %w", input.UserID, err)
}
result = ports.BlockResult{
Outcome: ports.AuthBlockOutcomeBlocked,
UserID: input.UserID,
}
handled = true
return nil
}); err != nil {
return ports.BlockResult{}, err
}
if !handled {
return ports.BlockResult{}, fmt.Errorf("block by user id %q in postgres: unhandled transaction outcome", input.UserID)
}
return result, nil
}
// BlockByEmail applies a block to email even when no account exists yet. If
// an account does exist for the e-mail, its user_id is recorded as
// resolved_user_id; soft-deleted accounts also count for this resolution.
func (store *Store) BlockByEmail(ctx context.Context, input ports.BlockByEmailInput) (ports.BlockResult, error) {
if err := input.Validate(); err != nil {
return ports.BlockResult{}, fmt.Errorf("block by email in postgres: %w", err)
}
var (
result ports.BlockResult
handled bool
)
if err := store.withTx(ctx, "block by email in postgres", func(ctx context.Context, tx *sql.Tx) error {
blocked, err := scanBlockedEmail(ctx, tx, input.Email, true)
switch {
case err == nil:
result = ports.BlockResult{
Outcome: ports.AuthBlockOutcomeAlreadyBlocked,
UserID: blocked.ResolvedUserID,
}
handled = true
return nil
case !errors.Is(err, ports.ErrNotFound):
return fmt.Errorf("block by email %q in postgres: %w", input.Email, err)
}
var resolvedUserID common.UserID
acc, err := scanAccountForUpdateByEmail(ctx, tx, input.Email)
switch {
case err == nil:
resolvedUserID = acc.UserID
case !errors.Is(err, ports.ErrNotFound):
return fmt.Errorf("block by email %q in postgres: %w", input.Email, err)
}
record := authblock.BlockedEmailSubject{
Email: input.Email,
ReasonCode: input.ReasonCode,
BlockedAt: input.BlockedAt.UTC(),
ResolvedUserID: resolvedUserID,
}
if err := upsertBlockedEmail(ctx, tx, record); err != nil {
return fmt.Errorf("block by email %q in postgres: %w", input.Email, err)
}
result = ports.BlockResult{
Outcome: ports.AuthBlockOutcomeBlocked,
UserID: resolvedUserID,
}
handled = true
return nil
}); err != nil {
return ports.BlockResult{}, err
}
if !handled {
return ports.BlockResult{}, fmt.Errorf("block by email %q in postgres: unhandled transaction outcome", input.Email)
}
return result, nil
}
// guard so external callers cannot mistake this file's helpers for a public
// surface.
var _ account.UserAccount = account.UserAccount{}
@@ -0,0 +1,175 @@
package userstore
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
pgtable "galaxy/user/internal/adapters/postgres/jet/user/table"
"galaxy/user/internal/domain/authblock"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// blockedEmailSelectColumns is the canonical SELECT list for blocked_emails.
var blockedEmailSelectColumns = pg.ColumnList{
pgtable.BlockedEmails.Email,
pgtable.BlockedEmails.ReasonCode,
pgtable.BlockedEmails.BlockedAt,
pgtable.BlockedEmails.ActorType,
pgtable.BlockedEmails.ActorID,
pgtable.BlockedEmails.ResolvedUserID,
}
// GetBlockedEmail returns the blocked-email subject for email.
func (store *Store) GetBlockedEmail(ctx context.Context, email common.Email) (authblock.BlockedEmailSubject, error) {
if err := email.Validate(); err != nil {
return authblock.BlockedEmailSubject{}, fmt.Errorf("get blocked email subject from postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "get blocked email subject from postgres")
if err != nil {
return authblock.BlockedEmailSubject{}, err
}
defer cancel()
record, err := scanBlockedEmail(operationCtx, store.db, email, false)
switch {
case errors.Is(err, ports.ErrNotFound):
return authblock.BlockedEmailSubject{}, fmt.Errorf("get blocked email subject %q from postgres: %w", email, ports.ErrNotFound)
case err != nil:
return authblock.BlockedEmailSubject{}, fmt.Errorf("get blocked email subject %q from postgres: %w", email, err)
}
return record, nil
}
// PutBlockedEmail stores or replaces the blocked-email subject for
// record.Email. The schema's PRIMARY KEY on (email) makes this an UPSERT via
// `INSERT … ON CONFLICT (email) DO UPDATE`.
func (store *Store) PutBlockedEmail(ctx context.Context, record authblock.BlockedEmailSubject) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("upsert blocked email subject in postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "upsert blocked email subject in postgres")
if err != nil {
return err
}
defer cancel()
if err := upsertBlockedEmail(operationCtx, store.db, record); err != nil {
return err
}
return nil
}
// upsertBlockedEmail centralises the UPSERT used by PutBlockedEmail and the
// composite block flows. q is a *sql.DB or *sql.Tx so it can run inside an
// auth-directory transaction.
func upsertBlockedEmail(ctx context.Context, q queryer, record authblock.BlockedEmailSubject) error {
stmt := pgtable.BlockedEmails.INSERT(
pgtable.BlockedEmails.Email,
pgtable.BlockedEmails.ReasonCode,
pgtable.BlockedEmails.BlockedAt,
pgtable.BlockedEmails.ActorType,
pgtable.BlockedEmails.ActorID,
pgtable.BlockedEmails.ResolvedUserID,
).VALUES(
record.Email.String(),
record.ReasonCode.String(),
record.BlockedAt.UTC(),
nullableActorType(record.Actor.Type),
nullableActorID(record.Actor.ID),
nullableUserID(record.ResolvedUserID),
).ON_CONFLICT(pgtable.BlockedEmails.Email).DO_UPDATE(
pg.SET(
pgtable.BlockedEmails.ReasonCode.SET(pgtable.BlockedEmails.EXCLUDED.ReasonCode),
pgtable.BlockedEmails.BlockedAt.SET(pgtable.BlockedEmails.EXCLUDED.BlockedAt),
pgtable.BlockedEmails.ActorType.SET(pgtable.BlockedEmails.EXCLUDED.ActorType),
pgtable.BlockedEmails.ActorID.SET(pgtable.BlockedEmails.EXCLUDED.ActorID),
pgtable.BlockedEmails.ResolvedUserID.SET(pgtable.BlockedEmails.EXCLUDED.ResolvedUserID),
),
)
query, args := stmt.Sql()
if _, err := q.ExecContext(ctx, query, args...); err != nil {
return fmt.Errorf("upsert blocked email subject %q in postgres: %w", record.Email, err)
}
return nil
}
// scanBlockedEmail loads one blocked-email row. forUpdate selects the
// `FOR UPDATE` lock variant used inside the auth-directory transaction.
func scanBlockedEmail(ctx context.Context, q queryer, email common.Email, forUpdate bool) (authblock.BlockedEmailSubject, error) {
stmt := pg.SELECT(blockedEmailSelectColumns).
FROM(pgtable.BlockedEmails).
WHERE(pgtable.BlockedEmails.Email.EQ(pg.String(email.String())))
if forUpdate {
stmt = stmt.FOR(pg.UPDATE())
}
query, args := stmt.Sql()
row := q.QueryRowContext(ctx, query, args...)
return scanBlockedEmailRow(row)
}
func scanBlockedEmailRow(row *sql.Row) (authblock.BlockedEmailSubject, error) {
var (
record authblock.BlockedEmailSubject
emailValue string
reasonCode string
blockedAt time.Time
actorType *string
actorID *string
resolvedUserID *string
)
if err := row.Scan(
&emailValue, &reasonCode, &blockedAt,
&actorType, &actorID, &resolvedUserID,
); err != nil {
return authblock.BlockedEmailSubject{}, mapNotFound(err)
}
record.Email = common.Email(emailValue)
record.ReasonCode = common.ReasonCode(reasonCode)
record.BlockedAt = blockedAt.UTC()
if actorType != nil {
record.Actor.Type = common.ActorType(*actorType)
}
if actorID != nil {
record.Actor.ID = common.ActorID(*actorID)
}
if resolvedUserID != nil {
record.ResolvedUserID = common.UserID(*resolvedUserID)
}
return record, nil
}
// BlockedEmailStore adapts Store to the BlockedEmailStore port.
type BlockedEmailStore struct {
store *Store
}
// BlockedEmails returns one adapter that exposes the blocked-email store
// port over Store.
func (store *Store) BlockedEmails() *BlockedEmailStore {
if store == nil {
return nil
}
return &BlockedEmailStore{store: store}
}
// GetByEmail returns the blocked-email subject for email.
func (adapter *BlockedEmailStore) GetByEmail(ctx context.Context, email common.Email) (authblock.BlockedEmailSubject, error) {
return adapter.store.GetBlockedEmail(ctx, email)
}
// Upsert stores or replaces the blocked-email subject for record.Email.
func (adapter *BlockedEmailStore) Upsert(ctx context.Context, record authblock.BlockedEmailSubject) error {
return adapter.store.PutBlockedEmail(ctx, record)
}
var _ ports.BlockedEmailStore = (*BlockedEmailStore)(nil)
@@ -0,0 +1,729 @@
package userstore
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
pgtable "galaxy/user/internal/adapters/postgres/jet/user/table"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// entitlementPeriodSelectColumns is the canonical SELECT list for
// entitlement_records, matching scanEntitlementPeriod's column order.
var entitlementPeriodSelectColumns = pg.ColumnList{
pgtable.EntitlementRecords.RecordID,
pgtable.EntitlementRecords.UserID,
pgtable.EntitlementRecords.PlanCode,
pgtable.EntitlementRecords.Source,
pgtable.EntitlementRecords.ActorType,
pgtable.EntitlementRecords.ActorID,
pgtable.EntitlementRecords.ReasonCode,
pgtable.EntitlementRecords.StartsAt,
pgtable.EntitlementRecords.EndsAt,
pgtable.EntitlementRecords.CreatedAt,
pgtable.EntitlementRecords.ClosedAt,
pgtable.EntitlementRecords.ClosedByType,
pgtable.EntitlementRecords.ClosedByID,
pgtable.EntitlementRecords.ClosedReasonCode,
}
// entitlementSnapshotSelectColumns is the canonical SELECT list for
// entitlement_snapshots, matching scanEntitlementSnapshotRow's column order.
var entitlementSnapshotSelectColumns = pg.ColumnList{
pgtable.EntitlementSnapshots.UserID,
pgtable.EntitlementSnapshots.PlanCode,
pgtable.EntitlementSnapshots.IsPaid,
pgtable.EntitlementSnapshots.StartsAt,
pgtable.EntitlementSnapshots.EndsAt,
pgtable.EntitlementSnapshots.Source,
pgtable.EntitlementSnapshots.ActorType,
pgtable.EntitlementSnapshots.ActorID,
pgtable.EntitlementSnapshots.ReasonCode,
pgtable.EntitlementSnapshots.UpdatedAt,
}
// CreateEntitlementRecord stores one new entitlement period history record.
// The unique key is record_id; a duplicate record_id returns
// ports.ErrConflict.
func (store *Store) CreateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("create entitlement record in postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "create entitlement record in postgres")
if err != nil {
return err
}
defer cancel()
return insertEntitlementPeriod(operationCtx, store.db, record)
}
// GetEntitlementRecordByID returns the entitlement period record identified
// by recordID.
func (store *Store) GetEntitlementRecordByID(ctx context.Context, recordID entitlement.EntitlementRecordID) (entitlement.PeriodRecord, error) {
if err := recordID.Validate(); err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record from postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "get entitlement record from postgres")
if err != nil {
return entitlement.PeriodRecord{}, err
}
defer cancel()
stmt := pg.SELECT(entitlementPeriodSelectColumns).
FROM(pgtable.EntitlementRecords).
WHERE(pgtable.EntitlementRecords.RecordID.EQ(pg.String(recordID.String())))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
record, err := scanEntitlementPeriodRow(row)
switch {
case errors.Is(err, ports.ErrNotFound):
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record %q from postgres: %w", recordID, ports.ErrNotFound)
case err != nil:
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record %q from postgres: %w", recordID, err)
}
return record, nil
}
// ListEntitlementRecordsByUserID returns every entitlement period record
// owned by userID, ordered by created_at ascending so historical replay is
// deterministic.
func (store *Store) ListEntitlementRecordsByUserID(ctx context.Context, userID common.UserID) ([]entitlement.PeriodRecord, error) {
if err := userID.Validate(); err != nil {
return nil, fmt.Errorf("list entitlement records from postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "list entitlement records from postgres")
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(entitlementPeriodSelectColumns).
FROM(pgtable.EntitlementRecords).
WHERE(pgtable.EntitlementRecords.UserID.EQ(pg.String(userID.String()))).
ORDER_BY(pgtable.EntitlementRecords.CreatedAt.ASC(), pgtable.EntitlementRecords.RecordID.ASC())
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("list entitlement records for %q from postgres: %w", userID, err)
}
defer func() { _ = rows.Close() }()
out := make([]entitlement.PeriodRecord, 0)
for rows.Next() {
record, err := scanEntitlementPeriodRows(rows)
if err != nil {
return nil, fmt.Errorf("list entitlement records for %q from postgres: %w", userID, err)
}
out = append(out, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list entitlement records for %q from postgres: %w", userID, err)
}
return out, nil
}
// UpdateEntitlementRecord replaces one stored entitlement period record. The
// statement matches by record_id; ports.ErrNotFound is returned when the
// record does not exist.
func (store *Store) UpdateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("update entitlement record in postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "update entitlement record in postgres")
if err != nil {
return err
}
defer cancel()
rows, err := updateEntitlementPeriod(operationCtx, store.db, record)
if err != nil {
return fmt.Errorf("update entitlement record %q in postgres: %w", record.RecordID, err)
}
if rows == 0 {
return fmt.Errorf("update entitlement record %q in postgres: %w", record.RecordID, ports.ErrNotFound)
}
return nil
}
func updateEntitlementPeriod(ctx context.Context, q queryer, record entitlement.PeriodRecord) (int64, error) {
stmt := pgtable.EntitlementRecords.UPDATE(
pgtable.EntitlementRecords.PlanCode,
pgtable.EntitlementRecords.Source,
pgtable.EntitlementRecords.ActorType,
pgtable.EntitlementRecords.ActorID,
pgtable.EntitlementRecords.ReasonCode,
pgtable.EntitlementRecords.StartsAt,
pgtable.EntitlementRecords.EndsAt,
pgtable.EntitlementRecords.CreatedAt,
pgtable.EntitlementRecords.ClosedAt,
pgtable.EntitlementRecords.ClosedByType,
pgtable.EntitlementRecords.ClosedByID,
pgtable.EntitlementRecords.ClosedReasonCode,
).SET(
string(record.PlanCode),
record.Source.String(),
record.Actor.Type.String(),
nullableActorID(record.Actor.ID),
record.ReasonCode.String(),
record.StartsAt.UTC(),
nullableTime(record.EndsAt),
record.CreatedAt.UTC(),
nullableTime(record.ClosedAt),
nullableActorType(record.ClosedBy.Type),
nullableActorID(record.ClosedBy.ID),
nullableReasonCode(record.ClosedReasonCode),
).WHERE(pgtable.EntitlementRecords.RecordID.EQ(pg.String(record.RecordID.String())))
query, args := stmt.Sql()
res, err := q.ExecContext(ctx, query, args...)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func insertEntitlementPeriod(ctx context.Context, q queryer, record entitlement.PeriodRecord) error {
stmt := pgtable.EntitlementRecords.INSERT(
pgtable.EntitlementRecords.RecordID,
pgtable.EntitlementRecords.UserID,
pgtable.EntitlementRecords.PlanCode,
pgtable.EntitlementRecords.Source,
pgtable.EntitlementRecords.ActorType,
pgtable.EntitlementRecords.ActorID,
pgtable.EntitlementRecords.ReasonCode,
pgtable.EntitlementRecords.StartsAt,
pgtable.EntitlementRecords.EndsAt,
pgtable.EntitlementRecords.CreatedAt,
pgtable.EntitlementRecords.ClosedAt,
pgtable.EntitlementRecords.ClosedByType,
pgtable.EntitlementRecords.ClosedByID,
pgtable.EntitlementRecords.ClosedReasonCode,
).VALUES(
record.RecordID.String(),
record.UserID.String(),
string(record.PlanCode),
record.Source.String(),
record.Actor.Type.String(),
nullableActorID(record.Actor.ID),
record.ReasonCode.String(),
record.StartsAt.UTC(),
nullableTime(record.EndsAt),
record.CreatedAt.UTC(),
nullableTime(record.ClosedAt),
nullableActorType(record.ClosedBy.Type),
nullableActorID(record.ClosedBy.ID),
nullableReasonCode(record.ClosedReasonCode),
)
query, args := stmt.Sql()
_, err := q.ExecContext(ctx, query, args...)
if err == nil {
return nil
}
if isUniqueViolation(err) {
return fmt.Errorf("create entitlement record %q in postgres: %w", record.RecordID, ports.ErrConflict)
}
return fmt.Errorf("create entitlement record %q in postgres: %w", record.RecordID, err)
}
// scannableRow abstracts *sql.Row and *sql.Rows so the row-scanner can be
// shared by single-row and iterating callers.
type scannableRow interface {
Scan(dest ...any) error
}
func scanEntitlementPeriodRow(row *sql.Row) (entitlement.PeriodRecord, error) {
record, err := scanEntitlementPeriod(row)
if errors.Is(err, sql.ErrNoRows) {
return entitlement.PeriodRecord{}, ports.ErrNotFound
}
return record, err
}
func scanEntitlementPeriodRows(rows *sql.Rows) (entitlement.PeriodRecord, error) {
return scanEntitlementPeriod(rows)
}
func scanEntitlementPeriod(row scannableRow) (entitlement.PeriodRecord, error) {
var (
recordID string
userID string
planCode string
source string
actorType string
actorID *string
reasonCode string
startsAt time.Time
endsAt *time.Time
createdAt time.Time
closedAt *time.Time
closedByType *string
closedByID *string
closedReason *string
)
if err := row.Scan(
&recordID, &userID, &planCode, &source,
&actorType, &actorID, &reasonCode,
&startsAt, &endsAt, &createdAt,
&closedAt, &closedByType, &closedByID, &closedReason,
); err != nil {
return entitlement.PeriodRecord{}, err
}
record := entitlement.PeriodRecord{
RecordID: entitlement.EntitlementRecordID(recordID),
UserID: common.UserID(userID),
PlanCode: entitlement.PlanCode(planCode),
Source: common.Source(source),
Actor: common.ActorRef{Type: common.ActorType(actorType)},
ReasonCode: common.ReasonCode(reasonCode),
StartsAt: startsAt.UTC(),
EndsAt: timeFromNullable(endsAt),
CreatedAt: createdAt.UTC(),
ClosedAt: timeFromNullable(closedAt),
}
if actorID != nil {
record.Actor.ID = common.ActorID(*actorID)
}
if closedByType != nil {
record.ClosedBy.Type = common.ActorType(*closedByType)
}
if closedByID != nil {
record.ClosedBy.ID = common.ActorID(*closedByID)
}
if closedReason != nil {
record.ClosedReasonCode = common.ReasonCode(*closedReason)
}
return record, nil
}
// GetEntitlementByUserID returns the current entitlement snapshot for userID.
func (store *Store) GetEntitlementByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
if err := userID.Validate(); err != nil {
return entitlement.CurrentSnapshot{}, fmt.Errorf("get entitlement snapshot from postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "get entitlement snapshot from postgres")
if err != nil {
return entitlement.CurrentSnapshot{}, err
}
defer cancel()
stmt := pg.SELECT(entitlementSnapshotSelectColumns).
FROM(pgtable.EntitlementSnapshots).
WHERE(pgtable.EntitlementSnapshots.UserID.EQ(pg.String(userID.String())))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
record, err := scanEntitlementSnapshotRow(row)
switch {
case errors.Is(err, ports.ErrNotFound):
return entitlement.CurrentSnapshot{}, fmt.Errorf("get entitlement snapshot for %q from postgres: %w", userID, ports.ErrNotFound)
case err != nil:
return entitlement.CurrentSnapshot{}, fmt.Errorf("get entitlement snapshot for %q from postgres: %w", userID, err)
}
return record, nil
}
// PutEntitlement stores the current entitlement snapshot for record.UserID.
// It is an UPSERT so the runtime path can call it on creation and on
// replacement uniformly.
func (store *Store) PutEntitlement(ctx context.Context, record entitlement.CurrentSnapshot) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("put entitlement snapshot in postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "put entitlement snapshot in postgres")
if err != nil {
return err
}
defer cancel()
return upsertEntitlementSnapshot(operationCtx, store.db, record)
}
func upsertEntitlementSnapshot(ctx context.Context, q queryer, record entitlement.CurrentSnapshot) error {
stmt := pgtable.EntitlementSnapshots.INSERT(
pgtable.EntitlementSnapshots.UserID,
pgtable.EntitlementSnapshots.PlanCode,
pgtable.EntitlementSnapshots.IsPaid,
pgtable.EntitlementSnapshots.StartsAt,
pgtable.EntitlementSnapshots.EndsAt,
pgtable.EntitlementSnapshots.Source,
pgtable.EntitlementSnapshots.ActorType,
pgtable.EntitlementSnapshots.ActorID,
pgtable.EntitlementSnapshots.ReasonCode,
pgtable.EntitlementSnapshots.UpdatedAt,
).VALUES(
record.UserID.String(),
string(record.PlanCode),
record.IsPaid,
record.StartsAt.UTC(),
nullableTime(record.EndsAt),
record.Source.String(),
record.Actor.Type.String(),
nullableActorID(record.Actor.ID),
record.ReasonCode.String(),
record.UpdatedAt.UTC(),
).ON_CONFLICT(pgtable.EntitlementSnapshots.UserID).DO_UPDATE(
pg.SET(
pgtable.EntitlementSnapshots.PlanCode.SET(pgtable.EntitlementSnapshots.EXCLUDED.PlanCode),
pgtable.EntitlementSnapshots.IsPaid.SET(pgtable.EntitlementSnapshots.EXCLUDED.IsPaid),
pgtable.EntitlementSnapshots.StartsAt.SET(pgtable.EntitlementSnapshots.EXCLUDED.StartsAt),
pgtable.EntitlementSnapshots.EndsAt.SET(pgtable.EntitlementSnapshots.EXCLUDED.EndsAt),
pgtable.EntitlementSnapshots.Source.SET(pgtable.EntitlementSnapshots.EXCLUDED.Source),
pgtable.EntitlementSnapshots.ActorType.SET(pgtable.EntitlementSnapshots.EXCLUDED.ActorType),
pgtable.EntitlementSnapshots.ActorID.SET(pgtable.EntitlementSnapshots.EXCLUDED.ActorID),
pgtable.EntitlementSnapshots.ReasonCode.SET(pgtable.EntitlementSnapshots.EXCLUDED.ReasonCode),
pgtable.EntitlementSnapshots.UpdatedAt.SET(pgtable.EntitlementSnapshots.EXCLUDED.UpdatedAt),
),
)
query, args := stmt.Sql()
if _, err := q.ExecContext(ctx, query, args...); err != nil {
return fmt.Errorf("upsert entitlement snapshot for %q in postgres: %w", record.UserID, err)
}
return nil
}
func scanEntitlementSnapshotRow(row *sql.Row) (entitlement.CurrentSnapshot, error) {
var (
userID string
planCode string
isPaid bool
startsAt time.Time
endsAt *time.Time
source string
actorType string
actorID *string
reasonCode string
updatedAt time.Time
)
err := row.Scan(
&userID, &planCode, &isPaid,
&startsAt, &endsAt,
&source, &actorType, &actorID, &reasonCode,
&updatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
if err != nil {
return entitlement.CurrentSnapshot{}, err
}
record := entitlement.CurrentSnapshot{
UserID: common.UserID(userID),
PlanCode: entitlement.PlanCode(planCode),
IsPaid: isPaid,
StartsAt: startsAt.UTC(),
EndsAt: timeFromNullable(endsAt),
Source: common.Source(source),
Actor: common.ActorRef{Type: common.ActorType(actorType)},
ReasonCode: common.ReasonCode(reasonCode),
UpdatedAt: updatedAt.UTC(),
}
if actorID != nil {
record.Actor.ID = common.ActorID(*actorID)
}
return record, nil
}
// GrantEntitlement atomically closes the current free period, inserts the
// new paid period, and replaces the snapshot.
func (store *Store) GrantEntitlement(ctx context.Context, input ports.GrantEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("grant entitlement in postgres: %w", err)
}
return store.withTx(ctx, "grant entitlement in postgres", func(ctx context.Context, tx *sql.Tx) error {
if err := lockSnapshotMatching(ctx, tx, input.ExpectedCurrentSnapshot); err != nil {
return fmt.Errorf("grant entitlement for %q in postgres: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if err := lockPeriodMatching(ctx, tx, input.ExpectedCurrentRecord); err != nil {
return fmt.Errorf("grant entitlement for %q in postgres: %w", input.ExpectedCurrentRecord.RecordID, err)
}
if err := updateEntitlementPeriodTx(ctx, tx, input.UpdatedCurrentRecord); err != nil {
return fmt.Errorf("grant entitlement for %q in postgres: %w", input.UpdatedCurrentRecord.RecordID, err)
}
if err := insertEntitlementPeriod(ctx, tx, input.NewRecord); err != nil {
return err
}
if err := upsertEntitlementSnapshot(ctx, tx, input.NewSnapshot); err != nil {
return err
}
return nil
})
}
// ExtendEntitlement atomically appends a new paid history segment and
// replaces the snapshot.
func (store *Store) ExtendEntitlement(ctx context.Context, input ports.ExtendEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("extend entitlement in postgres: %w", err)
}
return store.withTx(ctx, "extend entitlement in postgres", func(ctx context.Context, tx *sql.Tx) error {
if err := lockSnapshotMatching(ctx, tx, input.ExpectedCurrentSnapshot); err != nil {
return fmt.Errorf("extend entitlement for %q in postgres: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if err := insertEntitlementPeriod(ctx, tx, input.NewRecord); err != nil {
return err
}
if err := upsertEntitlementSnapshot(ctx, tx, input.NewSnapshot); err != nil {
return err
}
return nil
})
}
// RevokeEntitlement atomically closes the current paid period, inserts a new
// free period, and replaces the snapshot.
func (store *Store) RevokeEntitlement(ctx context.Context, input ports.RevokeEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("revoke entitlement in postgres: %w", err)
}
return store.withTx(ctx, "revoke entitlement in postgres", func(ctx context.Context, tx *sql.Tx) error {
if err := lockSnapshotMatching(ctx, tx, input.ExpectedCurrentSnapshot); err != nil {
return fmt.Errorf("revoke entitlement for %q in postgres: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if err := lockPeriodMatching(ctx, tx, input.ExpectedCurrentRecord); err != nil {
return fmt.Errorf("revoke entitlement for %q in postgres: %w", input.ExpectedCurrentRecord.RecordID, err)
}
if err := updateEntitlementPeriodTx(ctx, tx, input.UpdatedCurrentRecord); err != nil {
return fmt.Errorf("revoke entitlement for %q in postgres: %w", input.UpdatedCurrentRecord.RecordID, err)
}
if err := insertEntitlementPeriod(ctx, tx, input.NewRecord); err != nil {
return err
}
if err := upsertEntitlementSnapshot(ctx, tx, input.NewSnapshot); err != nil {
return err
}
return nil
})
}
// RepairExpiredEntitlement atomically replaces an expired finite paid
// snapshot with a materialised free state.
func (store *Store) RepairExpiredEntitlement(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("repair expired entitlement in postgres: %w", err)
}
return store.withTx(ctx, "repair expired entitlement in postgres", func(ctx context.Context, tx *sql.Tx) error {
if err := lockSnapshotMatching(ctx, tx, input.ExpectedExpiredSnapshot); err != nil {
return fmt.Errorf("repair expired entitlement for %q in postgres: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
if err := insertEntitlementPeriod(ctx, tx, input.NewRecord); err != nil {
return err
}
if err := upsertEntitlementSnapshot(ctx, tx, input.NewSnapshot); err != nil {
return err
}
return nil
})
}
// lockSnapshotMatching loads the current snapshot under FOR UPDATE and
// verifies it matches expected. Mismatches surface as ports.ErrConflict so
// optimistic-replacement callers can retry.
func lockSnapshotMatching(ctx context.Context, tx *sql.Tx, expected entitlement.CurrentSnapshot) error {
stmt := pg.SELECT(entitlementSnapshotSelectColumns).
FROM(pgtable.EntitlementSnapshots).
WHERE(pgtable.EntitlementSnapshots.UserID.EQ(pg.String(expected.UserID.String()))).
FOR(pg.UPDATE())
query, args := stmt.Sql()
row := tx.QueryRowContext(ctx, query, args...)
current, err := scanEntitlementSnapshotRow(row)
switch {
case errors.Is(err, ports.ErrNotFound):
return ports.ErrNotFound
case err != nil:
return err
}
if !snapshotsEqual(current, expected) {
return ports.ErrConflict
}
return nil
}
func lockPeriodMatching(ctx context.Context, tx *sql.Tx, expected entitlement.PeriodRecord) error {
stmt := pg.SELECT(entitlementPeriodSelectColumns).
FROM(pgtable.EntitlementRecords).
WHERE(pgtable.EntitlementRecords.RecordID.EQ(pg.String(expected.RecordID.String()))).
FOR(pg.UPDATE())
query, args := stmt.Sql()
row := tx.QueryRowContext(ctx, query, args...)
current, err := scanEntitlementPeriodRow(row)
switch {
case errors.Is(err, ports.ErrNotFound):
return ports.ErrNotFound
case err != nil:
return err
}
if !periodsEqual(current, expected) {
return ports.ErrConflict
}
return nil
}
func updateEntitlementPeriodTx(ctx context.Context, tx *sql.Tx, record entitlement.PeriodRecord) error {
rows, err := updateEntitlementPeriod(ctx, tx, record)
if err != nil {
return err
}
if rows == 0 {
return ports.ErrNotFound
}
return nil
}
func snapshotsEqual(left entitlement.CurrentSnapshot, right entitlement.CurrentSnapshot) bool {
if left.UserID != right.UserID ||
left.PlanCode != right.PlanCode ||
left.IsPaid != right.IsPaid ||
left.Source != right.Source ||
left.Actor != right.Actor ||
left.ReasonCode != right.ReasonCode {
return false
}
if !left.StartsAt.Equal(right.StartsAt) || !left.UpdatedAt.Equal(right.UpdatedAt) {
return false
}
return optionalTimeEqual(left.EndsAt, right.EndsAt)
}
func periodsEqual(left entitlement.PeriodRecord, right entitlement.PeriodRecord) bool {
if left.RecordID != right.RecordID ||
left.UserID != right.UserID ||
left.PlanCode != right.PlanCode ||
left.Source != right.Source ||
left.Actor != right.Actor ||
left.ReasonCode != right.ReasonCode ||
left.ClosedBy != right.ClosedBy ||
left.ClosedReasonCode != right.ClosedReasonCode {
return false
}
if !left.StartsAt.Equal(right.StartsAt) || !left.CreatedAt.Equal(right.CreatedAt) {
return false
}
if !optionalTimeEqual(left.EndsAt, right.EndsAt) {
return false
}
return optionalTimeEqual(left.ClosedAt, right.ClosedAt)
}
func optionalTimeEqual(left *time.Time, right *time.Time) bool {
switch {
case left == nil && right == nil:
return true
case left == nil || right == nil:
return false
default:
return left.Equal(*right)
}
}
// EntitlementSnapshotStore adapts Store to the EntitlementSnapshotStore port.
type EntitlementSnapshotStore struct {
store *Store
}
// EntitlementSnapshots returns one adapter that exposes the entitlement-
// snapshot store port over Store.
func (store *Store) EntitlementSnapshots() *EntitlementSnapshotStore {
if store == nil {
return nil
}
return &EntitlementSnapshotStore{store: store}
}
// GetByUserID returns the current entitlement snapshot for userID.
func (adapter *EntitlementSnapshotStore) GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
return adapter.store.GetEntitlementByUserID(ctx, userID)
}
// Put stores the current entitlement snapshot for record.UserID.
func (adapter *EntitlementSnapshotStore) Put(ctx context.Context, record entitlement.CurrentSnapshot) error {
return adapter.store.PutEntitlement(ctx, record)
}
var _ ports.EntitlementSnapshotStore = (*EntitlementSnapshotStore)(nil)
// EntitlementHistoryStore adapts Store to the EntitlementHistoryStore port.
type EntitlementHistoryStore struct {
store *Store
}
// EntitlementHistory returns one adapter that exposes the entitlement
// history store port over Store.
func (store *Store) EntitlementHistory() *EntitlementHistoryStore {
if store == nil {
return nil
}
return &EntitlementHistoryStore{store: store}
}
// Create stores one new entitlement history record.
func (adapter *EntitlementHistoryStore) Create(ctx context.Context, record entitlement.PeriodRecord) error {
return adapter.store.CreateEntitlementRecord(ctx, record)
}
// GetByRecordID returns the entitlement history record identified by
// recordID.
func (adapter *EntitlementHistoryStore) GetByRecordID(ctx context.Context, recordID entitlement.EntitlementRecordID) (entitlement.PeriodRecord, error) {
return adapter.store.GetEntitlementRecordByID(ctx, recordID)
}
// ListByUserID returns every entitlement history record owned by userID.
func (adapter *EntitlementHistoryStore) ListByUserID(ctx context.Context, userID common.UserID) ([]entitlement.PeriodRecord, error) {
return adapter.store.ListEntitlementRecordsByUserID(ctx, userID)
}
// Update replaces one stored entitlement history record.
func (adapter *EntitlementHistoryStore) Update(ctx context.Context, record entitlement.PeriodRecord) error {
return adapter.store.UpdateEntitlementRecord(ctx, record)
}
var _ ports.EntitlementHistoryStore = (*EntitlementHistoryStore)(nil)
// EntitlementLifecycleStore adapts Store to the EntitlementLifecycleStore
// port.
type EntitlementLifecycleStore struct {
store *Store
}
// EntitlementLifecycle returns one adapter that exposes the entitlement
// lifecycle store port over Store.
func (store *Store) EntitlementLifecycle() *EntitlementLifecycleStore {
if store == nil {
return nil
}
return &EntitlementLifecycleStore{store: store}
}
// Grant atomically closes the current free period and starts a new paid
// period.
func (adapter *EntitlementLifecycleStore) Grant(ctx context.Context, input ports.GrantEntitlementInput) error {
return adapter.store.GrantEntitlement(ctx, input)
}
// Extend appends a paid history segment.
func (adapter *EntitlementLifecycleStore) Extend(ctx context.Context, input ports.ExtendEntitlementInput) error {
return adapter.store.ExtendEntitlement(ctx, input)
}
// Revoke closes the current paid period and starts a fresh free period.
func (adapter *EntitlementLifecycleStore) Revoke(ctx context.Context, input ports.RevokeEntitlementInput) error {
return adapter.store.RevokeEntitlement(ctx, input)
}
// RepairExpired replaces an expired finite paid snapshot with a free state.
func (adapter *EntitlementLifecycleStore) RepairExpired(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
return adapter.store.RepairExpiredEntitlement(ctx, input)
}
var _ ports.EntitlementLifecycleStore = (*EntitlementLifecycleStore)(nil)
@@ -0,0 +1,203 @@
package userstore
import (
"context"
"database/sql"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
"galaxy/postgres"
"galaxy/user/internal/adapters/postgres/migrations"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
pkgPostgresImage = "postgres:16-alpine"
pkgSuperUser = "galaxy"
pkgSuperPassword = "galaxy"
pkgSuperDatabase = "galaxy_user"
pkgServiceRole = "userservice"
pkgServicePassword = "userservice"
pkgServiceSchema = "user"
pkgContainerStartup = 90 * time.Second
pkgOperationTimeout = 10 * time.Second
)
var (
pkgContainerOnce sync.Once
pkgContainerErr error
pkgContainerEnv *postgresEnv
)
type postgresEnv struct {
container *tcpostgres.PostgresContainer
dsn string
pool *sql.DB
}
func ensurePostgresEnv(t testing.TB) *postgresEnv {
t.Helper()
pkgContainerOnce.Do(func() {
pkgContainerEnv, pkgContainerErr = startPostgresEnv()
})
if pkgContainerErr != nil {
t.Skipf("postgres container start failed (Docker unavailable?): %v", pkgContainerErr)
}
return pkgContainerEnv
}
func startPostgresEnv() (*postgresEnv, error) {
ctx := context.Background()
container, err := tcpostgres.Run(ctx, pkgPostgresImage,
tcpostgres.WithDatabase(pkgSuperDatabase),
tcpostgres.WithUsername(pkgSuperUser),
tcpostgres.WithPassword(pkgSuperPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(pkgContainerStartup),
),
)
if err != nil {
return nil, err
}
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
_ = testcontainers.TerminateContainer(container)
return nil, err
}
if err := provisionRoleAndSchema(ctx, baseDSN); err != nil {
_ = testcontainers.TerminateContainer(container)
return nil, err
}
scopedDSN, err := dsnForServiceRole(baseDSN)
if err != nil {
_ = testcontainers.TerminateContainer(container)
return nil, err
}
cfg := postgres.DefaultConfig()
cfg.PrimaryDSN = scopedDSN
cfg.OperationTimeout = pkgOperationTimeout
pool, err := postgres.OpenPrimary(ctx, cfg)
if err != nil {
_ = testcontainers.TerminateContainer(container)
return nil, err
}
if err := postgres.Ping(ctx, pool, pkgOperationTimeout); err != nil {
_ = pool.Close()
_ = testcontainers.TerminateContainer(container)
return nil, err
}
if err := postgres.RunMigrations(ctx, pool, migrations.FS(), "."); err != nil {
_ = pool.Close()
_ = testcontainers.TerminateContainer(container)
return nil, err
}
return &postgresEnv{
container: container,
dsn: scopedDSN,
pool: pool,
}, nil
}
func provisionRoleAndSchema(ctx context.Context, baseDSN string) error {
cfg := postgres.DefaultConfig()
cfg.PrimaryDSN = baseDSN
cfg.OperationTimeout = pkgOperationTimeout
db, err := postgres.OpenPrimary(ctx, cfg)
if err != nil {
return err
}
defer func() { _ = db.Close() }()
statements := []string{
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'userservice') THEN
CREATE ROLE userservice LOGIN PASSWORD 'userservice';
END IF;
END $$;`,
`CREATE SCHEMA IF NOT EXISTS "user" AUTHORIZATION userservice;`,
`GRANT USAGE ON SCHEMA "user" TO userservice;`,
}
for _, statement := range statements {
if _, err := db.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
func dsnForServiceRole(baseDSN string) (string, error) {
parsed, err := url.Parse(baseDSN)
if err != nil {
return "", err
}
values := url.Values{}
values.Set("search_path", pkgServiceSchema)
values.Set("sslmode", "disable")
scoped := url.URL{
Scheme: parsed.Scheme,
User: url.UserPassword(pkgServiceRole, pkgServicePassword),
Host: parsed.Host,
Path: parsed.Path,
RawQuery: values.Encode(),
}
return scoped.String(), nil
}
// newTestStore returns a Store backed by the package-scoped pool. Every
// invocation truncates the user-owned tables so individual tests start from
// a clean slate while sharing one container start.
func newTestStore(t *testing.T) *Store {
t.Helper()
env := ensurePostgresEnv(t)
truncateAll(t, env.pool)
store, err := New(Config{DB: env.pool, OperationTimeout: pkgOperationTimeout})
if err != nil {
t.Fatalf("new store: %v", err)
}
return store
}
func truncateAll(t *testing.T, db *sql.DB) {
t.Helper()
statement := strings.Join([]string{
"TRUNCATE TABLE",
"sanction_active, limit_active,",
"sanction_records, limit_records,",
"entitlement_snapshots, entitlement_records,",
"blocked_emails, accounts",
"RESTART IDENTITY CASCADE",
}, " ")
if _, err := db.ExecContext(context.Background(), statement); err != nil {
t.Fatalf("truncate tables: %v", err)
}
}
// TestMain runs first when `go test` enters the package. We drive it through
// a TestMain so the container started by the first test is shut down on the
// way out, even when individual tests panic.
func TestMain(m *testing.M) {
code := m.Run()
if pkgContainerEnv != nil {
if pkgContainerEnv.pool != nil {
_ = pkgContainerEnv.pool.Close()
}
if pkgContainerEnv.container != nil {
_ = testcontainers.TerminateContainer(pkgContainerEnv.container)
}
}
os.Exit(code)
}
@@ -0,0 +1,149 @@
package userstore
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"github.com/jackc/pgx/v5/pgconn"
)
// pgUniqueViolationCode identifies the SQLSTATE returned by PostgreSQL when
// a UNIQUE constraint is violated by INSERT or UPDATE.
const pgUniqueViolationCode = "23505"
// classifyUniqueViolation maps a PostgreSQL unique-violation error to the
// matching ports sentinel. constraint identifies which UNIQUE constraint name
// the caller cares about so we can surface ports.ErrUserNameConflict for the
// dedicated user-name index. Returns nil when err is not a unique violation
// or does not match constraint.
func classifyUniqueViolation(err error, constraint string, mapped error) error {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != pgUniqueViolationCode {
return nil
}
if constraint != "" && pgErr.ConstraintName != constraint {
return nil
}
return mapped
}
// isUniqueViolation reports whether err is a PostgreSQL unique-violation,
// regardless of constraint name. Useful for "any conflict ⇒ ErrConflict"
// translations on simple INSERT calls.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
return pgErr.Code == pgUniqueViolationCode
}
// nullableString returns the trimmed string when s is non-empty, otherwise
// reports a NULL stand-in usable in $-parameter lists. Empty strings are
// stored as NULL so optional columns round-trip through nil.
func nullableString(s string) any {
if s == "" {
return nil
}
return s
}
// nullableActorID converts an optional ActorID (the zero value indicates
// "no caller supplied this field") to a NULL stand-in for SQL parameters.
func nullableActorID(id common.ActorID) any {
if id.IsZero() {
return nil
}
return id.String()
}
// nullableActorType mirrors nullableActorID for ActorType.
func nullableActorType(t common.ActorType) any {
if t.IsZero() {
return nil
}
return t.String()
}
// nullableReasonCode mirrors nullableActorID for ReasonCode.
func nullableReasonCode(code common.ReasonCode) any {
if code.IsZero() {
return nil
}
return code.String()
}
// nullableUserID mirrors nullableActorID for UserID.
func nullableUserID(id common.UserID) any {
if id.IsZero() {
return nil
}
return id.String()
}
// nullableTime returns t.UTC() when non-nil, otherwise nil for NULL columns.
func nullableTime(t *time.Time) any {
if t == nil {
return nil
}
return t.UTC()
}
// nullableCountry returns the upper-cased ISO 3166-1 alpha-2 string when set,
// otherwise nil.
func nullableCountry(code common.CountryCode) any {
if code.IsZero() {
return nil
}
return code.String()
}
// stringFromNullable trims an optional sql.NullString-like *string (read from
// Postgres COLUMNAR_NULL) into an ActorID/ReasonCode/UserID-friendly string.
func stringFromNullable(value *string) string {
if value == nil {
return ""
}
return *value
}
// timeFromNullable copies an optional *time.Time read from Postgres into a
// new pointer normalised to UTC.
func timeFromNullable(value *time.Time) *time.Time {
if value == nil {
return nil
}
utc := value.UTC()
return &utc
}
// mapNotFound translates sql.ErrNoRows into ports.ErrNotFound, leaving every
// other error untouched.
func mapNotFound(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return ports.ErrNotFound
}
return err
}
// withTimeout derives a child context bounded by timeout and prefixes context
// errors with operation. Callers must always invoke the returned cancel.
func withTimeout(ctx context.Context, operation string, timeout time.Duration) (context.Context, context.CancelFunc, error) {
if ctx == nil {
return nil, nil, fmt.Errorf("%s: nil context", operation)
}
if err := ctx.Err(); err != nil {
return nil, nil, fmt.Errorf("%s: %w", operation, err)
}
if timeout <= 0 {
return nil, nil, fmt.Errorf("%s: operation timeout must be positive", operation)
}
bounded, cancel := context.WithTimeout(ctx, timeout)
return bounded, cancel, nil
}
@@ -0,0 +1,160 @@
package userstore
import (
"context"
"fmt"
"time"
pgtable "galaxy/user/internal/adapters/postgres/jet/user/table"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// ListUserIDs returns one deterministic page of user identifiers ordered by
// `created_at desc`, then `user_id desc`, mirroring the ordering used by the
// previous Redis adapter.
//
// The Postgres implementation keeps the listing surface storage-thin: it
// only paginates on `created_at` + `user_id` and does not attempt to push
// the full filter matrix into SQL. The service layer (`adminusers.Lister`)
// continues to load each candidate via the per-user loader and apply the
// filter set in memory, exactly as it did with the Redis adapter. Pushing
// the filter matrix down to SQL is a follow-up optimisation noted in
// `galaxy/user/docs/postgres-migration.md`.
func (store *Store) ListUserIDs(ctx context.Context, input ports.ListUsersInput) (ports.ListUsersResult, error) {
if err := input.Validate(); err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "list users in postgres")
if err != nil {
return ports.ListUsersResult{}, err
}
defer cancel()
filters := userListFiltersFromPorts(input.Filters)
var (
cursorCreatedAt time.Time
cursorUserID common.UserID
cursored bool
)
if input.PageToken != "" {
cursor, err := decodePageToken(input.PageToken, filters)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in postgres: %w", ports.ErrInvalidPageToken)
}
cursorCreatedAt = cursor.CreatedAt
cursorUserID = cursor.UserID
cursored = true
}
limit := input.PageSize + 1
rows, err := queryListPage(operationCtx, store, cursored, cursorCreatedAt, cursorUserID, limit)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in postgres: %w", err)
}
result := ports.ListUsersResult{
UserIDs: make([]common.UserID, 0, min(len(rows), input.PageSize)),
}
visible := min(len(rows), input.PageSize)
for index := range visible {
result.UserIDs = append(result.UserIDs, rows[index].UserID)
}
if len(rows) > input.PageSize {
last := rows[input.PageSize-1]
token, err := encodePageToken(pageCursor{
CreatedAt: last.CreatedAt,
UserID: last.UserID,
}, filters)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in postgres: %w", err)
}
result.NextPageToken = token
}
return result, nil
}
// listRow is the lightweight projection returned by queryListPage; only
// (created_at, user_id) is needed for the listing index plus cursor token
// generation.
type listRow struct {
CreatedAt time.Time
UserID common.UserID
}
// queryListPage returns up to limit rows ordered by created_at DESC, user_id
// DESC. When cursored is true, the query starts strictly after the
// (cursorCreatedAt, cursorUserID) tuple per the keyset pagination rule.
func queryListPage(ctx context.Context, store *Store, cursored bool, cursorCreatedAt time.Time, cursorUserID common.UserID, limit int) ([]listRow, error) {
stmt := pg.SELECT(pgtable.Accounts.CreatedAt, pgtable.Accounts.UserID).
FROM(pgtable.Accounts)
if cursored {
// (created_at, user_id) < (cursorCreatedAt, cursorUserID) expressed as
// the equivalent OR/AND expansion since jet has no row-comparison
// builder.
ts := pg.TimestampzT(cursorCreatedAt.UTC())
uid := pg.String(cursorUserID.String())
stmt = stmt.WHERE(pg.OR(
pgtable.Accounts.CreatedAt.LT(ts),
pg.AND(
pgtable.Accounts.CreatedAt.EQ(ts),
pgtable.Accounts.UserID.LT(uid),
),
))
}
stmt = stmt.
ORDER_BY(pgtable.Accounts.CreatedAt.DESC(), pgtable.Accounts.UserID.DESC()).
LIMIT(int64(limit))
query, args := stmt.Sql()
rows, err := store.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
out := make([]listRow, 0, limit)
for rows.Next() {
var (
createdAt time.Time
userID string
)
if err := rows.Scan(&createdAt, &userID); err != nil {
return nil, err
}
uid := common.UserID(userID)
if err := uid.Validate(); err != nil {
return nil, fmt.Errorf("created_at index member user id: %w", err)
}
out = append(out, listRow{CreatedAt: createdAt.UTC(), UserID: uid})
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
// UserList adapts Store to the UserListStore port.
type UserList struct{ store *Store }
// UserListAdapter returns one adapter that exposes the user-list store port.
func (store *Store) UserListAdapter() *UserList {
if store == nil {
return nil
}
return &UserList{store: store}
}
// ListUserIDs returns one deterministic page of user identifiers.
func (a *UserList) ListUserIDs(ctx context.Context, input ports.ListUsersInput) (ports.ListUsersResult, error) {
return a.store.ListUserIDs(ctx, input)
}
var _ ports.UserListStore = (*UserList)(nil)
var _ ports.UserListStore = (*Store)(nil)
@@ -0,0 +1,198 @@
package userstore
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
)
// errPageTokenFiltersMismatch reports that a supplied page token was created
// for a different normalised filter set. Callers translate it to
// ports.ErrInvalidPageToken on the boundary.
var errPageTokenFiltersMismatch = errors.New("page token filters do not match current filters")
// pageCursor identifies the last (created_at, user_id) tuple visible on the
// previous listing page. The cursor is paired with a normalised filter
// fingerprint so the token cannot be replayed across a different filter set.
type pageCursor struct {
CreatedAt time.Time
UserID common.UserID
}
func (cursor pageCursor) Validate() error {
if err := common.ValidateTimestamp("page cursor created at", cursor.CreatedAt); err != nil {
return err
}
if err := cursor.UserID.Validate(); err != nil {
return fmt.Errorf("page cursor user id: %w", err)
}
return nil
}
// userListFilters mirrors ports.UserListFilters but excludes the fields that
// only the service layer enforces (display_name match, user_name) so token
// replay across a UI re-render that toggles a UI-only filter does not
// invalidate the cursor.
type userListFilters struct {
PaidState entitlement.PaidState
PaidExpiresBefore *time.Time
PaidExpiresAfter *time.Time
DeclaredCountry common.CountryCode
SanctionCode policy.SanctionCode
LimitCode policy.LimitCode
CanLogin *bool
CanCreatePrivateGame *bool
CanJoinGame *bool
}
// userListFiltersFromPorts copies the listing-stable subset of port-level
// filters into the form embedded into the page token fingerprint.
func userListFiltersFromPorts(filters ports.UserListFilters) userListFilters {
return userListFilters{
PaidState: filters.PaidState,
PaidExpiresBefore: filters.PaidExpiresBefore,
PaidExpiresAfter: filters.PaidExpiresAfter,
DeclaredCountry: filters.DeclaredCountry,
SanctionCode: filters.SanctionCode,
LimitCode: filters.LimitCode,
CanLogin: filters.CanLogin,
CanCreatePrivateGame: filters.CanCreatePrivateGame,
CanJoinGame: filters.CanJoinGame,
}
}
func (filters userListFilters) Validate() error {
if !filters.PaidState.IsKnown() {
return fmt.Errorf("paid state %q is unsupported", filters.PaidState)
}
if filters.PaidExpiresBefore != nil && filters.PaidExpiresBefore.IsZero() {
return fmt.Errorf("paid expires before must not be zero")
}
if filters.PaidExpiresAfter != nil && filters.PaidExpiresAfter.IsZero() {
return fmt.Errorf("paid expires after must not be zero")
}
if !filters.DeclaredCountry.IsZero() {
if err := filters.DeclaredCountry.Validate(); err != nil {
return fmt.Errorf("declared country: %w", err)
}
}
if filters.SanctionCode != "" && !filters.SanctionCode.IsKnown() {
return fmt.Errorf("sanction code %q is unsupported", filters.SanctionCode)
}
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
}
return nil
}
// encodePageToken encodes cursor + filters into the frozen opaque page token
// shape used by the trusted admin listing surface. The encoding is identical
// to the previous Redis implementation so existing public clients can keep
// using their stored tokens through the migration cut-over.
func encodePageToken(cursor pageCursor, filters userListFilters) (string, error) {
if err := cursor.Validate(); err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
fingerprint, err := normaliseFilters(filters)
if err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
payload, err := json.Marshal(pageTokenPayload{
CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano),
UserID: cursor.UserID.String(),
Filters: fingerprint,
})
if err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
// decodePageToken parses raw and verifies the embedded fingerprint matches
// expected. The token's wire format is preserved across the Redis-to-
// PostgreSQL adapter swap.
func decodePageToken(raw string, expected userListFilters) (pageCursor, error) {
fingerprint, err := normaliseFilters(expected)
if err != nil {
return pageCursor{}, fmt.Errorf("decode page token: %w", err)
}
payload, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return pageCursor{}, fmt.Errorf("decode page token: %w", err)
}
var token pageTokenPayload
if err := json.Unmarshal(payload, &token); err != nil {
return pageCursor{}, fmt.Errorf("decode page token: %w", err)
}
if token.Filters != fingerprint {
return pageCursor{}, errPageTokenFiltersMismatch
}
createdAt, err := time.Parse(time.RFC3339Nano, token.CreatedAt)
if err != nil {
return pageCursor{}, fmt.Errorf("decode page token: parse created_at: %w", err)
}
cursor := pageCursor{CreatedAt: createdAt.UTC(), UserID: common.UserID(token.UserID)}
if err := cursor.Validate(); err != nil {
return pageCursor{}, fmt.Errorf("decode page token: %w", err)
}
return cursor, nil
}
type pageTokenPayload struct {
CreatedAt string `json:"created_at"`
UserID string `json:"user_id"`
Filters normalisedFilterFields `json:"filters"`
}
type normalisedFilterFields struct {
PaidState string `json:"paid_state,omitempty"`
PaidExpiresBeforeUTC string `json:"paid_expires_before_utc,omitempty"`
PaidExpiresAfterUTC string `json:"paid_expires_after_utc,omitempty"`
DeclaredCountry string `json:"declared_country,omitempty"`
SanctionCode string `json:"sanction_code,omitempty"`
LimitCode string `json:"limit_code,omitempty"`
CanLogin string `json:"can_login,omitempty"`
CanCreatePrivateGame string `json:"can_create_private_game,omitempty"`
CanJoinGame string `json:"can_join_game,omitempty"`
}
func normaliseFilters(filters userListFilters) (normalisedFilterFields, error) {
if err := filters.Validate(); err != nil {
return normalisedFilterFields{}, err
}
return normalisedFilterFields{
PaidState: string(filters.PaidState),
PaidExpiresBeforeUTC: formatOptionalUTC(filters.PaidExpiresBefore),
PaidExpiresAfterUTC: formatOptionalUTC(filters.PaidExpiresAfter),
DeclaredCountry: filters.DeclaredCountry.String(),
SanctionCode: string(filters.SanctionCode),
LimitCode: string(filters.LimitCode),
CanLogin: formatOptionalBool(filters.CanLogin),
CanCreatePrivateGame: formatOptionalBool(filters.CanCreatePrivateGame),
CanJoinGame: formatOptionalBool(filters.CanJoinGame),
}, nil
}
func formatOptionalUTC(value *time.Time) string {
if value == nil {
return ""
}
return value.UTC().Format(time.RFC3339Nano)
}
func formatOptionalBool(value *bool) string {
if value == nil {
return ""
}
if *value {
return "true"
}
return "false"
}
@@ -0,0 +1,870 @@
package userstore
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
pgtable "galaxy/user/internal/adapters/postgres/jet/user/table"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// sanctionSelectColumns is the canonical SELECT list for sanction_records,
// matching scanSanction's column order.
var sanctionSelectColumns = pg.ColumnList{
pgtable.SanctionRecords.RecordID,
pgtable.SanctionRecords.UserID,
pgtable.SanctionRecords.SanctionCode,
pgtable.SanctionRecords.Scope,
pgtable.SanctionRecords.ReasonCode,
pgtable.SanctionRecords.ActorType,
pgtable.SanctionRecords.ActorID,
pgtable.SanctionRecords.AppliedAt,
pgtable.SanctionRecords.ExpiresAt,
pgtable.SanctionRecords.RemovedAt,
pgtable.SanctionRecords.RemovedByType,
pgtable.SanctionRecords.RemovedByID,
pgtable.SanctionRecords.RemovedReasonCode,
}
// limitSelectColumns is the canonical SELECT list for limit_records, matching
// scanLimit's column order.
var limitSelectColumns = pg.ColumnList{
pgtable.LimitRecords.RecordID,
pgtable.LimitRecords.UserID,
pgtable.LimitRecords.LimitCode,
pgtable.LimitRecords.Value,
pgtable.LimitRecords.ReasonCode,
pgtable.LimitRecords.ActorType,
pgtable.LimitRecords.ActorID,
pgtable.LimitRecords.AppliedAt,
pgtable.LimitRecords.ExpiresAt,
pgtable.LimitRecords.RemovedAt,
pgtable.LimitRecords.RemovedByType,
pgtable.LimitRecords.RemovedByID,
pgtable.LimitRecords.RemovedReasonCode,
}
// CreateSanction stores one new sanction history record.
func (store *Store) CreateSanction(ctx context.Context, record policy.SanctionRecord) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("create sanction in postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "create sanction in postgres")
if err != nil {
return err
}
defer cancel()
return insertSanctionRecord(operationCtx, store.db, record)
}
func insertSanctionRecord(ctx context.Context, q queryer, record policy.SanctionRecord) error {
stmt := pgtable.SanctionRecords.INSERT(
pgtable.SanctionRecords.RecordID,
pgtable.SanctionRecords.UserID,
pgtable.SanctionRecords.SanctionCode,
pgtable.SanctionRecords.Scope,
pgtable.SanctionRecords.ReasonCode,
pgtable.SanctionRecords.ActorType,
pgtable.SanctionRecords.ActorID,
pgtable.SanctionRecords.AppliedAt,
pgtable.SanctionRecords.ExpiresAt,
pgtable.SanctionRecords.RemovedAt,
pgtable.SanctionRecords.RemovedByType,
pgtable.SanctionRecords.RemovedByID,
pgtable.SanctionRecords.RemovedReasonCode,
).VALUES(
record.RecordID.String(),
record.UserID.String(),
string(record.SanctionCode),
record.Scope.String(),
record.ReasonCode.String(),
record.Actor.Type.String(),
nullableActorID(record.Actor.ID),
record.AppliedAt.UTC(),
nullableTime(record.ExpiresAt),
nullableTime(record.RemovedAt),
nullableActorType(record.RemovedBy.Type),
nullableActorID(record.RemovedBy.ID),
nullableReasonCode(record.RemovedReasonCode),
)
query, args := stmt.Sql()
_, err := q.ExecContext(ctx, query, args...)
if err == nil {
return nil
}
if isUniqueViolation(err) {
return fmt.Errorf("create sanction %q in postgres: %w", record.RecordID, ports.ErrConflict)
}
return fmt.Errorf("create sanction %q in postgres: %w", record.RecordID, err)
}
// GetSanctionByRecordID returns the sanction history record identified by
// recordID.
func (store *Store) GetSanctionByRecordID(ctx context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error) {
if err := recordID.Validate(); err != nil {
return policy.SanctionRecord{}, fmt.Errorf("get sanction from postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "get sanction from postgres")
if err != nil {
return policy.SanctionRecord{}, err
}
defer cancel()
stmt := pg.SELECT(sanctionSelectColumns).
FROM(pgtable.SanctionRecords).
WHERE(pgtable.SanctionRecords.RecordID.EQ(pg.String(recordID.String())))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
record, err := scanSanctionRow(row)
switch {
case errors.Is(err, ports.ErrNotFound):
return policy.SanctionRecord{}, fmt.Errorf("get sanction %q from postgres: %w", recordID, ports.ErrNotFound)
case err != nil:
return policy.SanctionRecord{}, fmt.Errorf("get sanction %q from postgres: %w", recordID, err)
}
return record, nil
}
// ListSanctionsByUserID returns every sanction history record owned by
// userID, ordered by applied_at ascending.
func (store *Store) ListSanctionsByUserID(ctx context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
if err := userID.Validate(); err != nil {
return nil, fmt.Errorf("list sanctions from postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "list sanctions from postgres")
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(sanctionSelectColumns).
FROM(pgtable.SanctionRecords).
WHERE(pgtable.SanctionRecords.UserID.EQ(pg.String(userID.String()))).
ORDER_BY(pgtable.SanctionRecords.AppliedAt.ASC(), pgtable.SanctionRecords.RecordID.ASC())
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("list sanctions for %q from postgres: %w", userID, err)
}
defer func() { _ = rows.Close() }()
out := make([]policy.SanctionRecord, 0)
for rows.Next() {
record, err := scanSanction(rows)
if err != nil {
return nil, fmt.Errorf("list sanctions for %q from postgres: %w", userID, err)
}
out = append(out, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list sanctions for %q from postgres: %w", userID, err)
}
return out, nil
}
// UpdateSanction replaces one stored sanction history record. The matched
// row is identified by record_id; ports.ErrNotFound is returned when no row
// matches.
func (store *Store) UpdateSanction(ctx context.Context, record policy.SanctionRecord) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("update sanction in postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "update sanction in postgres")
if err != nil {
return err
}
defer cancel()
return updateSanctionRecordTx(operationCtx, store.db, record)
}
func updateSanctionRecordTx(ctx context.Context, q queryer, record policy.SanctionRecord) error {
stmt := pgtable.SanctionRecords.UPDATE(
pgtable.SanctionRecords.UserID,
pgtable.SanctionRecords.SanctionCode,
pgtable.SanctionRecords.Scope,
pgtable.SanctionRecords.ReasonCode,
pgtable.SanctionRecords.ActorType,
pgtable.SanctionRecords.ActorID,
pgtable.SanctionRecords.AppliedAt,
pgtable.SanctionRecords.ExpiresAt,
pgtable.SanctionRecords.RemovedAt,
pgtable.SanctionRecords.RemovedByType,
pgtable.SanctionRecords.RemovedByID,
pgtable.SanctionRecords.RemovedReasonCode,
).SET(
record.UserID.String(),
string(record.SanctionCode),
record.Scope.String(),
record.ReasonCode.String(),
record.Actor.Type.String(),
nullableActorID(record.Actor.ID),
record.AppliedAt.UTC(),
nullableTime(record.ExpiresAt),
nullableTime(record.RemovedAt),
nullableActorType(record.RemovedBy.Type),
nullableActorID(record.RemovedBy.ID),
nullableReasonCode(record.RemovedReasonCode),
).WHERE(pgtable.SanctionRecords.RecordID.EQ(pg.String(record.RecordID.String())))
query, args := stmt.Sql()
res, err := q.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("update sanction %q in postgres: %w", record.RecordID, err)
}
rows, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update sanction %q in postgres: %w", record.RecordID, err)
}
if rows == 0 {
return fmt.Errorf("update sanction %q in postgres: %w", record.RecordID, ports.ErrNotFound)
}
return nil
}
func scanSanctionRow(row *sql.Row) (policy.SanctionRecord, error) {
record, err := scanSanction(row)
if errors.Is(err, sql.ErrNoRows) {
return policy.SanctionRecord{}, ports.ErrNotFound
}
return record, err
}
func scanSanction(row scannableRow) (policy.SanctionRecord, error) {
var (
recordID string
userID string
code string
scope string
reason string
actorType string
actorID *string
appliedAt time.Time
expiresAt *time.Time
removedAt *time.Time
rmByType *string
rmByID *string
rmReason *string
)
if err := row.Scan(
&recordID, &userID, &code, &scope, &reason,
&actorType, &actorID, &appliedAt,
&expiresAt, &removedAt,
&rmByType, &rmByID, &rmReason,
); err != nil {
return policy.SanctionRecord{}, err
}
record := policy.SanctionRecord{
RecordID: policy.SanctionRecordID(recordID),
UserID: common.UserID(userID),
SanctionCode: policy.SanctionCode(code),
Scope: common.Scope(scope),
ReasonCode: common.ReasonCode(reason),
Actor: common.ActorRef{Type: common.ActorType(actorType)},
AppliedAt: appliedAt.UTC(),
ExpiresAt: timeFromNullable(expiresAt),
RemovedAt: timeFromNullable(removedAt),
}
if actorID != nil {
record.Actor.ID = common.ActorID(*actorID)
}
if rmByType != nil {
record.RemovedBy.Type = common.ActorType(*rmByType)
}
if rmByID != nil {
record.RemovedBy.ID = common.ActorID(*rmByID)
}
if rmReason != nil {
record.RemovedReasonCode = common.ReasonCode(*rmReason)
}
return record, nil
}
// CreateLimit stores one new limit history record.
func (store *Store) CreateLimit(ctx context.Context, record policy.LimitRecord) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("create limit in postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "create limit in postgres")
if err != nil {
return err
}
defer cancel()
return insertLimitRecord(operationCtx, store.db, record)
}
func insertLimitRecord(ctx context.Context, q queryer, record policy.LimitRecord) error {
stmt := pgtable.LimitRecords.INSERT(
pgtable.LimitRecords.RecordID,
pgtable.LimitRecords.UserID,
pgtable.LimitRecords.LimitCode,
pgtable.LimitRecords.Value,
pgtable.LimitRecords.ReasonCode,
pgtable.LimitRecords.ActorType,
pgtable.LimitRecords.ActorID,
pgtable.LimitRecords.AppliedAt,
pgtable.LimitRecords.ExpiresAt,
pgtable.LimitRecords.RemovedAt,
pgtable.LimitRecords.RemovedByType,
pgtable.LimitRecords.RemovedByID,
pgtable.LimitRecords.RemovedReasonCode,
).VALUES(
record.RecordID.String(),
record.UserID.String(),
string(record.LimitCode),
record.Value,
record.ReasonCode.String(),
record.Actor.Type.String(),
nullableActorID(record.Actor.ID),
record.AppliedAt.UTC(),
nullableTime(record.ExpiresAt),
nullableTime(record.RemovedAt),
nullableActorType(record.RemovedBy.Type),
nullableActorID(record.RemovedBy.ID),
nullableReasonCode(record.RemovedReasonCode),
)
query, args := stmt.Sql()
_, err := q.ExecContext(ctx, query, args...)
if err == nil {
return nil
}
if isUniqueViolation(err) {
return fmt.Errorf("create limit %q in postgres: %w", record.RecordID, ports.ErrConflict)
}
return fmt.Errorf("create limit %q in postgres: %w", record.RecordID, err)
}
// GetLimitByRecordID returns the limit history record identified by recordID.
func (store *Store) GetLimitByRecordID(ctx context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error) {
if err := recordID.Validate(); err != nil {
return policy.LimitRecord{}, fmt.Errorf("get limit from postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "get limit from postgres")
if err != nil {
return policy.LimitRecord{}, err
}
defer cancel()
stmt := pg.SELECT(limitSelectColumns).
FROM(pgtable.LimitRecords).
WHERE(pgtable.LimitRecords.RecordID.EQ(pg.String(recordID.String())))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
record, err := scanLimitRow(row)
switch {
case errors.Is(err, ports.ErrNotFound):
return policy.LimitRecord{}, fmt.Errorf("get limit %q from postgres: %w", recordID, ports.ErrNotFound)
case err != nil:
return policy.LimitRecord{}, fmt.Errorf("get limit %q from postgres: %w", recordID, err)
}
return record, nil
}
// ListLimitsByUserID returns every limit history record owned by userID,
// ordered by applied_at ascending.
func (store *Store) ListLimitsByUserID(ctx context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
if err := userID.Validate(); err != nil {
return nil, fmt.Errorf("list limits from postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "list limits from postgres")
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(limitSelectColumns).
FROM(pgtable.LimitRecords).
WHERE(pgtable.LimitRecords.UserID.EQ(pg.String(userID.String()))).
ORDER_BY(pgtable.LimitRecords.AppliedAt.ASC(), pgtable.LimitRecords.RecordID.ASC())
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("list limits for %q from postgres: %w", userID, err)
}
defer func() { _ = rows.Close() }()
out := make([]policy.LimitRecord, 0)
for rows.Next() {
record, err := scanLimit(rows)
if err != nil {
return nil, fmt.Errorf("list limits for %q from postgres: %w", userID, err)
}
out = append(out, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list limits for %q from postgres: %w", userID, err)
}
return out, nil
}
// UpdateLimit replaces one stored limit history record.
func (store *Store) UpdateLimit(ctx context.Context, record policy.LimitRecord) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("update limit in postgres: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "update limit in postgres")
if err != nil {
return err
}
defer cancel()
return updateLimitRecordTx(operationCtx, store.db, record)
}
func updateLimitRecordTx(ctx context.Context, q queryer, record policy.LimitRecord) error {
stmt := pgtable.LimitRecords.UPDATE(
pgtable.LimitRecords.UserID,
pgtable.LimitRecords.LimitCode,
pgtable.LimitRecords.Value,
pgtable.LimitRecords.ReasonCode,
pgtable.LimitRecords.ActorType,
pgtable.LimitRecords.ActorID,
pgtable.LimitRecords.AppliedAt,
pgtable.LimitRecords.ExpiresAt,
pgtable.LimitRecords.RemovedAt,
pgtable.LimitRecords.RemovedByType,
pgtable.LimitRecords.RemovedByID,
pgtable.LimitRecords.RemovedReasonCode,
).SET(
record.UserID.String(),
string(record.LimitCode),
record.Value,
record.ReasonCode.String(),
record.Actor.Type.String(),
nullableActorID(record.Actor.ID),
record.AppliedAt.UTC(),
nullableTime(record.ExpiresAt),
nullableTime(record.RemovedAt),
nullableActorType(record.RemovedBy.Type),
nullableActorID(record.RemovedBy.ID),
nullableReasonCode(record.RemovedReasonCode),
).WHERE(pgtable.LimitRecords.RecordID.EQ(pg.String(record.RecordID.String())))
query, args := stmt.Sql()
res, err := q.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("update limit %q in postgres: %w", record.RecordID, err)
}
rows, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update limit %q in postgres: %w", record.RecordID, err)
}
if rows == 0 {
return fmt.Errorf("update limit %q in postgres: %w", record.RecordID, ports.ErrNotFound)
}
return nil
}
func scanLimitRow(row *sql.Row) (policy.LimitRecord, error) {
record, err := scanLimit(row)
if errors.Is(err, sql.ErrNoRows) {
return policy.LimitRecord{}, ports.ErrNotFound
}
return record, err
}
func scanLimit(row scannableRow) (policy.LimitRecord, error) {
var (
recordID string
userID string
code string
value int
reason string
actorType string
actorID *string
appliedAt time.Time
expiresAt *time.Time
removedAt *time.Time
rmByType *string
rmByID *string
rmReason *string
)
if err := row.Scan(
&recordID, &userID, &code, &value, &reason,
&actorType, &actorID, &appliedAt,
&expiresAt, &removedAt,
&rmByType, &rmByID, &rmReason,
); err != nil {
return policy.LimitRecord{}, err
}
record := policy.LimitRecord{
RecordID: policy.LimitRecordID(recordID),
UserID: common.UserID(userID),
LimitCode: policy.LimitCode(code),
Value: value,
ReasonCode: common.ReasonCode(reason),
Actor: common.ActorRef{Type: common.ActorType(actorType)},
AppliedAt: appliedAt.UTC(),
ExpiresAt: timeFromNullable(expiresAt),
RemovedAt: timeFromNullable(removedAt),
}
if actorID != nil {
record.Actor.ID = common.ActorID(*actorID)
}
if rmByType != nil {
record.RemovedBy.Type = common.ActorType(*rmByType)
}
if rmByID != nil {
record.RemovedBy.ID = common.ActorID(*rmByID)
}
if rmReason != nil {
record.RemovedReasonCode = common.ReasonCode(*rmReason)
}
return record, nil
}
// ApplySanction inserts the new sanction history row and points
// sanction_active at it. Re-applying the same code while another active
// record exists returns ports.ErrConflict.
func (store *Store) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("apply sanction in postgres: %w", err)
}
return store.withTx(ctx, "apply sanction in postgres", func(ctx context.Context, tx *sql.Tx) error {
if err := insertSanctionRecord(ctx, tx, input.NewRecord); err != nil {
return err
}
stmt := pgtable.SanctionActive.INSERT(
pgtable.SanctionActive.UserID,
pgtable.SanctionActive.SanctionCode,
pgtable.SanctionActive.RecordID,
).VALUES(
input.NewRecord.UserID.String(),
string(input.NewRecord.SanctionCode),
input.NewRecord.RecordID.String(),
)
query, args := stmt.Sql()
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
if isUniqueViolation(err) {
return fmt.Errorf("apply sanction %q in postgres: %w", input.NewRecord.RecordID, ports.ErrConflict)
}
return fmt.Errorf("apply sanction %q in postgres: %w", input.NewRecord.RecordID, err)
}
return nil
})
}
// RemoveSanction updates the existing sanction record with remove metadata
// and clears the sanction_active row that pointed at it.
func (store *Store) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("remove sanction in postgres: %w", err)
}
return store.withTx(ctx, "remove sanction in postgres", func(ctx context.Context, tx *sql.Tx) error {
if err := lockSanctionMatching(ctx, tx, input.ExpectedActiveRecord); err != nil {
return fmt.Errorf("remove sanction %q in postgres: %w", input.ExpectedActiveRecord.RecordID, err)
}
if err := updateSanctionRecordTx(ctx, tx, input.UpdatedRecord); err != nil {
return err
}
stmt := pgtable.SanctionActive.DELETE().
WHERE(pg.AND(
pgtable.SanctionActive.UserID.EQ(pg.String(input.ExpectedActiveRecord.UserID.String())),
pgtable.SanctionActive.SanctionCode.EQ(pg.String(string(input.ExpectedActiveRecord.SanctionCode))),
pgtable.SanctionActive.RecordID.EQ(pg.String(input.ExpectedActiveRecord.RecordID.String())),
))
query, args := stmt.Sql()
res, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("remove sanction %q in postgres: %w", input.ExpectedActiveRecord.RecordID, err)
}
rows, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("remove sanction %q in postgres: %w", input.ExpectedActiveRecord.RecordID, err)
}
if rows == 0 {
return fmt.Errorf("remove sanction %q in postgres: %w", input.ExpectedActiveRecord.RecordID, ports.ErrConflict)
}
return nil
})
}
// SetLimit creates a new active limit (or replaces one) for the user. When
// ExpectedActiveRecord is nil the call must succeed only if no active row
// exists for (user_id, limit_code); otherwise the existing record is
// updated with remove metadata and superseded by NewRecord.
func (store *Store) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("set limit in postgres: %w", err)
}
return store.withTx(ctx, "set limit in postgres", func(ctx context.Context, tx *sql.Tx) error {
if input.ExpectedActiveRecord != nil {
if err := lockLimitMatching(ctx, tx, *input.ExpectedActiveRecord); err != nil {
return fmt.Errorf("set limit %q in postgres: %w", input.NewRecord.RecordID, err)
}
if err := updateLimitRecordTx(ctx, tx, *input.UpdatedActiveRecord); err != nil {
return err
}
} else {
probe := pg.SELECT(pgtable.LimitActive.RecordID).
FROM(pgtable.LimitActive).
WHERE(pg.AND(
pgtable.LimitActive.UserID.EQ(pg.String(input.NewRecord.UserID.String())),
pgtable.LimitActive.LimitCode.EQ(pg.String(string(input.NewRecord.LimitCode))),
)).
FOR(pg.UPDATE())
probeQuery, probeArgs := probe.Sql()
row := tx.QueryRowContext(ctx, probeQuery, probeArgs...)
var marker string
if err := row.Scan(&marker); err == nil {
return fmt.Errorf("set limit %q in postgres: %w", input.NewRecord.RecordID, ports.ErrConflict)
} else if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("set limit %q in postgres: %w", input.NewRecord.RecordID, err)
}
}
if err := insertLimitRecord(ctx, tx, input.NewRecord); err != nil {
return err
}
upsert := pgtable.LimitActive.INSERT(
pgtable.LimitActive.UserID,
pgtable.LimitActive.LimitCode,
pgtable.LimitActive.RecordID,
pgtable.LimitActive.Value,
).VALUES(
input.NewRecord.UserID.String(),
string(input.NewRecord.LimitCode),
input.NewRecord.RecordID.String(),
input.NewRecord.Value,
).ON_CONFLICT(pgtable.LimitActive.UserID, pgtable.LimitActive.LimitCode).DO_UPDATE(
pg.SET(
pgtable.LimitActive.RecordID.SET(pgtable.LimitActive.EXCLUDED.RecordID),
pgtable.LimitActive.Value.SET(pgtable.LimitActive.EXCLUDED.Value),
),
)
upsertQuery, upsertArgs := upsert.Sql()
if _, err := tx.ExecContext(ctx, upsertQuery, upsertArgs...); err != nil {
return fmt.Errorf("set limit %q in postgres: %w", input.NewRecord.RecordID, err)
}
return nil
})
}
// RemoveLimit updates the limit record with remove metadata and removes the
// active row that referenced it.
func (store *Store) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("remove limit in postgres: %w", err)
}
return store.withTx(ctx, "remove limit in postgres", func(ctx context.Context, tx *sql.Tx) error {
if err := lockLimitMatching(ctx, tx, input.ExpectedActiveRecord); err != nil {
return fmt.Errorf("remove limit %q in postgres: %w", input.ExpectedActiveRecord.RecordID, err)
}
if err := updateLimitRecordTx(ctx, tx, input.UpdatedRecord); err != nil {
return err
}
stmt := pgtable.LimitActive.DELETE().
WHERE(pg.AND(
pgtable.LimitActive.UserID.EQ(pg.String(input.ExpectedActiveRecord.UserID.String())),
pgtable.LimitActive.LimitCode.EQ(pg.String(string(input.ExpectedActiveRecord.LimitCode))),
pgtable.LimitActive.RecordID.EQ(pg.String(input.ExpectedActiveRecord.RecordID.String())),
))
query, args := stmt.Sql()
res, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("remove limit %q in postgres: %w", input.ExpectedActiveRecord.RecordID, err)
}
rows, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("remove limit %q in postgres: %w", input.ExpectedActiveRecord.RecordID, err)
}
if rows == 0 {
return fmt.Errorf("remove limit %q in postgres: %w", input.ExpectedActiveRecord.RecordID, ports.ErrConflict)
}
return nil
})
}
func lockSanctionMatching(ctx context.Context, tx *sql.Tx, expected policy.SanctionRecord) error {
stmt := pg.SELECT(sanctionSelectColumns).
FROM(pgtable.SanctionRecords).
WHERE(pgtable.SanctionRecords.RecordID.EQ(pg.String(expected.RecordID.String()))).
FOR(pg.UPDATE())
query, args := stmt.Sql()
row := tx.QueryRowContext(ctx, query, args...)
current, err := scanSanctionRow(row)
switch {
case errors.Is(err, ports.ErrNotFound):
return ports.ErrNotFound
case err != nil:
return err
}
if !sanctionsEqual(current, expected) {
return ports.ErrConflict
}
return nil
}
func lockLimitMatching(ctx context.Context, tx *sql.Tx, expected policy.LimitRecord) error {
stmt := pg.SELECT(limitSelectColumns).
FROM(pgtable.LimitRecords).
WHERE(pgtable.LimitRecords.RecordID.EQ(pg.String(expected.RecordID.String()))).
FOR(pg.UPDATE())
query, args := stmt.Sql()
row := tx.QueryRowContext(ctx, query, args...)
current, err := scanLimitRow(row)
switch {
case errors.Is(err, ports.ErrNotFound):
return ports.ErrNotFound
case err != nil:
return err
}
if !limitsEqual(current, expected) {
return ports.ErrConflict
}
return nil
}
func sanctionsEqual(left policy.SanctionRecord, right policy.SanctionRecord) bool {
if left.RecordID != right.RecordID ||
left.UserID != right.UserID ||
left.SanctionCode != right.SanctionCode ||
left.Scope != right.Scope ||
left.ReasonCode != right.ReasonCode ||
left.Actor != right.Actor ||
left.RemovedBy != right.RemovedBy ||
left.RemovedReasonCode != right.RemovedReasonCode {
return false
}
if !left.AppliedAt.Equal(right.AppliedAt) {
return false
}
if !optionalTimeEqual(left.ExpiresAt, right.ExpiresAt) {
return false
}
return optionalTimeEqual(left.RemovedAt, right.RemovedAt)
}
func limitsEqual(left policy.LimitRecord, right policy.LimitRecord) bool {
if left.RecordID != right.RecordID ||
left.UserID != right.UserID ||
left.LimitCode != right.LimitCode ||
left.Value != right.Value ||
left.ReasonCode != right.ReasonCode ||
left.Actor != right.Actor ||
left.RemovedBy != right.RemovedBy ||
left.RemovedReasonCode != right.RemovedReasonCode {
return false
}
if !left.AppliedAt.Equal(right.AppliedAt) {
return false
}
if !optionalTimeEqual(left.ExpiresAt, right.ExpiresAt) {
return false
}
return optionalTimeEqual(left.RemovedAt, right.RemovedAt)
}
// SanctionStore adapts Store to the SanctionStore port.
type SanctionStore struct{ store *Store }
// Sanctions returns one adapter that exposes the sanction store port.
func (store *Store) Sanctions() *SanctionStore {
if store == nil {
return nil
}
return &SanctionStore{store: store}
}
// Create stores one new sanction history record.
func (a *SanctionStore) Create(ctx context.Context, record policy.SanctionRecord) error {
return a.store.CreateSanction(ctx, record)
}
// GetByRecordID returns the sanction record identified by recordID.
func (a *SanctionStore) GetByRecordID(ctx context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error) {
return a.store.GetSanctionByRecordID(ctx, recordID)
}
// ListByUserID returns every sanction record owned by userID.
func (a *SanctionStore) ListByUserID(ctx context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
return a.store.ListSanctionsByUserID(ctx, userID)
}
// Update replaces one stored sanction record.
func (a *SanctionStore) Update(ctx context.Context, record policy.SanctionRecord) error {
return a.store.UpdateSanction(ctx, record)
}
var _ ports.SanctionStore = (*SanctionStore)(nil)
// LimitStore adapts Store to the LimitStore port.
type LimitStore struct{ store *Store }
// Limits returns one adapter that exposes the limit store port.
func (store *Store) Limits() *LimitStore {
if store == nil {
return nil
}
return &LimitStore{store: store}
}
// Create stores one new limit history record.
func (a *LimitStore) Create(ctx context.Context, record policy.LimitRecord) error {
return a.store.CreateLimit(ctx, record)
}
// GetByRecordID returns the limit record identified by recordID.
func (a *LimitStore) GetByRecordID(ctx context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error) {
return a.store.GetLimitByRecordID(ctx, recordID)
}
// ListByUserID returns every limit record owned by userID.
func (a *LimitStore) ListByUserID(ctx context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
return a.store.ListLimitsByUserID(ctx, userID)
}
// Update replaces one stored limit record.
func (a *LimitStore) Update(ctx context.Context, record policy.LimitRecord) error {
return a.store.UpdateLimit(ctx, record)
}
var _ ports.LimitStore = (*LimitStore)(nil)
// PolicyLifecycleStore adapts Store to the PolicyLifecycleStore port.
type PolicyLifecycleStore struct{ store *Store }
// PolicyLifecycle returns one adapter that exposes the policy-lifecycle
// store port.
func (store *Store) PolicyLifecycle() *PolicyLifecycleStore {
if store == nil {
return nil
}
return &PolicyLifecycleStore{store: store}
}
// ApplySanction atomically creates one new active sanction record.
func (a *PolicyLifecycleStore) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
return a.store.ApplySanction(ctx, input)
}
// RemoveSanction atomically removes one active sanction record.
func (a *PolicyLifecycleStore) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
return a.store.RemoveSanction(ctx, input)
}
// SetLimit atomically creates or replaces one active limit record.
func (a *PolicyLifecycleStore) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
return a.store.SetLimit(ctx, input)
}
// RemoveLimit atomically removes one active limit record.
func (a *PolicyLifecycleStore) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
return a.store.RemoveLimit(ctx, input)
}
var _ ports.PolicyLifecycleStore = (*PolicyLifecycleStore)(nil)
@@ -0,0 +1,138 @@
// 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)
)
@@ -0,0 +1,656 @@
package userstore
import (
"context"
"errors"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/authblock"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"github.com/stretchr/testify/require"
)
// All time values are aligned to microseconds because PostgreSQL's
// timestamptz only stores microsecond precision; using nanoseconds here
// would cause round-trip mismatches.
var fixtureCreatedAt = time.Unix(1_775_240_000, 0).UTC()
func validAccount() account.UserAccount {
return account.UserAccount{
UserID: common.UserID("user-pilot-001"),
Email: common.Email("pilot@example.com"),
UserName: common.UserName("player-aaaaaaaa"),
DisplayName: common.DisplayName("NovaPrime"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
CreatedAt: fixtureCreatedAt,
UpdatedAt: fixtureCreatedAt,
}
}
func validFreeSnapshot(userID common.UserID, at time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: at.UTC(),
Source: common.Source("auth_signup"),
Actor: common.ActorRef{Type: common.ActorType("auth")},
ReasonCode: common.ReasonCode("initial_free_entitlement"),
UpdatedAt: at.UTC(),
}
}
func validFreePeriod(userID common.UserID, recordID entitlement.EntitlementRecordID, at time.Time) entitlement.PeriodRecord {
return entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: common.Source("auth_signup"),
Actor: common.ActorRef{Type: common.ActorType("auth")},
ReasonCode: common.ReasonCode("initial_free_entitlement"),
StartsAt: at.UTC(),
CreatedAt: at.UTC(),
}
}
func paidPeriod(userID common.UserID, recordID entitlement.EntitlementRecordID, startsAt, endsAt time.Time) entitlement.PeriodRecord {
end := endsAt.UTC()
return entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: entitlement.PlanCodePaidMonthly,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
StartsAt: startsAt.UTC(),
EndsAt: &end,
CreatedAt: startsAt.UTC(),
}
}
func paidSnapshot(userID common.UserID, startsAt, endsAt, updatedAt time.Time) entitlement.CurrentSnapshot {
end := endsAt.UTC()
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: startsAt.UTC(),
EndsAt: &end,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: updatedAt.UTC(),
}
}
func validSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
return policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-" + string(code) + "-1"),
UserID: userID,
SanctionCode: code,
Scope: common.Scope("platform"),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt.UTC(),
}
}
func validLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
return policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-" + string(code) + "-1"),
UserID: userID,
LimitCode: code,
Value: value,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt.UTC(),
}
}
func TestAccountCreateAndLookups(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
record := validAccount()
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
got, err := store.GetByUserID(ctx, record.UserID)
require.NoError(t, err)
require.Equal(t, record, got)
got, err = store.GetByEmail(ctx, record.Email)
require.NoError(t, err)
require.Equal(t, record, got)
got, err = store.GetByUserName(ctx, record.UserName)
require.NoError(t, err)
require.Equal(t, record, got)
exists, err := store.ExistsByUserID(ctx, record.UserID)
require.NoError(t, err)
require.True(t, exists)
}
func TestAccountCreateConflictsAreClassified(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
record := validAccount()
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
// Same UserID -> generic conflict.
require.True(t, errors.Is(store.Create(ctx, ports.CreateAccountInput{Account: record}), ports.ErrConflict))
// Same UserName, different UserID/email -> ErrUserNameConflict (which
// also satisfies errors.Is(ErrConflict)).
clone := validAccount()
clone.UserID = common.UserID("user-pilot-002")
clone.Email = common.Email("pilot2@example.com")
err := store.Create(ctx, ports.CreateAccountInput{Account: clone})
require.True(t, errors.Is(err, ports.ErrUserNameConflict))
require.True(t, errors.Is(err, ports.ErrConflict))
// Same email, different UserID/user_name -> generic conflict.
clone = validAccount()
clone.UserID = common.UserID("user-pilot-003")
clone.UserName = common.UserName("player-bbbbbbbb")
err = store.Create(ctx, ports.CreateAccountInput{Account: clone})
require.True(t, errors.Is(err, ports.ErrConflict))
require.False(t, errors.Is(err, ports.ErrUserNameConflict))
}
func TestAccountUpdateRespectsImmutableFieldsAndSoftDelete(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
record := validAccount()
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
updated := record
updated.DisplayName = common.DisplayName("HelloWorld")
updated.DeclaredCountry = common.CountryCode("DE")
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
require.NoError(t, store.Update(ctx, updated))
got, err := store.GetByUserID(ctx, record.UserID)
require.NoError(t, err)
require.Equal(t, updated, got)
// Mutating user_name must surface as ErrConflict.
mutating := updated
mutating.UserName = common.UserName("player-xxxxxxxx")
require.True(t, errors.Is(store.Update(ctx, mutating), ports.ErrConflict))
// Soft-delete via Update sets DeletedAt; ExistsByUserID flips to false.
deletedAt := updated.UpdatedAt.Add(time.Minute)
soft := updated
soft.DeletedAt = &deletedAt
soft.UpdatedAt = deletedAt
require.NoError(t, store.Update(ctx, soft))
exists, err := store.ExistsByUserID(ctx, record.UserID)
require.NoError(t, err)
require.False(t, exists)
}
func TestBlockedEmailUpsertAndGet(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
record := authblock.BlockedEmailSubject{
Email: common.Email("blocked@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: fixtureCreatedAt,
}
require.NoError(t, store.PutBlockedEmail(ctx, record))
got, err := store.GetBlockedEmail(ctx, record.Email)
require.NoError(t, err)
require.Equal(t, record, got)
// Upsert replaces existing.
updated := record
updated.ReasonCode = common.ReasonCode("admin_blocked")
updated.BlockedAt = record.BlockedAt.Add(time.Hour)
updated.Actor = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
require.NoError(t, store.PutBlockedEmail(ctx, updated))
got, err = store.GetBlockedEmail(ctx, record.Email)
require.NoError(t, err)
require.Equal(t, updated, got)
}
func TestResolveByEmailReturnsCreatableExistingBlockedAndDeleted(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
creatable, err := store.ResolveByEmail(ctx, common.Email("nobody@example.com"))
require.NoError(t, err)
require.Equal(t, ports.AuthResolutionKindCreatable, creatable.Kind)
require.NoError(t, store.PutBlockedEmail(ctx, authblock.BlockedEmailSubject{
Email: common.Email("blocked@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: fixtureCreatedAt,
}))
blocked, err := store.ResolveByEmail(ctx, common.Email("blocked@example.com"))
require.NoError(t, err)
require.Equal(t, ports.AuthResolutionKindBlocked, blocked.Kind)
require.Equal(t, common.ReasonCode("policy_blocked"), blocked.BlockReasonCode)
record := validAccount()
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
existing, err := store.ResolveByEmail(ctx, record.Email)
require.NoError(t, err)
require.Equal(t, ports.AuthResolutionKindExisting, existing.Kind)
require.Equal(t, record.UserID, existing.UserID)
// Soft-delete the account; the email lookup must now resolve to blocked.
deletedAt := record.UpdatedAt.Add(time.Minute)
soft := record
soft.DeletedAt = &deletedAt
soft.UpdatedAt = deletedAt
require.NoError(t, store.Update(ctx, soft))
deletedResult, err := store.ResolveByEmail(ctx, record.Email)
require.NoError(t, err)
require.Equal(t, ports.AuthResolutionKindBlocked, deletedResult.Kind)
require.Equal(t, deletedAccountBlockReasonCode, deletedResult.BlockReasonCode)
}
func TestEnsureByEmailCoversAllOutcomes(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
record := validAccount()
snapshot := validFreeSnapshot(record.UserID, record.CreatedAt)
period := validFreePeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-initial"), record.CreatedAt)
created, err := store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
Email: record.Email,
Account: record,
Entitlement: snapshot,
EntitlementRecord: period,
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeCreated, created.Outcome)
require.Equal(t, record.UserID, created.UserID)
// Second call with the same email returns existing. The Account input
// describes the would-be-created record if no account existed yet; its
// email must match the request email per ports.EnsureByEmailInput.Validate.
existingCandidate := validSecondAccount()
existingCandidate.Email = record.Email
existing, err := store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
Email: record.Email,
Account: existingCandidate,
Entitlement: validFreeSnapshot(existingCandidate.UserID, record.CreatedAt),
EntitlementRecord: validFreePeriod(existingCandidate.UserID, entitlement.EntitlementRecordID("entitlement-second"), record.CreatedAt),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeExisting, existing.Outcome)
require.Equal(t, record.UserID, existing.UserID)
// Blocked email path.
require.NoError(t, store.PutBlockedEmail(ctx, authblock.BlockedEmailSubject{
Email: common.Email("blocked@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: fixtureCreatedAt,
}))
blockedAccount := validSecondAccount()
blockedAccount.Email = common.Email("blocked@example.com")
blockedSnapshot := validFreeSnapshot(blockedAccount.UserID, record.CreatedAt)
blockedPeriod := validFreePeriod(blockedAccount.UserID, entitlement.EntitlementRecordID("entitlement-blocked"), record.CreatedAt)
blocked, err := store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
Email: blockedAccount.Email,
Account: blockedAccount,
Entitlement: blockedSnapshot,
EntitlementRecord: blockedPeriod,
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, blocked.Outcome)
require.Equal(t, common.ReasonCode("policy_blocked"), blocked.BlockReasonCode)
// Soft-deleted account → blocked(account_deleted).
deletedAt := record.UpdatedAt.Add(time.Hour)
soft := record
soft.DeletedAt = &deletedAt
soft.UpdatedAt = deletedAt
require.NoError(t, store.Update(ctx, soft))
deletedCandidate := validSecondAccount()
deletedCandidate.Email = record.Email
deletedCandidate.UserID = common.UserID("user-third")
deletedCandidate.UserName = common.UserName("player-cccccccc")
deletedResult, err := store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
Email: record.Email,
Account: deletedCandidate,
Entitlement: validFreeSnapshot(deletedCandidate.UserID, record.CreatedAt),
EntitlementRecord: validFreePeriod(deletedCandidate.UserID, entitlement.EntitlementRecordID("entitlement-second-2"), record.CreatedAt),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, deletedResult.Outcome)
require.Equal(t, deletedAccountBlockReasonCode, deletedResult.BlockReasonCode)
}
func validSecondAccount() account.UserAccount {
return account.UserAccount{
UserID: common.UserID("user-second"),
Email: common.Email("second@example.com"),
UserName: common.UserName("player-bbbbbbbb"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("UTC"),
CreatedAt: fixtureCreatedAt,
UpdatedAt: fixtureCreatedAt,
}
}
func TestBlockByUserIDAndBlockByEmail(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
record := validAccount()
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
res, err := store.BlockByUserID(ctx, ports.BlockByUserIDInput{
UserID: record.UserID,
ReasonCode: common.ReasonCode("manual_block"),
BlockedAt: fixtureCreatedAt.Add(time.Hour),
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeBlocked, res.Outcome)
require.Equal(t, record.UserID, res.UserID)
// Replay returns AlreadyBlocked.
res, err = store.BlockByUserID(ctx, ports.BlockByUserIDInput{
UserID: record.UserID,
ReasonCode: common.ReasonCode("manual_block"),
BlockedAt: fixtureCreatedAt.Add(2 * time.Hour),
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, res.Outcome)
require.Equal(t, record.UserID, res.UserID)
// Block by email for a non-existing address records the block with
// nil resolved_user_id.
res, err = store.BlockByEmail(ctx, ports.BlockByEmailInput{
Email: common.Email("ghost@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: fixtureCreatedAt.Add(time.Hour),
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeBlocked, res.Outcome)
require.True(t, res.UserID.IsZero())
}
func TestEntitlementSnapshotPutAndGet(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
record := validAccount()
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
snapshot := validFreeSnapshot(record.UserID, record.CreatedAt)
require.NoError(t, store.PutEntitlement(ctx, snapshot))
got, err := store.GetEntitlementByUserID(ctx, record.UserID)
require.NoError(t, err)
require.Equal(t, snapshot, got)
// Upsert replaces.
paid := paidSnapshot(record.UserID, record.CreatedAt, record.CreatedAt.Add(30*24*time.Hour), record.CreatedAt.Add(time.Minute))
require.NoError(t, store.PutEntitlement(ctx, paid))
got, err = store.GetEntitlementByUserID(ctx, record.UserID)
require.NoError(t, err)
require.Equal(t, paid, got)
}
func TestEntitlementHistoryCRUDAndList(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
record := validAccount()
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
first := validFreePeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-1"), record.CreatedAt)
second := paidPeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-2"), record.CreatedAt.Add(time.Hour), record.CreatedAt.Add(48*time.Hour))
require.NoError(t, store.CreateEntitlementRecord(ctx, first))
require.NoError(t, store.CreateEntitlementRecord(ctx, second))
require.True(t, errors.Is(store.CreateEntitlementRecord(ctx, first), ports.ErrConflict))
got, err := store.GetEntitlementRecordByID(ctx, first.RecordID)
require.NoError(t, err)
require.Equal(t, first, got)
list, err := store.ListEntitlementRecordsByUserID(ctx, record.UserID)
require.NoError(t, err)
require.Len(t, list, 2)
require.Equal(t, first.RecordID, list[0].RecordID)
require.Equal(t, second.RecordID, list[1].RecordID)
closedAt := record.CreatedAt.Add(2 * time.Hour)
updated := first
updated.ClosedAt = &closedAt
updated.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
updated.ClosedReasonCode = common.ReasonCode("superseded")
require.NoError(t, store.UpdateEntitlementRecord(ctx, updated))
got, err = store.GetEntitlementRecordByID(ctx, updated.RecordID)
require.NoError(t, err)
require.Equal(t, updated, got)
}
func TestEntitlementLifecycleGrantExtendRevokeRepair(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
record := validAccount()
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
freeSnap := validFreeSnapshot(record.UserID, record.CreatedAt)
freeRecord := validFreePeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-free-1"), record.CreatedAt)
require.NoError(t, store.PutEntitlement(ctx, freeSnap))
require.NoError(t, store.CreateEntitlementRecord(ctx, freeRecord))
closedAt := record.CreatedAt.Add(time.Hour)
closedFree := freeRecord
closedFree.ClosedAt = &closedAt
closedFree.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
closedFree.ClosedReasonCode = common.ReasonCode("superseded")
paidStart := closedAt
paidEnd := paidStart.Add(30 * 24 * time.Hour)
paid := paidPeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-paid-1"), paidStart, paidEnd)
paidSnap := paidSnapshot(record.UserID, paidStart, paidEnd, paidStart)
require.NoError(t, store.GrantEntitlement(ctx, ports.GrantEntitlementInput{
ExpectedCurrentSnapshot: freeSnap,
ExpectedCurrentRecord: freeRecord,
UpdatedCurrentRecord: closedFree,
NewRecord: paid,
NewSnapshot: paidSnap,
}))
got, err := store.GetEntitlementByUserID(ctx, record.UserID)
require.NoError(t, err)
require.Equal(t, paidSnap, got)
// Extend with a new paid segment.
extendStart := paidEnd
extendEnd := extendStart.Add(30 * 24 * time.Hour)
extendRecord := paidPeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-paid-2"), extendStart, extendEnd)
extendSnap := paidSnapshot(record.UserID, paidStart, extendEnd, extendStart)
require.NoError(t, store.ExtendEntitlement(ctx, ports.ExtendEntitlementInput{
ExpectedCurrentSnapshot: paidSnap,
NewRecord: extendRecord,
NewSnapshot: extendSnap,
}))
// Revoke -> back to free.
revokeAt := extendStart.Add(time.Hour)
revokedPaid := extendRecord
revokedPaid.ClosedAt = &revokeAt
revokedPaid.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
revokedPaid.ClosedReasonCode = common.ReasonCode("revoked")
freeAgain := validFreePeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-free-2"), revokeAt)
freeAgainSnap := validFreeSnapshot(record.UserID, revokeAt)
require.NoError(t, store.RevokeEntitlement(ctx, ports.RevokeEntitlementInput{
ExpectedCurrentSnapshot: extendSnap,
ExpectedCurrentRecord: extendRecord,
UpdatedCurrentRecord: revokedPaid,
NewRecord: freeAgain,
NewSnapshot: freeAgainSnap,
}))
got, err = store.GetEntitlementByUserID(ctx, record.UserID)
require.NoError(t, err)
require.Equal(t, freeAgainSnap, got)
}
func TestEntitlementLifecycleConflictsOnSnapshotMismatch(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
record := validAccount()
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
freeSnap := validFreeSnapshot(record.UserID, record.CreatedAt)
require.NoError(t, store.PutEntitlement(ctx, freeSnap))
stale := freeSnap
stale.UpdatedAt = freeSnap.UpdatedAt.Add(-time.Hour)
freeRecord := validFreePeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-free-1"), record.CreatedAt)
require.NoError(t, store.CreateEntitlementRecord(ctx, freeRecord))
closedAt := record.CreatedAt.Add(time.Hour)
closedFree := freeRecord
closedFree.ClosedAt = &closedAt
closedFree.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
closedFree.ClosedReasonCode = common.ReasonCode("superseded")
paid := paidPeriod(record.UserID, entitlement.EntitlementRecordID("entitlement-paid-1"), closedAt, closedAt.Add(time.Hour))
paidSnap := paidSnapshot(record.UserID, closedAt, closedAt.Add(time.Hour), closedAt)
err := store.GrantEntitlement(ctx, ports.GrantEntitlementInput{
ExpectedCurrentSnapshot: stale,
ExpectedCurrentRecord: freeRecord,
UpdatedCurrentRecord: closedFree,
NewRecord: paid,
NewSnapshot: paidSnap,
})
require.True(t, errors.Is(err, ports.ErrConflict))
}
func TestPolicyApplyRemoveSanctionAndLimit(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
record := validAccount()
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: record}))
sanction := validSanction(record.UserID, policy.SanctionCodeLoginBlock, fixtureCreatedAt.Add(time.Minute))
require.NoError(t, store.ApplySanction(ctx, ports.ApplySanctionInput{NewRecord: sanction}))
got, err := store.GetSanctionByRecordID(ctx, sanction.RecordID)
require.NoError(t, err)
require.Equal(t, sanction, got)
// Re-applying the same sanction code without removing first must return
// ErrConflict because (user_id, sanction_code) is unique on
// sanction_active.
dup := sanction
dup.RecordID = policy.SanctionRecordID("sanction-login_block-2")
require.True(t, errors.Is(store.ApplySanction(ctx, ports.ApplySanctionInput{NewRecord: dup}), ports.ErrConflict))
removedAt := sanction.AppliedAt.Add(time.Hour)
updated := sanction
updated.RemovedAt = &removedAt
updated.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
updated.RemovedReasonCode = common.ReasonCode("manual_unblock")
require.NoError(t, store.RemoveSanction(ctx, ports.RemoveSanctionInput{
ExpectedActiveRecord: sanction,
UpdatedRecord: updated,
}))
got, err = store.GetSanctionByRecordID(ctx, sanction.RecordID)
require.NoError(t, err)
require.Equal(t, updated, got)
// Now SetLimit on a fresh code; replay must conflict.
limit := validLimit(record.UserID, policy.LimitCodeMaxOwnedPrivateGames, 5, fixtureCreatedAt.Add(2*time.Minute))
require.NoError(t, store.SetLimit(ctx, ports.SetLimitInput{NewRecord: limit}))
dupLimit := limit
dupLimit.RecordID = policy.LimitRecordID("limit-max_owned_private_games-2")
require.True(t, errors.Is(store.SetLimit(ctx, ports.SetLimitInput{NewRecord: dupLimit}), ports.ErrConflict))
// SetLimit with ExpectedActiveRecord -> replaces in the active slot.
expected := limit
expected.RemovedAt = nil
expected.RemovedBy = common.ActorRef{}
expected.RemovedReasonCode = ""
supersededTime := limit.AppliedAt.Add(time.Hour)
supersededLimit := limit
supersededLimit.RemovedAt = &supersededTime
supersededLimit.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
supersededLimit.RemovedReasonCode = common.ReasonCode("superseded")
newLimit := validLimit(record.UserID, policy.LimitCodeMaxOwnedPrivateGames, 7, supersededTime)
newLimit.RecordID = policy.LimitRecordID("limit-max_owned_private_games-3")
require.NoError(t, store.SetLimit(ctx, ports.SetLimitInput{
ExpectedActiveRecord: &expected,
UpdatedActiveRecord: &supersededLimit,
NewRecord: newLimit,
}))
gotLimit, err := store.GetLimitByRecordID(ctx, newLimit.RecordID)
require.NoError(t, err)
require.Equal(t, newLimit, gotLimit)
}
func TestUserListPaginatesNewestFirstAndDetectsFilterMismatch(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
base := fixtureCreatedAt
for index, suffix := range []string{"a", "b", "c", "d", "e"} {
acc := validAccount()
acc.UserID = common.UserID("user-list-" + suffix)
acc.Email = common.Email("list-" + suffix + "@example.com")
acc.UserName = common.UserName("player-list" + suffix + "xx")
acc.CreatedAt = base.Add(time.Duration(index) * time.Minute)
acc.UpdatedAt = acc.CreatedAt
require.NoError(t, store.Create(ctx, ports.CreateAccountInput{Account: acc}))
}
page1, err := store.ListUserIDs(ctx, ports.ListUsersInput{PageSize: 2})
require.NoError(t, err)
require.Len(t, page1.UserIDs, 2)
require.Equal(t, common.UserID("user-list-e"), page1.UserIDs[0])
require.Equal(t, common.UserID("user-list-d"), page1.UserIDs[1])
require.NotEmpty(t, page1.NextPageToken)
page2, err := store.ListUserIDs(ctx, ports.ListUsersInput{
PageSize: 2,
PageToken: page1.NextPageToken,
})
require.NoError(t, err)
require.Len(t, page2.UserIDs, 2)
require.Equal(t, common.UserID("user-list-c"), page2.UserIDs[0])
require.Equal(t, common.UserID("user-list-b"), page2.UserIDs[1])
// Mismatched filters must reject the previously-issued token.
mismatched, err := store.ListUserIDs(ctx, ports.ListUsersInput{
PageSize: 2,
PageToken: page1.NextPageToken,
Filters: ports.UserListFilters{PaidState: entitlement.PaidStatePaid},
})
require.True(t, errors.Is(err, ports.ErrInvalidPageToken), "got result %#v err %v", mismatched, err)
}