хранилище переехало с 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
+152 -40
View File
@@ -113,6 +113,31 @@ func TestТранспортыНеЗнаютДругОДруге(t *testing.T) {
}
}
// Транспорт не знает адаптеров. Изъятие, разрешавшее ему знать адаптер
// хранилища, снято вместе с предметом: HTTP-поверхность была роутером
// встроенного хранилища, а стала своей, и правило на это направление заводится
// впервые.
//
// Что оно ловит: возврат `EnsureUser`, `NewFileRepository` и прочих имён
// адаптера в обработчики. Знание о внешнем мире приходит транспорту интерфейсом
// `internal/contract`, а реализацию подставляет точка входа.
func TestТранспортыНеЗнаютАдаптеров(t *testing.T) {
for pkg, imports := range internalImports(t) {
if !transports[pkg] {
continue
}
for _, imp := range imports {
if strings.HasPrefix(imp, adapterPrefix) {
t.Errorf(
"транспорт %s импортирует адаптер %s: реализацию подставляет "+
"cmd/transcriber, а транспорт знает только internal/contract",
pkg, imp,
)
}
}
}
}
func TestАдаптерыНеЗнаютНиЯдра_НиТранспортов(t *testing.T) {
for pkg, imports := range internalImports(t) {
if !strings.HasPrefix(pkg, adapterPrefix) {
@@ -173,16 +198,19 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
}
}
// Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`,
// читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре
// «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает
// с нулевым полем, и первое же сохранение пишет этот ноль поверх значения.
// Перечень колонок аудиозаписи компилятор не видит: их пишет `writeOwnedByPipeline`
// вместе с `writeRecord`, читает `readRecordColumns`, доводит до сущности
// `rowToAudioRecord`, и заводит шаг схемы. Колонка, забытая в любом звене этой
// цепочки, теряется молча: запись, прочитанная не тем путём, приезжает с нулевым
// полем, и первое же сохранение пишет этот ноль поверх значения.
//
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
// колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила
// ниже держат оставшуюся пару плюс шаг схемы.
// Отображение работает **по имени колонки** — именованные параметры запроса и
// место назначения, найденное по имени, — поэтому правила ниже сверяют имена, а
// не порядок полей. Ту поломку, где колонка не забыта, а перепутана местом, эта
// форма снимает сама: позиционного списка, который сдвинулся бы на одно поле, у
// отображения нет вовсе.
const (
repoPkg = "internal/adapter/repo/pocketbase"
repoPkg = "internal/adapter/repo/sqlite"
mappingFile = repoPkg + "/record_mapping.go"
migrationsPath = repoPkg + "/migrations"
stageFile = "internal/entity/stage.go"
@@ -190,46 +218,64 @@ const (
serviceFile = "internal/service/transcribe.go"
)
// Колонки, которые заводит и заполняет само хранилище: нашего кода они не
// касаются.
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
written := writtenColumns(t)
read := readColumns(t)
for col := range written {
if storageOwned[col] {
continue
}
if !read[col] {
t.Errorf(
"колонку %q пишет отображение записи, но recordToAudioRecord её не "+
"читает: запись приедет из хранилища без этого поля",
"колонку %q пишет отображение записи, но readRecordColumns её не "+
"читает: запись приедет из базы без этого поля",
col,
)
}
}
for col := range read {
if storageOwned[col] {
continue
}
if !written[col] {
t.Errorf(
"колонку %q читает recordToAudioRecord, но её не пишет ни "+
"applyOwnedByPipeline, ни applyToRecord: поле не сохранится",
"колонку %q читает readRecordColumns, но её не пишет ни "+
"writeOwnedByPipeline, ни writeRecord: поле не сохранится",
col,
)
}
}
}
// Колонка, прочитанная в поле сырой строки, обязана доехать до сущности:
// `readRecordColumns` называет, куда ляжет значение, а `rowToAudioRecord`
// решает, возьмут ли его оттуда. Поле, забытое во втором, теряется молча —
// компилятор его не видит, спрошенная колонка приезжает и остаётся лежать в
// сырой строке, сущность получает нулевое значение, а ближайшее сохранение
// пишет этот ноль поверх сохранённого.
func TestПрочитанныеКолонкиДоезжаютДоСущности(t *testing.T) {
targets := readTargets(t)
used := rowFieldsTakenByEntity(t)
for field, column := range targets {
if !used[field] {
t.Errorf(
"колонка %q читается в поле row.%s, но rowToAudioRecord его не берёт: "+
"значение не доедет до сущности, а ближайшее сохранение запишет "+
"нулевое поверх сохранённого",
column, field,
)
}
}
for field := range used {
if _, ok := targets[field]; !ok {
t.Errorf(
"rowToAudioRecord берёт поле row.%s, но readRecordColumns ни одной "+
"колонки в него не кладёт: сущность получит нулевое значение всегда",
field,
)
}
}
}
func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
declared := schemaFieldNames(t)
for col := range writtenColumns(t) {
if storageOwned[col] {
continue
}
if !declared[col] {
t.Errorf(
"колонка %q пишется отображением записи, но ни один шаг схемы её не "+
@@ -312,13 +358,16 @@ func TestШагиОбъявленыРубежамиДескриптора(t *tes
}
}
// writtenColumns — колонки, которые пишет отображение записи в хранилище.
// columnKey — имя колонки в карте отображения: строковый ключ в начале строки.
var columnKey = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":`)
// writtenColumns — колонки, которые пишет отображение записи в базу.
func writtenColumns(t *testing.T) map[string]bool {
t.Helper()
body := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
funcBody(t, mappingFile, "func applyToRecord(")
body := funcBody(t, mappingFile, "func writeOwnedByPipeline(") +
funcBody(t, mappingFile, "func writeRecord(")
out := map[string]bool{}
for _, m := range regexp.MustCompile(`record\.Set\("([^"]+)"`).FindAllStringSubmatch(body, -1) {
for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
out[m[1]] = true
}
if len(out) == 0 {
@@ -327,16 +376,51 @@ func writtenColumns(t *testing.T) map[string]bool {
return out
}
// readColumns — колонки, которые читает обратное отображение.
// readColumns — колонки, которые читает обратное отображение. Перечень выборки
// собирается из той же карты, поэтому сверяется именно она.
func readColumns(t *testing.T) map[string]bool {
t.Helper()
body := funcBody(t, mappingFile, "func recordToAudioRecord(")
body := funcBody(t, mappingFile, "func readRecordColumns(")
out := map[string]bool{}
for _, m := range regexp.MustCompile(`\.Get\w+\("([^"]+)"\)`).FindAllStringSubmatch(body, -1) {
for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("recordToAudioRecord не читает ни одной колонки: правило потеряло предмет")
t.Fatalf("readRecordColumns не читает ни одной колонки: правило потеряло предмет")
}
return out
}
// readTarget — колонка чтения и поле сырой строки, куда она ложится.
var readTarget = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":\s*&row\.(\w+),`)
// rowFieldUse — обращение к полю сырой строки при сборке сущности.
var rowFieldUse = regexp.MustCompile(`\brow\.(\w+)\b`)
// readTargets — поле сырой строки в имя колонки, которая в него читается.
func readTargets(t *testing.T) map[string]string {
t.Helper()
body := funcBody(t, mappingFile, "func readRecordColumns(")
out := map[string]string{}
for _, m := range readTarget.FindAllStringSubmatch(body, -1) {
out[m[2]] = m[1]
}
if len(out) == 0 {
t.Fatalf("readRecordColumns не кладёт ни одной колонки в поле строки: правило потеряло предмет")
}
return out
}
// rowFieldsTakenByEntity — поля сырой строки, которые берёт сборка сущности.
func rowFieldsTakenByEntity(t *testing.T) map[string]bool {
t.Helper()
body := funcBody(t, mappingFile, "func rowToAudioRecord(")
out := map[string]bool{}
for _, m := range rowFieldUse.FindAllStringSubmatch(body, -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("rowToAudioRecord не берёт ни одного поля строки: правило потеряло предмет")
}
return out
}
@@ -447,9 +531,11 @@ func funcBody(t *testing.T, file, header string) string {
return body[start : start+end]
}
// schemaFieldNames собирает имена полей, заведённых шагами схемы: `Name: "…"` в
// любом файле каталога шагов. Перечень объединённый — колонку заводит тот шаг,
// который её добавил, а переписывать применённый шаг нельзя.
// schemaFieldNames собирает имена колонок аудиозаписи, заведённых шагами схемы.
//
// Читается объявление таблицы в любом файле каталога шагов: колонку заводит тот
// шаг, который её добавил, а переписывать применённый шаг нельзя. Перечень
// поэтому объединённый — по всем шагам сразу.
func schemaFieldNames(t *testing.T) map[string]bool {
t.Helper()
dir := filepath.Join(repoRoot, migrationsPath)
@@ -457,7 +543,8 @@ func schemaFieldNames(t *testing.T) map[string]bool {
if err != nil {
t.Fatalf("читаю каталог шагов схемы: %v", err)
}
re := regexp.MustCompile(`Name:\s*"([^"]+)"`)
column := regexp.MustCompile(`(?m)^\s*([a-z_0-9]+)\s+(TEXT|INTEGER)`)
out := map[string]bool{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
@@ -467,16 +554,41 @@ func schemaFieldNames(t *testing.T) map[string]bool {
if err != nil {
t.Fatalf("читаю %s: %v", e.Name(), err)
}
for _, m := range re.FindAllStringSubmatch(string(body), -1) {
out[m[1]] = true
for _, block := range tableBlocks(string(body), recordsTable) {
for _, m := range column.FindAllStringSubmatch(block, -1) {
out[m[1]] = true
}
}
}
if len(out) == 0 {
t.Fatalf("шаги схемы не объявили ни одного поля: правило потеряло предмет")
t.Fatalf("шаги схемы не объявили ни одной колонки аудиозаписи: правило потеряло предмет")
}
return out
}
// recordsTable — имя таблицы аудиозаписей в шагах схемы.
const recordsTable = "audio_records"
// tableBlocks вырезает объявления названной таблицы: от `CREATE TABLE имя (` до
// закрывающей скобки в начале строки.
func tableBlocks(body, table string) []string {
var out []string
marker := "CREATE TABLE " + table + " ("
for {
start := strings.Index(body, marker)
if start < 0 {
return out
}
body = body[start+len(marker):]
end := strings.Index(body, "\n\t\t)")
if end < 0 {
return out
}
out = append(out, body[:end])
body = body[end:]
}
}
func readFile(t *testing.T, rel string) string {
t.Helper()
body, err := os.ReadFile(filepath.Join(repoRoot, rel))