- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
206 lines
8.4 KiB
Go
206 lines
8.4 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
|
)
|
|
|
|
type TextRepository struct {
|
|
db *DB
|
|
}
|
|
|
|
func NewTextRepository(db *DB) *TextRepository {
|
|
return &TextRepository{db: db}
|
|
}
|
|
|
|
// Put кладёт текст записи, заменяя прежний того же вида.
|
|
//
|
|
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
|
|
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
|
|
// человеку» стал бы вопросом порядка записи, а не состояния.
|
|
//
|
|
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
|
|
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
|
|
// умер, сохранение рубежа отказало, человек вернул запись в работу. Провайдер
|
|
// при этом вправе ответить пустым потоком, отказом это не считается, и
|
|
// безусловная замена стирала бы сохранённую расшифровку живого человека без
|
|
// следа и без возврата. Та же защита стоит у сохранённого ответа провайдера, и
|
|
// разное правило у двух хранителей одного результата читалось бы как недосмотр.
|
|
//
|
|
// **Граница транзакции — весь метод.** Он читает состояние, которое сам же
|
|
// пишет, и идёт целиком по пишущему соединению: разорванный надвое, он завёл бы
|
|
// вторую строку на гонке двух шагов.
|
|
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
|
|
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open a transaction for the text of record %s: %w", recordID, err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
var (
|
|
id string
|
|
existing string
|
|
)
|
|
err = tx.QueryRowContext(context.Background(),
|
|
"SELECT id, contents FROM texts WHERE record_id = ? AND kind = ?", recordID, kind,
|
|
).Scan(&id, &existing)
|
|
|
|
now := formatTime(clock.Now())
|
|
|
|
switch {
|
|
case err == nil:
|
|
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
|
|
// есть, и вызывающий получает её обратно.
|
|
if contents == "" && existing != "" {
|
|
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: existing}, nil
|
|
}
|
|
if _, err := tx.ExecContext(context.Background(),
|
|
"UPDATE texts SET contents = ?, updated_at = ? WHERE id = ?", contents, now, id,
|
|
); err != nil {
|
|
// Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от
|
|
// драйвера несёт значение поля.
|
|
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
|
|
}
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
id = ident.New()
|
|
if _, err := tx.ExecContext(context.Background(),
|
|
`INSERT INTO texts (id, record_id, kind, contents, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
id, recordID, kind, contents, now, now,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
|
|
}
|
|
default:
|
|
// Отказ базы «строкой нет» не является, и подменять его вставкой нельзя:
|
|
// она упрётся в уникальный индекс, и наверх уедет жалоба на запись
|
|
// вместо правды о недоступной базе.
|
|
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("failed to commit the text of record %s: %w", recordID, err)
|
|
}
|
|
|
|
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: contents}, nil
|
|
}
|
|
|
|
func (repo *TextRepository) GetByID(id string) (*entity.Text, error) {
|
|
text := &entity.Text{Id: id}
|
|
err := repo.db.Reader().QueryRowContext(context.Background(),
|
|
"SELECT record_id, kind, contents FROM texts WHERE id = ?", id,
|
|
).Scan(&text.RecordID, &text.Kind, &text.Contents)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get text %s: %w", id, err)
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
type StructureRepository struct {
|
|
db *DB
|
|
}
|
|
|
|
func NewStructureRepository(db *DB) *StructureRepository {
|
|
return &StructureRepository{db: db}
|
|
}
|
|
|
|
// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот
|
|
// же, что и у текста: повтор шага не должен заводить второй строки, а пустой
|
|
// перечень реплик поверх непустого не кладётся.
|
|
func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) {
|
|
if replicas == nil {
|
|
replicas = []entity.Replica{}
|
|
}
|
|
contents, err := json.Marshal(replicas)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode structure of record %s", recordID)
|
|
}
|
|
|
|
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open a transaction for the structure of record %s: %w", recordID, err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
var (
|
|
id string
|
|
existing string
|
|
)
|
|
err = tx.QueryRowContext(context.Background(),
|
|
"SELECT id, contents FROM structures WHERE record_id = ? AND version = ?", recordID, version,
|
|
).Scan(&id, &existing)
|
|
|
|
now := formatTime(clock.Now())
|
|
|
|
switch {
|
|
case err == nil:
|
|
if len(replicas) == 0 && len(existing) > len("[]") {
|
|
stored, decodeErr := decodeReplicas(id, existing)
|
|
if decodeErr != nil {
|
|
return nil, decodeErr
|
|
}
|
|
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: stored}, nil
|
|
}
|
|
if _, err := tx.ExecContext(context.Background(),
|
|
"UPDATE structures SET contents = ?, updated_at = ? WHERE id = ?", string(contents), now, id,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
|
|
}
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
id = ident.New()
|
|
if _, err := tx.ExecContext(context.Background(),
|
|
`INSERT INTO structures (id, record_id, version, contents, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
id, recordID, version, string(contents), now, now,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("failed to commit the structure of record %s: %w", recordID, err)
|
|
}
|
|
|
|
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: replicas}, nil
|
|
}
|
|
|
|
func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) {
|
|
structure := &entity.Structure{Id: id}
|
|
var contents string
|
|
err := repo.db.Reader().QueryRowContext(context.Background(),
|
|
"SELECT record_id, version, contents FROM structures WHERE id = ?", id,
|
|
).Scan(&structure.RecordID, &structure.Version, &contents)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get structure %s: %w", id, err)
|
|
}
|
|
|
|
replicas, err := decodeReplicas(id, contents)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
structure.Replicas = replicas
|
|
|
|
return structure, nil
|
|
}
|
|
|
|
// decodeReplicas разбирает сохранённые реплики. Текст расшифровки наружу
|
|
// отказом не выходит: сообщение несёт идентификатор строки, и только его.
|
|
func decodeReplicas(id, contents string) ([]entity.Replica, error) {
|
|
if contents == "" {
|
|
return nil, nil
|
|
}
|
|
var replicas []entity.Replica
|
|
if err := json.Unmarshal([]byte(contents), &replicas); err != nil {
|
|
return nil, fmt.Errorf("failed to decode structure %s", id)
|
|
}
|
|
return replicas, nil
|
|
}
|