Albums endpoint, and the browser's album filter and album-position sort #141

Closed
opened 2026-08-04 06:20:46 +00:00 by claude-bot · 2 comments

Severity: MEDIUM

Split out of #105, and the second half of what #94 deferred. Blocked on #140 — there is no point building a control until something creates the rows it would show.

What #94 left behind

#94 built the browser's filter and sort controls to docs/circa-ui-spec.md §8.3 and deliberately omitted two of them, because album_id is NULL in every deployment. The reasoning and measurements are on #94's thread; the short version is that a dropdown which can only ever say "no albums" is a control that cannot answer.

Scope

GET /api/albums — a listing scoped to the active collection, so the browser can populate a filter. This is a new route, and it will trip two suites by design:

  • test_api_contract.py's ROUTE_ACCESS fails until the route declares who may reach it.
  • test_concurrency_protocol.py's TestNoWriteEscapesTheProtocol sweeps the write surface; a read-only route needs no review_version but the matrix still has to know about it.

Both failures are the tests working, not obstacles.

The browser's album filter — a select populated from that endpoint, wired into the existing PhotoQuery shape, which already carries album_id and already sends it. The chips and clear-all in PhotoBrowserPage derive from one list, so an album chip should fall out of adding it there.

Album-position sortPhotoSort currently has ingest and date. Adding album needs a keyset, and #94 established what that costs:

Sorting on a nullable column breaks keyset pagination — a row-value cursor comparison against NULL matches nothing and the listing terminates at that page rather than skipping rows.

Both album_id and sequence_in_album are nullable, so album-position sort needs the same treatment date_sort got in migration 012: a VIRTUAL generated column carrying a COALESCE(…) sentinel, indexed as a column. An index on the expression is not enough — SQLite will not seek on a row-value whose leading term is an expression, and the deep page degrades to a scan linear in the archive (measured: 0.018 ms against 2.674 ms at 50,000 rows).

test_pagination.py::TestPaginationHoldsForEverySortOrder is parametrized over PhotoSort itself, so a new member with a broken keyset fails the suite automatically. That is deliberate and is the safety net for this work.

Done when

  • GET /api/albums lists the active collection's albums, with its access level declared
  • The browser can filter by album, and the filter appears in the chips and clears with the rest
  • Sorting by album position pages correctly, including for photographs with no album or no sequence
  • The query plans for the new sort are asserted, as migration 012's are

References

  • #94's thread — the deferral, the NULL-keyset finding, and the plan measurements
  • backend/alembic/versions/012_photo_date_sort.py — the pattern to follow
  • backend/tests/test_pagination.py, test_query_plans.py
  • Split from #105. Depends on #140.
## Severity: MEDIUM Split out of #105, and the second half of what #94 deferred. **Blocked on #140** — there is no point building a control until something creates the rows it would show. ## What #94 left behind #94 built the browser's filter and sort controls to `docs/circa-ui-spec.md` §8.3 and deliberately omitted two of them, because `album_id` is NULL in every deployment. The reasoning and measurements are on #94's thread; the short version is that a dropdown which can only ever say "no albums" is a control that cannot answer. ## Scope **`GET /api/albums`** — a listing scoped to the active collection, so the browser can populate a filter. This is a new route, and it will trip two suites by design: - `test_api_contract.py`'s `ROUTE_ACCESS` fails until the route declares who may reach it. - `test_concurrency_protocol.py`'s `TestNoWriteEscapesTheProtocol` sweeps the write surface; a read-only route needs no `review_version` but the matrix still has to know about it. Both failures are the tests working, not obstacles. **The browser's album filter** — a select populated from that endpoint, wired into the existing `PhotoQuery` shape, which already carries `album_id` and already sends it. The chips and clear-all in `PhotoBrowserPage` derive from one list, so an album chip should fall out of adding it there. **Album-position sort** — `PhotoSort` currently has `ingest` and `date`. Adding `album` needs a keyset, and #94 established what that costs: > Sorting on a nullable column breaks keyset pagination — a row-value cursor comparison against NULL matches nothing and the listing **terminates** at that page rather than skipping rows. Both `album_id` and `sequence_in_album` are nullable, so album-position sort needs the same treatment `date_sort` got in migration 012: a **VIRTUAL generated column** carrying a `COALESCE(…)` sentinel, indexed as a column. An index on the *expression* is not enough — SQLite will not seek on a row-value whose leading term is an expression, and the deep page degrades to a scan linear in the archive (measured: 0.018 ms against 2.674 ms at 50,000 rows). `test_pagination.py::TestPaginationHoldsForEverySortOrder` is parametrized over `PhotoSort` itself, so a new member with a broken keyset **fails the suite automatically**. That is deliberate and is the safety net for this work. ## Done when - [ ] `GET /api/albums` lists the active collection's albums, with its access level declared - [ ] The browser can filter by album, and the filter appears in the chips and clears with the rest - [ ] Sorting by album position pages correctly, including for photographs with no album or no sequence - [ ] The query plans for the new sort are asserted, as migration 012's are ## References - #94's thread — the deferral, the NULL-keyset finding, and the plan measurements - `backend/alembic/versions/012_photo_date_sort.py` — the pattern to follow - `backend/tests/test_pagination.py`, `test_query_plans.py` - Split from #105. Depends on #140.
Author

