feat(store): add status column with keep-on-empty upsert rule

This commit is contained in:
2026-07-27 12:46:27 +07:00
parent a587b16423
commit 051c429b24
2 changed files with 154 additions and 4 deletions
+38 -4
View File
@@ -27,6 +27,10 @@ type Bookmark struct {
LatestChapter string `json:"latest_chapter"` LatestChapter string `json:"latest_chapter"`
LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert 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. // HasNewChapter reports whether the site has published past the read point.
@@ -45,6 +49,13 @@ func (b Bookmark) ContinueURL() string {
return b.SeriesURL return b.SeriesURL
} }
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
const (
statusReading = "reading"
statusArchived = "archived"
statusFinished = "finished"
)
const schema = ` const schema = `
CREATE TABLE IF NOT EXISTS bookmarks ( CREATE TABLE IF NOT EXISTS bookmarks (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -60,6 +71,7 @@ CREATE TABLE IF NOT EXISTS bookmarks (
latest_chapter TEXT NOT NULL DEFAULT '', latest_chapter TEXT NOT NULL DEFAULT '',
latest_chapter_num REAL, latest_chapter_num REAL,
latest_checked_at INTEGER NOT NULL DEFAULT 0, latest_checked_at INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'reading',
updated_at INTEGER NOT NULL 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 // sorts first so a new bookmark is picked up on the next tick with no
// special case. Deliberately NOT in bookmarkColumns — see MarkLatestChecked. // special case. Deliberately NOT in bookmarkColumns — see MarkLatestChecked.
{"latest_checked_at", `ALTER TABLE bookmarks ADD COLUMN latest_checked_at INTEGER NOT NULL DEFAULT 0`}, {"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, const bookmarkColumns = `key, site, series_id, title, series_url, cover,
last_chapter, last_chapter_num, last_chapter_url, 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. // Store is the SQLite-backed bookmark store.
type Store struct { type Store struct {
@@ -156,13 +171,14 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
b Bookmark b Bookmark
title, seriesURL, cover sql.NullString title, seriesURL, cover sql.NullString
lastChapter, lastChapterURL, latestChapter sql.NullString lastChapter, lastChapterURL, latestChapter sql.NullString
status sql.NullString
lastChapterNum, latestChapterNum sql.NullFloat64 lastChapterNum, latestChapterNum sql.NullFloat64
favorite sql.NullInt64 favorite sql.NullInt64
) )
if err := scan( if err := scan(
&b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover, &b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover,
&lastChapter, &lastChapterNum, &lastChapterURL, &lastChapter, &lastChapterNum, &lastChapterURL,
&favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status,
); err != nil { ); err != nil {
return Bookmark{}, err return Bookmark{}, err
} }
@@ -177,6 +193,12 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
if latestChapterNum.Valid { if latestChapterNum.Valid {
b.LatestChapterNum = &latestChapterNum.Float64 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 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 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 // 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. // 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(` if _, err := tx.Exec(`
INSERT INTO bookmarks (`+bookmarkColumns+`) INSERT INTO bookmarks (`+bookmarkColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading'))
ON CONFLICT(key) DO UPDATE SET ON CONFLICT(key) DO UPDATE SET
site=excluded.site, series_id=excluded.series_id, title=excluded.title, site=excluded.site, series_id=excluded.series_id, title=excluded.title,
series_url=excluded.series_url, cover=excluded.cover, series_url=excluded.series_url, cover=excluded.cover,
@@ -253,6 +285,7 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
favorite=excluded.favorite, favorite=excluded.favorite,
latest_chapter=excluded.latest_chapter, latest_chapter=excluded.latest_chapter,
latest_chapter_num=excluded.latest_chapter_num, latest_chapter_num=excluded.latest_chapter_num,
status=excluded.status,
updated_at=CASE updated_at=CASE
WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num
THEN excluded.updated_at THEN excluded.updated_at
@@ -260,7 +293,8 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
END`, END`,
b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover, b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover,
b.LastChapter, b.LastChapterNum, b.LastChapterURL, 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) return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err)
} }
+116
View File
@@ -722,3 +722,119 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got) 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)
}
}