Implements spec #135 (spec 2 of 4, derived from wayfinder map #114; decisions settled in #120/#121/#125/#131). Blocked-by #134 is merged, so this lands on `main`. Four owner actions the dashboard can now perform, one ticket each: - **#149** — Latest Chapter correction: one numeric input, overwritten by the next machine write. - **#151** — Series URL repair: owner-typed, gated by the poller's own fetch gate. - **#150 / #153 / #154** — Cover replacement: addresses derived from bytes (`#150`), a Forced Poll replaces the Cover while an ordinary pass still only fills a blank one (`#153`), and byte reclamation is one guarded helper, file first / covers row last (`#154`). - **#155** — Orphan removal: one Series at a time, with the foreign key as the guard. Plus **#152** — Latest Chapter provenance: one derived line naming the actor class, so an owner can tell a hand-edited number from a machine read. - Migration `0015_latest_correction.sql` adds the correction/provenance columns; `0009` now derives cover addresses from bytes. - ADR `0014-cover-addresses-from-bytes.md` records the address scheme. Backend tests cover the store, poller, admin handlers, and web routes (`go test ./...`, needs Docker). Reviewed-on: #156 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #156.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
-- The Cover splits into two facts. `cover` keeps the third-party address the
|
||||
-- bytes come from, which is what the acquisition path refetches and dedupes
|
||||
-- on; `cover_address` is the content address of the bytes once they are
|
||||
-- actually stored, and is what the wire's absolute URL is built from.
|
||||
-- bytes come from, which is what the refetch path dedupes on; `cover_address`
|
||||
-- is the content address of the bytes once they are actually stored, and is
|
||||
-- what the wire's absolute URL is built from.
|
||||
--
|
||||
-- Empty `cover_address` therefore means "no Cover yet" rather than "a Cover
|
||||
-- that 404s", which is the distinction the API and the UI both depend on.
|
||||
--
|
||||
-- The content address was originally the hex SHA-256 of the source URL
|
||||
-- (ADR-0007). Since ADR-0014 it is the hex SHA-256 of the bytes themselves,
|
||||
-- so a re-art behind the same URL is a new address. Rows written before
|
||||
-- ADR-0014 keep their URL-derived addresses; they are never rehashed and heal
|
||||
-- into byte addressing on their first forced replacement. Both derivations
|
||||
-- share the 64-hex-digit shape, so the serving guard is unchanged.
|
||||
ALTER TABLE series ADD COLUMN cover_address text NOT NULL DEFAULT '';
|
||||
|
||||
@@ -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;
|
||||
+204
-37
@@ -16,6 +16,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
@@ -60,10 +61,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
|
||||
@@ -704,67 +706,112 @@ func (s *Store) getCoverByAddress(address string) ([]byte, string, bool, error)
|
||||
return body, contentType, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) putCover(sourceURL string, body []byte, contentType string) error {
|
||||
func (s *Store) putCover(sourceURL string, body []byte, contentType string) (string, error) {
|
||||
stored, ok := CoverContentType(contentType)
|
||||
if !ok {
|
||||
return fmt.Errorf("put cover %q: unsupported content type %q", sourceURL, contentType)
|
||||
return "", fmt.Errorf("put cover %q: unsupported content type %q", sourceURL, contentType)
|
||||
}
|
||||
contentType = stored
|
||||
address := coverSourceAddress(sourceURL)
|
||||
address := CoverAddressForBytes(body)
|
||||
relativePath := coverRelativePath(address)
|
||||
coverPath := filepath.Join(s.coverDir, filepath.FromSlash(relativePath))
|
||||
if err := os.MkdirAll(filepath.Dir(coverPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create cover shard: %w", err)
|
||||
return "", fmt.Errorf("create cover shard: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(coverPath), ".cover-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create cover temp file: %w", err)
|
||||
return "", fmt.Errorf("create cover temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(body); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write cover temp file: %w", err)
|
||||
return "", fmt.Errorf("write cover temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("sync cover temp file: %w", err)
|
||||
return "", fmt.Errorf("sync cover temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close cover temp file: %w", err)
|
||||
return "", fmt.Errorf("close cover temp file: %w", err)
|
||||
}
|
||||
if err := os.Link(tmpName, coverPath); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||
return fmt.Errorf("install cover file: %w", err)
|
||||
return "", fmt.Errorf("install cover file: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO covers (address, path, content_type)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (address) DO NOTHING`, address, relativePath, contentType); err != nil {
|
||||
return fmt.Errorf("record cover %q: %w", address, err)
|
||||
return "", fmt.Errorf("record cover %q: %w", address, err)
|
||||
}
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// ReclaimCover permanently removes a Cover nothing references: the sharded
|
||||
// file first, the covers row last. A blank address is a no-op, and so is any
|
||||
// address a Series row still points at — byte-identical artwork is one row by
|
||||
// construction (ADR-0014), so reclaiming one Series' stranded bytes must not
|
||||
// blank another's. The file goes first because the covers row is the handle:
|
||||
// an interrupted run stays findable in SQL — covers rows unreferenced by any
|
||||
// series cover_address — and re-running finishes the job, whereas deleting
|
||||
// the row first would leave a file nothing names. A concurrent Forced Poll
|
||||
// repointing a live Series at this address between the guard and the unlink
|
||||
// is the repairable case: the missing file reads as ok=false and the next
|
||||
// pass re-installs it. Failures are returned, never logged here — the caller
|
||||
// logs and carries on — and a failed unlink leaves the row in place for a
|
||||
// retry. A whole-table sweep, if ever wanted, is one SQL query over covers,
|
||||
// not a tree walk and not this function.
|
||||
func (s *Store) ReclaimCover(address string) error {
|
||||
if address == "" {
|
||||
return nil
|
||||
}
|
||||
var referenced int
|
||||
err := s.db.QueryRow(`SELECT 1 FROM series WHERE cover_address = $1 LIMIT 1`, address).Scan(&referenced)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("guard reclaim of cover %q: %w", address, err)
|
||||
}
|
||||
coverPath := filepath.Join(s.coverDir, filepath.FromSlash(coverRelativePath(address)))
|
||||
if err := os.Remove(coverPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("remove cover file %q: %w", address, err)
|
||||
}
|
||||
if _, err := s.db.Exec(`DELETE FROM covers WHERE address = $1`, address); err != nil {
|
||||
return fmt.Errorf("delete cover row %q: %w", address, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCover returns the immutable object addressed by its source URL. Missing
|
||||
// GetCover returns the immutable object a source URL's own hash names. Rows
|
||||
// written before byte addressing (ADR-0014) are the only ones that ever reach
|
||||
// it; it hashes the URL, so a byte-addressed Cover is invisible to it. Missing
|
||||
// files are reported with ok=false so callers can retry acquisition later.
|
||||
func (s *Store) GetCover(sourceURL string) ([]byte, string, bool, error) {
|
||||
return s.getCover(sourceURL)
|
||||
}
|
||||
|
||||
// PutCover persists bytes under the source URL's content address. A later
|
||||
// write for the same URL cannot replace the immutable object.
|
||||
// PutCover persists bytes under their own content address (ADR-0014). A later
|
||||
// write of the same bytes cannot replace the immutable object.
|
||||
func (s *Store) PutCover(sourceURL string, body []byte, contentType string) error {
|
||||
return s.putCover(sourceURL, body, contentType)
|
||||
_, err := s.putCover(sourceURL, body, contentType)
|
||||
return err
|
||||
}
|
||||
|
||||
// CoverAddress is the content address bytes fetched from sourceURL are stored
|
||||
// under. It is a pure function of the URL, so the acquisition path can name a
|
||||
// Cover before it has the bytes.
|
||||
func CoverAddress(sourceURL string) string { return coverSourceAddress(sourceURL) }
|
||||
// CoverAddressForBytes is the content address body is stored under: the hex
|
||||
// SHA-256 of the bytes, so identical artwork is one address and a re-art a
|
||||
// new one. Legacy rows were addressed from their source URL instead and are
|
||||
// never rehashed — both derivations coexist (ADR-0014).
|
||||
func CoverAddressForBytes(body []byte) string {
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// coverAddressRe is the shape of a stored address: the hex SHA-256 of a source
|
||||
// URL. Request paths reach CoverByAddress, so the shape is checked before the
|
||||
// value is ever turned into a filesystem path.
|
||||
// coverAddressRe is the shape of a stored address: 64 lowercase hex digits —
|
||||
// the hex SHA-256 of the cover bytes, or of the source URL for legacy rows
|
||||
// (ADR-0014). Request paths reach CoverByAddress, so the shape is checked
|
||||
// before the value is ever turned into a filesystem path; byte-derived
|
||||
// addresses keep the same shape, so the guard is unchanged.
|
||||
var coverAddressRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// CoverByAddress returns the immutable object at one content address. An
|
||||
@@ -788,24 +835,68 @@ func (s *Store) CoverWireURL(address string) string {
|
||||
return s.coverBaseURL + "/covers/" + address
|
||||
}
|
||||
|
||||
// SetSeriesCover stores the bytes and points the Series at them, but only
|
||||
// while the Series has no Cover: acquisition at creation and the poll both
|
||||
// call this, and whichever arrives second must not overwrite the first. The
|
||||
// bytes themselves are content-addressed and immutable, so storing them twice
|
||||
// is free.
|
||||
// SetSeriesCover stores the bytes and points the Series at their address, but
|
||||
// only while the Series has no Cover: acquisition at creation and the poll
|
||||
// both call this, and whichever arrives second must not overwrite the first.
|
||||
// The bytes themselves are content-addressed and immutable, so storing them
|
||||
// twice is free. See ReplaceSeriesCover for the write that may move a Cover
|
||||
// once one exists (ADR-0014).
|
||||
func (s *Store) SetSeriesCover(site, seriesID, sourceURL string, body []byte, contentType string) error {
|
||||
if err := s.putCover(sourceURL, body, contentType); err != nil {
|
||||
address, err := s.putCover(sourceURL, body, contentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
UPDATE series SET cover = $3, cover_address = $4
|
||||
WHERE site = $1 AND series_id = $2 AND cover_address = ''`,
|
||||
site, seriesID, sourceURL, coverSourceAddress(sourceURL)); err != nil {
|
||||
site, seriesID, sourceURL, address); err != nil {
|
||||
return fmt.Errorf("set cover for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceSeriesCover stores the bytes and points the Series at their address
|
||||
// whether or not one already exists, writing the current source URL alongside
|
||||
// — the Forced Poll's installer and the only write that may move a Cover once
|
||||
// one exists (ADR-0014). previous is the address the row held before the write
|
||||
// ("" if it had none) and current the address of the bytes just stored; both
|
||||
// are read and written in one transaction, so a concurrent replacement reports
|
||||
// the exact displacement. previous == current means the Site served identical
|
||||
// artwork, an honest no-op; otherwise previous is stranded — the row no
|
||||
// longer points at it, and reclaiming its bytes is the caller's separate act
|
||||
// (the poller's replace path calls ReclaimCover on it). This write itself
|
||||
// removes nothing.
|
||||
func (s *Store) ReplaceSeriesCover(site, seriesID, sourceURL string, body []byte, contentType string) (previous, current string, err error) {
|
||||
current, err = s.putCover(sourceURL, body, contentType)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("begin replace cover for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
err = tx.QueryRow(`
|
||||
SELECT cover_address FROM series
|
||||
WHERE site = $1 AND series_id = $2 FOR UPDATE`,
|
||||
site, seriesID).Scan(&previous)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
previous = ""
|
||||
} else if err != nil {
|
||||
return "", "", fmt.Errorf("read cover for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE series SET cover = $3, cover_address = $4
|
||||
WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID, sourceURL, current); err != nil {
|
||||
return "", "", fmt.Errorf("replace cover for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", "", fmt.Errorf("commit cover replace for %q: %w", site+":"+seriesID, err)
|
||||
}
|
||||
return previous, current, nil
|
||||
}
|
||||
|
||||
// List returns every bookmark of one reader, newest activity first.
|
||||
// Series-owned fields are joined in, so each Bookmark reads back whole and
|
||||
// flat (ADR-0004).
|
||||
@@ -904,6 +995,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 +1010,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 {
|
||||
@@ -984,6 +1083,37 @@ func (s *Store) Delete(readerID int64, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pgForeignKeyViolation is the SQLSTATE the driver surfaces when a Bookmark
|
||||
// row refuses a Series delete (bookmarks_series_fk). pgconn exports no named
|
||||
// constant for it, so the store names it here.
|
||||
const pgForeignKeyViolation = "23503"
|
||||
|
||||
// ErrSeriesHasBookmarks is RemoveSeries' refusal: a Reader still holds the
|
||||
// Series, so the owner's removal must not reach past that record. The
|
||||
// delete is the check — no NOT EXISTS pre-check that can race the insert —
|
||||
// and the driver's foreign-key violation is translated here so no driver
|
||||
// type escapes the store (issue #155).
|
||||
var ErrSeriesHasBookmarks = errors.New("series has bookmarks")
|
||||
|
||||
// RemoveSeries deletes one Series row by (site, series_id). It is refused
|
||||
// while any Bookmark references the row; deleting an absent key is not an
|
||||
// error, matching Delete. The caller owns the stranded Cover: read the row's
|
||||
// cover_address before the delete and call ReclaimCover after it — the
|
||||
// helper's guard cannot pass while the series row still points at the
|
||||
// address, so the order is the sequence, not a preference.
|
||||
func (s *Store) RemoveSeries(site, seriesID string) error {
|
||||
if _, err := s.db.Exec(
|
||||
`DELETE FROM series WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == pgForeignKeyViolation {
|
||||
return ErrSeriesHasBookmarks
|
||||
}
|
||||
return fmt.Errorf("remove series %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordLanePass appends one pass and prunes every older row in the same
|
||||
// transaction. retainBefore is supplied by the poller's clock.
|
||||
func (s *Store) RecordLanePass(p LanePass, retainBefore int64) error {
|
||||
@@ -1320,10 +1450,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 +1464,42 @@ 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
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -850,7 +852,7 @@ func TestSetSeriesCoverDoesNotOverwrite(t *testing.T) {
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get = %v, %v", ok, err)
|
||||
}
|
||||
if want := "https://bookmarks.test/covers/" + CoverAddress(first); got.Cover != want {
|
||||
if want := "https://bookmarks.test/covers/" + CoverAddressForBytes([]byte("first")); got.Cover != want {
|
||||
t.Fatalf("Cover = %q, want the first one %q", got.Cover, want)
|
||||
}
|
||||
}
|
||||
@@ -869,7 +871,7 @@ func TestCoverByAddress(t *testing.T) {
|
||||
t.Fatalf("SetSeriesCover: %v", err)
|
||||
}
|
||||
|
||||
body, contentType, ok, err := store.CoverByAddress(CoverAddress(source))
|
||||
body, contentType, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("bytes")))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CoverByAddress = %v, %v", ok, err)
|
||||
}
|
||||
@@ -877,8 +879,8 @@ func TestCoverByAddress(t *testing.T) {
|
||||
t.Fatalf("CoverByAddress = %q, %q, want the stored bytes", body, contentType)
|
||||
}
|
||||
|
||||
for _, address := range []string{"", "../../etc/passwd", "ZZ" + CoverAddress(source)[2:],
|
||||
CoverAddress("never stored")} {
|
||||
for _, address := range []string{"", "../../etc/passwd", "ZZ" + CoverAddressForBytes([]byte("bytes"))[2:],
|
||||
CoverAddressForBytes([]byte("never stored"))} {
|
||||
_, _, ok, err := store.CoverByAddress(address)
|
||||
if err != nil || ok {
|
||||
t.Fatalf("CoverByAddress(%q) = %v, %v, want a clean miss", address, ok, err)
|
||||
@@ -913,7 +915,7 @@ func TestUpsertExistingSeriesIgnoresClientTitleCoverURL(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
wantCover := "https://bookmarks.test/covers/" + CoverAddress(acquired)
|
||||
wantCover := "https://bookmarks.test/covers/" + CoverAddressForBytes([]byte("bytes"))
|
||||
if got.Title != "Solo Leveling" || got.SeriesURL != "https://asurascans.com/comics/solo" ||
|
||||
got.Cover != wantCover {
|
||||
t.Fatalf("stored = %+v, want original title/url/cover kept", got)
|
||||
@@ -981,7 +983,7 @@ func TestDeleteKeepsSeriesRow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("re-upsert: %v", err)
|
||||
}
|
||||
wantCover := "https://bookmarks.test/covers/" + CoverAddress(acquired)
|
||||
wantCover := "https://bookmarks.test/covers/" + CoverAddressForBytes([]byte("bytes"))
|
||||
if stored.Title != "Solo Leveling" || stored.Cover != wantCover {
|
||||
t.Fatalf("re-bookmark = %+v, want title/cover from the surviving series row", stored)
|
||||
}
|
||||
@@ -1498,9 +1500,9 @@ func TestCoverPersistsAcrossReopen(t *testing.T) {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer second.Close()
|
||||
got, contentType, ok, err := second.GetCover(sourceURL)
|
||||
got, contentType, ok, err := second.CoverByAddress(CoverAddressForBytes(body))
|
||||
if err != nil {
|
||||
t.Fatalf("GetCover: %v", err)
|
||||
t.Fatalf("CoverByAddress: %v", err)
|
||||
}
|
||||
if !ok || !bytes.Equal(got, body) || contentType != "image/webp" {
|
||||
t.Fatalf("stored cover = (%q, %q, %v), want (%q, image/webp, true)", got, contentType, ok, body)
|
||||
@@ -1539,7 +1541,7 @@ func TestCoverIsContentAddressedOnFilesystem(t *testing.T) {
|
||||
}
|
||||
defer first.Close()
|
||||
|
||||
addressBytes := sha256.Sum256([]byte(sourceURL))
|
||||
addressBytes := sha256.Sum256(body)
|
||||
address := hex.EncodeToString(addressBytes[:])
|
||||
wantPath := filepath.Join(address[:2], address[2:4], address)
|
||||
|
||||
@@ -1570,9 +1572,9 @@ func TestCoverStoreAcceptsAnySourceURL(t *testing.T) {
|
||||
if err := s.PutCover(sourceURL, want, "image/jpeg"); err != nil {
|
||||
t.Fatalf("PutCover: %v", err)
|
||||
}
|
||||
got, contentType, ok, err := s.GetCover(sourceURL)
|
||||
got, contentType, ok, err := s.CoverByAddress(CoverAddressForBytes(want))
|
||||
if err != nil {
|
||||
t.Fatalf("GetCover: %v", err)
|
||||
t.Fatalf("CoverByAddress: %v", err)
|
||||
}
|
||||
if !ok || !bytes.Equal(got, want) || contentType != "image/jpeg" {
|
||||
t.Fatalf("GetCover = (%q, %q, %v), want (%q, image/jpeg, true)", got, contentType, ok, want)
|
||||
@@ -1580,7 +1582,7 @@ func TestCoverStoreAcceptsAnySourceURL(t *testing.T) {
|
||||
if err := s.PutCover("https://cdn.example/not-image", []byte("html"), "text/html"); err == nil {
|
||||
t.Fatal("PutCover accepted a non-image")
|
||||
}
|
||||
if _, _, ok, err := s.GetCover("https://cdn.example/not-image"); err != nil || ok {
|
||||
if _, _, ok, err := s.CoverByAddress(CoverAddressForBytes([]byte("html"))); err != nil || ok {
|
||||
t.Fatalf("rejected cover = found %v, err %v; want missing", ok, err)
|
||||
}
|
||||
}
|
||||
@@ -1899,3 +1901,551 @@ 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 }
|
||||
|
||||
// --- Cover addressing (ADR-0014): the address is the bytes' SHA-256 ---
|
||||
|
||||
// The address is what makes a re-art visible at all, so the same bytes must
|
||||
// always name the same address and different bytes different ones — and the
|
||||
// address must keep the 64-hex-digit shape CoverByAddress's guard still checks
|
||||
// before any request-supplied value becomes a filesystem path.
|
||||
func TestCoverAddressForBytesIsDeterministicAndDistinct(t *testing.T) {
|
||||
first := CoverAddressForBytes([]byte("art"))
|
||||
again := CoverAddressForBytes([]byte("art"))
|
||||
other := CoverAddressForBytes([]byte("artwork"))
|
||||
if first != again {
|
||||
t.Fatalf("same bytes gave %q then %q, want one address", first, again)
|
||||
}
|
||||
if first == other {
|
||||
t.Fatalf("different bytes gave the same address %q", first)
|
||||
}
|
||||
if !coverAddressRe.MatchString(first) {
|
||||
t.Fatalf("address %q is not the 64-hex-digit shape the serving guard checks", first)
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceSeriesCover is the forced-replacement installer: it moves a Cover
|
||||
// whether or not one exists, writes the source URL alongside it, and reports
|
||||
// the three outcomes the Forced Poll has to tell apart.
|
||||
func TestReplaceSeriesCover(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// A blank Cover: previous is "", and the row points at the new bytes.
|
||||
previous, current, err := store.ReplaceSeriesCover("asura", "solo",
|
||||
"https://cdn.asurascans.com/covers/solo.webp", []byte("first-art"), "image/webp")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceSeriesCover on a blank: %v", err)
|
||||
}
|
||||
if previous != "" {
|
||||
t.Fatalf("previous on a blank = %q, want empty", previous)
|
||||
}
|
||||
if want := CoverAddressForBytes([]byte("first-art")); current != want {
|
||||
t.Fatalf("current = %q, want %q", current, want)
|
||||
}
|
||||
if sr := readSeries(t, store, "asura", "solo"); sr.CoverAddress != current ||
|
||||
sr.Cover != "https://cdn.asurascans.com/covers/solo.webp" {
|
||||
t.Fatalf("series after blank fill = %+v, want the new address and source URL", sr)
|
||||
}
|
||||
|
||||
// A re-art: previous is the stranded address, current the new one.
|
||||
previous, current, err = store.ReplaceSeriesCover("asura", "solo",
|
||||
"https://cdn.asurascans.com/covers/solo-rebrand.webp", []byte("second-art"), "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceSeriesCover over a filled Cover: %v", err)
|
||||
}
|
||||
if want := CoverAddressForBytes([]byte("first-art")); previous != want {
|
||||
t.Fatalf("previous = %q, want the replaced address %q", previous, want)
|
||||
}
|
||||
if want := CoverAddressForBytes([]byte("second-art")); current != want {
|
||||
t.Fatalf("current = %q, want %q", current, want)
|
||||
}
|
||||
if sr := readSeries(t, store, "asura", "solo"); sr.CoverAddress != current ||
|
||||
sr.Cover != "https://cdn.asurascans.com/covers/solo-rebrand.webp" {
|
||||
t.Fatalf("series after replacement = %+v, want the new address and source URL", sr)
|
||||
}
|
||||
// The replaced bytes stay served under their old address: ReplaceSeriesCover
|
||||
// itself reclaims nothing, reclamation is the caller's separate act (#154).
|
||||
if _, _, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("first-art"))); err != nil || !ok {
|
||||
t.Fatalf("superseded bytes = found %v, err %v, want still served", ok, err)
|
||||
}
|
||||
|
||||
// The Site is serving the same artwork again: previous == current is the
|
||||
// honest no-op the caller reports as "unchanged".
|
||||
previous, current, err = store.ReplaceSeriesCover("asura", "solo",
|
||||
"https://cdn.asurascans.com/covers/solo-rebrand.webp", []byte("second-art"), "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceSeriesCover over identical bytes: %v", err)
|
||||
}
|
||||
if previous != current {
|
||||
t.Fatalf("identical bytes: previous = %q, current = %q, want one address", previous, current)
|
||||
}
|
||||
if want := CoverAddressForBytes([]byte("second-art")); current != want {
|
||||
t.Fatalf("current = %q, want %q", current, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows written before byte addressing hold the hash of their source URL and
|
||||
// are never rehashed: GetCover — the poller's heal path — keeps resolving
|
||||
// them through coverSourceAddress.
|
||||
func TestGetCoverResolvesLegacyURLDerivedAddress(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := "https://cdn.example/legacy.jpg"
|
||||
legacy := coverSourceAddress(source)
|
||||
relativePath := coverRelativePath(legacy)
|
||||
coverPath := filepath.Join(store.coverDir, filepath.FromSlash(relativePath))
|
||||
if err := os.MkdirAll(filepath.Dir(coverPath), 0o755); err != nil {
|
||||
t.Fatalf("create shard dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(coverPath, []byte("legacy-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write legacy file: %v", err)
|
||||
}
|
||||
if _, err := store.db.Exec(
|
||||
`INSERT INTO covers (address, path, content_type) VALUES ($1, $2, $3)`,
|
||||
legacy, relativePath, "image/jpeg"); err != nil {
|
||||
t.Fatalf("plant legacy row: %v", err)
|
||||
}
|
||||
|
||||
body, contentType, ok, err := store.GetCover(source)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetCover on a legacy row = %v, %v, want found", ok, err)
|
||||
}
|
||||
if string(body) != "legacy-bytes" || contentType != "image/jpeg" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cover byte reclamation (issue #154): one guarded helper, file first ---
|
||||
|
||||
// coverShardPath is the on-disk location of one address's bytes, built the
|
||||
// same way getCoverByAddress reads them.
|
||||
func coverShardPath(t *testing.T, s *Store, address string) string {
|
||||
t.Helper()
|
||||
return filepath.Join(s.coverDir, filepath.FromSlash(coverRelativePath(address)))
|
||||
}
|
||||
|
||||
// ReclaimCover removes a Cover nothing references: the row alone is not the
|
||||
// point — the sharded file must be gone too, because the file is the reclaimed
|
||||
// disk space.
|
||||
func TestReclaimCoverRemovesUnreferencedBytes(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/old.jpg", []byte("old-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
old := CoverAddressForBytes([]byte("old-art"))
|
||||
if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/new.jpg", []byte("new-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("replace cover: %v", err)
|
||||
}
|
||||
|
||||
if err := store.ReclaimCover(old); err != nil {
|
||||
t.Fatalf("ReclaimCover: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, old)); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("sharded path after reclaim = %v, want fs.ErrNotExist", err)
|
||||
}
|
||||
if _, _, ok, err := store.CoverByAddress(old); err != nil || ok {
|
||||
t.Fatalf("covers row after reclaim = found %v err %v, want gone", ok, err)
|
||||
}
|
||||
// The live Cover survives the reclamation of the stranded one.
|
||||
if body, _, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("new-art"))); err != nil || !ok || string(body) != "new-art" {
|
||||
t.Fatalf("new bytes after reclaim = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The guard is the whole design: byte-identical artwork is one covers row by
|
||||
// construction (ADR-0014), so a second Series pointing at the address must
|
||||
// keep the bytes — reclaiming one Series' stranded artwork may not blank
|
||||
// another's.
|
||||
func TestReclaimCoverSparesReferencedAddress(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
const src = "https://cdn.example/covers/shared.jpg"
|
||||
addr := CoverAddressForBytes([]byte("shared-art"))
|
||||
if err := store.SetSeriesCover("asura", "solo", src, []byte("shared-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
|
||||
// One Series pointing at the address is enough for the guard.
|
||||
if err := store.ReclaimCover(addr); err != nil {
|
||||
t.Fatalf("ReclaimCover on a referenced address: %v", err)
|
||||
}
|
||||
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" {
|
||||
t.Fatalf("bytes after no-op = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil {
|
||||
t.Fatalf("sharded file after no-op: %v, want present", err)
|
||||
}
|
||||
|
||||
// A second Series serving identical bytes shares the row by construction.
|
||||
seedForCheck(t, store, "asura:second", "https://asurascans.com/comics/second", 0)
|
||||
if err := store.SetSeriesCover("asura", "second", src, []byte("shared-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("share cover: %v", err)
|
||||
}
|
||||
if err := store.ReclaimCover(addr); err != nil {
|
||||
t.Fatalf("ReclaimCover on a shared address: %v", err)
|
||||
}
|
||||
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" {
|
||||
t.Fatalf("shared bytes after no-op = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil {
|
||||
t.Fatalf("sharded file after shared no-op: %v, want present", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A blank address is the wire value for "no Cover" (ADR-0007), never a
|
||||
// reclaimable one.
|
||||
func TestReclaimCoverBlankAddressIsNoOp(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if err := store.ReclaimCover(""); err != nil {
|
||||
t.Fatalf("ReclaimCover(\"\") = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An interrupted reclamation is the state the file-first order exists for:
|
||||
// the row is the handle, so the unreferenced-covers query finds the torn
|
||||
// Cover and re-running ReclaimCover finishes the job — a missing file is
|
||||
// "already gone", which counts as success.
|
||||
func TestReclaimCoverInterruptedRunIsFindableAndFinishes(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/torn.jpg", []byte("torn-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
torn := CoverAddressForBytes([]byte("torn-art"))
|
||||
if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/new.jpg", []byte("new-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("replace cover: %v", err)
|
||||
}
|
||||
if err := os.Remove(coverShardPath(t, store, torn)); err != nil {
|
||||
t.Fatalf("unlink mid-reclamation: %v", err)
|
||||
}
|
||||
|
||||
var found string
|
||||
err := store.db.QueryRow(`
|
||||
SELECT address FROM covers c
|
||||
WHERE NOT EXISTS (SELECT 1 FROM series s WHERE s.cover_address = c.address)
|
||||
LIMIT 1`).Scan(&found)
|
||||
if err != nil || found != torn {
|
||||
t.Fatalf("unreferenced-covers query = (%q, %v), want the torn row %q", found, err, torn)
|
||||
}
|
||||
|
||||
if err := store.ReclaimCover(torn); err != nil {
|
||||
t.Fatalf("re-run over a missing file: %v", err)
|
||||
}
|
||||
if _, _, ok, err := store.CoverByAddress(torn); err != nil || ok {
|
||||
t.Fatalf("row after re-run = found %v err %v, want gone", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed file removal is the one state that is not self-cleaning: the
|
||||
// covers row must survive so a retry can finish the job, and the store
|
||||
// returns the error rather than logging — each caller logs and carries on,
|
||||
// so the failure has no user-facing surface.
|
||||
func TestReclaimCoverFailedUnlinkKeepsRow(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/stuck.jpg", []byte("stuck-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
stuck := CoverAddressForBytes([]byte("stuck-art"))
|
||||
if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/other.jpg", []byte("other-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("replace cover: %v", err)
|
||||
}
|
||||
// Make the unlink fail: the sharded path becomes a non-empty directory,
|
||||
// which os.Remove refuses.
|
||||
shard := coverShardPath(t, store, stuck)
|
||||
if err := os.Remove(shard); err != nil {
|
||||
t.Fatalf("clear file: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(shard, 0o755); err != nil {
|
||||
t.Fatalf("replace file with dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(shard, "blob"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("fill dir: %v", err)
|
||||
}
|
||||
|
||||
if err := store.ReclaimCover(stuck); err == nil {
|
||||
t.Fatal("ReclaimCover over an unremovable file = nil, want the error")
|
||||
}
|
||||
var one int
|
||||
if err := store.db.QueryRow(`SELECT 1 FROM covers WHERE address = $1`, stuck).Scan(&one); err != nil {
|
||||
t.Fatal("covers row after failed unlink is gone; want it left for a retry")
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSeries is the orphan removal (#155): one Series, one delete, refused
|
||||
// by the database while any Bookmark points at it. The store translates the
|
||||
// foreign-key violation into its own sentinel so no driver type escapes, and
|
||||
// the caller reaps the stranded Cover through ReclaimCover.
|
||||
func TestRemoveSeriesRemovesOrphanAndReclaimsCover(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil {
|
||||
t.Fatalf("orphan the series: %v", err)
|
||||
}
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/old.jpg", []byte("old-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
addr := CoverAddressForBytes([]byte("old-art"))
|
||||
|
||||
if err := store.RemoveSeries("asura", "solo"); err != nil {
|
||||
t.Fatalf("RemoveSeries: %v", err)
|
||||
}
|
||||
// The caller's sequence: the row is deleted first, then the address is
|
||||
// reclaimed — the guard cannot pass while the row still points at it.
|
||||
if err := store.ReclaimCover(addr); err != nil {
|
||||
t.Fatalf("ReclaimCover: %v", err)
|
||||
}
|
||||
var one int
|
||||
if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "solo").Scan(&one); err != sql.ErrNoRows {
|
||||
t.Fatalf("series row after remove = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
if _, _, ok, err := store.CoverByAddress(addr); err != nil || ok {
|
||||
t.Fatalf("covers row after remove = found %v err %v, want gone", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, addr)); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("sharded file after remove = %v, want fs.ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal is the whole point of the sentinel: a Series a Reader still
|
||||
// holds is not removed, its row is untouched and its Cover keeps serving.
|
||||
func TestRemoveSeriesRefusedWhileBookmarked(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/kept.jpg", []byte("kept-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover: %v", err)
|
||||
}
|
||||
addr := CoverAddressForBytes([]byte("kept-art"))
|
||||
|
||||
if err := store.RemoveSeries("asura", "solo"); !errors.Is(err, ErrSeriesHasBookmarks) {
|
||||
t.Fatalf("RemoveSeries on a bookmarked series = %v, want ErrSeriesHasBookmarks", err)
|
||||
}
|
||||
var held int
|
||||
if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "solo").Scan(&held); err != nil {
|
||||
t.Fatal("series row after refusal is gone; want it untouched")
|
||||
}
|
||||
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "kept-art" {
|
||||
t.Fatalf("cover after refusal = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A Series sharing its Cover address with a second Series is removed while
|
||||
// the artwork stays readable through CoverByAddress: the series row stops
|
||||
// referencing the address first, so ReclaimCover's guard passes for this
|
||||
// caller without touching the shared bytes (ADR-0014).
|
||||
func TestRemoveSeriesSparesSharedCover(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
seedForCheck(t, store, "asura:second", "https://asurascans.com/comics/second", 0)
|
||||
if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil {
|
||||
t.Fatalf("orphan solo: %v", err)
|
||||
}
|
||||
if err := store.Delete(store.OwnerID(), "asura:second"); err != nil {
|
||||
t.Fatalf("orphan second: %v", err)
|
||||
}
|
||||
const src = "https://cdn.example/covers/shared.jpg"
|
||||
if err := store.SetSeriesCover("asura", "solo", src, []byte("shared-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover on solo: %v", err)
|
||||
}
|
||||
if err := store.SetSeriesCover("asura", "second", src, []byte("shared-art"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("put cover on second: %v", err)
|
||||
}
|
||||
addr := CoverAddressForBytes([]byte("shared-art"))
|
||||
|
||||
if err := store.RemoveSeries("asura", "solo"); err != nil {
|
||||
t.Fatalf("RemoveSeries: %v", err)
|
||||
}
|
||||
// The guard spares the shared bytes even though this caller reclaims.
|
||||
if err := store.ReclaimCover(addr); err != nil {
|
||||
t.Fatalf("ReclaimCover over a shared address: %v", err)
|
||||
}
|
||||
if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" {
|
||||
t.Fatalf("shared bytes after remove = found %v err %v, want still served", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil {
|
||||
t.Fatalf("sharded file after remove: %v, want present", err)
|
||||
}
|
||||
var one int
|
||||
if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "second").Scan(&one); err != nil {
|
||||
t.Fatal("the second series row vanished with the first")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting an absent key removes nothing and is not an error, matching the
|
||||
// Delete precedent — the handler's own lookups turn the absent case into the
|
||||
// 404 before the store ever sees it.
|
||||
func TestRemoveSeriesMissingKeyIsCleanNoOp(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if err := store.RemoveSeries("asura", "ghost"); err != nil {
|
||||
t.Fatalf("RemoveSeries on a missing key = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user