fix(koreader): stop a stale device position undoing a progress reset

Clearing a book's progress deleted the shared row and left the KOReader device row behind, so
the next pull served the deleted position straight back. Clearing that row too is not enough
either: an empty response reads as "this server knows nothing about your book", which is the
one reply that leaves a device certain its own sidecar is the only position there is, and it
pushes that back on the next sync.

Record the reset as a durable fact and answer the pull with a real start position attributed to
BookOrbit. Route the position by format, because KOReader's own kosync plugin feeds it straight
to GotoPage or GotoXPointer with no percentage fallback and an empty value leaves the reader
where it was. Stamp each delivery as current, because both clients compare that against their
last page turn and default backward syncs to disabled, so the original reset time would be
dropped on any pull deferred past a page turn without ever prompting.

Serving a reset proves nothing, since either client can classify it as a backward sync and drop
it silently, so the marker survives delivery and a device is held until its own push reports the
start. Retire it once a device that took the reset reads on: the pull carries no device
identity, so a marker kept alive past its purpose is answered to every device and would send
that one back to the start on every sync. The bound this leaves is deliberate. A second device
that never took the reset is protected only until the first reads on, and closing that needs
device_id on the pull, which is a plugin change.

Judge convergence on the reported position rather than the percentage, so page 1 of a hundred
page comic is not mistaken for a position no short book could ever reach. Wind Kobo bookmarks
back through the same paths, since a Kobo device otherwise resumes from its own bookmark and
reports it back the same way.

