From 2e6cc5d71c8d3efdf0c8957f7c69049a7c2fe822 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 19 Aug 2026 18:53:46 +0800 Subject: [PATCH] fix(web): preserve drafts during memo creation --- apps/web/src/lib/sync-queue.ts | 54 +++++++++++++++++++++++++++- tests/web-sync-queue.test.ts | 66 ++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/sync-queue.ts b/apps/web/src/lib/sync-queue.ts index 7c5880e6..c8f09fca 100644 --- a/apps/web/src/lib/sync-queue.ts +++ b/apps/web/src/lib/sync-queue.ts @@ -194,7 +194,50 @@ const remapQueuedMemoId = async (scope: string, temporaryId: string, remoteMemo: if (temporaryDraft) { const remoteDraft = await localDb.drafts.get(remoteId); const newestDraft = selectNewestLocalDraft(temporaryDraft, remoteDraft); - if (newestDraft) await localDb.drafts.put({ ...newestDraft, memoId: remoteId }); + if (newestDraft) { + const remappedDraft = { ...newestDraft, memoId: remoteId }; + await localDb.drafts.put(remappedDraft); + + const queuedUpdate = await localDb.syncQueue.get(getMemoUpdateQueueId(remoteId)); + const draftIsCovered = queuedUpdate + ? isDraftCoveredByMemoUpdate(queuedUpdate, remappedDraft) + : false; + const draftMatchesCreatedMemo = + remappedDraft.title.trim() === (remoteMemo.title ?? "") && + JSON.stringify(parseTagsText(remappedDraft.tagsText)) === JSON.stringify(remoteMemo.tags) && + JSON.stringify(remappedDraft.contentJson) === JSON.stringify(remoteMemo.contentJson); + + // Editor autosave can persist a draft while memo.create is already in + // flight, before the temporary memo has a server revision that can be + // queued as memo.update. Preserve that edit as the successor request. + if (!draftIsCovered && !draftMatchesCreatedMemo) { + const now = new Date().toISOString(); + await localDb.syncQueue.put({ + id: getMemoUpdateQueueId(remoteId), + kind: "memo.update", + scope, + memoId: remoteId, + status: "pending", + payload: { + memoId: remoteId, + expectedRevision: remoteMemo.revision, + expectedContentHash: remoteMemo.contentHash, + editSessionId: `create-remap:${remoteId}`, + title: remappedDraft.title, + contentJson: remappedDraft.contentJson, + tags: parseTagsText(remappedDraft.tagsText), + }, + attemptCount: queuedUpdate?.attemptCount ?? 0, + lastError: null, + lastErrorCode: null, + lastErrorDetails: null, + nextAttemptAt: null, + claimId: null, + createdAt: queuedUpdate?.createdAt ?? now, + updatedAt: now, + }); + } + } await localDb.drafts.delete(temporaryId); } }); @@ -275,6 +318,15 @@ export const isMemoUpdateAlreadyApplied = (memo: MemoDetail, item: SyncQueueItem return false; } const payload = item.payload as MemoUpdateSyncPayload; + // A local mirror can project a draft over the memo returned by memo.create + // while retaining that response's server base. Matching visible content is + // not an acknowledgement until the revision or hash has advanced. + if ( + memo.revision === payload.expectedRevision && + memo.contentHash === payload.expectedContentHash + ) { + return false; + } if (memo.id !== item.memoId || memo.title !== payload.title) { return false; } diff --git a/tests/web-sync-queue.test.ts b/tests/web-sync-queue.test.ts index f22ef8a7..f2618868 100644 --- a/tests/web-sync-queue.test.ts +++ b/tests/web-sync-queue.test.ts @@ -6,8 +6,10 @@ import type { MemoUpdateSyncPayload } from "../apps/web/src/lib/local-db"; const { localDb } = await import("../apps/web/src/lib/local-db"); const { discardWebMemoConflict, + getMemoCreateQueueId, getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, + queueMemoCreate, queueMemoUpdate, syncQueuedChanges, } = await import("../apps/web/src/lib/sync-queue"); @@ -83,6 +85,7 @@ describe("web sync queue concurrency", () => { expect(queued).toBeDefined(); expect(isMemoUpdateAlreadyApplied(memo("already saved"), queued!)).toBe(true); + expect(isMemoUpdateAlreadyApplied(memo("already saved", 0), queued!)).toBe(false); expect(isMemoUpdateAlreadyApplied(memo("different remote text"), queued!)).toBe(false); }); @@ -141,6 +144,69 @@ describe("web sync queue concurrency", () => { expect(queued?.payload.expectedContentHash).toBe("hash-1"); }); + test("queues a draft saved while memo creation is in flight after remapping its id", async () => { + const scope = "test-scope"; + const temporaryId = "local_create_race"; + const remoteMemo = { ...memo("", 1), id: "memo_created", title: null }; + const draftTitle = "written during create"; + const draftContent = payload(draftTitle).contentJson; + await queueMemoCreate(scope, { + temporaryId, + notebookId: "nb_inbox", + title: "", + contentMarkdown: "", + tags: [], + createdAt: "2026-07-15T00:00:00.000Z", + updatedAt: "2026-07-15T00:00:00.000Z", + }); + await localDb.drafts.put({ + memoId: temporaryId, + title: draftTitle, + contentJson: draftContent, + tagsText: "", + updatedAt: "2026-07-15T00:00:02.000Z", + }); + + const requests: Array<{ method: string; url: string }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + requests.push({ method, url }); + if (method === "POST" && url.endsWith("/api/v1/memos")) { + return jsonResponse({ memo: remoteMemo }, 201); + } + if (url.endsWith("/edit-sessions")) { + return jsonResponse({ editSession: { ...session(1), memoId: remoteMemo.id } }); + } + return jsonResponse({ memo: { ...remoteMemo, title: draftTitle, contentJson: draftContent, revision: 2, contentHash: "hash-2" } }); + }) as typeof fetch; + + const createResult = await syncQueuedChanges({ scope }); + const queuedUpdate = await localDb.syncQueue.get(getMemoUpdateQueueId(remoteMemo.id)); + + expect(createResult).toEqual({ attempted: 1, synced: 1, failed: 0, conflicted: 0 }); + expect(await localDb.syncQueue.get(getMemoCreateQueueId(temporaryId))).toBeUndefined(); + expect(await localDb.drafts.get(temporaryId)).toBeUndefined(); + expect(await localDb.drafts.get(remoteMemo.id)).toMatchObject({ title: draftTitle, contentJson: draftContent }); + expect(queuedUpdate).toMatchObject({ + kind: "memo.update", + scope, + memoId: remoteMemo.id, + status: "pending", + payload: { + expectedRevision: 1, + expectedContentHash: "hash-1", + title: draftTitle, + contentJson: draftContent, + }, + }); + + const updateResult = await syncQueuedChanges({ scope }); + expect(updateResult).toEqual({ attempted: 1, synced: 1, failed: 0, conflicted: 0 }); + expect(requests.some(({ method, url }) => method === "PATCH" && url.endsWith(`/api/v1/memos/${remoteMemo.id}`))).toBe(true); + expect(await localDb.syncQueue.get(getMemoUpdateQueueId(remoteMemo.id))).toBeUndefined(); + }); + 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;