Measured while building #140: ix_photo_collection_album does not work, and this issue has to fix it

Not a new regression — it has never been exercised, because until #140 no row had an album_id. Now that they will, here is what the album filter actually costs. 50,000 rows, 200 albums, 250 photos each, 4% excluded, best of 7, against the SQL list_photos really emits (captured from the repository rather than retyped).

Without ANALYZE — which is every real deployment, since nothing in app/ or alembic/ ever runs one:

as shipped   SEARCH photo USING INDEX ix_photo_collection_created (collection_id=?)
             page 1  4.117 ms      deep page  4.893 ms        <- album index unused
widened      SEARCH photo USING INDEX ix_photo_collection_album (collection_id=? AND album_id=?)
             page 1  0.196 ms      deep page  0.021 ms

~230x on the deep page. With ANALYZE present the planner does reach the index but still sorts — … | USE TEMP B-TREE FOR ORDER BY, 0.311 / 0.104 ms.

Cause is the one this repository has now hit four times. ix_photo_collection_album is (collection_id, album_id) and does not carry the (created_at, id) sort key, so using it costs a temporary B-tree and the planner prefers the index that gives the order for free. That is migration 010's finding (#88), migration 012's (ix_photo_collection_status_datesort, #94), and #106's excluded-view index — the same lesson each time: the index has to carry the order the query asks for, not just the predicate it filters on. ix_photo_collection_album is the one listing index nobody ever widened, because nothing could exercise it.

What this issue therefore needs

  1. Widen it to (collection_id, album_id, created_at, id), and — since #106partial on WHERE deleted_at IS NULL, or it will not be used at all. #106 measured what a non-partial listing index costs once a partial sibling exists: SQLite mis-chose it and every live listing went 0.06 ms → ~250 ms.

  2. Album-position sort (sequence_in_album) is the PhotoSort member this issue adds, and it is nullable — so it needs the migration-012 pattern: a VIRTUAL generated column with a COALESCE sentinel, indexed as a column, not an index on the expression. SQLite will not seek on a row-value comparison whose leading term is an expression; migration 012 measured 2.674 ms → 0.018 ms for exactly that. test_pagination.py is parametrized over PhotoSort, so a new member with a broken keyset fails the suite by itself.

  3. test_query_plans.py::test_the_album_filter_is_not_left_scanning passes today and will keep passing, because the fallback is SEARCH … ix_photo_collection_created, not SCAN photo — it only forbids a scan. A companion assertion in the shape of test_the_status_paired_index_is_what_answers_a_filtered_date_sort is what would catch this, and it belongs with the migration rather than before it, since it would be red today.

Recorded here rather than fixed under #140: it is a schema change, and #140's remit was linkage.

## Measured while building #140: `ix_photo_collection_album` does not work, and this issue has to fix it Not a new regression — it has never been exercised, because until #140 no row had an `album_id`. Now that they will, here is what the album filter actually costs. 50,000 rows, 200 albums, 250 photos each, 4% excluded, best of 7, against the SQL `list_photos` really emits (captured from the repository rather than retyped). **Without `ANALYZE` — which is every real deployment, since nothing in `app/` or `alembic/` ever runs one:** ``` as shipped SEARCH photo USING INDEX ix_photo_collection_created (collection_id=?) page 1 4.117 ms deep page 4.893 ms <- album index unused widened SEARCH photo USING INDEX ix_photo_collection_album (collection_id=? AND album_id=?) page 1 0.196 ms deep page 0.021 ms ``` ~230x on the deep page. With `ANALYZE` present the planner does reach the index but still sorts — `… | USE TEMP B-TREE FOR ORDER BY`, 0.311 / 0.104 ms. **Cause is the one this repository has now hit four times.** `ix_photo_collection_album` is `(collection_id, album_id)` and does not carry the `(created_at, id)` sort key, so using it costs a temporary B-tree and the planner prefers the index that gives the order for free. That is migration 010's finding (#88), migration 012's (`ix_photo_collection_status_datesort`, #94), and #106's excluded-view index — the same lesson each time: **the index has to carry the order the query asks for, not just the predicate it filters on.** `ix_photo_collection_album` is the one listing index nobody ever widened, because nothing could exercise it. ### What this issue therefore needs 1. **Widen it to `(collection_id, album_id, created_at, id)`**, and — since #106 — **partial on `WHERE deleted_at IS NULL`**, or it will not be used at all. #106 measured what a non-partial listing index costs once a partial sibling exists: SQLite mis-chose it and every live listing went 0.06 ms → ~250 ms. 2. **Album-position sort** (`sequence_in_album`) is the `PhotoSort` member this issue adds, and it is nullable — so it needs the **migration-012 pattern**: a VIRTUAL generated column with a COALESCE sentinel, indexed as a column, *not* an index on the expression. SQLite will not seek on a row-value comparison whose leading term is an expression; migration 012 measured 2.674 ms → 0.018 ms for exactly that. `test_pagination.py` is parametrized over `PhotoSort`, so a new member with a broken keyset fails the suite by itself. 3. **`test_query_plans.py::test_the_album_filter_is_not_left_scanning` passes today and will keep passing**, because the fallback is `SEARCH … ix_photo_collection_created`, not `SCAN photo` — it only forbids a scan. A companion assertion in the shape of `test_the_status_paired_index_is_what_answers_a_filtered_date_sort` is what would catch this, and it belongs with the migration rather than before it, since it would be red today. Recorded here rather than fixed under #140: it is a schema change, and #140's remit was linkage.
Author

Done in 1aabafb, CI green (run 67).

  • GET /api/albums lists the active collection's albums, with its access level declared
  • The browser can filter by album, and the filter appears in the chips and clears with the rest
  • Sorting by album position pages correctly, including for photographs with no album or no sequence
  • The query plans for the new sort are asserted, as migration 012's are

The photo count is what the grid will actually show — the working set only, and for a viewer approved only. A count that promised photographs the grid then withheld would be the failure the dropdown exists to prevent. Albums with zero visible photographs are still listed at zero rather than hidden, matching /photos/stats' "report 0, don't omit". And the dropdown is absent entirely from a collection with no albums, which is #100's lesson rather than a special case.

The sort key, and the trap in it

COALESCE(album_id, '~' × 36)
|| CASE WHEN sequence_in_album IS NULL THEN '~' × 8
        ELSE printf('%08d', sequence_in_album) END

Every piece is load-bearing. ~ (0x7E) is the last printable ASCII character, so it beats any UUID under BINARY collation and unfiled photographs land at the end — the useful end, as 012 put undated ones there. Thirty-six wide because an album id is exactly 36, making the key a fixed 44 characters so one album's rows cannot interleave with another's by prefix. The sequence sentinel is eight tildes rather than '99999999' because printf('%08d', …) pads but does not truncate, so a nine-digit sequence renders nine characters and would sort before a numeric sentinel.

And it is a CASE, not the obvious COALESCE(printf(…), sentinel), because of the sharpest thing found here: printf('%08d', NULL) returns '00000000' in SQLite, not NULL. The COALESCE never fires — and it fails silently: pagination stays complete, every row remains reachable, and unsequenced photographs simply sit at position zero at the front of their album. Pinned by a schema assertion so the tidier-looking form cannot come back.

ix_photo_collection_album had never worked

Reported on this issue before the work started, and confirmed: (collection_id, album_id) carries no sort key, so the planner preferred ix_photo_collection_created and an album filter cost 11.788 ms against 0.064 ms widened. Nothing had noticed because until #140 no row had an album_id to exercise it.

The status-paired index is included on measurement rather than on 012's precedent, and the difference is worth recording: 012 found that adding only the unfiltered index made disputed 2.6× slower than having no index at all, and that regression does not recur here, because the pre-existing status index did not carry album order either. The pair is justified instead by 6.3× and by the plan shape — without it SQLite reads the entire collection in album order filtering for status.

Three things the tests said

  • The parametrized sweep did not catch a NULL sentinel at limit=4 — only at 1, 2 and 3. A single limit would have shipped it.
  • That sweep stayed entirely green against the printf/COALESCE trap, because that bug misplaces rows without losing any. Completeness and placement are different properties, which is why a dedicated placement class exists rather than relying on the parametrized net.
  • Un-widening the album index did not trip test_the_album_filter_is_not_left_scanning, exactly as predicted above: it forbids a SCAN, and the fallback is a SEARCH. The new companion assertion is what catches it.

One cost recorded and deliberately not paid

An album filter combined with album-position sort walks the album-sorted index, so page one of an album late in id order costs 15.401 ms against 0.062 ms with a fourth index on (collection_id, album_id, album_sort, id). Left out because deep pages are unaffected — the cursor seeks into the album's own range — the album view's default order is ingest, which the widened index answers in 0.064 ms, and it would be an eighth photo index paid on every write. That is the trade 012 declined for its filter-only shapes and #106 declined for the excluded queue in date order.

I considered overruling that and did not. It is a one-line migration, the numbers are in 015's docstring, and a deliberately loose plan test marks the spot — so if the combination turns out to be what reviewers actually do, adding it is cheap.

Backend tests 1320 → 1376, frontend 212 → 223, end-to-end 11 → 12.

Done in `1aabafb`, CI green (run 67). - [x] `GET /api/albums` lists the active collection's albums, with its access level declared - [x] The browser can filter by album, and the filter appears in the chips and clears with the rest - [x] Sorting by album position pages correctly, including for photographs with no album or no sequence - [x] The query plans for the new sort are asserted, as migration 012's are **The photo count is what the grid will actually show** — the working set only, and for a viewer approved only. A count that promised photographs the grid then withheld would be the failure the dropdown exists to prevent. Albums with zero visible photographs are still listed at zero rather than hidden, matching `/photos/stats`' "report 0, don't omit". And the dropdown is absent entirely from a collection with no albums, which is #100's lesson rather than a special case. ## The sort key, and the trap in it ```sql COALESCE(album_id, '~' × 36) || CASE WHEN sequence_in_album IS NULL THEN '~' × 8 ELSE printf('%08d', sequence_in_album) END ``` Every piece is load-bearing. `~` (0x7E) is the last printable ASCII character, so it beats any UUID under BINARY collation and unfiled photographs land at the end — the useful end, as 012 put undated ones there. Thirty-six wide because an album id is exactly 36, making the key a fixed 44 characters so one album's rows cannot interleave with another's by prefix. The sequence sentinel is eight tildes rather than `'99999999'` because **`printf('%08d', …)` pads but does not truncate**, so a nine-digit sequence renders nine characters and would sort *before* a numeric sentinel. And it is a `CASE`, not the obvious `COALESCE(printf(…), sentinel)`, because of the sharpest thing found here: **`printf('%08d', NULL)` returns `'00000000'` in SQLite, not NULL.** The `COALESCE` never fires — and it fails *silently*: pagination stays complete, every row remains reachable, and unsequenced photographs simply sit at position zero at the front of their album. Pinned by a schema assertion so the tidier-looking form cannot come back. ## `ix_photo_collection_album` had never worked Reported on this issue before the work started, and confirmed: `(collection_id, album_id)` carries no sort key, so the planner preferred `ix_photo_collection_created` and an album filter cost 11.788 ms against 0.064 ms widened. Nothing had noticed because until #140 no row had an `album_id` to exercise it. The status-paired index is included **on measurement rather than on 012's precedent**, and the difference is worth recording: 012 found that adding only the unfiltered index made `disputed` 2.6× *slower than having no index at all*, and **that regression does not recur here**, because the pre-existing status index did not carry album order either. The pair is justified instead by 6.3× and by the plan shape — without it SQLite reads the entire collection in album order filtering for status. ## Three things the tests said - The parametrized sweep **did not catch a NULL sentinel at `limit=4`** — only at 1, 2 and 3. A single limit would have shipped it. - That sweep stayed **entirely green** against the `printf`/`COALESCE` trap, because that bug misplaces rows without losing any. Completeness and placement are different properties, which is why a dedicated placement class exists rather than relying on the parametrized net. - Un-widening the album index did **not** trip `test_the_album_filter_is_not_left_scanning`, exactly as predicted above: it forbids a `SCAN`, and the fallback is a `SEARCH`. The new companion assertion is what catches it. ## One cost recorded and deliberately not paid An album filter **combined with** album-position sort walks the album-sorted index, so page one of an album late in id order costs **15.401 ms against 0.062 ms** with a fourth index on `(collection_id, album_id, album_sort, id)`. Left out because deep pages are unaffected — the cursor seeks into the album's own range — the album view's default order is ingest, which the widened index answers in 0.064 ms, and it would be an eighth `photo` index paid on every write. That is the trade 012 declined for its filter-only shapes and #106 declined for the excluded queue in date order. I considered overruling that and did not. It is a one-line migration, the numbers are in `015`'s docstring, and a deliberately loose plan test marks the spot — so if the combination turns out to be what reviewers actually do, adding it is cheap. Backend tests 1320 → 1376, frontend 212 → 223, end-to-end 11 → 12.
Sign in to join this conversation.
No description provided.