feat(series): composite identity (name + volume_number) + display name (bookshelf-uj5h.2) #932

Merged
zombor merged 8 commits from bd-bookshelf-uj5h.2 into main 2026-07-05 00:58:14 +00:00
Owner

Summary

  • Composite series identity: (series_name, volume_number) — distinct comic volumes of the same title (e.g. "X-Men (2011)" vs "X-Men (2021)") now appear as separate series instead of collapsing.
  • Display name: Series cards and detail pages show "Name (Year)" when volume_number is set, bare Name when NULL (ebook/undated comics).
  • URL shape: /series/{name}?vol={year} for comic volumes; /series/{name} for name-only series. The ?vol= param is parsed and validated (400 on bad input) and threaded to getDetail.
  • Covering index (migration 0042): idx_comic_metadata_book_volume on comic_metadata(book_id, volume_number) — covering scan for the series-list GROUP BY query.
  • DRY helpers: buildSeriesLink and comicDisplayVolumeNumber extracted from both handler.go and step_edit_handler.go, eliminating the duplicate inline pattern.

Test plan

  • make test — all packages pass
  • make coverage — 100% gate passes, no new exclusions
  • make lint — no new violations (31 errcheck issues shown are from bd-bookshelf-bbsd.8 worktree, not this PR)
  • New service_test: two X-Men volumes → 2 distinct rows with DisplayName "X-Men (2011)"/"X-Men (2021)"
  • New service_test: NULL-volume "Foundation" → DisplayName equals bare "Foundation"
  • New service_test: cursor encodes VolumeNumber for composite-key pagination
  • New handler_test: ?vol=2021 → parseVolParam success, volume passed to getDetail
  • New handler_test: ?vol=notanumber → 400 Bad Request
  • New store_test: BooksInSeries/SeriesAllAuthors with non-nil volumeNumber
  • New store_test: ListSeriesCovers with non-nil VolumeNumber key
  • New store_test: ListSeries cursor with non-nil CursorVolumeNumber (covers coalesceVol)
  • New tmpl_test: seriesItem structs updated with DisplayName/VolumeNumber fields
  • New step_edit_handler_test: comic book + series + VolumeNumber → ListSeriesBooks called with vol

Closes bead bookshelf-uj5h.2 on merge.

## Summary - **Composite series identity**: `(series_name, volume_number)` — distinct comic volumes of the same title (e.g. "X-Men (2011)" vs "X-Men (2021)") now appear as separate series instead of collapsing. - **Display name**: Series cards and detail pages show `"Name (Year)"` when `volume_number` is set, bare `Name` when NULL (ebook/undated comics). - **URL shape**: `/series/{name}?vol={year}` for comic volumes; `/series/{name}` for name-only series. The `?vol=` param is parsed and validated (400 on bad input) and threaded to `getDetail`. - **Covering index** (migration 0042): `idx_comic_metadata_book_volume` on `comic_metadata(book_id, volume_number)` — covering scan for the series-list GROUP BY query. - **DRY helpers**: `buildSeriesLink` and `comicDisplayVolumeNumber` extracted from both `handler.go` and `step_edit_handler.go`, eliminating the duplicate inline pattern. ## Test plan - [x] `make test` — all packages pass - [x] `make coverage` — 100% gate passes, no new exclusions - [x] `make lint` — no new violations (31 errcheck issues shown are from `bd-bookshelf-bbsd.8` worktree, not this PR) - [x] New service_test: two X-Men volumes → 2 distinct rows with DisplayName "X-Men (2011)"/"X-Men (2021)" - [x] New service_test: NULL-volume "Foundation" → DisplayName equals bare "Foundation" - [x] New service_test: cursor encodes VolumeNumber for composite-key pagination - [x] New handler_test: `?vol=2021` → parseVolParam success, volume passed to getDetail - [x] New handler_test: `?vol=notanumber` → 400 Bad Request - [x] New store_test: BooksInSeries/SeriesAllAuthors with non-nil volumeNumber - [x] New store_test: ListSeriesCovers with non-nil VolumeNumber key - [x] New store_test: ListSeries cursor with non-nil CursorVolumeNumber (covers coalesceVol) - [x] New tmpl_test: seriesItem structs updated with DisplayName/VolumeNumber fields - [x] New step_edit_handler_test: comic book + series + VolumeNumber → ListSeriesBooks called with vol Closes bead bookshelf-uj5h.2 on merge.
Checkpoint of in-progress work: SeriesKey composite type, displayName
'Name (Year)', GROUP BY (series_name, volume_number) with COALESCE
total-order tiebreakers on both name+count cursor paths. Production
compiles; test files + templates + coverage still incomplete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(series): composite identity (series_name, volume_number) + display name
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m33s
/ E2E API (pull_request) Successful in 2m51s
/ Integration (pull_request) Successful in 3m55s
/ Lint (pull_request) Successful in 4m46s
/ E2E Browser (pull_request) Successful in 4m12s
/ Test (pull_request) Successful in 5m29s
23bcc01f71
Series identity is now (series_name, volume_number) so distinct comic volumes
of the same title (e.g. "X-Men (2011)" vs "X-Men (2021)") appear as separate
series instead of being collapsed into one.

Changes:
- ListSeries groups by (series_name, cm.volume_number) with total-order cursor
- ListSeriesCovers/ListSeriesAuthors keyed by SeriesKey{Name, VolumeNumber}
- BooksInSeries/SeriesAllAuthors accept *int volumeNumber for scoped filtering
- DetailHandler parses ?vol= query param (validation + pass-through to getDetail)
- Series.DisplayName returns "Name (Year)" when VolumeNumber is set, bare name otherwise
- series_index.html / series_index_fragment.html link to /series/{name}?vol={year}
- series_show.html h1 uses DisplayName
- buildSeriesLink/comicDisplayVolumeNumber extracted as package-level helpers (DRY)
- Migration 0042: covering index on comic_metadata(book_id, volume_number)

Closes bead bookshelf-uj5h.2.
Author
Owner

UI Screenshots — composite series identity

Series list: X-Men (2011) and X-Men (2021) rendered as distinct rows (de-collapsed):

series list

Series detail page for X-Men (2021) (/series/X-Men?vol=2021):

series detail

## UI Screenshots — composite series identity Series list: `X-Men (2011)` and `X-Men (2021)` rendered as **distinct rows** (de-collapsed): ![series list](https://git.zombor.net/attachments/27b4be73-7b8c-4400-bf39-0d866fdb86a0) Series detail page for `X-Men (2021)` (`/series/X-Men?vol=2021`): ![series detail](https://git.zombor.net/attachments/4a4ee3a5-bc50-47ce-a23e-de1b62720d3f)
Author
Owner

CODE REVIEW — bookshelf-uj5h.2 / PR #932

Reviewed: composite series identity (series_name, volume_number), cursor pagination, library scoping, ?vol= validation, migration 0042, templates, test hygiene.


