fix(desktop): recover blocked sync outbox items (#322)
This commit is contained in:
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
[¬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<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() {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}}”",
|
||||
|
||||
@@ -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}}」",
|
||||
|
||||
@@ -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"] }));
|
||||
Reference in new issue
Block a user