ADR-018 — Re-ingestion updates chapters in place; reader data never cascades with chapters
Status: Accepted · Date: 2026-10-05 · PRs: #715, #718 · Write-up: changelog archive
Context
A chapter row is where reader data hangs: reading_progresses, bookmarks and notes point at
chapters.id, highlights at chapters.id or user_chapters.id. Positions also name a chapter by
slug in strings and JSON (progress locator scroll:<slug>:<px>, the ADR-015 position JSON
chapterSlug, bookmark chapter:<slug>, BookInsight.ChapterSlug).
Re-ingestion (/admin/reprocess, reprocess-all, TextStack reimport, a retry after a quality fix)
deleted all of an edition’s chapters and inserted new ones with new Ids. The FKs to chapters were
ON DELETE CASCADE (progress, bookmarks, notes) and SET NULL (highlights). So one admin click
silently deleted every reader’s progress and bookmarks for that book, and their highlights lost their
chapter and never painted again. Upload Retry on a Ready book did the same to a user’s own book.
Found by the 2026-10 reader bug hunt (data C1, H1); a scratch harness on real Postgres failed 10/10
checks on origin/main.
Decision
- Chapters are updated in place, Ids kept.
ChapterReconciler(backend/src/Application/Ingestion/ChapterReconciler.cs) plans a match between existing and incoming chapters in three greedy passes, existing chapters in number order: slug (same position and title) → title, case-insensitive (a chapter was added or removed in front, so every slug shifted) → chapter number (the extractor retitled a chapter in place). A matched row keeps itsIdand gets the new number, slug, title, html, text and counts; unmatched incoming chapters are inserted. All of it runs in one transaction. Existing rows are first parked on negative numbers (and, for uploads,~<id>slugs) so the unique keys allow any final order. - A removed chapter hands its readers to a neighbour first. The nearest surviving chapter
before it, else the nearest after it (
RemoveEditionChapterAsync/RemoveUserChapterAsync). Previous, not next: a reader must never skip text they have not read, and a merged chapter’s text now lives in the previous one. Progress, bookmarks, notes and highlights are re-pointed withExecuteUpdate, then the chapter is deleted. Only when an edition has no chapter left are progress, bookmarks and notes deleted. - Slug-bearing positions are rewritten (#718).
SlugMovesmaps each old slug to where it went. A matched chapter keeps the in-chapter part (offset, text anchor) — same text. A re-pointed one is reset to the chapter start (scroll:<slug>:0, position JSON → null), because the anchor quotes text that chapter does not have. Applied to catalog progress and bookmarks, upload progress fields and bookmarks, and insights. Insights are one per (user, book, chapter): a move onto a slug the user already holds is skipped and logged, and a removed chapter’s insight that cannot move is parked on~orphan:<old slug>— kept, not deleted. - An empty re-extraction fails instead of wiping the book (“keeping the existing ones and their readers”).
- FKs
reading_progresses/bookmarks/notes→chaptersareNO ACTION(migrationChapterDependentsNoActionOnDelete;AppDbContext.Reading.cs). Any code path that deletes a chapter without going through the reconciler now fails loudly with 23503 instead of silently deleting reader data. Highlights staySET NULL(a highlight also belongs to the edition and is still listed).
Callers: IngestionService, TextStackImportService, admin chapter delete
(AdminService.Chapters.cs), quality-pipeline delete/merge (InternalEndpoints.cs),
UserIngestionService (old assets deleted only after the new chapters commit).
Alternatives
- Keep delete-and-recreate, remap afterwards by number. Rejected: the delete already cascaded; the remap would have nothing to move.
SET NULLon progress/bookmarks/notes. Rejected: it trades deletion for orphan rows — a progress row with no chapter restores nowhere, and the failure is just as silent.RESTRICTinstead ofNO ACTION. Rejected. In Postgres,RESTRICTis checked immediately, inside the statement;NO ACTIONis checked at the end of the statement. Deleting an edition cascades to its chapters and (viaEditionId) to their progress, bookmarks and notes in one statement. WithNO ACTIONthat statement ends consistent and succeeds; withRESTRICTthe chapter delete would be refused before the dependents are gone. Both refuse a bare chapter delete, which is the guard we want.
Consequences
- Re-processing a book is safe for its readers: progress, bookmarks, notes and highlights survive, and in the common case (same chapters) the reader resumes at the same spot.
- Every chapter delete must go through
ChapterReconciler.Remove*ChapterAsyncinside a transaction. A new delete path that forgets it gets a 23503 in tests, not data loss in production. - Tests:
tests/TextStack.UnitTests/ChapterReconcilerTests.cs,tests/TextStack.UnitTests/ChapterPositionRemapTests.cs,tests/TextStack.IntegrationTests/ReingestKeepsReaderDataTests.cs.
Known limits
Fixed in R3:MaxChapterNumberis not remapped.RemapMaxChapterNumberruns on re-ingest and on standalone renumbering (new frontier = highest new number among old chapters at or below the old one; a removed chapter counts as its predecessor).Standalone admin delete/merge paths do not rewrite locators.Fixed in R3: admin delete and the quality pipeline’s delete/merge go throughPlanFromIds+ the sameSlugMoves/MoveLocator/MovePosition/MoveInsightsAsync. They pass a null logger, so insight-collision warnings there are not logged.- Duplicate titles pair greedily. Two chapters with the same title (e.g. “Notes”, “Chapter”) are matched in number order by the title pass, which can pair the wrong two when chapters were added in front. Readers then land in a sibling chapter with the offset kept.
- Rewriting slug positions loads every progress/bookmark row of the edition into memory — fine at today’s sizes; per-row SQL if an edition ever has thousands of readers.