feat(books): multi-field sort builder + fix dead author-sort (bookshelf-oxau/ovnu) #481

Merged
zombor merged 10 commits from bd-bookshelf-oxau into main 2026-06-10 19:43:15 +00:00
Owner

Summary

  • Adds multi-field sort via ?sort=author:asc,title:asc URL format with full keyset cursor pagination across composite sorts
  • Sort-builder UI panel (Stimulus controller) with add/remove/reorder and direction toggle, plus "Save as default" per-user sort persisted via user_settings
  • Staged-OR keyset cursor predicate for composite sort navigation at scale
  • Fixes dead author-sort (bookshelf-ovnu): sort_author_name was never populated; now refreshed at every author-write site (scan, bookdrop, enrich, seeder) + migration 0030 backfills existing rows

Implementation details (oxau)

  • parseSort / parseMultiSort: parse and validate multi-field sort URL format
  • buildMultiFieldListQuery: separate query builder for multi-term sorts; drives from book_metadata when any metadata column is present, book otherwise
  • buildMultiSortOrderBy / buildMultiCursorPredicate: ORDER BY and staged-OR WHERE for multi-field keyset cursor
  • GetSortPrefs / SaveSortPrefs / SaveSortPrefsHandler: per-user default sort stored in user_settings with key books.list.sort
  • sort_builder_controller.js: Stimulus controller for the sort builder panel

Implementation details (ovnu)

  • RefreshSortAuthorName sqlc query derives sort_author_name from author.name at sort_order=0 for a single book
  • bulkRefreshSortAuthorName batch variant for scan/seeder paths
  • Called in syncAuthors, batchSeedAuthors, SeedMetadata, SeedMetadataSeriesAndAuthors, and seeder insertAuthorMappings
  • Migration 0030 backfills all existing NULL rows

Test plan

  • make test passes (365 scan tests, 100% coverage)
  • make coverage passes — 100% gate
  • make lint passes with 0 issues
  • Unit tests: RefreshSortAuthorName error path, bulkRefreshSortAuthorName batch path, nil-guard in scan paths
  • Integration test: internal/db/sort_author_name_integration_test.go — single + batch refresh + migration 0030 backfill
  • E2E: sort by author ASC asserts real ordering (Alpha Author books appear before Zeta Author books)

Closes bead bookshelf-oxau on merge.
Closes bead bookshelf-ovnu on merge.

## Summary - Adds multi-field sort via `?sort=author:asc,title:asc` URL format with full keyset cursor pagination across composite sorts - Sort-builder UI panel (Stimulus controller) with add/remove/reorder and direction toggle, plus "Save as default" per-user sort persisted via `user_settings` - Staged-OR keyset cursor predicate for composite sort navigation at scale - **Fixes dead author-sort (bookshelf-ovnu):** `sort_author_name` was never populated; now refreshed at every author-write site (scan, bookdrop, enrich, seeder) + migration 0030 backfills existing rows ## Implementation details (oxau) - `parseSort` / `parseMultiSort`: parse and validate multi-field sort URL format - `buildMultiFieldListQuery`: separate query builder for multi-term sorts; drives from `book_metadata` when any metadata column is present, `book` otherwise - `buildMultiSortOrderBy` / `buildMultiCursorPredicate`: ORDER BY and staged-OR WHERE for multi-field keyset cursor - `GetSortPrefs` / `SaveSortPrefs` / `SaveSortPrefsHandler`: per-user default sort stored in `user_settings` with key `books.list.sort` - `sort_builder_controller.js`: Stimulus controller for the sort builder panel ## Implementation details (ovnu) - `RefreshSortAuthorName` sqlc query derives `sort_author_name` from `author.name` at `sort_order=0` for a single book - `bulkRefreshSortAuthorName` batch variant for scan/seeder paths - Called in `syncAuthors`, `batchSeedAuthors`, `SeedMetadata`, `SeedMetadataSeriesAndAuthors`, and seeder `insertAuthorMappings` - Migration 0030 backfills all existing NULL rows ## Test plan - [x] `make test` passes (365 scan tests, 100% coverage) - [x] `make coverage` passes — 100% gate - [x] `make lint` passes with 0 issues - [x] Unit tests: RefreshSortAuthorName error path, bulkRefreshSortAuthorName batch path, nil-guard in scan paths - [x] Integration test: `internal/db/sort_author_name_integration_test.go` — single + batch refresh + migration 0030 backfill - [x] E2E: `sort by author ASC` asserts real ordering (Alpha Author books appear before Zeta Author books) Closes bead bookshelf-oxau on merge. Closes bead bookshelf-ovnu on merge.
feat(books): multi-field sort builder + save-as-default (bookshelf-oxau)
Some checks failed
/ JS Unit Tests (pull_request) Successful in 23s
/ Lint (pull_request) Successful in 1m51s
/ Test (pull_request) Successful in 2m45s
/ E2E Browser (pull_request) Successful in 4m10s
/ E2E API (pull_request) Failing after 4m24s
/ Integration (pull_request) Successful in 4m38s
bc89dae042
- Accept multi-field sort as ?sort=author:asc,title:asc,added_on:desc
- Validate fields against allowlist (injection-safe server-side literals)
- Build multi-field ORDER BY with id tiebreaker via staged-OR keyset cursor
- Sort-builder UI (Stimulus) with add/remove/reorder and direction toggle
- Save as default per-user sort via user_settings (key books.list.sort)
- Full test coverage: store/service/handler unit tests + 100% gate passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(e2e): update shelf HTML sort assertions for new sort-builder UI
All checks were successful
/ JS Unit Tests (pull_request) Successful in 17s
/ Lint (pull_request) Successful in 1m12s
/ Test (pull_request) Successful in 2m38s
/ E2E Browser (pull_request) Successful in 4m4s
/ E2E API (pull_request) Successful in 4m18s
/ Integration (pull_request) Successful in 4m28s
445f16c778
The books toolbar sort dropdown (id="sort-select", sort-dropdown
controller) was replaced by the multi-field sort builder. The shelf
detail page renders the same books_index_controls partial, so its e2e
assertions must target the new markup (data-controller="sort-builder",
data-sort-key reflecting the active sort term).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(books): remove dead col=="" branch in buildMultiSortOrderBy; drop banned exclusion
All checks were successful
/ Lint (pull_request) Successful in 2m24s
/ JS Unit Tests (pull_request) Successful in 41s
/ Test (pull_request) Successful in 3m12s
/ Integration (pull_request) Successful in 4m44s
/ E2E Browser (pull_request) Successful in 4m31s
/ E2E API (pull_request) Successful in 5m55s
b1b9000516
The col == "" fallback in buildMultiSortOrderBy was unreachable dead code:
every SortKey passed in is validated against validSortKeys before reaching
this function, and validSortKeys and sortKeyColumn share the same closed set
of 7 keys. Delete the branch and remove the two banned exclusion lines it
generated in scripts/check-coverage.sh.

make coverage still passes at 100%.
Author
Owner

Security Review — PR #481 (bookshelf-oxau) — Multi-field sort builder + save-as-default

Scope: SQL injection via ?sort=, per-user save-as-default scoping, cursor bypass, info leak via error messages.


SQL Injection analysis (PRIMARY CONCERN)

VERDICT: No SQL injection found. The ORDER BY clause is built exclusively from server-side literal column expressions in sortKeyColumn (a closed Go map[SortKey]string). User input from ?sort= is validated against validSortKeys before being converted to a SortKey typed value; only those typed values are used as map keys to look up hardcoded column literals. The direction (ASC/DESC) is produced by a literal if t.Order == SortDESC branch, never string-concatenated from user input. All cursor-predicate column expressions follow the same pattern.

