Give Web, desktop, and mobile the same product-baseline conflict recovery: copy the local draft, then adopt the cloud version and rehydrate, instead of telling users to reload while durable local conflict state remains.
193 lines
6.7 KiB
TypeScript
193 lines
6.7 KiB
TypeScript
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 {
|
|
discardWebMemoConflict,
|
|
getMemoUpdateQueueId,
|
|
isMemoUpdateAlreadyApplied,
|
|
queueMemoUpdate,
|
|
syncQueuedChanges,
|
|
} = await import("../apps/web/src/lib/sync-queue");
|
|
const { formatLocalDraftClipboardText } = await import("../apps/web/src/lib/memo-save-conflict");
|
|
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, localDb.memos, async () => {
|
|
await localDb.drafts.clear();
|
|
await localDb.syncQueue.clear();
|
|
await localDb.memos.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");
|
|
});
|
|
|
|
test("discardWebMemoConflict replaces local draft with the cloud memo", async () => {
|
|
const scope = "test-scope";
|
|
await queueMemoUpdate(payload("local conflicted edit"), scope);
|
|
await localDb.syncQueue.update(getMemoUpdateQueueId("memo_sync_test"), { status: "conflict" });
|
|
await localDb.drafts.put({
|
|
memoId: "memo_sync_test",
|
|
title: "local conflicted edit",
|
|
tagsText: "",
|
|
contentJson: payload("local conflicted edit").contentJson,
|
|
updatedAt: "2026-07-15T00:00:02.000Z",
|
|
});
|
|
|
|
const cloud = memo("cloud wins", 3);
|
|
globalThis.fetch = (async () => jsonResponse({ memo: cloud })) as typeof fetch;
|
|
|
|
const adopted = await discardWebMemoConflict(scope, "memo_sync_test");
|
|
|
|
expect(adopted).toEqual(cloud);
|
|
expect(await localDb.syncQueue.get(getMemoUpdateQueueId("memo_sync_test"))).toBeUndefined();
|
|
expect(await localDb.drafts.get("memo_sync_test")).toBeUndefined();
|
|
expect(await localDb.memos.get([scope, "memo_sync_test"])).toMatchObject({
|
|
title: "cloud wins",
|
|
revision: 3,
|
|
scope,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("local draft clipboard formatting", () => {
|
|
test("includes title, tags, and body", () => {
|
|
expect(
|
|
formatLocalDraftClipboardText({
|
|
title: "Welcome",
|
|
tags: ["overview", "demo"],
|
|
contentMarkdown: "Hello world\n",
|
|
}),
|
|
).toBe("# Welcome\n#overview #demo\n\nHello world");
|
|
});
|
|
});
|