From cfb14fa0bd749b053c5300bd1f5f7a5a4f9a8a26 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sat, 29 Aug 2026 11:50:21 +0800 Subject: [PATCH] fix(desktop): recover blocked sync outbox items (#322) --- .../components/DesktopSyncIssuesDialog.tsx | 211 ++++++++++++++++++ apps/web/src/components/NotebookPane.tsx | 52 ++++- apps/web/src/lib/desktop-sync.test.mjs | 45 ++++ apps/web/src/lib/desktop-sync.ts | 93 +++++++- crates/desktop-sidecar/src/database.rs | 69 +++++- crates/desktop-sidecar/src/main.rs | 143 +++++++++++- packages/shared/src/desktop-rpc.ts | 13 +- packages/shared/src/i18n/en-US.ts | 42 +++- packages/shared/src/i18n/zh-CN.ts | 42 +++- scripts/test-desktop-sidecar.mjs | 40 +++- 10 files changed, 710 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/DesktopSyncIssuesDialog.tsx diff --git a/apps/web/src/components/DesktopSyncIssuesDialog.tsx b/apps/web/src/components/DesktopSyncIssuesDialog.tsx new file mode 100644 index 00000000..bb8c23f0 --- /dev/null +++ b/apps/web/src/components/DesktopSyncIssuesDialog.tsx @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AlertTriangle, ClipboardCopy, RefreshCw, RotateCcw, Trash2 } from "lucide-react"; +import type { DesktopOutboxItem, Notebook } from "@edgeever/shared"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs"; +import { copyTextToClipboard } from "@/lib/clipboard"; +import { + createDesktopSyncDiagnosticText, + discardDesktopSyncIssue, + getDesktopSyncIssues, + recoverDesktopMemoUpdate, + retryDesktopSyncIssue, +} from "@/lib/desktop-sync"; + +const isRecoverableMissingMemo = (item: DesktopOutboxItem) => item.kind === "memo.update" + && item.status === "error" + && (item.lastErrorCode === "memo_not_found" || /memo not found/i.test(item.lastError ?? "")); + +const operationLabelKey = (kind: DesktopOutboxItem["kind"]) => `notebookPane.syncDetails.operations.${kind.replaceAll(".", "_")}`; + +export const DesktopSyncIssuesDialog = ({ + notebooks, + onOpenChange, + onSyncNow, + open, +}: { + notebooks: Notebook[]; + onOpenChange: (open: boolean) => void; + onSyncNow: () => Promise | void; + open: boolean; +}) => { + const { i18n, t } = useTranslation(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [workingId, setWorkingId] = useState(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [recovering, setRecovering] = useState(null); + const [discarding, setDiscarding] = useState(null); + const [recoveryNotebookId, setRecoveryNotebookId] = useState(""); + + const availableNotebooks = notebooks; + const dateTimeFormatter = useMemo( + () => new Intl.DateTimeFormat(i18n.language, { dateStyle: "short", timeStyle: "medium" }), + [i18n.language], + ); + const formatTime = (value: string | null | undefined) => { + if (!value) return null; + const time = new Date(value); + return Number.isFinite(time.getTime()) ? dateTimeFormatter.format(time) : null; + }; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setItems(await getDesktopSyncIssues()); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : String(loadError)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (open) void load(); + }, [load, open]); + + const runAction = async (item: DesktopOutboxItem, action: () => Promise) => { + setWorkingId(item.id); + setError(null); + try { + await action(); + await onSyncNow(); + await load(); + } catch (actionError) { + setError(actionError instanceof Error ? actionError.message : String(actionError)); + } finally { + setWorkingId(null); + } + }; + + const confirmRecovery = () => { + if (!recovering || !recoveryNotebookId) return; + const item = recovering; + setRecovering(null); + void runAction(item, () => recoverDesktopMemoUpdate(item, recoveryNotebookId)); + }; + + const confirmDiscard = () => { + if (!discarding) return; + const item = discarding; + setDiscarding(null); + void runAction(item, () => discardDesktopSyncIssue(item)); + }; + + return ( + <> + { if (workingId === null) onOpenChange(nextOpen); }}> + + + {t("notebookPane.syncDetails.title")} + {t("notebookPane.syncDetails.description")} + + + {error ?

{error}

: null} + {loading ?

{t("common.loading")}

: null} + {!loading && items.length === 0 ?

{t("notebookPane.syncDetails.empty")}

: null} + +
+ {items.map((item) => { + const recoverable = isRecoverableMissingMemo(item); + const working = workingId === item.id; + const lastAttemptAt = formatTime(item.updatedAt); + const nextAttemptAt = item.retryable ? formatTime(item.nextAttemptAt) : null; + return ( +
+
+ +
+
+ {t(operationLabelKey(item.kind), { defaultValue: item.kind })} + {t("notebookPane.syncDetails.attempts", { count: item.attemptCount })} +
+

{item.lastError || t("notebookPane.syncDetails.unknownError")}

+ {item.lastErrorCode ?

{t("notebookPane.syncDetails.errorCode", { code: item.lastErrorCode })}

: null} + {lastAttemptAt ?

{t("notebookPane.syncDetails.lastAttempt", { time: lastAttemptAt })}

: null} + {nextAttemptAt ?

{t("notebookPane.syncDetails.nextRetry", { time: nextAttemptAt })}

: null} + {recoverable ?

{t("notebookPane.syncDetails.missingMemoHint")}

: null} +
+
+
+ {recoverable && ( + + )} + + +
+
+ ); + })} +
+ + + + + +
+
+ + {recovering && ( + { if (!nextOpen) setRecovering(null); }}> + + + {t("notebookPane.syncDetails.recoverTitle")} + {t("notebookPane.syncDetails.recoverDescription")} + + + + + + + + + )} + + {discarding && ( + setDiscarding(null)} + onConfirm={confirmDiscard} + /> + )} + + ); +}; diff --git a/apps/web/src/components/NotebookPane.tsx b/apps/web/src/components/NotebookPane.tsx index 19d4d0b3..95f9e01f 100644 --- a/apps/web/src/components/NotebookPane.tsx +++ b/apps/web/src/components/NotebookPane.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import * as m from "motion/react-m"; @@ -56,6 +56,8 @@ import type { EdgeEverRepository } from "@/lib/repository"; import { statusSettleMotion } from "@/lib/motion"; import { DesktopUpdateNotice } from "./DesktopUpdateNotice"; +const DesktopSyncIssuesDialog = lazy(() => import("./DesktopSyncIssuesDialog").then((module) => ({ default: module.DesktopSyncIssuesDialog }))); + const NOTEBOOK_DRAG_SCROLL_EDGE_PX = 56; const NOTEBOOK_DRAG_SCROLL_MAX_STEP_PX = 18; const DESKTOP_DOWNLOAD_URL = "https://github.com/tianma-if/edgeever/releases/latest"; @@ -240,14 +242,17 @@ const SyncStatusBar = ({ isSyncing, onSyncNow, onDiscardConflicts, + notebooks, }: { summary: SyncQueueSummary; isOnline: boolean; isSyncing: boolean; onSyncNow: () => void; onDiscardConflicts: () => void; + notebooks: Notebook[]; }) => { const { t } = useTranslation(); + const [detailsOpen, setDetailsOpen] = useState(false); const hasQueuedWork = summary.total > 0; const label = getSyncStatusLabel(summary, isOnline, isSyncing, t); const statusClassName = !isOnline @@ -280,7 +285,13 @@ const SyncStatusBar = ({ )} - {label} + {summary.conflict > 0 && ( + + + + + + {t("notebookPane.syncNow")} + + + )} + {detailsOpen && ( + + + )} ); @@ -513,6 +540,7 @@ export const NotebookPane = ({ isSyncing={isSyncingQueuedChanges} onSyncNow={onSyncQueuedChanges} onDiscardConflicts={onDiscardConflicts} + notebooks={notebooks} /> )} diff --git a/apps/web/src/lib/desktop-sync.test.mjs b/apps/web/src/lib/desktop-sync.test.mjs index bc5188c5..3c0e3a68 100644 --- a/apps/web/src/lib/desktop-sync.test.mjs +++ b/apps/web/src/lib/desktop-sync.test.mjs @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; const { + classifyDesktopSyncFailure, + createDesktopSyncDiagnosticText, isStagedResourceReferenced, hasDesktopSyncStateReset, mergeMemoIdMappings, @@ -9,7 +11,9 @@ const { orderBootstrapNotebooks, resolveDesktopMemoSyncBase, rewriteStagedResource, + shouldPullDesktopChanges, } = await import("./desktop-sync.ts"); +const { ApiRequestError } = await import("./api.ts"); describe("desktop staged resource sync", () => { test("rewrites placeholders in memo JSON and markdown", () => { @@ -112,3 +116,44 @@ describe("desktop memo sync base", () => { )).toEqual({ expectedRevision: 3, expectedContentHash: "cloud-3" }); }); }); + +describe("desktop sync failure handling", () => { + test("stops retrying a missing memo update and keeps transient failures retryable", () => { + expect(classifyDesktopSyncFailure( + { kind: "memo.update" }, + new ApiRequestError("Memo not found", 404, "not_found"), + )).toEqual({ conflict: false, retryable: false, errorCode: "memo_not_found" }); + + expect(classifyDesktopSyncFailure( + { kind: "memo.update" }, + new ApiRequestError("Temporarily unavailable", 503), + )).toEqual({ conflict: false, retryable: true, errorCode: "http_503" }); + }); + + test("copies diagnostics without exposing entity ids or payload content", () => { + const diagnostic = createDesktopSyncDiagnosticText([{ + id: 7, + kind: "memo.update", + entityId: "private-memo-id", + payload: { contentMarkdown: "private note body" }, + attemptCount: 3, + version: 1, + status: "error", + lastError: "Memo memo_private123 not found at https://private.example.test/api", + lastErrorCode: "memo_not_found", + retryable: false, + }]); + + expect(diagnostic).toContain("memo_not_found"); + expect(diagnostic).not.toContain("private-memo-id"); + expect(diagnostic).not.toContain("private note body"); + expect(diagnostic).not.toContain("memo_private123"); + expect(diagnostic).not.toContain("private.example.test"); + }); + + test("allows remote pulls while durable errors are handled separately", () => { + expect(shouldPullDesktopChanges({ pending: 0, syncing: 0, error: 1, conflict: 1 }, true)).toBe(true); + expect(shouldPullDesktopChanges({ pending: 1, syncing: 0, error: 0, conflict: 0 }, true)).toBe(false); + expect(shouldPullDesktopChanges({ pending: 0, syncing: 0, error: 0, conflict: 0 }, false)).toBe(false); + }); +}); diff --git a/apps/web/src/lib/desktop-sync.ts b/apps/web/src/lib/desktop-sync.ts index f37ed265..7192855a 100644 --- a/apps/web/src/lib/desktop-sync.ts +++ b/apps/web/src/lib/desktop-sync.ts @@ -1,5 +1,6 @@ import { getMemoSyncBaseConflictDetails, + getSyncRetryAt, isMemoSyncBaseCurrent, type DesktopOutboxItem, type DesktopRpcParams, @@ -399,10 +400,17 @@ const syncOutbox = async (stagedRewrites: StagedResourceRewrite[], onlyKinds?: S } synced += 1; } catch (error) { - const conflict = error instanceof ApiRequestError - && (error.code === "revision_conflict" || error.code === "content_conflict" || error.code === "edit_session_conflict"); - await request("sync.outbox.fail", { id: item.id, version: item.version, error: error instanceof Error ? error.message : String(error), conflict }); - if (conflict) conflicted += 1; + const disposition = classifyDesktopSyncFailure(item, error); + await request("sync.outbox.fail", { + id: item.id, + version: item.version, + error: error instanceof Error ? error.message : String(error), + errorCode: disposition.errorCode, + conflict: disposition.conflict, + retryable: disposition.retryable, + nextAttemptAt: disposition.retryable ? getSyncRetryAt(item.attemptCount + 1) : null, + }); + if (disposition.conflict) conflicted += 1; else failed += 1; } } @@ -424,6 +432,32 @@ export const hasDesktopSyncStateReset = ( remote.syncIdentity && remote.syncIdentity !== local.syncIdentity, ); +export const classifyDesktopSyncFailure = (item: Pick, error: unknown) => { + const conflict = error instanceof ApiRequestError + && (error.code === "revision_conflict" || error.code === "content_conflict" || error.code === "edit_session_conflict"); + if (conflict) { + return { conflict: true, retryable: false, errorCode: error.code ?? "sync_conflict" }; + } + if (error instanceof ApiRequestError) { + const missingMemo = item.kind === "memo.update" && error.status === 404; + return { + conflict: false, + retryable: !missingMemo && (error.status === 408 || error.status === 429 || error.status >= 500), + errorCode: missingMemo ? "memo_not_found" : error.code ?? `http_${error.status}`, + }; + } + return { + conflict: false, + retryable: error instanceof TypeError, + errorCode: error instanceof TypeError ? "network_error" : "unexpected_sync_error", + }; +}; + +export const shouldPullDesktopChanges = ( + status: Pick, + online: boolean, +) => online && status.pending === 0 && status.syncing === 0; + export const orderBootstrapNotebooks = (notebooks: SyncBootstrapResponse["notebooks"]) => { const remaining = new Map(notebooks.map((notebook) => [notebook.id, notebook])); const ordered: SyncBootstrapResponse["notebooks"] = []; @@ -535,8 +569,9 @@ export const syncDesktopData = () => { await removeSyncedStagedResources(stagedResources.stagedIds); } const remaining = await request("sync.status", {}); - if (remaining.pending === 0 && remaining.syncing === 0 && remaining.error === 0 && remaining.conflict === 0 - && (typeof navigator === "undefined" || navigator.onLine)) await pullRemoteChanges(); + // A durable failed or conflicted outbox item must not freeze unrelated + // remote changes. The payload remains recoverable while pulls continue. + if (shouldPullDesktopChanges(remaining, typeof navigator === "undefined" || navigator.onLine)) await pullRemoteChanges(); // Catch resources staged while the network sync itself was running. await remapStagedResourceMemoIds(creates.memoIdMappings); lastSyncFailed = false; @@ -566,6 +601,52 @@ export const getDesktopSyncSummary = async () => { return { total: status.pending + status.syncing + status.conflict + error, pending: status.pending, syncing: status.syncing, conflict: status.conflict, error }; }; +export const getDesktopSyncIssues = async () => { + const response = await request("sync.outbox.list", { limit: 200, includeConflicts: true }); + return response.items.filter((item) => item.status === "error" || item.status === "conflict"); +}; + +export const retryDesktopSyncIssue = async (item: DesktopOutboxItem) => { + await request("sync.outbox.retry", { id: item.id, version: item.version }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); +}; + +export const discardDesktopSyncIssue = async (item: DesktopOutboxItem) => { + await request("sync.outbox.discard", { id: item.id, version: item.version }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); +}; + +export const recoverDesktopMemoUpdate = async (item: DesktopOutboxItem, notebookId: string) => { + const result = await request("sync.outbox.recoverMemoUpdate", { id: item.id, version: item.version, notebookId }); + window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")); + return result.memo; +}; + +const sanitizeDesktopSyncDiagnosticError = (value: string | null | undefined) => value + ? value + .slice(0, 500) + .replace(/https?:\/\/[^\s)\]}]+/gi, "[redacted-url]") + .replace(/\b(?:memo|notebook|template|resource)_[A-Za-z0-9_-]+\b/g, "[redacted-id]") + .replace(/\/Users\/[^/\s]+/g, "/Users/[redacted]") + .replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [redacted]") + : value; + +export const createDesktopSyncDiagnosticText = (items: DesktopOutboxItem[]) => JSON.stringify({ + generatedAt: new Date().toISOString(), + items: items.map((item) => ({ + id: item.id, + kind: item.kind, + status: item.status, + attemptCount: item.attemptCount, + lastError: sanitizeDesktopSyncDiagnosticError(item.lastError), + lastErrorCode: item.lastErrorCode, + retryable: item.retryable, + nextAttemptAt: item.nextAttemptAt, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })), +}, null, 2); + export const discardDesktopConflicts = async () => { const response = await request("sync.outbox.list", { limit: 200, includeConflicts: true }); const conflicts = response.items.filter((item) => item.status === "conflict"); diff --git a/crates/desktop-sidecar/src/database.rs b/crates/desktop-sidecar/src/database.rs index 9851c9b4..6590f67b 100644 --- a/crates/desktop-sidecar/src/database.rs +++ b/crates/desktop-sidecar/src/database.rs @@ -112,6 +112,9 @@ fn ensure_sidecar_schema(connection: &Connection) -> rusqlite::Result<()> { status TEXT NOT NULL DEFAULT 'pending', attempt_count INTEGER NOT NULL DEFAULT 0, last_error TEXT, + last_error_code TEXT, + retryable INTEGER NOT NULL DEFAULT 1, + next_attempt_at TEXT, version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) @@ -119,18 +122,34 @@ fn ensure_sidecar_schema(connection: &Connection) -> rusqlite::Result<()> { CREATE INDEX IF NOT EXISTS idx_sidecar_outbox_status ON _edgeever_sidecar_outbox(status, id);", )?; - let has_version = connection + let columns = connection .prepare("PRAGMA table_info(_edgeever_sidecar_outbox)")? .query_map([], |row| row.get::<_, String>(1))? - .collect::, _>>()? - .iter() - .any(|name| name == "version"); - if !has_version { + .collect::, _>>()?; + if !columns.iter().any(|name| name == "version") { connection.execute( "ALTER TABLE _edgeever_sidecar_outbox ADD COLUMN version INTEGER NOT NULL DEFAULT 1", [], )?; } + if !columns.iter().any(|name| name == "last_error_code") { + connection.execute( + "ALTER TABLE _edgeever_sidecar_outbox ADD COLUMN last_error_code TEXT", + [], + )?; + } + if !columns.iter().any(|name| name == "retryable") { + connection.execute( + "ALTER TABLE _edgeever_sidecar_outbox ADD COLUMN retryable INTEGER NOT NULL DEFAULT 1", + [], + )?; + } + if !columns.iter().any(|name| name == "next_attempt_at") { + connection.execute( + "ALTER TABLE _edgeever_sidecar_outbox ADD COLUMN next_attempt_at TEXT", + [], + )?; + } Ok(()) } @@ -324,3 +343,43 @@ pub(crate) fn open_database(root: &Path, migrations: &Path) -> rusqlite::Result< } Ok(connection) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upgrades_legacy_outbox_with_retry_metadata() { + let connection = Connection::open_in_memory().expect("open test database"); + connection + .execute_batch( + "CREATE TABLE _edgeever_sidecar_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + entity_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO _edgeever_sidecar_outbox + (kind, entity_id, payload_json, status, last_error, created_at, updated_at) + VALUES ('memo.update', 'memo_legacy', '{}', 'error', 'Memo not found', '2026-01-01', '2026-01-01');", + ) + .expect("create legacy schema"); + + ensure_sidecar_schema(&connection).expect("upgrade legacy schema"); + + let upgraded: (bool, Option, Option) = connection + .query_row( + "SELECT retryable, last_error_code, next_attempt_at FROM _edgeever_sidecar_outbox WHERE entity_id = 'memo_legacy'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("read upgraded row"); + assert_eq!(upgraded, (true, None, None)); + } +} diff --git a/crates/desktop-sidecar/src/main.rs b/crates/desktop-sidecar/src/main.rs index f28269bc..f44ac14b 100644 --- a/crates/desktop-sidecar/src/main.rs +++ b/crates/desktop-sidecar/src/main.rs @@ -12,6 +12,8 @@ use std::io::{self, BufRead, Write}; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; +const PROTOCOL_VERSION: i64 = 2; + #[derive(Debug, Deserialize)] struct RpcRequest { id: Value, @@ -61,8 +63,11 @@ fn enqueue_change( .execute( "UPDATE _edgeever_sidecar_outbox SET payload_json = ?1, - status = ?2, + status = CASE WHEN ?2 = 'conflict' THEN 'conflict' ELSE 'pending' END, last_error = CASE WHEN ?2 = 'conflict' THEN last_error ELSE NULL END, + last_error_code = CASE WHEN ?2 = 'conflict' THEN last_error_code ELSE NULL END, + retryable = 1, + next_attempt_at = NULL, version = version + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?3", @@ -999,7 +1004,10 @@ fn create_memo(database: &Connection, params: &Value) -> Result { .unwrap_or("") .to_owned(); let tags = params.get("tags").cloned().unwrap_or_else(|| json!([])); - let content_json = markdown_doc(&markdown); + let content_json = params + .get("contentJson") + .cloned() + .unwrap_or_else(|| markdown_doc(&markdown)); let content_json_text = content_json.to_string(); let text = markdown.lines().collect::>().join(" "); let id = now_id("memo_local"); @@ -1328,14 +1336,14 @@ fn sync_outbox_list(database: &Connection, params: &Value) -> Result(0)?, "kind": row.get::<_, String>(1)?, "entityId": row.get::<_, String>(2)?, "payload": serde_json::from_str::(&payload).unwrap_or_else(|_| json!({})), "attemptCount": row.get::<_, i64>(4)?, "status": row.get::<_, String>(5)?, "lastError": row.get::<_, Option>(6)?, "version": row.get::<_, i64>(7)? })) + Ok(json!({ "id": row.get::<_, i64>(0)?, "kind": row.get::<_, String>(1)?, "entityId": row.get::<_, String>(2)?, "payload": serde_json::from_str::(&payload).unwrap_or_else(|_| json!({})), "attemptCount": row.get::<_, i64>(4)?, "status": row.get::<_, String>(5)?, "lastError": row.get::<_, Option>(6)?, "version": row.get::<_, i64>(7)?, "lastErrorCode": row.get::<_, Option>(8)?, "retryable": row.get::<_, bool>(9)?, "nextAttemptAt": row.get::<_, Option>(10)?, "createdAt": row.get::<_, String>(11)?, "updatedAt": row.get::<_, String>(12)? })) }).map_err(|e| e.to_string())?; let items: Result, _> = rows.collect(); Ok(json!({ "items": items.map_err(|e| e.to_string())? })) @@ -1664,22 +1672,126 @@ fn sync_outbox_fail(database: &Connection, params: &Value) -> Result Result { + let id = params + .get("id") + .and_then(Value::as_i64) + .ok_or_else(|| "Missing outbox id".to_owned())?; + let version = params.get("version").and_then(Value::as_i64); + let updated = if let Some(version) = version { + database.execute( + "UPDATE _edgeever_sidecar_outbox SET status = 'pending', retryable = 1, next_attempt_at = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1 AND version = ?2 AND status = 'error'", + rusqlite::params![id, version], + ) + } else { + database.execute( + "UPDATE _edgeever_sidecar_outbox SET status = 'pending', retryable = 1, next_attempt_at = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1 AND status = 'error'", + [id], + ) + } + .map_err(|e| e.to_string())?; + if updated == 0 { + return Err("The failed sync item changed before retry".to_owned()); + } + Ok(json!({ "ok": true })) +} + +fn sync_outbox_recover_memo_update(database: &Connection, params: &Value) -> Result { + let id = params + .get("id") + .and_then(Value::as_i64) + .ok_or_else(|| "Missing outbox id".to_owned())?; + let notebook_id = string_param(params, "notebookId")?; + let requested_version = params.get("version").and_then(Value::as_i64); + let (kind, payload_text, version, status): (String, String, i64, String) = database + .query_row( + "SELECT kind, payload_json, version, status FROM _edgeever_sidecar_outbox WHERE id = ?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .map_err(|e| e.to_string())?; + if kind != "memo.update" || status != "error" { + return Err("Only failed memo updates can be recovered".to_owned()); + } + if requested_version.is_some_and(|value| value != version) { + return Err("The failed update changed before recovery".to_owned()); + } + let notebook_exists: bool = database + .query_row( + "SELECT EXISTS(SELECT 1 FROM notebooks WHERE id = ?1 AND is_deleted = 0)", + [¬ebook_id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + if !notebook_exists { + return Err("Recovery notebook not found".to_owned()); + } + let mut payload: Value = serde_json::from_str(&payload_text).map_err(|e| e.to_string())?; + let object = payload + .as_object_mut() + .ok_or_else(|| "Invalid failed memo payload".to_owned())?; + object.insert("notebookId".to_owned(), json!(notebook_id)); + let memo = create_memo(database, &payload)?; + let recovered_id = string_param(&memo, "id")?; + payload["memoId"] = json!(recovered_id); + payload["expectedRevision"] = json!(0); + payload["expectedContentHash"] = memo + .get("contentHash") + .cloned() + .unwrap_or_else(|| json!("")); + enqueue_change(database, "memo.update", &recovered_id, &payload)?; + let removed = database + .execute( + "DELETE FROM _edgeever_sidecar_outbox WHERE id = ?1 AND version = ?2 AND status = 'error'", + rusqlite::params![id, version], + ) + .map_err(|e| e.to_string())?; + if removed == 0 { + database + .execute( + "DELETE FROM _edgeever_sidecar_outbox WHERE entity_id = ?1", + [&recovered_id], + ) + .ok(); + database + .execute("DELETE FROM memos WHERE id = ?1", [&recovered_id]) + .ok(); + return Err("The failed update changed before recovery".to_owned()); + } + Ok(json!({ "ok": true, "memo": memo })) +} + fn sync_outbox_discard(database: &Connection, params: &Value) -> Result { let id = params .get("id") .and_then(Value::as_i64) .ok_or_else(|| "Missing outbox id".to_owned())?; - database - .execute("DELETE FROM _edgeever_sidecar_outbox WHERE id = ?1", [id]) - .map_err(|e| e.to_string())?; + let removed = if let Some(version) = params.get("version").and_then(Value::as_i64) { + database.execute( + "DELETE FROM _edgeever_sidecar_outbox WHERE id = ?1 AND version = ?2", + rusqlite::params![id, version], + ) + } else { + database.execute("DELETE FROM _edgeever_sidecar_outbox WHERE id = ?1", [id]) + } + .map_err(|e| e.to_string())?; + if removed == 0 { + return Err("The sync item changed before it was discarded".to_owned()); + } Ok(json!({ "ok": true })) } @@ -1763,7 +1875,7 @@ fn handle( "platform": env::consts::OS, "architecture": env::consts::ARCH, "dataDir": root, - "protocolVersion": 1, + "protocolVersion": PROTOCOL_VERSION, })), "storage.health" => database .query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) @@ -1779,6 +1891,10 @@ fn handle( "sync.outbox.list" => sync_outbox_list(database, &request.params), "sync.outbox.ack" => sync_outbox_ack(database, &request.params), "sync.outbox.fail" => sync_outbox_fail(database, &request.params), + "sync.outbox.retry" => sync_outbox_retry(database, &request.params), + "sync.outbox.recoverMemoUpdate" => { + sync_outbox_recover_memo_update(database, &request.params) + } "sync.outbox.discard" => sync_outbox_discard(database, &request.params), "sync.apply" => apply_sync_changes(database, &request.params), "sync.cursor.set" => { @@ -1878,7 +1994,10 @@ fn chrono_like_now() -> String { fn main() -> Result<(), Box> { let root = data_dir(); let mut database = open_database(&root, &migrations_dir())?; - println!("{}", json!({ "event": "ready", "protocolVersion": 1 })); + println!( + "{}", + json!({ "event": "ready", "protocolVersion": PROTOCOL_VERSION }) + ); io::stdout().flush()?; for line in io::stdin().lock().lines() { diff --git a/packages/shared/src/desktop-rpc.ts b/packages/shared/src/desktop-rpc.ts index be4b4cb8..67f41fec 100644 --- a/packages/shared/src/desktop-rpc.ts +++ b/packages/shared/src/desktop-rpc.ts @@ -110,6 +110,8 @@ export type DesktopRpcResponses = { "sync.outbox.list": { items: DesktopOutboxItem[] }; "sync.outbox.ack": { ok: true; memo: MemoDetail | null; notebook: Notebook | null; template: MemoTemplate | null }; "sync.outbox.fail": { ok: true }; + "sync.outbox.retry": { ok: true }; + "sync.outbox.recoverMemoUpdate": { ok: true; memo: MemoDetail }; "sync.outbox.discard": { ok: true }; "sync.apply": { applied: number }; "sync.cursor.set": { ok: true }; @@ -155,8 +157,10 @@ export type DesktopRpcParams = { "sync.bootstrap.prepare": { reset?: boolean }; "sync.outbox.list": { limit?: number; includeConflicts?: boolean }; "sync.outbox.ack": { id: number; version?: number; remoteMemo?: MemoDetail; remoteNotebook?: Notebook; remoteTemplate?: MemoTemplate }; - "sync.outbox.fail": { id: number; version?: number; error: string; conflict?: boolean }; - "sync.outbox.discard": { id: number }; + "sync.outbox.fail": { id: number; version?: number; error: string; errorCode?: string | null; conflict?: boolean; retryable?: boolean; nextAttemptAt?: string | null }; + "sync.outbox.retry": { id: number; version?: number }; + "sync.outbox.recoverMemoUpdate": { id: number; version?: number; notebookId: string }; + "sync.outbox.discard": { id: number; version?: number }; "sync.apply": { changes: Array<{ entityType: "memo" | "notebook"; operation: "upsert" | "delete"; memo?: MemoDetail | null; notebook?: Notebook | null; entityId: string }> }; "sync.cursor.set": { cursor: number; syncIdentity: string }; "system.info": Record; @@ -175,4 +179,9 @@ export type DesktopOutboxItem = { version: number; status?: "pending" | "syncing" | "conflict" | "error"; lastError?: string | null; + lastErrorCode?: string | null; + retryable?: boolean; + nextAttemptAt?: string | null; + createdAt?: string; + updatedAt?: string; }; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 82bdd757..73e69480 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -148,10 +148,50 @@ export const enUS = { conflicts: "{{count}} sync conflicts", discardConflicts: "Use cloud versions", discardingConflicts: "Using cloud versions", - retry: "{{count}} waiting to retry", + retry: "{{count}} sync items need attention", pending: "{{count}} pending", synced: "Synced", }, + syncDetails: { + title: "Sync details", + description: "Failed items remain safely stored on this device and no longer prevent changes from other devices from reaching the desktop app.", + empty: "There are no sync items that need attention.", + attempts: "Attempted {{count}} times", + unknownError: "Unknown sync error", + errorCode: "Error code: {{code}}", + lastAttempt: "Last attempt: {{time}}", + nextRetry: "Next retry: {{time}}", + missingMemoHint: "The cloud note no longer exists. Recover the local changes as a new note, or explicitly discard this local copy.", + retry: "Retry now", + recover: "Recover as new note", + recoverTitle: "Recover local changes", + recoverDescription: "Choose a notebook. EdgeEver will preserve the failed item's content in a new note and continue syncing.", + recoveryNotebook: "Choose a notebook", + discard: "Discard local changes", + discardTitle: "Discard these local changes?", + discardDescription: "Content that has not been uploaded from this sync item will be permanently deleted. Other notes and cloud data are not affected.", + copyDiagnostics: "Copy redacted diagnostics", + operations: { + memo_create: "Create note", + memo_update: "Update note", + memo_delete: "Delete note", + memo_restore: "Restore note", + memo_move: "Move note", + memo_deleteBatch: "Delete notes", + memo_emptyTrash: "Empty trash", + memo_pinBatch: "Pin notes", + memo_merge: "Merge notes", + notebook_create: "Create notebook", + notebook_update: "Update notebook", + notebook_delete: "Delete notebook", + notebook_restore: "Restore notebook", + template_create: "Create template", + template_update: "Update template", + template_delete: "Delete template", + tag_rename: "Rename tag", + tag_delete: "Delete tag", + }, + }, }, notebookTree: { dragNotebook: "Move notebook “{{name}}”", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index bc77a123..8ba18c6b 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -148,10 +148,50 @@ export const zhCN = { conflicts: "{{count}} 项同步冲突", discardConflicts: "采用云端版本", discardingConflicts: "正在采用云端版本", - retry: "{{count}} 项等待重试", + retry: "{{count}} 项同步需要处理", pending: "{{count}} 项待同步", synced: "已同步", }, + syncDetails: { + title: "同步详情", + description: "失败项会安全保留在本地;它们不会再阻止其他设备上的更改同步到此桌面端。", + empty: "当前没有需要处理的同步项。", + attempts: "已尝试 {{count}} 次", + unknownError: "未知同步错误", + errorCode: "错误代码:{{code}}", + lastAttempt: "最后尝试:{{time}}", + nextRetry: "下次重试:{{time}}", + missingMemoHint: "云端笔记已不存在。你可以把本地修改恢复为一条新笔记,或确认后放弃这份本地修改。", + retry: "立即重试", + recover: "恢复为新笔记", + recoverTitle: "恢复本地修改", + recoverDescription: "选择目标笔记本。EdgeEver 会保留失败项中的内容并创建一条新笔记,然后继续同步。", + recoveryNotebook: "选择目标笔记本", + discard: "放弃本地修改", + discardTitle: "放弃这项本地修改?", + discardDescription: "该同步项中尚未上传的内容会被永久删除。其他笔记和云端数据不会受到影响。", + copyDiagnostics: "复制脱敏诊断", + operations: { + memo_create: "新建笔记", + memo_update: "更新笔记", + memo_delete: "删除笔记", + memo_restore: "恢复笔记", + memo_move: "移动笔记", + memo_deleteBatch: "批量删除笔记", + memo_emptyTrash: "清空回收站", + memo_pinBatch: "批量置顶笔记", + memo_merge: "合并笔记", + notebook_create: "新建笔记本", + notebook_update: "更新笔记本", + notebook_delete: "删除笔记本", + notebook_restore: "恢复笔记本", + template_create: "新建模板", + template_update: "更新模板", + template_delete: "删除模板", + tag_rename: "重命名标签", + tag_delete: "删除标签", + }, + }, }, notebookTree: { dragNotebook: "移动笔记本「{{name}}」", diff --git a/scripts/test-desktop-sidecar.mjs b/scripts/test-desktop-sidecar.mjs index 2d7557e4..db8d0aab 100644 --- a/scripts/test-desktop-sidecar.mjs +++ b/scripts/test-desktop-sidecar.mjs @@ -291,6 +291,44 @@ await assert.rejects(() => request("memo.get", { memoId: afterBackup.memo.id }), assert.equal(readFileSync(join(stagedResourceDirectory, "stage-test.bin"), "utf8"), "offline attachment snapshot", "restore should recover staged resources"); const outbox = await request("sync.outbox.list", { limit: 100 }); assert.ok(!outbox.items.some((item) => item.id === mergeOutbox.id), "a restored backup must not resurrect an acknowledged merge"); + +const deferredMemo = await request("memo.create", { notebookId: inbox.id, title: "Deferred retry", contentMarkdown: "retry later", tags: [] }); +const deferredItem = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.entityId === deferredMemo.memo.id); +assert.ok(deferredItem, "deferred retry test should create an outbox item"); +await request("sync.outbox.fail", { + id: deferredItem.id, + version: deferredItem.version, + error: "temporary outage", + errorCode: "http_503", + retryable: true, + nextAttemptAt: "2099-01-01T00:00:00.000Z", +}); +assert.ok(!(await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.id === deferredItem.id), "a deferred error should wait until its retry time"); +const deferredDetails = (await request("sync.outbox.list", { limit: 200, includeConflicts: true })).items.find((item) => item.id === deferredItem.id); +assert.equal(deferredDetails.lastErrorCode, "http_503"); +assert.equal(deferredDetails.retryable, true); +assert.equal(deferredDetails.nextAttemptAt, "2099-01-01T00:00:00.000Z"); +await request("sync.outbox.retry", { id: deferredItem.id, version: deferredItem.version }); +assert.ok((await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.id === deferredItem.id), "manual retry should make a deferred item immediately eligible"); +await request("sync.outbox.discard", { id: deferredItem.id }); + +const recoveryCandidate = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.kind === "memo.update" && item.entityId === coalesced.memo.id); +assert.ok(recoveryCandidate, "recovery test should reuse a durable memo update"); +await request("sync.outbox.fail", { + id: recoveryCandidate.id, + version: recoveryCandidate.version, + error: "Memo not found", + errorCode: "memo_not_found", + retryable: false, +}); +assert.ok(!(await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.id === recoveryCandidate.id), "a permanent missing memo error must stop automatic retries"); +const recovered = await request("sync.outbox.recoverMemoUpdate", { id: recoveryCandidate.id, version: recoveryCandidate.version, notebookId: inbox.id }); +assert.equal(recovered.memo.contentMarkdown, "latest autosave", "recovery should preserve the failed update content"); +assert.deepEqual(recovered.memo.contentJson, recoveryCandidate.payload.contentJson, "recovery should preserve rich document content"); +assert.ok(!(await request("sync.outbox.list", { limit: 200, includeConflicts: true })).items.some((item) => item.id === recoveryCandidate.id), "recovery should remove the failed update"); +assert.ok((await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.kind === "memo.create" && item.entityId === recovered.memo.id), "recovery should queue the new local note for upload"); +assert.ok((await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.kind === "memo.update" && item.entityId === recovered.memo.id && JSON.stringify(item.payload.contentJson) === JSON.stringify(recoveryCandidate.payload.contentJson)), "recovery should queue a successor update that preserves rich content after create acknowledgement"); + const conflictMemo = await request("memo.create", { notebookId: inbox.id, title: "Conflict test", contentMarkdown: "local conflict", tags: [] }); const conflictCandidate = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.entityId === conflictMemo.memo.id); assert.ok(conflictCandidate, "conflict test should create an outbox item"); @@ -302,4 +340,4 @@ assert.equal((await request("sync.status")).conflict, 0); child.stdin.end(); await new Promise((resolve) => child.once("close", resolve)); -console.log(JSON.stringify({ ok: true, checked: ["memo.create", "memo.list.search", "memo.list.subtree", "memo.update", "memo.update.coalesce", "memo.revisions", "memo.restoreRevision", "memo.revision.cache", "tag.rename", "memo.moveBatch", "memo.pinBatch", "memo.deleteBatch", "memo.restore", "memo.emptyTrash", "memo.merge", "template.cache", "template.create.payload", "template.delete", "storage.backup", "storage.backups", "storage.restore", "sync.outbox", "sync.outbox.discard"] })); +console.log(JSON.stringify({ ok: true, checked: ["memo.create", "memo.list.search", "memo.list.subtree", "memo.update", "memo.update.coalesce", "memo.revisions", "memo.restoreRevision", "memo.revision.cache", "tag.rename", "memo.moveBatch", "memo.pinBatch", "memo.deleteBatch", "memo.restore", "memo.emptyTrash", "memo.merge", "template.cache", "template.create.payload", "template.delete", "storage.backup", "storage.backups", "storage.restore", "sync.outbox", "sync.outbox.retry", "sync.outbox.recoverMemoUpdate", "sync.outbox.discard"] }));