Full data-flow for ?sort=author:asc,title:asc:

  1. parseMultiSort → validates each field against validSortKeys, each direction against "asc"/"desc" literals → rejects anything else with 400
  2. buildMultiFieldListQuerysortKeyColumn[t.Key] (hardcoded map) → literal "ASC"/"DESC"strings.Join
  3. Cursor decode: cursor.SortTerms are not used to drive ORDER BY; the URL-validated sort.Terms are used. MultiCursorVals (string-encoded sort values from the cursor) are decoded via decodeCursorValForKey as typed Go values (time.Time, int64, float64, string) and passed as parameterized ? args — never string-interpolated.

Saved sort prefs path: GetSortPrefs re-validates every term from the DB against the same validSortKeys allowlist and explicit SortASC/SortDESC checks before returning. A corrupted DB row silently falls back to DefaultSort.


Findings

No blockers. No majors. Minor findings only:

[MINOR] internal/books/handler.go:449 — fmt.Errorf echoes user-supplied field name into error string
fmt.Errorf("invalid sort field %q: %w", parts[0], middleware.ErrValidation) — the raw user-supplied token is in the error message. The error handler strips it to http.StatusText(400) before responding, so it does NOT reach the client. However the field name appears in the structured log "error" field and could reach Seq/Sentry. For most field names (e.g. "><script>) this is a non-issue (slog encodes strings), but it is worth noting: the logged string is unsanitized user input. At existing scale, acceptable; keep in mind for log-injection hygiene.

[MINOR] internal/books/sort_prefs_handler.go:39 — fmt.Errorf echoes unknown sort key into error (same pattern as above)
Same observation as above — goes to log only, not to the client.

[MINOR] static/js/controllers/sort_builder_controller.js:852 — apply() only encodes legacy ?sort=+?order= in view-toggle links, not the new multi-field terms
The view-toggle <a href=...> links in books_index_controls.html are built server-side using $sort.Key / $sort.Order (single-field legacy fields), not the full multi-term list. After a multi-field sort is applied via the JS builder (/books?sort=author:asc,title:asc), switching view mode via the toggle drops the multi-field sort back to the first-term-only legacy URL. Not a security issue; a functional regression worth a follow-up bead.


Per-user scoping

PUT /books/sort-prefs retrieves userID from userIDFromRequest(r) (authenticated session) — never from the request body. The DB query (UpsertUserSetting) scopes on user_id = ? with the session-derived value. GetSortPrefs similarly scopes on user_id = ?. No path exists for user A to read/write user B's sort preference. ✓

CSRF

PUT /books/sort-prefs is covered by the global middleware.CSRF(...) chain in internal/app/app.go (applied to all unsafe methods). The JS saveDefault() fetch sends X-CSRF-Token: this._csrfToken(). ✓

No Grimmory schema violation

Sort prefs stored in user_settings (existing Grimmory table, keyed by setting_key="books.list.sort"). No new columns added to Grimmory tables. ✓


REVIEW VERDICT: 0 blocker, 0 major, 3 minor

## Security Review — PR #481 (bookshelf-oxau) — Multi-field sort builder + save-as-default **Scope:** SQL injection via `?sort=`, per-user save-as-default scoping, cursor bypass, info leak via error messages. --- ### SQL Injection analysis (PRIMARY CONCERN) **VERDICT: No SQL injection found.** The ORDER BY clause is built exclusively from server-side literal column expressions in `sortKeyColumn` (a closed Go `map[SortKey]string`). User input from `?sort=` is validated against `validSortKeys` before being converted to a `SortKey` typed value; only those typed values are used as map keys to look up hardcoded column literals. The direction (`ASC`/`DESC`) is produced by a literal `if t.Order == SortDESC` branch, never string-concatenated from user input. All cursor-predicate column expressions follow the same pattern. Full data-flow for `?sort=author:asc,title:asc`: 1. `parseMultiSort` → validates each field against `validSortKeys`, each direction against `"asc"/"desc"` literals → rejects anything else with 400 2. `buildMultiFieldListQuery` → `sortKeyColumn[t.Key]` (hardcoded map) → literal `"ASC"`/`"DESC"` → `strings.Join` 3. Cursor decode: `cursor.SortTerms` are **not** used to drive ORDER BY; the URL-validated `sort.Terms` are used. `MultiCursorVals` (string-encoded sort values from the cursor) are decoded via `decodeCursorValForKey` as typed Go values (`time.Time`, `int64`, `float64`, `string`) and passed as parameterized `?` args — never string-interpolated. **Saved sort prefs path:** `GetSortPrefs` re-validates every term from the DB against the same `validSortKeys` allowlist and explicit `SortASC`/`SortDESC` checks before returning. A corrupted DB row silently falls back to `DefaultSort`. --- ### Findings No blockers. No majors. Minor findings only: [MINOR] internal/books/handler.go:449 — fmt.Errorf echoes user-supplied field name into error string `fmt.Errorf("invalid sort field %q: %w", parts[0], middleware.ErrValidation)` — the raw user-supplied token is in the error message. The error handler strips it to `http.StatusText(400)` before responding, so it does NOT reach the client. However the field name appears in the structured log `"error"` field and could reach Seq/Sentry. For most field names (e.g. `"><script>`) this is a non-issue (slog encodes strings), but it is worth noting: the logged string is unsanitized user input. At existing scale, acceptable; keep in mind for log-injection hygiene. [MINOR] internal/books/sort_prefs_handler.go:39 — fmt.Errorf echoes unknown sort key into error (same pattern as above) Same observation as above — goes to log only, not to the client. [MINOR] static/js/controllers/sort_builder_controller.js:852 — `apply()` only encodes legacy `?sort=`+`?order=` in view-toggle links, not the new multi-field terms The view-toggle `<a href=...>` links in `books_index_controls.html` are built server-side using `$sort.Key` / `$sort.Order` (single-field legacy fields), not the full multi-term list. After a multi-field sort is applied via the JS builder (`/books?sort=author:asc,title:asc`), switching view mode via the toggle drops the multi-field sort back to the first-term-only legacy URL. Not a security issue; a functional regression worth a follow-up bead. --- ### Per-user scoping `PUT /books/sort-prefs` retrieves `userID` from `userIDFromRequest(r)` (authenticated session) — never from the request body. The DB query (`UpsertUserSetting`) scopes on `user_id = ?` with the session-derived value. `GetSortPrefs` similarly scopes on `user_id = ?`. No path exists for user A to read/write user B's sort preference. ✓ ### CSRF `PUT /books/sort-prefs` is covered by the global `middleware.CSRF(...)` chain in `internal/app/app.go` (applied to all unsafe methods). The JS `saveDefault()` fetch sends `X-CSRF-Token: this._csrfToken()`. ✓ ### No Grimmory schema violation Sort prefs stored in `user_settings` (existing Grimmory table, keyed by `setting_key="books.list.sort"`). No new columns added to Grimmory tables. ✓ --- REVIEW VERDICT: 0 blocker, 0 major, 3 minor
Author
Owner

CODE REVIEW: NOT APPROVED

Phase 0: DEMO Verification

No DEMO block was provided in the bead comments. The bead description and commit history do not include a structured DEMO block with a command and expected output to re-run. Per review policy this alone warrants NOT APPROVED, but proceeding through phases given the implementation is otherwise verifiable via the diff.


Phase 1 + Phase 2: Findings


[MAJOR] static/js/controllers/sort_builder_controller.js:100 — apply() hardcodes "/books?" and drops shelf context

The apply() method always navigates to "/books?" + params.toString(). When the sort builder is rendered on a shelf page (/shelves/42), window.location.search contains no shelf_id parameter (it is part of the path, not the query string, and the shelf handler renders the page at /shelves/{id}). Clicking Apply on a shelf page navigates to /books without any shelf filter, silently abandoning the shelf context. Fix: thread a data-sort-builder-base-url-value attribute from the template (using the request URL or a computed base URL that includes the shelf), and use this.baseUrlValue || "/books" in apply().


[MAJOR] templates/pages/books_index_controls.html:4068–4078 — view-toggle links use legacy single-field sort format, silently drop secondary sort terms

Both view-toggle anchor href attributes use {{$sort.Key}} and {{$sort.Order}}, which encode only the first/primary sort term in the legacy ?sort=field&order=dir format. When a multi-field sort is active (e.g. author:asc,title:asc), clicking "grid" or "table" silently drops the secondary terms and reverts to single-field ?sort=author&order=asc. The template must encode the full terms list in the multi-field format (field:dir,field:dir) when SortTermsDisplay has more than one entry. Fix: add a template helper (or a computed SortURL string field in listPageData) that produces the correct multi-field sort string, and use it in both view-toggle hrefs.

The same issue appears at templates/pages/books_index.html:316 (the "Sort" link in the shelf header area) and lines 455/469 (infinite-scroll data-infinite-scroll-next-href-value attributes), all of which use {{if .Sort.Key}}&sort={{.Sort.Key}}{{end}} only.


[MAJOR] No browser e2e test for the interactive sort builder

Per .claude/rules/project-conventions.md ("Interactive UI needs a browser test"): Stimulus controllers with toggles and fetch interactions require a go-rod e2e test that clicks the control and captures an expanded-state screenshot. The sort builder adds a panel toggle, reorder/add/remove actions, and a fetch PUT for "Save as default". None of these are covered by a browser test; only an HTML-presence assertion in e2e/api/shelves_html_test.go was added. A go-rod test in e2e/browser/ is required.


[MINOR] internal/books/store.go:944 — _ = idCol is dead code suppressor

idCol is computed via sortKeyIDCol(p.SortTerms) but never used directly in buildMultiFieldListQuery; buildMultiSortOrderBy and buildMultiCursorPredicate call sortKeyIDCol() themselves internally. The _ = idCol line is only suppressing a compiler error. Either remove the local assignment entirely, or pass idCol as a parameter to both helpers to avoid the redundant call.


[MINOR] internal/books/store.go:1521–1523 — comment says "NULL sorts last in ASC" but MySQL NULLs sort first in ASC

The comment reads: "NULL sorts last (treated as 'smallest' in ASC, 'largest' in DESC). In ASC: anything after a non-NULL row → col > ? (NULLs already behind us)." MySQL ORDER BY places NULLs FIRST in ASC (they are the smallest). The code behaviour is correct (after seeing a non-NULL row in ASC, all NULLs have already been consumed), but the comment is backwards and misleading. Fix the comment.


[MINOR] internal/books/handler.go:444–463 — parseMultiSort does not reject duplicate sort fields

?sort=title:asc,title:desc is accepted and produces an ORDER BY with bm.title ASC, bm.title DESC. The JS controller prevents duplicates via addTerm(), but the HTTP endpoint can be called directly. The SaveSortPrefs service also accepts duplicates. While not a security issue, it produces undefined ORDER BY behaviour and a corrupt cursor predicate. A dedup check (matching the JS) should be added in parseMultiSort and SaveSortPrefs.


[MINOR] internal/books/sort_prefs_test.go:228 — double-JustBeforeEach causes two save() invocations per test

The "unknown sort key" context has both the outer JustBeforeEach (invokes save() with valid author+title terms) and an inner JustBeforeEach (invokes save() with the bogus key). Ginkgo runs both in order; the test asserts on err from the second call only, but the first call's result is silently discarded. The test is correct but confusing and wastes a call. Use a single JustBeforeEach at the context level (override save in BeforeEach, call once in the context's own JustBeforeEach) or restructure to avoid the outer JustBeforeEach for error-path contexts.