[MAJOR] templates/pages/series_index.html + series_index_fragment.html + series_show.html — No rendered screenshot posted to PR

The review-standard screenshot gate requires any PR touching templates/ to post a rendered screenshot captured with the go-rod browser harness (post-ui-screenshots-in-pr). PR #932 has no screenshot in the PR body or comments. The UI reviewer cannot verify that DisplayName renders as "X-Men (2021)" on cards, that ?vol= links are formed correctly in the rendered anchor hrefs, or that the series_show.html detail page heading is visually correct. Per policy: "A UI PR with no rendered screenshot is not review-ready."
Fix: Capture and post screenshots of (a) the series list page showing at least two volumes of the same title with year suffixes, and (b) the series detail heading. The go-rod harness in the e2e suite is the canonical tool for this.

[MINOR] internal/series/store.go:170–178 — ORDER BY on COALESCE(volume_number, 0) is technically non-total when volume_number=0 exists alongside NULL for the same series_name

The GROUP BY key is (series_name, cm.volume_number), which correctly separates NULL from 0 into distinct groups. But the ORDER BY sorts both as COALESCE(NULL, 0) = 0 and COALESCE(0, 0) = 0 — identical positions for two distinct rows. The cursor predicates in buildNameCursorHaving and buildCountCursorHaving share the same COALESCE collapse, so pagination would be undefined between these two rows. In practice volume_number=0 (year 0 AD) is not a valid comic publication year and cannot appear in real data, so this is not a real flake risk. Per the strict non-deterministic ORDER BY rule, the order is not provably total.
Fix (choose one): (a) add a DB-level CHECK (volume_number > 0) constraint on comic_metadata.volume_number so the data model documents the invariant and makes the ORDER BY total by construction; or (b) add a secondary sort cm.volume_number IS NOT NULL ASC after the COALESCE column to separate NULL from 0 explicitly.

[MINOR] templates/pages/series_show.html:69 — series-title CSS class added but has no rule in main.css

<h1 class="book-detail__title series-title"> introduces a class that has no matching rule in static/css/main.css (confirmed: grep -r "series-title" static/css/ returns nothing). This is a no-op today. If it is a semantic hook for future styling, add a comment; if it is leftover from development, remove it to keep the template clean.


What I verified:

  • Cursor pagination total-order (all 4 paths): buildNameCursorHaving ASC/DESC and buildCountCursorHaving ASC/DESC predicates correctly implement lexicographic ordering on (series_name, COALESCE(vol, 0)) and (count, series_name, COALESCE(vol, 0)) respectively. The three-column ORDER BY in buildSeriesOrderClause matches the cursor predicate columns. HAVING uses the same COALESCE as ORDER BY — consistent.
  • Library scoping preserved: buildSeriesLibraryClause (nil → unscoped, empty → AND 1=0) is unchanged and present on every query path: series list, ListSeriesCovers, ListSeriesAuthors (both the list-level and the new {{pairs}}-based variant), BooksInSeries, SeriesAllAuthors. Composite key refactor did not regress any scoping path.
  • ?vol= validation: parseVolParam at internal/series/handler.go:330 calls strconv.Atoi; non-integer input wraps middleware.ErrValidation → 400. Absent param returns nil, nil. The raw param value is never interpolated into SQL — it is parsed to *int and bound as a prepared-statement placeholder.
  • Migration 0042: index-only (CREATE INDEX ... ON comic_metadata(book_id, volume_number)), no column addition — Grimmory schema compat preserved. Down migration drops by index name. Index shape covers the LEFT JOIN on cm.book_id plus the GROUP BY cm.volume_number projection.
  • Templates: DisplayName used in all card text and headings; no inline style= attributes added; {{with .VolumeNumber}}?vol={{.}}{{end}} correctly omits the param for nil volumes.
  • Black-box tests: All new/modified *_test.go files declare package series_test or package books_test. No unexported symbols referenced.
  • No new .golangci.yml exclusions: diff of .golangci.yml is empty.
  • step_edit_handler.go operation reorder: Fetching comic meta was moved BEFORE building the series link so that comicDisplayVolumeNumber(cd) operates on a populated struct rather than nil. Correct.

REVIEW VERDICT: 0 blocker, 1 major, 2 minor

## CODE REVIEW — bookshelf-uj5h.2 / PR #932 **Reviewed:** composite series identity (series_name, volume_number), cursor pagination, library scoping, ?vol= validation, migration 0042, templates, test hygiene. --- [MAJOR] templates/pages/series_index.html + series_index_fragment.html + series_show.html — No rendered screenshot posted to PR The review-standard screenshot gate requires any PR touching `templates/` to post a rendered screenshot captured with the go-rod browser harness (`post-ui-screenshots-in-pr`). PR #932 has no screenshot in the PR body or comments. The UI reviewer cannot verify that DisplayName renders as "X-Men (2021)" on cards, that `?vol=` links are formed correctly in the rendered anchor hrefs, or that the `series_show.html` detail page heading is visually correct. Per policy: "A UI PR with no rendered screenshot is not review-ready." Fix: Capture and post screenshots of (a) the series list page showing at least two volumes of the same title with year suffixes, and (b) the series detail heading. The go-rod harness in the e2e suite is the canonical tool for this. [MINOR] internal/series/store.go:170–178 — ORDER BY on `COALESCE(volume_number, 0)` is technically non-total when `volume_number=0` exists alongside NULL for the same series_name The GROUP BY key is `(series_name, cm.volume_number)`, which correctly separates NULL from 0 into distinct groups. But the ORDER BY sorts both as `COALESCE(NULL, 0) = 0` and `COALESCE(0, 0) = 0` — identical positions for two distinct rows. The cursor predicates in `buildNameCursorHaving` and `buildCountCursorHaving` share the same COALESCE collapse, so pagination would be undefined between these two rows. In practice `volume_number=0` (year 0 AD) is not a valid comic publication year and cannot appear in real data, so this is not a real flake risk. Per the strict non-deterministic ORDER BY rule, the order is not provably total. Fix (choose one): (a) add a DB-level `CHECK (volume_number > 0)` constraint on `comic_metadata.volume_number` so the data model documents the invariant and makes the ORDER BY total by construction; or (b) add a secondary sort `cm.volume_number IS NOT NULL ASC` after the COALESCE column to separate NULL from 0 explicitly. [MINOR] templates/pages/series_show.html:69 — `series-title` CSS class added but has no rule in main.css `<h1 class="book-detail__title series-title">` introduces a class that has no matching rule in `static/css/main.css` (confirmed: `grep -r "series-title" static/css/` returns nothing). This is a no-op today. If it is a semantic hook for future styling, add a comment; if it is leftover from development, remove it to keep the template clean. --- **What I verified:** - **Cursor pagination total-order (all 4 paths):** `buildNameCursorHaving` ASC/DESC and `buildCountCursorHaving` ASC/DESC predicates correctly implement lexicographic ordering on (series_name, COALESCE(vol, 0)) and (count, series_name, COALESCE(vol, 0)) respectively. The three-column ORDER BY in `buildSeriesOrderClause` matches the cursor predicate columns. HAVING uses the same COALESCE as ORDER BY — consistent. - **Library scoping preserved:** `buildSeriesLibraryClause` (nil → unscoped, empty → AND 1=0) is unchanged and present on every query path: series list, ListSeriesCovers, ListSeriesAuthors (both the list-level and the new `{{pairs}}`-based variant), BooksInSeries, SeriesAllAuthors. Composite key refactor did not regress any scoping path. - **?vol= validation:** `parseVolParam` at `internal/series/handler.go:330` calls `strconv.Atoi`; non-integer input wraps `middleware.ErrValidation` → 400. Absent param returns `nil, nil`. The raw param value is never interpolated into SQL — it is parsed to `*int` and bound as a prepared-statement placeholder. - **Migration 0042:** index-only (`CREATE INDEX ... ON comic_metadata(book_id, volume_number)`), no column addition — Grimmory schema compat preserved. Down migration drops by index name. Index shape covers the LEFT JOIN on `cm.book_id` plus the `GROUP BY cm.volume_number` projection. - **Templates:** DisplayName used in all card text and headings; no inline `style=` attributes added; `{{with .VolumeNumber}}?vol={{.}}{{end}}` correctly omits the param for nil volumes. - **Black-box tests:** All new/modified `*_test.go` files declare `package series_test` or `package books_test`. No unexported symbols referenced. - **No new `.golangci.yml` exclusions:** diff of `.golangci.yml` is empty. - **`step_edit_handler.go` operation reorder:** Fetching comic meta was moved BEFORE building the series link so that `comicDisplayVolumeNumber(cd)` operates on a populated struct rather than nil. Correct. REVIEW VERDICT: 0 blocker, 1 major, 2 minor
Author
Owner

