fix(web): prevent autosave sync races (#210)
This commit is contained in:
1 parent
b7043e7aaf
commit
3c8a51f6a3
7 files changed
+381
-32
No files matched your search
@@ -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<MemoDetail>).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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string>
|
||||
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 }));
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<SyncRunResult> | null = null;
|
||||
|
||||
export const syncQueuedChanges = (options: {
|
||||
scope?: string;
|
||||
onMemoAcknowledged?: (memo: MemoDetail, item: SyncQueueItem, acknowledgement: MemoUpdateAcknowledgement) => void | Promise<void>;
|
||||
onSynced?: (memo: MemoDetail, item: SyncQueueItem) => void | Promise<void>;
|
||||
onActionSynced?: (result: SyncQueueResult, item: SyncQueueItem) => void | Promise<void>;
|
||||
} = {}): Promise<SyncRunResult> => {
|
||||
@@ -289,6 +306,7 @@ export const syncQueuedChanges = (options: {
|
||||
|
||||
const runQueuedChanges = async (options: {
|
||||
scope?: string;
|
||||
onMemoAcknowledged?: (memo: MemoDetail, item: SyncQueueItem, acknowledgement: MemoUpdateAcknowledgement) => void | Promise<void>;
|
||||
onSynced?: (memo: MemoDetail, item: SyncQueueItem) => void | Promise<void>;
|
||||
onActionSynced?: (result: SyncQueueResult, item: SyncQueueItem) => void | Promise<void>;
|
||||
}): Promise<SyncRunResult> => {
|
||||
@@ -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<MemoUpdateAcknowledgement> =>
|
||||
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<SyncQueueItem>) =>
|
||||
|
||||
@@ -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<StoredQueueItem[]>((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<void>((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<void>((resolve) => { releaseFirstUpdate = resolve; });
|
||||
const firstUpdateStarted = new Promise<void>((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`);
|
||||
}
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user