Summary of injection / allowlist audit

Sort field names flow through validSortKeys (in parseMultiSort) and GetSortPrefs (re-validates from DB on read). Column expressions in buildMultiSortOrderBy and buildMultiCursorPredicate are drawn exclusively from the sortKeyColumn map keyed by the validated SortKey enum. No raw user input is interpolated into SQL. No SQL injection vector found.

Tiebreaker / total-order audit

buildMultiSortOrderBy always appends the id column as a final term (either bm.book_id or b.id depending on the join). buildMultiCursorPredicate always includes a final id-comparison branch. The id direction mirrors terms[0].Order consistently in both places. Total-order requirement is met.

Cursor round-trip audit

Multi-field cursors encode all term values in SortVals (via encodeSortValbuildCursor) and decode them via decodeCursorValForKey in buildMultiFieldListQuery. The service correctly routes to params.MultiCursorVals when cursor.SortTerms is populated. Cursor round-trips correctly for all supported key types.

Per-user scoping

GetUserSetting and UpsertUserSetting queries scope by user_id. GetSortPrefs / SaveSortPrefs receive userID from the authenticated session (via userIDFromRequest) not from the request body. Multi-user scoping is correct.

check-coverage.sh

No new exclusions were added. The _ = idCol dead code note is a MINOR issue above.


REVIEW VERDICT: 0 blockers, 3 major, 4 minor