CORRECTION to my review (comment 12117): I incorrectly flagged missing screenshots as [MAJOR]. Screenshots were already posted in comment 12116 (at 18:41, before my review). I have now viewed both screenshots:

  • Series list: "Avengers (2018)", "X-Men (2011)", "X-Men (2021)" — three distinct cards, volumes de-collapsed. DisplayName format correct.
  • Series detail (/series/X-Men?vol=2021): heading renders "X-Men (2021)" correctly. Canonical layout reused, no bespoke classes visible.

Corrected verdict: REVIEW VERDICT: 0 blocker, 0 major, 2 minor

The [MAJOR] from the prior comment is retracted. Only the 2 [MINOR] findings stand (COALESCE total-order theoretical edge, series-title class undefined in CSS). PR is APPROVED per review-standard (minor-only findings do not block).

**CORRECTION to my review (comment 12117):** I incorrectly flagged missing screenshots as [MAJOR]. Screenshots were already posted in comment 12116 (at 18:41, before my review). I have now viewed both screenshots: - Series list: "Avengers (2018)", "X-Men (2011)", "X-Men (2021)" — three distinct cards, volumes de-collapsed. DisplayName format correct. - Series detail (`/series/X-Men?vol=2021`): heading renders "X-Men (2021)" correctly. Canonical layout reused, no bespoke classes visible. **Corrected verdict: REVIEW VERDICT: 0 blocker, 0 major, 2 minor** The [MAJOR] from the prior comment is retracted. Only the 2 [MINOR] findings stand (COALESCE total-order theoretical edge, `series-title` class undefined in CSS). PR is APPROVED per review-standard (minor-only findings do not block).
Author
Owner

Security Review — PR #932 (bookshelf-uj5h.2)

Scope: composite comic series identity (series_name, volume_number); ?vol= URL param; new SQL grouping/cursor predicates; migration 0042.


Multi-user / library scoping

Every enumeration and detail path was traced:

Path Scope mechanism Fail-closed?
buildListSeriesQuery buildSeriesLibraryClauseAND b.library_id IN (?) / AND 1=0 Yes
ListSeriesCovers early return on len(userLibraryIDs)==0; AND b.library_id IN (?) Yes
ListSeriesAuthors (new) same pattern as Covers Yes
BooksInSeries (detail) early return on empty-non-nil; AND b.library_id IN (?) inside subquery Yes
SeriesAllAuthors nil check → AND 1=0; AND b.library_id IN (?) Yes
SeriesReadStatus scoped WHERE user_id = ? from session; book IDs come from already-scoped BooksInSeries Yes
"More in Series" (Show + StepEdit handlers) showLibraryIDs from getUserLibraryIDs(sessionUserID) Yes

userID is always extracted from the session (extractUser(r).ID, d.UserIDFromRequest(r)), never from request params. No cross-user data leak found.


SQL injection

All user-controlled values (?vol=, series name, cursor fields, library IDs) reach the DB only as bound ? parameters. Dynamic SQL clauses (ORDER BY, HAVING) use only allowlist-validated constants (SortDir"ASC"/"DESC", SortKey"name"/"count"). The {{pairs}} and {{libraryFilter}} template substituions in query strings expand to (?, ?) placeholders and ?-parameterized IN-lists respectively — no user content concatenated.

The LIKE prefix search (q+"%") passes the user string as a bound param; %/_ metacharacters in the term widen the match but cannot leak out-of-scope data (results remain library-scoped).


Findings

[MINOR] internal/series/handler.go:97 — parseVolParam accepts arbitrarily large integers for ?vol=
strconv.Atoi on a 64-bit platform accepts values up to INT64_MAX without error; the DB column is INT32. A value like ?vol=2147483648 parses successfully, finds no results (no match), and returns an empty page rather than a 400. No data leak, but a well-bounded year-range check (e.g., 1900–2100) would give a cleaner error response.

[MINOR] templates/pages/series_index.html:14, series_index_fragment.html:2 — {{urlquery .Name}} used in a URL path segment
urlquery applies url.QueryEscape, which encodes spaces as +. Go's HTTP router does NOT decode + as a space in r.PathValue(...), so a series named "Batman Robin" generates the link /series/Batman+Robin, which the router hands to the handler as Batman+Robin — not matching the DB value Batman Robin. The server-side buildSeriesLink in step_edit_handler.go:348 correctly uses url.PathEscape (space → %20). The templates should use a custom urlpath helper or a pre-escaped value from the server to stay consistent with the Go code. Series names without spaces are unaffected.


No architecture boundary violations (series package imports no workflow engine). No secrets or PII in log paths. .golangci.yml unchanged. All test files use package series_test / package books_test (black-box). Migration 0042 is an index-only addition — no column schema change.

REVIEW VERDICT: 0 blocker, 0 major, 2 minor

