fix(desktop): recover blocked sync outbox items (#322)

This commit is contained in:
tianma-if committed 2026-08-29 11:50:21 +08:00
1 parent d03554f1ee
commit cfb14fa0bd
10 files changed
+710 -40

No files matched your search

@@ -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> | void;
open: boolean;
}) => {
const { i18n, t } = useTranslation();
const [items, setItems] = useState<DesktopOutboxItem[]>([]);
const [loading, setLoading] = useState(false);
const [workingId, setWorkingId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [recovering, setRecovering] = useState<DesktopOutboxItem | null>(null);
const [discarding, setDiscarding] = useState<DesktopOutboxItem | null>(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<unknown>) => {
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 (
<>
<Dialog open={open && !recovering && !discarding} onOpenChange={(nextOpen) => { if (workingId === null) onOpenChange(nextOpen); }}>
<DialogContent className="max-h-[min(720px,calc(100vh-2rem))] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("notebookPane.syncDetails.title")}</DialogTitle>
<DialogDescription>{t("notebookPane.syncDetails.description")}</DialogDescription>
</DialogHeader>
{error ? <p className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700" role="alert">{error}</p> : null}
{loading ? <p className="py-8 text-center text-sm text-slate-500">{t("common.loading")}</p> : null}
{!loading && items.length === 0 ? <p className="py-8 text-center text-sm text-slate-500">{t("notebookPane.syncDetails.empty")}</p> : null}
<div className="space-y-3">
{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 (
<section key={item.id} className="rounded-lg border border-amber-200 bg-amber-50/60 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-semibold text-slate-900">{t(operationLabelKey(item.kind), { defaultValue: item.kind })}</span>
<span className="text-xs text-slate-500">{t("notebookPane.syncDetails.attempts", { count: item.attemptCount })}</span>
</div>
<p className="mt-1 break-words text-sm text-amber-900">{item.lastError || t("notebookPane.syncDetails.unknownError")}</p>
{item.lastErrorCode ? <p className="mt-1 text-xs text-slate-500">{t("notebookPane.syncDetails.errorCode", { code: item.lastErrorCode })}</p> : null}
{lastAttemptAt ? <p className="mt-1 text-xs text-slate-500">{t("notebookPane.syncDetails.lastAttempt", { time: lastAttemptAt })}</p> : null}
{nextAttemptAt ? <p className="mt-1 text-xs text-slate-500">{t("notebookPane.syncDetails.nextRetry", { time: nextAttemptAt })}</p> : null}
{recoverable ? <p className="mt-2 text-xs leading-5 text-slate-600">{t("notebookPane.syncDetails.missingMemoHint")}</p> : null}
</div>
</div>
<div className="mt-3 flex flex-wrap justify-end gap-2">
{recoverable && (
<Button size="sm" variant="outline" disabled={working || availableNotebooks.length === 0} onClick={() => {
setRecoveryNotebookId(availableNotebooks[0]?.id ?? "");
setRecovering(item);
}}>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{t("notebookPane.syncDetails.recover")}
</Button>
)}
<Button size="sm" variant="outline" disabled={working} onClick={() => void runAction(item, () => retryDesktopSyncIssue(item))}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
{t("notebookPane.syncDetails.retry")}
</Button>
<Button size="sm" variant="ghost" className="text-rose-700 hover:bg-rose-50 hover:text-rose-800" disabled={working} onClick={() => setDiscarding(item)}>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
{t("notebookPane.syncDetails.discard")}
</Button>
</div>
</section>
);
})}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button
variant="outline"
disabled={items.length === 0}
onClick={() => void copyTextToClipboard(createDesktopSyncDiagnosticText(items)).then((success) => {
if (!success) return;
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
})}
>
<ClipboardCopy className="mr-1.5 h-4 w-4" />
{copied ? t("common.copied") : t("notebookPane.syncDetails.copyDiagnostics")}
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t("common.close")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{recovering && (
<Dialog open onOpenChange={(nextOpen) => { if (!nextOpen) setRecovering(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("notebookPane.syncDetails.recoverTitle")}</DialogTitle>
<DialogDescription>{t("notebookPane.syncDetails.recoverDescription")}</DialogDescription>
</DialogHeader>
<Select value={recoveryNotebookId} onValueChange={setRecoveryNotebookId}>
<SelectTrigger aria-label={t("notebookPane.syncDetails.recoveryNotebook")}>
<SelectValue placeholder={t("notebookPane.syncDetails.recoveryNotebook")} />
</SelectTrigger>
<SelectContent>
{availableNotebooks.map((notebook) => <SelectItem key={notebook.id} value={notebook.id}>{notebook.name}</SelectItem>)}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" onClick={() => setRecovering(null)}>{t("common.cancel")}</Button>
<Button disabled={!recoveryNotebookId} onClick={confirmRecovery}>{t("notebookPane.syncDetails.recover")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{discarding && (
<AppConfirmDialog
title={t("notebookPane.syncDetails.discardTitle")}
description={t("notebookPane.syncDetails.discardDescription")}
confirmLabel={t("notebookPane.syncDetails.discard")}
onCancel={() => setDiscarding(null)}
onConfirm={confirmDiscard}
/>
)}
</>
);
};
+40 -12
View File
@@ -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 = ({
<CheckCircle2 className="h-4 w-4" />
)}
</m.span>
<span className="min-w-0 flex-1 truncate text-xs font-medium">{label}</span>
<button
className="min-w-0 flex-1 truncate text-left text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40"
type="button"
onClick={() => setDetailsOpen(true)}
>
{label}
</button>
{summary.conflict > 0 && (
<button
className="shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold text-amber-800 transition-colors hover:bg-white/70 disabled:opacity-50"
@@ -292,16 +303,32 @@ const SyncStatusBar = ({
</button>
)}
{hasQueuedWork && (
<button
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/70 disabled:opacity-50 transition-colors"
type="button"
title={t("notebookPane.syncNow")}
aria-label={t("notebookPane.syncNow")}
disabled={!isOnline || isSyncing}
onClick={onSyncNow}
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/70 disabled:opacity-50 transition-colors"
type="button"
aria-label={t("notebookPane.syncNow")}
disabled={!isOnline || isSyncing}
onClick={onSyncNow}
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{t("notebookPane.syncNow")}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{detailsOpen && (
<Suspense fallback={null}>
<DesktopSyncIssuesDialog
open={detailsOpen}
onOpenChange={setDetailsOpen}
notebooks={notebooks}
onSyncNow={onSyncNow}
/>
</Suspense>
)}
</div>
);
@@ -513,6 +540,7 @@ export const NotebookPane = ({
isSyncing={isSyncingQueuedChanges}
onSyncNow={onSyncQueuedChanges}
onDiscardConflicts={onDiscardConflicts}
notebooks={notebooks}
/>
</div>
)}
+45
View File
@@ -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);
});
});
+87 -6
View File
@@ -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<DesktopOutboxItem, "kind">, 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<DesktopRpcResponses["sync.status"], "pending" | "syncing">,
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");
+64 -5
View File
@@ -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::<Result<Vec<_>, _>>()?
.iter()
.any(|name| name == "version");
if !has_version {
.collect::<Result<Vec<_>, _>>()?;
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<String>, Option<String>) = 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));
}
}
+131 -12
View File
@@ -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<Value, String> {
.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::<Vec<_>>().join(" ");
let id = now_id("memo_local");
@@ -1328,14 +1336,14 @@ fn sync_outbox_list(database: &Connection, params: &Value) -> Result<Value, Stri
.clamp(1, 200);
let include_conflicts = bool_param(params, "includeConflicts", false);
let sql = if include_conflicts {
"SELECT id, kind, entity_id, payload_json, attempt_count, status, last_error, version FROM _edgeever_sidecar_outbox WHERE status IN ('pending', 'error', 'conflict') ORDER BY id LIMIT ?1"
"SELECT id, kind, entity_id, payload_json, attempt_count, status, last_error, version, last_error_code, retryable, next_attempt_at, created_at, updated_at FROM _edgeever_sidecar_outbox WHERE status IN ('pending', 'error', 'conflict') ORDER BY id LIMIT ?1"
} else {
"SELECT id, kind, entity_id, payload_json, attempt_count, status, last_error, version FROM _edgeever_sidecar_outbox WHERE status IN ('pending', 'error') ORDER BY id LIMIT ?1"
"SELECT id, kind, entity_id, payload_json, attempt_count, status, last_error, version, last_error_code, retryable, next_attempt_at, created_at, updated_at FROM _edgeever_sidecar_outbox WHERE status = 'pending' OR (status = 'error' AND retryable = 1 AND (next_attempt_at IS NULL OR next_attempt_at <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))) ORDER BY id LIMIT ?1"
};
let mut statement = database.prepare(sql).map_err(|e| e.to_string())?;
let rows = statement.query_map([limit], |row| {
let payload: String = row.get(3)?;
Ok(json!({ "id": row.get::<_, i64>(0)?, "kind": row.get::<_, String>(1)?, "entityId": row.get::<_, String>(2)?, "payload": serde_json::from_str::<Value>(&payload).unwrap_or_else(|_| json!({})), "attemptCount": row.get::<_, i64>(4)?, "status": row.get::<_, String>(5)?, "lastError": row.get::<_, Option<String>>(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::<Value>(&payload).unwrap_or_else(|_| json!({})), "attemptCount": row.get::<_, i64>(4)?, "status": row.get::<_, String>(5)?, "lastError": row.get::<_, Option<String>>(6)?, "version": row.get::<_, i64>(7)?, "lastErrorCode": row.get::<_, Option<String>>(8)?, "retryable": row.get::<_, bool>(9)?, "nextAttemptAt": row.get::<_, Option<String>>(10)?, "createdAt": row.get::<_, String>(11)?, "updatedAt": row.get::<_, String>(12)? }))
}).map_err(|e| e.to_string())?;
let items: Result<Vec<_>, _> = 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<Value, Stri
} else {
"error"
};
let error_code = params.get("errorCode").and_then(Value::as_str);
let retryable = params
.get("retryable")
.and_then(Value::as_bool)
.unwrap_or(true);
let next_attempt_at = params.get("nextAttemptAt").and_then(Value::as_str);
let updated = if let Some(version) = params.get("version").and_then(Value::as_i64) {
database.execute("UPDATE _edgeever_sidecar_outbox SET status = ?1, attempt_count = attempt_count + 1, last_error = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?3 AND version = ?4", rusqlite::params![status, error, id, version]).map_err(|e| e.to_string())?
database.execute("UPDATE _edgeever_sidecar_outbox SET status = ?1, attempt_count = attempt_count + 1, last_error = ?2, last_error_code = ?3, retryable = ?4, next_attempt_at = ?5, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?6 AND version = ?7", rusqlite::params![status, error, error_code, retryable, next_attempt_at, id, version]).map_err(|e| e.to_string())?
} else {
database.execute("UPDATE _edgeever_sidecar_outbox SET status = ?1, attempt_count = attempt_count + 1, last_error = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?3", rusqlite::params![status, error, id]).map_err(|e| e.to_string())?
database.execute("UPDATE _edgeever_sidecar_outbox SET status = ?1, attempt_count = attempt_count + 1, last_error = ?2, last_error_code = ?3, retryable = ?4, next_attempt_at = ?5, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?6", rusqlite::params![status, error, error_code, retryable, next_attempt_at, id]).map_err(|e| e.to_string())?
};
Ok(json!({ "ok": true, "superseded": updated == 0 }))
}
fn sync_outbox_retry(database: &Connection, params: &Value) -> Result<Value, String> {
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<Value, String> {
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)",
[&notebook_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<Value, String> {
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<dyn std::error::Error>> {
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() {
+11 -2
View File
@@ -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<string, never>;
@@ -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;
};
+41 -1
View File
@@ -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}}”",
+41 -1
View File
@@ -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}}」",
+39 -1
View File
@@ -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"] }));