fix(web): preserve drafts during memo creation
This commit is contained in:
1 parent
c674719e80
commit
2e6cc5d71c
2 files changed
+119
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user