Files
jellybit/internal/store/list.go
T
avandClaude Fable 5 37f2f6481a Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)
Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase,
internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши
загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп
и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба
хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится.

Инвариант «не более одной активной загрузки на infohash» вместо снятого
unique-индекса держат guarded-методы store в одной write-транзакции
(_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом
недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ
до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет
терминал→активное как механический бэкстоп.

Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при
включённых FK, backfill ULID с timestamp из created_at (хронология id
сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id
в URL/логах/Telegram, REST-поля id (string) и infohashes (список).

Новая конвенция docs/conventions/database.md (без числовых PK), корреляция
в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability
identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены
ревью дизайна и кода (по 8 углов), все находки исправлены с
регрессионными тестами.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:25:00 +03:00

118 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"fmt"
"strings"
)
// StateGroup — группа состояний для фильтра списка загрузок. Единый источник
// истины «группа → состояния» для транспорта (чипы) и запроса (WHERE).
type StateGroup string
const (
GroupAll StateGroup = "all" // все, кроме deleted (если не IncludeDeleted)
GroupReview StateGroup = "review" // ждут человека
GroupActive StateGroup = "active" // в работе
GroupDone StateGroup = "done" // готово
GroupProblem StateGroup = "problem" // проблемы
)
// statesInGroup возвращает состояния группы фильтра. nil — группа не сужает
// выборку по состоянию (all/неизвестная): состояния перечисляются здесь и
// нигде больше, чтобы UI и запрос не разъезжались. Терминальные cancelled/
// reverted/deleted своей группы (чипа) не имеют — под all они видны (deleted —
// только при IncludeDeleted), точечно к ним не фильтруем (см. tasks 2.1).
func statesInGroup(g StateGroup) []State {
switch g {
case GroupReview:
return []State{StateReview, StateDeferred}
case GroupActive:
return []State{StateDownloading, StateCompleted, StateRecognizing, StateLinking}
case GroupDone:
return []State{StateDone}
case GroupProblem:
return []State{StateStuck, StateFailed, StateTargetMissing, StateOrphaned}
default:
return nil
}
}
// ListFilter — параметры страницы списка загрузок.
type ListFilter struct {
Group StateGroup // группа состояний ("" / all — без сужения)
Query string // поиск по названию/infohash/контексту (подстрока)
IncludeDeleted bool // показывать состояние deleted (только под all)
Limit int // размер страницы
Offset int // смещение страницы
}
// listWhere строит условие WHERE и его аргументы по фильтру.
func listWhere(f ListFilter) (string, []any) {
var conds []string
var args []any
if states := statesInGroup(f.Group); states != nil {
ph := make([]string, len(states))
for i, st := range states {
ph[i] = "?"
args = append(args, string(st))
}
conds = append(conds, "state IN ("+strings.Join(ph, ",")+")")
} else if !f.IncludeDeleted {
// Группа all: скрываем удалённые по умолчанию (шум в ленте).
conds = append(conds, "state <> ?")
args = append(args, string(StateDeleted))
}
if q := strings.TrimSpace(f.Query); q != "" {
// LIKE даёт регистронезависимость только для ASCII (встроенное
// case-folding SQLite), для кириллицы поиск чувствителен к регистру —
// ICU в чистом modernc-драйвере нет. Достаточно для инфохэша/латиницы;
// кириллические названия ищем как введены.
like := "%" + q + "%"
conds = append(conds, "(source_ref LIKE ? COLLATE NOCASE "+
"OR display_name LIKE ? COLLATE NOCASE "+
"OR context LIKE ? COLLATE NOCASE "+
"OR EXISTS (SELECT 1 FROM download_infohash dh "+
"WHERE dh.download_id = download.id AND dh.infohash LIKE ? COLLATE NOCASE))")
args = append(args, like, like, like, like)
}
if len(conds) == 0 {
return "", args
}
return " WHERE " + strings.Join(conds, " AND "), args
}
// ListDownloadsPage возвращает страницу загрузок под фильтром и общее число
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break
// по id: без вторичного ключа при равных метках порядок неустойчив, и строки
// задваивались бы/пропадали между страницами. RecTitle подтягивается LEFT JOIN
// текущей попытки распознавания — для фолбека заголовка карточки.
func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download, int, error) {
where, args := listWhere(f)
var total int
if err := s.DB.GetContext(ctx, &total, `SELECT COUNT(*) FROM download`+where, args...); err != nil {
return nil, 0, fmt.Errorf("list downloads page count: %w", err)
}
q := `SELECT download.*, r.title AS rec_title
FROM download
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where +
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
LIMIT ? OFFSET ?`
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)
var out []Download
if err := s.DB.SelectContext(ctx, &out, q, pageArgs...); err != nil {
return nil, 0, fmt.Errorf("list downloads page: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, 0, fmt.Errorf("list downloads page: %w", err)
}
return out, total, nil
}