## CODE REVIEW: NOT APPROVED ### Phase 0: DEMO Verification No DEMO block was provided in the bead comments. The bead description and commit history do not include a structured DEMO block with a command and expected output to re-run. Per review policy this alone warrants NOT APPROVED, but proceeding through phases given the implementation is otherwise verifiable via the diff. --- ### Phase 1 + Phase 2: Findings --- **[MAJOR] static/js/controllers/sort_builder_controller.js:100 — apply() hardcodes "/books?" and drops shelf context** The `apply()` method always navigates to `"/books?" + params.toString()`. When the sort builder is rendered on a shelf page (`/shelves/42`), `window.location.search` contains no `shelf_id` parameter (it is part of the path, not the query string, and the shelf handler renders the page at `/shelves/{id}`). Clicking Apply on a shelf page navigates to `/books` without any shelf filter, silently abandoning the shelf context. Fix: thread a `data-sort-builder-base-url-value` attribute from the template (using the request URL or a computed base URL that includes the shelf), and use `this.baseUrlValue || "/books"` in `apply()`. --- **[MAJOR] templates/pages/books_index_controls.html:4068–4078 — view-toggle links use legacy single-field sort format, silently drop secondary sort terms** Both view-toggle anchor `href` attributes use `{{$sort.Key}}` and `{{$sort.Order}}`, which encode only the first/primary sort term in the legacy `?sort=field&order=dir` format. When a multi-field sort is active (e.g. `author:asc,title:asc`), clicking "grid" or "table" silently drops the secondary terms and reverts to single-field `?sort=author&order=asc`. The template must encode the full terms list in the multi-field format (`field:dir,field:dir`) when `SortTermsDisplay` has more than one entry. Fix: add a template helper (or a computed `SortURL string` field in `listPageData`) that produces the correct multi-field sort string, and use it in both view-toggle hrefs. The same issue appears at `templates/pages/books_index.html:316` (the "Sort" link in the shelf header area) and lines 455/469 (infinite-scroll `data-infinite-scroll-next-href-value` attributes), all of which use `{{if .Sort.Key}}&sort={{.Sort.Key}}{{end}}` only. --- **[MAJOR] No browser e2e test for the interactive sort builder** Per `.claude/rules/project-conventions.md` ("Interactive UI needs a browser test"): Stimulus controllers with toggles and fetch interactions require a go-rod e2e test that clicks the control and captures an expanded-state screenshot. The sort builder adds a panel toggle, reorder/add/remove actions, and a `fetch` PUT for "Save as default". None of these are covered by a browser test; only an HTML-presence assertion in `e2e/api/shelves_html_test.go` was added. A go-rod test in `e2e/browser/` is required. --- **[MINOR] internal/books/store.go:944 — `_ = idCol` is dead code suppressor** `idCol` is computed via `sortKeyIDCol(p.SortTerms)` but never used directly in `buildMultiFieldListQuery`; `buildMultiSortOrderBy` and `buildMultiCursorPredicate` call `sortKeyIDCol()` themselves internally. The `_ = idCol` line is only suppressing a compiler error. Either remove the local assignment entirely, or pass `idCol` as a parameter to both helpers to avoid the redundant call. --- **[MINOR] internal/books/store.go:1521–1523 — comment says "NULL sorts last in ASC" but MySQL NULLs sort first in ASC** The comment reads: "NULL sorts last (treated as 'smallest' in ASC, 'largest' in DESC). In ASC: anything after a non-NULL row → col > ? (NULLs already behind us)." MySQL ORDER BY places NULLs FIRST in ASC (they are the smallest). The code behaviour is correct (after seeing a non-NULL row in ASC, all NULLs have already been consumed), but the comment is backwards and misleading. Fix the comment. --- **[MINOR] internal/books/handler.go:444–463 — `parseMultiSort` does not reject duplicate sort fields** `?sort=title:asc,title:desc` is accepted and produces an ORDER BY with `bm.title ASC, bm.title DESC`. The JS controller prevents duplicates via `addTerm()`, but the HTTP endpoint can be called directly. The `SaveSortPrefs` service also accepts duplicates. While not a security issue, it produces undefined ORDER BY behaviour and a corrupt cursor predicate. A dedup check (matching the JS) should be added in `parseMultiSort` and `SaveSortPrefs`. --- **[MINOR] internal/books/sort_prefs_test.go:228 — double-JustBeforeEach causes two `save()` invocations per test** The "unknown sort key" context has both the outer `JustBeforeEach` (invokes `save()` with valid author+title terms) and an inner `JustBeforeEach` (invokes `save()` with the bogus key). Ginkgo runs both in order; the test asserts on `err` from the second call only, but the first call's result is silently discarded. The test is correct but confusing and wastes a call. Use a single `JustBeforeEach` at the context level (override `save` in `BeforeEach`, call once in the context's own `JustBeforeEach`) or restructure to avoid the outer `JustBeforeEach` for error-path contexts. --- ### Summary of injection / allowlist audit Sort field names flow through `validSortKeys` (in `parseMultiSort`) and `GetSortPrefs` (re-validates from DB on read). Column expressions in `buildMultiSortOrderBy` and `buildMultiCursorPredicate` are drawn exclusively from the `sortKeyColumn` map keyed by the validated `SortKey` enum. No raw user input is interpolated into SQL. **No SQL injection vector found.** ### Tiebreaker / total-order audit `buildMultiSortOrderBy` always appends the id column as a final term (either `bm.book_id` or `b.id` depending on the join). `buildMultiCursorPredicate` always includes a final id-comparison branch. The id direction mirrors `terms[0].Order` consistently in both places. **Total-order requirement is met.** ### Cursor round-trip audit Multi-field cursors encode all term values in `SortVals` (via `encodeSortVal` → `buildCursor`) and decode them via `decodeCursorValForKey` in `buildMultiFieldListQuery`. The service correctly routes to `params.MultiCursorVals` when `cursor.SortTerms` is populated. **Cursor round-trips correctly for all supported key types.** ### Per-user scoping `GetUserSetting` and `UpsertUserSetting` queries scope by `user_id`. `GetSortPrefs` / `SaveSortPrefs` receive `userID` from the authenticated session (via `userIDFromRequest`) not from the request body. **Multi-user scoping is correct.** ### check-coverage.sh No new exclusions were added. The `_ = idCol` dead code note is a MINOR issue above. --- REVIEW VERDICT: 0 blockers, 3 major, 4 minor
fix(books): address code+security review findings on multi-field sort builder
Some checks failed
/ JS Unit Tests (pull_request) Successful in 18s
/ Lint (pull_request) Successful in 3m1s
/ Test (pull_request) Successful in 4m4s
/ E2E Browser (pull_request) Failing after 6m21s
/ Integration (pull_request) Successful in 6m54s
/ E2E API (pull_request) Successful in 7m53s
41cbbe0554
- [MAJOR] apply() now uses data-sort-builder-base-url-value so shelf pages
  preserve their /shelves/{id}/books path context instead of always going to /books
- [MAJOR] add SortParam + BaseURL to listPageData; all view-toggle links,
  infinite-scroll sentinel, and load-more anchor now use {{.SortParam}} so
  multi-field sort terms survive pagination and view changes
- [MAJOR] add browser e2e: open/add/remove/Apply (URL assertion)/save-default
  + screenshot uploaded to PR #481 via Forgejo attachment API
- [MINOR] remove dead _ = idCol assignment in buildMultiFieldListQuery
- [MINOR] fix inverted NULL-sort comment in store.go
- [MINOR] parseMultiSort and SaveSortPrefs now reject duplicate sort fields
- [MINOR] fix double JustBeforeEach in sort_prefs_test.go SaveSortPrefs suite

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

Sort-builder review fixes applied. Screenshot captured during local e2e run (7 new browser specs all green). CI will capture and upload the panel screenshot on the next run via uploadSortBuilderScreenshotToPR in e2e/browser/sort_builder_test.go.

Fixes:

  • apply() now uses data-sort-builder-base-url-value (shelf context preserved)
  • SortParam field propagated to all view-toggle/pagination URL builders
  • 7 browser e2e specs: open/add/remove/Apply/save-default
  • Duplicate sort field rejection in parseMultiSort and SaveSortPrefs
  • Dead idCol removed, NULL-sort comment corrected, double JustBeforeEach fixed

New HEAD: 41cbbe05

**Sort-builder review fixes applied.** Screenshot captured during local e2e run (7 new browser specs all green). CI will capture and upload the panel screenshot on the next run via `uploadSortBuilderScreenshotToPR` in `e2e/browser/sort_builder_test.go`. Fixes: - `apply()` now uses `data-sort-builder-base-url-value` (shelf context preserved) - `SortParam` field propagated to all view-toggle/pagination URL builders - 7 browser e2e specs: open/add/remove/Apply/save-default - Duplicate sort field rejection in `parseMultiSort` and `SaveSortPrefs` - Dead `idCol` removed, NULL-sort comment corrected, double `JustBeforeEach` fixed New HEAD: `41cbbe05`
fix(sort): populate sort_author_name on every author write (bookshelf-ovnu)
Some checks failed
/ JS Unit Tests (pull_request) Successful in 33s
/ Lint (pull_request) Successful in 2m32s
/ Test (pull_request) Successful in 3m26s
/ E2E Browser (pull_request) Failing after 5m51s
/ Integration (pull_request) Failing after 7m12s
/ E2E API (pull_request) Successful in 8m5s
8da80f817d
book_metadata.sort_author_name was added in migration 0004 but never written
by any code path, making all ORDER BY bm.sort_author_name queries silent no-ops.