## Security Review — PR #932 (bookshelf-uj5h.2) **Scope:** composite comic series identity (`series_name`, `volume_number`); `?vol=` URL param; new SQL grouping/cursor predicates; migration 0042. --- ### Multi-user / library scoping Every enumeration and detail path was traced: | Path | Scope mechanism | Fail-closed? | |---|---|---| | `buildListSeriesQuery` | `buildSeriesLibraryClause` → `AND b.library_id IN (?)` / `AND 1=0` | Yes | | `ListSeriesCovers` | early return on `len(userLibraryIDs)==0`; `AND b.library_id IN (?)` | Yes | | `ListSeriesAuthors` (new) | same pattern as Covers | Yes | | `BooksInSeries` (detail) | early return on empty-non-nil; `AND b.library_id IN (?)` inside subquery | Yes | | `SeriesAllAuthors` | nil check → `AND 1=0`; `AND b.library_id IN (?)` | Yes | | `SeriesReadStatus` | scoped `WHERE user_id = ?` from session; book IDs come from already-scoped `BooksInSeries` | Yes | | "More in Series" (Show + StepEdit handlers) | `showLibraryIDs` from `getUserLibraryIDs(sessionUserID)` | Yes | `userID` is always extracted from the session (`extractUser(r).ID`, `d.UserIDFromRequest(r)`), never from request params. No cross-user data leak found. --- ### SQL injection All user-controlled values (`?vol=`, series name, cursor fields, library IDs) reach the DB only as bound `?` parameters. Dynamic SQL clauses (ORDER BY, HAVING) use only allowlist-validated constants (`SortDir` → `"ASC"/"DESC"`, `SortKey` → `"name"/"count"`). The `{{pairs}}` and `{{libraryFilter}}` template substituions in query strings expand to `(?, ?)` placeholders and `?`-parameterized IN-lists respectively — no user content concatenated. The LIKE prefix search (`q+"%"`) passes the user string as a bound param; `%`/`_` metacharacters in the term widen the match but cannot leak out-of-scope data (results remain library-scoped). --- ### Findings [MINOR] internal/series/handler.go:97 — `parseVolParam` accepts arbitrarily large integers for `?vol=` `strconv.Atoi` on a 64-bit platform accepts values up to INT64_MAX without error; the DB column is INT32. A value like `?vol=2147483648` parses successfully, finds no results (no match), and returns an empty page rather than a 400. No data leak, but a well-bounded year-range check (e.g., 1900–2100) would give a cleaner error response. [MINOR] templates/pages/series_index.html:14, series_index_fragment.html:2 — `{{urlquery .Name}}` used in a URL path segment `urlquery` applies `url.QueryEscape`, which encodes spaces as `+`. Go's HTTP router does NOT decode `+` as a space in `r.PathValue(...)`, so a series named "Batman Robin" generates the link `/series/Batman+Robin`, which the router hands to the handler as `Batman+Robin` — not matching the DB value `Batman Robin`. The server-side `buildSeriesLink` in `step_edit_handler.go:348` correctly uses `url.PathEscape` (space → `%20`). The templates should use a custom `urlpath` helper or a pre-escaped value from the server to stay consistent with the Go code. Series names without spaces are unaffected. --- No architecture boundary violations (`series` package imports no workflow engine). No secrets or PII in log paths. `.golangci.yml` unchanged. All test files use `package series_test` / `package books_test` (black-box). Migration 0042 is an index-only addition — no column schema change. REVIEW VERDICT: 0 blocker, 0 major, 2 minor
fix(series): address review findings on PR #932
Some checks failed
/ JS Unit Tests (pull_request) Successful in 1m18s
/ E2E API (pull_request) Successful in 2m13s
/ Lint (pull_request) Failing after 2m44s
/ Integration (pull_request) Successful in 4m7s
/ E2E Browser (pull_request) Successful in 4m15s
/ Test (pull_request) Successful in 5m15s
2f683f1247
- CountDistinctSeries now counts composite (series_name, volume_number)
  groups to match the series list page grouping; sidebar badge now agrees
  with the "Showing N" count
- Series card hrefs use {{urlpath}} (space→%20) instead of {{urlquery}}
  (space→+) so multi-word series links work in Go's PathValue()
- parseVolParam rejects vol values outside [1000,3000] with 400
- ORDER BY gains (cm.volume_number IS NULL) discriminant making sort total;
  cursor predicates updated to three/four-level HAVING for name/count sort
- Remove dead series-title CSS class from series_show.html h1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(sqlc): align CountDistinctSeries generated constant with sqlc output format
All checks were successful
/ JS Unit Tests (pull_request) Successful in 50s
/ E2E API (pull_request) Successful in 2m28s
/ Integration (pull_request) Successful in 2m43s
/ Lint (pull_request) Successful in 4m20s
/ E2E Browser (pull_request) Successful in 4m31s
/ Test (pull_request) Successful in 5m14s
9ffb451f63
sqlc puts SQL-source -- comments as Go // doc comments above the function,
not as -- lines inside the const string. The manual edit had them wrong.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
chore: add series screenshot script and rendered screenshots for PR #932
All checks were successful
/ JS Unit Tests (pull_request) Successful in 51s
/ E2E API (pull_request) Successful in 1m32s
/ E2E Browser (pull_request) Successful in 3m8s
/ Lint (pull_request) Successful in 3m24s
/ Integration (pull_request) Successful in 3m24s
/ Test (pull_request) Successful in 4m32s
8c9714eb3b
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

CODE REVIEW: APPROVED

Re-review of the round-2 fix commits. All 5 tagged findings verified resolved. One new minor found.


Phase 0: DEMO

No standalone DEMO block (series page is a full-stack browser feature). CI is green and PR is mergeable — proceeding.

Phase 1: Fix verification

Finding 1 — Nav count now counts composite identity
internal/db/queries/nav_counts.sql + internal/db/sqlc/nav_counts.sql.go
New query does GROUP BY bm.series_name, cm.volume_number inside a subselect and COUNT(*) over it — correctly counting distinct (name, vol) pairs, NULL-volume as its own group. sqlc doc comment is now correctly placed immediately above the function (prior format-mismatch resolved). Nav count carries no library scoping, but this was pre-existing on main before this PR. RESOLVED.

Finding 2 — Series links use urlpath
templates/pages/series_index.html and series_index_fragment.html
Both templates now use href="/series/{{urlpath .Name}}{{with .VolumeNumber}}?vol={{.}}{{end}}". Regression test in internal/series/handler_test.go renders the real production template with "Batman Robin" and asserts ContainSubstring("/series/Batman%20Robin") and Not(ContainSubstring("/series/Batman+Robin")). RESOLVED.

Finding 3 — parseVolParam rejects values outside [1000, 3000] with 400
internal/series/handler.go:parseVolParam
Constants minVolYear=1000 / maxVolYear=3000 in place. Three test cases: vol=2021 -> 200, vol=notanumber -> 400, vol=2147483648 -> 400 (overflow path). RESOLVED.

