feat(server): keep a per-book reading-progress history

reading_progress keeps one row per book and last-writer-wins, which answers
"where am I" and loses "where was I": a stale device, a mistapped 目录 entry
or a clock running ahead overwrote the place for good (P0: 进度历史).

Every valid progress write now also lands in reading_progress_history,
including a write that lost the merge. A device reading on through one
chapter updates its latest entry (same chapter, within 5% and 30 minutes)
instead of adding rows; another chapter, a jump, another device or an
out-of-order write is a new entry. The newest 50 per book are kept.
GET /api/v1/sync/progress/:bookId/history lists them, newest first, for the
signed-in account only.

The table is new (CREATE TABLE IF NOT EXISTS), so the schema change is
additive and older builds sharing the database ignore it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-31 03:51:42 +00:00
1 parent 67954ba592
commit 842331684a
5 files changed
+123

No files matched your search

+8
View File
@@ -829,6 +829,14 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
→ `{ "progress": {...} | null }`
### `GET /sync/progress/:bookId/history`
`?limit=`(默认 30,最大 50)→ `{ "history": [ {...}, ... ] }`,字段同上,按 `updatedAt` 倒序。
阅读记录:每次有效的进度写入都会记下,**包括在「后写者胜」合并中输掉的那次**(例如离线设备的旧位置)。
同一设备在同一章、半小时内、进度相差不到 5% 的连续写入合并成一条并向前移动;换章、跳转、换设备、乱序到达的写入各成一条。
每本书保留最新 50 条,只返回当前账号自己的记录。
### `PUT /sync/progress/:bookId`
单个进度上报的便捷写法:
+19
View File
@@ -237,6 +237,25 @@ CREATE TABLE IF NOT EXISTS reading_progress (
);
CREATE INDEX IF NOT EXISTS idx_progress_updated ON reading_progress(user_id, updated_at);
-- Where the reader has been in a book, so a jump can be undone (P0: 进度历史).
-- reading_progress keeps one row and last-writer-wins, which is right for "where am
-- I" and wrong for "where was I": a stale device, a mistapped 目录 entry or a device
-- whose clock runs fast all overwrite the place for good. Every valid progress write
-- lands here too — including one that lost the merge — and a device reading on
-- through one chapter updates its latest entry instead of adding rows. A new table,
-- so older builds that share this database simply do not see it.
CREATE TABLE IF NOT EXISTS reading_progress_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
locator TEXT NOT NULL DEFAULT '',
percentage REAL NOT NULL DEFAULT 0,
chapter_title TEXT NOT NULL DEFAULT '',
device TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_progress_history ON reading_progress_history(user_id, book_id, updated_at);
-- Notes, highlights and bookmarks. deleted is a tombstone rather than a hard
-- delete so that an offline client cannot resurrect a note it removed elsewhere.
CREATE TABLE IF NOT EXISTS notes (
+7
View File
@@ -55,6 +55,13 @@ export function registerSyncRoutes(app: FastifyInstance, ctx: AppContext): void
return { progress: ctx.sync.progressFor(user.id, bookId) };
});
app.get('/api/v1/sync/progress/:bookId/history', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { bookId } = request.params as { bookId: string };
const limit = Number((request.query as { limit?: string }).limit) || 30;
return { history: ctx.sync.progressHistory(user.id, bookId, limit) };
});
app.put('/api/v1/sync/progress/:bookId', { preHandler: auth }, async (request) => {
const user = currentUser(request);
const { bookId } = request.params as { bookId: string };
+60
View File
@@ -11,6 +11,11 @@ import type { BookDto, ShelfService } from './shelf.ts';
* offline for a week can push its local changes and receive only what changed
* on the server since `since`.
*/
/** Progress history: entries kept per book, and what still counts as reading on in one place. */
const HISTORY_KEEP = 50;
const HISTORY_SESSION_MS = 30 * 60 * 1000;
const HISTORY_JUMP = 0.05;
export interface ProgressRecord {
bookId: string;
/** Opaque locator: EPUB CFI, pagination index or PDF page. Client-defined. */
@@ -123,6 +128,61 @@ export class SyncService {
userId, record.bookId, record.locator ?? '', percentage,
record.chapterTitle ?? '', record.device ?? '', record.updatedAt,
);
this.recordHistory(userId, record, percentage);
}
/**
* Adds a progress write to the book's history (schema: reading_progress_history).
*
* Reading on through one chapter on one device is one entry that moves forward,
* not a row per page: the latest entry of the same device is updated when the
* write is newer, in the same chapter, within 5% of it and within half an hour.
* Anything else — another chapter, a jump, another device, a write that arrives
* out of order — is a new entry, because those are exactly the places a reader may
* want back. The newest 50 per book are kept.
*/
private recordHistory(userId: string, record: ProgressRecord, percentage: number): void {
const device = record.device ?? '', chapter = record.chapterTitle ?? '';
const latest = this.db.get<{ id: number; chapter_title: string; percentage: number; updated_at: number }>(
`SELECT id, chapter_title, percentage, updated_at FROM reading_progress_history
WHERE user_id = ? AND book_id = ? AND device = ? ORDER BY updated_at DESC, id DESC LIMIT 1`,
userId, record.bookId, device,
);
const continues = latest
&& record.updatedAt >= latest.updated_at
&& record.updatedAt - latest.updated_at <= HISTORY_SESSION_MS
&& latest.chapter_title === chapter
&& Math.abs(latest.percentage - percentage) < HISTORY_JUMP;
if (continues) {
this.db.run(
'UPDATE reading_progress_history SET locator = ?, percentage = ?, updated_at = ? WHERE id = ?',
record.locator ?? '', percentage, record.updatedAt, latest.id,
);
return;
}
this.db.run(
`INSERT INTO reading_progress_history (user_id, book_id, locator, percentage, chapter_title, device, updated_at)
VALUES (?,?,?,?,?,?,?)`,
userId, record.bookId, record.locator ?? '', percentage, chapter, device, record.updatedAt,
);
this.db.run(
`DELETE FROM reading_progress_history WHERE user_id = ? AND book_id = ? AND id NOT IN (
SELECT id FROM reading_progress_history WHERE user_id = ? AND book_id = ?
ORDER BY updated_at DESC, id DESC LIMIT ?)`,
userId, record.bookId, userId, record.bookId, HISTORY_KEEP,
);
}
/** The book's reading history, newest first. */
progressHistory(userId: string, bookId: string, limit = 30): ProgressRecord[] {
return this.db.all<{ locator: string; percentage: number; chapter_title: string; device: string; updated_at: number }>(
`SELECT locator, percentage, chapter_title, device, updated_at FROM reading_progress_history
WHERE user_id = ? AND book_id = ? ORDER BY updated_at DESC, id DESC LIMIT ?`,
userId, bookId, Math.max(1, Math.min(HISTORY_KEEP, limit)),
).map((row) => ({
bookId, locator: row.locator, percentage: row.percentage,
chapterTitle: row.chapter_title, device: row.device, updatedAt: row.updated_at,
}));
}
private upsertNote(userId: string, record: NoteRecord): void {
+29
View File
@@ -294,6 +294,35 @@ test('a stale offline device cannot roll back newer progress', async () => {
assert.equal((view.json() as { progress: { locator: string } }).progress.locator, 'fresh');
});
test('progress history keeps the places a write replaced, one entry per stretch of reading', async () => {
const owner = await login('owner');
const member = await login('family');
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;
const push = (items: Array<{ locator: string; percentage: number; chapterTitle: string; device: string; updatedAt: number }>, token = owner.token) =>
app.inject({ method: 'POST', url: '/api/v1/sync', headers: auth(token), payload: { progress: items.map(item => ({ bookId: book.id, ...item })) } });
const minute = 60_000, base = 1_000_000_000_000;
// Reading on through chapter 3 on the phone: one entry that moves forward.
await push([{ locator: 'c3-a', percentage: 0.30, chapterTitle: '第三章', device: 'phone', updatedAt: base }]);
await push([{ locator: 'c3-b', percentage: 0.31, chapterTitle: '第三章', device: 'phone', updatedAt: base + minute }]);
// A mistapped 目录 entry jumps to chapter 40: a new entry, and chapter 3 stays restorable.
await push([{ locator: 'c40', percentage: 0.90, chapterTitle: '第四十章', device: 'phone', updatedAt: base + 2 * minute }]);
// A stale tablet loses the merge, but where it was is history too.
await push([{ locator: 'c2', percentage: 0.20, chapterTitle: '第二章', device: 'tablet', updatedAt: base - minute }]);
const read = async (token = owner.token) => (await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}/history`, headers: auth(token) })).json() as { history: Array<{ locator: string; device: string }> };
// Newest first; earlier tests in this file left older entries for the same book behind.
assert.deepEqual((await read()).history.slice(0, 3).map(item => [item.locator, item.device]), [['c40', 'phone'], ['c3-b', 'phone'], ['c2', 'tablet']]);
const current = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}`, headers: auth(owner.token) });
assert.equal((current.json() as { progress: { locator: string } }).progress.locator, 'c40');
// Another account sees none of it (only its own places).
assert.ok((await read(member.token)).history.every(item => !['c40', 'c3-b', 'c2'].includes(item.locator)));
// Kept bounded: the newest 50.
await push(Array.from({ length: 60 }, (_, index) => ({ locator: `x${index}`, percentage: index / 60, chapterTitle: `第${index}章`, device: 'phone', updatedAt: base + (10 + index) * minute })));
const all = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}/history?limit=100`, headers: auth(owner.token) });
const entries = (all.json() as { history: Array<{ locator: string }> }).history;
assert.equal(entries.length, 50);
assert.equal(entries[0]!.locator, 'x59');
});
test('deleting a note leaves a tombstone that a stale device cannot undo', async () => {
const owner = await login('owner');
const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;