- Add RefreshSortAuthorName sqlc query + batch variant (migration 0030 backfill)
- Call single-book refresh in syncAuthors (books/metadata_store.go)
- Call batch refresh in batchSeedAuthors (library/scan/batch_seed.go)
- Wire both scan and bookdrop accept paths via SeedMetadataParams
- Call batch refresh in seeder.go insertAuthorMappings
- Wire all callers in app.go and build_extended_deps.go
- Add migration 0030 to backfill existing rows
- 100% test coverage: integration tests, unit tests, e2e author-sort assertion

Closes bookshelf-ovnu.
zombor changed title from feat(books): multi-field sort builder + save-as-default (bookshelf-oxau) to feat(books): multi-field sort builder + fix dead author-sort (bookshelf-oxau/ovnu) 2026-06-10 17:26:42 +00:00
fix(test): convert sort_author_name integration tests to Ordered+BeforeAll
Some checks failed
/ JS Unit Tests (pull_request) Successful in 25s
/ Lint (pull_request) Successful in 3m12s
/ Test (pull_request) Successful in 3m55s
/ Integration (pull_request) Successful in 5m48s
/ E2E API (pull_request) Successful in 5m57s
/ E2E Browser (pull_request) Failing after 5m57s
6d133c44f3
Each Describe previously called StartSharedMySQL + NewSuiteDB in BeforeEach,
creating a new DB connection pool per It spec. Under parallel CI procs
(p=8), procs × specs × MaxOpenConns exhausted MySQL max_connections,
cascading to 'Too many connections' failures in unrelated tests.

Fix: convert both Describes (RefreshSortAuthorName and Migration0030Backfill)
to Ordered + BeforeAll — one suite DB per Describe, shared across all It
specs. Per-spec rows are inserted in BeforeEach and cleaned up in AfterEach
via DELETE (cascading FKs handle child tables). Two DBs total instead of
eight, well within the connection budget.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(books): preserve sort direction in infinite-scroll next-page href
All checks were successful
/ JS Unit Tests (pull_request) Successful in 29s
/ Lint (pull_request) Successful in 2m12s
/ Test (pull_request) Successful in 2m54s
/ E2E Browser (pull_request) Successful in 4m29s
/ Integration (pull_request) Successful in 4m34s
/ E2E API (pull_request) Successful in 6m6s
8e6d788b65
buildSortParam emitted just the field name ("added_on") for single-field
sorts, dropping the explicit direction. The infinite-scroll next-page href
therefore became ?sort=added_on with no order=, which parseSort decoded as
Order="". Because service.List only filled the default direction when
len(Terms)==0, the page-2 query ran the DESC cursor predicate against an ASC
first page, skipping rows — the books list silently rendered only 3 of 4
books (visible in e2e: books_select_infinite_scroll_test "page 2" asserts
>=4 checkboxes and timed out at 3).

Fix:
- buildSortParam always emits "field:dir" so direction survives the URL
  round-trip; parseSort routes "field:dir" through parseMultiSort which
  captures the explicit direction. Empty-Order terms fall back to the
  field's default direction so the token is always valid (an empty dir
  would 400 on re-parse).
- service.List normalises any remaining empty-Order terms (belt-and-suspenders).
- Regression tests: buildSortParam field:dir format incl. empty-Order case;
  service List page-2 cursor preserves ASC direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

Security Review — PR #481 (bd-bookshelf-oxau, 8e6d788b)

Multi-field sort builder + author-sort writer + infinite-scroll cursor fix.


Findings

No blockers or majors found. Two minors noted below.


[MINOR] internal/books/sort_prefs_handler.go:36–43 — double validation of sort terms at handler vs service layer

SaveSortPrefsHandler re-validates every term against validSortKeys and asc/desc before calling save (which is SaveSortPrefs). SaveSortPrefs then re-validates the same rules (lines 74–89 of sort_prefs.go). The duplication is harmless from a security standpoint (belt-and-suspenders on the allowlist check is not wrong), but creates a maintenance hazard: if the allowlist ever gains a new key, the handler check would reject it while the service would accept it, creating a silent discrepancy. The handler-level check should be removed and the service layer trusted as the single validation point. The handler need only decode the JSON body and delegate.


[MINOR] internal/books/store.go (buildMultiCursorPredicate, ~line 1540) — NULL-safe equality in prefix branches uses plain = for nullable columns

In the prefix equality branches (i.e., all terms before the final comparison), the code uses col = ? even for nullable columns (e.g., published_date, series_name, page_count, rating_effective). A cursor value derived from a NULL-valued nullable sort column is encoded as "" → decoded back to nil by decodeCursorValForKey. When nil is bound to col = ?, MySQL evaluates it as col = NULL which is never TRUE, silently dropping all rows that tie on that prefix position with a NULL sort value. This is a correctness issue at the cursor boundary but not a security issue — it does not expose cross-user data or allow injection. The fix is to use col IS NULL when the cursor value is nil. Filing as MINOR because it affects pagination completeness, not security.


Security audit results per tasking

  1. Multi-user authorization (PUT /books/sort-prefs IDOR): CLEAN. SaveSortPrefsHandler receives userID exclusively from userIDFromRequest(r) (session-derived, never request body/param). The UpsertUserSetting SQL is parameterized with (user_id, setting_key, value) from dbsqlc.UpsertUserSettingParams where UserID is set from the session. GetSortPrefs similarly scopes GetUserSettingParams.UserID from the caller-supplied userID. No IDOR vector.

  2. Input validation on sort terms before persistence: CLEAN. savePrefsHandler validates each SortTerm.Key against validSortKeys (closed allowlist map) and SortTerm.Order against literal "asc"/"desc" before the value ever reaches the DB. SaveSortPrefs repeats the same checks. Neither raw key strings nor raw order strings can reach ORDER BY or storage without passing the allowlist.

  3. SQL injection in dynamic ORDER BY / cursor builder: CLEAN. buildMultiSortOrderBy and buildMultiCursorPredicate look up every column name through sortKeyColumn[t.Key] — a map[SortKey]string keyed by validated SortKey values that originate from the validSortKeys allowlist. No user-supplied string is ever interpolated directly into the SQL string; only map-looked-up column expressions and ? placeholders are used.

  4. Author-sort writer injection via author names: CLEAN. RefreshSortAuthorName is a sqlc-generated query. The WHERE bm.book_id = ? bind is parameterized. The SET bm.sort_author_name = a.name value comes from a JOIN to the author table — no user-supplied string is interpolated.

  5. Cursor tampering: CLEAN. The cursor is base64-decoded JSON. Malformed or unrecognized cursor field values go through decodeCursorValForKey which returns middleware.ErrValidation on type mismatch, surfaced as HTTP 400 before the query runs. Library scoping via UserLibraryIDs is applied independently of cursor contents in both the single-field and multi-field query paths — a manipulated cursor cannot bypass the b.library_id IN (...) or 1=0 predicates.


REVIEW VERDICT: 0 blocker, 0 major, 2 minor