Closes #1032
This commit is contained in:
neon
2026-08-18 21:29:36 +00:00
parent d06e29a889
commit b1ba3a03e7
26 changed files with 34191 additions and 47 deletions
@@ -117,7 +117,11 @@ function togglePersonalReview() {
}
const { weights: scoreWeights, fetchWeights } = useMetadataScoreWeights()
const { bookProgress: koreaderBookProgress, fetchBookProgress: fetchKoreaderProgress } = useKoreaderBookProgress()
const {
bookProgress: koreaderBookProgress,
fetchBookProgress: fetchKoreaderProgress,
releaseResetHold: releaseKoreaderResetHold,
} = useKoreaderBookProgress()
onMounted(() => {
void fetchWeights()
@@ -867,6 +871,31 @@ function formatKoboDeviceNames(snapshots: BookKoboState['snapshots']): string {
return `${snapshots.length} devices`
}
const resetHeldDevices = computed(() => (canViewKoreader.value ? (koreaderBookProgress.value?.heldByReset ?? []) : []))
const releasingDeviceIds = ref<string[]>([])
const failedReleaseDeviceIds = ref<string[]>([])
function isReleasingHold(deviceId: string): boolean {
return releasingDeviceIds.value.includes(deviceId)
}
function hasReleaseFailed(deviceId: string): boolean {
return failedReleaseDeviceIds.value.includes(deviceId)
}
async function handleReleaseResetHold(deviceId: string) {
if (isReleasingHold(deviceId)) return
releasingDeviceIds.value = [...releasingDeviceIds.value, deviceId]
failedReleaseDeviceIds.value = failedReleaseDeviceIds.value.filter((id) => id !== deviceId)
try {
const released = await releaseKoreaderResetHold(props.book.id, deviceId)
// A button that re-enables with the hold still showing reads as nothing having happened.
if (!released) failedReleaseDeviceIds.value = [...failedReleaseDeviceIds.value, deviceId]
} finally {
releasingDeviceIds.value = releasingDeviceIds.value.filter((id) => id !== deviceId)
}
}
const koboAnomaly = computed(() => {
if (!canViewKobo.value) return null
const snapshots = koboState.value?.snapshots ?? []
@@ -1710,6 +1739,24 @@ watch(
{{ t('book.detail.details.moreCount', { count: leftColumnProgressOverflow }) }}
</p>
</div>
<div v-for="held in resetHeldDevices" :key="held.deviceId" class="mt-2 flex items-start gap-1.5">
<TriangleAlert class="size-3 text-amber-500 shrink-0 mt-0.5" aria-hidden="true" />
<div class="min-w-0">
<p class="text-[11px] text-amber-500">
{{ t('book.detail.details.resetHoldNotice', { device: held.device, percent: formatPercent(held.percentage) }) }}
</p>
<button
class="mt-0.5 text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isReleasingHold(held.deviceId)"
@click="handleReleaseResetHold(held.deviceId)"
>
{{ isReleasingHold(held.deviceId) ? t('book.detail.details.resetHoldReleasing') : t('book.detail.details.resetHoldRelease') }}
</button>
<p v-if="hasReleaseFailed(held.deviceId)" role="alert" class="text-[11px] text-destructive">
{{ t('book.detail.details.resetHoldReleaseFailed') }}
</p>
</div>
</div>
<Tooltip v-if="koboAnomaly">
<TooltipTrigger as-child>
<div class="mt-2 flex items-center gap-1.5 cursor-help" tabindex="0">
@@ -2281,7 +2328,10 @@ watch(
</dl>
<!-- Mobile-only: reading progress from left column -->
<div v-if="leftColumnProgressVisible.length || koboAnomaly" class="md:hidden mt-6 pt-5 border-t border-border space-y-3">
<div
v-if="leftColumnProgressVisible.length || koboAnomaly || resetHeldDevices.length"
class="md:hidden mt-6 pt-5 border-t border-border space-y-3"
>
<div v-if="leftColumnProgressVisible.length" class="space-y-2">
<div v-for="row in leftColumnProgressVisible" :key="row.label" class="flex items-center gap-2 cursor-default">
<span
@@ -2321,6 +2371,24 @@ watch(
{{ t('book.detail.details.moreCount', { count: leftColumnProgressOverflow }) }}
</p>
</div>
<div v-for="held in resetHeldDevices" :key="held.deviceId" class="mt-2 flex items-start gap-1.5">
<TriangleAlert class="size-3 text-amber-500 shrink-0 mt-0.5" aria-hidden="true" />
<div class="min-w-0">
<p class="text-[11px] text-amber-500">
{{ t('book.detail.details.resetHoldNotice', { device: held.device, percent: formatPercent(held.percentage) }) }}
</p>
<button
class="mt-0.5 text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isReleasingHold(held.deviceId)"
@click="handleReleaseResetHold(held.deviceId)"
>
{{ isReleasingHold(held.deviceId) ? t('book.detail.details.resetHoldReleasing') : t('book.detail.details.resetHoldRelease') }}
</button>
<p v-if="hasReleaseFailed(held.deviceId)" role="alert" class="text-[11px] text-destructive">
{{ t('book.detail.details.resetHoldReleaseFailed') }}
</p>
</div>
</div>
<Tooltip v-if="koboAnomaly">
<TooltipTrigger as-child>
<div class="flex items-center gap-1.5 cursor-help" tabindex="0">
@@ -27,6 +27,7 @@ function makeBookProgress(overrides: Partial<KoreaderBookSyncInfo> = {}): Koread
},
],
fileModifiedSinceLastSync: false,
heldByReset: [],
...overrides,
}
}
@@ -116,4 +117,27 @@ describe('useKoreaderBookProgress', () => {
expect(bookProgress.value).toBeNull()
expect(loading.value).toBe(false)
})
it('posts the device id when releasing a reset hold and refreshes the book', async () => {
apiMock.mockResolvedValue(makeResponse(makeBookProgress()))
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { releaseResetHold } = useKoreaderBookProgress()
await expect(releaseResetHold(42, 'device-1')).resolves.toBe(true)
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/koreader/books/42/reset-hold/release',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ deviceId: 'device-1' }) }),
)
// The hold disappears from the book page only if the refreshed state is pulled back in.
expect(apiMock).toHaveBeenCalledWith('/api/v1/koreader/books/42/progress')
})
it('reports a failed release rather than pretending the hold is gone', async () => {
apiMock.mockResolvedValue(makeResponse(null, { ok: false, status: 404 }))
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { releaseResetHold } = useKoreaderBookProgress()
await expect(releaseResetHold(42, 'device-1')).resolves.toBe(false)
})
})
@@ -19,5 +19,16 @@ export function useKoreaderBookProgress() {
}
}
return { bookProgress, loading, fetchBookProgress }
async function releaseResetHold(bookId: number, deviceId: string): Promise<boolean> {
const res = await api(`/api/v1/koreader/books/${bookId}/reset-hold/release`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId }),
})
if (!res.ok) return false
await fetchBookProgress(bookId)
return true
}
return { bookProgress, loading, fetchBookProgress, releaseResetHold }
}
+5 -1
View File
@@ -3908,7 +3908,11 @@
"koboNotSynced": "Not synced",
"koboNotSyncedTooltip": "Queued for next Kobo sync.",
"filesNotFound": "Files not found",
"filesNotFoundDescription": "The file(s) for this book can no longer be found on disk. Metadata is still available. Run a library scan to confirm, or remove the record."
"filesNotFoundDescription": "The file(s) for this book can no longer be found on disk. Metadata is still available. Run a library scan to confirm, or remove the record.",
"resetHoldNotice": "{device} still has the old position ({percent})",
"resetHoldRelease": "Use this device's position",
"resetHoldReleasing": "Releasing...",
"resetHoldReleaseFailed": "Could not use this position. Try again."
},
"editMetadata": {
"fieldCount": "{count, plural, =0 {no fields} one {one field} other {# fields}}",
@@ -0,0 +1,172 @@
-- The server can only ask for a reset; the client decides whether to act on it. These cases
-- drive that decision, because a server-side test passes on a body the reader would discard.
package.loaded["gettext"] = function(text)
return text
end
package.loaded["ffi/util"] = {
template = function(text, ...)
local values = { ... }
return (text:gsub("%%(%d+)", function(index)
return tostring(values[tonumber(index)])
end))
end,
}
local confirm_boxes = {}
package.loaded["ui/widget/confirmbox"] = {
new = function(_, opts)
table.insert(confirm_boxes, opts)
return opts
end,
}
package.loaded["device"] = { model = "Kobo Libra" }
local events = {}
package.loaded["ui/event"] = {
new = function(_, name, value)
local event = { name = name, value = value }
table.insert(events, event)
return event
end,
}
package.loaded["ui/widget/infomessage"] = { new = function(_, opts) return opts end }
package.loaded["optmath"] = {
roundPercent = function(value) return value end,
round = function(value) return value end,
}
package.loaded["ui/network/manager"] = {
willRerunWhenConnected = function() return false end,
}
local shown_texts = {}
package.loaded["ui/uimanager"] = {
show = function(_, message)
table.insert(shown_texts, message.text)
end,
scheduleIn = function() end,
unschedule = function() end,
getElapsedTimeSinceBoot = function() return 10000 end,
}
package.loaded["logger"] = { dbg = function() end }
package.loaded["ui/time"] = { s = function(value) return value end }
package.loaded["util"] = { partialMD5 = function(file) return "digest:" .. tostring(file) end }
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local ProgressSync = require("bookorbit_progress_sync")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local function assertTrue(value, label)
if not value then error(label .. ": expected truthy, got " .. tostring(value)) end
end
--- Builds a plugin sitting at `local_percent` in a book, ready to pull `body` from the server.
local function makePlugin(body, opts)
opts = opts or {}
confirm_boxes = {}
events = {}
shown_texts = {}
local plugin = {
SYNC_STRATEGY = { PROMPT = 1, SILENT = 2, DISABLE = 3 },
}
ProgressSync.install(plugin)
plugin.pull_timestamp = 0
plugin.push_timestamp = 0
plugin.device_id = "this-device"
-- Defaults as shipped: forward prompts, backward is disabled.
plugin.settings = { sync_forward = 1, sync_backward = 3, auto_sync = true }
plugin.last_page_turn_timestamp = opts.last_page_turn_timestamp or 0
plugin.ui = {
document = {
file = "/tmp/book" .. (opts.paged and ".cbz" or ".epub"),
info = { has_pages = opts.paged == true },
},
handleEvent = function() end,
}
plugin.isLoggedIn = function() return true end
plugin.getLastProgress = function() return opts.local_progress or "/body/DocFragment[6]/body" end
plugin.getLastPercent = function() return opts.local_percent or 0.42 end
plugin.requestUpdateCheck = function() end
plugin.recordSyncSuccess = function() end
plugin.recordSyncError = function() end
plugin.newClient = function()
return { getProgress = function() return body end }
end
return plugin
end
local RESET_BODY_REFLOWABLE = {
percentage = 0,
progress = "/body/DocFragment[1]/body",
device = "web",
device_id = "bookorbit-web",
timestamp = 5000,
}
-- 1. The reset must not be mistaken for this device's own progress, or it is skipped outright.
local plugin = makePlugin(RESET_BODY_REFLOWABLE)
plugin:getProgress(false, false)
assertEqual(#confirm_boxes, 1, "a reset from BookOrbit reaches the conflict prompt")
-- 2. Accepting it has to actually move the reader to the start.
confirm_boxes[1].ok_callback()
assertEqual(events[1].name, "GotoXPointer", "reflowable reset navigates by xpointer")
assertEqual(events[1].value, "/body/DocFragment[1]/body", "reflowable reset targets the first fragment")
-- 3. Paged books take a page number, and an empty position would arrive as nil.
local paged = makePlugin({
percentage = 0,
progress = "1",
device = "web",
device_id = "bookorbit-web",
timestamp = 5000,
}, { paged = true, local_progress = "117" })
paged:getProgress(false, false)
assertEqual(#confirm_boxes, 1, "a paged reset reaches the conflict prompt")
confirm_boxes[1].ok_callback()
assertEqual(events[1].name, "GotoPage", "paged reset navigates by page")
assertEqual(events[1].value, 1, "paged reset targets page one")
-- 4. The classification is what decides whether the reader is ever asked. A reset stamped
-- behind the last page turn is read as a backward sync, and backward defaults to disabled,
-- so it is dropped in silence. This is why the server stamps each delivery as current.
local stale = makePlugin(RESET_BODY_REFLOWABLE, { last_page_turn_timestamp = 9000 })
stale:getProgress(false, false)
assertEqual(#confirm_boxes, 0, "a reset older than the last page turn is silently discarded")
local fresh = makePlugin({
percentage = 0,
progress = "/body/DocFragment[1]/body",
device = "web",
device_id = "bookorbit-web",
timestamp = 9500,
}, { last_page_turn_timestamp = 9000 })
fresh:getProgress(false, false)
assertEqual(#confirm_boxes, 1, "a reset stamped after the last page turn still prompts")
-- 5. A device already at the start has nothing to do, and must not be nagged.
local converged = makePlugin(RESET_BODY_REFLOWABLE, { local_percent = 0 })
converged:getProgress(false, false)
assertEqual(#confirm_boxes, 0, "a device already at the start is not prompted")
-- 6. The reset must never be attributed to the pulling device itself, or the guard skips it.
local own = makePlugin({
percentage = 0,
progress = "/body/DocFragment[1]/body",
device = "Kobo Libra",
device_id = "this-device",
timestamp = 5000,
})
own:getProgress(false, false)
assertEqual(#confirm_boxes, 0, "progress from this device is skipped, which is why the reset is not attributed to it")
print("bookorbit_progress_reset_test.lua: ok")
+13
View File
@@ -79,6 +79,19 @@ export interface KoreaderBookSyncInfo {
canonicalUpdatedAt: string;
devices: KoreaderBookProgress[];
fileModifiedSinceLastSync: boolean;
/**
* Devices sitting on a position the user reset away from. Their pushes are recorded but do
* not move the book until the device takes the reset, so this is the state behind a device
* and a book that visibly disagree.
*/
heldByReset: KoreaderResetHeldDevice[];
}
export interface KoreaderResetHeldDevice {
device: string;
deviceId: string;
percentage: number;
updatedAt: string;
}
export interface KoreaderUnmatchedBook {
+20
View File
@@ -551,6 +551,26 @@ export const E2E_SUITES = Object.freeze({
...SHARED_DB_AND_HELPER_PATHS,
],
},
"koreader-progress-reset": {
id: "koreader-progress-reset",
name: "KOReader Progress Reset Propagation",
timeout: 45,
lane: "full",
description: "KOReader reset propagation: a cleared position survives a device replaying its old one",
vitestTarget: "test/koreader-progress-reset.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/koreader-progress-reset-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
changedPaths: [
"server/src/modules/koreader/**",
"server/src/modules/book/book.repository.ts",
"server/src/modules/reading-state/**",
"server/src/db/schema/koreader.ts",
"server/test/koreader-progress-reset.e2e-spec.ts",
"server/test/e2e/reader-state-isolation/**",
...SHARED_DB_AND_HELPER_PATHS,
],
},
"koreader-progress-position": {
id: "koreader-progress-position",
name: "KOReader Progress Position Routing",
@@ -0,0 +1,10 @@
CREATE TABLE "koreader_progress_resets" (
"user_id" integer NOT NULL,
"book_file_id" integer NOT NULL,
"reset_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "koreader_progress_resets_user_id_book_file_id_pk" PRIMARY KEY("user_id","book_file_id")
);
--> statement-breakpoint
ALTER TABLE "koreader_progress_resets" ADD CONSTRAINT "koreader_progress_resets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "koreader_progress_resets" ADD CONSTRAINT "koreader_progress_resets_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "koreader_progress_resets_book_file_id_idx" ON "koreader_progress_resets" USING btree ("book_file_id");
@@ -0,0 +1,9 @@
CREATE TABLE "koreader_progress_reset_devices" (
"user_id" integer NOT NULL,
"book_file_id" integer NOT NULL,
"device_id" varchar(100) NOT NULL,
"converged_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "koreader_progress_reset_devices_user_id_book_file_id_device_id_pk" PRIMARY KEY("user_id","book_file_id","device_id")
);
--> statement-breakpoint
ALTER TABLE "koreader_progress_reset_devices" ADD CONSTRAINT "koreader_progress_reset_devices_reset_fk" FOREIGN KEY ("user_id","book_file_id") REFERENCES "public"."koreader_progress_resets"("user_id","book_file_id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -491,6 +491,20 @@
"when": 1787023974136,
"tag": "0069_add_author_metadata_fields",
"breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1787083687102,
"tag": "0070_add_koreader_progress_resets",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1787086030151,
"tag": "0071_add_koreader_progress_reset_devices",
"breakpoints": true
}
]
}
+66
View File
@@ -352,3 +352,69 @@ export const koreaderBookmarkLinks = pgTable(
export type KoreaderBookmarkLink = typeof koreaderBookmarkLinks.$inferSelect;
export type NewKoreaderBookmarkLink = typeof koreaderBookmarkLinks.$inferInsert;
/**
* A deliberate server-side progress reset, recorded as a fact rather than as the absence of
* a reading_progress row. KOReader reads a missing position as "unknown" and answers by
* pushing its own sidecar back, so a reset that is only a deletion is undone the next time
* the book is opened on a device.
*
* A marker is live until it is retired, and serving it retires nothing: both clients can
* classify a reset as a backward sync and drop it without saying so, so being handed the
* position is no evidence the reader ever saw it. What retires a marker is a device that took
* the reset and then read on, or a web reader write, which is the way out for a device that
* never pulls.
*
* It has to be retired on that signal, and not merely held. The kosync pull carries no device
* identity, so a marker kept alive past its purpose is answered to every device, and one that
* already took the reset would be asked to jump back to the start on every sync.
*
* While a marker is live a KOReader push still records its per-device row, so nothing the
* device reports is lost, but it does not move shared progress.
*/
export const koreaderProgressResets = pgTable(
'koreader_progress_resets',
{
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
bookFileId: integer('book_file_id')
.notNull()
.references(() => bookFiles.id, { onDelete: 'cascade' }),
resetAt: timestamp('reset_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [primaryKey({ columns: [t.userId, t.bookFileId] }), index('koreader_progress_resets_book_file_id_idx').on(t.bookFileId)],
);
export type KoreaderProgressReset = typeof koreaderProgressResets.$inferSelect;
export type NewKoreaderProgressReset = typeof koreaderProgressResets.$inferInsert;
/**
* Which devices have taken a given reset. The kosync pull carries no device identity, so a
* reset cannot be handed out per device, but the push does carry one and the hold is decided
* there. That asymmetry is what lets this be per-device without a plugin change: a device is
* held until its own push shows it at the start, and one device converging says nothing about
* the others.
*
* Rows hang off the marker and cascade with it, so re-arming a reset holds every device again.
*/
export const koreaderProgressResetDevices = pgTable(
'koreader_progress_reset_devices',
{
userId: integer('user_id').notNull(),
bookFileId: integer('book_file_id').notNull(),
deviceId: varchar('device_id', { length: 100 }).notNull(),
convergedAt: timestamp('converged_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
primaryKey({ columns: [t.userId, t.bookFileId, t.deviceId] }),
foreignKey({
columns: [t.userId, t.bookFileId],
foreignColumns: [koreaderProgressResets.userId, koreaderProgressResets.bookFileId],
name: 'koreader_progress_reset_devices_reset_fk',
}).onDelete('cascade'),
],
);
export type KoreaderProgressResetDevice = typeof koreaderProgressResetDevices.$inferSelect;
export type NewKoreaderProgressResetDevice = typeof koreaderProgressResetDevices.$inferInsert;
+132 -10
View File
@@ -1,7 +1,7 @@
import { BookRepository } from './book.repository';
import { sql } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
import { bookMetadata, books } from '../../db/schema';
import { audiobookProgress, bookMetadata, books, koreaderDeviceProgress, koreaderProgressResets, readingProgress } from '../../db/schema';
function makeSelectChain<T>(terminalMethod: string, terminalResult: T) {
const chain: Record<string, vi.Mock> = {
@@ -823,7 +823,9 @@ describe('BookRepository', () => {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
const insert = vi.fn().mockReturnValue({ values });
const db = { insert };
const resetWhere = vi.fn().mockResolvedValue(undefined);
const del = vi.fn().mockReturnValue({ where: resetWhere });
const db = { insert, delete: del };
const repo = new BookRepository(db as never);
await repo.upsertProgress(
@@ -1185,20 +1187,140 @@ describe('BookRepository', () => {
await expect(repo.isKoboTwoWayProgressSyncEnabled(6)).resolves.toBe(false);
});
it('clears both reading_progress and audiobook_progress rows for a file id', async () => {
const readingWhere = vi.fn().mockResolvedValue(undefined);
const audioWhere = vi.fn().mockResolvedValue(undefined);
const del = vi.fn().mockReturnValueOnce({ where: readingWhere }).mockReturnValueOnce({ where: audioWhere });
const db = {
function makeClearProgressDb(file: { bookId: number; primaryFileId: number } | undefined, koboState: Record<string, unknown> | undefined) {
const deleted: unknown[] = [];
const inserted: unknown[] = [];
const updated: unknown[] = [];
const executed: unknown[] = [];
const del = vi.fn().mockImplementation((table: unknown) => ({
where: vi.fn().mockImplementation(() => {
deleted.push(table);
return Promise.resolve(undefined);
}),
}));
// Recorded at values() so it captures both a plain insert and a chained upsert.
const insert = vi.fn().mockImplementation((table: unknown) => ({
values: vi.fn().mockImplementation((rows: unknown) => {
inserted.push({ table, rows });
return Object.assign(Promise.resolve(undefined), {
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
onConflictDoNothing: vi.fn().mockResolvedValue(undefined),
});
}),
}));
const update = vi.fn().mockImplementation((table: unknown) => ({
set: vi.fn().mockImplementation((patch: unknown) => ({
where: vi.fn().mockImplementation(() => {
updated.push({ table, patch });
return Promise.resolve(undefined);
}),
})),
}));
const execute = vi.fn().mockImplementation((statement: unknown) => {
executed.push(statement);
return Promise.resolve(undefined);
});
// clearFileProgress looks the file up outside the transaction; inside it, the Kobo reset
// reads the existing reading state and clearBookProgress lists the book's files.
const select = vi
.fn()
.mockImplementation(() => makeSelectChain('limit', koboState ? [koboState] : []))
.mockImplementationOnce(() => makeSelectChain('limit', file ? [file] : []));
const tx = {
delete: del,
insert,
update,
execute,
select: vi.fn().mockImplementation(() => makeSelectChain('limit', koboState ? [koboState] : [])),
};
const db = {
select,
delete: del,
insert,
update,
execute,
transaction: vi.fn().mockImplementation(async (cb: (t: unknown) => Promise<void>) => cb(tx)),
};
return { db, tx, deleted, inserted, updated, executed };
}
it('clears reading, audio and KOReader device rows for a file id and records the reset', async () => {
const { db, deleted, inserted } = makeClearProgressDb({ bookId: 3, primaryFileId: 99 }, undefined);
const repo = new BookRepository(db as never);
await repo.clearFileProgress(7, 99);
expect(del).toHaveBeenCalledTimes(2);
expect(readingWhere).toHaveBeenCalledTimes(1);
expect(audioWhere).toHaveBeenCalledTimes(1);
expect(deleted).toEqual([readingProgress, audiobookProgress, koreaderDeviceProgress, koreaderProgressResets]);
expect(inserted).toHaveLength(1);
expect(inserted[0]).toEqual({ table: koreaderProgressResets, rows: [{ userId: 7, bookFileId: 99 }] });
});
it('leaves the Kobo bookmark alone when the cleared file is not the book primary', async () => {
const { db, updated } = makeClearProgressDb({ bookId: 3, primaryFileId: 42 }, { lastModifiedKobo: '2026-01-01T00:00:00Z' });
const repo = new BookRepository(db as never);
await repo.clearFileProgress(7, 99);
expect(updated).toEqual([]);
});
it('winds the Kobo bookmark back to the start when the primary file is cleared', async () => {
const { db, updated, executed } = makeClearProgressDb({ bookId: 3, primaryFileId: 99 }, { lastModifiedKobo: '2026-01-01T00:00:00Z' });
const repo = new BookRepository(db as never);
await repo.clearFileProgress(7, 99);
expect(updated).toHaveLength(1);
const patch = (updated[0] as { patch: Record<string, unknown> }).patch;
expect(patch.currentBookmark).toEqual(expect.objectContaining({ ProgressPercent: 0 }));
expect(patch.statusInfo).toEqual(expect.objectContaining({ Status: 'ReadyToRead', TimesStartedReading: 0 }));
// A Kobo bookmark only wins on a strictly newer timestamp, so the reset has to advance past
// whatever the device last reported rather than stamping wall clock over it.
expect(String(patch.lastModifiedKobo) > '2026-01-01T00:00:00Z').toBe(true);
expect(executed).toHaveLength(1);
});
it('records a reset for every file of a book', async () => {
const { db, inserted, deleted } = makeClearProgressDb(undefined, undefined);
const repo = new BookRepository(db as never);
db.transaction = vi.fn().mockImplementation(async (cb: (t: unknown) => Promise<void>) =>
cb({
...db,
select: vi
.fn()
.mockImplementation(() => makeSelectChain('limit', []))
.mockImplementationOnce(() => makeSelectChain('where', [{ id: 11 }, { id: 12 }])),
}),
) as never;
await repo.clearBookProgress(7, 3);
expect(deleted).toEqual([readingProgress, audiobookProgress, koreaderDeviceProgress, koreaderProgressResets]);
expect(inserted).toEqual([
{
table: koreaderProgressResets,
rows: [
{ userId: 7, bookFileId: 11 },
{ userId: 7, bookFileId: 12 },
],
},
]);
});
it('retires a pending reset when the web reader writes progress back', async () => {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const insert = vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({ onConflictDoUpdate }) });
const resetWhere = vi.fn().mockResolvedValue(undefined);
const del = vi.fn().mockReturnValue({ where: resetWhere });
const repo = new BookRepository({ insert, delete: del } as never);
await repo.upsertProgress(5, 9, 'epubcfi(/6/2)', null, 40);
expect(del).toHaveBeenCalledWith(koreaderProgressResets);
expect(resetWhere).toHaveBeenCalledTimes(1);
});
describe('temporal jump buckets', () => {
+110 -3
View File
@@ -44,6 +44,8 @@ import {
koboReadingStates,
koboSnapshotBooks,
koboSyncSettings,
koreaderDeviceProgress,
koreaderProgressResets,
libraries,
narrators,
audiobookProgress,
@@ -58,6 +60,7 @@ type DbTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
type MetadataUpdateExecutor = Pick<Db, 'delete' | 'insert' | 'select' | 'update'>;
type MetadataReadExecutor = Pick<Db, 'select'>;
type JsonObj = Record<string, unknown>;
type BookRepositoryTx = Parameters<Parameters<NodePgDatabase<typeof schema>['transaction']>[0]>[0];
type CollapsedRawRow = {
id: number;
@@ -2059,6 +2062,11 @@ export class BookRepository {
updatedAt: now,
},
});
// Reading in BookOrbit is fresher intent than the reset that came before it, and it is
// the way out for a device that never pulls and would otherwise have every push held
// back indefinitely.
await this.db.delete(koreaderProgressResets).where(and(eq(koreaderProgressResets.userId, userId), eq(koreaderProgressResets.bookFileId, fileId)));
}
async syncKoboReadingStateFromProgress(
@@ -2208,18 +2216,117 @@ export class BookRepository {
}
async clearFileProgress(userId: number, fileId: number): Promise<void> {
await this.db.delete(readingProgress).where(and(eq(readingProgress.userId, userId), eq(readingProgress.bookFileId, fileId)));
await this.db.delete(audiobookProgress).where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.currentFileId, fileId)));
const [file] = await this.db
.select({ bookId: bookFiles.bookId, primaryFileId: books.primaryFileId })
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(eq(bookFiles.id, fileId))
.limit(1);
await this.db.transaction(async (tx) => {
await tx.delete(readingProgress).where(and(eq(readingProgress.userId, userId), eq(readingProgress.bookFileId, fileId)));
await tx.delete(audiobookProgress).where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.currentFileId, fileId)));
await this.clearExternalDeviceProgress(tx, userId, [fileId]);
// Kobo tracks the book through its primary file, so clearing a secondary file leaves
// the device's bookmark alone.
if (file && file.primaryFileId === fileId) await this.resetKoboReadingState(tx, userId, file.bookId);
});
}
async clearBookProgress(userId: number, bookId: number): Promise<void> {
const fileIds = this.db.select({ id: bookFiles.id }).from(bookFiles).where(eq(bookFiles.bookId, bookId));
await this.db.transaction(async (tx) => {
const files = await tx.select({ id: bookFiles.id }).from(bookFiles).where(eq(bookFiles.bookId, bookId));
const fileIds = files.map((file) => file.id);
await tx.delete(readingProgress).where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, fileIds)));
await tx.delete(audiobookProgress).where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.bookId, bookId)));
await this.clearExternalDeviceProgress(tx, userId, fileIds);
await this.resetKoboReadingState(tx, userId, bookId);
});
}
/**
* Drops the KOReader per-device positions for these files and records the reset, so the
* cleared position survives contact with a device. Deleting the shared row alone is not
* enough: the sync path would keep serving the device row it left behind, and a device
* that has not been told about the reset would push its own position straight back.
*
* Reading statistics are deliberately untouched. Clearing a position says nothing about
* the time already spent in the book; only the full reading-state reset discards that.
*/
private async clearExternalDeviceProgress(tx: BookRepositoryTx, userId: number, fileIds: number[]): Promise<void> {
if (fileIds.length === 0) return;
await tx
.delete(koreaderDeviceProgress)
.where(
and(
eq(koreaderDeviceProgress.userId, userId),
inArray(koreaderDeviceProgress.bookFileId, fileIds),
eq(koreaderDeviceProgress.orphaned, false),
),
);
// Deleted rather than upserted so a repeat reset cascades away the per-device convergence
// rows: every device has to take the new reset, including ones that took the last one.
await tx
.delete(koreaderProgressResets)
.where(and(eq(koreaderProgressResets.userId, userId), inArray(koreaderProgressResets.bookFileId, fileIds)));
await tx.insert(koreaderProgressResets).values(fileIds.map((bookFileId) => ({ userId, bookFileId })));
}
/**
* Winds the Kobo bookmark back to the start of the book, matching what the full reading
* state reset does. Without this a Kobo device resumes from its own bookmark and reports
* that position back, undoing the reset the same way KOReader does.
*/
private async resetKoboReadingState(tx: BookRepositoryTx, userId: number, bookId: number): Promise<void> {
const [existing] = await tx
.select({
lastModifiedKobo: koboReadingStates.lastModifiedKobo,
priorityTimestamp: koboReadingStates.priorityTimestamp,
currentBookmark: koboReadingStates.currentBookmark,
statistics: koboReadingStates.statistics,
statusInfo: koboReadingStates.statusInfo,
})
.from(koboReadingStates)
.where(and(eq(koboReadingStates.userId, userId), eq(koboReadingStates.bookId, bookId)))
.limit(1);
if (!existing) return;
const now = new Date();
const existingBookmark = this.asJsonObj(existing.currentBookmark);
const existingStatusInfo = this.asJsonObj(existing.statusInfo);
const nowIso = advanceIsoTimestamp(
now,
existing.lastModifiedKobo,
existing.priorityTimestamp,
typeof existingBookmark?.LastModified === 'string' ? existingBookmark.LastModified : null,
typeof existingStatusInfo?.LastModified === 'string' ? existingStatusInfo.LastModified : null,
);
await tx
.update(koboReadingStates)
.set({
lastModifiedKobo: nowIso,
priorityTimestamp: nowIso,
currentBookmark: { LastModified: nowIso, ProgressPercent: 0 },
statistics: { ...(this.asJsonObj(existing.statistics) ?? {}), LastModified: nowIso },
statusInfo: { ...(existingStatusInfo ?? {}), LastModified: nowIso, Status: 'ReadyToRead', TimesStartedReading: 0 },
updatedAt: now,
})
.where(and(eq(koboReadingStates.userId, userId), eq(koboReadingStates.bookId, bookId)));
await tx.execute(sql`
UPDATE ${koboSnapshotBooks} AS sb
SET synced = false,
is_new = false
FROM ${koboLibrarySnapshots} AS snap
WHERE snap.id = sb.snapshot_id
AND snap.user_id = ${userId}
AND sb.book_id = ${bookId}
AND sb.pending_delete = false
AND sb.removed_by_device = false
`);
}
async findAudioProgress(userId: number, bookId: number) {
const [row] = await this.db
.select()
+1
View File
@@ -7,4 +7,5 @@ export * from './koreader-link.dto';
export * from './koreader-package.dto';
export * from './koreader-plugin.dto';
export * from './koreader-progress.dto';
export * from './koreader-reset-hold.dto';
export * from './koreader-user.dto';
@@ -0,0 +1,8 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class ReleaseResetHoldDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
deviceId!: string;
}
@@ -238,7 +238,7 @@ export class KoreaderPluginService {
});
this.logger.log(
`[${BULK_PROGRESS_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} accepted=${results.length} shared=${applied.shared} stale=${applied.stale} unmatched=${unmatched.length} - bulk progress completed`,
`[${BULK_PROGRESS_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} accepted=${results.length} shared=${applied.shared} stale=${applied.stale} held=${applied.held} unmatched=${unmatched.length} - bulk progress completed`,
);
return { results, unmatched };
@@ -30,6 +30,7 @@ import {
DownloadPluginPackageDto,
KoreaderDeviceParamDto,
KoreaderSaveProgressDto,
ReleaseResetHoldDto,
LinkKoreaderUnmatchedBookDto,
TestConnectionDto,
UpdateKoreaderDeviceDto,
@@ -215,6 +216,13 @@ export class KoreaderController {
return this.koreaderService.getBookProgress(user.id, bookId);
}
@RequirePermission(Permission.KoreaderSync)
@Post('books/:bookId/reset-hold/release')
async releaseResetHold(@CurrentUser() user: RequestUser, @Param('bookId', ParseIntPipe) bookId: number, @Body() dto: ReleaseResetHoldDto) {
await this.koreaderService.releaseResetHold(user.id, bookId, dto.deviceId);
return { released: true };
}
@RequirePermission(Permission.KoreaderSync)
@Get('plugin-package')
async downloadPluginPackage(@CurrentUser() user: RequestUser, @Query() query: DownloadPluginPackageDto, @Res() reply: FastifyReply) {
@@ -45,6 +45,49 @@ describe('KoreaderRepository', () => {
repo = new KoreaderRepository(db as never);
});
describe('progress resets', () => {
it('returns the reset timestamp when one is outstanding', async () => {
const resetAt = new Date('2026-02-02T12:00:00.000Z');
db.select.mockReturnValue(makeQueryChain([{ resetAt }]));
await expect(repo.getProgressReset(10, 7)).resolves.toEqual(resetAt);
});
it('returns null when no reset is outstanding', async () => {
db.select.mockReturnValue(makeQueryChain([]));
await expect(repo.getProgressReset(10, 7)).resolves.toBeNull();
});
it('records convergence per device rather than retiring the marker', async () => {
const onConflictDoNothing = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoNothing });
db.insert.mockReturnValue({ values });
await repo.recordResetConvergence(10, 7, 'device-1');
// One device taking the reset says nothing about the others, so the marker stays.
expect(values).toHaveBeenCalledWith({ userId: 7, bookFileId: 10, deviceId: 'device-1' });
expect(db.delete).not.toHaveBeenCalled();
});
it('reads back the devices that have taken the reset', async () => {
db.select.mockReturnValue(makeQueryChain([{ deviceId: 'device-1' }, { deviceId: 'device-2' }]));
await expect(repo.getConvergedResetDeviceIds(10, 7)).resolves.toEqual(new Set(['device-1', 'device-2']));
});
it('retires a marker when the outcome is settled for every device', async () => {
const where = vi.fn().mockResolvedValue(undefined);
db.delete.mockReturnValue({ where });
await repo.clearProgressReset(10, 7);
expect(db.delete).toHaveBeenCalledTimes(1);
expect(where).toHaveBeenCalledTimes(1);
});
});
describe('resolveBookFileByHash', () => {
it('short-circuits when accessible libraries are empty', async () => {
await expect(repo.resolveBookFileByHash('hash', [])).resolves.toBeNull();
@@ -650,6 +650,122 @@ export class KoreaderRepository {
return byFile;
}
/**
* The live reset marker for a file, or null when none is outstanding. A live marker means
* the user cleared this file's position server-side and no device has been told yet, so it
* outranks every stored position regardless of timestamps: a device that pushed after the
* reset carries a fresh push clock but a pre-reset position, and the two are
* indistinguishable on the wire.
*/
async getProgressReset(bookFileId: number, userId: number) {
const [row] = await this.db
.select({ resetAt: schema.koreaderProgressResets.resetAt })
.from(schema.koreaderProgressResets)
.where(and(eq(schema.koreaderProgressResets.bookFileId, bookFileId), eq(schema.koreaderProgressResets.userId, userId)))
.limit(1);
return row?.resetAt ?? null;
}
async getProgressResetsForFiles(bookFileIds: number[], userId: number) {
const byFile = new Map<number, Date>();
for (const batch of chunk([...new Set(bookFileIds)], BATCH_QUERY_SIZE)) {
const rows = await this.db
.select({ bookFileId: schema.koreaderProgressResets.bookFileId, resetAt: schema.koreaderProgressResets.resetAt })
.from(schema.koreaderProgressResets)
.where(and(inArray(schema.koreaderProgressResets.bookFileId, batch), eq(schema.koreaderProgressResets.userId, userId)));
for (const row of rows) byFile.set(row.bookFileId, row.resetAt);
}
return byFile;
}
/**
* The file a book's KOReader progress hangs off, in the shape the shared-progress path needs.
* Resolved through the book's primary file, matching findBookFileIdByBookId: the book page
* reports one file's holds, so the release action has to act on that same file rather than
* whichever one a marker happens to be found on first.
*/
async findProgressBookFileByBookId(bookId: number, accessibleLibraryIds: number[] | null) {
if (accessibleLibraryIds !== null && accessibleLibraryIds.length === 0) return null;
const [row] = await this.db
.select({
id: schema.bookFiles.id,
bookId: schema.bookFiles.bookId,
libraryId: schema.books.libraryId,
format: schema.bookFiles.format,
})
.from(schema.bookFiles)
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
.where(
and(
eq(schema.books.id, bookId),
eq(schema.books.primaryFileId, schema.bookFiles.id),
// Scoped the same way every other entry point into this module is. A device progress
// row outlives the library grant that created it, so ownership of the row is not
// ownership of the book, and this path writes.
accessibleLibraryIds === null ? undefined : inArray(schema.books.libraryId, accessibleLibraryIds),
),
)
.limit(1);
return row ?? null;
}
async getDeviceProgressForDevice(bookFileId: number, userId: number, deviceId: string) {
const [row] = await this.db
.select()
.from(schema.koreaderDeviceProgress)
.where(
and(
eq(schema.koreaderDeviceProgress.bookFileId, bookFileId),
eq(schema.koreaderDeviceProgress.userId, userId),
eq(schema.koreaderDeviceProgress.deviceId, deviceId),
eq(schema.koreaderDeviceProgress.orphaned, false),
),
)
.limit(1);
return row ?? null;
}
/** Device ids that have taken the outstanding reset for this file. */
async getConvergedResetDeviceIds(bookFileId: number, userId: number) {
const rows = await this.db
.select({ deviceId: schema.koreaderProgressResetDevices.deviceId })
.from(schema.koreaderProgressResetDevices)
.where(and(eq(schema.koreaderProgressResetDevices.bookFileId, bookFileId), eq(schema.koreaderProgressResetDevices.userId, userId)));
return new Set(rows.map((row) => row.deviceId));
}
async getConvergedResetDeviceIdsForFiles(bookFileIds: number[], userId: number) {
const byFile = new Map<number, Set<string>>();
for (const batch of chunk([...new Set(bookFileIds)], BATCH_QUERY_SIZE)) {
const rows = await this.db
.select({ bookFileId: schema.koreaderProgressResetDevices.bookFileId, deviceId: schema.koreaderProgressResetDevices.deviceId })
.from(schema.koreaderProgressResetDevices)
.where(and(inArray(schema.koreaderProgressResetDevices.bookFileId, batch), eq(schema.koreaderProgressResetDevices.userId, userId)));
for (const row of rows) {
const existing = byFile.get(row.bookFileId);
if (existing) existing.add(row.deviceId);
else byFile.set(row.bookFileId, new Set([row.deviceId]));
}
}
return byFile;
}
/**
* Records that a device has landed on the reset position. Deliberately per-device and not a
* retirement of the marker: another device may still be holding a pre-reset position, and it
* has to keep being held until it takes the reset itself.
*/
async recordResetConvergence(bookFileId: number, userId: number, deviceId: string) {
await this.db.insert(schema.koreaderProgressResetDevices).values({ userId, bookFileId, deviceId }).onConflictDoNothing();
}
/** Retires a marker whose outcome is settled for every device, cascading the per-device rows. */
async clearProgressReset(bookFileId: number, userId: number) {
await this.db
.delete(schema.koreaderProgressResets)
.where(and(eq(schema.koreaderProgressResets.bookFileId, bookFileId), eq(schema.koreaderProgressResets.userId, userId)));
}
async getDevicesList(userId: number) {
const result = await this.db.execute<{
device: string;
@@ -60,6 +60,14 @@ describe('KoreaderService', () => {
getLatestDeviceProgress: ReturnType<typeof vi.fn>;
getDeviceProgressForFiles: ReturnType<typeof vi.fn>;
getReadingProgressUpdatedAtForFiles: ReturnType<typeof vi.fn>;
getProgressReset: ReturnType<typeof vi.fn>;
getProgressResetsForFiles: ReturnType<typeof vi.fn>;
getConvergedResetDeviceIds: ReturnType<typeof vi.fn>;
getConvergedResetDeviceIdsForFiles: ReturnType<typeof vi.fn>;
recordResetConvergence: ReturnType<typeof vi.fn>;
clearProgressReset: ReturnType<typeof vi.fn>;
findProgressBookFileByBookId: ReturnType<typeof vi.fn>;
getDeviceProgressForDevice: ReturnType<typeof vi.fn>;
getReadingProgress: ReturnType<typeof vi.fn>;
getTotalSyncedBooks: ReturnType<typeof vi.fn>;
getDevicesList: ReturnType<typeof vi.fn>;
@@ -132,6 +140,14 @@ describe('KoreaderService', () => {
getLatestDeviceProgress: vi.fn(),
getDeviceProgressForFiles: vi.fn().mockResolvedValue(new Map()),
getReadingProgressUpdatedAtForFiles: vi.fn().mockResolvedValue(new Map()),
getProgressReset: vi.fn().mockResolvedValue(null),
getProgressResetsForFiles: vi.fn().mockResolvedValue(new Map()),
getConvergedResetDeviceIds: vi.fn().mockResolvedValue(new Set<string>()),
getConvergedResetDeviceIdsForFiles: vi.fn().mockResolvedValue(new Map()),
recordResetConvergence: vi.fn().mockResolvedValue(undefined),
clearProgressReset: vi.fn().mockResolvedValue(undefined),
findProgressBookFileByBookId: vi.fn().mockResolvedValue(null),
getDeviceProgressForDevice: vi.fn().mockResolvedValue(null),
getReadingProgress: vi.fn(),
getTotalSyncedBooks: vi.fn(),
getDevicesList: vi.fn().mockResolvedValue([]),
@@ -557,7 +573,7 @@ describe('KoreaderService', () => {
expect.objectContaining({ bookFileId: 11, syncTimestamp: null }),
]);
expect(mockRepo.upsertReadingProgress).toHaveBeenCalledTimes(2);
expect(result).toEqual({ shared: 2, stale: 0 });
expect(result).toEqual({ shared: 2, stale: 0, held: 0 });
});
it('routes each entry by its own format within a single sweep', async () => {
@@ -586,7 +602,7 @@ describe('KoreaderService', () => {
});
it('issues no query for an empty entry list', async () => {
await expect(service.applyBulkProgress(7, [], device)).resolves.toEqual({ shared: 0, stale: 0 });
await expect(service.applyBulkProgress(7, [], device)).resolves.toEqual({ shared: 0, stale: 0, held: 0 });
expect(mockRepo.getDeviceProgressForFiles).not.toHaveBeenCalled();
expect(mockRepo.upsertDeviceProgressMany).not.toHaveBeenCalled();
@@ -602,7 +618,7 @@ describe('KoreaderService', () => {
expect(mockRepo.upsertDeviceProgressMany).toHaveBeenCalledTimes(1);
expect(mockRepo.upsertReadingProgress).not.toHaveBeenCalled();
expect(mockChapterExtractor.extractAndStoreChapters).not.toHaveBeenCalled();
expect(result).toEqual({ shared: 0, stale: 1 });
expect(result).toEqual({ shared: 0, stale: 1, held: 0 });
});
it('treats the web reader position as newer known state', async () => {
@@ -610,7 +626,7 @@ describe('KoreaderService', () => {
const result = await service.applyBulkProgress(7, [{ bookFile: bookFile(10), percentage: 0.2, timestamp: 1700000000 }], device);
expect(result).toEqual({ shared: 0, stale: 1 });
expect(result).toEqual({ shared: 0, stale: 1, held: 0 });
});
it('never treats an entry without a device timestamp as stale', async () => {
@@ -620,7 +636,7 @@ describe('KoreaderService', () => {
const result = await service.applyBulkProgress(7, [{ bookFile: bookFile(10), percentage: 0.2 }], device);
expect(result).toEqual({ shared: 1, stale: 0 });
expect(result).toEqual({ shared: 1, stale: 0, held: 0 });
});
it('lets a later entry for the same file observe the position this batch already applied', async () => {
@@ -633,7 +649,7 @@ describe('KoreaderService', () => {
device,
);
expect(result).toEqual({ shared: 1, stale: 1 });
expect(result).toEqual({ shared: 1, stale: 1, held: 0 });
// One row per file: the last entry wins, exactly as a sequential upsert would leave it.
expect(mockRepo.upsertDeviceProgressMany.mock.calls[0]![0]).toEqual([expect.objectContaining({ bookFileId: 10, percentage: 0.3 })]);
});
@@ -670,6 +686,248 @@ describe('KoreaderService', () => {
});
});
describe('progress resets', () => {
const resetAt = new Date('2026-02-02T12:00:00.000Z');
const device = { device: 'Kobo Libra 2', deviceId: 'device-a' };
function bookFile(id: number, bookId = id * 10, format: string | null = 'epub') {
return { id, bookId, libraryId: 1, format };
}
it('answers a pending reset with a real start position, not an empty one', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
const pulled = await service.getProgress(7, 'doc-hash');
// Stock kosync feeds progress straight to GotoXPointer with no percentage fallback, so an
// empty string leaves the reader exactly where it was.
expect(pulled).toEqual(expect.objectContaining({ percentage: 0, progress: '/body/DocFragment[1]/body', device: 'web' }));
expect(mockRepo.getLatestDeviceProgress).not.toHaveBeenCalled();
});
it('sends the page form of the start position for a paged document', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'cbz' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
expect((await service.getProgress(7, 'doc-hash'))?.progress).toBe('1');
});
it('stamps each delivery as current so the client reads it as a forward sync', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
const pulled = await service.getProgress(7, 'doc-hash');
// Both clients compare this against their last page turn and default backward syncs to
// disabled, so the original reset time would be dropped without ever prompting.
expect(pulled!.timestamp).toBeGreaterThan(Math.floor(resetAt.getTime() / 1000));
});
it('keeps the reset after serving it, because delivery is not application', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
await service.getProgress(7, 'doc-hash');
expect(mockRepo.clearProgressReset).not.toHaveBeenCalled();
expect(mockRepo.recordResetConvergence).not.toHaveBeenCalled();
});
it('records a stale push while a reset is pending but holds it out of shared progress', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
await service.saveProgress(7, { document: 'doc-hash', percentage: 0.42, progress: '/body/DocFragment[6]/body', device_id: 'device-1' });
expect(mockRepo.upsertDeviceProgress).toHaveBeenCalledTimes(1);
expect(mockRepo.upsertReadingProgress).not.toHaveBeenCalled();
expect(mockBookService.autoUpdateReadStatusForProgress).not.toHaveBeenCalled();
});
it('lets a push that reports the start position through, and marks that device converged', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
await service.saveProgress(7, { document: 'doc-hash', percentage: 0.004, progress: '/body/DocFragment[1]/body', device_id: 'device-1' });
expect(mockRepo.recordResetConvergence).toHaveBeenCalledWith(10, 7, 'device-1');
expect(mockRepo.upsertReadingProgress).toHaveBeenCalledTimes(1);
});
it('judges convergence on the position, so page one of a short book still counts', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'cbz' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
// Page 1 of a hundred-page comic reports 1%, which no threshold meaning "the start of a
// long book" could accept, and that device would be held forever.
await service.saveProgress(7, { document: 'doc-hash', percentage: 0.01, progress: '1', device_id: 'device-1' });
expect(mockRepo.recordResetConvergence).toHaveBeenCalledWith(10, 7, 'device-1');
});
it('does not accept an early position that is not the start', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'cbz' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
await service.saveProgress(7, { document: 'doc-hash', percentage: 0.008, progress: '9', device_id: 'device-1' });
expect(mockRepo.recordResetConvergence).not.toHaveBeenCalled();
expect(mockRepo.upsertReadingProgress).not.toHaveBeenCalled();
});
it('retires the reset once a converged device reads on, so the pull stops serving it', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
mockRepo.getConvergedResetDeviceIds.mockResolvedValue(new Set(['device-1']));
await service.saveProgress(7, { document: 'doc-hash', percentage: 0.5, progress: '/body/DocFragment[9]/body', device_id: 'device-1' });
// The pull is anonymous, so a marker left alive past its purpose is answered to this
// device too, and it would be sent back to the start on every sync.
expect(mockRepo.clearProgressReset).toHaveBeenCalledWith(10, 7);
});
it('does not retire the reset while a converged device is still sitting at the start', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
mockRepo.getConvergedResetDeviceIds.mockResolvedValue(new Set(['device-1']));
await service.saveProgress(7, { document: 'doc-hash', percentage: 0, progress: '/body/DocFragment[1]/body', device_id: 'device-1' });
expect(mockRepo.clearProgressReset).not.toHaveBeenCalled();
});
it('does not treat a single-fragment EPUB position as the start on percentage alone', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
// Every position in a one-spine EPUB is inside DocFragment[1], so the fragment alone
// would call this device converged wherever it happens to be sitting.
await service.saveProgress(7, { document: 'doc-hash', percentage: 0.5, progress: '/body/DocFragment[1]/body/p[80]', device_id: 'device-1' });
expect(mockRepo.recordResetConvergence).not.toHaveBeenCalled();
expect(mockRepo.upsertReadingProgress).not.toHaveBeenCalled();
});
it('keeps holding a second device after the first one has converged', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
mockRepo.getConvergedResetDeviceIds.mockResolvedValue(new Set(['device-1']));
await service.saveProgress(7, { document: 'doc-hash', percentage: 0.42, progress: '/body/DocFragment[6]/body', device_id: 'device-2' });
expect(mockRepo.upsertReadingProgress).not.toHaveBeenCalled();
});
it('lets a device that already converged push freely', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
mockRepo.getConvergedResetDeviceIds.mockResolvedValue(new Set(['device-1']));
await service.saveProgress(7, { document: 'doc-hash', percentage: 0.55, progress: '/body/DocFragment[9]/body', device_id: 'device-1' });
expect(mockRepo.upsertReadingProgress).toHaveBeenCalledTimes(1);
});
it('holds swept positions behind a pending reset and reports them separately from stale ones', async () => {
mockRepo.getProgressResetsForFiles.mockResolvedValue(new Map([[10, resetAt]]));
const result = await service.applyBulkProgress(
7,
[
{ bookFile: bookFile(10), percentage: 0.42, progress: '/body/DocFragment[6]/body' },
{ bookFile: bookFile(11), percentage: 0.3 },
],
device,
);
expect(result).toEqual({ shared: 1, stale: 0, held: 1 });
// The device row is still written for the held file: nothing the device reported is lost,
// it just does not move the book.
expect(mockRepo.upsertDeviceProgressMany.mock.calls[0]![0]).toHaveLength(2);
expect(mockRepo.upsertReadingProgress).toHaveBeenCalledTimes(1);
});
it('does not report a held device as KOReader-latest', async () => {
mockRepo.findBookFileIdByBookId.mockResolvedValue(10);
mockRepo.getProgressReset.mockResolvedValue(resetAt);
mockRepo.getBookProgressForDashboard.mockResolvedValue({
deviceProgress: [
{ device: 'Kobo Libra', deviceId: 'device-1', percentage: 0.42, chapterIndex: 3, updatedAt: new Date('2026-02-02T13:00:00.000Z') },
],
readingProgress: null,
});
mockRepo.getChapters.mockResolvedValue([]);
mockRepo.getLastFileWriteTime.mockResolvedValue(null);
const info = await service.getBookProgress(7, 20);
expect(info?.canonicalSource).toBe('web_reader');
expect(info?.canonicalPercentage).toBe(0);
// Reported rather than hidden, so the hold is legible instead of looking like a lost sync.
expect(info?.heldByReset).toEqual([expect.objectContaining({ deviceId: 'device-1', percentage: 42 })]);
});
it('counts a converged device as canonical again even while the marker is still live', async () => {
mockRepo.findBookFileIdByBookId.mockResolvedValue(10);
mockRepo.getProgressReset.mockResolvedValue(resetAt);
mockRepo.getConvergedResetDeviceIds.mockResolvedValue(new Set(['device-1']));
mockRepo.getBookProgressForDashboard.mockResolvedValue({
deviceProgress: [
{ device: 'Kobo Libra', deviceId: 'device-1', percentage: 0.2, chapterIndex: 1, updatedAt: new Date('2026-02-02T13:00:00.000Z') },
],
readingProgress: null,
});
mockRepo.getChapters.mockResolvedValue([]);
mockRepo.getLastFileWriteTime.mockResolvedValue(null);
const info = await service.getBookProgress(7, 20);
expect(info?.canonicalSource).toBe('koreader');
expect(info?.heldByReset).toEqual([]);
});
it('releasing a hold accepts that device position and retires the reset', async () => {
mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getDeviceProgressForDevice.mockResolvedValue({ percentage: 0.42, progress: '/body/DocFragment[6]/body', syncTimestamp: null });
mockRepo.getProgressReset.mockResolvedValue(resetAt);
await service.releaseResetHold(7, 20, 'device-1');
// Recording convergence instead would leave the marker live, and the next pull would ask
// this same device to go back to the start.
expect(mockRepo.clearProgressReset).toHaveBeenCalledWith(10, 7);
expect(mockRepo.upsertReadingProgress).toHaveBeenCalledTimes(1);
});
it('refuses to release a hold for a device with no recorded position', async () => {
mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getDeviceProgressForDevice.mockResolvedValue(null);
await expect(service.releaseResetHold(7, 20, 'device-1')).rejects.toThrow(NotFoundException);
});
it('answers a release with no pending reset as not found rather than a database error', async () => {
mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
mockRepo.getDeviceProgressForDevice.mockResolvedValue({ percentage: 0.42, progress: '/body/DocFragment[6]/body', syncTimestamp: null });
mockRepo.getProgressReset.mockResolvedValue(null);
await expect(service.releaseResetHold(7, 20, 'device-1')).rejects.toThrow(NotFoundException);
expect(mockRepo.upsertReadingProgress).not.toHaveBeenCalled();
});
it('resolves the release target only within libraries the user can still reach', async () => {
mockRepo.getAccessibleLibraryIds.mockResolvedValue([3]);
mockRepo.findProgressBookFileByBookId.mockResolvedValue(null);
// A device progress row outlives the library grant that created it, so holding a row is
// not proof of access to the book it points at.
await expect(service.releaseResetHold(7, 20, 'device-1')).rejects.toThrow(NotFoundException);
expect(mockRepo.findProgressBookFileByBookId).toHaveBeenCalledWith(20, [3]);
});
});
describe('getProgress', () => {
it('returns device progress when the device sync is latest', async () => {
const latestDeviceTime = new Date('2026-02-01T10:00:00.000Z');
@@ -1342,6 +1600,7 @@ describe('KoreaderService', () => {
},
],
fileModifiedSinceLastSync: true,
heldByReset: [],
});
});
@@ -1400,6 +1659,7 @@ describe('KoreaderService', () => {
},
],
fileModifiedSinceLastSync: false,
heldByReset: [],
});
});
+199 -12
View File
@@ -29,6 +29,19 @@ const FILE_NAMING_CACHE_TTL_MS = 30_000;
const FILE_NAMING_CACHE_MAX_ENTRIES = 5_000;
const SHARED_PROGRESS_CONCURRENCY = 4;
const CHAPTER_EXTRACTION_CONCURRENCY = 2;
const RESET_DEVICE = 'web';
const RESET_DEVICE_ID = 'bookorbit-web';
/** The start of a paged document, in the page-number form KOReader sends for those formats. */
const RESET_PAGED_POSITION = '1';
/** The start of a reflowable document, in the same xpointer shape the web branch synthesises. */
const RESET_REFLOWABLE_POSITION = '/body/DocFragment[1]/body';
/**
* Only used when a device reports no position at all. A percentage cannot carry this on its
* own: page 1 of a hundred-page comic already reads as 1%, so any threshold tight enough to
* mean "the start" of a long book would never be reachable on a short one, and that device
* would stay held forever.
*/
const RESET_CONVERGED_PERCENTAGE_FALLBACK = 0.01;
/** `format` routes the incoming position to the cfi or the pageNumber column. */
export interface KoreaderProgressBookFile {
@@ -197,10 +210,54 @@ export class KoreaderService {
if (options?.skipSharedProgress) return;
if (!(await this.resolveResetHold(userId, bookFile, data))) return;
const previousPercentage = previousDeviceProgress?.percentage != null ? toBookorbitPercentage(previousDeviceProgress.percentage) : null;
await this.applySharedProgress(userId, bookFile, data, previousPercentage);
}
/**
* Decides whether a pushed position may move shared progress while a reset is outstanding.
*
* The push clock cannot answer this. KOReader stamps a push with the time it sends it, not
* the time the position was reached, so a device replaying a position from before the reset
* looks exactly as recent as one that genuinely read on afterwards. What separates them is
* whether that device has been observed at the reset position since the reset was made.
*
* The judgement is per device. Serving the reset proves nothing, because both clients can
* classify it as a backward sync and drop it without telling anyone, so only the device's
* own push counts, and one device converging says nothing about the others.
*/
private async resolveResetHold(
userId: number,
bookFile: KoreaderProgressBookFile,
data: { percentage: number; progress?: string; device: string; deviceId: string },
): Promise<boolean> {
const resetAt = await this.repo.getProgressReset(bookFile.id, userId);
if (!resetAt) return true;
const atStart = isResetStartPosition(bookFile.format, data.percentage, data.progress);
const converged = await this.repo.getConvergedResetDeviceIds(bookFile.id, userId);
if (converged.has(data.deviceId)) {
// This device took the reset and has since read on, so the reset has done its work and
// must stop being served. The pull carries no device identity, so a marker kept alive
// for some other device would be answered to this one too, and it would be asked to
// jump back to the start on every sync for the rest of the book.
if (!atStart) await this.repo.clearProgressReset(bookFile.id, userId);
return true;
}
if (atStart) {
await this.repo.recordResetConvergence(bookFile.id, userId, data.deviceId);
return true;
}
this.logger.log(
`[${SYNC_EVENT}] [end] userId=${userId} bookFileId=${bookFile.id} device="${sanitizeLogValue(data.device)}" deviceId="${sanitizeLogValue(data.deviceId)}" percentage=${data.percentage} resetAt=${resetAt.toISOString()} held=true - device position recorded but held behind a pending reset`,
);
return false;
}
/**
* Bulk sibling of applyProgressForResolvedFile for whole-library sweeps. Reads every
* file's device and reader progress in bounded batch queries, decides staleness in
@@ -216,13 +273,15 @@ export class KoreaderService {
userId: number,
entries: BulkProgressEntry[],
device: { device: string; deviceId: string },
): Promise<{ shared: number; stale: number }> {
if (entries.length === 0) return { shared: 0, stale: 0 };
): Promise<{ shared: number; stale: number; held: number }> {
if (entries.length === 0) return { shared: 0, stale: 0, held: 0 };
const bookFileIds = entries.map((entry) => entry.bookFile.id);
const [deviceRows, readerUpdatedAt] = await Promise.all([
const [deviceRows, readerUpdatedAt, resets, convergedByFile] = await Promise.all([
this.repo.getDeviceProgressForFiles(bookFileIds, userId),
this.repo.getReadingProgressUpdatedAtForFiles(bookFileIds, userId),
this.repo.getProgressResetsForFiles(bookFileIds, userId),
this.repo.getConvergedResetDeviceIdsForFiles(bookFileIds, userId),
]);
const appliedAt = new Date();
@@ -251,7 +310,7 @@ export class KoreaderService {
// sees what a sequential apply would have seen.
state.ownDeviceNewest = entry.timestamp ?? appliedAtSeconds;
state.latestPercentage = entry.percentage;
return { entry, stale, previousPercentage };
return { entry, stale, previousPercentage, heldByReset: false };
});
const deviceUpserts = new Map<number, DeviceProgressUpsert>();
@@ -270,7 +329,34 @@ export class KoreaderService {
await this.repo.upsertDeviceProgressMany([...deviceUpserts.values()], appliedAt);
await this.repo.restoreDevice(userId, device.deviceId);
const shared = plans.filter((plan) => !plan.stale);
// A sweep carries the device's whole shelf, so a book the user reset in BookOrbit shows up
// here holding its pre-reset sidecar position. Same judgement as a single push, for the one
// device this sweep speaks for.
const convergedResetFileIds = new Set<number>();
const retiredResetFileIds = new Set<number>();
for (const plan of plans) {
if (plan.stale) continue;
const bookFileId = plan.entry.bookFile.id;
if (!resets.get(bookFileId)) continue;
const atStart = isResetStartPosition(plan.entry.bookFile.format, plan.entry.percentage, plan.entry.progress);
if (convergedByFile.get(bookFileId)?.has(device.deviceId)) {
if (!atStart) retiredResetFileIds.add(bookFileId);
continue;
}
if (atStart) convergedResetFileIds.add(bookFileId);
else plan.heldByReset = true;
}
for (const bookFileId of convergedResetFileIds) await this.repo.recordResetConvergence(bookFileId, userId, device.deviceId);
for (const bookFileId of retiredResetFileIds) await this.repo.clearProgressReset(bookFileId, userId);
const held = plans.filter((plan) => plan.heldByReset).length;
if (held > 0) {
this.logger.log(
`[${SYNC_EVENT}] [end] userId=${userId} deviceId="${sanitizeLogValue(device.deviceId)}" held=${held} - bulk positions recorded but held behind pending resets`,
);
}
const shared = plans.filter((plan) => !plan.stale && !plan.heldByReset);
// Chapter extraction parses an EPUB per file, so it stays off the request path as it
// does for a single sync, but bounded instead of one unawaited call per item.
const extractionFileIds = [...new Set(shared.map((plan) => plan.entry.bookFile.id))];
@@ -297,7 +383,7 @@ export class KoreaderService {
}
});
return { shared: shared.length, stale: plans.length - shared.length };
return { shared: shared.length, stale: plans.filter((plan) => plan.stale).length, held };
}
private async applySharedProgress(
@@ -352,6 +438,34 @@ export class KoreaderService {
if (!bookFile) return null;
// An outstanding reset is answered before anything stored, and answered with a position
// rather than with silence. An empty body reads as "this server knows nothing about your
// book", which is the one reply that leaves the device certain its own position is the
// only one there is, and it pushes it back. Zero attributed to BookOrbit is a position the
// device can act on, and the plugin's default forward strategy puts it to the reader.
const resetAt = await this.repo.getProgressReset(bookFile.id, userId);
if (resetAt) {
this.logger.log(
`[${SYNC_EVENT}] [end] userId=${userId} bookFileId=${bookFile.id} resetAt=${resetAt.toISOString()} - reset position served to device`,
);
return {
document: documentHash,
percentage: 0,
// A real position, not an empty string: KOReader's own kosync plugin feeds this
// straight to GotoPage or GotoXPointer with no percentage fallback, so an empty value
// leaves it sitting exactly where it was. This is also the position a converged push
// has to report back, so what we ask for and what we accept are the same thing.
progress: isPagedReadingFormat(bookFile.format) ? RESET_PAGED_POSITION : RESET_REFLOWABLE_POSITION,
device: RESET_DEVICE,
device_id: RESET_DEVICE_ID,
// Stamped now rather than when the user reset, because a live marker is a standing
// instruction. Both clients compare this against their last page turn and default
// backward syncs to disabled, so the original time would lose that comparison on any
// pull deferred past a page turn, and be discarded without ever prompting.
timestamp: Math.floor(Date.now() / 1000),
};
}
const latestDevice = await this.repo.getLatestDeviceProgress(bookFile.id, userId);
const readingProg = await this.repo.getReadingProgress(bookFile.id, userId);
@@ -593,13 +707,22 @@ export class KoreaderService {
if (deviceProgress.length === 0 && !readingProgress) return null;
const chapters = await this.repo.getChapters(bookFileId);
const latestDevice = deviceProgress[0];
const deviceTime = latestDevice?.updatedAt?.getTime() ?? 0;
const readerTime = readingProgress?.updatedAt?.getTime() ?? 0;
const isKoreaderLatest = latestDevice && deviceTime >= readerTime;
const canonicalPercentage = isKoreaderLatest ? toBookorbitPercentage(latestDevice.percentage ?? 0) : (readingProgress?.percentage ?? 0);
const canonicalChapterIndex = isKoreaderLatest ? (latestDevice.chapterIndex ?? null) : null;
// A held device's row can be newer than everything else and still not be the canonical
// position, because it is a position the user asked to discard. Scoped to the devices
// actually being held: once a device takes the reset, its reading counts again even though
// the marker stays live for whichever devices have not.
const resetAt = await this.repo.getProgressReset(bookFileId, userId);
const convergedDeviceIds = resetAt ? await this.repo.getConvergedResetDeviceIds(bookFileId, userId) : null;
const isHeld = (deviceId: string) => convergedDeviceIds !== null && !convergedDeviceIds.has(deviceId);
const heldDevices = deviceProgress.filter((dp) => isHeld(dp.deviceId));
const liveDevice = deviceProgress.find((dp) => !isHeld(dp.deviceId));
const liveDeviceTime = liveDevice?.updatedAt?.getTime() ?? 0;
const isKoreaderLatest = liveDevice && liveDeviceTime >= readerTime;
const canonicalPercentage = isKoreaderLatest ? toBookorbitPercentage(liveDevice.percentage ?? 0) : (readingProgress?.percentage ?? 0);
const canonicalTime = isKoreaderLatest ? Math.max(liveDeviceTime, readerTime) : readerTime;
const canonicalChapterIndex = isKoreaderLatest ? (liveDevice.chapterIndex ?? null) : null;
const lastWriteTime = await this.repo.getLastFileWriteTime(bookFileId);
const fileModifiedSinceLastSync =
@@ -616,7 +739,7 @@ export class KoreaderService {
canonicalChapterIndex,
canonicalChapterTitle: canonicalChapterIndex != null ? (chapters.find((c) => c.chapterIndex === canonicalChapterIndex)?.title ?? null) : null,
canonicalSource: isKoreaderLatest ? 'koreader' : 'web_reader',
canonicalUpdatedAt: new Date(Math.max(deviceTime, readerTime)).toISOString(),
canonicalUpdatedAt: new Date(canonicalTime).toISOString(),
devices: deviceProgress.map((dp) => ({
device: dp.device,
deviceId: dp.deviceId,
@@ -626,8 +749,72 @@ export class KoreaderService {
updatedAt: dp.updatedAt!.toISOString(),
})),
fileModifiedSinceLastSync,
heldByReset: heldDevices.map((dp) => ({
device: dp.device,
deviceId: dp.deviceId,
percentage: toBookorbitPercentage(dp.percentage ?? 0),
updatedAt: dp.updatedAt!.toISOString(),
})),
};
}
/**
* Accepts a held device's position and stops holding it. The reader answered the prompt on
* the device and said no, and without this the only ways out are reading the book in
* BookOrbit or resetting again, neither of which is reachable from seeing the hold.
*/
async releaseResetHold(userId: number, bookId: number, deviceId: string): Promise<void> {
const startedAt = Date.now();
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(userId);
const bookFile = await this.repo.findProgressBookFileByBookId(bookId, accessibleLibraryIds);
if (!bookFile) throw new NotFoundException(`No synced file found for book ${bookId}`);
const held = await this.repo.getDeviceProgressForDevice(bookFile.id, userId, deviceId);
if (!held) throw new NotFoundException(`No KOReader progress found for device on book ${bookId}`);
// Checked before writing, so a hold released twice, or released as the device converges on
// its own, answers with a plain 404 rather than tripping the marker's foreign key.
if ((await this.repo.getProgressReset(bookFile.id, userId)) === null) {
throw new NotFoundException(`No pending reset for book ${bookId}`);
}
// Retired outright rather than recorded as convergence: the reader has said this position
// is the one they want, so leaving the marker live would have the next pull answer zero
// and ask the same device to go back to the start again.
await this.repo.clearProgressReset(bookFile.id, userId);
await this.applySharedProgress(
userId,
bookFile,
{ percentage: held.percentage ?? 0, progress: held.progress ?? undefined, timestamp: held.syncTimestamp ?? undefined },
null,
);
this.logger.log(
`[${SYNC_EVENT}] [end] userId=${userId} bookFileId=${bookFile.id} deviceId="${sanitizeLogValue(deviceId)}" durationMs=${Date.now() - startedAt} - reset hold released to the device position`,
);
}
}
/**
* Whether a reported position is the start of the book, which is what a device landing on a
* reset looks like. Routed by format because KOReader describes the two kinds of document
* differently, and judged on the position rather than the percentage wherever one is sent.
*/
export function isResetStartPosition(format: string | null | undefined, percentage: number, progress?: string | null): boolean {
if (isPagedReadingFormat(format)) {
const page = parseKoreaderPageNumber(progress);
if (page !== null) return page <= 1;
} else {
const trimmed = progress?.trim();
// The percentage has to agree for a reflowable book. An EPUB with a single spine item puts
// every position in the first fragment, so the fragment alone would call a device converged
// wherever it happens to be sitting. Paged formats need no such guard, because a page
// number says exactly where the reader is however short the book.
if (trimmed) {
return /^\/body\/DocFragment\[1\](\/|$)/.test(trimmed) && percentage <= RESET_CONVERGED_PERCENTAGE_FALLBACK;
}
}
return percentage <= RESET_CONVERGED_PERCENTAGE_FALLBACK;
}
function toBookorbitPercentage(koreaderPct: number): number {
@@ -4,6 +4,7 @@ import {
audiobookProgress,
koreaderDeviceProgress,
koreaderPageStats,
koreaderProgressResets,
koboReadingStates,
readingProgress,
readingSessions,
@@ -38,9 +39,12 @@ function makeHarness(selectResults: unknown[]) {
const statusValues = vi.fn(() => ({ onConflictDoUpdate: statusConflict }));
const dailyConflict = vi.fn().mockResolvedValue(undefined);
const dailyValues = vi.fn(() => ({ onConflictDoUpdate: dailyConflict }));
const resetConflict = vi.fn().mockResolvedValue(undefined);
const resetValues = vi.fn(() => ({ onConflictDoUpdate: resetConflict }));
const insert = vi.fn((table: unknown) => {
if (table === userBookStatus) return { values: statusValues };
if (table === userReadingDailyStats) return { values: dailyValues };
if (table === koreaderProgressResets) return { values: resetValues };
throw new Error('Unexpected table in insert');
});
@@ -62,6 +66,7 @@ function makeHarness(selectResults: unknown[]) {
statusConflict,
dailyValues,
dailyConflict,
resetValues,
update,
updateSet,
};
@@ -83,17 +88,20 @@ describe('ReadingStateRepository', () => {
durationSeconds: 1800,
progressDelta: 6,
};
const { repo, transaction, tx, deleteFn, statusValues, statusConflict, dailyValues, dailyConflict, update, updateSet } = makeHarness([
[{ libraryId: 3 }],
[deletedSession],
[],
[{ total: 2 }],
[{ total: 1 }],
[{ total: 3 }],
[{ total: 4 }],
[{ id: 88 }],
[remainingSession],
]);
const { repo, transaction, tx, deleteFn, statusValues, statusConflict, dailyValues, dailyConflict, resetValues, update, updateSet } = makeHarness(
[
[{ libraryId: 3 }],
[deletedSession],
[],
[{ total: 2 }],
[{ total: 1 }],
[{ total: 3 }],
[{ total: 4 }],
[{ id: 88 }],
[{ id: 501 }, { id: 502 }],
[remainingSession],
],
);
const result = await repo.resetBookReadingState(7, 42, 'UTC');
@@ -103,6 +111,12 @@ describe('ReadingStateRepository', () => {
expect(deleteFn).toHaveBeenCalledWith(koreaderDeviceProgress);
expect(deleteFn).toHaveBeenCalledWith(koreaderPageStats);
expect(deleteFn).toHaveBeenCalledWith(readingSessions);
// The wipe has to leave a reset behind for each file, or a KOReader device reopening the
// book reads the missing position as unknown and pushes its own straight back.
expect(resetValues).toHaveBeenCalledWith([
{ userId: 7, bookFileId: 501 },
{ userId: 7, bookFileId: 502 },
]);
expect(deleteFn).toHaveBeenCalledWith(userReadingDailyStats);
expect(statusValues).toHaveBeenCalledWith(
expect.objectContaining({
@@ -21,6 +21,7 @@ import {
koboSnapshotBooks,
koreaderDeviceProgress,
koreaderPageStats,
koreaderProgressResets,
readingProgress,
readingAttempts,
readingSessions,
@@ -74,6 +75,10 @@ export class ReadingStateRepository {
const now = new Date();
const nowIso = now.toISOString();
// Materialised separately from the subquery above because the reset markers are
// inserted per file, and a wiped book still has to be able to tell a device that its
// position is gone rather than merely unknown.
const resetFileIds = (await tx.select({ id: bookFiles.id }).from(bookFiles).where(eq(bookFiles.bookId, bookId))).map((file) => file.id);
await Promise.all([
tx.delete(readingProgress).where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, fileIds))),
@@ -81,6 +86,12 @@ export class ReadingStateRepository {
tx.delete(koreaderDeviceProgress).where(and(eq(koreaderDeviceProgress.userId, userId), inArray(koreaderDeviceProgress.bookFileId, fileIds))),
tx.delete(koreaderPageStats).where(and(eq(koreaderPageStats.userId, userId), inArray(koreaderPageStats.bookFileId, fileIds))),
tx.delete(readingSessions).where(and(eq(readingSessions.userId, userId), eq(readingSessions.bookId, bookId))),
resetFileIds.length > 0
? tx
.delete(koreaderProgressResets)
.where(and(eq(koreaderProgressResets.userId, userId), inArray(koreaderProgressResets.bookFileId, resetFileIds)))
.then(() => tx.insert(koreaderProgressResets).values(resetFileIds.map((bookFileId) => ({ userId, bookFileId }))))
: Promise.resolve(),
tx.delete(readingAttempts).where(and(eq(readingAttempts.userId, userId), eq(readingAttempts.bookId, bookId))),
tx
.insert(userBookStatus)
@@ -0,0 +1,341 @@
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import { createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
import {
authHeader,
closeReaderStateIsolationE2EContext,
createLibraryWithFolder,
createReaderStateIsolationE2EContext,
locateBookByAbsolutePath,
triggerAndWaitForLibraryScan,
type CreatedLibrary,
type LocatedBookFile,
type ReaderStateIsolationE2EContext,
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
const KOREADER_USERNAME = `progress-reset-device-${randomUUID().slice(0, 8)}`;
const KOREADER_PASSWORD = 'ProgressResetPass123';
const XPOINTER = '/body/DocFragment[8]/body/p[12]/text().0';
/**
* A reset made in BookOrbit has to survive contact with a device that still holds the old
* position in its sidecar. The device cannot tell the server when it reached that position,
* only when it sent it, so these cases pin the behaviour that makes the reset win anyway:
* the pull answers with a position rather than with silence, and a push that predates the
* reset is recorded without moving the book.
*/
describe('KOReader progress reset propagation (e2e)', { timeout: 180_000 }, () => {
let ctx!: ReaderStateIsolationE2EContext;
let library!: CreatedLibrary;
let epub!: LocatedBookFile;
let epubHash!: string;
function deviceHeaders(): Record<string, string> {
return { 'x-auth-user': KOREADER_USERNAME, 'x-auth-key': KOREADER_PASSWORD };
}
async function pushFromDevice(percentage: number, progress = XPOINTER, deviceId = 'reset-device-1') {
const response = await ctx.app.inject({
method: 'PUT',
url: '/api/v1/koreader/syncs/progress',
headers: deviceHeaders(),
payload: { document: epubHash, percentage, progress, device: 'Kobo Libra', device_id: deviceId },
});
expect(response.statusCode).toBe(200);
}
async function pullFromDevice() {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/koreader/syncs/progress/${epubHash}`,
headers: deviceHeaders(),
});
expect(response.statusCode).toBe(200);
return response.json() as { percentage?: number; progress?: string | null; device?: string; device_id?: string; timestamp?: number };
}
async function clearProgressFromWeb() {
const response = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/files/${epub.bookFileId}/progress`,
headers: authHeader(ctx.adminToken),
});
expect([200, 204]).toContain(response.statusCode);
}
async function storedPercentage(): Promise<number | null> {
const [row] = await ctx.db
.select({ percentage: schema.readingProgress.percentage })
.from(schema.readingProgress)
.where(eq(schema.readingProgress.bookFileId, epub.bookFileId));
return row?.percentage ?? null;
}
async function deviceRows() {
return ctx.db
.select({ percentage: schema.koreaderDeviceProgress.percentage })
.from(schema.koreaderDeviceProgress)
.where(eq(schema.koreaderDeviceProgress.bookFileId, epub.bookFileId));
}
async function convergedDevices() {
return ctx.db
.select({ deviceId: schema.koreaderProgressResetDevices.deviceId })
.from(schema.koreaderProgressResetDevices)
.where(eq(schema.koreaderProgressResetDevices.bookFileId, epub.bookFileId));
}
async function pendingResets() {
return ctx.db
.select({ resetAt: schema.koreaderProgressResets.resetAt })
.from(schema.koreaderProgressResets)
.where(eq(schema.koreaderProgressResets.bookFileId, epub.bookFileId));
}
beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext();
library = await createLibraryWithFolder(ctx, { name: `koreader-progress-reset-${randomUUID()}` });
const epubPath = await createEpubFixture(library.folderPath, 'progress-reset-book.epub', {
title: `Progress Reset Book ${randomUUID()}`,
uid: `urn:uuid:${randomUUID()}`,
});
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
epub = await locateBookByAbsolutePath(ctx, epubPath);
const [row] = await ctx.db.select({ fileHash: schema.bookFiles.fileHash }).from(schema.bookFiles).where(eq(schema.bookFiles.id, epub.bookFileId));
expect(row?.fileHash).toBeTruthy();
epubHash = row!.fileHash!;
const credentials = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/credentials',
headers: authHeader(ctx.adminToken),
payload: { username: KOREADER_USERNAME, password: KOREADER_PASSWORD },
});
expect([200, 201]).toContain(credentials.statusCode);
}, 180_000);
afterAll(async () => {
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
});
beforeEach(async () => {
await ctx.db.delete(schema.koreaderProgressResets).where(eq(schema.koreaderProgressResets.bookFileId, epub.bookFileId));
await ctx.db.delete(schema.koreaderDeviceProgress).where(eq(schema.koreaderDeviceProgress.bookFileId, epub.bookFileId));
await ctx.db.delete(schema.readingProgress).where(eq(schema.readingProgress.bookFileId, epub.bookFileId));
});
it('clears the device position alongside the shared one', async () => {
await pushFromDevice(0.42);
expect(await deviceRows()).toHaveLength(1);
await clearProgressFromWeb();
// Leaving the device row behind is what let the old position be served straight back.
expect(await deviceRows()).toHaveLength(0);
expect(await storedPercentage()).toBeNull();
expect(await pendingResets()).toHaveLength(1);
});
it('answers the next pull with a start position a stock client can act on', async () => {
await pushFromDevice(0.42);
await clearProgressFromWeb();
const pulled = await pullFromDevice();
expect(pulled.percentage).toBe(0);
// Stock kosync has no percentage fallback, so an empty position would leave it put.
expect(pulled.progress).toBe('/body/DocFragment[1]/body');
expect(pulled.device).toBe('web');
expect(pulled.device_id).toBe('bookorbit-web');
});
it('stamps the delivery as current so the client reads it as a forward sync', async () => {
await pushFromDevice(0.42);
await clearProgressFromWeb();
const [marker] = await pendingResets();
const pulled = await pullFromDevice();
expect(pulled.timestamp).toBeGreaterThanOrEqual(Math.floor(marker!.resetAt.getTime() / 1000));
});
it('keeps the reset after serving it, because the client can discard it silently', async () => {
await pushFromDevice(0.42);
await clearProgressFromWeb();
await pullFromDevice();
expect(await pendingResets()).toHaveLength(1);
// Still held, so a reset the reader never saw cannot be undone by the next push.
await pushFromDevice(0.42);
expect(await storedPercentage()).toBeNull();
});
it('holds a push that predates the reset without losing what the device reported', async () => {
await pushFromDevice(0.42);
await clearProgressFromWeb();
await pushFromDevice(0.42);
// The push clock says "now" either way, so only the reset marker can tell these apart.
expect(await storedPercentage()).toBeNull();
expect(await deviceRows()).toHaveLength(1);
expect(await pendingResets()).toHaveLength(1);
});
it('keeps answering the reset while a device keeps replaying its old position', async () => {
await pushFromDevice(0.42);
await clearProgressFromWeb();
await pushFromDevice(0.42);
expect((await pullFromDevice()).percentage).toBe(0);
});
it('accepts a device once it reports the start position, and lets it read on', async () => {
await pushFromDevice(0.42);
await clearProgressFromWeb();
await pushFromDevice(0.004, '/body/DocFragment[1]/body');
expect(await convergedDevices()).toHaveLength(1);
expect(await storedPercentage()).toBeCloseTo(0.4, 5);
await pushFromDevice(0.15);
expect(await storedPercentage()).toBeCloseTo(15, 5);
});
it('stops answering with the reset once a device has taken it and read on', async () => {
await pushFromDevice(0.42);
await clearProgressFromWeb();
await pushFromDevice(0.004, '/body/DocFragment[1]/body');
await pushFromDevice(0.5, '/body/DocFragment[9]/body');
const pulled = await pullFromDevice();
expect(pulled.percentage).toBeCloseTo(0.5, 5);
});
it('keeps holding a second device after the first one has taken the reset', async () => {
await pushFromDevice(0.42, XPOINTER, 'reset-device-1');
await pushFromDevice(0.42, XPOINTER, 'reset-device-2');
await clearProgressFromWeb();
await pushFromDevice(0.004, '/body/DocFragment[1]/body', 'reset-device-1');
expect(await storedPercentage()).toBeCloseTo(0.4, 5);
// Device two never saw the reset, so its position is still not the book's.
await pushFromDevice(0.42, XPOINTER, 'reset-device-2');
expect(await storedPercentage()).toBeCloseTo(0.4, 5);
});
it('stops protecting a second device once the first has read on, which is the accepted bound', async () => {
await pushFromDevice(0.42, XPOINTER, 'reset-device-1');
await pushFromDevice(0.42, XPOINTER, 'reset-device-2');
await clearProgressFromWeb();
await pushFromDevice(0.004, '/body/DocFragment[1]/body', 'reset-device-1');
// Reading on retires the marker, because the pull carries no device identity and a marker
// kept alive for device two would send device one back to the start on every sync.
await pushFromDevice(0.5, '/body/DocFragment[9]/body', 'reset-device-1');
expect(await pendingResets()).toHaveLength(0);
// So device two's stale position lands. This is a deliberate bound, not an oversight:
// holding it would need device_id on the pull, which is a plugin change.
await pushFromDevice(0.42, XPOINTER, 'reset-device-2');
expect(await storedPercentage()).toBeCloseTo(42, 5);
});
it('a released hold is not re-served on the next pull', async () => {
await pushFromDevice(0.42);
await clearProgressFromWeb();
await pushFromDevice(0.42);
const released = await ctx.app.inject({
method: 'POST',
url: `/api/v1/koreader/books/${epub.bookId}/reset-hold/release`,
headers: authHeader(ctx.adminToken),
payload: { deviceId: 'reset-device-1' },
});
expect([200, 201]).toContain(released.statusCode);
// Leaving the marker live here would ask the device that was just released to go back.
expect((await pullFromDevice()).percentage).toBeCloseTo(0.42, 5);
});
it('answers a release with no pending reset as not found', async () => {
await pushFromDevice(0.42);
const released = await ctx.app.inject({
method: 'POST',
url: `/api/v1/koreader/books/${epub.bookId}/reset-hold/release`,
headers: authHeader(ctx.adminToken),
payload: { deviceId: 'reset-device-1' },
});
expect(released.statusCode).toBe(404);
});
it('releases a hold on request and takes that device position', async () => {
await pushFromDevice(0.42);
await clearProgressFromWeb();
await pushFromDevice(0.42);
const released = await ctx.app.inject({
method: 'POST',
url: `/api/v1/koreader/books/${epub.bookId}/reset-hold/release`,
headers: authHeader(ctx.adminToken),
payload: { deviceId: 'reset-device-1' },
});
expect([200, 201]).toContain(released.statusCode);
expect(await storedPercentage()).toBeCloseTo(42, 5);
});
it('re-arms every device when the book is reset a second time', async () => {
await pushFromDevice(0.42);
await clearProgressFromWeb();
await pushFromDevice(0.004, '/body/DocFragment[1]/body');
expect(await convergedDevices()).toHaveLength(1);
await pushFromDevice(0.5);
await clearProgressFromWeb();
// A device that took the previous reset has not taken this one.
expect(await convergedDevices()).toHaveLength(0);
await pushFromDevice(0.5);
expect(await storedPercentage()).toBeNull();
});
it('lets reading in BookOrbit retire a reset the device never collected', async () => {
await pushFromDevice(0.42);
await clearProgressFromWeb();
const saved = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/files/${epub.bookFileId}/progress`,
headers: authHeader(ctx.adminToken),
payload: { percentage: 30, cfi: 'epubcfi(/6/4!/4/2/2:10)' },
});
expect([200, 201, 204]).toContain(saved.statusCode);
expect(await pendingResets()).toHaveLength(0);
expect(await storedPercentage()).toBeCloseTo(30, 5);
});
it('arms the same reset when the whole reading state is wiped', async () => {
await pushFromDevice(0.42);
const reset = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/reset-reading-state`,
headers: authHeader(ctx.adminToken),
});
expect([200, 201]).toContain(reset.statusCode);
expect(await pendingResets()).toHaveLength(1);
expect((await pullFromDevice()).percentage).toBe(0);
});
});