Guard the database update against duplicates and damaged indexes

This commit is contained in:
AntsyLich
2026-10-12 22:33:30 +06:00
parent 1ca9b0efb8
commit ea2b73ad78
2 changed files with 72 additions and 15 deletions
+1
View File
@@ -37,6 +37,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
### Fixed ### Fixed
- Fix long floating point numbers showing up in Comick progress ([@MajorTanya](https://github.com/MajorTanya)) ([#4090](https://github.com/mihonapp/mihon/pull/4090)) - Fix long floating point numbers showing up in Comick progress ([@MajorTanya](https://github.com/MajorTanya)) ([#4090](https://github.com/mihonapp/mihon/pull/4090))
- Fixed updating from v0.20.x failing when the database had duplicate history or tracking entries, or damaged indexes ([@AntsyLich](https://github.com/AntsyLich))
## [v0.21.1] - 2026-10-12 ## [v0.21.1] - 2026-10-12
### Fixed ### Fixed
@@ -4,6 +4,64 @@ import kotlin.String;
import kotlin.collections.List; import kotlin.collections.List;
import kotlinx.serialization.json.JsonObject; import kotlinx.serialization.json.JsonObject;
-- Every view reads `mangas`, so they are dropped here and recreated against the new tables at the end
DROP VIEW historyView;
DROP VIEW libraryView;
DROP VIEW updatesView;
-- ---------------------------------------------------------------------------------------------------------
-- damaged indexes
-- ---------------------------------------------------------------------------------------------------------
-- Some databases hold rows their unique constraints should have rejected, which a damaged index explains. A
-- write to a table also writes its indexes and fails on a damaged one, and a read through one can miss rows,
-- so each table changed in place below is first copied from its rows alone, leaving its indexes behind.
CREATE TABLE mangas_copy AS
SELECT
_id, source, url, artist, author, description, genre, title, status, thumbnail_url, last_update, next_update,
initialized, viewer, chapter_flags, cover_last_modified, update_strategy, calculate_interval, notes, memo,
favorite_at
FROM mangas NOT INDEXED;
DROP TABLE mangas;
ALTER TABLE mangas_copy RENAME TO mangas;
CREATE TABLE chapters_copy AS
SELECT
_id, manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch,
date_upload, memo
FROM chapters NOT INDEXED;
DROP TABLE chapters;
ALTER TABLE chapters_copy RENAME TO chapters;
CREATE TABLE history_copy AS
SELECT _id, chapter_id, last_read, time_read
FROM history NOT INDEXED;
DROP TABLE history;
ALTER TABLE history_copy RENAME TO history;
CREATE TABLE mangas_categories_copy AS
SELECT _id, manga_id, category_id
FROM mangas_categories NOT INDEXED;
DROP TABLE mangas_categories;
ALTER TABLE mangas_categories_copy RENAME TO mangas_categories;
CREATE TABLE manga_sync_copy AS
SELECT
_id, manga_id, sync_id, remote_id, library_id, title, last_chapter_read, total_chapters, status, score,
remote_url, start_date, finish_date, private
FROM manga_sync NOT INDEXED;
DROP TABLE manga_sync;
ALTER TABLE manga_sync_copy RENAME TO manga_sync;
CREATE TABLE excluded_scanlators_copy AS
SELECT manga_id, scanlator
FROM excluded_scanlators NOT INDEXED;
DROP TABLE excluded_scanlators;
ALTER TABLE excluded_scanlators_copy RENAME TO excluded_scanlators;
-- Merging duplicate chapters looks their history up by chapter
CREATE INDEX history_chapter_id_index ON history(chapter_id);
-- --------------------------------------------------------------------------------------------------------- -- ---------------------------------------------------------------------------------------------------------
-- orphans -- orphans
-- --------------------------------------------------------------------------------------------------------- -- ---------------------------------------------------------------------------------------------------------
@@ -21,11 +79,6 @@ DELETE FROM excluded_scanlators WHERE manga_id NOT IN (SELECT _id FROM mangas);
-- table - manga -- table - manga
-- --------------------------------------------------------------------------------------------------------- -- ---------------------------------------------------------------------------------------------------------
-- Every view reads `mangas`, so they are dropped here and recreated against the new table below
DROP VIEW historyView;
DROP VIEW libraryView;
DROP VIEW updatesView;
-- Entries sharing a source and url are merged into one, since the new table won't hold both. The copies are -- Entries sharing a source and url are merged into one, since the new table won't hold both. The copies are
-- ranked with the ones in the library first, since that is the entry the user has, then by age. The first -- ranked with the ones in the library first, since that is the entry the user has, then by age. The first
-- one stays, takes each detail it is missing from the first copy that has it, and takes over everything -- one stays, takes each detail it is missing from the first copy that has it, and takes over everything
@@ -326,8 +379,7 @@ CREATE INDEX manga_category_category_id_index ON manga_category(category_id);
-- --------------------------------------------------------------------------------------------------------- -- ---------------------------------------------------------------------------------------------------------
-- `last_read` is when the chapter was last read and `time_read` is how long it has been read for, so -- `last_read` is when the chapter was last read and `time_read` is how long it has been read for, so
-- both are renamed to say so. The chapter it belongs to is already unique, which leaves -- both are renamed to say so. The chapter it belongs to is already unique, so the constraint indexes it.
-- `history_history_chapter_id_index` a prefix of that constraint.
-- The table keeps its name, so the original is renamed aside and the new one built under -- The table keeps its name, so the original is renamed aside and the new one built under
-- the real name; renaming a table into place makes SQLite rewrite its stored definition, -- the real name; renaming a table into place makes SQLite rewrite its stored definition,
-- leaving it spelled differently from the same table on a fresh install. The views were -- leaving it spelled differently from the same table on a fresh install. The views were
@@ -349,11 +401,14 @@ CREATE TABLE history(
-- Reaching the entry a row belongs to meant going through its chapter; it names the entry -- Reaching the entry a row belongs to meant going through its chapter; it names the entry
-- itself now, taken from that same chapter. A row whose chapter is already gone cannot name -- itself now, taken from that same chapter. A row whose chapter is already gone cannot name
-- one and is dropped, which is what the foreign key would do to it once it is enforced again. -- one and is dropped, which is what the foreign key would do to it once it is enforced again.
-- Some databases hold more than one row for a chapter despite the old unique constraint, and the new one
-- rejects them, so they merge the way the history of duplicate chapters does above.
INSERT INTO history(id, manga_id, chapter_id, read_at, read_duration) INSERT INTO history(id, manga_id, chapter_id, read_at, read_duration)
SELECT H._id, C.manga_id, H.chapter_id, H.last_read, H.time_read SELECT min(H._id), C.manga_id, H.chapter_id, max(H.last_read), sum(H.time_read)
FROM history_temp H FROM history_temp H
JOIN chapter C JOIN chapter C
ON C.id = H.chapter_id; ON C.id = H.chapter_id
GROUP BY H.chapter_id;
DROP TABLE history_temp; DROP TABLE history_temp;
@@ -388,19 +443,20 @@ DROP TABLE excluded_scanlators;
-- --------------------------------------------------------------------------------------------------------- -- ---------------------------------------------------------------------------------------------------------
-- `manga_sync` is what tracking was called when it was still called syncing; `sync_id` is the tracker. -- `manga_sync` is what tracking was called when it was still called syncing; `sync_id` is the tracker.
-- An entry that merged can leave two rows for the same tracker on the survivor, which the unique constraint -- An entry that merged can leave two rows for the same tracker on the survivor, and an entry that didn't can
-- rejects, so the one to keep is chosen first: one on a copy in the library, then the one furthest read, then -- still hold two despite the old unique constraint. The new one rejects both, so the one to keep is chosen
-- the one on the copy ranked first, then the oldest. A track is taken whole rather than merged field by field, -- first: one on a copy in the library, then the one furthest read, then the one on the copy ranked first, then
-- because two rows on the same tracker can point at different remote entries. -- the oldest. A track is taken whole rather than merged field by field, because two rows on the same tracker
-- can point at different remote entries.
CREATE TEMP TABLE duplicate_tracks AS CREATE TEMP TABLE duplicate_tracks AS
SELECT SELECT
T._id, T._id,
first_value(T._id) OVER ( first_value(T._id) OVER (
PARTITION BY D.keep_id, T.sync_id PARTITION BY coalesce(D.keep_id, T.manga_id), T.sync_id
ORDER BY D.favorite DESC, T.last_chapter_read DESC, D.precedence ASC, T._id ASC ORDER BY D.favorite DESC, T.last_chapter_read DESC, D.precedence ASC, T._id ASC
) AS keep_track_id ) AS keep_track_id
FROM manga_sync T FROM manga_sync T
JOIN duplicate_mangas D LEFT JOIN duplicate_mangas D
ON D._id = T.manga_id; ON D._id = T.manga_id;
DELETE FROM manga_sync DELETE FROM manga_sync