fix: repair desktop initial sync
This commit is contained in:
1 parent
c2ec65f263
commit
261f2a7974
7 files changed
+199
-9
No files matched your search
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in new issue
Block a user