хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Отображение аудиозаписи в строку базы и обратно живёт одним местом.
|
||||
//
|
||||
// Работает оно **по имени колонки**: именованные параметры запроса и место
|
||||
// назначения, найденное по имени. Причина в самой сущности — у аудиозаписи поля
|
||||
// одного типа идут длинным непрерывным рядом, и ссылки на файл, на структуру
|
||||
// реплик, на два вида текста и на попытку распознавания стоят в нём подряд.
|
||||
// Позиционный список дал бы сдвиг на одно поле, который компилируется молча и
|
||||
// кладёт идентификатор файла в колонку текста. По имени такого сдвига не
|
||||
// существует вовсе: лишнее имя или недостающее — отказ запроса, а не тихая
|
||||
// подмена значения.
|
||||
//
|
||||
// Инвариант проекта о колонках записи эта форма не снимает: колонку по-прежнему
|
||||
// можно забыть в отображении или в шаге схемы, и сверку держат правила
|
||||
// `internal/archrules`.
|
||||
|
||||
// writeOwnedByPipeline — колонки, которыми распоряжается конвейер.
|
||||
//
|
||||
// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до
|
||||
// своего сохранения, а это часы. Всё, что владелец правил за это время,
|
||||
// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа
|
||||
// тому, кто правил. Владелец, заголовок, краткое описание, имя файла
|
||||
// отправителя, длительность, размер и темы не трогаются вовсе.
|
||||
func writeOwnedByPipeline(r *entity.AudioRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"state": r.State,
|
||||
"state_entered_at": formatTime(r.StateEnteredAt),
|
||||
"halted_at": timeValue(r.HaltedAt),
|
||||
"halt_reason": stringValue(r.HaltReason),
|
||||
"error_text": stringValue(r.ErrorText),
|
||||
"acquisition_id": stringValue(r.AcquisitionID),
|
||||
"acquire_expires_at": timeValue(r.AcquireExpiresAt),
|
||||
"delay_time": timeValue(r.DelayTime),
|
||||
"attempts": r.Attempts,
|
||||
"original_file_id": stringValue(r.OriginalFileID),
|
||||
"normalized_file_id": stringValue(r.NormalizedFileID),
|
||||
"transcript_text_id": stringValue(r.TranscriptTextID),
|
||||
"literary_text_id": stringValue(r.LiteraryTextID),
|
||||
"structure_id": stringValue(r.StructureID),
|
||||
"recognition_id": stringValue(r.RecognitionID),
|
||||
"updated_at": formatTime(r.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// writeRecord — запись целиком: это заведение, и спорить за поля здесь не с кем.
|
||||
//
|
||||
// Колонки заведения перечислены той же формой, что и колонки конвейера, — картой
|
||||
// «имя колонки в значение»: сверка колонок в `internal/archrules` читает именно
|
||||
// её, и колонка, положенная присваиванием мимо карты, выпала бы из-под правила
|
||||
// молча.
|
||||
func writeRecord(r *entity.AudioRecord) map[string]any {
|
||||
values := writeOwnedByPipeline(r)
|
||||
|
||||
own := map[string]any{
|
||||
"id": r.Id,
|
||||
// Владелец кладётся только здесь, при заведении. В перечне конвейера его
|
||||
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок
|
||||
// шага, записанный поверх, стёр бы его молча.
|
||||
"owner_id": r.OwnerID,
|
||||
"title": stringValue(r.Title),
|
||||
"brief": stringValue(r.Brief),
|
||||
// Имя файла отправителя, длительность и размер кладёт приём и только он:
|
||||
// это снимок принятого, и конвейер его не пересчитывает.
|
||||
"original_filename": stringValue(r.OriginalFilename),
|
||||
"duration_ms": numberValue(r.DurationMs),
|
||||
"size_bytes": numberValue(r.SizeBytes),
|
||||
"created_at": formatTime(r.CreatedAt),
|
||||
}
|
||||
for name, value := range own {
|
||||
values[name] = value
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// recordRow — сырые значения одной строки аудиозаписи.
|
||||
type recordRow struct {
|
||||
id string
|
||||
ownerID string
|
||||
title sql.NullString
|
||||
brief sql.NullString
|
||||
originalFilename sql.NullString
|
||||
durationMs int64
|
||||
sizeBytes int64
|
||||
state string
|
||||
stateEnteredAt string
|
||||
haltedAt sql.NullString
|
||||
haltReason sql.NullString
|
||||
errorText sql.NullString
|
||||
acquisitionID sql.NullString
|
||||
acquireExpiresAt sql.NullString
|
||||
delayTime sql.NullString
|
||||
attempts int
|
||||
originalFileID sql.NullString
|
||||
normalizedFileID sql.NullString
|
||||
transcriptTextID sql.NullString
|
||||
literaryTextID sql.NullString
|
||||
structureID sql.NullString
|
||||
recognitionID sql.NullString
|
||||
createdAt string
|
||||
updatedAt string
|
||||
}
|
||||
|
||||
// readRecordColumns — куда кладётся каждая колонка при чтении.
|
||||
//
|
||||
// Перечень колонок выборки собирается из этой же карты, поэтому расхождению
|
||||
// между тем, что спрошено, и тем, куда оно ляжет, взяться неоткуда.
|
||||
func readRecordColumns(row *recordRow) map[string]any {
|
||||
return map[string]any{
|
||||
"id": &row.id,
|
||||
"owner_id": &row.ownerID,
|
||||
"title": &row.title,
|
||||
"brief": &row.brief,
|
||||
"original_filename": &row.originalFilename,
|
||||
"duration_ms": &row.durationMs,
|
||||
"size_bytes": &row.sizeBytes,
|
||||
"state": &row.state,
|
||||
"state_entered_at": &row.stateEnteredAt,
|
||||
"halted_at": &row.haltedAt,
|
||||
"halt_reason": &row.haltReason,
|
||||
"error_text": &row.errorText,
|
||||
"acquisition_id": &row.acquisitionID,
|
||||
"acquire_expires_at": &row.acquireExpiresAt,
|
||||
"delay_time": &row.delayTime,
|
||||
"attempts": &row.attempts,
|
||||
"original_file_id": &row.originalFileID,
|
||||
"normalized_file_id": &row.normalizedFileID,
|
||||
"transcript_text_id": &row.transcriptTextID,
|
||||
"literary_text_id": &row.literaryTextID,
|
||||
"structure_id": &row.structureID,
|
||||
"recognition_id": &row.recognitionID,
|
||||
"created_at": &row.createdAt,
|
||||
"updated_at": &row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// rowToAudioRecord собирает доменную запись из прочитанной строки.
|
||||
func rowToAudioRecord(row *recordRow) *entity.AudioRecord {
|
||||
return &entity.AudioRecord{
|
||||
Id: row.id,
|
||||
OwnerID: row.ownerID,
|
||||
Title: stringOf(row.title),
|
||||
Brief: stringOf(row.brief),
|
||||
OriginalFilename: stringOf(row.originalFilename),
|
||||
DurationMs: numberOf(row.durationMs),
|
||||
SizeBytes: numberOf(row.sizeBytes),
|
||||
State: row.state,
|
||||
StateEnteredAt: requiredTimeOf(row.stateEnteredAt),
|
||||
HaltedAt: timeOf(row.haltedAt),
|
||||
HaltReason: stringOf(row.haltReason),
|
||||
ErrorText: stringOf(row.errorText),
|
||||
AcquisitionID: stringOf(row.acquisitionID),
|
||||
AcquireExpiresAt: timeOf(row.acquireExpiresAt),
|
||||
DelayTime: timeOf(row.delayTime),
|
||||
Attempts: row.attempts,
|
||||
OriginalFileID: stringOf(row.originalFileID),
|
||||
NormalizedFileID: stringOf(row.normalizedFileID),
|
||||
TranscriptTextID: stringOf(row.transcriptTextID),
|
||||
LiteraryTextID: stringOf(row.literaryTextID),
|
||||
StructureID: stringOf(row.structureID),
|
||||
RecognitionID: stringOf(row.recognitionID),
|
||||
TopicIDs: []string{},
|
||||
CreatedAt: requiredTimeOf(row.createdAt),
|
||||
UpdatedAt: requiredTimeOf(row.updatedAt),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user