package sqlite import ( "context" "database/sql" "errors" "fmt" "git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/ident" ) // UserRepository — учётные записи сервиса. type UserRepository struct { db *DB } func NewUserRepository(db *DB) *UserRepository { return &UserRepository{db: db} } // EnsureUser находит учётную запись по логину у провайдера, а не найдя — заводит // её. // // **Дом правила один, и он здесь, а не в транспорте.** Второй способ // представиться — личные токены — возьмёт этот же метод; правило, уложенное // куском в слой транспорта, пришлось бы тогда либо дублировать вторым куском, // либо вытаскивать задним числом. // // Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью // в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди // его работы. // // **Поиск идёт читающим пулом, и пишущая транзакция открывается только тогда, // когда запись не нашлась.** Узнавание одето на весь корень приложения, поэтому // пишущая транзакция, взятая до поиска, доставалась бы всему узнанному потоку — // опросу карточки и каждому запросу диапазона при проигрывании, — и вставала бы // в очередь к единственному пишущему соединению. Ждать там нечего: заводится // учётная запись один раз за жизнь человека. // // **Окно между двумя соединениями закрыто повторным поиском внутри // транзакции.** Между поиском читающим пулом и открытием пишущей транзакции // запись успевает завести сосед; ветвь ниже находит её и берёт заведённую, а // уникальность ключа держит схема — не порядок обращений. func (repo *UserRepository) EnsureUser(identity contract.Identity) (*contract.UserAccount, bool, error) { login, ok := entity.AcceptProviderLogin(identity.Login) if !ok { return nil, false, contract.ErrLoginNotAcceptable } account, err := findUserByLogin(repo.db.Reader(), login) if err != nil { return nil, false, err } if account != nil { return account, false, nil } tx, err := repo.db.Writer().BeginTx(context.Background(), nil) if err != nil { return nil, false, fmt.Errorf("failed to open a transaction for the user account: %w", err) } defer func() { _ = tx.Rollback() }() // Повторный поиск закрывает окно между читающим пулом и пишущей // транзакцией: пока её ждали, запись мог завести сосед. account, err = findUserByLogin(tx, login) if err != nil { return nil, false, err } if account != nil { return account, false, commitAccount(tx, account) } name := entity.AcceptDisplayName(identity.Name) email, _ := entity.AcceptEmail(identity.Email) account, err = insertUser(tx, login, name, email) switch { case err == nil: return account, true, commitAccount(tx, account) case !isUniqueViolation(err): return nil, false, fmt.Errorf("failed to create user account: %w", err) } // **Два отказа уникальности различаются, и исход у них разный**, а какая // колонка не сошлась, код отказа не называет. Различает их повторный поиск // по ключу: нашёлся — это гонка двух первых обращений одним логином, и надо // просто взять заведённую соседом запись. account, err = findUserByLogin(tx, login) if err != nil { return nil, false, err } if account != nil { return account, false, commitAccount(tx, account) } // Не нашёлся — значит не сошлась другая колонка: адрес почты, пришедший от // провайдера, занят другой учётной записью (общий ящик, семья, группа). // Запись заводится **без почты**: она необязательна и ключом не служит. Без // этого разреза второй человек с общим адресом не завёлся бы никогда — // повторный поиск по логину снова ничего не находит. account, err = insertUser(tx, login, name, "") if err != nil { return nil, false, fmt.Errorf("failed to create user account without email: %w", err) } return account, true, commitAccount(tx, account) } func commitAccount(tx *sql.Tx, account *contract.UserAccount) error { if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit the user account %s: %w", account.ID, err) } return nil } func insertUser(tx *sql.Tx, login, name, email string) (*contract.UserAccount, error) { id := ident.New() now := formatTime(clock.Now()) _, err := tx.ExecContext(context.Background(), `INSERT INTO users (id, provider_login, name, email, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, id, login, name, email, now, now, ) if err != nil { return nil, err } return &contract.UserAccount{ID: id, Name: name}, nil } // rowQuerier — то общее, чем поиск учётной записи пользуется у читающего пула и // у пишущей транзакции. Оба поиска — до транзакции и внутри неё — идут одним // запросом: второй его копией они разошлись бы молча. type rowQuerier interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row } // findUserByLogin ищет учётную запись по ключу. Значение уходит базе // **параметром** запроса, а не подстановкой в текст: строка приходит снаружи, и // подставленная в текст она правила бы сам запрос, а не только его аргумент. func findUserByLogin(q rowQuerier, login string) (*contract.UserAccount, error) { account := &contract.UserAccount{} err := q.QueryRowContext(context.Background(), "SELECT id, name FROM users WHERE provider_login = ?", login, ).Scan(&account.ID, &account.Name) switch { case err == nil: return account, nil case errors.Is(err, sql.ErrNoRows): return nil, nil default: return nil, fmt.Errorf("failed to look up user account: %w", err) } }