Finding 4 — ORDER BY made total via IS_NULL discriminant
internal/series/store.go:buildSeriesOrderClause
Name sort: bm.series_name <dir>, (cm.volume_number IS NULL) <dir>, COALESCE(cm.volume_number, 0) <dir> — 3-column total order.
Count sort: book_count <dir>, bm.series_name <dir>, (cm.volume_number IS NULL) <dir>, COALESCE(cm.volume_number, 0) <dir> — 4-column total order.
Cursor HAVING predicates in buildNameCursorHaving and buildCountCursorHaving updated with volumeIsNull() helper. Store tests assert the IS_NULL discriminant present in both query and cursor args. RESOLVED.

Finding 5 — Dead series-title CSS class removed
templates/pages/series_show.html:66
h1 now uses class="book-detail__title" and {{.Detail.DisplayName}}. RESOLVED.


Phase 2: Code Quality

[MINOR] internal/series/store.go — stale doc comment in buildListSeriesQuery
The function doc comment says "Sort=name: ORDER BY bm.series_name

, COALESCE(cm.volume_number, 0) " (2 columns) and "Sort=count: ... (stable three-column total order)" but the actual buildSeriesOrderClause produces 3 columns for name sort and 4 for count sort (both include the IS_NULL discriminant). The buildSeriesOrderClause function doc is correct; only the caller summary is stale. No correctness impact.


All 5 tagged findings resolved. No blockers, no majors.

REVIEW VERDICT: 0 blocker, 0 major, 1 minor

CODE REVIEW: APPROVED Re-review of the round-2 fix commits. All 5 tagged findings verified resolved. One new minor found. --- ## Phase 0: DEMO No standalone DEMO block (series page is a full-stack browser feature). CI is green and PR is mergeable — proceeding. ## Phase 1: Fix verification **Finding 1 — Nav count now counts composite identity** `internal/db/queries/nav_counts.sql` + `internal/db/sqlc/nav_counts.sql.go` New query does `GROUP BY bm.series_name, cm.volume_number` inside a subselect and `COUNT(*)` over it — correctly counting distinct (name, vol) pairs, NULL-volume as its own group. sqlc doc comment is now correctly placed immediately above the function (prior format-mismatch resolved). Nav count carries no library scoping, but this was pre-existing on main before this PR. RESOLVED. **Finding 2 — Series links use urlpath** `templates/pages/series_index.html` and `series_index_fragment.html` Both templates now use `href="/series/{{urlpath .Name}}{{with .VolumeNumber}}?vol={{.}}{{end}}"`. Regression test in `internal/series/handler_test.go` renders the real production template with "Batman Robin" and asserts `ContainSubstring("/series/Batman%20Robin")` and `Not(ContainSubstring("/series/Batman+Robin"))`. RESOLVED. **Finding 3 — parseVolParam rejects values outside [1000, 3000] with 400** `internal/series/handler.go:parseVolParam` Constants minVolYear=1000 / maxVolYear=3000 in place. Three test cases: vol=2021 -> 200, vol=notanumber -> 400, vol=2147483648 -> 400 (overflow path). RESOLVED. **Finding 4 — ORDER BY made total via IS_NULL discriminant** `internal/series/store.go:buildSeriesOrderClause` Name sort: `bm.series_name <dir>, (cm.volume_number IS NULL) <dir>, COALESCE(cm.volume_number, 0) <dir>` — 3-column total order. Count sort: `book_count <dir>, bm.series_name <dir>, (cm.volume_number IS NULL) <dir>, COALESCE(cm.volume_number, 0) <dir>` — 4-column total order. Cursor HAVING predicates in buildNameCursorHaving and buildCountCursorHaving updated with volumeIsNull() helper. Store tests assert the IS_NULL discriminant present in both query and cursor args. RESOLVED. **Finding 5 — Dead `series-title` CSS class removed** `templates/pages/series_show.html:66` h1 now uses `class="book-detail__title"` and `{{.Detail.DisplayName}}`. RESOLVED. --- ## Phase 2: Code Quality [MINOR] internal/series/store.go — stale doc comment in buildListSeriesQuery The function doc comment says "Sort=name: ORDER BY bm.series_name <dir>, COALESCE(cm.volume_number, 0) <dir>" (2 columns) and "Sort=count: ... (stable three-column total order)" but the actual buildSeriesOrderClause produces 3 columns for name sort and 4 for count sort (both include the IS_NULL discriminant). The buildSeriesOrderClause function doc is correct; only the caller summary is stale. No correctness impact. --- All 5 tagged findings resolved. No blockers, no majors. REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Author
Owner

Security Re-Review — PR #932 (bookshelf-uj5h.2)

Scope: focused re-review of the fix round — CountDistinctSeries nav_counts SQL rewrite, parseVolParam [1000,3000] guard, and URL path-escaping change.


[MAJOR] internal/db/queries/nav_counts.sql:20 — CountDistinctSeries nav count is not library-scoped

The new CountDistinctSeries SQL query counts every distinct (series_name, volume_number) pair across all libraries, all users with no AND b.library_id IN (?) guard:

SELECT COUNT(*) AS n
FROM (
  SELECT 1
  FROM book_metadata bm
  JOIN book b ON b.id = bm.book_id AND b.deleted = 0
  LEFT JOIN comic_metadata cm ON cm.book_id = bm.book_id
  WHERE bm.series_name IS NOT NULL
    AND bm.series_name <> ''
  GROUP BY bm.series_name, cm.volume_number
) t;

The generated Go function signature is func(ctx context.Context) (int64, error) — it accepts no user ID or library IDs, so the wiring in app.go (middleware.WrapInt64(q.CountDistinctSeries, navCacheTTL)) cannot scope it per user. Every authenticated user sees the global series count in the nav sidebar regardless of which libraries they have access to. A user with access to zero series will see the total count for the whole installation. The series list page (ListHandler) is correctly fail-closed (userLibraryIDs threaded throughout), making the nav count contradictory and an info leak.

Note: the pre-existing query on main had the same scoping gap. The fix round rewrote the SQL (to count composite keys) but did not correct the scoping deficiency — it perpetuates rather than newly introduces it, but the hard rule requires it to be closed.

Fix: mirror CountBooksByShelves — add a userLibraryIDs []int64 parameter (or a user_id join), apply AND b.library_id IN (?) with AND 1=0 on empty-non-nil, and thread the resolved library IDs through NavCountDeps.CountDistinctSerieslaunchNavMetaCounts → the DB call.


parseVolParam [1000,3000] guard — CLEAN. strconv.Atoi handles full int range (no int32 overflow); the [1000,3000] bounds check returns ErrValidation (HTTP 400); the ?vol= parameter is the only user-controlled input and flows to a typed *int that is never string-interpolated into SQL.

URL path-escaping — CLEAN. url.PathEscape (registered as {{urlpath}}) is applied to series names in templates and buildSeriesLink. The hardcoded /series/ prefix rules out open-redirect. Volume number is rendered via strconv.Itoa(*int) — no injection vector.

