feat(backend)!: split Series from Bookmark, keeping the wire format flat

Series becomes a shared row keyed (site, series_id) owning title, cover,
canonical URL, kind, latest chapter and last-checked time (ADR-0003).
A bookmark keeps only progress, favourite, lifecycle bucket, updated_at.

Store.Upsert decomposes one flat body across both tables in one
transaction: client title/series_url/cover apply only when the series row
is new, then only the poll may change them (security boundary — the row
is shared and the values are scraped page content). Reads join series
back in, so GET/PUT emit and accept exactly the flat field set they did
before (ADR-0004), asserted by TestFlatWireFieldSet.

The poller walks Series instead of Bookmarks: one fetch per shared
series per due cycle, due queue ordered reader_count DESC then
latest_checked_at ASC, orphaned series never due and never deleted, row
still stamped before the fetch. Batch/stagger/interval unchanged.

Migration 0002 backfills series from existing bookmarks; verified by
TestMigration0002BackfillsExistingBookmarks.
This commit is contained in:
2026-08-08 07:11:43 +07:00
parent 08749df050
commit a48aba67b8
8 changed files with 756 additions and 158 deletions
+52 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"time"
@@ -25,26 +26,35 @@ func newTestStore(t *testing.T) *store.Store {
return s
}
// seedForCheck inserts a bookmark and forces its latest_checked_at.
// seedForCheck inserts a bookmark (and with it its series) and forces the
// series' latest_checked_at.
func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) {
t.Helper()
site, seriesID, ok := strings.Cut(key, ":")
if !ok {
t.Fatalf("key %q: no ':' separator", key)
}
if _, err := s.Upsert(store.Bookmark{
Key: key,
Site: "asura",
SeriesID: key,
Site: site,
SeriesID: seriesID,
SeriesURL: seriesURL,
UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed %q: %v", key, err)
}
if err := s.MarkLatestChecked(key, checkedAt); err != nil {
if err := s.MarkLatestChecked(site, seriesID, checkedAt); err != nil {
t.Fatalf("seed mark %q: %v", key, err)
}
}
func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 {
t.Helper()
ts, err := s.LatestCheckedAt(key)
site, seriesID, ok := strings.Cut(key, ":")
if !ok {
t.Fatalf("key %q: no ':' separator", key)
}
ts, err := s.LatestCheckedAt(site, seriesID)
if err != nil {
t.Fatalf("LatestCheckedAt %q: %v", key, err)
}
@@ -217,6 +227,41 @@ func TestRunOnceRespectsBatchLimit(t *testing.T) {
}
}
// The point of the split (ADR-0003): a series referenced by several bookmarks
// is fetched once per due cycle, not once per bookmark. Today the bookmark key
// is <site>:<series_id>, so the second bookmark only exists once keys stop
// being derived from the series identity (issue #22).
func TestRunOnceFetchesSharedSeriesOnce(t *testing.T) {
s := newTestStore(t)
// The slug must match the fixture's own anchors: asura's parser scopes
// chapter links to the stored slug.
const slug = "chronicles-of-the-demon-faction-f886a8af"
const url = "https://asurascans.com/comics/" + slug
seedForCheck(t, s, "asura:"+slug, url, 0)
if _, err := s.Upsert(store.Bookmark{
Key: "asura:" + slug + ":2", Site: "asura", SeriesID: slug, UpdatedAt: 2000,
}); err != nil {
t.Fatalf("seed second reader: %v", err)
}
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
newTestPoller(t, s, f, time.UnixMilli(5_000_000)).runOnce(context.Background())
if got := f.callCount(); got != 1 {
t.Fatalf("fetched shared series %d times, want 1", got)
}
// Both bookmarks join to the same updated series row.
for _, key := range []string{"asura:" + slug, "asura:" + slug + ":2"} {
b, ok, err := s.Get(key)
if err != nil || !ok {
t.Fatalf("Get %s: %v ok=%v", key, err, ok)
}
if b.LatestChapterNum == nil || *b.LatestChapterNum != 181 {
t.Fatalf("%s LatestChapterNum = %v, want 181", key, b.LatestChapterNum)
}
}
}
// One unreachable series must not abandon the rest of the batch.
func TestRunOnceOneBadSeriesDoesNotStallBatch(t *testing.T) {
s := newTestStore(t)
@@ -330,8 +375,8 @@ func TestCheckOneValidatesSeriesURLBeforeFetching(t *testing.T) {
now := time.UnixMilli(4_000_000)
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
newTestPoller(t, s, f, now).checkOne(context.Background(), store.Bookmark{
Key: key, Site: tt.site, SeriesURL: tt.seriesURL,
newTestPoller(t, s, f, now).checkOne(context.Background(), store.Series{
Site: tt.site, SeriesID: "x", SeriesURL: tt.seriesURL,
})
if got := f.callCount(); got != tt.wantCalls {