feat(store): add status column with keep-on-empty upsert rule
This commit is contained in:
+38
-4
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user