SQL injection in new queries — CLEAN. All WHERE predicates use bound ? parameters. ORDER BY is constructed from allowlisted SortKey/SortDir constants validated in parseSeriesSort against validSortKeys/validSortDirs maps before any interpolation. HAVING predicates are also fully parameterized.

Architecture boundary — CLEAN. No domain package imports the workflow engine.

Secrets/PII — CLEAN. No tokens, credentials, or PII in new log paths.


REVIEW VERDICT: 0 blocker, 1 major, 0 minor

## Security Re-Review — PR #932 (bookshelf-uj5h.2) Scope: focused re-review of the fix round — `CountDistinctSeries` nav_counts SQL rewrite, `parseVolParam` [1000,3000] guard, and URL path-escaping change. --- [MAJOR] internal/db/queries/nav_counts.sql:20 — CountDistinctSeries nav count is not library-scoped The new `CountDistinctSeries` SQL query counts every distinct `(series_name, volume_number)` pair across **all libraries, all users** with no `AND b.library_id IN (?)` guard: ```sql SELECT COUNT(*) AS n FROM ( SELECT 1 FROM book_metadata bm JOIN book b ON b.id = bm.book_id AND b.deleted = 0 LEFT JOIN comic_metadata cm ON cm.book_id = bm.book_id WHERE bm.series_name IS NOT NULL AND bm.series_name <> '' GROUP BY bm.series_name, cm.volume_number ) t; ``` The generated Go function signature is `func(ctx context.Context) (int64, error)` — it accepts no user ID or library IDs, so the wiring in `app.go` (`middleware.WrapInt64(q.CountDistinctSeries, navCacheTTL)`) cannot scope it per user. Every authenticated user sees the global series count in the nav sidebar regardless of which libraries they have access to. A user with access to zero series will see the total count for the whole installation. The series *list* page (`ListHandler`) is correctly fail-closed (userLibraryIDs threaded throughout), making the nav count contradictory and an info leak. Note: the pre-existing query on `main` had the same scoping gap. The fix round rewrote the SQL (to count composite keys) but did not correct the scoping deficiency — it perpetuates rather than newly introduces it, but the hard rule requires it to be closed. Fix: mirror `CountBooksByShelves` — add a `userLibraryIDs []int64` parameter (or a `user_id` join), apply `AND b.library_id IN (?)` with `AND 1=0` on empty-non-nil, and thread the resolved library IDs through `NavCountDeps.CountDistinctSeries` → `launchNavMetaCounts` → the DB call. --- **parseVolParam [1000,3000] guard — CLEAN.** `strconv.Atoi` handles full `int` range (no int32 overflow); the [1000,3000] bounds check returns `ErrValidation` (HTTP 400); the `?vol=` parameter is the only user-controlled input and flows to a typed `*int` that is never string-interpolated into SQL. **URL path-escaping — CLEAN.** `url.PathEscape` (registered as `{{urlpath}}`) is applied to series names in templates and `buildSeriesLink`. The hardcoded `/series/` prefix rules out open-redirect. Volume number is rendered via `strconv.Itoa(*int)` — no injection vector. **SQL injection in new queries — CLEAN.** All WHERE predicates use bound `?` parameters. ORDER BY is constructed from allowlisted `SortKey`/`SortDir` constants validated in `parseSeriesSort` against `validSortKeys`/`validSortDirs` maps before any interpolation. HAVING predicates are also fully parameterized. **Architecture boundary — CLEAN.** No domain package imports the workflow engine. **Secrets/PII — CLEAN.** No tokens, credentials, or PII in new log paths. --- REVIEW VERDICT: 0 blocker, 1 major, 0 minor
zombor force-pushed bd-bookshelf-uj5h.2 from 8c9714eb3b
All checks were successful
/ JS Unit Tests (pull_request) Successful in 51s
/ E2E API (pull_request) Successful in 1m32s
/ E2E Browser (pull_request) Successful in 3m8s
/ Lint (pull_request) Successful in 3m24s
/ Integration (pull_request) Successful in 3m24s
/ Test (pull_request) Successful in 4m32s
to 35f184cee3
Some checks failed
/ JS Unit Tests (pull_request) Successful in 1m10s
/ E2E API (pull_request) Successful in 2m18s
/ Lint (pull_request) Failing after 3m9s
/ Integration (pull_request) Successful in 3m7s
/ Test (pull_request) Successful in 4m12s
/ E2E Browser (pull_request) Successful in 3m25s
2026-07-04 23:03:24 +00:00
Compare
fix(uj5h.2): remove extra trailing newline in nav_counts.sql.go
All checks were successful
/ E2E API (pull_request) Successful in 2m33s
/ JS Unit Tests (pull_request) Successful in 1m19s
/ Lint (pull_request) Successful in 4m8s
/ Integration (pull_request) Successful in 4m6s
/ Test (pull_request) Successful in 5m37s
/ E2E Browser (pull_request) Successful in 4m25s
7a9c8bb91b
sqlc verify CI step detected extra blank line vs regenerated output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Security Re-Review: PR #932 (bookshelf-uj5h.2)

This is a focused re-review confirming the prior [MAJOR] finding (nav-count badge not library-scoped, cross-user info leak) is closed, and verifying the new per-user cache does not introduce a new vulnerability.


Findings

No new security issues found. The fix is correct. Detailed verification below.

Prior [MAJOR] — closed: CountDistinctSeries is now library-scoped. The SQL wraps the count in a subquery with buildSeriesLibraryClause injected, which produces AND 1=0 for authenticated users with zero library access (fail-closed) and AND b.library_id IN (?, ...) with bound params for non-empty sets. An authenticated user with an empty library set also hits an explicit early-return return 0, nil in the curried function before any DB call. Both paths independently enforce fail-closed.

Cache key correctness — verified: librarySetKey maps nil to the literal string "nil" and any non-nil slice to a sorted comma-separated decimal integer string via healthKey. Key collision analysis:

  • nil"nil" (distinct from all numeric sequences)
  • []"" (empty join, distinct from "nil" and from any non-empty sequence since "" cannot be produced by any non-empty integer list)
  • {1,2}"1,2", {1,3}"1,3" — different
  • {12}"12" vs {1,2}"1,2" — different (comma is non-digit)
  • Sort-invariant: {2,1}"1,2" same as {1,2}

No collisions. Users with different library sets always get distinct cache entries. Users sharing the same library set share a cache entry — the count is identical for them, so sharing is correct.

Cache concurrency — verified: The outer entries map is protected by sync.Mutex throughout the lookup, creation, and eviction loop. c.Get(ctx) is called outside the lock with only a local pointer copy. NavCache.Get is independently synchronized with its own sync.Mutex and handles concurrent fill via a single in-flight channel. No data race.

Eviction bounds memory — verified: Entries with lastGet.Before(t.Add(-2×TTL)) are deleted on every call under the lock. The eviction loop runs in O(n) where n is the number of distinct library-ID sets seen recently — bounded in practice by the number of active users.

