fix: Include All Chapters Beyond the 1000-Row UI Limit (#1938)

* chapter backup and restore fix

* Update .gitignore

Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>

* test: update backup mocks for getAllNovelChaptersForBackup

---------

Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>
This commit is contained in:
Ali
2026-08-09 13:58:12 +03:00
committed by GitHub
parent 67e01bc2df
commit 179feb56ee
5 changed files with 53 additions and 7 deletions
+17
View File
@@ -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<ChapterInfo[]> =>
dbManager
.select()
.from(chapterSchema)
.where(eq(chapterSchema.novelId, novelId))
.orderBy(asc(chapterSchema.id))
.all();
export const getNovelChaptersSync = (
novelId: number,
sort?: ChapterOrderKey,
@@ -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();
+1
View File
@@ -81,6 +81,7 @@ export const testModules = {
'clearUpdates',
'getCustomPages',
'getNovelChapters',
'getAllNovelChaptersForBackup',
'getUnreadNovelChapters',
'getAllUndownloadedChapters',
'getAllUndownloadedAndUnreadChapters',
+5 -5
View File
@@ -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<ReturnType<typeof getNovelChapters>>);
] as Awaited<ReturnType<typeof getAllNovelChaptersForBackup>>);
await prepareBackupData('/cache', {
library: true,
+2 -2
View File
@@ -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 => ({