хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user