a48aba67b8
Series becomes a shared row keyed (site, series_id) owning title, cover, canonical URL, kind, latest chapter and last-checked time (ADR-0003). A bookmark keeps only progress, favourite, lifecycle bucket, updated_at. Store.Upsert decomposes one flat body across both tables in one transaction: client title/series_url/cover apply only when the series row is new, then only the poll may change them (security boundary — the row is shared and the values are scraped page content). Reads join series back in, so GET/PUT emit and accept exactly the flat field set they did before (ADR-0004), asserted by TestFlatWireFieldSet. The poller walks Series instead of Bookmarks: one fetch per shared series per due cycle, due queue ordered reader_count DESC then latest_checked_at ASC, orphaned series never due and never deleted, row still stamped before the fetch. Batch/stagger/interval unchanged. Migration 0002 backfills series from existing bookmarks; verified by TestMigration0002BackfillsExistingBookmarks.
7.6 KiB
7.6 KiB
Guidance for OpenCode (and Claude Code) working under backend/. See root AGENTS.md for the project-wide architecture diagram, hard constraints, and design system.
- Backend (
backend/): stdlibnet/http(handful routes, no framework) + Postgres overjackc/pgx/v5(pure Go,CGO_ENABLED=0-> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain:8080. Single binary, split into packages underbackend/internal/:store(Bookmark type, Postgres persistence, migration runner),latest(background poller, site parsers, TLS fetcher),session(cookie signing, login rate limiter),httpmw(Auth/Gzip/CORS middleware),api(JSON bookmark handlers),userscript(userscript-serving handler),web(browser UI handler +templates/+static/,go:embed-ed).backend/main.gois the composition root — the only place that wires packages together intonewRouter. Root-level*_test.gohold integration tests that exercise the full router; unit tests for a package live beside it underinternal/. - Schema is migration-owned.
internal/store/migrations/*.sqlisgo:embed-ed and applied on every start bystore.migrate: one numbered file per change, one transaction each, versions recorded inschema_migrations. Files are append-only — editing an applied one changes nothing on a database that already ran it. No column probing, no data-fixup migrations: both were SQLite-era machinery and are gone. - Tests need Docker.
internal/pgteststarts onepostgres:17-alpinecontainer per test binary (TestMain->pgtest.Main) and hands each test its own database (pgtest.URL(t)). A package whose tests touch the store must have thatTestMain. - Single-user store, two tables.
serieskeyed(site, series_id)(asura|demonic|comix|kagane|novelfull|lightnovelworld) owns the shared facts — title, cover, canonical URL,kind(manga|novel), Latest Chapter,latest_checked_at— andbookmarksholds only what differs between readers: progress, favourite, lifecycle bucket,updated_at. Sync last-write-wins; the wire format stays flat (ADR-0004).Store.Upsertdecomposes one flat body across both tables and enforces the ownership rule: clienttitle/series_url/coverare written only when the series row is new (ADR-0003). - Endpoints:
GET /bookmarks,PUT /bookmarks/{key}(upsert; seeupdated_atrule below),DELETE /bookmarks/{key},GET /healthz(no auth). - Web UI: same binary serve password-gated browser UI on second
hostname —
GET /(list, or login page when no session),POST /login,POST /logout,GET /static/*, htmx fragment endpoints under/ui/*. Templates + assetsgo:embed-ed underbackend/internal/web/, sobackend/Dockerfilemust copy the wholeinternal/tree, not just*.go. Sessions stateless HMAC cookies keyed offAPI_TOKEN;WEB_PASSWORDgates them, and when empty, web routes not registered at all. UI mutations read-modify-write throughStore.Get+Store.Upsertsoupdated_atrule stays one place. Seedocs/superpowers/specs/2026-07-25-web-ui-design.md. Design-tool caveat: templates link/static/style.cssroot-absolutely (correct — served from/), but impeccable detector resolves stylesheet href withpath.resolve(fileDir, href), drops directory on leading/and silently skip file. Relative href don't help either: template's directory isn't its served path. Sodetect.mjs backend/internal/web/templatesreports false clean — always passbackend/internal/web/statictoo. One finding there,overused-fonton "Instrument Serif", deliberate identity choice, not debt. - Every action that moves series out of list is confirm-gated.
Archive, finish, remove each open own
.confirm-rowdisclosure (toggleConfirmRow(key, kind)infilter.js,kind∈archive|finish|remove); restore fire instantly since it's the reversal. Remove's row wear ember wash, two reversible ones wear.calmgrey.--emberstay reserved for new-chapter signal: busy bar and inline error use--mute. - Latest-chapter poller: ticker goroutine in same binary re-check
each bookmarked series' newest published chapter from backend's own
network access, so
latest_chapterstay fresh when user not browsing. Second, parallel signal — userscript keep ownmaybeCaptureLatestOnSeriesPage/backgroundRefreshLatestlogic unchanged. Two independent clocks: per-series cooldown (series.latest_checked_at, enforced byStore.DueForLatestCheck's WHERE clause) and wake interval. The poller walks Series, not Bookmarks — a series referenced by several bookmarks is fetched once per cycle, and the due queue ordersreader_count DESC, latest_checked_at ASC(ADR-0003). Series row stamped before fetch so broken series wait out full cooldown instead of retrying every tick; found chapter written straight to the series row viaStore.SetLatestChapter, so a bookmark'supdated_at— and the list order — is never touched. Fetches usebogdanfinn/tls-clientwith Chrome profile as defence in depth against fingerprint-based blocking; any failure log and skip. kagane and novelfull sit behind Cloudflare JavaScript challenges the TLS client can't clear, so they are browser-only: fetched over CDP viaBROWSER_WS_URL, and simply not polled when that's unset. Seedocs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md. The poller's series write is a single-column UPDATE (Store.SetLatestChapter), not a read-modify-write of the whole bookmark: it cannot revert read progress or moveupdated_at, so the old stale-re-read race is gone with the Get+Upsert flow. updated_atdrives list order, so moves only on real reading progress: server apply its timestamp when row new orlast_chapter_numchanges, else keep stored value — favouriting series or recording newly published chapter must not reorder list.PUTtherefore returns row as stored, clients must adopt that response rather than own payload. Seeplans/2026-07-25-bookmark-list-favorites-design.md§4.- Lifecycle buckets:
statuson each bookmark isreading|archived|finished, orthogonal tofavorite. Archived and finished appear only in own tab — not in All, Updated, Favourites, or recent strip. Poller keeps checking archived series and skip finished ones.finishedsettable only from web UI;PUT /bookmarks/{key}reject it with 400. Empty incoming status means "keep stored one" — resolved on theVALUESside ofStore.Upsert, not conflict clause, sinceexcluded.*is post-evaluation row and default applied there would wipe bucket on every PUT from client that predates column. Seedocs/superpowers/specs/2026-07-27-status-buckets-design.md. - Config via env:
API_TOKEN,ALLOWED_ORIGINS(comma list),DATABASE_URL(Postgres connection URL, required — no default),PORT(default8080),WEB_PASSWORD(gates browser UI; unset disable it),LATEST_CHAPTER_POLL_ENABLED/_COOLDOWN/_INTERVAL/_BATCH/_STAGGER(background latest-chapter poller; defaults on,1h/10m/14/20s).USERSCRIPT_PATHandNOVEL_USERSCRIPT_PATH(files served at/u/{token}/manga-bookmark.user.jsand/u/{token}/novel-bookmark.user.js, defaults/userscript/manga-bookmark.user.jsand/userscript/novel-bookmark.user.js, both supplied by bindmount).BROWSER_WS_URL(headless-shell CDP endpoint for kagane and novelfull; unset disables browser polling and leaves those sites to the userscript alone).