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 }