From bcbf0b382e31f325e265f4d9544c8786ac03bb56 Mon Sep 17 00:00:00 2001 From: Chmouel Boudjnah Date: Mon, 7 Sep 2026 23:52:13 +0200 Subject: [PATCH] fix: Preserve offline error state --- internal/webui/static/offline-download.js | 20 ++++++++++++++++--- internal/webui/static/offline-sync.js | 10 ++++++++-- .../webui/testdata/offlinebrowser.test.mjs | 9 +++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/internal/webui/static/offline-download.js b/internal/webui/static/offline-download.js index abfcaae..544ad88 100644 --- a/internal/webui/static/offline-download.js +++ b/internal/webui/static/offline-download.js @@ -58,10 +58,12 @@ if (buttons.length) { button.disabled = false; const controller = new AbortController(); active = controller; + let operationAccount = ""; button.textContent = "Cancel download"; say(button, "Preparing offline copy…"); try { const identity = await auth.acquire(); + operationAccount = identity.account; const partition = storagePartition(); const context = await accountContext(partition, identity.account); const authorize = async current => { @@ -119,9 +121,21 @@ if (buttons.length) { } } catch (error) { if (error.name === "AbortError") say(button, "Download cancelled"); - else if (error.code === "quota") say(button, error.message, true); - else say(button, error.message || "Offline download failed", true); - button.textContent = "Save offline"; + else { + const message = error.code === "quota" + ? error.message : error.message || "Offline download failed"; + let restored = false; + if (operationAccount) { + try { + await updateReadyState(button, operationAccount); + restored = true; + } catch (stateError) { + console.warn("offline download state could not be restored", stateError); + } + } + if (!restored) button.textContent = "Save offline"; + say(button, message, true); + } } finally { active = null; } diff --git a/internal/webui/static/offline-sync.js b/internal/webui/static/offline-sync.js index 43e4d00..5abc949 100644 --- a/internal/webui/static/offline-sync.js +++ b/internal/webui/static/offline-sync.js @@ -24,7 +24,9 @@ export async function drainOfflineOutbox(context, request) { responseCurrent: () => true, accepted: done, refused: (_, code) => failed("failed", code || "session rejected"), - deferred: () => {}, + deferred: (status, code) => failed( + "pending", code || `HTTP ${status || "unknown"}`, + ), }); continue; } @@ -49,11 +51,15 @@ export async function drainOfflineOutbox(context, request) { if (response.status === 409) await failed("conflict", "revision conflict", body?.server); else if ([400, 403, 404, 413, 422].includes(response.status)) await failed("failed", body?.error || `HTTP ${response.status}`); + else await failed("pending", body?.error || `HTTP ${response.status}`); continue; } const result = body?.results?.find(value => (annotation ? value.id : value.op_id) === (annotation ? record.annotationID : record.id)); - if (!result) continue; + if (!result) { + await failed("pending", "missing acknowledgement"); + continue; + } if (["applied", "duplicate"].includes(result.status)) { if (annotation) { const server = { ...record.payload.annotation, rev: result.rev, seq: result.seq }; diff --git a/internal/webui/testdata/offlinebrowser.test.mjs b/internal/webui/testdata/offlinebrowser.test.mjs index f71f7fa..f81d79a 100644 --- a/internal/webui/testdata/offlinebrowser.test.mjs +++ b/internal/webui/testdata/offlinebrowser.test.mjs @@ -188,6 +188,15 @@ async function storageChecks() { "permanent annotation error does not stop sessions or other annotations"); await rejected(() => s.finishOfflineSession({ ...context, bookID, payload: { ...payload, active_ms: 2000 } }), "delivered session retains immutable identity"); + await queue("retryable", "retryable", "retryable"); + await drainOfflineOutbox(context, async () => reply({ error: "temporary failure" }, "", 500)); + const retryable = (await pending()).find(row => row.id === "retryable"); + check(retryable?.state === "pending" && retryable.error === "temporary failure", + "unexpected server errors stay retryable with an error"); + await drainOfflineOutbox(context, async () => + reply({ results: [{ id: "retryable", status: "applied", rev: 1 }] })); + check(!(await pending()).some(row => row.id === "retryable"), + "retryable outbox records recover after a later success"); await s.saveOfflineSessionCheckpoint({ ...context, bookID, checkpoint: { id: "session" } }); check(!await s.getOfflineSessionCheckpoint({ ...context, bookID }), "late checkpoint cannot recreate finalized session"); await s.saveOfflineSessionCheckpoint({ ...context, bookID, checkpoint: { id: "next-session" } });