userID from session, not request params — verified: userID = extractUser(r).ID where extractUser reads from the JWT/session context set by AuthMiddleware. Library IDs are then fetched from DB keyed on that session userID via users.GetUserLibraryIDs. Neither userID nor library IDs can be overridden from request params.

SQL injection — clean: Library IDs are formatted as ? placeholders with bound args. Sort direction comes from a validated validSortDirs allowlist enum, then strings.ToUpper — interpolated into SQL as "ASC" or "DESC" only. Cursor values (CursorName, CursorCount, CursorVolumeNumber) are always bound params. The ?vol= handler param is parsed as strconv.Atoi and range-checked to [1000,3000], used only to filter the series detail query as a bound param.

Architecture boundary — clean: No domain package imports the workflow engine.

No secrets/PII in logs — verified.


REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security Re-Review: PR #932 (bookshelf-uj5h.2) This is a focused re-review confirming the prior [MAJOR] finding (nav-count badge not library-scoped, cross-user info leak) is closed, and verifying the new per-user cache does not introduce a new vulnerability. --- ### Findings **No new security issues found.** The fix is correct. Detailed verification below. **Prior [MAJOR] — closed:** `CountDistinctSeries` is now library-scoped. The SQL wraps the count in a subquery with `buildSeriesLibraryClause` injected, which produces `AND 1=0` for authenticated users with zero library access (fail-closed) and `AND b.library_id IN (?, ...)` with bound params for non-empty sets. An authenticated user with an empty library set also hits an explicit early-return `return 0, nil` in the curried function before any DB call. Both paths independently enforce fail-closed. **Cache key correctness — verified:** `librarySetKey` maps `nil` to the literal string `"nil"` and any non-nil slice to a sorted comma-separated decimal integer string via `healthKey`. Key collision analysis: - `nil` → `"nil"` (distinct from all numeric sequences) - `[]` → `""` (empty join, distinct from `"nil"` and from any non-empty sequence since `""` cannot be produced by any non-empty integer list) - `{1,2}` → `"1,2"`, `{1,3}` → `"1,3"` — different - `{12}` → `"12"` vs `{1,2}` → `"1,2"` — different (comma is non-digit) - Sort-invariant: `{2,1}` → `"1,2"` same as `{1,2}` No collisions. Users with different library sets always get distinct cache entries. Users sharing the same library set share a cache entry — the count is identical for them, so sharing is correct. **Cache concurrency — verified:** The outer `entries` map is protected by `sync.Mutex` throughout the lookup, creation, and eviction loop. `c.Get(ctx)` is called outside the lock with only a local pointer copy. `NavCache.Get` is independently synchronized with its own `sync.Mutex` and handles concurrent fill via a single in-flight channel. No data race. **Eviction bounds memory — verified:** Entries with `lastGet.Before(t.Add(-2×TTL))` are deleted on every call under the lock. The eviction loop runs in O(n) where n is the number of distinct library-ID sets seen recently — bounded in practice by the number of active users. **userID from session, not request params — verified:** `userID = extractUser(r).ID` where `extractUser` reads from the JWT/session context set by `AuthMiddleware`. Library IDs are then fetched from DB keyed on that session userID via `users.GetUserLibraryIDs`. Neither userID nor library IDs can be overridden from request params. **SQL injection — clean:** Library IDs are formatted as `?` placeholders with bound args. Sort direction comes from a validated `validSortDirs` allowlist enum, then `strings.ToUpper` — interpolated into SQL as `"ASC"` or `"DESC"` only. Cursor values (`CursorName`, `CursorCount`, `CursorVolumeNumber`) are always bound params. The `?vol=` handler param is parsed as `strconv.Atoi` and range-checked to `[1000,3000]`, used only to filter the series detail query as a bound param. **Architecture boundary — clean:** No domain package imports the workflow engine. **No secrets/PII in logs** — verified. --- REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Author
Owner