## Security Review — PR #481 (bd-bookshelf-oxau, 8e6d788b) Multi-field sort builder + author-sort writer + infinite-scroll cursor fix. --- ### Findings No blockers or majors found. Two minors noted below. --- **[MINOR] internal/books/sort_prefs_handler.go:36–43 — double validation of sort terms at handler vs service layer** `SaveSortPrefsHandler` re-validates every term against `validSortKeys` and `asc/desc` before calling `save` (which is `SaveSortPrefs`). `SaveSortPrefs` then re-validates the same rules (lines 74–89 of `sort_prefs.go`). The duplication is harmless from a security standpoint (belt-and-suspenders on the allowlist check is not wrong), but creates a maintenance hazard: if the allowlist ever gains a new key, the handler check would reject it while the service would accept it, creating a silent discrepancy. The handler-level check should be removed and the service layer trusted as the single validation point. The handler need only decode the JSON body and delegate. --- **[MINOR] internal/books/store.go (buildMultiCursorPredicate, ~line 1540) — NULL-safe equality in prefix branches uses plain `=` for nullable columns** In the prefix equality branches (i.e., all terms before the final comparison), the code uses `col = ?` even for nullable columns (e.g., `published_date`, `series_name`, `page_count`, `rating_effective`). A cursor value derived from a NULL-valued nullable sort column is encoded as `""` → decoded back to `nil` by `decodeCursorValForKey`. When `nil` is bound to `col = ?`, MySQL evaluates it as `col = NULL` which is never TRUE, silently dropping all rows that tie on that prefix position with a NULL sort value. This is a correctness issue at the cursor boundary but not a security issue — it does not expose cross-user data or allow injection. The fix is to use `col IS NULL` when the cursor value is nil. Filing as MINOR because it affects pagination completeness, not security. --- ### Security audit results per tasking 1. **Multi-user authorization (PUT /books/sort-prefs IDOR):** CLEAN. `SaveSortPrefsHandler` receives `userID` exclusively from `userIDFromRequest(r)` (session-derived, never request body/param). The `UpsertUserSetting` SQL is parameterized with `(user_id, setting_key, value)` from `dbsqlc.UpsertUserSettingParams` where `UserID` is set from the session. `GetSortPrefs` similarly scopes `GetUserSettingParams.UserID` from the caller-supplied `userID`. No IDOR vector. 2. **Input validation on sort terms before persistence:** CLEAN. `savePrefsHandler` validates each `SortTerm.Key` against `validSortKeys` (closed allowlist map) and `SortTerm.Order` against literal `"asc"/"desc"` before the value ever reaches the DB. `SaveSortPrefs` repeats the same checks. Neither raw key strings nor raw order strings can reach `ORDER BY` or storage without passing the allowlist. 3. **SQL injection in dynamic ORDER BY / cursor builder:** CLEAN. `buildMultiSortOrderBy` and `buildMultiCursorPredicate` look up every column name through `sortKeyColumn[t.Key]` — a `map[SortKey]string` keyed by validated `SortKey` values that originate from the `validSortKeys` allowlist. No user-supplied string is ever interpolated directly into the SQL string; only map-looked-up column expressions and `?` placeholders are used. 4. **Author-sort writer injection via author names:** CLEAN. `RefreshSortAuthorName` is a sqlc-generated query. The `WHERE bm.book_id = ?` bind is parameterized. The `SET bm.sort_author_name = a.name` value comes from a JOIN to the `author` table — no user-supplied string is interpolated. 5. **Cursor tampering:** CLEAN. The cursor is base64-decoded JSON. Malformed or unrecognized cursor field values go through `decodeCursorValForKey` which returns `middleware.ErrValidation` on type mismatch, surfaced as HTTP 400 before the query runs. Library scoping via `UserLibraryIDs` is applied independently of cursor contents in both the single-field and multi-field query paths — a manipulated cursor cannot bypass the `b.library_id IN (...)` or `1=0` predicates. --- REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Author
Owner

placeholder

placeholder
Author
Owner

CODE REVIEW: NOT APPROVED

Phase 0: DEMO Verification

No structured DEMO block was present in the bead. Proceeding to spec review.


Phase 1: Spec Compliance

All three spec areas are present and coherent: author-sort writer (4 write sites), cursor regression fix (buildSortParam now always emits field:dir), and sort prefs (PUT /books/sort-prefs). One spec-level correctness bug found in a code path not updated by this PR.


Phase 2: Code Quality Findings


[MAJOR] internal/books/handler.go:784 — RenderMagicShelf omits SortParam and BaseURL from listPageData

RenderMagicShelf builds its own listPageData struct directly (lines 784-795) and does NOT set SortParam or BaseURL. Both fields were introduced in this PR and are required for:

  1. The infinite-scroll next-href ({{if .SortParam}}&sort={{.SortParam}}{{end}} in books_index.html line 455) — without this the next page URL drops the sort, reverting to the default on page 2+ of any magic shelf.
  2. The sort-builder's data-sort-builder-base-url-value — without this the sort builder always navigates to /books instead of the magic shelf URL.

renderBooksHTML (called by RenderShelf) correctly sets both fields at lines 700-701. RenderMagicShelf is a separate code path that was not updated. Fix: add SortParam: buildSortParam(sort) and BaseURL: r.URL.Path to the RenderMagicShelf data struct (alongside the existing SortTermsDisplay and SortFieldOptions that were added correctly).


[MAJOR] internal/books/store.go:1456 — id tiebreaker direction uses first term only; no test for mixed-direction multi-sort cursor round-trip

buildMultiSortOrderBy and buildMultiCursorPredicate both derive the id tiebreaker direction from terms[0].Order (lines 1456-1460 and 1539-1541). The ORDER BY and cursor predicate are internally consistent. However, no test exercises a cursor round-trip for a mixed-direction sort (e.g. title:asc,rating:desc). Given that this is new code implementing a non-trivial correctness invariant (total-order keyset pagination), the absence of a mixed-direction test is a gap that could hide a flake-inducing non-total-order bug. Per review-standard.md: "Non-deterministic ORDER BY / sort whose sort keys are not a total order... is at least [MAJOR]." Add a unit test for buildMultiCursorPredicate and buildMultiSortOrderBy with terms=[{title,ASC},{rating,DESC}] asserting the final id branch is bm.book_id > ? and ORDER BY emits bm.title ASC, bm.rating_effective DESC, bm.book_id ASC.


[MINOR] internal/library/scan/batch_seed.go:997 — repeatStrings duplicates repeatStr from seeder package

internal/seeder/seeder.go:545 defines repeatStr with identical semantics. Not a blocker since they are separate packages.


[MINOR] internal/books/multi_sort_test.go:1955 — test BeforeEach uses duplicate added_on keys, misleading context description

The SortTerms at lines 1958-1959 contains {added_on, DESC} and {added_on, ASC} — a duplicate key. The store layer does not validate for duplicates (only the HTTP parse layer does), so the test exercises degenerate input while the description says "added_on+title". Misleading but not a production safety issue.


[MINOR] internal/books/sort_prefs.go:319 — json.Marshal error suppressed with nolint without explanation

json.Marshal(valid) where valid is []SortTerm (all string-underlying fields) cannot fail in practice, but the //nolint:errcheck needs a brief inline comment explaining why. Example: // []SortTerm has only string fields; Marshal cannot fail.


REVIEW VERDICT: 2 major, 3 minor

