хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+205
View File
@@ -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
}