fix: repair desktop initial sync

This commit is contained in:
tianma-if committed 2026-07-28 23:58:35 +08:00
1 parent c2ec65f263
commit 261f2a7974
7 files changed
+199 -9

No files matched your search

+66 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
const storage = new Map();
const calls = [];
const events = [];
let completeSave;
globalThis.window = {
@@ -23,10 +24,23 @@ globalThis.window = {
calls.push(["storage", value]);
storage.set(key, value);
},
removeItem: (key) => {
storage.delete(key);
},
},
dispatchEvent: (event) => {
events.push(event.type);
return true;
},
};
const { DESKTOP_API_BASE_URL_STORAGE_KEY, saveDesktopApiBaseUrl } = await import("./api.ts");
const {
DESKTOP_API_BASE_URL_STORAGE_KEY,
api,
cacheDesktopSession,
getCachedDesktopSession,
saveDesktopApiBaseUrl,
} = await import("./api.ts");
describe("desktop instance setup", () => {
test("can retry with a valid URL after invalid input", async () => {
@@ -46,4 +60,55 @@ describe("desktop instance setup", () => {
]);
expect(storage.get(DESKTOP_API_BASE_URL_STORAGE_KEY)).toBe("https://notes.example.com");
});
test("uses the desktop session token and stops network retries after a 401", async () => {
calls.length = 0;
events.length = 0;
cacheDesktopSession({
authRequired: true,
authenticated: true,
demoMode: false,
sessionToken: "desktop-session-token",
user: { id: "user-1", username: "admin", displayName: null, role: "owner" },
});
const requests = [];
globalThis.fetch = async (url, init) => {
requests.push({ url, authorization: new Headers(init?.headers).get("Authorization") });
return new Response(JSON.stringify({ error: { code: "unauthorized", message: "Authentication required" } }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
};
await expect(api.syncBootstrap({ limit: 200 })).rejects.toMatchObject({ status: 401 });
await expect(api.syncBootstrap({ limit: 200 })).rejects.toMatchObject({ status: 401 });
expect(requests).toEqual([{
url: "https://notes.example.com/api/v1/sync/bootstrap?limit=200",
authorization: "Bearer desktop-session-token",
}]);
expect(events).toEqual(["edgeever:unauthorized"]);
expect(getCachedDesktopSession()).toBeNull();
globalThis.fetch = async (url, init) => {
requests.push({ url, authorization: new Headers(init?.headers).get("Authorization") });
return Response.json({
authRequired: true,
authenticated: true,
demoMode: false,
sessionToken: "replacement-session-token",
user: { id: "user-1", username: "admin", displayName: null, role: "owner" },
});
};
const replacement = await api.login({ username: "admin", password: "secret" });
cacheDesktopSession(replacement);
await api.syncBootstrap({ limit: 200 });
expect(requests.at(-1)).toEqual({
url: "https://notes.example.com/api/v1/sync/bootstrap?limit=200",
authorization: "Bearer replacement-session-token",
});
});
});
+29 -2
View File
@@ -186,8 +186,23 @@ export class ApiRequestError extends Error {
}
}
let desktopSessionRejected = false;
const isDesktopAuthenticationRequest = (path: string) =>
path === "/api/v1/auth/login" || path === "/api/v1/auth/session";
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const headers = new Headers(init?.headers);
const isDesktop = Boolean(window.edgeeverDesktop?.isAvailable);
const sessionToken = isDesktop ? getCachedDesktopSession()?.sessionToken : undefined;
if (isDesktop && desktopSessionRejected && !isDesktopAuthenticationRequest(path)) {
throw new ApiRequestError("Authentication required", 401, "unauthorized");
}
if (sessionToken && !headers.has("Authorization") && path !== "/api/v1/auth/login") {
headers.set("Authorization", `Bearer ${sessionToken}`);
}
if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
@@ -209,13 +224,25 @@ const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
: response.statusText;
if (response.status === 401) {
window.dispatchEvent(new CustomEvent("edgeever:unauthorized"));
if (isDesktop) {
clearCachedDesktopSession();
if (!desktopSessionRejected) {
desktopSessionRejected = true;
window.dispatchEvent(new CustomEvent("edgeever:unauthorized"));
}
} else {
window.dispatchEvent(new CustomEvent("edgeever:unauthorized"));
}
}
throw new ApiRequestError(message || "Request failed", response.status, error?.code);
}
return response.json() as Promise<T>;
const body = await response.json() as T;
if (path === "/api/v1/auth/login") {
desktopSessionRejected = false;
}
return body;
};
export const api = {
+15 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
const { rewriteStagedResource } = await import("./desktop-sync.ts");
const { orderBootstrapNotebooks, rewriteStagedResource } = await import("./desktop-sync.ts");
describe("desktop staged resource sync", () => {
test("rewrites placeholders in memo JSON and markdown", () => {
@@ -16,3 +16,17 @@ describe("desktop staged resource sync", () => {
});
});
});
describe("desktop bootstrap sync", () => {
test("orders parent notebooks before their children", () => {
const child = { id: "child", parentId: "parent", name: "Child" };
const parent = { id: "parent", parentId: null, name: "Parent" };
const grandchild = { id: "grandchild", parentId: "child", name: "Grandchild" };
expect(orderBootstrapNotebooks([grandchild, child, parent]).map((notebook) => notebook.id)).toEqual([
"parent",
"child",
"grandchild",
]);
});
});
+30 -3
View File
@@ -3,6 +3,7 @@ import { api, ApiRequestError } from "@/lib/api";
import { isDesktopResourceRuntime } from "@/lib/desktop-resources";
type StagedResourceRewrite = { memoId: string; placeholder: string; url: string };
let lastSyncFailed = false;
const syncStagedResources = async (memoIdMappings: Map<string, string>) => {
if (!isDesktopResourceRuntime() || (typeof navigator !== "undefined" && !navigator.onLine)) return { attempted: 0, synced: 0, failed: 0, rewrites: [] as StagedResourceRewrite[], stagedIds: [] as string[] };
@@ -313,15 +314,37 @@ const syncOutbox = async (stagedRewrites: StagedResourceRewrite[], onlyKinds?: S
const applyBootstrap = async (page: SyncBootstrapResponse) => {
const changes = [
...page.notebooks.map((notebook) => ({ entityType: "notebook" as const, operation: "upsert" as const, entityId: notebook.id, notebook, memo: null })),
...orderBootstrapNotebooks(page.notebooks).map((notebook) => ({ entityType: "notebook" as const, operation: "upsert" as const, entityId: notebook.id, notebook, memo: null })),
...page.memos.map((memo) => ({ entityType: "memo" as const, operation: "upsert" as const, entityId: memo.id, memo, notebook: null })),
];
if (changes.length > 0) await request("sync.apply", { changes });
};
export const orderBootstrapNotebooks = (notebooks: SyncBootstrapResponse["notebooks"]) => {
const remaining = new Map(notebooks.map((notebook) => [notebook.id, notebook]));
const ordered: SyncBootstrapResponse["notebooks"] = [];
while (remaining.size > 0) {
let added = 0;
for (const [id, notebook] of remaining) {
if (notebook.parentId && remaining.has(notebook.parentId)) continue;
ordered.push(notebook);
remaining.delete(id);
added += 1;
}
if (added === 0) {
ordered.push(...remaining.values());
break;
}
}
return ordered;
};
const pullRemoteChanges = async () => {
const status = await request("sync.status", {});
if (!status.syncIdentity) {
await request("sync.bootstrap.prepare", {});
let afterId: string | null = null;
let snapshotCursor = 0;
let syncIdentity = "";
@@ -363,8 +386,11 @@ export const syncDesktopData = () => {
await removeSyncedStagedResources(stagedResources.stagedIds);
}
if (outbox.conflicted === 0 && creates.conflicted === 0 && (typeof navigator === "undefined" || navigator.onLine)) await pullRemoteChanges();
lastSyncFailed = false;
return { ...outbox, attempted: creates.attempted + outbox.attempted + stagedResources.attempted, synced: creates.synced + outbox.synced + stagedResources.synced, failed: creates.failed + outbox.failed + stagedResources.failed, conflicted: creates.conflicted + outbox.conflicted };
} catch {
} catch (error) {
lastSyncFailed = true;
console.error("[desktop-sync] Sync failed", error);
return { attempted: 0, synced: 0, failed: 1, conflicted: 0 };
}
})().finally(() => { activeSync = null; });
@@ -373,7 +399,8 @@ export const syncDesktopData = () => {
export const getDesktopSyncSummary = async () => {
const status = await request("sync.status", {});
return { total: status.pending + status.syncing + status.conflict + status.error, pending: status.pending, syncing: status.syncing, conflict: status.conflict, error: status.error };
const error = status.error + (lastSyncFailed ? 1 : 0);
return { total: status.pending + status.syncing + status.conflict + error, pending: status.pending, syncing: status.syncing, conflict: status.conflict, error };
};
export const discardDesktopConflicts = async () => {
+48
View File
@@ -1350,6 +1350,53 @@ fn sync_status(database: &Connection) -> Result<Value, String> {
}))
}
fn prepare_sync_bootstrap(database: &Connection) -> Result<Value, String> {
if meta_value(database, "sync.identity").is_some_and(|identity| !identity.is_empty()) {
return Ok(json!({ "clearedSeedData": false }));
}
let outbox_count = database
.query_row("SELECT COUNT(*) FROM _edgeever_sidecar_outbox", [], |row| {
row.get::<_, i64>(0)
})
.map_err(|e| e.to_string())?;
let non_seed_memos = database
.query_row(
"SELECT COUNT(*) FROM memos WHERE id <> 'memo_welcome'",
[],
|row| row.get::<_, i64>(0),
)
.map_err(|e| e.to_string())?;
let non_seed_notebooks = database
.query_row(
"SELECT COUNT(*) FROM notebooks
WHERE id NOT IN ('nb_inbox', 'nb_projects', 'nb_learning', 'nb_creative', 'nb_personal')",
[],
|row| row.get::<_, i64>(0),
)
.map_err(|e| e.to_string())?;
if outbox_count > 0 || non_seed_memos > 0 || non_seed_notebooks > 0 {
return Ok(json!({ "clearedSeedData": false }));
}
let tx = database
.unchecked_transaction()
.map_err(|e| e.to_string())?;
tx.execute("DELETE FROM memos_fts WHERE memo_id = 'memo_welcome'", [])
.map_err(|e| e.to_string())?;
tx.execute("DELETE FROM memos WHERE id = 'memo_welcome'", [])
.map_err(|e| e.to_string())?;
tx.execute(
"DELETE FROM notebooks
WHERE id IN ('nb_inbox', 'nb_projects', 'nb_learning', 'nb_creative', 'nb_personal')",
[],
)
.map_err(|e| e.to_string())?;
tx.commit().map_err(|e| e.to_string())?;
Ok(json!({ "clearedSeedData": true }))
}
fn sync_outbox_list(database: &Connection, params: &Value) -> Result<Value, String> {
let limit = params
.get("limit")
@@ -1648,6 +1695,7 @@ fn handle(
"storage.backups" => list_backups(root).map_err(|error| error.to_string()),
"storage.restore" => restore_database(database, root, migrations, &request.params),
"sync.status" => sync_status(database),
"sync.bootstrap.prepare" => prepare_sync_bootstrap(database),
"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),
+3
View File
@@ -8,6 +8,7 @@ export type DesktopRpcMethod =
| "storage.backups"
| "storage.restore"
| "sync.status"
| "sync.bootstrap.prepare"
| "notebook.list"
| "notebook.create"
| "notebook.update"
@@ -103,6 +104,7 @@ export type DesktopRpcResponses = {
"memo.restoreRevision": { memo: MemoDetail };
"memo.revision.cache": { ok: true };
"sync.status": { pending: number; syncing: number; conflict: number; error: number; cursor: number; syncIdentity: string | null; lastSyncedAt: string | null };
"sync.bootstrap.prepare": { clearedSeedData: boolean };
"sync.outbox.list": { items: DesktopOutboxItem[] };
"sync.outbox.ack": { ok: true; memo: MemoDetail | null; notebook: Notebook | null; template: MemoTemplate | null };
"sync.outbox.fail": { ok: true };
@@ -147,6 +149,7 @@ export type DesktopRpcParams = {
"memo.restoreRevision": { memoId: string; revisionId: string };
"memo.revision.cache": { revision: import("./types").MemoRevision };
"sync.status": Record<string, never>;
"sync.bootstrap.prepare": Record<string, never>;
"sync.outbox.list": { limit?: number };
"sync.outbox.ack": { id: number; remoteMemo?: MemoDetail; remoteNotebook?: Notebook; remoteTemplate?: MemoTemplate };
"sync.outbox.fail": { id: number; error: string; conflict?: boolean };
+8 -2
View File
@@ -36,14 +36,20 @@ const request = async (method, params = {}) => {
await waitFor((message) => message.event === "ready");
const notebooks = await request("notebook.list");
const inbox = notebooks.notebooks.find((notebook) => notebook.slug === "inbox");
assert.ok(inbox, "seed inbox notebook should exist");
const seedInbox = notebooks.notebooks.find((notebook) => notebook.slug === "inbox");
assert.ok(seedInbox, "seed inbox notebook should exist");
assert.deepEqual(await request("sync.bootstrap.prepare"), { clearedSeedData: true });
assert.equal((await request("memo.list", { limit: 20 })).totalCount, 0, "bootstrap preparation should remove only pristine seed data");
assert.equal((await request("notebook.list")).notebooks.length, 0, "bootstrap preparation should remove pristine seed notebooks");
const inbox = (await request("notebook.create", { name: "Inbox" })).notebook;
if (process.platform !== "win32") {
assert.equal(statSync(dataDir).mode & 0o777, 0o700, "sidecar data directory should be private");
assert.equal(statSync(join(dataDir, "edgeever.sqlite")).mode & 0o777, 0o600, "sidecar database should be private");
}
const first = await request("memo.create", { notebookId: inbox.id, title: "Local first", contentMarkdown: "searchable body", tags: ["local"] });
assert.deepEqual(await request("sync.bootstrap.prepare"), { clearedSeedData: false });
assert.equal((await request("memo.get", { memoId: first.memo.id })).memo.id, first.memo.id, "bootstrap preparation must preserve local user data");
const second = await request("memo.create", { notebookId: inbox.id, title: "Second memo", contentMarkdown: "another body", tags: [] });
const search = await request("memo.list", { q: "searchable", limit: 20 });
assert.deepEqual(search.memos.map((memo) => memo.id), [first.memo.id]);