fix(desktop): preserve notebook dependencies during sync

This commit is contained in:
tianma-if committed 2026-10-09 10:18:09 +08:00
1 parent 89193d3986
commit d18ccc2e9b
4 files changed
+233 -18

No files matched your search

+97
View File
@@ -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;
+14 -3
View File
@@ -570,8 +570,16 @@ const syncOutbox = async (stagedRewrites: StagedResourceRewrite[], onlyKinds?: S
const response = await request("sync.outbox.list", { limit: 100 });
const memoIdMappings = new Map<string, string>();
const syncedMemos = new Map<string, DesktopRpcResponses["memo.get"]["memo"]>();
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";
+53 -14
View File
@@ -328,7 +328,11 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result<V
} else {
outbox_entity
};
database.execute("UPDATE _edgeever_sidecar_outbox SET entity_id = ?1, payload_json = ?2, version = version + 1 WHERE id = ?3", rusqlite::params![next_entity, payload.to_string(), outbox_id]).map_err(|e| e.to_string())?;
database.execute("UPDATE _edgeever_sidecar_outbox SET entity_id = ?1, payload_json = ?2, version = version + 1,
status = CASE WHEN status = 'error' AND last_error_code = 'memo_not_found' THEN 'pending' ELSE status END,
retryable = CASE WHEN status = 'error' AND last_error_code = 'memo_not_found' THEN 1 ELSE retryable END,
next_attempt_at = CASE WHEN status = 'error' AND last_error_code = 'memo_not_found' THEN NULL ELSE next_attempt_at END
WHERE id = ?3", rusqlite::params![next_entity, payload.to_string(), outbox_id]).map_err(|e| e.to_string())?;
}
if kind == "memo.create" {
if let Some((temporary_revision, temporary_content_hash)) =
@@ -352,26 +356,39 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result<V
if kind == "notebook.create" {
if let Some(remote) = remote_notebook.as_ref() {
let remote_id = string_param(remote, "id")?;
let remote_exists: bool = database
let tx = database
.unchecked_transaction()
.map_err(|e| e.to_string())?;
let remote_exists: bool = tx
.query_row(
"SELECT EXISTS(SELECT 1 FROM notebooks WHERE id = ?1)",
[&remote_id],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
if remote_exists {
database
.execute("DELETE FROM notebooks WHERE id = ?1", [&entity_id])
.map_err(|e| e.to_string())?;
} else {
database
.execute(
"UPDATE notebooks SET id = ?1 WHERE id = ?2",
rusqlite::params![remote_id, entity_id],
)
if remote_exists && remote_id != entity_id {
// The renderer caches the cloud notebook before acknowledging it.
// Move notes and children before deleting the local placeholder.
tx.execute(
"UPDATE memos SET notebook_id = ?1 WHERE notebook_id = ?2",
rusqlite::params![remote_id, entity_id],
)
.map_err(|e| e.to_string())?;
tx.execute(
"UPDATE notebooks SET parent_id = ?1 WHERE parent_id = ?2",
rusqlite::params![remote_id, entity_id],
)
.map_err(|e| e.to_string())?;
tx.execute("DELETE FROM notebooks WHERE id = ?1", [&entity_id])
.map_err(|e| e.to_string())?;
} else if !remote_exists {
tx.execute(
"UPDATE notebooks SET id = ?1 WHERE id = ?2",
rusqlite::params![remote_id, entity_id],
)
.map_err(|e| e.to_string())?;
}
let mut pending = database.prepare("SELECT id, entity_id, payload_json FROM _edgeever_sidecar_outbox WHERE id <> ?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<V
if payload.get("notebookId").and_then(Value::as_str) == Some(entity_id.as_str()) {
payload["notebookId"] = json!(remote_id);
}
if payload.get("parentId").and_then(Value::as_str) == Some(entity_id.as_str()) {
payload["parentId"] = json!(remote_id);
}
if payload.get("temporaryId").and_then(Value::as_str) == Some(entity_id.as_str()) {
payload["temporaryId"] = json!(remote_id);
}
@@ -394,8 +414,27 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result<V
} else {
outbox_entity
};
database.execute("UPDATE _edgeever_sidecar_outbox SET entity_id = ?1, payload_json = ?2, version = version + 1 WHERE id = ?3", rusqlite::params![next_entity, payload.to_string(), outbox_id]).map_err(|e| e.to_string())?;
tx.execute("UPDATE _edgeever_sidecar_outbox SET entity_id = ?1, payload_json = ?2, version = version + 1,
status = CASE WHEN status = 'error' AND last_error_code = 'not_found' THEN 'pending' ELSE status END,
retryable = CASE WHEN status = 'error' AND last_error_code = 'not_found' THEN 1 ELSE retryable END,
next_attempt_at = CASE WHEN status = 'error' AND last_error_code = 'not_found' THEN NULL ELSE next_attempt_at END
WHERE id = ?3", rusqlite::params![next_entity, payload.to_string(), outbox_id]).map_err(|e| e.to_string())?;
}
drop(pending);
// Commit the remap and acknowledgement together. Otherwise a crash
// can leave an already-created notebook queued for another create.
let deleted = tx.execute(
"DELETE FROM _edgeever_sidecar_outbox WHERE id = ?1 AND (?2 IS NULL OR version = ?2)",
rusqlite::params![id, requested_version],
).map_err(|e| e.to_string())?;
tx.commit().map_err(|e| e.to_string())?;
return Ok(json!({
"ok": true,
"superseded": deleted == 0,
"memo": remote_memo,
"notebook": remote_notebook,
"template": remote_template
}));
}
}
if kind == "template.create" {
+69 -1
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { Database } from "bun:sqlite";
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
@@ -521,6 +522,73 @@ const blockedNotebookIds = new Set((await request("notebook.list")).notebooks.ma
assert.equal(blockedNotebookIds.has(blockedNotebook.id), true, "a notebook with notes in a child should stay");
assert.equal(blockedNotebookIds.has(blockedChild.id), true, "a child notebook that still has notes should stay");
// Reproduce the installed client's failed notebook acknowledgement with notes
// and a child already referring to the placeholder.
const pendingNotebook = (await request("notebook.create", { name: "Notebook remap regression" })).notebook;
const pendingChild = (await request("notebook.create", { name: "Pending child", parentId: pendingNotebook.id })).notebook;
const pendingNotes = [];
for (const contentMarkdown of ["first preserved body", "second preserved body"]) {
pendingNotes.push((await request("memo.create", { notebookId: pendingNotebook.id, contentMarkdown, tags: [] })).memo);
}
pendingNotes[0] = (await request("memo.update", {
memoId: pendingNotes[0].id,
contentMarkdown: "edited body survives recovery",
contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "edited body survives recovery" }] }] },
tags: [],
})).memo;
const notebookQueue = (await request("sync.outbox.list", { limit: 200 })).items;
const parentCreate = notebookQueue.find((item) => 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"] }));