UI/UX списка и карточки загрузки: серверные фильтр/поиск/пагинация, матч-ссылка, имя раздачи (web-ui-list-detail)
- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
(added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,7 @@ type Download struct {
|
||||
ID int64 `db:"id"`
|
||||
SourceType SourceType `db:"source_type"`
|
||||
SourceRef string `db:"source_ref"`
|
||||
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
|
||||
Context string `db:"context"`
|
||||
Infohash sql.NullString `db:"infohash"`
|
||||
IdempotencyKey sql.NullString `db:"idempotency_key"`
|
||||
@@ -82,9 +83,18 @@ type Download struct {
|
||||
ErrorMsg sql.NullString `db:"error_msg"`
|
||||
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
|
||||
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
|
||||
SourceMissCount int `db:"source_miss_count"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
SourceMissCount int `db:"source_miss_count"`
|
||||
// SourceAddedAt — время добавления торрента в qBittorrent (added_on), базис
|
||||
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
|
||||
// формате sqliteTimeLayout (UTC), как created_at.
|
||||
SourceAddedAt sql.NullString `db:"source_added_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
|
||||
// RecTitle — распознанное название текущей попытки (LEFT JOIN recognition).
|
||||
// Заполняется только листингом ListDownloadsPage для фолбека заголовка; в
|
||||
// прочих выборках остаётся пустым.
|
||||
RecTitle sql.NullString `db:"rec_title"`
|
||||
}
|
||||
|
||||
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
|
||||
@@ -95,6 +105,14 @@ func ParseTime(s string) (time.Time, error) {
|
||||
return time.ParseInLocation(sqliteTimeLayout, s, time.UTC)
|
||||
}
|
||||
|
||||
// FormatTime форматирует время в формат меток SQLite (datetime('now'), UTC) —
|
||||
// байт-в-байт тот же, что у created_at/updated_at. Нужен, чтобы лексикографи-
|
||||
// ческое сравнение строк времени (COALESCE(source_added_at, created_at) в
|
||||
// сортировке списка) давало верный хронологический порядок.
|
||||
func FormatTime(t time.Time) string {
|
||||
return t.UTC().Format(sqliteTimeLayout)
|
||||
}
|
||||
|
||||
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
||||
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
|
||||
|
||||
@@ -106,10 +124,10 @@ func NullString(s string) sql.NullString {
|
||||
// CreateDownload вставляет загрузку и возвращает её id.
|
||||
func (s *Store) CreateDownload(ctx context.Context, d *Download) (int64, error) {
|
||||
const q = `
|
||||
INSERT INTO download (source_type, source_ref, context, infohash, idempotency_key, state)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
INSERT INTO download (source_type, source_ref, display_name, context, infohash, idempotency_key, state)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
res, err := s.DB.ExecContext(ctx, q,
|
||||
d.SourceType, d.SourceRef, d.Context, d.Infohash, d.IdempotencyKey, d.State)
|
||||
d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.Infohash, d.IdempotencyKey, d.State)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert download: %w", err)
|
||||
}
|
||||
@@ -132,6 +150,17 @@ func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// SetSourceAddedAt однократно фиксирует время добавления торрента в источник
|
||||
// (qBittorrent added_on). SQL-гард `source_added_at IS NULL` не даёт перезапи-
|
||||
// сать значение на повторных наблюдениях: время добавления неизменно.
|
||||
func (s *Store) SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error {
|
||||
const q = `UPDATE download SET source_added_at = ? WHERE id = ? AND source_added_at IS NULL`
|
||||
if _, err := s.DB.ExecContext(ctx, q, FormatTime(t), id); err != nil {
|
||||
return fmt.Errorf("set source added at %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDownloads возвращает все загрузки, новые сверху.
|
||||
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
|
||||
var out []Download
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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 IFNULL(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)
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// hashN — детерминированный валидный 40-hex infohash по номеру.
|
||||
func hashN(n int) string { return fmt.Sprintf("%040x", n) }
|
||||
|
||||
// mkDownload заводит загрузку в заданном состоянии с display_name.
|
||||
func mkDownload(t *testing.T, st *Store, n int, state State, display string) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
d := newDownloading(hashN(n))
|
||||
d.DisplayName = display
|
||||
id, err := st.CreateDownload(ctx, d)
|
||||
if err != nil {
|
||||
t.Fatalf("create #%d: %v", n, err)
|
||||
}
|
||||
if state != StateDownloading {
|
||||
if err := st.SetDownloadState(ctx, id, state, "", ""); err != nil {
|
||||
t.Fatalf("set state #%d: %v", n, err)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func ids(ds []Download) []int64 {
|
||||
out := make([]int64, len(ds))
|
||||
for i, d := range ds {
|
||||
out[i] = d.ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
review := mkDownload(t, st, 1, StateReview, "Дюна")
|
||||
active := mkDownload(t, st, 2, StateDownloading, "Fargo")
|
||||
done := mkDownload(t, st, 3, StateDone, "Оно")
|
||||
deleted := mkDownload(t, st, 4, StateDeleted, "Старьё")
|
||||
|
||||
// Группа review — только review/deferred.
|
||||
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupReview, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(page) != 1 || page[0].ID != review {
|
||||
t.Fatalf("review group = %v (total %d), want [%d]", ids(page), total, review)
|
||||
}
|
||||
|
||||
// all: deleted скрыт по умолчанию.
|
||||
page, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Fatalf("all (без deleted) total = %d, want 3; ids=%v", total, ids(page))
|
||||
}
|
||||
for _, d := range page {
|
||||
if d.ID == deleted {
|
||||
t.Fatalf("deleted не должен быть виден по умолчанию")
|
||||
}
|
||||
}
|
||||
_ = active
|
||||
_ = done
|
||||
|
||||
// all + IncludeDeleted: показываем всё.
|
||||
_, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, IncludeDeleted: true, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 4 {
|
||||
t.Fatalf("all+deleted total = %d, want 4", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDownloadsPageSearch(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mkDownload(t, st, 1, StateReview, "Дюна: Часть вторая")
|
||||
mkDownload(t, st, 2, StateReview, "Fargo")
|
||||
|
||||
// Поиск по display_name (кириллица — как введено, с учётом регистра).
|
||||
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Query: "Дюна", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(page) != 1 || page[0].DisplayName != "Дюна: Часть вторая" {
|
||||
t.Fatalf("search 'Дюна' = %v (total %d)", ids(page), total)
|
||||
}
|
||||
|
||||
// Латиница ищется регистронезависимо (ASCII case-folding LIKE).
|
||||
page, _, err = st.ListDownloadsPage(ctx, ListFilter{Query: "fargo", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page) != 1 || page[0].DisplayName != "Fargo" {
|
||||
t.Fatalf("search 'fargo' = %v", ids(page))
|
||||
}
|
||||
|
||||
// Поиск по infohash.
|
||||
page, _, err = st.ListDownloadsPage(ctx, ListFilter{Query: hashN(2), Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page) != 1 || page[0].Infohash.String != hashN(2) {
|
||||
t.Fatalf("search by infohash = %v", ids(page))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDownloadsPageOrderAndPagination(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Три задачи; у #1 и #2 задано source_added_at (разное), у #3 — нет (фолбек
|
||||
// на created_at). Проверяем порядок DESC по времени добавления и tie-break.
|
||||
id1 := mkDownload(t, st, 1, StateDownloading, "one")
|
||||
id2 := mkDownload(t, st, 2, StateDownloading, "two")
|
||||
id3 := mkDownload(t, st, 3, StateDownloading, "three")
|
||||
|
||||
base := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
// id1 добавлен раньше, id2 позже → id2 должен идти выше id1.
|
||||
if err := st.SetSourceAddedAt(ctx, id1, base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetSourceAddedAt(ctx, id2, base.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Fatalf("total = %d, want 3", total)
|
||||
}
|
||||
// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
|
||||
// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
|
||||
got := ids(page)
|
||||
want := []int64{id3, id2, id1}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("порядок = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Пагинация: по 2 на страницу.
|
||||
p1, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 2, Offset: 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 || len(p1) != 2 {
|
||||
t.Fatalf("page1 len=%d total=%d, want 2/3", len(p1), total)
|
||||
}
|
||||
p2, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 2, Offset: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p2) != 1 {
|
||||
t.Fatalf("page2 len=%d, want 1", len(p2))
|
||||
}
|
||||
// Страницы не пересекаются и покрывают весь список.
|
||||
if p1[0].ID != id3 || p1[1].ID != id2 || p2[0].ID != id1 {
|
||||
t.Fatalf("страницы разъехались: p1=%v p2=%v", ids(p1), ids(p2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
|
||||
same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
var idList []int64
|
||||
for i := 1; i <= 3; i++ {
|
||||
id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
|
||||
if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idList = append(idList, id)
|
||||
}
|
||||
page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ids(page)
|
||||
want := []int64{idList[2], idList[1], idList[0]} // id DESC
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("tie-break порядок = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSourceAddedAtOnce(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id := mkDownload(t, st, 1, StateDownloading, "x")
|
||||
first := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
||||
second := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := st.SetSourceAddedAt(ctx, id, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Повторная запись не должна перезаписать (гард IS NULL).
|
||||
if err := st.SetSourceAddedAt(ctx, id, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := st.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !d.SourceAddedAt.Valid || d.SourceAddedAt.String != FormatTime(first) {
|
||||
t.Fatalf("source_added_at = %q, want %q (первое значение неизменно)",
|
||||
d.SourceAddedAt.String, FormatTime(first))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDownloadsPageRecTitleFallback(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Загрузка без display_name, но с распознанным названием — RecTitle из join.
|
||||
id := mkDownload(t, st, 1, StateReview, "")
|
||||
if _, err := st.CreateRecognition(ctx, &Recognition{
|
||||
DownloadID: id,
|
||||
Title: NullString("Настоящее название"),
|
||||
}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page) != 1 {
|
||||
t.Fatalf("len = %d", len(page))
|
||||
}
|
||||
if !page[0].RecTitle.Valid || page[0].RecTitle.String != "Настоящее название" {
|
||||
t.Fatalf("RecTitle = %q, want распознанное название", page[0].RecTitle.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDownloadPersistsDisplayName(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id := mkDownload(t, st, 1, StateDownloading, "Дюна: Часть вторая (2024)")
|
||||
d, err := st.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.DisplayName != "Дюна: Часть вторая (2024)" {
|
||||
t.Fatalf("display_name = %q", d.DisplayName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- Отображаемое имя раздачи (то, что уходит в qBittorrent как rename) — для
|
||||
-- заголовка карточки/страницы в веб-UI. source_added_at — время добавления
|
||||
-- торрента в qBittorrent (added_on), базис сортировки списка; nullable, пока
|
||||
-- воркер не наблюдал раздачу.
|
||||
ALTER TABLE download ADD COLUMN display_name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE download ADD COLUMN source_added_at TEXT;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE download DROP COLUMN source_added_at;
|
||||
ALTER TABLE download DROP COLUMN display_name;
|
||||
Reference in New Issue
Block a user