fix(web): preserve drafts during memo creation

This commit is contained in:
tianma-if committed 2026-08-19 18:53:46 +08:00
1 parent c674719e80
commit 2e6cc5d71c
2 files changed
+119 -1

No files matched your search

+53 -1
View File
@@ -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;
}
+66
View File
@@ -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;