Series URL repair: owner-typed, gated by the poller's own fetch gate (#151)

This commit is contained in:
2026-08-22 09:21:00 +07:00
parent 448631c78e
commit 424d2c6600
15 changed files with 265 additions and 22 deletions
+20 -4
View File
@@ -60,10 +60,11 @@ type Bookmark struct {
// exists once no matter how many bookmarks point at it (ADR-0003).
//
// Title, SeriesURL and Cover are written once, at creation: a PUT naming an
// existing Series has them ignored, and only the backend's own Poll may change
// them. Kind and the latest-chapter fields are last-write-wins like the
// bookmark's own fields. Never serialized: the wire format is the flat
// Bookmark (ADR-0004).
// existing Series has them ignored, and only the backend's own Poll may
// change them. The one exception is SeriesURL, which the owner's
// SetSeriesURL may repair (issue #151). Kind and the latest-chapter fields
// are last-write-wins like the bookmark's own fields. Never serialized: the
// wire format is the flat Bookmark (ADR-0004).
type Series struct {
Site string
SeriesID string
@@ -1394,6 +1395,21 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro
return nil
}
// SetSeriesURL stores the owner's repair for a Series' source address
// (issue #151): the one write that lifts the write-once rule documented on
// Series.SeriesURL. It is a store, not a verification — the caller has
// already passed the poller's fetch gate. The handler 404s on an unknown row
// before calling; the write itself is a plain single-column UPDATE like
// MarkLatestChecked.
func (s *Store) SetSeriesURL(site, seriesID, seriesURL string) error {
if _, err := s.db.Exec(
`UPDATE series SET series_url = $3 WHERE site = $1 AND series_id = $2`,
site, seriesID, seriesURL); err != nil {
return fmt.Errorf("set series url %s:%s: %w", site, seriesID, err)
}
return nil
}
// CorrectLatestChapter makes the Latest Chapter the owner's: one UPDATE
// carrying the number, the derived label and the correction stamp. The label
// shape is the poller's and the userscript's ("Chapter " + the number as
+35
View File
@@ -2144,3 +2144,38 @@ func TestGetCoverResolvesLegacyURLDerivedAddress(t *testing.T) {
t.Fatalf("legacy cover = (%q, %q), want the planted bytes", body, contentType)
}
}
// SetSeriesURL is the one write that lifts the write-once rule of
// Series.SeriesURL (issue #151): a client PUT naming an existing Series still
// has its new URL dropped, yet the owner's repair lands where the Upsert
// would have ignored it.
func TestSetSeriesURLWritesWhereUpsertIgnores(t *testing.T) {
store := newTestStore(t)
base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
UpdatedAt: 1000,
}
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
// A client PUT naming the existing Series is refused: the row is shared,
// so the stored URL stands.
base.SeriesURL = "https://evil.example/solo"
if got, err := store.Upsert(store.OwnerID(), base); err != nil {
t.Fatalf("Upsert: %v", err)
} else if got.SeriesURL != "https://asurascans.com/comics/solo" {
t.Fatalf("Upsert stored %q, want the original URL untouched", got.SeriesURL)
}
// The owner's repair writes where the Upsert would have ignored it.
repair := "https://asurascans.com/comics/solo-renumbered"
if err := store.SetSeriesURL("asura", "solo", repair); err != nil {
t.Fatalf("SetSeriesURL: %v", err)
}
sr := readSeries(t, store, "asura", "solo")
if sr.SeriesURL != repair {
t.Fatalf("stored URL = %q, want %q", sr.SeriesURL, repair)
}
}