Skip to content

Keep reading history when its book is hard-deleted - #44

Merged
AshDevFr merged 4 commits into
mainfrom
feat/reading-history-survives-book-delete
Sep 27, 2026
Merged

AshDevFr merged 4 commits into
mainfrom
feat/reading-history-survives-book-delete

Conversation

@AshDevFr

@AshDevFr AshDevFr commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Reading statistics are derived entirely from reading_sessions, and read_completions is the only
record that a book was ever finished. Both cascaded on books.id, so purging deleted books or
deleting a library silently erased hours a reader really spent reading, with nothing left to rebuild
them from. Soft deletes were always safe; this is about the two paths that reach a real DELETE.

Schema: book_id becomes nullable, ON DELETE SET NULL

On both history tables. The row survives the delete and loses only its attribution. The user_id
cascade is unchanged and tested: deleting a user still removes their history.

PostgreSQL alters the column and swaps the constraint in place. SQLite cannot, so each table is
rebuilt: create, copy, check the row count before dropping the original, drop, rename, recreate every
index, foreign_key_check. It runs in one transaction the migration opens itself (sea-orm-migration
only wraps PostgreSQL), and it never touches PRAGMA foreign_keys: that step of SQLite's
procedure exists to stop dropping a parent from cascading into its children, and nothing references
either table. That is also what avoids the pragma trouble m20260508_000081 ran into.

The migration tests compare index SQL, pragma_table_info and pragma_foreign_key_list against a
database migrated to just before this change, so a lost index, column or DESC fails loudly.
Rolling back is lossy by construction: down has to delete the orphaned rows to restore NOT NULL,
and the doc comment says so.

The rebuild also adds an index on book_id to both tables (PostgreSQL gets the same). Neither had
one leading with it, so every deleted book made the foreign key action scan both tables in full,
and deleting a library does that once per book. If pre-existing rows already reference a missing
user or book, the SQLite path now fails naming the table instead of a bare constraint error.

Also run against a copy of a real dev database (13,419 books, 2,808 progress rows): every session and
completion kept, all five indexes present, no FK violations, and a hard delete leaves orphans.

Statistics keep counting it

Totals, the per-period chart, the per-device split and coverage never joined books, so they are
unchanged by a hard delete. The series and format breakdowns now LEFT JOIN and gather that time
into one row: removedFromLibrary: true, with a null seriesId/seriesName or format. It is
ranked like any other row and placed after named rows on ties, explicitly, because SQLite and
PostgreSQL disagree about where a null sorts.

One figure does move, by design. books and booksFinished are distinct counts keyed on the
book, and two deleted books cannot be told apart, so a deleted book drops out of both. Time, pages
and sittings are per-row sums and stay exact. I chose undercounting over guessing; recovering it
would need per-session snapshot columns, which were considered and rejected as not worth the
backfill.

A reader can clear it, knowingly

  • GET /api/v1/reading-stats/orphaned: unwindowed totals of the caller's detached history.
  • DELETE /api/v1/reading-stats/orphaned: removes the caller's detached sessions and completions in
    one transaction and returns the counts. Scoped to the caller; attributed history is never touched.

The dashboard row is windowed and the purge is not, so the confirmation reads its numbers from the
GET rather than from the row, which would understate what is deleted. Sittings are counted the way
the dashboard counts them.

UI and docs

"Removed from library" appears as an italic, unlinked row in the series and format panels. The way to
discard it is a page-level Reading of removed books notice driven by the unwindowed totals, not
an action on that row: the row only exists when the reading is inside the window, in the top eight,
and non-zero for the chosen metric, so orphaned completions with no sittings, or anything under
"books finished", would otherwise have no control at all. The confirmation re-reads the totals on
open and stays disabled until they arrive, so it never confirms against stale or failed figures. The user guide explains what survives a permanent delete, why books read/finished
drop the deleted book, and how to clear it.

Verification

  • make test-fast: 5405 passed
  • make test-fast-postgres-run: 5444 passed
  • web: npm run test:run 3708 passed, npm run lint, npm run build clean
  • cargo clippy --workspace --all-targets -- -D warnings clean

#45, stacked on this one, adds reading-progress export/import, which can put orphaned sessions
back on their books after a library reorganisation.

reading_sessions and read_completions cascaded on books.id, so purging
deleted books or removing a library erased every statistic derived from
that reading, with nothing left to rebuild it from.

book_id is now nullable on both tables with ON DELETE SET NULL: the row
survives and loses only its attribution. The user cascade is unchanged.

PostgreSQL alters the constraint in place. SQLite rebuilds each table in
one transaction, checking the copied row count before the original is
dropped and recreating every index. Nothing references either table, so
the rebuild never has to switch foreign key enforcement off. Rolling back
deletes the orphaned rows first, since they cannot satisfy NOT NULL.
Reading history now survives its book being hard-deleted, with a null
book_id. Totals, the per-period chart, the per-device split and coverage
never joined books, so they keep counting that time unchanged.

The series and format breakdowns did join books, so they now LEFT JOIN
and gather those sessions into one row flagged removedFromLibrary, with
a null series or format. The orphan row sorts after named rows on both
engines, which disagree about where a null goes.

Books read and books finished count distinct books, and deleted books
cannot be told apart, so those two figures drop a deleted book's
contribution. Time, pages and sittings are per-row sums and stay exact.

DELETE /api/v1/reading-stats/orphaned removes the caller's detached
sessions and completions in one transaction and reports the counts.
Nothing else is touched, and no other user's history is reachable.
…te it

The series and format panels render the removedFromLibrary row as an
italic "Removed from library" line with no link, and the series row
carries a Delete action.

The confirmation states what will actually go. The dashboard row covers
only the dates on screen while a purge is not windowed, so the dialog
reads its figures from a new GET /api/v1/reading-stats/orphaned, which
totals every detached session and completion for the caller.

The user guide explains what survives a permanent delete, why books read
and books finished drop the deleted book, and how to clear it.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Deploying codex with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0ed41c7
Status: ✅  Deploy successful!
Preview URL: https://c1fc95f3.codex-asm.pages.dev
Branch Preview URL: https://feat-reading-history-survive.codex-asm.pages.dev

View logs

@github-actions

Copy link
Copy Markdown

API contract changes

Compared against main. These are changes a client generated from the
previous document would notice. Not a failure: breaking changes are a
release-time decision, and make release-prepare checks the bump against
them when the version is chosen.

Report
6 changes: 6 error, 0 warning, 0 info

The purge control lived on the "Removed from library" series row, which
only exists when that reading falls inside the dates on screen, ranks in
the top eight, and is non-zero for the chosen metric. Under "books
finished" it never is, and orphaned completions with no sittings never
produce a row at all. The entry point is now a page-level notice driven
by the unwindowed totals, shown whenever any removed history exists.

The confirmation re-reads those totals on open and stays disabled until
they arrive, so it cannot confirm against figures from before it opened
or alongside a failed refresh. "Nothing left" requires no sittings and
no completions, and sittings are counted as the dashboard counts them.

Also drop doc comments promising a reading-progress import that does
not exist yet, give the trigger an accessible name, and in the
migration: run a rollback's orphan delete inside the SQLite rebuild's
transaction, name the table when pre-existing rows would fail the copy,
and add a book_id index so each deleted book stops scanning both tables
in full for the foreign key action.
@AshDevFr
AshDevFr merged commit 7d86ea6 into main Sep 27, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant