From 051c429b24082c7ff31ad7a4e18104ec15708b60 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 12:46:27 +0700 Subject: [PATCH] feat(store): add status column with keep-on-empty upsert rule --- backend/store.go | 42 +++++++++++++-- backend/store_test.go | 116 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 4 deletions(-) diff --git a/backend/store.go b/backend/store.go index d75916c..a6b7b61 100644 --- a/backend/store.go +++ b/backend/store.go @@ -27,6 +27,10 @@ type Bookmark struct { LatestChapter string `json:"latest_chapter"` LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert + // Status is the lifecycle bucket: reading, archived, or finished. + // Archived series stay polled for new chapters; finished ones do not. + // Empty on the way in means "no opinion" — see Upsert. + Status string `json:"status"` } // HasNewChapter reports whether the site has published past the read point. @@ -45,6 +49,13 @@ func (b Bookmark) ContinueURL() string { return b.SeriesURL } +// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal. +const ( + statusReading = "reading" + statusArchived = "archived" + statusFinished = "finished" +) + const schema = ` CREATE TABLE IF NOT EXISTS bookmarks ( key TEXT PRIMARY KEY, @@ -60,6 +71,7 @@ CREATE TABLE IF NOT EXISTS bookmarks ( latest_chapter TEXT NOT NULL DEFAULT '', latest_chapter_num REAL, latest_checked_at INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'reading', updated_at INTEGER NOT NULL );` @@ -73,11 +85,14 @@ var addedColumns = []struct{ name, ddl string }{ // sorts first so a new bookmark is picked up on the next tick with no // special case. Deliberately NOT in bookmarkColumns — see MarkLatestChecked. {"latest_checked_at", `ALTER TABLE bookmarks ADD COLUMN latest_checked_at INTEGER NOT NULL DEFAULT 0`}, + // Lifecycle bucket. The DEFAULT backfills every pre-existing row as + // 'reading', so there is no separate migration step. + {"status", `ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'`}, } const bookmarkColumns = `key, site, series_id, title, series_url, cover, last_chapter, last_chapter_num, last_chapter_url, - favorite, latest_chapter, latest_chapter_num, updated_at` + favorite, latest_chapter, latest_chapter_num, updated_at, status` // Store is the SQLite-backed bookmark store. type Store struct { @@ -156,13 +171,14 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) { b Bookmark title, seriesURL, cover sql.NullString lastChapter, lastChapterURL, latestChapter sql.NullString + status sql.NullString lastChapterNum, latestChapterNum sql.NullFloat64 favorite sql.NullInt64 ) if err := scan( &b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover, &lastChapter, &lastChapterNum, &lastChapterURL, - &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, + &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status, ); err != nil { return Bookmark{}, err } @@ -177,6 +193,12 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) { if latestChapterNum.Valid { b.LatestChapterNum = &latestChapterNum.Float64 } + // A NULL or empty bucket would leave the row in no list at all, so it + // reads as the default rather than being passed through. + b.Status = status.String + if b.Status == "" { + b.Status = statusReading + } return b, nil } @@ -242,9 +264,19 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) { // IS NOT is SQLite's null-safe comparison. Within DO UPDATE, a bare column // is the stored row and excluded.* is the incoming one; a brand-new key // never reaches this clause, so it keeps the fresh timestamp from VALUES. + // + // The status column resolves on the VALUES side, not in the conflict + // clause: excluded.* is the row *after* these expressions are evaluated, + // so a default applied there would look identical to a real 'reading' and + // would overwrite an archived row on every PUT from a client that knows + // nothing about the column. Resolved once here, an empty incoming status + // means "keep what is stored", and only a brand-new row falls through to + // the literal default. The subquery runs inside this transaction, so it + // sees the row this statement is about to conflict with. if _, err := tx.Exec(` INSERT INTO bookmarks (`+bookmarkColumns+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading')) ON CONFLICT(key) DO UPDATE SET site=excluded.site, series_id=excluded.series_id, title=excluded.title, series_url=excluded.series_url, cover=excluded.cover, @@ -253,6 +285,7 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) { favorite=excluded.favorite, latest_chapter=excluded.latest_chapter, latest_chapter_num=excluded.latest_chapter_num, + status=excluded.status, updated_at=CASE WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num THEN excluded.updated_at @@ -260,7 +293,8 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) { END`, b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover, b.LastChapter, b.LastChapterNum, b.LastChapterURL, - b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt); err != nil { + b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt, + b.Status, b.Key); err != nil { return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err) } diff --git a/backend/store_test.go b/backend/store_test.go index 6102db9..3cc3792 100644 --- a/backend/store_test.go +++ b/backend/store_test.go @@ -722,3 +722,119 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) { t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got) } } + +func TestUpsertDefaultsStatusToReading(t *testing.T) { + store := newTestStore(t) + stored, err := store.Upsert(Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + UpdatedAt: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if stored.Status != statusReading { + t.Fatalf("Status = %q, want %q", stored.Status, statusReading) + } +} + +// An empty status means "no opinion" — a userscript build that predates the +// column must not reset the bucket on every progress PUT. +func TestUpsertEmptyStatusPreservesStored(t *testing.T) { + store := newTestStore(t) + base := Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Status: statusArchived, UpdatedAt: time.Now().UnixMilli(), + } + if _, err := store.Upsert(base); err != nil { + t.Fatalf("seed: %v", err) + } + + base.Status = "" + base.LastChapterNum = 12 + stored, err := store.Upsert(base) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if stored.Status != statusArchived { + t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived) + } +} + +func TestUpsertReplacesStatusWhenGiven(t *testing.T) { + store := newTestStore(t) + base := Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Status: statusArchived, UpdatedAt: time.Now().UnixMilli(), + } + if _, err := store.Upsert(base); err != nil { + t.Fatalf("seed: %v", err) + } + + base.Status = statusReading + stored, err := store.Upsert(base) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if stored.Status != statusReading { + t.Fatalf("Status = %q, want %q", stored.Status, statusReading) + } +} + +// A status change is not reading progress, so the list must not reorder. +func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) { + store := newTestStore(t) + base := Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + LastChapter: "45", LastChapterNum: 45, + UpdatedAt: time.Now().UnixMilli(), + } + first, err := store.Upsert(base) + if err != nil { + t.Fatalf("seed: %v", err) + } + + base.Status = statusArchived + base.UpdatedAt = first.UpdatedAt + 60_000 + stored, err := store.Upsert(base) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if stored.UpdatedAt != first.UpdatedAt { + t.Fatalf("UpdatedAt = %d, want it frozen at %d", stored.UpdatedAt, first.UpdatedAt) + } +} + +// A database written before the column existed must gain it, with every +// pre-existing row landing in the reading bucket. +func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("open: %v", err) + } + if _, err := db.Exec(` + CREATE TABLE bookmarks ( + key TEXT PRIMARY KEY, site TEXT NOT NULL, series_id TEXT NOT NULL, + title TEXT, series_url TEXT, cover TEXT, + last_chapter TEXT, last_chapter_num REAL, last_chapter_url TEXT, + updated_at INTEGER NOT NULL); + INSERT INTO bookmarks (key, site, series_id, updated_at) + VALUES ('asura:old', 'asura', 'old', 1)`); err != nil { + t.Fatalf("seed legacy: %v", err) + } + db.Close() + + store, err := OpenStore(path) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + defer store.Close() + + b, ok, err := store.Get("asura:old") + if err != nil || !ok { + t.Fatalf("Get: ok=%v err=%v", ok, err) + } + if b.Status != statusReading { + t.Fatalf("Status = %q, want %q", b.Status, statusReading) + } +}