fix(sync): clear conflicts after confirmed memo deletion

This commit is contained in:
tianma-if committed 2026-10-07 22:01:49 +08:00
1 parent 27f3ce7749
commit a7f55da099
9 files changed
+260 -19

No files matched your search

+28 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
const {
classifyDesktopSyncFailure,
discardDesktopConflicts,
createDesktopSyncIssueDetails,
createDesktopSyncDiagnosticText,
createDesktopSyncSummary,
@@ -22,7 +23,33 @@ const {
shouldAttemptDesktopRecoveryPull,
shouldPullDesktopChanges,
} = await import("./desktop-sync.ts");
const { ApiRequestError } = await import("./api.ts");
const { api, ApiRequestError } = await import("./api.ts");
test("desktop explicit discard clears a conflict whose cloud note is gone", async () => {
const previousWindow = globalThis.window;
const originalGetMemo = api.getMemo;
const calls = [];
globalThis.window = {
edgeeverDesktop: {
isAvailable: true,
sidecarRequest: async (method, params) => {
calls.push({ method, params });
if (method === "sync.outbox.list") return { items: [{ id: 7, version: 2, kind: "memo.update", entityId: "memo-gone", status: "conflict" }] };
return { ok: true };
},
},
};
api.getMemo = async () => { throw new ApiRequestError("Not found", 404, "not_found"); };
try {
expect(await discardDesktopConflicts()).toBe(1);
expect(calls.map((call) => call.method)).toEqual(["sync.outbox.list", "sync.outbox.discard"]);
expect(calls[1].params).toEqual({ id: 7, version: 2 });
} finally {
api.getMemo = originalGetMemo;
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});
describe("desktop staged resource sync", () => {
test("holds cloud sync until an imported screenshot is saved", async () => {
+13 -3
View File
@@ -959,9 +959,19 @@ export const discardDesktopConflicts = async () => {
for (const item of conflicts) {
if (item.kind === "memo.update") {
// Replace the local conflicted draft with the authoritative remote memo
// before removing the queue item. If the remote read fails, keep the
// conflict so the user's local changes remain recoverable.
const remote = await api.getMemo(item.entityId, { includeDeleted: true });
// before removing the queue item. Keep the conflict on read failures
// unless the server confirms that the note no longer exists.
let remote: Awaited<ReturnType<typeof api.getMemo>>;
try {
remote = await api.getMemo(item.entityId, { includeDeleted: true });
} catch (error) {
if (!(error instanceof ApiRequestError) || error.status !== 404) throw error;
// Explicit discard can resolve a conflict left behind after the note
// was permanently deleted and no cloud version exists to adopt.
await request("sync.outbox.discard", { id: item.id, version: item.version });
discarded += 1;
continue;
}
await request("sync.apply", {
changes: [{ entityType: "memo", operation: "upsert", entityId: remote.memo.id, memo: remote.memo, notebook: null }],
});
+19 -1
View File
@@ -7,7 +7,7 @@ globalThis.IDBKeyRange = IDBKeyRange;
const { localDb } = await import("./local-db.ts");
const { api } = await import("./api.ts");
const { createWebRepository } = await import("./repository.ts");
const { createLocalMemo } = await import("./local-mirror.ts");
const { createLocalMemo, putLocalMemo } = await import("./local-mirror.ts");
const installTestWindow = ({ hostname = "localhost" } = {}) => {
const previousWindow = globalThis.window;
@@ -36,6 +36,24 @@ afterEach(async () => {
});
describe("web repository offline boundaries", () => {
test("empty trash records the removed memo ids for conflict cleanup after sync", async () => {
const restoreWindow = installTestWindow();
const scope = "https://demo.edgeever.org|user-1";
const trashed = await createLocalMemo(scope, { notebookId: "nb-1" });
const active = await createLocalMemo(scope, { notebookId: "nb-1" });
await putLocalMemo(scope, { ...trashed, isDeleted: true });
try {
expect(await createWebRepository(scope).emptyTrash()).toEqual({ ok: true, deleted: 1 });
await putLocalMemo(scope, { ...active, isDeleted: true });
expect(await createWebRepository(scope).emptyTrash()).toEqual({ ok: true, deleted: 1 });
const action = await localDb.syncQueue.get(`action:${scope}:memo.emptyTrash:trash`);
expect(action?.payload).toEqual({ memoIds: [trashed.id, active.id] });
expect(await localDb.memos.get([scope, active.id])).toBeUndefined();
} finally {
restoreWindow();
}
});
test("reconciles stale template cache entries while preserving queued local edits", async () => {
const previousOnline = globalThis.navigator?.onLine;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
+4 -1
View File
@@ -413,8 +413,11 @@ export const createWebRepository = (scope: string): EdgeEverRepository => {
return { ok: true as const, deleted };
},
emptyTrash: async () => {
const memoIds = (await localDb.memos.where("scope").equals(scope).toArray())
.filter((memo) => memo.isDeleted)
.map((memo) => memo.id);
const deleted = await applyLocalEmptyTrash(scope);
await queueLocalAction(scope, "memo.emptyTrash", "trash", {});
await queueLocalAction(scope, "memo.emptyTrash", "trash", { memoIds });
window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed"));
return { ok: true as const, deleted };
},
+76 -2
View File
@@ -5,9 +5,9 @@ globalThis.indexedDB = indexedDB;
globalThis.IDBKeyRange = IDBKeyRange;
const { localDb } = await import("./local-db.ts");
const { api } = await import("./api.ts");
const { api, ApiRequestError } = await import("./api.ts");
const { createLocalDataScope, createLocalMemo, getLocalMemo, putLocalMemo, replaceLocalMemoId, createLocalResource, listLocalResources } = await import("./local-mirror.ts");
const { discardWebConflicts, getMemoUpdateQueueId, queueLocalAction, queueMemoCreate, queueMemoUpdate, syncQueuedChanges } = await import("./sync-queue.ts");
const { discardWebConflicts, getMemoUpdateQueueId, queueLocalAction, queueMemoCreate, queueMemoDelete, queueMemoUpdate, syncQueuedChanges } = await import("./sync-queue.ts");
afterEach(async () => {
await localDb.transaction(
@@ -30,6 +30,60 @@ afterEach(async () => {
});
describe("web sync conflict recovery", () => {
test("confirmed note deletions clear only their older local conflicts", async () => {
const previousOnline = globalThis.navigator?.onLine;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
const scope = createLocalDataScope("https://demo.edgeever.org", "user-1");
const old = "2026-01-01T00:00:00.000Z";
for (const memoId of ["memo-single", "memo-batch", "memo-trash", "memo-unrelated"]) {
await localDb.syncQueue.put({
id: getMemoUpdateQueueId(memoId), kind: "memo.update", scope, memoId,
status: "conflict", payload: { memoId }, attemptCount: 1,
lastError: "conflict", nextAttemptAt: null, claimId: null,
createdAt: old, updatedAt: old,
});
}
await queueMemoDelete(scope, { memoId: "memo-single", permanent: false });
await queueLocalAction(scope, "memo.deleteBatch", "batch", { memoIds: ["memo-batch"], permanent: true });
await queueLocalAction(scope, "memo.emptyTrash", "trash", { memoIds: ["memo-trash"] });
const original = { deleteMemo: api.deleteMemo, deleteMemos: api.deleteMemos, emptyTrash: api.emptyTrash };
api.deleteMemo = async () => ({ ok: true });
api.deleteMemos = async () => ({ ok: true, deleted: 1 });
api.emptyTrash = async () => ({ ok: true, deleted: 1 });
try {
expect(await syncQueuedChanges({ scope })).toMatchObject({ synced: 3, conflicted: 0 });
for (const memoId of ["memo-single", "memo-batch", "memo-trash"]) {
expect(await localDb.syncQueue.get(getMemoUpdateQueueId(memoId))).toBeUndefined();
}
expect(await localDb.syncQueue.get(getMemoUpdateQueueId("memo-unrelated"))).toBeDefined();
} finally {
Object.assign(api, original);
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline });
}
});
test("failed note deletion preserves the local conflict", async () => {
const previousOnline = globalThis.navigator?.onLine;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
const scope = createLocalDataScope("https://demo.edgeever.org", "user-1");
await localDb.syncQueue.put({
id: getMemoUpdateQueueId("memo-failed"), kind: "memo.update", scope, memoId: "memo-failed",
status: "conflict", payload: { memoId: "memo-failed" }, attemptCount: 1,
lastError: "conflict", nextAttemptAt: null, claimId: null,
createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
});
await queueMemoDelete(scope, { memoId: "memo-failed", permanent: false });
const original = api.deleteMemo;
api.deleteMemo = async () => { throw new Error("offline"); };
try {
expect(await syncQueuedChanges({ scope })).toMatchObject({ failed: 1 });
expect(await localDb.syncQueue.get(getMemoUpdateQueueId("memo-failed"))).toMatchObject({ status: "conflict" });
} finally {
api.deleteMemo = original;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline });
}
});
test("preserves a newer draft written while a memo update is in flight", async () => {
const previousOnline = globalThis.navigator?.onLine;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
@@ -263,6 +317,26 @@ describe("web sync conflict recovery", () => {
api.getMemo = originalGetMemo;
});
test("explicitly discards an old conflict when its cloud note is gone", async () => {
const scope = createLocalDataScope("https://demo.edgeever.org", "user-1");
const memo = await createLocalMemo(scope, { notebookId: "inbox", title: "Deleted cloud note" });
await localDb.syncQueue.put({
id: getMemoUpdateQueueId(memo.id), kind: "memo.update", scope, memoId: memo.id,
status: "conflict", payload: { memoId: memo.id }, attemptCount: 1,
lastError: "conflict", nextAttemptAt: null, claimId: null,
createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
});
const original = api.getMemo;
api.getMemo = async () => { throw new ApiRequestError("Not found", 404, "not_found"); };
try {
expect(await discardWebConflicts(scope)).toBe(1);
expect(await localDb.syncQueue.get(getMemoUpdateQueueId(memo.id))).toBeUndefined();
expect(await getLocalMemo(scope, memo.id)).toBeNull();
} finally {
api.getMemo = original;
}
});
test("uploads an offline resource and rewrites its memo reference", async () => {
const previousCaches = globalThis.caches;
const previousOnline = globalThis.navigator?.onLine;
+41 -4
View File
@@ -50,13 +50,24 @@ export const getLocalActionQueueId = (scope: string, kind: LocalActionKind, enti
export const queueLocalAction = async (scope: string, kind: LocalActionKind, entityId: string, payload: LocalActionPayload, memoId = entityId) => {
const id = getLocalActionQueueId(scope, kind, entityId);
const now = new Date().toISOString();
const previous = kind === "memo.emptyTrash" ? await localDb.syncQueue.get(id) : null;
const previousMemoIds = (previous?.payload as LocalActionPayload | undefined)?.memoIds;
const queuedPayload = kind === "memo.emptyTrash" && previous?.kind === kind
? {
...payload,
memoIds: [...new Set([
...(Array.isArray(previousMemoIds) ? previousMemoIds : []),
...(Array.isArray(payload.memoIds) ? payload.memoIds : []),
])],
}
: payload;
await localDb.syncQueue.put({
id,
kind,
scope,
memoId,
status: "pending",
payload,
payload: queuedPayload,
attemptCount: 0,
lastError: null,
lastErrorCode: null,
@@ -267,12 +278,19 @@ export const discardWebConflicts = async (scope: string) => {
}
}
let discarded = 0;
const { putLocalMemo } = await import("@/lib/local-mirror");
const { deleteLocalMemo, putLocalMemo } = await import("@/lib/local-mirror");
for (const item of conflicts) {
try {
if (item.kind === "memo.update") {
const remote = await api.getMemo(item.memoId, { includeDeleted: true });
await putLocalMemo(scope, remote.memo);
try {
const remote = await api.getMemo(item.memoId, { includeDeleted: true });
await putLocalMemo(scope, remote.memo);
} catch (error) {
if (!(error instanceof ApiRequestError) || error.status !== 404) throw error;
// The user explicitly chose to discard this local draft, and the
// cloud note was permanently removed on another sync pass.
await deleteLocalMemo(scope, item.memoId, true);
}
await localDb.drafts.delete(item.memoId);
}
await localDb.syncQueue.delete(item.id);
@@ -489,6 +507,13 @@ const isDraftCoveredByMemoUpdate = (item: SyncQueueItem, draft: LocalDraft | und
JSON.stringify(draft.contentJson) === JSON.stringify(payload.contentJson);
};
const deletedMemoIdsFromAction = (item: SyncQueueItem): string[] => {
if (item.kind === "memo.delete") return [item.memoId];
if (item.kind !== "memo.deleteBatch" && item.kind !== "memo.emptyTrash") return [];
const memoIds = (item.payload as LocalActionPayload).memoIds;
return Array.isArray(memoIds) ? memoIds.filter((id): id is string => typeof id === "string") : [];
};
const acknowledgeClaimedQueueItem = (item: SyncQueueItem, result: SyncQueueResult): Promise<MemoUpdateAcknowledgement> =>
localDb.transaction("rw", [localDb.syncQueue, localDb.drafts, localDb.memos], async () => {
const current = await localDb.syncQueue.get(item.id);
@@ -498,6 +523,18 @@ const acknowledgeClaimedQueueItem = (item: SyncQueueItem, result: SyncQueueResul
if (current?.claimId === item.claimId && current.status === "syncing") {
await localDb.syncQueue.delete(item.id);
// A successful delete supersedes older conflicted edits to those notes.
// Keep the draft until the server has confirmed the delete, so a failed
// delete still leaves the local conflict recoverable.
for (const memoId of deletedMemoIdsFromAction(item)) {
for (const queued of await localDb.syncQueue.where("memoId").equals(memoId).toArray()) {
if (queued.kind !== "memo.update" || queued.status !== "conflict") continue;
if (queued.scope && queued.scope !== item.scope) continue;
if (queued.createdAt > item.createdAt) continue;
await localDb.syncQueue.delete(queued.id);
await localDb.drafts.delete(memoId);
}
}
if (item.kind !== "memo.create") {
const draft = await localDb.drafts.get(item.memoId);
if (!draft || item.kind !== "memo.update" || isDraftCoveredByMemoUpdate(item, draft)) {
+44 -1
View File
@@ -193,7 +193,50 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
mod tests {
use super::*;
use crate::memo::{resolve_custom_merge_title, resolve_sidecar_merge_markdown};
use crate::sync::{apply_sync_changes, prepare_sync_bootstrap};
use crate::sync::{apply_sync_changes, prepare_sync_bootstrap, sync_outbox_ack};
#[test]
fn acknowledged_deletes_clear_only_older_conflicted_memo_edits() {
for (kind, entity_id, payload) in [
("memo.delete", "memo-a", json!({ "memoId": "memo-a" })),
("memo.deleteBatch", "batch", json!({ "memoIds": ["memo-a"] })),
("memo.emptyTrash", "trash", json!({ "memoIds": ["memo-a"] })),
] {
let database = Connection::open_in_memory().unwrap();
database.execute_batch(
"CREATE TABLE _edgeever_sidecar_outbox (
id INTEGER PRIMARY KEY, kind TEXT NOT NULL, entity_id TEXT NOT NULL,
payload_json TEXT NOT NULL, status TEXT NOT NULL, version INTEGER NOT NULL
);
INSERT INTO _edgeever_sidecar_outbox VALUES
(1, 'memo.update', 'memo-a', '{}', 'conflict', 1),
(2, 'memo.update', 'memo-b', '{}', 'conflict', 1);",
).unwrap();
database.execute(
"INSERT INTO _edgeever_sidecar_outbox VALUES (3, ?1, ?2, ?3, 'pending', 1)",
rusqlite::params![kind, entity_id, payload.to_string()],
).unwrap();
database.execute(
"INSERT INTO _edgeever_sidecar_outbox VALUES (4, 'memo.update', 'memo-a', '{}', 'conflict', 1)",
[],
).unwrap();
let stale = sync_outbox_ack(&database, &json!({ "id": 3, "version": 0 })).unwrap();
assert_eq!(stale["superseded"], true);
let count: i64 = database.query_row(
"SELECT COUNT(*) FROM _edgeever_sidecar_outbox WHERE status = 'conflict'",
[], |row| row.get(0),
).unwrap();
assert_eq!(count, 3, "stale acknowledgement must preserve drafts");
sync_outbox_ack(&database, &json!({ "id": 3, "version": 1 })).unwrap();
let remaining: Vec<i64> = database.prepare(
"SELECT id FROM _edgeever_sidecar_outbox ORDER BY id"
).unwrap().query_map([], |row| row.get(0)).unwrap()
.collect::<Result<_, _>>().unwrap();
assert_eq!(remaining, vec![2, 4], "{kind} must clear only the deleted memo's older conflict");
}
}
#[test]
fn string_param_rejects_missing_or_non_string_values() {
+8 -1
View File
@@ -224,6 +224,13 @@ pub(crate) fn empty_trash(database: &Connection) -> Result<Value, String> {
let tx = database
.unchecked_transaction()
.map_err(|e| e.to_string())?;
let memo_ids: Vec<String> = tx
.prepare("SELECT id FROM memos WHERE is_deleted = 1")
.map_err(|e| e.to_string())?
.query_map([], |row| row.get(0))
.map_err(|e| e.to_string())?
.collect::<Result<_, _>>()
.map_err(|e| e.to_string())?;
tx.execute(
"DELETE FROM resources WHERE memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)",
[],
@@ -233,7 +240,7 @@ pub(crate) fn empty_trash(database: &Connection) -> Result<Value, String> {
.execute("DELETE FROM memos WHERE is_deleted = 1", [])
.map_err(|e| e.to_string())?;
tx.commit().map_err(|e| e.to_string())?;
enqueue_change(database, "memo.emptyTrash", "trash", &json!({}))?;
enqueue_change(database, "memo.emptyTrash", "trash", &json!({ "memoIds": memo_ids }))?;
Ok(json!({ "ok": true, "deleted": deleted }))
}
+27 -5
View File
@@ -160,11 +160,11 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result<V
.get("id")
.and_then(Value::as_i64)
.ok_or_else(|| "Missing outbox id".to_owned())?;
let (kind, entity_id, current_version): (String, String, i64) = database
let (kind, entity_id, payload_json, current_version): (String, String, String, i64) = database
.query_row(
"SELECT kind, entity_id, version FROM _edgeever_sidecar_outbox WHERE id = ?1",
"SELECT kind, entity_id, payload_json, version FROM _edgeever_sidecar_outbox WHERE id = ?1",
[id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.map_err(|e| e.to_string())?;
let requested_version = params.get("version").and_then(Value::as_i64);
@@ -447,18 +447,40 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result<V
}
}
}
let tx = database
.unchecked_transaction()
.map_err(|e| e.to_string())?;
let deleted = if let Some(version) = requested_version {
database
tx
.execute(
"DELETE FROM _edgeever_sidecar_outbox WHERE id = ?1 AND version = ?2",
rusqlite::params![id, version],
)
.map_err(|e| e.to_string())?
} else {
database
tx
.execute("DELETE FROM _edgeever_sidecar_outbox WHERE id = ?1", [id])
.map_err(|e| e.to_string())?
};
if deleted == 1 && matches!(kind.as_str(), "memo.delete" | "memo.deleteBatch" | "memo.emptyTrash") {
let payload: Value = serde_json::from_str(&payload_json).unwrap_or_else(|_| json!({}));
let memo_ids: Vec<&str> = if kind == "memo.delete" {
vec![entity_id.as_str()]
} else {
payload.get("memoIds").and_then(Value::as_array)
.map(|ids| ids.iter().filter_map(Value::as_str).collect())
.unwrap_or_default()
};
for memo_id in memo_ids {
// Delete only older conflicted edits after the server accepted the
// delete. A failed delete must leave its local draft recoverable.
tx.execute(
"DELETE FROM _edgeever_sidecar_outbox WHERE kind = 'memo.update' AND entity_id = ?1 AND status = 'conflict' AND id < ?2",
rusqlite::params![memo_id, id],
).map_err(|e| e.to_string())?;
}
}
tx.commit().map_err(|e| e.to_string())?;
Ok(
json!({ "ok": true, "superseded": deleted == 0, "memo": remote_memo, "notebook": remote_notebook, "template": remote_template }),
)