diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 43834698..c6f1a52d 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -59,7 +59,7 @@ import { docToMarkdown, markdownToDoc, type Notebook, type MemoDetail, type Memo import { codeBlockLowlight } from "@/lib/code-block"; import { compressImageForUpload } from "@/lib/image-compression"; import { localDb, type MemoUpdateSyncPayload } from "@/lib/local-db"; -import { getMemoUpdateQueueId, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue"; +import { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue"; import { getNotebookMoveOptions, DEFAULT_MEMO_TITLE, @@ -717,7 +717,7 @@ const MobileNativeEditorPane = ({ } void (async () => { - const [draft, queuedUpdate, editSessionResponse] = memo.isDeleted + let [draft, queuedUpdate, editSessionResponse] = memo.isDeleted ? [null, null, null] : await Promise.all([ localDb.drafts.get(memo.id), @@ -729,6 +729,15 @@ const MobileNativeEditorPane = ({ return; } + if (queuedUpdate && isMemoUpdateAlreadyApplied(memo, queuedUpdate)) { + await Promise.all([ + localDb.syncQueue.delete(queuedUpdate.id), + localDb.drafts.delete(memo.id), + ]); + draft = null; + queuedUpdate = undefined; + } + const draftUpdatedAt = draft ? Date.parse(draft.updatedAt) : 0; const remoteUpdatedAt = Date.parse(memo.updatedAt); const useDraft = Boolean(draft && (queuedUpdate || draftUpdatedAt >= remoteUpdatedAt)); @@ -1640,7 +1649,7 @@ const RichEditorPane = ({ } void (async () => { - const [draft, queuedUpdate, editSessionResponse] = memo.isDeleted + let [draft, queuedUpdate, editSessionResponse] = memo.isDeleted ? [null, null, null] : await Promise.all([ localDb.drafts.get(memo.id), @@ -1652,6 +1661,15 @@ const RichEditorPane = ({ return; } + if (queuedUpdate && isMemoUpdateAlreadyApplied(memo, queuedUpdate)) { + await Promise.all([ + localDb.syncQueue.delete(queuedUpdate.id), + localDb.drafts.delete(memo.id), + ]); + draft = null; + queuedUpdate = undefined; + } + const draftUpdatedAt = draft ? Date.parse(draft.updatedAt) : 0; const remoteUpdatedAt = Date.parse(memo.updatedAt); const useDraft = Boolean(draft && (queuedUpdate || draftUpdatedAt >= remoteUpdatedAt)); diff --git a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx index a1d9a9c7..b325452a 100644 --- a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx +++ b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx @@ -32,7 +32,7 @@ import { type MobileEditorMemoResponse, type MobileEditorSaveState, } from "@/lib/mobile-editor-standalone"; -import { getMemoUpdateQueueId, queueMemoUpdate, shouldQueueMemoSaveError, syncQueuedChanges } from "@/lib/sync-queue"; +import { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue"; type ListNotebooksResponse = { notebooks: Notebook[]; @@ -122,15 +122,6 @@ export const MobileStandaloneTiptapEditor = ({ contentJson: draft.contentJson, updatedAt: draft.updatedAt, }); - void queueMemoUpdate({ - memoId: currentMemo.id, - expectedRevision: currentMemo.revision, - expectedContentHash: currentMemo.contentHash, - editSessionId: editSessionRef.current?.id ?? "", - title: draft.title, - contentJson: draft.contentJson, - tags: parseMobileEditorTags(draft.tagsText), - }); }, [draftKey]); const readLocalDraft = useCallback((): MobileEditorDraft | null => { @@ -307,18 +298,20 @@ export const MobileStandaloneTiptapEditor = ({ } const path = `/api/v1/memos/${encodeURIComponent(currentMemo.id)}/save`; - const body = JSON.stringify(buildSavePayload(currentMemo)); + const payload = buildSavePayload(currentMemo); + const body = JSON.stringify(payload); const snapshot = currentSnapshot(); + // A direct save supersedes an older retry for this memo. The local draft remains + // durable until the server response is confirmed, so removing the retry cannot + // lose the user's latest text even if the page is suspended immediately after. + void localDb.syncQueue.delete(getMemoUpdateQueueId(currentMemo.id)); + if (typeof navigator.sendBeacon === "function") { const accepted = navigator.sendBeacon(path, new Blob([body], { type: "application/json" })); if (accepted) { backgroundSavePendingRef.current = true; - void Promise.all([ - localDb.drafts.delete(currentMemo.id), - localDb.syncQueue.delete(getMemoUpdateQueueId(currentMemo.id)), - ]); return true; } } @@ -329,8 +322,11 @@ export const MobileStandaloneTiptapEditor = ({ body, }) .then((data) => applySavedMemo(data.memo, snapshot)) - .catch(() => { + .catch((saveError) => { persistLocalDraft(); + if (shouldQueueMemoSaveError(saveError)) { + void queueMemoUpdate({ memoId: currentMemo.id, ...payload }); + } setSaveStateStable("local-draft"); }); @@ -397,22 +393,27 @@ export const MobileStandaloneTiptapEditor = ({ setSaveStateStable("saving"); setError(null); - currentSavePromiseRef.current = (async () => { - const data = await requestMobileEditorJson(`/api/v1/memos/${encodeURIComponent(currentMemo.id)}`, { - method: "PATCH", - keepalive, - body: JSON.stringify(buildSavePayload(currentMemo)), - }); - - await applySavedMemo(data.memo, snapshot); - return true; - })(); + let payload: ReturnType | null = null; try { + payload = buildSavePayload(currentMemo); + await localDb.syncQueue.delete(getMemoUpdateQueueId(currentMemo.id)).catch(() => undefined); + currentSavePromiseRef.current = (async () => { + const data = await requestMobileEditorJson(`/api/v1/memos/${encodeURIComponent(currentMemo.id)}`, { + method: "PATCH", + keepalive, + body: JSON.stringify(payload), + }); + + await applySavedMemo(data.memo, snapshot); + return true; + })(); + return await currentSavePromiseRef.current; } catch (saveError) { persistLocalDraft(); - if (shouldQueueMemoSaveError(saveError)) { + if (payload && shouldQueueMemoSaveError(saveError)) { + await queueMemoUpdate({ memoId: currentMemo.id, ...payload }); setError(null); setSaveStateStable("local-draft"); return true; @@ -643,8 +644,19 @@ export const MobileStandaloneTiptapEditor = ({ const nextTitle = data.memo.title || ""; const nextTagsText = Array.isArray(data.memo.tags) ? data.memo.tags.join(", ") : ""; const nextContentJson = normalizeMobileEditorDoc(data.memo); - const draft = await readBestLocalDraft(); - const queuedUpdate = await localDb.syncQueue.get(getMemoUpdateQueueId(data.memo.id)); + let draft = await readBestLocalDraft(); + let queuedUpdate = await localDb.syncQueue.get(getMemoUpdateQueueId(data.memo.id)); + if (queuedUpdate && isMemoUpdateAlreadyApplied(data.memo, queuedUpdate)) { + await Promise.all([ + localDb.syncQueue.delete(queuedUpdate.id), + localDb.drafts.delete(data.memo.id), + ]); + if (draftKey) { + localStorage.removeItem(draftKey); + } + draft = null; + queuedUpdate = undefined; + } const useDraft = Boolean(draft && (queuedUpdate || Date.parse(draft.updatedAt || "") >= Date.parse(data.memo.updatedAt || ""))); setMemo(data.memo); @@ -720,42 +732,23 @@ export const MobileStandaloneTiptapEditor = ({ } if (document.visibilityState === "visible") { - void reconcileBackgroundSave(); - void syncQueuedChanges({ - onSynced: (syncedMemo) => { - if (syncedMemo.id !== memoRef.current?.id) { - return; - } - - memoRef.current = syncedMemo; - setMemo(syncedMemo); - lastSavedSnapshotRef.current = currentSnapshot(); - dirtyRef.current = false; - if (draftKey) { - localStorage.removeItem(draftKey); - } - setSaveStateStable("saved"); - }, - }); + void (async () => { + await reconcileBackgroundSave(); + if (dirtyRef.current && !backgroundSavePendingRef.current) { + await saveNowRef.current(); + } + })(); } }; const handleOnline = () => { - void syncQueuedChanges({ - onSynced: (syncedMemo) => { - if (syncedMemo.id !== memoRef.current?.id) { - return; - } - - memoRef.current = syncedMemo; - setMemo(syncedMemo); - lastSavedSnapshotRef.current = currentSnapshot(); - dirtyRef.current = false; - if (draftKey) { - localStorage.removeItem(draftKey); - } - setSaveStateStable("saved"); - }, - }); + void (async () => { + if (backgroundSavePendingRef.current) { + await reconcileBackgroundSave(); + } + if (dirtyRef.current && !backgroundSavePendingRef.current) { + await saveNowRef.current(); + } + })(); }; window.addEventListener("pagehide", handlePageHide); diff --git a/apps/web/src/lib/local-db.ts b/apps/web/src/lib/local-db.ts index e059d32f..e02c1c62 100644 --- a/apps/web/src/lib/local-db.ts +++ b/apps/web/src/lib/local-db.ts @@ -30,6 +30,7 @@ export type SyncQueueItem = { attemptCount: number; lastError: string | null; nextAttemptAt: string | null; + claimId: string | null; createdAt: string; updatedAt: string; }; diff --git a/apps/web/src/lib/sync-queue.ts b/apps/web/src/lib/sync-queue.ts index d83b2f95..b9c7cf8d 100644 --- a/apps/web/src/lib/sync-queue.ts +++ b/apps/web/src/lib/sync-queue.ts @@ -31,19 +31,22 @@ export const getMemoUpdateQueueId = (memoId: string) => `memo.update:${memoId}`; export const queueMemoUpdate = async (payload: MemoUpdateSyncPayload) => { const id = getMemoUpdateQueueId(payload.memoId); const now = new Date().toISOString(); - const existing = await localDb.syncQueue.get(id); + await localDb.transaction("rw", localDb.syncQueue, async () => { + const existing = await localDb.syncQueue.get(id); - await localDb.syncQueue.put({ - id, - kind: "memo.update", - memoId: payload.memoId, - status: "pending", - payload, - attemptCount: existing?.attemptCount ?? 0, - lastError: null, - nextAttemptAt: null, - createdAt: existing?.createdAt ?? now, - updatedAt: now, + await localDb.syncQueue.put({ + id, + kind: "memo.update", + memoId: payload.memoId, + status: "pending", + payload, + attemptCount: existing?.attemptCount ?? 0, + lastError: null, + nextAttemptAt: null, + claimId: null, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }); }); }; @@ -56,9 +59,36 @@ export const observeSyncQueue = (onChange: (summary: SyncQueueSummary) => void) return () => subscription.unsubscribe(); }; -export const syncQueuedChanges = async (options: { +export const isMemoUpdateAlreadyApplied = (memo: MemoDetail, item: SyncQueueItem) => { + if (memo.id !== item.memoId || memo.title !== item.payload.title) { + return false; + } + + const remoteTags = [...memo.tags].sort((left, right) => left.localeCompare(right)); + const queuedTags = [...item.payload.tags].sort((left, right) => left.localeCompare(right)); + return JSON.stringify(remoteTags) === JSON.stringify(queuedTags) && + JSON.stringify(memo.contentJson) === JSON.stringify(item.payload.contentJson); +}; + +let activeSyncPromise: Promise | null = null; + +export const syncQueuedChanges = (options: { onSynced?: (memo: MemoDetail) => void | Promise; } = {}): Promise => { + if (activeSyncPromise) { + return activeSyncPromise; + } + + activeSyncPromise = runQueuedChanges(options).finally(() => { + activeSyncPromise = null; + }); + + return activeSyncPromise; +}; + +const runQueuedChanges = async (options: { + onSynced?: (memo: MemoDetail) => void | Promise; +}): Promise => { const result: SyncRunResult = { attempted: 0, synced: 0, @@ -75,32 +105,38 @@ export const syncQueuedChanges = async (options: { .filter((item) => !item.nextAttemptAt || new Date(item.nextAttemptAt) <= now) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); - for (const item of items) { + for (const candidate of items) { + const item = await claimQueueItem(candidate.id); + if (!item) { + continue; + } + result.attempted += 1; - await localDb.syncQueue.update(item.id, { - status: "syncing", - updatedAt: new Date().toISOString(), - }); try { const memo = await syncQueueItem(item); - await localDb.syncQueue.delete(item.id); - await localDb.drafts.delete(item.memoId); - await options.onSynced?.(memo); - result.synced += 1; + const removed = await removeClaimedQueueItem(item); + if (removed) { + await localDb.drafts.delete(item.memoId); + await options.onSynced?.(memo); + result.synced += 1; + } } catch (error) { const status = isRevisionConflict(error) ? "conflict" : "error"; const attemptCount = item.attemptCount + 1; - await localDb.syncQueue.update(item.id, { + const updated = await updateClaimedQueueItem(item, { status, attemptCount, lastError: getErrorMessage(error), nextAttemptAt: status === "error" ? nextRetryAt(attemptCount) : null, + claimId: null, updatedAt: new Date().toISOString(), }); - if (status === "conflict") { + if (!updated) { + continue; + } else if (status === "conflict") { result.conflicted += 1; } else { result.failed += 1; @@ -111,6 +147,46 @@ export const syncQueuedChanges = async (options: { return result; }; +const claimQueueItem = (id: string) => + localDb.transaction("rw", localDb.syncQueue, async () => { + const item = await localDb.syncQueue.get(id); + if (!item || (item.status !== "pending" && item.status !== "error")) { + return null; + } + + const claimId = crypto.randomUUID(); + const claimedItem: SyncQueueItem = { + ...item, + status: "syncing", + claimId, + updatedAt: new Date().toISOString(), + }; + await localDb.syncQueue.put(claimedItem); + return claimedItem; + }); + +const removeClaimedQueueItem = (item: SyncQueueItem) => + localDb.transaction("rw", localDb.syncQueue, async () => { + const current = await localDb.syncQueue.get(item.id); + if (!current || current.claimId !== item.claimId || current.status !== "syncing") { + return false; + } + + await localDb.syncQueue.delete(item.id); + return true; + }); + +const updateClaimedQueueItem = (item: SyncQueueItem, patch: Partial) => + localDb.transaction("rw", localDb.syncQueue, async () => { + const current = await localDb.syncQueue.get(item.id); + if (!current || current.claimId !== item.claimId || current.status !== "syncing") { + return false; + } + + await localDb.syncQueue.update(item.id, patch); + return true; + }); + export const shouldQueueMemoSaveError = (error: unknown) => { if (typeof navigator !== "undefined" && !navigator.onLine) { return true; diff --git a/bun.lock b/bun.lock index 96559a9c..f22a954c 100644 --- a/bun.lock +++ b/bun.lock @@ -56,6 +56,7 @@ "@vitejs/plugin-react": "latest", "autoprefixer": "latest", "concurrently": "latest", + "fake-indexeddb": "^6.2.5", "postcss": "latest", "tailwindcss": "^3.4.17", "typescript": "latest", @@ -1606,6 +1607,8 @@ "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], diff --git a/package.json b/package.json index 279dd264..8c1b1355 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "@vitejs/plugin-react": "latest", "autoprefixer": "latest", "concurrently": "latest", + "fake-indexeddb": "^6.2.5", "postcss": "latest", "tailwindcss": "^3.4.17", "typescript": "latest", diff --git a/tests/web-sync-queue.test.ts b/tests/web-sync-queue.test.ts new file mode 100644 index 00000000..145e434d --- /dev/null +++ b/tests/web-sync-queue.test.ts @@ -0,0 +1,145 @@ +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { MemoDetail, MemoEditSession } from "@edgeever/shared"; +import type { MemoUpdateSyncPayload } from "../apps/web/src/lib/local-db"; + +const { localDb } = await import("../apps/web/src/lib/local-db"); +const { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate, syncQueuedChanges } = await import("../apps/web/src/lib/sync-queue"); +const originalFetch = globalThis.fetch; + +const payload = (title: string, revision = 0): MemoUpdateSyncPayload => ({ + memoId: "memo_sync_test", + expectedRevision: revision, + expectedContentHash: `hash-${revision}`, + editSessionId: "original-session", + title, + contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: title }] }] }, + tags: [], +}); + +const memo = (title: string, revision = 1): MemoDetail => ({ + id: "memo_sync_test", + notebookId: "nb_inbox", + title, + excerpt: title, + tags: [], + isPinned: false, + isArchived: false, + isDeleted: false, + revision, + createdAt: "2026-07-15T00:00:00.000Z", + updatedAt: "2026-07-15T00:00:01.000Z", + deletedAt: null, + contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: title }] }] }, + contentMarkdown: title, + contentText: title, + contentHash: `hash-${revision}`, + sourceMemoIds: [], + mergeSourceCount: 0, + mergedIntoMemoId: null, +}); + +const session = (revision = 0): MemoEditSession => ({ + id: `edit-${revision}`, + memoId: "memo_sync_test", + baseRevision: revision, + baseContentHash: `hash-${revision}`, + expiresAt: "2026-08-15T00:00:00.000Z", +}); + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + +beforeEach(async () => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { onLine: true }, + }); + await localDb.transaction("rw", localDb.drafts, localDb.syncQueue, async () => { + await localDb.drafts.clear(); + await localDb.syncQueue.clear(); + }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("web sync queue concurrency", () => { + test("recognizes a legacy queued snapshot that is already fully stored on the server", async () => { + await queueMemoUpdate(payload("already saved")); + const queued = await localDb.syncQueue.get(getMemoUpdateQueueId("memo_sync_test")); + + expect(queued).toBeDefined(); + expect(isMemoUpdateAlreadyApplied(memo("already saved"), queued!)).toBe(true); + expect(isMemoUpdateAlreadyApplied(memo("different remote text"), queued!)).toBe(false); + }); + + test("coalesces simultaneous sync runs so one queued save is sent only once", async () => { + await queueMemoUpdate(payload("latest text")); + let requestCount = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + requestCount += 1; + const url = String(input); + if (url.endsWith("/edit-sessions")) { + return jsonResponse({ editSession: session() }); + } + return jsonResponse({ memo: memo("latest text") }); + }) as typeof fetch; + + const [first, second] = await Promise.all([syncQueuedChanges(), syncQueuedChanges()]); + + expect(requestCount).toBe(2); + expect(first).toEqual({ attempted: 1, synced: 1, failed: 0, conflicted: 0 }); + expect(second).toEqual(first); + expect(await localDb.syncQueue.count()).toBe(0); + }); + + test("does not delete a newer draft queued while an older request is in flight", async () => { + await queueMemoUpdate(payload("older text")); + let releaseUpdate!: () => void; + let updateStarted!: () => void; + const updateStartedPromise = new Promise((resolve) => { + updateStarted = resolve; + }); + const releaseUpdatePromise = new Promise((resolve) => { + releaseUpdate = resolve; + }); + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/edit-sessions")) { + return jsonResponse({ editSession: session() }); + } + updateStarted(); + await releaseUpdatePromise; + return jsonResponse({ memo: memo("older text") }); + }) as typeof fetch; + + const runningSync = syncQueuedChanges(); + await updateStartedPromise; + await queueMemoUpdate(payload("newer text")); + releaseUpdate(); + const result = await runningSync; + + const queued = await localDb.syncQueue.get(getMemoUpdateQueueId("memo_sync_test")); + expect(result).toEqual({ attempted: 1, synced: 0, failed: 0, conflicted: 0 }); + expect(queued?.status).toBe("pending"); + expect(queued?.payload.title).toBe("newer text"); + }); + + test("keeps a genuine server revision mismatch as a conflict", async () => { + await queueMemoUpdate(payload("offline edit")); + globalThis.fetch = (async () => jsonResponse({ editSession: session(1) })) as typeof fetch; + + const result = await syncQueuedChanges(); + const queued = await localDb.syncQueue.get(getMemoUpdateQueueId("memo_sync_test")); + + expect(result).toEqual({ attempted: 1, synced: 0, failed: 0, conflicted: 1 }); + expect(queued?.status).toBe("conflict"); + expect(queued?.payload.title).toBe("offline edit"); + }); +});