52f898ca6f
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Link an email (confirm-code) or Telegram (web Login Widget) to the current account; if the identity already has its own account, merge the two into the one in use (the current account is primary, except a guest initiator whose durable counterpart wins). The merge runs in one transaction (internal/accountmerge): stats + hint wallet summed, paid_account ORed, identities/games/chat/complaints transferred, friends/blocks de-duplicated, the secondary kept as a merged_into tombstone so a shared finished game's no-cascade FKs hold; a shared active game blocks the merge. - migration 00009: accounts.paid_account, merged_into, merged_at (+ jetgen) - internal/link orchestrator; session.RevokeAllForAccount on merge - connector ValidateLoginWidget RPC + loginwidget HMAC validator - edge ops link.email.request/confirm/merge, link.telegram.confirm/merge; supersedes the Stage 8 email.bind.* surface (request never reveals 'taken' before the code is verified, so a probe cannot enumerate addresses) - UI Profile link section + irreversible-merge dialog; Telegram web sign-in - focused regression tests (merge core, guest inversion, active-game refusal, finished-shared-game kept), gateway transcode + connector + UI codec/e2e - docs: PLAN, ARCHITECTURE 3/4/9, FUNCTIONAL(+ru), module READMEs
498 lines
20 KiB
Go
498 lines
20 KiB
Go
// Package accountmerge retires a secondary account into a primary one in a single
|
|
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
|
|
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
|
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
|
// (accounts.merged_into). It is the data core of Stage 11 account linking & merge
|
|
// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated
|
|
// one layer up (the link service), since the in-memory session cache lives there.
|
|
package accountmerge
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/go-jet/jet/v2/postgres"
|
|
"github.com/go-jet/jet/v2/qrm"
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
|
)
|
|
|
|
// statusActive mirrors game.StatusActive; the active-shared-game guard reads it
|
|
// without taking a dependency on the game package.
|
|
const statusActive = "active"
|
|
|
|
// Friendship statuses, highest precedence first, mirroring internal/social.
|
|
const (
|
|
friendAccepted = "accepted"
|
|
friendPending = "pending"
|
|
friendDeclined = "declined"
|
|
)
|
|
|
|
// ErrActiveGameConflict is returned when the primary and secondary accounts share
|
|
// an active game: merging would seat one player against themselves, so the caller
|
|
// must wait for the game to finish.
|
|
var ErrActiveGameConflict = errors.New("accountmerge: primary and secondary share an active game")
|
|
|
|
// ErrSameAccount is returned when primary and secondary are the same account.
|
|
var ErrSameAccount = errors.New("accountmerge: primary and secondary are the same account")
|
|
|
|
// Merger merges accounts over a Postgres handle.
|
|
type Merger struct {
|
|
db *sql.DB
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewMerger constructs a Merger over db.
|
|
func NewMerger(db *sql.DB) *Merger {
|
|
return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }}
|
|
}
|
|
|
|
// Merge retires secondary into primary atomically. The secondary is kept as a
|
|
// tombstone (merged_into=primary) so the no-cascade foreign keys of any shared
|
|
// finished game stay valid; its seat in such a game is left untouched. The merge
|
|
// is refused with ErrActiveGameConflict when the two share an active game.
|
|
func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error {
|
|
if primary == secondary {
|
|
return ErrSameAccount
|
|
}
|
|
now := m.now()
|
|
return withTx(ctx, m.db, func(tx *sql.Tx) error {
|
|
if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil {
|
|
return err
|
|
}
|
|
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
|
|
return err
|
|
}
|
|
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
|
return err
|
|
}
|
|
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
|
|
return fmt.Errorf("accountmerge: identities: %w", err)
|
|
}
|
|
if err := transferGamePlayers(ctx, tx, primary, secondary); err != nil {
|
|
return err
|
|
}
|
|
if err := reassignColumn(ctx, tx, table.ChatMessages, table.ChatMessages.SenderID, primary, secondary); err != nil {
|
|
return fmt.Errorf("accountmerge: chat: %w", err)
|
|
}
|
|
if err := reassignColumn(ctx, tx, table.Complaints, table.Complaints.ComplainantID, primary, secondary); err != nil {
|
|
return fmt.Errorf("accountmerge: complaints: %w", err)
|
|
}
|
|
if err := mergeFriendships(ctx, tx, primary, secondary); err != nil {
|
|
return err
|
|
}
|
|
if err := mergeBlocks(ctx, tx, primary, secondary); err != nil {
|
|
return err
|
|
}
|
|
if err := mergeInvitations(ctx, tx, primary, secondary); err != nil {
|
|
return err
|
|
}
|
|
if err := deleteEphemerals(ctx, tx, secondary); err != nil {
|
|
return err
|
|
}
|
|
return tombstone(ctx, tx, primary, secondary, now)
|
|
})
|
|
}
|
|
|
|
// guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary
|
|
// are both seated in the same active game.
|
|
func guardActiveSharedGame(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
|
pri, err := activeGameIDs(ctx, tx, primary)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(pri) == 0 {
|
|
return nil
|
|
}
|
|
sec, err := activeGameIDs(ctx, tx, secondary)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
have := make(map[uuid.UUID]struct{}, len(pri))
|
|
for _, id := range pri {
|
|
have[id] = struct{}{}
|
|
}
|
|
for _, id := range sec {
|
|
if _, ok := have[id]; ok {
|
|
return ErrActiveGameConflict
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// activeGameIDs lists the active games accountID is seated in.
|
|
func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid.UUID, error) {
|
|
stmt := postgres.SELECT(table.GamePlayers.GameID).
|
|
FROM(table.GamePlayers.INNER_JOIN(table.Games, table.Games.GameID.EQ(table.GamePlayers.GameID))).
|
|
WHERE(
|
|
table.GamePlayers.AccountID.EQ(postgres.UUID(accountID)).
|
|
AND(table.Games.Status.EQ(postgres.String(statusActive))),
|
|
)
|
|
var rows []model.GamePlayers
|
|
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
|
|
if errors.Is(err, qrm.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("accountmerge: active games %s: %w", accountID, err)
|
|
}
|
|
out := make([]uuid.UUID, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, r.GameID)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
|
|
// summed, max points kept) and deletes the secondary row.
|
|
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
|
var sec model.AccountStats
|
|
err := postgres.SELECT(table.AccountStats.AllColumns).
|
|
FROM(table.AccountStats).
|
|
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary))).
|
|
QueryContext(ctx, tx, &sec)
|
|
if errors.Is(err, qrm.ErrNoRows) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("accountmerge: load secondary stats: %w", err)
|
|
}
|
|
|
|
ensure := table.AccountStats.INSERT(table.AccountStats.AccountID).
|
|
VALUES(primary).ON_CONFLICT(table.AccountStats.AccountID).DO_NOTHING()
|
|
if _, err := ensure.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("accountmerge: ensure primary stats: %w", err)
|
|
}
|
|
var pri model.AccountStats
|
|
if err := postgres.SELECT(table.AccountStats.AllColumns).
|
|
FROM(table.AccountStats).
|
|
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary))).
|
|
FOR(postgres.UPDATE()).
|
|
QueryContext(ctx, tx, &pri); err != nil {
|
|
return fmt.Errorf("accountmerge: lock primary stats: %w", err)
|
|
}
|
|
|
|
upd := table.AccountStats.UPDATE(
|
|
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
|
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
|
|
).SET(
|
|
postgres.Int(int64(pri.Wins+sec.Wins)),
|
|
postgres.Int(int64(pri.Losses+sec.Losses)),
|
|
postgres.Int(int64(pri.Draws+sec.Draws)),
|
|
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
|
|
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
|
|
postgres.TimestampzT(now),
|
|
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
|
|
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("accountmerge: update primary stats: %w", err)
|
|
}
|
|
|
|
del := table.AccountStats.DELETE().WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary)))
|
|
if _, err := del.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("accountmerge: delete secondary stats: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
|
|
// all other profile fields stay the primary's.
|
|
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
|
var sec model.Accounts
|
|
if err := postgres.SELECT(table.Accounts.AllColumns).
|
|
FROM(table.Accounts).
|
|
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))).
|
|
QueryContext(ctx, tx, &sec); err != nil {
|
|
return fmt.Errorf("accountmerge: load secondary account: %w", err)
|
|
}
|
|
upd := table.Accounts.UPDATE(
|
|
table.Accounts.HintBalance, table.Accounts.PaidAccount, table.Accounts.UpdatedAt,
|
|
).SET(
|
|
table.Accounts.HintBalance.ADD(postgres.Int(int64(sec.HintBalance))),
|
|
table.Accounts.PaidAccount.OR(postgres.Bool(sec.PaidAccount)),
|
|
postgres.TimestampzT(now),
|
|
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
|
|
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("accountmerge: update primary account: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// transferGamePlayers moves secondary's seats to primary, except in a game primary
|
|
// already sits in (a shared finished game — active is barred by the guard), where
|
|
// the secondary seat is left as the tombstone so the no-cascade FK stays valid.
|
|
func transferGamePlayers(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
|
var prows []model.GamePlayers
|
|
if err := postgres.SELECT(table.GamePlayers.GameID).
|
|
FROM(table.GamePlayers).
|
|
WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(primary))).
|
|
QueryContext(ctx, tx, &prows); err != nil {
|
|
if !errors.Is(err, qrm.ErrNoRows) {
|
|
return fmt.Errorf("accountmerge: primary seats: %w", err)
|
|
}
|
|
}
|
|
cond := table.GamePlayers.AccountID.EQ(postgres.UUID(secondary))
|
|
if len(prows) > 0 {
|
|
ids := make([]postgres.Expression, len(prows))
|
|
for i, r := range prows {
|
|
ids[i] = postgres.UUID(r.GameID)
|
|
}
|
|
cond = cond.AND(table.GamePlayers.GameID.NOT_IN(ids...))
|
|
}
|
|
upd := table.GamePlayers.UPDATE(table.GamePlayers.AccountID).SET(postgres.UUID(primary)).WHERE(cond)
|
|
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("accountmerge: transfer seats: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reassignColumn blanket-reassigns a no-collision account column from secondary to
|
|
// primary (identities, chat sender, complaint complainant).
|
|
func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col postgres.ColumnString, primary, secondary uuid.UUID) error {
|
|
upd := tbl.UPDATE(col).SET(postgres.UUID(primary)).
|
|
WHERE(col.EQ(postgres.UUID(secondary)))
|
|
_, err := upd.ExecContext(ctx, tx)
|
|
return err
|
|
}
|
|
|
|
// friendRank ranks a friendship status for dedupe precedence (higher wins).
|
|
func friendRank(status string) int {
|
|
switch status {
|
|
case friendAccepted:
|
|
return 3
|
|
case friendPending:
|
|
return 2
|
|
case friendDeclined:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// mergeFriendships repoints secondary's friendships to primary, dropping the direct
|
|
// primary-secondary edge (it would become a self-edge) and de-duplicating a shared
|
|
// counterparty by keeping the higher-precedence status (accepted > pending >
|
|
// declined). Each account has at most one edge per unordered pair, so the per-other
|
|
// decision is unambiguous.
|
|
func mergeFriendships(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
|
if err := deletePair(ctx, tx, table.Friendships.DELETE(),
|
|
table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, secondary); err != nil {
|
|
return fmt.Errorf("accountmerge: drop self-friendship: %w", err)
|
|
}
|
|
|
|
priByOther := map[uuid.UUID]string{}
|
|
var prows []model.Friendships
|
|
if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, &prows); err != nil {
|
|
return fmt.Errorf("accountmerge: primary friendships: %w", err)
|
|
}
|
|
for _, r := range prows {
|
|
priByOther[otherOf(r.RequesterID, r.AddresseeID, primary)] = r.Status
|
|
}
|
|
|
|
var srows []model.Friendships
|
|
if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, secondary, &srows); err != nil {
|
|
return fmt.Errorf("accountmerge: secondary friendships: %w", err)
|
|
}
|
|
for _, r := range srows {
|
|
other := otherOf(r.RequesterID, r.AddresseeID, secondary)
|
|
if priStatus, ok := priByOther[other]; ok {
|
|
if friendRank(r.Status) <= friendRank(priStatus) {
|
|
if err := deleteEdge(ctx, tx, table.Friendships.DELETE(),
|
|
table.Friendships.RequesterID, table.Friendships.AddresseeID, r.RequesterID, r.AddresseeID); err != nil {
|
|
return fmt.Errorf("accountmerge: drop dominated friendship: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
if err := deletePair(ctx, tx, table.Friendships.DELETE(),
|
|
table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, other); err != nil {
|
|
return fmt.Errorf("accountmerge: drop superseded friendship: %w", err)
|
|
}
|
|
}
|
|
if err := repointEdge(ctx, tx, table.Friendships, table.Friendships.RequesterID, table.Friendships.AddresseeID,
|
|
r.RequesterID, r.AddresseeID, primary, secondary); err != nil {
|
|
return fmt.Errorf("accountmerge: repoint friendship: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mergeBlocks repoints secondary's blocks to primary, dropping the direct
|
|
// primary-secondary block (a self-block) and de-duplicating a counterparty already
|
|
// blocked by primary in either direction (a block is undirected for suppression).
|
|
func mergeBlocks(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
|
if err := deletePair(ctx, tx, table.Blocks.DELETE(),
|
|
table.Blocks.BlockerID, table.Blocks.BlockedID, primary, secondary); err != nil {
|
|
return fmt.Errorf("accountmerge: drop self-block: %w", err)
|
|
}
|
|
|
|
priOthers := map[uuid.UUID]struct{}{}
|
|
var prows []model.Blocks
|
|
if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, primary, &prows); err != nil {
|
|
return fmt.Errorf("accountmerge: primary blocks: %w", err)
|
|
}
|
|
for _, r := range prows {
|
|
priOthers[otherOf(r.BlockerID, r.BlockedID, primary)] = struct{}{}
|
|
}
|
|
|
|
var srows []model.Blocks
|
|
if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, secondary, &srows); err != nil {
|
|
return fmt.Errorf("accountmerge: secondary blocks: %w", err)
|
|
}
|
|
for _, r := range srows {
|
|
if _, ok := priOthers[otherOf(r.BlockerID, r.BlockedID, secondary)]; ok {
|
|
if err := deleteEdge(ctx, tx, table.Blocks.DELETE(),
|
|
table.Blocks.BlockerID, table.Blocks.BlockedID, r.BlockerID, r.BlockedID); err != nil {
|
|
return fmt.Errorf("accountmerge: drop dup block: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
if err := repointEdge(ctx, tx, table.Blocks, table.Blocks.BlockerID, table.Blocks.BlockedID,
|
|
r.BlockerID, r.BlockedID, primary, secondary); err != nil {
|
|
return fmt.Errorf("accountmerge: repoint block: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mergeInvitations deletes secondary's pending invitations as inviter (cascading to
|
|
// their invitees) and repoints its invitee rows to primary, dropping a row where
|
|
// primary is already an invitee of the same invitation.
|
|
func mergeInvitations(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
|
delInv := table.GameInvitations.DELETE().
|
|
WHERE(table.GameInvitations.InviterID.EQ(postgres.UUID(secondary)))
|
|
if _, err := delInv.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("accountmerge: delete secondary invitations: %w", err)
|
|
}
|
|
|
|
priInv := map[uuid.UUID]struct{}{}
|
|
var prows []model.GameInvitationInvitees
|
|
if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
|
|
FROM(table.GameInvitationInvitees).
|
|
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(primary))).
|
|
QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
|
return fmt.Errorf("accountmerge: primary invitees: %w", err)
|
|
}
|
|
for _, r := range prows {
|
|
priInv[r.InvitationID] = struct{}{}
|
|
}
|
|
|
|
var srows []model.GameInvitationInvitees
|
|
if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
|
|
FROM(table.GameInvitationInvitees).
|
|
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary))).
|
|
QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
|
return fmt.Errorf("accountmerge: secondary invitees: %w", err)
|
|
}
|
|
for _, r := range srows {
|
|
where := table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(r.InvitationID)).
|
|
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary)))
|
|
if _, dup := priInv[r.InvitationID]; dup {
|
|
if _, err := table.GameInvitationInvitees.DELETE().WHERE(where).ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("accountmerge: drop dup invitee: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
upd := table.GameInvitationInvitees.UPDATE(table.GameInvitationInvitees.AccountID).
|
|
SET(postgres.UUID(primary)).WHERE(where)
|
|
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("accountmerge: repoint invitee: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deleteEphemerals drops the secondary's pending email confirmations and friend
|
|
// codes (short-lived, single-use; not worth carrying over).
|
|
func deleteEphemerals(ctx context.Context, tx *sql.Tx, secondary uuid.UUID) error {
|
|
if _, err := table.EmailConfirmations.DELETE().
|
|
WHERE(table.EmailConfirmations.AccountID.EQ(postgres.UUID(secondary))).
|
|
ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("accountmerge: delete confirmations: %w", err)
|
|
}
|
|
if _, err := table.FriendCodes.DELETE().
|
|
WHERE(table.FriendCodes.AccountID.EQ(postgres.UUID(secondary))).
|
|
ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("accountmerge: delete friend codes: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// tombstone marks secondary retired, pointing at primary for audit.
|
|
func tombstone(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
|
upd := table.Accounts.UPDATE(table.Accounts.MergedInto, table.Accounts.MergedAt, table.Accounts.UpdatedAt).
|
|
SET(postgres.UUID(primary), postgres.TimestampzT(now), postgres.TimestampzT(now)).
|
|
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary)))
|
|
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("accountmerge: tombstone secondary: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// otherOf returns the endpoint of a two-account edge that is not self.
|
|
func otherOf(a, b, self uuid.UUID) uuid.UUID {
|
|
if a == self {
|
|
return b
|
|
}
|
|
return a
|
|
}
|
|
|
|
// selectEdges loads the rows of a symmetric two-column edge table touching account.
|
|
func selectEdges[T any](ctx context.Context, tx *sql.Tx, tbl postgres.Table, cols postgres.Projection, left, right postgres.ColumnString, account uuid.UUID, dest *[]T) error {
|
|
err := postgres.SELECT(cols).
|
|
FROM(tbl).
|
|
WHERE(left.EQ(postgres.UUID(account)).OR(right.EQ(postgres.UUID(account)))).
|
|
QueryContext(ctx, tx, dest)
|
|
if errors.Is(err, qrm.ErrNoRows) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// deletePair deletes the directed-or-reverse edge between a and b.
|
|
func deletePair(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, a, b uuid.UUID) error {
|
|
cond := left.EQ(postgres.UUID(a)).AND(right.EQ(postgres.UUID(b))).
|
|
OR(left.EQ(postgres.UUID(b)).AND(right.EQ(postgres.UUID(a))))
|
|
_, err := del.WHERE(cond).ExecContext(ctx, tx)
|
|
return err
|
|
}
|
|
|
|
// deleteEdge deletes the single edge identified by its (left, right) primary key.
|
|
func deleteEdge(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, l, r uuid.UUID) error {
|
|
cond := left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(r)))
|
|
_, err := del.WHERE(cond).ExecContext(ctx, tx)
|
|
return err
|
|
}
|
|
|
|
// repointEdge replaces the secondary endpoint of edge (l, r) with primary, keeping
|
|
// the edge's direction.
|
|
func repointEdge(ctx context.Context, tx *sql.Tx, tbl postgres.Table, left, right postgres.ColumnString, l, r, primary, secondary uuid.UUID) error {
|
|
var col postgres.ColumnString
|
|
var where postgres.BoolExpression
|
|
if l == secondary {
|
|
col, where = left, left.EQ(postgres.UUID(secondary)).AND(right.EQ(postgres.UUID(r)))
|
|
} else {
|
|
col, where = right, left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(secondary)))
|
|
}
|
|
_, err := tbl.UPDATE(col).SET(postgres.UUID(primary)).WHERE(where).ExecContext(ctx, tx)
|
|
return err
|
|
}
|
|
|
|
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
|
|
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("accountmerge: begin tx: %w", err)
|
|
}
|
|
if err := fn(tx); err != nil {
|
|
_ = tx.Rollback()
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("accountmerge: commit tx: %w", err)
|
|
}
|
|
return nil
|
|
}
|