package store import ( "database/sql" "embed" "errors" "fmt" "io/fs" "path" "regexp" "slices" "strconv" "strings" _ "github.com/jackc/pgx/v5/stdlib" ) // Bookmark is one tracked series, keyed ":" across both sites. // // LastChapter* is the user's read progress; LatestChapter* is the newest // chapter the site has published, captured opportunistically by the userscript. // // Title, SeriesURL, Cover, Kind and LatestChapter* live on the shared Series // row (ADR-0003) and are joined in on read; Bookmark carries only what differs // between readers: progress, favourite, lifecycle bucket, updated_at. The wire // format stays flat regardless — see ADR-0004. type Bookmark struct { Key string `json:"key"` Site string `json:"site"` SeriesID string `json:"series_id"` Title string `json:"title"` SeriesURL string `json:"series_url"` Cover string `json:"cover"` LastChapter string `json:"last_chapter"` LastChapterNum float64 `json:"last_chapter_num"` LastChapterURL string `json:"last_chapter_url"` Favorite bool `json:"favorite"` 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"` // Kind is the library bucket: manga or novel. Empty on the way in means // "no opinion" — see Upsert. Kind string `json:"kind"` } // Series is one distinct work, shared by every bookmark that tracks it. It is // keyed (site, series_id) — the pair a bookmark key decomposes into — and // exists once no matter how many bookmarks point at it (ADR-0003). // // Title, SeriesURL and Cover are written once, at creation: a PUT naming an // existing Series has them ignored, and only the backend's own Poll may change // them. Kind and the latest-chapter fields are last-write-wins like the // bookmark's own fields. Never serialized: the wire format is the flat // Bookmark (ADR-0004). type Series struct { Site string SeriesID string Title string SeriesURL string Cover string Kind string LatestChapter string LatestChapterNum *float64 // nil until first captured LatestCheckedAt int64 // unix ms; see MarkLatestChecked // readerCount is the number of bookmarks referencing this series, filled // only by the due-queue query that orders on it. readerCount int } // Key returns the canonical identity in bookmark-key form (":"), // used by the poller's logs and by tests asserting on the due queue. func (s Series) Key() string { return s.Site + ":" + s.SeriesID } // HasNewChapter reports whether the site has published past the read point. // A nil LatestChapterNum means nothing has been captured yet, which is not the // same as "nothing new". func (b Bookmark) HasNewChapter() bool { return b.LatestChapterNum != nil && *b.LatestChapterNum > b.LastChapterNum } // chapterLeadIn matches the prefix the userscript and the poller both write // ("Chapter 250"), so the UI can add exactly one "Ch " of its own instead of // doubling it. A manual edit through the web UI stores a bare "250", which is // the same string minus the lead-in. var chapterLeadIn = regexp.MustCompile(`(?i)^\s*(?:chapter|ch\.?)\s*`) func displayChapter(raw string, num float64) string { rest := strings.TrimSpace(chapterLeadIn.ReplaceAllString(raw, "")) if rest == "" { rest = strconv.FormatFloat(num, 'f', -1, 64) } // "Ch " only makes sense in front of a number; anything else is a label the // site gave us, so pass it through as written. if rest[0] < '0' || rest[0] > '9' { return rest } return "Ch " + rest } // DisplayChapter is the read-progress line: one canonical "Ch N" whatever // format the write came in as. func (b Bookmark) DisplayChapter() string { return displayChapter(b.LastChapter, b.LastChapterNum) } // DisplayLatest is the same for the newest published chapter, which arrives // with the same "Chapter N" lead-in from both the userscript and the poller. func (b Bookmark) DisplayLatest() string { var num float64 if b.LatestChapterNum != nil { num = *b.LatestChapterNum } return displayChapter(b.LatestChapter, num) } // ContinueURL is where the Continue button points: the chapter last read, or // the series page when no chapter URL was ever captured. func (b Bookmark) ContinueURL() string { if b.LastChapterURL != "" { return b.LastChapterURL } return b.SeriesURL } // Initial is the monogram the web UI shows in place of a cover when the // source site never gave us an og:image. First rune, uppercased; "?" when even // the title is missing, so the slot is never empty. func (b Bookmark) Initial() string { for _, r := range b.Title { return strings.ToUpper(string(r)) } return "?" } // Library buckets. A bookmark is in exactly one. This cannot be derived from // Site: asurascans serves manga and novels from the same /comics/ path, so the // userscript that recorded the page is the only party that knows which. const ( KindManga = "manga" KindNovel = "novel" ) // Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal. const ( StatusReading = "reading" StatusArchived = "archived" StatusFinished = "finished" ) //go:embed migrations/*.sql var migrations embed.FS // bookmarkColumns is the only value ever concatenated into query text. It is a // compile-time constant; every request value is bound as a parameter. The // series-owned fields are joined in from the series table, in scanBookmark // order, so the flat Bookmark reads back whole despite the split (ADR-0004). const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover, b.last_chapter, b.last_chapter_num, b.last_chapter_url, b.favorite, s.latest_chapter, s.latest_chapter_num, b.updated_at, b.status, s.kind` // seriesColumns is the series row in scanSeries order, used by the poller's // due query. latest_checked_at lives only on series — see MarkLatestChecked // for why it stays off every client-visible write. const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover, s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at` // Owner is the person running the service: the first Reader, and the only one // until registration exists. The seed makes sure exactly one readers row // matches their Discord ID, carrying the SHA-256 of their userscript token — // which today is the global API token. type Owner struct { DiscordID string // TokenHash is the SHA-256 of the userscript token; the array shape makes // it a compile error to store anything that is not a hash. TokenHash [32]byte } // Store is the Postgres-backed bookmark store. type Store struct { db *sql.DB // ownerID is the seeded owner Reader (issue #22). Authentication is still // the single global token, so every request acts as this Reader; the store // methods take the id explicitly so the scoping survives per-Reader auth. ownerID int64 } // OwnerID returns the seeded owner Reader's id — the Reader every request // acts as while the global token is still the only credential. func (s *Store) OwnerID() int64 { return s.ownerID } // readersMigration is the version that creates the readers table. The owner // seed runs between two migrate passes, so that the run-once migration which // attaches existing bookmarks (0004) finds the owner row. const readersMigration = 3 // allMigrations is the migrate() cap that applies every pending version. const allMigrations = 0 // Open connects to Postgres at url — a libpq connection URL such as // "postgres://user:pass@host:5432/bookmarks?sslmode=disable" — brings its // schema up to date, and seeds the owner Reader. func Open(url string, owner Owner) (*Store, error) { db, err := sql.Open("pgx", url) if err != nil { return nil, fmt.Errorf("open postgres: %w", err) } // Schema runs in two passes with the seed between: 0003 creates the // readers table, the owner row must exist before 0004 attaches the // existing bookmarks to it. Anything past 0004 is applied by the second // pass. if err := migrate(db, readersMigration); err != nil { db.Close() return nil, fmt.Errorf("migrate schema: %w", err) } if err := seedOwner(db, owner); err != nil { db.Close() return nil, fmt.Errorf("seed owner: %w", err) } if err := migrate(db, allMigrations); err != nil { db.Close() return nil, fmt.Errorf("migrate: %w", err) } var ownerID int64 if err := db.QueryRow( `SELECT id FROM readers WHERE discord_id = $1`, owner.DiscordID).Scan(&ownerID); err != nil { db.Close() return nil, fmt.Errorf("resolve owner: %w", err) } return &Store{db: db, ownerID: ownerID}, nil } // seedOwner makes sure the configured owner exists as exactly one readers row, // and keeps its token hash current on every start: rotating the userscript // token must refresh the hash, or the stored credential goes stale. func seedOwner(db *sql.DB, o Owner) error { if _, err := db.Exec(` INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) ON CONFLICT (discord_id) DO UPDATE SET token_sha256 = EXCLUDED.token_sha256`, o.DiscordID, o.TokenHash[:]); err != nil { return fmt.Errorf("seed owner: %w", err) } return nil } // migrate applies every embedded migration this database has not recorded, in // filename order, each in its own transaction. upto caps the highest version // applied; 0 means all. Files are named "_.sql" and are // append-only: editing an applied file changes nothing, because // schema_migrations is how a database remembers what it ran. Runs on every // start and is a no-op once current. func migrate(db *sql.DB, upto int64) error { if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( version bigint PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { return fmt.Errorf("create version table: %w", err) } names, err := fs.Glob(migrations, "migrations/*.sql") if err != nil { return err } slices.Sort(names) for _, name := range names { version, err := strconv.ParseInt(strings.SplitN(path.Base(name), "_", 2)[0], 10, 64) if err != nil { return fmt.Errorf("migration %q: filename must start with a version number", name) } if upto > 0 && version > upto { continue } body, err := migrations.ReadFile(name) if err != nil { return err } if err := applyMigration(db, version, string(body)); err != nil { return fmt.Errorf("migration %q: %w", name, err) } } return nil } // applyMigration runs one migration and records its version in the same // transaction, so an interrupted start leaves neither half behind. func applyMigration(db *sql.DB, version int64, body string) error { tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() var applied bool if err := tx.QueryRow( `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&applied); err != nil { return err } if applied { return nil } // No parameters, so this goes over the simple protocol and a migration may // hold more than one statement. if _, err := tx.Exec(body); err != nil { return err } if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil { return err } return tx.Commit() } // scanBookmark reads one row in bookmarkColumns order. Every column is NOT // NULL except latest_chapter_num, where NULL means "never captured" — a // distinct state from chapter zero, and the reason for the pointer. func scanBookmark(scan func(...any) error) (Bookmark, error) { var ( b Bookmark latestChapterNum sql.NullFloat64 ) if err := scan( &b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &b.Cover, &b.LastChapter, &b.LastChapterNum, &b.LastChapterURL, &b.Favorite, &b.LatestChapter, &latestChapterNum, &b.UpdatedAt, &b.Status, &b.Kind, ); err != nil { return Bookmark{}, err } if latestChapterNum.Valid { b.LatestChapterNum = &latestChapterNum.Float64 } // The wire identity is derived: there is no stored key column, the // bookmark is keyed (reader_id, site, series_id) (issue #22). b.Key = b.Site + ":" + b.SeriesID // An unrecognised bucket (a hand-edited row) would leave the row in no list // at all, so anything outside the three known buckets reads as the default // rather than being passed through. if b.Status != StatusReading && b.Status != StatusArchived && b.Status != StatusFinished { b.Status = StatusReading } return b, nil } // scanSeries reads one row in seriesColumns order, plus the due query's // reader_count column. latest_chapter_num is NULL until the first capture, // same as on the bookmark read path. func scanSeries(scan func(...any) error) (Series, error) { var ( sr Series latestChapterNum sql.NullFloat64 ) if err := scan( &sr.Site, &sr.SeriesID, &sr.Title, &sr.SeriesURL, &sr.Cover, &sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt, &sr.readerCount, ); err != nil { return Series{}, err } if latestChapterNum.Valid { sr.LatestChapterNum = &latestChapterNum.Float64 } return sr, nil } // Close releases the underlying database handle. func (s *Store) Close() error { return s.db.Close() } // List returns every bookmark of one reader, newest activity first. // Series-owned fields are joined in, so each Bookmark reads back whole and // flat (ADR-0004). func (s *Store) List(readerID int64) ([]Bookmark, error) { rows, err := s.db.Query(`SELECT `+bookmarkColumns+` FROM bookmarks b JOIN series s ON s.site = b.site AND s.series_id = b.series_id WHERE b.reader_id = $1 ORDER BY b.updated_at DESC`, readerID) if err != nil { return nil, fmt.Errorf("query bookmarks: %w", err) } defer rows.Close() out := []Bookmark{} for rows.Next() { b, err := scanBookmark(rows.Scan) if err != nil { return nil, fmt.Errorf("scan bookmark: %w", err) } out = append(out, b) } return out, rows.Err() } // Get returns one bookmark of one reader by key. A missing key is not an // error: ok is false and err is nil. UI mutations read-modify-write through // this so they preserve the fields they do not touch. func (s *Store) Get(readerID int64, key string) (Bookmark, bool, error) { site, seriesID, ok := strings.Cut(key, ":") if !ok { return Bookmark{}, false, nil } b, err := scanBookmark(s.db.QueryRow( `SELECT `+bookmarkColumns+` FROM bookmarks b JOIN series s ON s.site = b.site AND s.series_id = b.series_id WHERE b.reader_id = $1 AND b.site = $2 AND b.series_id = $3`, readerID, site, seriesID).Scan) if errors.Is(err, sql.ErrNoRows) { return Bookmark{}, false, nil } if err != nil { return Bookmark{}, false, fmt.Errorf("get %q: %w", key, err) } return b, true, nil } // Upsert inserts or replaces one reader's bookmark by key (last-write-wins) // and returns the row as actually stored — one flat object with the // series-owned fields joined in, exactly as GET reports it (ADR-0004). A // bookmark is keyed (reader_id, site, series_id), so the same key upserts two // independent rows for two readers. // // The flat body is decomposed across two tables in one transaction. The series // row is written first (the bookmarks FK requires it to exist), then the // bookmark row. On the series side, title/series_url/cover are applied only // when the row is brand new: once a series exists, client-supplied values are // ignored, because the row is shared and the values are scraped page content — // see ADR-0003. Kind and the latest-chapter fields are last-write-wins. // // b.UpdatedAt is only a candidate: it is applied when the row is new or when // last_chapter_num changes, and otherwise the stored value is kept. Clients // order their list by updated_at, so favoriting a series or recording a newly // published chapter must not disturb that order — only real reading progress // does. Callers must therefore use the returned bookmark, not the argument. func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) { tx, err := s.db.Begin() if err != nil { return Bookmark{}, fmt.Errorf("begin %q: %w", b.Key, err) } defer tx.Rollback() var latestNum any if b.LatestChapterNum != nil { latestNum = *b.LatestChapterNum } // The kind 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 'manga' and would // overwrite a novel series on every PUT from a client that knows nothing // about the column. Resolved once here, an empty incoming kind 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. Same pattern as the status // COALESCE on the bookmark insert below. // // The ::text casts are load-bearing: inside COALESCE/NULLIF there is no // target column to infer the parameter type from, and Postgres rejects the // statement rather than guessing. if _, err := tx.Exec(` INSERT INTO series (site, series_id, title, series_url, cover, kind, latest_chapter, latest_chapter_num) VALUES ($1, $2, $3, $4, $5, COALESCE(NULLIF($6::text, ''), (SELECT kind FROM series WHERE site = $1 AND series_id = $2), 'manga'), $7, $8) ON CONFLICT (site, series_id) DO UPDATE SET kind=excluded.kind, latest_chapter=excluded.latest_chapter, latest_chapter_num=excluded.latest_chapter_num`, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover, b.Kind, b.LatestChapter, latestNum); err != nil { return Bookmark{}, fmt.Errorf("upsert series for %q: %w", b.Key, err) } // IS DISTINCT FROM is Postgres's null-safe comparison, and it is what // implements the ordering rule. 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. if _, err := tx.Exec(` INSERT INTO bookmarks (reader_id, site, series_id, last_chapter, last_chapter_num, last_chapter_url, favorite, status, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE(NULLIF($8::text, ''), (SELECT status FROM bookmarks WHERE reader_id = $1 AND site = $2 AND series_id = $3), 'reading'), $9) ON CONFLICT (reader_id, site, series_id) DO UPDATE SET last_chapter=excluded.last_chapter, last_chapter_num=excluded.last_chapter_num, last_chapter_url=excluded.last_chapter_url, favorite=excluded.favorite, status=excluded.status, updated_at=CASE WHEN bookmarks.last_chapter_num IS DISTINCT FROM excluded.last_chapter_num THEN excluded.updated_at ELSE bookmarks.updated_at END`, readerID, b.Site, b.SeriesID, b.LastChapter, b.LastChapterNum, b.LastChapterURL, b.Favorite, b.Status, b.UpdatedAt); err != nil { return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err) } stored, err := scanBookmark(tx.QueryRow( `SELECT `+bookmarkColumns+` FROM bookmarks b JOIN series s ON s.site = b.site AND s.series_id = b.series_id WHERE b.reader_id = $1 AND b.site = $2 AND b.series_id = $3`, readerID, b.Site, b.SeriesID).Scan) if err != nil { return Bookmark{}, fmt.Errorf("read back %q: %w", b.Key, err) } if err := tx.Commit(); err != nil { return Bookmark{}, fmt.Errorf("commit %q: %w", b.Key, err) } return stored, nil } // Delete removes one reader's bookmark by key. Deleting a missing key is not // an error. func (s *Store) Delete(readerID int64, key string) error { site, seriesID, ok := strings.Cut(key, ":") if !ok { return nil } if _, err := s.db.Exec( `DELETE FROM bookmarks WHERE reader_id = $1 AND site = $2 AND series_id = $3`, readerID, site, seriesID); err != nil { return fmt.Errorf("delete %q: %w", key, err) } return nil } // DueForLatestCheck returns series whose server-side latest-chapter check has // aged past cutoffMs, ordered by how many bookmarks reference them (descending) // then least-recently-checked first, at most limit of them. // // The reader_count ordering is the point of the split (ADR-0003): a series // shared by several readers is fetched once per due cycle, and the popular // ones stay freshest while the long tail absorbs any shortfall. Within one // reader count, oldest-first keeps the poll fair when the backlog outgrows its // throughput: the most neglected series is always next, so a large collection // refreshes uniformly slower rather than leaving a tail that never refreshes at // all. The userscript sorts its own queue the same way (L453). // // Series with no series_url are skipped — there is nothing to fetch, which is // the same filter the userscript applies at L452. Series whose only bookmarks // are finished are skipped too: nothing more is coming, so fetching them only // burns requests. Archived bookmarks still count — knowing what a shelved // series is up to is the whole reason for archiving instead of deleting. // A series with no bookmarks at all never appears: the join excludes it. func (s *Store) DueForLatestCheck(cutoffMs int64, limit int) ([]Series, error) { rows, err := s.db.Query(`SELECT `+seriesColumns+`, COUNT(*) AS reader_count FROM series s JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id WHERE s.series_url <> '' AND s.latest_checked_at <= $1 GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover, s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0 ORDER BY reader_count DESC, s.latest_checked_at ASC LIMIT $2`, cutoffMs, limit) if err != nil { return nil, fmt.Errorf("query due series: %w", err) } defer rows.Close() out := []Series{} for rows.Next() { sr, err := scanSeries(rows.Scan) if err != nil { return nil, fmt.Errorf("scan due series: %w", err) } out = append(out, sr) } return out, rows.Err() } // MarkLatestChecked records that the server looked at a series at ts, whatever // the look turned up. Marking a missing series is not an error: the row may // have been orphaned while a fetch was in flight. // // This is the one write that does not go through Upsert, and the column is kept // out of the client-visible read path on purpose. PUT /bookmarks/{key} decodes // a whole Bookmark from the client and Upsert writes every series column it // knows about, so a userscript PUT — which has no idea this field exists — // would write a zero and reset the cooldown, making the poller re-fetch that // series every tick for as long as the user kept reading it. func (s *Store) MarkLatestChecked(site, seriesID string, ts int64) error { if _, err := s.db.Exec( `UPDATE series SET latest_checked_at = $1 WHERE site = $2 AND series_id = $3`, ts, site, seriesID); err != nil { return fmt.Errorf("mark checked %s:%s: %w", site, seriesID, err) } return nil } // LatestCheckedAt reads the column MarkLatestChecked writes. It exists for // tests outside this package (the poller's own tests assert on cooldown // bookkeeping) — see MarkLatestChecked for why the field stays off the // client-visible row. func (s *Store) LatestCheckedAt(site, seriesID string) (int64, error) { var ts int64 if err := s.db.QueryRow( `SELECT latest_checked_at FROM series WHERE site = $1 AND series_id = $2`, site, seriesID).Scan(&ts); err != nil { return 0, fmt.Errorf("latest checked at %s:%s: %w", site, seriesID, err) } return ts, nil } // SetLatestChapter records the newest chapter the poll found on a series page. // The poller walks Series rather than Bookmarks, so this is a series-level // write: the row is shared, and updating it once refreshes every bookmark that // joins to it. Touching a missing series is not an error. func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) error { if _, err := s.db.Exec( `UPDATE series SET latest_chapter = $3, latest_chapter_num = $4 WHERE site = $1 AND series_id = $2`, site, seriesID, label, num); err != nil { return fmt.Errorf("set latest chapter %s:%s: %w", site, seriesID, err) } return nil }