diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index eae551d8..70e76c9f 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -143,7 +143,7 @@ import { isAttachmentLinkHref, } from "@/lib/editor-external-link"; import { processFilesSequentially } from "@/lib/file-batch"; -import { MEMO_ID_REMAPPED_EVENT } from "@/lib/sync-events"; +import { MEMO_ID_REMAPPED_EVENT, MEMO_SYNC_ACKNOWLEDGED_EVENT } from "@/lib/sync-events"; import { useStandaloneMobileEditor } from "@/hooks/useStandaloneMobileEditor"; import { statusSettleMotion } from "@/lib/motion"; import { getAttachmentFilenameFromLabel, getAttachmentResourceId } from "@/lib/attachment-links"; @@ -2117,6 +2117,33 @@ const RichEditorPane = ({ }, [editor, markDirty, memo, persistCurrentDraft]); useEffect(() => { + const advanceMemoSyncBase = (syncedMemo: MemoDetail | null | undefined) => { + const currentMemo = memoRef.current; + if (!syncedMemo || currentMemo?.id !== syncedMemo.id || syncedMemo.revision < currentMemo.revision) { + return; + } + + // Keep the live document, title, and tags intact while moving its + // concurrency base to the revision acknowledged for this device. + memoRef.current = { + ...currentMemo, + revision: syncedMemo.revision, + contentHash: syncedMemo.contentHash, + updatedAt: syncedMemo.updatedAt, + }; + if (editSessionRef.current?.memoId === syncedMemo.id) { + editSessionRef.current = { + ...editSessionRef.current, + baseRevision: syncedMemo.revision, + baseContentHash: syncedMemo.contentHash, + }; + } + }; + + const handleMemoSyncAcknowledged = (event: Event) => { + advanceMemoSyncBase((event as CustomEvent).detail); + }; + const handleSyncCompleted = (event: Event) => { const result = (event as CustomEvent<{ failed?: number; @@ -2126,24 +2153,7 @@ const RichEditorPane = ({ const memoId = memoRef.current?.id; const syncedMemo = memoId ? result?.syncedMemos?.get(memoId) : null; - if (syncedMemo && memoRef.current?.id === syncedMemo.id) { - // Keep the live editor document, title, and tags intact while moving - // its concurrency base to the revision just acknowledged by this - // device. This closes the query-refresh window after an autosave. - memoRef.current = { - ...memoRef.current, - revision: syncedMemo.revision, - contentHash: syncedMemo.contentHash, - updatedAt: syncedMemo.updatedAt, - }; - if (editSessionRef.current?.memoId === syncedMemo.id) { - editSessionRef.current = { - ...editSessionRef.current, - baseRevision: syncedMemo.revision, - baseContentHash: syncedMemo.contentHash, - }; - } - } + advanceMemoSyncBase(syncedMemo); if (memoId && (result?.conflicted ?? 0) > 0) { void localDb.syncQueue.get(getMemoUpdateQueueId(memoId)).then((item) => { @@ -2169,8 +2179,12 @@ const RichEditorPane = ({ }); }; + window.addEventListener(MEMO_SYNC_ACKNOWLEDGED_EVENT, handleMemoSyncAcknowledged); window.addEventListener("edgeever:sync-completed", handleSyncCompleted); - return () => window.removeEventListener("edgeever:sync-completed", handleSyncCompleted); + return () => { + window.removeEventListener(MEMO_SYNC_ACKNOWLEDGED_EVENT, handleMemoSyncAcknowledged); + window.removeEventListener("edgeever:sync-completed", handleSyncCompleted); + }; }, []); const handleMarkdownModeChange = useCallback(() => { diff --git a/apps/web/src/hooks/useWorkspaceQueuedSync.ts b/apps/web/src/hooks/useWorkspaceQueuedSync.ts index fc3dfcdf..cb81075e 100644 --- a/apps/web/src/hooks/useWorkspaceQueuedSync.ts +++ b/apps/web/src/hooks/useWorkspaceQueuedSync.ts @@ -3,7 +3,7 @@ import type { QueryClient } from "@tanstack/react-query"; import type { MemoDetail, MemoTemplate, Notebook, Resource } from "@edgeever/shared"; import { isBrowserOffline } from "@/lib/network-status"; import { emptySyncQueueSummary, type SyncQueueSummary } from "@/lib/sync-queue"; -import { notifyMemoIdRemapped } from "@/lib/sync-events"; +import { notifyMemoIdRemapped, notifyMemoSyncAcknowledged } from "@/lib/sync-events"; import { putLocalMemo, replaceLocalMemoId } from "@/lib/local-mirror"; import { resolveCreatedMemoSelection, resolveSyncedMemoId } from "@/lib/workspace-refresh"; @@ -64,6 +64,12 @@ export const useWorkspaceQueuedSync = ({ const { syncQueuedChanges } = await import("@/lib/sync-queue"); const result = await syncQueuedChanges({ scope: localDataScope, + onMemoAcknowledged: async (memo) => { + // Advance the live editor's concurrency base before the acknowledged + // server snapshot reaches React Query. This keeps a self-sync from + // being mistaken for new document content and moving the caret. + notifyMemoSyncAcknowledged(memo); + }, onSynced: async (memo, item) => { if (item.kind === "memo.create") { const remappedMemo = await replaceLocalMemoId(localDataScope, item.memoId, memo); diff --git a/apps/web/src/lib/sync-events.ts b/apps/web/src/lib/sync-events.ts index 08d27310..77bf143f 100644 --- a/apps/web/src/lib/sync-events.ts +++ b/apps/web/src/lib/sync-events.ts @@ -1,6 +1,9 @@ +import type { MemoDetail } from "@edgeever/shared"; + export const SYNC_QUEUE_CHANGED_EVENT = "edgeever:sync-queue-changed"; export const SYNC_QUEUE_DEFERRED_EVENT = "edgeever:sync-queue-deferred"; export const MEMO_ID_REMAPPED_EVENT = "edgeever:memo-id-remapped"; +export const MEMO_SYNC_ACKNOWLEDGED_EVENT = "edgeever:memo-sync-acknowledged"; export const notifySyncQueueChanged = () => { window.dispatchEvent(new CustomEvent(SYNC_QUEUE_CHANGED_EVENT)); @@ -14,3 +17,7 @@ export const notifyMemoIdRemapped = (memoIdMappings: ReadonlyMap if (memoIdMappings.size === 0) return; window.dispatchEvent(new CustomEvent(MEMO_ID_REMAPPED_EVENT, { detail: memoIdMappings })); }; + +export const notifyMemoSyncAcknowledged = (memo: MemoDetail) => { + window.dispatchEvent(new CustomEvent(MEMO_SYNC_ACKNOWLEDGED_EVENT, { detail: memo })); +}; diff --git a/apps/web/src/lib/sync-queue.test.mjs b/apps/web/src/lib/sync-queue.test.mjs index c46b3cba..75b8f0f0 100644 --- a/apps/web/src/lib/sync-queue.test.mjs +++ b/apps/web/src/lib/sync-queue.test.mjs @@ -30,6 +30,75 @@ afterEach(async () => { }); describe("web sync conflict recovery", () => { + 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 }); + const scope = createLocalDataScope("https://demo.edgeever.org", "user-1"); + const createdMemo = await createLocalMemo(scope, { notebookId: "inbox" }); + const remoteMemo = { + ...createdMemo, + id: "memo-draft-in-flight", + revision: 1, + contentHash: "hash-1", + }; + const syncedContent = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "synced snapshot" }] }] }; + const newerContent = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "still typing" }] }] }; + await putLocalMemo(scope, remoteMemo); + await queueMemoUpdate({ + memoId: remoteMemo.id, + expectedRevision: 1, + expectedContentHash: "hash-1", + editSessionId: "edit-a", + title: "Synced snapshot", + contentJson: syncedContent, + tags: [], + }, scope); + + let updateStarted; + let releaseUpdate; + const updateStartedPromise = new Promise((resolve) => { updateStarted = resolve; }); + const releaseUpdatePromise = new Promise((resolve) => { releaseUpdate = resolve; }); + const original = { + createMemoEditSession: api.createMemoEditSession, + updateMemo: api.updateMemo, + }; + api.createMemoEditSession = async () => ({ editSession: { id: "fresh-edit", baseRevision: 1, baseContentHash: "hash-1" } }); + api.updateMemo = async (_memoId, payload) => { + updateStarted(); + await releaseUpdatePromise; + return { memo: { ...remoteMemo, ...payload, revision: 2, contentHash: "hash-2" } }; + }; + + try { + const callbackOrder = []; + const syncing = syncQueuedChanges({ + scope, + onMemoAcknowledged: () => callbackOrder.push("acknowledged"), + onSynced: () => callbackOrder.push("synced"), + }); + await updateStartedPromise; + await localDb.drafts.put({ + memoId: remoteMemo.id, + title: "Still typing", + tagsText: "", + contentJson: newerContent, + updatedAt: "2099-01-01T00:00:00.000Z", + }); + releaseUpdate(); + + expect(await syncing).toMatchObject({ synced: 1, conflicted: 0 }); + expect(callbackOrder).toEqual(["acknowledged", "synced"]); + expect(await localDb.drafts.get(remoteMemo.id)).toMatchObject({ + title: "Still typing", + contentJson: newerContent, + }); + } finally { + api.createMemoEditSession = original.createMemoEditSession; + api.updateMemo = original.updateMemo; + if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline }); + } + }); + test("preserves a draft written while a new memo create request is in flight", 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/sync-queue.ts b/apps/web/src/lib/sync-queue.ts index 2d1b42f4..7c5880e6 100644 --- a/apps/web/src/lib/sync-queue.ts +++ b/apps/web/src/lib/sync-queue.ts @@ -23,15 +23,18 @@ import { type MemoRestoreSyncPayload, type LocalActionKind, type LocalActionPayload, + type LocalDraft, type MemoUpdateSyncPayload, type SyncQueueItem, } from "@/lib/local-db"; import { getMemoSaveConflictInfo, parseMemoSaveConflictDetails } from "@/lib/memo-save-conflict"; import { getCachedLocalResourceBytes, removeCachedLocalResourceBytes } from "@/lib/local-resource-cache"; import { isBrowserOffline } from "@/lib/network-status"; +import { parseTagsText } from "@/lib/utils"; export type { SyncQueueSummary, SyncRunResult } from "@edgeever/shared"; export type SyncQueueResult = MemoDetail | Notebook | MemoTemplate | Resource | null; +export type MemoUpdateAcknowledgement = "completed" | "rebased" | "stale"; export const emptySyncQueueSummary = createEmptySyncQueueSummary; @@ -68,6 +71,19 @@ export const queueMemoUpdate = async (payload: MemoUpdateSyncPayload, scope?: st const now = new Date().toISOString(); await localDb.transaction("rw", localDb.syncQueue, async () => { const existing = await localDb.syncQueue.get(id); + const existingPayload = existing?.kind === "memo.update" + ? existing.payload as MemoUpdateSyncPayload + : null; + // A previous in-flight save may already have advanced this queue row to a + // newer acknowledged server base. A local autosave that started just + // before that acknowledgement must not move the successor back again. + const nextPayload = existingPayload && existingPayload.expectedRevision > payload.expectedRevision + ? { + ...payload, + expectedRevision: existingPayload.expectedRevision, + expectedContentHash: existingPayload.expectedContentHash, + } + : payload; await localDb.syncQueue.put({ id, @@ -75,7 +91,7 @@ export const queueMemoUpdate = async (payload: MemoUpdateSyncPayload, scope?: st scope: scope ?? existing?.scope, memoId: payload.memoId, status: "pending", - payload, + payload: nextPayload, attemptCount: existing?.attemptCount ?? 0, lastError: null, lastErrorCode: null, @@ -273,6 +289,7 @@ let activeSyncPromise: Promise | null = null; export const syncQueuedChanges = (options: { scope?: string; + onMemoAcknowledged?: (memo: MemoDetail, item: SyncQueueItem, acknowledgement: MemoUpdateAcknowledgement) => void | Promise; onSynced?: (memo: MemoDetail, item: SyncQueueItem) => void | Promise; onActionSynced?: (result: SyncQueueResult, item: SyncQueueItem) => void | Promise; } = {}): Promise => { @@ -289,6 +306,7 @@ export const syncQueuedChanges = (options: { const runQueuedChanges = async (options: { scope?: string; + onMemoAcknowledged?: (memo: MemoDetail, item: SyncQueueItem, acknowledgement: MemoUpdateAcknowledgement) => void | Promise; onSynced?: (memo: MemoDetail, item: SyncQueueItem) => void | Promise; onActionSynced?: (result: SyncQueueResult, item: SyncQueueItem) => void | Promise; }): Promise => { @@ -334,15 +352,19 @@ const runQueuedChanges = async (options: { if (item.kind === "memo.create" && memo && item.scope) { await remapQueuedMemoId(item.scope, item.memoId, memo as MemoDetail); } - const removed = await removeClaimedQueueItem(item); - if (removed) { - if (item.kind !== "memo.create") await localDb.drafts.delete(item.memoId); + const acknowledgement = await acknowledgeClaimedQueueItem(item, memo); + if (memo && item.kind === "memo.update" && acknowledgement !== "stale") { + await options.onMemoAcknowledged?.(memo as MemoDetail, item, acknowledgement); + } + if (acknowledgement === "completed") { if (memo && (item.kind === "memo.create" || item.kind === "memo.update" || item.kind === "memo.restore" || item.kind === "memo.delete")) { await options.onSynced?.(memo as MemoDetail, item); } if (item.kind !== "memo.create" && item.kind !== "memo.update" && item.kind !== "memo.restore" && item.kind !== "memo.delete") { await options.onActionSynced?.(memo, item); } + } + if (acknowledgement !== "stale") { result.synced += 1; } } catch (error) { @@ -395,15 +417,79 @@ const claimQueueItem = (id: string) => return claimedItem; }); -const removeClaimedQueueItem = (item: SyncQueueItem) => - localDb.transaction("rw", localDb.syncQueue, async () => { +const isDraftCoveredByMemoUpdate = (item: SyncQueueItem, draft: LocalDraft | undefined) => { + if (item.kind !== "memo.update" || !draft) return false; + const payload = item.payload as MemoUpdateSyncPayload; + return draft.title === payload.title && + JSON.stringify(parseTagsText(draft.tagsText)) === JSON.stringify(payload.tags) && + JSON.stringify(draft.contentJson) === JSON.stringify(payload.contentJson); +}; + +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); - if (!current || current.claimId !== item.claimId || current.status !== "syncing") { - return false; + const memo = result && item.kind === "memo.update" && "contentHash" in result + ? result as MemoDetail + : null; + + if (current?.claimId === item.claimId && current.status === "syncing") { + await localDb.syncQueue.delete(item.id); + if (item.kind !== "memo.create") { + const draft = await localDb.drafts.get(item.memoId); + if (!draft || item.kind !== "memo.update" || isDraftCoveredByMemoUpdate(item, draft)) { + await localDb.drafts.delete(item.memoId); + } + } + if (memo && item.scope) { + const stored = await localDb.memos.get([item.scope, item.memoId]); + if (stored && memo.revision > stored.revision) { + await localDb.memos.put({ ...stored, revision: memo.revision, contentHash: memo.contentHash }); + } + } + return "completed"; } - await localDb.syncQueue.delete(item.id); - return true; + if ( + !memo || + !current || + current.kind !== "memo.update" || + current.memoId !== item.memoId || + current.claimId || + (current.status !== "pending" && current.status !== "error") + ) { + return "stale"; + } + + const claimedPayload = item.payload as MemoUpdateSyncPayload; + const successorPayload = current.payload as MemoUpdateSyncPayload; + const successorUsesClaimedBase = successorPayload.expectedRevision === claimedPayload.expectedRevision && + successorPayload.expectedContentHash === claimedPayload.expectedContentHash; + const successorAlreadyRebased = successorPayload.expectedRevision === memo.revision && + successorPayload.expectedContentHash === memo.contentHash; + + if (!successorUsesClaimedBase && !successorAlreadyRebased) { + return "stale"; + } + + if (successorUsesClaimedBase) { + await localDb.syncQueue.put({ + ...current, + payload: { + ...successorPayload, + expectedRevision: memo.revision, + expectedContentHash: memo.contentHash, + }, + updatedAt: new Date().toISOString(), + }); + } + + if (item.scope) { + const stored = await localDb.memos.get([item.scope, item.memoId]); + if (stored && memo.revision > stored.revision) { + await localDb.memos.put({ ...stored, revision: memo.revision, contentHash: memo.contentHash }); + } + } + return "rebased"; }); const updateClaimedQueueItem = (item: SyncQueueItem, patch: Partial) => diff --git a/tests/e2e/memo-sync-race.spec.ts b/tests/e2e/memo-sync-race.spec.ts new file mode 100644 index 00000000..364b7c98 --- /dev/null +++ b/tests/e2e/memo-sync-race.spec.ts @@ -0,0 +1,165 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +type StoredQueueItem = { + kind?: string; + memoId?: string; + status?: string; + payload?: unknown; +}; + +const readSyncQueue = (page: Page) => page.evaluate( + () => new Promise((resolve, reject) => { + const openRequest = indexedDB.open("edgeever-local"); + openRequest.onerror = () => reject(openRequest.error); + openRequest.onsuccess = () => { + const database = openRequest.result; + if (!database.objectStoreNames.contains("syncQueue")) { + database.close(); + resolve([]); + return; + } + const transaction = database.transaction("syncQueue", "readonly"); + const request = transaction.objectStore("syncQueue").getAll(); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + database.close(); + resolve(request.result as StoredQueueItem[]); + }; + }; + }), +); + +const queueItemWithContent = async (page: Page, memoId: string, marker: string) => { + const item = (await readSyncQueue(page)).find((candidate) => candidate.kind === "memo.update" && candidate.memoId === memoId); + return item && JSON.stringify(item.payload).includes(marker) ? item : null; +}; + +const waitForQueuedContent = (page: Page, memoId: string, marker: string) => expect.poll( + async () => Boolean(await queueItemWithContent(page, memoId, marker)), +).toBe(true); + +const waitForPendingQueuedContent = (page: Page, memoId: string, marker: string) => expect.poll( + async () => (await queueItemWithContent(page, memoId, marker))?.status ?? null, +).toBe("pending"); + +const waitForSyncCompleted = (page: Page) => page.evaluate(() => new Promise((resolve) => { + window.addEventListener("edgeever:sync-completed", () => resolve(), { once: true }); +})); + +const readEditorSelection = (page: Page) => page.evaluate(() => { + const selection = window.getSelection(); + const paragraph = selection?.anchorNode?.parentElement?.closest("p"); + const range = document.createRange(); + if (paragraph && selection?.anchorNode) { + range.selectNodeContents(paragraph); + range.setEnd(selection.anchorNode, selection.anchorOffset); + } + return { + offset: paragraph && selection?.anchorNode ? range.toString().length : -1, + paragraphText: paragraph?.textContent ?? "", + }; +}); + +test("keeps the caret and rebases edits made while autosave sync is in flight", async ({ page }) => { + const marker = `sync-race-${Date.now()}`; + const notebooksResponse = await page.request.get("/api/v1/notebooks"); + expect(notebooksResponse.ok()).toBe(true); + const notebooks = await notebooksResponse.json() as { notebooks: Array<{ id: string }> }; + const notebookId = notebooks.notebooks[0]?.id; + expect(notebookId).toBeTruthy(); + + const createResponse = await page.request.post("/api/v1/memos", { + data: { + notebookId, + title: marker, + contentMarkdown: `First paragraph ${marker}\n\nSecond paragraph\n\nLast paragraph`, + }, + }); + expect(createResponse.status()).toBe(201); + const memoId = (await createResponse.json() as { memo: { id: string } }).memo.id; + + let releaseFirstUpdate!: () => void; + let markFirstUpdateStarted!: () => void; + const firstUpdateGate = new Promise((resolve) => { releaseFirstUpdate = resolve; }); + const firstUpdateStarted = new Promise((resolve) => { markFirstUpdateStarted = resolve; }); + let updateCount = 0; + + await page.route(`**/api/v1/memos/${memoId}`, async (route: Route) => { + if (route.request().method() !== "PATCH") { + await route.continue(); + return; + } + updateCount += 1; + if (updateCount === 1) { + markFirstUpdateStarted(); + await firstUpdateGate; + } + await route.continue(); + }); + + try { + await page.goto("/"); + await page.getByRole("button", { name: "全部笔记", exact: true }).click(); + await page.getByPlaceholder("搜索笔记").fill(marker); + await page.locator(`[data-memo-id="${memoId}"]`).locator("button").first().click(); + await page.getByRole("button", { name: "清空搜索", exact: true }).click(); + + const editor = page.locator(".ProseMirror[contenteditable='true']"); + const firstParagraph = editor.locator("p").first(); + await expect(firstParagraph).toContainText(`First paragraph ${marker}`); + await firstParagraph.click(); + await page.keyboard.press("End"); + await page.keyboard.insertText(" first-save"); + await waitForQueuedContent(page, memoId, "first-save"); + + await page.evaluate(() => window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed"))); + await firstUpdateStarted; + + await firstParagraph.click(); + await page.keyboard.press("End"); + await page.keyboard.insertText(" typed-during-sync"); + await waitForPendingQueuedContent(page, memoId, "typed-during-sync"); + + const selectionBeforeAcknowledgement = await readEditorSelection(page); + expect(selectionBeforeAcknowledgement.offset).toBe(selectionBeforeAcknowledgement.paragraphText.length); + + const firstSyncCompleted = waitForSyncCompleted(page); + releaseFirstUpdate(); + await firstSyncCompleted; + + await expect.poll(async () => { + const item = (await readSyncQueue(page)).find((candidate) => candidate.kind === "memo.update" && candidate.memoId === memoId); + return item ? JSON.stringify(item.payload) : ""; + }).toContain("typed-during-sync"); + + expect(await readEditorSelection(page)).toEqual(selectionBeforeAcknowledgement); + + await page.keyboard.insertText(" after-ack"); + await expect(firstParagraph).toContainText("typed-during-sync after-ack"); + await expect(editor.locator("p").last()).not.toContainText("after-ack"); + await waitForPendingQueuedContent(page, memoId, "after-ack"); + const selectionBeforeCompletedSync = await readEditorSelection(page); + + const secondUpdateResponse = page.waitForResponse( + (response) => response.request().method() === "PATCH" && new URL(response.url()).pathname === `/api/v1/memos/${memoId}`, + ); + const secondSyncCompleted = waitForSyncCompleted(page); + await page.evaluate(() => window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed"))); + expect((await secondUpdateResponse).ok()).toBe(true); + await secondSyncCompleted; + expect(await readEditorSelection(page)).toEqual(selectionBeforeCompletedSync); + + await expect.poll(async () => (await readSyncQueue(page)).some( + (item) => item.memoId === memoId && item.status === "conflict", + )).toBe(false); + await expect.poll(async () => { + const response = await page.request.get(`/api/v1/memos/${memoId}`); + const body = await response.json() as { memo: { contentJson: unknown } }; + return JSON.stringify(body.memo.contentJson); + }).toContain("typed-during-sync after-ack"); + } finally { + await page.unroute(`**/api/v1/memos/${memoId}`); + await page.request.delete(`/api/v1/memos/${memoId}`); + await page.request.delete(`/api/v1/memos/${memoId}?permanent=1`); + } +}); diff --git a/tests/web-sync-queue.test.ts b/tests/web-sync-queue.test.ts index fb97beae..f22ef8a7 100644 --- a/tests/web-sync-queue.test.ts +++ b/tests/web-sync-queue.test.ts @@ -134,9 +134,11 @@ describe("web sync queue concurrency", () => { 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(result).toEqual({ attempted: 1, synced: 1, failed: 0, conflicted: 0 }); expect(queued?.status).toBe("pending"); expect(queued?.payload.title).toBe("newer text"); + expect(queued?.payload.expectedRevision).toBe(1); + expect(queued?.payload.expectedContentHash).toBe("hash-1"); }); test("keeps a genuine server revision mismatch as a conflict", async () => {