fix: prevent mobile self-conflicts

Fixes #25
This commit is contained in:
yoma committed 2026-07-15 10:55:13 +08:00
1 parent c32d995112
commit e3db66d901
7 files changed
+326 -89

No files matched your search

+21 -3
View File
@@ -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));
@@ -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<MobileEditorMemoResponse>(`/api/v1/memos/${encodeURIComponent(currentMemo.id)}`, {
method: "PATCH",
keepalive,
body: JSON.stringify(buildSavePayload(currentMemo)),
});
await applySavedMemo(data.memo, snapshot);
return true;
})();
let payload: ReturnType<typeof buildSavePayload> | null = null;
try {
payload = buildSavePayload(currentMemo);
await localDb.syncQueue.delete(getMemoUpdateQueueId(currentMemo.id)).catch(() => undefined);
currentSavePromiseRef.current = (async () => {
const data = await requestMobileEditorJson<MobileEditorMemoResponse>(`/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);
+1
View File
@@ -30,6 +30,7 @@ export type SyncQueueItem = {
attemptCount: number;
lastError: string | null;
nextAttemptAt: string | null;
claimId: string | null;
createdAt: string;
updatedAt: string;
};
+100 -24
View File
@@ -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<SyncRunResult> | null = null;
export const syncQueuedChanges = (options: {
onSynced?: (memo: MemoDetail) => void | Promise<void>;
} = {}): Promise<SyncRunResult> => {
if (activeSyncPromise) {
return activeSyncPromise;
}
activeSyncPromise = runQueuedChanges(options).finally(() => {
activeSyncPromise = null;
});
return activeSyncPromise;
};
const runQueuedChanges = async (options: {
onSynced?: (memo: MemoDetail) => void | Promise<void>;
}): Promise<SyncRunResult> => {
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<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.update(item.id, patch);
return true;
});
export const shouldQueueMemoSaveError = (error: unknown) => {
if (typeof navigator !== "undefined" && !navigator.onLine) {
return true;
+3
View File
@@ -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=="],
+1
View File
@@ -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",
+145
View File
@@ -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<void>((resolve) => {
updateStarted = resolve;
});
const releaseUpdatePromise = new Promise<void>((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");
});
});