From 179feb56ee2f23d62bbfe9609db9780c7410aaf0 Mon Sep 17 00:00:00 2001 From: Ali Date: Sun, 9 Aug 2026 13:58:12 +0300 Subject: [PATCH] fix: Include All Chapters Beyond the 1000-Row UI Limit (#1938) * chapter backup and restore fix * Update .gitignore Co-authored-by: Rajarshee Chatterjee * test: update backup mocks for getAllNovelChaptersForBackup --------- Co-authored-by: Rajarshee Chatterjee --- src/database/queries/ChapterQueries.ts | 17 +++++++++++ .../queries/__tests__/ChapterQueries.test.ts | 28 +++++++++++++++++++ src/database/queries/__tests__/index.ts | 1 + src/services/backup/__tests__/utils.test.ts | 10 +++---- src/services/backup/utils.ts | 4 +-- 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/database/queries/ChapterQueries.ts b/src/database/queries/ChapterQueries.ts index fdb92643..d78acbec 100644 --- a/src/database/queries/ChapterQueries.ts +++ b/src/database/queries/ChapterQueries.ts @@ -427,6 +427,23 @@ export const getNovelChapters = async ( .all(); }; +/** + * Returns every chapter row for a novel. + * + * This is intentionally separate from getNovelChapters, whose default limit is + * used to keep interactive screens responsive. Backup creation must never use + * a paginated UI query because doing so silently produces incomplete backups. + */ +export const getAllNovelChaptersForBackup = async ( + novelId: number, +): Promise => + dbManager + .select() + .from(chapterSchema) + .where(eq(chapterSchema.novelId, novelId)) + .orderBy(asc(chapterSchema.id)) + .all(); + export const getNovelChaptersSync = ( novelId: number, sort?: ChapterOrderKey, diff --git a/src/database/queries/__tests__/ChapterQueries.test.ts b/src/database/queries/__tests__/ChapterQueries.test.ts index f02d05fe..00abbe9b 100644 --- a/src/database/queries/__tests__/ChapterQueries.test.ts +++ b/src/database/queries/__tests__/ChapterQueries.test.ts @@ -18,6 +18,7 @@ import { markAllChaptersRead, markAllChaptersUnread, getNovelChapters, + getAllNovelChaptersForBackup, getUnreadNovelChapters, getAllUndownloadedChapters, getAllUndownloadedAndUnreadChapters, @@ -128,6 +129,33 @@ describe('ChapterQueries', () => { }); }); + describe('getAllNovelChaptersForBackup', () => { + it('returns chapters beyond the 1000-row UI query limit', async () => { + const testDb = getTestDb(); + // No Novel row is needed for this selector test. Keeping the id orphaned + // also avoids running aggregate-stat triggers 1001 times during setup. + const novelId = 123456; + + const values = Array.from( + { length: 1001 }, + (_, position) => + `(${novelId}, '/chapter/${position + 1}', ` + + `'Chapter ${position + 1}', ${position + 1}, '1', ${position})`, + ).join(','); + testDb.sqlite.executeSync( + `INSERT INTO Chapter + (novelId, path, name, chapterNumber, page, position) + VALUES ${values}`, + ); + + expect(await getNovelChapters(novelId)).toHaveLength(1000); + + const backupChapters = await getAllNovelChaptersForBackup(novelId); + expect(backupChapters).toHaveLength(1001); + expect(backupChapters.at(-1)?.name).toBe('Chapter 1001'); + }); + }); + describe('markChaptersRead', () => { it('should mark multiple chapters as read', async () => { const testDb = getTestDb(); diff --git a/src/database/queries/__tests__/index.ts b/src/database/queries/__tests__/index.ts index d9ee4a99..fe7903ca 100644 --- a/src/database/queries/__tests__/index.ts +++ b/src/database/queries/__tests__/index.ts @@ -81,6 +81,7 @@ export const testModules = { 'clearUpdates', 'getCustomPages', 'getNovelChapters', + 'getAllNovelChaptersForBackup', 'getUnreadNovelChapters', 'getAllUndownloadedChapters', 'getAllUndownloadedAndUnreadChapters', diff --git a/src/services/backup/__tests__/utils.test.ts b/src/services/backup/__tests__/utils.test.ts index 3856e3c6..8e549688 100644 --- a/src/services/backup/__tests__/utils.test.ts +++ b/src/services/backup/__tests__/utils.test.ts @@ -2,7 +2,7 @@ import { _restoreNovelAndChapters, getAllNovels, } from '@database/queries/NovelQueries'; -import { getNovelChapters } from '@database/queries/ChapterQueries'; +import { getAllNovelChaptersForBackup } from '@database/queries/ChapterQueries'; import { _restoreCategory, getAllNovelCategories, @@ -19,7 +19,7 @@ jest.mock('@database/queries/NovelQueries', () => ({ })); jest.mock('@database/queries/ChapterQueries', () => ({ - getNovelChapters: jest.fn(), + getAllNovelChaptersForBackup: jest.fn(), })); jest.mock('@database/queries/CategoryQueries', () => ({ @@ -74,7 +74,7 @@ describe('selective backup data', () => { jest.mocked(NativeFile.mkdir).mockResolvedValue(undefined); jest.mocked(NativeFile.writeFile).mockResolvedValue(undefined); jest.mocked(getAllNovels).mockResolvedValue([]); - jest.mocked(getNovelChapters).mockResolvedValue([]); + jest.mocked(getAllNovelChaptersForBackup).mockResolvedValue([]); jest.mocked(getCategoriesFromDb).mockResolvedValue([]); jest.mocked(getAllNovelCategories).mockResolvedValue([]); }); @@ -139,7 +139,7 @@ describe('selective backup data', () => { cover: 'file:///storage/Novels/source/1/cover.png', }, ]); - jest.mocked(getNovelChapters).mockResolvedValueOnce([ + jest.mocked(getAllNovelChaptersForBackup).mockResolvedValueOnce([ { id: 10, novelId: 1, @@ -147,7 +147,7 @@ describe('selective backup data', () => { name: 'Chapter 1', isDownloaded: true, }, - ] as Awaited>); + ] as Awaited>); await prepareBackupData('/cache', { library: true, diff --git a/src/services/backup/utils.ts b/src/services/backup/utils.ts index 55f914b4..d2f22b61 100644 --- a/src/services/backup/utils.ts +++ b/src/services/backup/utils.ts @@ -7,7 +7,7 @@ import { _restoreNovelAndChapters, getAllNovels, } from '@database/queries/NovelQueries'; -import { getNovelChapters } from '@database/queries/ChapterQueries'; +import { getAllNovelChaptersForBackup } from '@database/queries/ChapterQueries'; import { _restoreCategory, getAllNovelCategories, @@ -104,7 +104,7 @@ export const prepareBackupData = async ( await getAllNovels().then(async novels => { for (const novel of novels) { try { - const chapters = await getNovelChapters(novel.id); + const chapters = await getAllNovelChaptersForBackup(novel.id); const backedUpChapters = options.downloadedFiles ? chapters : chapters.map(chapter => ({