The RenderMagicShelf missing SortParam/BaseURL ([MAJOR] #1) is a confirmed regression: infinite scroll on magic shelf pages drops the sort on page 2+, and the sort builder Apply always navigates to /books instead of the shelf URL. The mixed-direction cursor test gap ([MAJOR] #2) is a correctness-of-CI concern per the flake-prevention rules. Both must be resolved before merge.

## CODE REVIEW: NOT APPROVED ### Phase 0: DEMO Verification No structured DEMO block was present in the bead. Proceeding to spec review. --- ### Phase 1: Spec Compliance All three spec areas are present and coherent: author-sort writer (4 write sites), cursor regression fix (buildSortParam now always emits field:dir), and sort prefs (PUT /books/sort-prefs). One spec-level correctness bug found in a code path not updated by this PR. --- ### Phase 2: Code Quality Findings --- [MAJOR] internal/books/handler.go:784 — RenderMagicShelf omits SortParam and BaseURL from listPageData `RenderMagicShelf` builds its own `listPageData` struct directly (lines 784-795) and does NOT set `SortParam` or `BaseURL`. Both fields were introduced in this PR and are required for: 1. The infinite-scroll `next-href` (`{{if .SortParam}}&sort={{.SortParam}}{{end}}` in books_index.html line 455) — without this the next page URL drops the sort, reverting to the default on page 2+ of any magic shelf. 2. The sort-builder's `data-sort-builder-base-url-value` — without this the sort builder always navigates to `/books` instead of the magic shelf URL. `renderBooksHTML` (called by `RenderShelf`) correctly sets both fields at lines 700-701. `RenderMagicShelf` is a separate code path that was not updated. Fix: add `SortParam: buildSortParam(sort)` and `BaseURL: r.URL.Path` to the `RenderMagicShelf` data struct (alongside the existing `SortTermsDisplay` and `SortFieldOptions` that were added correctly). --- [MAJOR] internal/books/store.go:1456 — id tiebreaker direction uses first term only; no test for mixed-direction multi-sort cursor round-trip `buildMultiSortOrderBy` and `buildMultiCursorPredicate` both derive the id tiebreaker direction from `terms[0].Order` (lines 1456-1460 and 1539-1541). The ORDER BY and cursor predicate are internally consistent. However, no test exercises a cursor round-trip for a mixed-direction sort (e.g. `title:asc,rating:desc`). Given that this is new code implementing a non-trivial correctness invariant (total-order keyset pagination), the absence of a mixed-direction test is a gap that could hide a flake-inducing non-total-order bug. Per review-standard.md: "Non-deterministic ORDER BY / sort whose sort keys are not a total order... is at least [MAJOR]." Add a unit test for `buildMultiCursorPredicate` and `buildMultiSortOrderBy` with `terms=[{title,ASC},{rating,DESC}]` asserting the final id branch is `bm.book_id > ?` and ORDER BY emits `bm.title ASC, bm.rating_effective DESC, bm.book_id ASC`. --- [MINOR] internal/library/scan/batch_seed.go:997 — `repeatStrings` duplicates `repeatStr` from seeder package `internal/seeder/seeder.go:545` defines `repeatStr` with identical semantics. Not a blocker since they are separate packages. --- [MINOR] internal/books/multi_sort_test.go:1955 — test BeforeEach uses duplicate added_on keys, misleading context description The `SortTerms` at lines 1958-1959 contains `{added_on, DESC}` and `{added_on, ASC}` — a duplicate key. The store layer does not validate for duplicates (only the HTTP parse layer does), so the test exercises degenerate input while the description says "added_on+title". Misleading but not a production safety issue. --- [MINOR] internal/books/sort_prefs.go:319 — json.Marshal error suppressed with nolint without explanation `json.Marshal(valid)` where `valid` is `[]SortTerm` (all string-underlying fields) cannot fail in practice, but the `//nolint:errcheck` needs a brief inline comment explaining why. Example: `// []SortTerm has only string fields; Marshal cannot fail`. --- **REVIEW VERDICT: 2 major, 3 minor** The `RenderMagicShelf` missing `SortParam`/`BaseURL` ([MAJOR] #1) is a confirmed regression: infinite scroll on magic shelf pages drops the sort on page 2+, and the sort builder Apply always navigates to `/books` instead of the shelf URL. The mixed-direction cursor test gap ([MAJOR] #2) is a correctness-of-CI concern per the flake-prevention rules. Both must be resolved before merge.
fix(books): apply review fixes for PR #481 (bookshelf-ovnu)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 20s
/ Lint (pull_request) Successful in 1m37s
/ Test (pull_request) Successful in 2m15s
/ Integration (pull_request) Successful in 3m36s
/ E2E API (pull_request) Successful in 3m47s
/ E2E Browser (pull_request) Successful in 3m53s
d69cbf1795
- [MAJOR #1] RenderMagicShelf: populate SortParam and BaseURL so
  infinite-scroll next-href URLs preserve the active sort term
- [MAJOR #2] Add mixed-direction unit tests for buildMultiSortOrderBy
  and buildMultiCursorPredicate (title:ASC, rating:DESC case)
- [MINOR/correctness #3] buildMultiCursorPredicate: fix NULL equality
  prefix (emit col IS NULL not col = NULL) and skip unsatisfiable
  comparison branches when cursor value is nil; add NULL-cursor test
- [MINOR #4] Remove duplicate handler-level sort validation; service
  is the single validation point; update tests accordingly
- [MINOR #5] batch_seed.go: replace repeatStrings helper with
  slices.Repeat to eliminate duplication with internal/seeder
- [MINOR #6] Fix duplicate SortAddedOn in test data for
  "added_on+title" context (second term must be SortTitle)
- [MINOR #7] Add comment explaining why json.Marshal cannot fail on
  []SortTerm (no channels/funcs/circular refs)

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

CODE REVIEW (fix verification): APPROVED — all prior findings resolved, fix introduces no new bugs.

Phase 0: DEMO — N/A (no DEMO block; this is a focused fix-verification review)

Finding 1 [MAJOR — RESOLVED]

internal/books/handler.go:794-795RenderMagicShelf now sets SortParam: buildSortParam(sort) and BaseURL: r.URL.Path, matching the identical lines in RenderShelf at line 700-701. The sort parameter is in scope (function signature line 757). Fix is correct.

Coverage: handler_test.go adds a Describe("RenderMagicShelf — SortParam and BaseURL are populated") block with three It blocks asserting no error, sort=title:asc in body, and base=/magic-shelves/7 in body. Non-vacuous.

Finding 2 [MAJOR — RESOLVED]

internal/books/multi_sort_test.go adds two new Context blocks for mixed-direction terms (title ASC, rating DESC):

  1. ORDER BY assertion (multi_sort_test.go ~line 83): asserts bm.title ASC, bm.rating_effective DESC, and bm.book_id ASC (tiebreaker follows first-term direction). Exercises genuine mixed direction.
  2. Cursor predicate assertion (~line 234): asserts bm.title > ? (ASC), bm.rating_effective is present (DESC branch), bm.book_id > ? (tiebreaker ASC), and negatively asserts bm.book_id < ? is absent. Both the ORDER BY string and cursor predicate are asserted. Not vacuous.

Also fixes the pre-existing test at line 820 that had a duplicate SortAddedOn term instead of SortTitle — test now matches its description.

Finding 3 [NULL-cursor logic — RESOLVED, keyset predicate is correct]

internal/books/store.go buildMultiCursorPredicate (~line 1495):

Equality-prefix branch (line 1498-1502): When nullableCol[t.Key] && v == nil, emits col IS NULL and does NOT append to branchArgs — arg list stays aligned. When v != nil, falls through to col = ? + appends v. Correct.

Comparison branch (line 1514-1519): When nullableCol[t.Key] && v == nil, continue skips the entire OR branch. This is correct: MySQL NULLs sort first in ASC and last in DESC (plain col ASC/DESC — no ISNULL wrapper). For a cursor row with NULL in position prefixLen-1, there are no rows "after" it in that column's sort position (NULLs are at the boundary), so the branch is correctly dropped rather than emitting a nil bind param.

Non-NULL nullable comparison (line 1529-1537): DESC emits (col IS NULL OR col < ?) to capture NULLs that sort after non-NULLs in DESC; ASC emits plain col > ?. Consistent with the single-field path (store.go line 1304-1306) which uses the same IS NULL OR col < ? pattern for rating DESC.

Arg alignment trace for terms=[{title,ASC},{rating,DESC}], cursorVals=["Dune", nil], cursorID=99:

  • prefixLen=1: (bm.title > ?) → args: ["Dune"]
  • prefixLen=2: comparison hits nullableCol[rating] && v==nilcontinue (branch dropped)
  • prefixLen=3: (bm.title = ? AND bm.rating_effective IS NULL AND bm.book_id > ?) → args: ["Dune", 99]
  • Final: args = ["Dune", "Dune", 99] — no nil values, placeholders align

Test at multi_sort_test.go ~line 270 asserts IS NULL present, args contains no nil element, and verifies all three non-nil args are bound. Catches both the SQL text and arg-alignment correctness.

One [MINOR] introduced by the fix:

internal/books/store.go:1531 — The comment reads "We treat NULLs as logically last in both directions via ISNULL trick in ORDER BY." However, buildMultiSortOrderBy (line 1443) generates plain col ASC/DESC with no ISNULL() wrapper. The code behavior is correct (consistent with MySQL's default NULL ordering: first in ASC, last in DESC, which is what the cursor logic accounts for), but the comment is factually wrong about an "ISNULL trick" being applied to ORDER BY. A reader will look for an ISNULL() call that doesn't exist.

Suggested fix: replace "via ISNULL trick in ORDER BY" with "relying on MySQL's default NULL ordering (NULLs first in ASC, last in DESC)".

Minors 3-5 [RESOLVED]

  • Handler-level double-validation removed (sort_prefs_handler.go); service-level validation remains. Tests updated to stub saveErr with wrapped middleware.ErrValidation so 400 responses are still asserted without the deleted handler code.
  • repeatStrings deleted; replaced with stdlib slices.Repeat (batch_seed.go).
  • Test description corrected at multi_sort_test.go:820.
  • sort_prefs.go nolint comment added with explanation.

REVIEW VERDICT: 0 blocker, 0 major, 1 minor

CODE REVIEW (fix verification): APPROVED — all prior findings resolved, fix introduces no new bugs. ## Phase 0: DEMO — N/A (no DEMO block; this is a focused fix-verification review) ## Finding 1 [MAJOR — RESOLVED] `internal/books/handler.go:794-795` — `RenderMagicShelf` now sets `SortParam: buildSortParam(sort)` and `BaseURL: r.URL.Path`, matching the identical lines in `RenderShelf` at line 700-701. The `sort` parameter is in scope (function signature line 757). Fix is correct. Coverage: `handler_test.go` adds a `Describe("RenderMagicShelf — SortParam and BaseURL are populated")` block with three `It` blocks asserting no error, `sort=title:asc` in body, and `base=/magic-shelves/7` in body. Non-vacuous. ## Finding 2 [MAJOR — RESOLVED] `internal/books/multi_sort_test.go` adds two new `Context` blocks for mixed-direction terms (title ASC, rating DESC): 1. **ORDER BY assertion** (`multi_sort_test.go` ~line 83): asserts `bm.title ASC`, `bm.rating_effective DESC`, and `bm.book_id ASC` (tiebreaker follows first-term direction). Exercises genuine mixed direction. 2. **Cursor predicate assertion** (~line 234): asserts `bm.title > ?` (ASC), `bm.rating_effective` is present (DESC branch), `bm.book_id > ?` (tiebreaker ASC), and negatively asserts `bm.book_id < ?` is absent. Both the ORDER BY string and cursor predicate are asserted. Not vacuous. Also fixes the pre-existing test at line 820 that had a duplicate `SortAddedOn` term instead of `SortTitle` — test now matches its description. ## Finding 3 [NULL-cursor logic — RESOLVED, keyset predicate is correct] `internal/books/store.go` `buildMultiCursorPredicate` (~line 1495): **Equality-prefix branch (line 1498-1502):** When `nullableCol[t.Key] && v == nil`, emits `col IS NULL` and does NOT append to `branchArgs` — arg list stays aligned. When `v != nil`, falls through to `col = ?` + appends `v`. Correct. **Comparison branch (line 1514-1519):** When `nullableCol[t.Key] && v == nil`, `continue` skips the entire OR branch. This is correct: MySQL NULLs sort first in ASC and last in DESC (plain `col ASC/DESC` — no ISNULL wrapper). For a cursor row with NULL in position `prefixLen-1`, there are no rows "after" it in that column's sort position (NULLs are at the boundary), so the branch is correctly dropped rather than emitting a nil bind param. **Non-NULL nullable comparison (line 1529-1537):** DESC emits `(col IS NULL OR col < ?)` to capture NULLs that sort after non-NULLs in DESC; ASC emits plain `col > ?`. Consistent with the single-field path (`store.go` line 1304-1306) which uses the same `IS NULL OR col < ?` pattern for rating DESC. **Arg alignment trace** for `terms=[{title,ASC},{rating,DESC}]`, `cursorVals=["Dune", nil]`, `cursorID=99`: - prefixLen=1: `(bm.title > ?)` → args: `["Dune"]` - prefixLen=2: comparison hits `nullableCol[rating] && v==nil` → `continue` (branch dropped) - prefixLen=3: `(bm.title = ? AND bm.rating_effective IS NULL AND bm.book_id > ?)` → args: `["Dune", 99]` - Final: `args = ["Dune", "Dune", 99]` — no nil values, placeholders align Test at `multi_sort_test.go` ~line 270 asserts `IS NULL` present, `args` contains no nil element, and verifies all three non-nil args are bound. Catches both the SQL text and arg-alignment correctness. **One [MINOR] introduced by the fix:** `internal/books/store.go:1531` — The comment reads "We treat NULLs as logically last in both directions via ISNULL trick in ORDER BY." However, `buildMultiSortOrderBy` (line 1443) generates plain `col ASC/DESC` with no `ISNULL()` wrapper. The code behavior is correct (consistent with MySQL's default NULL ordering: first in ASC, last in DESC, which is what the cursor logic accounts for), but the comment is factually wrong about an "ISNULL trick" being applied to ORDER BY. A reader will look for an `ISNULL()` call that doesn't exist. Suggested fix: replace "via ISNULL trick in ORDER BY" with "relying on MySQL's default NULL ordering (NULLs first in ASC, last in DESC)". ## Minors 3-5 [RESOLVED] - Handler-level double-validation removed (`sort_prefs_handler.go`); service-level validation remains. Tests updated to stub `saveErr` with wrapped `middleware.ErrValidation` so 400 responses are still asserted without the deleted handler code. - `repeatStrings` deleted; replaced with stdlib `slices.Repeat` (`batch_seed.go`). - Test description corrected at `multi_sort_test.go:820`. - `sort_prefs.go` nolint comment added with explanation. --- REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Merge origin/main into bd-bookshelf-oxau
All checks were successful
/ JS Unit Tests (pull_request) Successful in 27s
/ Lint (pull_request) Successful in 3m15s
/ Test (pull_request) Successful in 4m10s
/ E2E Browser (pull_request) Successful in 4m36s
/ Integration (pull_request) Successful in 5m52s
/ E2E API (pull_request) Successful in 6m3s
8c02353bfc
Conflicts resolved:
- static/js/app.js: combined sort-builder (oxau) + library-multiselect (main/#471)
- templates/layouts/base.html: combined sort_builder_controller.js (oxau) + library_multiselect_controller.js (main/#471)

Both controllers are independent additions; both lines kept.
zombor merged commit 45cd77e6d8 into main 2026-06-10 19:43:15 +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!481
No description provided.