Code Review — bookshelf-uj5h.2 (PR #932)

Focused re-review of the nav-count scoping fix: CountDistinctSeries library-scoping + WrapInt64WithLibraries per-user cache.

nav_cache.go — WrapInt64WithLibraries

Cache key derivation (librarySetKey) — correct and collision-free.

  • nil"nil" (3-char alphabetic string, unreachable by any decimal int64 set)
  • []int64{}"" (empty string, unreachable by any non-empty set)
  • Non-empty → healthKey → sorted, comma-joined decimal ints; two distinct sorted sets never collide.
    All three key spaces are disjoint. ✓

Eviction logic (2×TTL idle sweep) — correct. e.lastGet = t is set BEFORE the sweep runs under the same lock, so the current entry's lastGet is never Before(cutoff) during its own eviction sweep — no self-eviction possible. Idle entries (those not accessed in 2×TTL) are deleted on the next call. Map growth is bounded. ✓

Concurrency safety — correct. All access to entries happens under sync.Mutex. After unlock, c.Get(ctx) runs on NavCache's own internal mutex (single-flight stampede protection). Even if a concurrent goroutine evicts an entry between the unlock and the c.Get(ctx) call, the locally captured c pointer is still valid and returns a correct (if uncached) value — no data corruption. ✓

No background goroutinesWrapInt64WithLibraries is fully inline; the only channel in the file is navInflight.done (pre-existing single-flight mechanism). No resource leaks. ✓

Deep-copy of ids slice in fill closure — correct. A fresh fill slice is allocated and copy'd before the inner NewNavCache closure captures it, so the caller's slice can't alias or mutate the cached key. ✓

nav.go — launchNavMetaCounts scoping

GetUserLibraryIDs is called inside the goroutine using the request context. The goroutine is bounded by wg.Wait() in launchNavWave1, which holds before the response is written — context lifetime is guaranteed valid. ✓

userID == 0 path correctly falls back to nil (unscoped) for unauthenticated users, consistent with the existing design. ✓

Nil-guard counts.GetUserLibraryIDs != nil is correct: any existing wiring that pre-dates this field omits it and gets nil → falls back to unscoped (not a crash). ✓

CountDistinctSeries query

SELECT COUNT(*) AS n
FROM (
  SELECT 1
  FROM book_metadata bm
  JOIN book b ON b.id = bm.book_id AND b.deleted = 0
  LEFT JOIN comic_metadata cm ON cm.book_id = bm.book_id
  WHERE bm.series_name IS NOT NULL AND bm.series_name <> ''<libraryWhere>
  GROUP BY bm.series_name, cm.volume_number
) t

The subquery groups by (series_name, volume_number), matching the series list page's grouping. MySQL treats multiple NULLs as equal in GROUP BY, so non-comic books in a series share one NULL-volume group — correct. COUNT(*) of the grouped subquery gives the right distinct-pair count. ✓

Library scoping reuses buildSeriesLibraryClause (same function as ListSeries): nil → no filter, empty → AND 1=0 (fail-closed), non-empty → AND b.library_id IN (...). The CountDistinctSeries curried function also has an early-return for the empty case (return 0, nil before the DB call), which is correct and slightly more efficient. ✓

No new golangci.yml exclusions

.golangci.yml is unchanged in this diff. ✓

Test coverage

  • nav_cache_test.gopackage middleware_test (black-box). ✓
  • nav_test.gopackage middleware_test (black-box). ✓
  • store_test.gopackage series_test (black-box). ✓
  • One Expect per It throughout all new tests. ✓
  • Eviction test uses an injected now func for deterministic time control. ✓
  • Key properties covered: nil-fn, zero-TTL passthrough, per-set cache isolation, order-independent key, nil vs empty distinct keys, 2×TTL eviction, GetUserLibraryIDs scoping wire-through, fail-closed with empty set, GetUserLibraryIDs error suppression. ✓

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Code Review — bookshelf-uj5h.2 (PR #932) Focused re-review of the nav-count scoping fix: `CountDistinctSeries` library-scoping + `WrapInt64WithLibraries` per-user cache. ### nav_cache.go — WrapInt64WithLibraries **Cache key derivation (`librarySetKey`)** — correct and collision-free. - `nil` → `"nil"` (3-char alphabetic string, unreachable by any decimal int64 set) - `[]int64{}` → `""` (empty string, unreachable by any non-empty set) - Non-empty → `healthKey` → sorted, comma-joined decimal ints; two distinct sorted sets never collide. All three key spaces are disjoint. ✓ **Eviction logic (2×TTL idle sweep)** — correct. `e.lastGet = t` is set BEFORE the sweep runs under the same lock, so the current entry's lastGet is never `Before(cutoff)` during its own eviction sweep — no self-eviction possible. Idle entries (those not accessed in 2×TTL) are deleted on the next call. Map growth is bounded. ✓ **Concurrency safety** — correct. All access to `entries` happens under `sync.Mutex`. After unlock, `c.Get(ctx)` runs on `NavCache`'s own internal mutex (single-flight stampede protection). Even if a concurrent goroutine evicts an entry between the unlock and the `c.Get(ctx)` call, the locally captured `c` pointer is still valid and returns a correct (if uncached) value — no data corruption. ✓ **No background goroutines** — `WrapInt64WithLibraries` is fully inline; the only channel in the file is `navInflight.done` (pre-existing single-flight mechanism). No resource leaks. ✓ **Deep-copy of ids slice in fill closure** — correct. A fresh `fill` slice is allocated and `copy`'d before the inner `NewNavCache` closure captures it, so the caller's slice can't alias or mutate the cached key. ✓ ### nav.go — launchNavMetaCounts scoping `GetUserLibraryIDs` is called inside the goroutine using the request context. The goroutine is bounded by `wg.Wait()` in `launchNavWave1`, which holds before the response is written — context lifetime is guaranteed valid. ✓ `userID == 0` path correctly falls back to `nil` (unscoped) for unauthenticated users, consistent with the existing design. ✓ Nil-guard `counts.GetUserLibraryIDs != nil` is correct: any existing wiring that pre-dates this field omits it and gets nil → falls back to unscoped (not a crash). ✓ ### CountDistinctSeries query ```sql SELECT COUNT(*) AS n FROM ( SELECT 1 FROM book_metadata bm JOIN book b ON b.id = bm.book_id AND b.deleted = 0 LEFT JOIN comic_metadata cm ON cm.book_id = bm.book_id WHERE bm.series_name IS NOT NULL AND bm.series_name <> ''<libraryWhere> GROUP BY bm.series_name, cm.volume_number ) t ``` The subquery groups by `(series_name, volume_number)`, matching the series list page's grouping. MySQL treats multiple NULLs as equal in GROUP BY, so non-comic books in a series share one `NULL`-volume group — correct. `COUNT(*)` of the grouped subquery gives the right distinct-pair count. ✓ Library scoping reuses `buildSeriesLibraryClause` (same function as `ListSeries`): nil → no filter, empty → `AND 1=0` (fail-closed), non-empty → `AND b.library_id IN (...)`. The `CountDistinctSeries` curried function also has an early-return for the empty case (`return 0, nil` before the DB call), which is correct and slightly more efficient. ✓ ### No new golangci.yml exclusions `.golangci.yml` is unchanged in this diff. ✓ ### Test coverage - `nav_cache_test.go` — `package middleware_test` (black-box). ✓ - `nav_test.go` — `package middleware_test` (black-box). ✓ - `store_test.go` — `package series_test` (black-box). ✓ - One `Expect` per `It` throughout all new tests. ✓ - Eviction test uses an injected `now` func for deterministic time control. ✓ - Key properties covered: nil-fn, zero-TTL passthrough, per-set cache isolation, order-independent key, nil vs empty distinct keys, 2×TTL eviction, GetUserLibraryIDs scoping wire-through, fail-closed with empty set, GetUserLibraryIDs error suppression. ✓ --- REVIEW VERDICT: 0 blocker, 0 major, 0 minor
zombor force-pushed bd-bookshelf-uj5h.2 from 7a9c8bb91b
All checks were successful
/ E2E API (pull_request) Successful in 2m33s
/ JS Unit Tests (pull_request) Successful in 1m19s
/ Lint (pull_request) Successful in 4m8s
/ Integration (pull_request) Successful in 4m6s
/ Test (pull_request) Successful in 5m37s
/ E2E Browser (pull_request) Successful in 4m25s
to 3f0b20f111
Some checks failed
/ JS Unit Tests (pull_request) Successful in 37s
/ E2E API (pull_request) Successful in 2m20s
/ Lint (pull_request) Successful in 3m11s
/ Integration (pull_request) Successful in 3m24s
/ E2E Browser (pull_request) Failing after 4m6s
/ Test (pull_request) Successful in 4m11s
2026-07-05 00:25:55 +00:00
Compare
fix(e2e): use page.Has() (non-blocking) in publishers tab Eventually loop
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m40s
/ E2E API (pull_request) Successful in 2m45s
/ Lint (pull_request) Successful in 3m36s
/ Integration (pull_request) Successful in 3m47s
/ E2E Browser (pull_request) Successful in 3m51s
/ Test (pull_request) Successful in 4m36s
f16c5c31d4
page.Element() retries until the element appears or the page deadline
expires (60s). Inside Eventually(30s, 100ms), the first blocking call
consumes the full 60s page deadline so the 30s Eventually window fires
before the element-not-found error is returned, giving the retry loop
zero chance to succeed.

page.Has() uses NotFoundSleeper, returning immediately when the element
is absent. This makes the 100ms-interval Eventually retry loop work as
intended: each iteration checks synchronously and retries up to 300 times
within 30s instead of blocking once for 60s.

Fixes bookshelf-5kje / bookshelf-rpr4 root cause — #892 fixed the panic
(MustElement → Element) but left the blocking-inside-Eventually pattern.
zombor merged commit 42cddf52ba into main 2026-07-05 00:58:14 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!932
No description provided.