From d18ccc2e9be0b90041afcd49ed577c57f2f61795 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 9 Oct 2026 10:18:09 +0800 Subject: [PATCH] fix(desktop): preserve notebook dependencies during sync --- apps/web/src/lib/desktop-sync.test.mjs | 97 ++++++++++++++++++++++++++ apps/web/src/lib/desktop-sync.ts | 17 ++++- crates/desktop-sidecar/src/sync.rs | 67 ++++++++++++++---- scripts/test-desktop-sidecar.mjs | 70 ++++++++++++++++++- 4 files changed, 233 insertions(+), 18 deletions(-) diff --git a/apps/web/src/lib/desktop-sync.test.mjs b/apps/web/src/lib/desktop-sync.test.mjs index f3173e0d..57e5e5cc 100644 --- a/apps/web/src/lib/desktop-sync.test.mjs +++ b/apps/web/src/lib/desktop-sync.test.mjs @@ -70,6 +70,103 @@ test("desktop notebook create announces its durable id after sidecar acknowledgm } }); +test("desktop creates notebooks before notes and rereads remapped queue versions", async () => { + const previousWindow = globalThis.window; + const previousNavigator = globalThis.navigator; + const originalCreateNotebook = api.createNotebook; + const originalCreateMemo = api.createMemo; + const calls = []; + let queue = [ + { id: 1, version: 1, kind: "notebook.create", entityId: "nb_local_parent", payload: { name: "对对对" } }, + ...[2, 3].map((id) => ({ id, version: 1, kind: "memo.create", entityId: `memo_local_${id}`, payload: { notebookId: "nb_local_parent", contentMarkdown: `body ${id}` } })), + ]; + globalThis.window = { + dispatchEvent: () => true, + edgeeverDesktop: { + isAvailable: true, + listStagedResources: async () => [], + listStagedResourceAliases: async () => [], + sidecarRequest: async (method, params) => { + if (method === "sync.outbox.list") return { items: queue.map((item) => ({ ...item, payload: { ...item.payload } })) }; + if (method === "sync.status") return { pending: 1, syncing: 0 }; + if (method === "sync.outbox.ack") { + const current = queue.find((item) => item.id === params.id); + expect(params.version).toBe(current.version); + queue = queue.filter((item) => item.id !== params.id); + if (params.remoteNotebook) { + queue = queue.map((item) => ({ ...item, version: item.version + 1, payload: { ...item.payload, notebookId: "nb_cloud" } })); + } + } + if (method === "sync.outbox.fail") throw new Error("No dependency should fail"); + return { ok: true }; + }, + }, + }; + globalThis.navigator = { onLine: true }; + api.createNotebook = async () => { + calls.push("notebook"); + return { notebook: { id: "nb_cloud", name: "对对对", parentId: null } }; + }; + api.createMemo = async (payload) => { + expect(payload.notebookId).toBe("nb_cloud"); + calls.push(payload.contentMarkdown); + return { memo: { id: `memo_cloud_${calls.length}`, contentMarkdown: payload.contentMarkdown, contentJson: { type: "doc", content: [] } } }; + }; + try { + const result = await syncDesktopData(); + expect(result.failed).toBe(0); + expect(result.synced).toBe(3); + expect(calls).toEqual(["notebook", "body 2", "body 3"]); + expect(queue).toEqual([]); + } finally { + api.createNotebook = originalCreateNotebook; + api.createMemo = originalCreateMemo; + globalThis.navigator = previousNavigator; + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + } +}); + +test("desktop leaves dependent notes pending when their notebook upload fails", async () => { + const previousWindow = globalThis.window; + const previousNavigator = globalThis.navigator; + const originalCreateNotebook = api.createNotebook; + const originalCreateMemo = api.createMemo; + const failures = []; + let notebookEligible = true; + const notebook = { id: 1, version: 1, attemptCount: 0, kind: "notebook.create", entityId: "nb_local_failed", payload: { name: "Pending" } }; + const note = { id: 2, version: 1, kind: "memo.create", entityId: "memo_local_waiting", payload: { notebookId: notebook.entityId } }; + globalThis.window = { + dispatchEvent: () => true, + edgeeverDesktop: { + isAvailable: true, + listStagedResources: async () => [], + listStagedResourceAliases: async () => [], + sidecarRequest: async (method, params) => { + if (method === "sync.outbox.list") return { items: notebookEligible ? [notebook, note] : [note] }; + if (method === "sync.status") return { pending: 1, syncing: 0, error: 1 }; + if (method === "sync.outbox.fail") { failures.push(params.id); notebookEligible = false; } + return { ok: true }; + }, + }, + }; + globalThis.navigator = { onLine: true }; + api.createNotebook = async () => { throw new TypeError("Network unavailable"); }; + api.createMemo = async () => { throw new Error("Dependent notes must wait"); }; + try { + const result = await syncDesktopData(); + expect(result.failed).toBe(1); + expect(result.attempted).toBe(1); + expect(failures).toEqual([notebook.id]); + } finally { + api.createNotebook = originalCreateNotebook; + api.createMemo = originalCreateMemo; + globalThis.navigator = previousNavigator; + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + } +}); + test("desktop explicit discard clears a conflict whose cloud note is gone", async () => { const previousWindow = globalThis.window; const originalGetMemo = api.getMemo; diff --git a/apps/web/src/lib/desktop-sync.ts b/apps/web/src/lib/desktop-sync.ts index a09b29cc..dcde6309 100644 --- a/apps/web/src/lib/desktop-sync.ts +++ b/apps/web/src/lib/desktop-sync.ts @@ -570,8 +570,16 @@ const syncOutbox = async (stagedRewrites: StagedResourceRewrite[], onlyKinds?: S const response = await request("sync.outbox.list", { limit: 100 }); const memoIdMappings = new Map(); const syncedMemos = new Map(); - for (const item of response.items) { + let currentItems = response.items; + for (const candidate of response.items) { + // Earlier creates may have remapped this item and advanced its version. + const item = currentItems.find((current) => current.id === candidate.id); + if (!item) continue; if (onlyKinds && !onlyKinds.has(item.kind)) continue; + // A local dependency has no cloud id yet. Leave the action pending until + // its create succeeds rather than making a permanent not-found error. + if ([item.payload.notebookId, item.payload.parentId].some((id) => typeof id === "string" && id.startsWith("nb_local_"))) continue; + if (item.kind !== "memo.create" && [item.entityId, item.payload.memoId].some((id) => typeof id === "string" && id.startsWith("memo_local_"))) continue; try { const result = await syncOutboxItem(item, stagedRewrites); if (item.kind === "memo.create" && result && typeof result === "object" && "id" in result && typeof result.id === "string") { @@ -590,6 +598,9 @@ const syncOutbox = async (stagedRewrites: StagedResourceRewrite[], onlyKinds?: S notifyMemoSyncAcknowledged(syncedMemo); } synced += 1; + if (["notebook.create", "memo.create", "memo.merge"].includes(item.kind)) { + currentItems = (await request("sync.outbox.list", { limit: 100 })).items; + } } catch (error) { const disposition = classifyDesktopSyncFailure(item, error); await request("sync.outbox.fail", { @@ -605,7 +616,7 @@ const syncOutbox = async (stagedRewrites: StagedResourceRewrite[], onlyKinds?: S else failed += 1; } } - return { attempted: onlyKinds ? synced + failed + conflicted : response.items.length, synced, failed, conflicted, memoIdMappings, syncedMemos }; + return { attempted: synced + failed + conflicted, synced, failed, conflicted, memoIdMappings, syncedMemos }; }; const applyBootstrap = async (page: SyncBootstrapResponse) => { @@ -805,7 +816,7 @@ export const syncDesktopData = () => { } let phase: DesktopSyncPhase = "sync_creates"; try { - const creates = await syncOutbox([], new Set(["memo.create"])); + const creates = await syncOutbox([], new Set(["notebook.create", "memo.create"])); mergeMemoIdMappings(memoIdMappings, creates.memoIdMappings); mergeSyncedMemos(syncedMemos, creates.syncedMemos); phase = "remap_created_resources"; diff --git a/crates/desktop-sidecar/src/sync.rs b/crates/desktop-sidecar/src/sync.rs index eb8ff89d..93b36279 100644 --- a/crates/desktop-sidecar/src/sync.rs +++ b/crates/desktop-sidecar/src/sync.rs @@ -328,7 +328,11 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result Result ?1").map_err(|e| e.to_string())?; + let mut pending = tx.prepare("SELECT id, entity_id, payload_json FROM _edgeever_sidecar_outbox WHERE id <> ?1").map_err(|e| e.to_string())?; let rows: Vec<(i64, String, String)> = pending .query_map([id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) .map_err(|e| e.to_string())? @@ -386,6 +403,9 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result Result item.kind === "notebook.create" && item.entityId === pendingNotebook.id); +for (const item of notebookQueue.filter((item) => item.kind === "memo.create" && pendingNotes.some((memo) => memo.id === item.entityId))) { + await request("sync.outbox.fail", { id: item.id, version: item.version, error: "Notebook not found", errorCode: "not_found", retryable: false }); +} +const dependentUpdate = notebookQueue.find((item) => item.kind === "memo.update" && item.entityId === pendingNotes[0].id); +await request("sync.outbox.fail", { id: dependentUpdate.id, version: dependentUpdate.version, error: "Memo not found", errorCode: "memo_not_found", retryable: false }); +const durableNotebook = { ...pendingNotebook, id: "nb_regression_durable" }; +await request("sync.apply", { changes: [{ entityType: "notebook", operation: "upsert", entityId: durableNotebook.id, notebook: durableNotebook }] }); +// Force the final acknowledgement to fail after all remapping statements. +// Every relationship and queue payload must roll back together. +const inspectionDb = new Database(join(dataDir, "edgeever.sqlite")); +inspectionDb.exec(`CREATE TRIGGER fail_notebook_ack BEFORE DELETE ON _edgeever_sidecar_outbox + WHEN OLD.id = ${parentCreate.id} + BEGIN SELECT RAISE(ABORT, 'injected notebook ack failure'); END`); +await assert.rejects(request("sync.outbox.ack", { id: parentCreate.id, version: parentCreate.version, remoteNotebook: durableNotebook }), /injected notebook ack failure/); +assert.equal(inspectionDb.query("SELECT notebook_id FROM memos WHERE id = ?").get(pendingNotes[0].id).notebook_id, pendingNotebook.id); +assert.equal(inspectionDb.query("SELECT parent_id FROM notebooks WHERE id = ?").get(pendingChild.id).parent_id, pendingNotebook.id); +assert.equal(inspectionDb.query("SELECT COUNT(*) AS count FROM notebooks WHERE id = ?").get(pendingNotebook.id).count, 1); +const failedRemapQueue = (await request("sync.outbox.list", { limit: 200, includeConflicts: true })).items; +for (const original of notebookQueue.filter((item) => item.payload.notebookId === pendingNotebook.id || item.payload.parentId === pendingNotebook.id)) { + const current = failedRemapQueue.find((item) => item.id === original.id); + assert.equal(current.version, original.version); + assert.deepEqual(current.payload, original.payload); +} +assert.ok(failedRemapQueue.some((item) => item.id === parentCreate.id)); +inspectionDb.exec("DROP TRIGGER fail_notebook_ack"); +inspectionDb.close(); +await request("sync.outbox.ack", { id: parentCreate.id, version: parentCreate.version, remoteNotebook: durableNotebook }); +const afterNotebooks = (await request("notebook.list")).notebooks; +assert.ok(!afterNotebooks.some((notebook) => notebook.id === pendingNotebook.id)); +assert.equal(afterNotebooks.find((notebook) => notebook.id === pendingChild.id).parentId, durableNotebook.id); +const remappedQueue = (await request("sync.outbox.list", { limit: 200 })).items; +assert.equal(remappedQueue.find((item) => item.entityId === pendingChild.id).payload.parentId, durableNotebook.id); +for (const memo of pendingNotes) { + assert.equal((await request("memo.get", { memoId: memo.id })).memo.notebookId, durableNotebook.id); + assert.equal((await request("memo.get", { memoId: memo.id })).memo.contentMarkdown, memo.contentMarkdown); + const item = remappedQueue.find((item) => item.kind === "memo.create" && item.entityId === memo.id); + assert.equal(item.payload.notebookId, durableNotebook.id); + assert.equal(item.status, "pending", "dependency errors must become eligible after remapping"); + assert.equal(item.version, notebookQueue.find((previous) => previous.id === item.id).version + 1); +} + +const recoveredCreate = remappedQueue.find((item) => item.kind === "memo.create" && item.entityId === pendingNotes[0].id); +const recoveredRemoteMemo = { ...pendingNotes[0], id: "memo_recovered_notebook", notebookId: durableNotebook.id, revision: 1 }; +await request("sync.outbox.ack", { id: recoveredCreate.id, version: recoveredCreate.version, remoteMemo: recoveredRemoteMemo }); +const recoveredUpdate = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.id === dependentUpdate.id); +assert.equal(recoveredUpdate.entityId, recoveredRemoteMemo.id); +assert.equal(recoveredUpdate.payload.memoId, recoveredRemoteMemo.id); +assert.equal(recoveredUpdate.payload.contentMarkdown, "edited body survives recovery"); +assert.equal(recoveredUpdate.payload.expectedRevision, 1); +assert.equal(recoveredUpdate.status, "pending", "missing-memo edits should recover after memo create acknowledgement"); + 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.noteKind", "memo.list.tag", "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.apply.merge-page-order", "sync.apply.deleted-notebook", "sync.apply.renamed-inbox", "sync.outbox", "sync.outbox.retry", "sync.outbox.recoverMemoUpdate", "sync.outbox.discard", "notebook.delete.empty-tree", "notebook.delete.not-empty"] })); +console.log(JSON.stringify({ ok: true, checked: ["memo.create", "memo.list.search", "memo.list.noteKind", "memo.list.tag", "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.apply.merge-page-order", "sync.apply.deleted-notebook", "sync.apply.renamed-inbox", "sync.outbox", "sync.outbox.notebook-remap", "sync.outbox.notebook-remap-rollback", "sync.outbox.retry", "sync.outbox.recoverMemoUpdate", "sync.outbox.discard", "notebook.delete.empty-tree", "notebook.delete.not-empty"] }));