Give every Bookmark an owner (Reader table) (#30)

Closes #22

## What

A `readers` table appears; every Bookmark belongs to one. The owner is seeded as the first and only Reader, and all existing rows are attached to them.

- **Migration 0003**: `readers` (discord_id UNIQUE, token_sha256 UNIQUE, created_at).
- **Migration 0004** (run-once, version-table-gated): attaches existing bookmarks to the seeded owner, drops the surrogate `key` column, composite PK `(reader_id, site, series_id)`, FK to readers `ON DELETE CASCADE` — a duplicate Bookmark for one Reader and Series is impossible at the database level.
- **Seed**: `Store.Open` runs schema to 0003, seeds exactly one owner row from `OWNER_DISCORD_ID` (hash = SHA-256 of `API_TOKEN`, refreshed on every start so rotation stays current), then migrates the rest.
- **Scoping**: `List/Get/Upsert/Delete` take `readerID`; the wire `key` is derived as `site:series_id` on read. Handlers act as `Store.OwnerID()` while the global token remains the only credential.
- **Unchanged**: authentication and the flat wire format — nothing observable changes from outside.
- **New env** `OWNER_DISCORD_ID` (required): compose, .env.example, DEPLOY.md, README.md, backend/AGENTS.md updated.

Series-level methods (due queue, mark-checked, set-latest-chapter) stay unscoped deliberately: series are shared rows polled once per due cycle, and the reader_count ordering requires cross-reader visibility (ADR-0003).

## Verification

- `go test ./...` green, including new tests: seed idempotency + hash refresh, 0004 attach migration, DB-level duplicate impossibility, per-reader scoping, reader-delete cascade.
- Live smoke test on fresh Postgres: seed → PUT/GET (flat wire intact) → restart idempotent; stored hash matches SHA-256 of the token.

## Deploy note

`OWNER_DISCORD_ID` is required after this lands — the backend refuses to start without it. Set it to the owner's Discord snowflake (Settings → Advanced → Developer Mode → right-click name → Copy User ID).

Reviewed-on: #30
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #30.
This commit is contained in:
2026-08-08 08:05:17 +07:00
committed by sulthan
parent 984965ed9f
commit 8cebb94b92
15 changed files with 553 additions and 164 deletions
+17 -17
View File
@@ -56,7 +56,7 @@ func TestIndexWithoutSessionShowsLogin(t *testing.T) {
func TestIndexWithSessionShowsList(t *testing.T) {
cfg := webConfig()
srv, st := newWebTestServer(t, cfg)
if _, err := st.Upsert(store.Bookmark{
if _, err := st.Upsert(st.OwnerID(), store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
UpdatedAt: time.Now().UnixMilli(),
@@ -203,7 +203,7 @@ func TestStaticAssetsServed(t *testing.T) {
// seed inserts one bookmark and returns it as stored.
func seed(t *testing.T, st *store.Store, b store.Bookmark) store.Bookmark {
t.Helper()
stored, err := st.Upsert(b)
stored, err := st.Upsert(st.OwnerID(), b)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
@@ -257,7 +257,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
t.Fatalf("favorite status = %d, want 200", rr.Code)
}
after, ok, err := st.Get("asura:solo")
after, ok, err := st.Get(st.OwnerID(), "asura:solo")
if err != nil || !ok {
t.Fatalf("Get after favorite: %v ok=%v", err, ok)
}
@@ -275,7 +275,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
// Toggling again turns it back off.
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
back, _, _ := st.Get("asura:solo")
back, _, _ := st.Get(st.OwnerID(), "asura:solo")
if back.Favorite {
t.Fatal("Favorite = true after a second toggle, want false")
}
@@ -334,7 +334,7 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
t.Fatalf("chapter override status = %d, want 200", rr.Code)
}
after, ok, err := st.Get("asura:solo")
after, ok, err := st.Get(st.OwnerID(), "asura:solo")
if err != nil || !ok {
t.Fatalf("Get after override: %v ok=%v", err, ok)
}
@@ -374,7 +374,7 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
t.Fatalf("chapter no-op status = %d, want 200", rr.Code)
}
after, ok, err := st.Get("asura:solo")
after, ok, err := st.Get(st.OwnerID(), "asura:solo")
if err != nil || !ok {
t.Fatalf("Get after no-op override: %v ok=%v", err, ok)
}
@@ -408,7 +408,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
after, _, _ := st.Get("asura:solo")
after, _, _ := st.Get(st.OwnerID(), "asura:solo")
if after.LastChapterNum != 45 {
t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
}
@@ -459,7 +459,7 @@ func TestUIDeleteRemovesRow(t *testing.T) {
if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
t.Fatalf("delete body = %q, want the out-of-band badge", body)
}
if _, ok, _ := st.Get("asura:solo"); ok {
if _, ok, _ := st.Get(st.OwnerID(), "asura:solo"); ok {
t.Fatal("row still present after delete")
}
}
@@ -538,7 +538,7 @@ func seedStatusRows(t *testing.T, st *store.Store) {
}
for _, b := range rows {
b.UpdatedAt = time.Now().UnixMilli()
if _, err := st.Upsert(b); err != nil {
if _, err := st.Upsert(st.OwnerID(), b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
@@ -612,7 +612,7 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
Status: store.StatusReading, LastChapterNum: 40, LatestChapter: "40",
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
}
if _, err := st.Upsert(caught); err != nil {
if _, err := st.Upsert(st.OwnerID(), caught); err != nil {
t.Fatalf("seed %s: %v", caught.Key, err)
}
@@ -632,12 +632,12 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
}
// Nothing new anywhere: the strip has nothing to say and does not render.
reading, _, err := st.Get("asura:reading")
reading, _, err := st.Get(st.OwnerID(), "asura:reading")
if err != nil {
t.Fatalf("Get: %v", err)
}
reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
if _, err := st.Upsert(reading); err != nil {
if _, err := st.Upsert(st.OwnerID(), reading); err != nil {
t.Fatalf("Upsert: %v", err)
}
// The section still ships (an out-of-band swap needs the id to exist) but
@@ -662,7 +662,7 @@ func TestRecentStripCapped(t *testing.T) {
Status: store.StatusReading, LastChapterNum: 1, LatestChapter: "2",
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
}
if _, err := st.Upsert(b); err != nil {
if _, err := st.Upsert(st.OwnerID(), b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
@@ -692,7 +692,7 @@ func TestUIStatusSetsBucket(t *testing.T) {
if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK {
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
}
b, ok, err := st.Get("asura:reading")
b, ok, err := st.Get(st.OwnerID(), "asura:reading")
if err != nil || !ok {
t.Fatalf("Get: ok=%v err=%v", ok, err)
}
@@ -710,7 +710,7 @@ func TestUIStatusRejectsUnknownValue(t *testing.T) {
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
b, _, _ := st.Get("asura:reading")
b, _, _ := st.Get(st.OwnerID(), "asura:reading")
if b.Status != store.StatusReading {
t.Fatalf("stored status = %q, want it untouched", b.Status)
}
@@ -736,12 +736,12 @@ func TestUIStatusDoesNotReorderList(t *testing.T) {
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
before, _, _ := st.Get("asura:reading")
before, _, _ := st.Get(st.OwnerID(), "asura:reading")
time.Sleep(2 * time.Millisecond)
if rr := postStatus(t, srv, cfg, "asura:reading", store.StatusArchived); rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
after, _, _ := st.Get("asura:reading")
after, _, _ := st.Get(st.OwnerID(), "asura:reading")
if after.UpdatedAt != before.UpdatedAt {
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
}