Latest Chapter correction: one numeric input, owner-gated (#149)

This commit is contained in:
2026-08-22 09:04:24 +07:00
parent 889f0f3f38
commit c9b1f2a334
10 changed files with 423 additions and 19 deletions
+13 -6
View File
@@ -41,7 +41,8 @@ type SeriesFilter struct {
// must never leave the store package — so the projection does not select it,
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at`
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at,
s.latest_corrected_at`
// raisedByReaderAnswer answers "did a Reader's report set this number" without
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
@@ -71,9 +72,15 @@ type AdminSeries struct {
// ForcePollAt is the owner's "check now" request stamp (issue #146), zero
// meaning never asked. Pending is derived, never stored: a request is
// pending while ForcePollAt is newer than LatestCheckedAt.
ForcePollAt int64
ReaderCount int
RaisedByReader bool // a Reader's report set LatestChapterNum
ForcePollAt int64
// LatestCorrectedAt is the correction stamp (issue #149): non-zero means
// the Latest Chapter is the owner's, zero means never corrected. The
// provenance line (#152) derives from it, so the zero-means-never meaning
// is load-bearing.
LatestCorrectedAt int64
ReaderCount int
RaisedByReader bool // a Reader's report set LatestChapterNum
}
// SeriesPage is one page of the owner's filtered Series list plus the count
@@ -189,7 +196,7 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
`+where+`
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
s.force_poll_at, s.latest_raised_by
s.force_poll_at, s.latest_corrected_at, s.latest_raised_by
`+having+`
ORDER BY s.latest_checked_at, s.site, s.series_id
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
@@ -264,7 +271,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
if err := scan(
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
&a.ForcePollAt,
&a.ForcePollAt, &a.LatestCorrectedAt,
&a.RaisedByReader, &a.ReaderCount, &total,
); err != nil {
return AdminSeries{}, 0, err
@@ -0,0 +1,4 @@
-- latest_corrected_at is the "the current Latest Chapter is the owner's" stamp
-- (#149). Written by the Correction; zeroed by every machine write of the
-- value. Zero means never corrected.
ALTER TABLE series ADD COLUMN latest_corrected_at bigint NOT NULL DEFAULT 0;
+35 -5
View File
@@ -904,6 +904,11 @@ func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) {
// xmax is zero only on a row this statement inserted, which is how a
// Series nobody had bookmarked before is told apart from one that already
// existed — DO UPDATE returns a row either way.
// latest_corrected_at is the one clause conditional on the value moving
// (#149): after a Correction a Reader's cached row holds the corrected
// number and resends it on the next Progress PUT, so unconditional
// zeroing would erase the fact while the value is still the owner's. The
// stamp survives a same-number PUT and dies the moment the number moves.
var created bool
if err := tx.QueryRow(`
INSERT INTO series (site, series_id, title, series_url, kind,
@@ -914,7 +919,10 @@ func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) {
ON CONFLICT (site, series_id) DO UPDATE SET
kind=excluded.kind,
latest_chapter=excluded.latest_chapter,
latest_chapter_num=excluded.latest_chapter_num
latest_chapter_num=excluded.latest_chapter_num,
latest_corrected_at = CASE
WHEN series.latest_chapter_num IS DISTINCT FROM excluded.latest_chapter_num
THEN 0 ELSE series.latest_corrected_at END
RETURNING xmax = 0`,
b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Kind,
b.LatestChapter, latestNum).Scan(&created); err != nil {
@@ -1320,10 +1328,13 @@ func (s *Store) LatestCheckedAt(site, seriesID string) (int64, error) {
// SetLatestChapter records the newest chapter the poll found on a series page.
// The poller walks Series rather than Bookmarks, so this is a series-level
// write: the row is shared, and updating it once refreshes every bookmark that
// joins to it. Touching a missing series is not an error.
// joins to it. Touching a missing series is not an error. The correction stamp
// is zeroed unconditionally: checkOne only calls this when the number differs,
// so a second copy of the condition would drift (#149).
func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) error {
if _, err := s.db.Exec(
`UPDATE series SET latest_chapter = $3, latest_chapter_num = $4
`UPDATE series SET latest_chapter = $3, latest_chapter_num = $4,
latest_corrected_at = 0
WHERE site = $1 AND series_id = $2`,
site, seriesID, label, num); err != nil {
return fmt.Errorf("set latest chapter %s:%s: %w", site, seriesID, err)
@@ -1331,8 +1342,27 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro
return nil
}
// RecordSighting notes that a Reader's browser reported this Series' Latest
// Chapter, which is the half of a Sighting the client body cannot express
// 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
// printed), so chapterLeadIn strips it and the UI renders "Ch N" with no
// special case. latest_checked_at is not touched: a Correction is not a check.
// A raising Reader is cleared without judgement: the number is the owner's
// now, and no Sighting counter moves (spec #135).
func (s *Store) CorrectLatestChapter(site, seriesID string, num float64, at int64) error {
if _, err := s.db.Exec(
`UPDATE series SET
latest_chapter = $3,
latest_chapter_num = $4,
latest_corrected_at = $5,
latest_raised_by = NULL
WHERE site = $1 AND series_id = $2`,
site, seriesID, "Chapter "+strconv.FormatFloat(num, 'f', -1, 64), num, at); err != nil {
return fmt.Errorf("correct latest chapter %s:%s: %w", site, seriesID, err)
}
return nil
}
// (issue #103). It must be called *before* the Upsert that stores the reported
// value: the raise test compares against what is still on the row, and after
// the Upsert there is nothing left to compare with. A Series that does not
+130
View File
@@ -1899,3 +1899,133 @@ func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
}
}
// A Correction writes the number, the derived label and the stamp, clears the
// raising Reader, and never touches either Sighting counter or the check
// stamp — a Correction is not a check and never judges a Reader (#149).
func TestCorrectLatestChapterStampsClearsAndDoesNotTouchCheckOrMarks(t *testing.T) {
s := newTestStore(t)
other := secondReader(t, s)
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 4321_000)
// A Reader raised the number, and carries a mark for it.
if err := s.RecordSighting(other, "asura", "solo", num2(3), 1000); err != nil {
t.Fatalf("RecordSighting: %v", err)
}
if _, err := s.db.Exec(`
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
WHERE id = $1`, other); err != nil {
t.Fatalf("mark reader: %v", err)
}
if err := s.CorrectLatestChapter("asura", "solo", 12.5, 9000); err != nil {
t.Fatalf("CorrectLatestChapter: %v", err)
}
var chapter string
var num float64
var stamp, checkedAt int64
var raisedBy any
if err := s.db.QueryRow(`
SELECT latest_chapter, latest_chapter_num, latest_corrected_at,
latest_checked_at, latest_raised_by
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
Scan(&chapter, &num, &stamp, &checkedAt, &raisedBy); err != nil {
t.Fatalf("read back: %v", err)
}
if chapter != "Chapter 12.5" {
t.Errorf("latest_chapter = %q, want the derived label %q", chapter, "Chapter 12.5")
}
if num != 12.5 {
t.Errorf("latest_chapter_num = %v, want 12.5", num)
}
if stamp != 9000 {
t.Errorf("latest_corrected_at = %d, want 9000", stamp)
}
if checkedAt != 4321_000 {
t.Errorf("latest_checked_at = %d, want the untouched 4321000", checkedAt)
}
if raisedBy != nil {
t.Errorf("latest_raised_by = %v, want the attribution cleared", raisedBy)
}
readers, err := s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
for _, r := range readers {
if r.ID == other && (r.Agreements != 5 || r.Disagreements != 2) {
t.Errorf("raising reader's marks = %+v, want agreements 5, disagreements 2 unchanged", r)
}
}
}
// The stamp follows the number (spec #135): an Upsert resending the corrected
// value — a Reader's cached row after a correction — keeps it, and an Upsert
// that actually moves the number kills it. Unconditional zeroing would erase
// the fact while the value is still the owner's; that is the whole point of
// the clause.
func TestUpsertCorrectionStampFollowsTheNumber(t *testing.T) {
s := newTestStore(t)
base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Kind: KindManga,
SeriesURL: "https://asurascans.com/comics/solo", UpdatedAt: 1000,
}
if _, err := s.Upsert(s.OwnerID(), base); err != nil {
t.Fatalf("seed: %v", err)
}
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
t.Fatalf("CorrectLatestChapter: %v", err)
}
// Same number back: the value is still the owner's.
same := base
same.LatestChapterNum = num2(5)
if _, err := s.Upsert(s.OwnerID(), same); err != nil {
t.Fatalf("same-number upsert: %v", err)
}
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 9000 {
t.Fatalf("stamp after same-number Upsert = %d, want 9000 kept", got)
}
// A different number: a machine (or a Reader) wrote the value.
moved := base
moved.LatestChapterNum = num2(7)
if _, err := s.Upsert(s.OwnerID(), moved); err != nil {
t.Fatalf("moved upsert: %v", err)
}
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
t.Fatalf("stamp after moved Upsert = %d, want zeroed", got)
}
}
// The poller's chapter setter zeroes the stamp unconditionally: checkOne only
// calls it when the number differs, so the condition lives upstream and a
// second copy here would drift (#149).
func TestSetLatestChapterZeroesCorrectionStamp(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
t.Fatalf("CorrectLatestChapter: %v", err)
}
if err := s.SetLatestChapter("asura", "solo", "Chapter 6", 6); err != nil {
t.Fatalf("SetLatestChapter: %v", err)
}
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
t.Fatalf("stamp after a machine write = %d, want zeroed", got)
}
}
// latestCorrectedAt reads the stamp column for the assertion above.
func (s *Store) latestCorrectedAt(t *testing.T, site, seriesID string) int64 {
t.Helper()
var stamp int64
if err := s.db.QueryRow(
`SELECT latest_corrected_at FROM series WHERE site = $1 AND series_id = $2`,
site, seriesID).Scan(&stamp); err != nil {
t.Fatalf("read stamp: %v", err)
}
return stamp
}
// num2 boxes a chapter number for the Bookmark fields that take a pointer.
func num2(f float64) *float64 { return &f }