fix(koreader): stop a rebuilt library silently dropping reading history

Deleting a library cascades away its books, files, page stats and reading sessions, and a rescan
mints new rows. The device's sync state is keyed by the file's partial md5, so an unchanged file
kept its cursors across that swap: the plugin asked only for events newer than a watermark earned
against rows the server no longer had, and nothing recorded before the swap was ever resent.
"Recheck all book matches" did not help, because it funnels back through setMatched.

Treat a changed book file id or book id as a fresh sync target and keep only the local file path.
Both ids matter: statistics hang off the file row while highlights, status, rating and review hang
off the book row, and an existing-folder merge re-parents a file to another book without changing
its id. Resending costs nothing because the page-stat dedup key makes a replay a server-side no-op.

Order both hash resolvers by book file id so content that exists as two files always resolves to
the same row. They previously took whichever row Postgres returned first, so they could disagree
with each other and, with the reset above, move a device's sync target under it.

Closes #1049
This commit is contained in:
neon
2026-08-21 16:38:02 +00:00
parent 0250a61858
commit 1e5fcedb2a
6 changed files with 252 additions and 14 deletions
+32 -2
View File
@@ -1,5 +1,7 @@
import { copyFile } from 'fs/promises';
import { randomUUID } from 'crypto';
import { and, asc, eq, like } from 'drizzle-orm';
import { join } from 'path';
import { and, asc, eq, gte, like } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import {
@@ -44,6 +46,7 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
let ctx!: ReaderStateIsolationE2EContext;
let library!: CreatedLibrary;
let epub!: LocatedBookFile;
let epubPath!: string;
let fileHash!: string;
function deviceHeaders(): Record<string, string> {
@@ -137,7 +140,7 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext();
library = await createLibraryWithFolder(ctx, { name: `koreader-page-stats-${randomUUID()}` });
const epubPath = await createEpubFixture(library.folderPath, 'page-stats-book.epub', {
epubPath = await createEpubFixture(library.folderPath, 'page-stats-book.epub', {
title: `Page Stats Book ${randomUUID()}`,
uid: `urn:uuid:${randomUUID()}`,
});
@@ -261,4 +264,31 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
expect(await waitForAchievement(session!.userId, 'power_hour')).toBe(true);
});
// A file hash is not unique: identical bytes in two places are two book file rows. The resolver
// has to land on the same one every time, or a device's statistics split across both and the
// plugin sees its sync target move, which discards its local upload cursor.
it('resolves a hash shared by two book files to the same row on every upload', async () => {
const duplicatePath = join(library.folderPath, 'page-stats-book-copy.epub');
await copyFile(epubPath, duplicatePath);
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
const sharing = await ctx.db
.select({ id: schema.bookFiles.id })
.from(schema.bookFiles)
.where(eq(schema.bookFiles.fileHash, fileHash))
.orderBy(asc(schema.bookFiles.id));
expect(sharing.length).toBeGreaterThan(1);
expect(sharing[0]!.id).toBe(epub.bookFileId);
const firstStart = BASE_EPOCH + 20_000_000;
await uploadPageStats([{ page: 1, startTime: firstStart, durationSeconds: 55, totalPages: TOTAL_PAGES }]);
await uploadPageStats([{ page: 2, startTime: firstStart + 60, durationSeconds: 55, totalPages: TOTAL_PAGES }]);
const targets = await ctx.db
.selectDistinct({ bookFileId: schema.koreaderPageStats.bookFileId })
.from(schema.koreaderPageStats)
.where(and(eq(schema.koreaderPageStats.deviceId, DEVICE_ID), gte(schema.koreaderPageStats.startTime, firstStart)));
expect(targets.map((row) => row.bookFileId)).toEqual([epub.bookFileId]);
});
});