diff --git a/apps/web/src/lib/desktop-sync.test.mjs b/apps/web/src/lib/desktop-sync.test.mjs index 5fb9b342..45c0d941 100644 --- a/apps/web/src/lib/desktop-sync.test.mjs +++ b/apps/web/src/lib/desktop-sync.test.mjs @@ -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 () => { diff --git a/apps/web/src/lib/desktop-sync.ts b/apps/web/src/lib/desktop-sync.ts index b0e60a53..df950c4d 100644 --- a/apps/web/src/lib/desktop-sync.ts +++ b/apps/web/src/lib/desktop-sync.ts @@ -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>; + 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 }], }); diff --git a/apps/web/src/lib/repository.test.mjs b/apps/web/src/lib/repository.test.mjs index 0bee6cb4..25ff7de2 100644 --- a/apps/web/src/lib/repository.test.mjs +++ b/apps/web/src/lib/repository.test.mjs @@ -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 }); diff --git a/apps/web/src/lib/repository.ts b/apps/web/src/lib/repository.ts index c3d43896..d1720753 100644 --- a/apps/web/src/lib/repository.ts +++ b/apps/web/src/lib/repository.ts @@ -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 }; }, diff --git a/apps/web/src/lib/sync-queue.test.mjs b/apps/web/src/lib/sync-queue.test.mjs index 75b8f0f0..590f68ea 100644 --- a/apps/web/src/lib/sync-queue.test.mjs +++ b/apps/web/src/lib/sync-queue.test.mjs @@ -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; diff --git a/apps/web/src/lib/sync-queue.ts b/apps/web/src/lib/sync-queue.ts index 87351ed4..cb9d747b 100644 --- a/apps/web/src/lib/sync-queue.ts +++ b/apps/web/src/lib/sync-queue.ts @@ -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 => 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)) { diff --git a/crates/desktop-sidecar/src/main.rs b/crates/desktop-sidecar/src/main.rs index 663f188c..f0dd7547 100644 --- a/crates/desktop-sidecar/src/main.rs +++ b/crates/desktop-sidecar/src/main.rs @@ -193,7 +193,50 @@ fn main() -> Result<(), Box> { 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 = database.prepare( + "SELECT id FROM _edgeever_sidecar_outbox ORDER BY id" + ).unwrap().query_map([], |row| row.get(0)).unwrap() + .collect::>().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() { diff --git a/crates/desktop-sidecar/src/memo.rs b/crates/desktop-sidecar/src/memo.rs index b3454391..255b4bc5 100644 --- a/crates/desktop-sidecar/src/memo.rs +++ b/crates/desktop-sidecar/src/memo.rs @@ -224,6 +224,13 @@ pub(crate) fn empty_trash(database: &Connection) -> Result { let tx = database .unchecked_transaction() .map_err(|e| e.to_string())?; + let memo_ids: Vec = 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::>() + .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 { .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 })) } diff --git a/crates/desktop-sidecar/src/sync.rs b/crates/desktop-sidecar/src/sync.rs index d65d9699..345a6fd1 100644 --- a/crates/desktop-sidecar/src/sync.rs +++ b/crates/desktop-sidecar/src/sync.rs @@ -160,11 +160,11 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result Result = 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 }), )